feat: implement comprehensive email system with rate limiting and extensive test suite

- Add robust email service with rate limiting and configuration management
- Implement shared rate limiter utility for consistent API protection
- Create comprehensive test suite for core processing pipeline
- Add API tests for dashboard metrics and authentication routes
- Fix date range picker infinite loop issue
- Improve session lookup in refresh sessions API
- Refactor session API routing with better code organization
- Update processing pipeline status monitoring
- Clean up leftover files and improve code formatting
This commit is contained in:
2025-07-05 13:42:47 +02:00
committed by Kaj Kowalski
parent 19628233ea
commit a0ac60cf04
36 changed files with 10714 additions and 5292 deletions

View File

@ -2,92 +2,36 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@prisma/client";
import { Pool } from "pg";
import type { Pool } from "pg";
import { env } from "./env";
// Enhanced connection pool configuration
const createConnectionPool = () => {
// Parse DATABASE_URL to get connection parameters
const databaseUrl = new URL(env.DATABASE_URL);
const pool = new Pool({
host: databaseUrl.hostname,
port: Number.parseInt(databaseUrl.port) || 5432,
user: databaseUrl.username,
password: databaseUrl.password,
database: databaseUrl.pathname.slice(1), // Remove leading slash
ssl: databaseUrl.searchParams.get("sslmode") !== "disable",
// Connection pool configuration
max: env.DATABASE_CONNECTION_LIMIT, // Maximum number of connections
min: 2, // Minimum number of connections to maintain
idleTimeoutMillis: env.DATABASE_POOL_TIMEOUT * 1000, // Close idle connections after timeout
connectionTimeoutMillis: 10000, // Connection timeout
maxUses: 1000, // Maximum uses per connection before cycling
allowExitOnIdle: true, // Allow process to exit when all connections are idle
// Health check configuration
query_timeout: 30000, // Query timeout
keepAlive: true,
keepAliveInitialDelayMillis: 30000,
});
// Connection pool event handlers
pool.on("connect", () => {
console.log(
`Database connection established. Active connections: ${pool.totalCount}`
);
});
pool.on("acquire", () => {
console.log(
`Connection acquired from pool. Waiting: ${pool.waitingCount}, Idle: ${pool.idleCount}`
);
});
pool.on("release", () => {
console.log(
`Connection released to pool. Active: ${pool.totalCount - pool.idleCount}, Idle: ${pool.idleCount}`
);
});
pool.on("error", (err) => {
console.error("Database pool error:", err);
});
pool.on("remove", () => {
console.log(
`Connection removed from pool. Total connections: ${pool.totalCount}`
);
});
return pool;
};
// Create adapter with connection pool
export const createEnhancedPrismaClient = () => {
// Parse DATABASE_URL to get connection parameters
const dbUrl = new URL(env.DATABASE_URL);
const poolConfig = {
host: dbUrl.hostname,
port: parseInt(dbUrl.port || "5432"),
port: Number.parseInt(dbUrl.port || "5432"),
database: dbUrl.pathname.slice(1), // Remove leading '/'
user: dbUrl.username,
password: decodeURIComponent(dbUrl.password),
ssl: dbUrl.searchParams.get("sslmode") !== "disable" ? { rejectUnauthorized: false } : undefined,
ssl:
dbUrl.searchParams.get("sslmode") !== "disable"
? { rejectUnauthorized: false }
: undefined,
// Connection pool settings
max: 20, // Maximum number of connections
idleTimeoutMillis: 30000, // 30 seconds
max: env.DATABASE_CONNECTION_LIMIT || 20, // Maximum number of connections
idleTimeoutMillis: env.DATABASE_POOL_TIMEOUT * 1000 || 30000, // Use env timeout
connectionTimeoutMillis: 5000, // 5 seconds
query_timeout: 10000, // 10 seconds
statement_timeout: 10000, // 10 seconds
// Connection lifecycle
allowExitOnIdle: true,
};
const adapter = new PrismaPg(poolConfig);
return new PrismaClient({

View File

@ -434,7 +434,7 @@ async function processQueuedImportsInternal(batchSize = 50): Promise<void> {
// Process with concurrency limit to avoid overwhelming the database
const concurrencyLimit = 5;
const results: Array<{
importRecord: typeof unprocessedImports[0];
importRecord: (typeof unprocessedImports)[0];
result: Awaited<ReturnType<typeof processSingleImport>>;
}> = [];

View File

@ -321,11 +321,346 @@ const stopWords = new Set([
// Add more domain-specific stop words if necessary
]);
/**
* Extract unique user identifiers from session data
*/
function extractUniqueUsers(
session: ChatSession,
uniqueUserIds: Set<string>
): void {
let identifierAdded = false;
if (session.ipAddress && session.ipAddress.trim() !== "") {
uniqueUserIds.add(session.ipAddress.trim());
identifierAdded = true;
}
// Fallback to sessionId only if ipAddress was not usable and sessionId is valid
if (
!identifierAdded &&
session.sessionId &&
session.sessionId.trim() !== ""
) {
uniqueUserIds.add(session.sessionId.trim());
}
}
/**
* Validate and convert timestamps to milliseconds
*/
function validateTimestamps(
session: ChatSession,
startTimeMs: number,
endTimeMs: number
): boolean {
if (Number.isNaN(startTimeMs)) {
console.warn(
`[metrics] Invalid startTime for session ${session.id || session.sessionId}: ${session.startTime}`
);
return false;
}
if (Number.isNaN(endTimeMs)) {
console.warn(
`[metrics] Invalid endTime for session ${session.id || session.sessionId}: ${session.endTime}`
);
return false;
}
return true;
}
/**
* Log duration warnings for edge cases
*/
function logDurationWarnings(
session: ChatSession,
timeDifference: number,
duration: number
): void {
if (timeDifference < 0) {
console.warn(
`[metrics] endTime (${session.endTime}) was before startTime (${session.startTime}) for session ${session.id || session.sessionId}. Using absolute difference as duration (${(duration / 1000).toFixed(2)} seconds).`
);
}
}
/**
* Calculate session duration and update totals
*/
function processSessionDuration(
session: ChatSession,
totals: { totalSessionDuration: number; validSessionsForDuration: number }
): void {
if (!session.startTime || !session.endTime) {
if (!session.startTime) {
console.warn(
`[metrics] Missing startTime for session ${session.id || session.sessionId}`
);
}
if (!session.endTime) {
console.log(
`[metrics] Missing endTime for session ${session.id || session.sessionId} - likely ongoing or data issue.`
);
}
return;
}
const startTimeMs = new Date(session.startTime).getTime();
const endTimeMs = new Date(session.endTime).getTime();
if (!validateTimestamps(session, startTimeMs, endTimeMs)) {
return;
}
const timeDifference = endTimeMs - startTimeMs;
const duration = Math.abs(timeDifference);
totals.totalSessionDuration += duration;
totals.validSessionsForDuration++;
logDurationWarnings(session, timeDifference, duration);
}
/**
* Update sentiment counters based on session sentiment
*/
function processSentiment(
session: ChatSession,
sentimentCounts: {
sentimentPositiveCount: number;
sentimentNeutralCount: number;
sentimentNegativeCount: number;
}
): void {
if (session.sentiment !== undefined && session.sentiment !== null) {
if (session.sentiment === "POSITIVE")
sentimentCounts.sentimentPositiveCount++;
else if (session.sentiment === "NEGATIVE")
sentimentCounts.sentimentNegativeCount++;
else if (session.sentiment === "NEUTRAL")
sentimentCounts.sentimentNeutralCount++;
}
}
/**
* Update category-based metrics
*/
function updateCategoryMetrics(
session: ChatSession,
metrics: {
byDay: DayMetrics;
byCategory: CategoryMetrics;
byLanguage: LanguageMetrics;
byCountry: CountryMetrics;
}
): void {
// Daily metrics
const day = new Date(session.startTime).toISOString().split("T")[0];
metrics.byDay[day] = (metrics.byDay[day] || 0) + 1;
// Category metrics
if (session.category) {
metrics.byCategory[session.category] =
(metrics.byCategory[session.category] || 0) + 1;
}
// Language metrics
if (session.language) {
metrics.byLanguage[session.language] =
(metrics.byLanguage[session.language] || 0) + 1;
}
// Country metrics
if (session.country) {
metrics.byCountry[session.country] =
(metrics.byCountry[session.country] || 0) + 1;
}
}
/**
* Extract questions from session messages and initial message
*/
function extractQuestions(
session: ChatSession,
questionCounts: { [question: string]: number }
): void {
const isQuestion = (content: string): boolean => {
return (
content.endsWith("?") ||
/\b(what|when|where|why|how|who|which|can|could|would|will|is|are|do|does|did)\b/i.test(
content
)
);
};
// Extract questions from user messages
if (session.messages) {
session.messages
.filter((msg) => msg.role === "User")
.forEach((msg) => {
const content = msg.content.trim();
if (isQuestion(content)) {
questionCounts[content] = (questionCounts[content] || 0) + 1;
}
});
}
// Extract questions from initial message as fallback
if (session.initialMsg) {
const content = session.initialMsg.trim();
if (isQuestion(content)) {
questionCounts[content] = (questionCounts[content] || 0) + 1;
}
}
}
/**
* Process text for word cloud generation
*/
function processTextForWordCloud(
text: string | undefined | null,
wordCounts: { [key: string]: number }
): void {
if (!text) return;
const words = text
.toLowerCase()
.replace(/[^\w\s'-]/gi, "")
.split(/\s+/);
for (const word of words) {
const cleanedWord = word.replace(/^['-]|['-]$/g, "");
if (cleanedWord && !stopWords.has(cleanedWord) && cleanedWord.length > 2) {
wordCounts[cleanedWord] = (wordCounts[cleanedWord] || 0) + 1;
}
}
}
/**
* Calculate peak usage time from hourly session counts
*/
function calculatePeakUsageTime(hourlySessionCounts: {
[hour: string]: number;
}): string {
if (Object.keys(hourlySessionCounts).length === 0) {
return "N/A";
}
const peakHour = Object.entries(hourlySessionCounts).sort(
([, a], [, b]) => b - a
)[0][0];
const peakHourNum = Number.parseInt(peakHour.split(":")[0]);
const endHour = (peakHourNum + 1) % 24;
return `${peakHour}-${endHour.toString().padStart(2, "0")}:00`;
}
/**
* Calculate top questions from question counts
*/
function calculateTopQuestions(questionCounts: {
[question: string]: number;
}): TopQuestion[] {
return Object.entries(questionCounts)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([question, count]) => ({ question, count }));
}
/**
* Process a single session and update all metrics
*/
function processSession(
session: ChatSession,
companyConfig: CompanyConfig,
metrics: {
uniqueUserIds: Set<string>;
sessionDurationTotals: {
totalSessionDuration: number;
validSessionsForDuration: number;
};
sentimentCounts: {
sentimentPositiveCount: number;
sentimentNeutralCount: number;
sentimentNegativeCount: number;
};
categoryMetrics: {
byDay: DayMetrics;
byCategory: CategoryMetrics;
byLanguage: LanguageMetrics;
byCountry: CountryMetrics;
};
hourlySessionCounts: { [hour: string]: number };
questionCounts: { [question: string]: number };
wordCounts: { [key: string]: number };
counters: {
escalatedCount: number;
forwardedHrCount: number;
totalResponseTime: number;
validSessionsForResponseTime: number;
alerts: number;
resolvedChatsCount: number;
};
}
): void {
// Track hourly usage
if (session.startTime) {
const hour = new Date(session.startTime).getHours();
const hourKey = `${hour.toString().padStart(2, "0")}:00`;
metrics.hourlySessionCounts[hourKey] =
(metrics.hourlySessionCounts[hourKey] || 0) + 1;
}
// Count resolved chats
if (session.endTime && !session.escalated) {
metrics.counters.resolvedChatsCount++;
}
// Extract unique users
extractUniqueUsers(session, metrics.uniqueUserIds);
// Process session duration
processSessionDuration(session, metrics.sessionDurationTotals);
// Process response time
if (
session.avgResponseTime !== undefined &&
session.avgResponseTime !== null &&
session.avgResponseTime >= 0
) {
metrics.counters.totalResponseTime += session.avgResponseTime;
metrics.counters.validSessionsForResponseTime++;
}
// Count escalated and forwarded
if (session.escalated) metrics.counters.escalatedCount++;
if (session.forwardedHr) metrics.counters.forwardedHrCount++;
// Process sentiment
processSentiment(session, metrics.sentimentCounts);
// Check sentiment alerts
if (
companyConfig.sentimentAlert !== undefined &&
session.sentiment === "NEGATIVE"
) {
metrics.counters.alerts++;
}
// Update category metrics
updateCategoryMetrics(session, metrics.categoryMetrics);
// Extract questions
extractQuestions(session, metrics.questionCounts);
// Process text for word cloud
processTextForWordCloud(session.initialMsg, metrics.wordCounts);
}
/**
* Main function to calculate session metrics with reduced complexity
*/
export function sessionMetrics(
sessions: ChatSession[],
companyConfig: CompanyConfig = {}
): MetricsResult {
const totalSessions = sessions.length; // Renamed from 'total' for clarity
const totalSessions = sessions.length;
const byDay: DayMetrics = {};
const byCategory: CategoryMetrics = {};
const byLanguage: LanguageMetrics = {};
@ -333,236 +668,56 @@ export function sessionMetrics(
const tokensByDay: DayMetrics = {};
const tokensCostByDay: DayMetrics = {};
let escalatedCount = 0; // Renamed from 'escalated' to match MetricsResult
let forwardedHrCount = 0; // Renamed from 'forwarded' to match MetricsResult
// Variables for calculations
const uniqueUserIds = new Set<string>();
let totalSessionDuration = 0;
let validSessionsForDuration = 0;
let totalResponseTime = 0;
let validSessionsForResponseTime = 0;
let sentimentPositiveCount = 0;
let sentimentNeutralCount = 0;
let sentimentNegativeCount = 0;
const totalTokens = 0;
const totalTokensEur = 0;
const wordCounts: { [key: string]: number } = {};
let alerts = 0;
// New metrics variables
const hourlySessionCounts: { [hour: string]: number } = {};
let resolvedChatsCount = 0;
const questionCounts: { [question: string]: number } = {};
// Initialize all metrics in a structured way
const metrics = {
uniqueUserIds: new Set<string>(),
sessionDurationTotals: {
totalSessionDuration: 0,
validSessionsForDuration: 0,
},
sentimentCounts: {
sentimentPositiveCount: 0,
sentimentNeutralCount: 0,
sentimentNegativeCount: 0,
},
categoryMetrics: { byDay, byCategory, byLanguage, byCountry },
hourlySessionCounts: {} as { [hour: string]: number },
questionCounts: {} as { [question: string]: number },
wordCounts: {} as { [key: string]: number },
counters: {
escalatedCount: 0,
forwardedHrCount: 0,
totalResponseTime: 0,
validSessionsForResponseTime: 0,
alerts: 0,
resolvedChatsCount: 0,
},
};
// Process each session
for (const session of sessions) {
// Track hourly usage for peak time calculation
if (session.startTime) {
const hour = new Date(session.startTime).getHours();
const hourKey = `${hour.toString().padStart(2, "0")}:00`;
hourlySessionCounts[hourKey] = (hourlySessionCounts[hourKey] || 0) + 1;
}
// Count resolved chats (sessions that have ended and are not escalated)
if (session.endTime && !session.escalated) {
resolvedChatsCount++;
}
// Unique Users: Prefer non-empty ipAddress, fallback to non-empty sessionId
let identifierAdded = false;
if (session.ipAddress && session.ipAddress.trim() !== "") {
uniqueUserIds.add(session.ipAddress.trim());
identifierAdded = true;
}
// Fallback to sessionId only if ipAddress was not usable and sessionId is valid
if (
!identifierAdded &&
session.sessionId &&
session.sessionId.trim() !== ""
) {
uniqueUserIds.add(session.sessionId.trim());
}
// Avg. Session Time
if (session.startTime && session.endTime) {
const startTimeMs = new Date(session.startTime).getTime();
const endTimeMs = new Date(session.endTime).getTime();
if (Number.isNaN(startTimeMs)) {
console.warn(
`[metrics] Invalid startTime for session ${session.id || session.sessionId}: ${session.startTime}`
);
}
if (Number.isNaN(endTimeMs)) {
console.warn(
`[metrics] Invalid endTime for session ${session.id || session.sessionId}: ${session.endTime}`
);
}
if (!Number.isNaN(startTimeMs) && !Number.isNaN(endTimeMs)) {
const timeDifference = endTimeMs - startTimeMs; // Calculate the signed delta
// Use the absolute difference for duration, ensuring it's not negative.
// If times are identical, duration will be 0.
// If endTime is before startTime, this still yields a positive duration representing the magnitude of the difference.
const duration = Math.abs(timeDifference);
// console.log(
// `[metrics] duration is ${duration} for session ${session.id || session.sessionId}`
// );
totalSessionDuration += duration; // Add this duration
if (timeDifference < 0) {
// Log a specific warning if the original endTime was before startTime
console.warn(
`[metrics] endTime (${session.endTime}) was before startTime (${session.startTime}) for session ${session.id || session.sessionId}. Using absolute difference as duration (${(duration / 1000).toFixed(2)} seconds).`
);
} else if (timeDifference === 0) {
// // Optionally, log if times are identical, though this might be verbose if common
// console.log(
// `[metrics] startTime and endTime are identical for session ${session.id || session.sessionId}. Duration is 0.`
// );
}
// If timeDifference > 0, it's a normal positive duration, no special logging needed here for that case.
validSessionsForDuration++; // Count this session for averaging
}
} else {
if (!session.startTime) {
console.warn(
`[metrics] Missing startTime for session ${session.id || session.sessionId}`
);
}
if (!session.endTime) {
// This is a common case for ongoing sessions, might not always be an error
console.log(
`[metrics] Missing endTime for session ${session.id || session.sessionId} - likely ongoing or data issue.`
);
}
}
// Avg. Response Time
if (
session.avgResponseTime !== undefined &&
session.avgResponseTime !== null &&
session.avgResponseTime >= 0
) {
totalResponseTime += session.avgResponseTime;
validSessionsForResponseTime++;
}
// Escalated and Forwarded
if (session.escalated) escalatedCount++;
if (session.forwardedHr) forwardedHrCount++;
// Sentiment (now using enum values)
if (session.sentiment !== undefined && session.sentiment !== null) {
if (session.sentiment === "POSITIVE") sentimentPositiveCount++;
else if (session.sentiment === "NEGATIVE") sentimentNegativeCount++;
else if (session.sentiment === "NEUTRAL") sentimentNeutralCount++;
}
// Sentiment Alert Check (simplified for enum)
if (
companyConfig.sentimentAlert !== undefined &&
session.sentiment === "NEGATIVE"
) {
alerts++;
}
// Daily metrics
const day = new Date(session.startTime).toISOString().split("T")[0];
byDay[day] = (byDay[day] || 0) + 1; // Sessions per day
// Note: tokens and tokensEur are not available in the new schema
// Category metrics
if (session.category) {
byCategory[session.category] = (byCategory[session.category] || 0) + 1;
}
// Language metrics
if (session.language) {
byLanguage[session.language] = (byLanguage[session.language] || 0) + 1;
}
// Country metrics
if (session.country) {
byCountry[session.country] = (byCountry[session.country] || 0) + 1;
}
// Extract questions from session
const extractQuestions = () => {
// 1. Extract questions from user messages (if available)
if (session.messages) {
session.messages
.filter((msg) => msg.role === "User")
.forEach((msg) => {
const content = msg.content.trim();
// Simple heuristic: if message ends with ? or contains question words, treat as question
if (
content.endsWith("?") ||
/\b(what|when|where|why|how|who|which|can|could|would|will|is|are|do|does|did)\b/i.test(
content
)
) {
questionCounts[content] = (questionCounts[content] || 0) + 1;
}
});
}
// 3. Extract questions from initial message as fallback
if (session.initialMsg) {
const content = session.initialMsg.trim();
if (
content.endsWith("?") ||
/\b(what|when|where|why|how|who|which|can|could|would|will|is|are|do|does|did)\b/i.test(
content
)
) {
questionCounts[content] = (questionCounts[content] || 0) + 1;
}
}
};
extractQuestions();
// Word Cloud Data (from initial message and transcript content)
const processTextForWordCloud = (text: string | undefined | null) => {
if (!text) return;
const words = text
.toLowerCase()
.replace(/[^\w\s'-]/gi, "")
.split(/\s+/); // Keep apostrophes and hyphens
for (const word of words) {
const cleanedWord = word.replace(/^['-]|['-]$/g, ""); // Remove leading/trailing apostrophes/hyphens
if (
cleanedWord &&
!stopWords.has(cleanedWord) &&
cleanedWord.length > 2
) {
wordCounts[cleanedWord] = (wordCounts[cleanedWord] || 0) + 1;
}
}
};
processTextForWordCloud(session.initialMsg);
// Note: transcriptContent is not available in ChatSession type
// Could be added later if transcript parsing is implemented
processSession(session, companyConfig, metrics);
}
const uniqueUsers = uniqueUserIds.size;
// Calculate derived metrics
const uniqueUsers = metrics.uniqueUserIds.size;
const avgSessionLength =
validSessionsForDuration > 0
? totalSessionDuration / validSessionsForDuration / 1000 // Convert ms to minutes
metrics.sessionDurationTotals.validSessionsForDuration > 0
? metrics.sessionDurationTotals.totalSessionDuration /
metrics.sessionDurationTotals.validSessionsForDuration /
1000
: 0;
const avgResponseTime =
validSessionsForResponseTime > 0
? totalResponseTime / validSessionsForResponseTime
: 0; // in seconds
metrics.counters.validSessionsForResponseTime > 0
? metrics.counters.totalResponseTime /
metrics.counters.validSessionsForResponseTime
: 0;
const wordCloudData: WordCloudWord[] = Object.entries(wordCounts)
const wordCloudData: WordCloudWord[] = Object.entries(metrics.wordCounts)
.sort(([, a], [, b]) => b - a)
.slice(0, 50) // Top 50 words
.slice(0, 50)
.map(([text, value]) => ({ text, value }));
// Calculate avgSessionsPerDay
const numDaysWithSessions = Object.keys(byDay).length;
const avgSessionsPerDay =
numDaysWithSessions > 0 ? totalSessions / numDaysWithSessions : 0;
@ -585,73 +740,48 @@ export function sessionMetrics(
mockPreviousPeriodData.avgResponseTime
);
// Calculate new metrics
// 1. Average Daily Costs (euros)
// Calculate additional metrics
const totalTokens = 0;
const totalTokensEur = 0;
const avgDailyCosts =
numDaysWithSessions > 0 ? totalTokensEur / numDaysWithSessions : 0;
// 2. Peak Usage Time
let peakUsageTime = "N/A";
if (Object.keys(hourlySessionCounts).length > 0) {
const peakHour = Object.entries(hourlySessionCounts).sort(
([, a], [, b]) => b - a
)[0][0];
const peakHourNum = Number.parseInt(peakHour.split(":")[0]);
const endHour = (peakHourNum + 1) % 24;
peakUsageTime = `${peakHour}-${endHour.toString().padStart(2, "0")}:00`;
}
// 3. Resolved Chats Percentage
const peakUsageTime = calculatePeakUsageTime(metrics.hourlySessionCounts);
const resolvedChatsPercentage =
totalSessions > 0 ? (resolvedChatsCount / totalSessions) * 100 : 0;
// 4. Top 5 Asked Questions
const topQuestions: TopQuestion[] = Object.entries(questionCounts)
.sort(([, a], [, b]) => b - a)
.slice(0, 5) // Top 5 questions
.map(([question, count]) => ({ question, count }));
// console.log("Debug metrics calculation:", {
// totalSessionDuration,
// validSessionsForDuration,
// calculatedAvgSessionLength: avgSessionLength,
// });
totalSessions > 0
? (metrics.counters.resolvedChatsCount / totalSessions) * 100
: 0;
const topQuestions = calculateTopQuestions(metrics.questionCounts);
return {
totalSessions,
uniqueUsers,
avgSessionLength, // Corrected to match MetricsResult interface
avgResponseTime, // Corrected to match MetricsResult interface
escalatedCount,
forwardedCount: forwardedHrCount, // Corrected to match MetricsResult interface (forwardedCount)
sentimentPositiveCount,
sentimentNeutralCount,
sentimentNegativeCount,
days: byDay, // Corrected to match MetricsResult interface (days)
categories: byCategory, // Corrected to match MetricsResult interface (categories)
languages: byLanguage, // Corrected to match MetricsResult interface (languages)
countries: byCountry, // Corrected to match MetricsResult interface (countries)
avgSessionLength,
avgResponseTime,
escalatedCount: metrics.counters.escalatedCount,
forwardedCount: metrics.counters.forwardedHrCount,
sentimentPositiveCount: metrics.sentimentCounts.sentimentPositiveCount,
sentimentNeutralCount: metrics.sentimentCounts.sentimentNeutralCount,
sentimentNegativeCount: metrics.sentimentCounts.sentimentNegativeCount,
days: byDay,
categories: byCategory,
languages: byLanguage,
countries: byCountry,
tokensByDay,
tokensCostByDay,
totalTokens,
totalTokensEur,
wordCloudData,
belowThresholdCount: alerts, // Corrected to match MetricsResult interface (belowThresholdCount)
avgSessionsPerDay, // Added to satisfy MetricsResult interface
// Map trend values to the expected property names in MetricsResult
belowThresholdCount: metrics.counters.alerts,
avgSessionsPerDay,
sessionTrend: totalSessionsTrend,
usersTrend: uniqueUsersTrend,
avgSessionTimeTrend: avgSessionLengthTrend,
// For response time, a negative trend is actually positive (faster responses are better)
avgResponseTimeTrend: -avgResponseTimeTrend, // Invert as lower response time is better
// Additional fields
avgResponseTimeTrend: -avgResponseTimeTrend,
sentimentThreshold: companyConfig.sentimentAlert,
lastUpdated: Date.now(),
totalSessionDuration,
validSessionsForDuration,
// New metrics
totalSessionDuration: metrics.sessionDurationTotals.totalSessionDuration,
validSessionsForDuration:
metrics.sessionDurationTotals.validSessionsForDuration,
avgDailyCosts,
peakUsageTime,
resolvedChatsPercentage,

View File

@ -254,7 +254,9 @@ async function processQuestions(
});
// Filter and prepare unique questions
const uniqueQuestions = Array.from(new Set(questions.filter((q) => q.trim())));
const uniqueQuestions = Array.from(
new Set(questions.filter((q) => q.trim()))
);
if (uniqueQuestions.length === 0) return;
// Batch create questions (skip duplicates)

View File

@ -1,4 +1,8 @@
import { ProcessingStage, ProcessingStatus, type PrismaClient } from "@prisma/client";
import {
type PrismaClient,
ProcessingStage,
ProcessingStatus,
} from "@prisma/client";
import { prisma } from "./prisma";
// Type-safe metadata interfaces
@ -172,10 +176,7 @@ export class ProcessingStatusManager {
/**
* Get sessions that need processing for a specific stage
*/
async getSessionsNeedingProcessing(
stage: ProcessingStage,
limit = 50
) {
async getSessionsNeedingProcessing(stage: ProcessingStage, limit = 50) {
return await this.prisma.sessionProcessingStatus.findMany({
where: {
stage,
@ -361,23 +362,43 @@ export class ProcessingStatusManager {
export const processingStatusManager = new ProcessingStatusManager();
// Also export the individual functions for backward compatibility
export const initializeSession = (sessionId: string) => processingStatusManager.initializeSession(sessionId);
export const startStage = (sessionId: string, stage: ProcessingStage, metadata?: ProcessingMetadata) =>
processingStatusManager.startStage(sessionId, stage, metadata);
export const completeStage = (sessionId: string, stage: ProcessingStage, metadata?: ProcessingMetadata) =>
processingStatusManager.completeStage(sessionId, stage, metadata);
export const failStage = (sessionId: string, stage: ProcessingStage, errorMessage: string, metadata?: ProcessingMetadata) =>
export const initializeSession = (sessionId: string) =>
processingStatusManager.initializeSession(sessionId);
export const startStage = (
sessionId: string,
stage: ProcessingStage,
metadata?: ProcessingMetadata
) => processingStatusManager.startStage(sessionId, stage, metadata);
export const completeStage = (
sessionId: string,
stage: ProcessingStage,
metadata?: ProcessingMetadata
) => processingStatusManager.completeStage(sessionId, stage, metadata);
export const failStage = (
sessionId: string,
stage: ProcessingStage,
errorMessage: string,
metadata?: ProcessingMetadata
) =>
processingStatusManager.failStage(sessionId, stage, errorMessage, metadata);
export const skipStage = (sessionId: string, stage: ProcessingStage, reason: string) =>
processingStatusManager.skipStage(sessionId, stage, reason);
export const getSessionStatus = (sessionId: string) => processingStatusManager.getSessionStatus(sessionId);
export const getSessionsNeedingProcessing = (stage: ProcessingStage, limit?: number) =>
processingStatusManager.getSessionsNeedingProcessing(stage, limit);
export const getPipelineStatus = () => processingStatusManager.getPipelineStatus();
export const getFailedSessions = (stage?: ProcessingStage) => processingStatusManager.getFailedSessions(stage);
export const skipStage = (
sessionId: string,
stage: ProcessingStage,
reason: string
) => processingStatusManager.skipStage(sessionId, stage, reason);
export const getSessionStatus = (sessionId: string) =>
processingStatusManager.getSessionStatus(sessionId);
export const getSessionsNeedingProcessing = (
stage: ProcessingStage,
limit?: number
) => processingStatusManager.getSessionsNeedingProcessing(stage, limit);
export const getPipelineStatus = () =>
processingStatusManager.getPipelineStatus();
export const getFailedSessions = (stage?: ProcessingStage) =>
processingStatusManager.getFailedSessions(stage);
export const resetStageForRetry = (sessionId: string, stage: ProcessingStage) =>
processingStatusManager.resetStageForRetry(sessionId, stage);
export const hasCompletedStage = (sessionId: string, stage: ProcessingStage) =>
processingStatusManager.hasCompletedStage(sessionId, stage);
export const isReadyForStage = (sessionId: string, stage: ProcessingStage) =>
processingStatusManager.isReadyForStage(sessionId, stage);
processingStatusManager.isReadyForStage(sessionId, stage);

107
lib/rateLimiter.ts Normal file
View File

@ -0,0 +1,107 @@
// Shared rate limiting utility to prevent code duplication
export interface RateLimitConfig {
maxAttempts: number;
windowMs: number;
maxEntries?: number;
cleanupIntervalMs?: number;
}
export interface RateLimitAttempt {
count: number;
resetTime: number;
}
export class InMemoryRateLimiter {
private attempts = new Map<string, RateLimitAttempt>();
private cleanupInterval: NodeJS.Timeout;
constructor(private config: RateLimitConfig) {
const cleanupMs = config.cleanupIntervalMs || 5 * 60 * 1000; // 5 minutes default
// Clean up expired entries periodically
this.cleanupInterval = setInterval(() => {
this.cleanup();
}, cleanupMs);
}
/**
* Check if a key (e.g., IP address) is rate limited
*/
checkRateLimit(key: string): { allowed: boolean; resetTime?: number } {
const now = Date.now();
const attempt = this.attempts.get(key);
if (!attempt || now > attempt.resetTime) {
// No previous attempt or window expired - allow and start new window
this.attempts.set(key, {
count: 1,
resetTime: now + this.config.windowMs,
});
return { allowed: true };
}
if (attempt.count >= this.config.maxAttempts) {
// Rate limit exceeded
return { allowed: false, resetTime: attempt.resetTime };
}
// Increment counter
attempt.count++;
return { allowed: true };
}
/**
* Clean up expired entries and prevent unbounded growth
*/
private cleanup(): void {
const now = Date.now();
const maxEntries = this.config.maxEntries || 10000;
// Remove expired entries
for (const [key, attempt] of Array.from(this.attempts.entries())) {
if (now > attempt.resetTime) {
this.attempts.delete(key);
}
}
// If still too many entries, remove oldest half
if (this.attempts.size > maxEntries) {
const entries = Array.from(this.attempts.entries());
entries.sort((a, b) => a[1].resetTime - b[1].resetTime);
const toRemove = Math.floor(entries.length / 2);
for (let i = 0; i < toRemove; i++) {
this.attempts.delete(entries[i][0]);
}
}
}
/**
* Clean up resources
*/
destroy(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
}
}
/**
* Extract client IP address from request headers
*/
export function extractClientIP(request: Request): string {
// Check multiple possible headers in order of preference
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) {
// Take the first IP from comma-separated list
return forwarded.split(",")[0].trim();
}
return (
request.headers.get("x-real-ip") ||
request.headers.get("x-client-ip") ||
request.headers.get("cf-connecting-ip") ||
"unknown"
);
}

View File

@ -1,8 +1,124 @@
import { InMemoryRateLimiter } from "./rateLimiter";
export interface EmailConfig {
smtpHost?: string;
smtpPort?: number;
smtpUser?: string;
smtpPassword?: string;
fromEmail?: string;
fromName?: string;
}
export interface EmailOptions {
to: string;
subject: string;
text?: string;
html?: string;
}
const emailRateLimit = new InMemoryRateLimiter({
maxAttempts: 5,
windowMs: 60 * 1000,
maxEntries: 1000,
});
export async function sendEmail(
options: EmailOptions
): Promise<{ success: boolean; error?: string }> {
const rateLimitCheck = emailRateLimit.checkRateLimit(options.to);
if (!rateLimitCheck.allowed) {
return {
success: false,
error: "Rate limit exceeded. Please try again later.",
};
}
const config = getEmailConfig();
if (!config.isConfigured) {
console.warn("Email not configured - would send:", options);
return {
success: false,
error: "Email service not configured",
};
}
try {
if (process.env.NODE_ENV === "development") {
console.log("📧 [DEV] Email would be sent:", {
to: options.to,
subject: options.subject,
text: options.text?.substring(0, 100) + "...",
});
return { success: true };
}
await sendEmailViaService(options, config);
return { success: true };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
console.error("Failed to send email:", errorMessage);
return {
success: false,
error: errorMessage,
};
}
}
function getEmailConfig(): EmailConfig & { isConfigured: boolean } {
const config = {
smtpHost: process.env.SMTP_HOST,
smtpPort: process.env.SMTP_PORT ? parseInt(process.env.SMTP_PORT) : 587,
smtpUser: process.env.SMTP_USER,
smtpPassword: process.env.SMTP_PASSWORD,
fromEmail: process.env.FROM_EMAIL || "noreply@livedash.app",
fromName: process.env.FROM_NAME || "LiveDash",
};
const isConfigured = !!(
config.smtpHost &&
config.smtpUser &&
config.smtpPassword
);
return { ...config, isConfigured };
}
async function sendEmailViaService(
_options: EmailOptions,
_config: EmailConfig
): Promise<void> {
throw new Error(
"Email service implementation required - install nodemailer or similar SMTP library"
);
}
export async function sendPasswordResetEmail(
email: string,
tempPassword: string
): Promise<{ success: boolean; error?: string }> {
const subject = "Your temporary password - LiveDash";
const text = `Your temporary password is: ${tempPassword}\n\nPlease log in and change your password immediately for security.`;
const html = `
<h2>Temporary Password</h2>
<p>Your temporary password is: <strong>${tempPassword}</strong></p>
<p>Please log in and change your password immediately for security.</p>
<p><a href="${process.env.NEXTAUTH_URL || "http://localhost:3000"}/login">Login here</a></p>
`;
return sendEmail({
to: email,
subject,
text,
html,
});
}
// Legacy function for backward compatibility
export async function sendEmailLegacy(
to: string,
subject: string,
text: string
): Promise<void> {
// For demo: log to console. Use nodemailer/sendgrid/whatever in prod.
process.stdout.write(`[Email to ${to}]: ${subject}\n${text}\n`);
}

View File

@ -37,6 +37,150 @@ function parseEuropeanDate(dateStr: string): Date {
);
}
/**
* Parse a single line for timestamp and role pattern
*/
function parseTimestampRoleLine(line: string): {
type: "timestamp-role";
timestamp: string;
role: string;
content: string;
} | null {
const timestampRoleMatch = line.match(
/^\[(\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}:\d{2})\]\s+(User|Assistant|System|user|assistant|system):\s*(.*)$/i
);
if (timestampRoleMatch) {
return {
type: "timestamp-role",
timestamp: timestampRoleMatch[1],
role:
timestampRoleMatch[2].charAt(0).toUpperCase() +
timestampRoleMatch[2].slice(1).toLowerCase(),
content: timestampRoleMatch[3] || "",
};
}
return null;
}
/**
* Parse a single line for role pattern only
*/
function parseRoleLine(line: string): {
type: "role";
role: string;
content: string;
} | null {
const roleMatch = line.match(
/^(User|Assistant|System|user|assistant|system):\s*(.*)$/i
);
if (roleMatch) {
return {
type: "role",
role:
roleMatch[1].charAt(0).toUpperCase() +
roleMatch[1].slice(1).toLowerCase(),
content: roleMatch[2] || "",
};
}
return null;
}
/**
* Save current message to messages array
*/
function saveCurrentMessage(
currentMessage: { role: string; content: string; timestamp?: string } | null,
messages: ParsedMessage[],
order: number
): number {
if (currentMessage) {
messages.push({
sessionId: "", // Will be set by caller
timestamp: new Date(), // Will be calculated later
role: currentMessage.role,
content: currentMessage.content.trim(),
order,
});
return order + 1;
}
return order;
}
/**
* Calculate timestamp for a message using distributed timing
*/
function calculateDistributedTimestamp(
startTime: Date,
endTime: Date,
index: number,
totalMessages: number
): Date {
const sessionDurationMs = endTime.getTime() - startTime.getTime();
const messageInterval =
totalMessages > 1 ? sessionDurationMs / (totalMessages - 1) : 0;
return new Date(startTime.getTime() + index * messageInterval);
}
/**
* Process timestamp calculations for all messages
*/
function processMessageTimestamps(
messages: ParsedMessage[],
startTime: Date,
endTime: Date
): void {
interface MessageWithTimestamp extends Omit<ParsedMessage, "timestamp"> {
timestamp: Date | string;
}
const hasTimestamps = messages.some(
(msg) => (msg as MessageWithTimestamp).timestamp
);
if (hasTimestamps) {
// Use parsed timestamps from the transcript
messages.forEach((message, index) => {
const msgWithTimestamp = message as MessageWithTimestamp;
if (
msgWithTimestamp.timestamp &&
typeof msgWithTimestamp.timestamp === "string"
) {
try {
message.timestamp = parseEuropeanDate(msgWithTimestamp.timestamp);
} catch {
// Fallback to distributed timestamp if parsing fails
message.timestamp = calculateDistributedTimestamp(
startTime,
endTime,
index,
messages.length
);
}
} else {
// Fallback to distributed timestamp
message.timestamp = calculateDistributedTimestamp(
startTime,
endTime,
index,
messages.length
);
}
});
} else {
// Distribute messages across session duration
messages.forEach((message, index) => {
message.timestamp = calculateDistributedTimestamp(
startTime,
endTime,
index,
messages.length
);
});
}
}
/**
* Parse raw transcript content into structured messages
* @param content Raw transcript content
@ -74,79 +218,43 @@ export function parseTranscriptToMessages(
continue;
}
// Check if line starts with a timestamp and role [DD.MM.YYYY HH:MM:SS] Role: content
const timestampRoleMatch = trimmedLine.match(
/^\[(\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}:\d{2})\]\s+(User|Assistant|System|user|assistant|system):\s*(.*)$/i
);
// Check if line starts with just a role (User:, Assistant:, System:, etc.)
const roleMatch = trimmedLine.match(
/^(User|Assistant|System|user|assistant|system):\s*(.*)$/i
);
if (timestampRoleMatch) {
// Try parsing timestamp + role pattern first
const timestampRoleResult = parseTimestampRoleLine(trimmedLine);
if (timestampRoleResult) {
// Save previous message if exists
if (currentMessage) {
messages.push({
sessionId: "", // Will be set by caller
timestamp: new Date(), // Will be calculated below
role: currentMessage.role,
content: currentMessage.content.trim(),
order: order++,
});
}
order = saveCurrentMessage(currentMessage, messages, order);
// Start new message with timestamp
const timestamp = timestampRoleMatch[1];
const role =
timestampRoleMatch[2].charAt(0).toUpperCase() +
timestampRoleMatch[2].slice(1).toLowerCase();
const content = timestampRoleMatch[3] || "";
currentMessage = {
role,
content,
timestamp, // Store the timestamp for later parsing
role: timestampRoleResult.role,
content: timestampRoleResult.content,
timestamp: timestampRoleResult.timestamp,
};
} else if (roleMatch) {
continue;
}
// Try parsing role-only pattern
const roleResult = parseRoleLine(trimmedLine);
if (roleResult) {
// Save previous message if exists
if (currentMessage) {
messages.push({
sessionId: "", // Will be set by caller
timestamp: new Date(), // Will be calculated below
role: currentMessage.role,
content: currentMessage.content.trim(),
order: order++,
});
}
order = saveCurrentMessage(currentMessage, messages, order);
// Start new message without timestamp
const role =
roleMatch[1].charAt(0).toUpperCase() +
roleMatch[1].slice(1).toLowerCase();
const content = roleMatch[2] || "";
currentMessage = {
role,
content,
role: roleResult.role,
content: roleResult.content,
};
} else if (currentMessage) {
// Continue previous message (multi-line)
continue;
}
// Continue previous message (multi-line) or skip orphaned content
if (currentMessage) {
currentMessage.content += `\n${trimmedLine}`;
}
// If no current message and no role match, skip the line (orphaned content)
}
// Save the last message
if (currentMessage) {
messages.push({
sessionId: "", // Will be set by caller
timestamp: new Date(), // Will be calculated below
role: currentMessage.role,
content: currentMessage.content.trim(),
order: order++,
});
}
saveCurrentMessage(currentMessage, messages, order);
if (messages.length === 0) {
return {
@ -155,57 +263,8 @@ export function parseTranscriptToMessages(
};
}
// Calculate timestamps - use parsed timestamps if available, otherwise distribute across session duration
interface MessageWithTimestamp extends Omit<ParsedMessage, 'timestamp'> {
timestamp: Date | string;
}
const hasTimestamps = messages.some(
(msg) => (msg as MessageWithTimestamp).timestamp
);
if (hasTimestamps) {
// Use parsed timestamps from the transcript
messages.forEach((message, index) => {
const msgWithTimestamp = message as MessageWithTimestamp;
if (
msgWithTimestamp.timestamp &&
typeof msgWithTimestamp.timestamp === "string"
) {
try {
message.timestamp = parseEuropeanDate(msgWithTimestamp.timestamp);
} catch (_error) {
// Fallback to distributed timestamp if parsing fails
const sessionDurationMs = endTime.getTime() - startTime.getTime();
const messageInterval =
messages.length > 1
? sessionDurationMs / (messages.length - 1)
: 0;
message.timestamp = new Date(
startTime.getTime() + index * messageInterval
);
}
} else {
// Fallback to distributed timestamp
const sessionDurationMs = endTime.getTime() - startTime.getTime();
const messageInterval =
messages.length > 1 ? sessionDurationMs / (messages.length - 1) : 0;
message.timestamp = new Date(
startTime.getTime() + index * messageInterval
);
}
});
} else {
// Distribute messages across session duration
const sessionDurationMs = endTime.getTime() - startTime.getTime();
const messageInterval =
messages.length > 1 ? sessionDurationMs / (messages.length - 1) : 0;
messages.forEach((message, index) => {
message.timestamp = new Date(
startTime.getTime() + index * messageInterval
);
});
}
// Calculate timestamps for all messages
processMessageTimestamps(messages, startTime, endTime);
return {
success: true,