mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 08:52:10 +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": [
|
"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
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
|
# 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
|
||||||
|
|||||||
4
.vscode/extensions.json
vendored
4
.vscode/extensions.json
vendored
@ -1,5 +1,3 @@
|
|||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": ["prisma.prisma"]
|
||||||
"prisma.prisma"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 />;
|
||||||
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
10
app/page.tsx
10
app/page.tsx
@ -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
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';
|
"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 (
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
157
lib/metrics.ts
157
lib/metrics.ts
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
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",
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
'@tailwindcss/postcss': {},
|
"@tailwindcss/postcss": {},
|
||||||
autoprefixer: {},
|
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} */
|
/** @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: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user