mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 14:52: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