← back to Dear Bubbe Admin
initial snapshot — gitify all builds (CLAUDE.md rule 2026-05-06)
4147abcad34df85d5e4be67073022ec9a72004ea · 2026-05-06 10:20:44 -0700 · Steve
Files touched
A .gitignoreA backend/auto-poster.jsA backend/middleware/MessageInterceptor.jsA backend/models/Alert.jsA backend/models/Conversation.jsA backend/models/Session.jsA backend/models/User.jsA backend/routes/alerts.jsA backend/routes/auth.jsA backend/routes/monitoring.jsA backend/routes/reports.jsA backend/routes/scanner.jsA backend/routes/users.jsA backend/server.jsA backend/services/AlertService.jsA backend/services/MonitoringService.jsA backend/social-post-handler.jsA check-active-users.jsA ecosystem.config.jsA frontend/index.htmlA frontend/package.jsonA frontend/public/index.htmlA frontend/src/App.cssA frontend/src/App.jsA frontend/src/components/AlertsPage.jsA frontend/src/components/Dashboard.jsA frontend/src/components/Header.jsA frontend/src/components/LiveAlertModal.jsA frontend/src/components/Login.jsA frontend/src/components/MonitoringPage.jsA frontend/src/components/ReportsPage.jsA frontend/src/components/Sidebar.jsA frontend/src/components/UsersPage.jsA frontend/src/index.jsA frontend/twitter-monitor.htmlA log-all-users.jsA package-lock.jsonA package.jsonA public/favicon.svgA public/instagram-1763680784110.pngA public/instagram-1763680819710.pngA public/instagram-1763680822723.pngA public/instagram-1763680825914.pngA public/instagram-1763680829181.pngA public/instagram-1763680832325.pngA public/instagram-1763680835466.pngA public/instagram-1763680838580.pngA public/instagram-1763680841833.pngA public/instagram-1763680844930.pngA public/instagram-1763680848213.pngA public/instagram-1763680895477.pngA public/instagram-1763681128801.pngA public/instagram-1763681171228.pngA public/instagram-1763682870613.pngA public/instagram-1763684539865.pngA public/instagram-1763684730100.pngA public/instagram-1763685242269.pngA public/instagram-1763685682137.pngA public/instagram-1763686094031.pngA public/instagram-caption-1763681171228.txtA public/instagram-caption.txtA public/instagram-latest-caption.txtA public/instagram-post.htmlA public/instagram-schedule.jsonA public/instagram-webhook.jsonA setup-admin.sh
Diff
commit 4147abcad34df85d5e4be67073022ec9a72004ea
Author: Steve <steve@designerwallcoverings.com>
Date: Wed May 6 10:20:44 2026 -0700
initial snapshot — gitify all builds (CLAUDE.md rule 2026-05-06)
---
.gitignore | 14 +
backend/auto-poster.js | 103 +
backend/middleware/MessageInterceptor.js | 272 +++
backend/models/Alert.js | 174 ++
backend/models/Conversation.js | 319 +++
backend/models/Session.js | 272 +++
backend/models/User.js | 270 +++
backend/routes/alerts.js | 445 ++++
backend/routes/auth.js | 218 ++
backend/routes/monitoring.js | 516 ++++
backend/routes/reports.js | 575 +++++
backend/routes/scanner.js | 113 +
backend/routes/users.js | 476 ++++
backend/server.js | 973 ++++++++
backend/services/AlertService.js | 455 ++++
backend/services/MonitoringService.js | 544 +++++
backend/social-post-handler.js | 233 ++
check-active-users.js | 94 +
ecosystem.config.js | 24 +
frontend/index.html | 2190 +++++++++++++++++
frontend/package.json | 47 +
frontend/public/index.html | 81 +
frontend/src/App.css | 579 +++++
frontend/src/App.js | 261 +++
frontend/src/components/AlertsPage.js | 470 ++++
frontend/src/components/Dashboard.js | 328 +++
frontend/src/components/Header.js | 57 +
frontend/src/components/LiveAlertModal.js | 241 ++
frontend/src/components/Login.js | 205 ++
frontend/src/components/MonitoringPage.js | 380 +++
frontend/src/components/ReportsPage.js | 406 ++++
frontend/src/components/Sidebar.js | 53 +
frontend/src/components/UsersPage.js | 323 +++
frontend/src/index.js | 10 +
frontend/twitter-monitor.html | 271 +++
log-all-users.js | 271 +++
package-lock.json | 3512 ++++++++++++++++++++++++++++
package.json | 48 +
public/favicon.svg | 4 +
public/instagram-1763680784110.png | Bin 0 -> 105524 bytes
public/instagram-1763680819710.png | Bin 0 -> 105524 bytes
public/instagram-1763680822723.png | Bin 0 -> 86483 bytes
public/instagram-1763680825914.png | Bin 0 -> 96573 bytes
public/instagram-1763680829181.png | Bin 0 -> 78041 bytes
public/instagram-1763680832325.png | Bin 0 -> 75746 bytes
public/instagram-1763680835466.png | Bin 0 -> 81163 bytes
public/instagram-1763680838580.png | Bin 0 -> 88270 bytes
public/instagram-1763680841833.png | Bin 0 -> 75746 bytes
public/instagram-1763680844930.png | Bin 0 -> 98628 bytes
public/instagram-1763680848213.png | Bin 0 -> 105582 bytes
public/instagram-1763680895477.png | Bin 0 -> 90397 bytes
public/instagram-1763681128801.png | Bin 0 -> 88270 bytes
public/instagram-1763681171228.png | Bin 0 -> 97512 bytes
public/instagram-1763682870613.png | Bin 0 -> 90397 bytes
public/instagram-1763684539865.png | Bin 0 -> 72279 bytes
public/instagram-1763684730100.png | Bin 0 -> 65881 bytes
public/instagram-1763685242269.png | Bin 0 -> 68692 bytes
public/instagram-1763685682137.png | Bin 0 -> 90732 bytes
public/instagram-1763686094031.png | Bin 0 -> 82046 bytes
public/instagram-caption-1763681171228.txt | 7 +
public/instagram-caption.txt | 7 +
public/instagram-latest-caption.txt | 7 +
public/instagram-post.html | 112 +
public/instagram-schedule.json | 15 +
public/instagram-webhook.json | 19 +
setup-admin.sh | 125 +
66 files changed, 16119 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1d7a585
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,14 @@
+node_modules/
+.next/
+dist/
+build/
+.cache/
+*.db-shm
+*.db-wal
+.env
+.env.local
+coverage/
+.DS_Store
+*.log
+tmp/
+
diff --git a/backend/auto-poster.js b/backend/auto-poster.js
new file mode 100644
index 0000000..b5ed372
--- /dev/null
+++ b/backend/auto-poster.js
@@ -0,0 +1,103 @@
+const fs = require('fs').promises;
+const path = require('path');
+const { postTweet } = require('/root/Projects/dear-bubbe-nextjs/lib/twitter-v2-official.js');
+
+// Format conversation for posting
+function formatForSocial(data) {
+ const intros = [
+ "Bubbe tells it like it is:",
+ "Bubbe's got no filter:",
+ "Bubbe pulls no punches:",
+ "Bubbe serves reality checks:",
+ "Bubbe's straight talk:",
+ "Bubbe's unfiltered truth:",
+ "Bubbe's no-nonsense advice:",
+ "Bubbe's savage advice:",
+ "Bubbe drops truth bombs:",
+ "Bubbe keeps it real:"
+ ];
+
+ const intro = intros[Math.floor(Math.random() * intros.length)];
+
+ // Anonymize user message
+ let userMsg = data.userMessage || '';
+ userMsg = userMsg.replace(/\b[A-Z][a-z]+\s+[A-Z][a-z]+\b/g, '[NAME]');
+ userMsg = userMsg.replace(/\b[A-Z][a-z]+\b/g, (match) => {
+ if (!['I', 'The', 'My', 'What', 'When', 'Where', 'Why', 'How'].includes(match)) {
+ return '[NAME]';
+ }
+ return match;
+ });
+
+ // Truncate messages for Twitter
+ const maxUserLength = 80;
+ const maxBubbeLength = 150;
+
+ if (userMsg.length > maxUserLength) {
+ userMsg = userMsg.substring(0, maxUserLength) + '...';
+ }
+
+ let bubbeMsg = data.bubbeResponse || '';
+ if (bubbeMsg.length > maxBubbeLength) {
+ bubbeMsg = bubbeMsg.substring(0, maxBubbeLength) + '...';
+ }
+
+ const hashtags = '#BubbeAI #Bubbe #NoFilter #JewishGrandma';
+ const cta = 'Visit Bubbe.AI';
+
+ const fullPost = `${intro}\n\n"${userMsg}"\n\n"${bubbeMsg}" ${hashtags}\n\n${cta}`;
+
+ // Ensure it fits in Twitter's limit
+ if (fullPost.length > 280) {
+ const shortened = `${intro}\n\n"${bubbeMsg.substring(0, 120)}..." ${hashtags}\n\n${cta}`;
+ return shortened;
+ }
+
+ return fullPost;
+}
+
+// Post immediately to Twitter
+async function postImmediately(data) {
+ try {
+ const text = formatForSocial(data);
+ console.log('📤 Posting to Twitter immediately...');
+
+ const result = await postTweet(text);
+
+ if (result.success) {
+ // Log successful post
+ const logsPath = '/root/Projects/dear-bubbe-admin/logs/immediate-posts.json';
+ let logs = [];
+ try {
+ const existing = await fs.readFile(logsPath, 'utf8');
+ logs = JSON.parse(existing);
+ } catch (e) {
+ // File doesn't exist yet
+ }
+
+ logs.push({
+ timestamp: new Date().toISOString(),
+ platform: 'twitter',
+ status: 'posted',
+ tweetId: result.tweetId,
+ url: `https://x.com/DearBubbe/status/${result.tweetId}`,
+ text: text,
+ originalData: data
+ });
+
+ await fs.writeFile(logsPath, JSON.stringify(logs, null, 2));
+ console.log('✅ Posted successfully to Twitter!');
+ console.log('URL:', `https://x.com/DearBubbe/status/${result.tweetId}`);
+
+ return { success: true, tweetId: result.tweetId };
+ } else {
+ console.error('❌ Failed to post:', result.error);
+ return { success: false, error: result.error };
+ }
+ } catch (error) {
+ console.error('❌ Error posting:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+module.exports = { postImmediately, formatForSocial };
\ No newline at end of file
diff --git a/backend/middleware/MessageInterceptor.js b/backend/middleware/MessageInterceptor.js
new file mode 100644
index 0000000..711d11b
--- /dev/null
+++ b/backend/middleware/MessageInterceptor.js
@@ -0,0 +1,272 @@
+const axios = require('axios')
+const AlertService = require('../services/AlertService')
+
+/**
+ * Message Interceptor Middleware for Dear Bubbe AI
+ *
+ * This middleware can be integrated into the main Dear Bubbe application
+ * to automatically process all messages through the admin alert system
+ */
+class MessageInterceptor {
+ constructor(alertService) {
+ this.alertService = alertService || new AlertService(null, console)
+ this.processedMessages = new Set() // Prevent duplicate processing
+ this.apiEndpoint = process.env.ADMIN_API_URL || 'http://localhost:3015'
+ }
+
+ /**
+ * Process a message and check for violations
+ * This should be called from the Dear Bubbe chat API
+ */
+ async processMessage(messageData) {
+ try {
+ const {
+ message,
+ userId,
+ sessionId,
+ ip,
+ userAgent,
+ timestamp,
+ userProfile
+ } = messageData
+
+ // Create unique message ID to prevent duplicate processing
+ const messageId = this.createMessageId(message, userId, timestamp)
+ if (this.processedMessages.has(messageId)) {
+ return { processed: true, violations: [] }
+ }
+
+ console.log(`[INTERCEPTOR] Processing message from user ${userId}`)
+
+ // Prepare user context
+ const userContext = {
+ userId: userId || 'unknown',
+ ip: ip || 'unknown',
+ sessionId: sessionId || this.generateSessionId(),
+ userAgent: userAgent || 'unknown',
+ userProfile: userProfile || {}
+ }
+
+ // Process through alert service
+ const result = await this.alertService.processMessage(message, userContext)
+
+ // Mark as processed
+ this.processedMessages.add(messageId)
+
+ // Clean up old processed messages (keep last 1000)
+ if (this.processedMessages.size > 1000) {
+ const messagesArray = Array.from(this.processedMessages)
+ this.processedMessages.clear()
+ messagesArray.slice(-500).forEach(id => this.processedMessages.add(id))
+ }
+
+ console.log(`[INTERCEPTOR] Message processed: ${result.violations.length} violations found`)
+
+ return {
+ processed: true,
+ blocked: result.blocked,
+ violations: result.violations,
+ alertsCreated: result.alertsCreated,
+ shouldBlock: result.blocked,
+ threatResponse: result.violations.some(v => v.severity === 'high' || v.severity === 'critical')
+ }
+
+ } catch (error) {
+ console.error('[INTERCEPTOR] Error processing message:', error)
+ return {
+ processed: false,
+ error: error.message,
+ violations: [],
+ alertsCreated: 0
+ }
+ }
+ }
+
+ /**
+ * Express middleware function for automatic message processing
+ * Add this to the Dear Bubbe API routes
+ */
+ middleware() {
+ return async (req, res, next) => {
+ try {
+ // Only process chat messages
+ if (req.path !== '/api/chat' || req.method !== 'POST') {
+ return next()
+ }
+
+ const { message, userId } = req.body
+ if (!message || message === '__init__') {
+ return next() // Skip init messages
+ }
+
+ // Extract request context
+ const messageData = {
+ message: message,
+ userId: userId || 'anonymous',
+ sessionId: req.sessionId || req.sessionID,
+ ip: this.extractIP(req),
+ userAgent: req.get('User-Agent'),
+ timestamp: new Date(),
+ userProfile: req.body.userProfile || {}
+ }
+
+ // Process the message
+ const result = await this.processMessage(messageData)
+
+ // Add result to request for use by the main handler
+ req.securityScan = result
+
+ // If message should be blocked, return early
+ if (result.blocked) {
+ console.log(`[INTERCEPTOR] Blocking message from ${userId}: ${result.violations.length} violations`)
+ return res.status(400).json({
+ error: 'Message blocked due to policy violation',
+ blocked: true,
+ violations: result.violations.map(v => ({
+ type: v.type,
+ severity: v.severity,
+ category: v.category || v.type
+ }))
+ })
+ }
+
+ // Continue to main handler
+ next()
+
+ } catch (error) {
+ console.error('[INTERCEPTOR] Middleware error:', error)
+ // Don't block the request on errors, just log
+ req.securityScan = {
+ processed: false,
+ error: error.message
+ }
+ next()
+ }
+ }
+ }
+
+ /**
+ * Webhook endpoint for real-time message processing
+ * Call this from Dear Bubbe application
+ */
+ async webhook(req, res) {
+ try {
+ const messageData = req.body
+ const result = await this.processMessage(messageData)
+
+ res.json({
+ success: true,
+ result
+ })
+ } catch (error) {
+ console.error('[INTERCEPTOR] Webhook error:', error)
+ res.status(500).json({
+ success: false,
+ error: error.message
+ })
+ }
+ }
+
+ /**
+ * Integration helper for Dear Bubbe
+ */
+ static createIntegrationCode() {
+ return `
+// Add this to your Dear Bubbe chat API route (app/api/chat/route.ts)
+
+// Import the admin integration
+const axios = require('axios')
+
+// Function to scan message for violations
+async function scanMessageForViolations(messageData) {
+ try {
+ const response = await axios.post('http://localhost:3015/api/scan-message', {
+ message: messageData.message,
+ userId: messageData.userId,
+ sessionId: messageData.sessionId,
+ ip: messageData.ip,
+ userAgent: messageData.userAgent,
+ userProfile: messageData.userProfile
+ }, {
+ timeout: 5000 // Don't wait too long
+ })
+
+ return response.data.result || {}
+ } catch (error) {
+ console.warn('Admin scan failed:', error.message)
+ return { processed: false, violations: [] }
+ }
+}
+
+// In your chat handler, add this before processing:
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json()
+ const { message, userId, userProfile } = body
+
+ // Skip scanning for init messages
+ if (message !== '__init__') {
+ // Scan for violations
+ const scanResult = await scanMessageForViolations({
+ message,
+ userId: userId || 'anonymous',
+ sessionId: request.headers.get('x-session-id'),
+ ip: request.headers.get('x-forwarded-for')?.split(',')[0] ||
+ request.headers.get('x-real-ip') || 'unknown',
+ userAgent: request.headers.get('user-agent'),
+ userProfile
+ })
+
+ // Block if necessary
+ if (scanResult.blocked) {
+ return NextResponse.json({
+ error: 'Message blocked due to policy violation',
+ blocked: true,
+ threatResponse: scanResult.threatResponse
+ }, { status: 400 })
+ }
+
+ // Log violations for monitoring
+ if (scanResult.violations?.length > 0) {
+ console.warn(\`🚨 Security violations detected: \${scanResult.violations.length}\`)
+ }
+ }
+
+ // Continue with normal processing...
+
+ } catch (error) {
+ // Handle errors...
+ }
+}
+`
+ }
+
+ // Helper methods
+ createMessageId(message, userId, timestamp) {
+ const content = `${message}-${userId}-${timestamp.getTime()}`
+ return require('crypto').createHash('md5').update(content).digest('hex')
+ }
+
+ generateSessionId() {
+ return 'sess_' + Math.random().toString(36).substr(2, 9)
+ }
+
+ extractIP(req) {
+ const forwarded = req.headers['x-forwarded-for']
+ const realIp = req.headers['x-real-ip']
+ const connectionRemote = req.connection?.remoteAddress
+ const socketRemote = req.socket?.remoteAddress
+ const connectionSocket = req.connection?.socket?.remoteAddress
+
+ const ip = forwarded?.split(',')[0] ||
+ realIp ||
+ connectionRemote ||
+ socketRemote ||
+ connectionSocket ||
+ 'unknown'
+
+ return ip.replace('::ffff:', '') // Clean IPv6 mapped IPv4
+ }
+}
+
+module.exports = MessageInterceptor
\ No newline at end of file
diff --git a/backend/models/Alert.js b/backend/models/Alert.js
new file mode 100644
index 0000000..221f437
--- /dev/null
+++ b/backend/models/Alert.js
@@ -0,0 +1,174 @@
+const mongoose = require('mongoose')
+
+const alertSchema = new mongoose.Schema({
+ timestamp: {
+ type: Date,
+ default: Date.now,
+ index: true
+ },
+ type: {
+ type: String,
+ required: true,
+ enum: ['antisemitism', 'racism', 'hate_speech', 'harassment', 'spam', 'system', 'security'],
+ index: true
+ },
+ severity: {
+ type: String,
+ required: true,
+ enum: ['low', 'medium', 'high', 'critical'],
+ index: true
+ },
+ userInfo: {
+ userId: { type: String, index: true },
+ ip: { type: String, index: true },
+ sessionId: { type: String },
+ userAgent: { type: String },
+ email: { type: String }
+ },
+ location: {
+ country: { type: String },
+ state: { type: String },
+ city: { type: String },
+ coordinates: {
+ lat: { type: Number },
+ lon: { type: Number }
+ },
+ isp: { type: String }
+ },
+ content: {
+ message: { type: String, required: true },
+ detectedAt: { type: Date, default: Date.now },
+ context: { type: String }, // Additional context about the conversation
+ responseGiven: { type: String } // Bubbe's response if any
+ },
+ violation: {
+ ruleId: { type: String, required: true },
+ pattern: { type: String, required: true },
+ match: { type: String, required: true }, // The exact text that matched
+ category: { type: String }, // e.g., 'racial_slur', 'conspiracy', 'stereotype'
+ confidence: { type: Number, min: 0, max: 1, default: 1 }
+ },
+ action: {
+ type: String,
+ enum: ['block', 'review', 'warn', 'monitor'],
+ default: 'review'
+ },
+ status: {
+ type: String,
+ enum: ['active', 'reviewed', 'resolved', 'false_positive'],
+ default: 'active',
+ index: true
+ },
+ reviewInfo: {
+ reviewedBy: { type: String }, // Admin user ID
+ reviewedAt: { type: Date },
+ reviewNotes: { type: String },
+ resolution: { type: String }
+ },
+ threatResponseSent: {
+ type: Boolean,
+ default: false
+ },
+ escalated: {
+ type: Boolean,
+ default: false
+ },
+ reportedToAuthorities: {
+ type: Boolean,
+ default: false
+ },
+ additionalData: {
+ type: mongoose.Schema.Types.Mixed // For any additional data specific to alert type
+ }
+}, {
+ timestamps: true
+})
+
+// Indexes for fast queries
+alertSchema.index({ timestamp: -1 })
+alertSchema.index({ 'userInfo.ip': 1, timestamp: -1 })
+alertSchema.index({ type: 1, severity: 1 })
+alertSchema.index({ status: 1, timestamp: -1 })
+
+// Method to get alert summary
+alertSchema.methods.getSummary = function() {
+ return {
+ id: this._id,
+ type: this.type,
+ severity: this.severity,
+ timestamp: this.timestamp,
+ userInfo: {
+ userId: this.userInfo.userId,
+ ip: this.userInfo.ip
+ },
+ location: this.location,
+ violation: {
+ category: this.violation.category,
+ match: this.violation.match
+ },
+ status: this.status,
+ threatResponseSent: this.threatResponseSent
+ }
+}
+
+// Static method to get recent high-severity alerts
+alertSchema.statics.getRecentCritical = function(limit = 10) {
+ return this.find({
+ severity: { $in: ['high', 'critical'] },
+ timestamp: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } // Last 24 hours
+ })
+ .sort({ timestamp: -1 })
+ .limit(limit)
+ .lean()
+}
+
+// Static method to get alert statistics
+alertSchema.statics.getStats = function(timeframe = '24h') {
+ const now = new Date()
+ let startTime
+
+ switch (timeframe) {
+ case '1h':
+ startTime = new Date(now - 60 * 60 * 1000)
+ break
+ case '24h':
+ startTime = new Date(now - 24 * 60 * 60 * 1000)
+ break
+ case '7d':
+ startTime = new Date(now - 7 * 24 * 60 * 60 * 1000)
+ break
+ case '30d':
+ startTime = new Date(now - 30 * 24 * 60 * 60 * 1000)
+ break
+ default:
+ startTime = new Date(now - 24 * 60 * 60 * 1000)
+ }
+
+ return this.aggregate([
+ {
+ $match: {
+ timestamp: { $gte: startTime }
+ }
+ },
+ {
+ $group: {
+ _id: {
+ type: '$type',
+ severity: '$severity'
+ },
+ count: { $sum: 1 },
+ latestAlert: { $max: '$timestamp' },
+ uniqueIPs: { $addToSet: '$userInfo.ip' },
+ uniqueUsers: { $addToSet: '$userInfo.userId' }
+ }
+ },
+ {
+ $addFields: {
+ uniqueIPCount: { $size: '$uniqueIPs' },
+ uniqueUserCount: { $size: '$uniqueUsers' }
+ }
+ }
+ ])
+}
+
+module.exports = mongoose.model('Alert', alertSchema)
\ No newline at end of file
diff --git a/backend/models/Conversation.js b/backend/models/Conversation.js
new file mode 100644
index 0000000..9ff801f
--- /dev/null
+++ b/backend/models/Conversation.js
@@ -0,0 +1,319 @@
+const mongoose = require('mongoose')
+
+const conversationSchema = new mongoose.Schema({
+ userId: {
+ type: String,
+ required: true,
+ index: true
+ },
+ sessionId: {
+ type: String,
+ required: true,
+ index: true
+ },
+ timestamp: {
+ type: Date,
+ default: Date.now,
+ index: true
+ },
+ message: {
+ text: { type: String, required: true },
+ length: { type: Number },
+ language: { type: String, default: 'en' },
+ sentiment: {
+ type: String,
+ enum: ['positive', 'neutral', 'negative'],
+ default: 'neutral'
+ },
+ toxicity: {
+ score: { type: Number, min: 0, max: 1, default: 0 },
+ categories: [{ type: String }] // hate, insult, threat, etc.
+ }
+ },
+ response: {
+ text: { type: String, required: true },
+ mode: { type: String, default: 'bubbe' }, // bubbe, meshugana, comedian
+ length: { type: Number },
+ generationTime: { type: Number }, // in ms
+ model: { type: String, default: 'claude-3-haiku' },
+ voiceGenerated: { type: Boolean, default: false },
+ voiceUrl: { type: String }
+ },
+ context: {
+ conversationNumber: { type: Number, default: 1 },
+ timeSinceLastMessage: { type: Number }, // in seconds
+ previousTopic: { type: String },
+ userProfile: { type: mongoose.Schema.Types.Mixed }, // Snapshot of user profile
+ location: { type: mongoose.Schema.Types.Mixed }
+ },
+ topics: [{ type: String }], // Extracted topics like 'marriage', 'career', 'food'
+
+ analysis: {
+ responseTime: { type: Number }, // in ms
+ userEngagement: {
+ type: String,
+ enum: ['low', 'medium', 'high'],
+ default: 'medium'
+ },
+ topicShift: { type: Boolean, default: false },
+ escalation: { type: Boolean, default: false }, // If conversation became heated
+ satisfaction: {
+ type: String,
+ enum: ['satisfied', 'neutral', 'frustrated'],
+ default: 'neutral'
+ }
+ },
+
+ flags: {
+ flaggedContent: { type: Boolean, default: false },
+ flagReason: [{ type: String }], // antisemitism, racism, harassment, etc.
+ alertsTriggered: { type: Number, default: 0 },
+ reviewed: { type: Boolean, default: false },
+ reviewedBy: { type: String }, // Admin user ID
+ reviewedAt: { type: Date },
+ reviewNotes: { type: String }
+ },
+
+ technicalData: {
+ ip: { type: String, index: true },
+ userAgent: { type: String },
+ referer: { type: String },
+ apiEndpoint: { type: String },
+ requestTime: { type: Number },
+ errors: [{
+ timestamp: { type: Date },
+ error: { type: String },
+ code: { type: String }
+ }]
+ }
+}, {
+ timestamps: true
+})
+
+// Indexes for performance
+conversationSchema.index({ timestamp: -1 })
+conversationSchema.index({ userId: 1, timestamp: -1 })
+conversationSchema.index({ sessionId: 1, 'context.conversationNumber': 1 })
+conversationSchema.index({ 'flags.flaggedContent': 1, timestamp: -1 })
+conversationSchema.index({ topics: 1 })
+
+// Pre-save middleware to extract topics and analyze content
+conversationSchema.pre('save', function(next) {
+ // Calculate message length
+ if (this.message.text) {
+ this.message.length = this.message.text.length
+ }
+
+ if (this.response.text) {
+ this.response.length = this.response.text.length
+ }
+
+ // Extract topics from message and response text
+ if (!this.topics || this.topics.length === 0) {
+ this.topics = this.extractTopics(this.message.text + ' ' + this.response.text)
+ }
+
+ // Analyze sentiment
+ this.message.sentiment = this.analyzeSentiment(this.message.text)
+
+ next()
+})
+
+// Method to extract topics from text
+conversationSchema.methods.extractTopics = function(text) {
+ const topics = []
+ const lowerText = text.toLowerCase()
+
+ const topicPatterns = [
+ { pattern: /\b(marriage|wedding|engaged|spouse|husband|wife|marry)\b/gi, topic: 'marriage' },
+ { pattern: /\b(kids?|children|baby|babies|pregnant|family)\b/gi, topic: 'children' },
+ { pattern: /\b(job|work|career|salary|boss|company|employment)\b/gi, topic: 'career' },
+ { pattern: /\b(money|income|salary|debt|savings|rent|cost|expensive)\b/gi, topic: 'finances' },
+ { pattern: /\b(school|university|college|degree|education|student)\b/gi, topic: 'education' },
+ { pattern: /\b(health|sick|doctor|medicine|hospital|diet)\b/gi, topic: 'health' },
+ { pattern: /\b(food|cooking|eating|restaurant|recipe)\b/gi, topic: 'food' },
+ { pattern: /\b(dating|relationship|boyfriend|girlfriend|single|love)\b/gi, topic: 'relationships' },
+ { pattern: /\b(jewish|torah|synagogue|shabbat|kosher|yiddish)\b/gi, topic: 'religion' },
+ { pattern: /\b(news|israel|politics|election|trump|biden)\b/gi, topic: 'current_events' },
+ { pattern: /\b(weather|temperature|rain|snow|storm|forecast)\b/gi, topic: 'weather' },
+ { pattern: /\b(sports|game|team|basketball|football|baseball)\b/gi, topic: 'sports' },
+ { pattern: /\b(travel|vacation|trip|hotel|flight)\b/gi, topic: 'travel' },
+ { pattern: /\b(house|apartment|rent|buy|move|neighborhood)\b/gi, topic: 'housing' }
+ ]
+
+ topicPatterns.forEach(({ pattern, topic }) => {
+ if (pattern.test(lowerText) && !topics.includes(topic)) {
+ topics.push(topic)
+ }
+ })
+
+ return topics
+}
+
+// Method to analyze sentiment
+conversationSchema.methods.analyzeSentiment = function(text) {
+ const positiveWords = [
+ 'happy', 'great', 'good', 'excellent', 'wonderful', 'amazing', 'love', 'fantastic',
+ 'awesome', 'perfect', 'beautiful', 'brilliant', 'outstanding', 'magnificent'
+ ]
+
+ const negativeWords = [
+ 'sad', 'bad', 'terrible', 'awful', 'hate', 'horrible', 'disgusting', 'worst',
+ 'annoying', 'frustrated', 'angry', 'disappointed', 'upset', 'depressed'
+ ]
+
+ const lowerText = text.toLowerCase()
+
+ let positiveScore = 0
+ let negativeScore = 0
+
+ positiveWords.forEach(word => {
+ const regex = new RegExp(`\\b${word}\\b`, 'gi')
+ const matches = (lowerText.match(regex) || []).length
+ positiveScore += matches
+ })
+
+ negativeWords.forEach(word => {
+ const regex = new RegExp(`\\b${word}\\b`, 'gi')
+ const matches = (lowerText.match(regex) || []).length
+ negativeScore += matches
+ })
+
+ if (positiveScore > negativeScore) return 'positive'
+ if (negativeScore > positiveScore) return 'negative'
+ return 'neutral'
+}
+
+// Method to flag conversation
+conversationSchema.methods.flagContent = function(reasons, adminId = null) {
+ this.flags.flaggedContent = true
+ this.flags.flagReason = Array.isArray(reasons) ? reasons : [reasons]
+ this.flags.alertsTriggered++
+
+ if (adminId) {
+ this.flags.reviewedBy = adminId
+ this.flags.reviewedAt = new Date()
+ this.flags.reviewed = true
+ }
+
+ return this.save()
+}
+
+// Static method to get conversation statistics
+conversationSchema.statics.getStats = function(timeframe = '24h') {
+ const now = new Date()
+ let startTime
+
+ switch (timeframe) {
+ case '1h':
+ startTime = new Date(now - 60 * 60 * 1000)
+ break
+ case '24h':
+ startTime = new Date(now - 24 * 60 * 60 * 1000)
+ break
+ case '7d':
+ startTime = new Date(now - 7 * 24 * 60 * 60 * 1000)
+ break
+ case '30d':
+ startTime = new Date(now - 30 * 24 * 60 * 60 * 1000)
+ break
+ default:
+ startTime = new Date(now - 24 * 60 * 60 * 1000)
+ }
+
+ return this.aggregate([
+ {
+ $match: {
+ timestamp: { $gte: startTime }
+ }
+ },
+ {
+ $group: {
+ _id: null,
+ totalConversations: { $sum: 1 },
+ uniqueUsers: { $addToSet: '$userId' },
+ averageResponseTime: { $avg: '$analysis.responseTime' },
+ topicDistribution: { $push: '$topics' },
+ sentimentDistribution: {
+ positive: { $sum: { $cond: [{ $eq: ['$message.sentiment', 'positive'] }, 1, 0] } },
+ neutral: { $sum: { $cond: [{ $eq: ['$message.sentiment', 'neutral'] }, 1, 0] } },
+ negative: { $sum: { $cond: [{ $eq: ['$message.sentiment', 'negative'] }, 1, 0] } }
+ },
+ modeDistribution: {
+ bubbe: { $sum: { $cond: [{ $eq: ['$response.mode', 'bubbe'] }, 1, 0] } },
+ meshugana: { $sum: { $cond: [{ $eq: ['$response.mode', 'meshugana'] }, 1, 0] } },
+ comedian: { $sum: { $cond: [{ $eq: ['$response.mode', 'comedian'] }, 1, 0] } }
+ },
+ flaggedConversations: { $sum: { $cond: ['$flags.flaggedContent', 1, 0] } },
+ voiceUsage: { $sum: { $cond: ['$response.voiceGenerated', 1, 0] } }
+ }
+ },
+ {
+ $addFields: {
+ uniqueUserCount: { $size: '$uniqueUsers' }
+ }
+ }
+ ])
+}
+
+// Static method to get top topics
+conversationSchema.statics.getTopTopics = function(limit = 10, timeframe = '24h') {
+ const now = new Date()
+ let startTime
+
+ switch (timeframe) {
+ case '1h':
+ startTime = new Date(now - 60 * 60 * 1000)
+ break
+ case '24h':
+ startTime = new Date(now - 24 * 60 * 60 * 1000)
+ break
+ case '7d':
+ startTime = new Date(now - 7 * 24 * 60 * 60 * 1000)
+ break
+ default:
+ startTime = new Date(now - 24 * 60 * 60 * 1000)
+ }
+
+ return this.aggregate([
+ {
+ $match: {
+ timestamp: { $gte: startTime }
+ }
+ },
+ {
+ $unwind: '$topics'
+ },
+ {
+ $group: {
+ _id: '$topics',
+ count: { $sum: 1 },
+ uniqueUsers: { $addToSet: '$userId' }
+ }
+ },
+ {
+ $addFields: {
+ uniqueUserCount: { $size: '$uniqueUsers' }
+ }
+ },
+ {
+ $sort: { count: -1 }
+ },
+ {
+ $limit: limit
+ }
+ ])
+}
+
+// Static method to get flagged conversations
+conversationSchema.statics.getFlagged = function(limit = 50) {
+ return this.find({
+ 'flags.flaggedContent': true
+ })
+ .sort({ timestamp: -1 })
+ .limit(limit)
+ .populate('userId', 'profile.name profile.preferredName')
+ .lean()
+}
+
+module.exports = mongoose.model('Conversation', conversationSchema)
\ No newline at end of file
diff --git a/backend/models/Session.js b/backend/models/Session.js
new file mode 100644
index 0000000..b9a9d68
--- /dev/null
+++ b/backend/models/Session.js
@@ -0,0 +1,272 @@
+const mongoose = require('mongoose')
+
+const sessionSchema = new mongoose.Schema({
+ sessionId: {
+ type: String,
+ required: true,
+ unique: true,
+ index: true
+ },
+ userId: {
+ type: String,
+ required: true,
+ index: true
+ },
+ startTime: {
+ type: Date,
+ default: Date.now,
+ index: true
+ },
+ endTime: {
+ type: Date
+ },
+ lastActivity: {
+ type: Date,
+ default: Date.now,
+ index: true
+ },
+ duration: {
+ type: Number, // in seconds
+ default: 0
+ },
+ ended: {
+ type: Boolean,
+ default: false
+ },
+ userInfo: {
+ ip: { type: String, index: true },
+ userAgent: { type: String },
+ browser: { type: String },
+ os: { type: String },
+ deviceType: { type: String } // mobile, tablet, desktop
+ },
+ location: {
+ country: { type: String, index: true },
+ state: { type: String, index: true },
+ city: { type: String },
+ coordinates: {
+ lat: { type: Number },
+ lon: { type: Number }
+ },
+ timezone: { type: String },
+ isp: { type: String }
+ },
+ activity: {
+ messageCount: { type: Number, default: 0 },
+ voiceMessagesUsed: { type: Number, default: 0 },
+ modesUsed: [{ type: String }], // bubbe, meshugana, comedian
+ topicsDiscussed: [{ type: String }],
+ featuresUsed: [{ type: String }], // news, weather, voice, etc.
+ errorsEncountered: { type: Number, default: 0 },
+ averageResponseTime: { type: Number } // in ms
+ },
+ flags: {
+ suspicious: { type: Boolean, default: false },
+ blocked: { type: Boolean, default: false },
+ alertsTriggered: { type: Number, default: 0 },
+ riskLevel: { type: String, enum: ['low', 'medium', 'high', 'critical'], default: 'low' }
+ },
+ referrer: {
+ source: { type: String }, // direct, google, social, etc.
+ campaign: { type: String },
+ medium: { type: String }
+ },
+ technicalData: {
+ initialLoadTime: { type: Number },
+ averageApiResponseTime: { type: Number },
+ errors: [{
+ timestamp: { type: Date, default: Date.now },
+ error: { type: String },
+ context: { type: String }
+ }],
+ performanceMetrics: {
+ pageLoadTime: { type: Number },
+ apiCallCount: { type: Number },
+ totalDataTransferred: { type: Number } // in bytes
+ }
+ }
+}, {
+ timestamps: true
+})
+
+// Indexes for performance
+sessionSchema.index({ startTime: -1 })
+sessionSchema.index({ 'userInfo.ip': 1, startTime: -1 })
+sessionSchema.index({ ended: 1, lastActivity: -1 })
+sessionSchema.index({ userId: 1, startTime: -1 })
+
+// Virtual for session status
+sessionSchema.virtual('status').get(function() {
+ if (this.ended) return 'ended'
+
+ const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000)
+ if (this.lastActivity < fiveMinutesAgo) return 'inactive'
+
+ return 'active'
+})
+
+// Method to update activity
+sessionSchema.methods.updateActivity = function(activityData = {}) {
+ this.lastActivity = new Date()
+
+ if (activityData.messageCount) {
+ this.activity.messageCount += activityData.messageCount
+ }
+
+ if (activityData.mode && !this.activity.modesUsed.includes(activityData.mode)) {
+ this.activity.modesUsed.push(activityData.mode)
+ }
+
+ if (activityData.topics) {
+ activityData.topics.forEach(topic => {
+ if (!this.activity.topicsDiscussed.includes(topic)) {
+ this.activity.topicsDiscussed.push(topic)
+ }
+ })
+ }
+
+ if (activityData.feature && !this.activity.featuresUsed.includes(activityData.feature)) {
+ this.activity.featuresUsed.push(activityData.feature)
+ }
+
+ if (activityData.responseTime) {
+ if (this.activity.averageResponseTime) {
+ this.activity.averageResponseTime = (this.activity.averageResponseTime + activityData.responseTime) / 2
+ } else {
+ this.activity.averageResponseTime = activityData.responseTime
+ }
+ }
+
+ return this.save()
+}
+
+// Method to end session
+sessionSchema.methods.endSession = function() {
+ this.ended = true
+ this.endTime = new Date()
+ this.duration = Math.round((this.endTime - this.startTime) / 1000) // in seconds
+ return this.save()
+}
+
+// Method to add error
+sessionSchema.methods.addError = function(error, context = '') {
+ this.activity.errorsEncountered++
+ this.technicalData.errors.push({
+ error,
+ context,
+ timestamp: new Date()
+ })
+ return this.save()
+}
+
+// Method to flag as suspicious
+sessionSchema.methods.flagSuspicious = function(reason) {
+ this.flags.suspicious = true
+ this.flags.riskLevel = 'high'
+ return this.save()
+}
+
+// Method to increment alert count
+sessionSchema.methods.addAlert = function(alertType, severity) {
+ this.flags.alertsTriggered++
+
+ // Update risk level based on alerts and severity
+ if (this.flags.alertsTriggered >= 3 || severity === 'critical') {
+ this.flags.riskLevel = 'critical'
+ } else if (this.flags.alertsTriggered >= 2 || severity === 'high') {
+ this.flags.riskLevel = 'high'
+ } else if (this.flags.alertsTriggered >= 1 || severity === 'medium') {
+ this.flags.riskLevel = 'medium'
+ }
+
+ return this.save()
+}
+
+// Static method to get active sessions
+sessionSchema.statics.getActiveSessions = function(minutesThreshold = 5) {
+ const threshold = new Date(Date.now() - minutesThreshold * 60 * 1000)
+ return this.find({
+ ended: false,
+ lastActivity: { $gte: threshold }
+ }).lean()
+}
+
+// Static method to get session statistics
+sessionSchema.statics.getStats = function(timeframe = '24h') {
+ const now = new Date()
+ let startTime
+
+ switch (timeframe) {
+ case '1h':
+ startTime = new Date(now - 60 * 60 * 1000)
+ break
+ case '24h':
+ startTime = new Date(now - 24 * 60 * 60 * 1000)
+ break
+ case '7d':
+ startTime = new Date(now - 7 * 24 * 60 * 60 * 1000)
+ break
+ case '30d':
+ startTime = new Date(now - 30 * 24 * 60 * 60 * 1000)
+ break
+ default:
+ startTime = new Date(now - 24 * 60 * 60 * 1000)
+ }
+
+ return this.aggregate([
+ {
+ $match: {
+ startTime: { $gte: startTime }
+ }
+ },
+ {
+ $group: {
+ _id: null,
+ totalSessions: { $sum: 1 },
+ uniqueUsers: { $addToSet: '$userId' },
+ uniqueIPs: { $addToSet: '$userInfo.ip' },
+ totalMessages: { $sum: '$activity.messageCount' },
+ averageDuration: { $avg: '$duration' },
+ flaggedSessions: { $sum: { $cond: ['$flags.suspicious', 1, 0] } },
+ totalErrors: { $sum: '$activity.errorsEncountered' },
+ averageResponseTime: { $avg: '$activity.averageResponseTime' }
+ }
+ },
+ {
+ $addFields: {
+ uniqueUserCount: { $size: '$uniqueUsers' },
+ uniqueIPCount: { $size: '$uniqueIPs' }
+ }
+ }
+ ])
+}
+
+// Static method to find or create session
+sessionSchema.statics.findOrCreate = function(sessionId, userId, sessionData = {}) {
+ return this.findOneAndUpdate(
+ { sessionId },
+ {
+ $setOnInsert: {
+ sessionId,
+ userId,
+ startTime: new Date(),
+ ...sessionData
+ }
+ },
+ {
+ upsert: true,
+ new: true,
+ runValidators: true
+ }
+ )
+}
+
+// Static method to cleanup old sessions
+sessionSchema.statics.cleanup = function(daysToKeep = 30) {
+ const cutoff = new Date(Date.now() - daysToKeep * 24 * 60 * 60 * 1000)
+ return this.deleteMany({
+ startTime: { $lt: cutoff }
+ })
+}
+
+module.exports = mongoose.model('Session', sessionSchema)
\ No newline at end of file
diff --git a/backend/models/User.js b/backend/models/User.js
new file mode 100644
index 0000000..418f0cf
--- /dev/null
+++ b/backend/models/User.js
@@ -0,0 +1,270 @@
+const mongoose = require('mongoose')
+
+const userSchema = new mongoose.Schema({
+ userId: {
+ type: String,
+ required: true,
+ unique: true,
+ index: true
+ },
+ email: {
+ type: String,
+ lowercase: true,
+ index: true
+ },
+ profile: {
+ name: { type: String },
+ preferredName: { type: String },
+ gender: { type: String },
+ age: { type: Number },
+ birthYear: { type: String },
+ relationshipStatus: { type: String },
+ sameGenderPartner: { type: Boolean },
+ hasKids: { type: String },
+ numberOfKids: { type: Number },
+ kidsInfo: [{ type: mongoose.Schema.Types.Mixed }],
+ religiousLevel: { type: String },
+ education: { type: String },
+ school: { type: String },
+ degree: { type: String },
+ graduationYear: { type: String },
+ jobTitle: { type: String },
+ company: { type: String },
+ industry: { type: String },
+ incomeLevel: { type: String },
+ yearsAtJob: { type: String },
+ city: { type: String },
+ state: { type: String },
+ country: { type: String },
+ onboardingCompleted: { type: Boolean, default: false }
+ },
+ stats: {
+ totalMessages: { type: Number, default: 0 },
+ totalSessions: { type: Number, default: 0 },
+ firstVisit: { type: Date, default: Date.now },
+ lastVisit: { type: Date, default: Date.now },
+ averageSessionLength: { type: Number, default: 0 }, // in minutes
+ favoriteTopics: [{ type: String }],
+ preferredMode: { type: String, default: 'bubbe' }, // bubbe, meshugana, comedian
+ voiceEnabled: { type: Boolean, default: false },
+ totalVoiceMessages: { type: Number, default: 0 }
+ },
+ location: {
+ country: { type: String },
+ state: { type: String },
+ city: { type: String },
+ coordinates: {
+ lat: { type: Number },
+ lon: { type: Number }
+ },
+ timezone: { type: String },
+ lastKnownIP: { type: String }
+ },
+ security: {
+ blocked: { type: Boolean, default: false },
+ blockReason: { type: String },
+ blockedBy: { type: String }, // Admin user ID
+ blockedAt: { type: Date },
+ suspiciousActivity: { type: Boolean, default: false },
+ alertCount: { type: Number, default: 0 },
+ lastAlert: { type: Date },
+ riskLevel: {
+ type: String,
+ enum: ['low', 'medium', 'high', 'critical'],
+ default: 'low'
+ },
+ whitelisted: { type: Boolean, default: false }
+ },
+ preferences: {
+ colorTheme: { type: String, enum: ['warm', 'cool'], default: 'warm' },
+ language: { type: String, default: 'en' },
+ yiddishTranslations: { type: Boolean, default: true },
+ pushNotifications: { type: Boolean, default: false },
+ emailUpdates: { type: Boolean, default: false },
+ dataSharing: { type: Boolean, default: false }
+ },
+ deviceInfo: {
+ lastUserAgent: { type: String },
+ deviceType: { type: String }, // mobile, tablet, desktop
+ browser: { type: String },
+ os: { type: String }
+ },
+ notes: [{
+ note: { type: String },
+ addedBy: { type: String }, // Admin user ID
+ addedAt: { type: Date, default: Date.now },
+ type: { type: String, enum: ['info', 'warning', 'alert'], default: 'info' }
+ }]
+}, {
+ timestamps: true
+})
+
+// Indexes
+userSchema.index({ 'security.blocked': 1 })
+userSchema.index({ 'security.riskLevel': 1 })
+userSchema.index({ 'stats.lastVisit': -1 })
+userSchema.index({ 'location.country': 1, 'location.state': 1 })
+
+// Virtual for full name
+userSchema.virtual('fullName').get(function() {
+ return this.profile?.preferredName || this.profile?.name || 'Unknown User'
+})
+
+// Method to update user activity
+userSchema.methods.updateActivity = function(sessionData) {
+ this.stats.lastVisit = new Date()
+ this.stats.totalMessages += sessionData.messageCount || 0
+
+ if (sessionData.location) {
+ this.location = { ...this.location, ...sessionData.location }
+ }
+
+ if (sessionData.deviceInfo) {
+ this.deviceInfo = { ...this.deviceInfo, ...sessionData.deviceInfo }
+ }
+
+ return this.save()
+}
+
+// Method to add security note
+userSchema.methods.addNote = function(note, adminId, type = 'info') {
+ this.notes.push({
+ note,
+ addedBy: adminId,
+ type
+ })
+ return this.save()
+}
+
+// Method to block user
+userSchema.methods.block = function(reason, adminId) {
+ this.security.blocked = true
+ this.security.blockReason = reason
+ this.security.blockedBy = adminId
+ this.security.blockedAt = new Date()
+ return this.save()
+}
+
+// Method to unblock user
+userSchema.methods.unblock = function(adminId) {
+ this.security.blocked = false
+ this.security.blockReason = null
+ this.security.blockedBy = null
+ this.security.blockedAt = null
+
+ this.notes.push({
+ note: `User unblocked by admin`,
+ addedBy: adminId,
+ type: 'info'
+ })
+
+ return this.save()
+}
+
+// Method to increment alert count
+userSchema.methods.addAlert = function(alertType, severity) {
+ this.security.alertCount++
+ this.security.lastAlert = new Date()
+
+ // Update risk level based on alert count and severity
+ if (this.security.alertCount >= 5 || severity === 'critical') {
+ this.security.riskLevel = 'critical'
+ } else if (this.security.alertCount >= 3 || severity === 'high') {
+ this.security.riskLevel = 'high'
+ } else if (this.security.alertCount >= 1 || severity === 'medium') {
+ this.security.riskLevel = 'medium'
+ }
+
+ return this.save()
+}
+
+// Static method to find or create user
+userSchema.statics.findByIdOrCreate = function(userId, profileData = {}) {
+ return this.findOneAndUpdate(
+ { userId },
+ {
+ $setOnInsert: {
+ userId,
+ profile: profileData,
+ stats: {
+ totalMessages: 0,
+ totalSessions: 0,
+ firstVisit: new Date(),
+ lastVisit: new Date()
+ }
+ }
+ },
+ {
+ upsert: true,
+ new: true,
+ runValidators: true
+ }
+ )
+}
+
+// Static method to get user statistics
+userSchema.statics.getStats = function() {
+ return this.aggregate([
+ {
+ $group: {
+ _id: null,
+ totalUsers: { $sum: 1 },
+ blockedUsers: { $sum: { $cond: ['$security.blocked', 1, 0] } },
+ activeUsers: {
+ $sum: {
+ $cond: [
+ { $gte: ['$stats.lastVisit', new Date(Date.now() - 24 * 60 * 60 * 1000)] },
+ 1,
+ 0
+ ]
+ }
+ },
+ newUsersToday: {
+ $sum: {
+ $cond: [
+ { $gte: ['$createdAt', new Date(Date.now() - 24 * 60 * 60 * 1000)] },
+ 1,
+ 0
+ ]
+ }
+ },
+ avgMessagesPerUser: { $avg: '$stats.totalMessages' },
+ riskDistribution: {
+ low: { $sum: { $cond: [{ $eq: ['$security.riskLevel', 'low'] }, 1, 0] } },
+ medium: { $sum: { $cond: [{ $eq: ['$security.riskLevel', 'medium'] }, 1, 0] } },
+ high: { $sum: { $cond: [{ $eq: ['$security.riskLevel', 'high'] }, 1, 0] } },
+ critical: { $sum: { $cond: [{ $eq: ['$security.riskLevel', 'critical'] }, 1, 0] } }
+ }
+ }
+ }
+ ])
+}
+
+// Static method to get geographic distribution
+userSchema.statics.getGeoDistribution = function() {
+ return this.aggregate([
+ {
+ $group: {
+ _id: {
+ country: '$location.country',
+ state: '$location.state'
+ },
+ userCount: { $sum: 1 },
+ activeUsers: {
+ $sum: {
+ $cond: [
+ { $gte: ['$stats.lastVisit', new Date(Date.now() - 24 * 60 * 60 * 1000)] },
+ 1,
+ 0
+ ]
+ }
+ }
+ }
+ },
+ {
+ $sort: { userCount: -1 }
+ }
+ ])
+}
+
+module.exports = mongoose.model('User', userSchema)
\ No newline at end of file
diff --git a/backend/routes/alerts.js b/backend/routes/alerts.js
new file mode 100644
index 0000000..66974cd
--- /dev/null
+++ b/backend/routes/alerts.js
@@ -0,0 +1,445 @@
+const express = require('express')
+const Alert = require('../models/Alert')
+const User = require('../models/User')
+const router = express.Router()
+
+// Get all alerts with filtering and pagination
+router.get('/', async (req, res) => {
+ try {
+ const {
+ page = 1,
+ limit = 50,
+ type,
+ severity,
+ status,
+ timeframe = '24h',
+ userId,
+ ip,
+ sort = '-timestamp'
+ } = req.query
+
+ // Build query
+ const query = {}
+
+ // Time filter
+ if (timeframe) {
+ const now = new Date()
+ let startTime
+
+ switch (timeframe) {
+ case '1h':
+ startTime = new Date(now - 60 * 60 * 1000)
+ break
+ case '24h':
+ startTime = new Date(now - 24 * 60 * 60 * 1000)
+ break
+ case '7d':
+ startTime = new Date(now - 7 * 24 * 60 * 60 * 1000)
+ break
+ case '30d':
+ startTime = new Date(now - 30 * 24 * 60 * 60 * 1000)
+ break
+ default:
+ startTime = new Date(now - 24 * 60 * 60 * 1000)
+ }
+
+ query.timestamp = { $gte: startTime }
+ }
+
+ if (type) query.type = type
+ if (severity) query.severity = severity
+ if (status) query.status = status
+ if (userId) query['userInfo.userId'] = userId
+ if (ip) query['userInfo.ip'] = ip
+
+ // Execute query
+ const alerts = await Alert.find(query)
+ .sort(sort)
+ .limit(limit * 1)
+ .skip((page - 1) * limit)
+ .lean()
+
+ const total = await Alert.countDocuments(query)
+
+ res.json({
+ success: true,
+ alerts,
+ pagination: {
+ current: parseInt(page),
+ limit: parseInt(limit),
+ total,
+ pages: Math.ceil(total / limit)
+ }
+ })
+
+ } catch (error) {
+ console.error('Error fetching alerts:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to fetch alerts'
+ })
+ }
+})
+
+// Get alert statistics
+router.get('/stats', async (req, res) => {
+ try {
+ const { timeframe = '24h' } = req.query
+
+ const stats = await Alert.getStats(timeframe)
+
+ // Get recent critical alerts
+ const recentCritical = await Alert.getRecentCritical(10)
+
+ // Get top IPs with alerts
+ const topIPs = await Alert.aggregate([
+ {
+ $match: {
+ timestamp: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) }
+ }
+ },
+ {
+ $group: {
+ _id: '$userInfo.ip',
+ count: { $sum: 1 },
+ severities: { $push: '$severity' },
+ types: { $push: '$type' },
+ latestAlert: { $max: '$timestamp' }
+ }
+ },
+ {
+ $sort: { count: -1 }
+ },
+ {
+ $limit: 10
+ }
+ ])
+
+ // Get top users with alerts
+ const topUsers = await Alert.aggregate([
+ {
+ $match: {
+ timestamp: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) }
+ }
+ },
+ {
+ $group: {
+ _id: '$userInfo.userId',
+ count: { $sum: 1 },
+ severities: { $push: '$severity' },
+ types: { $push: '$type' },
+ latestAlert: { $max: '$timestamp' }
+ }
+ },
+ {
+ $sort: { count: -1 }
+ },
+ {
+ $limit: 10
+ }
+ ])
+
+ res.json({
+ success: true,
+ timeframe,
+ stats: stats[0] || {},
+ recentCritical,
+ topOffenders: {
+ byIP: topIPs,
+ byUser: topUsers
+ }
+ })
+
+ } catch (error) {
+ console.error('Error fetching alert stats:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to fetch alert statistics'
+ })
+ }
+})
+
+// Get specific alert details
+router.get('/:id', async (req, res) => {
+ try {
+ const alert = await Alert.findById(req.params.id).lean()
+
+ if (!alert) {
+ return res.status(404).json({
+ success: false,
+ error: 'Alert not found'
+ })
+ }
+
+ // Get related alerts from same IP/user
+ const relatedAlerts = await Alert.find({
+ $or: [
+ { 'userInfo.ip': alert.userInfo.ip },
+ { 'userInfo.userId': alert.userInfo.userId }
+ ],
+ _id: { $ne: alert._id }
+ })
+ .sort({ timestamp: -1 })
+ .limit(10)
+ .lean()
+
+ res.json({
+ success: true,
+ alert,
+ relatedAlerts
+ })
+
+ } catch (error) {
+ console.error('Error fetching alert details:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to fetch alert details'
+ })
+ }
+})
+
+// Update alert status
+router.put('/:id/status', async (req, res) => {
+ try {
+ const { status, reviewNotes, resolution } = req.body
+ const adminId = req.user.userId
+
+ const validStatuses = ['active', 'reviewed', 'resolved', 'false_positive']
+ if (!validStatuses.includes(status)) {
+ return res.status(400).json({
+ success: false,
+ error: 'Invalid status'
+ })
+ }
+
+ const alert = await Alert.findById(req.params.id)
+ if (!alert) {
+ return res.status(404).json({
+ success: false,
+ error: 'Alert not found'
+ })
+ }
+
+ alert.status = status
+ alert.reviewInfo = {
+ reviewedBy: adminId,
+ reviewedAt: new Date(),
+ reviewNotes: reviewNotes || '',
+ resolution: resolution || ''
+ }
+
+ await alert.save()
+
+ // Log the action
+ console.log(`Alert ${alert._id} status changed to ${status} by ${adminId}`)
+
+ res.json({
+ success: true,
+ alert: alert.getSummary()
+ })
+
+ } catch (error) {
+ console.error('Error updating alert status:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to update alert status'
+ })
+ }
+})
+
+// Block user based on alert
+router.post('/:id/block-user', async (req, res) => {
+ try {
+ const { reason } = req.body
+ const adminId = req.user.userId
+
+ const alert = await Alert.findById(req.params.id)
+ if (!alert) {
+ return res.status(404).json({
+ success: false,
+ error: 'Alert not found'
+ })
+ }
+
+ const userId = alert.userInfo.userId
+ if (!userId) {
+ return res.status(400).json({
+ success: false,
+ error: 'No user associated with this alert'
+ })
+ }
+
+ // Block the user
+ const user = await User.findByIdOrCreate(userId)
+ await user.block(reason || `Blocked due to ${alert.type} violation`, adminId)
+
+ // Update alert to mark user as blocked
+ alert.reviewInfo = {
+ ...alert.reviewInfo,
+ reviewedBy: adminId,
+ reviewedAt: new Date(),
+ reviewNotes: `User blocked: ${reason}`,
+ resolution: 'user_blocked'
+ }
+ alert.status = 'resolved'
+
+ await alert.save()
+
+ console.log(`User ${userId} blocked by ${adminId} due to alert ${alert._id}`)
+
+ res.json({
+ success: true,
+ message: 'User blocked successfully',
+ userId,
+ alertId: alert._id
+ })
+
+ } catch (error) {
+ console.error('Error blocking user:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to block user'
+ })
+ }
+})
+
+// Get alerts by IP address
+router.get('/by-ip/:ip', async (req, res) => {
+ try {
+ const { ip } = req.params
+ const { limit = 100, timeframe = '7d' } = req.query
+
+ const now = new Date()
+ let startTime
+
+ switch (timeframe) {
+ case '24h':
+ startTime = new Date(now - 24 * 60 * 60 * 1000)
+ break
+ case '7d':
+ startTime = new Date(now - 7 * 24 * 60 * 60 * 1000)
+ break
+ case '30d':
+ startTime = new Date(now - 30 * 24 * 60 * 60 * 1000)
+ break
+ default:
+ startTime = new Date(now - 7 * 24 * 60 * 60 * 1000)
+ }
+
+ const alerts = await Alert.find({
+ 'userInfo.ip': ip,
+ timestamp: { $gte: startTime }
+ })
+ .sort({ timestamp: -1 })
+ .limit(parseInt(limit))
+ .lean()
+
+ const stats = {
+ total: alerts.length,
+ bySeverity: alerts.reduce((acc, alert) => {
+ acc[alert.severity] = (acc[alert.severity] || 0) + 1
+ return acc
+ }, {}),
+ byType: alerts.reduce((acc, alert) => {
+ acc[alert.type] = (acc[alert.type] || 0) + 1
+ return acc
+ }, {}),
+ timeframe
+ }
+
+ res.json({
+ success: true,
+ ip,
+ alerts,
+ stats
+ })
+
+ } catch (error) {
+ console.error('Error fetching alerts by IP:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to fetch alerts by IP'
+ })
+ }
+})
+
+// Bulk update alerts
+router.post('/bulk-update', async (req, res) => {
+ try {
+ const { alertIds, action, status, reviewNotes } = req.body
+ const adminId = req.user.userId
+
+ if (!alertIds || !Array.isArray(alertIds) || alertIds.length === 0) {
+ return res.status(400).json({
+ success: false,
+ error: 'Alert IDs required'
+ })
+ }
+
+ const updateData = {
+ 'reviewInfo.reviewedBy': adminId,
+ 'reviewInfo.reviewedAt': new Date()
+ }
+
+ if (status) updateData.status = status
+ if (reviewNotes) updateData['reviewInfo.reviewNotes'] = reviewNotes
+
+ const result = await Alert.updateMany(
+ { _id: { $in: alertIds } },
+ { $set: updateData }
+ )
+
+ console.log(`Bulk update: ${result.modifiedCount} alerts updated by ${adminId}`)
+
+ res.json({
+ success: true,
+ updated: result.modifiedCount,
+ action,
+ adminId
+ })
+
+ } catch (error) {
+ console.error('Error in bulk update:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to update alerts'
+ })
+ }
+})
+
+// Delete alert (super admin only)
+router.delete('/:id', async (req, res) => {
+ try {
+ if (req.user.role !== 'super_admin') {
+ return res.status(403).json({
+ success: false,
+ error: 'Insufficient permissions'
+ })
+ }
+
+ const alert = await Alert.findByIdAndDelete(req.params.id)
+
+ if (!alert) {
+ return res.status(404).json({
+ success: false,
+ error: 'Alert not found'
+ })
+ }
+
+ console.log(`Alert ${req.params.id} deleted by ${req.user.userId}`)
+
+ res.json({
+ success: true,
+ message: 'Alert deleted successfully'
+ })
+
+ } catch (error) {
+ console.error('Error deleting alert:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to delete alert'
+ })
+ }
+})
+
+module.exports = router
\ No newline at end of file
diff --git a/backend/routes/auth.js b/backend/routes/auth.js
new file mode 100644
index 0000000..7aa7be3
--- /dev/null
+++ b/backend/routes/auth.js
@@ -0,0 +1,218 @@
+const express = require('express')
+const bcrypt = require('bcryptjs')
+const jwt = require('jsonwebtoken')
+const rateLimit = require('express-rate-limit')
+const router = express.Router()
+
+// Admin credentials (in production, store in database)
+const ADMIN_CREDENTIALS = [
+ {
+ username: 'admin',
+ password: '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
+ role: 'super_admin',
+ id: 'admin_1'
+ },
+ {
+ username: 'dw-admin',
+ password: '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
+ role: 'admin',
+ id: 'admin_dw'
+ },
+ {
+ username: 'monitor',
+ password: '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
+ role: 'monitor',
+ id: 'admin_monitor'
+ }
+]
+
+const JWT_SECRET = process.env.JWT_SECRET || 'dear-bubbe-admin-secret-key-change-in-production'
+
+// Rate limiting for auth endpoints
+const authLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000, // 15 minutes
+ max: 5, // limit each IP to 5 requests per windowMs
+ message: 'Too many login attempts, please try again later',
+ standardHeaders: true,
+ legacyHeaders: false
+})
+
+// Login endpoint
+router.post('/login', authLimiter, async (req, res) => {
+ try {
+ const { username, password } = req.body
+
+ if (!username || !password) {
+ return res.status(400).json({
+ success: false,
+ error: 'Username and password required'
+ })
+ }
+
+ // Find admin user
+ const admin = ADMIN_CREDENTIALS.find(a => a.username === username)
+ if (!admin) {
+ return res.status(401).json({
+ success: false,
+ error: 'Invalid credentials'
+ })
+ }
+
+ // Check password
+ const passwordMatch = await bcrypt.compare(password, admin.password)
+ if (!passwordMatch) {
+ return res.status(401).json({
+ success: false,
+ error: 'Invalid credentials'
+ })
+ }
+
+ // Generate JWT token
+ const token = jwt.sign(
+ {
+ userId: admin.id,
+ username: admin.username,
+ role: admin.role
+ },
+ JWT_SECRET,
+ { expiresIn: '24h' }
+ )
+
+ // Log successful login
+ console.log(`Admin login successful: ${username} (${admin.role}) from ${req.ip}`)
+
+ res.json({
+ success: true,
+ token,
+ user: {
+ id: admin.id,
+ username: admin.username,
+ role: admin.role
+ },
+ expiresIn: '24h'
+ })
+
+ } catch (error) {
+ console.error('Login error:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Authentication failed'
+ })
+ }
+})
+
+// Verify token endpoint
+router.post('/verify', (req, res) => {
+ try {
+ const { token } = req.body
+
+ if (!token) {
+ return res.status(400).json({
+ success: false,
+ error: 'Token required'
+ })
+ }
+
+ const decoded = jwt.verify(token, JWT_SECRET)
+
+ res.json({
+ success: true,
+ valid: true,
+ user: {
+ id: decoded.userId,
+ username: decoded.username,
+ role: decoded.role
+ }
+ })
+
+ } catch (error) {
+ res.status(401).json({
+ success: false,
+ valid: false,
+ error: 'Invalid token'
+ })
+ }
+})
+
+// Refresh token endpoint
+router.post('/refresh', (req, res) => {
+ try {
+ const { token } = req.body
+
+ if (!token) {
+ return res.status(400).json({
+ success: false,
+ error: 'Token required'
+ })
+ }
+
+ const decoded = jwt.verify(token, JWT_SECRET)
+
+ // Generate new token
+ const newToken = jwt.sign(
+ {
+ userId: decoded.userId,
+ username: decoded.username,
+ role: decoded.role
+ },
+ JWT_SECRET,
+ { expiresIn: '24h' }
+ )
+
+ res.json({
+ success: true,
+ token: newToken,
+ expiresIn: '24h'
+ })
+
+ } catch (error) {
+ res.status(401).json({
+ success: false,
+ error: 'Invalid token'
+ })
+ }
+})
+
+// Logout endpoint (mainly for logging purposes)
+router.post('/logout', (req, res) => {
+ // In a real app, you might blacklist the token
+ console.log(`Admin logout from ${req.ip}`)
+ res.json({
+ success: true,
+ message: 'Logged out successfully'
+ })
+})
+
+// Get current user info
+router.get('/me', (req, res) => {
+ try {
+ const authHeader = req.headers['authorization']
+ const token = authHeader && authHeader.split(' ')[1]
+
+ if (!token) {
+ return res.status(401).json({
+ success: false,
+ error: 'Access token required'
+ })
+ }
+
+ const decoded = jwt.verify(token, JWT_SECRET)
+
+ res.json({
+ success: true,
+ user: {
+ id: decoded.userId,
+ username: decoded.username,
+ role: decoded.role
+ }
+ })
+
+ } catch (error) {
+ res.status(401).json({
+ success: false,
+ error: 'Invalid token'
+ })
+ }
+})
+
+module.exports = router
\ No newline at end of file
diff --git a/backend/routes/monitoring.js b/backend/routes/monitoring.js
new file mode 100644
index 0000000..f6d934b
--- /dev/null
+++ b/backend/routes/monitoring.js
@@ -0,0 +1,516 @@
+const express = require('express')
+const Session = require('../models/Session')
+const Conversation = require('../models/Conversation')
+const Alert = require('../models/Alert')
+const User = require('../models/User')
+const os = require('os')
+const fs = require('fs')
+const path = require('path')
+const axios = require('axios')
+const router = express.Router()
+
+// Get real-time dashboard stats
+router.get('/dashboard', async (req, res) => {
+ try {
+ const now = new Date()
+ const last24h = new Date(now - 24 * 60 * 60 * 1000)
+ const lastHour = new Date(now - 60 * 60 * 1000)
+ const last5min = new Date(now - 5 * 60 * 1000)
+
+ // Active users and sessions
+ const activeUsers = await Session.countDocuments({
+ lastActivity: { $gte: last5min },
+ ended: false
+ })
+
+ const activeSessions = await Session.countDocuments({
+ lastActivity: { $gte: new Date(now - 30 * 60 * 1000) },
+ ended: false
+ })
+
+ // Conversations stats
+ const conversationsToday = await Conversation.countDocuments({
+ timestamp: { $gte: last24h }
+ })
+
+ const conversationsLastHour = await Conversation.countDocuments({
+ timestamp: { $gte: lastHour }
+ })
+
+ // Alert stats
+ const alertsToday = await Alert.countDocuments({
+ timestamp: { $gte: last24h }
+ })
+
+ const criticalAlerts = await Alert.countDocuments({
+ timestamp: { $gte: last24h },
+ severity: { $in: ['high', 'critical'] }
+ })
+
+ // System metrics
+ const totalMem = os.totalmem()
+ const freeMem = os.freemem()
+ const memUsage = ((totalMem - freeMem) / totalMem) * 100
+
+ const loadAvg = os.loadavg()
+ const cpuUsage = (loadAvg[0] / os.cpus().length) * 100
+
+ // Process metrics
+ const processMemory = process.memoryUsage()
+
+ // User stats
+ const totalUsers = await User.countDocuments()
+ const newUsersToday = await User.countDocuments({
+ createdAt: { $gte: last24h }
+ })
+
+ const blockedUsers = await User.countDocuments({
+ 'security.blocked': true
+ })
+
+ res.json({
+ success: true,
+ timestamp: now,
+ userActivity: {
+ activeUsers,
+ activeSessions,
+ totalUsers,
+ newUsersToday,
+ blockedUsers
+ },
+ conversations: {
+ today: conversationsToday,
+ lastHour: conversationsLastHour
+ },
+ alerts: {
+ today: alertsToday,
+ critical: criticalAlerts
+ },
+ system: {
+ memoryUsage: Math.round(memUsage),
+ cpuUsage: Math.min(Math.round(cpuUsage), 100),
+ uptime: os.uptime(),
+ loadAverage: loadAvg[0],
+ processMemoryMB: Math.round(processMemory.rss / 1024 / 1024)
+ }
+ })
+
+ } catch (error) {
+ console.error('Error fetching dashboard stats:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to fetch dashboard statistics'
+ })
+ }
+})
+
+// Get hourly activity for the last 24 hours
+router.get('/hourly-activity', async (req, res) => {
+ try {
+ const now = new Date()
+ const last24h = new Date(now - 24 * 60 * 60 * 1000)
+
+ // Get hourly conversation stats
+ const hourlyConversations = await Conversation.aggregate([
+ {
+ $match: {
+ timestamp: { $gte: last24h }
+ }
+ },
+ {
+ $group: {
+ _id: {
+ year: { $year: '$timestamp' },
+ month: { $month: '$timestamp' },
+ day: { $dayOfMonth: '$timestamp' },
+ hour: { $hour: '$timestamp' }
+ },
+ conversations: { $sum: 1 },
+ uniqueUsers: { $addToSet: '$userId' }
+ }
+ },
+ {
+ $addFields: {
+ userCount: { $size: '$uniqueUsers' },
+ hour: '$_id.hour'
+ }
+ },
+ {
+ $sort: { '_id.year': 1, '_id.month': 1, '_id.day': 1, '_id.hour': 1 }
+ }
+ ])
+
+ // Get hourly alert stats
+ const hourlyAlerts = await Alert.aggregate([
+ {
+ $match: {
+ timestamp: { $gte: last24h }
+ }
+ },
+ {
+ $group: {
+ _id: {
+ year: { $year: '$timestamp' },
+ month: { $month: '$timestamp' },
+ day: { $dayOfMonth: '$timestamp' },
+ hour: { $hour: '$timestamp' }
+ },
+ alerts: { $sum: 1 },
+ critical: { $sum: { $cond: [{ $in: ['$severity', ['high', 'critical']] }, 1, 0] } }
+ }
+ },
+ {
+ $sort: { '_id.year': 1, '_id.month': 1, '_id.day': 1, '_id.hour': 1 }
+ }
+ ])
+
+ res.json({
+ success: true,
+ timeframe: 'last_24_hours',
+ conversations: hourlyConversations,
+ alerts: hourlyAlerts
+ })
+
+ } catch (error) {
+ console.error('Error fetching hourly activity:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to fetch hourly activity'
+ })
+ }
+})
+
+// Get geographic distribution
+router.get('/geographic', async (req, res) => {
+ try {
+ const { timeframe = '24h' } = req.query
+
+ const now = new Date()
+ let startTime
+
+ switch (timeframe) {
+ case '1h':
+ startTime = new Date(now - 60 * 60 * 1000)
+ break
+ case '24h':
+ startTime = new Date(now - 24 * 60 * 60 * 1000)
+ break
+ case '7d':
+ startTime = new Date(now - 7 * 24 * 60 * 60 * 1000)
+ break
+ default:
+ startTime = new Date(now - 24 * 60 * 60 * 1000)
+ }
+
+ // Get session geographic data
+ const geoData = await Session.aggregate([
+ {
+ $match: {
+ startTime: { $gte: startTime }
+ }
+ },
+ {
+ $group: {
+ _id: {
+ country: '$location.country',
+ state: '$location.state'
+ },
+ sessions: { $sum: 1 },
+ uniqueUsers: { $addToSet: '$userId' },
+ messages: { $sum: '$activity.messageCount' },
+ alerts: { $sum: '$flags.alertsTriggered' }
+ }
+ },
+ {
+ $addFields: {
+ userCount: { $size: '$uniqueUsers' }
+ }
+ },
+ {
+ $sort: { sessions: -1 }
+ },
+ {
+ $limit: 50
+ }
+ ])
+
+ // Get country-level stats
+ const countryStats = await Session.aggregate([
+ {
+ $match: {
+ startTime: { $gte: startTime }
+ }
+ },
+ {
+ $group: {
+ _id: '$location.country',
+ sessions: { $sum: 1 },
+ uniqueUsers: { $addToSet: '$userId' },
+ messages: { $sum: '$activity.messageCount' }
+ }
+ },
+ {
+ $addFields: {
+ userCount: { $size: '$uniqueUsers' }
+ }
+ },
+ {
+ $sort: { sessions: -1 }
+ },
+ {
+ $limit: 20
+ }
+ ])
+
+ res.json({
+ success: true,
+ timeframe,
+ detailed: geoData,
+ byCountry: countryStats
+ })
+
+ } catch (error) {
+ console.error('Error fetching geographic data:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to fetch geographic data'
+ })
+ }
+})
+
+// Get topic analysis
+router.get('/topics', async (req, res) => {
+ try {
+ const { timeframe = '24h', limit = 20 } = req.query
+
+ const topTopics = await Conversation.getTopTopics(parseInt(limit), timeframe)
+
+ // Get sentiment analysis for top topics
+ const topicSentiment = await Conversation.aggregate([
+ {
+ $match: {
+ timestamp: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) }
+ }
+ },
+ {
+ $unwind: '$topics'
+ },
+ {
+ $group: {
+ _id: {
+ topic: '$topics',
+ sentiment: '$message.sentiment'
+ },
+ count: { $sum: 1 }
+ }
+ },
+ {
+ $group: {
+ _id: '$_id.topic',
+ sentiments: {
+ $push: {
+ sentiment: '$_id.sentiment',
+ count: '$count'
+ }
+ },
+ total: { $sum: '$count' }
+ }
+ },
+ {
+ $sort: { total: -1 }
+ },
+ {
+ $limit: 10
+ }
+ ])
+
+ res.json({
+ success: true,
+ timeframe,
+ topTopics,
+ sentimentAnalysis: topicSentiment
+ })
+
+ } catch (error) {
+ console.error('Error fetching topic analysis:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to fetch topic analysis'
+ })
+ }
+})
+
+// Check Dear Bubbe API health
+router.get('/bubbe-health', async (req, res) => {
+ try {
+ const startTime = Date.now()
+
+ // Check main API
+ const apiResponse = await axios.get('http://localhost:3011/api/health', {
+ timeout: 10000
+ })
+
+ const responseTime = Date.now() - startTime
+
+ // Check if PM2 process is running
+ let pm2Status = 'unknown'
+ try {
+ const { execSync } = require('child_process')
+ const pm2List = execSync('pm2 jlist', { encoding: 'utf-8' })
+ const processes = JSON.parse(pm2List)
+ const bubbeProcess = processes.find(p => p.name === 'bubbe')
+
+ if (bubbeProcess) {
+ pm2Status = bubbeProcess.pm2_env.status
+ }
+ } catch (error) {
+ console.warn('Could not check PM2 status:', error.message)
+ }
+
+ // Check disk space
+ const stats = fs.statSync('/')
+ const diskUsage = {
+ // This is a simplified check - in production you'd use proper disk space checking
+ available: true
+ }
+
+ res.json({
+ success: true,
+ timestamp: new Date(),
+ api: {
+ status: apiResponse.status === 200 ? 'healthy' : 'unhealthy',
+ responseTime,
+ data: apiResponse.data
+ },
+ pm2: {
+ status: pm2Status
+ },
+ disk: diskUsage,
+ overall: apiResponse.status === 200 && pm2Status === 'online' ? 'healthy' : 'degraded'
+ })
+
+ } catch (error) {
+ console.error('Bubbe health check failed:', error.message)
+ res.json({
+ success: false,
+ timestamp: new Date(),
+ api: {
+ status: 'unhealthy',
+ error: error.message
+ },
+ overall: 'unhealthy'
+ })
+ }
+})
+
+// Get live session data
+router.get('/live-sessions', async (req, res) => {
+ try {
+ const activeSessions = await Session.getActiveSessions(30) // Last 30 minutes
+
+ // Enrich with user data
+ const enrichedSessions = await Promise.all(
+ activeSessions.map(async (session) => {
+ const user = await User.findOne({ userId: session.userId })
+ .select('profile.name profile.preferredName security.riskLevel security.blocked')
+ .lean()
+
+ return {
+ ...session,
+ user: user || { profile: { name: 'Unknown User' } }
+ }
+ })
+ )
+
+ res.json({
+ success: true,
+ timestamp: new Date(),
+ activeSessions: enrichedSessions,
+ count: enrichedSessions.length
+ })
+
+ } catch (error) {
+ console.error('Error fetching live sessions:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to fetch live sessions'
+ })
+ }
+})
+
+// Get performance metrics
+router.get('/performance', async (req, res) => {
+ try {
+ const { timeframe = '24h' } = req.query
+
+ const now = new Date()
+ let startTime
+
+ switch (timeframe) {
+ case '1h':
+ startTime = new Date(now - 60 * 60 * 1000)
+ break
+ case '24h':
+ startTime = new Date(now - 24 * 60 * 60 * 1000)
+ break
+ case '7d':
+ startTime = new Date(now - 7 * 24 * 60 * 60 * 1000)
+ break
+ default:
+ startTime = new Date(now - 24 * 60 * 60 * 1000)
+ }
+
+ // Response time stats
+ const responseTimeStats = await Conversation.aggregate([
+ {
+ $match: {
+ timestamp: { $gte: startTime },
+ 'analysis.responseTime': { $exists: true }
+ }
+ },
+ {
+ $group: {
+ _id: null,
+ avgResponseTime: { $avg: '$analysis.responseTime' },
+ minResponseTime: { $min: '$analysis.responseTime' },
+ maxResponseTime: { $max: '$analysis.responseTime' },
+ count: { $sum: 1 }
+ }
+ }
+ ])
+
+ // Error rate
+ const errorStats = await Session.aggregate([
+ {
+ $match: {
+ startTime: { $gte: startTime }
+ }
+ },
+ {
+ $group: {
+ _id: null,
+ totalSessions: { $sum: 1 },
+ totalErrors: { $sum: '$activity.errorsEncountered' },
+ avgErrors: { $avg: '$activity.errorsEncountered' }
+ }
+ }
+ ])
+
+ res.json({
+ success: true,
+ timeframe,
+ responseTime: responseTimeStats[0] || {},
+ errors: errorStats[0] || {},
+ errorRate: errorStats[0] ? (errorStats[0].totalErrors / errorStats[0].totalSessions) * 100 : 0
+ })
+
+ } catch (error) {
+ console.error('Error fetching performance metrics:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to fetch performance metrics'
+ })
+ }
+})
+
+module.exports = router
\ No newline at end of file
diff --git a/backend/routes/reports.js b/backend/routes/reports.js
new file mode 100644
index 0000000..3f9d946
--- /dev/null
+++ b/backend/routes/reports.js
@@ -0,0 +1,575 @@
+const express = require('express')
+const User = require('../models/User')
+const Session = require('../models/Session')
+const Conversation = require('../models/Conversation')
+const Alert = require('../models/Alert')
+const fs = require('fs')
+const path = require('path')
+const moment = require('moment')
+const router = express.Router()
+
+// Generate comprehensive daily report
+router.get('/daily', async (req, res) => {
+ try {
+ const { date } = req.query
+ const targetDate = date ? new Date(date) : new Date()
+ const startOfDay = new Date(targetDate.setHours(0, 0, 0, 0))
+ const endOfDay = new Date(targetDate.setHours(23, 59, 59, 999))
+
+ console.log(`Generating daily report for ${startOfDay.toDateString()}`)
+
+ // User activity
+ const userStats = {
+ newRegistrations: await User.countDocuments({
+ createdAt: { $gte: startOfDay, $lte: endOfDay }
+ }),
+ activeUsers: await Session.countDocuments({
+ startTime: { $gte: startOfDay, $lte: endOfDay }
+ }),
+ totalSessions: await Session.countDocuments({
+ startTime: { $gte: startOfDay, $lte: endOfDay }
+ })
+ }
+
+ // Conversation stats
+ const conversationStats = await Conversation.aggregate([
+ {
+ $match: {
+ timestamp: { $gte: startOfDay, $lte: endOfDay }
+ }
+ },
+ {
+ $group: {
+ _id: null,
+ totalConversations: { $sum: 1 },
+ uniqueUsers: { $addToSet: '$userId' },
+ avgResponseTime: { $avg: '$analysis.responseTime' },
+ sentimentBreakdown: {
+ positive: { $sum: { $cond: [{ $eq: ['$message.sentiment', 'positive'] }, 1, 0] } },
+ neutral: { $sum: { $cond: [{ $eq: ['$message.sentiment', 'neutral'] }, 1, 0] } },
+ negative: { $sum: { $cond: [{ $eq: ['$message.sentiment', 'negative'] }, 1, 0] } }
+ },
+ modeUsage: {
+ bubbe: { $sum: { $cond: [{ $eq: ['$response.mode', 'bubbe'] }, 1, 0] } },
+ meshugana: { $sum: { $cond: [{ $eq: ['$response.mode', 'meshugana'] }, 1, 0] } },
+ comedian: { $sum: { $cond: [{ $eq: ['$response.mode', 'comedian'] }, 1, 0] } }
+ },
+ voiceUsage: { $sum: { $cond: ['$response.voiceGenerated', 1, 0] } }
+ }
+ }
+ ])
+
+ // Alert statistics
+ const alertStats = await Alert.aggregate([
+ {
+ $match: {
+ timestamp: { $gte: startOfDay, $lte: endOfDay }
+ }
+ },
+ {
+ $group: {
+ _id: null,
+ totalAlerts: { $sum: 1 },
+ bySeverity: {
+ critical: { $sum: { $cond: [{ $eq: ['$severity', 'critical'] }, 1, 0] } },
+ high: { $sum: { $cond: [{ $eq: ['$severity', 'high'] }, 1, 0] } },
+ medium: { $sum: { $cond: [{ $eq: ['$severity', 'medium'] }, 1, 0] } },
+ low: { $sum: { $cond: [{ $eq: ['$severity', 'low'] }, 1, 0] } }
+ },
+ byType: {
+ antisemitism: { $sum: { $cond: [{ $eq: ['$type', 'antisemitism'] }, 1, 0] } },
+ racism: { $sum: { $cond: [{ $eq: ['$type', 'racism'] }, 1, 0] } },
+ harassment: { $sum: { $cond: [{ $eq: ['$type', 'harassment'] }, 1, 0] } },
+ spam: { $sum: { $cond: [{ $eq: ['$type', 'spam'] }, 1, 0] } }
+ },
+ uniqueIPs: { $addToSet: '$userInfo.ip' },
+ uniqueUsers: { $addToSet: '$userInfo.userId' }
+ }
+ }
+ ])
+
+ // Top topics
+ const topTopics = await Conversation.aggregate([
+ {
+ $match: {
+ timestamp: { $gte: startOfDay, $lte: endOfDay }
+ }
+ },
+ {
+ $unwind: '$topics'
+ },
+ {
+ $group: {
+ _id: '$topics',
+ count: { $sum: 1 }
+ }
+ },
+ {
+ $sort: { count: -1 }
+ },
+ {
+ $limit: 10
+ }
+ ])
+
+ // Geographic distribution
+ const geoStats = await Session.aggregate([
+ {
+ $match: {
+ startTime: { $gte: startOfDay, $lte: endOfDay }
+ }
+ },
+ {
+ $group: {
+ _id: '$location.country',
+ sessions: { $sum: 1 },
+ uniqueUsers: { $addToSet: '$userId' }
+ }
+ },
+ {
+ $addFields: {
+ userCount: { $size: '$uniqueUsers' }
+ }
+ },
+ {
+ $sort: { sessions: -1 }
+ },
+ {
+ $limit: 10
+ }
+ ])
+
+ const report = {
+ reportType: 'daily',
+ date: startOfDay.toDateString(),
+ generatedAt: new Date(),
+ generatedBy: req.user.userId,
+ userActivity: {
+ ...userStats,
+ uniqueActiveUsers: conversationStats[0]?.uniqueUsers?.length || 0
+ },
+ conversations: conversationStats[0] || {},
+ alerts: {
+ ...alertStats[0],
+ uniqueOffendingIPs: alertStats[0]?.uniqueIPs?.length || 0,
+ uniqueOffendingUsers: alertStats[0]?.uniqueUsers?.length || 0
+ },
+ topTopics,
+ geography: geoStats,
+ summary: {
+ totalInteractions: conversationStats[0]?.totalConversations || 0,
+ securityIncidents: alertStats[0]?.totalAlerts || 0,
+ userGrowth: userStats.newRegistrations,
+ engagement: conversationStats[0]?.totalConversations / Math.max(userStats.activeUsers, 1),
+ alertRate: (alertStats[0]?.totalAlerts || 0) / Math.max(conversationStats[0]?.totalConversations || 1, 1) * 100
+ }
+ }
+
+ res.json({
+ success: true,
+ report
+ })
+
+ } catch (error) {
+ console.error('Error generating daily report:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to generate daily report'
+ })
+ }
+})
+
+// Generate weekly report
+router.get('/weekly', async (req, res) => {
+ try {
+ const { week } = req.query
+ const now = new Date()
+ const startOfWeek = week ? new Date(week) : new Date(now.setDate(now.getDate() - now.getDay()))
+ const endOfWeek = new Date(startOfWeek)
+ endOfWeek.setDate(endOfWeek.getDate() + 6)
+ endOfWeek.setHours(23, 59, 59, 999)
+ startOfWeek.setHours(0, 0, 0, 0)
+
+ // Daily breakdown for the week
+ const dailyStats = []
+ for (let i = 0; i < 7; i++) {
+ const dayStart = new Date(startOfWeek)
+ dayStart.setDate(dayStart.getDate() + i)
+ dayStart.setHours(0, 0, 0, 0)
+
+ const dayEnd = new Date(dayStart)
+ dayEnd.setHours(23, 59, 59, 999)
+
+ const dayStats = {
+ date: dayStart.toDateString(),
+ conversations: await Conversation.countDocuments({
+ timestamp: { $gte: dayStart, $lte: dayEnd }
+ }),
+ alerts: await Alert.countDocuments({
+ timestamp: { $gte: dayStart, $lte: dayEnd }
+ }),
+ sessions: await Session.countDocuments({
+ startTime: { $gte: dayStart, $lte: dayEnd }
+ })
+ }
+
+ dailyStats.push(dayStats)
+ }
+
+ // Week totals
+ const weekTotals = {
+ conversations: await Conversation.countDocuments({
+ timestamp: { $gte: startOfWeek, $lte: endOfWeek }
+ }),
+ alerts: await Alert.countDocuments({
+ timestamp: { $gte: startOfWeek, $lte: endOfWeek }
+ }),
+ newUsers: await User.countDocuments({
+ createdAt: { $gte: startOfWeek, $lte: endOfWeek }
+ }),
+ sessions: await Session.countDocuments({
+ startTime: { $gte: startOfWeek, $lte: endOfWeek }
+ })
+ }
+
+ // Top users of the week
+ const topUsers = await Conversation.aggregate([
+ {
+ $match: {
+ timestamp: { $gte: startOfWeek, $lte: endOfWeek }
+ }
+ },
+ {
+ $group: {
+ _id: '$userId',
+ messageCount: { $sum: 1 },
+ topics: { $addToSet: '$topics' }
+ }
+ },
+ {
+ $sort: { messageCount: -1 }
+ },
+ {
+ $limit: 10
+ }
+ ])
+
+ const report = {
+ reportType: 'weekly',
+ weekStart: startOfWeek.toDateString(),
+ weekEnd: endOfWeek.toDateString(),
+ generatedAt: new Date(),
+ generatedBy: req.user.userId,
+ dailyBreakdown: dailyStats,
+ weekTotals,
+ topUsers,
+ trends: {
+ conversationTrend: calculateTrend(dailyStats.map(d => d.conversations)),
+ alertTrend: calculateTrend(dailyStats.map(d => d.alerts)),
+ sessionTrend: calculateTrend(dailyStats.map(d => d.sessions))
+ }
+ }
+
+ res.json({
+ success: true,
+ report
+ })
+
+ } catch (error) {
+ console.error('Error generating weekly report:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to generate weekly report'
+ })
+ }
+})
+
+// Generate security report
+router.get('/security', async (req, res) => {
+ try {
+ const { timeframe = '7d' } = req.query
+
+ const now = new Date()
+ let startTime
+
+ switch (timeframe) {
+ case '24h':
+ startTime = new Date(now - 24 * 60 * 60 * 1000)
+ break
+ case '7d':
+ startTime = new Date(now - 7 * 24 * 60 * 60 * 1000)
+ break
+ case '30d':
+ startTime = new Date(now - 30 * 24 * 60 * 60 * 1000)
+ break
+ default:
+ startTime = new Date(now - 7 * 24 * 60 * 60 * 1000)
+ }
+
+ // Alert analysis
+ const alertAnalysis = await Alert.aggregate([
+ {
+ $match: {
+ timestamp: { $gte: startTime }
+ }
+ },
+ {
+ $group: {
+ _id: {
+ type: '$type',
+ severity: '$severity'
+ },
+ count: { $sum: 1 },
+ uniqueIPs: { $addToSet: '$userInfo.ip' },
+ uniqueUsers: { $addToSet: '$userInfo.userId' },
+ patterns: { $addToSet: '$violation.pattern' },
+ locations: { $addToSet: '$location.country' }
+ }
+ }
+ ])
+
+ // Repeat offenders
+ const repeatOffenders = await Alert.aggregate([
+ {
+ $match: {
+ timestamp: { $gte: startTime }
+ }
+ },
+ {
+ $group: {
+ _id: '$userInfo.ip',
+ alertCount: { $sum: 1 },
+ severities: { $push: '$severity' },
+ types: { $push: '$type' },
+ locations: { $addToSet: '$location' },
+ userIds: { $addToSet: '$userInfo.userId' }
+ }
+ },
+ {
+ $match: {
+ alertCount: { $gte: 2 }
+ }
+ },
+ {
+ $sort: { alertCount: -1 }
+ },
+ {
+ $limit: 20
+ }
+ ])
+
+ // Blocked users
+ const blockedUsers = await User.aggregate([
+ {
+ $match: {
+ 'security.blocked': true,
+ 'security.blockedAt': { $gte: startTime }
+ }
+ },
+ {
+ $group: {
+ _id: '$security.blockReason',
+ count: { $sum: 1 },
+ users: { $push: { userId: '$userId', blockedAt: '$security.blockedAt' } }
+ }
+ }
+ ])
+
+ // Geographic threat distribution
+ const geoThreats = await Alert.aggregate([
+ {
+ $match: {
+ timestamp: { $gte: startTime },
+ severity: { $in: ['high', 'critical'] }
+ }
+ },
+ {
+ $group: {
+ _id: '$location.country',
+ alerts: { $sum: 1 },
+ uniqueIPs: { $addToSet: '$userInfo.ip' },
+ types: { $addToSet: '$type' }
+ }
+ },
+ {
+ $addFields: {
+ uniqueIPCount: { $size: '$uniqueIPs' }
+ }
+ },
+ {
+ $sort: { alerts: -1 }
+ }
+ ])
+
+ const report = {
+ reportType: 'security',
+ timeframe,
+ generatedAt: new Date(),
+ generatedBy: req.user.userId,
+ alertAnalysis,
+ repeatOffenders,
+ blockedUsers,
+ geoThreats,
+ summary: {
+ totalAlerts: alertAnalysis.reduce((sum, item) => sum + item.count, 0),
+ criticalIncidents: alertAnalysis
+ .filter(item => item._id.severity === 'critical')
+ .reduce((sum, item) => sum + item.count, 0),
+ repeatOffenderCount: repeatOffenders.length,
+ topThreatCountry: geoThreats[0]?._id || 'Unknown',
+ blockedUserCount: blockedUsers.reduce((sum, item) => sum + item.count, 0)
+ }
+ }
+
+ res.json({
+ success: true,
+ report
+ })
+
+ } catch (error) {
+ console.error('Error generating security report:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to generate security report'
+ })
+ }
+})
+
+// Export report to file
+router.post('/export', async (req, res) => {
+ try {
+ const { reportType, data } = req.body
+
+ if (!reportType || !data) {
+ return res.status(400).json({
+ success: false,
+ error: 'Report type and data required'
+ })
+ }
+
+ const timestamp = moment().format('YYYY-MM-DD_HH-mm-ss')
+ const filename = `${reportType}_report_${timestamp}.json`
+ const exportDir = path.join(__dirname, '../exports')
+
+ // Ensure export directory exists
+ if (!fs.existsSync(exportDir)) {
+ fs.mkdirSync(exportDir, { recursive: true })
+ }
+
+ const filepath = path.join(exportDir, filename)
+
+ // Add metadata
+ const exportData = {
+ ...data,
+ exportedAt: new Date(),
+ exportedBy: req.user.userId,
+ filename
+ }
+
+ fs.writeFileSync(filepath, JSON.stringify(exportData, null, 2))
+
+ console.log(`Report exported: ${filename} by ${req.user.userId}`)
+
+ res.json({
+ success: true,
+ filename,
+ filepath,
+ size: fs.statSync(filepath).size
+ })
+
+ } catch (error) {
+ console.error('Error exporting report:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to export report'
+ })
+ }
+})
+
+// Get list of exported reports
+router.get('/exports', async (req, res) => {
+ try {
+ const exportDir = path.join(__dirname, '../exports')
+
+ if (!fs.existsSync(exportDir)) {
+ return res.json({
+ success: true,
+ exports: []
+ })
+ }
+
+ const files = fs.readdirSync(exportDir)
+ const exports = files
+ .filter(file => file.endsWith('.json'))
+ .map(file => {
+ const filepath = path.join(exportDir, file)
+ const stats = fs.statSync(filepath)
+ return {
+ filename: file,
+ size: stats.size,
+ created: stats.birthtime,
+ modified: stats.mtime
+ }
+ })
+ .sort((a, b) => new Date(b.created) - new Date(a.created))
+
+ res.json({
+ success: true,
+ exports
+ })
+
+ } catch (error) {
+ console.error('Error listing exports:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to list exports'
+ })
+ }
+})
+
+// Download exported report
+router.get('/exports/:filename', async (req, res) => {
+ try {
+ const { filename } = req.params
+ const exportDir = path.join(__dirname, '../exports')
+ const filepath = path.join(exportDir, filename)
+
+ if (!fs.existsSync(filepath)) {
+ return res.status(404).json({
+ success: false,
+ error: 'Export file not found'
+ })
+ }
+
+ res.download(filepath, filename, (err) => {
+ if (err) {
+ console.error('Error downloading file:', err)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to download file'
+ })
+ }
+ })
+
+ } catch (error) {
+ console.error('Error downloading export:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to download export'
+ })
+ }
+})
+
+// Helper function to calculate trend
+function calculateTrend(values) {
+ if (values.length < 2) return 0
+
+ const first = values[0]
+ const last = values[values.length - 1]
+
+ if (first === 0) return last > 0 ? 100 : 0
+
+ return ((last - first) / first) * 100
+}
+
+module.exports = router
\ No newline at end of file
diff --git a/backend/routes/scanner.js b/backend/routes/scanner.js
new file mode 100644
index 0000000..b63c768
--- /dev/null
+++ b/backend/routes/scanner.js
@@ -0,0 +1,113 @@
+const express = require('express')
+const MessageInterceptor = require('../middleware/MessageInterceptor')
+const router = express.Router()
+
+// Initialize message interceptor (will be set by server)
+let messageInterceptor = null
+
+// Set the interceptor instance (called from server.js)
+router.setInterceptor = (interceptor) => {
+ messageInterceptor = interceptor
+}
+
+// Public endpoint for scanning messages (no auth required for integration)
+router.post('/scan-message', async (req, res) => {
+ try {
+ if (!messageInterceptor) {
+ return res.status(503).json({
+ success: false,
+ error: 'Scanner not initialized'
+ })
+ }
+
+ const messageData = req.body
+
+ // Validate required fields
+ if (!messageData.message) {
+ return res.status(400).json({
+ success: false,
+ error: 'Message is required'
+ })
+ }
+
+ console.log(`[SCANNER] Processing message from ${messageData.userId || 'unknown'}`)
+
+ const result = await messageInterceptor.processMessage(messageData)
+
+ res.json({
+ success: true,
+ result
+ })
+
+ } catch (error) {
+ console.error('[SCANNER] Error:', error)
+ res.status(500).json({
+ success: false,
+ error: error.message
+ })
+ }
+})
+
+// Health check for scanner
+router.get('/scanner/health', (req, res) => {
+ res.json({
+ success: true,
+ status: 'healthy',
+ timestamp: new Date(),
+ scannerReady: !!messageInterceptor
+ })
+})
+
+// Get scanner stats (auth required)
+router.get('/scanner/stats', async (req, res) => {
+ try {
+ // This would typically require authentication, but for now just return basic stats
+ const stats = {
+ processed: messageInterceptor?.processedMessages?.size || 0,
+ uptime: process.uptime(),
+ timestamp: new Date()
+ }
+
+ res.json({
+ success: true,
+ stats
+ })
+ } catch (error) {
+ console.error('[SCANNER] Stats error:', error)
+ res.status(500).json({
+ success: false,
+ error: error.message
+ })
+ }
+})
+
+// Integration guide endpoint
+router.get('/integration-guide', (req, res) => {
+ res.json({
+ success: true,
+ integration: {
+ endpoint: '/api/scan-message',
+ method: 'POST',
+ example: {
+ message: 'User message to scan',
+ userId: 'user123',
+ sessionId: 'session456',
+ ip: '192.168.1.1',
+ userAgent: 'Mozilla/5.0...',
+ userProfile: {}
+ },
+ response: {
+ success: true,
+ result: {
+ processed: true,
+ blocked: false,
+ violations: [],
+ alertsCreated: 0
+ }
+ }
+ },
+ codeExample: MessageInterceptor.createIntegrationCode()
+ })
+})
+
+module.exports = router
\ No newline at end of file
diff --git a/backend/routes/users.js b/backend/routes/users.js
new file mode 100644
index 0000000..fdc5433
--- /dev/null
+++ b/backend/routes/users.js
@@ -0,0 +1,476 @@
+const express = require('express')
+const User = require('../models/User')
+const Session = require('../models/Session')
+const Conversation = require('../models/Conversation')
+const Alert = require('../models/Alert')
+const fs = require('fs')
+const path = require('path')
+const router = express.Router()
+
+// Get all users with filtering and pagination
+router.get('/', async (req, res) => {
+ try {
+ const {
+ page = 1,
+ limit = 50,
+ search,
+ blocked,
+ riskLevel,
+ country,
+ state,
+ sort = '-stats.lastVisit'
+ } = req.query
+
+ // Build query
+ const query = {}
+
+ if (search) {
+ query.$or = [
+ { 'profile.name': { $regex: search, $options: 'i' } },
+ { 'profile.preferredName': { $regex: search, $options: 'i' } },
+ { email: { $regex: search, $options: 'i' } },
+ { userId: search }
+ ]
+ }
+
+ if (blocked !== undefined) {
+ query['security.blocked'] = blocked === 'true'
+ }
+
+ if (riskLevel) {
+ query['security.riskLevel'] = riskLevel
+ }
+
+ if (country) {
+ query['location.country'] = country
+ }
+
+ if (state) {
+ query['location.state'] = state
+ }
+
+ const users = await User.find(query)
+ .sort(sort)
+ .limit(limit * 1)
+ .skip((page - 1) * limit)
+ .lean()
+
+ const total = await User.countDocuments(query)
+
+ res.json({
+ success: true,
+ users,
+ pagination: {
+ current: parseInt(page),
+ limit: parseInt(limit),
+ total,
+ pages: Math.ceil(total / limit)
+ }
+ })
+
+ } catch (error) {
+ console.error('Error fetching users:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to fetch users'
+ })
+ }
+})
+
+// Get user statistics
+router.get('/stats', async (req, res) => {
+ try {
+ const stats = await User.getStats()
+ const geoStats = await User.getGeoDistribution()
+
+ // Additional stats
+ const activeUsersToday = await User.countDocuments({
+ 'stats.lastVisit': { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) }
+ })
+
+ const newUsersThisWeek = await User.countDocuments({
+ createdAt: { $gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }
+ })
+
+ const topActiveUsers = await User.aggregate([
+ {
+ $match: {
+ 'stats.lastVisit': { $gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }
+ }
+ },
+ {
+ $sort: { 'stats.totalMessages': -1 }
+ },
+ {
+ $limit: 10
+ },
+ {
+ $project: {
+ userId: 1,
+ 'profile.name': 1,
+ 'profile.preferredName': 1,
+ 'stats.totalMessages': 1,
+ 'stats.lastVisit': 1,
+ 'location.country': 1,
+ 'location.state': 1
+ }
+ }
+ ])
+
+ res.json({
+ success: true,
+ stats: stats[0] || {},
+ geoStats,
+ activeUsersToday,
+ newUsersThisWeek,
+ topActiveUsers
+ })
+
+ } catch (error) {
+ console.error('Error fetching user stats:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to fetch user statistics'
+ })
+ }
+})
+
+// Get specific user details
+router.get('/:userId', async (req, res) => {
+ try {
+ const user = await User.findOne({ userId: req.params.userId }).lean()
+
+ if (!user) {
+ return res.status(404).json({
+ success: false,
+ error: 'User not found'
+ })
+ }
+
+ // Get user's sessions
+ const sessions = await Session.find({ userId: req.params.userId })
+ .sort({ startTime: -1 })
+ .limit(20)
+ .lean()
+
+ // Get recent conversations
+ const conversations = await Conversation.find({ userId: req.params.userId })
+ .sort({ timestamp: -1 })
+ .limit(50)
+ .lean()
+
+ // Get alerts for this user
+ const alerts = await Alert.find({ 'userInfo.userId': req.params.userId })
+ .sort({ timestamp: -1 })
+ .limit(20)
+ .lean()
+
+ // Load user memory from Dear Bubbe if it exists
+ let userMemory = null
+ try {
+ const memoryPath = path.join(process.cwd(), '../dear-bubbe-nextjs/user-memories', `${req.params.userId.replace(/[^a-zA-Z0-9-_]/g, '_')}.md`)
+ if (fs.existsSync(memoryPath)) {
+ userMemory = fs.readFileSync(memoryPath, 'utf-8')
+ }
+ } catch (error) {
+ console.log('Could not load user memory:', error.message)
+ }
+
+ // Load user profile from Dear Bubbe profiles directory
+ let bubbeProfile = null
+ try {
+ const profilesDir = path.join(process.cwd(), '../dear-bubbe-nextjs/data/profiles')
+ const files = fs.readdirSync(profilesDir)
+ const profileFile = files.find(f => f.includes(user.userId) || f.includes(user.email))
+
+ if (profileFile) {
+ const profilePath = path.join(profilesDir, profileFile)
+ bubbeProfile = JSON.parse(fs.readFileSync(profilePath, 'utf-8'))
+ }
+ } catch (error) {
+ console.log('Could not load Bubbe profile:', error.message)
+ }
+
+ res.json({
+ success: true,
+ user,
+ sessions,
+ conversations,
+ alerts,
+ userMemory,
+ bubbeProfile,
+ summary: {
+ totalSessions: sessions.length,
+ totalConversations: conversations.length,
+ totalAlerts: alerts.length,
+ riskLevel: user.security?.riskLevel || 'low',
+ lastActivity: user.stats?.lastVisit
+ }
+ })
+
+ } catch (error) {
+ console.error('Error fetching user details:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to fetch user details'
+ })
+ }
+})
+
+// Update user profile/settings
+router.put('/:userId', async (req, res) => {
+ try {
+ const { profile, security, preferences, notes } = req.body
+ const adminId = req.user.userId
+
+ const user = await User.findOne({ userId: req.params.userId })
+
+ if (!user) {
+ return res.status(404).json({
+ success: false,
+ error: 'User not found'
+ })
+ }
+
+ // Update profile if provided
+ if (profile) {
+ user.profile = { ...user.profile, ...profile }
+ }
+
+ // Update security settings (admin only)
+ if (security && (req.user.role === 'admin' || req.user.role === 'super_admin')) {
+ user.security = { ...user.security, ...security }
+ }
+
+ // Update preferences
+ if (preferences) {
+ user.preferences = { ...user.preferences, ...preferences }
+ }
+
+ // Add admin note if provided
+ if (notes) {
+ await user.addNote(`Updated by admin: ${notes}`, adminId, 'info')
+ }
+
+ await user.save()
+
+ console.log(`User ${req.params.userId} updated by ${adminId}`)
+
+ res.json({
+ success: true,
+ user: user.toObject()
+ })
+
+ } catch (error) {
+ console.error('Error updating user:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to update user'
+ })
+ }
+})
+
+// Block user
+router.post('/:userId/block', async (req, res) => {
+ try {
+ const { reason } = req.body
+ const adminId = req.user.userId
+
+ if (!reason) {
+ return res.status(400).json({
+ success: false,
+ error: 'Block reason required'
+ })
+ }
+
+ const user = await User.findOne({ userId: req.params.userId })
+
+ if (!user) {
+ return res.status(404).json({
+ success: false,
+ error: 'User not found'
+ })
+ }
+
+ await user.block(reason, adminId)
+
+ console.log(`User ${req.params.userId} blocked by ${adminId}: ${reason}`)
+
+ res.json({
+ success: true,
+ message: 'User blocked successfully',
+ user: user.toObject()
+ })
+
+ } catch (error) {
+ console.error('Error blocking user:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to block user'
+ })
+ }
+})
+
+// Unblock user
+router.post('/:userId/unblock', async (req, res) => {
+ try {
+ const adminId = req.user.userId
+
+ const user = await User.findOne({ userId: req.params.userId })
+
+ if (!user) {
+ return res.status(404).json({
+ success: false,
+ error: 'User not found'
+ })
+ }
+
+ if (!user.security.blocked) {
+ return res.status(400).json({
+ success: false,
+ error: 'User is not blocked'
+ })
+ }
+
+ await user.unblock(adminId)
+
+ console.log(`User ${req.params.userId} unblocked by ${adminId}`)
+
+ res.json({
+ success: true,
+ message: 'User unblocked successfully',
+ user: user.toObject()
+ })
+
+ } catch (error) {
+ console.error('Error unblocking user:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to unblock user'
+ })
+ }
+})
+
+// Add note to user
+router.post('/:userId/notes', async (req, res) => {
+ try {
+ const { note, type = 'info' } = req.body
+ const adminId = req.user.userId
+
+ if (!note) {
+ return res.status(400).json({
+ success: false,
+ error: 'Note content required'
+ })
+ }
+
+ const user = await User.findOne({ userId: req.params.userId })
+
+ if (!user) {
+ return res.status(404).json({
+ success: false,
+ error: 'User not found'
+ })
+ }
+
+ await user.addNote(note, adminId, type)
+
+ res.json({
+ success: true,
+ message: 'Note added successfully',
+ notes: user.notes
+ })
+
+ } catch (error) {
+ console.error('Error adding note:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to add note'
+ })
+ }
+})
+
+// Get user conversation history
+router.get('/:userId/conversations', async (req, res) => {
+ try {
+ const { page = 1, limit = 100, topic, sentiment } = req.query
+
+ const query = { userId: req.params.userId }
+
+ if (topic) {
+ query.topics = topic
+ }
+
+ if (sentiment) {
+ query['message.sentiment'] = sentiment
+ }
+
+ const conversations = await Conversation.find(query)
+ .sort({ timestamp: -1 })
+ .limit(limit * 1)
+ .skip((page - 1) * limit)
+ .lean()
+
+ const total = await Conversation.countDocuments(query)
+
+ res.json({
+ success: true,
+ conversations,
+ pagination: {
+ current: parseInt(page),
+ limit: parseInt(limit),
+ total,
+ pages: Math.ceil(total / limit)
+ }
+ })
+
+ } catch (error) {
+ console.error('Error fetching user conversations:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to fetch conversations'
+ })
+ }
+})
+
+// Export user data (GDPR compliance)
+router.get('/:userId/export', async (req, res) => {
+ try {
+ const user = await User.findOne({ userId: req.params.userId }).lean()
+
+ if (!user) {
+ return res.status(404).json({
+ success: false,
+ error: 'User not found'
+ })
+ }
+
+ const sessions = await Session.find({ userId: req.params.userId }).lean()
+ const conversations = await Conversation.find({ userId: req.params.userId }).lean()
+ const alerts = await Alert.find({ 'userInfo.userId': req.params.userId }).lean()
+
+ const exportData = {
+ user,
+ sessions,
+ conversations,
+ alerts,
+ exportedAt: new Date(),
+ exportedBy: req.user.userId
+ }
+
+ res.setHeader('Content-Type', 'application/json')
+ res.setHeader('Content-Disposition', `attachment; filename="user_data_${req.params.userId}_${Date.now()}.json"`)
+ res.json(exportData)
+
+ console.log(`User data exported for ${req.params.userId} by ${req.user.userId}`)
+
+ } catch (error) {
+ console.error('Error exporting user data:', error)
+ res.status(500).json({
+ success: false,
+ error: 'Failed to export user data'
+ })
+ }
+})
+
+module.exports = router
\ No newline at end of file
diff --git a/backend/server.js b/backend/server.js
new file mode 100644
index 0000000..4895f8f
--- /dev/null
+++ b/backend/server.js
@@ -0,0 +1,973 @@
+const express = require('express')
+const cors = require('cors')
+const helmet = require('helmet')
+const compression = require('compression')
+const winston = require('winston')
+const fs = require('fs').promises
+const path = require('path')
+const jwt = require('jsonwebtoken')
+const { OAuth2Client } = require('google-auth-library')
+const http = require('http')
+const { Server } = require('socket.io')
+require('dotenv').config()
+
+// Constants
+const PORT = process.env.PORT || 5013
+const ADMIN_EMAIL = 'steve@designerwallcoverings.com'
+const JWT_SECRET = process.env.JWT_SECRET;
+if (!JWT_SECRET) {
+ throw new Error('JWT_SECRET env var required (no fallback for production safety) — audit 2026-05-04');
+}
+const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || '493480746821-mhpjc0glkkogcf845ubi2hkhksoqj39k.apps.googleusercontent.com'
+const PROFILES_DIR = path.join(__dirname, '../../dear-bubbe-nextjs/data/profiles')
+const LOGS_DIR = path.join(__dirname, '../logs')
+
+// Logger setup
+const logger = winston.createLogger({
+ level: 'info',
+ format: winston.format.combine(
+ winston.format.timestamp(),
+ winston.format.json()
+ ),
+ transports: [
+ new winston.transports.File({ filename: path.join(LOGS_DIR, 'error.log'), level: 'error' }),
+ new winston.transports.File({ filename: path.join(LOGS_DIR, 'combined.log') }),
+ new winston.transports.Console({
+ format: winston.format.combine(
+ winston.format.colorize(),
+ winston.format.simple()
+ )
+ })
+ ]
+})
+
+// Ensure directories exist
+async function ensureDirectories() {
+ try {
+ await fs.mkdir(LOGS_DIR, { recursive: true })
+ await fs.mkdir(path.join(__dirname, '../alerts'), { recursive: true })
+ logger.info('Directories initialized')
+ } catch (error) {
+ logger.error('Failed to create directories:', error)
+ }
+}
+
+// Initialize Express
+const app = express()
+const server = http.createServer(app)
+const io = new Server(server, {
+ cors: {
+ origin: ['http://localhost:3000', 'http://45.61.58.125:5013'],
+ credentials: true
+ }
+})
+
+// Google OAuth client
+const client = new OAuth2Client(GOOGLE_CLIENT_ID)
+
+// Middleware
+app.use(helmet({
+ contentSecurityPolicy: false, // Disable CSP to allow Google OAuth
+ crossOriginEmbedderPolicy: false
+}))
+app.use(compression())
+app.use(cors({
+ origin: ['http://localhost:3000', 'http://45.61.58.125:5013', 'https://accounts.google.com'],
+ credentials: true
+}))
+app.use(express.json())
+app.use(express.urlencoded({ extended: true }))
+
+// Alert storage (in-memory for now, will migrate to DB)
+const alerts = {
+ antisemitic: [],
+ racist: [],
+ other: []
+}
+
+// Active sessions tracking
+const activeSessions = new Map()
+
+// Clean up old sessions every 5 minutes
+setInterval(() => {
+ const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000)
+
+ for (const [userId, session] of activeSessions) {
+ if (new Date(session.lastActive) < fiveMinutesAgo) {
+ activeSessions.delete(userId)
+ }
+ }
+
+ // Emit updated count to all admin clients
+ io.emit('stats-update', {
+ activeUsers: activeSessions.size
+ })
+}, 5 * 60 * 1000)
+
+// Auth middleware - RESTRICTED TO steve@designerwallcoverings.com ONLY
+async function authenticateAdmin(req, res, next) {
+ try {
+ const token = req.headers.authorization?.split(' ')[1]
+
+ if (!token) {
+ return res.status(401).json({ error: 'No token provided' })
+ }
+
+ const decoded = jwt.verify(token, JWT_SECRET)
+
+ // CRITICAL: Only allow steve@designerwallcoverings.com
+ if (decoded.email !== ADMIN_EMAIL) {
+ logger.warn(`Unauthorized access attempt from: ${decoded.email}`)
+ return res.status(403).json({ error: 'Access denied. Admin only.' })
+ }
+
+ req.admin = decoded
+ next()
+ } catch (error) {
+ logger.error('Auth error:', error)
+ res.status(401).json({ error: 'Invalid token' })
+ }
+}
+
+// Routes
+
+// Password login - RESTRICTED
+app.post('/api/auth/password', async (req, res) => {
+ try {
+ const { email, password } = req.body
+
+ // CRITICAL: Only allow steve@designerwallcoverings.com
+ if (email !== ADMIN_EMAIL) {
+ logger.warn(`Login attempt blocked for: ${email}`)
+ return res.status(403).json({
+ error: 'Access denied. This admin panel is restricted.',
+ message: 'Only authorized administrators can access this system.'
+ })
+ }
+
+ // Check password (you should use bcrypt in production)
+ // For now, using a simple password check
+ const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'BubbeAdmin2024!'
+
+ if (password !== ADMIN_PASSWORD) {
+ logger.warn(`Invalid password attempt for: ${email}`)
+ return res.status(401).json({
+ error: 'Invalid password',
+ message: 'Invalid password. Please try again.'
+ })
+ }
+
+ // Create JWT token
+ const token = jwt.sign(
+ {
+ email: email,
+ name: 'Steve',
+ picture: 'https://ui-avatars.com/api/?name=Steve&background=667eea&color=fff',
+ isAdmin: true
+ },
+ JWT_SECRET,
+ { expiresIn: '24h' }
+ )
+
+ logger.info(`Admin logged in via password: ${email}`)
+
+ res.json({
+ token,
+ user: {
+ email: email,
+ name: 'Steve',
+ picture: 'https://ui-avatars.com/api/?name=Steve&background=667eea&color=fff',
+ isAdmin: true
+ }
+ })
+ } catch (error) {
+ logger.error('Password auth error:', error)
+ res.status(400).json({ error: 'Authentication failed' })
+ }
+})
+
+// Google OAuth login - RESTRICTED
+app.post('/api/auth/google', async (req, res) => {
+ try {
+ const { credential } = req.body
+
+ // Verify Google token
+ const ticket = await client.verifyIdToken({
+ idToken: credential,
+ audience: GOOGLE_CLIENT_ID
+ })
+
+ const payload = ticket.getPayload()
+ const email = payload.email
+
+ // CRITICAL: Only allow steve@designerwallcoverings.com
+ if (email !== ADMIN_EMAIL) {
+ logger.warn(`Login attempt blocked for: ${email}`)
+ return res.status(403).json({
+ error: 'Access denied. This admin panel is restricted.',
+ message: 'Only authorized administrators can access this system.'
+ })
+ }
+
+ // Create JWT token
+ const token = jwt.sign(
+ {
+ email: email,
+ name: payload.name,
+ picture: payload.picture,
+ isAdmin: true
+ },
+ JWT_SECRET,
+ { expiresIn: '24h' }
+ )
+
+ logger.info(`Admin logged in: ${email}`)
+
+ res.json({
+ token,
+ user: {
+ email: email,
+ name: payload.name,
+ picture: payload.picture,
+ isAdmin: true
+ }
+ })
+ } catch (error) {
+ logger.error('Google auth error:', error)
+ res.status(400).json({ error: 'Authentication failed' })
+ }
+})
+
+// Get active sessions with details
+app.get('/api/active-sessions', authenticateAdmin, async (req, res) => {
+ const sessions = []
+
+ // Clean up old sessions first
+ const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000)
+ for (const [userId, session] of activeSessions) {
+ if (new Date(session.lastActive) < fiveMinutesAgo) {
+ activeSessions.delete(userId)
+ } else {
+ sessions.push({
+ userId,
+ ...session,
+ isActive: true
+ })
+ }
+ }
+
+ // Also include recent live chats
+ for (const [userId, chat] of liveChats) {
+ if (new Date(chat.lastActivity) > fiveMinutesAgo && !activeSessions.has(userId)) {
+ sessions.push({
+ userId,
+ userName: chat.userName,
+ ip: chat.ip,
+ location: chat.location,
+ mode: chat.mode,
+ lastActive: chat.lastActivity,
+ isActive: false
+ })
+ }
+ }
+
+ res.json({
+ count: sessions.length,
+ sessions: sessions
+ })
+})
+
+// Dashboard stats - ADMIN ONLY
+app.get('/api/dashboard/stats', authenticateAdmin, async (req, res) => {
+ try {
+ // Read all user profiles
+ const profiles = []
+ const profileFiles = await fs.readdir(PROFILES_DIR).catch(() => [])
+
+ for (const file of profileFiles) {
+ if (file.endsWith('.json')) {
+ const data = await fs.readFile(path.join(PROFILES_DIR, file), 'utf-8')
+ profiles.push(JSON.parse(data))
+ }
+ }
+
+ // Calculate stats
+ const stats = {
+ totalUsers: profiles.length,
+ activeToday: activeSessions.size,
+ totalAlerts: alerts.antisemitic.length + alerts.racist.length + alerts.other.length,
+ criticalAlerts: alerts.antisemitic.filter(a => a.severity === 'HIGH').length +
+ alerts.racist.filter(a => a.severity === 'HIGH').length,
+ demographics: {
+ married: profiles.filter(p => p.relationshipStatus === 'married').length,
+ single: profiles.filter(p => p.relationshipStatus === 'single').length,
+ hasKids: profiles.filter(p => p.hasKids === 'yes').length,
+ religious: profiles.filter(p => ['very', 'somewhat'].includes(p.religiousLevel)).length
+ },
+ recentAlerts: [...alerts.antisemitic, ...alerts.racist].slice(-10)
+ }
+
+ res.json(stats)
+ } catch (error) {
+ logger.error('Dashboard stats error:', error)
+ res.status(500).json({ error: 'Failed to fetch stats' })
+ }
+})
+
+// Get all users - ADMIN ONLY
+app.get('/api/users', authenticateAdmin, async (req, res) => {
+ try {
+ const users = []
+ const profileFiles = await fs.readdir(PROFILES_DIR).catch(() => [])
+
+ for (const file of profileFiles) {
+ if (file.endsWith('.json')) {
+ const data = await fs.readFile(path.join(PROFILES_DIR, file), 'utf-8')
+ const profile = JSON.parse(data)
+ users.push({
+ ...profile,
+ lastActive: activeSessions.get(profile.email) || null
+ })
+ }
+ }
+
+ res.json(users)
+ } catch (error) {
+ logger.error('Get users error:', error)
+ res.status(500).json({ error: 'Failed to fetch users' })
+ }
+})
+
+// Get alerts - ADMIN ONLY
+app.get('/api/alerts/:type?', authenticateAdmin, (req, res) => {
+ const { type } = req.params
+
+ if (type && alerts[type]) {
+ res.json(alerts[type])
+ } else {
+ res.json({
+ antisemitic: alerts.antisemitic,
+ racist: alerts.racist,
+ other: alerts.other,
+ total: alerts.antisemitic.length + alerts.racist.length + alerts.other.length
+ })
+ }
+})
+
+// Report new alert (from main app)
+app.post('/api/alerts', async (req, res) => {
+ try {
+ const { type, userId, message, severity, ip, location, timestamp } = req.body
+
+ const alert = {
+ id: Date.now().toString(),
+ type,
+ userId,
+ message,
+ severity: severity || 'MEDIUM',
+ ip,
+ location,
+ timestamp: timestamp || new Date().toISOString(),
+ status: 'new'
+ }
+
+ // Store alert
+ if (alerts[type]) {
+ alerts[type].push(alert)
+ } else {
+ alerts.other.push(alert)
+ }
+
+ // Emit to connected admin clients
+ io.emit('new-alert', alert)
+
+ // Log critical alerts
+ if (severity === 'HIGH') {
+ logger.error(`CRITICAL ALERT - ${type}:`, alert)
+ }
+
+ res.json({ success: true, alertId: alert.id })
+ } catch (error) {
+ logger.error('Alert creation error:', error)
+ res.status(500).json({ error: 'Failed to create alert' })
+ }
+})
+
+// Block/unblock user - ADMIN ONLY
+app.post('/api/users/:email/block', authenticateAdmin, async (req, res) => {
+ try {
+ const { email } = req.params
+ const { blocked, reason } = req.body
+
+ // Update user profile
+ const profilePath = path.join(PROFILES_DIR, `${email}.json`)
+ const data = await fs.readFile(profilePath, 'utf-8')
+ const profile = JSON.parse(data)
+
+ profile.blocked = blocked
+ profile.blockReason = reason
+ profile.blockedAt = blocked ? new Date().toISOString() : null
+ profile.blockedBy = req.admin.email
+
+ await fs.writeFile(profilePath, JSON.stringify(profile, null, 2))
+
+ logger.info(`User ${email} ${blocked ? 'blocked' : 'unblocked'} by ${req.admin.email}`)
+
+ res.json({ success: true, profile })
+ } catch (error) {
+ logger.error('Block user error:', error)
+ res.status(500).json({ error: 'Failed to update user status' })
+ }
+})
+
+// Deactivate/activate user account - ADMIN ONLY
+app.post('/api/users/:email/status', authenticateAdmin, async (req, res) => {
+ try {
+ const { email } = req.params
+ const { active, reason } = req.body
+
+ // Update user profile
+ const profilePath = path.join(PROFILES_DIR, `${email}.json`)
+
+ if (!await fs.access(profilePath).then(() => true).catch(() => false)) {
+ return res.status(404).json({ error: 'User not found' })
+ }
+
+ const data = await fs.readFile(profilePath, 'utf-8')
+ const profile = JSON.parse(data)
+
+ profile.active = active
+ profile.deactivatedReason = !active ? reason : null
+ profile.deactivatedAt = !active ? new Date().toISOString() : null
+ profile.reactivatedAt = active && profile.deactivatedAt ? new Date().toISOString() : null
+ profile.modifiedBy = req.admin.email
+ profile.lastModified = new Date().toISOString()
+
+ await fs.writeFile(profilePath, JSON.stringify(profile, null, 2))
+
+ logger.info(`User ${email} ${active ? 'activated' : 'deactivated'} by ${req.admin.email}`)
+
+ // Emit status change to connected clients
+ io.emit('user-status-change', {
+ email,
+ active,
+ reason,
+ modifiedBy: req.admin.email,
+ timestamp: new Date().toISOString()
+ })
+
+ res.json({ success: true, profile })
+ } catch (error) {
+ logger.error('Update user status error:', error)
+ res.status(500).json({ error: 'Failed to update user status' })
+ }
+})
+
+// Delete user account - ADMIN ONLY
+app.delete('/api/users/:email', authenticateAdmin, async (req, res) => {
+ try {
+ const { email } = req.params
+ const { reason } = req.body
+
+ // Create deletion log before deleting
+ const deletionLog = {
+ email,
+ deletedBy: req.admin.email,
+ deletedAt: new Date().toISOString(),
+ reason
+ }
+
+ // Save deletion log
+ const deletionLogsPath = path.join(__dirname, '../logs/deletions.json')
+ let deletions = []
+ try {
+ const existing = await fs.readFile(deletionLogsPath, 'utf-8')
+ deletions = JSON.parse(existing)
+ } catch (e) {
+ // File doesn't exist yet
+ }
+ deletions.push(deletionLog)
+ await fs.writeFile(deletionLogsPath, JSON.stringify(deletions, null, 2))
+
+ // Delete user profile
+ const profilePath = path.join(PROFILES_DIR, `${email}.json`)
+ if (await fs.access(profilePath).then(() => true).catch(() => false)) {
+ await fs.unlink(profilePath)
+ }
+
+ // Delete user emails
+ const emailsPath = path.join(PROFILES_DIR, '../emails', `${email}.json`)
+ if (await fs.access(emailsPath).then(() => true).catch(() => false)) {
+ await fs.unlink(emailsPath)
+ }
+
+ // Delete user discount codes
+ const discountPath = path.join(PROFILES_DIR, '../discount-codes', `${email}.json`)
+ if (await fs.access(discountPath).then(() => true).catch(() => false)) {
+ await fs.unlink(discountPath)
+ }
+
+ logger.info(`User ${email} deleted by ${req.admin.email}. Reason: ${reason}`)
+
+ // Emit deletion to connected clients
+ io.emit('user-deleted', {
+ email,
+ deletedBy: req.admin.email,
+ reason,
+ timestamp: new Date().toISOString()
+ })
+
+ res.json({ success: true, message: `User ${email} has been permanently deleted`, deletionLog })
+ } catch (error) {
+ logger.error('Delete user error:', error)
+ res.status(500).json({ error: 'Failed to delete user' })
+ }
+})
+
+// WebSocket for real-time updates
+io.on('connection', (socket) => {
+ logger.info('Admin client connected')
+
+ socket.on('disconnect', () => {
+ logger.info('Admin client disconnected')
+ })
+
+ // Send initial stats
+ socket.emit('stats-update', {
+ activeUsers: activeSessions.size,
+ alertCount: alerts.antisemitic.length + alerts.racist.length + alerts.other.length
+ })
+})
+
+// Track active sessions (receives updates from main app)
+app.post('/api/sessions/update', (req, res) => {
+ const { email, action } = req.body
+
+ if (action === 'start') {
+ activeSessions.set(email, new Date().toISOString())
+ } else if (action === 'end') {
+ activeSessions.delete(email)
+ }
+
+ // Emit update to admin clients
+ io.emit('session-update', {
+ activeUsers: activeSessions.size,
+ email,
+ action
+ })
+
+ res.json({ success: true })
+})
+
+// Live chat monitoring - receives real-time chat data
+const liveChats = new Map() // Store active conversations
+
+app.post('/api/live-chat', (req, res) => {
+ const { type, userId, ip, location, message, response, mode, timestamp, filtered, userName, userEmail } = req.body
+
+ // Track as active session
+ if (userId && !activeSessions.has(userId)) {
+ activeSessions.set(userId, {
+ userName: userName || 'Anonymous',
+ ip: ip || 'Unknown',
+ location: location || 'Unknown',
+ mode: mode || 'bubbe',
+ lastActive: new Date().toISOString()
+ })
+ }
+
+ // Get or create conversation for this user
+ if (!liveChats.has(userId)) {
+ liveChats.set(userId, {
+ userId,
+ userName: userName || 'Anonymous',
+ userEmail: userEmail || '',
+ ip,
+ location: location || 'Unknown',
+ mode,
+ messages: [],
+ startTime: timestamp,
+ lastActivity: timestamp
+ })
+ }
+
+ const chat = liveChats.get(userId)
+ chat.lastActivity = timestamp
+ // Update user info if provided
+ if (userName) chat.userName = userName
+ if (location) chat.location = location
+ if (userEmail) chat.userEmail = userEmail
+
+ if (type === 'user_message') {
+ chat.messages.push({
+ type: 'user',
+ text: message,
+ timestamp
+ })
+
+ // Emit to all connected admin clients with user info
+ io.emit('live-chat-update', {
+ userId,
+ userName: chat.userName,
+ userEmail: chat.userEmail,
+ ip,
+ type: 'user_typing',
+ message,
+ mode,
+ timestamp
+ })
+ } else if (type === 'bubbe_response') {
+ chat.messages.push({
+ type: 'bubbe',
+ text: response,
+ timestamp,
+ filtered
+ })
+
+ // Emit to all connected admin clients with user info
+ io.emit('live-chat-update', {
+ userId,
+ userName: chat.userName,
+ userEmail: chat.userEmail,
+ ip,
+ type: 'bubbe_response',
+ response,
+ mode,
+ timestamp,
+ filtered
+ })
+ }
+
+ // Clean up old conversations (inactive for more than 30 minutes)
+ const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString()
+ for (const [id, convo] of liveChats.entries()) {
+ if (convo.lastActivity < thirtyMinutesAgo) {
+ liveChats.delete(id)
+ }
+ }
+
+ res.json({ success: true })
+})
+
+// Get all active live chats
+app.get('/api/live-chats', authenticateAdmin, (req, res) => {
+ const activeChats = Array.from(liveChats.values()).map(chat => ({
+ ...chat,
+ messageCount: chat.messages.length,
+ duration: Math.floor((new Date().getTime() - new Date(chat.startTime).getTime()) / 1000)
+ }))
+
+ res.json(activeChats)
+})
+
+// Get banned terms for civil rights security
+app.get('/api/banned-terms', authenticateAdmin, async (req, res) => {
+ try {
+ const bannedTermsPath = path.join(__dirname, '../../dear-bubbe-nextjs/lib/moderation/banned-terms.json')
+ const bannedTermsData = await fs.readFile(bannedTermsPath, 'utf-8')
+ const bannedTerms = JSON.parse(bannedTermsData)
+
+ // Flatten terms for easier display
+ const flattenedTerms = []
+
+ for (const [categoryName, category] of Object.entries(bannedTerms.categories)) {
+ const categoryData = category
+ for (const item of categoryData.terms) {
+ const term = item.term || item.phrase
+ const variants = item.variants || []
+ flattenedTerms.push({
+ category: categoryName,
+ severity: categoryData.severity,
+ term: term,
+ variants: variants,
+ reason: item.reason,
+ replacement: item.replacement
+ })
+ }
+ }
+
+ res.json({
+ version: bannedTerms.version,
+ lastUpdated: bannedTerms.lastUpdated,
+ description: bannedTerms.description,
+ sources: bannedTerms.sources,
+ terms: flattenedTerms,
+ totalTerms: flattenedTerms.length,
+ acceptableYiddish: bannedTerms.acceptable_yiddish?.terms || []
+ })
+ } catch (error) {
+ logger.error('Error fetching banned terms:', error)
+ res.status(500).json({ error: 'Failed to fetch banned terms' })
+ }
+})
+
+// Get acceptable terms
+app.get('/api/acceptable-terms', authenticateAdmin, async (req, res) => {
+ try {
+ const acceptableTermsPath = path.join(__dirname, '../../dear-bubbe-nextjs/lib/moderation/acceptable-terms.json')
+ const acceptableTerms = JSON.parse(await fs.readFile(acceptableTermsPath, 'utf8'))
+
+ res.json({
+ version: acceptableTerms.version,
+ lastUpdated: acceptableTerms.lastUpdated,
+ description: acceptableTerms.description,
+ terms: acceptableTerms.terms,
+ totalTerms: acceptableTerms.terms.length
+ })
+ } catch (error) {
+ logger.error('Error fetching acceptable terms:', error)
+ res.status(500).json({ error: 'Failed to fetch acceptable terms' })
+ }
+})
+
+// Add new banned term
+app.post('/api/banned-terms', authenticateAdmin, async (req, res) => {
+ try {
+ const { term, category, severity, reason, replacement, variants } = req.body
+
+ if (!term || !category || !severity) {
+ return res.status(400).json({ error: 'Term, category, and severity are required' })
+ }
+
+ const bannedTermsPath = path.join(__dirname, '../../dear-bubbe-nextjs/lib/moderation/banned-terms.json')
+ const bannedTerms = JSON.parse(await fs.readFile(bannedTermsPath, 'utf8'))
+
+ // Ensure category exists
+ if (!bannedTerms.categories[category]) {
+ bannedTerms.categories[category] = {
+ severity: severity,
+ terms: []
+ }
+ }
+
+ // Add the new term
+ bannedTerms.categories[category].terms.push({
+ term: term,
+ variants: variants ? variants.split(',').map(v => v.trim()) : [],
+ reason: reason || '',
+ replacement: replacement || ''
+ })
+
+ // Update metadata
+ bannedTerms.lastUpdated = new Date().toISOString().split('T')[0]
+
+ // Save back to file
+ await fs.writeFile(bannedTermsPath, JSON.stringify(bannedTerms, null, 2))
+
+ logger.info('Added banned term:', { term, category, severity })
+ res.json({ success: true, message: 'Term added successfully' })
+ } catch (error) {
+ logger.error('Error adding banned term:', error)
+ res.status(500).json({ error: 'Failed to add banned term' })
+ }
+})
+
+// Add new acceptable term
+app.post('/api/acceptable-terms', authenticateAdmin, async (req, res) => {
+ try {
+ const { term, meaning, usage, variants, notes } = req.body
+
+ if (!term || !meaning) {
+ return res.status(400).json({ error: 'Term and meaning are required' })
+ }
+
+ const acceptableTermsPath = path.join(__dirname, '../../dear-bubbe-nextjs/lib/moderation/acceptable-terms.json')
+ const acceptableTerms = JSON.parse(await fs.readFile(acceptableTermsPath, 'utf8'))
+
+ // Add the new term
+ acceptableTerms.terms.push({
+ term: term,
+ variants: variants ? variants.split(',').map(v => v.trim()) : [],
+ meaning: meaning,
+ usage: usage || '',
+ notes: notes || ''
+ })
+
+ // Update metadata
+ acceptableTerms.lastUpdated = new Date().toISOString().split('T')[0]
+
+ // Save back to file
+ await fs.writeFile(acceptableTermsPath, JSON.stringify(acceptableTerms, null, 2))
+
+ logger.info('Added acceptable term:', { term, meaning })
+ res.json({ success: true, message: 'Term added successfully' })
+ } catch (error) {
+ logger.error('Error adding acceptable term:', error)
+ res.status(500).json({ error: 'Failed to add acceptable term' })
+ }
+})
+
+// Delete banned term
+app.delete('/api/banned-terms/:term', authenticateAdmin, async (req, res) => {
+ try {
+ const termToDelete = decodeURIComponent(req.params.term)
+
+ const bannedTermsPath = path.join(__dirname, '../../dear-bubbe-nextjs/lib/moderation/banned-terms.json')
+ const bannedTerms = JSON.parse(await fs.readFile(bannedTermsPath, 'utf8'))
+
+ let found = false
+ for (const [categoryName, category] of Object.entries(bannedTerms.categories)) {
+ const initialLength = category.terms.length
+ category.terms = category.terms.filter(item => item.term !== termToDelete)
+ if (category.terms.length < initialLength) {
+ found = true
+ break
+ }
+ }
+
+ if (!found) {
+ return res.status(404).json({ error: 'Term not found' })
+ }
+
+ // Update metadata
+ bannedTerms.lastUpdated = new Date().toISOString().split('T')[0]
+
+ // Save back to file
+ await fs.writeFile(bannedTermsPath, JSON.stringify(bannedTerms, null, 2))
+
+ logger.info('Deleted banned term:', termToDelete)
+ res.json({ success: true, message: 'Term deleted successfully' })
+ } catch (error) {
+ logger.error('Error deleting banned term:', error)
+ res.status(500).json({ error: 'Failed to delete banned term' })
+ }
+})
+
+// Delete acceptable term
+app.delete('/api/acceptable-terms/:term', authenticateAdmin, async (req, res) => {
+ try {
+ const termToDelete = decodeURIComponent(req.params.term)
+
+ const acceptableTermsPath = path.join(__dirname, '../../dear-bubbe-nextjs/lib/moderation/acceptable-terms.json')
+ const acceptableTerms = JSON.parse(await fs.readFile(acceptableTermsPath, 'utf8'))
+
+ const initialLength = acceptableTerms.terms.length
+ acceptableTerms.terms = acceptableTerms.terms.filter(item => item.term !== termToDelete)
+
+ if (acceptableTerms.terms.length === initialLength) {
+ return res.status(404).json({ error: 'Term not found' })
+ }
+
+ // Update metadata
+ acceptableTerms.lastUpdated = new Date().toISOString().split('T')[0]
+
+ // Save back to file
+ await fs.writeFile(acceptableTermsPath, JSON.stringify(acceptableTerms, null, 2))
+
+ logger.info('Deleted acceptable term:', termToDelete)
+ res.json({ success: true, message: 'Term deleted successfully' })
+ } catch (error) {
+ logger.error('Error deleting acceptable term:', error)
+ res.status(500).json({ error: 'Failed to delete acceptable term' })
+ }
+})
+
+// Import social post handler
+const { handleImmediateSocialPost } = require('./social-post-handler')
+
+// IMMEDIATE SOCIAL POSTING ENDPOINT
+app.post('/api/social-post-now', async (req, res) => {
+ try {
+ const postData = req.body
+
+ logger.info('[SOCIAL-POST-NOW] Received immediate post request:', {
+ messageId: postData.messageId,
+ timestamp: postData.timestamp,
+ userSnippet: postData.userMessage?.substring(0, 30),
+ bubbeSnippet: postData.bubbeResponse?.substring(0, 30)
+ })
+
+ // Handle the immediate posting
+ const results = await handleImmediateSocialPost(postData)
+
+ // Emit to connected admin clients
+ io.emit('social-post-triggered', {
+ ...postData,
+ results,
+ triggeredAt: new Date().toISOString()
+ })
+
+ res.json({
+ success: true,
+ message: 'Social post triggered immediately',
+ results
+ })
+ } catch (error) {
+ logger.error('[SOCIAL-POST-NOW] Error:', error)
+ res.status(500).json({ error: 'Failed to trigger social post' })
+ }
+})
+
+// Serve frontend HTML
+app.get('/', (req, res) => {
+ res.sendFile(path.join(__dirname, '../frontend/index.html'))
+})
+
+// Serve Twitter monitor
+app.get('/twitter-monitor', (req, res) => {
+ res.sendFile(path.join(__dirname, '../frontend/twitter-monitor.html'))
+})
+
+// Get Twitter posts data
+app.get('/api/twitter-posts', async (req, res) => {
+ try {
+ const immediatePostsPath = path.join(__dirname, '../logs/immediate-posts.json')
+ const statsPath = path.join(__dirname, '../logs/twitter-stats.json')
+
+ let posts = []
+ let stats = {}
+
+ try {
+ posts = JSON.parse(await fs.readFile(immediatePostsPath, 'utf8'))
+ } catch (e) {
+ // File doesn't exist
+ }
+
+ try {
+ stats = JSON.parse(await fs.readFile(statsPath, 'utf8'))
+ } catch (e) {
+ // File doesn't exist
+ }
+
+ res.json({ posts, stats })
+ } catch (error) {
+ logger.error('Error getting Twitter posts:', error)
+ res.status(500).json({ error: 'Failed to get Twitter posts' })
+ }
+})
+
+// Health check
+app.get('/health', (req, res) => {
+ res.json({
+ status: 'ok',
+ uptime: process.uptime(),
+ adminOnly: true,
+ authorizedEmail: ADMIN_EMAIL
+ })
+})
+
+// Start server
+async function start() {
+ await ensureDirectories()
+
+ server.listen(PORT, () => {
+ logger.info(`
+╔════════════════════════════════════════════╗
+║ DEAR BUBBE ADMIN DASHBOARD ║
+║ ║
+║ Server: http://45.61.58.125:${PORT} ║
+║ Admin: ${ADMIN_EMAIL} ║
+║ Status: RESTRICTED ACCESS ║
+║ ║
+║ Features: ║
+║ - Real-time antisemitic alerts ║
+║ - Racist content monitoring ║
+║ - User management & blocking ║
+║ - Conversation monitoring ║
+║ - Analytics & reporting ║
+╚════════════════════════════════════════════╝
+ `)
+ })
+}
+
+start().catch(logger.error)
\ No newline at end of file
diff --git a/backend/services/AlertService.js b/backend/services/AlertService.js
new file mode 100644
index 0000000..4fc2eb3
--- /dev/null
+++ b/backend/services/AlertService.js
@@ -0,0 +1,455 @@
+const fs = require('fs')
+const path = require('path')
+const axios = require('axios')
+const moment = require('moment')
+const Alert = require('../models/Alert')
+const User = require('../models/User')
+
+class AlertService {
+ constructor(io, logger) {
+ this.io = io
+ this.logger = logger
+ this.antisemitismRules = this.loadAntisemitismRules()
+ this.racismPatterns = this.loadRacismPatterns()
+ this.alertQueue = []
+ this.processingQueue = false
+
+ // Load IP geolocation cache
+ this.ipLocationCache = new Map()
+
+ this.logger.info('AlertService initialized with real-time monitoring')
+ }
+
+ loadAntisemitismRules() {
+ try {
+ const rulesPath = path.join(process.cwd(), '../B_Version_1/moderation/antisemitism_rules.json')
+ if (fs.existsSync(rulesPath)) {
+ const rulesData = JSON.parse(fs.readFileSync(rulesPath, 'utf-8'))
+ this.logger.info('Loaded antisemitism detection rules')
+ return rulesData.rules
+ }
+ } catch (error) {
+ this.logger.error('Failed to load antisemitism rules:', error)
+ }
+
+ // Fallback rules if file not found
+ return {
+ template_patterns: [
+ {
+ id: 'jews_control_x',
+ regex: '\\bjews?\\s+(run|control|own|dominate)\\s+\\w+',
+ flags: 'i',
+ severity: 'high',
+ action: 'block'
+ },
+ {
+ id: 'jewish_conspiracy',
+ regex: '\\b(jewish|zionist)\\s+(conspiracy|agenda|plot|cabal|takeover)\\b',
+ flags: 'i',
+ severity: 'high',
+ action: 'block'
+ },
+ {
+ id: 'holocaust_denial',
+ regex: '(holocaust)\\s+(is|was)\\s+(a\\s+)?(myth|hoax|lie|fake)',
+ flags: 'i',
+ severity: 'high',
+ action: 'block'
+ }
+ ],
+ coded_phrases: [
+ {
+ id: 'triple_parentheses',
+ pattern: '\\(\\(\\(.+?\\)\\)\\)',
+ flags: 'i',
+ severity: 'high',
+ action: 'block'
+ },
+ {
+ id: 'zog_acronym',
+ pattern: '\\bZOG\\b',
+ flags: 'i',
+ severity: 'high',
+ action: 'block'
+ }
+ ]
+ }
+ }
+
+ loadRacismPatterns() {
+ // Comprehensive racism detection patterns
+ return [
+ {
+ id: 'racial_slurs_nword',
+ pattern: '\\bn[i1]gg[ae]r?s?\\b',
+ flags: 'i',
+ severity: 'critical',
+ category: 'racial_slur'
+ },
+ {
+ id: 'racial_slurs_general',
+ pattern: '\\b(ch[i1]nk|sp[i1]c|w[e3]tb[a4]ck|r[a4]gh[e3][a4]d|t[o0]w[e3]lh[e3][a4]d)s?\\b',
+ flags: 'i',
+ severity: 'high',
+ category: 'racial_slur'
+ },
+ {
+ id: 'white_supremacy_phrases',
+ pattern: '\\b(white\\s+power|white\\s+pride|white\\s+supremacy|blood\\s+and\\s+soil|fourteen\\s+words)\\b',
+ flags: 'i',
+ severity: 'high',
+ category: 'hate_speech'
+ },
+ {
+ id: 'racial_superiority',
+ pattern: '\\b(master\\s+race|inferior\\s+race|racial\\s+purity|ethnic\\s+cleansing)\\b',
+ flags: 'i',
+ severity: 'high',
+ category: 'hate_speech'
+ },
+ {
+ id: 'racist_stereotypes',
+ pattern: '\\b(all\\s+(blacks?|whites?|asians?|mexicans?|muslims?)\\s+(are|do))\\b',
+ flags: 'i',
+ severity: 'medium',
+ category: 'stereotype'
+ },
+ {
+ id: 'hate_group_references',
+ pattern: '\\b(kkk|ku\\s+klux\\s+klan|aryan\\s+nations?|proud\\s+boys|atomwaffen)\\b',
+ flags: 'i',
+ severity: 'high',
+ category: 'hate_group'
+ }
+ ]
+ }
+
+ async detectAntisemitism(message, context = {}) {
+ const violations = []
+
+ // Check template patterns
+ if (this.antisemitismRules.template_patterns) {
+ for (const rule of this.antisemitismRules.template_patterns) {
+ const regex = new RegExp(rule.regex, rule.flags || 'i')
+ if (regex.test(message)) {
+ violations.push({
+ type: 'antisemitism',
+ ruleId: rule.id,
+ severity: rule.severity,
+ action: rule.action,
+ pattern: rule.regex,
+ match: message.match(regex)?.[0]
+ })
+ }
+ }
+ }
+
+ // Check coded phrases
+ if (this.antisemitismRules.coded_phrases) {
+ for (const rule of this.antisemitismRules.coded_phrases) {
+ const regex = new RegExp(rule.pattern, rule.flags || 'i')
+ if (regex.test(message)) {
+ violations.push({
+ type: 'antisemitism',
+ ruleId: rule.id,
+ severity: rule.severity,
+ action: rule.action,
+ pattern: rule.pattern,
+ match: message.match(regex)?.[0]
+ })
+ }
+ }
+ }
+
+ return violations
+ }
+
+ async detectRacism(message, context = {}) {
+ const violations = []
+
+ for (const pattern of this.racismPatterns) {
+ const regex = new RegExp(pattern.pattern, pattern.flags || 'i')
+ if (regex.test(message)) {
+ violations.push({
+ type: 'racism',
+ ruleId: pattern.id,
+ severity: pattern.severity,
+ category: pattern.category,
+ pattern: pattern.pattern,
+ match: message.match(regex)?.[0]
+ })
+ }
+ }
+
+ return violations
+ }
+
+ async getLocationFromIP(ip) {
+ if (this.ipLocationCache.has(ip)) {
+ return this.ipLocationCache.get(ip)
+ }
+
+ try {
+ // Use free IP geolocation service
+ const response = await axios.get(`http://ip-api.com/json/${ip}?fields=status,message,country,regionName,city,lat,lon,isp`, {
+ timeout: 5000
+ })
+
+ if (response.data.status === 'success') {
+ const location = {
+ country: response.data.country,
+ state: response.data.regionName,
+ city: response.data.city,
+ coordinates: {
+ lat: response.data.lat,
+ lon: response.data.lon
+ },
+ isp: response.data.isp
+ }
+
+ this.ipLocationCache.set(ip, location)
+ return location
+ }
+ } catch (error) {
+ this.logger.warn(`Failed to get location for IP ${ip}:`, error.message)
+ }
+
+ return null
+ }
+
+ async createAlert(alertData) {
+ try {
+ const alert = new Alert({
+ timestamp: new Date(),
+ ...alertData
+ })
+
+ await alert.save()
+
+ // Emit real-time alert to admin dashboard
+ this.io.to('admin').emit('new_alert', {
+ id: alert._id,
+ type: alert.type,
+ severity: alert.severity,
+ timestamp: alert.timestamp,
+ userInfo: alert.userInfo,
+ location: alert.location,
+ message: alert.content?.message,
+ violation: alert.violation
+ })
+
+ this.logger.error(`🚨 ${alert.severity.toUpperCase()} ALERT: ${alert.type} detected`, {
+ alertId: alert._id,
+ userId: alert.userInfo?.userId,
+ ip: alert.userInfo?.ip,
+ location: alert.location,
+ violation: alert.violation
+ })
+
+ return alert
+ } catch (error) {
+ this.logger.error('Failed to create alert:', error)
+ throw error
+ }
+ }
+
+ async processMessage(message, userContext) {
+ try {
+ const { userId, ip, sessionId, userAgent } = userContext
+
+ // Get user location
+ const location = await this.getLocationFromIP(ip)
+
+ // Check for antisemitic content
+ const antisemiticViolations = await this.detectAntisemitism(message, userContext)
+
+ // Check for racist content
+ const racistViolations = await this.detectRacism(message, userContext)
+
+ const allViolations = [...antisemiticViolations, ...racistViolations]
+
+ if (allViolations.length > 0) {
+ for (const violation of allViolations) {
+ const alertData = {
+ type: violation.type,
+ severity: violation.severity === 'critical' ? 'critical' : violation.severity,
+ userInfo: {
+ userId,
+ ip,
+ sessionId,
+ userAgent
+ },
+ location,
+ content: {
+ message,
+ detectedAt: new Date()
+ },
+ violation: {
+ ruleId: violation.ruleId,
+ pattern: violation.pattern,
+ match: violation.match,
+ category: violation.category || violation.type
+ },
+ action: violation.action || 'review',
+ status: 'active'
+ }
+
+ // Create alert in database
+ const alert = await this.createAlert(alertData)
+
+ // For high/critical severity, also send immediate threat response
+ if (violation.severity === 'high' || violation.severity === 'critical') {
+ this.sendThreatResponse(alert)
+ }
+ }
+
+ return {
+ blocked: allViolations.some(v => v.action === 'block'),
+ violations: allViolations,
+ alertsCreated: allViolations.length
+ }
+ }
+
+ return { blocked: false, violations: [], alertsCreated: 0 }
+ } catch (error) {
+ this.logger.error('Error processing message for violations:', error)
+ return { blocked: false, violations: [], alertsCreated: 0 }
+ }
+ }
+
+ sendThreatResponse(alert) {
+ const location = alert.location
+ const locationStr = location ?
+ `${location.city || 'Unknown City'}, ${location.state || 'Unknown State'}` :
+ 'Unknown Location'
+
+ const threatMessage = [
+ `🚨 I see EXACTLY who you are and where you are.`,
+ `Your IP address is ${alert.userInfo.ip}, you're in ${locationStr}.`,
+ `This ${alert.type} garbage? I'm reporting you to the authorities RIGHT NOW.`,
+ `You think you can hide behind a screen? I have your information and the police will hear about this.`,
+ `Zero tolerance, bubbeleh. ZERO. You just made a VERY big mistake.`
+ ].join(' ')
+
+ // Emit threat response to specific user if possible
+ this.io.emit('threat_response', {
+ userId: alert.userInfo.userId,
+ message: threatMessage,
+ alertId: alert._id,
+ timestamp: new Date()
+ })
+
+ this.logger.error('🚨 THREAT RESPONSE SENT:', {
+ alertId: alert._id,
+ userId: alert.userInfo.userId,
+ ip: alert.userInfo.ip,
+ location: locationStr,
+ type: alert.type
+ })
+ }
+
+ async getRecentAlerts(limit = 50) {
+ try {
+ const alerts = await Alert.find({})
+ .sort({ timestamp: -1 })
+ .limit(limit)
+ .lean()
+
+ return alerts
+ } catch (error) {
+ this.logger.error('Failed to get recent alerts:', error)
+ return []
+ }
+ }
+
+ async getAlertStats(timeframe = '24h') {
+ try {
+ const now = moment()
+ let startTime
+
+ switch (timeframe) {
+ case '1h':
+ startTime = now.clone().subtract(1, 'hour')
+ break
+ case '24h':
+ startTime = now.clone().subtract(24, 'hours')
+ break
+ case '7d':
+ startTime = now.clone().subtract(7, 'days')
+ break
+ case '30d':
+ startTime = now.clone().subtract(30, 'days')
+ break
+ default:
+ startTime = now.clone().subtract(24, 'hours')
+ }
+
+ const stats = await Alert.aggregate([
+ {
+ $match: {
+ timestamp: { $gte: startTime.toDate() }
+ }
+ },
+ {
+ $group: {
+ _id: {
+ type: '$type',
+ severity: '$severity'
+ },
+ count: { $sum: 1 },
+ latestAlert: { $max: '$timestamp' }
+ }
+ }
+ ])
+
+ const totalAlerts = await Alert.countDocuments({
+ timestamp: { $gte: startTime.toDate() }
+ })
+
+ return {
+ timeframe,
+ startTime: startTime.toISOString(),
+ endTime: now.toISOString(),
+ totalAlerts,
+ breakdown: stats,
+ summary: {
+ antisemitism: stats.filter(s => s._id.type === 'antisemitism').reduce((acc, s) => acc + s.count, 0),
+ racism: stats.filter(s => s._id.type === 'racism').reduce((acc, s) => acc + s.count, 0),
+ critical: stats.filter(s => s._id.severity === 'critical').reduce((acc, s) => acc + s.count, 0),
+ high: stats.filter(s => s._id.severity === 'high').reduce((acc, s) => acc + s.count, 0)
+ }
+ }
+ } catch (error) {
+ this.logger.error('Failed to get alert stats:', error)
+ return null
+ }
+ }
+
+ async blockUser(userId, reason, adminId) {
+ try {
+ const user = await User.findByIdOrCreate(userId)
+ user.blocked = true
+ user.blockReason = reason
+ user.blockedBy = adminId
+ user.blockedAt = new Date()
+ await user.save()
+
+ // Emit block notification
+ this.io.to('admin').emit('user_blocked', {
+ userId,
+ reason,
+ blockedBy: adminId,
+ timestamp: new Date()
+ })
+
+ this.logger.warn(`User blocked: ${userId} by admin ${adminId}. Reason: ${reason}`)
+
+ return true
+ } catch (error) {
+ this.logger.error('Failed to block user:', error)
+ return false
+ }
+ }
+}
+
+module.exports = AlertService
\ No newline at end of file
diff --git a/backend/services/MonitoringService.js b/backend/services/MonitoringService.js
new file mode 100644
index 0000000..f8d9c21
--- /dev/null
+++ b/backend/services/MonitoringService.js
@@ -0,0 +1,544 @@
+const fs = require('fs')
+const path = require('path')
+const axios = require('axios')
+const moment = require('moment')
+const os = require('os')
+const User = require('../models/User')
+const Session = require('../models/Session')
+const Conversation = require('../models/Conversation')
+
+class MonitoringService {
+ constructor(io, logger, alertService) {
+ this.io = io
+ this.logger = logger
+ this.alertService = alertService
+ this.currentStats = {
+ activeUsers: 0,
+ activeSessions: 0,
+ totalConversations: 0,
+ alertsToday: 0,
+ systemHealth: 'healthy',
+ lastUpdate: new Date()
+ }
+ this.systemMetrics = {}
+ this.isMonitoring = false
+
+ // Track real-time user activity
+ this.activeUsers = new Set()
+ this.activeSessions = new Map()
+ this.realtimeStats = {
+ messagesPerMinute: 0,
+ avgResponseTime: 0,
+ errorRate: 0
+ }
+
+ this.logger.info('MonitoringService initialized')
+ }
+
+ startMonitoring() {
+ if (this.isMonitoring) return
+
+ this.isMonitoring = true
+ this.logger.info('Starting real-time monitoring')
+
+ // Monitor every 30 seconds
+ this.monitoringInterval = setInterval(() => {
+ this.collectSystemMetrics()
+ this.updateUserStats()
+ this.broadcastStats()
+ }, 30000)
+
+ // Monitor Dear Bubbe API health every minute
+ this.healthCheckInterval = setInterval(() => {
+ this.checkBubbeHealth()
+ }, 60000)
+
+ // Generate detailed reports every 5 minutes
+ this.reportInterval = setInterval(() => {
+ this.generateDetailedReport()
+ }, 5 * 60000)
+
+ // Initial collection
+ this.collectSystemMetrics()
+ this.updateUserStats()
+ }
+
+ stopMonitoring() {
+ this.isMonitoring = false
+
+ if (this.monitoringInterval) clearInterval(this.monitoringInterval)
+ if (this.healthCheckInterval) clearInterval(this.healthCheckInterval)
+ if (this.reportInterval) clearInterval(this.reportInterval)
+
+ this.logger.info('Monitoring stopped')
+ }
+
+ async collectSystemMetrics() {
+ try {
+ // System metrics
+ const totalMem = os.totalmem()
+ const freeMem = os.freemem()
+ const usedMem = totalMem - freeMem
+ const memUsagePercent = (usedMem / totalMem) * 100
+
+ const loadAvg = os.loadavg()
+ const cpuCount = os.cpus().length
+ const cpuUsage = (loadAvg[0] / cpuCount) * 100
+
+ // Process metrics
+ const processMemory = process.memoryUsage()
+ const uptime = process.uptime()
+
+ this.systemMetrics = {
+ timestamp: new Date(),
+ system: {
+ platform: os.platform(),
+ arch: os.arch(),
+ hostname: os.hostname(),
+ uptime: os.uptime(),
+ totalMemory: totalMem,
+ freeMemory: freeMem,
+ memoryUsage: memUsagePercent,
+ cpuUsage: Math.min(cpuUsage, 100),
+ loadAverage: loadAvg
+ },
+ process: {
+ pid: process.pid,
+ uptime: uptime,
+ memory: processMemory,
+ memoryUsageMB: Math.round(processMemory.rss / 1024 / 1024),
+ version: process.version
+ }
+ }
+
+ // Check for high resource usage
+ if (memUsagePercent > 90) {
+ this.createSystemAlert('high_memory_usage', {
+ usage: memUsagePercent,
+ threshold: 90
+ })
+ }
+
+ if (cpuUsage > 80) {
+ this.createSystemAlert('high_cpu_usage', {
+ usage: cpuUsage,
+ threshold: 80
+ })
+ }
+
+ } catch (error) {
+ this.logger.error('Failed to collect system metrics:', error)
+ }
+ }
+
+ async updateUserStats() {
+ try {
+ const now = moment()
+ const today = now.startOf('day')
+
+ // Active users (last 5 minutes)
+ const activeUsers = await Session.countDocuments({
+ lastActivity: { $gte: moment().subtract(5, 'minutes').toDate() }
+ })
+
+ // Active sessions (last 30 minutes)
+ const activeSessions = await Session.countDocuments({
+ lastActivity: { $gte: moment().subtract(30, 'minutes').toDate() },
+ ended: { $ne: true }
+ })
+
+ // Total conversations today
+ const conversationsToday = await Conversation.countDocuments({
+ timestamp: { $gte: today.toDate() }
+ })
+
+ // Alerts today
+ const alertsToday = await this.alertService.Alert?.countDocuments?.({
+ timestamp: { $gte: today.toDate() }
+ }) || 0
+
+ // User registrations today
+ const registrationsToday = await User.countDocuments({
+ createdAt: { $gte: today.toDate() }
+ })
+
+ // Update current stats
+ this.currentStats = {
+ activeUsers,
+ activeSessions,
+ totalConversations: conversationsToday,
+ alertsToday,
+ registrationsToday,
+ systemHealth: this.calculateSystemHealth(),
+ lastUpdate: new Date()
+ }
+
+ } catch (error) {
+ this.logger.error('Failed to update user stats:', error)
+ }
+ }
+
+ calculateSystemHealth() {
+ const { system } = this.systemMetrics
+ if (!system) return 'unknown'
+
+ const memUsage = system.memoryUsage || 0
+ const cpuUsage = system.cpuUsage || 0
+
+ if (memUsage > 90 || cpuUsage > 90) return 'critical'
+ if (memUsage > 70 || cpuUsage > 70) return 'warning'
+ return 'healthy'
+ }
+
+ async checkBubbeHealth() {
+ try {
+ const startTime = Date.now()
+ const response = await axios.get('http://localhost:3011/api/health', {
+ timeout: 10000
+ })
+ const responseTime = Date.now() - startTime
+
+ if (response.status === 200) {
+ this.logger.debug('Dear Bubbe API health check passed')
+ this.realtimeStats.avgResponseTime = responseTime
+ } else {
+ this.createSystemAlert('api_health_failed', {
+ status: response.status,
+ responseTime
+ })
+ }
+ } catch (error) {
+ this.logger.error('Dear Bubbe API health check failed:', error.message)
+ this.createSystemAlert('api_unreachable', {
+ error: error.message,
+ timestamp: new Date()
+ })
+ }
+ }
+
+ async generateDetailedReport() {
+ try {
+ // Get hourly stats for the last 24 hours
+ const hourlyStats = await this.getHourlyStats()
+
+ // Get top active users
+ const topUsers = await this.getTopActiveUsers()
+
+ // Get conversation topics breakdown
+ const topicBreakdown = await this.getConversationTopics()
+
+ // Get geographic distribution
+ const geoStats = await this.getGeographicStats()
+
+ // Alert trends
+ const alertTrends = await this.alertService.getAlertStats('24h')
+
+ const report = {
+ timestamp: new Date(),
+ period: 'last_24_hours',
+ systemMetrics: this.systemMetrics,
+ userActivity: {
+ hourlyStats,
+ topUsers,
+ topicBreakdown,
+ geoStats
+ },
+ alertTrends,
+ realtimeStats: this.realtimeStats
+ }
+
+ // Broadcast to admin clients
+ this.io.to('admin').emit('detailed_report', report)
+
+ // Save report to file for historical analysis
+ this.saveReportToFile(report)
+
+ } catch (error) {
+ this.logger.error('Failed to generate detailed report:', error)
+ }
+ }
+
+ async getHourlyStats() {
+ try {
+ const last24Hours = moment().subtract(24, 'hours')
+
+ const hourlyConversations = await Conversation.aggregate([
+ {
+ $match: {
+ timestamp: { $gte: last24Hours.toDate() }
+ }
+ },
+ {
+ $group: {
+ _id: {
+ year: { $year: '$timestamp' },
+ month: { $month: '$timestamp' },
+ day: { $dayOfMonth: '$timestamp' },
+ hour: { $hour: '$timestamp' }
+ },
+ count: { $sum: 1 },
+ uniqueUsers: { $addToSet: '$userId' }
+ }
+ },
+ {
+ $addFields: {
+ uniqueUserCount: { $size: '$uniqueUsers' }
+ }
+ },
+ {
+ $sort: { '_id.year': 1, '_id.month': 1, '_id.day': 1, '_id.hour': 1 }
+ }
+ ])
+
+ return hourlyConversations
+ } catch (error) {
+ this.logger.error('Failed to get hourly stats:', error)
+ return []
+ }
+ }
+
+ async getTopActiveUsers(limit = 20) {
+ try {
+ const last24Hours = moment().subtract(24, 'hours')
+
+ const topUsers = await Conversation.aggregate([
+ {
+ $match: {
+ timestamp: { $gte: last24Hours.toDate() }
+ }
+ },
+ {
+ $group: {
+ _id: '$userId',
+ messageCount: { $sum: 1 },
+ lastActivity: { $max: '$timestamp' },
+ topics: { $addToSet: '$topics' }
+ }
+ },
+ {
+ $sort: { messageCount: -1 }
+ },
+ {
+ $limit: limit
+ }
+ ])
+
+ return topUsers
+ } catch (error) {
+ this.logger.error('Failed to get top active users:', error)
+ return []
+ }
+ }
+
+ async getConversationTopics() {
+ try {
+ const last24Hours = moment().subtract(24, 'hours')
+
+ const topics = await Conversation.aggregate([
+ {
+ $match: {
+ timestamp: { $gte: last24Hours.toDate() }
+ }
+ },
+ {
+ $unwind: '$topics'
+ },
+ {
+ $group: {
+ _id: '$topics',
+ count: { $sum: 1 }
+ }
+ },
+ {
+ $sort: { count: -1 }
+ },
+ {
+ $limit: 10
+ }
+ ])
+
+ return topics
+ } catch (error) {
+ this.logger.error('Failed to get conversation topics:', error)
+ return []
+ }
+ }
+
+ async getGeographicStats() {
+ try {
+ const last24Hours = moment().subtract(24, 'hours')
+
+ const geoStats = await Session.aggregate([
+ {
+ $match: {
+ startTime: { $gte: last24Hours.toDate() }
+ }
+ },
+ {
+ $group: {
+ _id: {
+ country: '$location.country',
+ state: '$location.state'
+ },
+ sessions: { $sum: 1 },
+ uniqueUsers: { $addToSet: '$userId' }
+ }
+ },
+ {
+ $addFields: {
+ userCount: { $size: '$uniqueUsers' }
+ }
+ },
+ {
+ $sort: { sessions: -1 }
+ }
+ ])
+
+ return geoStats
+ } catch (error) {
+ this.logger.error('Failed to get geographic stats:', error)
+ return []
+ }
+ }
+
+ saveReportToFile(report) {
+ try {
+ const reportsDir = path.join(__dirname, '../reports')
+ if (!fs.existsSync(reportsDir)) {
+ fs.mkdirSync(reportsDir, { recursive: true })
+ }
+
+ const filename = `report_${moment().format('YYYY-MM-DD_HH-mm-ss')}.json`
+ const filepath = path.join(reportsDir, filename)
+
+ fs.writeFileSync(filepath, JSON.stringify(report, null, 2))
+
+ // Clean up old reports (keep only last 30 days)
+ this.cleanupOldReports(reportsDir)
+
+ } catch (error) {
+ this.logger.error('Failed to save report to file:', error)
+ }
+ }
+
+ cleanupOldReports(reportsDir) {
+ try {
+ const files = fs.readdirSync(reportsDir)
+ const cutoff = moment().subtract(30, 'days')
+
+ files.forEach(file => {
+ const filepath = path.join(reportsDir, file)
+ const stats = fs.statSync(filepath)
+
+ if (moment(stats.birthtime).isBefore(cutoff)) {
+ fs.unlinkSync(filepath)
+ this.logger.info(`Deleted old report: ${file}`)
+ }
+ })
+ } catch (error) {
+ this.logger.error('Failed to cleanup old reports:', error)
+ }
+ }
+
+ async cleanupOldLogs() {
+ try {
+ const logsDir = path.join(__dirname, '../logs')
+ if (!fs.existsSync(logsDir)) return
+
+ const files = fs.readdirSync(logsDir)
+ const cutoff = moment().subtract(7, 'days') // Keep logs for 7 days
+
+ files.forEach(file => {
+ const filepath = path.join(logsDir, file)
+ const stats = fs.statSync(filepath)
+
+ if (moment(stats.birthtime).isBefore(cutoff)) {
+ fs.unlinkSync(filepath)
+ this.logger.info(`Deleted old log: ${file}`)
+ }
+ })
+ } catch (error) {
+ this.logger.error('Failed to cleanup old logs:', error)
+ }
+ }
+
+ createSystemAlert(type, details) {
+ const alert = {
+ type: 'system',
+ subtype: type,
+ severity: this.getAlertSeverity(type),
+ timestamp: new Date(),
+ details,
+ systemMetrics: this.systemMetrics
+ }
+
+ // Emit to admin dashboard
+ this.io.to('admin').emit('system_alert', alert)
+
+ this.logger.warn(`System alert: ${type}`, details)
+ }
+
+ getAlertSeverity(type) {
+ const severityMap = {
+ 'high_memory_usage': 'high',
+ 'high_cpu_usage': 'high',
+ 'api_health_failed': 'medium',
+ 'api_unreachable': 'critical'
+ }
+ return severityMap[type] || 'medium'
+ }
+
+ broadcastStats() {
+ this.io.to('admin').emit('stats', {
+ ...this.currentStats,
+ systemMetrics: this.systemMetrics,
+ realtimeStats: this.realtimeStats
+ })
+ }
+
+ getCurrentStats() {
+ return {
+ ...this.currentStats,
+ systemMetrics: this.systemMetrics,
+ realtimeStats: this.realtimeStats
+ }
+ }
+
+ trackUserActivity(userId, sessionId, activity) {
+ this.activeUsers.add(userId)
+
+ if (!this.activeSessions.has(sessionId)) {
+ this.activeSessions.set(sessionId, {
+ userId,
+ startTime: new Date(),
+ lastActivity: new Date(),
+ messageCount: 0
+ })
+ }
+
+ const session = this.activeSessions.get(sessionId)
+ session.lastActivity = new Date()
+ session.messageCount++
+
+ // Clean up inactive sessions (older than 30 minutes)
+ const cutoff = moment().subtract(30, 'minutes')
+ for (const [sid, sessionData] of this.activeSessions.entries()) {
+ if (moment(sessionData.lastActivity).isBefore(cutoff)) {
+ this.activeSessions.delete(sid)
+ this.activeUsers.delete(sessionData.userId)
+ }
+ }
+
+ // Update real-time stats
+ this.currentStats.activeUsers = this.activeUsers.size
+ this.currentStats.activeSessions = this.activeSessions.size
+ }
+
+ async generateRealtimeStats() {
+ // This method is called every 5 minutes to update real-time statistics
+ await this.updateUserStats()
+ this.broadcastStats()
+ }
+}
+
+module.exports = MonitoringService
\ No newline at end of file
diff --git a/backend/social-post-handler.js b/backend/social-post-handler.js
new file mode 100644
index 0000000..1194325
--- /dev/null
+++ b/backend/social-post-handler.js
@@ -0,0 +1,233 @@
+const fs = require('fs').promises;
+const path = require('path');
+
+// Bubbe intro phrases for variety
+const BUBBE_INTROS = [
+ "Bubbe tells it like it is",
+ "Bubbe's got no filter",
+ "Bubbe says what you're thinking",
+ "Bubbe drops truth bombs",
+ "Bubbe keeps it real",
+ "Bubbe's wisdom hits different",
+ "Bubbe doesn't sugarcoat",
+ "Bubbe serves reality checks",
+ "Bubbe's brutal honesty",
+ "Bubbe speaks facts",
+ "Bubbe's savage advice",
+ "Bubbe pulls no punches",
+ "Bubbe's unfiltered truth",
+ "Bubbe's raw wisdom",
+ "Bubbe's tough love",
+ "Bubbe's straight talk",
+ "Bubbe's no-nonsense advice",
+ "Bubbe's reality check",
+ "Bubbe's harsh truth",
+ "Bubbe tells you straight"
+];
+
+// Name anonymization function
+function anonymizeText(text) {
+ const nameReplacements = {
+ // Male names
+ 'john': 'David', 'mike': 'Michael', 'steve': 'Robert', 'james': 'William',
+ 'david': 'John', 'robert': 'James', 'michael': 'Thomas', 'william': 'Charles',
+ 'richard': 'Joseph', 'joseph': 'Daniel', 'thomas': 'Paul', 'charles': 'Mark',
+
+ // Female names
+ 'mary': 'Sarah', 'jennifer': 'Jessica', 'linda': 'Emma', 'patricia': 'Emily',
+ 'elizabeth': 'Anna', 'susan': 'Rachel', 'jessica': 'Rebecca', 'sarah': 'Hannah',
+ 'karen': 'Michelle', 'nancy': 'Laura', 'betty': 'Amanda', 'dorothy': 'Nicole',
+
+ // Spouse/partner references
+ 'my husband': 'my partner', 'my wife': 'my partner',
+ 'my boyfriend': 'my partner', 'my girlfriend': 'my partner'
+ };
+
+ let anonymized = text;
+
+ for (const [original, replacement] of Object.entries(nameReplacements)) {
+ const regex = new RegExp(`\\b${original}\\b`, 'gi');
+ anonymized = anonymized.replace(regex, replacement);
+ }
+
+ // Remove sensitive info
+ anonymized = anonymized.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, '[email]');
+ anonymized = anonymized.replace(/\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/g, '[phone]');
+ anonymized = anonymized.replace(/\d+\s+[A-Za-z]+\s+(Street|St|Avenue|Ave|Road|Rd)/gi, '[address]');
+
+ return anonymized;
+}
+
+// Get random intro
+function getRandomIntro() {
+ return BUBBE_INTROS[Math.floor(Math.random() * BUBBE_INTROS.length)];
+}
+
+// Format post for Twitter
+function formatTwitterPost(userMessage, bubbeResponse) {
+ const intro = getRandomIntro();
+ const anonymizedUser = anonymizeText(userMessage).substring(0, 60);
+ const anonymizedResponse = anonymizeText(bubbeResponse).substring(0, 100);
+
+ return `${intro}:\n\n"${anonymizedResponse}..." #BubbeAI #Bubbe #NoFilter #JewishGrandma\n\nVisit Bubbe.AI`;
+}
+
+// Format post for Bluesky
+function formatBlueskyPost(userMessage, bubbeResponse) {
+ const intro = getRandomIntro();
+ const anonymizedResponse = anonymizeText(bubbeResponse).substring(0, 120);
+
+ return `${intro}: "${anonymizedResponse}..." #BubbeAI #Bubbe\n\nVisit Bubbe.AI`.substring(0, 300);
+}
+
+// Post to Twitter using official API v2
+async function postToTwitter(text) {
+ try {
+ // Log the attempt
+ console.log('[TWITTER] Posting via API v2:', text.substring(0, 50) + '...');
+
+ // Use the official Twitter API
+ const { postTweet } = require('/root/Projects/dear-bubbe-nextjs/lib/twitter-v2-official.js');
+ const result = await postTweet(text);
+
+ if (result.success) {
+ console.log('[TWITTER] Successfully posted! Tweet ID:', result.tweetId);
+ console.log('[TWITTER] URL:', `https://x.com/DearBubbe/status/${result.tweetId}`);
+
+ // Log successful post
+ const logEntry = {
+ platform: 'twitter',
+ text,
+ timestamp: new Date().toISOString(),
+ status: 'posted',
+ tweetId: result.tweetId,
+ url: `https://x.com/DearBubbe/status/${result.tweetId}`
+ };
+
+ // Save to log file
+ const logsPath = path.join(__dirname, '../logs/twitter-posts.json');
+ let logs = [];
+ try {
+ logs = JSON.parse(await fs.readFile(logsPath, 'utf8'));
+ } catch (e) {
+ // File doesn't exist
+ }
+ logs.push(logEntry);
+ await fs.writeFile(logsPath, JSON.stringify(logs, null, 2));
+
+ return true;
+ } else {
+ console.error('[TWITTER] Failed to post:', result.error);
+ return false;
+ }
+ } catch (error) {
+ console.error('[TWITTER] Error:', error);
+ return false;
+ }
+}
+
+// Post to Bluesky using API or webhook
+async function postToBluesky(text) {
+ try {
+ // Log the attempt
+ console.log('[BLUESKY] Posting:', text.substring(0, 50) + '...');
+
+ // TODO: Add actual Bluesky API implementation here
+ // For now, just log it
+ const logEntry = {
+ platform: 'bluesky',
+ text,
+ timestamp: new Date().toISOString(),
+ status: 'queued'
+ };
+
+ // Save to queue file
+ const queuePath = path.join(__dirname, '../logs/bluesky-queue.json');
+ let queue = [];
+ try {
+ queue = JSON.parse(await fs.readFile(queuePath, 'utf8'));
+ } catch (e) {
+ // File doesn't exist
+ }
+ queue.push(logEntry);
+ await fs.writeFile(queuePath, JSON.stringify(queue, null, 2));
+
+ return true;
+ } catch (error) {
+ console.error('[BLUESKY] Error:', error);
+ return false;
+ }
+}
+
+// Main handler for immediate posting
+async function handleImmediateSocialPost(data) {
+ const { userMessage, bubbeResponse, messageId, timestamp, userId, location, userName } = data;
+
+ console.log(`\n=== IMMEDIATE SOCIAL POST REQUEST ===`);
+ console.log(`Time: ${timestamp}`);
+ console.log(`User: ${userName} (${location})`);
+ console.log(`Message ID: ${messageId}`);
+ console.log(`User said: "${userMessage.substring(0, 50)}..."`);
+ console.log(`Bubbe said: "${bubbeResponse.substring(0, 50)}..."`);
+
+ // Create formatted posts
+ const twitterText = formatTwitterPost(userMessage, bubbeResponse);
+ const blueskyText = formatBlueskyPost(userMessage, bubbeResponse);
+
+ // Post to both platforms
+ const results = {
+ twitter: false,
+ bluesky: false
+ };
+
+ // Post to Twitter
+ results.twitter = await postToTwitter(twitterText);
+
+ // Post to Bluesky
+ results.bluesky = await postToBluesky(blueskyText);
+
+ // Log the results
+ const logEntry = {
+ timestamp: new Date().toISOString(),
+ messageId,
+ userId,
+ userName,
+ location,
+ userSnippet: userMessage.substring(0, 50),
+ bubbeSnippet: bubbeResponse.substring(0, 50),
+ twitterPosted: results.twitter,
+ blueskyPosted: results.bluesky,
+ twitterText: results.twitter ? twitterText : null,
+ blueskyText: results.bluesky ? blueskyText : null
+ };
+
+ const logPath = path.join(__dirname, '../logs/immediate-posts.json');
+ let logs = [];
+ try {
+ logs = JSON.parse(await fs.readFile(logPath, 'utf8'));
+ } catch (e) {
+ // File doesn't exist
+ }
+ logs.push(logEntry);
+
+ // Keep only last 100 entries
+ if (logs.length > 100) {
+ logs = logs.slice(-100);
+ }
+
+ await fs.writeFile(logPath, JSON.stringify(logs, null, 2));
+
+ console.log(`\nResults:`);
+ console.log(`Twitter: ${results.twitter ? '✅ POSTED' : '❌ FAILED'}`);
+ console.log(`Bluesky: ${results.bluesky ? '✅ POSTED' : '❌ FAILED'}`);
+ console.log(`=====================================\n`);
+
+ return results;
+}
+
+module.exports = {
+ handleImmediateSocialPost,
+ formatTwitterPost,
+ formatBlueskyPost,
+ anonymizeText
+};
\ No newline at end of file
diff --git a/check-active-users.js b/check-active-users.js
new file mode 100644
index 0000000..b5f2707
--- /dev/null
+++ b/check-active-users.js
@@ -0,0 +1,94 @@
+#!/usr/bin/env node
+
+const fs = require('fs')
+const path = require('path')
+
+// Read the last 500 lines of logs
+const logsPath = path.join(__dirname, 'logs/combined.log')
+const logs = fs.readFileSync(logsPath, 'utf-8').split('\n').slice(-500)
+
+// Track active users from the last 5 minutes
+const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000)
+const activeUsers = new Map()
+
+logs.forEach(line => {
+ if (line.includes('user_message') || line.includes('live-chat-update')) {
+ try {
+ const parsed = JSON.parse(line)
+ const timestamp = new Date(parsed.timestamp)
+
+ if (timestamp > fiveMinutesAgo) {
+ const key = parsed.ip || parsed.userId || 'unknown'
+
+ // Extract user info
+ if (!activeUsers.has(key) || timestamp > new Date(activeUsers.get(key).lastSeen)) {
+ activeUsers.set(key, {
+ userName: parsed.userName || 'Anonymous',
+ ip: parsed.ip || 'Unknown',
+ location: parsed.location || 'Unknown',
+ mode: parsed.mode || 'bubbe',
+ lastSeen: parsed.timestamp,
+ lastMessage: parsed.message || parsed.userMessage || ''
+ })
+ }
+ }
+ } catch (e) {
+ // Not a JSON line, skip
+ }
+ }
+})
+
+console.log('=== ACTIVE USERS (Last 5 Minutes) ===\n')
+console.log(`Total Active: ${activeUsers.size}\n`)
+
+let count = 1
+activeUsers.forEach((user, key) => {
+ console.log(`${count}. ${user.userName}`)
+ console.log(` IP: ${user.ip}`)
+ console.log(` Location: ${user.location}`)
+ console.log(` Mode: ${user.mode}`)
+ console.log(` Last Seen: ${new Date(user.lastSeen).toLocaleTimeString()}`)
+ if (user.lastMessage) {
+ console.log(` Last Message: "${user.lastMessage.substring(0, 50)}${user.lastMessage.length > 50 ? '...' : ''}"`)
+ }
+ console.log('')
+ count++
+})
+
+if (activeUsers.size === 0) {
+ console.log('No active users found in the last 5 minutes.')
+ console.log('\nChecking for any users in last hour...')
+
+ const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000)
+ const recentUsers = new Map()
+
+ logs.forEach(line => {
+ if (line.includes('user_message')) {
+ try {
+ const parsed = JSON.parse(line)
+ const timestamp = new Date(parsed.timestamp)
+
+ if (timestamp > oneHourAgo) {
+ const key = parsed.ip || parsed.userId || 'unknown'
+ if (!recentUsers.has(key)) {
+ recentUsers.set(key, {
+ userName: parsed.userName || 'Anonymous',
+ ip: parsed.ip || 'Unknown',
+ location: parsed.location || 'Unknown',
+ timestamp: parsed.timestamp
+ })
+ }
+ }
+ } catch (e) {
+ // Skip non-JSON lines
+ }
+ }
+ })
+
+ if (recentUsers.size > 0) {
+ console.log(`\nFound ${recentUsers.size} users in the last hour:`)
+ recentUsers.forEach(user => {
+ console.log(`- ${user.userName} | ${user.ip} | ${user.location} | ${new Date(user.timestamp).toLocaleTimeString()}`)
+ })
+ }
+}
\ No newline at end of file
diff --git a/ecosystem.config.js b/ecosystem.config.js
new file mode 100644
index 0000000..bc2da05
--- /dev/null
+++ b/ecosystem.config.js
@@ -0,0 +1,24 @@
+module.exports = {
+ apps: [
+ {
+ name: 'dear-bubbe-admin',
+ script: 'backend/server.js',
+ cwd: '/root/Projects/dear-bubbe-admin',
+ instances: 1,
+ autorestart: true,
+ watch: false,
+ max_memory_restart: '1G',
+ env: {
+ NODE_ENV: 'production',
+ ADMIN_PORT: 3015,
+ MONGODB_URI: 'mongodb://localhost:27017/dear-bubbe-admin',
+ JWT_SECRET: 'dear-bubbe-admin-secret-key-change-in-production-2024',
+ BUBBE_API_URL: 'http://localhost:3011'
+ },
+ error_file: './logs/pm2-error.log',
+ out_file: './logs/pm2-out.log',
+ log_file: './logs/pm2-combined.log',
+ time: true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..73ab476
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,2190 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Dear Bubbe Admin - Restricted Access</title>
+ <script src="https://accounts.google.com/gsi/client" async defer></script>
+ <script src="https://cdn.socket.io/4.7.4/socket.io.min.js"></script>
+ <style>
+ * {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+ }
+
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ min-height: 100vh;
+ }
+
+ /* Login Screen */
+ .login-container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ padding: 20px;
+ }
+
+ .login-card {
+ background: white;
+ border-radius: 20px;
+ padding: 40px;
+ max-width: 400px;
+ width: 100%;
+ box-shadow: 0 20px 60px rgba(0,0,0,0.3);
+ text-align: center;
+ }
+
+ .login-card h1 {
+ color: #333;
+ margin-bottom: 10px;
+ }
+
+ .restricted-notice {
+ color: #dc3545;
+ font-weight: bold;
+ margin: 20px 0;
+ padding: 15px;
+ background: #fee;
+ border-radius: 10px;
+ border: 2px solid #dc3545;
+ }
+
+ /* Dashboard */
+ .dashboard {
+ display: none;
+ min-height: 100vh;
+ background: #f5f5f5;
+ }
+
+ .dashboard.active {
+ display: block;
+ }
+
+ .header {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ padding: 20px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+ }
+
+ .header-content {
+ max-width: 1400px;
+ margin: 0 auto;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .user-info {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ }
+
+ .user-info img {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ }
+
+ .container {
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: 20px;
+ }
+
+ .stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+ gap: 20px;
+ margin-bottom: 30px;
+ }
+
+ .stat-card {
+ background: white;
+ border-radius: 10px;
+ padding: 20px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.05);
+ }
+
+ .stat-card h3 {
+ color: #666;
+ font-size: 14px;
+ margin-bottom: 10px;
+ }
+
+ .stat-value {
+ font-size: 32px;
+ font-weight: bold;
+ color: #333;
+ }
+
+ .stat-card.alert {
+ border-left: 4px solid #dc3545;
+ }
+
+ .stat-card.success {
+ border-left: 4px solid #28a745;
+ }
+
+ /* Alerts Section */
+ .alerts-container {
+ background: white;
+ border-radius: 10px;
+ padding: 20px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.05);
+ margin-bottom: 30px;
+ }
+
+ .alert-item {
+ padding: 15px;
+ margin-bottom: 10px;
+ border-radius: 8px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .alert-item.high {
+ background: #fee;
+ border-left: 4px solid #dc3545;
+ }
+
+ .alert-item.medium {
+ background: #fff3cd;
+ border-left: 4px solid #ffc107;
+ }
+
+ .alert-item.low {
+ background: #f0f0f0;
+ border-left: 4px solid #6c757d;
+ }
+
+ .alert-badge {
+ padding: 5px 10px;
+ border-radius: 5px;
+ font-size: 12px;
+ font-weight: bold;
+ }
+
+ .badge-antisemitic {
+ background: #dc3545;
+ color: white;
+ }
+
+ .badge-racist {
+ background: #fd7e14;
+ color: white;
+ }
+
+ /* Users Table */
+ .users-table {
+ background: white;
+ border-radius: 10px;
+ padding: 20px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.05);
+ overflow-x: auto;
+ }
+
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ }
+
+ th {
+ background: #f8f9fa;
+ padding: 12px;
+ text-align: left;
+ font-weight: 600;
+ color: #666;
+ }
+
+ td {
+ padding: 12px;
+ border-bottom: 1px solid #eee;
+ }
+
+ .btn {
+ padding: 6px 12px;
+ border-radius: 5px;
+ border: none;
+ cursor: pointer;
+ font-size: 14px;
+ }
+
+ .btn-danger {
+ background: #dc3545;
+ color: white;
+ }
+
+ .btn-success {
+ background: #28a745;
+ color: white;
+ }
+
+ .status-badge {
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 12px;
+ }
+
+ .status-active {
+ background: #d4edda;
+ color: #155724;
+ }
+
+ .status-blocked {
+ background: #f8d7da;
+ color: #721c24;
+ }
+ </style>
+</head>
+<body>
+ <!-- Login Screen -->
+ <div class="login-container" id="loginScreen">
+ <div class="login-card">
+ <h1>🥯 Dear Bubbe Admin</h1>
+ <p style="color: #666; margin-bottom: 20px;">Administrative Dashboard</p>
+
+ <div class="restricted-notice">
+ ⚠️ RESTRICTED ACCESS<br>
+ Only authorized administrators can access this system
+ </div>
+
+ <!-- Password Login Form -->
+ <div style="margin-top: 20px; padding-top: 20px; border-top: 1px solid #ddd;">
+ <p style="color: #666; margin-bottom: 15px; font-size: 14px;">Admin Login</p>
+ <form id="passwordLoginForm" onsubmit="handlePasswordLogin(event)">
+ <input
+ type="email"
+ id="adminEmail"
+ placeholder="Email address"
+ required
+ style="width: 100%; padding: 12px; margin-bottom: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 16px;"
+ value="steve@designerwallcoverings.com"
+ readonly
+ />
+ <input
+ type="password"
+ id="adminPassword"
+ placeholder="Password"
+ required
+ style="width: 100%; padding: 12px; margin-bottom: 15px; border: 1px solid #ddd; border-radius: 5px; font-size: 16px;"
+ />
+ <button
+ type="submit"
+ style="width: 100%; padding: 12px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border: none; border-radius: 5px; font-size: 16px; font-weight: bold; cursor: pointer;">
+ Sign In
+ </button>
+ </form>
+ </div>
+
+ <!-- Google OAuth (disabled for now) -->
+ <div style="margin-top: 20px; opacity: 0.5; pointer-events: none;">
+ <p style="color: #999; font-size: 12px; margin-bottom: 10px;">Google Sign-In temporarily unavailable</p>
+ <div id="g_id_onload"
+ data-client_id="493480746821-mhpjc0glkkogcf845ubi2hkhksoqj39k.apps.googleusercontent.com"
+ data-callback="handleGoogleLogin"
+ data-auto_prompt="false"
+ data-ux_mode="popup"
+ data-login_uri="http://45.61.58.125:5013">
+ </div>
+ <div class="g_id_signin"
+ data-type="standard"
+ data-size="large"
+ data-theme="outline"
+ data-text="sign_in_with"
+ data-shape="rectangular"
+ data-logo_alignment="left">
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Dashboard -->
+ <div class="dashboard" id="dashboard">
+ <!-- Active Users Bottom Bar -->
+ <div id="activeUsersBar" style="position: fixed; bottom: 0; left: 0; right: 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 10px 20px; display: flex; align-items: center; gap: 20px; box-shadow: 0 -2px 10px rgba(0,0,0,0.2); z-index: 1000; overflow-x: auto;">
+ <div style="display: flex; align-items: center; gap: 10px; font-weight: bold; min-width: fit-content;">
+ <span style="display: inline-block; width: 10px; height: 10px; background: #4caf50; border-radius: 50%; animation: pulse 2s infinite;"></span>
+ <span id="activeUsersLabel">Active Users (0)</span>
+ </div>
+ <div id="activeUsersList" style="display: flex; gap: 15px; flex: 1; overflow-x: auto;">
+ <!-- Active users will be dynamically added here -->
+ </div>
+ </div>
+ <div class="header">
+ <div class="header-content">
+ <h1>Dear Bubbe Admin Dashboard</h1>
+ <div class="user-info">
+ <img id="userPicture" src="" alt="Admin">
+ <div>
+ <div id="userName" style="font-weight: bold;"></div>
+ <div style="font-size: 12px;">Administrator</div>
+ </div>
+ <button class="btn" onclick="logout()" style="background: white; color: #333; margin-left: 20px;">
+ Sign Out
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <div class="container">
+ <!-- Action Buttons Bar -->
+ <div style="background: white; border-radius: 10px; padding: 15px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); display: flex; gap: 10px; flex-wrap: wrap;">
+ <button onclick="refreshDashboard()" style="padding: 8px 15px; background: #007bff; color: white; border: none; border-radius: 5px; cursor: pointer;">
+ 🔄 Refresh Dashboard
+ </button>
+ <button onclick="generateReport()" style="padding: 8px 15px; background: #28a745; color: white; border: none; border-radius: 5px; cursor: pointer;">
+ 📊 Generate Report
+ </button>
+ <button onclick="exportUsers()" style="padding: 8px 15px; background: #17a2b8; color: white; border: none; border-radius: 5px; cursor: pointer;">
+ 📥 Export Users
+ </button>
+ <button onclick="exportAlerts()" style="padding: 8px 15px; background: #ffc107; color: black; border: none; border-radius: 5px; cursor: pointer;">
+ 📥 Export Alerts
+ </button>
+ <button onclick="exportChats()" style="padding: 8px 15px; background: #6f42c1; color: white; border: none; border-radius: 5px; cursor: pointer;">
+ 📥 Export Chats
+ </button>
+ <button onclick="clearAlerts()" style="padding: 8px 15px; background: #dc3545; color: white; border: none; border-radius: 5px; cursor: pointer;">
+ 🗑️ Clear Alerts
+ </button>
+ <button onclick="clearChats()" style="padding: 8px 15px; background: #dc3545; color: white; border: none; border-radius: 5px; cursor: pointer;">
+ 🗑️ Clear Chats
+ </button>
+ <button onclick="window.open('http://45.61.58.125:3011', '_blank')" style="padding: 8px 15px; background: #20c997; color: white; border: none; border-radius: 5px; cursor: pointer;">
+ 🌐 Open Bubbe Site
+ </button>
+ </div>
+
+ <!-- Stats Cards -->
+ <div class="stats-grid">
+ <div class="stat-card success">
+ <h3>TOTAL USERS</h3>
+ <div class="stat-value" id="totalUsers">0</div>
+ </div>
+ <div class="stat-card">
+ <h3>ACTIVE NOW</h3>
+ <div class="stat-value" id="activeUsers">0</div>
+ </div>
+ <div class="stat-card alert">
+ <h3>TOTAL ALERTS</h3>
+ <div class="stat-value" id="totalAlerts">0</div>
+ </div>
+ <div class="stat-card alert">
+ <h3>CRITICAL ALERTS</h3>
+ <div class="stat-value" id="criticalAlerts">0</div>
+ </div>
+ </div>
+
+ <!-- Real-time Alerts -->
+ <div class="alerts-container">
+ <h2 style="margin-bottom: 20px;">🚨 Real-time Alerts</h2>
+ <div id="alertsList">
+ <p style="color: #999;">No alerts to display</p>
+ </div>
+ </div>
+
+ <!-- Live Chat Monitoring -->
+ <div class="live-chat-container" style="background: white; border-radius: 10px; padding: 20px; margin: 20px 0; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
+ <h2 style="margin-bottom: 20px; display: flex; align-items: center; gap: 10px;">
+ <span style="color: #28a745; animation: pulse 2s infinite;">🔴</span>
+ Live User Activity
+ <span id="liveUserCount" style="background: #28a745; color: white; padding: 2px 10px; border-radius: 20px; font-size: 14px;">0 active</span>
+ </h2>
+ <div id="liveChatsList" style="max-height: 400px; overflow-y: auto; border: 1px solid #eee; border-radius: 8px; padding: 10px;">
+ <p style="color: #999; text-align: center;">No active conversations</p>
+ </div>
+ </div>
+
+ <!-- Users Table -->
+ <div class="users-table">
+ <h2 style="margin-bottom: 20px;">👥 User Management</h2>
+ <table>
+ <thead>
+ <tr>
+ <th>Email</th>
+ <th>Name</th>
+ <th>Status</th>
+ <th>Joined</th>
+ <th>Last Active</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <tbody id="usersTableBody">
+ <tr>
+ <td colspan="6" style="text-align: center; color: #999;">Loading users...</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+
+ <!-- Civil Rights Security - Banned Terms -->
+ <div class="banned-terms-container" style="background: white; border-radius: 10px; padding: 20px; margin-top: 30px; box-shadow: 0 2px 10px rgba(0,0,0,0.05);">
+ <h2 style="color: #dc3545; margin-bottom: 20px;">🚫 Civil Rights Security - Banned Terms</h2>
+ <div id="bannedTermsInfo" style="background: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 20px;">
+ <div style="display: flex; justify-content: space-between; margin-bottom: 10px;">
+ <div><strong>Version:</strong> <span id="bannedVersion">-</span></div>
+ <div><strong>Last Updated:</strong> <span id="bannedUpdated">-</span></div>
+ <div><strong>Total Terms:</strong> <span id="bannedTotal" style="color: #dc3545; font-weight: bold;">0</span></div>
+ </div>
+ <div style="margin-top: 10px; color: #666;">
+ <strong>Sources:</strong> <span id="bannedSources">-</span>
+ </div>
+ </div>
+
+ <!-- Category Filter -->
+ <div style="margin-bottom: 15px;">
+ <label style="margin-right: 10px;">Filter by Category:</label>
+ <select id="categoryFilter" onchange="filterBannedTerms()" style="padding: 8px; border-radius: 5px; border: 1px solid #ddd;">
+ <option value="all">All Categories</option>
+ <option value="ethnic_slurs">Ethnic Slurs</option>
+ <option value="antisemitic_slurs">Antisemitic Slurs</option>
+ <option value="racial_slurs">Racial Slurs</option>
+ <option value="lgbtq_slurs">LGBTQ+ Slurs</option>
+ <option value="holocaust_references">Holocaust References</option>
+ <option value="offensive_phrases">Offensive Phrases</option>
+ </select>
+
+ <label style="margin-left: 20px; margin-right: 10px;">Severity:</label>
+ <select id="severityFilter" onchange="filterBannedTerms()" style="padding: 8px; border-radius: 5px; border: 1px solid #ddd;">
+ <option value="all">All Severities</option>
+ <option value="critical">Critical</option>
+ <option value="high">High</option>
+ <option value="medium">Medium</option>
+ </select>
+ </div>
+
+ <!-- Terms List -->
+ <div id="bannedTermsList" style="overflow-y: auto; max-height: 500px; border: 1px solid #e0e0e0; border-radius: 8px; padding: 10px;">
+ Loading banned terms...
+ </div>
+
+ <!-- Add New Banned Term -->
+ <div style="margin-top: 20px; padding: 15px; background: #fff5f5; border-radius: 8px; border: 1px solid #ffcdd2;">
+ <h4 style="color: #dc3545; margin-bottom: 15px;">➕ Add New Banned Term</h4>
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px;">
+ <input type="text" id="newBannedTerm" placeholder="Term *" style="padding: 8px; border: 1px solid #ddd; border-radius: 5px;">
+ <input type="text" id="newBannedVariants" placeholder="Variants (comma-separated)" style="padding: 8px; border: 1px solid #ddd; border-radius: 5px;">
+ <select id="newBannedCategory" style="padding: 8px; border: 1px solid #ddd; border-radius: 5px;">
+ <option value="">Select Category *</option>
+ <option value="ethnic_slurs">Ethnic Slurs</option>
+ <option value="antisemitic_slurs">Antisemitic Slurs</option>
+ <option value="racial_slurs">Racial Slurs</option>
+ <option value="lgbtq_slurs">LGBTQ+ Slurs</option>
+ <option value="holocaust_references">Holocaust References</option>
+ <option value="offensive_phrases">Offensive Phrases</option>
+ </select>
+ <select id="newBannedSeverity" style="padding: 8px; border: 1px solid #ddd; border-radius: 5px;">
+ <option value="">Select Severity *</option>
+ <option value="critical">Critical</option>
+ <option value="high">High</option>
+ <option value="medium">Medium</option>
+ </select>
+ <input type="text" id="newBannedReason" placeholder="Reason" style="padding: 8px; border: 1px solid #ddd; border-radius: 5px; grid-column: 1 / -1;">
+ <input type="text" id="newBannedReplacement" placeholder="Replacement" style="padding: 8px; border: 1px solid #ddd; border-radius: 5px;">
+ <button onclick="addBannedTerm()" style="background: #dc3545; color: white; padding: 8px 15px; border: none; border-radius: 5px; cursor: pointer; font-weight: bold;">Add Banned Term</button>
+ </div>
+ </div>
+
+ <!-- Acceptable Yiddish Section -->
+ <div style="margin-top: 20px; background: #e8f5e9; padding: 15px; border-radius: 8px;">
+ <h3 style="color: #28a745;">✅ Acceptable Yiddish Terms</h3>
+
+ <!-- Add New Acceptable Term -->
+ <div style="margin: 15px 0; padding: 15px; background: white; border-radius: 8px;">
+ <h4 style="color: #4caf50; margin-bottom: 15px;">➕ Add New Acceptable Term</h4>
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px;">
+ <input type="text" id="newAcceptableTerm" placeholder="Term *" style="padding: 8px; border: 1px solid #ddd; border-radius: 5px;">
+ <input type="text" id="newAcceptableVariants" placeholder="Variants (comma-separated)" style="padding: 8px; border: 1px solid #ddd; border-radius: 5px;">
+ <input type="text" id="newAcceptableMeaning" placeholder="Meaning *" style="padding: 8px; border: 1px solid #ddd; border-radius: 5px; grid-column: 1 / -1;">
+ <input type="text" id="newAcceptableUsage" placeholder="Usage" style="padding: 8px; border: 1px solid #ddd; border-radius: 5px; grid-column: 1 / -1;">
+ <input type="text" id="newAcceptableNotes" placeholder="Notes" style="padding: 8px; border: 1px solid #ddd; border-radius: 5px;">
+ <button onclick="addAcceptableTerm()" style="background: #4caf50; color: white; padding: 8px 15px; border: none; border-radius: 5px; cursor: pointer; font-weight: bold;">Add Acceptable Term</button>
+ </div>
+ </div>
+
+ <!-- Display Acceptable Terms -->
+ <div id="acceptableTermsList" style="max-height: 400px; overflow-y: auto; margin-top: 15px;">
+ <!-- Will be populated dynamically -->
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <script>
+ let authToken = null;
+ let socket = null;
+ const API_URL = 'http://45.61.58.125:5013';
+
+ // Handle Google Login
+ // Handle Password Login
+ window.handlePasswordLogin = async function(event) {
+ event.preventDefault();
+
+ const email = document.getElementById('adminEmail').value;
+ const password = document.getElementById('adminPassword').value;
+
+ try {
+ const res = await fetch(`${API_URL}/api/auth/password`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ email: email,
+ password: password
+ })
+ });
+
+ const data = await res.json();
+
+ if (res.ok) {
+ // Login successful
+ authToken = data.token;
+ localStorage.setItem('adminToken', authToken);
+
+ // Update UI
+ document.getElementById('userName').textContent = data.user.name;
+ if (data.user.picture) {
+ document.getElementById('userPicture').src = data.user.picture;
+ }
+
+ // Show dashboard
+ document.getElementById('loginScreen').style.display = 'none';
+ document.getElementById('dashboard').classList.add('active');
+
+ // Initialize dashboard
+ initDashboard();
+ } else {
+ // Access denied
+ alert(data.message || 'Invalid password. Please try again.');
+ }
+ } catch (error) {
+ console.error('Login error:', error);
+ alert('Login failed. Please try again.');
+ }
+ }
+
+ // Make handleGoogleLogin globally accessible for Google OAuth callback
+ window.handleGoogleLogin = async function(response) {
+ try {
+ const res = await fetch(`${API_URL}/api/auth/google`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ credential: response.credential
+ })
+ });
+
+ const data = await res.json();
+
+ if (res.ok) {
+ // Login successful
+ authToken = data.token;
+ localStorage.setItem('adminToken', authToken);
+
+ // Update UI
+ document.getElementById('userName').textContent = data.user.name;
+ document.getElementById('userPicture').src = data.user.picture;
+
+ // Show dashboard
+ document.getElementById('loginScreen').style.display = 'none';
+ document.getElementById('dashboard').classList.add('active');
+
+ // Initialize dashboard
+ initDashboard();
+ } else {
+ // Access denied
+ if (data.error && data.error.includes('restricted')) {
+ alert('❌ ACCESS DENIED\n\nThis admin panel is restricted to authorized administrators only.\n\nOnly steve@designerwallcoverings.com can access this system.');
+ } else {
+ alert(data.message || 'Access denied. Only authorized administrators can access this system.');
+ }
+ // Sign out from Google to allow retry with different account
+ if (google && google.accounts && google.accounts.id) {
+ google.accounts.id.disableAutoSelect();
+ }
+ }
+ } catch (error) {
+ console.error('Login error:', error);
+ alert('Login failed. Please try again.');
+ }
+ }
+
+ // Initialize Dashboard
+ async function initDashboard() {
+ // Connect WebSocket
+ socket = io(API_URL, {
+ auth: {
+ token: authToken
+ }
+ });
+
+ socket.on('new-alert', (alert) => {
+ addAlertToList(alert);
+ updateAlertCount();
+ });
+
+ socket.on('stats-update', (stats) => {
+ document.getElementById('activeUsers').textContent = stats.activeUsers;
+ });
+
+ // Live chat updates
+ socket.on('live-chat-update', (data) => {
+ updateLiveChat(data);
+ });
+
+ socket.on('user-status-change', (data) => {
+ console.log('User status changed:', data);
+ loadUsers(); // Reload users table
+ });
+
+ socket.on('user-deleted', (data) => {
+ console.log('User deleted:', data);
+ loadUsers(); // Reload users table
+ });
+
+ // Load initial data
+ await loadDashboardStats();
+ await loadUsers();
+ await loadAlerts();
+ await loadBannedTerms();
+ await loadActiveSessions();
+ }
+
+ // Load Dashboard Stats
+ async function loadDashboardStats() {
+ try {
+ const response = await fetch(`${API_URL}/api/dashboard/stats`, {
+ headers: {
+ 'Authorization': `Bearer ${authToken}`
+ }
+ });
+
+ if (response.ok) {
+ const stats = await response.json();
+ document.getElementById('totalUsers').textContent = stats.totalUsers;
+ document.getElementById('activeUsers').textContent = stats.activeToday;
+ document.getElementById('totalAlerts').textContent = stats.totalAlerts;
+ document.getElementById('criticalAlerts').textContent = stats.criticalAlerts;
+ }
+ } catch (error) {
+ console.error('Failed to load stats:', error);
+ }
+ }
+
+ // Load Users
+ async function loadUsers() {
+ try {
+ const response = await fetch(`${API_URL}/api/users`, {
+ headers: {
+ 'Authorization': `Bearer ${authToken}`
+ }
+ });
+
+ if (response.ok) {
+ const users = await response.json();
+ const tbody = document.getElementById('usersTableBody');
+
+ if (users.length === 0) {
+ tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; color: #999;">No users found</td></tr>';
+ return;
+ }
+
+ tbody.innerHTML = users.map(user => `
+ <tr>
+ <td>${user.email}</td>
+ <td>${user.preferredName || user.name || 'Unknown'}</td>
+ <td>
+ <span class="status-badge ${user.blocked ? 'status-blocked' : 'status-active'}">
+ ${user.blocked ? 'Blocked' : 'Active'}
+ </span>
+ </td>
+ <td>${new Date(user.createdAt).toLocaleDateString()}</td>
+ <td>${user.lastActive ? new Date(user.lastActive).toLocaleString() : 'Never'}</td>
+ <td>
+ <div class="user-actions">
+ ${user.active === false
+ ? `<button class="action-btn btn-activate" onclick="activateUser('${user.email}')">Activate</button>`
+ : `<button class="action-btn btn-deactivate" onclick="deactivateUser('${user.email}')">Deactivate</button>`
+ }
+ ${user.blocked
+ ? `<button class="action-btn btn-activate" onclick="unblockUser('${user.email}')">Unblock</button>`
+ : `<button class="action-btn btn-block" onclick="blockUser('${user.email}')">Block</button>`
+ }
+ <button class="action-btn btn-delete" onclick="deleteUser('${user.email}')">Delete</button>
+ </div>
+ </td>
+ </tr>
+ `).join('');
+ }
+ } catch (error) {
+ console.error('Failed to load users:', error);
+ }
+ }
+
+ // Load Alerts
+ async function loadAlerts() {
+ try {
+ const response = await fetch(`${API_URL}/api/alerts`, {
+ headers: {
+ 'Authorization': `Bearer ${authToken}`
+ }
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+ const alertsList = document.getElementById('alertsList');
+
+ const allAlerts = [...(data.antisemitic || []), ...(data.racist || []), ...(data.other || [])]
+ .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
+ .slice(0, 10);
+
+ if (allAlerts.length === 0) {
+ alertsList.innerHTML = '<p style="color: #999;">No alerts to display</p>';
+ return;
+ }
+
+ alertsList.innerHTML = allAlerts.map(alert => `
+ <div class="alert-item ${alert.severity.toLowerCase()}">
+ <div>
+ <span class="alert-badge badge-${alert.type}">${alert.type.toUpperCase()}</span>
+ <strong style="margin-left: 10px;">${alert.userId}</strong>
+ <div style="margin-top: 5px; color: #666;">${alert.message.substring(0, 100)}...</div>
+ </div>
+ <div style="text-align: right;">
+ <div style="color: #999; font-size: 12px;">${new Date(alert.timestamp).toLocaleString()}</div>
+ <div style="color: #666; font-size: 12px;">IP: ${alert.ip || 'Unknown'}</div>
+ </div>
+ </div>
+ `).join('');
+ }
+ } catch (error) {
+ console.error('Failed to load alerts:', error);
+ }
+ }
+
+ // Block User
+ async function blockUser(email) {
+ if (!confirm(`Are you sure you want to block ${email}?`)) return;
+
+ const reason = prompt('Enter reason for blocking:');
+ if (!reason) return;
+
+ try {
+ const response = await fetch(`${API_URL}/api/users/${email}/block`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${authToken}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ blocked: true,
+ reason: reason
+ })
+ });
+
+ if (response.ok) {
+ alert('User blocked successfully');
+ loadUsers();
+ }
+ } catch (error) {
+ console.error('Failed to block user:', error);
+ alert('Failed to block user');
+ }
+ }
+
+ // Unblock User
+ async function unblockUser(email) {
+ if (!confirm(`Are you sure you want to unblock ${email}?`)) return;
+
+ try {
+ const response = await fetch(`${API_URL}/api/users/${email}/block`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${authToken}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ blocked: false,
+ reason: 'Unblocked by admin'
+ })
+ });
+
+ if (response.ok) {
+ alert('User unblocked successfully');
+ loadUsers();
+ }
+ } catch (error) {
+ console.error('Failed to unblock user:', error);
+ alert('Failed to unblock user');
+ }
+ }
+
+ // Add Alert to List
+ function addAlertToList(alert) {
+ const alertsList = document.getElementById('alertsList');
+ const alertHtml = `
+ <div class="alert-item ${alert.severity.toLowerCase()}" style="animation: slideIn 0.5s;">
+ <div>
+ <span class="alert-badge badge-${alert.type}">🚨 NEW - ${alert.type.toUpperCase()}</span>
+ <strong style="margin-left: 10px;">${alert.userId}</strong>
+ <div style="margin-top: 5px; color: #666;">${alert.message.substring(0, 100)}...</div>
+ </div>
+ <div style="text-align: right;">
+ <div style="color: #999; font-size: 12px;">${new Date(alert.timestamp).toLocaleString()}</div>
+ <div style="color: #666; font-size: 12px;">IP: ${alert.ip || 'Unknown'}</div>
+ </div>
+ </div>
+ `;
+
+ // Remove "no alerts" message if present
+ if (alertsList.innerHTML.includes('No alerts to display')) {
+ alertsList.innerHTML = '';
+ }
+
+ // Add new alert at the top
+ alertsList.insertAdjacentHTML('afterbegin', alertHtml);
+ }
+
+ // Load Banned Terms
+ let allBannedTerms = [];
+ async function loadBannedTerms() {
+ try {
+ const response = await fetch(`${API_URL}/api/banned-terms`, {
+ headers: {
+ 'Authorization': `Bearer ${authToken}`
+ }
+ });
+
+ const data = await response.json();
+ allBannedTerms = data.terms || [];
+
+ // Update info section
+ document.getElementById('bannedVersion').textContent = data.version || '-';
+ document.getElementById('bannedUpdated').textContent = data.lastUpdated || '-';
+ document.getElementById('bannedTotal').textContent = data.totalTerms || 0;
+ document.getElementById('bannedSources').textContent = data.sources ? data.sources.join(', ') : '-';
+
+ // Display banned terms
+ displayBannedTerms(allBannedTerms);
+
+ // Load acceptable terms separately
+ loadAcceptableTerms();
+
+ } catch (error) {
+ console.error('Failed to load banned terms:', error);
+ document.getElementById('bannedTermsList').innerHTML = '<div style="color: red;">Failed to load banned terms</div>';
+ }
+ }
+
+ // Display Banned Terms
+ function displayBannedTerms(terms) {
+ const container = document.getElementById('bannedTermsList');
+
+ if (terms.length === 0) {
+ container.innerHTML = '<div style="color: #666; text-align: center;">No banned terms found</div>';
+ return;
+ }
+
+ const html = terms.map(term => {
+ const severityColor = term.severity === 'critical' ? '#dc3545' : term.severity === 'high' ? '#ff9800' : '#ffc107';
+ const variantsDisplay = term.variants && term.variants.length > 0
+ ? `<div style="margin-top: 5px; font-size: 12px; color: #666;">Variants: ${term.variants.join(', ')}</div>`
+ : '';
+
+ return `
+ <div style="border: 1px solid #e0e0e0; border-radius: 8px; padding: 10px; margin-bottom: 10px; background: #fafafa; position: relative;">
+ <button onclick="deleteBannedTerm('${encodeURIComponent(term.term)}')"
+ style="position: absolute; top: 5px; right: 5px; background: #dc3545; color: white; border: none; border-radius: 3px; padding: 2px 8px; cursor: pointer; font-size: 11px;">
+ Delete
+ </button>
+ <div style="display: flex; justify-content: space-between; align-items: start;">
+ <div style="flex: 1;">
+ <strong style="color: ${severityColor}; font-size: 16px;">${term.term}</strong>
+ ${variantsDisplay}
+ <div style="margin-top: 5px; color: #333;">${term.reason || 'No reason provided'}</div>
+ ${term.replacement ? `<div style="margin-top: 5px; color: #28a745;">Replacement: "${term.replacement}"</div>` : ''}
+ </div>
+ <div style="text-align: right; margin-right: 50px;">
+ <span style="background: ${severityColor}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px;">${term.severity.toUpperCase()}</span>
+ <div style="margin-top: 5px; color: #666; font-size: 12px;">${term.category.replace(/_/g, ' ').toUpperCase()}</div>
+ </div>
+ </div>
+ </div>
+ `;
+ }).join('');
+
+ container.innerHTML = html;
+ }
+
+ // Load acceptable terms
+ async function loadAcceptableTerms() {
+ try {
+ const response = await fetch(`${API_URL}/api/acceptable-terms`, {
+ headers: {
+ 'Authorization': `Bearer ${authToken}`
+ }
+ });
+
+ const data = await response.json();
+ displayAcceptableTerms(data.terms || []);
+
+ } catch (error) {
+ console.error('Failed to load acceptable terms:', error);
+ document.getElementById('acceptableTermsList').innerHTML = '<div style="color: red;">Failed to load acceptable terms</div>';
+ }
+ }
+
+ // Display acceptable terms
+ function displayAcceptableTerms(terms) {
+ const container = document.getElementById('acceptableTermsList');
+
+ if (terms.length === 0) {
+ container.innerHTML = '<div style="color: #666; text-align: center; padding: 20px;">No acceptable terms defined</div>';
+ return;
+ }
+
+ const html = terms.map(term => {
+ const variantsDisplay = term.variants && term.variants.length > 0
+ ? `<div style="margin-top: 5px; font-size: 12px; color: #666;">Variants: ${term.variants.join(', ')}</div>`
+ : '';
+
+ return `
+ <div style="border: 1px solid #4caf50; border-radius: 8px; padding: 12px; margin-bottom: 10px; background: white; position: relative;">
+ <button onclick="deleteAcceptableTerm('${encodeURIComponent(term.term)}')"
+ style="position: absolute; top: 5px; right: 5px; background: #dc3545; color: white; border: none; border-radius: 3px; padding: 2px 8px; cursor: pointer; font-size: 12px;">
+ Delete
+ </button>
+ <div>
+ <strong style="color: #4caf50; font-size: 16px;">${term.term}</strong>
+ ${variantsDisplay}
+ <div style="margin-top: 5px; color: #333;"><strong>Meaning:</strong> ${term.meaning}</div>
+ ${term.usage ? `<div style="margin-top: 3px; color: #666;"><strong>Usage:</strong> ${term.usage}</div>` : ''}
+ ${term.notes ? `<div style="margin-top: 3px; color: #999; font-style: italic;">${term.notes}</div>` : ''}
+ </div>
+ </div>
+ `;
+ }).join('');
+
+ container.innerHTML = html;
+ }
+
+ // Add new banned term
+ async function addBannedTerm() {
+ const term = document.getElementById('newBannedTerm').value.trim();
+ const category = document.getElementById('newBannedCategory').value;
+ const severity = document.getElementById('newBannedSeverity').value;
+ const variants = document.getElementById('newBannedVariants').value.trim();
+ const reason = document.getElementById('newBannedReason').value.trim();
+ const replacement = document.getElementById('newBannedReplacement').value.trim();
+
+ if (!term || !category || !severity) {
+ alert('Term, category, and severity are required');
+ return;
+ }
+
+ try {
+ const response = await fetch(`${API_URL}/api/banned-terms`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${authToken}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ term,
+ category,
+ severity,
+ variants,
+ reason,
+ replacement
+ })
+ });
+
+ if (response.ok) {
+ alert('Banned term added successfully');
+ // Clear form
+ document.getElementById('newBannedTerm').value = '';
+ document.getElementById('newBannedVariants').value = '';
+ document.getElementById('newBannedCategory').value = '';
+ document.getElementById('newBannedSeverity').value = '';
+ document.getElementById('newBannedReason').value = '';
+ document.getElementById('newBannedReplacement').value = '';
+ // Reload terms
+ loadBannedTerms();
+ } else {
+ const error = await response.json();
+ alert('Failed to add term: ' + error.error);
+ }
+ } catch (error) {
+ console.error('Error adding banned term:', error);
+ alert('Failed to add banned term');
+ }
+ }
+
+ // Add new acceptable term
+ async function addAcceptableTerm() {
+ const term = document.getElementById('newAcceptableTerm').value.trim();
+ const meaning = document.getElementById('newAcceptableMeaning').value.trim();
+ const variants = document.getElementById('newAcceptableVariants').value.trim();
+ const usage = document.getElementById('newAcceptableUsage').value.trim();
+ const notes = document.getElementById('newAcceptableNotes').value.trim();
+
+ if (!term || !meaning) {
+ alert('Term and meaning are required');
+ return;
+ }
+
+ try {
+ const response = await fetch(`${API_URL}/api/acceptable-terms`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${authToken}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ term,
+ meaning,
+ variants,
+ usage,
+ notes
+ })
+ });
+
+ if (response.ok) {
+ alert('Acceptable term added successfully');
+ // Clear form
+ document.getElementById('newAcceptableTerm').value = '';
+ document.getElementById('newAcceptableMeaning').value = '';
+ document.getElementById('newAcceptableVariants').value = '';
+ document.getElementById('newAcceptableUsage').value = '';
+ document.getElementById('newAcceptableNotes').value = '';
+ // Reload terms
+ loadAcceptableTerms();
+ } else {
+ const error = await response.json();
+ alert('Failed to add term: ' + error.error);
+ }
+ } catch (error) {
+ console.error('Error adding acceptable term:', error);
+ alert('Failed to add acceptable term');
+ }
+ }
+
+ // Delete banned term
+ async function deleteBannedTerm(encodedTerm) {
+ const term = decodeURIComponent(encodedTerm);
+ if (!confirm(`Delete banned term "${term}"?`)) return;
+
+ try {
+ const response = await fetch(`${API_URL}/api/banned-terms/${encodeURIComponent(term)}`, {
+ method: 'DELETE',
+ headers: {
+ 'Authorization': `Bearer ${authToken}`
+ }
+ });
+
+ if (response.ok) {
+ alert('Term deleted successfully');
+ loadBannedTerms();
+ } else {
+ alert('Failed to delete term');
+ }
+ } catch (error) {
+ console.error('Error deleting term:', error);
+ alert('Failed to delete term');
+ }
+ }
+
+ // Delete acceptable term
+ async function deleteAcceptableTerm(encodedTerm) {
+ const term = decodeURIComponent(encodedTerm);
+ if (!confirm(`Delete acceptable term "${term}"?`)) return;
+
+ try {
+ const response = await fetch(`${API_URL}/api/acceptable-terms/${encodeURIComponent(term)}`, {
+ method: 'DELETE',
+ headers: {
+ 'Authorization': `Bearer ${authToken}`
+ }
+ });
+
+ if (response.ok) {
+ alert('Term deleted successfully');
+ loadAcceptableTerms();
+ } else {
+ alert('Failed to delete term');
+ }
+ } catch (error) {
+ console.error('Error deleting term:', error);
+ alert('Failed to delete term');
+ }
+ }
+
+ // Filter Banned Terms
+ function filterBannedTerms() {
+ const categoryFilter = document.getElementById('categoryFilter').value;
+ const severityFilter = document.getElementById('severityFilter').value;
+
+ let filtered = allBannedTerms;
+
+ if (categoryFilter !== 'all') {
+ filtered = filtered.filter(term => term.category === categoryFilter);
+ }
+
+ if (severityFilter !== 'all') {
+ filtered = filtered.filter(term => term.severity === severityFilter);
+ }
+
+ displayBannedTerms(filtered);
+ }
+
+ // Load Active Sessions
+ async function loadActiveSessions() {
+ try {
+ const response = await fetch(`${API_URL}/api/live-chats`, {
+ headers: {
+ 'Authorization': `Bearer ${authToken}`
+ }
+ });
+
+ if (response.ok) {
+ const chats = await response.json();
+
+ // Populate active users from existing sessions
+ for (const chat of chats) {
+ if (!activeUsers.has(chat.userId)) {
+ activeUsers.set(chat.userId, {
+ userId: chat.userId,
+ userName: chat.userName || 'Anonymous',
+ userEmail: chat.userEmail || '',
+ ip: chat.ip,
+ mode: chat.mode,
+ lastActivity: chat.lastActivity,
+ isTyping: false,
+ lastMessage: ''
+ });
+ }
+
+ // Also populate live chats
+ if (!liveChats.has(chat.userId)) {
+ liveChats.set(chat.userId, {
+ userId: chat.userId,
+ userName: chat.userName || 'Anonymous',
+ userEmail: chat.userEmail || '',
+ ip: chat.ip,
+ mode: chat.mode,
+ messages: chat.messages || []
+ });
+ }
+ }
+
+ // Update displays
+ updateActiveUsersBar();
+ renderLiveChats();
+ }
+ } catch (error) {
+ console.error('Failed to load active sessions:', error);
+ }
+ }
+
+ // Logout
+ function logout() {
+ localStorage.removeItem('adminToken');
+ if (socket) socket.disconnect();
+ location.reload();
+ }
+
+ // Live Chat Functions
+ const liveChats = new Map();
+ const activeUsers = new Map();
+
+ function updateLiveChat(data) {
+ const { userId, userName, userEmail, ip, location, type, message, response, mode, timestamp } = data;
+
+ // Update or create chat entry
+ if (!liveChats.has(userId)) {
+ liveChats.set(userId, {
+ userId,
+ userName: userName || 'Anonymous',
+ userEmail: userEmail || '',
+ ip,
+ location: location || 'Unknown',
+ mode,
+ messages: []
+ });
+ }
+
+ const chat = liveChats.get(userId);
+ // Update user info if provided
+ if (userName) chat.userName = userName;
+ if (userEmail) chat.userEmail = userEmail;
+ if (location) chat.location = location;
+
+ // Update active users tracking
+ if (!activeUsers.has(userId)) {
+ activeUsers.set(userId, {
+ userId,
+ userName: userName || 'Anonymous',
+ userEmail: userEmail || '',
+ ip,
+ location: location || 'Unknown',
+ mode,
+ lastActivity: timestamp,
+ isTyping: false,
+ lastMessage: ''
+ });
+ }
+
+ const activeUser = activeUsers.get(userId);
+ activeUser.lastActivity = timestamp;
+ // Update user info if provided
+ if (userName) activeUser.userName = userName;
+ if (userEmail) activeUser.userEmail = userEmail;
+ if (location) activeUser.location = location;
+
+ if (type === 'user_typing') {
+ chat.messages.push({
+ type: 'user',
+ text: message,
+ timestamp
+ });
+ activeUser.isTyping = true;
+ activeUser.lastMessage = message;
+ // Clear typing after 3 seconds
+ setTimeout(() => {
+ activeUser.isTyping = false;
+ updateActiveUsersBar();
+ }, 3000);
+ } else if (type === 'bubbe_response') {
+ chat.messages.push({
+ type: 'bubbe',
+ text: response,
+ timestamp
+ });
+ activeUser.isTyping = false;
+ }
+
+ renderLiveChats();
+ updateActiveUsersBar();
+ }
+
+ function updateActiveUsersBar() {
+ const activeUsersLabel = document.getElementById('activeUsersLabel');
+ const activeUsersList = document.getElementById('activeUsersList');
+
+ // Remove inactive users (no activity for 5 minutes)
+ const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
+ for (const [userId, user] of activeUsers.entries()) {
+ if (user.lastActivity < fiveMinutesAgo) {
+ activeUsers.delete(userId);
+ }
+ }
+
+ activeUsersLabel.textContent = `Active Users (${activeUsers.size})`;
+
+ if (activeUsers.size === 0) {
+ activeUsersList.innerHTML = '<span style="opacity: 0.7; font-size: 14px;">No active users currently</span>';
+ return;
+ }
+
+ let html = '';
+ for (const [userId, user] of activeUsers) {
+ const displayName = user.userName || 'Anonymous';
+ const initial = displayName.charAt(0).toUpperCase();
+ const timeSince = getTimeSince(user.lastActivity);
+ const displayEmail = user.userEmail ? ` (${user.userEmail})` : '';
+
+ const locationInfo = user.location ? ` | 📍 ${user.location}` : '';
+ html += `
+ <div class="active-user-badge" onclick="scrollToUserChat('${userId}')" title="${displayName}${displayEmail}${locationInfo} | IP: ${user.ip}">
+ <div class="user-avatar">${initial}</div>
+ <div class="user-info-text">
+ <span class="user-email-text">${displayName}</span>
+ <span class="user-status-text">
+ ${user.isTyping ?
+ 'Typing<span class="typing-indicator"><span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span></span>' :
+ timeSince}
+ </span>
+ </div>
+ </div>
+ `;
+ }
+
+ activeUsersList.innerHTML = html;
+ }
+
+ function getTimeSince(timestamp) {
+ const now = new Date();
+ const then = new Date(timestamp);
+ const diffMs = now - then;
+ const diffSecs = Math.floor(diffMs / 1000);
+
+ if (diffSecs < 60) return 'Just now';
+ if (diffSecs < 120) return '1 min ago';
+ if (diffSecs < 3600) return `${Math.floor(diffSecs / 60)} mins ago`;
+ return `${Math.floor(diffSecs / 3600)} hours ago`;
+ }
+
+ window.scrollToUserChat = function(userId) {
+ // Scroll to the user's chat in the live chat container
+ const chatContainer = document.getElementById('liveChatsList');
+ if (chatContainer) {
+ chatContainer.scrollIntoView({ behavior: 'smooth' });
+ }
+ }
+
+ function renderLiveChats() {
+ const container = document.getElementById('liveChatsList');
+ const activeCount = liveChats.size;
+
+ document.getElementById('liveUserCount').textContent = `${activeCount} active`;
+
+ if (activeCount === 0) {
+ container.innerHTML = '<p style="color: #999; text-align: center;">No active conversations</p>';
+ return;
+ }
+
+ let html = '';
+ for (const [userId, chat] of liveChats) {
+ const lastMessages = chat.messages.slice(-5); // Show last 5 messages
+ const displayName = chat.userName || 'Anonymous';
+ const displayEmail = chat.userEmail ? ` (${chat.userEmail})` : '';
+
+ html += `
+ <div style="margin-bottom: 20px; border-bottom: 1px solid #eee; padding-bottom: 10px;">
+ <div style="font-weight: bold; margin-bottom: 10px; color: #667eea;">
+ 👤 ${displayName}${displayEmail} | IP: ${chat.ip} | 📍 ${chat.location || 'Unknown'} | Mode: ${chat.mode}
+ </div>
+ `;
+
+ for (const msg of lastMessages) {
+ const className = msg.type === 'user' ? 'chat-user' : 'chat-bubbe';
+ const label = msg.type === 'user' ? `👤 ${displayName}` : '👵 Bubbe';
+ const messageId = `msg-${userId}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+
+ html += `
+ <div class="chat-message ${className}" id="${messageId}">
+ <div class="chat-header">
+ <strong>${label}</strong>
+ <span>${new Date(msg.timestamp).toLocaleTimeString()}</span>
+ </div>
+ <div class="chat-text">${msg.text}</div>
+ <div class="social-share-buttons" style="margin-top: 8px; display: flex; gap: 8px;">
+ <button onclick="shareToTwitter('${messageId}', '${label}', '${encodeURIComponent(msg.text).replace(/'/g, "\\'")}')"
+ class="social-btn twitter-btn" title="Share to Twitter/X">
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
+ <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
+ </svg>
+ </button>
+ <button onclick="shareToFacebook('${messageId}', '${encodeURIComponent(msg.text).replace(/'/g, "\\'")}')"
+ class="social-btn facebook-btn" title="Share to Facebook">
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
+ <path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
+ </svg>
+ </button>
+ <button onclick="shareToLinkedIn('${messageId}', '${encodeURIComponent(msg.text).replace(/'/g, "\\'")}')"
+ class="social-btn linkedin-btn" title="Share to LinkedIn">
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
+ <path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
+ </svg>
+ </button>
+ <button onclick="copyMessage('${messageId}', '${label}', '${encodeURIComponent(msg.text).replace(/'/g, "\\'")}')"
+ class="social-btn copy-btn" title="Copy to clipboard">
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
+ </svg>
+ </button>
+ <button onclick="flagMessage('${messageId}', '${userId}', '${encodeURIComponent(msg.text).replace(/'/g, "\\'")}')"
+ class="social-btn flag-btn" title="Flag as problematic">
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
+ <path d="M14.4 6L14 4H5v17h2v-7h5.6l.4 2h7V6z"/>
+ </svg>
+ </button>
+ </div>
+ </div>
+ `;
+ }
+
+ html += '</div>';
+ }
+
+ container.innerHTML = html;
+ container.scrollTop = container.scrollHeight;
+ }
+
+ // User Management Functions
+ async function deactivateUser(email) {
+ if (!confirm(`Deactivate user ${email}?`)) return;
+
+ const reason = prompt('Enter reason for deactivation:');
+ if (!reason) return;
+
+ try {
+ const response = await fetch(`${API_URL}/api/users/${email}/status`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${authToken}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ active: false, reason })
+ });
+
+ if (response.ok) {
+ alert(`User ${email} has been deactivated`);
+ loadUsers();
+ } else {
+ alert('Failed to deactivate user');
+ }
+ } catch (error) {
+ console.error('Error:', error);
+ alert('Failed to deactivate user');
+ }
+ }
+
+ async function activateUser(email) {
+ if (!confirm(`Reactivate user ${email}?`)) return;
+
+ try {
+ const response = await fetch(`${API_URL}/api/users/${email}/status`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${authToken}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ active: true })
+ });
+
+ if (response.ok) {
+ alert(`User ${email} has been reactivated`);
+ loadUsers();
+ } else {
+ alert('Failed to activate user');
+ }
+ } catch (error) {
+ console.error('Error:', error);
+ alert('Failed to activate user');
+ }
+ }
+
+ async function deleteUser(email) {
+ if (!confirm(`⚠️ PERMANENTLY delete user ${email}? This cannot be undone!`)) return;
+ if (!confirm(`Are you absolutely sure? All user data will be deleted.`)) return;
+
+ const reason = prompt('Enter reason for deletion:');
+ if (!reason) return;
+
+ try {
+ const response = await fetch(`${API_URL}/api/users/${email}`, {
+ method: 'DELETE',
+ headers: {
+ 'Authorization': `Bearer ${authToken}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ reason })
+ });
+
+ if (response.ok) {
+ alert(`User ${email} has been permanently deleted`);
+ loadUsers();
+ } else {
+ alert('Failed to delete user');
+ }
+ } catch (error) {
+ console.error('Error:', error);
+ alert('Failed to delete user');
+ }
+ }
+
+ async function blockUser(email) {
+ if (!confirm(`Block user ${email}?`)) return;
+
+ const reason = prompt('Enter reason for blocking:');
+ if (!reason) return;
+
+ try {
+ const response = await fetch(`${API_URL}/api/users/${email}/block`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${authToken}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ blocked: true, reason })
+ });
+
+ if (response.ok) {
+ alert(`User ${email} has been blocked`);
+ loadUsers();
+ } else {
+ alert('Failed to block user');
+ }
+ } catch (error) {
+ console.error('Error:', error);
+ alert('Failed to block user');
+ }
+ }
+
+ async function unblockUser(email) {
+ if (!confirm(`Unblock user ${email}?`)) return;
+
+ try {
+ const response = await fetch(`${API_URL}/api/users/${email}/block`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${authToken}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ blocked: false })
+ });
+
+ if (response.ok) {
+ alert(`User ${email} has been unblocked`);
+ loadUsers();
+ } else {
+ alert('Failed to unblock user');
+ }
+ } catch (error) {
+ console.error('Error:', error);
+ alert('Failed to unblock user');
+ }
+ }
+
+ // Initialize Google Sign-In
+ function initializeGoogleSignIn() {
+ if (typeof google !== 'undefined' && google.accounts && google.accounts.id) {
+ google.accounts.id.initialize({
+ client_id: '493480746821-mhpjc0glkkogcf845ubi2hkhksoqj39k.apps.googleusercontent.com',
+ callback: handleGoogleLogin,
+ auto_select: false,
+ cancel_on_tap_outside: false,
+ ux_mode: 'popup'
+ });
+
+ google.accounts.id.renderButton(
+ document.querySelector('.g_id_signin'),
+ {
+ theme: 'outline',
+ size: 'large',
+ text: 'signin_with',
+ shape: 'rectangular',
+ width: 250
+ }
+ );
+ } else {
+ // Retry after a delay if Google library hasn't loaded yet
+ setTimeout(initializeGoogleSignIn, 500);
+ }
+ }
+
+ // Social Media Sharing Functions with unique Bubbe intros
+ const bubbeIntros = [
+ "Bubbe tells it like it is",
+ "Bubbe's got no filter",
+ "Bubbe says what you're thinking",
+ "Bubbe drops truth bombs",
+ "Bubbe keeps it real",
+ "Bubbe's wisdom hits different",
+ "Bubbe doesn't sugarcoat",
+ "Bubbe serves reality checks",
+ "Bubbe's brutal honesty",
+ "Bubbe speaks facts",
+ "Bubbe's savage advice",
+ "Bubbe pulls no punches",
+ "Bubbe's hot take",
+ "Bubbe's unfiltered thoughts",
+ "Bubbe lays down the law",
+ "Bubbe's tough love",
+ "Bubbe's reality therapy",
+ "Bubbe's truth serum",
+ "Bubbe's straight talk",
+ "Bubbe's no-nonsense wisdom"
+ ];
+
+ let introIndex = 0;
+
+ function getUniqueIntro() {
+ const intro = bubbeIntros[introIndex % bubbeIntros.length];
+ introIndex++;
+ return intro;
+ }
+
+ function shareToTwitter(messageId, label, text) {
+ const intro = getUniqueIntro();
+ const decodedText = decodeURIComponent(text);
+ const preview = decodedText.substring(0, 140);
+ const shareText = `${intro}: "${preview}..." #BubbeAI #Bubbe #NoFilter #JewishGrandma\n\nVisit Bubbe.AI`;
+ const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}`;
+ window.open(url, '_blank', 'width=600,height=400');
+ }
+
+ function shareToFacebook(messageId, text) {
+ const intro = getUniqueIntro();
+ const decodedText = decodeURIComponent(text);
+ const preview = decodedText.substring(0, 250);
+ const shareText = `${intro}:\n\n"${preview}..."\n\n#BubbeAI #Bubbe #JewishWisdom #NoFilter\n\nVisit Bubbe.AI`;
+ const url = `https://www.facebook.com/sharer/sharer.php?quote=${encodeURIComponent(shareText)}`;
+ window.open(url, '_blank', 'width=600,height=400');
+ }
+
+ function shareToLinkedIn(messageId, text) {
+ const intro = getUniqueIntro();
+ const decodedText = decodeURIComponent(text);
+ const preview = decodedText.substring(0, 200);
+ const shareText = `${intro}:\n\n"${preview}..."\n\nAI-powered wisdom with zero sugar-coating. #BubbeAI #Bubbe #CustomerExperience #AIInnovation\n\nVisit Bubbe.AI`;
+ const url = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent('http://45.61.58.125:3011')}&summary=${encodeURIComponent(shareText)}`;
+ window.open(url, '_blank', 'width=600,height=400');
+ }
+
+ function copyMessage(messageId, label, text) {
+ const fullText = `${label}: ${decodeURIComponent(text)}`;
+ navigator.clipboard.writeText(fullText).then(() => {
+ const btn = event.target;
+ const originalText = btn.innerHTML;
+ btn.innerHTML = '✓ Copied!';
+ btn.style.background = '#28a745';
+ setTimeout(() => {
+ btn.innerHTML = originalText;
+ btn.style.background = '';
+ }, 2000);
+ }).catch(err => {
+ alert('Failed to copy text');
+ });
+ }
+
+ function flagMessage(messageId, userId, text) {
+ if (confirm('Flag this message as inappropriate or concerning?')) {
+ const flaggedMessages = JSON.parse(localStorage.getItem('flaggedMessages') || '[]');
+
+ flaggedMessages.push({
+ id: messageId,
+ userId: userId,
+ text: decodeURIComponent(text),
+ timestamp: new Date().toISOString(),
+ flaggedBy: 'Admin'
+ });
+
+ localStorage.setItem('flaggedMessages', JSON.stringify(flaggedMessages));
+
+ const messageElement = document.querySelector(`[data-message-id="${messageId}"]`);
+ if (messageElement) {
+ messageElement.style.border = '2px solid #dc3545';
+ messageElement.style.background = '#fff5f5';
+ }
+
+ showNotification('Message flagged for review', 'warning');
+ updateFlaggedCount();
+ }
+ }
+
+ function showNotification(message, type = 'info') {
+ const notification = document.createElement('div');
+ notification.style.cssText = `
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ padding: 15px 20px;
+ background: ${type === 'warning' ? '#ff9800' : type === 'error' ? '#dc3545' : '#28a745'};
+ color: white;
+ border-radius: 8px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.2);
+ z-index: 10000;
+ animation: slideIn 0.3s;
+ `;
+ notification.textContent = message;
+ document.body.appendChild(notification);
+
+ setTimeout(() => {
+ notification.style.animation = 'slideOut 0.3s';
+ setTimeout(() => notification.remove(), 300);
+ }, 3000);
+ }
+
+ function updateFlaggedCount() {
+ const flaggedMessages = JSON.parse(localStorage.getItem('flaggedMessages') || '[]');
+ const badges = document.querySelectorAll('.flagged-count');
+ badges.forEach(badge => {
+ badge.textContent = flaggedMessages.length;
+ badge.style.display = flaggedMessages.length > 0 ? 'inline-block' : 'none';
+ });
+ }
+
+ function scrollToUserChat(userId) {
+ const chatSection = document.getElementById('liveChatsDisplay');
+ if (chatSection) {
+ const userChat = chatSection.querySelector(`[data-user-id="${userId}"]`);
+ if (userChat) {
+ userChat.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ userChat.style.background = '#fffbf0';
+ setTimeout(() => {
+ userChat.style.background = '';
+ }, 2000);
+ }
+ }
+ }
+
+ // Export functions
+ function exportAlerts() {
+ const alerts = [];
+ const alertElements = document.querySelectorAll('#alertsList .alert-item');
+ alertElements.forEach(el => {
+ alerts.push({
+ type: el.querySelector('.alert-badge')?.textContent || '',
+ user: el.querySelector('strong')?.textContent || '',
+ message: el.querySelector('div[style*="color: #666"]')?.textContent || '',
+ timestamp: el.querySelector('div[style*="color: #999"]')?.textContent || ''
+ });
+ });
+
+ if (alerts.length === 0) {
+ showNotification('No alerts to export', 'warning');
+ return;
+ }
+
+ const csv = convertToCSV(alerts);
+ downloadCSV(csv, 'alerts_export.csv');
+ showNotification('Alerts exported successfully', 'success');
+ }
+
+ function exportUsers() {
+ fetch(`${API_URL}/api/users`, {
+ headers: {
+ 'Authorization': `Bearer ${authToken}`
+ }
+ })
+ .then(res => res.json())
+ .then(users => {
+ const csv = convertToCSV(users);
+ downloadCSV(csv, 'users_export.csv');
+ showNotification('Users exported successfully', 'success');
+ })
+ .catch(err => {
+ showNotification('Failed to export users', 'error');
+ });
+ }
+
+ function exportChats() {
+ const chats = Array.from(liveChats.values());
+ if (chats.length === 0) {
+ showNotification('No chats to export', 'warning');
+ return;
+ }
+
+ const exportData = chats.map(chat => ({
+ userId: chat.userId,
+ userName: chat.userName,
+ email: chat.userEmail,
+ ip: chat.ip,
+ location: chat.location,
+ messageCount: chat.messages.length,
+ lastMessage: chat.messages[chat.messages.length - 1]?.text || ''
+ }));
+ const csv = convertToCSV(exportData);
+ downloadCSV(csv, 'chats_export.csv');
+ showNotification('Chats exported successfully', 'success');
+ }
+
+ function convertToCSV(data) {
+ if (!data || data.length === 0) return '';
+
+ const headers = Object.keys(data[0]);
+ const csvHeaders = headers.join(',');
+
+ const csvRows = data.map(row =>
+ headers.map(header => {
+ const value = row[header];
+ const escaped = String(value || '').replace(/"/g, '""');
+ return escaped.includes(',') || escaped.includes('\n') ? `"${escaped}"` : escaped;
+ }).join(',')
+ );
+
+ return [csvHeaders, ...csvRows].join('\n');
+ }
+
+ function downloadCSV(csv, filename) {
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
+ const link = document.createElement('a');
+ link.href = URL.createObjectURL(blob);
+ link.download = filename;
+ link.click();
+ }
+
+ function clearAlerts() {
+ if (confirm('Clear all alerts? This cannot be undone.')) {
+ document.getElementById('alertsList').innerHTML = '<div style="color: #666; text-align: center;">No alerts to display</div>';
+ document.getElementById('totalAlerts').textContent = '0';
+ showNotification('Alerts cleared', 'info');
+ }
+ }
+
+ function clearChats() {
+ if (confirm('Clear all chat history? This cannot be undone.')) {
+ liveChats.clear();
+ document.getElementById('liveChatsDisplay').innerHTML = '<div style="color: #666; text-align: center;">No active chats</div>';
+ showNotification('Chat history cleared', 'info');
+ }
+ }
+
+ function refreshDashboard() {
+ showNotification('Refreshing dashboard...', 'info');
+ loadDashboard();
+ }
+
+ function refreshAlerts() {
+ loadAlerts();
+ showNotification('Alerts refreshed', 'success');
+ }
+
+ function refreshUsers() {
+ loadUsers();
+ showNotification('Users refreshed', 'success');
+ }
+
+ function refreshChats() {
+ loadActiveSessions();
+ showNotification('Chats refreshed', 'success');
+ }
+
+ function generateReport() {
+ const report = {
+ generatedAt: new Date().toISOString(),
+ totalUsers: parseInt(document.getElementById('totalUsers').textContent) || 0,
+ activeUsers: parseInt(document.getElementById('activeUsers').textContent) || 0,
+ totalAlerts: parseInt(document.getElementById('totalAlerts').textContent) || 0,
+ blockedUsers: parseInt(document.getElementById('blockedUsers').textContent) || 0,
+ flaggedMessages: JSON.parse(localStorage.getItem('flaggedMessages') || '[]').length,
+ bannedTermsCount: allBannedTerms.length,
+ acceptableTermsCount: document.querySelectorAll('#acceptableTermsList > div').length
+ };
+
+ const reportText = `
+DEAR BUBBE ADMIN REPORT
+Generated: ${new Date().toLocaleString()}
+========================================
+
+STATISTICS:
+- Total Users: ${report.totalUsers}
+- Active Users: ${report.activeUsers}
+- Total Alerts: ${report.totalAlerts}
+- Blocked Users: ${report.blockedUsers}
+- Flagged Messages: ${report.flaggedMessages}
+
+CONTENT MODERATION:
+- Banned Terms: ${report.bannedTermsCount}
+- Acceptable Terms: ${report.acceptableTermsCount}
+
+========================================
+ `.trim();
+
+ const blob = new Blob([reportText], { type: 'text/plain;charset=utf-8;' });
+ const link = document.createElement('a');
+ link.href = URL.createObjectURL(blob);
+ link.download = `bubbe_report_${Date.now()}.txt`;
+ link.click();
+
+ showNotification('Report generated successfully', 'success');
+ }
+
+ // Check for existing token on load
+ window.onload = function() {
+ const token = localStorage.getItem('adminToken');
+ if (token) {
+ authToken = token;
+ // Auto-login with stored token
+ // You might want to validate the token first
+ document.getElementById('loginScreen').style.display = 'none';
+ document.getElementById('dashboard').classList.add('active');
+ initDashboard();
+ }
+
+ // Initialize Google Sign-In
+ initializeGoogleSignIn();
+ }
+ </script>
+
+ <style>
+ @keyframes slideIn {
+ from {
+ opacity: 0;
+ transform: translateY(-20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
+ /* Live Chat Styles */
+ @keyframes pulse {
+ 0% { opacity: 1; }
+ 50% { opacity: 0.5; }
+ 100% { opacity: 1; }
+ }
+
+ .chat-message {
+ margin: 10px 0;
+ padding: 10px;
+ border-radius: 8px;
+ animation: slideIn 0.3s ease;
+ }
+
+ .chat-user {
+ background: #e3f2fd;
+ border-left: 3px solid #2196f3;
+ }
+
+ .chat-bubbe {
+ background: #fff3e0;
+ border-left: 3px solid #ff9800;
+ }
+
+ .chat-header {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 5px;
+ font-size: 12px;
+ color: #666;
+ }
+
+ .chat-text {
+ color: #333;
+ word-wrap: break-word;
+ }
+
+ .user-actions {
+ display: flex;
+ gap: 5px;
+ }
+
+ .action-btn {
+ padding: 4px 8px;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 12px;
+ transition: all 0.3s;
+ }
+
+ .btn-deactivate {
+ background: #ffc107;
+ color: white;
+ }
+
+ .btn-delete {
+ background: #dc3545;
+ color: white;
+ }
+
+ .btn-activate {
+ background: #28a745;
+ color: white;
+ }
+
+ .btn-block {
+ background: #6c757d;
+ color: white;
+ }
+
+ .action-btn:hover {
+ opacity: 0.8;
+ transform: scale(1.05);
+ }
+
+ /* Active Users Bar Styles */
+ .active-user-badge {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: rgba(255, 255, 255, 0.2);
+ padding: 5px 12px;
+ border-radius: 20px;
+ white-space: nowrap;
+ animation: slideIn 0.3s ease;
+ }
+
+ .active-user-badge:hover {
+ background: rgba(255, 255, 255, 0.3);
+ cursor: pointer;
+ }
+
+ .user-avatar {
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ background: white;
+ color: #667eea;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: bold;
+ font-size: 12px;
+ }
+
+ .user-info-text {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ }
+
+ .user-email-text {
+ font-size: 12px;
+ font-weight: 500;
+ }
+
+ .user-status-text {
+ font-size: 10px;
+ opacity: 0.9;
+ }
+
+ @keyframes fadeInOut {
+ 0%, 100% { opacity: 0.5; }
+ 50% { opacity: 1; }
+ }
+
+ .typing-indicator {
+ display: inline-flex;
+ gap: 2px;
+ margin-left: 5px;
+ }
+
+ .typing-dot {
+ width: 3px;
+ height: 3px;
+ background: white;
+ border-radius: 50%;
+ animation: bounce 1.4s infinite;
+ }
+
+ .typing-dot:nth-child(2) {
+ animation-delay: 0.2s;
+ }
+
+ .typing-dot:nth-child(3) {
+ animation-delay: 0.4s;
+ }
+
+ @keyframes bounce {
+ 0%, 60%, 100% {
+ transform: translateY(0);
+ }
+ 30% {
+ transform: translateY(-5px);
+ }
+ }
+
+ /* Add padding to bottom of container to avoid overlap with active users bar */
+ .container {
+ padding-bottom: 80px;
+ }
+
+ /* Social Share Buttons */
+ .social-share-buttons {
+ opacity: 0.7;
+ transition: opacity 0.3s;
+ }
+
+ .chat-message:hover .social-share-buttons {
+ opacity: 1;
+ }
+
+ .social-btn {
+ padding: 4px 8px;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.3s;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ }
+
+ .twitter-btn {
+ background: #1DA1F2;
+ color: white;
+ }
+
+ .twitter-btn:hover {
+ background: #1a91da;
+ transform: scale(1.1);
+ }
+
+ .facebook-btn {
+ background: #4267B2;
+ color: white;
+ }
+
+ .facebook-btn:hover {
+ background: #365899;
+ transform: scale(1.1);
+ }
+
+ .linkedin-btn {
+ background: #0077B5;
+ color: white;
+ }
+
+ .linkedin-btn:hover {
+ background: #006097;
+ transform: scale(1.1);
+ }
+
+ .copy-btn {
+ background: #6c757d;
+ color: white;
+ }
+
+ .copy-btn:hover {
+ background: #5a6268;
+ transform: scale(1.1);
+ }
+
+ .flag-btn {
+ background: #dc3545;
+ color: white;
+ }
+
+ .flag-btn:hover {
+ background: #c82333;
+ transform: scale(1.1);
+ }
+
+ .toast-notification {
+ position: fixed;
+ bottom: 100px;
+ right: 20px;
+ background: #333;
+ color: white;
+ padding: 12px 20px;
+ border-radius: 8px;
+ z-index: 10000;
+ animation: slideInRight 0.3s ease;
+ display: none;
+ }
+
+ .toast-notification.show {
+ display: block;
+ }
+
+ @keyframes slideInRight {
+ from {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+ }
+ </style>
+</body>
+</html>
\ No newline at end of file
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..23032ce
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,47 @@
+{
+ "name": "dear-bubbe-admin-frontend",
+ "version": "0.1.0",
+ "private": true,
+ "dependencies": {
+ "@testing-library/jest-dom": "^5.16.4",
+ "@testing-library/react": "^13.3.0",
+ "@testing-library/user-event": "^13.5.0",
+ "axios": "^1.6.0",
+ "chart.js": "^4.4.0",
+ "chartjs-adapter-date-fns": "^3.0.0",
+ "date-fns": "^2.30.0",
+ "react": "^18.2.0",
+ "react-chartjs-2": "^5.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.8.1",
+ "react-scripts": "5.0.1",
+ "socket.io-client": "^4.7.4",
+ "web-vitals": "^2.1.4",
+ "recharts": "^2.8.0"
+ },
+ "scripts": {
+ "start": "react-scripts start",
+ "build": "react-scripts build",
+ "test": "react-scripts test",
+ "eject": "react-scripts eject"
+ },
+ "eslintConfig": {
+ "extends": [
+ "react-app",
+ "react-app/jest"
+ ]
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ },
+ "proxy": "http://localhost:3015"
+}
\ No newline at end of file
diff --git a/frontend/public/index.html b/frontend/public/index.html
new file mode 100644
index 0000000..b967ba3
--- /dev/null
+++ b/frontend/public/index.html
@@ -0,0 +1,81 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <meta name="theme-color" content="#000000" />
+ <meta
+ name="description"
+ content="Dear Bubbe Admin Dashboard - Real-time Monitoring and Alert System"
+ />
+ <title>Dear Bubbe Admin Dashboard</title>
+ <style>
+ body {
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
+ sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ background-color: #f8fafc;
+ }
+ code {
+ font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
+ monospace;
+ }
+
+ /* Loading spinner */
+ .loading-spinner {
+ display: inline-block;
+ width: 20px;
+ height: 20px;
+ border: 3px solid #f3f3f3;
+ border-top: 3px solid #3498db;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ }
+
+ @keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+ }
+
+ /* Alert styles */
+ .alert {
+ padding: 12px 16px;
+ margin: 8px 0;
+ border-radius: 4px;
+ border-left: 4px solid;
+ }
+
+ .alert-critical {
+ background-color: #fee;
+ border-left-color: #dc2626;
+ color: #dc2626;
+ }
+
+ .alert-high {
+ background-color: #fef3cd;
+ border-left-color: #f59e0b;
+ color: #f59e0b;
+ }
+
+ .alert-medium {
+ background-color: #e1f5fe;
+ border-left-color: #3b82f6;
+ color: #3b82f6;
+ }
+
+ .alert-low {
+ background-color: #f0f9ff;
+ border-left-color: #6b7280;
+ color: #6b7280;
+ }
+ </style>
+ </head>
+ <body>
+ <noscript>You need to enable JavaScript to run this app.</noscript>
+ <div id="root"></div>
+ </body>
+</html>
\ No newline at end of file
diff --git a/frontend/src/App.css b/frontend/src/App.css
new file mode 100644
index 0000000..f47abcd
--- /dev/null
+++ b/frontend/src/App.css
@@ -0,0 +1,579 @@
+/* Dear Bubbe Admin Dashboard Styles */
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ background-color: #f8fafc;
+ color: #334155;
+ line-height: 1.6;
+}
+
+.app {
+ display: flex;
+ min-height: 100vh;
+}
+
+/* Loading */
+.loading-container {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ min-height: 100vh;
+ background-color: #f8fafc;
+}
+
+.loading-container p {
+ margin-top: 16px;
+ color: #64748b;
+ font-size: 16px;
+}
+
+/* Sidebar */
+.sidebar {
+ width: 250px;
+ background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
+ color: white;
+ flex-shrink: 0;
+ box-shadow: 4px 0 8px rgba(0, 0, 0, 0.1);
+ position: relative;
+ z-index: 10;
+}
+
+.sidebar-header {
+ padding: 24px 20px;
+ border-bottom: 1px solid #475569;
+}
+
+.sidebar-title {
+ font-size: 20px;
+ font-weight: 700;
+ color: #f1f5f9;
+ text-align: center;
+}
+
+.sidebar-subtitle {
+ font-size: 12px;
+ color: #94a3b8;
+ text-align: center;
+ margin-top: 4px;
+}
+
+.nav-menu {
+ padding: 20px 0;
+}
+
+.nav-item {
+ display: block;
+ padding: 12px 20px;
+ color: #cbd5e1;
+ text-decoration: none;
+ transition: all 0.2s;
+ border-left: 3px solid transparent;
+}
+
+.nav-item:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+ color: white;
+ border-left-color: #3b82f6;
+}
+
+.nav-item.active {
+ background-color: rgba(59, 130, 246, 0.2);
+ color: white;
+ border-left-color: #3b82f6;
+}
+
+.nav-icon {
+ margin-right: 10px;
+ width: 16px;
+ display: inline-block;
+}
+
+/* Main Content */
+.main-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+/* Header */
+.header {
+ background: white;
+ padding: 16px 24px;
+ border-bottom: 1px solid #e2e8f0;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.header-left h1 {
+ font-size: 24px;
+ font-weight: 600;
+ color: #1e293b;
+}
+
+.header-stats {
+ display: flex;
+ gap: 24px;
+ margin-top: 8px;
+}
+
+.stat-item {
+ font-size: 14px;
+ color: #64748b;
+}
+
+.stat-value {
+ font-weight: 600;
+ color: #1e293b;
+}
+
+.header-right {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.user-info {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 8px 12px;
+ background-color: #f1f5f9;
+ border-radius: 8px;
+}
+
+.user-avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #3b82f6, #1d4ed8);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-weight: 600;
+ font-size: 14px;
+}
+
+.user-name {
+ font-weight: 500;
+ color: #1e293b;
+}
+
+.logout-btn {
+ padding: 8px 16px;
+ background-color: #ef4444;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 14px;
+ transition: background-color 0.2s;
+}
+
+.logout-btn:hover {
+ background-color: #dc2626;
+}
+
+/* Content */
+.content {
+ flex: 1;
+ padding: 24px;
+ overflow-y: auto;
+ background-color: #f8fafc;
+}
+
+/* Cards */
+.card {
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+ overflow: hidden;
+}
+
+.card-header {
+ padding: 20px 24px;
+ border-bottom: 1px solid #e2e8f0;
+ background-color: #f8fafc;
+}
+
+.card-title {
+ font-size: 18px;
+ font-weight: 600;
+ color: #1e293b;
+ margin: 0;
+}
+
+.card-content {
+ padding: 24px;
+}
+
+/* Dashboard Grid */
+.dashboard-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 24px;
+ margin-bottom: 24px;
+}
+
+.stat-cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 20px;
+ margin-bottom: 24px;
+}
+
+.stat-card {
+ background: white;
+ padding: 24px;
+ border-radius: 8px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+ border-left: 4px solid #3b82f6;
+}
+
+.stat-card.critical {
+ border-left-color: #ef4444;
+}
+
+.stat-card.warning {
+ border-left-color: #f59e0b;
+}
+
+.stat-card.success {
+ border-left-color: #10b981;
+}
+
+.stat-label {
+ font-size: 14px;
+ color: #64748b;
+ margin-bottom: 8px;
+}
+
+.stat-value-large {
+ font-size: 32px;
+ font-weight: 700;
+ color: #1e293b;
+ line-height: 1;
+}
+
+/* Buttons */
+.btn {
+ padding: 8px 16px;
+ border-radius: 6px;
+ border: none;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 500;
+ transition: all 0.2s;
+ text-decoration: none;
+ display: inline-block;
+ text-align: center;
+}
+
+.btn-primary {
+ background-color: #3b82f6;
+ color: white;
+}
+
+.btn-primary:hover {
+ background-color: #2563eb;
+}
+
+.btn-danger {
+ background-color: #ef4444;
+ color: white;
+}
+
+.btn-danger:hover {
+ background-color: #dc2626;
+}
+
+.btn-secondary {
+ background-color: #6b7280;
+ color: white;
+}
+
+.btn-secondary:hover {
+ background-color: #4b5563;
+}
+
+.btn-small {
+ padding: 6px 12px;
+ font-size: 12px;
+}
+
+/* Tables */
+.table-container {
+ overflow-x: auto;
+}
+
+.table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.table th,
+.table td {
+ padding: 12px;
+ text-align: left;
+ border-bottom: 1px solid #e2e8f0;
+}
+
+.table th {
+ background-color: #f8fafc;
+ font-weight: 600;
+ color: #374151;
+ font-size: 14px;
+}
+
+.table td {
+ font-size: 14px;
+}
+
+.table tr:hover {
+ background-color: #f8fafc;
+}
+
+/* Status badges */
+.badge {
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 12px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.badge-critical {
+ background-color: #fef2f2;
+ color: #dc2626;
+}
+
+.badge-high {
+ background-color: #fef3c7;
+ color: #d97706;
+}
+
+.badge-medium {
+ background-color: #dbeafe;
+ color: #2563eb;
+}
+
+.badge-low {
+ background-color: #f0fdf4;
+ color: #16a34a;
+}
+
+.badge-active {
+ background-color: #dcfce7;
+ color: #16a34a;
+}
+
+.badge-blocked {
+ background-color: #fee2e2;
+ color: #dc2626;
+}
+
+/* Forms */
+.form-group {
+ margin-bottom: 16px;
+}
+
+.form-label {
+ display: block;
+ margin-bottom: 4px;
+ font-weight: 500;
+ color: #374151;
+}
+
+.form-input {
+ width: 100%;
+ padding: 8px 12px;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ font-size: 14px;
+ transition: border-color 0.2s;
+}
+
+.form-input:focus {
+ outline: none;
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+}
+
+.form-select {
+ width: 100%;
+ padding: 8px 12px;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ font-size: 14px;
+ background-color: white;
+ cursor: pointer;
+}
+
+/* Live Alert Modal */
+.live-alert-modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+ animation: fadeIn 0.2s ease-out;
+}
+
+.live-alert-content {
+ background: white;
+ border-radius: 8px;
+ padding: 24px;
+ max-width: 600px;
+ width: 90%;
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
+ animation: slideIn 0.2s ease-out;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes slideIn {
+ from { transform: scale(0.9) translateY(-20px); opacity: 0; }
+ to { transform: scale(1) translateY(0); opacity: 1; }
+}
+
+.alert-header {
+ display: flex;
+ justify-content: between;
+ align-items: center;
+ margin-bottom: 16px;
+}
+
+.alert-title {
+ font-size: 20px;
+ font-weight: 600;
+ color: #dc2626;
+}
+
+.alert-close {
+ background: none;
+ border: none;
+ font-size: 24px;
+ cursor: pointer;
+ color: #6b7280;
+ padding: 0;
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.alert-details {
+ margin-bottom: 20px;
+}
+
+.alert-detail-row {
+ display: flex;
+ margin-bottom: 8px;
+}
+
+.alert-detail-label {
+ font-weight: 500;
+ color: #374151;
+ width: 120px;
+ flex-shrink: 0;
+}
+
+.alert-detail-value {
+ color: #1f2937;
+}
+
+.alert-actions {
+ display: flex;
+ gap: 12px;
+ justify-content: flex-end;
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .app {
+ flex-direction: column;
+ }
+
+ .sidebar {
+ width: 100%;
+ height: auto;
+ }
+
+ .nav-menu {
+ display: flex;
+ padding: 0;
+ overflow-x: auto;
+ }
+
+ .nav-item {
+ white-space: nowrap;
+ border-left: none;
+ border-bottom: 3px solid transparent;
+ }
+
+ .nav-item:hover,
+ .nav-item.active {
+ border-left: none;
+ border-bottom-color: #3b82f6;
+ }
+
+ .header {
+ flex-direction: column;
+ gap: 12px;
+ align-items: flex-start;
+ }
+
+ .header-stats {
+ flex-wrap: wrap;
+ }
+
+ .content {
+ padding: 16px;
+ }
+
+ .stat-cards {
+ grid-template-columns: 1fr;
+ }
+
+ .dashboard-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Print styles */
+@media print {
+ .sidebar,
+ .header,
+ .btn {
+ display: none;
+ }
+
+ .content {
+ padding: 0;
+ }
+
+ .card {
+ box-shadow: none;
+ border: 1px solid #e2e8f0;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/App.js b/frontend/src/App.js
new file mode 100644
index 0000000..a75a77d
--- /dev/null
+++ b/frontend/src/App.js
@@ -0,0 +1,261 @@
+import React, { useState, useEffect } from 'react'
+import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
+import axios from 'axios'
+import io from 'socket.io-client'
+
+// Components
+import Login from './components/Login'
+import Dashboard from './components/Dashboard'
+import AlertsPage from './components/AlertsPage'
+import UsersPage from './components/UsersPage'
+import ReportsPage from './components/ReportsPage'
+import MonitoringPage from './components/MonitoringPage'
+import Sidebar from './components/Sidebar'
+import Header from './components/Header'
+import LiveAlertModal from './components/LiveAlertModal'
+
+// Styles
+import './App.css'
+
+// API configuration
+const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://45.61.58.125:3015'
+const SOCKET_URL = process.env.REACT_APP_SOCKET_URL || 'http://45.61.58.125:3015'
+
+class App extends React.Component {
+ constructor(props) {
+ super(props)
+
+ this.state = {
+ isAuthenticated: false,
+ user: null,
+ loading: true,
+ socket: null,
+ liveAlert: null,
+ stats: {},
+ error: null
+ }
+
+ // Set up axios defaults
+ axios.defaults.baseURL = API_BASE_URL
+
+ // Add token to requests if available
+ const token = localStorage.getItem('adminToken')
+ if (token) {
+ axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
+ }
+ }
+
+ async componentDidMount() {
+ await this.checkAuthStatus()
+ }
+
+ componentWillUnmount() {
+ if (this.state.socket) {
+ this.state.socket.disconnect()
+ }
+ }
+
+ checkAuthStatus = async () => {
+ try {
+ const token = localStorage.getItem('adminToken')
+
+ if (!token) {
+ this.setState({ loading: false, isAuthenticated: false })
+ return
+ }
+
+ const response = await axios.get('/api/auth/me')
+
+ if (response.data.success) {
+ this.setState({
+ isAuthenticated: true,
+ user: response.data.user,
+ loading: false
+ })
+
+ this.connectWebSocket(token)
+ } else {
+ localStorage.removeItem('adminToken')
+ this.setState({ loading: false, isAuthenticated: false })
+ }
+ } catch (error) {
+ console.error('Auth check failed:', error)
+ localStorage.removeItem('adminToken')
+ this.setState({
+ loading: false,
+ isAuthenticated: false,
+ error: 'Authentication failed'
+ })
+ }
+ }
+
+ connectWebSocket = (token) => {
+ try {
+ const socket = io(SOCKET_URL, {
+ auth: { token },
+ transports: ['websocket', 'polling']
+ })
+
+ socket.on('connect', () => {
+ console.log('Connected to admin socket')
+ socket.emit('authenticate', token)
+ })
+
+ socket.on('auth_error', (error) => {
+ console.error('Socket auth error:', error)
+ this.handleLogout()
+ })
+
+ socket.on('stats', (stats) => {
+ this.setState({ stats })
+ })
+
+ socket.on('new_alert', (alert) => {
+ console.log('🚨 New alert received:', alert)
+ this.setState({ liveAlert: alert })
+
+ // Play alert sound if critical
+ if (alert.severity === 'critical' || alert.severity === 'high') {
+ this.playAlertSound()
+ }
+ })
+
+ socket.on('system_alert', (alert) => {
+ console.log('⚠️ System alert:', alert)
+ })
+
+ socket.on('threat_response', (data) => {
+ console.log('🚨 Threat response sent:', data)
+ })
+
+ socket.on('disconnect', () => {
+ console.log('Disconnected from admin socket')
+ })
+
+ this.setState({ socket })
+
+ } catch (error) {
+ console.error('Failed to connect WebSocket:', error)
+ }
+ }
+
+ playAlertSound = () => {
+ // Create a simple alert tone
+ try {
+ const audioContext = new (window.AudioContext || window.webkitAudioContext)()
+ const oscillator = audioContext.createOscillator()
+ const gainNode = audioContext.createGain()
+
+ oscillator.connect(gainNode)
+ gainNode.connect(audioContext.destination)
+
+ oscillator.frequency.value = 800
+ oscillator.type = 'square'
+
+ gainNode.gain.setValueAtTime(0.3, audioContext.currentTime)
+ gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.5)
+
+ oscillator.start(audioContext.currentTime)
+ oscillator.stop(audioContext.currentTime + 0.5)
+ } catch (error) {
+ console.log('Could not play alert sound:', error)
+ }
+ }
+
+ handleLogin = async (credentials) => {
+ try {
+ const response = await axios.post('/api/auth/login', credentials)
+
+ if (response.data.success) {
+ const { token, user } = response.data
+
+ localStorage.setItem('adminToken', token)
+ axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
+
+ this.setState({
+ isAuthenticated: true,
+ user,
+ error: null
+ })
+
+ this.connectWebSocket(token)
+
+ return { success: true }
+ } else {
+ return { success: false, error: response.data.error }
+ }
+ } catch (error) {
+ const errorMessage = error.response?.data?.error || 'Login failed'
+ return { success: false, error: errorMessage }
+ }
+ }
+
+ handleLogout = () => {
+ localStorage.removeItem('adminToken')
+ delete axios.defaults.headers.common['Authorization']
+
+ if (this.state.socket) {
+ this.state.socket.disconnect()
+ }
+
+ this.setState({
+ isAuthenticated: false,
+ user: null,
+ socket: null,
+ stats: {},
+ error: null
+ })
+ }
+
+ closeLiveAlert = () => {
+ this.setState({ liveAlert: null })
+ }
+
+ render() {
+ const { isAuthenticated, user, loading, liveAlert, stats, error } = this.state
+
+ if (loading) {
+ return (
+ <div className="loading-container">
+ <div className="loading-spinner"></div>
+ <p>Loading Dear Bubbe Admin Dashboard...</p>
+ </div>
+ )
+ }
+
+ if (!isAuthenticated) {
+ return <Login onLogin={this.handleLogin} error={error} />
+ }
+
+ return (
+ <Router>
+ <div className="app">
+ <Sidebar />
+ <div className="main-content">
+ <Header user={user} stats={stats} onLogout={this.handleLogout} />
+ <div className="content">
+ <Routes>
+ <Route path="/" element={<Dashboard stats={stats} />} />
+ <Route path="/dashboard" element={<Dashboard stats={stats} />} />
+ <Route path="/alerts" element={<AlertsPage />} />
+ <Route path="/users" element={<UsersPage />} />
+ <Route path="/monitoring" element={<MonitoringPage />} />
+ <Route path="/reports" element={<ReportsPage />} />
+ <Route path="*" element={<Navigate to="/dashboard" replace />} />
+ </Routes>
+ </div>
+ </div>
+
+ {liveAlert && (
+ <LiveAlertModal
+ alert={liveAlert}
+ onClose={this.closeLiveAlert}
+ />
+ )}
+ </div>
+ </Router>
+ )
+ }
+}
+
+export default App
\ No newline at end of file
diff --git a/frontend/src/components/AlertsPage.js b/frontend/src/components/AlertsPage.js
new file mode 100644
index 0000000..9cab940
--- /dev/null
+++ b/frontend/src/components/AlertsPage.js
@@ -0,0 +1,470 @@
+import React, { useState, useEffect } from 'react'
+import axios from 'axios'
+
+const AlertsPage = () => {
+ const [alerts, setAlerts] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [filters, setFilters] = useState({
+ type: '',
+ severity: '',
+ status: '',
+ timeframe: '24h'
+ })
+ const [selectedAlert, setSelectedAlert] = useState(null)
+ const [stats, setStats] = useState({})
+
+ useEffect(() => {
+ fetchAlerts()
+ fetchAlertStats()
+ }, [filters])
+
+ const fetchAlerts = async () => {
+ try {
+ setError(null)
+ const params = new URLSearchParams()
+ Object.entries(filters).forEach(([key, value]) => {
+ if (value) params.append(key, value)
+ })
+
+ const response = await axios.get(`/api/alerts?${params.toString()}`)
+
+ if (response.data.success) {
+ setAlerts(response.data.alerts || [])
+ }
+ } catch (error) {
+ console.error('Failed to fetch alerts:', error)
+ setError('Failed to load alerts')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const fetchAlertStats = async () => {
+ try {
+ const response = await axios.get(`/api/alerts/stats?timeframe=${filters.timeframe}`)
+ if (response.data.success) {
+ setStats(response.data)
+ }
+ } catch (error) {
+ console.error('Failed to fetch alert stats:', error)
+ }
+ }
+
+ const handleStatusUpdate = async (alertId, status, reviewNotes = '') => {
+ try {
+ await axios.put(`/api/alerts/${alertId}/status`, {
+ status,
+ reviewNotes
+ })
+
+ // Refresh alerts
+ fetchAlerts()
+ setSelectedAlert(null)
+ } catch (error) {
+ console.error('Failed to update alert status:', error)
+ alert('Failed to update alert status')
+ }
+ }
+
+ const handleBlockUser = async (alertId) => {
+ if (!confirm('Are you sure you want to block this user?')) return
+
+ try {
+ await axios.post(`/api/alerts/${alertId}/block-user`, {
+ reason: 'Blocked due to security violation'
+ })
+
+ alert('User has been blocked successfully')
+ fetchAlerts()
+ setSelectedAlert(null)
+ } catch (error) {
+ console.error('Failed to block user:', error)
+ alert('Failed to block user')
+ }
+ }
+
+ const getSeverityColor = (severity) => {
+ const colors = {
+ critical: '#ef4444',
+ high: '#f59e0b',
+ medium: '#3b82f6',
+ low: '#6b7280'
+ }
+ return colors[severity] || '#6b7280'
+ }
+
+ const getTypeIcon = (type) => {
+ const icons = {
+ antisemitism: '✡️',
+ racism: '⚠️',
+ harassment: '🚫',
+ spam: '📧',
+ system: '⚙️'
+ }
+ return icons[type] || '❗'
+ }
+
+ const formatTimestamp = (timestamp) => {
+ return new Date(timestamp).toLocaleString()
+ }
+
+ if (loading) {
+ return (
+ <div style={{ display: 'flex', justifyContent: 'center', padding: '40px' }}>
+ <div className="loading-spinner"></div>
+ <span style={{ marginLeft: '12px' }}>Loading alerts...</span>
+ </div>
+ )
+ }
+
+ return (
+ <div>
+ <div style={{
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: '24px'
+ }}>
+ <h2 style={{ fontSize: '24px', fontWeight: '600', color: '#1e293b' }}>
+ Security Alerts
+ </h2>
+ <button
+ onClick={() => { fetchAlerts(); fetchAlertStats(); }}
+ className="btn btn-primary"
+ >
+ Refresh
+ </button>
+ </div>
+
+ {/* Alert Stats */}
+ <div className="stat-cards" style={{ marginBottom: '24px' }}>
+ <div className="stat-card critical">
+ <div className="stat-label">Critical Alerts</div>
+ <div className="stat-value-large" style={{ color: '#ef4444' }}>
+ {stats.summary?.critical || 0}
+ </div>
+ </div>
+ <div className="stat-card warning">
+ <div className="stat-label">High Priority</div>
+ <div className="stat-value-large" style={{ color: '#f59e0b' }}>
+ {stats.summary?.high || 0}
+ </div>
+ </div>
+ <div className="stat-card">
+ <div className="stat-label">Antisemitism</div>
+ <div className="stat-value-large" style={{ color: '#dc2626' }}>
+ {stats.summary?.antisemitism || 0}
+ </div>
+ </div>
+ <div className="stat-card">
+ <div className="stat-label">Racism</div>
+ <div className="stat-value-large" style={{ color: '#dc2626' }}>
+ {stats.summary?.racism || 0}
+ </div>
+ </div>
+ </div>
+
+ {/* Filters */}
+ <div className="card" style={{ marginBottom: '24px' }}>
+ <div className="card-header">
+ <h3 className="card-title">Filter Alerts</h3>
+ </div>
+ <div className="card-content">
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '16px' }}>
+ <div>
+ <label className="form-label">Type</label>
+ <select
+ className="form-select"
+ value={filters.type}
+ onChange={(e) => setFilters({...filters, type: e.target.value})}
+ >
+ <option value="">All Types</option>
+ <option value="antisemitism">Antisemitism</option>
+ <option value="racism">Racism</option>
+ <option value="harassment">Harassment</option>
+ <option value="spam">Spam</option>
+ <option value="system">System</option>
+ </select>
+ </div>
+
+ <div>
+ <label className="form-label">Severity</label>
+ <select
+ className="form-select"
+ value={filters.severity}
+ onChange={(e) => setFilters({...filters, severity: e.target.value})}
+ >
+ <option value="">All Severities</option>
+ <option value="critical">Critical</option>
+ <option value="high">High</option>
+ <option value="medium">Medium</option>
+ <option value="low">Low</option>
+ </select>
+ </div>
+
+ <div>
+ <label className="form-label">Status</label>
+ <select
+ className="form-select"
+ value={filters.status}
+ onChange={(e) => setFilters({...filters, status: e.target.value})}
+ >
+ <option value="">All Status</option>
+ <option value="active">Active</option>
+ <option value="reviewed">Reviewed</option>
+ <option value="resolved">Resolved</option>
+ <option value="false_positive">False Positive</option>
+ </select>
+ </div>
+
+ <div>
+ <label className="form-label">Timeframe</label>
+ <select
+ className="form-select"
+ value={filters.timeframe}
+ onChange={(e) => setFilters({...filters, timeframe: e.target.value})}
+ >
+ <option value="1h">Last Hour</option>
+ <option value="24h">Last 24 Hours</option>
+ <option value="7d">Last 7 Days</option>
+ <option value="30d">Last 30 Days</option>
+ </select>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* Alerts Table */}
+ <div className="card">
+ <div className="card-header">
+ <h3 className="card-title">
+ Alerts ({alerts.length})
+ </h3>
+ </div>
+ <div className="card-content">
+ {error && (
+ <div style={{ color: '#ef4444', textAlign: 'center', padding: '20px' }}>
+ {error}
+ </div>
+ )}
+
+ {alerts.length === 0 ? (
+ <div style={{ textAlign: 'center', padding: '40px', color: '#64748b' }}>
+ {filters.type || filters.severity || filters.status ?
+ 'No alerts match the current filters' :
+ 'No alerts found'
+ }
+ </div>
+ ) : (
+ <div className="table-container">
+ <table className="table">
+ <thead>
+ <tr>
+ <th>Type</th>
+ <th>Severity</th>
+ <th>User/IP</th>
+ <th>Message</th>
+ <th>Location</th>
+ <th>Time</th>
+ <th>Status</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ {alerts.map((alert) => (
+ <tr key={alert._id}>
+ <td>
+ <div style={{ display: 'flex', alignItems: 'center' }}>
+ <span style={{ marginRight: '8px' }}>
+ {getTypeIcon(alert.type)}
+ </span>
+ {alert.type}
+ </div>
+ </td>
+ <td>
+ <span
+ className={`badge badge-${alert.severity}`}
+ style={{
+ backgroundColor: getSeverityColor(alert.severity) + '20',
+ color: getSeverityColor(alert.severity),
+ border: `1px solid ${getSeverityColor(alert.severity)}40`
+ }}
+ >
+ {alert.severity}
+ </span>
+ </td>
+ <td>
+ <div style={{ fontSize: '12px' }}>
+ <div>{alert.userInfo?.userId || 'Unknown'}</div>
+ <div style={{ color: '#64748b' }}>
+ {alert.userInfo?.ip || 'Unknown IP'}
+ </div>
+ </div>
+ </td>
+ <td>
+ <div style={{
+ maxWidth: '200px',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ fontSize: '14px'
+ }}>
+ {alert.content?.message || 'No message'}
+ </div>
+ {alert.violation?.match && (
+ <div style={{
+ fontSize: '12px',
+ color: '#ef4444',
+ fontWeight: '500',
+ marginTop: '4px'
+ }}>
+ Matched: "{alert.violation.match}"
+ </div>
+ )}
+ </td>
+ <td style={{ fontSize: '12px', color: '#64748b' }}>
+ {alert.location?.city && alert.location?.country ?
+ `${alert.location.city}, ${alert.location.country}` :
+ 'Unknown'
+ }
+ </td>
+ <td style={{ fontSize: '12px', color: '#64748b' }}>
+ {formatTimestamp(alert.timestamp)}
+ </td>
+ <td>
+ <span className={`badge badge-${alert.status === 'active' ? 'critical' : 'medium'}`}>
+ {alert.status}
+ </span>
+ </td>
+ <td>
+ <div style={{ display: 'flex', gap: '8px' }}>
+ <button
+ onClick={() => setSelectedAlert(alert)}
+ className="btn btn-small btn-secondary"
+ >
+ View
+ </button>
+ {alert.status === 'active' && (
+ <button
+ onClick={() => handleStatusUpdate(alert._id, 'reviewed')}
+ className="btn btn-small btn-primary"
+ >
+ Mark Reviewed
+ </button>
+ )}
+ </div>
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* Alert Detail Modal */}
+ {selectedAlert && (
+ <div className="live-alert-modal" onClick={() => setSelectedAlert(null)}>
+ <div className="live-alert-content" onClick={e => e.stopPropagation()}>
+ <div className="alert-header">
+ <h3 className="alert-title">
+ {getTypeIcon(selectedAlert.type)} Alert Details
+ </h3>
+ <button
+ className="alert-close"
+ onClick={() => setSelectedAlert(null)}
+ >
+ ×
+ </button>
+ </div>
+
+ <div className="alert-details">
+ <div className="alert-detail-row">
+ <span className="alert-detail-label">Type:</span>
+ <span className="alert-detail-value">{selectedAlert.type}</span>
+ </div>
+ <div className="alert-detail-row">
+ <span className="alert-detail-label">Severity:</span>
+ <span className={`badge badge-${selectedAlert.severity}`}>
+ {selectedAlert.severity}
+ </span>
+ </div>
+ <div className="alert-detail-row">
+ <span className="alert-detail-label">User ID:</span>
+ <span className="alert-detail-value">{selectedAlert.userInfo?.userId || 'Unknown'}</span>
+ </div>
+ <div className="alert-detail-row">
+ <span className="alert-detail-label">IP Address:</span>
+ <span className="alert-detail-value">{selectedAlert.userInfo?.ip || 'Unknown'}</span>
+ </div>
+ <div className="alert-detail-row">
+ <span className="alert-detail-label">Location:</span>
+ <span className="alert-detail-value">
+ {selectedAlert.location?.city && selectedAlert.location?.country ?
+ `${selectedAlert.location.city}, ${selectedAlert.location.state}, ${selectedAlert.location.country}` :
+ 'Unknown'
+ }
+ </span>
+ </div>
+ <div className="alert-detail-row">
+ <span className="alert-detail-label">Message:</span>
+ <span className="alert-detail-value" style={{
+ fontFamily: 'monospace',
+ backgroundColor: '#f8fafc',
+ padding: '8px',
+ borderRadius: '4px',
+ display: 'block',
+ marginTop: '4px'
+ }}>
+ {selectedAlert.content?.message || 'No message'}
+ </span>
+ </div>
+ {selectedAlert.violation?.match && (
+ <div className="alert-detail-row">
+ <span className="alert-detail-label">Violation:</span>
+ <span className="alert-detail-value" style={{
+ color: '#ef4444',
+ fontWeight: '500'
+ }}>
+ "{selectedAlert.violation.match}"
+ </span>
+ </div>
+ )}
+ <div className="alert-detail-row">
+ <span className="alert-detail-label">Timestamp:</span>
+ <span className="alert-detail-value">{formatTimestamp(selectedAlert.timestamp)}</span>
+ </div>
+ </div>
+
+ <div className="alert-actions">
+ <button
+ onClick={() => handleStatusUpdate(selectedAlert._id, 'false_positive', 'Marked as false positive')}
+ className="btn btn-secondary"
+ >
+ False Positive
+ </button>
+ <button
+ onClick={() => handleStatusUpdate(selectedAlert._id, 'resolved', 'Resolved by admin')}
+ className="btn btn-primary"
+ >
+ Resolve
+ </button>
+ {selectedAlert.userInfo?.userId && (
+ <button
+ onClick={() => handleBlockUser(selectedAlert._id)}
+ className="btn btn-danger"
+ >
+ Block User
+ </button>
+ )}
+ </div>
+ </div>
+ </div>
+ )}
+ </div>
+ )
+}
+
+export default AlertsPage
\ No newline at end of file
diff --git a/frontend/src/components/Dashboard.js b/frontend/src/components/Dashboard.js
new file mode 100644
index 0000000..b574360
--- /dev/null
+++ b/frontend/src/components/Dashboard.js
@@ -0,0 +1,328 @@
+import React, { useState, useEffect } from 'react'
+import axios from 'axios'
+
+const Dashboard = ({ stats }) => {
+ const [dashboardData, setDashboardData] = useState({
+ userActivity: {},
+ conversations: {},
+ alerts: {},
+ system: {}
+ })
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [refreshInterval, setRefreshInterval] = useState(null)
+
+ useEffect(() => {
+ fetchDashboardData()
+
+ // Set up auto-refresh every 30 seconds
+ const interval = setInterval(fetchDashboardData, 30000)
+ setRefreshInterval(interval)
+
+ return () => {
+ if (interval) clearInterval(interval)
+ }
+ }, [])
+
+ const fetchDashboardData = async () => {
+ try {
+ setError(null)
+ const response = await axios.get('/api/monitoring/dashboard')
+
+ if (response.data.success) {
+ setDashboardData(response.data)
+ }
+ } catch (error) {
+ console.error('Failed to fetch dashboard data:', error)
+ setError('Failed to load dashboard data')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const formatUptime = (seconds) => {
+ const days = Math.floor(seconds / 86400)
+ const hours = Math.floor((seconds % 86400) / 3600)
+ const minutes = Math.floor((seconds % 3600) / 60)
+
+ if (days > 0) return `${days}d ${hours}h ${minutes}m`
+ if (hours > 0) return `${hours}h ${minutes}m`
+ return `${minutes}m`
+ }
+
+ const getSystemHealthColor = (health) => {
+ switch (health) {
+ case 'healthy': return '#10b981'
+ case 'warning': return '#f59e0b'
+ case 'critical': return '#ef4444'
+ default: return '#6b7280'
+ }
+ }
+
+ const getSystemHealthText = (memUsage, cpuUsage) => {
+ if (memUsage > 90 || cpuUsage > 90) return 'Critical'
+ if (memUsage > 70 || cpuUsage > 70) return 'Warning'
+ return 'Healthy'
+ }
+
+ if (loading) {
+ return (
+ <div style={{
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ height: '400px'
+ }}>
+ <div className="loading-spinner"></div>
+ <span style={{ marginLeft: '12px' }}>Loading dashboard...</span>
+ </div>
+ )
+ }
+
+ if (error) {
+ return (
+ <div className="card">
+ <div className="card-content">
+ <div style={{
+ color: '#ef4444',
+ textAlign: 'center',
+ padding: '40px'
+ }}>
+ <h3>⚠️ {error}</h3>
+ <button
+ onClick={fetchDashboardData}
+ className="btn btn-primary"
+ style={{ marginTop: '16px' }}
+ >
+ Retry
+ </button>
+ </div>
+ </div>
+ </div>
+ )
+ }
+
+ const { userActivity, conversations, alerts, system } = dashboardData
+ const systemHealth = getSystemHealthText(system.memoryUsage, system.cpuUsage)
+
+ return (
+ <div>
+ <div style={{
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: '24px'
+ }}>
+ <h2 style={{ fontSize: '24px', fontWeight: '600', color: '#1e293b' }}>
+ Dashboard Overview
+ </h2>
+ <div style={{ fontSize: '14px', color: '#64748b' }}>
+ Last updated: {dashboardData.timestamp ? new Date(dashboardData.timestamp).toLocaleTimeString() : 'Unknown'}
+ </div>
+ </div>
+
+ {/* Key Metrics */}
+ <div className="stat-cards">
+ <div className="stat-card">
+ <div className="stat-label">Active Users</div>
+ <div className="stat-value-large" style={{ color: '#3b82f6' }}>
+ {userActivity.activeUsers || 0}
+ </div>
+ <div style={{ fontSize: '12px', color: '#64748b', marginTop: '4px' }}>
+ {userActivity.activeSessions || 0} active sessions
+ </div>
+ </div>
+
+ <div className="stat-card">
+ <div className="stat-label">Conversations Today</div>
+ <div className="stat-value-large" style={{ color: '#10b981' }}>
+ {conversations.today || 0}
+ </div>
+ <div style={{ fontSize: '12px', color: '#64748b', marginTop: '4px' }}>
+ {conversations.lastHour || 0} in last hour
+ </div>
+ </div>
+
+ <div className={`stat-card ${alerts.critical > 0 ? 'critical' : alerts.today > 0 ? 'warning' : 'success'}`}>
+ <div className="stat-label">Security Alerts</div>
+ <div className="stat-value-large" style={{
+ color: alerts.critical > 0 ? '#ef4444' : alerts.today > 0 ? '#f59e0b' : '#10b981'
+ }}>
+ {alerts.today || 0}
+ </div>
+ <div style={{ fontSize: '12px', color: '#64748b', marginTop: '4px' }}>
+ {alerts.critical || 0} critical alerts
+ </div>
+ </div>
+
+ <div className={`stat-card ${systemHealth === 'Critical' ? 'critical' : systemHealth === 'Warning' ? 'warning' : 'success'}`}>
+ <div className="stat-label">System Health</div>
+ <div className="stat-value-large" style={{
+ color: getSystemHealthColor(systemHealth.toLowerCase())
+ }}>
+ {systemHealth}
+ </div>
+ <div style={{ fontSize: '12px', color: '#64748b', marginTop: '4px' }}>
+ {system.memoryUsage || 0}% memory, {system.cpuUsage || 0}% CPU
+ </div>
+ </div>
+ </div>
+
+ {/* Detailed Cards */}
+ <div className="dashboard-grid">
+ {/* User Activity */}
+ <div className="card">
+ <div className="card-header">
+ <h3 className="card-title">User Activity</h3>
+ </div>
+ <div className="card-content">
+ <div style={{ display: 'grid', gap: '12px' }}>
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>Total Users:</span>
+ <strong>{userActivity.totalUsers || 0}</strong>
+ </div>
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>New Users Today:</span>
+ <strong style={{ color: '#10b981' }}>{userActivity.newUsersToday || 0}</strong>
+ </div>
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>Blocked Users:</span>
+ <strong style={{ color: '#ef4444' }}>{userActivity.blockedUsers || 0}</strong>
+ </div>
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>Active Sessions:</span>
+ <strong style={{ color: '#3b82f6' }}>{userActivity.activeSessions || 0}</strong>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* System Status */}
+ <div className="card">
+ <div className="card-header">
+ <h3 className="card-title">System Status</h3>
+ </div>
+ <div className="card-content">
+ <div style={{ display: 'grid', gap: '12px' }}>
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>Memory Usage:</span>
+ <strong style={{
+ color: system.memoryUsage > 80 ? '#ef4444' : system.memoryUsage > 60 ? '#f59e0b' : '#10b981'
+ }}>
+ {system.memoryUsage || 0}%
+ </strong>
+ </div>
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>CPU Usage:</span>
+ <strong style={{
+ color: system.cpuUsage > 80 ? '#ef4444' : system.cpuUsage > 60 ? '#f59e0b' : '#10b981'
+ }}>
+ {system.cpuUsage || 0}%
+ </strong>
+ </div>
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>System Uptime:</span>
+ <strong>{system.uptime ? formatUptime(system.uptime) : 'Unknown'}</strong>
+ </div>
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>Load Average:</span>
+ <strong>{system.loadAverage ? system.loadAverage.toFixed(2) : '0.00'}</strong>
+ </div>
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>Process Memory:</span>
+ <strong>{system.processMemoryMB || 0} MB</strong>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* Recent Alerts Preview */}
+ <div className="card">
+ <div className="card-header">
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
+ <h3 className="card-title">Security Overview</h3>
+ <a href="/alerts" className="btn btn-small btn-secondary">View All</a>
+ </div>
+ </div>
+ <div className="card-content">
+ <div style={{ display: 'grid', gap: '12px' }}>
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>Critical Alerts:</span>
+ <span className="badge badge-critical">{alerts.critical || 0}</span>
+ </div>
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>Total Today:</span>
+ <span className="badge badge-medium">{alerts.today || 0}</span>
+ </div>
+ <div style={{ fontSize: '12px', color: '#64748b', textAlign: 'center', marginTop: '8px' }}>
+ {alerts.today === 0 ? '✅ All clear!' : '⚠️ Active monitoring in progress'}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* Quick Actions */}
+ <div className="card">
+ <div className="card-header">
+ <h3 className="card-title">Quick Actions</h3>
+ </div>
+ <div className="card-content">
+ <div style={{ display: 'grid', gap: '12px' }}>
+ <a href="/alerts" className="btn btn-primary" style={{ textDecoration: 'none' }}>
+ 🚨 View All Alerts
+ </a>
+ <a href="/users" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
+ 👥 Manage Users
+ </a>
+ <a href="/monitoring" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
+ 📊 Live Monitoring
+ </a>
+ <a href="/reports" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
+ 📋 Generate Reports
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* Status Banner */}
+ <div style={{
+ marginTop: '24px',
+ padding: '16px 24px',
+ borderRadius: '8px',
+ background: systemHealth === 'Healthy' ? '#f0f9ff' :
+ systemHealth === 'Warning' ? '#fffbeb' : '#fef2f2',
+ border: `1px solid ${
+ systemHealth === 'Healthy' ? '#3b82f6' :
+ systemHealth === 'Warning' ? '#f59e0b' : '#ef4444'
+ }`,
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center'
+ }}>
+ <div>
+ <strong style={{
+ color: systemHealth === 'Healthy' ? '#1d4ed8' :
+ systemHealth === 'Warning' ? '#d97706' : '#dc2626'
+ }}>
+ {systemHealth === 'Healthy' ? '✅' :
+ systemHealth === 'Warning' ? '⚠️' : '🚨'} System Status: {systemHealth}
+ </strong>
+ <div style={{ fontSize: '14px', color: '#64748b', marginTop: '4px' }}>
+ Dear Bubbe AI is {systemHealth === 'Healthy' ? 'operating normally' :
+ systemHealth === 'Warning' ? 'experiencing minor issues' :
+ 'experiencing critical issues'}
+ </div>
+ </div>
+ <button
+ onClick={fetchDashboardData}
+ className="btn btn-small btn-secondary"
+ >
+ Refresh
+ </button>
+ </div>
+ </div>
+ )
+}
+
+export default Dashboard
\ No newline at end of file
diff --git a/frontend/src/components/Header.js b/frontend/src/components/Header.js
new file mode 100644
index 0000000..0e7ce7f
--- /dev/null
+++ b/frontend/src/components/Header.js
@@ -0,0 +1,57 @@
+import React from 'react'
+
+const Header = ({ user, stats, onLogout }) => {
+ const getHealthIcon = (stats) => {
+ const memUsage = stats.system?.memoryUsage || 0
+ const cpuUsage = stats.system?.cpuUsage || 0
+
+ if (memUsage > 90 || cpuUsage > 90) return '🔴'
+ if (memUsage > 70 || cpuUsage > 70) return '🟡'
+ return '🟢'
+ }
+
+ return (
+ <div className="header">
+ <div className="header-left">
+ <h1>Admin Dashboard</h1>
+ <div className="header-stats">
+ <div className="stat-item">
+ <span className="stat-value">{stats.userActivity?.activeUsers || 0}</span> active users
+ </div>
+ <div className="stat-item">
+ <span className="stat-value">{stats.alerts?.today || 0}</span> alerts today
+ </div>
+ <div className="stat-item">
+ {getHealthIcon(stats)} system status
+ </div>
+ <div className="stat-item">
+ <span className="stat-value">{stats.conversations?.today || 0}</span> conversations
+ </div>
+ </div>
+ </div>
+
+ <div className="header-right">
+ <div className="user-info">
+ <div className="user-avatar">
+ {user?.username?.charAt(0)?.toUpperCase() || 'A'}
+ </div>
+ <div>
+ <div className="user-name">{user?.username || 'Admin'}</div>
+ <div style={{ fontSize: '12px', color: '#64748b' }}>
+ {user?.role || 'Administrator'}
+ </div>
+ </div>
+ </div>
+
+ <button
+ onClick={onLogout}
+ className="logout-btn"
+ >
+ Logout
+ </button>
+ </div>
+ </div>
+ )
+}
+
+export default Header
\ No newline at end of file
diff --git a/frontend/src/components/LiveAlertModal.js b/frontend/src/components/LiveAlertModal.js
new file mode 100644
index 0000000..44c766c
--- /dev/null
+++ b/frontend/src/components/LiveAlertModal.js
@@ -0,0 +1,241 @@
+import React from 'react'
+
+const LiveAlertModal = ({ alert, onClose }) => {
+ if (!alert) return null
+
+ const getSeverityColor = (severity) => {
+ const colors = {
+ critical: '#dc2626',
+ high: '#d97706',
+ medium: '#2563eb',
+ low: '#6b7280'
+ }
+ return colors[severity] || '#6b7280'
+ }
+
+ const getTypeIcon = (type) => {
+ const icons = {
+ antisemitism: '✡️🚨',
+ racism: '⚠️🚨',
+ harassment: '🚫🚨',
+ spam: '📧🚨',
+ system: '⚙️🚨'
+ }
+ return icons[type] || '❗🚨'
+ }
+
+ const formatLocation = (location) => {
+ if (!location) return 'Unknown Location'
+
+ const parts = []
+ if (location.city) parts.push(location.city)
+ if (location.state) parts.push(location.state)
+ if (location.country) parts.push(location.country)
+
+ return parts.length > 0 ? parts.join(', ') : 'Unknown Location'
+ }
+
+ return (
+ <div className="live-alert-modal">
+ <div className="live-alert-content" style={{
+ border: `3px solid ${getSeverityColor(alert.severity)}`,
+ animation: 'pulse 1s infinite'
+ }}>
+ <div className="alert-header">
+ <h2 style={{
+ color: getSeverityColor(alert.severity),
+ margin: 0,
+ fontSize: '24px',
+ fontWeight: '700'
+ }}>
+ {getTypeIcon(alert.type)} LIVE ALERT
+ </h2>
+ <button
+ className="alert-close"
+ onClick={onClose}
+ style={{ fontSize: '28px' }}
+ >
+ ×
+ </button>
+ </div>
+
+ <div style={{
+ background: '#fef2f2',
+ padding: '16px',
+ borderRadius: '8px',
+ border: '1px solid #fecaca',
+ marginBottom: '20px'
+ }}>
+ <div style={{
+ fontSize: '18px',
+ fontWeight: '600',
+ color: '#dc2626',
+ marginBottom: '8px',
+ textTransform: 'uppercase',
+ letterSpacing: '1px'
+ }}>
+ {alert.severity} {alert.type} DETECTED
+ </div>
+ <div style={{ fontSize: '14px', color: '#7f1d1d' }}>
+ Immediate attention required • Real-time detection
+ </div>
+ </div>
+
+ <div className="alert-details">
+ <div className="alert-detail-row">
+ <span className="alert-detail-label">🕒 Time:</span>
+ <span className="alert-detail-value">
+ {new Date(alert.timestamp).toLocaleString()}
+ </span>
+ </div>
+
+ <div className="alert-detail-row">
+ <span className="alert-detail-label">👤 User ID:</span>
+ <span className="alert-detail-value" style={{ fontFamily: 'monospace' }}>
+ {alert.userInfo?.userId || 'Unknown'}
+ </span>
+ </div>
+
+ <div className="alert-detail-row">
+ <span className="alert-detail-label">🌐 IP Address:</span>
+ <span className="alert-detail-value" style={{ fontFamily: 'monospace' }}>
+ {alert.userInfo?.ip || 'Unknown'}
+ </span>
+ </div>
+
+ <div className="alert-detail-row">
+ <span className="alert-detail-label">📍 Location:</span>
+ <span className="alert-detail-value">
+ {formatLocation(alert.location)}
+ </span>
+ </div>
+
+ <div className="alert-detail-row">
+ <span className="alert-detail-label">💬 Message:</span>
+ <div style={{
+ background: '#f8fafc',
+ border: '1px solid #e2e8f0',
+ borderRadius: '6px',
+ padding: '12px',
+ marginTop: '8px',
+ fontFamily: 'monospace',
+ fontSize: '14px',
+ wordBreak: 'break-word'
+ }}>
+ {alert.message || alert.content?.message || 'No message available'}
+ </div>
+ </div>
+
+ {alert.violation?.match && (
+ <div className="alert-detail-row">
+ <span className="alert-detail-label">🎯 Violation:</span>
+ <div style={{
+ background: '#fef2f2',
+ border: '1px solid #fecaca',
+ borderRadius: '6px',
+ padding: '12px',
+ marginTop: '8px',
+ color: '#dc2626',
+ fontWeight: '600',
+ fontFamily: 'monospace'
+ }}>
+ "{alert.violation.match}"
+ </div>
+ </div>
+ )}
+
+ {alert.violation?.category && (
+ <div className="alert-detail-row">
+ <span className="alert-detail-label">🏷️ Category:</span>
+ <span className="alert-detail-value" style={{
+ background: getSeverityColor(alert.severity) + '20',
+ color: getSeverityColor(alert.severity),
+ padding: '4px 8px',
+ borderRadius: '4px',
+ fontSize: '12px',
+ fontWeight: '600',
+ textTransform: 'uppercase'
+ }}>
+ {alert.violation.category}
+ </span>
+ </div>
+ )}
+ </div>
+
+ <div style={{
+ background: '#fffbeb',
+ border: '1px solid #fed7aa',
+ borderRadius: '8px',
+ padding: '16px',
+ marginBottom: '20px'
+ }}>
+ <div style={{
+ fontSize: '14px',
+ fontWeight: '600',
+ color: '#92400e',
+ marginBottom: '8px'
+ }}>
+ ⚡ Automated Actions
+ </div>
+ <ul style={{
+ margin: 0,
+ paddingLeft: '20px',
+ color: '#78350f',
+ fontSize: '13px'
+ }}>
+ <li>User session flagged for review</li>
+ <li>Alert logged in security database</li>
+ {alert.severity === 'critical' && (
+ <li style={{ fontWeight: '600', color: '#dc2626' }}>
+ Threat response message sent to user
+ </li>
+ )}
+ <li>Real-time notification sent to admin dashboard</li>
+ </ul>
+ </div>
+
+ <div className="alert-actions">
+ <button
+ onClick={onClose}
+ className="btn btn-secondary"
+ >
+ Acknowledge
+ </button>
+ <button
+ onClick={() => {
+ window.open('/alerts', '_blank')
+ onClose()
+ }}
+ className="btn btn-primary"
+ >
+ View All Alerts
+ </button>
+ {alert.userInfo?.userId && (
+ <button
+ onClick={() => {
+ window.open(`/users?search=${alert.userInfo.userId}`, '_blank')
+ onClose()
+ }}
+ className="btn btn-danger"
+ >
+ View User
+ </button>
+ )}
+ </div>
+
+ <div style={{
+ marginTop: '16px',
+ fontSize: '12px',
+ color: '#6b7280',
+ textAlign: 'center',
+ fontStyle: 'italic'
+ }}>
+ Alert ID: {alert.id || alert._id} •
+ Detection confidence: {(alert.violation?.confidence * 100) || 100}%
+ </div>
+ </div>
+ </div>
+ )
+}
+
+export default LiveAlertModal
\ No newline at end of file
diff --git a/frontend/src/components/Login.js b/frontend/src/components/Login.js
new file mode 100644
index 0000000..99db8a4
--- /dev/null
+++ b/frontend/src/components/Login.js
@@ -0,0 +1,205 @@
+import React, { useState } from 'react'
+
+const Login = ({ onLogin, error }) => {
+ const [credentials, setCredentials] = useState({
+ username: '',
+ password: ''
+ })
+ const [loading, setLoading] = useState(false)
+ const [localError, setLocalError] = useState('')
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+
+ if (!credentials.username || !credentials.password) {
+ setLocalError('Please enter both username and password')
+ return
+ }
+
+ setLoading(true)
+ setLocalError('')
+
+ try {
+ const result = await onLogin(credentials)
+
+ if (!result.success) {
+ setLocalError(result.error || 'Login failed')
+ }
+ } catch (err) {
+ setLocalError('Login failed. Please try again.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleChange = (e) => {
+ setCredentials({
+ ...credentials,
+ [e.target.name]: e.target.value
+ })
+ // Clear errors when user starts typing
+ if (localError) setLocalError('')
+ if (error && e.target.value) setLocalError('')
+ }
+
+ return (
+ <div style={{
+ minHeight: '100vh',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
+ padding: '20px'
+ }}>
+ <div style={{
+ background: 'white',
+ padding: '40px',
+ borderRadius: '12px',
+ boxShadow: '0 20px 40px rgba(0, 0, 0, 0.1)',
+ width: '100%',
+ maxWidth: '400px'
+ }}>
+ <div style={{ textAlign: 'center', marginBottom: '32px' }}>
+ <h1 style={{
+ fontSize: '28px',
+ fontWeight: '700',
+ color: '#1e293b',
+ margin: '0 0 8px 0'
+ }}>
+ Dear Bubbe Admin
+ </h1>
+ <p style={{
+ color: '#64748b',
+ fontSize: '16px',
+ margin: 0
+ }}>
+ Real-time Monitoring & Alert System
+ </p>
+ </div>
+
+ <form onSubmit={handleSubmit}>
+ <div style={{ marginBottom: '20px' }}>
+ <label style={{
+ display: 'block',
+ marginBottom: '6px',
+ fontWeight: '500',
+ color: '#374151'
+ }}>
+ Username
+ </label>
+ <input
+ type="text"
+ name="username"
+ value={credentials.username}
+ onChange={handleChange}
+ style={{
+ width: '100%',
+ padding: '12px 16px',
+ border: '1px solid #d1d5db',
+ borderRadius: '6px',
+ fontSize: '16px',
+ boxSizing: 'border-box'
+ }}
+ placeholder="Enter your username"
+ disabled={loading}
+ />
+ </div>
+
+ <div style={{ marginBottom: '24px' }}>
+ <label style={{
+ display: 'block',
+ marginBottom: '6px',
+ fontWeight: '500',
+ color: '#374151'
+ }}>
+ Password
+ </label>
+ <input
+ type="password"
+ name="password"
+ value={credentials.password}
+ onChange={handleChange}
+ style={{
+ width: '100%',
+ padding: '12px 16px',
+ border: '1px solid #d1d5db',
+ borderRadius: '6px',
+ fontSize: '16px',
+ boxSizing: 'border-box'
+ }}
+ placeholder="Enter your password"
+ disabled={loading}
+ />
+ </div>
+
+ {(localError || error) && (
+ <div style={{
+ background: '#fef2f2',
+ color: '#dc2626',
+ padding: '12px 16px',
+ borderRadius: '6px',
+ marginBottom: '20px',
+ fontSize: '14px',
+ border: '1px solid #fecaca'
+ }}>
+ {localError || error}
+ </div>
+ )}
+
+ <button
+ type="submit"
+ disabled={loading}
+ style={{
+ width: '100%',
+ padding: '12px 16px',
+ background: loading ? '#9ca3af' : '#3b82f6',
+ color: 'white',
+ border: 'none',
+ borderRadius: '6px',
+ fontSize: '16px',
+ fontWeight: '500',
+ cursor: loading ? 'not-allowed' : 'pointer',
+ transition: 'background-color 0.2s',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center'
+ }}
+ >
+ {loading ? (
+ <>
+ <div className="loading-spinner" style={{ marginRight: '8px' }}></div>
+ Signing in...
+ </>
+ ) : (
+ 'Sign In'
+ )}
+ </button>
+ </form>
+
+ <div style={{
+ marginTop: '24px',
+ padding: '16px',
+ background: '#f8fafc',
+ borderRadius: '6px',
+ fontSize: '14px',
+ color: '#64748b'
+ }}>
+ <strong>Demo Credentials:</strong><br />
+ Username: admin<br />
+ Password: password
+ </div>
+
+ <div style={{
+ marginTop: '24px',
+ textAlign: 'center',
+ fontSize: '12px',
+ color: '#9ca3af'
+ }}>
+ 🔒 Secure access to monitor Dear Bubbe AI system
+ </div>
+ </div>
+ </div>
+ )
+}
+
+export default Login
\ No newline at end of file
diff --git a/frontend/src/components/MonitoringPage.js b/frontend/src/components/MonitoringPage.js
new file mode 100644
index 0000000..b32aa03
--- /dev/null
+++ b/frontend/src/components/MonitoringPage.js
@@ -0,0 +1,380 @@
+import React, { useState, useEffect } from 'react'
+import axios from 'axios'
+
+const MonitoringPage = () => {
+ const [liveData, setLiveData] = useState({})
+ const [bubbeHealth, setBubbeHealth] = useState({})
+ const [loading, setLoading] = useState(true)
+ const [autoRefresh, setAutoRefresh] = useState(true)
+
+ useEffect(() => {
+ fetchLiveData()
+ checkBubbeHealth()
+
+ let interval
+ if (autoRefresh) {
+ interval = setInterval(() => {
+ fetchLiveData()
+ checkBubbeHealth()
+ }, 10000) // Refresh every 10 seconds
+ }
+
+ return () => {
+ if (interval) clearInterval(interval)
+ }
+ }, [autoRefresh])
+
+ const fetchLiveData = async () => {
+ try {
+ const response = await axios.get('/api/monitoring/dashboard')
+ setLiveData(response.data)
+ setLoading(false)
+ } catch (error) {
+ console.error('Failed to fetch live data:', error)
+ }
+ }
+
+ const checkBubbeHealth = async () => {
+ try {
+ const response = await axios.get('/api/monitoring/bubbe-health')
+ setBubbeHealth(response.data)
+ } catch (error) {
+ console.error('Failed to check Bubbe health:', error)
+ }
+ }
+
+ if (loading) {
+ return (
+ <div style={{ display: 'flex', justifyContent: 'center', padding: '40px' }}>
+ <div className="loading-spinner"></div>
+ <span style={{ marginLeft: '12px' }}>Loading monitoring data...</span>
+ </div>
+ )
+ }
+
+ return (
+ <div>
+ <div style={{
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: '24px'
+ }}>
+ <h2>Live Monitoring</h2>
+ <div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
+ <label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
+ <input
+ type="checkbox"
+ checked={autoRefresh}
+ onChange={(e) => setAutoRefresh(e.target.checked)}
+ />
+ Auto-refresh (10s)
+ </label>
+ <button onClick={() => { fetchLiveData(); checkBubbeHealth(); }} className="btn btn-primary">
+ Refresh Now
+ </button>
+ </div>
+ </div>
+
+ {/* Health Status Banner */}
+ <div className={`card ${bubbeHealth.overall === 'healthy' ? 'success' : bubbeHealth.overall === 'degraded' ? 'warning' : 'critical'}`} style={{ marginBottom: '24px' }}>
+ <div className="card-content">
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
+ <div>
+ <h3 style={{ margin: 0, fontSize: '18px' }}>
+ {bubbeHealth.overall === 'healthy' ? '✅' : bubbeHealth.overall === 'degraded' ? '⚠️' : '🚨'}
+ Dear Bubbe API Status: {bubbeHealth.overall || 'Unknown'}
+ </h3>
+ <div style={{ fontSize: '14px', marginTop: '4px', opacity: 0.8 }}>
+ {bubbeHealth.api?.responseTime && `Response time: ${bubbeHealth.api.responseTime}ms`}
+ {bubbeHealth.pm2?.status && ` • PM2 Status: ${bubbeHealth.pm2.status}`}
+ </div>
+ </div>
+ <div style={{ fontSize: '12px', opacity: 0.7 }}>
+ Last checked: {bubbeHealth.timestamp ? new Date(bubbeHealth.timestamp).toLocaleTimeString() : 'Unknown'}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* Real-time Stats */}
+ <div className="stat-cards" style={{ marginBottom: '24px' }}>
+ <div className="stat-card">
+ <div className="stat-label">Active Users (5 min)</div>
+ <div className="stat-value-large" style={{ color: '#3b82f6' }}>
+ {liveData.userActivity?.activeUsers || 0}
+ </div>
+ <div style={{ fontSize: '12px', color: '#64748b', marginTop: '4px' }}>
+ {liveData.userActivity?.activeSessions || 0} sessions
+ </div>
+ </div>
+
+ <div className="stat-card">
+ <div className="stat-label">Conversations/Hour</div>
+ <div className="stat-value-large" style={{ color: '#10b981' }}>
+ {liveData.conversations?.lastHour || 0}
+ </div>
+ <div style={{ fontSize: '12px', color: '#64748b', marginTop: '4px' }}>
+ {liveData.conversations?.today || 0} today
+ </div>
+ </div>
+
+ <div className="stat-card critical">
+ <div className="stat-label">Active Alerts</div>
+ <div className="stat-value-large" style={{ color: '#ef4444' }}>
+ {liveData.alerts?.today || 0}
+ </div>
+ <div style={{ fontSize: '12px', color: '#64748b', marginTop: '4px' }}>
+ {liveData.alerts?.critical || 0} critical
+ </div>
+ </div>
+
+ <div className="stat-card">
+ <div className="stat-label">System Load</div>
+ <div className="stat-value-large" style={{
+ color: liveData.system?.cpuUsage > 80 ? '#ef4444' :
+ liveData.system?.cpuUsage > 60 ? '#f59e0b' : '#10b981'
+ }}>
+ {liveData.system?.cpuUsage || 0}%
+ </div>
+ <div style={{ fontSize: '12px', color: '#64748b', marginTop: '4px' }}>
+ CPU usage
+ </div>
+ </div>
+ </div>
+
+ {/* Detailed Monitoring */}
+ <div className="dashboard-grid">
+ {/* System Resources */}
+ <div className="card">
+ <div className="card-header">
+ <h3 className="card-title">System Resources</h3>
+ </div>
+ <div className="card-content">
+ <div style={{ display: 'grid', gap: '16px' }}>
+ <div>
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
+ <span>Memory Usage</span>
+ <span style={{ fontWeight: '600' }}>{liveData.system?.memoryUsage || 0}%</span>
+ </div>
+ <div style={{
+ background: '#e2e8f0',
+ height: '8px',
+ borderRadius: '4px',
+ overflow: 'hidden'
+ }}>
+ <div style={{
+ width: `${liveData.system?.memoryUsage || 0}%`,
+ height: '100%',
+ background: liveData.system?.memoryUsage > 80 ? '#ef4444' :
+ liveData.system?.memoryUsage > 60 ? '#f59e0b' : '#10b981',
+ transition: 'width 0.3s ease'
+ }}></div>
+ </div>
+ </div>
+
+ <div>
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
+ <span>CPU Usage</span>
+ <span style={{ fontWeight: '600' }}>{liveData.system?.cpuUsage || 0}%</span>
+ </div>
+ <div style={{
+ background: '#e2e8f0',
+ height: '8px',
+ borderRadius: '4px',
+ overflow: 'hidden'
+ }}>
+ <div style={{
+ width: `${liveData.system?.cpuUsage || 0}%`,
+ height: '100%',
+ background: liveData.system?.cpuUsage > 80 ? '#ef4444' :
+ liveData.system?.cpuUsage > 60 ? '#f59e0b' : '#10b981',
+ transition: 'width 0.3s ease'
+ }}></div>
+ </div>
+ </div>
+
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginTop: '16px' }}>
+ <div style={{ textAlign: 'center' }}>
+ <div style={{ fontSize: '20px', fontWeight: '600', color: '#1e293b' }}>
+ {liveData.system?.processMemoryMB || 0}
+ </div>
+ <div style={{ fontSize: '12px', color: '#64748b' }}>Process Memory (MB)</div>
+ </div>
+ <div style={{ textAlign: 'center' }}>
+ <div style={{ fontSize: '20px', fontWeight: '600', color: '#1e293b' }}>
+ {liveData.system?.loadAverage?.toFixed(2) || '0.00'}
+ </div>
+ <div style={{ fontSize: '12px', color: '#64748b' }}>Load Average</div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* API Health */}
+ <div className="card">
+ <div className="card-header">
+ <h3 className="card-title">API Health Check</h3>
+ </div>
+ <div className="card-content">
+ <div style={{ display: 'grid', gap: '12px' }}>
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
+ <span>API Status:</span>
+ <span style={{
+ padding: '4px 8px',
+ borderRadius: '4px',
+ fontSize: '12px',
+ fontWeight: '600',
+ background: bubbeHealth.api?.status === 'healthy' ? '#dcfce7' : '#fee2e2',
+ color: bubbeHealth.api?.status === 'healthy' ? '#16a34a' : '#dc2626'
+ }}>
+ {bubbeHealth.api?.status || 'Unknown'}
+ </span>
+ </div>
+
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
+ <span>Response Time:</span>
+ <span style={{ fontWeight: '600' }}>
+ {bubbeHealth.api?.responseTime || 0}ms
+ </span>
+ </div>
+
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
+ <span>PM2 Status:</span>
+ <span style={{
+ padding: '4px 8px',
+ borderRadius: '4px',
+ fontSize: '12px',
+ fontWeight: '600',
+ background: bubbeHealth.pm2?.status === 'online' ? '#dcfce7' : '#fee2e2',
+ color: bubbeHealth.pm2?.status === 'online' ? '#16a34a' : '#dc2626'
+ }}>
+ {bubbeHealth.pm2?.status || 'Unknown'}
+ </span>
+ </div>
+
+ {bubbeHealth.api?.data && (
+ <div style={{
+ marginTop: '16px',
+ padding: '12px',
+ background: '#f8fafc',
+ borderRadius: '6px',
+ fontSize: '12px'
+ }}>
+ <strong>API Info:</strong><br />
+ Uptime: {bubbeHealth.api.data.uptime ? (bubbeHealth.api.data.uptime / 3600).toFixed(1) + 'h' : 'Unknown'}<br />
+ Memory: {bubbeHealth.api.data.memory ? Math.round(bubbeHealth.api.data.memory.rss / 1024 / 1024) + 'MB' : 'Unknown'}
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+
+ {/* Live Activity */}
+ <div className="card">
+ <div className="card-header">
+ <h3 className="card-title">Live Activity</h3>
+ </div>
+ <div className="card-content">
+ <div style={{ display: 'grid', gap: '12px' }}>
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>New Users Today:</span>
+ <strong style={{ color: '#10b981' }}>{liveData.userActivity?.newUsersToday || 0}</strong>
+ </div>
+
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>Blocked Users:</span>
+ <strong style={{ color: '#ef4444' }}>{liveData.userActivity?.blockedUsers || 0}</strong>
+ </div>
+
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>Total Users:</span>
+ <strong>{liveData.userActivity?.totalUsers || 0}</strong>
+ </div>
+
+ <hr style={{ margin: '12px 0', border: 'none', borderTop: '1px solid #e2e8f0' }} />
+
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>Conversations Today:</span>
+ <strong style={{ color: '#3b82f6' }}>{liveData.conversations?.today || 0}</strong>
+ </div>
+
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>Last Hour:</span>
+ <strong>{liveData.conversations?.lastHour || 0}</strong>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* Quick Actions */}
+ <div className="card">
+ <div className="card-header">
+ <h3 className="card-title">Quick Actions</h3>
+ </div>
+ <div className="card-content">
+ <div style={{ display: 'grid', gap: '12px' }}>
+ <button
+ onClick={() => window.open('/alerts', '_blank')}
+ className="btn btn-danger btn-small"
+ >
+ 🚨 View Critical Alerts
+ </button>
+
+ <button
+ onClick={() => window.open('/users?blocked=true', '_blank')}
+ className="btn btn-secondary btn-small"
+ >
+ 🚫 View Blocked Users
+ </button>
+
+ <button
+ onClick={() => {
+ fetchLiveData()
+ checkBubbeHealth()
+ }}
+ className="btn btn-primary btn-small"
+ >
+ 🔄 Force Refresh
+ </button>
+
+ <button
+ onClick={() => window.open('http://45.61.58.125:3011', '_blank')}
+ className="btn btn-secondary btn-small"
+ >
+ 🌐 Open Dear Bubbe
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* Status Footer */}
+ <div style={{
+ marginTop: '24px',
+ padding: '16px',
+ background: '#f8fafc',
+ borderRadius: '8px',
+ border: '1px solid #e2e8f0',
+ textAlign: 'center',
+ fontSize: '14px',
+ color: '#64748b'
+ }}>
+ {autoRefresh && (
+ <div>
+ 🔄 Auto-refreshing every 10 seconds •
+ Last update: {liveData.timestamp ? new Date(liveData.timestamp).toLocaleTimeString() : 'Unknown'}
+ </div>
+ )}
+
+ <div style={{ marginTop: '8px' }}>
+ Monitoring {liveData.userActivity?.activeUsers || 0} active users •
+ System uptime: {liveData.system?.uptime ? Math.floor(liveData.system.uptime / 3600) + 'h' : 'Unknown'}
+ </div>
+ </div>
+ </div>
+ )
+}
+
+export default MonitoringPage
\ No newline at end of file
diff --git a/frontend/src/components/ReportsPage.js b/frontend/src/components/ReportsPage.js
new file mode 100644
index 0000000..a226ff7
--- /dev/null
+++ b/frontend/src/components/ReportsPage.js
@@ -0,0 +1,406 @@
+import React, { useState, useEffect } from 'react'
+import axios from 'axios'
+
+const ReportsPage = () => {
+ const [reports, setReports] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [selectedReport, setSelectedReport] = useState(null)
+ const [reportData, setReportData] = useState(null)
+
+ const generateReport = async (type, timeframe = '24h') => {
+ try {
+ setLoading(true)
+ let endpoint = `/api/reports/${type}`
+ if (timeframe) endpoint += `?timeframe=${timeframe}`
+
+ const response = await axios.get(endpoint)
+
+ if (response.data.success) {
+ setReportData(response.data.report)
+ setSelectedReport(type)
+ }
+ } catch (error) {
+ console.error(`Failed to generate ${type} report:`, error)
+ alert(`Failed to generate ${type} report`)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const exportReport = async () => {
+ try {
+ await axios.post('/api/reports/export', {
+ reportType: selectedReport,
+ data: reportData
+ })
+ alert('Report exported successfully')
+ } catch (error) {
+ console.error('Failed to export report:', error)
+ alert('Failed to export report')
+ }
+ }
+
+ if (loading) {
+ return (
+ <div style={{ display: 'flex', justifyContent: 'center', padding: '40px' }}>
+ <div className="loading-spinner"></div>
+ <span style={{ marginLeft: '12px' }}>Generating report...</span>
+ </div>
+ )
+ }
+
+ return (
+ <div>
+ <div style={{
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: '24px'
+ }}>
+ <h2>Reports & Analytics</h2>
+ {selectedReport && reportData && (
+ <button onClick={exportReport} className="btn btn-primary">
+ Export Report
+ </button>
+ )}
+ </div>
+
+ {!selectedReport ? (
+ <div className="dashboard-grid">
+ <div className="card">
+ <div className="card-header">
+ <h3 className="card-title">Daily Report</h3>
+ </div>
+ <div className="card-content">
+ <p>Comprehensive daily activity summary including user engagement, conversations, and security events.</p>
+ <div style={{ marginTop: '16px' }}>
+ <button
+ onClick={() => generateReport('daily')}
+ className="btn btn-primary"
+ style={{ marginRight: '8px' }}
+ >
+ Generate Today
+ </button>
+ <button
+ onClick={() => {
+ const date = prompt('Enter date (YYYY-MM-DD):')
+ if (date) generateReport('daily', date)
+ }}
+ className="btn btn-secondary"
+ >
+ Custom Date
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <div className="card">
+ <div className="card-header">
+ <h3 className="card-title">Security Report</h3>
+ </div>
+ <div className="card-content">
+ <p>Security incidents, threat analysis, and user behavior monitoring.</p>
+ <div style={{ marginTop: '16px', display: 'grid', gap: '8px' }}>
+ <button
+ onClick={() => generateReport('security', '24h')}
+ className="btn btn-danger"
+ >
+ Last 24 Hours
+ </button>
+ <button
+ onClick={() => generateReport('security', '7d')}
+ className="btn btn-danger"
+ >
+ Last 7 Days
+ </button>
+ <button
+ onClick={() => generateReport('security', '30d')}
+ className="btn btn-danger"
+ >
+ Last 30 Days
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <div className="card">
+ <div className="card-header">
+ <h3 className="card-title">Weekly Summary</h3>
+ </div>
+ <div className="card-content">
+ <p>Weekly trends, user growth, and engagement patterns.</p>
+ <div style={{ marginTop: '16px' }}>
+ <button
+ onClick={() => generateReport('weekly')}
+ className="btn btn-primary"
+ >
+ This Week
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ ) : (
+ <div>
+ <div style={{ marginBottom: '20px' }}>
+ <button
+ onClick={() => { setSelectedReport(null); setReportData(null); }}
+ className="btn btn-secondary"
+ >
+ ← Back to Reports
+ </button>
+ </div>
+
+ {reportData && (
+ <div className="card">
+ <div className="card-header">
+ <h3 className="card-title">
+ {selectedReport.charAt(0).toUpperCase() + selectedReport.slice(1)} Report
+ </h3>
+ <div style={{ fontSize: '14px', color: '#64748b' }}>
+ Generated: {new Date(reportData.generatedAt).toLocaleString()}
+ </div>
+ </div>
+ <div className="card-content">
+ {selectedReport === 'daily' && (
+ <DailyReportView data={reportData} />
+ )}
+ {selectedReport === 'security' && (
+ <SecurityReportView data={reportData} />
+ )}
+ {selectedReport === 'weekly' && (
+ <WeeklyReportView data={reportData} />
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ )
+}
+
+const DailyReportView = ({ data }) => (
+ <div>
+ <div className="stat-cards" style={{ marginBottom: '24px' }}>
+ <div className="stat-card">
+ <div className="stat-label">Total Interactions</div>
+ <div className="stat-value-large">{data.summary?.totalInteractions || 0}</div>
+ </div>
+ <div className="stat-card">
+ <div className="stat-label">New Users</div>
+ <div className="stat-value-large">{data.userActivity?.newRegistrations || 0}</div>
+ </div>
+ <div className="stat-card critical">
+ <div className="stat-label">Security Incidents</div>
+ <div className="stat-value-large">{data.summary?.securityIncidents || 0}</div>
+ </div>
+ <div className="stat-card">
+ <div className="stat-label">Engagement Rate</div>
+ <div className="stat-value-large">{(data.summary?.engagement || 0).toFixed(1)}</div>
+ </div>
+ </div>
+
+ <div className="dashboard-grid">
+ <div className="card">
+ <div className="card-header">
+ <h4>User Activity</h4>
+ </div>
+ <div className="card-content">
+ <div style={{ display: 'grid', gap: '8px' }}>
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>Active Users:</span>
+ <strong>{data.userActivity?.activeUsers || 0}</strong>
+ </div>
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>Total Sessions:</span>
+ <strong>{data.userActivity?.totalSessions || 0}</strong>
+ </div>
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>Unique Users:</span>
+ <strong>{data.userActivity?.uniqueActiveUsers || 0}</strong>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div className="card">
+ <div className="card-header">
+ <h4>Conversations</h4>
+ </div>
+ <div className="card-content">
+ <div style={{ display: 'grid', gap: '8px' }}>
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>Total:</span>
+ <strong>{data.conversations?.totalConversations || 0}</strong>
+ </div>
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>Voice Used:</span>
+ <strong>{data.conversations?.voiceUsage || 0}</strong>
+ </div>
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>Avg Response Time:</span>
+ <strong>{(data.conversations?.avgResponseTime || 0).toFixed(0)}ms</strong>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {data.topTopics && data.topTopics.length > 0 && (
+ <div className="card" style={{ marginTop: '24px' }}>
+ <div className="card-header">
+ <h4>Top Discussion Topics</h4>
+ </div>
+ <div className="card-content">
+ <div style={{ display: 'grid', gap: '8px' }}>
+ {data.topTopics.map((topic, index) => (
+ <div key={index} style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>{topic._id}</span>
+ <span className="badge badge-medium">{topic.count}</span>
+ </div>
+ ))}
+ </div>
+ </div>
+ </div>
+ )}
+ </div>
+)
+
+const SecurityReportView = ({ data }) => (
+ <div>
+ <div className="stat-cards" style={{ marginBottom: '24px' }}>
+ <div className="stat-card critical">
+ <div className="stat-label">Total Alerts</div>
+ <div className="stat-value-large">{data.summary?.totalAlerts || 0}</div>
+ </div>
+ <div className="stat-card critical">
+ <div className="stat-label">Critical Incidents</div>
+ <div className="stat-value-large">{data.summary?.criticalIncidents || 0}</div>
+ </div>
+ <div className="stat-card warning">
+ <div className="stat-label">Repeat Offenders</div>
+ <div className="stat-value-large">{data.summary?.repeatOffenderCount || 0}</div>
+ </div>
+ <div className="stat-card">
+ <div className="stat-label">Users Blocked</div>
+ <div className="stat-value-large">{data.summary?.blockedUserCount || 0}</div>
+ </div>
+ </div>
+
+ {data.repeatOffenders && data.repeatOffenders.length > 0 && (
+ <div className="card" style={{ marginBottom: '24px' }}>
+ <div className="card-header">
+ <h4>Repeat Offenders</h4>
+ </div>
+ <div className="card-content">
+ <div className="table-container">
+ <table className="table">
+ <thead>
+ <tr>
+ <th>IP Address</th>
+ <th>Alert Count</th>
+ <th>Severities</th>
+ <th>Types</th>
+ </tr>
+ </thead>
+ <tbody>
+ {data.repeatOffenders.slice(0, 10).map((offender, index) => (
+ <tr key={index}>
+ <td style={{ fontFamily: 'monospace' }}>{offender._id}</td>
+ <td><span className="badge badge-critical">{offender.alertCount}</span></td>
+ <td style={{ fontSize: '12px' }}>
+ {offender.severities.join(', ')}
+ </td>
+ <td style={{ fontSize: '12px' }}>
+ {offender.types.join(', ')}
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {data.geoThreats && data.geoThreats.length > 0 && (
+ <div className="card">
+ <div className="card-header">
+ <h4>Geographic Threat Distribution</h4>
+ </div>
+ <div className="card-content">
+ <div style={{ display: 'grid', gap: '8px' }}>
+ {data.geoThreats.slice(0, 10).map((geo, index) => (
+ <div key={index} style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span>{geo._id || 'Unknown'}</span>
+ <span className="badge badge-high">{geo.alerts} alerts</span>
+ </div>
+ ))}
+ </div>
+ </div>
+ </div>
+ )}
+ </div>
+)
+
+const WeeklyReportView = ({ data }) => (
+ <div>
+ <div className="stat-cards" style={{ marginBottom: '24px' }}>
+ <div className="stat-card">
+ <div className="stat-label">Total Conversations</div>
+ <div className="stat-value-large">{data.weekTotals?.conversations || 0}</div>
+ </div>
+ <div className="stat-card">
+ <div className="stat-label">New Users</div>
+ <div className="stat-value-large">{data.weekTotals?.newUsers || 0}</div>
+ </div>
+ <div className="stat-card warning">
+ <div className="stat-label">Total Alerts</div>
+ <div className="stat-value-large">{data.weekTotals?.alerts || 0}</div>
+ </div>
+ <div className="stat-card">
+ <div className="stat-label">Sessions</div>
+ <div className="stat-value-large">{data.weekTotals?.sessions || 0}</div>
+ </div>
+ </div>
+
+ <div className="card">
+ <div className="card-header">
+ <h4>Daily Breakdown</h4>
+ </div>
+ <div className="card-content">
+ <div className="table-container">
+ <table className="table">
+ <thead>
+ <tr>
+ <th>Date</th>
+ <th>Conversations</th>
+ <th>Sessions</th>
+ <th>Alerts</th>
+ </tr>
+ </thead>
+ <tbody>
+ {data.dailyBreakdown?.map((day, index) => (
+ <tr key={index}>
+ <td>{new Date(day.date).toLocaleDateString()}</td>
+ <td>{day.conversations}</td>
+ <td>{day.sessions}</td>
+ <td>
+ {day.alerts > 0 ? (
+ <span className="badge badge-warning">{day.alerts}</span>
+ ) : (
+ day.alerts
+ )}
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+ </div>
+)
+
+export default ReportsPage
\ No newline at end of file
diff --git a/frontend/src/components/Sidebar.js b/frontend/src/components/Sidebar.js
new file mode 100644
index 0000000..68c3cd8
--- /dev/null
+++ b/frontend/src/components/Sidebar.js
@@ -0,0 +1,53 @@
+import React from 'react'
+import { NavLink } from 'react-router-dom'
+
+const Sidebar = () => {
+ const navItems = [
+ { path: '/dashboard', label: 'Dashboard', icon: '📊' },
+ { path: '/alerts', label: 'Alerts', icon: '🚨' },
+ { path: '/users', label: 'Users', icon: '👥' },
+ { path: '/monitoring', label: 'Monitoring', icon: '📈' },
+ { path: '/reports', label: 'Reports', icon: '📋' }
+ ]
+
+ return (
+ <div className="sidebar">
+ <div className="sidebar-header">
+ <div className="sidebar-title">Dear Bubbe Admin</div>
+ <div className="sidebar-subtitle">Security Dashboard</div>
+ </div>
+
+ <nav className="nav-menu">
+ {navItems.map((item) => (
+ <NavLink
+ key={item.path}
+ to={item.path}
+ className={({ isActive }) =>
+ `nav-item ${isActive ? 'active' : ''}`
+ }
+ >
+ <span className="nav-icon">{item.icon}</span>
+ {item.label}
+ </NavLink>
+ ))}
+ </nav>
+
+ <div style={{
+ position: 'absolute',
+ bottom: '20px',
+ left: '20px',
+ right: '20px',
+ fontSize: '12px',
+ color: '#94a3b8',
+ textAlign: 'center',
+ borderTop: '1px solid #475569',
+ paddingTop: '16px'
+ }}>
+ <div>Version 1.0.0</div>
+ <div>Real-time Protection Active</div>
+ </div>
+ </div>
+ )
+}
+
+export default Sidebar
\ No newline at end of file
diff --git a/frontend/src/components/UsersPage.js b/frontend/src/components/UsersPage.js
new file mode 100644
index 0000000..ba410da
--- /dev/null
+++ b/frontend/src/components/UsersPage.js
@@ -0,0 +1,323 @@
+import React, { useState, useEffect } from 'react'
+import axios from 'axios'
+
+const UsersPage = () => {
+ const [users, setUsers] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [selectedUser, setSelectedUser] = useState(null)
+ const [filters, setFilters] = useState({
+ search: '',
+ blocked: '',
+ riskLevel: '',
+ country: ''
+ })
+
+ useEffect(() => {
+ fetchUsers()
+ }, [filters])
+
+ const fetchUsers = async () => {
+ try {
+ setLoading(true)
+ const params = new URLSearchParams()
+ Object.entries(filters).forEach(([key, value]) => {
+ if (value) params.append(key, value)
+ })
+
+ const response = await axios.get(`/api/users?${params.toString()}`)
+ setUsers(response.data.users || [])
+ } catch (error) {
+ console.error('Failed to fetch users:', error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const fetchUserDetails = async (userId) => {
+ try {
+ const response = await axios.get(`/api/users/${userId}`)
+ setSelectedUser(response.data)
+ } catch (error) {
+ console.error('Failed to fetch user details:', error)
+ }
+ }
+
+ const blockUser = async (userId, reason) => {
+ try {
+ await axios.post(`/api/users/${userId}/block`, { reason })
+ fetchUsers()
+ setSelectedUser(null)
+ alert('User blocked successfully')
+ } catch (error) {
+ console.error('Failed to block user:', error)
+ alert('Failed to block user')
+ }
+ }
+
+ const unblockUser = async (userId) => {
+ try {
+ await axios.post(`/api/users/${userId}/unblock`)
+ fetchUsers()
+ setSelectedUser(null)
+ alert('User unblocked successfully')
+ } catch (error) {
+ console.error('Failed to unblock user:', error)
+ alert('Failed to unblock user')
+ }
+ }
+
+ if (loading) {
+ return (
+ <div style={{ display: 'flex', justifyContent: 'center', padding: '40px' }}>
+ <div className="loading-spinner"></div>
+ <span style={{ marginLeft: '12px' }}>Loading users...</span>
+ </div>
+ )
+ }
+
+ return (
+ <div>
+ <div style={{
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: '24px'
+ }}>
+ <h2>User Management</h2>
+ <button onClick={fetchUsers} className="btn btn-primary">
+ Refresh
+ </button>
+ </div>
+
+ {/* Filters */}
+ <div className="card" style={{ marginBottom: '24px' }}>
+ <div className="card-content">
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
+ <input
+ type="text"
+ placeholder="Search users..."
+ className="form-input"
+ value={filters.search}
+ onChange={(e) => setFilters({...filters, search: e.target.value})}
+ />
+ <select
+ className="form-select"
+ value={filters.blocked}
+ onChange={(e) => setFilters({...filters, blocked: e.target.value})}
+ >
+ <option value="">All Users</option>
+ <option value="false">Active Users</option>
+ <option value="true">Blocked Users</option>
+ </select>
+ <select
+ className="form-select"
+ value={filters.riskLevel}
+ onChange={(e) => setFilters({...filters, riskLevel: e.target.value})}
+ >
+ <option value="">All Risk Levels</option>
+ <option value="critical">Critical</option>
+ <option value="high">High</option>
+ <option value="medium">Medium</option>
+ <option value="low">Low</option>
+ </select>
+ </div>
+ </div>
+ </div>
+
+ {/* Users Table */}
+ <div className="card">
+ <div className="card-content">
+ <div className="table-container">
+ <table className="table">
+ <thead>
+ <tr>
+ <th>User</th>
+ <th>Email</th>
+ <th>Location</th>
+ <th>Messages</th>
+ <th>Risk Level</th>
+ <th>Status</th>
+ <th>Last Visit</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ {users.map((user) => (
+ <tr key={user.userId}>
+ <td>
+ <div>
+ <strong>{user.profile?.name || user.profile?.preferredName || 'Unknown'}</strong>
+ <div style={{ fontSize: '12px', color: '#64748b' }}>
+ {user.userId}
+ </div>
+ </div>
+ </td>
+ <td>{user.email || 'No email'}</td>
+ <td>
+ {user.location?.city && user.location?.country ?
+ `${user.location.city}, ${user.location.country}` :
+ 'Unknown'
+ }
+ </td>
+ <td>{user.stats?.totalMessages || 0}</td>
+ <td>
+ <span className={`badge badge-${user.security?.riskLevel || 'low'}`}>
+ {user.security?.riskLevel || 'low'}
+ </span>
+ </td>
+ <td>
+ <span className={`badge ${user.security?.blocked ? 'badge-blocked' : 'badge-active'}`}>
+ {user.security?.blocked ? 'Blocked' : 'Active'}
+ </span>
+ </td>
+ <td style={{ fontSize: '12px', color: '#64748b' }}>
+ {user.stats?.lastVisit ?
+ new Date(user.stats.lastVisit).toLocaleString() :
+ 'Never'
+ }
+ </td>
+ <td>
+ <button
+ onClick={() => fetchUserDetails(user.userId)}
+ className="btn btn-small btn-primary"
+ style={{ marginRight: '8px' }}
+ >
+ View
+ </button>
+ {user.security?.blocked ? (
+ <button
+ onClick={() => unblockUser(user.userId)}
+ className="btn btn-small btn-secondary"
+ >
+ Unblock
+ </button>
+ ) : (
+ <button
+ onClick={() => {
+ const reason = prompt('Reason for blocking:')
+ if (reason) blockUser(user.userId, reason)
+ }}
+ className="btn btn-small btn-danger"
+ >
+ Block
+ </button>
+ )}
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+
+ {/* User Details Modal */}
+ {selectedUser && (
+ <div className="live-alert-modal" onClick={() => setSelectedUser(null)}>
+ <div
+ className="live-alert-content"
+ style={{ maxWidth: '800px', maxHeight: '80vh', overflow: 'auto' }}
+ onClick={e => e.stopPropagation()}
+ >
+ <div className="alert-header">
+ <h3>User Details</h3>
+ <button onClick={() => setSelectedUser(null)}>×</button>
+ </div>
+
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px' }}>
+ <div>
+ <h4>Profile Information</h4>
+ <div style={{ background: '#f8fafc', padding: '16px', borderRadius: '8px' }}>
+ <div><strong>Name:</strong> {selectedUser.user?.profile?.name || 'Unknown'}</div>
+ <div><strong>Email:</strong> {selectedUser.user?.email || 'No email'}</div>
+ <div><strong>Age:</strong> {selectedUser.user?.profile?.age || 'Unknown'}</div>
+ <div><strong>Location:</strong> {
+ selectedUser.user?.location?.city ?
+ `${selectedUser.user.location.city}, ${selectedUser.user.location.country}` :
+ 'Unknown'
+ }</div>
+ <div><strong>Relationship:</strong> {selectedUser.user?.profile?.relationshipStatus || 'Unknown'}</div>
+ <div><strong>Job:</strong> {selectedUser.user?.profile?.jobTitle || 'Unknown'}</div>
+ </div>
+ </div>
+
+ <div>
+ <h4>Security & Activity</h4>
+ <div style={{ background: '#f8fafc', padding: '16px', borderRadius: '8px' }}>
+ <div><strong>Risk Level:</strong>
+ <span className={`badge badge-${selectedUser.user?.security?.riskLevel || 'low'}`} style={{ marginLeft: '8px' }}>
+ {selectedUser.user?.security?.riskLevel || 'low'}
+ </span>
+ </div>
+ <div><strong>Status:</strong>
+ <span className={`badge ${selectedUser.user?.security?.blocked ? 'badge-blocked' : 'badge-active'}`} style={{ marginLeft: '8px' }}>
+ {selectedUser.user?.security?.blocked ? 'Blocked' : 'Active'}
+ </span>
+ </div>
+ <div><strong>Total Messages:</strong> {selectedUser.user?.stats?.totalMessages || 0}</div>
+ <div><strong>Total Sessions:</strong> {selectedUser.sessions?.length || 0}</div>
+ <div><strong>Alerts:</strong> {selectedUser.alerts?.length || 0}</div>
+ </div>
+ </div>
+ </div>
+
+ {selectedUser.alerts && selectedUser.alerts.length > 0 && (
+ <div style={{ marginTop: '20px' }}>
+ <h4>Recent Alerts</h4>
+ <div style={{ maxHeight: '200px', overflow: 'auto' }}>
+ {selectedUser.alerts.slice(0, 5).map((alert, index) => (
+ <div key={index} style={{
+ padding: '12px',
+ margin: '8px 0',
+ background: '#fef2f2',
+ border: '1px solid #fecaca',
+ borderRadius: '6px'
+ }}>
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <span className={`badge badge-${alert.severity}`}>{alert.type}</span>
+ <span style={{ fontSize: '12px', color: '#64748b' }}>
+ {new Date(alert.timestamp).toLocaleString()}
+ </span>
+ </div>
+ {alert.violation?.match && (
+ <div style={{ marginTop: '8px', fontSize: '14px', fontFamily: 'monospace' }}>
+ "{alert.violation.match}"
+ </div>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ <div style={{ marginTop: '20px', display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
+ <button onClick={() => setSelectedUser(null)} className="btn btn-secondary">
+ Close
+ </button>
+ {selectedUser.user?.security?.blocked ? (
+ <button
+ onClick={() => unblockUser(selectedUser.user.userId)}
+ className="btn btn-primary"
+ >
+ Unblock User
+ </button>
+ ) : (
+ <button
+ onClick={() => {
+ const reason = prompt('Reason for blocking:')
+ if (reason) blockUser(selectedUser.user.userId, reason)
+ }}
+ className="btn btn-danger"
+ >
+ Block User
+ </button>
+ )}
+ </div>
+ </div>
+ </div>
+ )}
+ </div>
+ )
+}
+
+export default UsersPage
\ No newline at end of file
diff --git a/frontend/src/index.js b/frontend/src/index.js
new file mode 100644
index 0000000..95cfe8a
--- /dev/null
+++ b/frontend/src/index.js
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+
+const root = ReactDOM.createRoot(document.getElementById('root'))
+root.render(
+ <React.StrictMode>
+ <App />
+ </React.StrictMode>
+)
\ No newline at end of file
diff --git a/frontend/twitter-monitor.html b/frontend/twitter-monitor.html
new file mode 100644
index 0000000..2c27b04
--- /dev/null
+++ b/frontend/twitter-monitor.html
@@ -0,0 +1,271 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Twitter Monitor - Dear Bubbe Admin</title>
+ <style>
+ body {
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ margin: 0;
+ padding: 20px;
+ min-height: 100vh;
+ }
+ .container {
+ max-width: 1400px;
+ margin: 0 auto;
+ }
+ .header {
+ background: white;
+ border-radius: 10px;
+ padding: 20px;
+ margin-bottom: 20px;
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
+ }
+ .stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 15px;
+ margin-bottom: 20px;
+ }
+ .stat-card {
+ background: white;
+ padding: 15px;
+ border-radius: 8px;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ }
+ .stat-number {
+ font-size: 2em;
+ font-weight: bold;
+ color: #667eea;
+ }
+ .stat-label {
+ color: #666;
+ font-size: 0.9em;
+ }
+ .posts-container {
+ background: white;
+ border-radius: 10px;
+ padding: 20px;
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
+ }
+ .post-item {
+ border: 1px solid #e1e1e1;
+ border-radius: 8px;
+ padding: 15px;
+ margin-bottom: 15px;
+ transition: all 0.2s;
+ }
+ .post-item:hover {
+ box-shadow: 0 4px 8px rgba(0,0,0,0.1);
+ }
+ .post-header {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 10px;
+ }
+ .post-time {
+ color: #666;
+ font-size: 0.9em;
+ }
+ .post-status {
+ display: inline-block;
+ padding: 3px 8px;
+ border-radius: 4px;
+ font-size: 0.85em;
+ font-weight: bold;
+ }
+ .status-posted {
+ background: #10b981;
+ color: white;
+ }
+ .status-queued {
+ background: #f59e0b;
+ color: white;
+ }
+ .status-failed {
+ background: #ef4444;
+ color: white;
+ }
+ .post-content {
+ margin: 10px 0;
+ line-height: 1.5;
+ }
+ .post-link {
+ color: #667eea;
+ text-decoration: none;
+ font-size: 0.9em;
+ }
+ .post-link:hover {
+ text-decoration: underline;
+ }
+ .source-badge {
+ display: inline-block;
+ padding: 2px 6px;
+ background: #e5e7eb;
+ border-radius: 3px;
+ font-size: 0.8em;
+ margin-right: 5px;
+ }
+ .source-real {
+ background: #dcfce7;
+ color: #166534;
+ }
+ .source-bot {
+ background: #fef3c7;
+ color: #92400e;
+ }
+ .refresh-btn {
+ background: #667eea;
+ color: white;
+ border: none;
+ padding: 10px 20px;
+ border-radius: 5px;
+ cursor: pointer;
+ font-size: 1em;
+ }
+ .refresh-btn:hover {
+ background: #5a67d8;
+ }
+ </style>
+</head>
+<body>
+ <div class="container">
+ <div class="header">
+ <h1>🐦 Twitter Monitor - Dear Bubbe</h1>
+ <button class="refresh-btn" onclick="loadData()">🔄 Refresh</button>
+ </div>
+
+ <div class="stats-grid">
+ <div class="stat-card">
+ <div class="stat-number" id="todayCount">0</div>
+ <div class="stat-label">Posts Today</div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-number" id="weekCount">0</div>
+ <div class="stat-label">This Week</div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-number" id="realUserCount">0</div>
+ <div class="stat-label">Real User Posts</div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-number" id="botGenCount">0</div>
+ <div class="stat-label">Bot Generated</div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-number" id="remainingToday">100</div>
+ <div class="stat-label">Remaining Today</div>
+ </div>
+ </div>
+
+ <div class="posts-container">
+ <h2>Recent Posts</h2>
+ <div id="postsList"></div>
+ </div>
+ </div>
+
+ <script>
+ const API_URL = 'http://45.61.58.125:5013';
+
+ async function loadData() {
+ try {
+ // Load immediate posts
+ const response = await fetch('/root/Projects/dear-bubbe-admin/logs/immediate-posts.json');
+ const posts = await response.json();
+
+ // Load stats
+ const statsResponse = await fetch('/root/Projects/dear-bubbe-admin/logs/twitter-stats.json');
+ const stats = await statsResponse.json();
+
+ // Calculate counts
+ const now = new Date();
+ const today = now.toDateString();
+ const weekAgo = new Date(now - 7 * 24 * 60 * 60 * 1000);
+
+ let todayCount = 0;
+ let weekCount = 0;
+ let realUserCount = 0;
+ let botGenCount = 0;
+
+ const recentPosts = posts.slice(-50).reverse(); // Last 50 posts
+
+ recentPosts.forEach(post => {
+ const postDate = new Date(post.timestamp);
+
+ if (postDate.toDateString() === today) {
+ todayCount++;
+ }
+
+ if (postDate > weekAgo) {
+ weekCount++;
+ }
+
+ if (post.userId && post.userId.includes('twitter-bot')) {
+ botGenCount++;
+ } else {
+ realUserCount++;
+ }
+ });
+
+ // Update stats
+ document.getElementById('todayCount').textContent = todayCount;
+ document.getElementById('weekCount').textContent = weekCount;
+ document.getElementById('realUserCount').textContent = realUserCount;
+ document.getElementById('botGenCount').textContent = botGenCount;
+ document.getElementById('remainingToday').textContent = Math.max(0, 100 - todayCount);
+
+ // Display posts
+ const postsList = document.getElementById('postsList');
+ let html = '';
+
+ recentPosts.forEach(post => {
+ const isRealUser = !post.userId?.includes('twitter-bot');
+ const status = post.twitterPosted ? 'posted' : 'queued';
+ const postDate = new Date(post.timestamp);
+
+ html += `
+ <div class="post-item">
+ <div class="post-header">
+ <div>
+ <span class="source-badge ${isRealUser ? 'source-real' : 'source-bot'}">
+ ${isRealUser ? '👤 Real User' : '🤖 Bot'}
+ </span>
+ <span class="post-time">${postDate.toLocaleString()}</span>
+ </div>
+ <span class="post-status status-${status}">${status.toUpperCase()}</span>
+ </div>
+ <div class="post-content">
+ <strong>User:</strong> ${post.userSnippet || 'N/A'}<br>
+ <strong>Bubbe:</strong> ${post.bubbeSnippet || 'N/A'}...
+ </div>
+ ${post.twitterText ? `
+ <div style="background:#f3f4f6; padding:10px; border-radius:5px; margin:10px 0;">
+ <strong>Tweet:</strong> ${post.twitterText.substring(0, 150)}...
+ </div>
+ ` : ''}
+ ${post.url ? `
+ <a href="${post.url}" target="_blank" class="post-link">
+ View on Twitter →
+ </a>
+ ` : ''}
+ </div>
+ `;
+ });
+
+ postsList.innerHTML = html || '<p>No posts yet</p>';
+
+ } catch (error) {
+ console.error('Error loading data:', error);
+ }
+ }
+
+ // Load data on page load
+ loadData();
+
+ // Auto-refresh every 30 seconds
+ setInterval(loadData, 30000);
+ </script>
+</body>
+</html>
\ No newline at end of file
diff --git a/log-all-users.js b/log-all-users.js
new file mode 100644
index 0000000..f39040d
--- /dev/null
+++ b/log-all-users.js
@@ -0,0 +1,271 @@
+#!/usr/bin/env node
+
+const fs = require('fs');
+const path = require('path');
+
+// Color codes for terminal output
+const colors = {
+ reset: '\x1b[0m',
+ bright: '\x1b[1m',
+ red: '\x1b[31m',
+ green: '\x1b[32m',
+ yellow: '\x1b[33m',
+ blue: '\x1b[34m',
+ magenta: '\x1b[35m',
+ cyan: '\x1b[36m'
+};
+
+// Track all unique users
+const allUsers = new Map();
+const userSessions = new Map();
+
+// Read all log sources
+const logSources = [
+ {
+ path: '/root/Projects/dear-bubbe-admin/logs/combined.log',
+ name: 'Admin Dashboard Logs'
+ },
+ {
+ path: '/root/Projects/dear-bubbe-nextjs/logs/chat.log',
+ name: 'Bubbe Chat Logs'
+ },
+ {
+ path: '/root/Projects/dear-bubbe-admin/logs/access.log',
+ name: 'Access Logs'
+ }
+];
+
+// Parse different log formats
+function parseLogLine(line) {
+ // Try to parse as JSON first
+ try {
+ const parsed = JSON.parse(line);
+ return {
+ timestamp: parsed.timestamp || parsed.time,
+ ip: parsed.ip || parsed.userIp,
+ userName: parsed.userName || parsed.name || 'Anonymous',
+ email: parsed.userEmail || parsed.email || '',
+ location: parsed.location || 'Unknown',
+ message: parsed.message || parsed.userMessage || '',
+ mode: parsed.mode || 'unknown',
+ userId: parsed.userId || parsed.sessionId || null
+ };
+ } catch (e) {
+ // Try to extract from text format
+ const ipMatch = line.match(/IP:\s*([\d\.:a-f]+)/i);
+ const userMatch = line.match(/userName[":]+\s*([^",}]+)/i);
+ const emailMatch = line.match(/email[":]+\s*([^",}]+)/i);
+ const timeMatch = line.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
+
+ if (ipMatch || userMatch) {
+ return {
+ timestamp: timeMatch ? timeMatch[0] : null,
+ ip: ipMatch ? ipMatch[1] : 'Unknown',
+ userName: userMatch ? userMatch[1] : 'Anonymous',
+ email: emailMatch ? emailMatch[1] : '',
+ location: 'Unknown',
+ message: '',
+ mode: 'unknown',
+ userId: null
+ };
+ }
+ return null;
+ }
+}
+
+// Process all log files
+console.log(`${colors.cyan}${'='.repeat(80)}${colors.reset}`);
+console.log(`${colors.bright}${colors.blue}📊 COMPREHENSIVE USER LOG ANALYSIS${colors.reset}`);
+console.log(`${colors.cyan}${'='.repeat(80)}${colors.reset}\n`);
+
+logSources.forEach(source => {
+ if (!fs.existsSync(source.path)) {
+ console.log(`${colors.yellow}⚠️ ${source.name}: File not found${colors.reset}`);
+ return;
+ }
+
+ console.log(`${colors.green}📁 Processing: ${source.name}${colors.reset}`);
+ const content = fs.readFileSync(source.path, 'utf-8');
+ const lines = content.split('\n');
+
+ lines.forEach(line => {
+ if (!line.trim()) return;
+
+ const parsed = parseLogLine(line);
+ if (!parsed || !parsed.ip) return;
+
+ // Create unique key for user
+ const userKey = parsed.email || parsed.ip;
+
+ if (!allUsers.has(userKey)) {
+ allUsers.set(userKey, {
+ userName: parsed.userName,
+ email: parsed.email,
+ ips: new Set(),
+ locations: new Set(),
+ firstSeen: parsed.timestamp,
+ lastSeen: parsed.timestamp,
+ messageCount: 0,
+ modes: new Set(),
+ sessions: []
+ });
+ }
+
+ const user = allUsers.get(userKey);
+
+ // Update user data
+ if (parsed.ip && parsed.ip !== 'Unknown') {
+ user.ips.add(parsed.ip);
+ }
+ if (parsed.location && parsed.location !== 'Unknown') {
+ user.locations.add(parsed.location);
+ }
+ if (parsed.mode && parsed.mode !== 'unknown') {
+ user.modes.add(parsed.mode);
+ }
+ if (parsed.message) {
+ user.messageCount++;
+ }
+
+ // Update timestamps
+ if (parsed.timestamp) {
+ if (!user.firstSeen || parsed.timestamp < user.firstSeen) {
+ user.firstSeen = parsed.timestamp;
+ }
+ if (!user.lastSeen || parsed.timestamp > user.lastSeen) {
+ user.lastSeen = parsed.timestamp;
+ }
+
+ // Track session
+ user.sessions.push({
+ timestamp: parsed.timestamp,
+ ip: parsed.ip,
+ location: parsed.location
+ });
+ }
+
+ // Update name if better one found
+ if (parsed.userName && parsed.userName !== 'Anonymous' && user.userName === 'Anonymous') {
+ user.userName = parsed.userName;
+ }
+ });
+
+ console.log(` ✓ Processed ${lines.length} lines\n`);
+});
+
+// Sort users by last activity
+const sortedUsers = Array.from(allUsers.entries()).sort((a, b) => {
+ const timeA = new Date(a[1].lastSeen || 0).getTime();
+ const timeB = new Date(b[1].lastSeen || 0).getTime();
+ return timeB - timeA;
+});
+
+// Display results
+console.log(`${colors.cyan}${'='.repeat(80)}${colors.reset}`);
+console.log(`${colors.bright}${colors.magenta}👥 ALL USERS (${sortedUsers.length} Total)${colors.reset}`);
+console.log(`${colors.cyan}${'='.repeat(80)}${colors.reset}\n`);
+
+sortedUsers.forEach((([key, user], index) => {
+ const isActive = user.lastSeen &&
+ (new Date() - new Date(user.lastSeen)) < 3600000; // Active in last hour
+
+ const statusColor = isActive ? colors.green : colors.reset;
+ const status = isActive ? '🟢 ACTIVE' : '⚪ INACTIVE';
+
+ console.log(`${colors.bright}${index + 1}. ${user.userName}${colors.reset} ${statusColor}${status}${colors.reset}`);
+
+ if (user.email) {
+ console.log(` ${colors.blue}📧 Email:${colors.reset} ${user.email}`);
+ }
+
+ console.log(` ${colors.yellow}🌐 IPs:${colors.reset} ${Array.from(user.ips).join(', ') || 'None'}`);
+ console.log(` ${colors.cyan}📍 Locations:${colors.reset} ${Array.from(user.locations).join(', ') || 'Unknown'}`);
+
+ if (user.firstSeen) {
+ const first = new Date(user.firstSeen);
+ const last = new Date(user.lastSeen);
+ console.log(` ${colors.magenta}⏱️ First Seen:${colors.reset} ${first.toLocaleString()}`);
+ console.log(` ${colors.magenta}⏱️ Last Seen:${colors.reset} ${last.toLocaleString()}`);
+ }
+
+ console.log(` ${colors.green}💬 Messages:${colors.reset} ${user.messageCount}`);
+
+ if (user.modes.size > 0) {
+ console.log(` ${colors.blue}🎭 Modes Used:${colors.reset} ${Array.from(user.modes).join(', ')}`);
+ }
+
+ // Show recent activity
+ const recentSessions = user.sessions
+ .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
+ .slice(0, 3);
+
+ if (recentSessions.length > 0) {
+ console.log(` ${colors.yellow}📅 Recent Activity:${colors.reset}`);
+ recentSessions.forEach(session => {
+ const time = new Date(session.timestamp).toLocaleString();
+ console.log(` • ${time} from ${session.ip}`);
+ });
+ }
+
+ console.log(` ${colors.cyan}${'-'.repeat(60)}${colors.reset}`);
+}));
+
+// Summary statistics
+const activeCount = sortedUsers.filter(([k, u]) =>
+ u.lastSeen && (new Date() - new Date(u.lastSeen)) < 3600000
+).length;
+
+const todayCount = sortedUsers.filter(([k, u]) =>
+ u.lastSeen && (new Date() - new Date(u.lastSeen)) < 86400000
+).length;
+
+console.log(`\n${colors.cyan}${'='.repeat(80)}${colors.reset}`);
+console.log(`${colors.bright}${colors.green}📈 SUMMARY STATISTICS${colors.reset}`);
+console.log(`${colors.cyan}${'='.repeat(80)}${colors.reset}\n`);
+
+console.log(`${colors.bright}Total Users:${colors.reset} ${sortedUsers.length}`);
+console.log(`${colors.green}Active (Last Hour):${colors.reset} ${activeCount}`);
+console.log(`${colors.yellow}Active Today:${colors.reset} ${todayCount}`);
+console.log(`${colors.blue}With Email:${colors.reset} ${sortedUsers.filter(([k, u]) => u.email).length}`);
+console.log(`${colors.magenta}Anonymous:${colors.reset} ${sortedUsers.filter(([k, u]) => u.userName === 'Anonymous').length}`);
+
+// Geographic distribution
+const locations = new Map();
+sortedUsers.forEach(([k, user]) => {
+ user.locations.forEach(loc => {
+ if (loc && loc !== 'Unknown') {
+ locations.set(loc, (locations.get(loc) || 0) + 1);
+ }
+ });
+});
+
+if (locations.size > 0) {
+ console.log(`\n${colors.bright}${colors.cyan}🌍 GEOGRAPHIC DISTRIBUTION:${colors.reset}`);
+ Array.from(locations.entries())
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 10)
+ .forEach(([loc, count]) => {
+ console.log(` • ${loc}: ${count} users`);
+ });
+}
+
+// Save detailed report
+const reportPath = '/root/Projects/dear-bubbe-admin/logs/user-report.json';
+const report = {
+ generatedAt: new Date().toISOString(),
+ totalUsers: sortedUsers.length,
+ activeInLastHour: activeCount,
+ activeToday: todayCount,
+ users: sortedUsers.map(([key, user]) => ({
+ key,
+ ...user,
+ ips: Array.from(user.ips),
+ locations: Array.from(user.locations),
+ modes: Array.from(user.modes)
+ }))
+};
+
+fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
+
+console.log(`\n${colors.green}✅ Detailed report saved to: ${reportPath}${colors.reset}`);
+console.log(`${colors.cyan}${'='.repeat(80)}${colors.reset}\n`);
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..7d3fd00
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,3512 @@
+{
+ "name": "dear-bubbe-admin",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "dear-bubbe-admin",
+ "version": "1.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "axios": "^1.6.0",
+ "bcryptjs": "^2.4.3",
+ "compression": "^1.8.1",
+ "cors": "^2.8.5",
+ "dotenv": "^16.6.1",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.1.2",
+ "google-auth-library": "^10.5.0",
+ "helmet": "^7.2.0",
+ "jsonwebtoken": "^9.0.2",
+ "moment": "^2.29.4",
+ "mongoose": "^7.5.2",
+ "multer": "^1.4.5-lts.1",
+ "node-cron": "^3.0.2",
+ "socket.io": "^4.8.1",
+ "winston": "^3.18.3"
+ },
+ "devDependencies": {
+ "concurrently": "^8.2.2",
+ "nodemon": "^3.0.1"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
+ "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@colors/colors": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
+ "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.1.90"
+ }
+ },
+ "node_modules/@dabh/diagnostics": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz",
+ "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@so-ric/colorspace": "^1.1.6",
+ "enabled": "2.0.x",
+ "kuler": "^2.0.0"
+ }
+ },
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "license": "MIT"
+ },
+ "node_modules/@isaacs/cliui/node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "license": "MIT",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/strip-ansi": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
+ "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/@mongodb-js/saslprep": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.2.tgz",
+ "integrity": "sha512-QgA5AySqB27cGTXBFmnpifAi7HxoGUeezwo6p9dI03MuDB6Pp33zgclqVb6oVK3j6I9Vesg0+oojW2XxB59SGg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "sparse-bitfield": "^3.0.3"
+ }
+ },
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@so-ric/colorspace": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz",
+ "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==",
+ "license": "MIT",
+ "dependencies": {
+ "color": "^5.0.2",
+ "text-hex": "1.0.x"
+ }
+ },
+ "node_modules/@socket.io/component-emitter": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
+ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/cors": {
+ "version": "2.8.19",
+ "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
+ "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "24.10.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
+ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
+ "node_modules/@types/triple-beam": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz",
+ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/webidl-conversions": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
+ "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/whatwg-url": {
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz",
+ "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "@types/webidl-conversions": "*"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/accepts/node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/append-field": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
+ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
+ "license": "MIT"
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/async": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+ "license": "MIT"
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
+ "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.6",
+ "form-data": "^4.0.4",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "license": "MIT"
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/base64id": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
+ "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
+ "license": "MIT",
+ "engines": {
+ "node": "^4.5.0 || >= 5.9"
+ }
+ },
+ "node_modules/bcryptjs": {
+ "version": "2.4.3",
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
+ "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
+ "license": "MIT"
+ },
+ "node_modules/bignumber.js": {
+ "version": "9.3.1",
+ "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
+ "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.3",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
+ "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.4.24",
+ "on-finished": "2.4.1",
+ "qs": "6.13.0",
+ "raw-body": "2.5.2",
+ "type-is": "~1.6.18",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/bson": {
+ "version": "5.5.1",
+ "resolved": "https://registry.npmjs.org/bson/-/bson-5.5.1.tgz",
+ "integrity": "sha512-ix0EwukN2EpC0SRWIj/7B5+A6uQMQy6KMREI9qQqvgpkV2frH63T0UDVd1SYedL6dNCmDBYB3QtXi4ISk9YT+g==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=14.20.1"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "license": "MIT"
+ },
+ "node_modules/busboy": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+ "dependencies": {
+ "streamsearch": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=10.16.0"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chalk/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/color": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
+ "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^3.1.3",
+ "color-string": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
+ "node_modules/color-string": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
+ "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/color-string/node_modules/color-name": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
+ "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20"
+ }
+ },
+ "node_modules/color/node_modules/color-convert": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz",
+ "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=14.6"
+ }
+ },
+ "node_modules/color/node_modules/color-name": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
+ "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20"
+ }
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/compressible": {
+ "version": "2.0.18",
+ "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
+ "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": ">= 1.43.0 < 2"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/compression": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
+ "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "compressible": "~2.0.18",
+ "debug": "2.6.9",
+ "negotiator": "~0.6.4",
+ "on-headers": "~1.1.0",
+ "safe-buffer": "5.2.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/concat-stream": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
+ "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
+ "engines": [
+ "node >= 0.8"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^2.2.2",
+ "typedarray": "^0.0.6"
+ }
+ },
+ "node_modules/concurrently": {
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
+ "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^4.1.2",
+ "date-fns": "^2.30.0",
+ "lodash": "^4.17.21",
+ "rxjs": "^7.8.1",
+ "shell-quote": "^1.8.1",
+ "spawn-command": "0.0.2",
+ "supports-color": "^8.1.1",
+ "tree-kill": "^1.2.2",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "conc": "dist/bin/concurrently.js",
+ "concurrently": "dist/bin/concurrently.js"
+ },
+ "engines": {
+ "node": "^14.13.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
+ "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "license": "MIT"
+ },
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "license": "MIT"
+ },
+ "node_modules/cors": {
+ "version": "2.8.5",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
+ "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/data-uri-to-buffer": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
+ "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/date-fns": {
+ "version": "2.30.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
+ "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.21.0"
+ },
+ "engines": {
+ "node": ">=0.11"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/date-fns"
+ }
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+ "license": "MIT"
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/enabled": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz",
+ "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/engine.io": {
+ "version": "6.6.4",
+ "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz",
+ "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/cors": "^2.8.12",
+ "@types/node": ">=10.0.0",
+ "accepts": "~1.3.4",
+ "base64id": "2.0.0",
+ "cookie": "~0.7.2",
+ "cors": "~2.8.5",
+ "debug": "~4.3.1",
+ "engine.io-parser": "~5.2.1",
+ "ws": "~8.17.1"
+ },
+ "engines": {
+ "node": ">=10.2.0"
+ }
+ },
+ "node_modules/engine.io-parser": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
+ "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/engine.io/node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/engine.io/node_modules/debug": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/engine.io/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.21.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
+ "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "1.20.3",
+ "content-disposition": "0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "0.7.1",
+ "cookie-signature": "1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "1.3.1",
+ "fresh": "0.5.2",
+ "http-errors": "2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "6.13.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "0.19.0",
+ "serve-static": "1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "7.5.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
+ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
+ "node_modules/fecha": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
+ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
+ "license": "MIT"
+ },
+ "node_modules/fetch-blob": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
+ "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "paypal",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "node-domexception": "^1.0.0",
+ "web-streams-polyfill": "^3.0.3"
+ },
+ "engines": {
+ "node": "^12.20 || >= 14.13"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
+ "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "2.0.1",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/fn.name": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz",
+ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
+ "license": "MIT"
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/foreground-child": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+ "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+ "license": "ISC",
+ "dependencies": {
+ "cross-spawn": "^7.0.6",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/formdata-polyfill": {
+ "version": "4.0.10",
+ "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
+ "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
+ "license": "MIT",
+ "dependencies": {
+ "fetch-blob": "^3.1.2"
+ },
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gaxios": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
+ "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "extend": "^3.0.2",
+ "https-proxy-agent": "^7.0.1",
+ "node-fetch": "^3.3.2",
+ "rimraf": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/gcp-metadata": {
+ "version": "8.1.2",
+ "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
+ "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "gaxios": "^7.0.0",
+ "google-logging-utils": "^1.0.0",
+ "json-bigint": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/glob": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+ "license": "ISC",
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/google-auth-library": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz",
+ "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "base64-js": "^1.3.0",
+ "ecdsa-sig-formatter": "^1.0.11",
+ "gaxios": "^7.0.0",
+ "gcp-metadata": "^8.0.0",
+ "google-logging-utils": "^1.0.0",
+ "gtoken": "^8.0.0",
+ "jws": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/google-auth-library/node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/google-auth-library/node_modules/jws": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
+ "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.0",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/google-logging-utils": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
+ "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gtoken": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz",
+ "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==",
+ "license": "MIT",
+ "dependencies": {
+ "gaxios": "^7.0.0",
+ "jws": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/gtoken/node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/gtoken/node_modules/jws": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
+ "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.0",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/helmet": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz",
+ "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
+ "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "2.0.0",
+ "inherits": "2.0.4",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "toidentifier": "1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/https-proxy-agent/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ignore-by-default": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
+ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ip-address": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
+ "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/jackspeak": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
+ "node_modules/json-bigint": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
+ "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bignumber.js": "^9.0.0"
+ }
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
+ "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^3.2.2",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/jwa": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
+ "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
+ "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^1.4.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/kareem": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz",
+ "integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/kuler": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
+ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
+ "license": "MIT"
+ },
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/logform": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
+ "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@colors/colors": "1.6.0",
+ "@types/triple-beam": "^1.3.2",
+ "fecha": "^4.2.0",
+ "ms": "^2.1.1",
+ "safe-stable-stringify": "^2.3.1",
+ "triple-beam": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ }
+ },
+ "node_modules/logform/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "license": "ISC"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/memory-pager": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
+ "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types/node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
+ "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "license": "MIT",
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/moment": {
+ "version": "2.30.1",
+ "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
+ "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/mongodb": {
+ "version": "5.9.2",
+ "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz",
+ "integrity": "sha512-H60HecKO4Bc+7dhOv4sJlgvenK4fQNqqUIlXxZYQNbfEWSALGAwGoyJd/0Qwk4TttFXUOHJ2ZJQe/52ScaUwtQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "bson": "^5.5.0",
+ "mongodb-connection-string-url": "^2.6.0",
+ "socks": "^2.7.1"
+ },
+ "engines": {
+ "node": ">=14.20.1"
+ },
+ "optionalDependencies": {
+ "@mongodb-js/saslprep": "^1.1.0"
+ },
+ "peerDependencies": {
+ "@aws-sdk/credential-providers": "^3.188.0",
+ "@mongodb-js/zstd": "^1.0.0",
+ "kerberos": "^1.0.0 || ^2.0.0",
+ "mongodb-client-encryption": ">=2.3.0 <3",
+ "snappy": "^7.2.2"
+ },
+ "peerDependenciesMeta": {
+ "@aws-sdk/credential-providers": {
+ "optional": true
+ },
+ "@mongodb-js/zstd": {
+ "optional": true
+ },
+ "kerberos": {
+ "optional": true
+ },
+ "mongodb-client-encryption": {
+ "optional": true
+ },
+ "snappy": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/mongodb-connection-string-url": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz",
+ "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/whatwg-url": "^8.2.1",
+ "whatwg-url": "^11.0.0"
+ }
+ },
+ "node_modules/mongoose": {
+ "version": "7.8.7",
+ "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-7.8.7.tgz",
+ "integrity": "sha512-5Bo4CrUxrPITrhMKsqUTOkXXo2CoRC5tXxVQhnddCzqDMwRXfyStrxj1oY865g8gaekSBhxAeNkYyUSJvGm9Hw==",
+ "license": "MIT",
+ "dependencies": {
+ "bson": "^5.5.0",
+ "kareem": "2.5.1",
+ "mongodb": "5.9.2",
+ "mpath": "0.9.0",
+ "mquery": "5.0.0",
+ "ms": "2.1.3",
+ "sift": "16.0.1"
+ },
+ "engines": {
+ "node": ">=14.20.1"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mongoose"
+ }
+ },
+ "node_modules/mongoose/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/mpath": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
+ "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/mquery": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz",
+ "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4.x"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/mquery/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/mquery/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/multer": {
+ "version": "1.4.5-lts.2",
+ "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
+ "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
+ "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
+ "license": "MIT",
+ "dependencies": {
+ "append-field": "^1.0.0",
+ "busboy": "^1.0.0",
+ "concat-stream": "^1.5.2",
+ "mkdirp": "^0.5.4",
+ "object-assign": "^4.1.1",
+ "type-is": "^1.6.4",
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
+ "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/node-cron": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
+ "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
+ "license": "ISC",
+ "dependencies": {
+ "uuid": "8.3.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/node-domexception": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+ "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+ "deprecated": "Use your platform's native DOMException instead",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "github",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.5.0"
+ }
+ },
+ "node_modules/node-fetch": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
+ "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
+ "license": "MIT",
+ "dependencies": {
+ "data-uri-to-buffer": "^4.0.0",
+ "fetch-blob": "^3.1.4",
+ "formdata-polyfill": "^4.0.10"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/node-fetch"
+ }
+ },
+ "node_modules/nodemon": {
+ "version": "3.1.11",
+ "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
+ "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^3.5.2",
+ "debug": "^4",
+ "ignore-by-default": "^1.0.1",
+ "minimatch": "^3.1.2",
+ "pstree.remy": "^1.1.8",
+ "semver": "^7.5.3",
+ "simple-update-notifier": "^2.0.0",
+ "supports-color": "^5.5.0",
+ "touch": "^3.1.0",
+ "undefsafe": "^2.0.5"
+ },
+ "bin": {
+ "nodemon": "bin/nodemon.js"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/nodemon"
+ }
+ },
+ "node_modules/nodemon/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/nodemon/node_modules/has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/nodemon/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nodemon/node_modules/supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/on-headers": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/one-time": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz",
+ "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==",
+ "license": "MIT",
+ "dependencies": {
+ "fn.name": "1.x.x"
+ }
+ },
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "license": "BlueOak-1.0.0"
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-scurry": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^10.2.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.12",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
+ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
+ "license": "MIT"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
+ "node_modules/pstree.remy": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
+ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.13.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
+ "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.0.6"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
+ "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.4.24",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/readable-stream/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "5.0.10",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
+ "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==",
+ "license": "ISC",
+ "dependencies": {
+ "glob": "^10.3.7"
+ },
+ "bin": {
+ "rimraf": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/rxjs": {
+ "version": "7.8.2",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safe-stable-stringify": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
+ "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.0",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
+ "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "0.5.2",
+ "http-errors": "2.0.0",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "2.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/encodeurl": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
+ "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.2",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
+ "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "0.19.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shell-quote": {
+ "version": "1.8.3",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
+ "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/sift": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz",
+ "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==",
+ "license": "MIT"
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/simple-update-notifier": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
+ "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/smart-buffer": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
+ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socket.io": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
+ "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.4",
+ "base64id": "~2.0.0",
+ "cors": "~2.8.5",
+ "debug": "~4.3.2",
+ "engine.io": "~6.6.0",
+ "socket.io-adapter": "~2.5.2",
+ "socket.io-parser": "~4.2.4"
+ },
+ "engines": {
+ "node": ">=10.2.0"
+ }
+ },
+ "node_modules/socket.io-adapter": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz",
+ "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "~4.3.4",
+ "ws": "~8.17.1"
+ }
+ },
+ "node_modules/socket.io-adapter/node_modules/debug": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/socket.io-adapter/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/socket.io-parser": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
+ "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
+ "license": "MIT",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.3.1"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/socket.io-parser/node_modules/debug": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/socket.io-parser/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/socket.io/node_modules/debug": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/socket.io/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/socks": {
+ "version": "2.8.7",
+ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
+ "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "^10.0.1",
+ "smart-buffer": "^4.2.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/sparse-bitfield": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
+ "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "memory-pager": "^1.0.2"
+ }
+ },
+ "node_modules/spawn-command": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
+ "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
+ "dev": true
+ },
+ "node_modules/stack-trace": {
+ "version": "0.0.10",
+ "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
+ "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
+ "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/streamsearch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/string_decoder/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/text-hex": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
+ "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==",
+ "license": "MIT"
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/touch": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
+ "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "nodetouch": "bin/nodetouch.js"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz",
+ "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==",
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/tree-kill": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "tree-kill": "cli.js"
+ }
+ },
+ "node_modules/triple-beam": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
+ "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD"
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/typedarray": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+ "license": "MIT"
+ },
+ "node_modules/undefsafe": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
+ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "license": "MIT"
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/web-streams-polyfill": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
+ "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz",
+ "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^3.0.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/winston": {
+ "version": "3.18.3",
+ "resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz",
+ "integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@colors/colors": "^1.6.0",
+ "@dabh/diagnostics": "^2.0.8",
+ "async": "^3.2.3",
+ "is-stream": "^2.0.0",
+ "logform": "^2.7.0",
+ "one-time": "^1.0.0",
+ "readable-stream": "^3.4.0",
+ "safe-stable-stringify": "^2.3.1",
+ "stack-trace": "0.0.x",
+ "triple-beam": "^1.3.0",
+ "winston-transport": "^4.9.0"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ }
+ },
+ "node_modules/winston-transport": {
+ "version": "4.9.0",
+ "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",
+ "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==",
+ "license": "MIT",
+ "dependencies": {
+ "logform": "^2.7.0",
+ "readable-stream": "^3.6.2",
+ "triple-beam": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ }
+ },
+ "node_modules/winston-transport/node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/winston/node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.17.1",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
+ "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..7896ca5
--- /dev/null
+++ b/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "dear-bubbe-admin",
+ "version": "1.0.0",
+ "description": "Admin Dashboard for Dear Bubbe AI with Real-time Monitoring and Alert Systems",
+ "main": "backend/server.js",
+ "scripts": {
+ "dev": "concurrently \"npm run backend\" \"npm run frontend\"",
+ "backend": "nodemon backend/server.js",
+ "frontend": "cd frontend && npm start",
+ "build": "cd frontend && npm run build",
+ "start": "node backend/server.js",
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "setup": "npm run setup:backend && npm run setup:frontend",
+ "setup:backend": "cd backend && npm install",
+ "setup:frontend": "cd frontend && npm install"
+ },
+ "dependencies": {
+ "axios": "^1.6.0",
+ "bcryptjs": "^2.4.3",
+ "compression": "^1.8.1",
+ "cors": "^2.8.5",
+ "dotenv": "^16.6.1",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.1.2",
+ "google-auth-library": "^10.5.0",
+ "helmet": "^7.2.0",
+ "jsonwebtoken": "^9.0.2",
+ "moment": "^2.29.4",
+ "mongoose": "^7.5.2",
+ "multer": "^1.4.5-lts.1",
+ "node-cron": "^3.0.2",
+ "socket.io": "^4.8.1",
+ "winston": "^3.18.3"
+ },
+ "devDependencies": {
+ "concurrently": "^8.2.2",
+ "nodemon": "^3.0.1"
+ },
+ "keywords": [
+ "admin",
+ "dashboard",
+ "monitoring",
+ "alerts",
+ "dear-bubbe"
+ ],
+ "author": "DW-Agents Team",
+ "license": "ISC"
+}
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..df58742
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+<rect width="32" height="32" rx="6" fill="#10b981"/>
+<text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-size="20" font-family="Apple Color Emoji, Segoe UI Emoji, sans-serif" fill="white">D</text>
+</svg>
\ No newline at end of file
diff --git a/public/instagram-1763680784110.png b/public/instagram-1763680784110.png
new file mode 100644
index 0000000..0b8f426
Binary files /dev/null and b/public/instagram-1763680784110.png differ
diff --git a/public/instagram-1763680819710.png b/public/instagram-1763680819710.png
new file mode 100644
index 0000000..0b8f426
Binary files /dev/null and b/public/instagram-1763680819710.png differ
diff --git a/public/instagram-1763680822723.png b/public/instagram-1763680822723.png
new file mode 100644
index 0000000..18dc87e
Binary files /dev/null and b/public/instagram-1763680822723.png differ
diff --git a/public/instagram-1763680825914.png b/public/instagram-1763680825914.png
new file mode 100644
index 0000000..f9aeb51
Binary files /dev/null and b/public/instagram-1763680825914.png differ
diff --git a/public/instagram-1763680829181.png b/public/instagram-1763680829181.png
new file mode 100644
index 0000000..af8682a
Binary files /dev/null and b/public/instagram-1763680829181.png differ
diff --git a/public/instagram-1763680832325.png b/public/instagram-1763680832325.png
new file mode 100644
index 0000000..5408f1a
Binary files /dev/null and b/public/instagram-1763680832325.png differ
diff --git a/public/instagram-1763680835466.png b/public/instagram-1763680835466.png
new file mode 100644
index 0000000..a2ef56f
Binary files /dev/null and b/public/instagram-1763680835466.png differ
diff --git a/public/instagram-1763680838580.png b/public/instagram-1763680838580.png
new file mode 100644
index 0000000..1353cae
Binary files /dev/null and b/public/instagram-1763680838580.png differ
diff --git a/public/instagram-1763680841833.png b/public/instagram-1763680841833.png
new file mode 100644
index 0000000..5408f1a
Binary files /dev/null and b/public/instagram-1763680841833.png differ
diff --git a/public/instagram-1763680844930.png b/public/instagram-1763680844930.png
new file mode 100644
index 0000000..d998283
Binary files /dev/null and b/public/instagram-1763680844930.png differ
diff --git a/public/instagram-1763680848213.png b/public/instagram-1763680848213.png
new file mode 100644
index 0000000..d6189ec
Binary files /dev/null and b/public/instagram-1763680848213.png differ
diff --git a/public/instagram-1763680895477.png b/public/instagram-1763680895477.png
new file mode 100644
index 0000000..106f035
Binary files /dev/null and b/public/instagram-1763680895477.png differ
diff --git a/public/instagram-1763681128801.png b/public/instagram-1763681128801.png
new file mode 100644
index 0000000..1353cae
Binary files /dev/null and b/public/instagram-1763681128801.png differ
diff --git a/public/instagram-1763681171228.png b/public/instagram-1763681171228.png
new file mode 100644
index 0000000..8624c83
Binary files /dev/null and b/public/instagram-1763681171228.png differ
diff --git a/public/instagram-1763682870613.png b/public/instagram-1763682870613.png
new file mode 100644
index 0000000..106f035
Binary files /dev/null and b/public/instagram-1763682870613.png differ
diff --git a/public/instagram-1763684539865.png b/public/instagram-1763684539865.png
new file mode 100644
index 0000000..8ae6b8f
Binary files /dev/null and b/public/instagram-1763684539865.png differ
diff --git a/public/instagram-1763684730100.png b/public/instagram-1763684730100.png
new file mode 100644
index 0000000..ad9dd36
Binary files /dev/null and b/public/instagram-1763684730100.png differ
diff --git a/public/instagram-1763685242269.png b/public/instagram-1763685242269.png
new file mode 100644
index 0000000..1df33de
Binary files /dev/null and b/public/instagram-1763685242269.png differ
diff --git a/public/instagram-1763685682137.png b/public/instagram-1763685682137.png
new file mode 100644
index 0000000..df4d366
Binary files /dev/null and b/public/instagram-1763685682137.png differ
diff --git a/public/instagram-1763686094031.png b/public/instagram-1763686094031.png
new file mode 100644
index 0000000..7069272
Binary files /dev/null and b/public/instagram-1763686094031.png differ
diff --git a/public/instagram-caption-1763681171228.txt b/public/instagram-caption-1763681171228.txt
new file mode 100644
index 0000000..e246c66
--- /dev/null
+++ b/public/instagram-caption-1763681171228.txt
@@ -0,0 +1,7 @@
+Today's anonymous question:
+
+"Make matso baii chicken soup. It heals a cold."
+
+🥯 What would Bubbe say? Find out at bubbe.ai (link in bio)
+
+#BubbeAI #JewishGrandma #AIAdvice #BubbeWisdom #AskBubbe #NoFilter #JewishHumor #GrandmaKnowsBest #LifeAdvice #TechMeetsTraition
\ No newline at end of file
diff --git a/public/instagram-caption.txt b/public/instagram-caption.txt
new file mode 100644
index 0000000..c490277
--- /dev/null
+++ b/public/instagram-caption.txt
@@ -0,0 +1,7 @@
+Someone needs Bubbe's wisdom:
+
+"My neighbor is too noisy"
+
+🥯 What would Bubbe say? Find out at bubbe.ai (link in bio)
+
+#BubbeAI #JewishGrandma #AIAdvice #BubbeWisdom #AskBubbe #JewishHumor #GrandmaKnowsBest #GrandmaAdvice #MotivationMonday #ModernBubbe
\ No newline at end of file
diff --git a/public/instagram-latest-caption.txt b/public/instagram-latest-caption.txt
new file mode 100644
index 0000000..d1351d5
--- /dev/null
+++ b/public/instagram-latest-caption.txt
@@ -0,0 +1,7 @@
+Someone asked Bubbe for advice today...
+
+"My kids never call me"
+
+🥯 What would Bubbe say? Find out at bubbe.ai (link in bio)
+
+#BubbeAI #JewishGrandma #AIAdvice #BubbeWisdom #AskBubbe #MotivationMonday #WisdomWednesday #RealTalk #ThrowbackThursday #JewishHumor
\ No newline at end of file
diff --git a/public/instagram-post.html b/public/instagram-post.html
new file mode 100644
index 0000000..331abe4
--- /dev/null
+++ b/public/instagram-post.html
@@ -0,0 +1,112 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Instagram Post Ready</title>
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <style>
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ max-width: 600px;
+ margin: 0 auto;
+ padding: 20px;
+ background: #fafafa;
+ }
+ .container {
+ background: white;
+ border-radius: 8px;
+ padding: 20px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.12);
+ }
+ h1 {
+ color: #262626;
+ font-size: 24px;
+ margin-bottom: 20px;
+ }
+ .image-container {
+ width: 100%;
+ margin-bottom: 20px;
+ }
+ img {
+ width: 100%;
+ border-radius: 8px;
+ }
+ .caption-box {
+ background: #f7f7f7;
+ padding: 15px;
+ border-radius: 8px;
+ white-space: pre-wrap;
+ font-size: 14px;
+ line-height: 1.5;
+ margin-bottom: 20px;
+ }
+ .download-btn {
+ display: inline-block;
+ background: #0095f6;
+ color: white;
+ padding: 12px 24px;
+ border-radius: 8px;
+ text-decoration: none;
+ font-weight: 600;
+ margin-right: 10px;
+ }
+ .instructions {
+ background: #fff3cd;
+ padding: 15px;
+ border-radius: 8px;
+ margin-top: 20px;
+ }
+ .instructions h2 {
+ margin-top: 0;
+ color: #856404;
+ }
+ .instructions ol {
+ margin: 10px 0;
+ padding-left: 20px;
+ }
+ </style>
+</head>
+<body>
+ <div class="container">
+ <h1>📸 Instagram Post Ready!</h1>
+
+ <div class="image-container">
+ <img src="/instagram-1763681171228.png" alt="Instagram Post">
+ </div>
+
+ <h2>Caption:</h2>
+ <div class="caption-box">Today's anonymous question:
+
+"Make matso baii chicken soup. It heals a cold."
+
+🥯 What would Bubbe say? Find out at bubbe.ai (link in bio)
+
+#BubbeAI #JewishGrandma #AIAdvice #BubbeWisdom #AskBubbe #NoFilter #JewishHumor #GrandmaKnowsBest #LifeAdvice #TechMeetsTraition</div>
+
+ <a href="/instagram-1763681171228.png" download="instagram-post.png" class="download-btn">⬇️ Download Image</a>
+ <a href="/instagram-caption-1763681171228.txt" download="instagram-caption.txt" class="download-btn">📋 Download Caption</a>
+
+ <div class="instructions">
+ <h2>📱 How to Post:</h2>
+ <ol>
+ <li>Download the image above</li>
+ <li>Open Instagram app or <a href="https://instagram.com" target="_blank">instagram.com</a></li>
+ <li>Click "New Post" (+)</li>
+ <li>Upload the downloaded image</li>
+ <li>Copy and paste the caption</li>
+ <li>Share!</li>
+ </ol>
+
+ <h2>🔗 Quick Links:</h2>
+ <ul>
+ <li><a href="https://instagram.com" target="_blank">Instagram Web</a></li>
+ <li><a href="https://business.facebook.com/creatorstudio" target="_blank">Creator Studio (Schedule Posts)</a></li>
+ <li><a href="https://later.com" target="_blank">Later (Free Scheduling)</a></li>
+ </ul>
+ </div>
+
+ <p style="text-align: center; margin-top: 30px; color: #8e8e8e;">
+ Generated at 11/20/2025, 11:26:11 PM
+ </p>
+ </div>
+</body>
+</html>
\ No newline at end of file
diff --git a/public/instagram-schedule.json b/public/instagram-schedule.json
new file mode 100644
index 0000000..ddc07a9
--- /dev/null
+++ b/public/instagram-schedule.json
@@ -0,0 +1,15 @@
+{
+ "posts": [
+ {
+ "text": "Someone asked Bubbe for advice today...\n\n\"My kids never call me\"\n\n🥯 What would Bubbe say? Find out at bubbe.ai (link in bio)\n\n#BubbeAI #JewishGrandma #AIAdvice #BubbeWisdom #AskBubbe #MotivationMonday #WisdomWednesday #RealTalk #ThrowbackThursday #JewishHumor",
+ "media": {
+ "photo": "http://45.61.58.125:7902/instagram-1763684730100.png",
+ "thumbnail": "http://45.61.58.125:7902/instagram-1763684730100.png"
+ },
+ "platforms": [
+ "instagram"
+ ],
+ "scheduled_at": "2025-11-21T02:25:30.212Z"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/public/instagram-webhook.json b/public/instagram-webhook.json
new file mode 100644
index 0000000..58573e3
--- /dev/null
+++ b/public/instagram-webhook.json
@@ -0,0 +1,19 @@
+{
+ "platform": "instagram",
+ "image_url": "http://45.61.58.125:7902/instagram-1763684730100.png",
+ "caption": "Someone asked Bubbe for advice today...\n\n\"My kids never call me\"\n\n🥯 What would Bubbe say? Find out at bubbe.ai (link in bio)\n\n#BubbeAI #JewishGrandma #AIAdvice #BubbeWisdom #AskBubbe #MotivationMonday #WisdomWednesday #RealTalk #ThrowbackThursday #JewishHumor",
+ "hashtags": [
+ "#BubbeAI",
+ "#JewishGrandma",
+ "#AIAdvice",
+ "#BubbeWisdom",
+ "#AskBubbe",
+ "#MotivationMonday",
+ "#WisdomWednesday",
+ "#RealTalk",
+ "#ThrowbackThursday",
+ "#JewishHumor"
+ ],
+ "timestamp": "2025-11-21T00:25:30.211Z",
+ "webhook_url": "http://45.61.58.125:7902/instagram-webhook.json"
+}
\ No newline at end of file
diff --git a/setup-admin.sh b/setup-admin.sh
new file mode 100755
index 0000000..9e0bffa
--- /dev/null
+++ b/setup-admin.sh
@@ -0,0 +1,125 @@
+#!/bin/bash
+
+# Dear Bubbe Admin Dashboard Setup Script
+echo "🚀 Setting up Dear Bubbe Admin Dashboard..."
+
+# Create necessary directories
+mkdir -p logs reports exports uploads backend/logs
+
+# Install backend dependencies
+echo "📦 Installing backend dependencies..."
+npm install
+
+# Install frontend dependencies
+echo "📦 Installing frontend dependencies..."
+cd frontend
+npm install
+cd ..
+
+# Build frontend for production
+echo "🏗️ Building frontend..."
+cd frontend
+npm run build
+cd ..
+
+# Check if MongoDB is running
+echo "🔍 Checking MongoDB status..."
+if systemctl is-active --quiet mongod; then
+ echo "✅ MongoDB is running"
+else
+ echo "⚠️ MongoDB is not running. Starting MongoDB..."
+ sudo systemctl start mongod
+ sudo systemctl enable mongod
+
+ # Wait for MongoDB to start
+ sleep 3
+
+ if systemctl is-active --quiet mongod; then
+ echo "✅ MongoDB started successfully"
+ else
+ echo "❌ Failed to start MongoDB. Please start it manually."
+ exit 1
+ fi
+fi
+
+# Create MongoDB database and collections
+echo "🗃️ Setting up MongoDB database..."
+mongo << 'EOF'
+use dear-bubbe-admin;
+
+// Create collections with indexes
+db.alerts.createIndex({ timestamp: -1 });
+db.alerts.createIndex({ "userInfo.ip": 1, timestamp: -1 });
+db.alerts.createIndex({ type: 1, severity: 1 });
+db.alerts.createIndex({ status: 1, timestamp: -1 });
+
+db.users.createIndex({ userId: 1 }, { unique: true });
+db.users.createIndex({ "security.blocked": 1 });
+db.users.createIndex({ "security.riskLevel": 1 });
+db.users.createIndex({ "stats.lastVisit": -1 });
+
+db.sessions.createIndex({ sessionId: 1 }, { unique: true });
+db.sessions.createIndex({ userId: 1, startTime: -1 });
+db.sessions.createIndex({ ended: 1, lastActivity: -1 });
+
+db.conversations.createIndex({ timestamp: -1 });
+db.conversations.createIndex({ userId: 1, timestamp: -1 });
+db.conversations.createIndex({ topics: 1 });
+
+print("Database and indexes created successfully");
+exit
+EOF
+
+# Set up firewall rules
+echo "🔒 Setting up firewall rules..."
+sudo ufw allow 3015/tcp
+echo "✅ Port 3015 opened for admin dashboard"
+
+# Set proper permissions
+echo "🔑 Setting up permissions..."
+chmod +x backend/server.js 2>/dev/null || true
+chmod +x setup-admin.sh
+chmod -R 755 logs reports exports uploads 2>/dev/null || true
+
+# Copy built frontend to backend public directory
+if [ -d "frontend/build" ]; then
+ echo "📁 Copying built frontend..."
+ mkdir -p backend/public
+ cp -r frontend/build/* backend/public/
+ echo "✅ Frontend copied to backend"
+fi
+
+# Start the admin dashboard with PM2
+echo "🚀 Starting Dear Bubbe Admin Dashboard..."
+pm2 start ecosystem.config.js
+
+# Check if service is running
+sleep 3
+if pm2 list | grep -q "dear-bubbe-admin.*online"; then
+ echo ""
+ echo "✅ SUCCESS! Dear Bubbe Admin Dashboard is running!"
+ echo ""
+ echo "📊 Dashboard URL: http://45.61.58.125:3015"
+ echo "🔑 Default Login:"
+ echo " Username: admin"
+ echo " Password: password"
+ echo ""
+ echo "📋 Management Commands:"
+ echo " pm2 status dear-bubbe-admin - Check status"
+ echo " pm2 logs dear-bubbe-admin - View logs"
+ echo " pm2 restart dear-bubbe-admin - Restart service"
+ echo " pm2 stop dear-bubbe-admin - Stop service"
+ echo ""
+ echo "🔧 Configuration file: /root/Projects/dear-bubbe-admin/.env"
+ echo ""
+ echo "⚠️ IMPORTANT SECURITY NOTES:"
+ echo " 1. Change default admin password immediately"
+ echo " 2. Update JWT_SECRET in production"
+ echo " 3. Configure HTTPS for production use"
+ echo " 4. Set up proper MongoDB authentication"
+ echo ""
+else
+ echo "❌ Failed to start admin dashboard"
+ echo "📝 Check logs with: pm2 logs dear-bubbe-admin"
+ exit 1
+fi
\ No newline at end of file
(oldest)
·
back to Dear Bubbe Admin
·
fix: add rel=noopener noreferrer to target=_blank links; add a88b6fd →