mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 11:32:13 +01:00
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:
9
.env.development
Normal file
9
.env.development
Normal 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
|
||||
@ -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
193
.gitignore
vendored
@ -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
|
||||
|
||||
4
.vscode/extensions.json
vendored
4
.vscode/extensions.json
vendored
@ -1,5 +1,3 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"prisma.prisma"
|
||||
]
|
||||
"recommendations": ["prisma.prisma"]
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
setRefreshing(true);
|
||||
await fetch('/api/admin/refresh-sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ companyId: company?.id }),
|
||||
});
|
||||
setRefreshing(false);
|
||||
window.location.reload();
|
||||
try {
|
||||
setRefreshing(true);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-3xl font-bold">Analytics Dashboard</h1>
|
||||
<button className="text-sm underline" onClick={() => signOut()}>
|
||||
Log out
|
||||
</button>
|
||||
<div className="space-y-6">
|
||||
{/* Header with company info */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<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 />;
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
background: #f3f4f6;
|
||||
font-family: system-ui, sans-serif;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@ -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">
|
||||
<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>
|
||||
</html>
|
||||
);
|
||||
|
||||
@ -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 (
|
||||
|
||||
10
app/page.tsx
10
app/page.tsx
@ -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
17
app/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -4,101 +4,108 @@ import { parse } from "csv-parse/sync";
|
||||
|
||||
// This type is used internally for parsing the CSV records
|
||||
interface CSVRecord {
|
||||
session_id: string;
|
||||
start_time: string;
|
||||
end_time?: string;
|
||||
ip_address?: string;
|
||||
country?: string;
|
||||
language?: string;
|
||||
messages_sent?: string;
|
||||
sentiment?: string;
|
||||
escalated?: string;
|
||||
forwarded_hr?: string;
|
||||
full_transcript_url?: string;
|
||||
avg_response_time?: string;
|
||||
tokens?: string;
|
||||
tokens_eur?: string;
|
||||
category?: string;
|
||||
initial_msg?: string;
|
||||
[key: string]: string | undefined;
|
||||
session_id: string;
|
||||
start_time: string;
|
||||
end_time?: string;
|
||||
ip_address?: string;
|
||||
country?: string;
|
||||
language?: string;
|
||||
messages_sent?: string;
|
||||
sentiment?: string;
|
||||
escalated?: string;
|
||||
forwarded_hr?: string;
|
||||
full_transcript_url?: string;
|
||||
avg_response_time?: string;
|
||||
tokens?: string;
|
||||
tokens_eur?: string;
|
||||
category?: string;
|
||||
initial_msg?: string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
interface SessionData {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
startTime: Date;
|
||||
endTime: Date | null;
|
||||
ipAddress?: string;
|
||||
country?: string;
|
||||
language?: string | null;
|
||||
messagesSent: number;
|
||||
sentiment: number | null;
|
||||
escalated: boolean;
|
||||
forwardedHr: boolean;
|
||||
fullTranscriptUrl?: string | null;
|
||||
avgResponseTime: number | null;
|
||||
tokens: number;
|
||||
tokensEur: number;
|
||||
category?: string | null;
|
||||
initialMsg?: string;
|
||||
id: string;
|
||||
sessionId: string;
|
||||
startTime: Date;
|
||||
endTime: Date | null;
|
||||
ipAddress?: string;
|
||||
country?: string;
|
||||
language?: string | null;
|
||||
messagesSent: number;
|
||||
sentiment: number | null;
|
||||
escalated: boolean;
|
||||
forwardedHr: boolean;
|
||||
fullTranscriptUrl?: string | null;
|
||||
avgResponseTime: number | null;
|
||||
tokens: number;
|
||||
tokensEur: number;
|
||||
category?: string | null;
|
||||
initialMsg?: string;
|
||||
}
|
||||
|
||||
export async function fetchAndParseCsv(url: string, username?: string, password?: string): Promise<Partial<SessionData>[]> {
|
||||
const authHeader = username && password
|
||||
? "Basic " + Buffer.from(`${username}:${password}`).toString("base64")
|
||||
: undefined;
|
||||
export async function fetchAndParseCsv(
|
||||
url: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
): Promise<Partial<SessionData>[]> {
|
||||
const authHeader =
|
||||
username && password
|
||||
? "Basic " + Buffer.from(`${username}:${password}`).toString("base64")
|
||||
: undefined;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: authHeader ? { Authorization: authHeader } : {},
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch CSV: " + res.statusText);
|
||||
const res = await fetch(url, {
|
||||
headers: authHeader ? { Authorization: authHeader } : {},
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch CSV: " + res.statusText);
|
||||
|
||||
const text = await res.text();
|
||||
const text = await res.text();
|
||||
|
||||
// Parse without expecting headers, using known order
|
||||
const records: CSVRecord[] = parse(text, {
|
||||
delimiter: ",",
|
||||
columns: [
|
||||
"session_id",
|
||||
"start_time",
|
||||
"end_time",
|
||||
"ip_address",
|
||||
"country",
|
||||
"language",
|
||||
"messages_sent",
|
||||
"sentiment",
|
||||
"escalated",
|
||||
"forwarded_hr",
|
||||
"full_transcript_url",
|
||||
"avg_response_time",
|
||||
"tokens",
|
||||
"tokens_eur",
|
||||
"category",
|
||||
"initial_msg",
|
||||
],
|
||||
from_line: 1,
|
||||
relax_column_count: true,
|
||||
skip_empty_lines: true,
|
||||
trim: true,
|
||||
});
|
||||
// Parse without expecting headers, using known order
|
||||
const records: CSVRecord[] = parse(text, {
|
||||
delimiter: ",",
|
||||
columns: [
|
||||
"session_id",
|
||||
"start_time",
|
||||
"end_time",
|
||||
"ip_address",
|
||||
"country",
|
||||
"language",
|
||||
"messages_sent",
|
||||
"sentiment",
|
||||
"escalated",
|
||||
"forwarded_hr",
|
||||
"full_transcript_url",
|
||||
"avg_response_time",
|
||||
"tokens",
|
||||
"tokens_eur",
|
||||
"category",
|
||||
"initial_msg",
|
||||
],
|
||||
from_line: 1,
|
||||
relax_column_count: true,
|
||||
skip_empty_lines: true,
|
||||
trim: true,
|
||||
});
|
||||
|
||||
// Coerce types for relevant columns
|
||||
return records.map((r) => ({
|
||||
id: r.session_id,
|
||||
startTime: new Date(r.start_time),
|
||||
endTime: r.end_time ? new Date(r.end_time) : null,
|
||||
ipAddress: r.ip_address,
|
||||
country: r.country,
|
||||
language: r.language,
|
||||
messagesSent: Number(r.messages_sent) || 0,
|
||||
sentiment: r.sentiment ? parseFloat(r.sentiment) : null,
|
||||
escalated: r.escalated === "1" || r.escalated === "true",
|
||||
forwardedHr: r.forwarded_hr === "1" || r.forwarded_hr === "true",
|
||||
fullTranscriptUrl: r.full_transcript_url,
|
||||
avgResponseTime: r.avg_response_time ? parseFloat(r.avg_response_time) : null,
|
||||
tokens: Number(r.tokens) || 0,
|
||||
tokensEur: r.tokens_eur ? parseFloat(r.tokens_eur) : 0,
|
||||
category: r.category,
|
||||
initialMsg: r.initial_msg,
|
||||
}));
|
||||
// Coerce types for relevant columns
|
||||
return records.map((r) => ({
|
||||
id: r.session_id,
|
||||
startTime: new Date(r.start_time),
|
||||
endTime: r.end_time ? new Date(r.end_time) : null,
|
||||
ipAddress: r.ip_address,
|
||||
country: r.country,
|
||||
language: r.language,
|
||||
messagesSent: Number(r.messages_sent) || 0,
|
||||
sentiment: r.sentiment ? parseFloat(r.sentiment) : null,
|
||||
escalated: r.escalated === "1" || r.escalated === "true",
|
||||
forwardedHr: r.forwarded_hr === "1" || r.forwarded_hr === "true",
|
||||
fullTranscriptUrl: r.full_transcript_url,
|
||||
avgResponseTime: r.avg_response_time
|
||||
? parseFloat(r.avg_response_time)
|
||||
: null,
|
||||
tokens: Number(r.tokens) || 0,
|
||||
tokensEur: r.tokens_eur ? parseFloat(r.tokens_eur) : 0,
|
||||
category: r.category,
|
||||
initialMsg: r.initial_msg,
|
||||
}));
|
||||
}
|
||||
|
||||
157
lib/metrics.ts
157
lib/metrics.ts
@ -1,85 +1,100 @@
|
||||
// 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;
|
||||
sentimentAlert?: number;
|
||||
}
|
||||
|
||||
export function sessionMetrics(sessions: ChatSession[], companyConfig: CompanyConfig = {}): MetricsResult {
|
||||
const total = sessions.length;
|
||||
const byDay: DayMetrics = {};
|
||||
const byCategory: CategoryMetrics = {};
|
||||
const byLanguage: LanguageMetrics = {};
|
||||
let escalated = 0, forwarded = 0;
|
||||
let totalSentiment = 0, sentimentCount = 0;
|
||||
let totalResponse = 0, responseCount = 0;
|
||||
let totalTokens = 0, totalTokensEur = 0;
|
||||
export function sessionMetrics(
|
||||
sessions: ChatSession[],
|
||||
companyConfig: CompanyConfig = {},
|
||||
): MetricsResult {
|
||||
const total = sessions.length;
|
||||
const byDay: DayMetrics = {};
|
||||
const byCategory: CategoryMetrics = {};
|
||||
const byLanguage: LanguageMetrics = {};
|
||||
let escalated = 0,
|
||||
forwarded = 0;
|
||||
let totalSentiment = 0,
|
||||
sentimentCount = 0;
|
||||
let totalResponse = 0,
|
||||
responseCount = 0;
|
||||
let totalTokens = 0,
|
||||
totalTokensEur = 0;
|
||||
|
||||
// Calculate total session duration in minutes
|
||||
let totalDuration = 0;
|
||||
let durationCount = 0;
|
||||
// Calculate total session duration in minutes
|
||||
let totalDuration = 0;
|
||||
let durationCount = 0;
|
||||
|
||||
sessions.forEach(s => {
|
||||
const day = s.startTime.toISOString().slice(0, 10);
|
||||
byDay[day] = (byDay[day] || 0) + 1;
|
||||
sessions.forEach((s) => {
|
||||
const day = s.startTime.toISOString().slice(0, 10);
|
||||
byDay[day] = (byDay[day] || 0) + 1;
|
||||
|
||||
if (s.category) byCategory[s.category] = (byCategory[s.category] || 0) + 1;
|
||||
if (s.language) byLanguage[s.language] = (byLanguage[s.language] || 0) + 1;
|
||||
if (s.category) byCategory[s.category] = (byCategory[s.category] || 0) + 1;
|
||||
if (s.language) byLanguage[s.language] = (byLanguage[s.language] || 0) + 1;
|
||||
|
||||
if (s.endTime) {
|
||||
const duration = (s.endTime.getTime() - s.startTime.getTime()) / (1000 * 60); // minutes
|
||||
totalDuration += duration;
|
||||
durationCount++;
|
||||
}
|
||||
|
||||
if (s.escalated) escalated++;
|
||||
if (s.forwardedHr) forwarded++;
|
||||
|
||||
if (s.sentiment != null) {
|
||||
totalSentiment += s.sentiment;
|
||||
sentimentCount++;
|
||||
}
|
||||
|
||||
if (s.avgResponseTime != null) {
|
||||
totalResponse += s.avgResponseTime;
|
||||
responseCount++;
|
||||
}
|
||||
|
||||
totalTokens += s.tokens || 0;
|
||||
totalTokensEur += s.tokensEur || 0;
|
||||
});
|
||||
|
||||
// Now add sentiment alert logic:
|
||||
let belowThreshold = 0;
|
||||
const threshold = companyConfig.sentimentAlert ?? null;
|
||||
if (threshold != null) {
|
||||
for (const s of sessions) {
|
||||
if (s.sentiment != null && s.sentiment < threshold) belowThreshold++;
|
||||
}
|
||||
if (s.endTime) {
|
||||
const duration =
|
||||
(s.endTime.getTime() - s.startTime.getTime()) / (1000 * 60); // minutes
|
||||
totalDuration += duration;
|
||||
durationCount++;
|
||||
}
|
||||
|
||||
// Calculate average sessions per day
|
||||
const dayCount = Object.keys(byDay).length;
|
||||
const avgSessionsPerDay = dayCount > 0 ? total / dayCount : 0;
|
||||
if (s.escalated) escalated++;
|
||||
if (s.forwardedHr) forwarded++;
|
||||
|
||||
// Calculate average session length
|
||||
const avgSessionLength = durationCount > 0 ? totalDuration / durationCount : null;
|
||||
if (s.sentiment != null) {
|
||||
totalSentiment += s.sentiment;
|
||||
sentimentCount++;
|
||||
}
|
||||
|
||||
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;
|
||||
if (s.avgResponseTime != null) {
|
||||
totalResponse += s.avgResponseTime;
|
||||
responseCount++;
|
||||
}
|
||||
|
||||
totalTokens += s.tokens || 0;
|
||||
totalTokensEur += s.tokensEur || 0;
|
||||
});
|
||||
|
||||
// Now add sentiment alert logic:
|
||||
let belowThreshold = 0;
|
||||
const threshold = companyConfig.sentimentAlert ?? null;
|
||||
if (threshold != null) {
|
||||
for (const s of sessions) {
|
||||
if (s.sentiment != null && s.sentiment < threshold) belowThreshold++;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate average sessions per day
|
||||
const dayCount = Object.keys(byDay).length;
|
||||
const avgSessionsPerDay = dayCount > 0 ? total / dayCount : 0;
|
||||
|
||||
// Calculate average session length
|
||||
const avgSessionLength =
|
||||
durationCount > 0 ? totalDuration / durationCount : null;
|
||||
|
||||
return {
|
||||
totalSessions: total,
|
||||
avgSessionsPerDay,
|
||||
avgSessionLength,
|
||||
days: byDay,
|
||||
languages: byLanguage,
|
||||
categories: byCategory,
|
||||
belowThresholdCount: belowThreshold,
|
||||
// Additional metrics not in the interface - using type assertion
|
||||
escalatedCount: escalated,
|
||||
forwardedCount: forwarded,
|
||||
avgSentiment: sentimentCount ? totalSentiment / sentimentCount : null,
|
||||
avgResponseTime: responseCount ? totalResponse / responseCount : null,
|
||||
totalTokens,
|
||||
totalTokensEur,
|
||||
sentimentThreshold: threshold,
|
||||
} as MetricsResult;
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ import { PrismaClient } from "@prisma/client";
|
||||
|
||||
// Prevent multiple instances of Prisma Client in development
|
||||
declare const global: {
|
||||
prisma: PrismaClient | undefined;
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
// Initialize Prisma Client
|
||||
@ -14,7 +14,7 @@ const prisma = global.prisma || new PrismaClient();
|
||||
|
||||
// Save in global if we're in development
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
global.prisma = prisma;
|
||||
global.prisma = prisma;
|
||||
}
|
||||
|
||||
export { prisma };
|
||||
|
||||
@ -4,50 +4,62 @@ import { prisma } from "./prisma";
|
||||
import { fetchAndParseCsv } from "./csvFetcher";
|
||||
|
||||
interface SessionCreateData {
|
||||
id: string;
|
||||
startTime: Date;
|
||||
companyId: string;
|
||||
[key: string]: unknown;
|
||||
id: string;
|
||||
startTime: Date;
|
||||
companyId: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function startScheduler() {
|
||||
cron.schedule("*/15 * * * *", async () => {
|
||||
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);
|
||||
await prisma.session.deleteMany({ where: { companyId: company.id } });
|
||||
cron.schedule("*/15 * * * *", async () => {
|
||||
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,
|
||||
);
|
||||
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()}`,
|
||||
// Ensure startTime is not undefined
|
||||
startTime: session.startTime || new Date()
|
||||
};
|
||||
for (const session of sessions) {
|
||||
const sessionData: SessionCreateData = {
|
||||
...session,
|
||||
companyId: company.id,
|
||||
id: session.id || session.sessionId || `sess_${Date.now()}`,
|
||||
// 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
|
||||
}
|
||||
});
|
||||
}
|
||||
console.log(`[Scheduler] Refreshed sessions for company: ${company.name}`);
|
||||
} catch (e) {
|
||||
console.error(`[Scheduler] Failed for company: ${company.name} - ${e}`);
|
||||
}
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
console.log(
|
||||
`[Scheduler] Refreshed sessions for company: ${company.name}`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(`[Scheduler] Failed for company: ${company.name} - ${e}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
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}`);
|
||||
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}`);
|
||||
}
|
||||
|
||||
132
lib/types.ts
132
lib/types.ts
@ -1,94 +1,94 @@
|
||||
import { Session as NextAuthSession } from 'next-auth';
|
||||
import { Session as NextAuthSession } from "next-auth";
|
||||
|
||||
export interface UserSession extends NextAuthSession {
|
||||
user: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
image?: string;
|
||||
companyId: string;
|
||||
role: string;
|
||||
};
|
||||
user: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
image?: string;
|
||||
companyId: string;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Company {
|
||||
id: string;
|
||||
name: string;
|
||||
csvUrl: string;
|
||||
csvUsername?: string;
|
||||
csvPassword?: string;
|
||||
sentimentAlert?: number; // Match Prisma schema naming
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
id: string;
|
||||
name: string;
|
||||
csvUrl: string;
|
||||
csvUsername?: string;
|
||||
csvPassword?: string;
|
||||
sentimentAlert?: number; // Match Prisma schema naming
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role: string;
|
||||
companyId: string;
|
||||
resetToken?: string | null;
|
||||
resetTokenExpiry?: Date | null;
|
||||
company?: Company;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
id: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role: string;
|
||||
companyId: string;
|
||||
resetToken?: string | null;
|
||||
resetTokenExpiry?: Date | null;
|
||||
company?: Company;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
companyId: string;
|
||||
userId?: string | null;
|
||||
category?: string | null;
|
||||
language?: string | null;
|
||||
sentiment?: number | null;
|
||||
startTime: Date;
|
||||
endTime?: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
id: string;
|
||||
sessionId: string;
|
||||
companyId: string;
|
||||
userId?: string | null;
|
||||
category?: string | null;
|
||||
language?: string | null;
|
||||
sentiment?: number | null;
|
||||
startTime: Date;
|
||||
endTime?: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
// Extended session properties that might be used in metrics
|
||||
avgResponseTime?: number | null;
|
||||
escalated?: boolean;
|
||||
forwardedHr?: boolean;
|
||||
tokens?: number;
|
||||
tokensEur?: number;
|
||||
initialMsg?: string;
|
||||
// Extended session properties that might be used in metrics
|
||||
avgResponseTime?: number | null;
|
||||
escalated?: boolean;
|
||||
forwardedHr?: boolean;
|
||||
tokens?: number;
|
||||
tokensEur?: number;
|
||||
initialMsg?: string;
|
||||
}
|
||||
|
||||
export interface DayMetrics {
|
||||
[day: string]: number;
|
||||
[day: string]: number;
|
||||
}
|
||||
|
||||
export interface CategoryMetrics {
|
||||
[category: string]: number;
|
||||
[category: string]: number;
|
||||
}
|
||||
|
||||
export interface LanguageMetrics {
|
||||
[language: string]: number;
|
||||
[language: string]: number;
|
||||
}
|
||||
|
||||
export interface MetricsResult {
|
||||
totalSessions: number;
|
||||
avgSessionsPerDay: number;
|
||||
avgSessionLength: number | null;
|
||||
days: DayMetrics;
|
||||
languages: LanguageMetrics;
|
||||
categories: CategoryMetrics;
|
||||
belowThresholdCount: number;
|
||||
// Additional properties for dashboard
|
||||
escalatedCount?: number;
|
||||
forwardedCount?: number;
|
||||
avgSentiment?: number;
|
||||
avgResponseTime?: number;
|
||||
totalTokens?: number;
|
||||
totalTokensEur?: number;
|
||||
sentimentThreshold?: number | null;
|
||||
totalSessions: number;
|
||||
avgSessionsPerDay: number;
|
||||
avgSessionLength: number | null;
|
||||
days: DayMetrics;
|
||||
languages: LanguageMetrics;
|
||||
categories: CategoryMetrics;
|
||||
belowThresholdCount: number;
|
||||
// Additional properties for dashboard
|
||||
escalatedCount?: number;
|
||||
forwardedCount?: number;
|
||||
avgSentiment?: number;
|
||||
avgResponseTime?: number;
|
||||
totalTokens?: number;
|
||||
totalTokensEur?: number;
|
||||
sentimentThreshold?: number | null;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
13598
package-lock.json
generated
13598
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
87
package.json
87
package.json
@ -1,45 +1,46 @@
|
||||
{
|
||||
"name": "livedash-node",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"format": "prettier --write .",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.8.2",
|
||||
"@types/node-fetch": "^3.0.2",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"chart.js": "^4.0.0",
|
||||
"csv-parse": "^5.5.0",
|
||||
"next": "^15.3.2",
|
||||
"next-auth": "^4.24.11",
|
||||
"node-cron": "^4.0.6",
|
||||
"node-fetch": "^3.3.2",
|
||||
"react": "^19.1.0",
|
||||
"react-chartjs-2": "^5.0.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"tailwindcss": "^4.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.7",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/node": "^22.15.21",
|
||||
"@types/node-cron": "^3.0.8",
|
||||
"@types/react": "^19.1.5",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"eslint": "^9.27.0",
|
||||
"eslint-config-next": "^15.3.2",
|
||||
"postcss": "^8.4.0",
|
||||
"prettier": "^3.5.3",
|
||||
"prisma": "^6.8.2",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
"name": "livedash-node",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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:seed": "node prisma/seed.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.8.2",
|
||||
"@types/node-fetch": "^3.0.2",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"chart.js": "^4.0.0",
|
||||
"csv-parse": "^5.5.0",
|
||||
"next": "^15.3.2",
|
||||
"next-auth": "^4.24.11",
|
||||
"node-cron": "^4.0.6",
|
||||
"node-fetch": "^3.3.2",
|
||||
"react": "^19.1.0",
|
||||
"react-chartjs-2": "^5.0.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"tailwindcss": "^4.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.7",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/node": "^22.15.21",
|
||||
"@types/node-cron": "^3.0.8",
|
||||
"@types/react": "^19.1.5",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"eslint": "^9.27.0",
|
||||
"eslint-config-next": "^15.3.2",
|
||||
"postcss": "^8.4.0",
|
||||
"prettier": "^3.5.3",
|
||||
"prisma": "^6.8.2",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,81 +4,95 @@ import { fetchAndParseCsv } from "../../../lib/csvFetcher";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
|
||||
interface SessionCreateData {
|
||||
id: string;
|
||||
startTime: Date;
|
||||
companyId: string;
|
||||
sessionId?: string;
|
||||
[key: string]: unknown;
|
||||
id: string;
|
||||
startTime: Date;
|
||||
companyId: string;
|
||||
sessionId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
// Check if this is a POST request
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
// Get companyId from body or query
|
||||
let { companyId } = req.body;
|
||||
|
||||
if (!companyId) {
|
||||
// Try to get user from prisma based on session cookie
|
||||
try {
|
||||
const session = await prisma.session.findFirst({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
where: { /* Add session check criteria here */ }
|
||||
});
|
||||
|
||||
if (session) {
|
||||
companyId = session.companyId;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching session:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!companyId) {
|
||||
return res.status(400).json({ error: "Company ID is required" });
|
||||
}
|
||||
|
||||
const company = await prisma.company.findUnique({ where: { id: companyId } });
|
||||
if (!company) return res.status(404).json({ error: "Company not found" });
|
||||
// Get companyId from body or query
|
||||
let { companyId } = req.body;
|
||||
|
||||
if (!companyId) {
|
||||
// Try to get user from prisma based on session cookie
|
||||
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)
|
||||
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 });
|
||||
if (session) {
|
||||
companyId = session.companyId;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching session:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!companyId) {
|
||||
return res.status(400).json({ error: "Company ID is required" });
|
||||
}
|
||||
|
||||
const company = await prisma.company.findUnique({ where: { id: companyId } });
|
||||
if (!company) return res.status(404).json({ error: "Company not found" });
|
||||
|
||||
try {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,85 +5,100 @@ import bcrypt from "bcryptjs";
|
||||
|
||||
// Define the shape of the JWT token
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
companyId: string;
|
||||
role: string;
|
||||
}
|
||||
interface JWT {
|
||||
companyId: string;
|
||||
role: string;
|
||||
}
|
||||
}
|
||||
|
||||
// Define the shape of the session object
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
image?: string;
|
||||
companyId: string;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
interface Session {
|
||||
user: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
image?: string;
|
||||
companyId: string;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
companyId: string;
|
||||
role: string;
|
||||
}
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
companyId: string;
|
||||
role: string;
|
||||
}
|
||||
}
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "text" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
return null;
|
||||
}
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "text" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: credentials.email }
|
||||
});
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: credentials.email },
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
if (!user) return null;
|
||||
|
||||
const valid = await bcrypt.compare(credentials.password, user.password);
|
||||
if (!valid) return null;
|
||||
const valid = await bcrypt.compare(credentials.password, user.password);
|
||||
if (!valid) return null;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
companyId: user.companyId,
|
||||
role: user.role,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: { strategy: "jwt" },
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.companyId = user.companyId;
|
||||
token.role = user.role;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (token && session.user) {
|
||||
session.user.companyId = token.companyId;
|
||||
session.user.role = token.role;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
companyId: user.companyId,
|
||||
role: user.role,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
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",
|
||||
},
|
||||
},
|
||||
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);
|
||||
|
||||
@ -4,24 +4,27 @@ import { getServerSession } from "next-auth";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { authOptions } from "../auth/[...nextauth]";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (!session?.user) return res.status(401).json({ error: "Not logged in" });
|
||||
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 }
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email as string },
|
||||
});
|
||||
|
||||
if (!user) return res.status(401).json({ error: "No user" });
|
||||
|
||||
if (req.method === "POST") {
|
||||
const { csvUrl } = req.body;
|
||||
await prisma.company.update({
|
||||
where: { id: user.companyId },
|
||||
data: { csvUrl },
|
||||
});
|
||||
|
||||
if (!user) return res.status(401).json({ error: "No user" });
|
||||
|
||||
if (req.method === "POST") {
|
||||
const { csvUrl } = req.body;
|
||||
await prisma.company.update({
|
||||
where: { id: user.companyId },
|
||||
data: { csvUrl }
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} else {
|
||||
res.status(405).end();
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} else {
|
||||
res.status(405).end();
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,39 +6,43 @@ import { sessionMetrics } from "../../../lib/metrics";
|
||||
import { authOptions } from "../auth/[...nextauth]";
|
||||
|
||||
interface SessionUser {
|
||||
email: string;
|
||||
name?: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface SessionData {
|
||||
user: SessionUser;
|
||||
user: SessionUser;
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
const session = await getServerSession(req, res, authOptions) as SessionData | null;
|
||||
if (!session?.user) return res.status(401).json({ error: "Not logged in" });
|
||||
const 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 }
|
||||
});
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
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({
|
||||
where: { companyId: user.companyId }
|
||||
});
|
||||
const sessions = await prisma.session.findMany({
|
||||
where: { companyId: user.companyId },
|
||||
});
|
||||
|
||||
// Pass company config to metrics
|
||||
// @ts-expect-error - Type conversion is needed between prisma session and ChatSession
|
||||
const metrics = sessionMetrics(sessions, user.company);
|
||||
// Pass company config to metrics
|
||||
// @ts-expect-error - Type conversion is needed between prisma session and ChatSession
|
||||
const metrics = sessionMetrics(sessions, user.company);
|
||||
|
||||
res.json({
|
||||
metrics,
|
||||
csvUrl: user.company.csvUrl,
|
||||
company: user.company
|
||||
});
|
||||
res.json({
|
||||
metrics,
|
||||
csvUrl: user.company.csvUrl,
|
||||
company: user.company,
|
||||
});
|
||||
}
|
||||
|
||||
@ -3,30 +3,35 @@ import { getServerSession } from "next-auth";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { authOptions } from "../auth/[...nextauth]";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (!session?.user || session.user.role !== "admin")
|
||||
return res.status(403).json({ error: "Forbidden" });
|
||||
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 }
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email as string },
|
||||
});
|
||||
|
||||
if (!user) return res.status(401).json({ error: "No user" });
|
||||
|
||||
if (req.method === "POST") {
|
||||
const { csvUrl, csvUsername, csvPassword, sentimentThreshold } = req.body;
|
||||
await prisma.company.update({
|
||||
where: { id: user.companyId },
|
||||
data: {
|
||||
csvUrl,
|
||||
csvUsername,
|
||||
...(csvPassword ? { csvPassword } : {}),
|
||||
sentimentAlert: sentimentThreshold
|
||||
? parseFloat(sentimentThreshold)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) return res.status(401).json({ error: "No user" });
|
||||
|
||||
if (req.method === "POST") {
|
||||
const { csvUrl, csvUsername, csvPassword, sentimentThreshold } = req.body;
|
||||
await prisma.company.update({
|
||||
where: { id: user.companyId },
|
||||
data: {
|
||||
csvUrl,
|
||||
csvUsername,
|
||||
...(csvPassword ? { csvPassword } : {}),
|
||||
sentimentAlert: sentimentThreshold ? parseFloat(sentimentThreshold) : null,
|
||||
}
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} else {
|
||||
res.status(405).end();
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} else {
|
||||
res.status(405).end();
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,51 +6,53 @@ import { authOptions } from "../auth/[...nextauth]";
|
||||
// User type from prisma is used instead of the one in lib/types
|
||||
|
||||
interface UserBasicInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (!session?.user || session.user.role !== "admin")
|
||||
return res.status(403).json({ error: "Forbidden" });
|
||||
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 }
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email as string },
|
||||
});
|
||||
|
||||
if (!user) return res.status(401).json({ error: "No user" });
|
||||
|
||||
if (req.method === "GET") {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { companyId: user.companyId },
|
||||
});
|
||||
|
||||
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") {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { companyId: user.companyId }
|
||||
});
|
||||
|
||||
const mappedUsers: UserBasicInfo[] = users.map(u => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
role: u.role
|
||||
}));
|
||||
|
||||
res.json({ users: mappedUsers });
|
||||
}
|
||||
else if (req.method === "POST") {
|
||||
const { email, role } = req.body;
|
||||
if (!email || !role) return res.status(400).json({ error: "Missing fields" });
|
||||
const exists = await prisma.user.findUnique({ where: { email } });
|
||||
if (exists) return res.status(409).json({ error: "Email exists" });
|
||||
const tempPassword = Math.random().toString(36).slice(-8); // random initial password
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: await bcrypt.hash(tempPassword, 10),
|
||||
companyId: user.companyId,
|
||||
role,
|
||||
}
|
||||
});
|
||||
// TODO: Email user their temp password (stub, for demo)
|
||||
res.json({ ok: true, tempPassword });
|
||||
}
|
||||
else res.status(405).end();
|
||||
res.json({ users: mappedUsers });
|
||||
} else if (req.method === "POST") {
|
||||
const { email, role } = req.body;
|
||||
if (!email || !role)
|
||||
return res.status(400).json({ error: "Missing fields" });
|
||||
const exists = await prisma.user.findUnique({ where: { email } });
|
||||
if (exists) return res.status(409).json({ error: "Email exists" });
|
||||
const tempPassword = Math.random().toString(36).slice(-8); // random initial password
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: await bcrypt.hash(tempPassword, 10),
|
||||
companyId: user.companyId,
|
||||
role,
|
||||
},
|
||||
});
|
||||
// TODO: Email user their temp password (stub, for demo)
|
||||
res.json({ ok: true, tempPassword });
|
||||
} else res.status(405).end();
|
||||
}
|
||||
|
||||
@ -1,35 +1,38 @@
|
||||
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: {
|
||||
email: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
body: {
|
||||
email: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type NextApiResponse = ServerResponse & {
|
||||
status: (code: number) => NextApiResponse;
|
||||
json: (data: Record<string, unknown>) => void;
|
||||
end: () => void;
|
||||
status: (code: number) => NextApiResponse;
|
||||
json: (data: Record<string, unknown>) => void;
|
||||
end: () => void;
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") return res.status(405).end();
|
||||
const { email } = req.body;
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) return res.status(200).end(); // always 200 for privacy
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method !== "POST") return res.status(405).end();
|
||||
const { email } = req.body;
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) return res.status(200).end(); // always 200 for privacy
|
||||
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const expiry = new Date(Date.now() + 1000 * 60 * 30); // 30 min expiry
|
||||
await prisma.user.update({
|
||||
where: { email },
|
||||
data: { resetToken: token, resetTokenExpiry: expiry },
|
||||
});
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const expiry = new Date(Date.now() + 1000 * 60 * 30); // 30 min expiry
|
||||
await prisma.user.update({
|
||||
where: { email },
|
||||
data: { resetToken: token, resetTokenExpiry: expiry },
|
||||
});
|
||||
|
||||
const resetUrl = `${process.env.NEXTAUTH_URL || "http://localhost:3000"}/reset-password?token=${token}`;
|
||||
await sendEmail(email, "Password Reset", `Reset your password: ${resetUrl}`);
|
||||
res.status(200).end();
|
||||
const resetUrl = `${process.env.NEXTAUTH_URL || "http://localhost:3000"}/reset-password?token=${token}`;
|
||||
await sendEmail(email, "Password Reset", `Reset your password: ${resetUrl}`);
|
||||
res.status(200).end();
|
||||
}
|
||||
|
||||
@ -4,50 +4,53 @@ import bcrypt from "bcryptjs";
|
||||
import { ApiResponse } from "../../lib/types";
|
||||
|
||||
interface RegisterRequestBody {
|
||||
email: string;
|
||||
password: string;
|
||||
company: string;
|
||||
csvUrl?: string;
|
||||
email: string;
|
||||
password: string;
|
||||
company: string;
|
||||
csvUrl?: string;
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<ApiResponse<{ success: boolean; } | { error: string; }>>) {
|
||||
if (req.method !== "POST") return res.status(405).end();
|
||||
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;
|
||||
const { email, password, company, csvUrl } = req.body as RegisterRequestBody;
|
||||
|
||||
if (!email || !password || !company) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Missing required fields"
|
||||
});
|
||||
}
|
||||
|
||||
// Check if email exists
|
||||
const exists = await prisma.user.findUnique({
|
||||
where: { email }
|
||||
if (!email || !password || !company) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Missing required fields",
|
||||
});
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: "Email already exists"
|
||||
});
|
||||
}
|
||||
// Check if email exists
|
||||
const exists = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
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 }
|
||||
if (exists) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: "Email already exists",
|
||||
});
|
||||
}
|
||||
|
||||
const newCompany = await prisma.company.create({
|
||||
data: { name: company, csvUrl: csvUrl || "" },
|
||||
});
|
||||
const hashed = await bcrypt.hash(password, 10);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: hashed,
|
||||
companyId: newCompany.id,
|
||||
role: "admin",
|
||||
},
|
||||
});
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: { success: true },
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,40 +1,43 @@
|
||||
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: {
|
||||
token: string;
|
||||
password: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
body: {
|
||||
token: string;
|
||||
password: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type NextApiResponse = ServerResponse & {
|
||||
status: (code: number) => NextApiResponse;
|
||||
json: (data: Record<string, unknown>) => void;
|
||||
end: () => void;
|
||||
status: (code: number) => NextApiResponse;
|
||||
json: (data: Record<string, unknown>) => void;
|
||||
end: () => void;
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") return res.status(405).end();
|
||||
const { token, password } = req.body;
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
resetToken: token,
|
||||
resetTokenExpiry: { gte: new Date() }
|
||||
}
|
||||
});
|
||||
if (!user) return res.status(400).json({ error: "Invalid or expired token" });
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method !== "POST") return res.status(405).end();
|
||||
const { token, password } = req.body;
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
resetToken: token,
|
||||
resetTokenExpiry: { gte: new Date() },
|
||||
},
|
||||
});
|
||||
if (!user) return res.status(400).json({ error: "Invalid or expired token" });
|
||||
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
password: hash,
|
||||
resetToken: null,
|
||||
resetTokenExpiry: null,
|
||||
}
|
||||
});
|
||||
res.status(200).end();
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
password: hash,
|
||||
resetToken: null,
|
||||
resetTokenExpiry: null,
|
||||
},
|
||||
});
|
||||
res.status(200).end();
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
50
prisma/migrations/20250521191702_init/migration.sql
Normal file
50
prisma/migrations/20250521191702_init/migration.sql
Normal 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");
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
39
prisma/seed.js
Normal 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
39
prisma/seed.mjs
Normal 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
39
prisma/seed.ts
Normal 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();
|
||||
@ -1,12 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
@ -1,14 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"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,
|
||||
@ -20,24 +16,15 @@
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user