mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 07:12:08 +01:00
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:
6
.eslintrc.json
Normal file
6
.eslintrc.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"next/typescript"
|
||||
]
|
||||
}
|
||||
54
.gitignore
vendored
Normal file
54
.gitignore
vendored
Normal 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
5
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"prisma.prisma"
|
||||
]
|
||||
}
|
||||
165
app/dashboard/page.tsx
Normal file
165
app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
app/dashboard/settings.tsx
Normal file
82
app/dashboard/settings.tsx
Normal 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
76
app/dashboard/users.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
app/forgot-password/page.tsx
Normal file
38
app/forgot-password/page.tsx
Normal 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
9
app/globals.css
Normal 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
19
app/layout.tsx
Normal 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
60
app/login/page.tsx
Normal 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
9
app/page.tsx
Normal 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
77
app/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
app/reset-password/page.tsx
Normal file
44
app/reset-password/page.tsx
Normal 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
80
components/Charts.tsx
Normal 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
104
lib/csvFetcher.ts
Normal 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
85
lib/metrics.ts
Normal 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
20
lib/prisma.ts
Normal 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
27
lib/scheduler.ts
Normal 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
4
lib/sendEmail.ts
Normal 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
86
lib/types.ts
Normal 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
8
next.config.js
Normal 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
6009
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
package.json
Normal file
44
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
60
pages/api/admin/refresh-sessions.ts
Normal file
60
pages/api/admin/refresh-sessions.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
89
pages/api/auth/[...nextauth].ts
Normal file
89
pages/api/auth/[...nextauth].ts
Normal 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);
|
||||
27
pages/api/dashboard/config.ts
Normal file
27
pages/api/dashboard/config.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
44
pages/api/dashboard/metrics.ts
Normal file
44
pages/api/dashboard/metrics.ts
Normal 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
|
||||
});
|
||||
}
|
||||
32
pages/api/dashboard/settings.ts
Normal file
32
pages/api/dashboard/settings.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
56
pages/api/dashboard/users.ts
Normal file
56
pages/api/dashboard/users.ts
Normal 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();
|
||||
}
|
||||
35
pages/api/forgot-password.ts
Normal file
35
pages/api/forgot-password.ts
Normal 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
53
pages/api/register.ts
Normal 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 }
|
||||
});
|
||||
}
|
||||
40
pages/api/reset-password.ts
Normal file
40
pages/api/reset-password.ts
Normal 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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
56
prisma/schema.prisma
Normal file
56
prisma/schema.prisma
Normal 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
12
tailwind.config.js
Normal 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
41
tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user