This commit is contained in:
Max Kowalski
2025-06-26 22:43:22 +02:00
parent 8774a1f155
commit fd55b30398
30 changed files with 1458 additions and 1859 deletions

View File

@ -7,6 +7,14 @@
"--db-path",
"./prisma/dev.db"
]
},
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"D:\\Notso\\Product\\Vibe-coding\\livedash-node"
]
}
}
}

47
GEMINI.md Normal file
View File

@ -0,0 +1,47 @@
# Project Overview
This project is a Next.js application with a Node.js backend, designed to provide a live dashboard for data visualization and session management.
## Setup
To set up the project, follow these steps:
1. **Install Dependencies:**
```bash
npm install
```
2. **Environment Variables:**
Create a `.env` file based on `.env.example` and fill in the necessary environment variables.
3. **Database Setup:**
Run database migrations:
```bash
npx prisma migrate dev
```
Seed the database (optional):
```bash
npx prisma db seed
```
4. **Run Development Server:**
```bash
npm run dev
```
## Common Commands
- **Run Tests:**
```bash
npm test
```
- **Run Linter:**
```bash
npm run lint
```
- **Build Project:**
```bash
npm run build
```

157
TODO.md
View File

@ -3,7 +3,7 @@
# Refactor!!!
> Based on my analysis of the codebase, here is a plan with recommendations for improving the project. The focus is on enhancing standardization, abstraction, user experience, and visual
design.
> design.
## High-Level Recommendations
@ -25,157 +25,54 @@ Here is a phased plan to implement these recommendations:
This phase focuses on cleaning up the codebase, standardizing the project structure, and improving the abstraction of core functionalities.
1. Standardize Project Structure:
* Unify Server File: Consolidate server.js, server.mjs, and server.ts into a single server.ts file to remove redundancy.
* Migrate to App Router: Move all routes from the pages/api directory to the app/api directory. This will centralize routing logic within the app directory.
* Standardize Naming Conventions: Ensure all files and components follow a consistent naming convention (e.g., PascalCase for components, kebab-case for files).
- [x] Unify Server File: Consolidated server.js, server.mjs, and server.ts into a single server.ts file to remove redundancy. ✅
- [x] Migrate to App Router: All API routes moved from `pages/api` to `app/api`. ✅
- [x] Standardize Naming Conventions: All files and components already follow a consistent naming convention (e.g., PascalCase for components, kebab-case for files). ✅
2. Introduce a UI Component Library:
* Integrate ShadCN/UI: Add ShadCN/UI to the project to leverage its extensive library of accessible and customizable components.
* Replace Custom Components: Gradually replace custom-built components in the components/ directory with their ShadCN/UI equivalents. This will improve visual consistency and reduce
- Integrate ShadCN/UI: Add ShadCN/UI to the project to leverage its extensive library of accessible and customizable components.
- Replace Custom Components: Gradually replace custom-built components in the components/ directory with their ShadCN/UI equivalents. This will improve visual consistency and reduce
maintenance overhead.
3. Refactor Core Logic:
* Centralize Data Fetching: Create a dedicated module (e.g., lib/data-service.ts) to handle all data fetching logic, abstracting away the details of using Prisma and external APIs.
* Isolate Business Logic: Ensure that business logic (e.g., session processing, metric calculation) is separated from the API routes and UI components.
- Centralize Data Fetching: Create a dedicated module (e.g., lib/data-service.ts) to handle all data fetching logic, abstracting away the details of using Prisma and external APIs.
- Isolate Business Logic: Ensure that business logic (e.g., session processing, metric calculation) is separated from the API routes and UI components.
### Phase 2: UX and Visual Enhancements
This phase focuses on improving the user-facing aspects of the application.
1. Implement Comprehensive Loading and Error States:
* Skeleton Loaders: Use skeleton loaders for dashboard components to provide a better loading experience.
* Global Error Handling: Implement a global error handling strategy to catch and display user-friendly error messages for API failures or other unexpected issues.
- Skeleton Loaders: Use skeleton loaders for dashboard components to provide a better loading experience.
- Global Error Handling: Implement a global error handling strategy to catch and display user-friendly error messages for API failures or other unexpected issues.
2. Redesign the Dashboard:
* Improve Information Hierarchy: Reorganize the dashboard to present the most important information first.
* Enhance Visual Appeal: Use the new component library to create a more modern and visually appealing design with a consistent color palette and typography.
* Improve Chart Interactivity: Add features like tooltips, zooming, and filtering to the charts to make them more interactive and informative.
- Improve Information Hierarchy: Reorganize the dashboard to present the most important information first.
- Enhance Visual Appeal: Use the new component library to create a more modern and visually appealing design with a consistent color palette and typography.
- Improve Chart Interactivity: Add features like tooltips, zooming, and filtering to the charts to make them more interactive and informative.
3. Ensure Full Responsiveness:
* Mobile-First Approach: Review and update all pages and components to ensure they are fully responsive and usable on a wide range of devices.
- Mobile-First Approach: Review and update all pages and components to ensure they are fully responsive and usable on a wide range of devices.
### Phase 3: Advanced Topics (Security, Performance, and Documentation)
This phase focuses on long-term improvements to the project's stability, performance, and maintainability.
1. Conduct a Security Review:
* Input Validation: Ensure that all user inputs are properly validated on both the client and server sides.
* Dependency Audit: Regularly audit dependencies for known vulnerabilities.
- Input Validation: Ensure that all user inputs are properly validated on both the client and server sides.
- Dependency Audit: Regularly audit dependencies for known vulnerabilities.
2. Optimize Performance:
* Code Splitting: Leverage Next.js's automatic code splitting to reduce initial load times.
* Caching: Implement caching strategies for frequently accessed data to reduce database load and improve API response times.
- Code Splitting: Leverage Next.js's automatic code splitting to reduce initial load times.
- Caching: Implement caching strategies for frequently accessed data to reduce database load and improve API response times.
3. Expand Documentation:
* API Documentation: Create detailed documentation for all API endpoints.
* Component Library: Document the usage and props of all reusable components.
* Update `AGENTS.md`: Keep the AGENTS.md file up-to-date with any architectural changes.
Would you like me to start implementing any part of this plan? I would suggest starting with Phase 1 to build a solid foundation for the other improvements.
## Dashboard Integration
- [ ] **Resolve `GeographicMap.tsx` and `ResponseTimeDistribution.tsx` data simulation**
- Investigate integrating real data sources with server-side analytics
- Replace simulated data mentioned in `docs/dashboard-components.md`
## Component Specific
- [ ] **Implement robust emailing of temporary passwords**
- File: `pages/api/dashboard/users.ts`
- Set up proper email service integration
- [x] **Session page improvements**
- File: `app/dashboard/sessions/page.tsx`
- Implemented pagination, advanced filtering, and sorting
## File Cleanup
- [x] **Remove backup files**
- Reviewed and removed `.bak` and `.new` files after integration
- Cleaned up `GeographicMap.tsx.bak`, `SessionDetails.tsx.bak`, `SessionDetails.tsx.new`
## Database Schema Improvements
- [ ] **Update EndTime field**
- Make `endTime` field nullable in Prisma schema to match TypeScript interfaces
- [ ] **Add database indices**
- Add appropriate indices to improve query performance
- Focus on dashboard metrics and session listing queries
- [ ] **Implement production email service**
- Replace console logging in `lib/sendEmail.ts`
- Consider providers: Nodemailer, SendGrid, AWS SES
## General Enhancements & Features
- [ ] **Real-time updates**
- Implement for dashboard and session list
- Consider WebSockets or Server-Sent Events
- [ ] **Data export functionality**
- Allow users (especially admins) to export session data
- Support CSV format initially
- [ ] **Customizable dashboard**
- Allow users to customize dashboard view
- Let users choose which metrics/charts are most important
## Testing & Quality Assurance
- [ ] **Comprehensive testing suite**
- [ ] Unit tests for utility functions and API logic
- [ ] Integration tests for API endpoints with database
- [ ] End-to-end tests for user flows (Playwright or Cypress)
- [ ] **Error monitoring and logging**
- Integrate robust error monitoring service (Sentry)
- Enhance server-side logging
- [ ] **Accessibility improvements**
- Review application against WCAG guidelines
- Improve keyboard navigation and screen reader compatibility
- Check color contrast ratios
## Security Enhancements
- [x] **Password reset functionality**
- Implemented secure password reset mechanism
- Files: `app/forgot-password/page.tsx`, `app/reset-password/page.tsx`, `pages/api/forgot-password.ts`, `pages/api/reset-password.ts`
- [ ] **Two-Factor Authentication (2FA)**
- Consider adding 2FA, especially for admin accounts
- [ ] **Input validation and sanitization**
- Review all user inputs (API request bodies, query parameters)
- Ensure proper validation and sanitization
## Code Quality & Development
- [ ] **Code review process**
- Enforce code reviews for all changes
- [ ] **Environment configuration**
- Ensure secure management of environment-specific configurations
- [ ] **Dependency management**
- Periodically review dependencies for vulnerabilities
- Keep dependencies updated
- [ ] **Documentation updates**
- [ ] Ensure `docs/dashboard-components.md` reflects actual implementations
- [ ] Verify "Dashboard Enhancements" are consistently applied
- [ ] Update documentation for improved layout and visual hierarchies
- API Documentation: Create detailed documentation for all API endpoints.
- Component Library: Document the usage and props of all reusable components.
- Update `AGENTS.md`: Keep the AGENTS.md file up-to-date with any architectural changes.

View File

@ -1 +1,120 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -1,6 +1,6 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "../pages/api/auth/[...nextauth]";
import { authOptions } from "./api/auth/[...nextauth]/route";
export default async function HomePage() {
const session = await getServerSession(authOptions);

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

50
lib/admin-service.ts Normal file
View File

@ -0,0 +1,50 @@
import { getServerSession } from "next-auth";
import { authOptions } from "../app/api/auth/[...nextauth]/route"; // Adjust path as needed
import { prisma } from "./prisma";
import { processUnprocessedSessions } from "./processingSchedulerNoCron";
export async function getAdminUser() {
const session = await getServerSession(authOptions);
if (!session?.user) {
throw new Error("Not logged in");
}
const user = await prisma.user.findUnique({
where: { email: session.user.email as string },
include: { company: true },
});
if (!user) {
throw new Error("No user found");
}
if (user.role !== "admin") {
throw new Error("Admin access required");
}
return user;
}
export async function triggerSessionProcessing(batchSize?: number, maxConcurrency?: number) {
const unprocessedCount = await prisma.session.count({
where: {
processed: false,
messages: { some: {} }, // Must have messages
},
});
if (unprocessedCount === 0) {
return { message: "No unprocessed sessions found", unprocessedCount: 0, processedCount: 0 };
}
processUnprocessedSessions(batchSize, maxConcurrency)
.then(() => {
console.log(`[Manual Trigger] Processing completed`);
})
.catch((error) => {
console.error(`[Manual Trigger] Processing failed:`, error);
});
return { message: `Started processing ${unprocessedCount} unprocessed sessions`, unprocessedCount };
}

7
lib/auth-service.ts Normal file
View File

@ -0,0 +1,7 @@
import { prisma } from "./prisma";
export async function findUserByEmail(email: string) {
return prisma.user.findUnique({
where: { email },
});
}

332
lib/data-service.ts Normal file
View File

@ -0,0 +1,332 @@
import { prisma } from "./prisma";
// Example: Function to get a user by ID
export async function getUserById(id: string) {
return prisma.user.findUnique({ where: { id } });
}
export async function getCompanyByUserId(userId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
});
if (!user) return null;
return prisma.company.findUnique({
where: { id: user.companyId },
});
}
export async function updateCompanyCsvUrl(companyId: string, csvUrl: string) {
return prisma.company.update({
where: { id: companyId },
data: { csvUrl },
});
}
export async function findUserByEmailWithCompany(email: string) {
return prisma.user.findUnique({
where: { email },
include: { company: true },
});
}
export async function findSessionsByCompanyIdAndDateRange(companyId: string, startDate?: string, endDate?: string) {
const whereClause: any = {
companyId,
processed: true,
};
if (startDate && endDate) {
whereClause.startTime = {
gte: new Date(startDate),
lte: new Date(endDate + "T23:59:59.999Z"),
};
}
return prisma.session.findMany({
where: whereClause,
include: {
messages: true,
},
});
}
export async function getDistinctSessionCategories(companyId: string) {
const categories = await prisma.session.findMany({
where: {
companyId,
category: {
not: null,
},
},
distinct: ["category"],
select: {
category: true,
},
orderBy: {
category: "asc",
},
});
return categories.map((s) => s.category).filter(Boolean) as string[];
}
export async function getDistinctSessionLanguages(companyId: string) {
const languages = await prisma.session.findMany({
where: {
companyId,
language: {
not: null,
},
},
distinct: ["language"],
select: {
language: true,
},
orderBy: {
language: "asc",
},
});
return languages.map((s) => s.language).filter(Boolean) as string[];
}
export async function getSessionById(id: string) {
return prisma.session.findUnique({
where: { id },
include: {
messages: {
orderBy: { order: "asc" },
},
},
});
}
export async function getFilteredAndPaginatedSessions(
companyId: string,
searchTerm: string | null,
category: string | null,
language: string | null,
startDate: string | null,
endDate: string | null,
sortKey: string | null,
sortOrder: string | null,
page: number,
pageSize: number
) {
const whereClause: Prisma.SessionWhereInput = { companyId };
// Search Term
if (
searchTerm &&
typeof searchTerm === "string" &&
searchTerm.trim() !== ""
) {
const searchConditions = [
{ id: { contains: searchTerm } },
{ category: { contains: searchTerm } },
{ initialMsg: { contains: searchTerm } },
];
whereClause.OR = searchConditions;
}
// Category Filter
if (category && typeof category === "string" && category.trim() !== "") {
whereClause.category = category;
}
// Language Filter
if (language && typeof language === "string" && language.trim() !== "") {
whereClause.language = language;
}
// Date Range Filter
if (startDate && typeof startDate === "string") {
whereClause.startTime = {
...((whereClause.startTime as object) || {}),
gte: new Date(startDate),
};
}
if (endDate && typeof endDate === "string") {
const inclusiveEndDate = new Date(endDate);
inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1);
whereClause.startTime = {
...((whereClause.startTime as object) || {}),
lt: inclusiveEndDate,
};
}
// Sorting
const validSortKeys: { [key: string]: string } = {
startTime: "startTime",
category: "category",
language: "language",
sentiment: "sentiment",
messagesSent: "messagesSent",
avgResponseTime: "avgResponseTime",
};
let orderByCondition:
| Prisma.SessionOrderByWithRelationInput
| Prisma.SessionOrderByWithRelationInput[];
const primarySortField =
sortKey && typeof sortKey === "string" && validSortKeys[sortKey]
? validSortKeys[sortKey]
: "startTime"; // Default to startTime field if sortKey is invalid/missing
const primarySortOrder =
sortOrder === "asc" || sortOrder === "desc" ? sortOrder : "desc"; // Default to desc order
if (primarySortField === "startTime") {
// If sorting by startTime, it's the only sort criteria
orderByCondition = { [primarySortField]: primarySortOrder };
} else {
// If sorting by another field, use startTime: "desc" as secondary sort
orderByCondition = [
{ [primarySortField]: primarySortOrder },
{ startTime: "desc" },
];
}
return prisma.session.findMany({
where: whereClause,
orderBy: orderByCondition,
skip: (page - 1) * pageSize,
take: pageSize,
});
}
export async function countFilteredSessions(
companyId: string,
searchTerm: string | null,
category: string | null,
language: string | null,
startDate: string | null,
endDate: string | null
) {
const whereClause: Prisma.SessionWhereInput = { companyId };
// Search Term
if (
searchTerm &&
typeof searchTerm === "string" &&
searchTerm.trim() !== ""
) {
const searchConditions = [
{ id: { contains: searchTerm } },
{ category: { contains: searchTerm } },
{ initialMsg: { contains: searchTerm } },
];
whereClause.OR = searchConditions;
}
// Category Filter
if (category && typeof category === "string" && category.trim() !== "") {
whereClause.category = category;
}
// Language Filter
if (language && typeof language === "string" && language.trim() !== "") {
whereClause.language = language;
}
// Date Range Filter
if (startDate && typeof startDate === "string") {
whereClause.startTime = {
...((whereClause.startTime as object) || {}),
gte: new Date(startDate),
};
}
if (endDate && typeof endDate === "string") {
const inclusiveEndDate = new Date(endDate);
inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1);
whereClause.startTime = {
...((whereClause.startTime as object) || {}),
lt: inclusiveEndDate,
};
}
return prisma.session.count({ where: whereClause });
}
export async function updateCompanySettings(
companyId: string,
data: {
csvUrl?: string;
csvUsername?: string;
csvPassword?: string;
sentimentAlert?: number | null;
}
) {
return prisma.company.update({
where: { id: companyId },
data,
});
}
export async function getUsersByCompanyId(companyId: string) {
return prisma.user.findMany({
where: { companyId },
});
}
export async function userExistsByEmail(email: string) {
return prisma.user.findUnique({ where: { email } });
}
export async function createUser(email: string, passwordHash: string, companyId: string, role: string) {
return prisma.user.create({
data: {
email,
password: passwordHash,
companyId,
role,
},
});
}
export async function updateUserResetToken(email: string, token: string, expiry: Date) {
return prisma.user.update({
where: { email },
data: { resetToken: token, resetTokenExpiry: expiry },
});
}
export async function createCompany(name: string, csvUrl: string) {
return prisma.company.create({
data: { name, csvUrl },
});
}
export async function findUserByResetToken(token: string) {
return prisma.user.findFirst({
where: {
resetToken: token,
resetTokenExpiry: { gte: new Date() },
},
});
}
export async function updateUserPasswordAndResetToken(userId: string, passwordHash: string) {
return prisma.user.update({
where: { id: userId },
data: {
password: passwordHash,
resetToken: null,
resetTokenExpiry: null,
},
});
}
// Add more data fetching functions here as needed
import { Prisma } from "@prisma/client";
export async function getSessionByCompanyId(where: Prisma.SessionWhereInput) {
return prisma.session.findFirst({
orderBy: { createdAt: "desc" },
where,
});
}
export async function getCompanyById(companyId: string) {
return prisma.company.findUnique({ where: { id: companyId } });
}

98
lib/session-service.ts Normal file
View File

@ -0,0 +1,98 @@
import { prisma } from "./prisma";
import { fetchAndParseCsv } from "./csvFetcher";
import { triggerCompleteWorkflow } from "./workflow";
interface SessionCreateData {
id: string;
startTime: Date;
companyId: string;
sessionId?: string;
[key: string]: unknown;
}
export async function processSessions(company: any) {
const sessions = await fetchAndParseCsv(
company.csvUrl,
company.csvUsername as string | undefined,
company.csvPassword as string | undefined
);
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(),
};
// Validate dates to prevent "Invalid Date" errors
const startTime =
sessionData.startTime instanceof Date &&
!isNaN(sessionData.startTime.getTime())
? sessionData.startTime
: new Date();
const endTime =
session.endTime instanceof Date && !isNaN(session.endTime.getTime())
? session.endTime
: new Date();
// Check if the session already exists
const existingSession = await prisma.session.findUnique({
where: { id: sessionData.id },
});
if (existingSession) {
// Skip this session as it already exists
continue;
}
// Only include fields that are properly typed for Prisma
await prisma.session.create({
data: {
id: sessionData.id,
companyId: sessionData.companyId,
startTime: startTime,
endTime: endTime,
ipAddress: session.ipAddress || null,
country: session.country || null,
language: session.language || null,
messagesSent:
typeof session.messagesSent === "number" ? session.messagesSent : 0,
sentiment:
typeof session.sentiment === "number" ? session.sentiment : null,
escalated:
typeof session.escalated === "boolean" ? session.escalated : null,
forwardedHr:
typeof session.forwardedHr === "boolean"
? session.forwardedHr
: null,
fullTranscriptUrl: session.fullTranscriptUrl || null,
avgResponseTime:
typeof session.avgResponseTime === "number"
? session.avgResponseTime
: null,
tokens: typeof session.tokens === "number" ? session.tokens : null,
tokensEur:
typeof session.tokensEur === "number" ? session.tokensEur : null,
category: session.category || null,
initialMsg: session.initialMsg || null,
},
});
}
// After importing sessions, automatically trigger complete workflow (fetch transcripts + process)
// This runs in the background without blocking the response
triggerCompleteWorkflow()
.then((result) => {
console.log(`[Refresh Sessions] Complete workflow finished: ${result.message}`);
})
.catch((error) => {
console.error(`[Refresh Sessions] Complete workflow failed:`, error);
});
return sessions.length;
}

6
lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

1
lib/workflow.ts Normal file
View File

@ -0,0 +1 @@
import { prisma } from "./prisma";import { processUnprocessedSessions } from "./processingSchedulerNoCron";import { fileURLToPath } from "url";import { dirname, join } from "path";import { readFileSync } from "fs";const __filename = fileURLToPath(import.meta.url);const __dirname = dirname(__filename);const envPath = join(__dirname, "..", ".env.local");try { const envFile = readFileSync(envPath, "utf8"); const envVars = envFile .split("\n") .filter((line) => line.trim() && !line.startsWith("#")); envVars.forEach((line) => { const [key, ...valueParts] = line.split("="); if (key && valueParts.length > 0) { const value = valueParts.join("=").trim(); if (!process.env[key.trim()]) { process.env[key.trim()] = value; } } });} catch (error) {}async function fetchTranscriptContent( url: string, username?: string, password?: string): Promise<string | null> { try { const authHeader = username && password ? "Basic " + Buffer.from(`${username}:${password}`).toString("base64") : undefined; const response = await fetch(url, { headers: authHeader ? { Authorization: authHeader } : {}, }); if (!response.ok) { process.stderr.write( `Error fetching transcript: ${response.statusText}\n` ); return null; } return await response.text(); } catch (error) { process.stderr.write(`Failed to fetch transcript: ${error}\n`); return null; }}export async function triggerCompleteWorkflow(): Promise<{ message: string }> { try { const sessionsWithoutMessages = await prisma.session.count({ where: { messages: { none: {} }, fullTranscriptUrl: { not: null } } }); if (sessionsWithoutMessages > 0) { console.log(`[Complete Workflow] Fetching transcripts for ${sessionsWithoutMessages} sessions`); const sessionsToProcess = await prisma.session.findMany({ where: { AND: [ { fullTranscriptUrl: { not: null } }, { messages: { none: {} } }, ], }, include: { company: true, }, take: 20, }); for (const session of sessionsToProcess) { try { if (!session.fullTranscriptUrl) continue; const transcriptContent = await fetchTranscriptContent( session.fullTranscriptUrl, session.company.csvUsername || undefined, session.company.csvPassword || undefined ); if (!transcriptContent) { console.log(`No transcript content for session ${session.id}`); continue; } const lines = transcriptContent.split("\n").filter((line) => line.trim()); const messages: Array<{ sessionId: string; role: string; content: string; timestamp: Date; order: number; }> = []; let messageOrder = 0; for (const line of lines) { const timestampMatch = line.match(/^\\[([^\]]+)\\]\\s*([^:]+):\\s*(.+)$/); if (timestampMatch) { const [, timestamp, role, content] = timestampMatch; const dateMatch = timestamp.match(/^(\\d{1,2})-(\\d{1,2})-(\\d{4}) (\\d{1,2}):(\\d{1,2}):(\\d{1,2})$/); let parsedTimestamp = new Date(); if (dateMatch) { const [, day, month, year, hour, minute, second] = dateMatch; parsedTimestamp = new Date( parseInt(year), parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute), parseInt(second) ); } messages.push({ sessionId: session.id, role: role.trim().toLowerCase(), content: content.trim(), timestamp: parsedTimestamp, order: messageOrder++, }); } } if (messages.length > 0) { await prisma.message.createMany({ data: messages as any, }); console.log(`Added ${messages.length} messages for session ${session.id}`); } } catch (error) { console.error(`Error processing session ${session.id}:`, error); } } } const unprocessedWithMessages = await prisma.session.count({ where: { processed: false, messages: { some: {} } } }); if (unprocessedWithMessages > 0) { console.log(`[Complete Workflow] Processing ${unprocessedWithMessages} sessions`); await processUnprocessedSessions(); } return { message: `Complete workflow finished successfully` }; } catch (error) { console.error('[Complete Workflow] Error:', error); throw error; }}

View File

@ -8,6 +8,21 @@ const nextConfig = {
"127.0.0.1",
"localhost"
],
// Disable Turbopack for now due to EISDIR error on Windows
webpack: (config, { isServer }) => {
if (!isServer) {
config.resolve.fallback = { fs: false, net: false, tls: false };
}
return config;
},
experimental: {
appDir: true,
serverComponentsExternalPackages: ['@prisma/client', 'bcryptjs'],
// disable the new Turbopack engine
// This is a temporary workaround for the EISDIR error on Windows
// Remove this once the issue is resolved in Next.js or Turbopack
turbopack: false,
},
};
export default nextConfig;

1183
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev --turbopack",
"dev": "next dev",
"dev:with-server": "tsx server.ts",
"format": "npx prettier --write .",
"format:check": "npx prettier --check .",
@ -31,22 +31,28 @@
"bcryptjs": "^3.0.2",
"chart.js": "^4.0.0",
"chartjs-plugin-annotation": "^3.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"csv-parse": "^5.5.0",
"d3": "^7.9.0",
"d3-cloud": "^1.2.7",
"i18n-iso-countries": "^7.14.0",
"iso-639-1": "^3.1.5",
"leaflet": "^1.9.4",
"next": "^15.3.2",
"lucide-react": "^0.523.0",
"next": "^15.3.4",
"next-auth": "^4.24.11",
"node-cron": "^4.0.7",
"node-fetch": "^3.3.2",
"picocolors": "^1.1.1",
"react": "^19.1.0",
"react-chartjs-2": "^5.0.0",
"react-dom": "^19.1.0",
"react-leaflet": "^5.0.0",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0"
"rehype-raw": "^7.0.0",
"source-map-js": "^1.2.1",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
@ -71,6 +77,7 @@
"tailwindcss": "^4.1.7",
"ts-node": "^10.9.2",
"tsx": "^4.20.3",
"tw-animate-css": "^1.3.4",
"typescript": "^5.0.0"
},
"prettier": {

View File

@ -1,315 +0,0 @@
// API route to refresh (fetch+parse+update) session data for a company
import { NextApiRequest, NextApiResponse } from "next";
import { fetchAndParseCsv } from "../../../lib/csvFetcher";
import { prisma } from "../../../lib/prisma";
import { processUnprocessedSessions } from "../../../lib/processingSchedulerNoCron";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
/**
* Triggers the complete workflow: fetch transcripts + process all sessions
*/
async function triggerCompleteWorkflow(): Promise<{ message: string }> {
try {
// Step 1: Fetch missing transcripts
const sessionsWithoutMessages = await prisma.session.count({
where: {
messages: { none: {} },
fullTranscriptUrl: { not: null }
}
});
if (sessionsWithoutMessages > 0) {
console.log(`[Complete Workflow] Fetching transcripts for ${sessionsWithoutMessages} sessions`);
// Get sessions that have fullTranscriptUrl but no messages
const sessionsToProcess = await prisma.session.findMany({
where: {
AND: [
{ fullTranscriptUrl: { not: null } },
{ messages: { none: {} } },
],
},
include: {
company: true,
},
take: 20, // Process in batches
});
for (const session of sessionsToProcess) {
try {
if (!session.fullTranscriptUrl) continue;
// Fetch transcript content
const transcriptContent = await fetchTranscriptContent(
session.fullTranscriptUrl,
session.company.csvUsername || undefined,
session.company.csvPassword || undefined
);
if (!transcriptContent) {
console.log(`No transcript content for session ${session.id}`);
continue;
}
// Parse transcript into messages
const lines = transcriptContent.split("\n").filter((line) => line.trim());
const messages: Array<{
sessionId: string;
role: string;
content: string;
timestamp: Date;
order: number;
}> = [];
let messageOrder = 0;
const currentTimestamp = new Date();
for (const line of lines) {
// Try format: [DD-MM-YYYY HH:MM:SS] Role: Content
const timestampMatch = line.match(/^\[([^\]]+)\]\s*([^:]+):\s*(.+)$/);
if (timestampMatch) {
const [, timestamp, role, content] = timestampMatch;
const dateMatch = timestamp.match(/^(\d{1,2})-(\d{1,2})-(\d{4}) (\d{1,2}):(\d{1,2}):(\d{1,2})$/);
let parsedTimestamp = new Date();
if (dateMatch) {
const [, day, month, year, hour, minute, second] = dateMatch;
parsedTimestamp = new Date(
parseInt(year),
parseInt(month) - 1,
parseInt(day),
parseInt(hour),
parseInt(minute),
parseInt(second)
);
}
messages.push({
sessionId: session.id,
role: role.trim().toLowerCase(),
content: content.trim(),
timestamp: parsedTimestamp,
order: messageOrder++,
});
}
}
if (messages.length > 0) {
// Save messages to database
await prisma.message.createMany({
data: messages as any, // Type assertion needed due to Prisma types
});
console.log(`Added ${messages.length} messages for session ${session.id}`);
}
} catch (error) {
console.error(`Error processing session ${session.id}:`, error);
}
}
}
// Step 2: Process all unprocessed sessions
const unprocessedWithMessages = await prisma.session.count({
where: {
processed: false,
messages: { some: {} }
}
});
if (unprocessedWithMessages > 0) {
console.log(`[Complete Workflow] Processing ${unprocessedWithMessages} sessions`);
await processUnprocessedSessions();
}
return { message: `Complete workflow finished successfully` };
} catch (error) {
console.error('[Complete Workflow] Error:', error);
throw error;
}
}
interface SessionCreateData {
id: string;
startTime: Date;
companyId: string;
sessionId?: string;
[key: string]: unknown;
}
/**
* Fetches transcript content from a URL
* @param url The URL to fetch the transcript from
* @param username Optional username for authentication
* @param password Optional password for authentication
* @returns The transcript content or null if fetching fails
*/
async function fetchTranscriptContent(
url: string,
username?: string,
password?: string
): Promise<string | null> {
try {
const authHeader =
username && password
? "Basic " + Buffer.from(`${username}:${password}`).toString("base64")
: undefined;
const response = await fetch(url, {
headers: authHeader ? { Authorization: authHeader } : {},
});
if (!response.ok) {
process.stderr.write(
`Error fetching transcript: ${response.statusText}\n`
);
return null;
}
return await response.text();
} catch (error) {
process.stderr.write(`Failed to fetch transcript: ${error}\n`);
return null;
}
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
// Check if this is a POST request
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
// Get companyId from body or query
let { companyId } = req.body;
if (!companyId) {
// Try to get user from prisma based on session cookie
try {
const session = await prisma.session.findFirst({
orderBy: { createdAt: "desc" },
where: {
/* Add session check criteria here */
},
});
if (session) {
companyId = session.companyId;
}
} catch (error) {
// Log error for server-side debugging
const errorMessage =
error instanceof Error ? error.message : String(error);
// Use a server-side logging approach instead of console
process.stderr.write(`Error fetching session: ${errorMessage}\n`);
}
}
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
);
// Only add sessions that don't already exist in the database
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(),
};
// Validate dates to prevent "Invalid Date" errors
const startTime =
sessionData.startTime instanceof Date &&
!isNaN(sessionData.startTime.getTime())
? sessionData.startTime
: new Date();
const endTime =
session.endTime instanceof Date && !isNaN(session.endTime.getTime())
? session.endTime
: new Date();
// Note: transcriptContent field was removed from schema
// Transcript content can be fetched on-demand from fullTranscriptUrl
// Check if the session already exists
const existingSession = await prisma.session.findUnique({
where: { id: sessionData.id },
});
if (existingSession) {
// Skip this session as it already exists
continue;
}
// Only include fields that are properly typed for Prisma
await prisma.session.create({
data: {
id: sessionData.id,
companyId: sessionData.companyId,
startTime: startTime,
endTime: endTime,
ipAddress: session.ipAddress || null,
country: session.country || null,
language: session.language || null,
messagesSent:
typeof session.messagesSent === "number" ? session.messagesSent : 0,
sentiment:
typeof session.sentiment === "number" ? session.sentiment : null,
escalated:
typeof session.escalated === "boolean" ? session.escalated : null,
forwardedHr:
typeof session.forwardedHr === "boolean"
? session.forwardedHr
: null,
fullTranscriptUrl: session.fullTranscriptUrl || null,
avgResponseTime:
typeof session.avgResponseTime === "number"
? session.avgResponseTime
: null,
tokens: typeof session.tokens === "number" ? session.tokens : null,
tokensEur:
typeof session.tokensEur === "number" ? session.tokensEur : null,
category: session.category || null,
initialMsg: session.initialMsg || null,
},
});
}
// After importing sessions, automatically trigger complete workflow (fetch transcripts + process)
// This runs in the background without blocking the response
triggerCompleteWorkflow()
.then((result) => {
console.log(`[Refresh Sessions] Complete workflow finished: ${result.message}`);
})
.catch((error) => {
console.error(`[Refresh Sessions] Complete workflow failed:`, error);
});
res.json({
ok: true,
imported: sessions.length,
message: "Sessions imported and complete processing workflow started automatically"
});
} catch (e) {
const error = e instanceof Error ? e.message : "An unknown error occurred";
res.status(500).json({ error });
}
}

View File

@ -1,109 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth";
import { authOptions } from "../auth/[...nextauth]";
import { prisma } from "../../../lib/prisma";
import { processUnprocessedSessions } from "../../../lib/processingSchedulerNoCron";
interface SessionUser {
email: string;
name?: string;
}
interface SessionData {
user: SessionUser;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
const session = (await getServerSession(
req,
res,
authOptions
)) as SessionData | null;
if (!session?.user) {
return res.status(401).json({ error: "Not logged in" });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: { company: true },
});
if (!user) {
return res.status(401).json({ error: "No user found" });
}
// Check if user has admin role
if (user.role !== "admin") {
return res.status(403).json({ error: "Admin access required" });
}
try {
// Get optional parameters from request body
const { batchSize, maxConcurrency } = req.body;
// Validate parameters
const validatedBatchSize =
batchSize && batchSize > 0 ? parseInt(batchSize) : null;
const validatedMaxConcurrency =
maxConcurrency && maxConcurrency > 0 ? parseInt(maxConcurrency) : 5;
// Check how many unprocessed sessions exist
const unprocessedCount = await prisma.session.count({
where: {
companyId: user.companyId,
processed: false,
messages: { some: {} }, // Must have messages
},
});
if (unprocessedCount === 0) {
return res.json({
success: true,
message: "No unprocessed sessions found",
unprocessedCount: 0,
processedCount: 0,
});
}
// Start processing (this will run asynchronously)
const startTime = Date.now();
// Note: We're calling the function but not awaiting it to avoid timeout
// The processing will continue in the background
processUnprocessedSessions(validatedBatchSize || undefined, validatedMaxConcurrency)
.then(() => {
console.log(
`[Manual Trigger] Processing completed for company ${user.companyId}`
);
})
.catch((error) => {
console.error(
`[Manual Trigger] Processing failed for company ${user.companyId}:`,
error
);
});
return res.json({
success: true,
message: `Started processing ${unprocessedCount} unprocessed sessions`,
unprocessedCount,
batchSize: validatedBatchSize || unprocessedCount,
maxConcurrency: validatedMaxConcurrency,
startedAt: new Date().toISOString(),
});
} catch (error) {
console.error("[Manual Trigger] Error:", error);
return res.status(500).json({
error: "Failed to trigger processing",
details: error instanceof Error ? error.message : String(error),
});
}
}

View File

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

View File

@ -1,36 +0,0 @@
// API endpoint: update company CSV URL config
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth";
import { prisma } from "../../../lib/prisma";
import { authOptions } from "../auth/[...nextauth]";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const session = await getServerSession(req, res, authOptions);
if (!session?.user) return res.status(401).json({ error: "Not logged in" });
const user = await prisma.user.findUnique({
where: { email: session.user.email as string },
});
if (!user) return res.status(401).json({ error: "No user" });
if (req.method === "POST") {
const { csvUrl } = req.body;
await prisma.company.update({
where: { id: user.companyId },
data: { csvUrl },
});
res.json({ ok: true });
} else if (req.method === "GET") {
// Get company data
const company = await prisma.company.findUnique({
where: { id: user.companyId },
});
res.json({ company });
} else {
res.status(405).end();
}
}

View File

@ -1,118 +0,0 @@
// API endpoint: return metrics for current company
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth";
import { prisma } from "../../../lib/prisma";
import { sessionMetrics } from "../../../lib/metrics";
import { authOptions } from "../auth/[...nextauth]";
import { ChatSession } from "../../../lib/types"; // Import ChatSession
interface SessionUser {
email: string;
name?: string;
}
interface SessionData {
user: SessionUser;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const session = (await getServerSession(
req,
res,
authOptions
)) as SessionData | null;
if (!session?.user) return res.status(401).json({ error: "Not logged in" });
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: { company: true },
});
if (!user) return res.status(401).json({ error: "No user" });
// Get date range from query parameters
const { startDate, endDate } = req.query;
// Build where clause with optional date filtering and only processed sessions
const whereClause: any = {
companyId: user.companyId,
processed: true, // Only show processed sessions in dashboard
};
if (startDate && endDate) {
whereClause.startTime = {
gte: new Date(startDate as string),
lte: new Date((endDate as string) + "T23:59:59.999Z"), // Include full end date
};
}
const prismaSessions = await prisma.session.findMany({
where: whereClause,
include: {
messages: true, // Include messages for question extraction
},
});
// Convert Prisma sessions to ChatSession[] type for sessionMetrics
const chatSessions: ChatSession[] = prismaSessions.map((ps) => ({
id: ps.id, // Map Prisma's id to ChatSession.id
sessionId: ps.id, // Map Prisma's id to ChatSession.sessionId
companyId: ps.companyId,
startTime: new Date(ps.startTime), // Ensure startTime is a Date object
endTime: ps.endTime ? new Date(ps.endTime) : null, // Ensure endTime is a Date object or null
transcriptContent: "", // Session model doesn't have transcriptContent field
createdAt: new Date(ps.createdAt), // Map Prisma's createdAt
updatedAt: new Date(ps.createdAt), // Use createdAt for updatedAt as Session model doesn't have updatedAt
category: ps.category || undefined,
language: ps.language || undefined,
country: ps.country || undefined,
ipAddress: ps.ipAddress || undefined,
sentiment: ps.sentiment === null ? undefined : ps.sentiment,
messagesSent: ps.messagesSent === null ? undefined : ps.messagesSent, // Handle null messagesSent
avgResponseTime:
ps.avgResponseTime === null ? undefined : ps.avgResponseTime,
tokens: ps.tokens === null ? undefined : ps.tokens,
tokensEur: ps.tokensEur === null ? undefined : ps.tokensEur,
escalated: ps.escalated || false,
forwardedHr: ps.forwardedHr || false,
initialMsg: ps.initialMsg || undefined,
fullTranscriptUrl: ps.fullTranscriptUrl || undefined,
questions: ps.questions || undefined, // Include questions field
summary: ps.summary || undefined, // Include summary field
messages: ps.messages || [], // Include messages for question extraction
// userId is missing in Prisma Session model, assuming it's not strictly needed for metrics or can be null
userId: undefined, // Or some other default/mapping if available
}));
// Pass company config to metrics
const companyConfigForMetrics = {
sentimentAlert:
user.company.sentimentAlert === null
? undefined
: user.company.sentimentAlert,
};
const metrics = sessionMetrics(chatSessions, companyConfigForMetrics);
// Calculate date range from sessions
let dateRange: { minDate: string; maxDate: string } | null = null;
if (prismaSessions.length > 0) {
const dates = prismaSessions
.map((s) => new Date(s.startTime))
.sort((a, b) => a.getTime() - b.getTime());
dateRange = {
minDate: dates[0].toISOString().split("T")[0], // First session date
maxDate: dates[dates.length - 1].toISOString().split("T")[0], // Last session date
};
}
res.json({
metrics,
csvUrl: user.company.csvUrl,
company: user.company,
dateRange,
});
}

View File

@ -1,76 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]";
import { prisma } from "../../../lib/prisma";
import { SessionFilterOptions } from "../../../lib/types";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<
SessionFilterOptions | { error: string; details?: string }
>
) {
if (req.method !== "GET") {
return res.status(405).json({ error: "Method not allowed" });
}
const authSession = await getServerSession(req, res, authOptions);
if (!authSession || !authSession.user?.companyId) {
return res.status(401).json({ error: "Unauthorized" });
}
const companyId = authSession.user.companyId;
try {
const categories = await prisma.session.findMany({
where: {
companyId,
category: {
not: null, // Ensure category is not null
},
},
distinct: ["category"],
select: {
category: true,
},
orderBy: {
category: "asc",
},
});
const languages = await prisma.session.findMany({
where: {
companyId,
language: {
not: null, // Ensure language is not null
},
},
distinct: ["language"],
select: {
language: true,
},
orderBy: {
language: "asc",
},
});
const distinctCategories = categories
.map((s) => s.category)
.filter(Boolean) as string[]; // Filter out any nulls and assert as string[]
const distinctLanguages = languages
.map((s) => s.language)
.filter(Boolean) as string[]; // Filter out any nulls and assert as string[]
return res
.status(200)
.json({ categories: distinctCategories, languages: distinctLanguages });
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "An unknown error occurred";
return res.status(500).json({
error: "Failed to fetch filter options",
details: errorMessage,
});
}
}

View File

@ -1,86 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "../../../../lib/prisma";
import { ChatSession } from "../../../../lib/types";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "GET") {
return res.status(405).json({ error: "Method not allowed" });
}
const { id } = req.query;
if (!id || typeof id !== "string") {
return res.status(400).json({ error: "Session ID is required" });
}
try {
const prismaSession = await prisma.session.findUnique({
where: { id },
include: {
messages: {
orderBy: { order: "asc" },
},
},
});
if (!prismaSession) {
return res.status(404).json({ error: "Session not found" });
}
// Map Prisma session object to ChatSession type
const session: ChatSession = {
// Spread prismaSession to include all its properties
...prismaSession,
// Override properties that need conversion or specific mapping
id: prismaSession.id, // ChatSession.id from Prisma.Session.id
sessionId: prismaSession.id, // ChatSession.sessionId from Prisma.Session.id
startTime: new Date(prismaSession.startTime),
endTime: prismaSession.endTime ? new Date(prismaSession.endTime) : null,
createdAt: new Date(prismaSession.createdAt),
// Prisma.Session does not have an `updatedAt` field. We'll use `createdAt` as a fallback.
// Or, if your business logic implies an update timestamp elsewhere, use that.
updatedAt: new Date(prismaSession.createdAt), // Fallback to createdAt
// Prisma.Session does not have a `userId` field.
userId: null, // Explicitly set to null or map if available from another source
// Ensure nullable fields from Prisma are correctly mapped to ChatSession's optional or nullable fields
category: prismaSession.category ?? null,
language: prismaSession.language ?? null,
country: prismaSession.country ?? null,
ipAddress: prismaSession.ipAddress ?? null,
sentiment: prismaSession.sentiment ?? null,
sentimentCategory: prismaSession.sentimentCategory ?? null, // New field
messagesSent: prismaSession.messagesSent ?? undefined, // Use undefined if ChatSession expects number | undefined
avgResponseTime: prismaSession.avgResponseTime ?? null,
escalated: prismaSession.escalated ?? undefined,
forwardedHr: prismaSession.forwardedHr ?? undefined,
tokens: prismaSession.tokens ?? undefined,
tokensEur: prismaSession.tokensEur ?? undefined,
initialMsg: prismaSession.initialMsg ?? undefined,
fullTranscriptUrl: prismaSession.fullTranscriptUrl ?? null,
processed: prismaSession.processed ?? null, // New field
questions: prismaSession.questions ?? null, // New field
summary: prismaSession.summary ?? null, // New field
messages:
prismaSession.messages?.map((msg) => ({
id: msg.id,
sessionId: msg.sessionId,
timestamp: new Date(msg.timestamp),
role: msg.role,
content: msg.content,
order: msg.order,
createdAt: new Date(msg.createdAt),
})) ?? [], // New field - parsed messages
};
return res.status(200).json({ session });
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "An unknown error occurred";
return res
.status(500)
.json({ error: "Failed to fetch session", details: errorMessage });
}
}

View File

@ -1,164 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]";
import { prisma } from "../../../lib/prisma";
import {
ChatSession,
SessionApiResponse,
SessionQuery,
} from "../../../lib/types";
import { Prisma } from "@prisma/client";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<SessionApiResponse | { error: string; details?: string }>
) {
if (req.method !== "GET") {
return res.status(405).json({ error: "Method not allowed" });
}
const authSession = await getServerSession(req, res, authOptions);
if (!authSession || !authSession.user?.companyId) {
return res.status(401).json({ error: "Unauthorized" });
}
const companyId = authSession.user.companyId;
const {
searchTerm,
category,
language,
startDate,
endDate,
sortKey,
sortOrder,
page: queryPage,
pageSize: queryPageSize,
} = req.query as SessionQuery;
const page = Number(queryPage) || 1;
const pageSize = Number(queryPageSize) || 10;
try {
const whereClause: Prisma.SessionWhereInput = { companyId };
// Search Term
if (
searchTerm &&
typeof searchTerm === "string" &&
searchTerm.trim() !== ""
) {
const searchConditions = [
{ id: { contains: searchTerm } },
{ category: { contains: searchTerm } },
{ initialMsg: { contains: searchTerm } },
{ transcriptContent: { contains: searchTerm } },
];
whereClause.OR = searchConditions;
}
// Category Filter
if (category && typeof category === "string" && category.trim() !== "") {
whereClause.category = category;
}
// Language Filter
if (language && typeof language === "string" && language.trim() !== "") {
whereClause.language = language;
}
// Date Range Filter
if (startDate && typeof startDate === "string") {
whereClause.startTime = {
...((whereClause.startTime as object) || {}),
gte: new Date(startDate),
};
}
if (endDate && typeof endDate === "string") {
const inclusiveEndDate = new Date(endDate);
inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1);
whereClause.startTime = {
...((whereClause.startTime as object) || {}),
lt: inclusiveEndDate,
};
}
// Sorting
const validSortKeys: { [key: string]: string } = {
startTime: "startTime",
category: "category",
language: "language",
sentiment: "sentiment",
messagesSent: "messagesSent",
avgResponseTime: "avgResponseTime",
};
let orderByCondition:
| Prisma.SessionOrderByWithRelationInput
| Prisma.SessionOrderByWithRelationInput[];
const primarySortField =
sortKey && typeof sortKey === "string" && validSortKeys[sortKey]
? validSortKeys[sortKey]
: "startTime"; // Default to startTime field if sortKey is invalid/missing
const primarySortOrder =
sortOrder === "asc" || sortOrder === "desc" ? sortOrder : "desc"; // Default to desc order
if (primarySortField === "startTime") {
// If sorting by startTime, it's the only sort criteria
orderByCondition = { [primarySortField]: primarySortOrder };
} else {
// If sorting by another field, use startTime: "desc" as secondary sort
orderByCondition = [
{ [primarySortField]: primarySortOrder },
{ startTime: "desc" },
];
}
// Note: If sortKey was initially undefined or invalid, primarySortField defaults to "startTime",
// and primarySortOrder defaults to "desc". This makes orderByCondition = { startTime: "desc" },
// which is the correct overall default sort.
const prismaSessions = await prisma.session.findMany({
where: whereClause,
orderBy: orderByCondition,
skip: (page - 1) * pageSize,
take: pageSize,
});
const totalSessions = await prisma.session.count({ where: whereClause });
const sessions: ChatSession[] = prismaSessions.map((ps) => ({
id: ps.id,
sessionId: ps.id,
companyId: ps.companyId,
startTime: new Date(ps.startTime),
endTime: ps.endTime ? new Date(ps.endTime) : null,
createdAt: new Date(ps.createdAt),
updatedAt: new Date(ps.createdAt),
userId: null,
category: ps.category ?? null,
language: ps.language ?? null,
country: ps.country ?? null,
ipAddress: ps.ipAddress ?? null,
sentiment: ps.sentiment ?? null,
messagesSent: ps.messagesSent ?? undefined,
avgResponseTime: ps.avgResponseTime ?? null,
escalated: ps.escalated ?? undefined,
forwardedHr: ps.forwardedHr ?? undefined,
tokens: ps.tokens ?? undefined,
tokensEur: ps.tokensEur ?? undefined,
initialMsg: ps.initialMsg ?? undefined,
fullTranscriptUrl: ps.fullTranscriptUrl ?? null,
transcriptContent: ps.transcriptContent ?? null,
}));
return res.status(200).json({ sessions, totalSessions });
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "An unknown error occurred";
return res
.status(500)
.json({ error: "Failed to fetch sessions", details: errorMessage });
}
}

View File

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

View File

@ -1,59 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import crypto from "crypto";
import { getServerSession } from "next-auth";
import { prisma } from "../../../lib/prisma";
import bcrypt from "bcryptjs";
import { authOptions } from "../auth/[...nextauth]";
// User type from prisma is used instead of the one in lib/types
interface UserBasicInfo {
id: string;
email: string;
role: string;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const session = await getServerSession(req, res, authOptions);
if (!session?.user || session.user.role !== "admin")
return res.status(403).json({ error: "Forbidden" });
const user = await prisma.user.findUnique({
where: { email: session.user.email as string },
});
if (!user) return res.status(401).json({ error: "No user" });
if (req.method === "GET") {
const users = await prisma.user.findMany({
where: { companyId: user.companyId },
});
const mappedUsers: UserBasicInfo[] = users.map((u) => ({
id: u.id,
email: u.email,
role: u.role,
}));
res.json({ users: mappedUsers });
} else if (req.method === "POST") {
const { email, role } = req.body;
if (!email || !role)
return res.status(400).json({ error: "Missing fields" });
const exists = await prisma.user.findUnique({ where: { email } });
if (exists) return res.status(409).json({ error: "Email exists" });
const tempPassword = crypto.randomBytes(12).toString("base64").slice(0, 12); // secure random initial password
await prisma.user.create({
data: {
email,
password: await bcrypt.hash(tempPassword, 10),
companyId: user.companyId,
role,
},
});
// TODO: Email user their temp password (stub, for demo) - Implement a robust and secure email sending mechanism. Consider using a transactional email service.
res.json({ ok: true, tempPassword });
} else res.status(405).end();
}

View File

@ -1,31 +0,0 @@
import { prisma } from "../../lib/prisma";
import { sendEmail } from "../../lib/sendEmail";
import crypto from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") {
res.setHeader("Allow", ["POST"]);
return res.status(405).end(`Method ${req.method} Not Allowed`);
}
// Type the body with a type assertion
const { email } = req.body as { email: string };
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return res.status(200).end(); // always 200 for privacy
const token = crypto.randomBytes(32).toString("hex");
const expiry = new Date(Date.now() + 1000 * 60 * 30); // 30 min expiry
await prisma.user.update({
where: { email },
data: { resetToken: token, resetTokenExpiry: expiry },
});
const resetUrl = `${process.env.NEXTAUTH_URL || "http://localhost:3000"}/reset-password?token=${token}`;
await sendEmail(email, "Password Reset", `Reset your password: ${resetUrl}`);
res.status(200).end();
}

View File

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

View File

@ -1,63 +0,0 @@
import { prisma } from "../../lib/prisma";
import bcrypt from "bcryptjs";
import type { NextApiRequest, NextApiResponse } from "next"; // Import official Next.js types
export default async function handler(
req: NextApiRequest, // Use official NextApiRequest
res: NextApiResponse // Use official NextApiResponse
) {
if (req.method !== "POST") {
res.setHeader("Allow", ["POST"]); // Good practice to set Allow header for 405
return res.status(405).end(`Method ${req.method} Not Allowed`);
}
// It's good practice to explicitly type the expected body for clarity and safety
const { token, password } = req.body as { token?: string; password?: string };
if (!token || !password) {
return res.status(400).json({ error: "Token and password are required." });
}
if (password.length < 8) {
// Example: Add password complexity rule
return res
.status(400)
.json({ error: "Password must be at least 8 characters long." });
}
try {
const user = await prisma.user.findFirst({
where: {
resetToken: token,
resetTokenExpiry: { gte: new Date() },
},
});
if (!user) {
return res.status(400).json({
error: "Invalid or expired token. Please request a new password reset.",
});
}
const hash = await bcrypt.hash(password, 10);
await prisma.user.update({
where: { id: user.id },
data: {
password: hash,
resetToken: null,
resetTokenExpiry: null,
},
});
// Instead of just res.status(200).end(), send a success message
return res
.status(200)
.json({ message: "Password has been reset successfully." });
} catch (error) {
console.error("Reset password error:", error); // Log the error for server-side debugging
// Provide a generic error message to the client
return res.status(500).json({
error: "An internal server error occurred. Please try again later.",
});
}
}

View File

@ -38,7 +38,7 @@ model Session {
company Company @relation(fields: [companyId], references: [id])
companyId String
startTime DateTime
endTime DateTime
endTime DateTime?
ipAddress String?
country String?
language String?

View File

@ -66,7 +66,7 @@ async function processTranscriptWithOpenAI(
"escalated": boolean,
"forwarded_hr": boolean,
"category": "one of the categories listed above",
"questions": "a single question or [\"question 1\", \"question 2\", ...]",
"questions": null, or array of questions,
"summary": "brief summary",
"tokens": number,
"tokens_eur": number