mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 11:12:11 +01:00
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:
@ -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({
|
||||
|
||||
@ -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>>;
|
||||
}> = [];
|
||||
|
||||
|
||||
670
lib/metrics.ts
670
lib/metrics.ts
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
107
lib/rateLimiter.ts
Normal 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"
|
||||
);
|
||||
}
|
||||
118
lib/sendEmail.ts
118
lib/sendEmail.ts
@ -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`);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user