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

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

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

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

style: format dashboard API routes for better readability

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

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

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

style: clean up PostCSS and Tailwind CSS configuration files

fix: update TypeScript configuration for stricter type checking

chore: add development environment variables for NextAuth

feat: create Providers component for session management in the app

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

9
.env.development Normal file
View File

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

View File

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

193
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

17
app/providers.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -4,101 +4,108 @@ import { parse } from "csv-parse/sync";
// This type is used internally for parsing the CSV records // This type is used internally for parsing the CSV records
interface CSVRecord { interface CSVRecord {
session_id: string; session_id: string;
start_time: string; start_time: string;
end_time?: string; end_time?: string;
ip_address?: string; ip_address?: string;
country?: string; country?: string;
language?: string; language?: string;
messages_sent?: string; messages_sent?: string;
sentiment?: string; sentiment?: string;
escalated?: string; escalated?: string;
forwarded_hr?: string; forwarded_hr?: string;
full_transcript_url?: string; full_transcript_url?: string;
avg_response_time?: string; avg_response_time?: string;
tokens?: string; tokens?: string;
tokens_eur?: string; tokens_eur?: string;
category?: string; category?: string;
initial_msg?: string; initial_msg?: string;
[key: string]: string | undefined; [key: string]: string | undefined;
} }
interface SessionData { interface SessionData {
id: string; id: string;
sessionId: string; sessionId: string;
startTime: Date; startTime: Date;
endTime: Date | null; endTime: Date | null;
ipAddress?: string; ipAddress?: string;
country?: string; country?: string;
language?: string | null; language?: string | null;
messagesSent: number; messagesSent: number;
sentiment: number | null; sentiment: number | null;
escalated: boolean; escalated: boolean;
forwardedHr: boolean; forwardedHr: boolean;
fullTranscriptUrl?: string | null; fullTranscriptUrl?: string | null;
avgResponseTime: number | null; avgResponseTime: number | null;
tokens: number; tokens: number;
tokensEur: number; tokensEur: number;
category?: string | null; category?: string | null;
initialMsg?: string; initialMsg?: string;
} }
export async function fetchAndParseCsv(url: string, username?: string, password?: string): Promise<Partial<SessionData>[]> { export async function fetchAndParseCsv(
const authHeader = username && password url: string,
? "Basic " + Buffer.from(`${username}:${password}`).toString("base64") username?: string,
: undefined; password?: string,
): Promise<Partial<SessionData>[]> {
const authHeader =
username && password
? "Basic " + Buffer.from(`${username}:${password}`).toString("base64")
: undefined;
const res = await fetch(url, { const res = await fetch(url, {
headers: authHeader ? { Authorization: authHeader } : {}, headers: authHeader ? { Authorization: authHeader } : {},
}); });
if (!res.ok) throw new Error("Failed to fetch CSV: " + res.statusText); if (!res.ok) throw new Error("Failed to fetch CSV: " + res.statusText);
const text = await res.text(); const text = await res.text();
// Parse without expecting headers, using known order // Parse without expecting headers, using known order
const records: CSVRecord[] = parse(text, { const records: CSVRecord[] = parse(text, {
delimiter: ",", delimiter: ",",
columns: [ columns: [
"session_id", "session_id",
"start_time", "start_time",
"end_time", "end_time",
"ip_address", "ip_address",
"country", "country",
"language", "language",
"messages_sent", "messages_sent",
"sentiment", "sentiment",
"escalated", "escalated",
"forwarded_hr", "forwarded_hr",
"full_transcript_url", "full_transcript_url",
"avg_response_time", "avg_response_time",
"tokens", "tokens",
"tokens_eur", "tokens_eur",
"category", "category",
"initial_msg", "initial_msg",
], ],
from_line: 1, from_line: 1,
relax_column_count: true, relax_column_count: true,
skip_empty_lines: true, skip_empty_lines: true,
trim: true, trim: true,
}); });
// Coerce types for relevant columns // Coerce types for relevant columns
return records.map((r) => ({ return records.map((r) => ({
id: r.session_id, id: r.session_id,
startTime: new Date(r.start_time), startTime: new Date(r.start_time),
endTime: r.end_time ? new Date(r.end_time) : null, endTime: r.end_time ? new Date(r.end_time) : null,
ipAddress: r.ip_address, ipAddress: r.ip_address,
country: r.country, country: r.country,
language: r.language, language: r.language,
messagesSent: Number(r.messages_sent) || 0, messagesSent: Number(r.messages_sent) || 0,
sentiment: r.sentiment ? parseFloat(r.sentiment) : null, sentiment: r.sentiment ? parseFloat(r.sentiment) : null,
escalated: r.escalated === "1" || r.escalated === "true", escalated: r.escalated === "1" || r.escalated === "true",
forwardedHr: r.forwarded_hr === "1" || r.forwarded_hr === "true", forwardedHr: r.forwarded_hr === "1" || r.forwarded_hr === "true",
fullTranscriptUrl: r.full_transcript_url, fullTranscriptUrl: r.full_transcript_url,
avgResponseTime: r.avg_response_time ? parseFloat(r.avg_response_time) : null, avgResponseTime: r.avg_response_time
tokens: Number(r.tokens) || 0, ? parseFloat(r.avg_response_time)
tokensEur: r.tokens_eur ? parseFloat(r.tokens_eur) : 0, : null,
category: r.category, tokens: Number(r.tokens) || 0,
initialMsg: r.initial_msg, tokensEur: r.tokens_eur ? parseFloat(r.tokens_eur) : 0,
})); category: r.category,
initialMsg: r.initial_msg,
}));
} }

View File

@ -1,85 +1,100 @@
// Functions to calculate metrics over sessions // Functions to calculate metrics over sessions
import { ChatSession, DayMetrics, CategoryMetrics, LanguageMetrics, MetricsResult } from './types'; import {
ChatSession,
DayMetrics,
CategoryMetrics,
LanguageMetrics,
MetricsResult,
} from "./types";
interface CompanyConfig { interface CompanyConfig {
sentimentAlert?: number; sentimentAlert?: number;
} }
export function sessionMetrics(sessions: ChatSession[], companyConfig: CompanyConfig = {}): MetricsResult { export function sessionMetrics(
const total = sessions.length; sessions: ChatSession[],
const byDay: DayMetrics = {}; companyConfig: CompanyConfig = {},
const byCategory: CategoryMetrics = {}; ): MetricsResult {
const byLanguage: LanguageMetrics = {}; const total = sessions.length;
let escalated = 0, forwarded = 0; const byDay: DayMetrics = {};
let totalSentiment = 0, sentimentCount = 0; const byCategory: CategoryMetrics = {};
let totalResponse = 0, responseCount = 0; const byLanguage: LanguageMetrics = {};
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 // Calculate total session duration in minutes
let totalDuration = 0; let totalDuration = 0;
let durationCount = 0; let durationCount = 0;
sessions.forEach(s => { sessions.forEach((s) => {
const day = s.startTime.toISOString().slice(0, 10); const day = s.startTime.toISOString().slice(0, 10);
byDay[day] = (byDay[day] || 0) + 1; byDay[day] = (byDay[day] || 0) + 1;
if (s.category) byCategory[s.category] = (byCategory[s.category] || 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.language) byLanguage[s.language] = (byLanguage[s.language] || 0) + 1;
if (s.endTime) { if (s.endTime) {
const duration = (s.endTime.getTime() - s.startTime.getTime()) / (1000 * 60); // minutes const duration =
totalDuration += duration; (s.endTime.getTime() - s.startTime.getTime()) / (1000 * 60); // minutes
durationCount++; 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 if (s.escalated) escalated++;
const dayCount = Object.keys(byDay).length; if (s.forwardedHr) forwarded++;
const avgSessionsPerDay = dayCount > 0 ? total / dayCount : 0;
// Calculate average session length if (s.sentiment != null) {
const avgSessionLength = durationCount > 0 ? totalDuration / durationCount : null; totalSentiment += s.sentiment;
sentimentCount++;
}
return { if (s.avgResponseTime != null) {
totalSessions: total, totalResponse += s.avgResponseTime;
avgSessionsPerDay, responseCount++;
avgSessionLength, }
days: byDay,
languages: byLanguage, totalTokens += s.tokens || 0;
categories: byCategory, totalTokensEur += s.tokensEur || 0;
belowThresholdCount: belowThreshold, });
// Additional metrics not in the interface - using type assertion
escalatedCount: escalated, // Now add sentiment alert logic:
forwardedCount: forwarded, let belowThreshold = 0;
avgSentiment: sentimentCount ? totalSentiment / sentimentCount : null, const threshold = companyConfig.sentimentAlert ?? null;
avgResponseTime: responseCount ? totalResponse / responseCount : null, if (threshold != null) {
totalTokens, for (const s of sessions) {
totalTokensEur, if (s.sentiment != null && s.sentiment < threshold) belowThreshold++;
sentimentThreshold: threshold, }
} as MetricsResult; }
// 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;
} }

View File

@ -6,7 +6,7 @@ import { PrismaClient } from "@prisma/client";
// Prevent multiple instances of Prisma Client in development // Prevent multiple instances of Prisma Client in development
declare const global: { declare const global: {
prisma: PrismaClient | undefined; prisma: PrismaClient | undefined;
}; };
// Initialize Prisma Client // Initialize Prisma Client
@ -14,7 +14,7 @@ const prisma = global.prisma || new PrismaClient();
// Save in global if we're in development // Save in global if we're in development
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {
global.prisma = prisma; global.prisma = prisma;
} }
export { prisma }; export { prisma };

View File

@ -4,50 +4,62 @@ import { prisma } from "./prisma";
import { fetchAndParseCsv } from "./csvFetcher"; import { fetchAndParseCsv } from "./csvFetcher";
interface SessionCreateData { interface SessionCreateData {
id: string; id: string;
startTime: Date; startTime: Date;
companyId: string; companyId: string;
[key: string]: unknown; [key: string]: unknown;
} }
export function startScheduler() { export function startScheduler() {
cron.schedule("*/15 * * * *", async () => { cron.schedule("*/15 * * * *", async () => {
const companies = await prisma.company.findMany(); const companies = await prisma.company.findMany();
for (const company of companies) { for (const company of companies) {
try { try {
const sessions = await fetchAndParseCsv(company.csvUrl, company.csvUsername as string | undefined, company.csvPassword as string | undefined); const sessions = await fetchAndParseCsv(
await prisma.session.deleteMany({ where: { companyId: company.id } }); 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) { for (const session of sessions) {
const sessionData: SessionCreateData = { const sessionData: SessionCreateData = {
...session, ...session,
companyId: company.id, companyId: company.id,
id: session.id || session.sessionId || `sess_${Date.now()}`, id: session.id || session.sessionId || `sess_${Date.now()}`,
// Ensure startTime is not undefined // Ensure startTime is not undefined
startTime: session.startTime || new Date() startTime: session.startTime || new Date(),
}; };
// Only include fields that are properly typed for Prisma // Only include fields that are properly typed for Prisma
await prisma.session.create({ await prisma.session.create({
data: { data: {
id: sessionData.id, id: sessionData.id,
companyId: sessionData.companyId, companyId: sessionData.companyId,
startTime: sessionData.startTime, startTime: sessionData.startTime,
// endTime is required in the schema, so use startTime if not available // endTime is required in the schema, so use startTime if not available
endTime: session.endTime || new Date(), endTime: session.endTime || new Date(),
ipAddress: session.ipAddress || null, ipAddress: session.ipAddress || null,
country: session.country || null, country: session.country || null,
language: session.language || null, language: session.language || null,
sentiment: typeof session.sentiment === 'number' ? session.sentiment : null, sentiment:
messagesSent: typeof session.messagesSent === 'number' ? session.messagesSent : 0, typeof session.sentiment === "number"
category: session.category || null ? session.sentiment
} : null,
}); messagesSent:
} typeof session.messagesSent === "number"
console.log(`[Scheduler] Refreshed sessions for company: ${company.name}`); ? session.messagesSent
} catch (e) { : 0,
console.error(`[Scheduler] Failed for company: ${company.name} - ${e}`); category: session.category || null,
} },
});
} }
}); 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(
// For demo: log to console. Use nodemailer/sendgrid/whatever in prod. to: string,
console.log(`[Email to ${to}]: ${subject}\n${text}`); 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,94 +1,94 @@
import { Session as NextAuthSession } from 'next-auth'; import { Session as NextAuthSession } from "next-auth";
export interface UserSession extends NextAuthSession { export interface UserSession extends NextAuthSession {
user: { user: {
id?: string; id?: string;
name?: string; name?: string;
email?: string; email?: string;
image?: string; image?: string;
companyId: string; companyId: string;
role: string; role: string;
}; };
} }
export interface Company { export interface Company {
id: string; id: string;
name: string; name: string;
csvUrl: string; csvUrl: string;
csvUsername?: string; csvUsername?: string;
csvPassword?: string; csvPassword?: string;
sentimentAlert?: number; // Match Prisma schema naming sentimentAlert?: number; // Match Prisma schema naming
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
export interface User { export interface User {
id: string; id: string;
email: string; email: string;
password: string; password: string;
role: string; role: string;
companyId: string; companyId: string;
resetToken?: string | null; resetToken?: string | null;
resetTokenExpiry?: Date | null; resetTokenExpiry?: Date | null;
company?: Company; company?: Company;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
export interface ChatSession { export interface ChatSession {
id: string; id: string;
sessionId: string; sessionId: string;
companyId: string; companyId: string;
userId?: string | null; userId?: string | null;
category?: string | null; category?: string | null;
language?: string | null; language?: string | null;
sentiment?: number | null; sentiment?: number | null;
startTime: Date; startTime: Date;
endTime?: Date | null; endTime?: Date | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
// Extended session properties that might be used in metrics // Extended session properties that might be used in metrics
avgResponseTime?: number | null; avgResponseTime?: number | null;
escalated?: boolean; escalated?: boolean;
forwardedHr?: boolean; forwardedHr?: boolean;
tokens?: number; tokens?: number;
tokensEur?: number; tokensEur?: number;
initialMsg?: string; initialMsg?: string;
} }
export interface DayMetrics { export interface DayMetrics {
[day: string]: number; [day: string]: number;
} }
export interface CategoryMetrics { export interface CategoryMetrics {
[category: string]: number; [category: string]: number;
} }
export interface LanguageMetrics { export interface LanguageMetrics {
[language: string]: number; [language: string]: number;
} }
export interface MetricsResult { export interface MetricsResult {
totalSessions: number; totalSessions: number;
avgSessionsPerDay: number; avgSessionsPerDay: number;
avgSessionLength: number | null; avgSessionLength: number | null;
days: DayMetrics; days: DayMetrics;
languages: LanguageMetrics; languages: LanguageMetrics;
categories: CategoryMetrics; categories: CategoryMetrics;
belowThresholdCount: number; belowThresholdCount: number;
// Additional properties for dashboard // Additional properties for dashboard
escalatedCount?: number; escalatedCount?: number;
forwardedCount?: number; forwardedCount?: number;
avgSentiment?: number; avgSentiment?: number;
avgResponseTime?: number; avgResponseTime?: number;
totalTokens?: number; totalTokens?: number;
totalTokensEur?: number; totalTokensEur?: number;
sentimentThreshold?: number | null; sentimentThreshold?: number | null;
} }
export interface ApiResponse<T> { export interface ApiResponse<T> {
success: boolean; success: boolean;
data?: T; data?: T;
error?: string; error?: string;
} }

View File

@ -1,6 +1,6 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
}; };
export default nextConfig; export default nextConfig;

13598
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -4,81 +4,95 @@ import { fetchAndParseCsv } from "../../../lib/csvFetcher";
import { prisma } from "../../../lib/prisma"; import { prisma } from "../../../lib/prisma";
interface SessionCreateData { interface SessionCreateData {
id: string; id: string;
startTime: Date; startTime: Date;
companyId: string; companyId: string;
sessionId?: string; sessionId?: string;
[key: string]: unknown; [key: string]: unknown;
} }
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(
// Check if this is a POST request req: NextApiRequest,
if (req.method !== "POST") { res: NextApiResponse,
return res.status(405).json({ error: "Method not allowed" }); ) {
} // 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 // Get companyId from body or query
let { companyId } = req.body; 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" });
if (!companyId) {
// Try to get user from prisma based on session cookie
try { try {
const sessions = await fetchAndParseCsv(company.csvUrl, company.csvUsername as string | undefined, company.csvPassword as string | undefined); const session = await prisma.session.findFirst({
orderBy: { createdAt: "desc" },
where: {
/* Add session check criteria here */
},
});
// Replace all session rows for this company (for demo simplicity) if (session) {
await prisma.session.deleteMany({ where: { companyId: company.id } }); companyId = session.companyId;
}
for (const session of sessions) { } catch (error) {
const sessionData: SessionCreateData = { console.error("Error fetching session:", error);
...session,
companyId: company.id,
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()
};
// Only include fields that are properly typed for Prisma
await prisma.session.create({
data: {
id: sessionData.id,
companyId: sessionData.companyId,
startTime: sessionData.startTime,
// endTime is required in the schema, so use startTime if not available
endTime: session.endTime || new Date(),
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
}
});
}
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 });
} }
}
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 {
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) {
const sessionData: SessionCreateData = {
...session,
companyId: company.id,
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(),
};
// Only include fields that are properly typed for Prisma
await prisma.session.create({
data: {
id: sessionData.id,
companyId: sessionData.companyId,
startTime: sessionData.startTime,
// endTime is required in the schema, so use startTime if not available
endTime: session.endTime || new Date(),
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,
},
});
}
res.json({ ok: true, imported: sessions.length });
} catch (e) {
const error = e instanceof Error ? e.message : "An unknown error occurred";
res.status(500).json({ error });
}
} }

View File

@ -5,85 +5,100 @@ import bcrypt from "bcryptjs";
// Define the shape of the JWT token // Define the shape of the JWT token
declare module "next-auth/jwt" { declare module "next-auth/jwt" {
interface JWT { interface JWT {
companyId: string; companyId: string;
role: string; role: string;
} }
} }
// Define the shape of the session object // Define the shape of the session object
declare module "next-auth" { declare module "next-auth" {
interface Session { interface Session {
user: { user: {
id?: string; id?: string;
name?: string; name?: string;
email?: string; email?: string;
image?: string; image?: string;
companyId: string; companyId: string;
role: string; role: string;
}; };
} }
interface User { interface User {
id: string; id: string;
email: string; email: string;
companyId: string; companyId: string;
role: string; role: string;
} }
} }
export const authOptions: NextAuthOptions = { export const authOptions: NextAuthOptions = {
providers: [ providers: [
CredentialsProvider({ CredentialsProvider({
name: "Credentials", name: "Credentials",
credentials: { credentials: {
email: { label: "Email", type: "text" }, email: { label: "Email", type: "text" },
password: { label: "Password", type: "password" }, password: { label: "Password", type: "password" },
}, },
async authorize(credentials) { async authorize(credentials) {
if (!credentials?.email || !credentials?.password) { if (!credentials?.email || !credentials?.password) {
return null; return null;
} }
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email: credentials.email } where: { email: credentials.email },
}); });
if (!user) return null; if (!user) return null;
const valid = await bcrypt.compare(credentials.password, user.password); const valid = await bcrypt.compare(credentials.password, user.password);
if (!valid) return null; if (!valid) return null;
return { return {
id: user.id, id: user.id,
email: user.email, email: user.email,
companyId: user.companyId, companyId: user.companyId,
role: user.role, role: user.role,
}; };
}, },
}), }),
], ],
session: { strategy: "jwt" }, session: {
callbacks: { strategy: "jwt",
async jwt({ token, user }) { maxAge: 30 * 24 * 60 * 60, // 30 days
if (user) { },
token.companyId = user.companyId; cookies: {
token.role = user.role; sessionToken: {
} name: `next-auth.session-token`,
return token; options: {
}, httpOnly: true,
async session({ session, token }) { sameSite: "lax",
if (token && session.user) { path: "/",
session.user.companyId = token.companyId; secure: process.env.NODE_ENV === "production",
session.user.role = token.role; },
}
return session;
},
}, },
pages: { },
signIn: "/login", callbacks: {
async jwt({ token, user }) {
if (user) {
token.companyId = user.companyId;
token.role = user.role;
}
return token;
}, },
secret: process.env.NEXTAUTH_SECRET || "fallback-secret-key-change-in-production", 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,
debug: process.env.NODE_ENV === "development",
}; };
export default NextAuth(authOptions); export default NextAuth(authOptions);

View File

@ -4,24 +4,27 @@ import { getServerSession } from "next-auth";
import { prisma } from "../../../lib/prisma"; import { prisma } from "../../../lib/prisma";
import { authOptions } from "../auth/[...nextauth]"; import { authOptions } from "../auth/[...nextauth]";
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(
const session = await getServerSession(req, res, authOptions); req: NextApiRequest,
if (!session?.user) return res.status(401).json({ error: "Not logged in" }); 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({ 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 === "POST") {
const { csvUrl } = req.body;
await prisma.company.update({
where: { id: user.companyId },
data: { csvUrl },
}); });
res.json({ ok: true });
if (!user) return res.status(401).json({ error: "No user" }); } else {
res.status(405).end();
if (req.method === "POST") { }
const { csvUrl } = req.body;
await prisma.company.update({
where: { id: user.companyId },
data: { csvUrl }
});
res.json({ ok: true });
} else {
res.status(405).end();
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

39
prisma/seed.js Normal file
View File

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

39
prisma/seed.mjs Normal file
View File

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

39
prisma/seed.ts Normal file
View File

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

View File

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

View File

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