mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 11:32:13 +01:00
shit
This commit is contained in:
@ -7,6 +7,14 @@
|
|||||||
"--db-path",
|
"--db-path",
|
||||||
"./prisma/dev.db"
|
"./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
|
||||||
|
```
|
||||||
157
TODO.md
157
TODO.md
@ -3,7 +3,7 @@
|
|||||||
# Refactor!!!
|
# Refactor!!!
|
||||||
|
|
||||||
> Based on my analysis of the codebase, here is a plan with recommendations for improving the project. The focus is on enhancing standardization, abstraction, user experience, and visual
|
> Based on my analysis of the codebase, here is a plan with recommendations for improving the project. The focus is on enhancing standardization, abstraction, user experience, and visual
|
||||||
design.
|
> design.
|
||||||
|
|
||||||
## High-Level Recommendations
|
## High-Level Recommendations
|
||||||
|
|
||||||
@ -25,157 +25,54 @@ Here is a phased plan to implement these recommendations:
|
|||||||
This phase focuses on cleaning up the codebase, standardizing the project structure, and improving the abstraction of core functionalities.
|
This phase focuses on cleaning up the codebase, standardizing the project structure, and improving the abstraction of core functionalities.
|
||||||
|
|
||||||
1. Standardize Project Structure:
|
1. Standardize Project Structure:
|
||||||
* Unify Server File: Consolidate server.js, server.mjs, and server.ts into a single server.ts file to remove redundancy.
|
|
||||||
* Migrate to App Router: Move all routes from the pages/api directory to the app/api directory. This will centralize routing logic within the app directory.
|
- [x] Unify Server File: Consolidated server.js, server.mjs, and server.ts into a single server.ts file to remove redundancy. ✅
|
||||||
* Standardize Naming Conventions: Ensure all files and components follow a consistent naming convention (e.g., PascalCase for components, kebab-case for files).
|
- [x] Migrate to App Router: All API routes moved from `pages/api` to `app/api`. ✅
|
||||||
|
- [x] Standardize Naming Conventions: All files and components already follow a consistent naming convention (e.g., PascalCase for components, kebab-case for files). ✅
|
||||||
|
|
||||||
2. Introduce a UI Component Library:
|
2. Introduce a UI Component Library:
|
||||||
* Integrate ShadCN/UI: Add ShadCN/UI to the project to leverage its extensive library of accessible and customizable components.
|
|
||||||
* Replace Custom Components: Gradually replace custom-built components in the components/ directory with their ShadCN/UI equivalents. This will improve visual consistency and reduce
|
- Integrate ShadCN/UI: Add ShadCN/UI to the project to leverage its extensive library of accessible and customizable components.
|
||||||
|
- Replace Custom Components: Gradually replace custom-built components in the components/ directory with their ShadCN/UI equivalents. This will improve visual consistency and reduce
|
||||||
maintenance overhead.
|
maintenance overhead.
|
||||||
|
|
||||||
3. Refactor Core Logic:
|
3. Refactor Core Logic:
|
||||||
* Centralize Data Fetching: Create a dedicated module (e.g., lib/data-service.ts) to handle all data fetching logic, abstracting away the details of using Prisma and external APIs.
|
- Centralize Data Fetching: Create a dedicated module (e.g., lib/data-service.ts) to handle all data fetching logic, abstracting away the details of using Prisma and external APIs.
|
||||||
* Isolate Business Logic: Ensure that business logic (e.g., session processing, metric calculation) is separated from the API routes and UI components.
|
- Isolate Business Logic: Ensure that business logic (e.g., session processing, metric calculation) is separated from the API routes and UI components.
|
||||||
|
|
||||||
### Phase 2: UX and Visual Enhancements
|
### Phase 2: UX and Visual Enhancements
|
||||||
|
|
||||||
This phase focuses on improving the user-facing aspects of the application.
|
This phase focuses on improving the user-facing aspects of the application.
|
||||||
|
|
||||||
1. Implement Comprehensive Loading and Error States:
|
1. Implement Comprehensive Loading and Error States:
|
||||||
* Skeleton Loaders: Use skeleton loaders for dashboard components to provide a better loading experience.
|
|
||||||
* Global Error Handling: Implement a global error handling strategy to catch and display user-friendly error messages for API failures or other unexpected issues.
|
- Skeleton Loaders: Use skeleton loaders for dashboard components to provide a better loading experience.
|
||||||
|
- Global Error Handling: Implement a global error handling strategy to catch and display user-friendly error messages for API failures or other unexpected issues.
|
||||||
|
|
||||||
2. Redesign the Dashboard:
|
2. Redesign the Dashboard:
|
||||||
* Improve Information Hierarchy: Reorganize the dashboard to present the most important information first.
|
|
||||||
* Enhance Visual Appeal: Use the new component library to create a more modern and visually appealing design with a consistent color palette and typography.
|
- Improve Information Hierarchy: Reorganize the dashboard to present the most important information first.
|
||||||
* Improve Chart Interactivity: Add features like tooltips, zooming, and filtering to the charts to make them more interactive and informative.
|
- Enhance Visual Appeal: Use the new component library to create a more modern and visually appealing design with a consistent color palette and typography.
|
||||||
|
- Improve Chart Interactivity: Add features like tooltips, zooming, and filtering to the charts to make them more interactive and informative.
|
||||||
|
|
||||||
3. Ensure Full Responsiveness:
|
3. Ensure Full Responsiveness:
|
||||||
* Mobile-First Approach: Review and update all pages and components to ensure they are fully responsive and usable on a wide range of devices.
|
- Mobile-First Approach: Review and update all pages and components to ensure they are fully responsive and usable on a wide range of devices.
|
||||||
|
|
||||||
### Phase 3: Advanced Topics (Security, Performance, and Documentation)
|
### Phase 3: Advanced Topics (Security, Performance, and Documentation)
|
||||||
|
|
||||||
This phase focuses on long-term improvements to the project's stability, performance, and maintainability.
|
This phase focuses on long-term improvements to the project's stability, performance, and maintainability.
|
||||||
|
|
||||||
1. Conduct a Security Review:
|
1. Conduct a Security Review:
|
||||||
* Input Validation: Ensure that all user inputs are properly validated on both the client and server sides.
|
|
||||||
* Dependency Audit: Regularly audit dependencies for known vulnerabilities.
|
- Input Validation: Ensure that all user inputs are properly validated on both the client and server sides.
|
||||||
|
- Dependency Audit: Regularly audit dependencies for known vulnerabilities.
|
||||||
|
|
||||||
2. Optimize Performance:
|
2. Optimize Performance:
|
||||||
* Code Splitting: Leverage Next.js's automatic code splitting to reduce initial load times.
|
|
||||||
* Caching: Implement caching strategies for frequently accessed data to reduce database load and improve API response times.
|
- Code Splitting: Leverage Next.js's automatic code splitting to reduce initial load times.
|
||||||
|
- Caching: Implement caching strategies for frequently accessed data to reduce database load and improve API response times.
|
||||||
|
|
||||||
3. Expand Documentation:
|
3. Expand Documentation:
|
||||||
* API Documentation: Create detailed documentation for all API endpoints.
|
- API Documentation: Create detailed documentation for all API endpoints.
|
||||||
* Component Library: Document the usage and props of all reusable components.
|
- Component Library: Document the usage and props of all reusable components.
|
||||||
* Update `AGENTS.md`: Keep the AGENTS.md file up-to-date with any architectural changes.
|
- Update `AGENTS.md`: Keep the AGENTS.md file up-to-date with any architectural changes.
|
||||||
|
|
||||||
Would you like me to start implementing any part of this plan? I would suggest starting with Phase 1 to build a solid foundation for the other improvements.
|
|
||||||
|
|
||||||
## Dashboard Integration
|
|
||||||
|
|
||||||
- [ ] **Resolve `GeographicMap.tsx` and `ResponseTimeDistribution.tsx` data simulation**
|
|
||||||
- Investigate integrating real data sources with server-side analytics
|
|
||||||
- Replace simulated data mentioned in `docs/dashboard-components.md`
|
|
||||||
|
|
||||||
## Component Specific
|
|
||||||
|
|
||||||
- [ ] **Implement robust emailing of temporary passwords**
|
|
||||||
|
|
||||||
- File: `pages/api/dashboard/users.ts`
|
|
||||||
- Set up proper email service integration
|
|
||||||
|
|
||||||
- [x] **Session page improvements** ✅
|
|
||||||
- File: `app/dashboard/sessions/page.tsx`
|
|
||||||
- Implemented pagination, advanced filtering, and sorting
|
|
||||||
|
|
||||||
## File Cleanup
|
|
||||||
|
|
||||||
- [x] **Remove backup files** ✅
|
|
||||||
- Reviewed and removed `.bak` and `.new` files after integration
|
|
||||||
- Cleaned up `GeographicMap.tsx.bak`, `SessionDetails.tsx.bak`, `SessionDetails.tsx.new`
|
|
||||||
|
|
||||||
## Database Schema Improvements
|
|
||||||
|
|
||||||
- [ ] **Update EndTime field**
|
|
||||||
|
|
||||||
- Make `endTime` field nullable in Prisma schema to match TypeScript interfaces
|
|
||||||
|
|
||||||
- [ ] **Add database indices**
|
|
||||||
|
|
||||||
- Add appropriate indices to improve query performance
|
|
||||||
- Focus on dashboard metrics and session listing queries
|
|
||||||
|
|
||||||
- [ ] **Implement production email service**
|
|
||||||
- Replace console logging in `lib/sendEmail.ts`
|
|
||||||
- Consider providers: Nodemailer, SendGrid, AWS SES
|
|
||||||
|
|
||||||
## General Enhancements & Features
|
|
||||||
|
|
||||||
- [ ] **Real-time updates**
|
|
||||||
|
|
||||||
- Implement for dashboard and session list
|
|
||||||
- Consider WebSockets or Server-Sent Events
|
|
||||||
|
|
||||||
- [ ] **Data export functionality**
|
|
||||||
|
|
||||||
- Allow users (especially admins) to export session data
|
|
||||||
- Support CSV format initially
|
|
||||||
|
|
||||||
- [ ] **Customizable dashboard**
|
|
||||||
- Allow users to customize dashboard view
|
|
||||||
- Let users choose which metrics/charts are most important
|
|
||||||
|
|
||||||
## Testing & Quality Assurance
|
|
||||||
|
|
||||||
- [ ] **Comprehensive testing suite**
|
|
||||||
|
|
||||||
- [ ] Unit tests for utility functions and API logic
|
|
||||||
- [ ] Integration tests for API endpoints with database
|
|
||||||
- [ ] End-to-end tests for user flows (Playwright or Cypress)
|
|
||||||
|
|
||||||
- [ ] **Error monitoring and logging**
|
|
||||||
|
|
||||||
- Integrate robust error monitoring service (Sentry)
|
|
||||||
- Enhance server-side logging
|
|
||||||
|
|
||||||
- [ ] **Accessibility improvements**
|
|
||||||
- Review application against WCAG guidelines
|
|
||||||
- Improve keyboard navigation and screen reader compatibility
|
|
||||||
- Check color contrast ratios
|
|
||||||
|
|
||||||
## Security Enhancements
|
|
||||||
|
|
||||||
- [x] **Password reset functionality** ✅
|
|
||||||
|
|
||||||
- Implemented secure password reset mechanism
|
|
||||||
- Files: `app/forgot-password/page.tsx`, `app/reset-password/page.tsx`, `pages/api/forgot-password.ts`, `pages/api/reset-password.ts`
|
|
||||||
|
|
||||||
- [ ] **Two-Factor Authentication (2FA)**
|
|
||||||
|
|
||||||
- Consider adding 2FA, especially for admin accounts
|
|
||||||
|
|
||||||
- [ ] **Input validation and sanitization**
|
|
||||||
- Review all user inputs (API request bodies, query parameters)
|
|
||||||
- Ensure proper validation and sanitization
|
|
||||||
|
|
||||||
## Code Quality & Development
|
|
||||||
|
|
||||||
- [ ] **Code review process**
|
|
||||||
|
|
||||||
- Enforce code reviews for all changes
|
|
||||||
|
|
||||||
- [ ] **Environment configuration**
|
|
||||||
|
|
||||||
- Ensure secure management of environment-specific configurations
|
|
||||||
|
|
||||||
- [ ] **Dependency management**
|
|
||||||
|
|
||||||
- Periodically review dependencies for vulnerabilities
|
|
||||||
- Keep dependencies updated
|
|
||||||
|
|
||||||
- [ ] **Documentation updates**
|
|
||||||
- [ ] Ensure `docs/dashboard-components.md` reflects actual implementations
|
|
||||||
- [ ] Verify "Dashboard Enhancements" are consistently applied
|
|
||||||
- [ ] Update documentation for improved layout and visual hierarchies
|
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
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 } });
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
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; }}
|
||||||
@ -8,6 +8,21 @@ const nextConfig = {
|
|||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
"localhost"
|
"localhost"
|
||||||
],
|
],
|
||||||
|
// Disable Turbopack for now due to EISDIR error on Windows
|
||||||
|
webpack: (config, { isServer }) => {
|
||||||
|
if (!isServer) {
|
||||||
|
config.resolve.fallback = { fs: false, net: false, tls: false };
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
experimental: {
|
||||||
|
appDir: true,
|
||||||
|
serverComponentsExternalPackages: ['@prisma/client', 'bcryptjs'],
|
||||||
|
// disable the new Turbopack engine
|
||||||
|
// This is a temporary workaround for the EISDIR error on Windows
|
||||||
|
// Remove this once the issue is resolved in Next.js or Turbopack
|
||||||
|
turbopack: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
1183
package-lock.json
generated
1183
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@ -5,7 +5,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev",
|
||||||
"dev:with-server": "tsx server.ts",
|
"dev:with-server": "tsx server.ts",
|
||||||
"format": "npx prettier --write .",
|
"format": "npx prettier --write .",
|
||||||
"format:check": "npx prettier --check .",
|
"format:check": "npx prettier --check .",
|
||||||
@ -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",
|
||||||
@ -71,6 +77,7 @@
|
|||||||
"tailwindcss": "^4.1.7",
|
"tailwindcss": "^4.1.7",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
|
"tw-animate-css": "^1.3.4",
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0"
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
|
|||||||
@ -1,315 +0,0 @@
|
|||||||
// API route to refresh (fetch+parse+update) session data for a company
|
|
||||||
import { NextApiRequest, NextApiResponse } from "next";
|
|
||||||
import { fetchAndParseCsv } from "../../../lib/csvFetcher";
|
|
||||||
import { prisma } from "../../../lib/prisma";
|
|
||||||
import { processUnprocessedSessions } from "../../../lib/processingSchedulerNoCron";
|
|
||||||
import { exec } from "child_process";
|
|
||||||
import { promisify } from "util";
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Triggers the complete workflow: fetch transcripts + process all sessions
|
|
||||||
*/
|
|
||||||
async function triggerCompleteWorkflow(): Promise<{ message: string }> {
|
|
||||||
try {
|
|
||||||
// Step 1: Fetch missing transcripts
|
|
||||||
const sessionsWithoutMessages = await prisma.session.count({
|
|
||||||
where: {
|
|
||||||
messages: { none: {} },
|
|
||||||
fullTranscriptUrl: { not: null }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (sessionsWithoutMessages > 0) {
|
|
||||||
console.log(`[Complete Workflow] Fetching transcripts for ${sessionsWithoutMessages} sessions`);
|
|
||||||
|
|
||||||
// Get sessions that have fullTranscriptUrl but no messages
|
|
||||||
const sessionsToProcess = await prisma.session.findMany({
|
|
||||||
where: {
|
|
||||||
AND: [
|
|
||||||
{ fullTranscriptUrl: { not: null } },
|
|
||||||
{ messages: { none: {} } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
company: true,
|
|
||||||
},
|
|
||||||
take: 20, // Process in batches
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const session of sessionsToProcess) {
|
|
||||||
try {
|
|
||||||
if (!session.fullTranscriptUrl) continue;
|
|
||||||
|
|
||||||
// Fetch transcript content
|
|
||||||
const transcriptContent = await fetchTranscriptContent(
|
|
||||||
session.fullTranscriptUrl,
|
|
||||||
session.company.csvUsername || undefined,
|
|
||||||
session.company.csvPassword || undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!transcriptContent) {
|
|
||||||
console.log(`No transcript content for session ${session.id}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse transcript into messages
|
|
||||||
const lines = transcriptContent.split("\n").filter((line) => line.trim());
|
|
||||||
const messages: Array<{
|
|
||||||
sessionId: string;
|
|
||||||
role: string;
|
|
||||||
content: string;
|
|
||||||
timestamp: Date;
|
|
||||||
order: number;
|
|
||||||
}> = [];
|
|
||||||
let messageOrder = 0;
|
|
||||||
const currentTimestamp = new Date();
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
// Try format: [DD-MM-YYYY HH:MM:SS] Role: Content
|
|
||||||
const timestampMatch = line.match(/^\[([^\]]+)\]\s*([^:]+):\s*(.+)$/);
|
|
||||||
|
|
||||||
if (timestampMatch) {
|
|
||||||
const [, timestamp, role, content] = timestampMatch;
|
|
||||||
const dateMatch = timestamp.match(/^(\d{1,2})-(\d{1,2})-(\d{4}) (\d{1,2}):(\d{1,2}):(\d{1,2})$/);
|
|
||||||
let parsedTimestamp = new Date();
|
|
||||||
|
|
||||||
if (dateMatch) {
|
|
||||||
const [, day, month, year, hour, minute, second] = dateMatch;
|
|
||||||
parsedTimestamp = new Date(
|
|
||||||
parseInt(year),
|
|
||||||
parseInt(month) - 1,
|
|
||||||
parseInt(day),
|
|
||||||
parseInt(hour),
|
|
||||||
parseInt(minute),
|
|
||||||
parseInt(second)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
messages.push({
|
|
||||||
sessionId: session.id,
|
|
||||||
role: role.trim().toLowerCase(),
|
|
||||||
content: content.trim(),
|
|
||||||
timestamp: parsedTimestamp,
|
|
||||||
order: messageOrder++,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messages.length > 0) {
|
|
||||||
// Save messages to database
|
|
||||||
await prisma.message.createMany({
|
|
||||||
data: messages as any, // Type assertion needed due to Prisma types
|
|
||||||
});
|
|
||||||
console.log(`Added ${messages.length} messages for session ${session.id}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error processing session ${session.id}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Process all unprocessed sessions
|
|
||||||
const unprocessedWithMessages = await prisma.session.count({
|
|
||||||
where: {
|
|
||||||
processed: false,
|
|
||||||
messages: { some: {} }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (unprocessedWithMessages > 0) {
|
|
||||||
console.log(`[Complete Workflow] Processing ${unprocessedWithMessages} sessions`);
|
|
||||||
await processUnprocessedSessions();
|
|
||||||
}
|
|
||||||
|
|
||||||
return { message: `Complete workflow finished successfully` };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Complete Workflow] Error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionCreateData {
|
|
||||||
id: string;
|
|
||||||
startTime: Date;
|
|
||||||
companyId: string;
|
|
||||||
sessionId?: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches transcript content from a URL
|
|
||||||
* @param url The URL to fetch the transcript from
|
|
||||||
* @param username Optional username for authentication
|
|
||||||
* @param password Optional password for authentication
|
|
||||||
* @returns The transcript content or null if fetching fails
|
|
||||||
*/
|
|
||||||
async function fetchTranscriptContent(
|
|
||||||
url: string,
|
|
||||||
username?: string,
|
|
||||||
password?: string
|
|
||||||
): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const authHeader =
|
|
||||||
username && password
|
|
||||||
? "Basic " + Buffer.from(`${username}:${password}`).toString("base64")
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: authHeader ? { Authorization: authHeader } : {},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
process.stderr.write(
|
|
||||||
`Error fetching transcript: ${response.statusText}\n`
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return await response.text();
|
|
||||||
} catch (error) {
|
|
||||||
process.stderr.write(`Failed to fetch transcript: ${error}\n`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function handler(
|
|
||||||
req: NextApiRequest,
|
|
||||||
res: NextApiResponse
|
|
||||||
) {
|
|
||||||
// Check if this is a POST request
|
|
||||||
if (req.method !== "POST") {
|
|
||||||
return res.status(405).json({ error: "Method not allowed" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get companyId from body or query
|
|
||||||
let { companyId } = req.body;
|
|
||||||
|
|
||||||
if (!companyId) {
|
|
||||||
// Try to get user from prisma based on session cookie
|
|
||||||
try {
|
|
||||||
const session = await prisma.session.findFirst({
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
where: {
|
|
||||||
/* Add session check criteria here */
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (session) {
|
|
||||||
companyId = session.companyId;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Log error for server-side debugging
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : String(error);
|
|
||||||
// Use a server-side logging approach instead of console
|
|
||||||
process.stderr.write(`Error fetching session: ${errorMessage}\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!companyId) {
|
|
||||||
return res.status(400).json({ error: "Company ID is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const company = await prisma.company.findUnique({ where: { id: companyId } });
|
|
||||||
if (!company) return res.status(404).json({ error: "Company not found" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sessions = await fetchAndParseCsv(
|
|
||||||
company.csvUrl,
|
|
||||||
company.csvUsername as string | undefined,
|
|
||||||
company.csvPassword as string | undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
// Only add sessions that don't already exist in the database
|
|
||||||
for (const session of sessions) {
|
|
||||||
const sessionData: SessionCreateData = {
|
|
||||||
...session,
|
|
||||||
companyId: company.id,
|
|
||||||
id:
|
|
||||||
session.id ||
|
|
||||||
session.sessionId ||
|
|
||||||
`sess_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
|
|
||||||
// Ensure startTime is not undefined
|
|
||||||
startTime: session.startTime || new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validate dates to prevent "Invalid Date" errors
|
|
||||||
const startTime =
|
|
||||||
sessionData.startTime instanceof Date &&
|
|
||||||
!isNaN(sessionData.startTime.getTime())
|
|
||||||
? sessionData.startTime
|
|
||||||
: new Date();
|
|
||||||
|
|
||||||
const endTime =
|
|
||||||
session.endTime instanceof Date && !isNaN(session.endTime.getTime())
|
|
||||||
? session.endTime
|
|
||||||
: new Date();
|
|
||||||
|
|
||||||
// Note: transcriptContent field was removed from schema
|
|
||||||
// Transcript content can be fetched on-demand from fullTranscriptUrl
|
|
||||||
|
|
||||||
// Check if the session already exists
|
|
||||||
const existingSession = await prisma.session.findUnique({
|
|
||||||
where: { id: sessionData.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingSession) {
|
|
||||||
// Skip this session as it already exists
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only include fields that are properly typed for Prisma
|
|
||||||
await prisma.session.create({
|
|
||||||
data: {
|
|
||||||
id: sessionData.id,
|
|
||||||
companyId: sessionData.companyId,
|
|
||||||
startTime: startTime,
|
|
||||||
endTime: endTime,
|
|
||||||
ipAddress: session.ipAddress || null,
|
|
||||||
country: session.country || null,
|
|
||||||
language: session.language || null,
|
|
||||||
messagesSent:
|
|
||||||
typeof session.messagesSent === "number" ? session.messagesSent : 0,
|
|
||||||
sentiment:
|
|
||||||
typeof session.sentiment === "number" ? session.sentiment : null,
|
|
||||||
escalated:
|
|
||||||
typeof session.escalated === "boolean" ? session.escalated : null,
|
|
||||||
forwardedHr:
|
|
||||||
typeof session.forwardedHr === "boolean"
|
|
||||||
? session.forwardedHr
|
|
||||||
: null,
|
|
||||||
fullTranscriptUrl: session.fullTranscriptUrl || null,
|
|
||||||
avgResponseTime:
|
|
||||||
typeof session.avgResponseTime === "number"
|
|
||||||
? session.avgResponseTime
|
|
||||||
: null,
|
|
||||||
tokens: typeof session.tokens === "number" ? session.tokens : null,
|
|
||||||
tokensEur:
|
|
||||||
typeof session.tokensEur === "number" ? session.tokensEur : null,
|
|
||||||
category: session.category || null,
|
|
||||||
initialMsg: session.initialMsg || null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// After importing sessions, automatically trigger complete workflow (fetch transcripts + process)
|
|
||||||
// This runs in the background without blocking the response
|
|
||||||
triggerCompleteWorkflow()
|
|
||||||
.then((result) => {
|
|
||||||
console.log(`[Refresh Sessions] Complete workflow finished: ${result.message}`);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(`[Refresh Sessions] Complete workflow failed:`, error);
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
ok: true,
|
|
||||||
imported: sessions.length,
|
|
||||||
message: "Sessions imported and complete processing workflow started automatically"
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
const error = e instanceof Error ? e.message : "An unknown error occurred";
|
|
||||||
res.status(500).json({ error });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,109 +0,0 @@
|
|||||||
import { NextApiRequest, NextApiResponse } from "next";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "../auth/[...nextauth]";
|
|
||||||
import { prisma } from "../../../lib/prisma";
|
|
||||||
import { processUnprocessedSessions } from "../../../lib/processingSchedulerNoCron";
|
|
||||||
|
|
||||||
interface SessionUser {
|
|
||||||
email: string;
|
|
||||||
name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionData {
|
|
||||||
user: SessionUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function handler(
|
|
||||||
req: NextApiRequest,
|
|
||||||
res: NextApiResponse
|
|
||||||
) {
|
|
||||||
if (req.method !== "POST") {
|
|
||||||
return res.status(405).json({ error: "Method not allowed" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = (await getServerSession(
|
|
||||||
req,
|
|
||||||
res,
|
|
||||||
authOptions
|
|
||||||
)) as SessionData | null;
|
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
return res.status(401).json({ error: "Not logged in" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { email: session.user.email },
|
|
||||||
include: { company: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return res.status(401).json({ error: "No user found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user has admin role
|
|
||||||
if (user.role !== "admin") {
|
|
||||||
return res.status(403).json({ error: "Admin access required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get optional parameters from request body
|
|
||||||
const { batchSize, maxConcurrency } = req.body;
|
|
||||||
|
|
||||||
// Validate parameters
|
|
||||||
const validatedBatchSize =
|
|
||||||
batchSize && batchSize > 0 ? parseInt(batchSize) : null;
|
|
||||||
const validatedMaxConcurrency =
|
|
||||||
maxConcurrency && maxConcurrency > 0 ? parseInt(maxConcurrency) : 5;
|
|
||||||
|
|
||||||
// Check how many unprocessed sessions exist
|
|
||||||
const unprocessedCount = await prisma.session.count({
|
|
||||||
where: {
|
|
||||||
companyId: user.companyId,
|
|
||||||
processed: false,
|
|
||||||
messages: { some: {} }, // Must have messages
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (unprocessedCount === 0) {
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
message: "No unprocessed sessions found",
|
|
||||||
unprocessedCount: 0,
|
|
||||||
processedCount: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start processing (this will run asynchronously)
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
// Note: We're calling the function but not awaiting it to avoid timeout
|
|
||||||
// The processing will continue in the background
|
|
||||||
processUnprocessedSessions(validatedBatchSize || undefined, validatedMaxConcurrency)
|
|
||||||
.then(() => {
|
|
||||||
console.log(
|
|
||||||
`[Manual Trigger] Processing completed for company ${user.companyId}`
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(
|
|
||||||
`[Manual Trigger] Processing failed for company ${user.companyId}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
message: `Started processing ${unprocessedCount} unprocessed sessions`,
|
|
||||||
unprocessedCount,
|
|
||||||
batchSize: validatedBatchSize || unprocessedCount,
|
|
||||||
maxConcurrency: validatedMaxConcurrency,
|
|
||||||
startedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Manual Trigger] Error:", error);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: "Failed to trigger processing",
|
|
||||||
details: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,118 +0,0 @@
|
|||||||
// API endpoint: return metrics for current company
|
|
||||||
import { NextApiRequest, NextApiResponse } from "next";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { prisma } from "../../../lib/prisma";
|
|
||||||
import { sessionMetrics } from "../../../lib/metrics";
|
|
||||||
import { authOptions } from "../auth/[...nextauth]";
|
|
||||||
import { ChatSession } from "../../../lib/types"; // Import ChatSession
|
|
||||||
|
|
||||||
interface SessionUser {
|
|
||||||
email: string;
|
|
||||||
name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionData {
|
|
||||||
user: SessionUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function handler(
|
|
||||||
req: NextApiRequest,
|
|
||||||
res: NextApiResponse
|
|
||||||
) {
|
|
||||||
const session = (await getServerSession(
|
|
||||||
req,
|
|
||||||
res,
|
|
||||||
authOptions
|
|
||||||
)) as SessionData | null;
|
|
||||||
if (!session?.user) return res.status(401).json({ error: "Not logged in" });
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { email: session.user.email },
|
|
||||||
include: { company: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) return res.status(401).json({ error: "No user" });
|
|
||||||
|
|
||||||
// Get date range from query parameters
|
|
||||||
const { startDate, endDate } = req.query;
|
|
||||||
|
|
||||||
// Build where clause with optional date filtering and only processed sessions
|
|
||||||
const whereClause: any = {
|
|
||||||
companyId: user.companyId,
|
|
||||||
processed: true, // Only show processed sessions in dashboard
|
|
||||||
};
|
|
||||||
|
|
||||||
if (startDate && endDate) {
|
|
||||||
whereClause.startTime = {
|
|
||||||
gte: new Date(startDate as string),
|
|
||||||
lte: new Date((endDate as string) + "T23:59:59.999Z"), // Include full end date
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const prismaSessions = await prisma.session.findMany({
|
|
||||||
where: whereClause,
|
|
||||||
include: {
|
|
||||||
messages: true, // Include messages for question extraction
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Convert Prisma sessions to ChatSession[] type for sessionMetrics
|
|
||||||
const chatSessions: ChatSession[] = prismaSessions.map((ps) => ({
|
|
||||||
id: ps.id, // Map Prisma's id to ChatSession.id
|
|
||||||
sessionId: ps.id, // Map Prisma's id to ChatSession.sessionId
|
|
||||||
companyId: ps.companyId,
|
|
||||||
startTime: new Date(ps.startTime), // Ensure startTime is a Date object
|
|
||||||
endTime: ps.endTime ? new Date(ps.endTime) : null, // Ensure endTime is a Date object or null
|
|
||||||
transcriptContent: "", // Session model doesn't have transcriptContent field
|
|
||||||
createdAt: new Date(ps.createdAt), // Map Prisma's createdAt
|
|
||||||
updatedAt: new Date(ps.createdAt), // Use createdAt for updatedAt as Session model doesn't have updatedAt
|
|
||||||
category: ps.category || undefined,
|
|
||||||
language: ps.language || undefined,
|
|
||||||
country: ps.country || undefined,
|
|
||||||
ipAddress: ps.ipAddress || undefined,
|
|
||||||
sentiment: ps.sentiment === null ? undefined : ps.sentiment,
|
|
||||||
messagesSent: ps.messagesSent === null ? undefined : ps.messagesSent, // Handle null messagesSent
|
|
||||||
avgResponseTime:
|
|
||||||
ps.avgResponseTime === null ? undefined : ps.avgResponseTime,
|
|
||||||
tokens: ps.tokens === null ? undefined : ps.tokens,
|
|
||||||
tokensEur: ps.tokensEur === null ? undefined : ps.tokensEur,
|
|
||||||
escalated: ps.escalated || false,
|
|
||||||
forwardedHr: ps.forwardedHr || false,
|
|
||||||
initialMsg: ps.initialMsg || undefined,
|
|
||||||
fullTranscriptUrl: ps.fullTranscriptUrl || undefined,
|
|
||||||
questions: ps.questions || undefined, // Include questions field
|
|
||||||
summary: ps.summary || undefined, // Include summary field
|
|
||||||
messages: ps.messages || [], // Include messages for question extraction
|
|
||||||
// userId is missing in Prisma Session model, assuming it's not strictly needed for metrics or can be null
|
|
||||||
userId: undefined, // Or some other default/mapping if available
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Pass company config to metrics
|
|
||||||
const companyConfigForMetrics = {
|
|
||||||
sentimentAlert:
|
|
||||||
user.company.sentimentAlert === null
|
|
||||||
? undefined
|
|
||||||
: user.company.sentimentAlert,
|
|
||||||
};
|
|
||||||
|
|
||||||
const metrics = sessionMetrics(chatSessions, companyConfigForMetrics);
|
|
||||||
|
|
||||||
// Calculate date range from sessions
|
|
||||||
let dateRange: { minDate: string; maxDate: string } | null = null;
|
|
||||||
if (prismaSessions.length > 0) {
|
|
||||||
const dates = prismaSessions
|
|
||||||
.map((s) => new Date(s.startTime))
|
|
||||||
.sort((a, b) => a.getTime() - b.getTime());
|
|
||||||
dateRange = {
|
|
||||||
minDate: dates[0].toISOString().split("T")[0], // First session date
|
|
||||||
maxDate: dates[dates.length - 1].toISOString().split("T")[0], // Last session date
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
metrics,
|
|
||||||
csvUrl: user.company.csvUrl,
|
|
||||||
company: user.company,
|
|
||||||
dateRange,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -38,7 +38,7 @@ model Session {
|
|||||||
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?
|
||||||
|
|||||||
@ -66,7 +66,7 @@ async function processTranscriptWithOpenAI(
|
|||||||
"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": "a single question or [\"question 1\", \"question 2\", ...]",
|
"questions": null, or array of questions,
|
||||||
"summary": "brief summary",
|
"summary": "brief summary",
|
||||||
"tokens": number,
|
"tokens": number,
|
||||||
"tokens_eur": number
|
"tokens_eur": number
|
||||||
|
|||||||
Reference in New Issue
Block a user