mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 14:12:10 +01:00
Compare commits
3 Commits
5042a6c016
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
| fd55b30398 | |||
| 8774a1f155 | |||
| 653d70022b |
20
.gemini/settings.json
Normal file
20
.gemini/settings.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"sqlite": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": [
|
||||||
|
"mcp-server-sqlite",
|
||||||
|
"--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
47
GEMINI.md
Normal 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
|
||||||
|
```
|
||||||
106
README.md
106
README.md
@ -2,65 +2,65 @@
|
|||||||
|
|
||||||
A real-time analytics dashboard for monitoring user sessions and interactions with interactive data visualizations and detailed metrics.
|
A real-time analytics dashboard for monitoring user sessions and interactions with interactive data visualizations and detailed metrics.
|
||||||
|
|
||||||
.*%22&replace=%24%3Cversion%3E&logo=nextdotjs&label=Nextjs&color=%23000000)
|
.*%22&replace=%24%3Cversion%3E&logo=nextdotjs&label=Nextjs&color=%23000000>)
|
||||||
.*%22&replace=%24%3Cversion%3E&logo=react&label=React&color=%2361DAFB)
|
.*%22&replace=%24%3Cversion%3E&logo=react&label=React&color=%2361DAFB>)
|
||||||
.*%22&replace=%24%3Cversion%3E&logo=typescript&label=TypeScript&color=%233178C6)
|
.*%22&replace=%24%3Cversion%3E&logo=typescript&label=TypeScript&color=%233178C6>)
|
||||||
.*%22&replace=%24%3Cversion%3E&logo=prisma&label=Prisma&color=%232D3748)
|
.*%22&replace=%24%3Cversion%3E&logo=prisma&label=Prisma&color=%232D3748>)
|
||||||
.*%22&replace=%24%3Cversion%3E&logo=tailwindcss&label=TailwindCSS&color=%2306B6D4)
|
.*%22&replace=%24%3Cversion%3E&logo=tailwindcss&label=TailwindCSS&color=%2306B6D4>)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Real-time Session Monitoring**: Track and analyze user sessions as they happen
|
- **Real-time Session Monitoring**: Track and analyze user sessions as they happen
|
||||||
- **Interactive Visualizations**: Geographic maps, response time distributions, and more
|
- **Interactive Visualizations**: Geographic maps, response time distributions, and more
|
||||||
- **Advanced Analytics**: Detailed metrics and insights about user behavior
|
- **Advanced Analytics**: Detailed metrics and insights about user behavior
|
||||||
- **User Management**: Secure authentication with role-based access control
|
- **User Management**: Secure authentication with role-based access control
|
||||||
- **Customizable Dashboard**: Filter and sort data based on your specific needs
|
- **Customizable Dashboard**: Filter and sort data based on your specific needs
|
||||||
- **Session Details**: In-depth analysis of individual user sessions
|
- **Session Details**: In-depth analysis of individual user sessions
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Frontend**: React 19, Next.js 15, TailwindCSS 4
|
- **Frontend**: React 19, Next.js 15, TailwindCSS 4
|
||||||
- **Backend**: Next.js API Routes, Node.js
|
- **Backend**: Next.js API Routes, Node.js
|
||||||
- **Database**: Prisma ORM with SQLite (default), compatible with PostgreSQL
|
- **Database**: Prisma ORM with SQLite (default), compatible with PostgreSQL
|
||||||
- **Authentication**: NextAuth.js
|
- **Authentication**: NextAuth.js
|
||||||
- **Visualization**: Chart.js, D3.js, React Leaflet
|
- **Visualization**: Chart.js, D3.js, React Leaflet
|
||||||
- **Data Processing**: Node-cron for scheduled tasks
|
- **Data Processing**: Node-cron for scheduled tasks
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js (LTS version recommended)
|
- Node.js (LTS version recommended)
|
||||||
- npm or yarn
|
- npm or yarn
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
1. Clone this repository:
|
1. Clone this repository:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/kjanat/livedash-node.git
|
git clone https://github.com/kjanat/livedash-node.git
|
||||||
cd livedash-node
|
cd livedash-node
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Install dependencies:
|
2. Install dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Set up the database:
|
3. Set up the database:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run prisma:generate
|
npm run prisma:generate
|
||||||
npm run prisma:migrate
|
npm run prisma:migrate
|
||||||
npm run prisma:seed
|
npm run prisma:seed
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Start the development server:
|
4. Start the development server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Open your browser and navigate to <http://localhost:3000>
|
5. Open your browser and navigate to <http://localhost:3000>
|
||||||
|
|
||||||
@ -76,22 +76,22 @@ NEXTAUTH_SECRET=your-secret-here
|
|||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
- `app/`: Next.js App Router components and pages
|
- `app/`: Next.js App Router components and pages
|
||||||
- `components/`: Reusable React components
|
- `components/`: Reusable React components
|
||||||
- `lib/`: Utility functions and shared code
|
- `lib/`: Utility functions and shared code
|
||||||
- `pages/`: API routes and server-side code
|
- `pages/`: API routes and server-side code
|
||||||
- `prisma/`: Database schema and migrations
|
- `prisma/`: Database schema and migrations
|
||||||
- `public/`: Static assets
|
- `public/`: Static assets
|
||||||
- `docs/`: Project documentation
|
- `docs/`: Project documentation
|
||||||
|
|
||||||
## Available Scripts
|
## Available Scripts
|
||||||
|
|
||||||
- `npm run dev`: Start the development server
|
- `npm run dev`: Start the development server
|
||||||
- `npm run build`: Build the application for production
|
- `npm run build`: Build the application for production
|
||||||
- `npm run start`: Run the production build
|
- `npm run start`: Run the production build
|
||||||
- `npm run lint`: Run ESLint
|
- `npm run lint`: Run ESLint
|
||||||
- `npm run format`: Format code with Prettier
|
- `npm run format`: Format code with Prettier
|
||||||
- `npm run prisma:studio`: Open Prisma Studio to view database
|
- `npm run prisma:studio`: Open Prisma Studio to view database
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@ -107,9 +107,9 @@ This project is not licensed for commercial use without explicit permission. Fre
|
|||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
- [Next.js](https://nextjs.org/)
|
- [Next.js](https://nextjs.org/)
|
||||||
- [Prisma](https://prisma.io/)
|
- [Prisma](https://prisma.io/)
|
||||||
- [TailwindCSS](https://tailwindcss.com/)
|
- [TailwindCSS](https://tailwindcss.com/)
|
||||||
- [Chart.js](https://www.chartjs.org/)
|
- [Chart.js](https://www.chartjs.org/)
|
||||||
- [D3.js](https://d3js.org/)
|
- [D3.js](https://d3js.org/)
|
||||||
- [React Leaflet](https://react-leaflet.js.org/)
|
- [React Leaflet](https://react-leaflet.js.org/)
|
||||||
|
|||||||
128
TODO.md
128
TODO.md
@ -1,108 +1,78 @@
|
|||||||
# TODO.md
|
# TODO.md
|
||||||
|
|
||||||
## Dashboard Integration
|
# Refactor!!!
|
||||||
|
|
||||||
- [ ] **Resolve `GeographicMap.tsx` and `ResponseTimeDistribution.tsx` data simulation**
|
> 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
|
||||||
- Investigate integrating real data sources with server-side analytics
|
> design.
|
||||||
- Replace simulated data mentioned in `docs/dashboard-components.md`
|
|
||||||
|
|
||||||
## Component Specific
|
## High-Level Recommendations
|
||||||
|
|
||||||
- [ ] **Implement robust emailing of temporary passwords**
|
The project has a solid foundation, but it could be significantly improved by focusing on three key areas:
|
||||||
|
|
||||||
- File: `pages/api/dashboard/users.ts`
|
1. Adopt a UI Component Library: While Tailwind CSS is excellent for styling, using a component library like ShadCN/UI or Headless UI would provide pre-built, accessible, and visually
|
||||||
- Set up proper email service integration
|
consistent components, saving development time and improving the user experience.
|
||||||
|
2. Refactor for Next.js App Router: The project currently uses a mix of the pages and app directories. Migrating fully to the App Router would simplify the project structure, improve
|
||||||
|
performance, and align with the latest Next.js features.
|
||||||
|
3. Enhance User Experience: Implementing consistent loading and error states, improving responsiveness, and providing better user feedback would make the application more robust and
|
||||||
|
user-friendly.
|
||||||
|
|
||||||
- [x] **Session page improvements** ✅
|
## Detailed Improvement Plan
|
||||||
- File: `app/dashboard/sessions/page.tsx`
|
|
||||||
- Implemented pagination, advanced filtering, and sorting
|
|
||||||
|
|
||||||
## File Cleanup
|
Here is a phased plan to implement these recommendations:
|
||||||
|
|
||||||
- [x] **Remove backup files** ✅
|
### Phase 1: Foundational Improvements (Standardization & Abstraction)
|
||||||
- Reviewed and removed `.bak` and `.new` files after integration
|
|
||||||
- Cleaned up `GeographicMap.tsx.bak`, `SessionDetails.tsx.bak`, `SessionDetails.tsx.new`
|
|
||||||
|
|
||||||
## Database Schema Improvements
|
This phase focuses on cleaning up the codebase, standardizing the project structure, and improving the abstraction of core functionalities.
|
||||||
|
|
||||||
- [ ] **Update EndTime field**
|
1. Standardize Project Structure:
|
||||||
|
|
||||||
- Make `endTime` field nullable in Prisma schema to match TypeScript interfaces
|
- [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). ✅
|
||||||
|
|
||||||
- [ ] **Add database indices**
|
2. Introduce a UI Component Library:
|
||||||
|
|
||||||
- Add appropriate indices to improve query performance
|
- Integrate ShadCN/UI: Add ShadCN/UI to the project to leverage its extensive library of accessible and customizable components.
|
||||||
- Focus on dashboard metrics and session listing queries
|
- 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.
|
||||||
|
|
||||||
- [ ] **Implement production email service**
|
3. Refactor Core Logic:
|
||||||
- Replace console logging in `lib/sendEmail.ts`
|
- 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.
|
||||||
- Consider providers: Nodemailer, SendGrid, AWS SES
|
- Isolate Business Logic: Ensure that business logic (e.g., session processing, metric calculation) is separated from the API routes and UI components.
|
||||||
|
|
||||||
## General Enhancements & Features
|
### Phase 2: UX and Visual Enhancements
|
||||||
|
|
||||||
- [ ] **Real-time updates**
|
This phase focuses on improving the user-facing aspects of the application.
|
||||||
|
|
||||||
- Implement for dashboard and session list
|
1. Implement Comprehensive Loading and Error States:
|
||||||
- Consider WebSockets or Server-Sent Events
|
|
||||||
|
|
||||||
- [ ] **Data export functionality**
|
- 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.
|
||||||
|
|
||||||
- Allow users (especially admins) to export session data
|
2. Redesign the Dashboard:
|
||||||
- Support CSV format initially
|
|
||||||
|
|
||||||
- [ ] **Customizable dashboard**
|
- Improve Information Hierarchy: Reorganize the dashboard to present the most important information first.
|
||||||
- Allow users to customize dashboard view
|
- Enhance Visual Appeal: Use the new component library to create a more modern and visually appealing design with a consistent color palette and typography.
|
||||||
- Let users choose which metrics/charts are most important
|
- Improve Chart Interactivity: Add features like tooltips, zooming, and filtering to the charts to make them more interactive and informative.
|
||||||
|
|
||||||
## Testing & Quality Assurance
|
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.
|
||||||
|
|
||||||
- [ ] **Comprehensive testing suite**
|
### Phase 3: Advanced Topics (Security, Performance, and Documentation)
|
||||||
|
|
||||||
- [ ] Unit tests for utility functions and API logic
|
This phase focuses on long-term improvements to the project's stability, performance, and maintainability.
|
||||||
- [ ] Integration tests for API endpoints with database
|
|
||||||
- [ ] End-to-end tests for user flows (Playwright or Cypress)
|
|
||||||
|
|
||||||
- [ ] **Error monitoring and logging**
|
1. Conduct a Security Review:
|
||||||
|
|
||||||
- Integrate robust error monitoring service (Sentry)
|
- Input Validation: Ensure that all user inputs are properly validated on both the client and server sides.
|
||||||
- Enhance server-side logging
|
- Dependency Audit: Regularly audit dependencies for known vulnerabilities.
|
||||||
|
|
||||||
- [ ] **Accessibility improvements**
|
2. Optimize Performance:
|
||||||
- Review application against WCAG guidelines
|
|
||||||
- Improve keyboard navigation and screen reader compatibility
|
|
||||||
- Check color contrast ratios
|
|
||||||
|
|
||||||
## Security Enhancements
|
- 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.
|
||||||
|
|
||||||
- [x] **Password reset functionality** ✅
|
3. Expand Documentation:
|
||||||
|
- API Documentation: Create detailed documentation for all API endpoints.
|
||||||
- Implemented secure password reset mechanism
|
- Component Library: Document the usage and props of all reusable components.
|
||||||
- Files: `app/forgot-password/page.tsx`, `app/reset-password/page.tsx`, `pages/api/forgot-password.ts`, `pages/api/reset-password.ts`
|
- Update `AGENTS.md`: Keep the AGENTS.md file up-to-date with any architectural changes.
|
||||||
|
|
||||||
- [ ] **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
|
|
||||||
|
|||||||
@ -27,46 +27,55 @@ function DashboardContent() {
|
|||||||
const [company, setCompany] = useState<Company | null>(null);
|
const [company, setCompany] = useState<Company | null>(null);
|
||||||
const [, setLoading] = useState<boolean>(false);
|
const [, setLoading] = useState<boolean>(false);
|
||||||
const [refreshing, setRefreshing] = useState<boolean>(false);
|
const [refreshing, setRefreshing] = useState<boolean>(false);
|
||||||
const [dateRange, setDateRange] = useState<{ minDate: string; maxDate: string } | null>(null);
|
const [dateRange, setDateRange] = useState<{
|
||||||
|
minDate: string;
|
||||||
|
maxDate: string;
|
||||||
|
} | null>(null);
|
||||||
const [selectedStartDate, setSelectedStartDate] = useState<string>("");
|
const [selectedStartDate, setSelectedStartDate] = useState<string>("");
|
||||||
const [selectedEndDate, setSelectedEndDate] = useState<string>("");
|
const [selectedEndDate, setSelectedEndDate] = useState<string>("");
|
||||||
|
|
||||||
const isAuditor = session?.user?.role === "auditor";
|
const isAuditor = session?.user?.role === "auditor";
|
||||||
|
|
||||||
// Function to fetch metrics with optional date range
|
// Function to fetch metrics with optional date range
|
||||||
const fetchMetrics = useCallback(async (startDate?: string, endDate?: string) => {
|
const fetchMetrics = useCallback(
|
||||||
setLoading(true);
|
async (startDate?: string, endDate?: string) => {
|
||||||
try {
|
setLoading(true);
|
||||||
let url = "/api/dashboard/metrics";
|
try {
|
||||||
if (startDate && endDate) {
|
let url = "/api/dashboard/metrics";
|
||||||
url += `?startDate=${startDate}&endDate=${endDate}`;
|
if (startDate && endDate) {
|
||||||
|
url += `?startDate=${startDate}&endDate=${endDate}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
setMetrics(data.metrics);
|
||||||
|
setCompany(data.company);
|
||||||
|
|
||||||
|
// Set date range from API response (only on initial load)
|
||||||
|
if (data.dateRange && !dateRange) {
|
||||||
|
setDateRange(data.dateRange);
|
||||||
|
setSelectedStartDate(data.dateRange.minDate);
|
||||||
|
setSelectedEndDate(data.dateRange.maxDate);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching metrics:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const res = await fetch(url);
|
[dateRange]
|
||||||
const data = await res.json();
|
);
|
||||||
|
|
||||||
setMetrics(data.metrics);
|
|
||||||
setCompany(data.company);
|
|
||||||
|
|
||||||
// Set date range from API response (only on initial load)
|
|
||||||
if (data.dateRange && !dateRange) {
|
|
||||||
setDateRange(data.dateRange);
|
|
||||||
setSelectedStartDate(data.dateRange.minDate);
|
|
||||||
setSelectedEndDate(data.dateRange.maxDate);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching metrics:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [dateRange]);
|
|
||||||
|
|
||||||
// Handle date range changes
|
// Handle date range changes
|
||||||
const handleDateRangeChange = useCallback((startDate: string, endDate: string) => {
|
const handleDateRangeChange = useCallback(
|
||||||
setSelectedStartDate(startDate);
|
(startDate: string, endDate: string) => {
|
||||||
setSelectedEndDate(endDate);
|
setSelectedStartDate(startDate);
|
||||||
fetchMetrics(startDate, endDate);
|
setSelectedEndDate(endDate);
|
||||||
}, [fetchMetrics]);
|
fetchMetrics(startDate, endDate);
|
||||||
|
},
|
||||||
|
[fetchMetrics]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Redirect if not authenticated
|
// Redirect if not authenticated
|
||||||
@ -368,7 +377,7 @@ function DashboardContent() {
|
|||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Avg. Daily Costs"
|
title="Avg. Daily Costs"
|
||||||
value={`€${metrics.avgDailyCosts?.toFixed(4) || '0.0000'}`}
|
value={`€${metrics.avgDailyCosts?.toFixed(4) || "0.0000"}`}
|
||||||
icon={
|
icon={
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
@ -388,7 +397,7 @@ function DashboardContent() {
|
|||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Peak Usage Time"
|
title="Peak Usage Time"
|
||||||
value={metrics.peakUsageTime || 'N/A'}
|
value={metrics.peakUsageTime || "N/A"}
|
||||||
icon={
|
icon={
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
@ -408,7 +417,7 @@ function DashboardContent() {
|
|||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Resolved Chats"
|
title="Resolved Chats"
|
||||||
value={`${metrics.resolvedChatsPercentage?.toFixed(1) || '0.0'}%`}
|
value={`${metrics.resolvedChatsPercentage?.toFixed(1) || "0.0"}%`}
|
||||||
icon={
|
icon={
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useParams, useRouter } from "next/navigation"; // Import useRouter
|
import { useParams, useRouter } from "next/navigation"; // Import useRouter
|
||||||
import { useSession } from "next-auth/react"; // Import useSession
|
import { useSession } from "next-auth/react"; // Import useSession
|
||||||
import SessionDetails from "../../../../components/SessionDetails";
|
import SessionDetails from "../../../../components/SessionDetails";
|
||||||
import TranscriptViewer from "../../../../components/TranscriptViewer";
|
|
||||||
import MessageViewer from "../../../../components/MessageViewer";
|
import MessageViewer from "../../../../components/MessageViewer";
|
||||||
import { ChatSession } from "../../../../lib/types";
|
import { ChatSession } from "../../../../lib/types";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|||||||
119
app/globals.css
119
app/globals.css
@ -1 +1,120 @@
|
|||||||
@import "tailwindcss";
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
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 "./api/auth/[...nextauth]/route";
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
|
|||||||
21
components.json
Normal file
21
components.json
Normal 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"
|
||||||
|
}
|
||||||
@ -63,10 +63,11 @@ export default function DateRangePicker({
|
|||||||
const setLast30Days = () => {
|
const setLast30Days = () => {
|
||||||
const thirtyDaysAgo = new Date();
|
const thirtyDaysAgo = new Date();
|
||||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
const thirtyDaysAgoStr = thirtyDaysAgo.toISOString().split('T')[0];
|
const thirtyDaysAgoStr = thirtyDaysAgo.toISOString().split("T")[0];
|
||||||
|
|
||||||
// Use the later of 30 days ago or minDate
|
// Use the later of 30 days ago or minDate
|
||||||
const newStartDate = thirtyDaysAgoStr > minDate ? thirtyDaysAgoStr : minDate;
|
const newStartDate =
|
||||||
|
thirtyDaysAgoStr > minDate ? thirtyDaysAgoStr : minDate;
|
||||||
setStartDate(newStartDate);
|
setStartDate(newStartDate);
|
||||||
setEndDate(maxDate);
|
setEndDate(maxDate);
|
||||||
};
|
};
|
||||||
@ -74,7 +75,7 @@ export default function DateRangePicker({
|
|||||||
const setLast7Days = () => {
|
const setLast7Days = () => {
|
||||||
const sevenDaysAgo = new Date();
|
const sevenDaysAgo = new Date();
|
||||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||||
const sevenDaysAgoStr = sevenDaysAgo.toISOString().split('T')[0];
|
const sevenDaysAgoStr = sevenDaysAgo.toISOString().split("T")[0];
|
||||||
|
|
||||||
// Use the later of 7 days ago or minDate
|
// Use the later of 7 days ago or minDate
|
||||||
const newStartDate = sevenDaysAgoStr > minDate ? sevenDaysAgoStr : minDate;
|
const newStartDate = sevenDaysAgoStr > minDate ? sevenDaysAgoStr : minDate;
|
||||||
@ -146,7 +147,8 @@ export default function DateRangePicker({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 text-xs text-gray-500">
|
<div className="mt-2 text-xs text-gray-500">
|
||||||
Available data: {new Date(minDate).toLocaleDateString()} - {new Date(maxDate).toLocaleDateString()}
|
Available data: {new Date(minDate).toLocaleDateString()} -{" "}
|
||||||
|
{new Date(maxDate).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -48,7 +48,7 @@ const getCountryCoordinates = (): Record<string, [number, number]> => {
|
|||||||
BG: [42.7339, 25.4858],
|
BG: [42.7339, 25.4858],
|
||||||
HR: [45.1, 15.2],
|
HR: [45.1, 15.2],
|
||||||
SK: [48.669, 19.699],
|
SK: [48.669, 19.699],
|
||||||
SI: [46.1512, 14.9955]
|
SI: [46.1512, 14.9955],
|
||||||
};
|
};
|
||||||
// This function now primarily returns fallbacks.
|
// This function now primarily returns fallbacks.
|
||||||
// The actual fetching using @rapideditor/country-coder will be in the component's useEffect.
|
// The actual fetching using @rapideditor/country-coder will be in the component's useEffect.
|
||||||
|
|||||||
@ -1,14 +1,17 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
import { TopQuestion } from '../lib/types';
|
import { TopQuestion } from "../lib/types";
|
||||||
|
|
||||||
interface TopQuestionsChartProps {
|
interface TopQuestionsChartProps {
|
||||||
data: TopQuestion[];
|
data: TopQuestion[];
|
||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TopQuestionsChart({ data, title = "Top 5 Asked Questions" }: TopQuestionsChartProps) {
|
export default function TopQuestionsChart({
|
||||||
|
data,
|
||||||
|
title = "Top 5 Asked Questions",
|
||||||
|
}: TopQuestionsChartProps) {
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||||
@ -21,7 +24,7 @@ export default function TopQuestionsChart({ data, title = "Top 5 Asked Questions
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find the maximum count to calculate relative bar widths
|
// Find the maximum count to calculate relative bar widths
|
||||||
const maxCount = Math.max(...data.map(q => q.count));
|
const maxCount = Math.max(...data.map((q) => q.count));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||||
@ -29,7 +32,8 @@ export default function TopQuestionsChart({ data, title = "Top 5 Asked Questions
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{data.map((question, index) => {
|
{data.map((question, index) => {
|
||||||
const percentage = maxCount > 0 ? (question.count / maxCount) * 100 : 0;
|
const percentage =
|
||||||
|
maxCount > 0 ? (question.count / maxCount) * 100 : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} className="relative">
|
<div key={index} className="relative">
|
||||||
|
|||||||
213
docs/automated-processing-system.md
Normal file
213
docs/automated-processing-system.md
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
# 🤖 Automated Processing System Documentation
|
||||||
|
|
||||||
|
## 🎯 Overview
|
||||||
|
|
||||||
|
The LiveDash system now features a complete automated processing pipeline that:
|
||||||
|
- ✅ **Processes ALL unprocessed sessions** in batches until completion
|
||||||
|
- ✅ **Runs hourly** to check for new unprocessed sessions
|
||||||
|
- ✅ **Triggers automatically** when dashboard refresh is pressed
|
||||||
|
- ✅ **Validates data quality** and filters out low-quality sessions
|
||||||
|
- ✅ **Requires zero manual intervention** for ongoing operations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Complete Workflow
|
||||||
|
|
||||||
|
### 1. **CSV Import** (Automatic/Manual)
|
||||||
|
```
|
||||||
|
📥 CSV Data → Session Records (processed: false)
|
||||||
|
```
|
||||||
|
- **Automatic**: Hourly scheduler imports new CSV data
|
||||||
|
- **Manual**: Dashboard refresh button triggers immediate import
|
||||||
|
- **Result**: New sessions created with `processed: false`
|
||||||
|
|
||||||
|
### 2. **Transcript Fetching** (As Needed)
|
||||||
|
```
|
||||||
|
🔗 fullTranscriptUrl → Message Records
|
||||||
|
```
|
||||||
|
- **Script**: `node scripts/fetch-and-parse-transcripts.js`
|
||||||
|
- **Purpose**: Convert transcript URLs into message records
|
||||||
|
- **Status**: Only sessions with messages can be AI processed
|
||||||
|
|
||||||
|
### 3. **AI Processing** (Automatic/Manual)
|
||||||
|
```
|
||||||
|
💬 Messages → 🤖 OpenAI Analysis → 📊 Structured Data
|
||||||
|
```
|
||||||
|
- **Automatic**: Hourly scheduler processes all unprocessed sessions
|
||||||
|
- **Manual**: Dashboard refresh or direct script execution
|
||||||
|
- **Batch Processing**: Processes ALL unprocessed sessions until none remain
|
||||||
|
- **Quality Validation**: Filters out empty questions and short summaries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Automated Triggers
|
||||||
|
|
||||||
|
### **Hourly Scheduler**
|
||||||
|
```javascript
|
||||||
|
// Runs every hour automatically
|
||||||
|
cron.schedule("0 * * * *", async () => {
|
||||||
|
await processUnprocessedSessions(); // Process ALL until completion
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Dashboard Refresh**
|
||||||
|
```javascript
|
||||||
|
// When user clicks refresh in dashboard
|
||||||
|
POST /api/admin/refresh-sessions
|
||||||
|
→ Import new CSV data
|
||||||
|
→ Automatically trigger processUnprocessedSessions()
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Manual Processing**
|
||||||
|
```bash
|
||||||
|
# Process all unprocessed sessions until completion
|
||||||
|
npx tsx scripts/trigger-processing-direct.js
|
||||||
|
|
||||||
|
# Check system status
|
||||||
|
node scripts/check-database-status.js
|
||||||
|
|
||||||
|
# Complete workflow demonstration
|
||||||
|
npx tsx scripts/complete-workflow-demo.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Processing Logic
|
||||||
|
|
||||||
|
### **Batch Processing Algorithm**
|
||||||
|
```javascript
|
||||||
|
while (true) {
|
||||||
|
// Get next batch of unprocessed sessions with messages
|
||||||
|
const sessions = await findUnprocessedSessions(batchSize: 10);
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
console.log("✅ All sessions processed!");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process batch with concurrency limit
|
||||||
|
await processInParallel(sessions, maxConcurrency: 3);
|
||||||
|
|
||||||
|
// Small delay between batches
|
||||||
|
await delay(1000ms);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Quality Validation**
|
||||||
|
```javascript
|
||||||
|
// Check data quality after AI processing
|
||||||
|
const hasValidQuestions = questions.length > 0;
|
||||||
|
const hasValidSummary = summary.length >= 10;
|
||||||
|
const isValidData = hasValidQuestions && hasValidSummary;
|
||||||
|
|
||||||
|
if (!isValidData) {
|
||||||
|
console.log("⚠️ Session marked as invalid data");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 System Behavior
|
||||||
|
|
||||||
|
### **What Gets Processed**
|
||||||
|
- ✅ Sessions with `processed: false`
|
||||||
|
- ✅ Sessions that have message records
|
||||||
|
- ❌ Sessions without messages (skipped until transcripts fetched)
|
||||||
|
- ❌ Already processed sessions (ignored)
|
||||||
|
|
||||||
|
### **Processing Results**
|
||||||
|
- **Valid Sessions**: Full AI analysis with categories, questions, summary
|
||||||
|
- **Invalid Sessions**: Marked as processed but flagged as low-quality
|
||||||
|
- **Failed Sessions**: Error logged, remains unprocessed for retry
|
||||||
|
|
||||||
|
### **Dashboard Integration**
|
||||||
|
- **Refresh Button**: Imports CSV + triggers processing automatically
|
||||||
|
- **Real-time Updates**: Processing happens in background
|
||||||
|
- **Quality Filtering**: Only meaningful conversations shown in analytics
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Current System Status
|
||||||
|
|
||||||
|
```
|
||||||
|
📊 Database Status:
|
||||||
|
📈 Total sessions: 108
|
||||||
|
✅ Processed sessions: 20 (All sessions with messages)
|
||||||
|
⏳ Unprocessed sessions: 88 (Sessions without transcript messages)
|
||||||
|
💬 Sessions with messages: 20 (Ready for/already processed)
|
||||||
|
🏢 Total companies: 1
|
||||||
|
|
||||||
|
🎯 System State: FULLY OPERATIONAL
|
||||||
|
✅ All sessions with messages have been processed
|
||||||
|
✅ Automated processing ready for new data
|
||||||
|
✅ Quality validation working perfectly
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Available Scripts
|
||||||
|
|
||||||
|
### **Core Processing**
|
||||||
|
```bash
|
||||||
|
# Process all unprocessed sessions (complete batch processing)
|
||||||
|
npx tsx scripts/trigger-processing-direct.js
|
||||||
|
|
||||||
|
# Check database status
|
||||||
|
node scripts/check-database-status.js
|
||||||
|
|
||||||
|
# Fetch missing transcripts
|
||||||
|
node scripts/fetch-and-parse-transcripts.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Data Management**
|
||||||
|
```bash
|
||||||
|
# Import fresh CSV data
|
||||||
|
node scripts/trigger-csv-refresh.js
|
||||||
|
|
||||||
|
# Reset all sessions to unprocessed (for reprocessing)
|
||||||
|
node scripts/reset-processed-status.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### **System Demonstration**
|
||||||
|
```bash
|
||||||
|
# Complete workflow demonstration
|
||||||
|
npx tsx scripts/complete-workflow-demo.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 Key Achievements
|
||||||
|
|
||||||
|
### **✅ Complete Automation**
|
||||||
|
- **Zero manual intervention** needed for ongoing operations
|
||||||
|
- **Hourly processing** of any new unprocessed sessions
|
||||||
|
- **Dashboard integration** with automatic processing triggers
|
||||||
|
|
||||||
|
### **✅ Batch Processing**
|
||||||
|
- **Processes ALL unprocessed sessions** until none remain
|
||||||
|
- **Configurable batch sizes** and concurrency limits
|
||||||
|
- **Progress tracking** with detailed logging
|
||||||
|
|
||||||
|
### **✅ Quality Validation**
|
||||||
|
- **Automatic filtering** of low-quality sessions
|
||||||
|
- **Enhanced OpenAI prompts** with crystal-clear instructions
|
||||||
|
- **Data quality checks** before and after processing
|
||||||
|
|
||||||
|
### **✅ Production Ready**
|
||||||
|
- **Error handling** and retry logic
|
||||||
|
- **Background processing** without blocking responses
|
||||||
|
- **Comprehensive logging** for monitoring and debugging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Production Deployment
|
||||||
|
|
||||||
|
The system is now **100% ready for production** with:
|
||||||
|
|
||||||
|
1. **Automated CSV import** every hour
|
||||||
|
2. **Automated AI processing** every hour
|
||||||
|
3. **Dashboard refresh integration** for immediate processing
|
||||||
|
4. **Quality validation** to ensure clean analytics
|
||||||
|
5. **Complete batch processing** until all sessions are analyzed
|
||||||
|
|
||||||
|
**No manual intervention required** - the system will automatically process all new data as it arrives!
|
||||||
50
lib/admin-service.ts
Normal file
50
lib/admin-service.ts
Normal 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
7
lib/auth-service.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
export async function findUserByEmail(email: string) {
|
||||||
|
return prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,636 +0,0 @@
|
|||||||
// JavaScript version of csvFetcher with session storage functionality
|
|
||||||
import fetch from "node-fetch";
|
|
||||||
import { parse } from "csv-parse/sync";
|
|
||||||
import ISO6391 from "iso-639-1";
|
|
||||||
import countries from "i18n-iso-countries";
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
|
|
||||||
// Register locales for i18n-iso-countries
|
|
||||||
import enLocale from "i18n-iso-countries/langs/en.json" with { type: "json" };
|
|
||||||
countries.registerLocale(enLocale);
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts country names to ISO 3166-1 alpha-2 codes
|
|
||||||
* @param {string} countryStr Raw country string from CSV
|
|
||||||
* @returns {string|null|undefined} ISO 3166-1 alpha-2 country code or null if not found
|
|
||||||
*/
|
|
||||||
function getCountryCode(countryStr) {
|
|
||||||
if (countryStr === undefined) return undefined;
|
|
||||||
if (countryStr === null || countryStr === "") return null;
|
|
||||||
|
|
||||||
// Clean the input
|
|
||||||
const normalized = countryStr.trim();
|
|
||||||
if (!normalized) return null;
|
|
||||||
|
|
||||||
// Direct ISO code check (if already a 2-letter code)
|
|
||||||
if (normalized.length === 2 && normalized === normalized.toUpperCase()) {
|
|
||||||
return countries.isValid(normalized) ? normalized : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special case for country codes used in the dataset
|
|
||||||
const countryMapping = {
|
|
||||||
BA: "BA", // Bosnia and Herzegovina
|
|
||||||
NL: "NL", // Netherlands
|
|
||||||
USA: "US", // United States
|
|
||||||
UK: "GB", // United Kingdom
|
|
||||||
GB: "GB", // Great Britain
|
|
||||||
Nederland: "NL",
|
|
||||||
Netherlands: "NL",
|
|
||||||
Netherland: "NL",
|
|
||||||
Holland: "NL",
|
|
||||||
Germany: "DE",
|
|
||||||
Deutschland: "DE",
|
|
||||||
Belgium: "BE",
|
|
||||||
België: "BE",
|
|
||||||
Belgique: "BE",
|
|
||||||
France: "FR",
|
|
||||||
Frankreich: "FR",
|
|
||||||
"United States": "US",
|
|
||||||
"United States of America": "US",
|
|
||||||
Bosnia: "BA",
|
|
||||||
"Bosnia and Herzegovina": "BA",
|
|
||||||
"Bosnia & Herzegovina": "BA",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check mapping
|
|
||||||
if (normalized in countryMapping) {
|
|
||||||
return countryMapping[normalized];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to get the code from the country name (in English)
|
|
||||||
try {
|
|
||||||
const code = countries.getAlpha2Code(normalized, "en");
|
|
||||||
if (code) return code;
|
|
||||||
} catch (error) {
|
|
||||||
process.stderr.write(
|
|
||||||
`[CSV] Error converting country name to code: ${normalized} - ${error}\n`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If all else fails, return null
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts language names to ISO 639-1 codes
|
|
||||||
* @param {string} languageStr Raw language string from CSV
|
|
||||||
* @returns {string|null|undefined} ISO 639-1 language code or null if not found
|
|
||||||
*/
|
|
||||||
function getLanguageCode(languageStr) {
|
|
||||||
if (languageStr === undefined) return undefined;
|
|
||||||
if (languageStr === null || languageStr === "") return null;
|
|
||||||
|
|
||||||
// Clean the input
|
|
||||||
const normalized = languageStr.trim();
|
|
||||||
if (!normalized) return null;
|
|
||||||
|
|
||||||
// Direct ISO code check (if already a 2-letter code)
|
|
||||||
if (normalized.length === 2 && normalized === normalized.toLowerCase()) {
|
|
||||||
return ISO6391.validate(normalized) ? normalized : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special case mappings
|
|
||||||
const languageMapping = {
|
|
||||||
english: "en",
|
|
||||||
English: "en",
|
|
||||||
dutch: "nl",
|
|
||||||
Dutch: "nl",
|
|
||||||
nederlands: "nl",
|
|
||||||
Nederlands: "nl",
|
|
||||||
nl: "nl",
|
|
||||||
bosnian: "bs",
|
|
||||||
Bosnian: "bs",
|
|
||||||
turkish: "tr",
|
|
||||||
Turkish: "tr",
|
|
||||||
german: "de",
|
|
||||||
German: "de",
|
|
||||||
deutsch: "de",
|
|
||||||
Deutsch: "de",
|
|
||||||
french: "fr",
|
|
||||||
French: "fr",
|
|
||||||
français: "fr",
|
|
||||||
Français: "fr",
|
|
||||||
spanish: "es",
|
|
||||||
Spanish: "es",
|
|
||||||
español: "es",
|
|
||||||
Español: "es",
|
|
||||||
italian: "it",
|
|
||||||
Italian: "it",
|
|
||||||
italiano: "it",
|
|
||||||
Italiano: "it",
|
|
||||||
nizozemski: "nl", // "Dutch" in some Slavic languages
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check mapping
|
|
||||||
if (normalized in languageMapping) {
|
|
||||||
return languageMapping[normalized];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to get code using the ISO6391 library
|
|
||||||
try {
|
|
||||||
const code = ISO6391.getCode(normalized);
|
|
||||||
if (code) return code;
|
|
||||||
} catch (error) {
|
|
||||||
process.stderr.write(
|
|
||||||
`[CSV] Error converting language name to code: ${normalized} - ${error}\n`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// If all else fails, return null
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes category values to standard groups
|
|
||||||
* @param {string} categoryStr The raw category string from CSV
|
|
||||||
* @returns {string|null} A normalized category string
|
|
||||||
*/
|
|
||||||
function normalizeCategory(categoryStr) {
|
|
||||||
if (!categoryStr) return null;
|
|
||||||
|
|
||||||
const normalized = categoryStr.toLowerCase().trim();
|
|
||||||
|
|
||||||
// Define category groups using keywords
|
|
||||||
const categoryMapping = {
|
|
||||||
Onboarding: [
|
|
||||||
"onboarding",
|
|
||||||
"start",
|
|
||||||
"begin",
|
|
||||||
"new",
|
|
||||||
"orientation",
|
|
||||||
"welcome",
|
|
||||||
"intro",
|
|
||||||
"getting started",
|
|
||||||
"documents",
|
|
||||||
"documenten",
|
|
||||||
"first day",
|
|
||||||
"eerste dag",
|
|
||||||
],
|
|
||||||
"General Information": [
|
|
||||||
"general",
|
|
||||||
"algemeen",
|
|
||||||
"info",
|
|
||||||
"information",
|
|
||||||
"informatie",
|
|
||||||
"question",
|
|
||||||
"vraag",
|
|
||||||
"inquiry",
|
|
||||||
"chat",
|
|
||||||
"conversation",
|
|
||||||
"gesprek",
|
|
||||||
"talk",
|
|
||||||
],
|
|
||||||
Greeting: [
|
|
||||||
"greeting",
|
|
||||||
"greet",
|
|
||||||
"hello",
|
|
||||||
"hi",
|
|
||||||
"hey",
|
|
||||||
"welcome",
|
|
||||||
"hallo",
|
|
||||||
"hoi",
|
|
||||||
"greetings",
|
|
||||||
],
|
|
||||||
"HR & Payroll": [
|
|
||||||
"salary",
|
|
||||||
"salaris",
|
|
||||||
"pay",
|
|
||||||
"payroll",
|
|
||||||
"loon",
|
|
||||||
"loonstrook",
|
|
||||||
"hr",
|
|
||||||
"human resources",
|
|
||||||
"benefits",
|
|
||||||
"vacation",
|
|
||||||
"leave",
|
|
||||||
"verlof",
|
|
||||||
"maaltijdvergoeding",
|
|
||||||
"vergoeding",
|
|
||||||
],
|
|
||||||
"Schedules & Hours": [
|
|
||||||
"schedule",
|
|
||||||
"hours",
|
|
||||||
"tijd",
|
|
||||||
"time",
|
|
||||||
"roster",
|
|
||||||
"rooster",
|
|
||||||
"planning",
|
|
||||||
"shift",
|
|
||||||
"dienst",
|
|
||||||
"working hours",
|
|
||||||
"werktijden",
|
|
||||||
"openingstijden",
|
|
||||||
],
|
|
||||||
"Role & Responsibilities": [
|
|
||||||
"role",
|
|
||||||
"job",
|
|
||||||
"function",
|
|
||||||
"functie",
|
|
||||||
"task",
|
|
||||||
"taak",
|
|
||||||
"responsibilities",
|
|
||||||
"leidinggevende",
|
|
||||||
"manager",
|
|
||||||
"teamleider",
|
|
||||||
"supervisor",
|
|
||||||
"team",
|
|
||||||
"lead",
|
|
||||||
],
|
|
||||||
"Technical Support": [
|
|
||||||
"technical",
|
|
||||||
"tech",
|
|
||||||
"support",
|
|
||||||
"laptop",
|
|
||||||
"computer",
|
|
||||||
"system",
|
|
||||||
"systeem",
|
|
||||||
"it",
|
|
||||||
"software",
|
|
||||||
"hardware",
|
|
||||||
],
|
|
||||||
Offboarding: [
|
|
||||||
"offboarding",
|
|
||||||
"leave",
|
|
||||||
"exit",
|
|
||||||
"quit",
|
|
||||||
"resign",
|
|
||||||
"resignation",
|
|
||||||
"ontslag",
|
|
||||||
"vertrek",
|
|
||||||
"afsluiting",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try to match the category using keywords
|
|
||||||
for (const [category, keywords] of Object.entries(categoryMapping)) {
|
|
||||||
if (keywords.some((keyword) => normalized.includes(keyword))) {
|
|
||||||
return category;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no match, return "Other"
|
|
||||||
return "Other";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts sentiment string values to numeric scores
|
|
||||||
* @param {string} sentimentStr The sentiment string from the CSV
|
|
||||||
* @returns {number|null} A numeric score representing the sentiment
|
|
||||||
*/
|
|
||||||
function mapSentimentToScore(sentimentStr) {
|
|
||||||
if (!sentimentStr) return null;
|
|
||||||
|
|
||||||
// Convert to lowercase for case-insensitive matching
|
|
||||||
const sentiment = sentimentStr.toLowerCase();
|
|
||||||
|
|
||||||
// Map sentiment strings to numeric values on a scale from -1 to 2
|
|
||||||
const sentimentMap = {
|
|
||||||
happy: 1.0,
|
|
||||||
excited: 1.5,
|
|
||||||
positive: 0.8,
|
|
||||||
neutral: 0.0,
|
|
||||||
playful: 0.7,
|
|
||||||
negative: -0.8,
|
|
||||||
angry: -1.0,
|
|
||||||
sad: -0.7,
|
|
||||||
frustrated: -0.9,
|
|
||||||
positief: 0.8, // Dutch
|
|
||||||
neutraal: 0.0, // Dutch
|
|
||||||
negatief: -0.8, // Dutch
|
|
||||||
positivo: 0.8, // Spanish/Italian
|
|
||||||
neutro: 0.0, // Spanish/Italian
|
|
||||||
negativo: -0.8, // Spanish/Italian
|
|
||||||
yes: 0.5, // For any "yes" sentiment
|
|
||||||
no: -0.5, // For any "no" sentiment
|
|
||||||
};
|
|
||||||
|
|
||||||
return sentimentMap[sentiment] !== undefined
|
|
||||||
? sentimentMap[sentiment]
|
|
||||||
: isNaN(parseFloat(sentiment))
|
|
||||||
? null
|
|
||||||
: parseFloat(sentiment);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a string value should be considered as boolean true
|
|
||||||
* @param {string} value The string value to check
|
|
||||||
* @returns {boolean} True if the string indicates a positive/true value
|
|
||||||
*/
|
|
||||||
function isTruthyValue(value) {
|
|
||||||
if (!value) return false;
|
|
||||||
|
|
||||||
const truthyValues = [
|
|
||||||
"1",
|
|
||||||
"true",
|
|
||||||
"yes",
|
|
||||||
"y",
|
|
||||||
"ja",
|
|
||||||
"si",
|
|
||||||
"oui",
|
|
||||||
"да",
|
|
||||||
"да",
|
|
||||||
"はい",
|
|
||||||
];
|
|
||||||
|
|
||||||
return truthyValues.includes(value.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely parses a date string into a Date object.
|
|
||||||
* @param {string} dateStr The date string to parse.
|
|
||||||
* @returns {Date|null} A Date object or null if parsing fails.
|
|
||||||
*/
|
|
||||||
function safeParseDate(dateStr) {
|
|
||||||
if (!dateStr) return null;
|
|
||||||
|
|
||||||
// Try to parse D-M-YYYY HH:MM:SS format (with hyphens or dots)
|
|
||||||
const dateTimeRegex =
|
|
||||||
/^(\d{1,2})[.-](\d{1,2})[.-](\d{4}) (\d{1,2}):(\d{1,2}):(\d{1,2})$/;
|
|
||||||
const match = dateStr.match(dateTimeRegex);
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
const day = match[1];
|
|
||||||
const month = match[2];
|
|
||||||
const year = match[3];
|
|
||||||
const hour = match[4];
|
|
||||||
const minute = match[5];
|
|
||||||
const second = match[6];
|
|
||||||
|
|
||||||
// Reformat to YYYY-MM-DDTHH:MM:SS (ISO-like, but local time)
|
|
||||||
// Ensure month and day are two digits
|
|
||||||
const formattedDateStr = `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}T${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:${second.padStart(2, "0")}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const date = new Date(formattedDateStr);
|
|
||||||
// Basic validation: check if the constructed date is valid
|
|
||||||
if (!isNaN(date.getTime())) {
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(
|
|
||||||
`[safeParseDate] Error parsing reformatted string ${formattedDateStr} from ${dateStr}:`,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback for other potential formats (e.g., direct ISO 8601) or if the primary parse failed
|
|
||||||
try {
|
|
||||||
const parsedDate = new Date(dateStr);
|
|
||||||
if (!isNaN(parsedDate.getTime())) {
|
|
||||||
return parsedDate;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`[safeParseDate] Error parsing with fallback ${dateStr}:`, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn(`Failed to parse date string: ${dateStr}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches transcript content from a URL
|
|
||||||
* @param {string} url The URL to fetch the transcript from
|
|
||||||
* @param {string} username Optional username for authentication
|
|
||||||
* @param {string} password Optional password for authentication
|
|
||||||
* @returns {Promise<string|null>} The transcript content or null if fetching fails
|
|
||||||
*/
|
|
||||||
async function fetchTranscriptContent(url, username, password) {
|
|
||||||
try {
|
|
||||||
const authHeader =
|
|
||||||
username && password
|
|
||||||
? "Basic " + Buffer.from(`${username}:${password}`).toString("base64")
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: authHeader ? { Authorization: authHeader } : {},
|
|
||||||
timeout: 10000, // 10 second timeout
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
// Only log error once per batch, not for every transcript
|
|
||||||
if (Math.random() < 0.1) {
|
|
||||||
// Log ~10% of errors to avoid spam
|
|
||||||
console.warn(
|
|
||||||
`[CSV] Transcript fetch failed for ${url}: ${response.status} ${response.statusText}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return await response.text();
|
|
||||||
} catch (error) {
|
|
||||||
// Only log error once per batch, not for every transcript
|
|
||||||
if (Math.random() < 0.1) {
|
|
||||||
// Log ~10% of errors to avoid spam
|
|
||||||
console.warn(`[CSV] Transcript fetch error for ${url}:`, error.message);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches and parses CSV data from a URL
|
|
||||||
* @param {string} url The CSV URL
|
|
||||||
* @param {string} username Optional username for authentication
|
|
||||||
* @param {string} password Optional password for authentication
|
|
||||||
* @returns {Promise<Object[]>} Array of parsed session data
|
|
||||||
*/
|
|
||||||
export async function fetchAndParseCsv(url, username, password) {
|
|
||||||
const authHeader =
|
|
||||||
username && password
|
|
||||||
? "Basic " + Buffer.from(`${username}:${password}`).toString("base64")
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const res = await fetch(url, {
|
|
||||||
headers: authHeader ? { Authorization: authHeader } : {},
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch CSV: " + res.statusText);
|
|
||||||
|
|
||||||
const text = await res.text();
|
|
||||||
|
|
||||||
// Parse without expecting headers, using known order
|
|
||||||
const records = parse(text, {
|
|
||||||
delimiter: ",",
|
|
||||||
columns: [
|
|
||||||
"session_id",
|
|
||||||
"start_time",
|
|
||||||
"end_time",
|
|
||||||
"ip_address",
|
|
||||||
"country",
|
|
||||||
"language",
|
|
||||||
"messages_sent",
|
|
||||||
"sentiment",
|
|
||||||
"escalated",
|
|
||||||
"forwarded_hr",
|
|
||||||
"full_transcript_url",
|
|
||||||
"avg_response_time",
|
|
||||||
"tokens",
|
|
||||||
"tokens_eur",
|
|
||||||
"category",
|
|
||||||
"initial_msg",
|
|
||||||
],
|
|
||||||
from_line: 1,
|
|
||||||
relax_column_count: true,
|
|
||||||
skip_empty_lines: true,
|
|
||||||
trim: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Coerce types for relevant columns
|
|
||||||
return records.map((r) => ({
|
|
||||||
id: r.session_id,
|
|
||||||
startTime: safeParseDate(r.start_time) || new Date(), // Fallback to current date if invalid
|
|
||||||
endTime: safeParseDate(r.end_time),
|
|
||||||
ipAddress: r.ip_address,
|
|
||||||
country: getCountryCode(r.country),
|
|
||||||
language: getLanguageCode(r.language),
|
|
||||||
messagesSent: Number(r.messages_sent) || 0,
|
|
||||||
sentiment: mapSentimentToScore(r.sentiment),
|
|
||||||
escalated: isTruthyValue(r.escalated),
|
|
||||||
forwardedHr: isTruthyValue(r.forwarded_hr),
|
|
||||||
fullTranscriptUrl: r.full_transcript_url,
|
|
||||||
avgResponseTime: r.avg_response_time
|
|
||||||
? parseFloat(r.avg_response_time)
|
|
||||||
: null,
|
|
||||||
tokens: Number(r.tokens) || 0,
|
|
||||||
tokensEur: r.tokens_eur ? parseFloat(r.tokens_eur) : 0,
|
|
||||||
category: normalizeCategory(r.category),
|
|
||||||
initialMsg: r.initial_msg,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches and stores sessions for all companies
|
|
||||||
*/
|
|
||||||
export async function fetchAndStoreSessionsForAllCompanies() {
|
|
||||||
try {
|
|
||||||
// Get all companies
|
|
||||||
const companies = await prisma.company.findMany();
|
|
||||||
|
|
||||||
for (const company of companies) {
|
|
||||||
if (!company.csvUrl) {
|
|
||||||
console.log(
|
|
||||||
`[Scheduler] Skipping company ${company.id} - no CSV URL configured`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip companies with invalid/example URLs
|
|
||||||
if (
|
|
||||||
company.csvUrl.includes("example.com") ||
|
|
||||||
company.csvUrl === "https://example.com/data.csv"
|
|
||||||
) {
|
|
||||||
console.log(
|
|
||||||
`[Scheduler] Skipping company ${company.id} - invalid/example CSV URL: ${company.csvUrl}`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[Scheduler] Processing sessions for company: ${company.id}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sessions = await fetchAndParseCsv(
|
|
||||||
company.csvUrl,
|
|
||||||
company.csvUsername,
|
|
||||||
company.csvPassword
|
|
||||||
);
|
|
||||||
|
|
||||||
// Only add sessions that don't already exist in the database
|
|
||||||
let addedCount = 0;
|
|
||||||
for (const session of sessions) {
|
|
||||||
const sessionData = {
|
|
||||||
...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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
addedCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[Scheduler] Added ${addedCount} new sessions for company ${company.id}`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`[Scheduler] Error processing company ${company.id}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Scheduler] Error fetching companies:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -35,10 +35,10 @@ interface SessionData {
|
|||||||
startTime: Date;
|
startTime: Date;
|
||||||
endTime: Date | null;
|
endTime: Date | null;
|
||||||
ipAddress?: string;
|
ipAddress?: string;
|
||||||
country?: string | null; // Will store ISO 3166-1 alpha-2 country code or null/undefined
|
country?: string | null;
|
||||||
language?: string | null; // Will store ISO 639-1 language code or null/undefined
|
language?: string | null;
|
||||||
messagesSent: number;
|
messagesSent: number;
|
||||||
sentiment: number | null;
|
sentiment?: string | null;
|
||||||
escalated: boolean;
|
escalated: boolean;
|
||||||
forwardedHr: boolean;
|
forwardedHr: boolean;
|
||||||
fullTranscriptUrl?: string | null;
|
fullTranscriptUrl?: string | null;
|
||||||
@ -50,65 +50,16 @@ interface SessionData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts country names to ISO 3166-1 alpha-2 codes
|
* Passes through country data as-is (no mapping)
|
||||||
* @param countryStr Raw country string from CSV
|
* @param countryStr Raw country string from CSV
|
||||||
* @returns ISO 3166-1 alpha-2 country code or null if not found
|
* @returns The country string as-is or null if empty
|
||||||
*/
|
*/
|
||||||
function getCountryCode(countryStr?: string): string | null | undefined {
|
function getCountryCode(countryStr?: string): string | null | undefined {
|
||||||
if (countryStr === undefined) return undefined;
|
if (countryStr === undefined) return undefined;
|
||||||
if (countryStr === null || countryStr === "") return null;
|
if (countryStr === null || countryStr === "") return null;
|
||||||
|
|
||||||
// Clean the input
|
|
||||||
const normalized = countryStr.trim();
|
const normalized = countryStr.trim();
|
||||||
if (!normalized) return null;
|
return normalized || null;
|
||||||
|
|
||||||
// Direct ISO code check (if already a 2-letter code)
|
|
||||||
if (normalized.length === 2 && normalized === normalized.toUpperCase()) {
|
|
||||||
return countries.isValid(normalized) ? normalized : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special case for country codes used in the dataset
|
|
||||||
const countryMapping: Record<string, string> = {
|
|
||||||
BA: "BA", // Bosnia and Herzegovina
|
|
||||||
NL: "NL", // Netherlands
|
|
||||||
USA: "US", // United States
|
|
||||||
UK: "GB", // United Kingdom
|
|
||||||
GB: "GB", // Great Britain
|
|
||||||
Nederland: "NL",
|
|
||||||
Netherlands: "NL",
|
|
||||||
Netherland: "NL",
|
|
||||||
Holland: "NL",
|
|
||||||
Germany: "DE",
|
|
||||||
Deutschland: "DE",
|
|
||||||
Belgium: "BE",
|
|
||||||
België: "BE",
|
|
||||||
Belgique: "BE",
|
|
||||||
France: "FR",
|
|
||||||
Frankreich: "FR",
|
|
||||||
"United States": "US",
|
|
||||||
"United States of America": "US",
|
|
||||||
Bosnia: "BA",
|
|
||||||
"Bosnia and Herzegovina": "BA",
|
|
||||||
"Bosnia & Herzegovina": "BA",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check mapping
|
|
||||||
if (normalized in countryMapping) {
|
|
||||||
return countryMapping[normalized];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to get the code from the country name (in English)
|
|
||||||
try {
|
|
||||||
const code = countries.getAlpha2Code(normalized, "en");
|
|
||||||
if (code) return code;
|
|
||||||
} catch (error) {
|
|
||||||
process.stderr.write(
|
|
||||||
`[CSV] Error converting country name to code: ${normalized} - ${error}\n`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If all else fails, return null
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -180,174 +131,15 @@ function getLanguageCode(languageStr?: string): string | null | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes category values to standard groups
|
* Passes through category data as-is (no mapping)
|
||||||
* @param categoryStr The raw category string from CSV
|
* @param categoryStr The raw category string from CSV
|
||||||
* @returns A normalized category string
|
* @returns The category string as-is or null if empty
|
||||||
*/
|
*/
|
||||||
function normalizeCategory(categoryStr?: string): string | null {
|
function normalizeCategory(categoryStr?: string): string | null {
|
||||||
if (!categoryStr) return null;
|
if (!categoryStr) return null;
|
||||||
|
|
||||||
const normalized = categoryStr.toLowerCase().trim();
|
const normalized = categoryStr.trim();
|
||||||
|
return normalized || null;
|
||||||
// Define category groups using keywords
|
|
||||||
const categoryMapping: Record<string, string[]> = {
|
|
||||||
Onboarding: [
|
|
||||||
"onboarding",
|
|
||||||
"start",
|
|
||||||
"begin",
|
|
||||||
"new",
|
|
||||||
"orientation",
|
|
||||||
"welcome",
|
|
||||||
"intro",
|
|
||||||
"getting started",
|
|
||||||
"documents",
|
|
||||||
"documenten",
|
|
||||||
"first day",
|
|
||||||
"eerste dag",
|
|
||||||
],
|
|
||||||
"General Information": [
|
|
||||||
"general",
|
|
||||||
"algemeen",
|
|
||||||
"info",
|
|
||||||
"information",
|
|
||||||
"informatie",
|
|
||||||
"question",
|
|
||||||
"vraag",
|
|
||||||
"inquiry",
|
|
||||||
"chat",
|
|
||||||
"conversation",
|
|
||||||
"gesprek",
|
|
||||||
"talk",
|
|
||||||
],
|
|
||||||
Greeting: [
|
|
||||||
"greeting",
|
|
||||||
"greet",
|
|
||||||
"hello",
|
|
||||||
"hi",
|
|
||||||
"hey",
|
|
||||||
"welcome",
|
|
||||||
"hallo",
|
|
||||||
"hoi",
|
|
||||||
"greetings",
|
|
||||||
],
|
|
||||||
"HR & Payroll": [
|
|
||||||
"salary",
|
|
||||||
"salaris",
|
|
||||||
"pay",
|
|
||||||
"payroll",
|
|
||||||
"loon",
|
|
||||||
"loonstrook",
|
|
||||||
"hr",
|
|
||||||
"human resources",
|
|
||||||
"benefits",
|
|
||||||
"vacation",
|
|
||||||
"leave",
|
|
||||||
"verlof",
|
|
||||||
"maaltijdvergoeding",
|
|
||||||
"vergoeding",
|
|
||||||
],
|
|
||||||
"Schedules & Hours": [
|
|
||||||
"schedule",
|
|
||||||
"hours",
|
|
||||||
"tijd",
|
|
||||||
"time",
|
|
||||||
"roster",
|
|
||||||
"rooster",
|
|
||||||
"planning",
|
|
||||||
"shift",
|
|
||||||
"dienst",
|
|
||||||
"working hours",
|
|
||||||
"werktijden",
|
|
||||||
"openingstijden",
|
|
||||||
],
|
|
||||||
"Role & Responsibilities": [
|
|
||||||
"role",
|
|
||||||
"job",
|
|
||||||
"function",
|
|
||||||
"functie",
|
|
||||||
"task",
|
|
||||||
"taak",
|
|
||||||
"responsibilities",
|
|
||||||
"leidinggevende",
|
|
||||||
"manager",
|
|
||||||
"teamleider",
|
|
||||||
"supervisor",
|
|
||||||
"team",
|
|
||||||
"lead",
|
|
||||||
],
|
|
||||||
"Technical Support": [
|
|
||||||
"technical",
|
|
||||||
"tech",
|
|
||||||
"support",
|
|
||||||
"laptop",
|
|
||||||
"computer",
|
|
||||||
"system",
|
|
||||||
"systeem",
|
|
||||||
"it",
|
|
||||||
"software",
|
|
||||||
"hardware",
|
|
||||||
],
|
|
||||||
Offboarding: [
|
|
||||||
"offboarding",
|
|
||||||
"leave",
|
|
||||||
"exit",
|
|
||||||
"quit",
|
|
||||||
"resign",
|
|
||||||
"resignation",
|
|
||||||
"ontslag",
|
|
||||||
"vertrek",
|
|
||||||
"afsluiting",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try to match the category using keywords
|
|
||||||
for (const [category, keywords] of Object.entries(categoryMapping)) {
|
|
||||||
if (keywords.some((keyword) => normalized.includes(keyword))) {
|
|
||||||
return category;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no match, return "Other"
|
|
||||||
return "Other";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts sentiment string values to numeric scores
|
|
||||||
* @param sentimentStr The sentiment string from the CSV
|
|
||||||
* @returns A numeric score representing the sentiment
|
|
||||||
*/
|
|
||||||
function mapSentimentToScore(sentimentStr?: string): number | null {
|
|
||||||
if (!sentimentStr) return null;
|
|
||||||
|
|
||||||
// Convert to lowercase for case-insensitive matching
|
|
||||||
const sentiment = sentimentStr.toLowerCase();
|
|
||||||
|
|
||||||
// Map sentiment strings to numeric values on a scale from -1 to 2
|
|
||||||
const sentimentMap: Record<string, number> = {
|
|
||||||
happy: 1.0,
|
|
||||||
excited: 1.5,
|
|
||||||
positive: 0.8,
|
|
||||||
neutral: 0.0,
|
|
||||||
playful: 0.7,
|
|
||||||
negative: -0.8,
|
|
||||||
angry: -1.0,
|
|
||||||
sad: -0.7,
|
|
||||||
frustrated: -0.9,
|
|
||||||
positief: 0.8, // Dutch
|
|
||||||
neutraal: 0.0, // Dutch
|
|
||||||
negatief: -0.8, // Dutch
|
|
||||||
positivo: 0.8, // Spanish/Italian
|
|
||||||
neutro: 0.0, // Spanish/Italian
|
|
||||||
negativo: -0.8, // Spanish/Italian
|
|
||||||
yes: 0.5, // For any "yes" sentiment
|
|
||||||
no: -0.5, // For any "no" sentiment
|
|
||||||
};
|
|
||||||
|
|
||||||
return sentimentMap[sentiment] !== undefined
|
|
||||||
? sentimentMap[sentiment]
|
|
||||||
: isNaN(parseFloat(sentiment))
|
|
||||||
? null
|
|
||||||
: parseFloat(sentiment);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -483,7 +275,7 @@ export async function fetchAndParseCsv(
|
|||||||
country: getCountryCode(r.country),
|
country: getCountryCode(r.country),
|
||||||
language: getLanguageCode(r.language),
|
language: getLanguageCode(r.language),
|
||||||
messagesSent: Number(r.messages_sent) || 0,
|
messagesSent: Number(r.messages_sent) || 0,
|
||||||
sentiment: mapSentimentToScore(r.sentiment),
|
sentiment: r.sentiment,
|
||||||
escalated: isTruthyValue(r.escalated),
|
escalated: isTruthyValue(r.escalated),
|
||||||
forwardedHr: isTruthyValue(r.forwarded_hr),
|
forwardedHr: isTruthyValue(r.forwarded_hr),
|
||||||
fullTranscriptUrl: r.full_transcript_url,
|
fullTranscriptUrl: r.full_transcript_url,
|
||||||
|
|||||||
332
lib/data-service.ts
Normal file
332
lib/data-service.ts
Normal 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 } });
|
||||||
|
}
|
||||||
@ -325,7 +325,16 @@ export function sessionMetrics(
|
|||||||
sessions: ChatSession[],
|
sessions: ChatSession[],
|
||||||
companyConfig: CompanyConfig = {}
|
companyConfig: CompanyConfig = {}
|
||||||
): MetricsResult {
|
): MetricsResult {
|
||||||
const totalSessions = sessions.length; // Renamed from 'total' for clarity
|
// Filter out invalid data sessions for analytics
|
||||||
|
const validSessions = sessions.filter(session => {
|
||||||
|
// Include sessions that are either:
|
||||||
|
// 1. Not processed yet (validData field doesn't exist or is undefined)
|
||||||
|
// 2. Processed and marked as valid (validData === true)
|
||||||
|
return session.validData !== false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalSessions = validSessions.length; // Only count valid sessions
|
||||||
|
const totalRawSessions = sessions.length; // Keep track of all sessions for debugging
|
||||||
const byDay: DayMetrics = {};
|
const byDay: DayMetrics = {};
|
||||||
const byCategory: CategoryMetrics = {};
|
const byCategory: CategoryMetrics = {};
|
||||||
const byLanguage: LanguageMetrics = {};
|
const byLanguage: LanguageMetrics = {};
|
||||||
@ -348,18 +357,18 @@ export function sessionMetrics(
|
|||||||
let totalTokens = 0;
|
let totalTokens = 0;
|
||||||
let totalTokensEur = 0;
|
let totalTokensEur = 0;
|
||||||
const wordCounts: { [key: string]: number } = {};
|
const wordCounts: { [key: string]: number } = {};
|
||||||
let alerts = 0;
|
const alerts = 0;
|
||||||
|
|
||||||
// New metrics variables
|
// New metrics variables
|
||||||
const hourlySessionCounts: { [hour: string]: number } = {};
|
const hourlySessionCounts: { [hour: string]: number } = {};
|
||||||
let resolvedChatsCount = 0;
|
let resolvedChatsCount = 0;
|
||||||
const questionCounts: { [question: string]: number } = {};
|
const questionCounts: { [question: string]: number } = {};
|
||||||
|
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
// Track hourly usage for peak time calculation
|
// Track hourly usage for peak time calculation
|
||||||
if (session.startTime) {
|
if (session.startTime) {
|
||||||
const hour = new Date(session.startTime).getHours();
|
const hour = new Date(session.startTime).getHours();
|
||||||
const hourKey = `${hour.toString().padStart(2, '0')}:00`;
|
const hourKey = `${hour.toString().padStart(2, "0")}:00`;
|
||||||
hourlySessionCounts[hourKey] = (hourlySessionCounts[hourKey] || 0) + 1;
|
hourlySessionCounts[hourKey] = (hourlySessionCounts[hourKey] || 0) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -454,22 +463,15 @@ export function sessionMetrics(
|
|||||||
if (session.forwardedHr) forwardedHrCount++;
|
if (session.forwardedHr) forwardedHrCount++;
|
||||||
|
|
||||||
// Sentiment
|
// Sentiment
|
||||||
if (session.sentiment !== undefined && session.sentiment !== null) {
|
if (session.sentiment === "positive") {
|
||||||
// Example thresholds, adjust as needed
|
sentimentPositiveCount++;
|
||||||
if (session.sentiment > 0.3) sentimentPositiveCount++;
|
} else if (session.sentiment === "neutral") {
|
||||||
else if (session.sentiment < -0.3) sentimentNegativeCount++;
|
sentimentNeutralCount++;
|
||||||
else sentimentNeutralCount++;
|
} else if (session.sentiment === "negative") {
|
||||||
|
sentimentNegativeCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sentiment Alert Check
|
|
||||||
if (
|
|
||||||
companyConfig.sentimentAlert !== undefined &&
|
|
||||||
session.sentiment !== undefined &&
|
|
||||||
session.sentiment !== null &&
|
|
||||||
session.sentiment < companyConfig.sentimentAlert
|
|
||||||
) {
|
|
||||||
alerts++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tokens
|
// Tokens
|
||||||
if (session.tokens !== undefined && session.tokens !== null) {
|
if (session.tokens !== undefined && session.tokens !== null) {
|
||||||
@ -514,24 +516,31 @@ export function sessionMetrics(
|
|||||||
questionsArray.forEach((question: string) => {
|
questionsArray.forEach((question: string) => {
|
||||||
if (question && question.trim().length > 0) {
|
if (question && question.trim().length > 0) {
|
||||||
const cleanQuestion = question.trim();
|
const cleanQuestion = question.trim();
|
||||||
questionCounts[cleanQuestion] = (questionCounts[cleanQuestion] || 0) + 1;
|
questionCounts[cleanQuestion] =
|
||||||
|
(questionCounts[cleanQuestion] || 0) + 1;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[metrics] Failed to parse questions JSON for session ${session.id}: ${error}`);
|
console.warn(
|
||||||
|
`[metrics] Failed to parse questions JSON for session ${session.id}: ${error}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Extract questions from user messages (if available)
|
// 2. Extract questions from user messages (if available)
|
||||||
if (session.messages) {
|
if (session.messages) {
|
||||||
session.messages
|
session.messages
|
||||||
.filter(msg => msg.role === 'User')
|
.filter((msg) => msg.role === "User")
|
||||||
.forEach(msg => {
|
.forEach((msg) => {
|
||||||
const content = msg.content.trim();
|
const content = msg.content.trim();
|
||||||
// Simple heuristic: if message ends with ? or contains question words, treat as question
|
// Simple heuristic: if message ends with ? or contains question words, treat as question
|
||||||
if (content.endsWith('?') ||
|
if (
|
||||||
/\b(what|when|where|why|how|who|which|can|could|would|will|is|are|do|does|did)\b/i.test(content)) {
|
content.endsWith("?") ||
|
||||||
|
/\b(what|when|where|why|how|who|which|can|could|would|will|is|are|do|does|did)\b/i.test(
|
||||||
|
content
|
||||||
|
)
|
||||||
|
) {
|
||||||
questionCounts[content] = (questionCounts[content] || 0) + 1;
|
questionCounts[content] = (questionCounts[content] || 0) + 1;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -540,8 +549,12 @@ export function sessionMetrics(
|
|||||||
// 3. Extract questions from initial message as fallback
|
// 3. Extract questions from initial message as fallback
|
||||||
if (session.initialMsg) {
|
if (session.initialMsg) {
|
||||||
const content = session.initialMsg.trim();
|
const content = session.initialMsg.trim();
|
||||||
if (content.endsWith('?') ||
|
if (
|
||||||
/\b(what|when|where|why|how|who|which|can|could|would|will|is|are|do|does|did)\b/i.test(content)) {
|
content.endsWith("?") ||
|
||||||
|
/\b(what|when|where|why|how|who|which|can|could|would|will|is|are|do|does|did)\b/i.test(
|
||||||
|
content
|
||||||
|
)
|
||||||
|
) {
|
||||||
questionCounts[content] = (questionCounts[content] || 0) + 1;
|
questionCounts[content] = (questionCounts[content] || 0) + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -613,20 +626,23 @@ export function sessionMetrics(
|
|||||||
// Calculate new metrics
|
// Calculate new metrics
|
||||||
|
|
||||||
// 1. Average Daily Costs (euros)
|
// 1. Average Daily Costs (euros)
|
||||||
const avgDailyCosts = numDaysWithSessions > 0 ? totalTokensEur / numDaysWithSessions : 0;
|
const avgDailyCosts =
|
||||||
|
numDaysWithSessions > 0 ? totalTokensEur / numDaysWithSessions : 0;
|
||||||
|
|
||||||
// 2. Peak Usage Time
|
// 2. Peak Usage Time
|
||||||
let peakUsageTime = "N/A";
|
let peakUsageTime = "N/A";
|
||||||
if (Object.keys(hourlySessionCounts).length > 0) {
|
if (Object.keys(hourlySessionCounts).length > 0) {
|
||||||
const peakHour = Object.entries(hourlySessionCounts)
|
const peakHour = Object.entries(hourlySessionCounts).sort(
|
||||||
.sort(([, a], [, b]) => b - a)[0][0];
|
([, a], [, b]) => b - a
|
||||||
const peakHourNum = parseInt(peakHour.split(':')[0]);
|
)[0][0];
|
||||||
|
const peakHourNum = parseInt(peakHour.split(":")[0]);
|
||||||
const endHour = (peakHourNum + 1) % 24;
|
const endHour = (peakHourNum + 1) % 24;
|
||||||
peakUsageTime = `${peakHour}-${endHour.toString().padStart(2, '0')}:00`;
|
peakUsageTime = `${peakHour}-${endHour.toString().padStart(2, "0")}:00`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Resolved Chats Percentage
|
// 3. Resolved Chats Percentage
|
||||||
const resolvedChatsPercentage = totalSessions > 0 ? (resolvedChatsCount / totalSessions) * 100 : 0;
|
const resolvedChatsPercentage =
|
||||||
|
totalSessions > 0 ? (resolvedChatsCount / totalSessions) * 100 : 0;
|
||||||
|
|
||||||
// 4. Top 5 Asked Questions
|
// 4. Top 5 Asked Questions
|
||||||
const topQuestions: TopQuestion[] = Object.entries(questionCounts)
|
const topQuestions: TopQuestion[] = Object.entries(questionCounts)
|
||||||
|
|||||||
@ -1,412 +0,0 @@
|
|||||||
// Session processing scheduler - JavaScript version
|
|
||||||
import cron from "node-cron";
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import fetch from "node-fetch";
|
|
||||||
import { readFileSync } from "fs";
|
|
||||||
import { fileURLToPath } from "url";
|
|
||||||
import { dirname, join } from "path";
|
|
||||||
|
|
||||||
// Load environment variables from .env.local
|
|
||||||
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) {
|
|
||||||
// Silently fail if .env.local doesn't exist
|
|
||||||
}
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
|
||||||
const OPENAI_API_URL = "https://api.openai.com/v1/chat/completions";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Processes a session transcript using OpenAI API
|
|
||||||
* @param {string} sessionId The session ID
|
|
||||||
* @param {string} transcript The transcript content to process
|
|
||||||
* @returns {Promise<Object>} Processed data from OpenAI
|
|
||||||
*/
|
|
||||||
async function processTranscriptWithOpenAI(sessionId, transcript) {
|
|
||||||
if (!OPENAI_API_KEY) {
|
|
||||||
throw new Error("OPENAI_API_KEY environment variable is not set");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a system message with instructions
|
|
||||||
const systemMessage = `
|
|
||||||
You are an AI assistant tasked with analyzing chat transcripts.
|
|
||||||
Extract the following information from the transcript:
|
|
||||||
1. The primary language used by the user (ISO 639-1 code)
|
|
||||||
2. Number of messages sent by the user
|
|
||||||
3. Overall sentiment (positive, neutral, or negative)
|
|
||||||
4. Whether the conversation was escalated
|
|
||||||
5. Whether HR contact was mentioned or provided
|
|
||||||
6. The best-fitting category for the conversation from this list:
|
|
||||||
- Schedule & Hours
|
|
||||||
- Leave & Vacation
|
|
||||||
- Sick Leave & Recovery
|
|
||||||
- Salary & Compensation
|
|
||||||
- Contract & Hours
|
|
||||||
- Onboarding
|
|
||||||
- Offboarding
|
|
||||||
- Workwear & Staff Pass
|
|
||||||
- Team & Contacts
|
|
||||||
- Personal Questions
|
|
||||||
- Access & Login
|
|
||||||
- Social questions
|
|
||||||
- Unrecognized / Other
|
|
||||||
7. Up to 5 paraphrased questions asked by the user (in English)
|
|
||||||
8. A brief summary of the conversation (10-300 characters)
|
|
||||||
|
|
||||||
Return the data in JSON format matching this schema:
|
|
||||||
{
|
|
||||||
"language": "ISO 639-1 code",
|
|
||||||
"messages_sent": number,
|
|
||||||
"sentiment": "positive|neutral|negative",
|
|
||||||
"escalated": boolean,
|
|
||||||
"forwarded_hr": boolean,
|
|
||||||
"category": "one of the categories listed above",
|
|
||||||
"questions": ["question 1", "question 2", ...],
|
|
||||||
"summary": "brief summary",
|
|
||||||
"session_id": "${sessionId}"
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(OPENAI_API_URL, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${OPENAI_API_KEY}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: "gpt-4-turbo",
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "system",
|
|
||||||
content: systemMessage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: transcript,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
temperature: 0.3, // Lower temperature for more consistent results
|
|
||||||
response_format: { type: "json_object" },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`OpenAI API error: ${response.status} - ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const processedData = JSON.parse(data.choices[0].message.content);
|
|
||||||
|
|
||||||
// Validate the response against our expected schema
|
|
||||||
validateOpenAIResponse(processedData);
|
|
||||||
|
|
||||||
return processedData;
|
|
||||||
} catch (error) {
|
|
||||||
process.stderr.write(`Error processing transcript with OpenAI: ${error}\n`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the OpenAI response against our expected schema
|
|
||||||
* @param {Object} data The data to validate
|
|
||||||
*/
|
|
||||||
function validateOpenAIResponse(data) {
|
|
||||||
// Check required fields
|
|
||||||
const requiredFields = [
|
|
||||||
"language",
|
|
||||||
"messages_sent",
|
|
||||||
"sentiment",
|
|
||||||
"escalated",
|
|
||||||
"forwarded_hr",
|
|
||||||
"category",
|
|
||||||
"questions",
|
|
||||||
"summary",
|
|
||||||
"session_id",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const field of requiredFields) {
|
|
||||||
if (!(field in data)) {
|
|
||||||
throw new Error(`Missing required field: ${field}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate field types
|
|
||||||
if (typeof data.language !== "string" || !/^[a-z]{2}$/.test(data.language)) {
|
|
||||||
throw new Error(
|
|
||||||
"Invalid language format. Expected ISO 639-1 code (e.g., 'en')"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof data.messages_sent !== "number" || data.messages_sent < 0) {
|
|
||||||
throw new Error("Invalid messages_sent. Expected non-negative number");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!["positive", "neutral", "negative"].includes(data.sentiment)) {
|
|
||||||
throw new Error(
|
|
||||||
"Invalid sentiment. Expected 'positive', 'neutral', or 'negative'"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof data.escalated !== "boolean") {
|
|
||||||
throw new Error("Invalid escalated. Expected boolean");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof data.forwarded_hr !== "boolean") {
|
|
||||||
throw new Error("Invalid forwarded_hr. Expected boolean");
|
|
||||||
}
|
|
||||||
|
|
||||||
const validCategories = [
|
|
||||||
"Schedule & Hours",
|
|
||||||
"Leave & Vacation",
|
|
||||||
"Sick Leave & Recovery",
|
|
||||||
"Salary & Compensation",
|
|
||||||
"Contract & Hours",
|
|
||||||
"Onboarding",
|
|
||||||
"Offboarding",
|
|
||||||
"Workwear & Staff Pass",
|
|
||||||
"Team & Contacts",
|
|
||||||
"Personal Questions",
|
|
||||||
"Access & Login",
|
|
||||||
"Social questions",
|
|
||||||
"Unrecognized / Other",
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!validCategories.includes(data.category)) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid category. Expected one of: ${validCategories.join(", ")}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(data.questions)) {
|
|
||||||
throw new Error("Invalid questions. Expected array of strings");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof data.summary !== "string" ||
|
|
||||||
data.summary.length < 10 ||
|
|
||||||
data.summary.length > 300
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
"Invalid summary. Expected string between 10-300 characters"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof data.session_id !== "string") {
|
|
||||||
throw new Error("Invalid session_id. Expected string");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process a single session
|
|
||||||
* @param {Object} session The session to process
|
|
||||||
* @returns {Promise<Object>} Result object with success/error info
|
|
||||||
*/
|
|
||||||
async function processSingleSession(session) {
|
|
||||||
if (session.messages.length === 0) {
|
|
||||||
return {
|
|
||||||
sessionId: session.id,
|
|
||||||
success: false,
|
|
||||||
error: "Session has no messages",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Convert messages back to transcript format for OpenAI processing
|
|
||||||
const transcript = session.messages
|
|
||||||
.map(
|
|
||||||
(msg) =>
|
|
||||||
`[${new Date(msg.timestamp)
|
|
||||||
.toLocaleString("en-GB", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
})
|
|
||||||
.replace(",", "")}] ${msg.role}: ${msg.content}`
|
|
||||||
)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
const processedData = await processTranscriptWithOpenAI(
|
|
||||||
session.id,
|
|
||||||
transcript
|
|
||||||
);
|
|
||||||
|
|
||||||
// Map sentiment string to float value for compatibility with existing data
|
|
||||||
const sentimentMap = {
|
|
||||||
positive: 0.8,
|
|
||||||
neutral: 0.0,
|
|
||||||
negative: -0.8,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update the session with processed data
|
|
||||||
await prisma.session.update({
|
|
||||||
where: { id: session.id },
|
|
||||||
data: {
|
|
||||||
language: processedData.language,
|
|
||||||
messagesSent: processedData.messages_sent,
|
|
||||||
sentiment: sentimentMap[processedData.sentiment] || 0,
|
|
||||||
sentimentCategory: processedData.sentiment,
|
|
||||||
escalated: processedData.escalated,
|
|
||||||
forwardedHr: processedData.forwarded_hr,
|
|
||||||
category: processedData.category,
|
|
||||||
questions: JSON.stringify(processedData.questions),
|
|
||||||
summary: processedData.summary,
|
|
||||||
processed: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId: session.id,
|
|
||||||
success: true,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
sessionId: session.id,
|
|
||||||
success: false,
|
|
||||||
error: error.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process sessions in parallel with concurrency limit
|
|
||||||
* @param {Array} sessions Array of sessions to process
|
|
||||||
* @param {number} maxConcurrency Maximum number of concurrent processing tasks
|
|
||||||
* @returns {Promise<Object>} Processing results
|
|
||||||
*/
|
|
||||||
async function processSessionsInParallel(sessions, maxConcurrency = 5) {
|
|
||||||
const results = [];
|
|
||||||
const executing = [];
|
|
||||||
|
|
||||||
for (const session of sessions) {
|
|
||||||
const promise = processSingleSession(session).then((result) => {
|
|
||||||
process.stdout.write(
|
|
||||||
result.success
|
|
||||||
? `[ProcessingScheduler] ✓ Successfully processed session ${result.sessionId}\n`
|
|
||||||
: `[ProcessingScheduler] ✗ Failed to process session ${result.sessionId}: ${result.error}\n`
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
|
|
||||||
results.push(promise);
|
|
||||||
executing.push(promise);
|
|
||||||
|
|
||||||
if (executing.length >= maxConcurrency) {
|
|
||||||
await Promise.race(executing);
|
|
||||||
executing.splice(
|
|
||||||
executing.findIndex((p) => p === promise),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.all(results);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process unprocessed sessions
|
|
||||||
* @param {number} batchSize Number of sessions to process in one batch (default: all unprocessed)
|
|
||||||
* @param {number} maxConcurrency Maximum number of concurrent processing tasks (default: 5)
|
|
||||||
*/
|
|
||||||
export async function processUnprocessedSessions(batchSize = null, maxConcurrency = 5) {
|
|
||||||
process.stdout.write(
|
|
||||||
"[ProcessingScheduler] Starting to process unprocessed sessions...\n"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Find sessions that have messages but haven't been processed
|
|
||||||
const queryOptions = {
|
|
||||||
where: {
|
|
||||||
AND: [
|
|
||||||
{ messages: { some: {} } }, // Must have messages
|
|
||||||
{ processed: false }, // Only unprocessed sessions (no longer checking for null)
|
|
||||||
],
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
messages: {
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add batch size limit if specified
|
|
||||||
if (batchSize && batchSize > 0) {
|
|
||||||
queryOptions.take = batchSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionsToProcess = await prisma.session.findMany(queryOptions);
|
|
||||||
|
|
||||||
// Filter to only sessions that have messages
|
|
||||||
const sessionsWithMessages = sessionsToProcess.filter(
|
|
||||||
(session) => session.messages.length > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
if (sessionsWithMessages.length === 0) {
|
|
||||||
process.stdout.write(
|
|
||||||
"[ProcessingScheduler] No sessions found requiring processing.\n"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
process.stdout.write(
|
|
||||||
`[ProcessingScheduler] Found ${sessionsWithMessages.length} sessions to process (max concurrency: ${maxConcurrency}).\n`
|
|
||||||
);
|
|
||||||
|
|
||||||
const startTime = Date.now();
|
|
||||||
const results = await processSessionsInParallel(sessionsWithMessages, maxConcurrency);
|
|
||||||
const endTime = Date.now();
|
|
||||||
|
|
||||||
const successCount = results.filter((r) => r.success).length;
|
|
||||||
const errorCount = results.filter((r) => !r.success).length;
|
|
||||||
|
|
||||||
process.stdout.write("[ProcessingScheduler] Session processing complete.\n");
|
|
||||||
process.stdout.write(
|
|
||||||
`[ProcessingScheduler] Successfully processed: ${successCount} sessions.\n`
|
|
||||||
);
|
|
||||||
process.stdout.write(
|
|
||||||
`[ProcessingScheduler] Failed to process: ${errorCount} sessions.\n`
|
|
||||||
);
|
|
||||||
process.stdout.write(
|
|
||||||
`[ProcessingScheduler] Total processing time: ${((endTime - startTime) / 1000).toFixed(2)}s\n`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the processing scheduler
|
|
||||||
*/
|
|
||||||
export function startProcessingScheduler() {
|
|
||||||
// Process unprocessed sessions every hour
|
|
||||||
cron.schedule("0 * * * *", async () => {
|
|
||||||
try {
|
|
||||||
await processUnprocessedSessions();
|
|
||||||
} catch (error) {
|
|
||||||
process.stderr.write(
|
|
||||||
`[ProcessingScheduler] Error in scheduler: ${error}\n`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
process.stdout.write(
|
|
||||||
"[ProcessingScheduler] Started processing scheduler (runs hourly).\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,24 +1,28 @@
|
|||||||
// Session processing scheduler - TypeScript version
|
// Session processing scheduler - TypeScript version
|
||||||
import cron from "node-cron";
|
// Note: Disabled due to Next.js compatibility issues
|
||||||
|
// import cron from "node-cron";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
|
import { VALID_CATEGORIES, ValidCategory, SentimentCategory } from "./types";
|
||||||
|
|
||||||
// Load environment variables from .env.local
|
// Load environment variables from .env.local
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
const envPath = join(__dirname, '..', '.env.local');
|
const envPath = join(__dirname, "..", ".env.local");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const envFile = readFileSync(envPath, 'utf8');
|
const envFile = readFileSync(envPath, "utf8");
|
||||||
const envVars = envFile.split('\n').filter(line => line.trim() && !line.startsWith('#'));
|
const envVars = envFile
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim() && !line.startsWith("#"));
|
||||||
|
|
||||||
envVars.forEach(line => {
|
envVars.forEach((line) => {
|
||||||
const [key, ...valueParts] = line.split('=');
|
const [key, ...valueParts] = line.split("=");
|
||||||
if (key && valueParts.length > 0) {
|
if (key && valueParts.length > 0) {
|
||||||
const value = valueParts.join('=').trim();
|
const value = valueParts.join("=").trim();
|
||||||
if (!process.env[key.trim()]) {
|
if (!process.env[key.trim()]) {
|
||||||
process.env[key.trim()] = value;
|
process.env[key.trim()] = value;
|
||||||
}
|
}
|
||||||
@ -34,14 +38,14 @@ const OPENAI_API_URL = "https://api.openai.com/v1/chat/completions";
|
|||||||
|
|
||||||
interface ProcessedData {
|
interface ProcessedData {
|
||||||
language: string;
|
language: string;
|
||||||
messages_sent: number;
|
|
||||||
sentiment: "positive" | "neutral" | "negative";
|
sentiment: "positive" | "neutral" | "negative";
|
||||||
escalated: boolean;
|
escalated: boolean;
|
||||||
forwarded_hr: boolean;
|
forwarded_hr: boolean;
|
||||||
category: string;
|
category: ValidCategory;
|
||||||
questions: string[];
|
questions: string | string[];
|
||||||
summary: string;
|
summary: string;
|
||||||
session_id: string;
|
tokens: number;
|
||||||
|
tokens_eur: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProcessingResult {
|
interface ProcessingResult {
|
||||||
@ -53,49 +57,50 @@ interface ProcessingResult {
|
|||||||
/**
|
/**
|
||||||
* Processes a session transcript using OpenAI API
|
* Processes a session transcript using OpenAI API
|
||||||
*/
|
*/
|
||||||
async function processTranscriptWithOpenAI(sessionId: string, transcript: string): Promise<ProcessedData> {
|
async function processTranscriptWithOpenAI(
|
||||||
|
sessionId: string,
|
||||||
|
transcript: string
|
||||||
|
): Promise<ProcessedData> {
|
||||||
if (!OPENAI_API_KEY) {
|
if (!OPENAI_API_KEY) {
|
||||||
throw new Error("OPENAI_API_KEY environment variable is not set");
|
throw new Error("OPENAI_API_KEY environment variable is not set");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a system message with instructions
|
// Create a system message with instructions
|
||||||
const systemMessage = `
|
const systemMessage = `
|
||||||
You are an AI assistant tasked with analyzing chat transcripts.
|
System: You are a JSON-generating assistant. Your task is to analyze raw chat transcripts between a user and an assistant and return structured data.
|
||||||
Extract the following information from the transcript:
|
|
||||||
1. The primary language used by the user (ISO 639-1 code)
|
⚠️ IMPORTANT:
|
||||||
2. Number of messages sent by the user
|
- You must return a **single, valid JSON object**.
|
||||||
3. Overall sentiment (positive, neutral, or negative)
|
- Do **not** include markdown formatting, code fences, explanations, or comments.
|
||||||
4. Whether the conversation was escalated
|
- The JSON must match the exact structure and constraints described below.
|
||||||
5. Whether HR contact was mentioned or provided
|
|
||||||
6. The best-fitting category for the conversation from this list:
|
Here is the schema you must follow:
|
||||||
- Schedule & Hours
|
|
||||||
- Leave & Vacation
|
{{
|
||||||
- Sick Leave & Recovery
|
"language": "ISO 639-1 code, e.g., 'en', 'nl'",
|
||||||
- Salary & Compensation
|
"sentiment": "'positive', 'neutral', or 'negative'",
|
||||||
- Contract & Hours
|
"escalated": "bool: true if the assistant connected or referred to a human agent, otherwise false",
|
||||||
- Onboarding
|
"forwarded_hr": "bool: true if HR contact info was given, otherwise false",
|
||||||
- Offboarding
|
"category": "one of: 'Schedule & Hours', 'Leave & Vacation', 'Sick Leave & Recovery', 'Salary & Compensation', 'Contract & Hours', 'Onboarding', 'Offboarding', 'Workwear & Staff Pass', 'Team & Contacts', 'Personal Questions', 'Access & Login', 'Social questions', 'Unrecognized / Other'",
|
||||||
- Workwear & Staff Pass
|
"questions": "a single question or an array of simplified questions asked by the user formulated in English, try to make a question out of messages",
|
||||||
- Team & Contacts
|
"summary": "Brief summary (1–2 sentences) of the conversation",
|
||||||
- Personal Questions
|
"tokens": "integer, number of tokens used for the API call",
|
||||||
- Access & Login
|
"tokens_eur": "float, cost of the API call in EUR",
|
||||||
- Social questions
|
}}
|
||||||
- Unrecognized / Other
|
|
||||||
7. Up to 5 paraphrased questions asked by the user (in English)
|
You must format your output as a JSON value that adheres to a given "JSON Schema" instance.
|
||||||
8. A brief summary of the conversation (10-300 characters)
|
|
||||||
|
"JSON Schema" is a declarative language that allows you to annotate and validate JSON documents.
|
||||||
Return the data in JSON format matching this schema:
|
|
||||||
{
|
For example, the example "JSON Schema" instance {"properties": {"foo": {"description": "a list of test words", "type": "array", "items": {"type": "string"}}}}, "required": ["foo"]}}
|
||||||
"language": "ISO 639-1 code",
|
would match an object with one required property, "foo". The "type" property specifies "foo" must be an "array", and the "description" property semantically describes it as "a list of test words". The items within "foo" must be strings.
|
||||||
"messages_sent": number,
|
Thus, the object {"foo": ["bar", "baz"]} is a well-formatted instance of this example "JSON Schema". The object {"properties": {"foo": ["bar", "baz"]}} is not well-formatted.
|
||||||
"sentiment": "positive|neutral|negative",
|
|
||||||
"escalated": boolean,
|
Your output will be parsed and type-checked according to the provided schema instance, so make sure all fields in your output match the schema exactly and there are no trailing commas!
|
||||||
"forwarded_hr": boolean,
|
|
||||||
"category": "one of the categories listed above",
|
Here is the JSON Schema instance your output must adhere to. Include the enclosing markdown codeblock:
|
||||||
"questions": ["question 1", "question 2", ...],
|
|
||||||
"summary": "brief summary",
|
{{"type":"object","properties":{"language":{"type":"string","pattern":"^[a-z]{2}$","description":"ISO 639-1 code for the user's primary language"},"sentiment":{"type":"string","enum":["positive","neutral","negative"],"description":"Overall tone of the user during the conversation"},"escalated":{"type":"boolean","description":"Whether the assistant indicated it could not help"},"forwarded_hr":{"type":"boolean","description":"Whether HR contact was mentioned or provided"},"category":{"type":"string","enum":["Schedule & Hours","Leave & Vacation","Sick Leave & Recovery","Salary & Compensation","Contract & Hours","Onboarding","Offboarding","Workwear & Staff Pass","Team & Contacts","Personal Questions","Access & Login","Social questions","Unrecognized / Other"],"description":"Best-fitting topic category for the conversation"},"questions":{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}],"description":"A single question or a list of paraphrased questions asked by the user in English"},"summary":{"type":"string","minLength":10,"maxLength":300,"description":"Brief summary of the conversation"},"tokens":{"type":"integer","description":"Number of tokens used for the API call"},"tokens_eur":{"type":"number","description":"Cost of the API call in EUR"}},"required":["language","sentiment","escalated","forwarded_hr","category","questions","summary","tokens","tokens_eur"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}}
|
||||||
"session_id": "${sessionId}"
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -147,14 +152,14 @@ function validateOpenAIResponse(data: any): void {
|
|||||||
// Check required fields
|
// Check required fields
|
||||||
const requiredFields = [
|
const requiredFields = [
|
||||||
"language",
|
"language",
|
||||||
"messages_sent",
|
|
||||||
"sentiment",
|
"sentiment",
|
||||||
"escalated",
|
"escalated",
|
||||||
"forwarded_hr",
|
"forwarded_hr",
|
||||||
"category",
|
"category",
|
||||||
"questions",
|
"questions",
|
||||||
"summary",
|
"summary",
|
||||||
"session_id",
|
"tokens",
|
||||||
|
"tokens_eur",
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const field of requiredFields) {
|
for (const field of requiredFields) {
|
||||||
@ -170,10 +175,6 @@ function validateOpenAIResponse(data: any): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof data.messages_sent !== "number" || data.messages_sent < 0) {
|
|
||||||
throw new Error("Invalid messages_sent. Expected non-negative number");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!["positive", "neutral", "negative"].includes(data.sentiment)) {
|
if (!["positive", "neutral", "negative"].includes(data.sentiment)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid sentiment. Expected 'positive', 'neutral', or 'negative'"
|
"Invalid sentiment. Expected 'positive', 'neutral', or 'negative'"
|
||||||
@ -188,30 +189,14 @@ function validateOpenAIResponse(data: any): void {
|
|||||||
throw new Error("Invalid forwarded_hr. Expected boolean");
|
throw new Error("Invalid forwarded_hr. Expected boolean");
|
||||||
}
|
}
|
||||||
|
|
||||||
const validCategories = [
|
if (!VALID_CATEGORIES.includes(data.category)) {
|
||||||
"Schedule & Hours",
|
|
||||||
"Leave & Vacation",
|
|
||||||
"Sick Leave & Recovery",
|
|
||||||
"Salary & Compensation",
|
|
||||||
"Contract & Hours",
|
|
||||||
"Onboarding",
|
|
||||||
"Offboarding",
|
|
||||||
"Workwear & Staff Pass",
|
|
||||||
"Team & Contacts",
|
|
||||||
"Personal Questions",
|
|
||||||
"Access & Login",
|
|
||||||
"Social questions",
|
|
||||||
"Unrecognized / Other",
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!validCategories.includes(data.category)) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid category. Expected one of: ${validCategories.join(", ")}`
|
`Invalid category. Expected one of: ${VALID_CATEGORIES.join(", ")}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(data.questions)) {
|
if (typeof data.questions !== "string" && !Array.isArray(data.questions)) {
|
||||||
throw new Error("Invalid questions. Expected array of strings");
|
throw new Error("Invalid questions. Expected string or array of strings");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -224,8 +209,12 @@ function validateOpenAIResponse(data: any): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof data.session_id !== "string") {
|
if (typeof data.tokens !== "number" || data.tokens < 0) {
|
||||||
throw new Error("Invalid session_id. Expected string");
|
throw new Error("Invalid tokens. Expected non-negative number");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.tokens_eur !== "number" || data.tokens_eur < 0) {
|
||||||
|
throw new Error("Invalid tokens_eur. Expected non-negative number");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -241,6 +230,28 @@ async function processSingleSession(session: any): Promise<ProcessingResult> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for minimum data quality requirements
|
||||||
|
const userMessages = session.messages.filter((msg: any) =>
|
||||||
|
msg.role.toLowerCase() === 'user' || msg.role.toLowerCase() === 'human'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userMessages.length === 0) {
|
||||||
|
// Mark as invalid data - no user interaction
|
||||||
|
await prisma.session.update({
|
||||||
|
where: { id: session.id },
|
||||||
|
data: {
|
||||||
|
processed: true,
|
||||||
|
summary: "No user messages found - marked as invalid data",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: session.id,
|
||||||
|
success: true,
|
||||||
|
error: "No user messages - marked as invalid data",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Convert messages back to transcript format for OpenAI processing
|
// Convert messages back to transcript format for OpenAI processing
|
||||||
const transcript = session.messages
|
const transcript = session.messages
|
||||||
@ -264,30 +275,42 @@ async function processSingleSession(session: any): Promise<ProcessingResult> {
|
|||||||
transcript
|
transcript
|
||||||
);
|
);
|
||||||
|
|
||||||
// Map sentiment string to float value for compatibility with existing data
|
// Check if the processed data indicates low quality (empty questions, very short summary, etc.)
|
||||||
const sentimentMap = {
|
const hasValidQuestions =
|
||||||
positive: 0.8,
|
processedData.questions &&
|
||||||
neutral: 0.0,
|
(Array.isArray(processedData.questions)
|
||||||
negative: -0.8,
|
? processedData.questions.length > 0
|
||||||
};
|
: typeof processedData.questions === "string");
|
||||||
|
const hasValidSummary = processedData.summary && processedData.summary.length >= 10;
|
||||||
|
const isValidData = hasValidQuestions && hasValidSummary;
|
||||||
|
|
||||||
// Update the session with processed data
|
// Update the session with processed data
|
||||||
await prisma.session.update({
|
await prisma.session.update({
|
||||||
where: { id: session.id },
|
where: { id: session.id },
|
||||||
data: {
|
data: {
|
||||||
language: processedData.language,
|
language: processedData.language,
|
||||||
messagesSent: processedData.messages_sent,
|
sentiment: processedData.sentiment,
|
||||||
sentiment: sentimentMap[processedData.sentiment] || 0,
|
|
||||||
sentimentCategory: processedData.sentiment,
|
|
||||||
escalated: processedData.escalated,
|
escalated: processedData.escalated,
|
||||||
forwardedHr: processedData.forwarded_hr,
|
forwardedHr: processedData.forwarded_hr,
|
||||||
category: processedData.category,
|
category: processedData.category,
|
||||||
questions: JSON.stringify(processedData.questions),
|
questions: processedData.questions,
|
||||||
summary: processedData.summary,
|
summary: processedData.summary,
|
||||||
|
tokens: {
|
||||||
|
increment: processedData.tokens,
|
||||||
|
},
|
||||||
|
tokensEur: {
|
||||||
|
increment: processedData.tokens_eur,
|
||||||
|
},
|
||||||
processed: true,
|
processed: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!isValidData) {
|
||||||
|
process.stdout.write(
|
||||||
|
`[ProcessingScheduler] ⚠️ Session ${session.id} marked as invalid data (empty questions or short summary)\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
success: true,
|
success: true,
|
||||||
@ -304,7 +327,10 @@ async function processSingleSession(session: any): Promise<ProcessingResult> {
|
|||||||
/**
|
/**
|
||||||
* Process sessions in parallel with concurrency limit
|
* Process sessions in parallel with concurrency limit
|
||||||
*/
|
*/
|
||||||
async function processSessionsInParallel(sessions: any[], maxConcurrency: number = 5): Promise<ProcessingResult[]> {
|
async function processSessionsInParallel(
|
||||||
|
sessions: any[],
|
||||||
|
maxConcurrency: number = 5
|
||||||
|
): Promise<ProcessingResult[]> {
|
||||||
const results: Promise<ProcessingResult>[] = [];
|
const results: Promise<ProcessingResult>[] = [];
|
||||||
const executing: Promise<ProcessingResult>[] = [];
|
const executing: Promise<ProcessingResult>[] = [];
|
||||||
|
|
||||||
@ -323,7 +349,7 @@ async function processSessionsInParallel(sessions: any[], maxConcurrency: number
|
|||||||
|
|
||||||
if (executing.length >= maxConcurrency) {
|
if (executing.length >= maxConcurrency) {
|
||||||
await Promise.race(executing);
|
await Promise.race(executing);
|
||||||
const completedIndex = executing.findIndex(p => p === promise);
|
const completedIndex = executing.findIndex((p) => p === promise);
|
||||||
if (completedIndex !== -1) {
|
if (completedIndex !== -1) {
|
||||||
executing.splice(completedIndex, 1);
|
executing.splice(completedIndex, 1);
|
||||||
}
|
}
|
||||||
@ -334,75 +360,104 @@ async function processSessionsInParallel(sessions: any[], maxConcurrency: number
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process unprocessed sessions
|
* Process unprocessed sessions in batches until completion
|
||||||
*/
|
*/
|
||||||
export async function processUnprocessedSessions(batchSize: number | null = null, maxConcurrency: number = 5): Promise<void> {
|
export async function processUnprocessedSessions(
|
||||||
|
batchSize: number = 10,
|
||||||
|
maxConcurrency: number = 5
|
||||||
|
): Promise<{ totalProcessed: number; totalFailed: number; totalTime: number }> {
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
"[ProcessingScheduler] Starting to process unprocessed sessions...\n"
|
"[ProcessingScheduler] Starting complete processing of all unprocessed sessions...\n"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Find sessions that have messages but haven't been processed
|
let totalProcessed = 0;
|
||||||
const queryOptions: any = {
|
let totalFailed = 0;
|
||||||
where: {
|
const overallStartTime = Date.now();
|
||||||
AND: [
|
let batchNumber = 1;
|
||||||
{ messages: { some: {} } }, // Must have messages
|
|
||||||
{ processed: false }, // Only unprocessed sessions
|
while (true) {
|
||||||
],
|
// Find sessions that have messages but haven't been processed
|
||||||
},
|
const sessionsToProcess = await prisma.session.findMany({
|
||||||
include: {
|
where: {
|
||||||
messages: {
|
AND: [
|
||||||
orderBy: { order: "asc" },
|
{ messages: { some: {} } }, // Must have messages
|
||||||
|
{ processed: false }, // Only unprocessed sessions
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
include: {
|
||||||
};
|
messages: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
take: batchSize,
|
||||||
|
});
|
||||||
|
|
||||||
// Add batch size limit if specified
|
// Filter to only sessions that have messages
|
||||||
if (batchSize && batchSize > 0) {
|
const sessionsWithMessages = sessionsToProcess.filter(
|
||||||
queryOptions.take = batchSize;
|
(session: any) => session.messages && session.messages.length > 0
|
||||||
}
|
|
||||||
|
|
||||||
const sessionsToProcess = await prisma.session.findMany(queryOptions);
|
|
||||||
|
|
||||||
// Filter to only sessions that have messages
|
|
||||||
const sessionsWithMessages = sessionsToProcess.filter(
|
|
||||||
(session: any) => session.messages && session.messages.length > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
if (sessionsWithMessages.length === 0) {
|
|
||||||
process.stdout.write(
|
|
||||||
"[ProcessingScheduler] No sessions found requiring processing.\n"
|
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
|
if (sessionsWithMessages.length === 0) {
|
||||||
|
process.stdout.write(
|
||||||
|
"[ProcessingScheduler] ✅ All sessions with messages have been processed!\n"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(
|
||||||
|
`[ProcessingScheduler] 📦 Batch ${batchNumber}: Processing ${sessionsWithMessages.length} sessions (max concurrency: ${maxConcurrency})...\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
const batchStartTime = Date.now();
|
||||||
|
const results = await processSessionsInParallel(
|
||||||
|
sessionsWithMessages,
|
||||||
|
maxConcurrency
|
||||||
|
);
|
||||||
|
const batchEndTime = Date.now();
|
||||||
|
|
||||||
|
const batchSuccessCount = results.filter((r) => r.success).length;
|
||||||
|
const batchErrorCount = results.filter((r) => !r.success).length;
|
||||||
|
|
||||||
|
totalProcessed += batchSuccessCount;
|
||||||
|
totalFailed += batchErrorCount;
|
||||||
|
|
||||||
|
process.stdout.write(
|
||||||
|
`[ProcessingScheduler] 📦 Batch ${batchNumber} complete: ${batchSuccessCount} success, ${batchErrorCount} failed (${((batchEndTime - batchStartTime) / 1000).toFixed(2)}s)\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
batchNumber++;
|
||||||
|
|
||||||
|
// Small delay between batches to prevent overwhelming the system
|
||||||
|
if (sessionsWithMessages.length === batchSize) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const overallEndTime = Date.now();
|
||||||
|
const totalTime = (overallEndTime - overallStartTime) / 1000;
|
||||||
|
|
||||||
|
process.stdout.write("[ProcessingScheduler] 🎉 Complete processing finished!\n");
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`[ProcessingScheduler] Found ${sessionsWithMessages.length} sessions to process (max concurrency: ${maxConcurrency}).\n`
|
`[ProcessingScheduler] 📊 Total results: ${totalProcessed} processed, ${totalFailed} failed\n`
|
||||||
|
);
|
||||||
|
process.stdout.write(
|
||||||
|
`[ProcessingScheduler] ⏱️ Total processing time: ${totalTime.toFixed(2)}s\n`
|
||||||
);
|
);
|
||||||
|
|
||||||
const startTime = Date.now();
|
return { totalProcessed, totalFailed, totalTime };
|
||||||
const results = await processSessionsInParallel(sessionsWithMessages, maxConcurrency);
|
|
||||||
const endTime = Date.now();
|
|
||||||
|
|
||||||
const successCount = results.filter((r) => r.success).length;
|
|
||||||
const errorCount = results.filter((r) => !r.success).length;
|
|
||||||
|
|
||||||
process.stdout.write("[ProcessingScheduler] Session processing complete.\n");
|
|
||||||
process.stdout.write(
|
|
||||||
`[ProcessingScheduler] Successfully processed: ${successCount} sessions.\n`
|
|
||||||
);
|
|
||||||
process.stdout.write(
|
|
||||||
`[ProcessingScheduler] Failed to process: ${errorCount} sessions.\n`
|
|
||||||
);
|
|
||||||
process.stdout.write(
|
|
||||||
`[ProcessingScheduler] Total processing time: ${((endTime - startTime) / 1000).toFixed(2)}s\n`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the processing scheduler
|
* Start the processing scheduler
|
||||||
*/
|
*/
|
||||||
export function startProcessingScheduler(): void {
|
export function startProcessingScheduler(): void {
|
||||||
// Process unprocessed sessions every hour
|
// Note: Scheduler disabled due to Next.js compatibility issues
|
||||||
|
// Use manual triggers via API endpoints instead
|
||||||
|
console.log("Processing scheduler disabled - using manual triggers via API endpoints");
|
||||||
|
|
||||||
|
// Original cron-based implementation commented out due to Next.js compatibility issues
|
||||||
|
// The functionality is now available via the /api/admin/trigger-processing endpoint
|
||||||
|
/*
|
||||||
cron.schedule("0 * * * *", async () => {
|
cron.schedule("0 * * * *", async () => {
|
||||||
try {
|
try {
|
||||||
await processUnprocessedSessions();
|
await processUnprocessedSessions();
|
||||||
@ -416,4 +471,5 @@ export function startProcessingScheduler(): void {
|
|||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
"[ProcessingScheduler] Started processing scheduler (runs hourly).\n"
|
"[ProcessingScheduler] Started processing scheduler (runs hourly).\n"
|
||||||
);
|
);
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|||||||
447
lib/processingSchedulerNoCron.ts
Normal file
447
lib/processingSchedulerNoCron.ts
Normal file
@ -0,0 +1,447 @@
|
|||||||
|
// Session processing without cron dependency - for Next.js API routes
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import fetch from "node-fetch";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { dirname, join } from "path";
|
||||||
|
import { VALID_CATEGORIES, ValidCategory, SentimentCategory } from "./types";
|
||||||
|
|
||||||
|
// Load environment variables from .env.local
|
||||||
|
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) {
|
||||||
|
// Silently fail if .env.local doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||||
|
const OPENAI_API_URL = "https://api.openai.com/v1/chat/completions";
|
||||||
|
|
||||||
|
interface ProcessedData {
|
||||||
|
language: string;
|
||||||
|
sentiment: "positive" | "neutral" | "negative";
|
||||||
|
escalated: boolean;
|
||||||
|
forwarded_hr: boolean;
|
||||||
|
category: ValidCategory;
|
||||||
|
questions: string | string[];
|
||||||
|
summary: string;
|
||||||
|
tokens: number;
|
||||||
|
tokens_eur: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProcessingResult {
|
||||||
|
sessionId: string;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a session transcript using OpenAI API
|
||||||
|
*/
|
||||||
|
async function processTranscriptWithOpenAI(
|
||||||
|
sessionId: string,
|
||||||
|
transcript: string
|
||||||
|
): Promise<ProcessedData> {
|
||||||
|
if (!OPENAI_API_KEY) {
|
||||||
|
throw new Error("OPENAI_API_KEY environment variable is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a system message with instructions
|
||||||
|
const systemMessage = `
|
||||||
|
System: You are a JSON-generating assistant. Your task is to analyze raw chat transcripts between a user and an assistant and return structured data.
|
||||||
|
|
||||||
|
⚠️ IMPORTANT:
|
||||||
|
- You must return a **single, valid JSON object**.
|
||||||
|
- Do **not** include markdown formatting, code fences, explanations, or comments.
|
||||||
|
- The JSON must match the exact structure and constraints described below.
|
||||||
|
|
||||||
|
Here is the schema you must follow:
|
||||||
|
|
||||||
|
{
|
||||||
|
"language": "ISO 639-1 code, e.g., 'en', 'nl'",
|
||||||
|
"sentiment": "'positive', 'neutral', or 'negative'",
|
||||||
|
"escalated": "bool: true if the assistant connected or referred to a human agent, otherwise false",
|
||||||
|
"forwarded_hr": "bool: true if HR contact info was given, otherwise false",
|
||||||
|
"category": "one of: 'Schedule & Hours', 'Leave & Vacation', 'Sick Leave & Recovery', 'Salary & Compensation', 'Contract & Hours', 'Onboarding', 'Offboarding', 'Workwear & Staff Pass', 'Team & Contacts', 'Personal Questions', 'Access & Login', 'Social questions', 'Unrecognized / Other'",
|
||||||
|
"questions": "a single question or an array of simplified questions asked by the user formulated in English, try to make a question out of messages",
|
||||||
|
"summary": "Brief summary (1–2 sentences) of the conversation",
|
||||||
|
"tokens": "integer, number of tokens used for the API call",
|
||||||
|
"tokens_eur": "float, cost of the API call in EUR",
|
||||||
|
}
|
||||||
|
|
||||||
|
You must format your output as a JSON value that adheres to a given "JSON Schema" instance.
|
||||||
|
|
||||||
|
"JSON Schema" is a declarative language that allows you to annotate and validate JSON documents.
|
||||||
|
|
||||||
|
For example, the example "JSON Schema" instance {{"properties": {{"foo": {{"description": "a list of test words", "type": "array", "items": {{"type": "string"}}}}}}, "required": ["foo"]}}}}
|
||||||
|
would match an object with one required property, "foo". The "type" property specifies "foo" must be an "array", and the "description" property semantically describes it as "a list of test words". The items within "foo" must be strings.
|
||||||
|
Thus, the object {{"foo": ["bar", "baz"]}} is a well-formatted instance of this example "JSON Schema". The object {{"properties": {{"foo": ["bar", "baz"]}}}} is not well-formatted.
|
||||||
|
|
||||||
|
Your output will be parsed and type-checked according to the provided schema instance, so make sure all fields in your output match the schema exactly and there are no trailing commas!
|
||||||
|
|
||||||
|
Here is the JSON Schema instance your output must adhere to. Include the enclosing markdown codeblock:
|
||||||
|
|
||||||
|
{{"type":"object","properties":{"language":{"type":"string","pattern":"^[a-z]{2}$","description":"ISO 639-1 code for the user's primary language"},"sentiment":{"type":"string","enum":["positive","neutral","negative"],"description":"Overall tone of the user during the conversation"},"escalated":{"type":"boolean","description":"Whether the assistant indicated it could not help"},"forwarded_hr":{"type":"boolean","description":"Whether HR contact was mentioned or provided"},"category":{"type":"string","enum":["Schedule & Hours","Leave & Vacation","Sick Leave & Recovery","Salary & Compensation","Contract & Hours","Onboarding","Offboarding","Workwear & Staff Pass","Team & Contacts","Personal Questions","Access & Login","Social questions","Unrecognized / Other"],"description":"Best-fitting topic category for the conversation"},"questions":{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}],"description":"A single question or a list of paraphrased questions asked by the user in English"},"summary":{"type":"string","minLength":10,"maxLength":300,"description":"Brief summary of the conversation"},"tokens":{"type":"integer","description":"Number of tokens used for the API call"},"tokens_eur":{"type":"number","description":"Cost of the API call in EUR"}},"required":["language","sentiment","escalated","forwarded_hr","category","questions","summary","tokens","tokens_eur"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}}
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(OPENAI_API_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${OPENAI_API_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "gpt-4-turbo",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: systemMessage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: transcript,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
temperature: 0.3, // Lower temperature for more consistent results
|
||||||
|
response_format: { type: "json_object" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`OpenAI API error: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: any = await response.json();
|
||||||
|
const processedData = JSON.parse(data.choices[0].message.content);
|
||||||
|
|
||||||
|
// Validate the response against our expected schema
|
||||||
|
validateOpenAIResponse(processedData);
|
||||||
|
|
||||||
|
return processedData;
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(`Error processing transcript with OpenAI: ${error}\n`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the OpenAI response against our expected schema
|
||||||
|
*/
|
||||||
|
function validateOpenAIResponse(data: any): void {
|
||||||
|
// Check required fields
|
||||||
|
const requiredFields = [
|
||||||
|
"language",
|
||||||
|
"sentiment",
|
||||||
|
"escalated",
|
||||||
|
"forwarded_hr",
|
||||||
|
"category",
|
||||||
|
"questions",
|
||||||
|
"summary",
|
||||||
|
"tokens",
|
||||||
|
"tokens_eur",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
if (!(field in data)) {
|
||||||
|
throw new Error(`Missing required field: ${field}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate field types
|
||||||
|
if (typeof data.language !== "string" || !/^[a-z]{2}$/.test(data.language)) {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid language format. Expected ISO 639-1 code (e.g., 'en')"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!["positive", "neutral", "negative"].includes(data.sentiment)) {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid sentiment. Expected 'positive', 'neutral', or 'negative'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.escalated !== "boolean") {
|
||||||
|
throw new Error("Invalid escalated. Expected boolean");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.forwarded_hr !== "boolean") {
|
||||||
|
throw new Error("Invalid forwarded_hr. Expected boolean");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_CATEGORIES.includes(data.category)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid category. Expected one of: ${VALID_CATEGORIES.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.questions !== "string" && !Array.isArray(data.questions)) {
|
||||||
|
throw new Error("Invalid questions. Expected string or array of strings");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof data.summary !== "string" ||
|
||||||
|
data.summary.length < 10 ||
|
||||||
|
data.summary.length > 300
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid summary. Expected string between 10-300 characters"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.tokens !== "number" || data.tokens < 0) {
|
||||||
|
throw new Error("Invalid tokens. Expected non-negative number");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.tokens_eur !== "number" || data.tokens_eur < 0) {
|
||||||
|
throw new Error("Invalid tokens_eur. Expected non-negative number");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a single session
|
||||||
|
*/
|
||||||
|
async function processSingleSession(session: any): Promise<ProcessingResult> {
|
||||||
|
if (session.messages.length === 0) {
|
||||||
|
return {
|
||||||
|
sessionId: session.id,
|
||||||
|
success: false,
|
||||||
|
error: "Session has no messages",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for minimum data quality requirements
|
||||||
|
const userMessages = session.messages.filter((msg: any) =>
|
||||||
|
msg.role.toLowerCase() === 'user' || msg.role.toLowerCase() === 'human'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userMessages.length === 0) {
|
||||||
|
// Mark as invalid data - no user interaction
|
||||||
|
await prisma.session.update({
|
||||||
|
where: { id: session.id },
|
||||||
|
data: {
|
||||||
|
processed: true,
|
||||||
|
summary: "No user messages found - marked as invalid data",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: session.id,
|
||||||
|
success: true,
|
||||||
|
error: "No user messages - marked as invalid data",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert messages back to transcript format for OpenAI processing
|
||||||
|
const transcript = session.messages
|
||||||
|
.map(
|
||||||
|
(msg: any) =>
|
||||||
|
`[${new Date(msg.timestamp)
|
||||||
|
.toLocaleString("en-GB", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
})
|
||||||
|
.replace(",", "")}] ${msg.role}: ${msg.content}`
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const processedData = await processTranscriptWithOpenAI(
|
||||||
|
session.id,
|
||||||
|
transcript
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if the processed data indicates low quality (empty questions, very short summary, etc.)
|
||||||
|
const hasValidQuestions =
|
||||||
|
processedData.questions &&
|
||||||
|
(Array.isArray(processedData.questions)
|
||||||
|
? processedData.questions.length > 0
|
||||||
|
: typeof processedData.questions === "string");
|
||||||
|
const hasValidSummary = processedData.summary && processedData.summary.length >= 10;
|
||||||
|
const isValidData = hasValidQuestions && hasValidSummary;
|
||||||
|
|
||||||
|
// Update the session with processed data
|
||||||
|
await prisma.session.update({
|
||||||
|
where: { id: session.id },
|
||||||
|
data: {
|
||||||
|
language: processedData.language,
|
||||||
|
sentiment: processedData.sentiment,
|
||||||
|
escalated: processedData.escalated,
|
||||||
|
forwardedHr: processedData.forwarded_hr,
|
||||||
|
category: processedData.category,
|
||||||
|
questions: processedData.questions,
|
||||||
|
summary: processedData.summary,
|
||||||
|
tokens: {
|
||||||
|
increment: processedData.tokens,
|
||||||
|
},
|
||||||
|
tokensEur: {
|
||||||
|
increment: processedData.tokens_eur,
|
||||||
|
},
|
||||||
|
processed: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isValidData) {
|
||||||
|
process.stdout.write(
|
||||||
|
`[ProcessingScheduler] ⚠️ Session ${session.id} marked as invalid data (empty questions or short summary)\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: session.id,
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
sessionId: session.id,
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process sessions in parallel with concurrency limit
|
||||||
|
*/
|
||||||
|
async function processSessionsInParallel(
|
||||||
|
sessions: any[],
|
||||||
|
maxConcurrency: number = 5
|
||||||
|
): Promise<ProcessingResult[]> {
|
||||||
|
const results: Promise<ProcessingResult>[] = [];
|
||||||
|
const executing: Promise<ProcessingResult>[] = [];
|
||||||
|
|
||||||
|
for (const session of sessions) {
|
||||||
|
const promise = processSingleSession(session).then((result) => {
|
||||||
|
process.stdout.write(
|
||||||
|
result.success
|
||||||
|
? `[ProcessingScheduler] ✓ Successfully processed session ${result.sessionId}\n`
|
||||||
|
: `[ProcessingScheduler] ✗ Failed to process session ${result.sessionId}: ${result.error}\n`
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
results.push(promise);
|
||||||
|
executing.push(promise);
|
||||||
|
|
||||||
|
if (executing.length >= maxConcurrency) {
|
||||||
|
await Promise.race(executing);
|
||||||
|
const completedIndex = executing.findIndex((p) => p === promise);
|
||||||
|
if (completedIndex !== -1) {
|
||||||
|
executing.splice(completedIndex, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process unprocessed sessions in batches until completion
|
||||||
|
*/
|
||||||
|
export async function processUnprocessedSessions(
|
||||||
|
batchSize: number = 10,
|
||||||
|
maxConcurrency: number = 5
|
||||||
|
): Promise<{ totalProcessed: number; totalFailed: number; totalTime: number }> {
|
||||||
|
process.stdout.write(
|
||||||
|
"[ProcessingScheduler] Starting complete processing of all unprocessed sessions...\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
let totalProcessed = 0;
|
||||||
|
let totalFailed = 0;
|
||||||
|
const overallStartTime = Date.now();
|
||||||
|
let batchNumber = 1;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
// Find sessions that have messages but haven't been processed
|
||||||
|
const sessionsToProcess = await prisma.session.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{ messages: { some: {} } }, // Must have messages
|
||||||
|
{ processed: false }, // Only unprocessed sessions
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
messages: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
take: batchSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter to only sessions that have messages
|
||||||
|
const sessionsWithMessages = sessionsToProcess.filter(
|
||||||
|
(session: any) => session.messages && session.messages.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sessionsWithMessages.length === 0) {
|
||||||
|
process.stdout.write(
|
||||||
|
"[ProcessingScheduler] ✅ All sessions with messages have been processed!\n"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(
|
||||||
|
`[ProcessingScheduler] 📦 Batch ${batchNumber}: Processing ${sessionsWithMessages.length} sessions (max concurrency: ${maxConcurrency})...\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
const batchStartTime = Date.now();
|
||||||
|
const results = await processSessionsInParallel(
|
||||||
|
sessionsWithMessages,
|
||||||
|
maxConcurrency
|
||||||
|
);
|
||||||
|
const batchEndTime = Date.now();
|
||||||
|
|
||||||
|
const batchSuccessCount = results.filter((r) => r.success).length;
|
||||||
|
const batchErrorCount = results.filter((r) => !r.success).length;
|
||||||
|
|
||||||
|
totalProcessed += batchSuccessCount;
|
||||||
|
totalFailed += batchErrorCount;
|
||||||
|
|
||||||
|
process.stdout.write(
|
||||||
|
`[ProcessingScheduler] 📦 Batch ${batchNumber} complete: ${batchSuccessCount} success, ${batchErrorCount} failed (${((batchEndTime - batchStartTime) / 1000).toFixed(2)}s)\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
batchNumber++;
|
||||||
|
|
||||||
|
// Small delay between batches to prevent overwhelming the system
|
||||||
|
if (sessionsWithMessages.length === batchSize) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overallEndTime = Date.now();
|
||||||
|
const totalTime = (overallEndTime - overallStartTime) / 1000;
|
||||||
|
|
||||||
|
process.stdout.write("[ProcessingScheduler] 🎉 Complete processing finished!\n");
|
||||||
|
process.stdout.write(
|
||||||
|
`[ProcessingScheduler] 📊 Total results: ${totalProcessed} processed, ${totalFailed} failed\n`
|
||||||
|
);
|
||||||
|
process.stdout.write(
|
||||||
|
`[ProcessingScheduler] ⏱️ Total processing time: ${totalTime.toFixed(2)}s\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
return { totalProcessed, totalFailed, totalTime };
|
||||||
|
}
|
||||||
@ -1,37 +0,0 @@
|
|||||||
// Session refresh scheduler - JavaScript version
|
|
||||||
import cron from "node-cron";
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import { fetchAndStoreSessionsForAllCompanies } from "./csvFetcher.js";
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh sessions for all companies
|
|
||||||
*/
|
|
||||||
async function refreshSessions() {
|
|
||||||
console.log("[Scheduler] Starting session refresh...");
|
|
||||||
try {
|
|
||||||
await fetchAndStoreSessionsForAllCompanies();
|
|
||||||
console.log("[Scheduler] Session refresh completed successfully.");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Scheduler] Error during session refresh:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the session refresh scheduler
|
|
||||||
*/
|
|
||||||
export function startScheduler() {
|
|
||||||
// Run every 15 minutes
|
|
||||||
cron.schedule("*/15 * * * *", async () => {
|
|
||||||
try {
|
|
||||||
await refreshSessions();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Scheduler] Error in scheduler:", error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
"[Scheduler] Started session refresh scheduler (runs every 15 minutes)."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,5 +1,6 @@
|
|||||||
// node-cron job to auto-refresh session data every 15 mins
|
// node-cron job to auto-refresh session data every 15 mins
|
||||||
import cron from "node-cron";
|
// Note: Disabled due to Next.js compatibility issues
|
||||||
|
// import cron from "node-cron";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
import { fetchAndParseCsv } from "./csvFetcher";
|
import { fetchAndParseCsv } from "./csvFetcher";
|
||||||
|
|
||||||
@ -11,68 +12,10 @@ interface SessionCreateData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function startScheduler() {
|
export function startScheduler() {
|
||||||
cron.schedule("*/15 * * * *", async () => {
|
// Note: Scheduler disabled due to Next.js compatibility issues
|
||||||
const companies = await prisma.company.findMany();
|
// Use manual triggers via API endpoints instead
|
||||||
for (const company of companies) {
|
console.log("Session refresh scheduler disabled - using manual triggers via API endpoints");
|
||||||
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()}`,
|
|
||||||
// Ensure startTime is not undefined
|
|
||||||
startTime: session.startTime || new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if the session already exists
|
// Original cron-based implementation commented out due to Next.js compatibility issues
|
||||||
const existingSession = await prisma.session.findUnique({
|
// The functionality is now available via the /api/admin/refresh-sessions endpoint
|
||||||
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: 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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Using process.stdout.write instead of console.log to avoid ESLint warning
|
|
||||||
process.stdout.write(
|
|
||||||
`[Scheduler] Refreshed sessions for company: ${company.name}\n`
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
// Using process.stderr.write instead of console.error to avoid ESLint warning
|
|
||||||
process.stderr.write(
|
|
||||||
`[Scheduler] Failed for company: ${company.name} - ${e}\n`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
// Combined scheduler initialization
|
// Combined scheduler initialization
|
||||||
import { startScheduler } from "./scheduler";
|
// Note: Removed cron-based scheduler imports to avoid Next.js compatibility issues
|
||||||
import { startProcessingScheduler } from "./processingScheduler";
|
// import { startScheduler } from "./scheduler";
|
||||||
|
// import { startProcessingScheduler } from "./processingScheduler";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize all schedulers
|
* Initialize all schedulers
|
||||||
@ -8,11 +9,9 @@ import { startProcessingScheduler } from "./processingScheduler";
|
|||||||
* - Session processing scheduler (runs every hour)
|
* - Session processing scheduler (runs every hour)
|
||||||
*/
|
*/
|
||||||
export function initializeSchedulers() {
|
export function initializeSchedulers() {
|
||||||
// Start the session refresh scheduler
|
// Note: All schedulers disabled due to Next.js compatibility issues
|
||||||
startScheduler();
|
// Use manual triggers via API endpoints instead
|
||||||
|
console.log("Schedulers disabled - using manual triggers via API endpoints");
|
||||||
// Start the session processing scheduler
|
// startScheduler();
|
||||||
startProcessingScheduler();
|
// startProcessingScheduler();
|
||||||
|
|
||||||
console.log("All schedulers initialized successfully");
|
|
||||||
}
|
}
|
||||||
|
|||||||
98
lib/session-service.ts
Normal file
98
lib/session-service.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -102,7 +102,9 @@ export async function storeMessagesForSession(sessionId, messages) {
|
|||||||
|
|
||||||
// Extract actual end time from the latest message
|
// Extract actual end time from the latest message
|
||||||
const latestMessage = messages.reduce((latest, current) => {
|
const latestMessage = messages.reduce((latest, current) => {
|
||||||
return new Date(current.timestamp) > new Date(latest.timestamp) ? current : latest;
|
return new Date(current.timestamp) > new Date(latest.timestamp)
|
||||||
|
? current
|
||||||
|
: latest;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update the session's endTime with the actual conversation end time
|
// Update the session's endTime with the actual conversation end time
|
||||||
|
|||||||
25
lib/types.ts
25
lib/types.ts
@ -1,5 +1,26 @@
|
|||||||
import { Session as NextAuthSession } from "next-auth";
|
import { Session as NextAuthSession } from "next-auth";
|
||||||
|
|
||||||
|
// Standardized enums
|
||||||
|
export type SentimentCategory = "positive" | "neutral" | "negative";
|
||||||
|
|
||||||
|
export const VALID_CATEGORIES = [
|
||||||
|
"Schedule & Hours",
|
||||||
|
"Leave & Vacation",
|
||||||
|
"Sick Leave & Recovery",
|
||||||
|
"Salary & Compensation",
|
||||||
|
"Contract & Hours",
|
||||||
|
"Onboarding",
|
||||||
|
"Offboarding",
|
||||||
|
"Workwear & Staff Pass",
|
||||||
|
"Team & Contacts",
|
||||||
|
"Personal Questions",
|
||||||
|
"Access & Login",
|
||||||
|
"Social questions",
|
||||||
|
"Unrecognized / Other",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ValidCategory = (typeof VALID_CATEGORIES)[number];
|
||||||
|
|
||||||
export interface UserSession extends NextAuthSession {
|
export interface UserSession extends NextAuthSession {
|
||||||
user: {
|
user: {
|
||||||
id?: string;
|
id?: string;
|
||||||
@ -54,8 +75,7 @@ export interface ChatSession {
|
|||||||
language?: string | null;
|
language?: string | null;
|
||||||
country?: string | null;
|
country?: string | null;
|
||||||
ipAddress?: string | null;
|
ipAddress?: string | null;
|
||||||
sentiment?: number | null;
|
sentiment?: string | null;
|
||||||
sentimentCategory?: string | null; // "positive", "neutral", "negative" from OpenAPI
|
|
||||||
messagesSent?: number;
|
messagesSent?: number;
|
||||||
startTime: Date;
|
startTime: Date;
|
||||||
endTime?: Date | null;
|
endTime?: Date | null;
|
||||||
@ -71,6 +91,7 @@ export interface ChatSession {
|
|||||||
initialMsg?: string;
|
initialMsg?: string;
|
||||||
fullTranscriptUrl?: string | null;
|
fullTranscriptUrl?: string | null;
|
||||||
processed?: boolean | null; // Flag for post-processing status
|
processed?: boolean | null; // Flag for post-processing status
|
||||||
|
validData?: boolean | null; // Flag for data quality (false = exclude from analytics)
|
||||||
questions?: string | null; // JSON array of questions asked by user
|
questions?: string | null; // JSON array of questions asked by user
|
||||||
summary?: string | null; // Brief summary of the conversation
|
summary?: string | null; // Brief summary of the conversation
|
||||||
messages?: Message[]; // Parsed messages from transcript
|
messages?: Message[]; // Parsed messages from transcript
|
||||||
|
|||||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal 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
1
lib/workflow.ts
Normal 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; }}
|
||||||
70
migration.sql
Normal file
70
migration.sql
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
-- 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" TEXT,
|
||||||
|
"escalated" BOOLEAN,
|
||||||
|
"forwardedHr" BOOLEAN,
|
||||||
|
"fullTranscriptUrl" TEXT,
|
||||||
|
"avgResponseTime" REAL,
|
||||||
|
"tokens" INTEGER,
|
||||||
|
"tokensEur" REAL,
|
||||||
|
"category" TEXT,
|
||||||
|
"initialMsg" TEXT,
|
||||||
|
"processed" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"validData" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"questions" JSONB,
|
||||||
|
"summary" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "Session_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Message" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"sessionId" TEXT NOT NULL,
|
||||||
|
"timestamp" DATETIME NOT NULL,
|
||||||
|
"role" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"order" INTEGER NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "Message_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Message_sessionId_order_idx" ON "Message"("sessionId", "order");
|
||||||
|
|
||||||
@ -5,11 +5,24 @@ const nextConfig = {
|
|||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
// Allow cross-origin requests from specific origins in development
|
// Allow cross-origin requests from specific origins in development
|
||||||
allowedDevOrigins: [
|
allowedDevOrigins: [
|
||||||
"192.168.1.2",
|
"127.0.0.1",
|
||||||
"localhost",
|
"localhost"
|
||||||
"propc",
|
|
||||||
"test123.kjanat.com",
|
|
||||||
],
|
],
|
||||||
|
// 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;
|
export default nextConfig;
|
||||||
|
|||||||
1685
package-lock.json
generated
1685
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@ -5,18 +5,18 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev",
|
||||||
"dev:with-schedulers": "node server.mjs",
|
"dev:with-server": "tsx server.ts",
|
||||||
"format": "npx prettier --write .",
|
"format": "npx prettier --write .",
|
||||||
"format:check": "npx prettier --check .",
|
"format:check": "npx prettier --check .",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"lint:fix": "npx eslint --fix",
|
"lint:fix": "npx eslint --fix .",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:seed": "node prisma/seed.mjs",
|
"prisma:seed": "tsx prisma/seed.ts",
|
||||||
"prisma:push": "prisma db push",
|
"prisma:push": "prisma db push",
|
||||||
"prisma:studio": "prisma studio",
|
"prisma:studio": "prisma studio",
|
||||||
"start": "node server.mjs",
|
"start": "tsx server.ts",
|
||||||
"lint:md": "markdownlint-cli2 \"**/*.md\" \"!.trunk/**\" \"!.venv/**\" \"!node_modules/**\"",
|
"lint:md": "markdownlint-cli2 \"**/*.md\" \"!.trunk/**\" \"!.venv/**\" \"!node_modules/**\"",
|
||||||
"lint:md:fix": "markdownlint-cli2 --fix \"**/*.md\" \"!.trunk/**\" \"!.venv/**\" \"!node_modules/**\""
|
"lint:md:fix": "markdownlint-cli2 --fix \"**/*.md\" \"!.trunk/**\" \"!.venv/**\" \"!node_modules/**\""
|
||||||
},
|
},
|
||||||
@ -31,22 +31,28 @@
|
|||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"chart.js": "^4.0.0",
|
"chart.js": "^4.0.0",
|
||||||
"chartjs-plugin-annotation": "^3.1.0",
|
"chartjs-plugin-annotation": "^3.1.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"csv-parse": "^5.5.0",
|
"csv-parse": "^5.5.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"d3-cloud": "^1.2.7",
|
"d3-cloud": "^1.2.7",
|
||||||
"i18n-iso-countries": "^7.14.0",
|
"i18n-iso-countries": "^7.14.0",
|
||||||
"iso-639-1": "^3.1.5",
|
"iso-639-1": "^3.1.5",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"next": "^15.3.2",
|
"lucide-react": "^0.523.0",
|
||||||
|
"next": "^15.3.4",
|
||||||
"next-auth": "^4.24.11",
|
"next-auth": "^4.24.11",
|
||||||
"node-cron": "^4.0.7",
|
"node-cron": "^4.0.7",
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-chartjs-2": "^5.0.0",
|
"react-chartjs-2": "^5.0.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-markdown": "^10.1.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": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
@ -70,6 +76,8 @@
|
|||||||
"prisma": "^6.10.1",
|
"prisma": "^6.10.1",
|
||||||
"tailwindcss": "^4.1.7",
|
"tailwindcss": "^4.1.7",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
|
"tsx": "^4.20.3",
|
||||||
|
"tw-animate-css": "^1.3.4",
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0"
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
|
|||||||
@ -1,174 +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";
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,103 +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/processingScheduler";
|
|
||||||
|
|
||||||
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, 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),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,116 +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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
@ -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 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
-- 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,
|
||||||
|
"sentimentCategory" TEXT,
|
||||||
|
"escalated" BOOLEAN,
|
||||||
|
"forwardedHr" BOOLEAN,
|
||||||
|
"fullTranscriptUrl" TEXT,
|
||||||
|
"avgResponseTime" REAL,
|
||||||
|
"tokens" INTEGER,
|
||||||
|
"tokensEur" REAL,
|
||||||
|
"category" TEXT,
|
||||||
|
"initialMsg" TEXT,
|
||||||
|
"processed" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"validData" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"questions" TEXT,
|
||||||
|
"summary" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "Session_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Message" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"sessionId" TEXT NOT NULL,
|
||||||
|
"timestamp" DATETIME NOT NULL,
|
||||||
|
"role" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"order" INTEGER NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "Message_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Message_sessionId_order_idx" ON "Message"("sessionId", "order");
|
||||||
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"
|
||||||
@ -34,17 +34,16 @@ model User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
id String @id
|
id String @id
|
||||||
company Company @relation(fields: [companyId], references: [id])
|
company Company @relation(fields: [companyId], references: [id])
|
||||||
companyId String
|
companyId String
|
||||||
startTime DateTime
|
startTime DateTime
|
||||||
endTime DateTime
|
endTime DateTime?
|
||||||
ipAddress String?
|
ipAddress String?
|
||||||
country String?
|
country String?
|
||||||
language String?
|
language String?
|
||||||
messagesSent Int?
|
messagesSent Int?
|
||||||
sentiment Float? // Original sentiment score (float)
|
sentiment String? // "positive", "neutral", or "negative"
|
||||||
sentimentCategory String? // "positive", "neutral", "negative" from OpenAPI
|
|
||||||
escalated Boolean?
|
escalated Boolean?
|
||||||
forwardedHr Boolean?
|
forwardedHr Boolean?
|
||||||
fullTranscriptUrl String?
|
fullTranscriptUrl String?
|
||||||
@ -53,11 +52,12 @@ model Session {
|
|||||||
tokensEur Float?
|
tokensEur Float?
|
||||||
category String?
|
category String?
|
||||||
initialMsg String?
|
initialMsg String?
|
||||||
processed Boolean @default(false) // Flag for post-processing status
|
processed Boolean @default(false)
|
||||||
questions String? // JSON array of questions asked by user
|
validData Boolean @default(true)
|
||||||
summary String? // Brief summary of the conversation
|
questions Json?
|
||||||
messages Message[] // Relation to parsed messages
|
summary String?
|
||||||
createdAt DateTime @default(now())
|
messages Message[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
model Message {
|
model Message {
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
// 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();
|
|
||||||
64
scripts/check-database-status.js
Normal file
64
scripts/check-database-status.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
// Check current database status
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function checkDatabaseStatus() {
|
||||||
|
try {
|
||||||
|
console.log('📊 Checking database status...\n');
|
||||||
|
|
||||||
|
// Count total sessions
|
||||||
|
const totalSessions = await prisma.session.count();
|
||||||
|
console.log(`📈 Total sessions: ${totalSessions}`);
|
||||||
|
|
||||||
|
// Count processed vs unprocessed
|
||||||
|
const processedSessions = await prisma.session.count({
|
||||||
|
where: { processed: true }
|
||||||
|
});
|
||||||
|
const unprocessedSessions = await prisma.session.count({
|
||||||
|
where: { processed: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Processed sessions: ${processedSessions}`);
|
||||||
|
console.log(`⏳ Unprocessed sessions: ${unprocessedSessions}`);
|
||||||
|
|
||||||
|
// Count valid vs invalid data
|
||||||
|
const validSessions = await prisma.session.count({
|
||||||
|
where: { validData: true }
|
||||||
|
});
|
||||||
|
const invalidSessions = await prisma.session.count({
|
||||||
|
where: { validData: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`🎯 Valid data sessions: ${validSessions}`);
|
||||||
|
console.log(`❌ Invalid data sessions: ${invalidSessions}`);
|
||||||
|
|
||||||
|
// Count sessions with messages
|
||||||
|
const sessionsWithMessages = await prisma.session.count({
|
||||||
|
where: {
|
||||||
|
messages: {
|
||||||
|
some: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`💬 Sessions with messages: ${sessionsWithMessages}`);
|
||||||
|
|
||||||
|
// Count companies
|
||||||
|
const totalCompanies = await prisma.company.count();
|
||||||
|
console.log(`🏢 Total companies: ${totalCompanies}`);
|
||||||
|
|
||||||
|
if (totalSessions === 0) {
|
||||||
|
console.log('\n💡 No sessions found. Run CSV refresh to import data:');
|
||||||
|
console.log(' curl -X POST http://localhost:3000/api/admin/refresh-sessions');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error checking database status:', error);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the script
|
||||||
|
checkDatabaseStatus();
|
||||||
69
scripts/check-questions-issue.js
Normal file
69
scripts/check-questions-issue.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
// Check why questions aren't being extracted properly
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function checkQuestionsIssue() {
|
||||||
|
console.log('🔍 INVESTIGATING QUESTIONS EXTRACTION ISSUE\n');
|
||||||
|
|
||||||
|
// Find a session with questions stored
|
||||||
|
const sessionWithQuestions = await prisma.session.findFirst({
|
||||||
|
where: {
|
||||||
|
processed: true,
|
||||||
|
questions: { not: null }
|
||||||
|
},
|
||||||
|
include: { messages: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sessionWithQuestions) {
|
||||||
|
console.log('📋 SAMPLE SESSION WITH QUESTIONS:');
|
||||||
|
console.log('Session ID:', sessionWithQuestions.id);
|
||||||
|
console.log('Questions stored:', sessionWithQuestions.questions);
|
||||||
|
console.log('Summary:', sessionWithQuestions.summary);
|
||||||
|
console.log('Messages count:', sessionWithQuestions.messages.length);
|
||||||
|
|
||||||
|
console.log('\n💬 FIRST FEW MESSAGES:');
|
||||||
|
sessionWithQuestions.messages.slice(0, 8).forEach((msg, i) => {
|
||||||
|
console.log(` ${i+1}. [${msg.role}]: ${msg.content.substring(0, 150)}...`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check sessions marked as invalid data
|
||||||
|
const invalidSessions = await prisma.session.count({
|
||||||
|
where: {
|
||||||
|
processed: true,
|
||||||
|
questions: '[]' // Empty questions array
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n⚠️ SESSIONS WITH EMPTY QUESTIONS: ${invalidSessions}`);
|
||||||
|
|
||||||
|
// Find a session with empty questions to analyze
|
||||||
|
const emptyQuestionSession = await prisma.session.findFirst({
|
||||||
|
where: {
|
||||||
|
processed: true,
|
||||||
|
questions: '[]'
|
||||||
|
},
|
||||||
|
include: { messages: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (emptyQuestionSession) {
|
||||||
|
console.log('\n❌ SAMPLE SESSION WITH EMPTY QUESTIONS:');
|
||||||
|
console.log('Session ID:', emptyQuestionSession.id);
|
||||||
|
console.log('Questions stored:', emptyQuestionSession.questions);
|
||||||
|
console.log('Summary:', emptyQuestionSession.summary);
|
||||||
|
console.log('Messages count:', emptyQuestionSession.messages.length);
|
||||||
|
|
||||||
|
console.log('\n💬 MESSAGES FROM EMPTY QUESTION SESSION:');
|
||||||
|
emptyQuestionSession.messages.slice(0, 8).forEach((msg, i) => {
|
||||||
|
console.log(` ${i+1}. [${msg.role}]: ${msg.content.substring(0, 150)}...`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🤖 CURRENT OPENAI MODEL: gpt-4-turbo');
|
||||||
|
console.log('🎯 PROMPT INSTRUCTION: "Max 5 user questions in English"');
|
||||||
|
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
checkQuestionsIssue();
|
||||||
@ -1,8 +1,8 @@
|
|||||||
// Script to check what's in the transcript files
|
// Script to check what's in the transcript files
|
||||||
// Usage: node scripts/check-transcript-content.js
|
// Usage: node scripts/check-transcript-content.js
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from "@prisma/client";
|
||||||
import fetch from 'node-fetch';
|
import fetch from "node-fetch";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@ -11,10 +11,7 @@ async function checkTranscriptContent() {
|
|||||||
// Get a few sessions without messages
|
// Get a few sessions without messages
|
||||||
const sessions = await prisma.session.findMany({
|
const sessions = await prisma.session.findMany({
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [{ fullTranscriptUrl: { not: null } }, { messages: { none: {} } }],
|
||||||
{ fullTranscriptUrl: { not: null } },
|
|
||||||
{ messages: { none: {} } },
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
include: { company: true },
|
include: { company: true },
|
||||||
take: 3,
|
take: 3,
|
||||||
@ -25,9 +22,13 @@ async function checkTranscriptContent() {
|
|||||||
console.log(` URL: ${session.fullTranscriptUrl}`);
|
console.log(` URL: ${session.fullTranscriptUrl}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authHeader = session.company.csvUsername && session.company.csvPassword
|
const authHeader =
|
||||||
? "Basic " + Buffer.from(`${session.company.csvUsername}:${session.company.csvPassword}`).toString("base64")
|
session.company.csvUsername && session.company.csvPassword
|
||||||
: undefined;
|
? "Basic " +
|
||||||
|
Buffer.from(
|
||||||
|
`${session.company.csvUsername}:${session.company.csvPassword}`
|
||||||
|
).toString("base64")
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const response = await fetch(session.fullTranscriptUrl, {
|
const response = await fetch(session.fullTranscriptUrl, {
|
||||||
headers: authHeader ? { Authorization: authHeader } : {},
|
headers: authHeader ? { Authorization: authHeader } : {},
|
||||||
@ -47,24 +48,26 @@ async function checkTranscriptContent() {
|
|||||||
} else if (content.length < 100) {
|
} else if (content.length < 100) {
|
||||||
console.log(` 📝 Full content: "${content}"`);
|
console.log(` 📝 Full content: "${content}"`);
|
||||||
} else {
|
} else {
|
||||||
console.log(` 📝 First 200 chars: "${content.substring(0, 200)}..."`);
|
console.log(
|
||||||
|
` 📝 First 200 chars: "${content.substring(0, 200)}..."`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it matches our expected format
|
// Check if it matches our expected format
|
||||||
const lines = content.split('\n').filter(line => line.trim());
|
const lines = content.split("\n").filter((line) => line.trim());
|
||||||
const formatMatches = lines.filter(line =>
|
const formatMatches = lines.filter((line) =>
|
||||||
line.match(/^\[([^\]]+)\]\s*([^:]+):\s*(.+)$/)
|
line.match(/^\[([^\]]+)\]\s*([^:]+):\s*(.+)$/)
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(` 🔍 Lines total: ${lines.length}, Format matches: ${formatMatches.length}`);
|
console.log(
|
||||||
|
` 🔍 Lines total: ${lines.length}, Format matches: ${formatMatches.length}`
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(` ❌ Error: ${error.message}`);
|
console.log(` ❌ Error: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error:', error);
|
console.error("❌ Error:", error);
|
||||||
} finally {
|
} finally {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
34
scripts/check-transcript-urls.js
Normal file
34
scripts/check-transcript-urls.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
// Check sessions for transcript URLs
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function checkTranscriptUrls() {
|
||||||
|
const sessions = await prisma.session.findMany({
|
||||||
|
where: {
|
||||||
|
messages: { none: {} },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
fullTranscriptUrl: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const withUrl = sessions.filter(s => s.fullTranscriptUrl);
|
||||||
|
const withoutUrl = sessions.filter(s => !s.fullTranscriptUrl);
|
||||||
|
|
||||||
|
console.log(`\n📊 Transcript URL Status for Sessions without Messages:`);
|
||||||
|
console.log(`✅ Sessions with transcript URL: ${withUrl.length}`);
|
||||||
|
console.log(`❌ Sessions without transcript URL: ${withoutUrl.length}`);
|
||||||
|
|
||||||
|
if (withUrl.length > 0) {
|
||||||
|
console.log(`\n🔍 Sample URLs:`);
|
||||||
|
withUrl.slice(0, 3).forEach(s => {
|
||||||
|
console.log(` ${s.id}: ${s.fullTranscriptUrl}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
checkTranscriptUrls();
|
||||||
144
scripts/complete-processing-workflow.js
Normal file
144
scripts/complete-processing-workflow.js
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
// Complete processing workflow - Fetches transcripts AND processes everything
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { processUnprocessedSessions } from '../lib/processingScheduler.ts';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function completeProcessingWorkflow() {
|
||||||
|
try {
|
||||||
|
console.log('🚀 COMPLETE PROCESSING WORKFLOW STARTED\n');
|
||||||
|
|
||||||
|
// Step 1: Check initial status
|
||||||
|
console.log('📊 STEP 1: Initial Status Check');
|
||||||
|
console.log('=' .repeat(50));
|
||||||
|
await checkStatus();
|
||||||
|
|
||||||
|
// Step 2: Fetch missing transcripts
|
||||||
|
console.log('\n📥 STEP 2: Fetching Missing Transcripts');
|
||||||
|
console.log('=' .repeat(50));
|
||||||
|
|
||||||
|
const sessionsWithoutMessages = await prisma.session.count({
|
||||||
|
where: {
|
||||||
|
messages: { none: {} },
|
||||||
|
fullTranscriptUrl: { not: null }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sessionsWithoutMessages > 0) {
|
||||||
|
console.log(`🔍 Found ${sessionsWithoutMessages} sessions needing transcript fetch`);
|
||||||
|
console.log('📥 Fetching transcripts...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync('node scripts/fetch-and-parse-transcripts.js');
|
||||||
|
console.log(stdout);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error fetching transcripts:', error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('✅ All sessions with transcript URLs already have messages');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Process ALL unprocessed sessions
|
||||||
|
console.log('\n🤖 STEP 3: AI Processing (Complete Batch Processing)');
|
||||||
|
console.log('=' .repeat(50));
|
||||||
|
|
||||||
|
const unprocessedWithMessages = await prisma.session.count({
|
||||||
|
where: {
|
||||||
|
processed: false,
|
||||||
|
messages: { some: {} }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (unprocessedWithMessages > 0) {
|
||||||
|
console.log(`🔄 Found ${unprocessedWithMessages} unprocessed sessions with messages`);
|
||||||
|
console.log('🤖 Starting complete batch processing...\n');
|
||||||
|
|
||||||
|
const result = await processUnprocessedSessions(10, 3);
|
||||||
|
|
||||||
|
console.log('\n🎉 AI Processing Results:');
|
||||||
|
console.log(` ✅ Successfully processed: ${result.totalProcessed}`);
|
||||||
|
console.log(` ❌ Failed to process: ${result.totalFailed}`);
|
||||||
|
console.log(` ⏱️ Total time: ${result.totalTime.toFixed(2)}s`);
|
||||||
|
} else {
|
||||||
|
console.log('✅ No unprocessed sessions with messages found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Continue fetching more transcripts if available
|
||||||
|
console.log('\n🔄 STEP 4: Checking for More Transcripts');
|
||||||
|
console.log('=' .repeat(50));
|
||||||
|
|
||||||
|
const remainingWithoutMessages = await prisma.session.count({
|
||||||
|
where: {
|
||||||
|
messages: { none: {} },
|
||||||
|
fullTranscriptUrl: { not: null }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (remainingWithoutMessages > 0) {
|
||||||
|
console.log(`🔍 Found ${remainingWithoutMessages} more sessions needing transcripts`);
|
||||||
|
console.log('📥 Fetching additional transcripts...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync('node scripts/fetch-and-parse-transcripts.js');
|
||||||
|
console.log(stdout);
|
||||||
|
|
||||||
|
// Process the newly fetched sessions
|
||||||
|
const newUnprocessed = await prisma.session.count({
|
||||||
|
where: {
|
||||||
|
processed: false,
|
||||||
|
messages: { some: {} }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newUnprocessed > 0) {
|
||||||
|
console.log(`\n🤖 Processing ${newUnprocessed} newly fetched sessions...\n`);
|
||||||
|
const result = await processUnprocessedSessions(10, 3);
|
||||||
|
console.log(`✅ Additional processing: ${result.totalProcessed} processed, ${result.totalFailed} failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error fetching additional transcripts:', error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('✅ No more sessions need transcript fetching');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Final status
|
||||||
|
console.log('\n📊 STEP 5: Final Status');
|
||||||
|
console.log('=' .repeat(50));
|
||||||
|
await checkStatus();
|
||||||
|
|
||||||
|
console.log('\n🎯 WORKFLOW COMPLETE!');
|
||||||
|
console.log('✅ All available sessions have been processed');
|
||||||
|
console.log('✅ System ready for new data');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error in complete workflow:', error);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkStatus() {
|
||||||
|
const totalSessions = await prisma.session.count();
|
||||||
|
const processedSessions = await prisma.session.count({ where: { processed: true } });
|
||||||
|
const unprocessedSessions = await prisma.session.count({ where: { processed: false } });
|
||||||
|
const sessionsWithMessages = await prisma.session.count({
|
||||||
|
where: { messages: { some: {} } }
|
||||||
|
});
|
||||||
|
const sessionsWithoutMessages = await prisma.session.count({
|
||||||
|
where: { messages: { none: {} } }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`📈 Total sessions: ${totalSessions}`);
|
||||||
|
console.log(`✅ Processed sessions: ${processedSessions}`);
|
||||||
|
console.log(`⏳ Unprocessed sessions: ${unprocessedSessions}`);
|
||||||
|
console.log(`💬 Sessions with messages: ${sessionsWithMessages}`);
|
||||||
|
console.log(`📄 Sessions without messages: ${sessionsWithoutMessages}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the complete workflow
|
||||||
|
completeProcessingWorkflow();
|
||||||
99
scripts/complete-workflow-demo.js
Normal file
99
scripts/complete-workflow-demo.js
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
// Complete workflow demonstration - Shows the full automated processing system
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { processUnprocessedSessions } from '../lib/processingScheduler.ts';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function demonstrateCompleteWorkflow() {
|
||||||
|
try {
|
||||||
|
console.log('🚀 COMPLETE AUTOMATED WORKFLOW DEMONSTRATION\n');
|
||||||
|
|
||||||
|
// Step 1: Check initial status
|
||||||
|
console.log('📊 STEP 1: Initial Database Status');
|
||||||
|
console.log('=' .repeat(50));
|
||||||
|
await checkDatabaseStatus();
|
||||||
|
|
||||||
|
// Step 2: Fetch any missing transcripts
|
||||||
|
console.log('\n📥 STEP 2: Fetching Missing Transcripts');
|
||||||
|
console.log('=' .repeat(50));
|
||||||
|
|
||||||
|
const sessionsWithoutMessages = await prisma.session.count({
|
||||||
|
where: {
|
||||||
|
messages: { none: {} },
|
||||||
|
fullTranscriptUrl: { not: null }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sessionsWithoutMessages > 0) {
|
||||||
|
console.log(`Found ${sessionsWithoutMessages} sessions without messages but with transcript URLs`);
|
||||||
|
console.log('💡 Run: node scripts/fetch-and-parse-transcripts.js');
|
||||||
|
} else {
|
||||||
|
console.log('✅ All sessions with transcript URLs already have messages');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Process all unprocessed sessions
|
||||||
|
console.log('\n🤖 STEP 3: Complete AI Processing (All Unprocessed Sessions)');
|
||||||
|
console.log('=' .repeat(50));
|
||||||
|
|
||||||
|
const unprocessedCount = await prisma.session.count({
|
||||||
|
where: {
|
||||||
|
processed: false,
|
||||||
|
messages: { some: {} }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (unprocessedCount > 0) {
|
||||||
|
console.log(`Found ${unprocessedCount} unprocessed sessions with messages`);
|
||||||
|
console.log('🔄 Starting complete batch processing...\n');
|
||||||
|
|
||||||
|
const result = await processUnprocessedSessions(10, 3);
|
||||||
|
|
||||||
|
console.log('\n🎉 Processing Results:');
|
||||||
|
console.log(` ✅ Successfully processed: ${result.totalProcessed}`);
|
||||||
|
console.log(` ❌ Failed to process: ${result.totalFailed}`);
|
||||||
|
console.log(` ⏱️ Total time: ${result.totalTime.toFixed(2)}s`);
|
||||||
|
} else {
|
||||||
|
console.log('✅ No unprocessed sessions found - all caught up!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Final status
|
||||||
|
console.log('\n📊 STEP 4: Final Database Status');
|
||||||
|
console.log('=' .repeat(50));
|
||||||
|
await checkDatabaseStatus();
|
||||||
|
|
||||||
|
// Step 5: System summary
|
||||||
|
console.log('\n🎯 STEP 5: Automated System Summary');
|
||||||
|
console.log('=' .repeat(50));
|
||||||
|
console.log('✅ HOURLY SCHEDULER: Processes new unprocessed sessions automatically');
|
||||||
|
console.log('✅ DASHBOARD REFRESH: Triggers processing when refresh button is pressed');
|
||||||
|
console.log('✅ BATCH PROCESSING: Processes ALL unprocessed sessions until completion');
|
||||||
|
console.log('✅ QUALITY VALIDATION: Filters out low-quality sessions automatically');
|
||||||
|
console.log('✅ COMPLETE AUTOMATION: No manual intervention needed for ongoing operations');
|
||||||
|
|
||||||
|
console.log('\n🚀 SYSTEM READY FOR PRODUCTION!');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error in workflow demonstration:', error);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkDatabaseStatus() {
|
||||||
|
const totalSessions = await prisma.session.count();
|
||||||
|
const processedSessions = await prisma.session.count({ where: { processed: true } });
|
||||||
|
const unprocessedSessions = await prisma.session.count({ where: { processed: false } });
|
||||||
|
const sessionsWithMessages = await prisma.session.count({
|
||||||
|
where: { messages: { some: {} } }
|
||||||
|
});
|
||||||
|
const companies = await prisma.company.count();
|
||||||
|
|
||||||
|
console.log(`📈 Total sessions: ${totalSessions}`);
|
||||||
|
console.log(`✅ Processed sessions: ${processedSessions}`);
|
||||||
|
console.log(`⏳ Unprocessed sessions: ${unprocessedSessions}`);
|
||||||
|
console.log(`💬 Sessions with messages: ${sessionsWithMessages}`);
|
||||||
|
console.log(`🏢 Total companies: ${companies}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the demonstration
|
||||||
|
demonstrateCompleteWorkflow();
|
||||||
53
scripts/create-admin-user.js
Normal file
53
scripts/create-admin-user.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function createAdminUser() {
|
||||||
|
try {
|
||||||
|
// Check if user exists
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { email: 'admin@example.com' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
console.log('✅ User already exists:', existingUser.email);
|
||||||
|
console.log('Password hash:', existingUser.password);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, ensure we have a company
|
||||||
|
let company = await prisma.company.findFirst();
|
||||||
|
if (!company) {
|
||||||
|
company = await prisma.company.create({
|
||||||
|
data: {
|
||||||
|
name: 'Demo Company',
|
||||||
|
csvUrl: 'https://example.com/demo.csv',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('✅ Created demo company:', company.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
const hashedPassword = await bcrypt.hash('admin123', 10);
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: 'admin@example.com',
|
||||||
|
password: hashedPassword,
|
||||||
|
role: 'admin',
|
||||||
|
companyId: company.id,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ User created successfully:', user.email);
|
||||||
|
console.log('Password hash:', user.password);
|
||||||
|
console.log('Role:', user.role);
|
||||||
|
console.log('Company:', company.name);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error creating user:', error);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createAdminUser();
|
||||||
@ -1,8 +1,8 @@
|
|||||||
// Script to fetch transcripts and parse them into messages
|
// Script to fetch transcripts and parse them into messages
|
||||||
// Usage: node scripts/fetch-and-parse-transcripts.js
|
// Usage: node scripts/fetch-and-parse-transcripts.js
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from "@prisma/client";
|
||||||
import fetch from 'node-fetch';
|
import fetch from "node-fetch";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@ -11,9 +11,10 @@ const prisma = new PrismaClient();
|
|||||||
*/
|
*/
|
||||||
async function fetchTranscriptContent(url, username, password) {
|
async function fetchTranscriptContent(url, username, password) {
|
||||||
try {
|
try {
|
||||||
const authHeader = username && password
|
const authHeader =
|
||||||
? "Basic " + Buffer.from(`${username}:${password}`).toString("base64")
|
username && password
|
||||||
: undefined;
|
? "Basic " + Buffer.from(`${username}:${password}`).toString("base64")
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: authHeader ? { Authorization: authHeader } : {},
|
headers: authHeader ? { Authorization: authHeader } : {},
|
||||||
@ -21,7 +22,9 @@ async function fetchTranscriptContent(url, username, password) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.log(`❌ Failed to fetch ${url}: ${response.status} ${response.statusText}`);
|
console.log(
|
||||||
|
`❌ Failed to fetch ${url}: ${response.status} ${response.statusText}`
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return await response.text();
|
return await response.text();
|
||||||
@ -35,11 +38,11 @@ async function fetchTranscriptContent(url, username, password) {
|
|||||||
* Parses transcript content into messages
|
* Parses transcript content into messages
|
||||||
*/
|
*/
|
||||||
function parseTranscriptToMessages(transcript, sessionId) {
|
function parseTranscriptToMessages(transcript, sessionId) {
|
||||||
if (!transcript || transcript.trim() === '') {
|
if (!transcript || transcript.trim() === "") {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = transcript.split('\n').filter(line => line.trim());
|
const lines = transcript.split("\n").filter((line) => line.trim());
|
||||||
const messages = [];
|
const messages = [];
|
||||||
let messageOrder = 0;
|
let messageOrder = 0;
|
||||||
let currentTimestamp = new Date();
|
let currentTimestamp = new Date();
|
||||||
@ -52,7 +55,9 @@ function parseTranscriptToMessages(transcript, sessionId) {
|
|||||||
const [, timestamp, role, content] = timestampMatch;
|
const [, timestamp, role, content] = timestampMatch;
|
||||||
|
|
||||||
// Parse timestamp (DD-MM-YYYY HH:MM:SS)
|
// Parse timestamp (DD-MM-YYYY HH:MM:SS)
|
||||||
const dateMatch = timestamp.match(/^(\d{1,2})-(\d{1,2})-(\d{4}) (\d{1,2}):(\d{1,2}):(\d{1,2})$/);
|
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();
|
let parsedTimestamp = new Date();
|
||||||
|
|
||||||
if (dateMatch) {
|
if (dateMatch) {
|
||||||
@ -104,7 +109,7 @@ function parseTranscriptToMessages(transcript, sessionId) {
|
|||||||
*/
|
*/
|
||||||
async function fetchAndParseTranscripts() {
|
async function fetchAndParseTranscripts() {
|
||||||
try {
|
try {
|
||||||
console.log('🔍 Finding sessions without messages...\n');
|
console.log("🔍 Finding sessions without messages...\n");
|
||||||
|
|
||||||
// Get sessions that have fullTranscriptUrl but no messages
|
// Get sessions that have fullTranscriptUrl but no messages
|
||||||
const sessionsWithoutMessages = await prisma.session.findMany({
|
const sessionsWithoutMessages = await prisma.session.findMany({
|
||||||
@ -112,7 +117,7 @@ async function fetchAndParseTranscripts() {
|
|||||||
AND: [
|
AND: [
|
||||||
{ fullTranscriptUrl: { not: null } },
|
{ fullTranscriptUrl: { not: null } },
|
||||||
{ messages: { none: {} } }, // No messages
|
{ messages: { none: {} } }, // No messages
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
company: true,
|
company: true,
|
||||||
@ -121,11 +126,15 @@ async function fetchAndParseTranscripts() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (sessionsWithoutMessages.length === 0) {
|
if (sessionsWithoutMessages.length === 0) {
|
||||||
console.log('✅ All sessions with transcript URLs already have messages!');
|
console.log(
|
||||||
|
"✅ All sessions with transcript URLs already have messages!"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`📥 Found ${sessionsWithoutMessages.length} sessions to process\n`);
|
console.log(
|
||||||
|
`📥 Found ${sessionsWithoutMessages.length} sessions to process\n`
|
||||||
|
);
|
||||||
|
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
@ -148,7 +157,10 @@ async function fetchAndParseTranscripts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse transcript into messages
|
// Parse transcript into messages
|
||||||
const messages = parseTranscriptToMessages(transcriptContent, session.id);
|
const messages = parseTranscriptToMessages(
|
||||||
|
transcriptContent,
|
||||||
|
session.id
|
||||||
|
);
|
||||||
|
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
console.log(` ⚠️ No messages found in transcript`);
|
console.log(` ⚠️ No messages found in transcript`);
|
||||||
@ -163,7 +175,6 @@ async function fetchAndParseTranscripts() {
|
|||||||
|
|
||||||
console.log(` ✅ Added ${messages.length} messages`);
|
console.log(` ✅ Added ${messages.length} messages`);
|
||||||
successCount++;
|
successCount++;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(` ❌ Error: ${error.message}`);
|
console.log(` ❌ Error: ${error.message}`);
|
||||||
errorCount++;
|
errorCount++;
|
||||||
@ -173,10 +184,11 @@ async function fetchAndParseTranscripts() {
|
|||||||
console.log(`\n📊 Results:`);
|
console.log(`\n📊 Results:`);
|
||||||
console.log(` ✅ Successfully processed: ${successCount} sessions`);
|
console.log(` ✅ Successfully processed: ${successCount} sessions`);
|
||||||
console.log(` ❌ Failed to process: ${errorCount} sessions`);
|
console.log(` ❌ Failed to process: ${errorCount} sessions`);
|
||||||
console.log(`\n💡 Now you can run the processing scheduler to analyze these sessions!`);
|
console.log(
|
||||||
|
`\n💡 Now you can run the processing scheduler to analyze these sessions!`
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error:', error);
|
console.error("❌ Error:", error);
|
||||||
} finally {
|
} finally {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,37 +1,39 @@
|
|||||||
// Simple script to test the manual processing trigger
|
// Simple script to test the manual processing trigger
|
||||||
// Usage: node scripts/manual-trigger-test.js
|
// Usage: node scripts/manual-trigger-test.js
|
||||||
|
|
||||||
import fetch from 'node-fetch';
|
import fetch from "node-fetch";
|
||||||
|
|
||||||
async function testManualTrigger() {
|
async function testManualTrigger() {
|
||||||
try {
|
try {
|
||||||
console.log('Testing manual processing trigger...');
|
console.log("Testing manual processing trigger...");
|
||||||
|
|
||||||
const response = await fetch('http://localhost:3000/api/admin/trigger-processing', {
|
const response = await fetch(
|
||||||
method: 'POST',
|
"http://localhost:3000/api/admin/trigger-processing",
|
||||||
headers: {
|
{
|
||||||
'Content-Type': 'application/json',
|
method: "POST",
|
||||||
// Note: In a real scenario, you'd need to include authentication cookies
|
headers: {
|
||||||
// For testing, you might need to login first and copy the session cookie
|
"Content-Type": "application/json",
|
||||||
},
|
// Note: In a real scenario, you'd need to include authentication cookies
|
||||||
body: JSON.stringify({
|
// For testing, you might need to login first and copy the session cookie
|
||||||
batchSize: 5, // Process max 5 sessions
|
},
|
||||||
maxConcurrency: 3 // Use 3 concurrent workers
|
body: JSON.stringify({
|
||||||
})
|
batchSize: 5, // Process max 5 sessions
|
||||||
});
|
maxConcurrency: 3, // Use 3 concurrent workers
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
console.log('✅ Manual trigger successful:');
|
console.log("✅ Manual trigger successful:");
|
||||||
console.log(JSON.stringify(result, null, 2));
|
console.log(JSON.stringify(result, null, 2));
|
||||||
} else {
|
} else {
|
||||||
console.log('❌ Manual trigger failed:');
|
console.log("❌ Manual trigger failed:");
|
||||||
console.log(JSON.stringify(result, null, 2));
|
console.log(JSON.stringify(result, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error testing manual trigger:', error.message);
|
console.error("❌ Error testing manual trigger:", error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,16 +10,18 @@ import { dirname, join } from "path";
|
|||||||
// Load environment variables from .env.local
|
// Load environment variables from .env.local
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
const envPath = join(__dirname, '..', '.env.local');
|
const envPath = join(__dirname, "..", ".env.local");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const envFile = readFileSync(envPath, 'utf8');
|
const envFile = readFileSync(envPath, "utf8");
|
||||||
const envVars = envFile.split('\n').filter(line => line.trim() && !line.startsWith('#'));
|
const envVars = envFile
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim() && !line.startsWith("#"));
|
||||||
|
|
||||||
envVars.forEach(line => {
|
envVars.forEach((line) => {
|
||||||
const [key, ...valueParts] = line.split('=');
|
const [key, ...valueParts] = line.split("=");
|
||||||
if (key && valueParts.length > 0) {
|
if (key && valueParts.length > 0) {
|
||||||
const value = valueParts.join('=').trim();
|
const value = valueParts.join("=").trim();
|
||||||
if (!process.env[key.trim()]) {
|
if (!process.env[key.trim()]) {
|
||||||
process.env[key.trim()] = value;
|
process.env[key.trim()] = value;
|
||||||
}
|
}
|
||||||
@ -65,11 +67,8 @@ async function triggerProcessingScheduler() {
|
|||||||
AND: [
|
AND: [
|
||||||
{ messages: { some: {} } },
|
{ messages: { some: {} } },
|
||||||
{
|
{
|
||||||
OR: [
|
OR: [{ processed: false }, { processed: null }],
|
||||||
{ processed: false },
|
},
|
||||||
{ processed: null }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@ -129,10 +128,7 @@ async function showProcessingStatus() {
|
|||||||
});
|
});
|
||||||
const unprocessedSessions = await prisma.session.count({
|
const unprocessedSessions = await prisma.session.count({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [{ processed: false }, { processed: null }],
|
||||||
{ processed: false },
|
|
||||||
{ processed: null }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const withMessages = await prisma.session.count({
|
const withMessages = await prisma.session.count({
|
||||||
@ -147,11 +143,8 @@ async function showProcessingStatus() {
|
|||||||
AND: [
|
AND: [
|
||||||
{ messages: { some: {} } },
|
{ messages: { some: {} } },
|
||||||
{
|
{
|
||||||
OR: [
|
OR: [{ processed: false }, { processed: null }],
|
||||||
{ processed: false },
|
},
|
||||||
{ processed: null }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -170,11 +163,8 @@ async function showProcessingStatus() {
|
|||||||
AND: [
|
AND: [
|
||||||
{ messages: { some: {} } },
|
{ messages: { some: {} } },
|
||||||
{
|
{
|
||||||
OR: [
|
OR: [{ processed: false }, { processed: null }],
|
||||||
{ processed: false },
|
},
|
||||||
{ processed: null }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
@ -1,283 +0,0 @@
|
|||||||
// Script to manually process unprocessed sessions with OpenAI
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import fetch from "node-fetch";
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
|
||||||
const OPENAI_API_URL = "https://api.openai.com/v1/chat/completions";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Processes a session transcript using OpenAI API
|
|
||||||
* @param {string} sessionId The session ID
|
|
||||||
* @param {string} transcript The transcript content to process
|
|
||||||
* @returns {Promise<Object>} Processed data from OpenAI
|
|
||||||
*/
|
|
||||||
async function processTranscriptWithOpenAI(sessionId, transcript) {
|
|
||||||
if (!OPENAI_API_KEY) {
|
|
||||||
throw new Error("OPENAI_API_KEY environment variable is not set");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a system message with instructions
|
|
||||||
const systemMessage = `
|
|
||||||
You are an AI assistant tasked with analyzing chat transcripts.
|
|
||||||
Extract the following information from the transcript:
|
|
||||||
1. The primary language used by the user (ISO 639-1 code)
|
|
||||||
2. Number of messages sent by the user
|
|
||||||
3. Overall sentiment (positive, neutral, or negative)
|
|
||||||
4. Whether the conversation was escalated
|
|
||||||
5. Whether HR contact was mentioned or provided
|
|
||||||
6. The best-fitting category for the conversation from this list:
|
|
||||||
- Schedule & Hours
|
|
||||||
- Leave & Vacation
|
|
||||||
- Sick Leave & Recovery
|
|
||||||
- Salary & Compensation
|
|
||||||
- Contract & Hours
|
|
||||||
- Onboarding
|
|
||||||
- Offboarding
|
|
||||||
- Workwear & Staff Pass
|
|
||||||
- Team & Contacts
|
|
||||||
- Personal Questions
|
|
||||||
- Access & Login
|
|
||||||
- Social questions
|
|
||||||
- Unrecognized / Other
|
|
||||||
7. Up to 5 paraphrased questions asked by the user (in English)
|
|
||||||
8. A brief summary of the conversation (10-300 characters)
|
|
||||||
|
|
||||||
Return the data in JSON format matching this schema:
|
|
||||||
{
|
|
||||||
"language": "ISO 639-1 code",
|
|
||||||
"messages_sent": number,
|
|
||||||
"sentiment": "positive|neutral|negative",
|
|
||||||
"escalated": boolean,
|
|
||||||
"forwarded_hr": boolean,
|
|
||||||
"category": "one of the categories listed above",
|
|
||||||
"questions": ["question 1", "question 2", ...],
|
|
||||||
"summary": "brief summary",
|
|
||||||
"session_id": "${sessionId}"
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(OPENAI_API_URL, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${OPENAI_API_KEY}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: "gpt-4-turbo",
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "system",
|
|
||||||
content: systemMessage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: transcript,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
temperature: 0.3, // Lower temperature for more consistent results
|
|
||||||
response_format: { type: "json_object" },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`OpenAI API error: ${response.status} - ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const processedData = JSON.parse(data.choices[0].message.content);
|
|
||||||
|
|
||||||
// Validate the response against our expected schema
|
|
||||||
validateOpenAIResponse(processedData);
|
|
||||||
|
|
||||||
return processedData;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error processing transcript with OpenAI:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the OpenAI response against our expected schema
|
|
||||||
* @param {Object} data The data to validate
|
|
||||||
*/
|
|
||||||
function validateOpenAIResponse(data) {
|
|
||||||
// Check required fields
|
|
||||||
const requiredFields = [
|
|
||||||
"language",
|
|
||||||
"messages_sent",
|
|
||||||
"sentiment",
|
|
||||||
"escalated",
|
|
||||||
"forwarded_hr",
|
|
||||||
"category",
|
|
||||||
"questions",
|
|
||||||
"summary",
|
|
||||||
"session_id",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const field of requiredFields) {
|
|
||||||
if (!(field in data)) {
|
|
||||||
throw new Error(`Missing required field: ${field}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate field types
|
|
||||||
if (typeof data.language !== "string" || !/^[a-z]{2}$/.test(data.language)) {
|
|
||||||
throw new Error(
|
|
||||||
"Invalid language format. Expected ISO 639-1 code (e.g., 'en')"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof data.messages_sent !== "number" || data.messages_sent < 0) {
|
|
||||||
throw new Error("Invalid messages_sent. Expected non-negative number");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!["positive", "neutral", "negative"].includes(data.sentiment)) {
|
|
||||||
throw new Error(
|
|
||||||
"Invalid sentiment. Expected 'positive', 'neutral', or 'negative'"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof data.escalated !== "boolean") {
|
|
||||||
throw new Error("Invalid escalated. Expected boolean");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof data.forwarded_hr !== "boolean") {
|
|
||||||
throw new Error("Invalid forwarded_hr. Expected boolean");
|
|
||||||
}
|
|
||||||
|
|
||||||
const validCategories = [
|
|
||||||
"Schedule & Hours",
|
|
||||||
"Leave & Vacation",
|
|
||||||
"Sick Leave & Recovery",
|
|
||||||
"Salary & Compensation",
|
|
||||||
"Contract & Hours",
|
|
||||||
"Onboarding",
|
|
||||||
"Offboarding",
|
|
||||||
"Workwear & Staff Pass",
|
|
||||||
"Team & Contacts",
|
|
||||||
"Personal Questions",
|
|
||||||
"Access & Login",
|
|
||||||
"Social questions",
|
|
||||||
"Unrecognized / Other",
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!validCategories.includes(data.category)) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid category. Expected one of: ${validCategories.join(", ")}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(data.questions)) {
|
|
||||||
throw new Error("Invalid questions. Expected array of strings");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof data.summary !== "string" ||
|
|
||||||
data.summary.length < 10 ||
|
|
||||||
data.summary.length > 300
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
"Invalid summary. Expected string between 10-300 characters"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof data.session_id !== "string") {
|
|
||||||
throw new Error("Invalid session_id. Expected string");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main function to process unprocessed sessions
|
|
||||||
*/
|
|
||||||
async function processUnprocessedSessions() {
|
|
||||||
console.log("Starting to process unprocessed sessions...");
|
|
||||||
|
|
||||||
// Find sessions that have transcript content but haven't been processed
|
|
||||||
const sessionsToProcess = await prisma.session.findMany({
|
|
||||||
where: {
|
|
||||||
AND: [
|
|
||||||
{ transcriptContent: { not: null } },
|
|
||||||
{ transcriptContent: { not: "" } },
|
|
||||||
{ processed: { not: true } }, // Either false or null
|
|
||||||
],
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
transcriptContent: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (sessionsToProcess.length === 0) {
|
|
||||||
console.log("No sessions found requiring processing.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Found ${sessionsToProcess.length} sessions to process.`);
|
|
||||||
let successCount = 0;
|
|
||||||
let errorCount = 0;
|
|
||||||
|
|
||||||
for (const session of sessionsToProcess) {
|
|
||||||
if (!session.transcriptContent) {
|
|
||||||
// Should not happen due to query, but good for type safety
|
|
||||||
console.warn(
|
|
||||||
`Session ${session.id} has no transcript content, skipping.`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Processing transcript for session ${session.id}...`);
|
|
||||||
try {
|
|
||||||
const processedData = await processTranscriptWithOpenAI(
|
|
||||||
session.id,
|
|
||||||
session.transcriptContent
|
|
||||||
);
|
|
||||||
|
|
||||||
// Map sentiment string to float value for compatibility with existing data
|
|
||||||
const sentimentMap = {
|
|
||||||
positive: 0.8,
|
|
||||||
neutral: 0.0,
|
|
||||||
negative: -0.8,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update the session with processed data
|
|
||||||
await prisma.session.update({
|
|
||||||
where: { id: session.id },
|
|
||||||
data: {
|
|
||||||
language: processedData.language,
|
|
||||||
messagesSent: processedData.messages_sent,
|
|
||||||
sentiment: sentimentMap[processedData.sentiment] || 0,
|
|
||||||
sentimentCategory: processedData.sentiment,
|
|
||||||
escalated: processedData.escalated,
|
|
||||||
forwardedHr: processedData.forwarded_hr,
|
|
||||||
category: processedData.category,
|
|
||||||
questions: JSON.stringify(processedData.questions),
|
|
||||||
summary: processedData.summary,
|
|
||||||
processed: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Successfully processed session ${session.id}.`);
|
|
||||||
successCount++;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error processing session ${session.id}:`, error);
|
|
||||||
errorCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Session processing complete.");
|
|
||||||
console.log(`Successfully processed: ${successCount} sessions.`);
|
|
||||||
console.log(`Failed to process: ${errorCount} sessions.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the main function
|
|
||||||
processUnprocessedSessions()
|
|
||||||
.catch((e) => {
|
|
||||||
console.error("An error occurred during the script execution:", e);
|
|
||||||
process.exitCode = 1;
|
|
||||||
})
|
|
||||||
.finally(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
@ -8,14 +8,14 @@ const OPENAI_API_URL = "https://api.openai.com/v1/chat/completions";
|
|||||||
// Define the expected response structure from OpenAI
|
// Define the expected response structure from OpenAI
|
||||||
interface OpenAIProcessedData {
|
interface OpenAIProcessedData {
|
||||||
language: string;
|
language: string;
|
||||||
messages_sent: number;
|
|
||||||
sentiment: "positive" | "neutral" | "negative";
|
sentiment: "positive" | "neutral" | "negative";
|
||||||
escalated: boolean;
|
escalated: boolean;
|
||||||
forwarded_hr: boolean;
|
forwarded_hr: boolean;
|
||||||
category: string;
|
category: string;
|
||||||
questions: string[];
|
questions: string | string[];
|
||||||
summary: string;
|
summary: string;
|
||||||
session_id: string;
|
tokens: number;
|
||||||
|
tokens_eur: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -37,11 +37,10 @@ async function processTranscriptWithOpenAI(
|
|||||||
You are an AI assistant tasked with analyzing chat transcripts.
|
You are an AI assistant tasked with analyzing chat transcripts.
|
||||||
Extract the following information from the transcript:
|
Extract the following information from the transcript:
|
||||||
1. The primary language used by the user (ISO 639-1 code)
|
1. The primary language used by the user (ISO 639-1 code)
|
||||||
2. Number of messages sent by the user
|
2. Overall sentiment (positive, neutral, or negative)
|
||||||
3. Overall sentiment (positive, neutral, or negative)
|
3. Whether the conversation was escalated
|
||||||
4. Whether the conversation was escalated
|
4. Whether HR contact was mentioned or provided
|
||||||
5. Whether HR contact was mentioned or provided
|
5. The best-fitting category for the conversation from this list:
|
||||||
6. The best-fitting category for the conversation from this list:
|
|
||||||
- Schedule & Hours
|
- Schedule & Hours
|
||||||
- Leave & Vacation
|
- Leave & Vacation
|
||||||
- Sick Leave & Recovery
|
- Sick Leave & Recovery
|
||||||
@ -55,20 +54,22 @@ async function processTranscriptWithOpenAI(
|
|||||||
- Access & Login
|
- Access & Login
|
||||||
- Social questions
|
- Social questions
|
||||||
- Unrecognized / Other
|
- Unrecognized / Other
|
||||||
7. Up to 5 paraphrased questions asked by the user (in English)
|
6. A single question or an array of simplified questions asked by the user formulated in English
|
||||||
8. A brief summary of the conversation (10-300 characters)
|
7. A brief summary of the conversation (10-300 characters)
|
||||||
|
8. The number of tokens used for the API call
|
||||||
|
9. The cost of the API call in EUR
|
||||||
|
|
||||||
Return the data in JSON format matching this schema:
|
Return the data in JSON format matching this schema:
|
||||||
{
|
{
|
||||||
"language": "ISO 639-1 code",
|
"language": "ISO 639-1 code",
|
||||||
"messages_sent": number,
|
|
||||||
"sentiment": "positive|neutral|negative",
|
"sentiment": "positive|neutral|negative",
|
||||||
"escalated": boolean,
|
"escalated": boolean,
|
||||||
"forwarded_hr": boolean,
|
"forwarded_hr": boolean,
|
||||||
"category": "one of the categories listed above",
|
"category": "one of the categories listed above",
|
||||||
"questions": ["question 1", "question 2", ...],
|
"questions": null, or array of questions,
|
||||||
"summary": "brief summary",
|
"summary": "brief summary",
|
||||||
"session_id": "${sessionId}"
|
"tokens": number,
|
||||||
|
"tokens_eur": number
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@ -124,14 +125,14 @@ function validateOpenAIResponse(
|
|||||||
// Check required fields
|
// Check required fields
|
||||||
const requiredFields = [
|
const requiredFields = [
|
||||||
"language",
|
"language",
|
||||||
"messages_sent",
|
|
||||||
"sentiment",
|
"sentiment",
|
||||||
"escalated",
|
"escalated",
|
||||||
"forwarded_hr",
|
"forwarded_hr",
|
||||||
"category",
|
"category",
|
||||||
"questions",
|
"questions",
|
||||||
"summary",
|
"summary",
|
||||||
"session_id",
|
"tokens",
|
||||||
|
"tokens_eur",
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const field of requiredFields) {
|
for (const field of requiredFields) {
|
||||||
@ -147,10 +148,6 @@ function validateOpenAIResponse(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof data.messages_sent !== "number" || data.messages_sent < 0) {
|
|
||||||
throw new Error("Invalid messages_sent. Expected non-negative number");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!["positive", "neutral", "negative"].includes(data.sentiment)) {
|
if (!["positive", "neutral", "negative"].includes(data.sentiment)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid sentiment. Expected 'positive', 'neutral', or 'negative'"
|
"Invalid sentiment. Expected 'positive', 'neutral', or 'negative'"
|
||||||
@ -187,8 +184,8 @@ function validateOpenAIResponse(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(data.questions)) {
|
if (typeof data.questions !== "string" && !Array.isArray(data.questions)) {
|
||||||
throw new Error("Invalid questions. Expected array of strings");
|
throw new Error("Invalid questions. Expected string or array of strings");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -201,8 +198,12 @@ function validateOpenAIResponse(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof data.session_id !== "string") {
|
if (typeof data.tokens !== "number" || data.tokens < 0) {
|
||||||
throw new Error("Invalid session_id. Expected string");
|
throw new Error("Invalid tokens. Expected non-negative number");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.tokens_eur !== "number" || data.tokens_eur < 0) {
|
||||||
|
throw new Error("Invalid tokens_eur. Expected non-negative number");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -252,26 +253,23 @@ async function processUnprocessedSessions() {
|
|||||||
session.transcriptContent
|
session.transcriptContent
|
||||||
);
|
);
|
||||||
|
|
||||||
// Map sentiment string to float value for compatibility with existing data
|
|
||||||
const sentimentMap: Record<string, number> = {
|
|
||||||
positive: 0.8,
|
|
||||||
neutral: 0.0,
|
|
||||||
negative: -0.8,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update the session with processed data
|
// Update the session with processed data
|
||||||
await prisma.session.update({
|
await prisma.session.update({
|
||||||
where: { id: session.id },
|
where: { id: session.id },
|
||||||
data: {
|
data: {
|
||||||
language: processedData.language,
|
language: processedData.language,
|
||||||
messagesSent: processedData.messages_sent,
|
sentiment: processedData.sentiment,
|
||||||
sentiment: sentimentMap[processedData.sentiment] || 0,
|
|
||||||
sentimentCategory: processedData.sentiment,
|
|
||||||
escalated: processedData.escalated,
|
escalated: processedData.escalated,
|
||||||
forwardedHr: processedData.forwarded_hr,
|
forwardedHr: processedData.forwarded_hr,
|
||||||
category: processedData.category,
|
category: processedData.category,
|
||||||
questions: JSON.stringify(processedData.questions),
|
questions: processedData.questions,
|
||||||
summary: processedData.summary,
|
summary: processedData.summary,
|
||||||
|
tokens: {
|
||||||
|
increment: processedData.tokens,
|
||||||
|
},
|
||||||
|
tokensEur: {
|
||||||
|
increment: processedData.tokens_eur,
|
||||||
|
},
|
||||||
processed: true,
|
processed: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
48
scripts/reset-processed-status.js
Normal file
48
scripts/reset-processed-status.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
// Reset all sessions to processed: false for reprocessing with new instructions
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function resetProcessedStatus() {
|
||||||
|
try {
|
||||||
|
console.log('🔄 Resetting processed status for all sessions...');
|
||||||
|
|
||||||
|
// Get count of currently processed sessions
|
||||||
|
const processedCount = await prisma.session.count({
|
||||||
|
where: { processed: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`📊 Found ${processedCount} processed sessions to reset`);
|
||||||
|
|
||||||
|
if (processedCount === 0) {
|
||||||
|
console.log('✅ No sessions need to be reset');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset all sessions to processed: false
|
||||||
|
const result = await prisma.session.updateMany({
|
||||||
|
where: { processed: true },
|
||||||
|
data: {
|
||||||
|
processed: false,
|
||||||
|
// Also reset AI-generated fields so they get fresh analysis
|
||||||
|
sentimentCategory: null,
|
||||||
|
category: null,
|
||||||
|
questions: null,
|
||||||
|
summary: null,
|
||||||
|
validData: true // Reset to default
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Successfully reset ${result.count} sessions to processed: false`);
|
||||||
|
console.log('🤖 These sessions will be reprocessed with the new OpenAI instructions');
|
||||||
|
console.log('🎯 Quality validation will now mark invalid data appropriately');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error resetting processed status:', error);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the script
|
||||||
|
resetProcessedStatus();
|
||||||
83
scripts/test-automation.js
Normal file
83
scripts/test-automation.js
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
// Test script to demonstrate the automated processing system
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { processUnprocessedSessions, startProcessingScheduler } from '../lib/processingScheduler.ts';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function testAutomation() {
|
||||||
|
console.log('🧪 TESTING AUTOMATED PROCESSING SYSTEM\n');
|
||||||
|
|
||||||
|
// Step 1: Show current status
|
||||||
|
console.log('📊 STEP 1: Current Database Status');
|
||||||
|
console.log('=' .repeat(50));
|
||||||
|
await showStatus();
|
||||||
|
|
||||||
|
// Step 2: Test the automated function
|
||||||
|
console.log('\n🤖 STEP 2: Testing Automated Processing Function');
|
||||||
|
console.log('=' .repeat(50));
|
||||||
|
console.log('This is the SAME function that runs automatically every hour...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// This is the EXACT same function that runs automatically every hour
|
||||||
|
const result = await processUnprocessedSessions(5, 2); // Smaller batch for demo
|
||||||
|
|
||||||
|
console.log('\n✅ AUTOMATION TEST RESULTS:');
|
||||||
|
console.log(` 📊 Sessions processed: ${result.totalProcessed}`);
|
||||||
|
console.log(` ❌ Sessions failed: ${result.totalFailed}`);
|
||||||
|
console.log(` ⏱️ Processing time: ${result.totalTime.toFixed(2)}s`);
|
||||||
|
|
||||||
|
if (result.totalProcessed === 0 && result.totalFailed === 0) {
|
||||||
|
console.log('\n🎉 PERFECT! No unprocessed sessions found.');
|
||||||
|
console.log('✅ This means the automation is working - everything is already processed!');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error testing automation:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Show what the scheduler does
|
||||||
|
console.log('\n⏰ STEP 3: Automated Scheduler Information');
|
||||||
|
console.log('=' .repeat(50));
|
||||||
|
console.log('🔄 HOURLY AUTOMATION:');
|
||||||
|
console.log(' • Runs every hour: cron.schedule("0 * * * *")');
|
||||||
|
console.log(' • Checks: WHERE processed = false AND messages: { some: {} }');
|
||||||
|
console.log(' • Processes: ALL unprocessed sessions through OpenAI');
|
||||||
|
console.log(' • Continues: Until NO unprocessed sessions remain');
|
||||||
|
console.log(' • Quality: Validates and filters low-quality sessions');
|
||||||
|
|
||||||
|
console.log('\n🚀 DASHBOARD INTEGRATION:');
|
||||||
|
console.log(' • Refresh button triggers: triggerCompleteWorkflow()');
|
||||||
|
console.log(' • Fetches transcripts: For sessions without messages');
|
||||||
|
console.log(' • Processes everything: Until all sessions are analyzed');
|
||||||
|
|
||||||
|
console.log('\n🎯 PRODUCTION STATUS:');
|
||||||
|
console.log(' ✅ System is FULLY AUTOMATED');
|
||||||
|
console.log(' ✅ No manual intervention needed');
|
||||||
|
console.log(' ✅ Processes new data automatically');
|
||||||
|
console.log(' ✅ Quality validation included');
|
||||||
|
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showStatus() {
|
||||||
|
const totalSessions = await prisma.session.count();
|
||||||
|
const processedSessions = await prisma.session.count({ where: { processed: true } });
|
||||||
|
const unprocessedSessions = await prisma.session.count({ where: { processed: false } });
|
||||||
|
const sessionsWithMessages = await prisma.session.count({
|
||||||
|
where: { messages: { some: {} } }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`📈 Total sessions: ${totalSessions}`);
|
||||||
|
console.log(`✅ Processed sessions: ${processedSessions}`);
|
||||||
|
console.log(`⏳ Unprocessed sessions: ${unprocessedSessions}`);
|
||||||
|
console.log(`💬 Sessions with messages: ${sessionsWithMessages}`);
|
||||||
|
|
||||||
|
if (processedSessions === sessionsWithMessages && unprocessedSessions === 0) {
|
||||||
|
console.log('\n🎉 AUTOMATION WORKING PERFECTLY!');
|
||||||
|
console.log('✅ All sessions with messages have been processed');
|
||||||
|
console.log('✅ No unprocessed sessions remaining');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the test
|
||||||
|
testAutomation();
|
||||||
47
scripts/test-improved-prompt.js
Normal file
47
scripts/test-improved-prompt.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
// Test the improved prompt on a few sessions
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function testImprovedPrompt() {
|
||||||
|
console.log('🧪 TESTING IMPROVED QUESTION EXTRACTION PROMPT\n');
|
||||||
|
|
||||||
|
// Reset a few sessions to test the new prompt
|
||||||
|
console.log('📝 Resetting 5 sessions to test improved prompt...');
|
||||||
|
|
||||||
|
const sessionsToReprocess = await prisma.session.findMany({
|
||||||
|
where: {
|
||||||
|
processed: true,
|
||||||
|
questions: '[]' // Sessions with empty questions
|
||||||
|
},
|
||||||
|
take: 5
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sessionsToReprocess.length > 0) {
|
||||||
|
// Reset these sessions to unprocessed
|
||||||
|
await prisma.session.updateMany({
|
||||||
|
where: {
|
||||||
|
id: { in: sessionsToReprocess.map(s => s.id) }
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
processed: false,
|
||||||
|
questions: null,
|
||||||
|
summary: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Reset ${sessionsToReprocess.length} sessions for reprocessing`);
|
||||||
|
console.log('Session IDs:', sessionsToReprocess.map(s => s.id));
|
||||||
|
|
||||||
|
console.log('\n🚀 Now run this command to test the improved prompt:');
|
||||||
|
console.log('npx tsx scripts/trigger-processing-direct.js');
|
||||||
|
console.log('\nThen check the results with:');
|
||||||
|
console.log('npx tsx scripts/check-questions-issue.js');
|
||||||
|
} else {
|
||||||
|
console.log('❌ No sessions with empty questions found to reprocess');
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
testImprovedPrompt();
|
||||||
@ -1,36 +1,39 @@
|
|||||||
// Script to check processing status and trigger processing
|
// Script to check processing status and trigger processing
|
||||||
// Usage: node scripts/test-processing-status.js
|
// Usage: node scripts/test-processing-status.js
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function checkProcessingStatus() {
|
async function checkProcessingStatus() {
|
||||||
try {
|
try {
|
||||||
console.log('🔍 Checking processing status...\n');
|
console.log("🔍 Checking processing status...\n");
|
||||||
|
|
||||||
// Get processing status
|
// Get processing status
|
||||||
const totalSessions = await prisma.session.count();
|
const totalSessions = await prisma.session.count();
|
||||||
const processedSessions = await prisma.session.count({
|
const processedSessions = await prisma.session.count({
|
||||||
where: { processed: true }
|
where: { processed: true },
|
||||||
});
|
});
|
||||||
const unprocessedSessions = await prisma.session.count({
|
const unprocessedSessions = await prisma.session.count({
|
||||||
where: { processed: false }
|
where: { processed: false },
|
||||||
});
|
});
|
||||||
const sessionsWithMessages = await prisma.session.count({
|
const sessionsWithMessages = await prisma.session.count({
|
||||||
where: {
|
where: {
|
||||||
processed: false,
|
processed: false,
|
||||||
messages: { some: {} }
|
messages: { some: {} },
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('📊 Processing Status:');
|
console.log("📊 Processing Status:");
|
||||||
console.log(` Total sessions: ${totalSessions}`);
|
console.log(` Total sessions: ${totalSessions}`);
|
||||||
console.log(` ✅ Processed: ${processedSessions}`);
|
console.log(` ✅ Processed: ${processedSessions}`);
|
||||||
console.log(` ⏳ Unprocessed: ${unprocessedSessions}`);
|
console.log(` ⏳ Unprocessed: ${unprocessedSessions}`);
|
||||||
console.log(` 📝 Unprocessed with messages: ${sessionsWithMessages}`);
|
console.log(` 📝 Unprocessed with messages: ${sessionsWithMessages}`);
|
||||||
|
|
||||||
const processedPercentage = ((processedSessions / totalSessions) * 100).toFixed(1);
|
const processedPercentage = (
|
||||||
|
(processedSessions / totalSessions) *
|
||||||
|
100
|
||||||
|
).toFixed(1);
|
||||||
console.log(` 📈 Processing progress: ${processedPercentage}%\n`);
|
console.log(` 📈 Processing progress: ${processedPercentage}%\n`);
|
||||||
|
|
||||||
// Check recent processing activity
|
// Check recent processing activity
|
||||||
@ -38,35 +41,40 @@ async function checkProcessingStatus() {
|
|||||||
where: {
|
where: {
|
||||||
processed: true,
|
processed: true,
|
||||||
createdAt: {
|
createdAt: {
|
||||||
gte: new Date(Date.now() - 60 * 60 * 1000) // Last hour
|
gte: new Date(Date.now() - 60 * 60 * 1000), // Last hour
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: "desc" },
|
||||||
take: 5,
|
take: 5,
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
category: true,
|
category: true,
|
||||||
sentiment: true
|
sentiment: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (recentlyProcessed.length > 0) {
|
if (recentlyProcessed.length > 0) {
|
||||||
console.log('🕒 Recently processed sessions:');
|
console.log("🕒 Recently processed sessions:");
|
||||||
recentlyProcessed.forEach(session => {
|
recentlyProcessed.forEach((session) => {
|
||||||
const timeAgo = Math.round((Date.now() - session.createdAt.getTime()) / 1000 / 60);
|
const timeAgo = Math.round(
|
||||||
console.log(` • ${session.id.substring(0, 8)}... (${timeAgo}m ago) - ${session.category || 'No category'}`);
|
(Date.now() - session.createdAt.getTime()) / 1000 / 60
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` • ${session.id.substring(0, 8)}... (${timeAgo}m ago) - ${session.category || "No category"}`
|
||||||
|
);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log('🕒 No sessions processed in the last hour');
|
console.log("🕒 No sessions processed in the last hour");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\n✨ Processing system is working correctly!');
|
console.log("\n✨ Processing system is working correctly!");
|
||||||
console.log('💡 The parallel processing successfully processed sessions.');
|
console.log("💡 The parallel processing successfully processed sessions.");
|
||||||
console.log('🎯 For manual triggers, you need to be logged in as an admin user.');
|
console.log(
|
||||||
|
"🎯 For manual triggers, you need to be logged in as an admin user."
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error checking status:', error);
|
console.error("❌ Error checking status:", error);
|
||||||
} finally {
|
} finally {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
57
scripts/trigger-csv-refresh.js
Normal file
57
scripts/trigger-csv-refresh.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
// Trigger CSV refresh for all companies
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function triggerCsvRefresh() {
|
||||||
|
try {
|
||||||
|
console.log('🔄 Triggering CSV refresh for all companies...\n');
|
||||||
|
|
||||||
|
// Get all companies
|
||||||
|
const companies = await prisma.company.findMany();
|
||||||
|
|
||||||
|
if (companies.length === 0) {
|
||||||
|
console.log('❌ No companies found. Run seed script first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🏢 Found ${companies.length} companies:`);
|
||||||
|
|
||||||
|
for (const company of companies) {
|
||||||
|
console.log(`📊 Company: ${company.name} (ID: ${company.id})`);
|
||||||
|
console.log(`📥 CSV URL: ${company.csvUrl}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:3000/api/admin/refresh-sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
companyId: company.id
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log(`✅ Successfully imported ${result.imported} sessions for ${company.name}`);
|
||||||
|
} else {
|
||||||
|
console.log(`❌ Error for ${company.name}: ${result.error}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`❌ Failed to refresh ${company.name}: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(''); // Empty line for readability
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error triggering CSV refresh:', error);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the script
|
||||||
|
triggerCsvRefresh();
|
||||||
@ -1,20 +1,21 @@
|
|||||||
// Direct trigger for processing scheduler (bypasses authentication)
|
// Direct processing trigger without authentication
|
||||||
// Usage: node scripts/trigger-processing-direct.js
|
import { processUnprocessedSessions } from '../lib/processingScheduler.ts';
|
||||||
|
|
||||||
import { processUnprocessedSessions } from '../lib/processingScheduler.js';
|
|
||||||
|
|
||||||
async function triggerProcessing() {
|
async function triggerProcessing() {
|
||||||
try {
|
try {
|
||||||
console.log('🚀 Manually triggering processing scheduler...\n');
|
console.log('🤖 Starting complete batch processing of all unprocessed sessions...\n');
|
||||||
|
|
||||||
// Process with custom parameters
|
// Process all unprocessed sessions in batches until completion
|
||||||
await processUnprocessedSessions(50, 3); // Process 50 sessions with 3 concurrent workers
|
const result = await processUnprocessedSessions(10, 3);
|
||||||
|
|
||||||
console.log('\n✅ Processing trigger completed!');
|
console.log('\n🎉 Complete processing finished!');
|
||||||
|
console.log(`📊 Final results: ${result.totalProcessed} processed, ${result.totalFailed} failed`);
|
||||||
|
console.log(`⏱️ Total time: ${result.totalTime.toFixed(2)}s`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error triggering processing:', error);
|
console.error('❌ Error during processing:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run the script
|
||||||
triggerProcessing();
|
triggerProcessing();
|
||||||
|
|||||||
56
server.mjs
56
server.mjs
@ -1,56 +0,0 @@
|
|||||||
// Custom Next.js server with scheduler initialization
|
|
||||||
import { createServer } from "http";
|
|
||||||
import { parse } from "url";
|
|
||||||
import next from "next";
|
|
||||||
|
|
||||||
// We'll need to dynamically import these after they're compiled
|
|
||||||
let startScheduler;
|
|
||||||
let startProcessingScheduler;
|
|
||||||
|
|
||||||
const dev = process.env.NODE_ENV !== "production";
|
|
||||||
const hostname = "localhost";
|
|
||||||
const port = parseInt(process.env.PORT || "3000", 10);
|
|
||||||
|
|
||||||
// Initialize Next.js
|
|
||||||
const app = next({ dev, hostname, port });
|
|
||||||
const handle = app.getRequestHandler();
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
try {
|
|
||||||
// Dynamically import the schedulers
|
|
||||||
const scheduler = await import("./lib/scheduler.js");
|
|
||||||
const processingScheduler = await import("./lib/processingScheduler.js");
|
|
||||||
|
|
||||||
startScheduler = scheduler.startScheduler;
|
|
||||||
startProcessingScheduler = processingScheduler.startProcessingScheduler;
|
|
||||||
|
|
||||||
app.prepare().then(() => {
|
|
||||||
// Initialize schedulers when the server starts
|
|
||||||
console.log("Starting schedulers...");
|
|
||||||
startScheduler();
|
|
||||||
startProcessingScheduler();
|
|
||||||
console.log("All schedulers initialized successfully");
|
|
||||||
|
|
||||||
createServer(async (req, res) => {
|
|
||||||
try {
|
|
||||||
// Parse the URL
|
|
||||||
const parsedUrl = parse(req.url || "", true);
|
|
||||||
|
|
||||||
// Let Next.js handle the request
|
|
||||||
await handle(req, res, parsedUrl);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error occurred handling", req.url, err);
|
|
||||||
res.statusCode = 500;
|
|
||||||
res.end("Internal Server Error");
|
|
||||||
}
|
|
||||||
}).listen(port, () => {
|
|
||||||
console.log(`> Ready on http://${hostname}:${port}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to initialize server:", error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
|
||||||
22
server.ts
22
server.ts
@ -2,8 +2,7 @@
|
|||||||
import { createServer } from "http";
|
import { createServer } from "http";
|
||||||
import { parse } from "url";
|
import { parse } from "url";
|
||||||
import next from "next";
|
import next from "next";
|
||||||
import { startScheduler } from "./lib/scheduler.js";
|
import { processUnprocessedSessions } from "./lib/processingSchedulerNoCron.js";
|
||||||
import { startProcessingScheduler } from "./lib/processingScheduler.js";
|
|
||||||
|
|
||||||
const dev = process.env.NODE_ENV !== "production";
|
const dev = process.env.NODE_ENV !== "production";
|
||||||
const hostname = "localhost";
|
const hostname = "localhost";
|
||||||
@ -14,11 +13,20 @@ const app = next({ dev, hostname, port });
|
|||||||
const handle = app.getRequestHandler();
|
const handle = app.getRequestHandler();
|
||||||
|
|
||||||
app.prepare().then(() => {
|
app.prepare().then(() => {
|
||||||
// Initialize schedulers when the server starts
|
// Start processing scheduler in the background
|
||||||
console.log("Starting schedulers...");
|
const BATCH_SIZE = 10;
|
||||||
startScheduler();
|
const MAX_CONCURRENCY = 5;
|
||||||
startProcessingScheduler();
|
const SCHEDULER_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||||
console.log("All schedulers initialized successfully");
|
|
||||||
|
// Initial processing run
|
||||||
|
processUnprocessedSessions(BATCH_SIZE, MAX_CONCURRENCY).catch(console.error);
|
||||||
|
|
||||||
|
// Schedule regular processing
|
||||||
|
setInterval(() => {
|
||||||
|
processUnprocessedSessions(BATCH_SIZE, MAX_CONCURRENCY).catch(console.error);
|
||||||
|
}, SCHEDULER_INTERVAL);
|
||||||
|
|
||||||
|
console.log("Processing scheduler started with 5 minute interval");
|
||||||
|
|
||||||
createServer(async (req, res) => {
|
createServer(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user