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

9
.env.development Normal file
View File

@ -0,0 +1,9 @@
# Development environment settings
# This file ensures NextAuth always has necessary environment variables in development
# NextAuth.js configuration
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=this_is_a_fixed_secret_for_development_only
NODE_ENV=development
# Database connection - already configured in your prisma/schema.prisma

View File

@ -1,11 +1,9 @@
{ {
"extends": [ "extends": ["next/core-web-vitals", "next/typescript"]
"next/core-web-vitals", // ,
"next/typescript" // "rules": {
], // "@typescript-eslint/no-explicit-any": "off",
"rules": { // "@typescript-eslint/no-unused-vars": "warn",
"@typescript-eslint/no-explicit-any": "off", // "@typescript-eslint/ban-ts-comment": "off"
"@typescript-eslint/no-unused-vars": "warn", // }
"@typescript-eslint/ban-ts-comment": "off"
}
} }

193
.gitignore vendored
View File

@ -1,3 +1,192 @@
# Created by https://www.toptal.com/developers/gitignore/api/node,nextjs,react
# Edit at https://www.toptal.com/developers/gitignore?templates=node,nextjs,react
### NextJS ###
# 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*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
### Node ###
# Logs
logs
*.log
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
### react ###
.DS_*
**/*.backup.*
**/*.back.*
node_modules
*.sublime*
psd
thumb
sketch
# End of https://www.toptal.com/developers/gitignore/api/node,nextjs,react
# dependencies # dependencies
/node_modules /node_modules
/.pnp /.pnp
@ -33,6 +222,10 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# Database files
*.db
*.sqlite?
# IDE # IDE
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json

View File

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

View File

@ -1,12 +1,11 @@
// Main dashboard page: metrics, refresh, config "use client";
'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
import { signOut, useSession } from 'next-auth/react'; import { signOut, useSession } from "next-auth/react";
import { SessionsLineChart, CategoriesBarChart } from '../../components/Charts'; import { SessionsLineChart, CategoriesBarChart } from "../../components/Charts";
import DashboardSettings from './settings'; import DashboardSettings from "./settings";
import UserManagement from './users'; import UserManagement from "./users";
import { Company, MetricsResult } from '../../lib/types'; import { Company, MetricsResult } from "../../lib/types";
interface MetricsCardProps { interface MetricsCardProps {
label: string; label: string;
@ -16,29 +15,29 @@ interface MetricsCardProps {
function MetricsCard({ label, value }: MetricsCardProps) { function MetricsCard({ label, value }: MetricsCardProps) {
return ( return (
<div className="bg-white rounded-xl p-4 shadow-md flex flex-col items-center"> <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-2xl font-bold">{value ?? "-"}</span>
<span className="text-gray-500">{label}</span> <span className="text-gray-500">{label}</span>
</div> </div>
); );
} }
export default function DashboardPage() { // Safely wrapped component with useSession
const { data: session } = useSession() || { data: null }; function DashboardContent() {
const { data: session } = useSession();
const [metrics, setMetrics] = useState<MetricsResult | null>(null); const [metrics, setMetrics] = useState<MetricsResult | null>(null);
const [company, setCompany] = useState<Company | null>(null); const [company, setCompany] = useState<Company | null>(null);
// Loading state used in the fetchData function
const [, setLoading] = useState<boolean>(false); const [, setLoading] = useState<boolean>(false);
const [csvUrl, setCsvUrl] = useState<string>(''); const [csvUrl, setCsvUrl] = useState<string>("");
const [refreshing, setRefreshing] = useState<boolean>(false); const [refreshing, setRefreshing] = useState<boolean>(false);
const isAdmin = session?.user?.role === 'admin'; const isAdmin = session?.user?.role === "admin";
const isAuditor = session?.user?.role === 'auditor'; const isAuditor = session?.user?.role === "auditor";
useEffect(() => { useEffect(() => {
// Fetch metrics, company, and CSV URL on mount // Fetch metrics, company, and CSV URL on mount
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
const res = await fetch('/api/dashboard/metrics'); const res = await fetch("/api/dashboard/metrics");
const data = await res.json(); const data = await res.json();
setMetrics(data.metrics); setMetrics(data.metrics);
setCompany(data.company); setCompany(data.company);
@ -50,122 +49,105 @@ export default function DashboardPage() {
async function handleRefresh() { async function handleRefresh() {
if (isAuditor) return; // Prevent auditors from refreshing if (isAuditor) return; // Prevent auditors from refreshing
try {
setRefreshing(true); setRefreshing(true);
await fetch('/api/admin/refresh-sessions', { const res = await fetch("/api/admin/refresh-sessions", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ companyId: company?.id }),
}); });
if (res.ok) {
// Refetch metrics
const metricsRes = await fetch("/api/dashboard/metrics");
const data = await metricsRes.json();
setMetrics(data.metrics);
}
} finally {
setRefreshing(false); setRefreshing(false);
window.location.reload(); }
} }
async function handleSaveConfig() { if (!metrics || !company) {
if (isAuditor) return; // Prevent auditors from changing config return <div className="text-center py-10">Loading dashboard...</div>;
await fetch('/api/dashboard/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ csvUrl }),
});
window.location.reload();
} }
return ( return (
<div className="space-y-6">
{/* Header with company info */}
<div className="flex justify-between items-center">
<div> <div>
<div className="flex items-center justify-between mb-6"> <h1 className="text-2xl font-bold">{company.name}</h1>
<h1 className="text-3xl font-bold">Analytics Dashboard</h1> <p className="text-gray-600">
<button className="text-sm underline" onClick={() => signOut()}> Dashboard updated{" "}
Log out {new Date(metrics.lastUpdated || Date.now()).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-4">
<button
className="bg-blue-600 text-white py-2 px-4 rounded-lg shadow-sm hover:bg-blue-700 disabled:opacity-50"
onClick={handleRefresh}
disabled={refreshing || isAuditor}
>
{refreshing ? "Refreshing..." : "Refresh Data"}
</button>
<button
className="bg-gray-200 py-2 px-4 rounded-lg shadow-sm hover:bg-gray-300"
onClick={() => signOut()}
>
Sign Out
</button> </button>
</div> </div>
</div>
{/* Admin-only settings and user management */} {/* Metrics Cards */}
{company && isAdmin && ( <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<MetricsCard label="Total Sessions" value={metrics.totalSessions} />
<MetricsCard
label="Avg Sessions/Day"
value={metrics.avgSessionsPerDay?.toFixed(1)}
/>
<MetricsCard
label="Avg Session Time"
value={
metrics.avgSessionLength
? `${metrics.avgSessionLength.toFixed(1)} min`
: null
}
/>
<MetricsCard
label="Avg Sentiment"
value={
metrics.avgSentiment
? metrics.avgSentiment.toFixed(2) + "/10"
: null
}
/>
</div>
{/* Charts Row */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white p-4 rounded-xl shadow">
<h3 className="font-bold text-lg mb-3">Sessions by Day</h3>
<SessionsLineChart data={metrics.days || {}} />
</div>
<div className="bg-white p-4 rounded-xl shadow">
<h3 className="font-bold text-lg mb-3">Categories</h3>
<CategoriesBarChart data={metrics.categories || {}} />
</div>
</div>
{/* Admin Controls */}
{isAdmin && (
<> <>
<DashboardSettings company={company} session={session} /> <DashboardSettings company={company} session={session} />
<UserManagement 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 !== undefined ?
metrics.avgSentiment.toFixed(2)
: undefined
}
/>
<MetricsCard
label="Total Tokens (€)"
value={
metrics?.totalTokensEur !== undefined ?
metrics.totalTokensEur.toFixed(2)
: undefined
}
/>
<MetricsCard
label="Below Sentiment Threshold"
value={metrics?.belowThresholdCount}
/>
</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?.days && Object.keys(metrics.days).length > 0 ?
<SessionsLineChart sessionsPerDay={metrics.days} />
: <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> </div>
); );
} }
// Our exported component
export default function DashboardPage() {
// We don't use useSession here to avoid the error outside the provider
return <DashboardContent />;
}

View File

@ -1,7 +1,7 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import { Company } from '../../lib/types'; import { Company } from "../../lib/types";
import { Session } from 'next-auth'; import { Session } from "next-auth";
interface DashboardSettingsProps { interface DashboardSettingsProps {
company: Company; company: Company;
@ -14,18 +14,18 @@ export default function DashboardSettings({
}: DashboardSettingsProps) { }: DashboardSettingsProps) {
const [csvUrl, setCsvUrl] = useState<string>(company.csvUrl); const [csvUrl, setCsvUrl] = useState<string>(company.csvUrl);
const [csvUsername, setCsvUsername] = useState<string>( const [csvUsername, setCsvUsername] = useState<string>(
company.csvUsername || '' company.csvUsername || "",
); );
const [csvPassword, setCsvPassword] = useState<string>(''); const [csvPassword, setCsvPassword] = useState<string>("");
const [sentimentThreshold, setSentimentThreshold] = useState<string>( const [sentimentThreshold, setSentimentThreshold] = useState<string>(
company.sentimentAlert?.toString() || '' company.sentimentAlert?.toString() || "",
); );
const [message, setMessage] = useState<string>(''); const [message, setMessage] = useState<string>("");
async function handleSave() { async function handleSave() {
const res = await fetch('/api/dashboard/settings', { const res = await fetch("/api/dashboard/settings", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
csvUrl, csvUrl,
csvUsername, csvUsername,
@ -33,11 +33,11 @@ export default function DashboardSettings({
sentimentThreshold, sentimentThreshold,
}), }),
}); });
if (res.ok) setMessage('Settings saved!'); if (res.ok) setMessage("Settings saved!");
else setMessage('Failed.'); else setMessage("Failed.");
} }
if (session.user.role !== 'admin') return null; if (session.user.role !== "admin") return null;
return ( return (
<div className="bg-white p-6 rounded-xl shadow mb-6"> <div className="bg-white p-6 rounded-xl shadow mb-6">

View File

@ -1,6 +1,6 @@
'use client'; "use client";
import { useState, useEffect } from 'react'; import { useState, useEffect } from "react";
import { UserSession } from '../../lib/types'; import { UserSession } from "../../lib/types";
interface UserItem { interface UserItem {
id: string; id: string;
@ -14,27 +14,27 @@ interface UserManagementProps {
export default function UserManagement({ session }: UserManagementProps) { export default function UserManagement({ session }: UserManagementProps) {
const [users, setUsers] = useState<UserItem[]>([]); const [users, setUsers] = useState<UserItem[]>([]);
const [email, setEmail] = useState<string>(''); const [email, setEmail] = useState<string>("");
const [role, setRole] = useState<string>('user'); const [role, setRole] = useState<string>("user");
const [msg, setMsg] = useState<string>(''); const [msg, setMsg] = useState<string>("");
useEffect(() => { useEffect(() => {
fetch('/api/dashboard/users') fetch("/api/dashboard/users")
.then((r) => r.json()) .then((r) => r.json())
.then((data) => setUsers(data.users)); .then((data) => setUsers(data.users));
}, []); }, []);
async function inviteUser() { async function inviteUser() {
const res = await fetch('/api/dashboard/users', { const res = await fetch("/api/dashboard/users", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, role }), body: JSON.stringify({ email, role }),
}); });
if (res.ok) setMsg('User invited.'); if (res.ok) setMsg("User invited.");
else setMsg('Failed.'); else setMsg("Failed.");
} }
if (session.user.role !== 'admin') return null; if (session.user.role !== "admin") return null;
return ( return (
<div className="bg-white p-6 rounded-xl shadow mb-6"> <div className="bg-white p-6 rounded-xl shadow mb-6">
@ -66,7 +66,7 @@ export default function UserManagement({ session }: UserManagementProps) {
<ul className="mt-4"> <ul className="mt-4">
{users.map((u) => ( {users.map((u) => (
<li key={u.id} className="flex justify-between border-b py-1"> <li key={u.id} className="flex justify-between border-b py-1">
{u.email}{' '} {u.email}{" "}
<span className="text-xs bg-gray-200 px-2 rounded">{u.role}</span> <span className="text-xs bg-gray-200 px-2 rounded">{u.role}</span>
</li> </li>
))} ))}

View File

@ -1,19 +1,19 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
const [email, setEmail] = useState<string>(''); const [email, setEmail] = useState<string>("");
const [message, setMessage] = useState<string>(''); const [message, setMessage] = useState<string>("");
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
const res = await fetch('/api/forgot-password', { const res = await fetch("/api/forgot-password", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }), body: JSON.stringify({ email }),
}); });
if (res.ok) setMessage('If that email exists, a reset link has been sent.'); if (res.ok) setMessage("If that email exists, a reset link has been sent.");
else setMessage('Failed. Try again.'); else setMessage("Failed. Try again.");
} }
return ( return (

View File

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

View File

@ -1,23 +1,23 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import { signIn } from 'next-auth/react'; import { signIn } from "next-auth/react";
import { useRouter } from 'next/navigation'; import { useRouter } from "next/navigation";
export default function LoginPage() { export default function LoginPage() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState("");
const [password, setPassword] = useState(''); const [password, setPassword] = useState("");
const [error, setError] = useState(''); const [error, setError] = useState("");
const router = useRouter(); const router = useRouter();
async function handleLogin(e: React.FormEvent) { async function handleLogin(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
const res = await signIn('credentials', { const res = await signIn("credentials", {
email, email,
password, password,
redirect: false, redirect: false,
}); });
if (res?.ok) router.push('/dashboard'); if (res?.ok) router.push("/dashboard");
else setError('Invalid credentials.'); else setError("Invalid credentials.");
} }
return ( return (

View File

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

17
app/providers.tsx Normal file
View File

@ -0,0 +1,17 @@
"use client";
import { SessionProvider } from "next-auth/react";
import { ReactNode } from "react";
export function Providers({ children }: { children: ReactNode }) {
// Including error handling and refetch interval for better user experience
return (
<SessionProvider
// Re-fetch session every 10 minutes
refetchInterval={10 * 60}
refetchOnWindowFocus={true}
>
{children}
</SessionProvider>
);
}

View File

@ -1,25 +1,25 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import { useRouter } from 'next/navigation'; import { useRouter } from "next/navigation";
export default function RegisterPage() { export default function RegisterPage() {
const [email, setEmail] = useState<string>(''); const [email, setEmail] = useState<string>("");
const [company, setCompany] = useState<string>(''); const [company, setCompany] = useState<string>("");
const [password, setPassword] = useState<string>(''); const [password, setPassword] = useState<string>("");
const [csvUrl, setCsvUrl] = useState<string>(''); const [csvUrl, setCsvUrl] = useState<string>("");
const [role, setRole] = useState<string>('admin'); // Default to admin for company registration const [role, setRole] = useState<string>("admin"); // Default to admin for company registration
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>("");
const router = useRouter(); const router = useRouter();
async function handleRegister(e: React.FormEvent) { async function handleRegister(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
const res = await fetch('/api/register', { const res = await fetch("/api/register", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, company, csvUrl, role }), body: JSON.stringify({ email, password, company, csvUrl, role }),
}); });
if (res.ok) router.push('/login'); if (res.ok) router.push("/login");
else setError('Registration failed.'); else setError("Registration failed.");
} }
return ( return (

View File

@ -1,26 +1,26 @@
'use client'; "use client";
import { useState, Suspense } from 'react'; import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from "next/navigation";
// Component that uses useSearchParams wrapped in Suspense // Component that uses useSearchParams wrapped in Suspense
function ResetPasswordForm() { function ResetPasswordForm() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const token = searchParams?.get('token'); const token = searchParams?.get("token");
const [password, setPassword] = useState<string>(''); const [password, setPassword] = useState<string>("");
const [message, setMessage] = useState<string>(''); const [message, setMessage] = useState<string>("");
const router = useRouter(); const router = useRouter();
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
const res = await fetch('/api/reset-password', { const res = await fetch("/api/reset-password", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }), body: JSON.stringify({ token, password }),
}); });
if (res.ok) { if (res.ok) {
setMessage('Password reset! Redirecting to login...'); setMessage("Password reset! Redirecting to login...");
setTimeout(() => router.push('/login'), 2000); setTimeout(() => router.push("/login"), 2000);
} else setMessage('Invalid or expired link.'); } else setMessage("Invalid or expired link.");
} }
return ( return (

View File

@ -1,6 +1,6 @@
'use client'; "use client";
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from "react";
import Chart from 'chart.js/auto'; import Chart from "chart.js/auto";
interface SessionsData { interface SessionsData {
[date: string]: number; [date: string]: number;
@ -23,16 +23,16 @@ export function SessionsLineChart({ sessionsPerDay }: SessionsLineChartProps) {
const ref = useRef<HTMLCanvasElement | null>(null); const ref = useRef<HTMLCanvasElement | null>(null);
useEffect(() => { useEffect(() => {
if (!ref.current || !sessionsPerDay) return; if (!ref.current || !sessionsPerDay) return;
const ctx = ref.current.getContext('2d'); const ctx = ref.current.getContext("2d");
if (!ctx) return; if (!ctx) return;
const chart = new Chart(ctx, { const chart = new Chart(ctx, {
type: 'line', type: "line",
data: { data: {
labels: Object.keys(sessionsPerDay), labels: Object.keys(sessionsPerDay),
datasets: [ datasets: [
{ {
label: 'Sessions', label: "Sessions",
data: Object.values(sessionsPerDay), data: Object.values(sessionsPerDay),
borderWidth: 2, borderWidth: 2,
}, },
@ -53,16 +53,16 @@ export function CategoriesBarChart({ categories }: CategoriesBarChartProps) {
const ref = useRef<HTMLCanvasElement | null>(null); const ref = useRef<HTMLCanvasElement | null>(null);
useEffect(() => { useEffect(() => {
if (!ref.current || !categories) return; if (!ref.current || !categories) return;
const ctx = ref.current.getContext('2d'); const ctx = ref.current.getContext("2d");
if (!ctx) return; if (!ctx) return;
const chart = new Chart(ctx, { const chart = new Chart(ctx, {
type: 'bar', type: "bar",
data: { data: {
labels: Object.keys(categories), labels: Object.keys(categories),
datasets: [ datasets: [
{ {
label: 'Categories', label: "Categories",
data: Object.values(categories), data: Object.values(categories),
borderWidth: 2, borderWidth: 2,
}, },

View File

@ -43,8 +43,13 @@ interface SessionData {
initialMsg?: string; initialMsg?: string;
} }
export async function fetchAndParseCsv(url: string, username?: string, password?: string): Promise<Partial<SessionData>[]> { export async function fetchAndParseCsv(
const authHeader = username && password url: string,
username?: string,
password?: string,
): Promise<Partial<SessionData>[]> {
const authHeader =
username && password
? "Basic " + Buffer.from(`${username}:${password}`).toString("base64") ? "Basic " + Buffer.from(`${username}:${password}`).toString("base64")
: undefined; : undefined;
@ -95,7 +100,9 @@ export async function fetchAndParseCsv(url: string, username?: string, password?
escalated: r.escalated === "1" || r.escalated === "true", escalated: r.escalated === "1" || r.escalated === "true",
forwardedHr: r.forwarded_hr === "1" || r.forwarded_hr === "true", forwardedHr: r.forwarded_hr === "1" || r.forwarded_hr === "true",
fullTranscriptUrl: r.full_transcript_url, fullTranscriptUrl: r.full_transcript_url,
avgResponseTime: r.avg_response_time ? parseFloat(r.avg_response_time) : null, avgResponseTime: r.avg_response_time
? parseFloat(r.avg_response_time)
: null,
tokens: Number(r.tokens) || 0, tokens: Number(r.tokens) || 0,
tokensEur: r.tokens_eur ? parseFloat(r.tokens_eur) : 0, tokensEur: r.tokens_eur ? parseFloat(r.tokens_eur) : 0,
category: r.category, category: r.category,

View File

@ -1,25 +1,38 @@
// Functions to calculate metrics over sessions // Functions to calculate metrics over sessions
import { ChatSession, DayMetrics, CategoryMetrics, LanguageMetrics, MetricsResult } from './types'; import {
ChatSession,
DayMetrics,
CategoryMetrics,
LanguageMetrics,
MetricsResult,
} from "./types";
interface CompanyConfig { interface CompanyConfig {
sentimentAlert?: number; sentimentAlert?: number;
} }
export function sessionMetrics(sessions: ChatSession[], companyConfig: CompanyConfig = {}): MetricsResult { export function sessionMetrics(
sessions: ChatSession[],
companyConfig: CompanyConfig = {},
): MetricsResult {
const total = sessions.length; const total = sessions.length;
const byDay: DayMetrics = {}; const byDay: DayMetrics = {};
const byCategory: CategoryMetrics = {}; const byCategory: CategoryMetrics = {};
const byLanguage: LanguageMetrics = {}; const byLanguage: LanguageMetrics = {};
let escalated = 0, forwarded = 0; let escalated = 0,
let totalSentiment = 0, sentimentCount = 0; forwarded = 0;
let totalResponse = 0, responseCount = 0; let totalSentiment = 0,
let totalTokens = 0, totalTokensEur = 0; sentimentCount = 0;
let totalResponse = 0,
responseCount = 0;
let totalTokens = 0,
totalTokensEur = 0;
// Calculate total session duration in minutes // Calculate total session duration in minutes
let totalDuration = 0; let totalDuration = 0;
let durationCount = 0; let durationCount = 0;
sessions.forEach(s => { sessions.forEach((s) => {
const day = s.startTime.toISOString().slice(0, 10); const day = s.startTime.toISOString().slice(0, 10);
byDay[day] = (byDay[day] || 0) + 1; byDay[day] = (byDay[day] || 0) + 1;
@ -27,7 +40,8 @@ export function sessionMetrics(sessions: ChatSession[], companyConfig: CompanyCo
if (s.language) byLanguage[s.language] = (byLanguage[s.language] || 0) + 1; if (s.language) byLanguage[s.language] = (byLanguage[s.language] || 0) + 1;
if (s.endTime) { if (s.endTime) {
const duration = (s.endTime.getTime() - s.startTime.getTime()) / (1000 * 60); // minutes const duration =
(s.endTime.getTime() - s.startTime.getTime()) / (1000 * 60); // minutes
totalDuration += duration; totalDuration += duration;
durationCount++; durationCount++;
} }
@ -63,7 +77,8 @@ export function sessionMetrics(sessions: ChatSession[], companyConfig: CompanyCo
const avgSessionsPerDay = dayCount > 0 ? total / dayCount : 0; const avgSessionsPerDay = dayCount > 0 ? total / dayCount : 0;
// Calculate average session length // Calculate average session length
const avgSessionLength = durationCount > 0 ? totalDuration / durationCount : null; const avgSessionLength =
durationCount > 0 ? totalDuration / durationCount : null;
return { return {
totalSessions: total, totalSessions: total,

View File

@ -15,7 +15,11 @@ export function startScheduler() {
const companies = await prisma.company.findMany(); const companies = await prisma.company.findMany();
for (const company of companies) { for (const company of companies) {
try { try {
const sessions = await fetchAndParseCsv(company.csvUrl, company.csvUsername as string | undefined, company.csvPassword as string | undefined); const sessions = await fetchAndParseCsv(
company.csvUrl,
company.csvUsername as string | undefined,
company.csvPassword as string | undefined,
);
await prisma.session.deleteMany({ where: { companyId: company.id } }); await prisma.session.deleteMany({ where: { companyId: company.id } });
for (const session of sessions) { for (const session of sessions) {
@ -24,7 +28,7 @@ export function startScheduler() {
companyId: company.id, companyId: company.id,
id: session.id || session.sessionId || `sess_${Date.now()}`, id: session.id || session.sessionId || `sess_${Date.now()}`,
// Ensure startTime is not undefined // Ensure startTime is not undefined
startTime: session.startTime || new Date() startTime: session.startTime || new Date(),
}; };
// Only include fields that are properly typed for Prisma // Only include fields that are properly typed for Prisma
@ -38,13 +42,21 @@ export function startScheduler() {
ipAddress: session.ipAddress || null, ipAddress: session.ipAddress || null,
country: session.country || null, country: session.country || null,
language: session.language || null, language: session.language || null,
sentiment: typeof session.sentiment === 'number' ? session.sentiment : null, sentiment:
messagesSent: typeof session.messagesSent === 'number' ? session.messagesSent : 0, typeof session.sentiment === "number"
category: session.category || null ? session.sentiment
} : null,
messagesSent:
typeof session.messagesSent === "number"
? session.messagesSent
: 0,
category: session.category || null,
},
}); });
} }
console.log(`[Scheduler] Refreshed sessions for company: ${company.name}`); console.log(
`[Scheduler] Refreshed sessions for company: ${company.name}`,
);
} catch (e) { } catch (e) {
console.error(`[Scheduler] Failed for company: ${company.name} - ${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> { export async function sendEmail(
to: string,
subject: string,
text: string,
): Promise<void> {
// For demo: log to console. Use nodemailer/sendgrid/whatever in prod. // For demo: log to console. Use nodemailer/sendgrid/whatever in prod.
console.log(`[Email to ${to}]: ${subject}\n${text}`); console.log(`[Email to ${to}]: ${subject}\n${text}`);
} }

View File

@ -1,4 +1,4 @@
import { Session as NextAuthSession } from 'next-auth'; import { Session as NextAuthSession } from "next-auth";
export interface UserSession extends NextAuthSession { export interface UserSession extends NextAuthSession {
user: { user: {

158
package-lock.json generated
View File

@ -35,6 +35,7 @@
"postcss": "^8.4.0", "postcss": "^8.4.0",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prisma": "^6.8.2", "prisma": "^6.8.2",
"ts-node": "^10.9.2",
"typescript": "^5.0.0" "typescript": "^5.0.0"
} }
}, },
@ -74,6 +75,30 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.4.3", "version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz",
@ -1400,6 +1425,34 @@
"tailwindcss": "4.1.7" "tailwindcss": "4.1.7"
} }
}, },
"node_modules/@tsconfig/node10": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
"integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.9.0", "version": "0.9.0",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",
@ -1978,6 +2031,19 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/acorn-walk": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@ -2011,6 +2077,13 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"license": "MIT"
},
"node_modules/argparse": { "node_modules/argparse": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@ -2547,6 +2620,13 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -2716,6 +2796,16 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -4808,6 +4898,13 @@
"@jridgewell/sourcemap-codec": "^1.5.0" "@jridgewell/sourcemap-codec": "^1.5.0"
} }
}, },
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true,
"license": "ISC"
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -6344,6 +6441,50 @@
"typescript": ">=4.8.4" "typescript": ">=4.8.4"
} }
}, },
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/tsconfig-paths": { "node_modules/tsconfig-paths": {
"version": "3.15.0", "version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@ -6577,6 +6718,13 @@
"uuid": "dist/bin/uuid" "uuid": "dist/bin/uuid"
} }
}, },
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"license": "MIT"
},
"node_modules/web-streams-polyfill": { "node_modules/web-streams-polyfill": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
@ -6707,6 +6855,16 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@ -4,13 +4,14 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"format": "prettier --write .", "format": "prettier --write .",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev" "prisma:migrate": "prisma migrate dev",
"prisma:seed": "node prisma/seed.mjs"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^6.8.2", "@prisma/client": "^6.8.2",

View File

@ -11,7 +11,10 @@ interface SessionCreateData {
[key: string]: unknown; [key: string]: unknown;
} }
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
// Check if this is a POST request // Check if this is a POST request
if (req.method !== "POST") { if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" }); return res.status(405).json({ error: "Method not allowed" });
@ -24,8 +27,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// Try to get user from prisma based on session cookie // Try to get user from prisma based on session cookie
try { try {
const session = await prisma.session.findFirst({ const session = await prisma.session.findFirst({
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: "desc" },
where: { /* Add session check criteria here */ } where: {
/* Add session check criteria here */
},
}); });
if (session) { if (session) {
@ -44,7 +49,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (!company) return res.status(404).json({ error: "Company not found" }); if (!company) return res.status(404).json({ error: "Company not found" });
try { try {
const sessions = await fetchAndParseCsv(company.csvUrl, company.csvUsername as string | undefined, company.csvPassword as string | undefined); 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) // Replace all session rows for this company (for demo simplicity)
await prisma.session.deleteMany({ where: { companyId: company.id } }); await prisma.session.deleteMany({ where: { companyId: company.id } });
@ -53,9 +62,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const sessionData: SessionCreateData = { const sessionData: SessionCreateData = {
...session, ...session,
companyId: company.id, companyId: company.id,
id: session.id || session.sessionId || `sess_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`, id:
session.id ||
session.sessionId ||
`sess_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
// Ensure startTime is not undefined // Ensure startTime is not undefined
startTime: session.startTime || new Date() startTime: session.startTime || new Date(),
}; };
// Only include fields that are properly typed for Prisma // Only include fields that are properly typed for Prisma
@ -69,16 +81,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
ipAddress: session.ipAddress || null, ipAddress: session.ipAddress || null,
country: session.country || null, country: session.country || null,
language: session.language || null, language: session.language || null,
sentiment: typeof session.sentiment === 'number' ? session.sentiment : null, sentiment:
messagesSent: typeof session.messagesSent === 'number' ? session.messagesSent : 0, typeof session.sentiment === "number" ? session.sentiment : null,
category: session.category || null messagesSent:
} typeof session.messagesSent === "number" ? session.messagesSent : 0,
category: session.category || null,
},
}); });
} }
res.json({ ok: true, imported: sessions.length }); res.json({ ok: true, imported: sessions.length });
} catch (e) { } catch (e) {
const error = e instanceof Error ? e.message : 'An unknown error occurred'; const error = e instanceof Error ? e.message : "An unknown error occurred";
res.status(500).json({ error }); res.status(500).json({ error });
} }
} }

View File

@ -46,7 +46,7 @@ export const authOptions: NextAuthOptions = {
} }
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email: credentials.email } where: { email: credentials.email },
}); });
if (!user) return null; if (!user) return null;
@ -63,7 +63,21 @@ export const authOptions: NextAuthOptions = {
}, },
}), }),
], ],
session: { strategy: "jwt" }, session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 days
},
cookies: {
sessionToken: {
name: `next-auth.session-token`,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: process.env.NODE_ENV === "production",
},
},
},
callbacks: { callbacks: {
async jwt({ token, user }) { async jwt({ token, user }) {
if (user) { if (user) {
@ -83,7 +97,8 @@ export const authOptions: NextAuthOptions = {
pages: { pages: {
signIn: "/login", signIn: "/login",
}, },
secret: process.env.NEXTAUTH_SECRET || "fallback-secret-key-change-in-production", secret: process.env.NEXTAUTH_SECRET,
debug: process.env.NODE_ENV === "development",
}; };
export default NextAuth(authOptions); export default NextAuth(authOptions);

View File

@ -4,12 +4,15 @@ import { getServerSession } from "next-auth";
import { prisma } from "../../../lib/prisma"; import { prisma } from "../../../lib/prisma";
import { authOptions } from "../auth/[...nextauth]"; import { authOptions } from "../auth/[...nextauth]";
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const session = await getServerSession(req, res, authOptions); const session = await getServerSession(req, res, authOptions);
if (!session?.user) return res.status(401).json({ error: "Not logged in" }); if (!session?.user) return res.status(401).json({ error: "Not logged in" });
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email: session.user.email as string } where: { email: session.user.email as string },
}); });
if (!user) return res.status(401).json({ error: "No user" }); if (!user) return res.status(401).json({ error: "No user" });
@ -18,7 +21,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const { csvUrl } = req.body; const { csvUrl } = req.body;
await prisma.company.update({ await prisma.company.update({
where: { id: user.companyId }, where: { id: user.companyId },
data: { csvUrl } data: { csvUrl },
}); });
res.json({ ok: true }); res.json({ ok: true });
} else { } else {

View File

@ -16,20 +16,24 @@ interface SessionData {
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse res: NextApiResponse,
) { ) {
const session = await getServerSession(req, res, authOptions) as SessionData | null; const session = (await getServerSession(
req,
res,
authOptions,
)) as SessionData | null;
if (!session?.user) return res.status(401).json({ error: "Not logged in" }); if (!session?.user) return res.status(401).json({ error: "Not logged in" });
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email: session.user.email }, where: { email: session.user.email },
include: { company: true } include: { company: true },
}); });
if (!user) return res.status(401).json({ error: "No user" }); if (!user) return res.status(401).json({ error: "No user" });
const sessions = await prisma.session.findMany({ const sessions = await prisma.session.findMany({
where: { companyId: user.companyId } where: { companyId: user.companyId },
}); });
// Pass company config to metrics // Pass company config to metrics
@ -39,6 +43,6 @@ export default async function handler(
res.json({ res.json({
metrics, metrics,
csvUrl: user.company.csvUrl, csvUrl: user.company.csvUrl,
company: user.company company: user.company,
}); });
} }

View File

@ -3,13 +3,16 @@ import { getServerSession } from "next-auth";
import { prisma } from "../../../lib/prisma"; import { prisma } from "../../../lib/prisma";
import { authOptions } from "../auth/[...nextauth]"; import { authOptions } from "../auth/[...nextauth]";
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const session = await getServerSession(req, res, authOptions); const session = await getServerSession(req, res, authOptions);
if (!session?.user || session.user.role !== "admin") if (!session?.user || session.user.role !== "admin")
return res.status(403).json({ error: "Forbidden" }); return res.status(403).json({ error: "Forbidden" });
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email: session.user.email as string } where: { email: session.user.email as string },
}); });
if (!user) return res.status(401).json({ error: "No user" }); if (!user) return res.status(401).json({ error: "No user" });
@ -22,8 +25,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
csvUrl, csvUrl,
csvUsername, csvUsername,
...(csvPassword ? { csvPassword } : {}), ...(csvPassword ? { csvPassword } : {}),
sentimentAlert: sentimentThreshold ? parseFloat(sentimentThreshold) : null, sentimentAlert: sentimentThreshold
} ? parseFloat(sentimentThreshold)
: null,
},
}); });
res.json({ ok: true }); res.json({ ok: true });
} else { } else {

View File

@ -11,33 +11,36 @@ interface UserBasicInfo {
role: string; role: string;
} }
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const session = await getServerSession(req, res, authOptions); const session = await getServerSession(req, res, authOptions);
if (!session?.user || session.user.role !== "admin") if (!session?.user || session.user.role !== "admin")
return res.status(403).json({ error: "Forbidden" }); return res.status(403).json({ error: "Forbidden" });
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email: session.user.email as string } where: { email: session.user.email as string },
}); });
if (!user) return res.status(401).json({ error: "No user" }); if (!user) return res.status(401).json({ error: "No user" });
if (req.method === "GET") { if (req.method === "GET") {
const users = await prisma.user.findMany({ const users = await prisma.user.findMany({
where: { companyId: user.companyId } where: { companyId: user.companyId },
}); });
const mappedUsers: UserBasicInfo[] = users.map(u => ({ const mappedUsers: UserBasicInfo[] = users.map((u) => ({
id: u.id, id: u.id,
email: u.email, email: u.email,
role: u.role role: u.role,
})); }));
res.json({ users: mappedUsers }); res.json({ users: mappedUsers });
} } else if (req.method === "POST") {
else if (req.method === "POST") {
const { email, role } = req.body; const { email, role } = req.body;
if (!email || !role) return res.status(400).json({ error: "Missing fields" }); if (!email || !role)
return res.status(400).json({ error: "Missing fields" });
const exists = await prisma.user.findUnique({ where: { email } }); const exists = await prisma.user.findUnique({ where: { email } });
if (exists) return res.status(409).json({ error: "Email exists" }); if (exists) return res.status(409).json({ error: "Email exists" });
const tempPassword = Math.random().toString(36).slice(-8); // random initial password const tempPassword = Math.random().toString(36).slice(-8); // random initial password
@ -47,10 +50,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
password: await bcrypt.hash(tempPassword, 10), password: await bcrypt.hash(tempPassword, 10),
companyId: user.companyId, companyId: user.companyId,
role, role,
} },
}); });
// TODO: Email user their temp password (stub, for demo) // TODO: Email user their temp password (stub, for demo)
res.json({ ok: true, tempPassword }); res.json({ ok: true, tempPassword });
} } else res.status(405).end();
else res.status(405).end();
} }

View File

@ -1,7 +1,7 @@
import { prisma } from "../../lib/prisma"; import { prisma } from "../../lib/prisma";
import { sendEmail } from "../../lib/sendEmail"; import { sendEmail } from "../../lib/sendEmail";
import crypto from "crypto"; import crypto from "crypto";
import type { IncomingMessage, ServerResponse } from 'http'; import type { IncomingMessage, ServerResponse } from "http";
type NextApiRequest = IncomingMessage & { type NextApiRequest = IncomingMessage & {
body: { body: {
@ -16,7 +16,10 @@ type NextApiResponse = ServerResponse & {
end: () => void; end: () => void;
}; };
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") return res.status(405).end(); if (req.method !== "POST") return res.status(405).end();
const { email } = req.body; const { email } = req.body;
const user = await prisma.user.findUnique({ where: { email } }); const user = await prisma.user.findUnique({ where: { email } });

View File

@ -10,7 +10,10 @@ interface RegisterRequestBody {
csvUrl?: string; csvUrl?: string;
} }
export default async function handler(req: NextApiRequest, res: NextApiResponse<ApiResponse<{ success: boolean; } | { error: string; }>>) { export default async function handler(
req: NextApiRequest,
res: NextApiResponse<ApiResponse<{ success: boolean } | { error: string }>>,
) {
if (req.method !== "POST") return res.status(405).end(); if (req.method !== "POST") return res.status(405).end();
const { email, password, company, csvUrl } = req.body as RegisterRequestBody; const { email, password, company, csvUrl } = req.body as RegisterRequestBody;
@ -18,19 +21,19 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
if (!email || !password || !company) { if (!email || !password || !company) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
error: "Missing required fields" error: "Missing required fields",
}); });
} }
// Check if email exists // Check if email exists
const exists = await prisma.user.findUnique({ const exists = await prisma.user.findUnique({
where: { email } where: { email },
}); });
if (exists) { if (exists) {
return res.status(409).json({ return res.status(409).json({
success: false, success: false,
error: "Email already exists" error: "Email already exists",
}); });
} }
@ -48,6 +51,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
}); });
res.status(201).json({ res.status(201).json({
success: true, success: true,
data: { success: true } data: { success: true },
}); });
} }

View File

@ -1,6 +1,6 @@
import { prisma } from "../../lib/prisma"; import { prisma } from "../../lib/prisma";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import type { IncomingMessage, ServerResponse } from 'http'; import type { IncomingMessage, ServerResponse } from "http";
type NextApiRequest = IncomingMessage & { type NextApiRequest = IncomingMessage & {
body: { body: {
@ -16,14 +16,17 @@ type NextApiResponse = ServerResponse & {
end: () => void; end: () => void;
}; };
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") return res.status(405).end(); if (req.method !== "POST") return res.status(405).end();
const { token, password } = req.body; const { token, password } = req.body;
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
where: { where: {
resetToken: token, resetToken: token,
resetTokenExpiry: { gte: new Date() } resetTokenExpiry: { gte: new Date() },
} },
}); });
if (!user) return res.status(400).json({ error: "Invalid or expired token" }); if (!user) return res.status(400).json({ error: "Invalid or expired token" });
@ -34,7 +37,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
password: hash, password: hash,
resetToken: null, resetToken: null,
resetTokenExpiry: null, resetTokenExpiry: null,
} },
}); });
res.status(200).end(); res.status(200).end();
} }

View File

@ -1,6 +1,6 @@
export default { export default {
plugins: { plugins: {
'@tailwindcss/postcss': {}, "@tailwindcss/postcss": {},
autoprefixer: {}, autoprefixer: {},
}, },
}; };

View File

@ -0,0 +1,50 @@
-- CreateTable
CREATE TABLE "Company" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"csvUrl" TEXT NOT NULL,
"csvUsername" TEXT,
"csvPassword" TEXT,
"sentimentAlert" REAL,
"dashboardOpts" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"role" TEXT NOT NULL,
"resetToken" TEXT,
"resetTokenExpiry" DATETIME,
CONSTRAINT "User_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL PRIMARY KEY,
"companyId" TEXT NOT NULL,
"startTime" DATETIME NOT NULL,
"endTime" DATETIME NOT NULL,
"ipAddress" TEXT,
"country" TEXT,
"language" TEXT,
"messagesSent" INTEGER,
"sentiment" REAL,
"escalated" BOOLEAN,
"forwardedHr" BOOLEAN,
"fullTranscriptUrl" TEXT,
"avgResponseTime" REAL,
"tokens" INTEGER,
"tokensEur" REAL,
"category" TEXT,
"initialMsg" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Session_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

39
prisma/seed.js Normal file
View File

@ -0,0 +1,39 @@
// seed.js - Create initial admin user and company
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
// Create a company
const company = await prisma.company.create({
data: {
name: 'Demo Company',
csvUrl: 'https://example.com/data.csv', // Replace with a real URL if available
}
});
// Create an admin user
const hashedPassword = await bcrypt.hash('admin123', 10);
await prisma.user.create({
data: {
email: 'admin@demo.com',
password: hashedPassword,
role: 'admin',
companyId: company.id
}
});
console.log('Seed data created successfully:');
console.log('Company: Demo Company');
console.log('Admin user: admin@demo.com (password: admin123)');
}
main()
.catch(e => {
console.error('Error seeding database:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

39
prisma/seed.mjs Normal file
View File

@ -0,0 +1,39 @@
// Seed script for creating initial data
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
async function main() {
try {
// Create a company
const company = await prisma.company.create({
data: {
name: "Demo Company",
csvUrl: "https://example.com/data.csv", // Replace with a real URL if available
},
});
// Create an admin user
const hashedPassword = await bcrypt.hash("admin123", 10);
await prisma.user.create({
data: {
email: "admin@demo.com",
password: hashedPassword,
role: "admin",
companyId: company.id,
},
});
console.log("Seed data created successfully:");
console.log("Company: Demo Company");
console.log("Admin user: admin@demo.com (password: admin123)");
} catch (error) {
console.error("Error seeding database:", error);
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
main();

39
prisma/seed.ts Normal file
View File

@ -0,0 +1,39 @@
// seed.ts - Create initial admin user and company
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
async function main() {
try {
// Create a company
const company = await prisma.company.create({
data: {
name: "Demo Company",
csvUrl: "https://example.com/data.csv", // Replace with a real URL if available
},
});
// Create an admin user
const hashedPassword = await bcrypt.hash("admin123", 10);
await prisma.user.create({
data: {
email: "admin@demo.com",
password: hashedPassword,
role: "admin",
companyId: company.id,
},
});
console.log("Seed data created successfully:");
console.log("Company: Demo Company");
console.log("Admin user: admin@demo.com (password: admin123)");
} catch (error) {
console.error("Error seeding database:", error);
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
main();

View File

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

View File

@ -1,14 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "es5",
"lib": [ "lib": ["dom", "dom.iterable", "esnext"],
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": false, // Temporarily disabled strict mode "strict": true,
"noImplicitAny": false, // Allow implicit any types "noImplicitAny": false, // Allow implicit any types
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noEmit": true, "noEmit": true,
@ -25,19 +21,10 @@
} }
], ],
"paths": { "paths": {
"@/*": [ "@/*": ["./*"]
"./*"
]
}, },
"strictNullChecks": true "strictNullChecks": true
}, },
"include": [ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"next-env.d.ts", "exclude": ["node_modules"]
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }