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

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

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

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

style: format dashboard API routes for better readability

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

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

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

style: clean up PostCSS and Tailwind CSS configuration files

fix: update TypeScript configuration for stricter type checking

chore: add development environment variables for NextAuth

feat: create Providers component for session management in the app

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

9
.env.development Normal file
View File

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

View File

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

193
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,19 +1,19 @@
'use client';
import { useState } from 'react';
"use client";
import { useState } from "react";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState<string>('');
const [message, setMessage] = useState<string>('');
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' },
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.');
if (res.ok) setMessage("If that email exists, a reset link has been sent.");
else setMessage("Failed. Try again.");
}
return (

View File

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

View File

@ -1,23 +1,23 @@
'use client';
import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
"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 [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', {
const res = await signIn("credentials", {
email,
password,
redirect: false,
});
if (res?.ok) router.push('/dashboard');
else setError('Invalid credentials.');
if (res?.ok) router.push("/dashboard");
else setError("Invalid credentials.");
}
return (

View File

@ -1,9 +1,9 @@
import { getServerSession } from 'next-auth';
import { redirect } from 'next/navigation';
import { authOptions } from '../pages/api/auth/[...nextauth]';
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');
if (session?.user) redirect("/dashboard");
else redirect("/login");
}

17
app/providers.tsx Normal file
View File

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

View File

@ -1,25 +1,25 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
"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 [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' },
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.');
if (res.ok) router.push("/login");
else setError("Registration failed.");
}
return (

View File

@ -1,26 +1,26 @@
'use client';
import { useState, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
"use client";
import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
// Component that uses useSearchParams wrapped in Suspense
function ResetPasswordForm() {
const searchParams = useSearchParams();
const token = searchParams?.get('token');
const [password, setPassword] = useState<string>('');
const [message, setMessage] = useState<string>('');
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' },
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.');
setMessage("Password reset! Redirecting to login...");
setTimeout(() => router.push("/login"), 2000);
} else setMessage("Invalid or expired link.");
}
return (

View File

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

View File

@ -43,8 +43,13 @@ interface SessionData {
initialMsg?: string;
}
export async function fetchAndParseCsv(url: string, username?: string, password?: string): Promise<Partial<SessionData>[]> {
const authHeader = username && password
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;
@ -95,7 +100,9 @@ export async function fetchAndParseCsv(url: string, username?: string, password?
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,
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,

View File

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

View File

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

View File

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

View File

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

158
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

39
prisma/seed.js Normal file
View File

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

39
prisma/seed.mjs Normal file
View File

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

39
prisma/seed.ts Normal file
View File

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

View File

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

View File

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