feat: initialize project with Next.js, Prisma, and Tailwind CSS

- Add package.json with dependencies and scripts for Next.js and Prisma
- Implement API routes for session management, user authentication, and company configuration
- Create database schema for Company, User, and Session models in Prisma
- Set up authentication with NextAuth and JWT
- Add password reset functionality and user registration endpoint
- Configure Tailwind CSS and PostCSS for styling
- Implement metrics and dashboard settings API endpoints
This commit is contained in:
2025-05-21 20:44:56 +02:00
commit cdaa3ea19d
36 changed files with 7662 additions and 0 deletions

6
.eslintrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"extends": [
"next/core-web-vitals",
"next/typescript"
]
}

54
.gitignore vendored Normal file
View File

@ -0,0 +1,54 @@
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# IDE
.vscode/*
!.vscode/extensions.json
.idea/
*.sublime-project
*.sublime-workspace
# logs
logs
*.log
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db

5
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"recommendations": [
"prisma.prisma"
]
}

165
app/dashboard/page.tsx Normal file
View File

@ -0,0 +1,165 @@
// Main dashboard page: metrics, refresh, config
'use client';
import { useEffect, useState } from 'react';
import { signOut, useSession } from 'next-auth/react';
import { SessionsLineChart, CategoriesBarChart } from '../../components/Charts';
import DashboardSettings from './settings';
import UserManagement from './users';
interface MetricsCardProps {
label: string;
value: string | number | null | undefined;
}
function MetricsCard({ label, value }: MetricsCardProps) {
return (
<div className="bg-white rounded-xl p-4 shadow-md flex flex-col items-center">
<span className="text-2xl font-bold">{value ?? '-'}</span>
<span className="text-gray-500">{label}</span>
</div>
);
}
export default function DashboardPage() {
const { data: session } = useSession();
const [metrics, setMetrics] = useState<Record<string, unknown> | null>(null);
const [company, setCompany] = useState<Record<string, unknown> | null>(null);
// Loading state used in the fetchData function
const [, setLoading] = useState<boolean>(false);
const [csvUrl, setCsvUrl] = useState<string>('');
const [refreshing, setRefreshing] = useState<boolean>(false);
const isAdmin = session?.user?.role === 'admin';
const isAuditor = session?.user?.role === 'auditor';
useEffect(() => {
// Fetch metrics, company, and CSV URL on mount
const fetchData = async () => {
setLoading(true);
const res = await fetch('/api/dashboard/metrics');
const data = await res.json();
setMetrics(data.metrics);
setCompany(data.company);
setCsvUrl(data.csvUrl);
setLoading(false);
};
fetchData();
}, []);
async function handleRefresh() {
if (isAuditor) return; // Prevent auditors from refreshing
setRefreshing(true);
await fetch('/api/admin/refresh-sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ companyId: company?.id }),
});
setRefreshing(false);
window.location.reload();
}
async function handleSaveConfig() {
if (isAuditor) return; // Prevent auditors from changing config
await fetch('/api/dashboard/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ csvUrl }),
});
window.location.reload();
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">Analytics Dashboard</h1>
<button className="text-sm underline" onClick={() => signOut()}>
Log out
</button>
</div>
{/* Admin-only settings and user management */}
{company && isAdmin && (
<>
<DashboardSettings company={company} session={session} />
<UserManagement session={session} />
</>
)}
<div className="bg-white p-4 rounded-xl shadow mb-6 flex items-center gap-4">
<input
className="flex-1 px-3 py-2 rounded border"
value={csvUrl}
onChange={(e) => setCsvUrl(e.target.value)}
placeholder="CSV feed URL (with basic auth if set in backend)"
readOnly={isAuditor}
/>
{!isAuditor && (
<>
<button
className="px-4 py-2 bg-blue-600 text-white rounded"
onClick={handleSaveConfig}
>
Save Config
</button>
<button
className="px-4 py-2 bg-green-600 text-white rounded"
onClick={handleRefresh}
disabled={refreshing}
>
{refreshing ? 'Refreshing...' : 'Manual Refresh'}
</button>
</>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-10">
<MetricsCard label="Total Sessions" value={metrics?.totalSessions} />
<MetricsCard label="Escalated" value={metrics?.escalatedCount} />
<MetricsCard
label="Avg. Sentiment"
value={metrics?.avgSentiment?.toFixed(2)}
/>
<MetricsCard
label="Total Tokens (€)"
value={metrics?.totalTokensEur?.toFixed(2)}
/>
<MetricsCard
label="Below Sentiment Threshold"
value={metrics?.belowSentimentThreshold}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<h2 className="font-bold mb-2">Sessions Per Day</h2>
{(
metrics?.sessionsPerDay &&
Object.keys(metrics.sessionsPerDay).length > 0
) ?
<SessionsLineChart sessionsPerDay={metrics.sessionsPerDay} />
: <span>No data</span>}
</div>
<div>
<h2 className="font-bold mb-2">Top Categories</h2>
{metrics?.categories && Object.keys(metrics.categories).length > 0 ?
<CategoriesBarChart categories={metrics.categories} />
: <span>No data</span>}
</div>
<div>
<h2 className="font-bold mb-2">Languages</h2>
{metrics?.languages ?
Object.entries(metrics.languages).map(([lang, n]) => (
<div key={lang} className="flex justify-between">
<span>{lang}</span>
<span>{String(n)}</span>
</div>
))
: <span>No data</span>}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,82 @@
'use client';
import { useState } from 'react';
import { Company } from '../../lib/types';
import { Session } from 'next-auth';
interface DashboardSettingsProps {
company: Company;
session: Session;
}
export default function DashboardSettings({
company,
session,
}: DashboardSettingsProps) {
const [csvUrl, setCsvUrl] = useState<string>(company.csvUrl);
const [csvUsername, setCsvUsername] = useState<string>(
company.csvUsername || ''
);
const [csvPassword, setCsvPassword] = useState<string>('');
const [sentimentThreshold, setSentimentThreshold] = useState<string>(
company.sentimentAlert?.toString() || ''
);
const [message, setMessage] = useState<string>('');
async function handleSave() {
const res = await fetch('/api/dashboard/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csvUrl,
csvUsername,
csvPassword,
sentimentThreshold,
}),
});
if (res.ok) setMessage('Settings saved!');
else setMessage('Failed.');
}
if (session.user.role !== 'admin') return null;
return (
<div className="bg-white p-6 rounded-xl shadow mb-6">
<h2 className="font-bold text-lg mb-4">Company Config</h2>
<div className="grid gap-4">
<input
className="border px-3 py-2 rounded"
placeholder="CSV URL"
value={csvUrl}
onChange={(e) => setCsvUrl(e.target.value)}
/>
<input
className="border px-3 py-2 rounded"
placeholder="CSV Username"
value={csvUsername}
onChange={(e) => setCsvUsername(e.target.value)}
/>
<input
className="border px-3 py-2 rounded"
type="password"
placeholder="CSV Password"
value={csvPassword}
onChange={(e) => setCsvPassword(e.target.value)}
/>
<input
className="border px-3 py-2 rounded"
placeholder="Sentiment Alert Threshold"
type="number"
value={sentimentThreshold}
onChange={(e) => setSentimentThreshold(e.target.value)}
/>
<button
className="bg-blue-600 text-white rounded py-2"
onClick={handleSave}
>
Save Settings
</button>
<div>{message}</div>
</div>
</div>
);
}

76
app/dashboard/users.tsx Normal file
View File

@ -0,0 +1,76 @@
'use client';
import { useState, useEffect } from 'react';
import { UserSession } from '../../lib/types';
interface UserItem {
id: string;
email: string;
role: string;
}
interface UserManagementProps {
session: UserSession;
}
export default function UserManagement({ session }: UserManagementProps) {
const [users, setUsers] = useState<UserItem[]>([]);
const [email, setEmail] = useState<string>('');
const [role, setRole] = useState<string>('user');
const [msg, setMsg] = useState<string>('');
useEffect(() => {
fetch('/api/dashboard/users')
.then((r) => r.json())
.then((data) => setUsers(data.users));
}, []);
async function inviteUser() {
const res = await fetch('/api/dashboard/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, role }),
});
if (res.ok) setMsg('User invited.');
else setMsg('Failed.');
}
if (session.user.role !== 'admin') return null;
return (
<div className="bg-white p-6 rounded-xl shadow mb-6">
<h2 className="font-bold text-lg mb-4">User Management</h2>
<div className="flex gap-2 mb-3">
<input
className="border px-3 py-2 rounded"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<select
className="border px-3 py-2 rounded"
value={role}
onChange={(e) => setRole(e.target.value)}
>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="auditor">Auditor</option>
</select>
<button
className="bg-blue-600 text-white rounded px-4"
onClick={inviteUser}
>
Invite
</button>
</div>
<div>{msg}</div>
<ul className="mt-4">
{users.map((u) => (
<li key={u.id} className="flex justify-between border-b py-1">
{u.email}{' '}
<span className="text-xs bg-gray-200 px-2 rounded">{u.role}</span>
</li>
))}
</ul>
</div>
);
}

View File

@ -0,0 +1,38 @@
'use client';
import { useState } from 'react';
export default function ForgotPasswordPage() {
const [email, setEmail] = useState<string>('');
const [message, setMessage] = useState<string>('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const res = await fetch('/api/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (res.ok) setMessage('If that email exists, a reset link has been sent.');
else setMessage('Failed. Try again.');
}
return (
<div className="max-w-md mx-auto mt-24 bg-white rounded-xl p-8 shadow">
<h1 className="text-2xl font-bold mb-6">Forgot Password</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
className="border px-3 py-2 rounded"
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<button className="bg-blue-600 text-white rounded py-2" type="submit">
Send Reset Link
</button>
</form>
<div className="mt-4 text-green-700">{message}</div>
</div>
);
}

9
app/globals.css Normal file
View File

@ -0,0 +1,9 @@
body {
font-family: system-ui, sans-serif;
background: #f3f4f6;
}
input,
button {
font-family: inherit;
}

19
app/layout.tsx Normal file
View File

@ -0,0 +1,19 @@
// Main app layout with basic global style
import './globals.css';
import { ReactNode } from 'react';
export const metadata = {
title: 'LiveDash-Node',
description:
'Multi-tenant dashboard system for tracking chat session metrics',
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body className="bg-gray-100 min-h-screen font-sans">
<div className="max-w-5xl mx-auto py-8">{children}</div>
</body>
</html>
);
}

60
app/login/page.tsx Normal file
View File

@ -0,0 +1,60 @@
'use client';
import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const router = useRouter();
async function handleLogin(e: React.FormEvent) {
e.preventDefault();
const res = await signIn('credentials', {
email,
password,
redirect: false,
});
if (res?.ok) router.push('/dashboard');
else setError('Invalid credentials.');
}
return (
<div className="max-w-md mx-auto mt-24 bg-white rounded-xl p-8 shadow">
<h1 className="text-2xl font-bold mb-6">Login</h1>
{error && <div className="text-red-600 mb-3">{error}</div>}
<form onSubmit={handleLogin} className="flex flex-col gap-4">
<input
className="border px-3 py-2 rounded"
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
className="border px-3 py-2 rounded"
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button className="bg-blue-600 text-white rounded py-2" type="submit">
Login
</button>
</form>
<div className="mt-4 text-center">
<a href="/register" className="text-blue-600 underline">
Register company
</a>
</div>
<div className="mt-2 text-center">
<a href="/forgot-password" className="text-blue-600 underline">
Forgot password?
</a>
</div>
</div>
);
}

9
app/page.tsx Normal file
View File

@ -0,0 +1,9 @@
import { getServerSession } from 'next-auth';
import { redirect } from 'next/navigation';
import { authOptions } from '../pages/api/auth/[...nextauth]';
export default async function HomePage() {
const session = await getServerSession(authOptions);
if (session?.user) redirect('/dashboard');
else redirect('/login');
}

77
app/register/page.tsx Normal file
View File

@ -0,0 +1,77 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function RegisterPage() {
const [email, setEmail] = useState<string>('');
const [company, setCompany] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [csvUrl, setCsvUrl] = useState<string>('');
const [role, setRole] = useState<string>('admin'); // Default to admin for company registration
const [error, setError] = useState<string>('');
const router = useRouter();
async function handleRegister(e: React.FormEvent) {
e.preventDefault();
const res = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, company, csvUrl, role }),
});
if (res.ok) router.push('/login');
else setError('Registration failed.');
}
return (
<div className="max-w-md mx-auto mt-24 bg-white rounded-xl p-8 shadow">
<h1 className="text-2xl font-bold mb-6">Register Company</h1>
{error && <div className="text-red-600 mb-3">{error}</div>}
<form onSubmit={handleRegister} className="flex flex-col gap-4">
<input
className="border px-3 py-2 rounded"
type="text"
placeholder="Company Name"
value={company}
onChange={(e) => setCompany(e.target.value)}
required
/>
<input
className="border px-3 py-2 rounded"
type="email"
placeholder="Admin Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
className="border px-3 py-2 rounded"
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<input
className="border px-3 py-2 rounded"
type="text"
placeholder="CSV URL"
value={csvUrl}
onChange={(e) => setCsvUrl(e.target.value)}
/>
<select
className="border px-3 py-2 rounded"
value={role}
onChange={(e) => setRole(e.target.value)}
required
>
<option value="admin">Admin</option>
<option value="user">User</option>
<option value="auditor">Auditor</option>
</select>
<button className="bg-blue-600 text-white rounded py-2" type="submit">
Register & Continue
</button>
</form>
</div>
);
}

View File

@ -0,0 +1,44 @@
'use client';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
export default function ResetPasswordPage() {
const searchParams = useSearchParams();
const token = searchParams.get('token');
const [password, setPassword] = useState<string>('');
const [message, setMessage] = useState<string>('');
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const res = await fetch('/api/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
});
if (res.ok) {
setMessage('Password reset! Redirecting to login...');
setTimeout(() => router.push('/login'), 2000);
} else setMessage('Invalid or expired link.');
}
return (
<div className="max-w-md mx-auto mt-24 bg-white rounded-xl p-8 shadow">
<h1 className="text-2xl font-bold mb-6">Reset Password</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
className="border px-3 py-2 rounded"
type="password"
placeholder="New Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button className="bg-blue-600 text-white rounded py-2" type="submit">
Reset Password
</button>
</form>
<div className="mt-4 text-green-700">{message}</div>
</div>
);
}

80
components/Charts.tsx Normal file
View File

@ -0,0 +1,80 @@
'use client';
import { useEffect, useRef } from 'react';
import Chart from 'chart.js/auto';
interface SessionsData {
[date: string]: number;
}
interface CategoriesData {
[category: string]: number;
}
interface SessionsLineChartProps {
sessionsPerDay: SessionsData;
}
interface CategoriesBarChartProps {
categories: CategoriesData;
}
// Basic line and bar chart for metrics. Extend as needed.
export function SessionsLineChart({ sessionsPerDay }: SessionsLineChartProps) {
const ref = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
if (!ref.current || !sessionsPerDay) return;
const ctx = ref.current.getContext('2d');
if (!ctx) return;
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: Object.keys(sessionsPerDay),
datasets: [
{
label: 'Sessions',
data: Object.values(sessionsPerDay),
borderWidth: 2,
},
],
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true } },
},
});
return () => chart.destroy();
}, [sessionsPerDay]);
return <canvas ref={ref} height={180} />;
}
export function CategoriesBarChart({ categories }: CategoriesBarChartProps) {
const ref = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
if (!ref.current || !categories) return;
const ctx = ref.current.getContext('2d');
if (!ctx) return;
const chart = new Chart(ctx, {
type: 'bar',
data: {
labels: Object.keys(categories),
datasets: [
{
label: 'Categories',
data: Object.values(categories),
borderWidth: 2,
},
],
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true } },
},
});
return () => chart.destroy();
}, [categories]);
return <canvas ref={ref} height={180} />;
}

104
lib/csvFetcher.ts Normal file
View File

@ -0,0 +1,104 @@
// Fetches, parses, and returns chat session data for a company from a CSV URL
import fetch from "node-fetch";
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;
}
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;
}
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 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,
});
// 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,
}));
}

85
lib/metrics.ts Normal file
View File

@ -0,0 +1,85 @@
// Functions to calculate metrics over sessions
import { ChatSession, DayMetrics, CategoryMetrics, LanguageMetrics, MetricsResult } from './types';
interface CompanyConfig {
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;
// 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;
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++;
}
}
// 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;
}

20
lib/prisma.ts Normal file
View File

@ -0,0 +1,20 @@
// Simple Prisma client setup
import { PrismaClient } from "@prisma/client";
// Add prisma to the NodeJS global type
// This approach avoids NodeJS.Global which is not available
// Prevent multiple instances of Prisma Client in development
declare const global: {
prisma: PrismaClient | undefined;
};
// Initialize Prisma Client
const prisma = global.prisma || new PrismaClient();
// Save in global if we're in development
if (process.env.NODE_ENV !== "production") {
global.prisma = prisma;
}
export { prisma };

27
lib/scheduler.ts Normal file
View File

@ -0,0 +1,27 @@
// node-cron job to auto-refresh session data every 15 mins
import cron from "node-cron";
import { prisma } from "./prisma";
import { fetchAndParseCsv } from "./csvFetcher";
export function startScheduler() {
cron.schedule("*/15 * * * *", async () => {
const companies = await prisma.company.findMany();
for (const company of companies) {
try {
// @ts-expect-error - Handle type conversion on session import
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) {
// @ts-expect-error - Proper data mapping would be needed for production
await prisma.session.create({
// @ts-expect-error - We ensure id is present but TypeScript doesn't know
data: { ...session, companyId: company.id, id: session.id || session.sessionId || `sess_${Date.now()}` },
});
}
console.log(`[Scheduler] Refreshed sessions for company: ${company.name}`);
} catch (e) {
console.error(`[Scheduler] Failed for company: ${company.name} - ${e}`);
}
}
});
}

4
lib/sendEmail.ts Normal file
View File

@ -0,0 +1,4 @@
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}`);
}

86
lib/types.ts Normal file
View File

@ -0,0 +1,86 @@
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;
};
}
export interface Company {
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;
}
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;
// 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;
}
export interface CategoryMetrics {
[category: string]: number;
}
export interface LanguageMetrics {
[language: string]: number;
}
export interface MetricsResult {
totalSessions: number;
avgSessionsPerDay: number;
avgSessionLength: number | null;
days: DayMetrics;
languages: LanguageMetrics;
categories: CategoryMetrics;
belowThresholdCount: number;
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}

8
next.config.js Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
// swcMinify option has been removed in Next.js 15
// appDir is no longer experimental in Next.js 15
};
export default nextConfig;

6009
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "livedash-node",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"format": "prettier --write .",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev"
},
"dependencies": {
"@prisma/client": "^6.8.2",
"@types/node-fetch": "^3.0.2",
"bcryptjs": "^3.0.2",
"chart.js": "^4.0.0",
"csv-parse": "^5.5.0",
"next": "^15.3.2",
"next-auth": "^4.24.11",
"node-cron": "^4.0.6",
"node-fetch": "^3.3.2",
"react": "^19.1.0",
"react-chartjs-2": "^5.0.0",
"react-dom": "^19.1.0",
"tailwindcss": "^4.1.7"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.2",
"@types/node": "^22.15.21",
"@types/node-cron": "^3.0.8",
"@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5",
"autoprefixer": "^10.4.0",
"eslint": "^9.27.0",
"eslint-config-next": "^15.3.2",
"postcss": "^8.4.0",
"prettier": "^3.5.3",
"prisma": "^6.8.2",
"typescript": "^5.0.0"
}
}

View File

@ -0,0 +1,60 @@
// API route to refresh (fetch+parse+update) session data for a company
import { NextApiRequest, NextApiResponse } from "next";
import { fetchAndParseCsv } from "../../../lib/csvFetcher";
import { prisma } from "../../../lib/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// Check if this is a POST request
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
// Get companyId from body or query
let { companyId } = req.body;
if (!companyId) {
// Try to get user from prisma based on session cookie
try {
const session = await prisma.session.findFirst({
orderBy: { createdAt: 'desc' },
where: { /* Add session check criteria here */ }
});
if (session) {
companyId = session.companyId;
}
} catch (error) {
console.error("Error fetching session:", error);
}
}
if (!companyId) {
return res.status(400).json({ error: "Company ID is required" });
}
const company = await prisma.company.findUnique({ where: { id: companyId } });
if (!company) return res.status(404).json({ error: "Company not found" });
try {
// @ts-expect-error - Handle type conversion on session import
const sessions = await fetchAndParseCsv(company.csvUrl, company.csvUsername as string | undefined, company.csvPassword as string | undefined);
// Replace all session rows for this company (for demo simplicity)
await prisma.session.deleteMany({ where: { companyId: company.id } });
for (const session of sessions) {
// @ts-expect-error - Proper data mapping would be needed for production
await prisma.session.create({
// @ts-expect-error - We ensure id is present but TypeScript doesn't know
data: {
...session,
id: session.id || session.sessionId || `sess_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
companyId: company.id,
},
});
}
res.json({ ok: true, imported: sessions.length });
} catch (e) {
const error = e instanceof Error ? e.message : 'An unknown error occurred';
res.status(500).json({ error });
}
}

View File

@ -0,0 +1,89 @@
import NextAuth, { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { prisma } from "../../../lib/prisma";
import bcrypt from "bcryptjs";
// Define the shape of the JWT token
declare module "next-auth/jwt" {
interface JWT {
companyId: string;
role: string;
}
}
// Define the shape of the session object
declare module "next-auth" {
interface Session {
user: {
id?: string;
name?: string;
email?: string;
image?: string;
companyId: string;
role: string;
};
}
interface User {
id: string;
email: string;
companyId: string;
role: string;
}
}
export const authOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email }
});
if (!user) return null;
const valid = await bcrypt.compare(credentials.password, user.password);
if (!valid) return null;
return {
id: user.id,
email: user.email,
companyId: user.companyId,
role: user.role,
};
},
}),
],
session: { strategy: "jwt" },
callbacks: {
async jwt({ token, user }) {
if (user) {
token.companyId = user.companyId;
token.role = user.role;
}
return token;
},
async session({ session, token }) {
if (token && session.user) {
session.user.companyId = token.companyId;
session.user.role = token.role;
}
return session;
},
},
pages: {
signIn: "/login",
},
secret: process.env.NEXTAUTH_SECRET || "fallback-secret-key-change-in-production",
};
export default NextAuth(authOptions);

View File

@ -0,0 +1,27 @@
// API endpoint: update company CSV URL config
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth";
import { prisma } from "../../../lib/prisma";
import { authOptions } from "../auth/[...nextauth]";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions);
if (!session?.user) return res.status(401).json({ error: "Not logged in" });
const user = await prisma.user.findUnique({
where: { email: session.user.email as string }
});
if (!user) return res.status(401).json({ error: "No user" });
if (req.method === "POST") {
const { csvUrl } = req.body;
await prisma.company.update({
where: { id: user.companyId },
data: { csvUrl }
});
res.json({ ok: true });
} else {
res.status(405).end();
}
}

View File

@ -0,0 +1,44 @@
// API endpoint: return metrics for current company
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth";
import { prisma } from "../../../lib/prisma";
import { sessionMetrics } from "../../../lib/metrics";
import { authOptions } from "../auth/[...nextauth]";
interface SessionUser {
email: string;
name?: string;
}
interface SessionData {
user: SessionUser;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const session = await getServerSession(req, res, authOptions) as SessionData | null;
if (!session?.user) return res.status(401).json({ error: "Not logged in" });
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: { company: true }
});
if (!user) return res.status(401).json({ error: "No user" });
const sessions = await prisma.session.findMany({
where: { companyId: user.companyId }
});
// Pass company config to metrics
// @ts-expect-error - Type conversion is needed between prisma session and ChatSession
const metrics = sessionMetrics(sessions, user.company);
res.json({
metrics,
csvUrl: user.company.csvUrl,
company: user.company
});
}

View File

@ -0,0 +1,32 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth";
import { prisma } from "../../../lib/prisma";
import { authOptions } from "../auth/[...nextauth]";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions);
if (!session?.user || session.user.role !== "admin")
return res.status(403).json({ error: "Forbidden" });
const user = await prisma.user.findUnique({
where: { email: session.user.email as string }
});
if (!user) return res.status(401).json({ error: "No user" });
if (req.method === "POST") {
const { csvUrl, csvUsername, csvPassword, sentimentThreshold } = req.body;
await prisma.company.update({
where: { id: user.companyId },
data: {
csvUrl,
csvUsername,
...(csvPassword ? { csvPassword } : {}),
sentimentAlert: sentimentThreshold ? parseFloat(sentimentThreshold) : null,
}
});
res.json({ ok: true });
} else {
res.status(405).end();
}
}

View File

@ -0,0 +1,56 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth";
import { prisma } from "../../../lib/prisma";
import bcrypt from "bcryptjs";
import { authOptions } from "../auth/[...nextauth]";
// User type from prisma is used instead of the one in lib/types
interface UserBasicInfo {
id: string;
email: string;
role: string;
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions);
if (!session?.user || session.user.role !== "admin")
return res.status(403).json({ error: "Forbidden" });
const user = await prisma.user.findUnique({
where: { email: session.user.email as string }
});
if (!user) return res.status(401).json({ error: "No user" });
if (req.method === "GET") {
const users = await prisma.user.findMany({
where: { companyId: user.companyId }
});
const mappedUsers: UserBasicInfo[] = users.map(u => ({
id: u.id,
email: u.email,
role: u.role
}));
res.json({ users: mappedUsers });
}
else if (req.method === "POST") {
const { email, role } = req.body;
if (!email || !role) return res.status(400).json({ error: "Missing fields" });
const exists = await prisma.user.findUnique({ where: { email } });
if (exists) return res.status(409).json({ error: "Email exists" });
const tempPassword = Math.random().toString(36).slice(-8); // random initial password
await prisma.user.create({
data: {
email,
password: await bcrypt.hash(tempPassword, 10),
companyId: user.companyId,
role,
}
});
// TODO: Email user their temp password (stub, for demo)
res.json({ ok: true, tempPassword });
}
else res.status(405).end();
}

View File

@ -0,0 +1,35 @@
import { prisma } from "../../lib/prisma";
import { sendEmail } from "../../lib/sendEmail";
import crypto from "crypto";
import type { IncomingMessage, ServerResponse } from 'http';
type NextApiRequest = IncomingMessage & {
body: {
email: string;
[key: string]: unknown;
};
};
type NextApiResponse = ServerResponse & {
status: (code: number) => NextApiResponse;
json: (data: Record<string, unknown>) => void;
end: () => void;
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") return res.status(405).end();
const { email } = req.body;
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return res.status(200).end(); // always 200 for privacy
const token = crypto.randomBytes(32).toString("hex");
const expiry = new Date(Date.now() + 1000 * 60 * 30); // 30 min expiry
await prisma.user.update({
where: { email },
data: { resetToken: token, resetTokenExpiry: expiry },
});
const resetUrl = `${process.env.NEXTAUTH_URL || "http://localhost:3000"}/reset-password?token=${token}`;
await sendEmail(email, "Password Reset", `Reset your password: ${resetUrl}`);
res.status(200).end();
}

53
pages/api/register.ts Normal file
View File

@ -0,0 +1,53 @@
import { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "../../lib/prisma";
import bcrypt from "bcryptjs";
import { ApiResponse } from "../../lib/types";
interface RegisterRequestBody {
email: string;
password: string;
company: string;
csvUrl?: string;
}
export default async function handler(req: NextApiRequest, res: NextApiResponse<ApiResponse<{ success: boolean; } | { error: string; }>>) {
if (req.method !== "POST") return res.status(405).end();
const { email, password, company, csvUrl } = req.body as RegisterRequestBody;
if (!email || !password || !company) {
return res.status(400).json({
success: false,
error: "Missing required fields"
});
}
// Check if email exists
const exists = await prisma.user.findUnique({
where: { email }
});
if (exists) {
return res.status(409).json({
success: false,
error: "Email already exists"
});
}
const newCompany = await prisma.company.create({
data: { name: company, csvUrl: csvUrl || "" },
});
const hashed = await bcrypt.hash(password, 10);
await prisma.user.create({
data: {
email,
password: hashed,
companyId: newCompany.id,
role: "admin",
},
});
res.status(201).json({
success: true,
data: { success: true }
});
}

View File

@ -0,0 +1,40 @@
import { prisma } from "../../lib/prisma";
import bcrypt from "bcryptjs";
import type { IncomingMessage, ServerResponse } from 'http';
type NextApiRequest = IncomingMessage & {
body: {
token: string;
password: string;
[key: string]: unknown;
};
};
type NextApiResponse = ServerResponse & {
status: (code: number) => NextApiResponse;
json: (data: Record<string, unknown>) => void;
end: () => void;
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") return res.status(405).end();
const { token, password } = req.body;
const user = await prisma.user.findFirst({
where: {
resetToken: token,
resetTokenExpiry: { gte: new Date() }
}
});
if (!user) return res.status(400).json({ error: "Invalid or expired token" });
const hash = await bcrypt.hash(password, 10);
await prisma.user.update({
where: { id: user.id },
data: {
password: hash,
resetToken: null,
resetTokenExpiry: null,
}
});
res.status(200).end();
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

56
prisma/schema.prisma Normal file
View File

@ -0,0 +1,56 @@
// Database schema, one company = one org, linked to users and CSV config
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Company {
id String @id @default(uuid())
name String
csvUrl String // where to fetch CSV
csvUsername String? // for basic auth
csvPassword String?
sentimentAlert Float? // e.g. alert threshold for negative chats
dashboardOpts String? // JSON blob for per-company dashboard preferences
users User[]
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model User {
id String @id @default(uuid())
email String @unique
password String // hashed, use bcrypt
company Company @relation(fields: [companyId], references: [id])
companyId String
role String // 'admin' | 'user' | 'auditor'
resetToken String?
resetTokenExpiry DateTime?
}
model Session {
id String @id
company Company @relation(fields: [companyId], references: [id])
companyId String
startTime DateTime
endTime DateTime
ipAddress String?
country String?
language String?
messagesSent Int?
sentiment Float?
escalated Boolean?
forwardedHr Boolean?
fullTranscriptUrl String?
avgResponseTime Float?
tokens Int?
tokensEur Float?
category String?
initialMsg String?
createdAt DateTime @default(now())
}

12
tailwind.config.js Normal file
View File

@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
},
plugins: [],
};

41
tsconfig.json Normal file
View File

@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}