feat: update package.json scripts and add prisma seed command

refactor: improve refresh-sessions API handler for better readability and error handling

fix: enhance NextAuth configuration with session token handling and cookie settings

chore: update dashboard API handlers for consistency and improved error responses

style: format dashboard API routes for better readability

feat: implement forgot password and reset password functionality with security improvements

feat: add user registration API with email existence check and initial company creation

chore: create initial database migration and seed script for demo data

style: clean up PostCSS and Tailwind CSS configuration files

fix: update TypeScript configuration for stricter type checking

chore: add development environment variables for NextAuth

feat: create Providers component for session management in the app

chore: initialize Prisma migration and seed files for database setup
This commit is contained in:
2025-05-21 21:41:07 +02:00
parent b6b67dcd78
commit 50b2fbda55
42 changed files with 8233 additions and 7627 deletions

View File

@ -4,101 +4,108 @@ import { parse } from "csv-parse/sync";
// This type is used internally for parsing the CSV records
interface CSVRecord {
session_id: string;
start_time: string;
end_time?: string;
ip_address?: string;
country?: string;
language?: string;
messages_sent?: string;
sentiment?: string;
escalated?: string;
forwarded_hr?: string;
full_transcript_url?: string;
avg_response_time?: string;
tokens?: string;
tokens_eur?: string;
category?: string;
initial_msg?: string;
[key: string]: string | undefined;
session_id: string;
start_time: string;
end_time?: string;
ip_address?: string;
country?: string;
language?: string;
messages_sent?: string;
sentiment?: string;
escalated?: string;
forwarded_hr?: string;
full_transcript_url?: string;
avg_response_time?: string;
tokens?: string;
tokens_eur?: string;
category?: string;
initial_msg?: string;
[key: string]: string | undefined;
}
interface SessionData {
id: string;
sessionId: string;
startTime: Date;
endTime: Date | null;
ipAddress?: string;
country?: string;
language?: string | null;
messagesSent: number;
sentiment: number | null;
escalated: boolean;
forwardedHr: boolean;
fullTranscriptUrl?: string | null;
avgResponseTime: number | null;
tokens: number;
tokensEur: number;
category?: string | null;
initialMsg?: string;
id: string;
sessionId: string;
startTime: Date;
endTime: Date | null;
ipAddress?: string;
country?: string;
language?: string | null;
messagesSent: number;
sentiment: number | null;
escalated: boolean;
forwardedHr: boolean;
fullTranscriptUrl?: string | null;
avgResponseTime: number | null;
tokens: number;
tokensEur: number;
category?: string | null;
initialMsg?: string;
}
export async function fetchAndParseCsv(url: string, username?: string, password?: string): Promise<Partial<SessionData>[]> {
const authHeader = username && password
? "Basic " + Buffer.from(`${username}:${password}`).toString("base64")
: undefined;
export async function fetchAndParseCsv(
url: string,
username?: string,
password?: string,
): Promise<Partial<SessionData>[]> {
const authHeader =
username && password
? "Basic " + Buffer.from(`${username}:${password}`).toString("base64")
: undefined;
const res = await fetch(url, {
headers: authHeader ? { Authorization: authHeader } : {},
});
if (!res.ok) throw new Error("Failed to fetch CSV: " + res.statusText);
const res = await fetch(url, {
headers: authHeader ? { Authorization: authHeader } : {},
});
if (!res.ok) throw new Error("Failed to fetch CSV: " + res.statusText);
const text = await res.text();
const text = await res.text();
// Parse without expecting headers, using known order
const records: CSVRecord[] = parse(text, {
delimiter: ",",
columns: [
"session_id",
"start_time",
"end_time",
"ip_address",
"country",
"language",
"messages_sent",
"sentiment",
"escalated",
"forwarded_hr",
"full_transcript_url",
"avg_response_time",
"tokens",
"tokens_eur",
"category",
"initial_msg",
],
from_line: 1,
relax_column_count: true,
skip_empty_lines: true,
trim: true,
});
// Parse without expecting headers, using known order
const records: CSVRecord[] = parse(text, {
delimiter: ",",
columns: [
"session_id",
"start_time",
"end_time",
"ip_address",
"country",
"language",
"messages_sent",
"sentiment",
"escalated",
"forwarded_hr",
"full_transcript_url",
"avg_response_time",
"tokens",
"tokens_eur",
"category",
"initial_msg",
],
from_line: 1,
relax_column_count: true,
skip_empty_lines: true,
trim: true,
});
// Coerce types for relevant columns
return records.map((r) => ({
id: r.session_id,
startTime: new Date(r.start_time),
endTime: r.end_time ? new Date(r.end_time) : null,
ipAddress: r.ip_address,
country: r.country,
language: r.language,
messagesSent: Number(r.messages_sent) || 0,
sentiment: r.sentiment ? parseFloat(r.sentiment) : null,
escalated: r.escalated === "1" || r.escalated === "true",
forwardedHr: r.forwarded_hr === "1" || r.forwarded_hr === "true",
fullTranscriptUrl: r.full_transcript_url,
avgResponseTime: r.avg_response_time ? parseFloat(r.avg_response_time) : null,
tokens: Number(r.tokens) || 0,
tokensEur: r.tokens_eur ? parseFloat(r.tokens_eur) : 0,
category: r.category,
initialMsg: r.initial_msg,
}));
// Coerce types for relevant columns
return records.map((r) => ({
id: r.session_id,
startTime: new Date(r.start_time),
endTime: r.end_time ? new Date(r.end_time) : null,
ipAddress: r.ip_address,
country: r.country,
language: r.language,
messagesSent: Number(r.messages_sent) || 0,
sentiment: r.sentiment ? parseFloat(r.sentiment) : null,
escalated: r.escalated === "1" || r.escalated === "true",
forwardedHr: r.forwarded_hr === "1" || r.forwarded_hr === "true",
fullTranscriptUrl: r.full_transcript_url,
avgResponseTime: r.avg_response_time
? parseFloat(r.avg_response_time)
: null,
tokens: Number(r.tokens) || 0,
tokensEur: r.tokens_eur ? parseFloat(r.tokens_eur) : 0,
category: r.category,
initialMsg: r.initial_msg,
}));
}

View File

@ -1,85 +1,100 @@
// Functions to calculate metrics over sessions
import { ChatSession, DayMetrics, CategoryMetrics, LanguageMetrics, MetricsResult } from './types';
import {
ChatSession,
DayMetrics,
CategoryMetrics,
LanguageMetrics,
MetricsResult,
} from "./types";
interface CompanyConfig {
sentimentAlert?: number;
sentimentAlert?: number;
}
export function sessionMetrics(sessions: ChatSession[], companyConfig: CompanyConfig = {}): MetricsResult {
const total = sessions.length;
const byDay: DayMetrics = {};
const byCategory: CategoryMetrics = {};
const byLanguage: LanguageMetrics = {};
let escalated = 0, forwarded = 0;
let totalSentiment = 0, sentimentCount = 0;
let totalResponse = 0, responseCount = 0;
let totalTokens = 0, totalTokensEur = 0;
export function sessionMetrics(
sessions: ChatSession[],
companyConfig: CompanyConfig = {},
): MetricsResult {
const total = sessions.length;
const byDay: DayMetrics = {};
const byCategory: CategoryMetrics = {};
const byLanguage: LanguageMetrics = {};
let escalated = 0,
forwarded = 0;
let totalSentiment = 0,
sentimentCount = 0;
let totalResponse = 0,
responseCount = 0;
let totalTokens = 0,
totalTokensEur = 0;
// Calculate total session duration in minutes
let totalDuration = 0;
let durationCount = 0;
// Calculate total session duration in minutes
let totalDuration = 0;
let durationCount = 0;
sessions.forEach(s => {
const day = s.startTime.toISOString().slice(0, 10);
byDay[day] = (byDay[day] || 0) + 1;
sessions.forEach((s) => {
const day = s.startTime.toISOString().slice(0, 10);
byDay[day] = (byDay[day] || 0) + 1;
if (s.category) byCategory[s.category] = (byCategory[s.category] || 0) + 1;
if (s.language) byLanguage[s.language] = (byLanguage[s.language] || 0) + 1;
if (s.category) byCategory[s.category] = (byCategory[s.category] || 0) + 1;
if (s.language) byLanguage[s.language] = (byLanguage[s.language] || 0) + 1;
if (s.endTime) {
const duration = (s.endTime.getTime() - s.startTime.getTime()) / (1000 * 60); // minutes
totalDuration += duration;
durationCount++;
}
if (s.escalated) escalated++;
if (s.forwardedHr) forwarded++;
if (s.sentiment != null) {
totalSentiment += s.sentiment;
sentimentCount++;
}
if (s.avgResponseTime != null) {
totalResponse += s.avgResponseTime;
responseCount++;
}
totalTokens += s.tokens || 0;
totalTokensEur += s.tokensEur || 0;
});
// Now add sentiment alert logic:
let belowThreshold = 0;
const threshold = companyConfig.sentimentAlert ?? null;
if (threshold != null) {
for (const s of sessions) {
if (s.sentiment != null && s.sentiment < threshold) belowThreshold++;
}
if (s.endTime) {
const duration =
(s.endTime.getTime() - s.startTime.getTime()) / (1000 * 60); // minutes
totalDuration += duration;
durationCount++;
}
// Calculate average sessions per day
const dayCount = Object.keys(byDay).length;
const avgSessionsPerDay = dayCount > 0 ? total / dayCount : 0;
if (s.escalated) escalated++;
if (s.forwardedHr) forwarded++;
// Calculate average session length
const avgSessionLength = durationCount > 0 ? totalDuration / durationCount : null;
if (s.sentiment != null) {
totalSentiment += s.sentiment;
sentimentCount++;
}
return {
totalSessions: total,
avgSessionsPerDay,
avgSessionLength,
days: byDay,
languages: byLanguage,
categories: byCategory,
belowThresholdCount: belowThreshold,
// Additional metrics not in the interface - using type assertion
escalatedCount: escalated,
forwardedCount: forwarded,
avgSentiment: sentimentCount ? totalSentiment / sentimentCount : null,
avgResponseTime: responseCount ? totalResponse / responseCount : null,
totalTokens,
totalTokensEur,
sentimentThreshold: threshold,
} as MetricsResult;
if (s.avgResponseTime != null) {
totalResponse += s.avgResponseTime;
responseCount++;
}
totalTokens += s.tokens || 0;
totalTokensEur += s.tokensEur || 0;
});
// Now add sentiment alert logic:
let belowThreshold = 0;
const threshold = companyConfig.sentimentAlert ?? null;
if (threshold != null) {
for (const s of sessions) {
if (s.sentiment != null && s.sentiment < threshold) belowThreshold++;
}
}
// Calculate average sessions per day
const dayCount = Object.keys(byDay).length;
const avgSessionsPerDay = dayCount > 0 ? total / dayCount : 0;
// Calculate average session length
const avgSessionLength =
durationCount > 0 ? totalDuration / durationCount : null;
return {
totalSessions: total,
avgSessionsPerDay,
avgSessionLength,
days: byDay,
languages: byLanguage,
categories: byCategory,
belowThresholdCount: belowThreshold,
// Additional metrics not in the interface - using type assertion
escalatedCount: escalated,
forwardedCount: forwarded,
avgSentiment: sentimentCount ? totalSentiment / sentimentCount : null,
avgResponseTime: responseCount ? totalResponse / responseCount : null,
totalTokens,
totalTokensEur,
sentimentThreshold: threshold,
} as MetricsResult;
}

View File

@ -6,7 +6,7 @@ import { PrismaClient } from "@prisma/client";
// Prevent multiple instances of Prisma Client in development
declare const global: {
prisma: PrismaClient | undefined;
prisma: PrismaClient | undefined;
};
// Initialize Prisma Client
@ -14,7 +14,7 @@ const prisma = global.prisma || new PrismaClient();
// Save in global if we're in development
if (process.env.NODE_ENV !== "production") {
global.prisma = prisma;
global.prisma = prisma;
}
export { prisma };

View File

@ -4,50 +4,62 @@ import { prisma } from "./prisma";
import { fetchAndParseCsv } from "./csvFetcher";
interface SessionCreateData {
id: string;
startTime: Date;
companyId: string;
[key: string]: unknown;
id: string;
startTime: Date;
companyId: string;
[key: string]: unknown;
}
export function startScheduler() {
cron.schedule("*/15 * * * *", async () => {
const companies = await prisma.company.findMany();
for (const company of companies) {
try {
const sessions = await fetchAndParseCsv(company.csvUrl, company.csvUsername as string | undefined, company.csvPassword as string | undefined);
await prisma.session.deleteMany({ where: { companyId: company.id } });
cron.schedule("*/15 * * * *", async () => {
const companies = await prisma.company.findMany();
for (const company of companies) {
try {
const sessions = await fetchAndParseCsv(
company.csvUrl,
company.csvUsername as string | undefined,
company.csvPassword as string | undefined,
);
await prisma.session.deleteMany({ where: { companyId: company.id } });
for (const session of sessions) {
const sessionData: SessionCreateData = {
...session,
companyId: company.id,
id: session.id || session.sessionId || `sess_${Date.now()}`,
// Ensure startTime is not undefined
startTime: session.startTime || new Date()
};
for (const session of sessions) {
const sessionData: SessionCreateData = {
...session,
companyId: company.id,
id: session.id || session.sessionId || `sess_${Date.now()}`,
// Ensure startTime is not undefined
startTime: session.startTime || new Date(),
};
// Only include fields that are properly typed for Prisma
await prisma.session.create({
data: {
id: sessionData.id,
companyId: sessionData.companyId,
startTime: sessionData.startTime,
// endTime is required in the schema, so use startTime if not available
endTime: session.endTime || new Date(),
ipAddress: session.ipAddress || null,
country: session.country || null,
language: session.language || null,
sentiment: typeof session.sentiment === 'number' ? session.sentiment : null,
messagesSent: typeof session.messagesSent === 'number' ? session.messagesSent : 0,
category: session.category || null
}
});
}
console.log(`[Scheduler] Refreshed sessions for company: ${company.name}`);
} catch (e) {
console.error(`[Scheduler] Failed for company: ${company.name} - ${e}`);
}
// Only include fields that are properly typed for Prisma
await prisma.session.create({
data: {
id: sessionData.id,
companyId: sessionData.companyId,
startTime: sessionData.startTime,
// endTime is required in the schema, so use startTime if not available
endTime: session.endTime || new Date(),
ipAddress: session.ipAddress || null,
country: session.country || null,
language: session.language || null,
sentiment:
typeof session.sentiment === "number"
? session.sentiment
: null,
messagesSent:
typeof session.messagesSent === "number"
? session.messagesSent
: 0,
category: session.category || null,
},
});
}
});
console.log(
`[Scheduler] Refreshed sessions for company: ${company.name}`,
);
} catch (e) {
console.error(`[Scheduler] Failed for company: ${company.name} - ${e}`);
}
}
});
}

View File

@ -1,4 +1,8 @@
export async function sendEmail(to: string, subject: string, text: string): Promise<void> {
// For demo: log to console. Use nodemailer/sendgrid/whatever in prod.
console.log(`[Email to ${to}]: ${subject}\n${text}`);
export async function sendEmail(
to: string,
subject: string,
text: string,
): Promise<void> {
// For demo: log to console. Use nodemailer/sendgrid/whatever in prod.
console.log(`[Email to ${to}]: ${subject}\n${text}`);
}

View File

@ -1,94 +1,94 @@
import { Session as NextAuthSession } from 'next-auth';
import { Session as NextAuthSession } from "next-auth";
export interface UserSession extends NextAuthSession {
user: {
id?: string;
name?: string;
email?: string;
image?: string;
companyId: string;
role: string;
};
user: {
id?: string;
name?: string;
email?: string;
image?: string;
companyId: string;
role: string;
};
}
export interface Company {
id: string;
name: string;
csvUrl: string;
csvUsername?: string;
csvPassword?: string;
sentimentAlert?: number; // Match Prisma schema naming
createdAt: Date;
updatedAt: Date;
id: string;
name: string;
csvUrl: string;
csvUsername?: string;
csvPassword?: string;
sentimentAlert?: number; // Match Prisma schema naming
createdAt: Date;
updatedAt: Date;
}
export interface User {
id: string;
email: string;
password: string;
role: string;
companyId: string;
resetToken?: string | null;
resetTokenExpiry?: Date | null;
company?: Company;
createdAt: Date;
updatedAt: Date;
id: string;
email: string;
password: string;
role: string;
companyId: string;
resetToken?: string | null;
resetTokenExpiry?: Date | null;
company?: Company;
createdAt: Date;
updatedAt: Date;
}
export interface ChatSession {
id: string;
sessionId: string;
companyId: string;
userId?: string | null;
category?: string | null;
language?: string | null;
sentiment?: number | null;
startTime: Date;
endTime?: Date | null;
createdAt: Date;
updatedAt: Date;
id: string;
sessionId: string;
companyId: string;
userId?: string | null;
category?: string | null;
language?: string | null;
sentiment?: number | null;
startTime: Date;
endTime?: Date | null;
createdAt: Date;
updatedAt: Date;
// Extended session properties that might be used in metrics
avgResponseTime?: number | null;
escalated?: boolean;
forwardedHr?: boolean;
tokens?: number;
tokensEur?: number;
initialMsg?: string;
// Extended session properties that might be used in metrics
avgResponseTime?: number | null;
escalated?: boolean;
forwardedHr?: boolean;
tokens?: number;
tokensEur?: number;
initialMsg?: string;
}
export interface DayMetrics {
[day: string]: number;
[day: string]: number;
}
export interface CategoryMetrics {
[category: string]: number;
[category: string]: number;
}
export interface LanguageMetrics {
[language: string]: number;
[language: string]: number;
}
export interface MetricsResult {
totalSessions: number;
avgSessionsPerDay: number;
avgSessionLength: number | null;
days: DayMetrics;
languages: LanguageMetrics;
categories: CategoryMetrics;
belowThresholdCount: number;
// Additional properties for dashboard
escalatedCount?: number;
forwardedCount?: number;
avgSentiment?: number;
avgResponseTime?: number;
totalTokens?: number;
totalTokensEur?: number;
sentimentThreshold?: number | null;
totalSessions: number;
avgSessionsPerDay: number;
avgSessionLength: number | null;
days: DayMetrics;
languages: LanguageMetrics;
categories: CategoryMetrics;
belowThresholdCount: number;
// Additional properties for dashboard
escalatedCount?: number;
forwardedCount?: number;
avgSentiment?: number;
avgResponseTime?: number;
totalTokens?: number;
totalTokensEur?: number;
sentimentThreshold?: number | null;
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
success: boolean;
data?: T;
error?: string;
}