mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 13:12:10 +01:00
shit
This commit is contained in:
@ -7,6 +7,14 @@
|
||||
"--db-path",
|
||||
"./prisma/dev.db"
|
||||
]
|
||||
},
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"D:\\Notso\\Product\\Vibe-coding\\livedash-node"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
47
GEMINI.md
Normal file
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!!!
|
||||
|
||||
> Based on my analysis of the codebase, here is a plan with recommendations for improving the project. The focus is on enhancing standardization, abstraction, user experience, and visual
|
||||
design.
|
||||
> design.
|
||||
|
||||
## High-Level Recommendations
|
||||
|
||||
@ -25,157 +25,54 @@ Here is a phased plan to implement these recommendations:
|
||||
This phase focuses on cleaning up the codebase, standardizing the project structure, and improving the abstraction of core functionalities.
|
||||
|
||||
1. Standardize Project Structure:
|
||||
* Unify Server File: Consolidate server.js, server.mjs, and server.ts into a single server.ts file to remove redundancy.
|
||||
* Migrate to App Router: Move all routes from the pages/api directory to the app/api directory. This will centralize routing logic within the app directory.
|
||||
* Standardize Naming Conventions: Ensure all files and components follow a consistent naming convention (e.g., PascalCase for components, kebab-case for files).
|
||||
|
||||
- [x] Unify Server File: Consolidated server.js, server.mjs, and server.ts into a single server.ts file to remove redundancy. ✅
|
||||
- [x] Migrate to App Router: All API routes moved from `pages/api` to `app/api`. ✅
|
||||
- [x] Standardize Naming Conventions: All files and components already follow a consistent naming convention (e.g., PascalCase for components, kebab-case for files). ✅
|
||||
|
||||
2. Introduce a UI Component Library:
|
||||
* Integrate ShadCN/UI: Add ShadCN/UI to the project to leverage its extensive library of accessible and customizable components.
|
||||
* Replace Custom Components: Gradually replace custom-built components in the components/ directory with their ShadCN/UI equivalents. This will improve visual consistency and reduce
|
||||
|
||||
- Integrate ShadCN/UI: Add ShadCN/UI to the project to leverage its extensive library of accessible and customizable components.
|
||||
- Replace Custom Components: Gradually replace custom-built components in the components/ directory with their ShadCN/UI equivalents. This will improve visual consistency and reduce
|
||||
maintenance overhead.
|
||||
|
||||
3. Refactor Core Logic:
|
||||
* Centralize Data Fetching: Create a dedicated module (e.g., lib/data-service.ts) to handle all data fetching logic, abstracting away the details of using Prisma and external APIs.
|
||||
* Isolate Business Logic: Ensure that business logic (e.g., session processing, metric calculation) is separated from the API routes and UI components.
|
||||
- Centralize Data Fetching: Create a dedicated module (e.g., lib/data-service.ts) to handle all data fetching logic, abstracting away the details of using Prisma and external APIs.
|
||||
- Isolate Business Logic: Ensure that business logic (e.g., session processing, metric calculation) is separated from the API routes and UI components.
|
||||
|
||||
### Phase 2: UX and Visual Enhancements
|
||||
|
||||
This phase focuses on improving the user-facing aspects of the application.
|
||||
|
||||
1. Implement Comprehensive Loading and Error States:
|
||||
* Skeleton Loaders: Use skeleton loaders for dashboard components to provide a better loading experience.
|
||||
* Global Error Handling: Implement a global error handling strategy to catch and display user-friendly error messages for API failures or other unexpected issues.
|
||||
|
||||
- Skeleton Loaders: Use skeleton loaders for dashboard components to provide a better loading experience.
|
||||
- Global Error Handling: Implement a global error handling strategy to catch and display user-friendly error messages for API failures or other unexpected issues.
|
||||
|
||||
2. Redesign the Dashboard:
|
||||
* Improve Information Hierarchy: Reorganize the dashboard to present the most important information first.
|
||||
* Enhance Visual Appeal: Use the new component library to create a more modern and visually appealing design with a consistent color palette and typography.
|
||||
* Improve Chart Interactivity: Add features like tooltips, zooming, and filtering to the charts to make them more interactive and informative.
|
||||
|
||||
- Improve Information Hierarchy: Reorganize the dashboard to present the most important information first.
|
||||
- Enhance Visual Appeal: Use the new component library to create a more modern and visually appealing design with a consistent color palette and typography.
|
||||
- Improve Chart Interactivity: Add features like tooltips, zooming, and filtering to the charts to make them more interactive and informative.
|
||||
|
||||
3. Ensure Full Responsiveness:
|
||||
* Mobile-First Approach: Review and update all pages and components to ensure they are fully responsive and usable on a wide range of devices.
|
||||
- Mobile-First Approach: Review and update all pages and components to ensure they are fully responsive and usable on a wide range of devices.
|
||||
|
||||
### Phase 3: Advanced Topics (Security, Performance, and Documentation)
|
||||
|
||||
This phase focuses on long-term improvements to the project's stability, performance, and maintainability.
|
||||
|
||||
1. Conduct a Security Review:
|
||||
* Input Validation: Ensure that all user inputs are properly validated on both the client and server sides.
|
||||
* Dependency Audit: Regularly audit dependencies for known vulnerabilities.
|
||||
|
||||
- Input Validation: Ensure that all user inputs are properly validated on both the client and server sides.
|
||||
- Dependency Audit: Regularly audit dependencies for known vulnerabilities.
|
||||
|
||||
2. Optimize Performance:
|
||||
* Code Splitting: Leverage Next.js's automatic code splitting to reduce initial load times.
|
||||
* Caching: Implement caching strategies for frequently accessed data to reduce database load and improve API response times.
|
||||
|
||||
- Code Splitting: Leverage Next.js's automatic code splitting to reduce initial load times.
|
||||
- Caching: Implement caching strategies for frequently accessed data to reduce database load and improve API response times.
|
||||
|
||||
3. Expand Documentation:
|
||||
* API Documentation: Create detailed documentation for all API endpoints.
|
||||
* Component Library: Document the usage and props of all reusable components.
|
||||
* Update `AGENTS.md`: Keep the AGENTS.md file up-to-date with any architectural changes.
|
||||
|
||||
Would you like me to start implementing any part of this plan? I would suggest starting with Phase 1 to build a solid foundation for the other improvements.
|
||||
|
||||
## Dashboard Integration
|
||||
|
||||
- [ ] **Resolve `GeographicMap.tsx` and `ResponseTimeDistribution.tsx` data simulation**
|
||||
- Investigate integrating real data sources with server-side analytics
|
||||
- Replace simulated data mentioned in `docs/dashboard-components.md`
|
||||
|
||||
## Component Specific
|
||||
|
||||
- [ ] **Implement robust emailing of temporary passwords**
|
||||
|
||||
- File: `pages/api/dashboard/users.ts`
|
||||
- Set up proper email service integration
|
||||
|
||||
- [x] **Session page improvements** ✅
|
||||
- File: `app/dashboard/sessions/page.tsx`
|
||||
- Implemented pagination, advanced filtering, and sorting
|
||||
|
||||
## File Cleanup
|
||||
|
||||
- [x] **Remove backup files** ✅
|
||||
- Reviewed and removed `.bak` and `.new` files after integration
|
||||
- Cleaned up `GeographicMap.tsx.bak`, `SessionDetails.tsx.bak`, `SessionDetails.tsx.new`
|
||||
|
||||
## Database Schema Improvements
|
||||
|
||||
- [ ] **Update EndTime field**
|
||||
|
||||
- Make `endTime` field nullable in Prisma schema to match TypeScript interfaces
|
||||
|
||||
- [ ] **Add database indices**
|
||||
|
||||
- Add appropriate indices to improve query performance
|
||||
- Focus on dashboard metrics and session listing queries
|
||||
|
||||
- [ ] **Implement production email service**
|
||||
- Replace console logging in `lib/sendEmail.ts`
|
||||
- Consider providers: Nodemailer, SendGrid, AWS SES
|
||||
|
||||
## General Enhancements & Features
|
||||
|
||||
- [ ] **Real-time updates**
|
||||
|
||||
- Implement for dashboard and session list
|
||||
- Consider WebSockets or Server-Sent Events
|
||||
|
||||
- [ ] **Data export functionality**
|
||||
|
||||
- Allow users (especially admins) to export session data
|
||||
- Support CSV format initially
|
||||
|
||||
- [ ] **Customizable dashboard**
|
||||
- Allow users to customize dashboard view
|
||||
- Let users choose which metrics/charts are most important
|
||||
|
||||
## Testing & Quality Assurance
|
||||
|
||||
- [ ] **Comprehensive testing suite**
|
||||
|
||||
- [ ] Unit tests for utility functions and API logic
|
||||
- [ ] Integration tests for API endpoints with database
|
||||
- [ ] End-to-end tests for user flows (Playwright or Cypress)
|
||||
|
||||
- [ ] **Error monitoring and logging**
|
||||
|
||||
- Integrate robust error monitoring service (Sentry)
|
||||
- Enhance server-side logging
|
||||
|
||||
- [ ] **Accessibility improvements**
|
||||
- Review application against WCAG guidelines
|
||||
- Improve keyboard navigation and screen reader compatibility
|
||||
- Check color contrast ratios
|
||||
|
||||
## Security Enhancements
|
||||
|
||||
- [x] **Password reset functionality** ✅
|
||||
|
||||
- Implemented secure password reset mechanism
|
||||
- Files: `app/forgot-password/page.tsx`, `app/reset-password/page.tsx`, `pages/api/forgot-password.ts`, `pages/api/reset-password.ts`
|
||||
|
||||
- [ ] **Two-Factor Authentication (2FA)**
|
||||
|
||||
- Consider adding 2FA, especially for admin accounts
|
||||
|
||||
- [ ] **Input validation and sanitization**
|
||||
- Review all user inputs (API request bodies, query parameters)
|
||||
- Ensure proper validation and sanitization
|
||||
|
||||
## Code Quality & Development
|
||||
|
||||
- [ ] **Code review process**
|
||||
|
||||
- Enforce code reviews for all changes
|
||||
|
||||
- [ ] **Environment configuration**
|
||||
|
||||
- Ensure secure management of environment-specific configurations
|
||||
|
||||
- [ ] **Dependency management**
|
||||
|
||||
- Periodically review dependencies for vulnerabilities
|
||||
- Keep dependencies updated
|
||||
|
||||
- [ ] **Documentation updates**
|
||||
- [ ] Ensure `docs/dashboard-components.md` reflects actual implementations
|
||||
- [ ] Verify "Dashboard Enhancements" are consistently applied
|
||||
- [ ] Update documentation for improved layout and visual hierarchies
|
||||
- API Documentation: Create detailed documentation for all API endpoints.
|
||||
- Component Library: Document the usage and props of all reusable components.
|
||||
- Update `AGENTS.md`: Keep the AGENTS.md file up-to-date with any architectural changes.
|
||||
|
||||
119
app/globals.css
119
app/globals.css
@ -1 +1,120 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { authOptions } from "../pages/api/auth/[...nextauth]";
|
||||
import { authOptions } from "./api/auth/[...nextauth]/route";
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
21
components.json
Normal file
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",
|
||||
"localhost"
|
||||
],
|
||||
// Disable Turbopack for now due to EISDIR error on Windows
|
||||
webpack: (config, { isServer }) => {
|
||||
if (!isServer) {
|
||||
config.resolve.fallback = { fs: false, net: false, tls: false };
|
||||
}
|
||||
return config;
|
||||
},
|
||||
experimental: {
|
||||
appDir: true,
|
||||
serverComponentsExternalPackages: ['@prisma/client', 'bcryptjs'],
|
||||
// disable the new Turbopack engine
|
||||
// This is a temporary workaround for the EISDIR error on Windows
|
||||
// Remove this once the issue is resolved in Next.js or Turbopack
|
||||
turbopack: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
1183
package-lock.json
generated
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,
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "next dev",
|
||||
"dev:with-server": "tsx server.ts",
|
||||
"format": "npx prettier --write .",
|
||||
"format:check": "npx prettier --check .",
|
||||
@ -31,22 +31,28 @@
|
||||
"bcryptjs": "^3.0.2",
|
||||
"chart.js": "^4.0.0",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"csv-parse": "^5.5.0",
|
||||
"d3": "^7.9.0",
|
||||
"d3-cloud": "^1.2.7",
|
||||
"i18n-iso-countries": "^7.14.0",
|
||||
"iso-639-1": "^3.1.5",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "^15.3.2",
|
||||
"lucide-react": "^0.523.0",
|
||||
"next": "^15.3.4",
|
||||
"next-auth": "^4.24.11",
|
||||
"node-cron": "^4.0.7",
|
||||
"node-fetch": "^3.3.2",
|
||||
"picocolors": "^1.1.1",
|
||||
"react": "^19.1.0",
|
||||
"react-chartjs-2": "^5.0.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-raw": "^7.0.0"
|
||||
"rehype-raw": "^7.0.0",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
@ -71,6 +77,7 @@
|
||||
"tailwindcss": "^4.1.7",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.20.3",
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"prettier": {
|
||||
|
||||
@ -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])
|
||||
companyId String
|
||||
startTime DateTime
|
||||
endTime DateTime
|
||||
endTime DateTime?
|
||||
ipAddress String?
|
||||
country String?
|
||||
language String?
|
||||
|
||||
@ -66,7 +66,7 @@ async function processTranscriptWithOpenAI(
|
||||
"escalated": boolean,
|
||||
"forwarded_hr": boolean,
|
||||
"category": "one of the categories listed above",
|
||||
"questions": "a single question or [\"question 1\", \"question 2\", ...]",
|
||||
"questions": null, or array of questions,
|
||||
"summary": "brief summary",
|
||||
"tokens": number,
|
||||
"tokens_eur": number
|
||||
|
||||
Reference in New Issue
Block a user