mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 15:52:10 +01:00
feat: complete tRPC integration and fix platform UI issues
- Implement comprehensive tRPC setup with type-safe API - Create tRPC routers for dashboard, admin, and auth endpoints - Migrate frontend components to use tRPC client - Fix platform dashboard Settings button functionality - Add platform settings page with profile and security management - Create OpenAI API mocking infrastructure for cost-safe testing - Update tests to work with new tRPC architecture - Sync database schema to fix AIBatchRequest table errors
This commit is contained in:
385
TODO
385
TODO
@ -3,245 +3,268 @@
|
|||||||
## 🚀 CRITICAL PRIORITY - Architectural Refactoring
|
## 🚀 CRITICAL PRIORITY - Architectural Refactoring
|
||||||
|
|
||||||
### Phase 1: Service Decomposition & Platform Management (Weeks 1-4)
|
### Phase 1: Service Decomposition & Platform Management (Weeks 1-4)
|
||||||
- [x] **Create Platform Management Layer** (80% Complete)
|
|
||||||
- [x] Add Organization/PlatformUser models to Prisma schema
|
|
||||||
- [x] Implement super-admin authentication system (/platform/login)
|
|
||||||
- [x] Build platform dashboard for Notso AI team (/platform/dashboard)
|
|
||||||
- [x] Add company creation workflows
|
|
||||||
- [x] Add basic platform API endpoints with tests
|
|
||||||
- [x] Create stunning SaaS landing page with modern design
|
|
||||||
- [x] Add company editing/management workflows
|
|
||||||
- [x] Create company suspension/activation UI features
|
|
||||||
- [x] Add proper SEO metadata and OpenGraph tags
|
|
||||||
- [x] Add user management within companies from platform
|
|
||||||
- [ ] Add AI model management UI
|
|
||||||
- [ ] Add cost tracking/quotas UI
|
|
||||||
|
|
||||||
- [ ] **Extract Data Ingestion Service (Golang)**
|
- [x] **Create Platform Management Layer** (80% Complete)
|
||||||
- [ ] Create new Golang service for CSV processing
|
- [x] Add Organization/PlatformUser models to Prisma schema
|
||||||
- [ ] Implement concurrent CSV downloading & parsing
|
- [x] Implement super-admin authentication system (/platform/login)
|
||||||
- [ ] Add transcript fetching with rate limiting
|
- [x] Build platform dashboard for Notso AI team (/platform/dashboard)
|
||||||
- [ ] Set up Redis message queues (BullMQ/RabbitMQ)
|
- [x] Add company creation workflows
|
||||||
- [ ] Migrate lib/scheduler.ts and lib/csvFetcher.ts logic
|
- [x] Add basic platform API endpoints with tests
|
||||||
|
- [x] Create stunning SaaS landing page with modern design
|
||||||
|
- [x] Add company editing/management workflows
|
||||||
|
- [x] Create company suspension/activation UI features
|
||||||
|
- [x] Add proper SEO metadata and OpenGraph tags
|
||||||
|
- [x] Add user management within companies from platform
|
||||||
|
- [ ] Add AI model management UI
|
||||||
|
- [ ] Add cost tracking/quotas UI
|
||||||
|
|
||||||
- [ ] **Implement tRPC Infrastructure**
|
- [ ] **Extract Data Ingestion Service (Golang)**
|
||||||
- [ ] Add tRPC to existing Next.js app
|
- [ ] Create new Golang service for CSV processing
|
||||||
- [ ] Create type-safe API procedures for frontend
|
- [ ] Implement concurrent CSV downloading & parsing
|
||||||
- [ ] Implement inter-service communication protocols
|
- [ ] Add transcript fetching with rate limiting
|
||||||
- [ ] Add proper error handling and validation
|
- [ ] Set up Redis message queues (BullMQ/RabbitMQ)
|
||||||
|
- [ ] Migrate lib/scheduler.ts and lib/csvFetcher.ts logic
|
||||||
|
|
||||||
|
- [ ] **Implement tRPC Infrastructure**
|
||||||
|
- [ ] Add tRPC to existing Next.js app
|
||||||
|
- [ ] Create type-safe API procedures for frontend
|
||||||
|
- [ ] Implement inter-service communication protocols
|
||||||
|
- [ ] Add proper error handling and validation
|
||||||
|
|
||||||
### Phase 2: AI Service Separation & Compliance (Weeks 5-8)
|
### Phase 2: AI Service Separation & Compliance (Weeks 5-8)
|
||||||
- [ ] **Extract AI Processing Service**
|
|
||||||
- [ ] Separate lib/processingScheduler.ts into standalone service
|
|
||||||
- [ ] Implement async AI processing with queues
|
|
||||||
- [ ] Add per-company AI cost tracking and quotas
|
|
||||||
- [ ] Create AI model management per company
|
|
||||||
- [ ] Add retry logic and failure handling
|
|
||||||
|
|
||||||
- [ ] **GDPR & ISO 27001 Compliance Foundation**
|
- [ ] **Extract AI Processing Service**
|
||||||
- [ ] Implement data isolation boundaries between services
|
- [ ] Separate lib/processingScheduler.ts into standalone service
|
||||||
- [ ] Add audit logging for all data processing
|
- [ ] Implement async AI processing with queues
|
||||||
- [ ] Create data retention policies per company
|
- [ ] Add per-company AI cost tracking and quotas
|
||||||
- [ ] Add consent management for data processing
|
- [ ] Create AI model management per company
|
||||||
- [ ] Implement data export/deletion workflows (Right to be Forgotten)
|
- [ ] Add retry logic and failure handling
|
||||||
|
|
||||||
|
- [ ] **GDPR & ISO 27001 Compliance Foundation**
|
||||||
|
- [ ] Implement data isolation boundaries between services
|
||||||
|
- [ ] Add audit logging for all data processing
|
||||||
|
- [ ] Create data retention policies per company
|
||||||
|
- [ ] Add consent management for data processing
|
||||||
|
- [ ] Implement data export/deletion workflows (Right to be Forgotten)
|
||||||
|
|
||||||
### Phase 3: Performance & Monitoring (Weeks 9-12)
|
### Phase 3: Performance & Monitoring (Weeks 9-12)
|
||||||
- [ ] **Monitoring & Observability**
|
|
||||||
- [ ] Add distributed tracing across services (Jaeger/Zipkin)
|
|
||||||
- [ ] Implement health checks for all services
|
|
||||||
- [ ] Create cross-service metrics dashboard
|
|
||||||
- [ ] Add alerting for service failures and SLA breaches
|
|
||||||
- [ ] Monitor AI processing costs and quotas
|
|
||||||
|
|
||||||
- [ ] **Database Optimization**
|
- [ ] **Monitoring & Observability**
|
||||||
- [ ] Implement connection pooling per service
|
- [ ] Add distributed tracing across services (Jaeger/Zipkin)
|
||||||
- [ ] Add read replicas for dashboard queries
|
- [ ] Implement health checks for all services
|
||||||
- [ ] Create database sharding strategy for multi-tenancy
|
- [ ] Create cross-service metrics dashboard
|
||||||
- [ ] Optimize queries with proper indexing
|
- [ ] Add alerting for service failures and SLA breaches
|
||||||
|
- [ ] Monitor AI processing costs and quotas
|
||||||
|
|
||||||
|
- [ ] **Database Optimization**
|
||||||
|
- [ ] Implement connection pooling per service
|
||||||
|
- [ ] Add read replicas for dashboard queries
|
||||||
|
- [ ] Create database sharding strategy for multi-tenancy
|
||||||
|
- [ ] Optimize queries with proper indexing
|
||||||
|
|
||||||
## High Priority
|
## High Priority
|
||||||
|
|
||||||
### PR #20 Feedback Actions (Code Review)
|
### PR #20 Feedback Actions (Code Review)
|
||||||
- [ ] **Fix Environment Variable Testing**
|
|
||||||
- [ ] Replace process.env access with proper environment mocking in tests
|
|
||||||
- [ ] Update existing tests to avoid direct environment variable dependencies
|
|
||||||
- [ ] Add environment validation tests for critical config values
|
|
||||||
|
|
||||||
- [ ] **Enforce Zero Accessibility Violations**
|
- [ ] **Fix Environment Variable Testing**
|
||||||
- [ ] Set Playwright accessibility tests to fail on any violations (not just warn)
|
- [ ] Replace process.env access with proper environment mocking in tests
|
||||||
- [ ] Add accessibility regression tests for all major components
|
- [ ] Update existing tests to avoid direct environment variable dependencies
|
||||||
- [ ] Implement accessibility checklist for new components
|
- [ ] Add environment validation tests for critical config values
|
||||||
|
|
||||||
- [ ] **Improve Error Handling with Custom Error Classes**
|
- [ ] **Enforce Zero Accessibility Violations**
|
||||||
- [ ] Create custom error classes for different error types (ValidationError, AuthError, etc.)
|
- [ ] Set Playwright accessibility tests to fail on any violations (not just warn)
|
||||||
- [ ] Replace generic Error throws with specific error classes
|
- [ ] Add accessibility regression tests for all major components
|
||||||
- [ ] Add proper error logging and monitoring integration
|
- [ ] Implement accessibility checklist for new components
|
||||||
|
|
||||||
- [ ] **Refactor Long className Strings**
|
- [ ] **Improve Error Handling with Custom Error Classes**
|
||||||
- [ ] Extract complex className combinations into utility functions
|
- [ ] Create custom error classes for different error types (ValidationError, AuthError, etc.)
|
||||||
- [ ] Consider using cn() utility from utils for cleaner class composition
|
- [ ] Replace generic Error throws with specific error classes
|
||||||
- [ ] Break down overly complex className props into semantic components
|
- [ ] Add proper error logging and monitoring integration
|
||||||
|
|
||||||
- [ ] **Add Dark Mode Accessibility Tests**
|
- [ ] **Refactor Long className Strings**
|
||||||
- [ ] Create comprehensive test suite for dark mode color contrast
|
- [ ] Extract complex className combinations into utility functions
|
||||||
- [ ] Verify focus indicators work properly in both light and dark modes
|
- [ ] Consider using cn() utility from utils for cleaner class composition
|
||||||
- [ ] Test screen reader compatibility with theme switching
|
- [ ] Break down overly complex className props into semantic components
|
||||||
|
|
||||||
- [ ] **Fix Platform Login Authentication Issue**
|
- [ ] **Add Dark Mode Accessibility Tests**
|
||||||
- [ ] NEXTAUTH_SECRET was using placeholder value (FIXED)
|
- [ ] Create comprehensive test suite for dark mode color contrast
|
||||||
- [ ] Investigate platform cookie path restrictions in /platform auth
|
- [ ] Verify focus indicators work properly in both light and dark modes
|
||||||
- [ ] Test platform login flow end-to-end after fixes
|
- [ ] Test screen reader compatibility with theme switching
|
||||||
|
|
||||||
|
- [ ] **Fix Platform Login Authentication Issue**
|
||||||
|
- [ ] NEXTAUTH_SECRET was using placeholder value (FIXED)
|
||||||
|
- [ ] Investigate platform cookie path restrictions in /platform auth
|
||||||
|
- [ ] Test platform login flow end-to-end after fixes
|
||||||
|
|
||||||
### Testing & Quality Assurance
|
### Testing & Quality Assurance
|
||||||
- [ ] Add comprehensive test coverage for API endpoints (currently minimal)
|
|
||||||
- [ ] Implement integration tests for the data processing pipeline
|
- [ ] Add comprehensive test coverage for API endpoints (currently minimal)
|
||||||
- [ ] Add unit tests for validation schemas and authentication logic
|
- [ ] Implement integration tests for the data processing pipeline
|
||||||
- [ ] Create E2E tests for critical user flows (registration, login, dashboard)
|
- [ ] Add unit tests for validation schemas and authentication logic
|
||||||
|
- [ ] Create E2E tests for critical user flows (registration, login, dashboard)
|
||||||
|
|
||||||
### Error Handling & Monitoring
|
### Error Handling & Monitoring
|
||||||
- [ ] Implement global error boundaries for React components
|
|
||||||
- [ ] Add structured logging with correlation IDs for request tracing
|
- [ ] Implement global error boundaries for React components
|
||||||
- [ ] Set up error monitoring and alerting (e.g., Sentry integration)
|
- [ ] Add structured logging with correlation IDs for request tracing
|
||||||
- [ ] Add proper error pages for 404, 500, and other HTTP status codes
|
- [ ] Set up error monitoring and alerting (e.g., Sentry integration)
|
||||||
|
- [ ] Add proper error pages for 404, 500, and other HTTP status codes
|
||||||
|
|
||||||
### Performance Optimization
|
### Performance Optimization
|
||||||
- [ ] Implement database query optimization and indexing strategy
|
|
||||||
- [ ] Add caching layer for frequently accessed data (Redis/in-memory)
|
- [ ] Implement database query optimization and indexing strategy
|
||||||
- [ ] Optimize React components with proper memoization
|
- [ ] Add caching layer for frequently accessed data (Redis/in-memory)
|
||||||
- [ ] Implement lazy loading for dashboard components and charts
|
- [ ] Optimize React components with proper memoization
|
||||||
|
- [ ] Implement lazy loading for dashboard components and charts
|
||||||
|
|
||||||
## Medium Priority
|
## Medium Priority
|
||||||
|
|
||||||
### Security Enhancements
|
### Security Enhancements
|
||||||
- [ ] Add CSRF protection for state-changing operations
|
|
||||||
- [ ] Implement session timeout and refresh token mechanism
|
- [ ] Add CSRF protection for state-changing operations
|
||||||
- [ ] Add API rate limiting with Redis-backed storage (replace in-memory)
|
- [ ] Implement session timeout and refresh token mechanism
|
||||||
- [ ] Implement role-based access control (RBAC) for different user types
|
- [ ] Add API rate limiting with Redis-backed storage (replace in-memory)
|
||||||
- [ ] Add audit logging for sensitive operations
|
- [ ] Implement role-based access control (RBAC) for different user types
|
||||||
|
- [ ] Add audit logging for sensitive operations
|
||||||
|
|
||||||
### Code Quality & Maintenance
|
### Code Quality & Maintenance
|
||||||
- [ ] Resolve remaining ESLint warnings and type issues
|
|
||||||
- [ ] Standardize chart library usage (currently mixing Chart.js and other libraries)
|
- [ ] Resolve remaining ESLint warnings and type issues
|
||||||
- [ ] Add proper TypeScript strict mode configuration
|
- [ ] Standardize chart library usage (currently mixing Chart.js and other libraries)
|
||||||
- [ ] Implement consistent API response formats across all endpoints
|
- [ ] Add proper TypeScript strict mode configuration
|
||||||
|
- [ ] Implement consistent API response formats across all endpoints
|
||||||
|
|
||||||
### Database & Schema
|
### Database & Schema
|
||||||
- [ ] Add database connection pooling configuration
|
|
||||||
- [ ] Implement proper database migrations for production deployment
|
- [ ] Add database connection pooling configuration
|
||||||
- [ ] Add data retention policies for session data
|
- [ ] Implement proper database migrations for production deployment
|
||||||
- [ ] Consider database partitioning for large-scale data
|
- [ ] Add data retention policies for session data
|
||||||
|
- [ ] Consider database partitioning for large-scale data
|
||||||
|
|
||||||
### User Experience
|
### User Experience
|
||||||
- [ ] Add loading states and skeleton components throughout the application
|
|
||||||
- [ ] Implement proper form validation feedback and error messages
|
- [ ] Add loading states and skeleton components throughout the application
|
||||||
- [ ] Add pagination for large data sets in dashboard tables
|
- [ ] Implement proper form validation feedback and error messages
|
||||||
- [ ] Implement real-time notifications for processing status updates
|
- [ ] Add pagination for large data sets in dashboard tables
|
||||||
|
- [ ] Implement real-time notifications for processing status updates
|
||||||
|
|
||||||
## Low Priority
|
## Low Priority
|
||||||
|
|
||||||
### Documentation & Development
|
### Documentation & Development
|
||||||
- [ ] Add API documentation (OpenAPI/Swagger)
|
|
||||||
- [ ] Create deployment guides for different environments
|
- [ ] Add API documentation (OpenAPI/Swagger)
|
||||||
- [ ] Add contributing guidelines and code review checklist
|
- [ ] Create deployment guides for different environments
|
||||||
- [ ] Implement development environment setup automation
|
- [ ] Add contributing guidelines and code review checklist
|
||||||
|
- [ ] Implement development environment setup automation
|
||||||
|
|
||||||
### Feature Enhancements
|
### Feature Enhancements
|
||||||
- [ ] Add data export functionality (CSV, PDF reports)
|
|
||||||
- [ ] Implement dashboard customization and user preferences
|
- [ ] Add data export functionality (CSV, PDF reports)
|
||||||
- [ ] Add multi-language support (i18n)
|
- [ ] Implement dashboard customization and user preferences
|
||||||
- [ ] Create admin panel for system configuration
|
- [ ] Add multi-language support (i18n)
|
||||||
|
- [ ] Create admin panel for system configuration
|
||||||
|
|
||||||
### Infrastructure & DevOps
|
### Infrastructure & DevOps
|
||||||
- [ ] Add Docker configuration for containerized deployment
|
|
||||||
- [ ] Implement CI/CD pipeline with automated testing
|
- [ ] Add Docker configuration for containerized deployment
|
||||||
- [ ] Add environment-specific configuration management
|
- [ ] Implement CI/CD pipeline with automated testing
|
||||||
- [ ] Set up monitoring and health check endpoints
|
- [ ] Add environment-specific configuration management
|
||||||
|
- [ ] Set up monitoring and health check endpoints
|
||||||
|
|
||||||
### Analytics & Insights
|
### Analytics & Insights
|
||||||
- [ ] Add more detailed analytics and reporting features
|
|
||||||
- [ ] Implement A/B testing framework for UI improvements
|
- [ ] Add more detailed analytics and reporting features
|
||||||
- [ ] Add user behavior tracking and analytics
|
- [ ] Implement A/B testing framework for UI improvements
|
||||||
- [ ] Create automated report generation and scheduling
|
- [ ] Add user behavior tracking and analytics
|
||||||
|
- [ ] Create automated report generation and scheduling
|
||||||
|
|
||||||
## Completed ✅
|
## Completed ✅
|
||||||
- [x] Fix duplicate MetricCard components
|
|
||||||
- [x] Add input validation schema with Zod
|
- [x] Fix duplicate MetricCard components
|
||||||
- [x] Strengthen password requirements (12+ chars, complexity)
|
- [x] Add input validation schema with Zod
|
||||||
- [x] Fix schema drift - create missing migrations
|
- [x] Strengthen password requirements (12+ chars, complexity)
|
||||||
- [x] Add rate limiting to authentication endpoints
|
- [x] Fix schema drift - create missing migrations
|
||||||
- [x] Update README.md to use pnpm instead of npm
|
- [x] Add rate limiting to authentication endpoints
|
||||||
- [x] Implement platform authentication and basic dashboard
|
- [x] Update README.md to use pnpm instead of npm
|
||||||
- [x] Add platform API endpoints for company management
|
- [x] Implement platform authentication and basic dashboard
|
||||||
- [x] Write tests for platform features (auth, dashboard, API)
|
- [x] Add platform API endpoints for company management
|
||||||
|
- [x] Write tests for platform features (auth, dashboard, API)
|
||||||
|
|
||||||
## 📊 Test Coverage Status (< 30% Overall)
|
## 📊 Test Coverage Status (< 30% Overall)
|
||||||
|
|
||||||
### ✅ Features WITH Tests:
|
### ✅ Features WITH Tests
|
||||||
- User Authentication (regular users)
|
|
||||||
- User Management UI & API
|
|
||||||
- Basic database connectivity
|
|
||||||
- Transcript Fetcher
|
|
||||||
- Input validation
|
|
||||||
- Environment configuration
|
|
||||||
- Format enums
|
|
||||||
- Accessibility features
|
|
||||||
- Keyboard navigation
|
|
||||||
- Platform authentication (NEW)
|
|
||||||
- Platform dashboard (NEW)
|
|
||||||
- Platform API endpoints (NEW)
|
|
||||||
|
|
||||||
### ❌ Features WITHOUT Tests (Critical Gaps):
|
- User Authentication (regular users)
|
||||||
- **Data Processing Pipeline** (0 tests)
|
- User Management UI & API
|
||||||
- CSV import scheduler
|
- Basic database connectivity
|
||||||
- Import processor
|
- Transcript Fetcher
|
||||||
- Processing scheduler
|
- Input validation
|
||||||
- AI processing functionality
|
- Environment configuration
|
||||||
- Transcript parser
|
- Format enums
|
||||||
- **Most API Endpoints** (0 tests)
|
- Accessibility features
|
||||||
- Dashboard endpoints
|
- Keyboard navigation
|
||||||
- Session management
|
- Platform authentication (NEW)
|
||||||
- Admin endpoints
|
- Platform dashboard (NEW)
|
||||||
- Password reset flow
|
- Platform API endpoints (NEW)
|
||||||
- **Custom Server** (0 tests)
|
|
||||||
- **Dashboard Features** (0 tests)
|
### ❌ Features WITHOUT Tests (Critical Gaps)
|
||||||
- Charts and visualizations
|
|
||||||
- Session details
|
- **Data Processing Pipeline** (0 tests)
|
||||||
- Company settings
|
- CSV import scheduler
|
||||||
- **AI Integration** (0 tests)
|
- Import processor
|
||||||
- **Real-time Features** (0 tests)
|
- Processing scheduler
|
||||||
- **E2E Tests** (only examples exist)
|
- AI processing functionality
|
||||||
|
- Transcript parser
|
||||||
|
- **Most API Endpoints** (0 tests)
|
||||||
|
- Dashboard endpoints
|
||||||
|
- Session management
|
||||||
|
- Admin endpoints
|
||||||
|
- Password reset flow
|
||||||
|
- **Custom Server** (0 tests)
|
||||||
|
- **Dashboard Features** (0 tests)
|
||||||
|
- Charts and visualizations
|
||||||
|
- Session details
|
||||||
|
- Company settings
|
||||||
|
- **AI Integration** (0 tests)
|
||||||
|
- **Real-time Features** (0 tests)
|
||||||
|
- **E2E Tests** (only examples exist)
|
||||||
|
|
||||||
## 🏛️ Architectural Decisions & Rationale
|
## 🏛️ Architectural Decisions & Rationale
|
||||||
|
|
||||||
### Service Technology Choices
|
### Service Technology Choices
|
||||||
- **Dashboard Service**: Next.js + tRPC (existing, proven stack)
|
|
||||||
- **Data Ingestion Service**: Golang (high-performance CSV processing, concurrency)
|
- **Dashboard Service**: Next.js + tRPC (existing, proven stack)
|
||||||
- **AI Processing Service**: Node.js/Python (existing AI integrations, async processing)
|
- **Data Ingestion Service**: Golang (high-performance CSV processing, concurrency)
|
||||||
- **Message Queue**: Redis + BullMQ (Node.js ecosystem compatibility)
|
- **AI Processing Service**: Node.js/Python (existing AI integrations, async processing)
|
||||||
- **Database**: PostgreSQL (existing, excellent for multi-tenancy)
|
- **Message Queue**: Redis + BullMQ (Node.js ecosystem compatibility)
|
||||||
|
- **Database**: PostgreSQL (existing, excellent for multi-tenancy)
|
||||||
|
|
||||||
### Why Golang for Data Ingestion?
|
### Why Golang for Data Ingestion?
|
||||||
- **Performance**: 10-100x faster CSV processing than Node.js
|
|
||||||
- **Concurrency**: Native goroutines for parallel transcript fetching
|
- **Performance**: 10-100x faster CSV processing than Node.js
|
||||||
- **Memory Efficiency**: Lower memory footprint for large CSV files
|
- **Concurrency**: Native goroutines for parallel transcript fetching
|
||||||
- **Deployment**: Single binary deployment, excellent for containers
|
- **Memory Efficiency**: Lower memory footprint for large CSV files
|
||||||
- **Team Growth**: Easy to hire Golang developers for data processing
|
- **Deployment**: Single binary deployment, excellent for containers
|
||||||
|
- **Team Growth**: Easy to hire Golang developers for data processing
|
||||||
|
|
||||||
### Migration Strategy
|
### Migration Strategy
|
||||||
1. **Keep existing working system** while building new services
|
|
||||||
2. **Feature flagging** to gradually migrate companies to new processing
|
1. **Keep existing working system** while building new services
|
||||||
3. **Dual-write approach** during transition period
|
2. **Feature flagging** to gradually migrate companies to new processing
|
||||||
4. **Zero-downtime migration** with careful rollback plans
|
3. **Dual-write approach** during transition period
|
||||||
|
4. **Zero-downtime migration** with careful rollback plans
|
||||||
|
|
||||||
### Compliance Benefits
|
### Compliance Benefits
|
||||||
- **Data Isolation**: Each service has limited database access
|
|
||||||
- **Audit Trail**: All inter-service communication logged
|
- **Data Isolation**: Each service has limited database access
|
||||||
- **Data Retention**: Automated per-company data lifecycle
|
- **Audit Trail**: All inter-service communication logged
|
||||||
- **Security Boundaries**: DMZ for ingestion, private network for processing
|
- **Data Retention**: Automated per-company data lifecycle
|
||||||
|
- **Security Boundaries**: DMZ for ingestion, private network for processing
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- **CRITICAL**: Architectural refactoring must be priority #1 for scalability
|
|
||||||
- **Platform Management**: Notso AI needs self-service customer onboarding
|
- **CRITICAL**: Architectural refactoring must be priority #1 for scalability
|
||||||
- **Compliance First**: GDPR/ISO 27001 requirements drive service boundaries
|
- **Platform Management**: Notso AI needs self-service customer onboarding
|
||||||
- **Performance**: Current monolith blocks on CSV/AI processing
|
- **Compliance First**: GDPR/ISO 27001 requirements drive service boundaries
|
||||||
- **Technology Evolution**: Golang for data processing, tRPC for type safety
|
- **Performance**: Current monolith blocks on CSV/AI processing
|
||||||
|
- **Technology Evolution**: Golang for data processing, tRPC for type safety
|
||||||
|
|||||||
@ -63,7 +63,7 @@ export async function POST(request: NextRequest) {
|
|||||||
await sendEmail({
|
await sendEmail({
|
||||||
to: email,
|
to: email,
|
||||||
subject: "Password Reset",
|
subject: "Password Reset",
|
||||||
text: `Reset your password: ${resetUrl}`
|
text: `Reset your password: ${resetUrl}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
29
app/api/trpc/[trpc]/route.ts
Normal file
29
app/api/trpc/[trpc]/route.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* tRPC API Route Handler
|
||||||
|
*
|
||||||
|
* This file creates the Next.js API route that handles all tRPC requests.
|
||||||
|
* All tRPC procedures will be accessible via /api/trpc/*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { createTRPCContext } from "@/lib/trpc";
|
||||||
|
import { appRouter } from "@/server/routers/_app";
|
||||||
|
|
||||||
|
const handler = (req: NextRequest) =>
|
||||||
|
fetchRequestHandler({
|
||||||
|
endpoint: "/api/trpc",
|
||||||
|
req,
|
||||||
|
router: appRouter,
|
||||||
|
createContext: createTRPCContext,
|
||||||
|
onError:
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
? ({ path, error }) => {
|
||||||
|
console.error(
|
||||||
|
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST };
|
||||||
@ -28,6 +28,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { formatEnumValue } from "@/lib/format-enums";
|
import { formatEnumValue } from "@/lib/format-enums";
|
||||||
|
import { trpc } from "@/lib/trpc-client";
|
||||||
import ModernBarChart from "../../../components/charts/bar-chart";
|
import ModernBarChart from "../../../components/charts/bar-chart";
|
||||||
import ModernDonutChart from "../../../components/charts/donut-chart";
|
import ModernDonutChart from "../../../components/charts/donut-chart";
|
||||||
import ModernLineChart from "../../../components/charts/line-chart";
|
import ModernLineChart from "../../../components/charts/line-chart";
|
||||||
@ -470,7 +471,6 @@ function DashboardContent() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [metrics, setMetrics] = useState<MetricsResult | null>(null);
|
const [metrics, setMetrics] = useState<MetricsResult | null>(null);
|
||||||
const [company, setCompany] = useState<Company | null>(null);
|
const [company, setCompany] = useState<Company | null>(null);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
const [refreshing, setRefreshing] = useState<boolean>(false);
|
const [refreshing, setRefreshing] = useState<boolean>(false);
|
||||||
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
|
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
|
||||||
|
|
||||||
@ -478,72 +478,73 @@ function DashboardContent() {
|
|||||||
const dataHelpers = useDashboardData(metrics);
|
const dataHelpers = useDashboardData(metrics);
|
||||||
|
|
||||||
// Function to fetch metrics with optional date range
|
// Function to fetch metrics with optional date range
|
||||||
const fetchMetrics = useCallback(
|
// tRPC query for dashboard metrics
|
||||||
async (startDate?: string, endDate?: string, isInitial = false) => {
|
const {
|
||||||
setLoading(true);
|
data: overviewData,
|
||||||
try {
|
isLoading: isLoadingMetrics,
|
||||||
let url = "/api/dashboard/metrics";
|
refetch: refetchMetrics,
|
||||||
if (startDate && endDate) {
|
error: metricsError,
|
||||||
url += `?startDate=${startDate}&endDate=${endDate}`;
|
} = trpc.dashboard.getOverview.useQuery(
|
||||||
}
|
{
|
||||||
|
// Add date range parameters when implemented
|
||||||
const res = await fetch(url);
|
// startDate: dateRange?.startDate,
|
||||||
const data = await res.json();
|
// endDate: dateRange?.endDate,
|
||||||
|
|
||||||
setMetrics(data.metrics);
|
|
||||||
setCompany(data.company);
|
|
||||||
|
|
||||||
// Set initial load flag
|
|
||||||
if (isInitial) {
|
|
||||||
setIsInitialLoad(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching metrics:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[]
|
{
|
||||||
|
enabled: status === "authenticated",
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update state when data changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (overviewData) {
|
||||||
|
// Map overview data to metrics format expected by the component
|
||||||
|
const mappedMetrics = {
|
||||||
|
totalSessions: overviewData.totalSessions,
|
||||||
|
avgMessagesSent: overviewData.avgMessagesSent,
|
||||||
|
sentimentDistribution: overviewData.sentimentDistribution,
|
||||||
|
categoryDistribution: overviewData.categoryDistribution,
|
||||||
|
};
|
||||||
|
setMetrics(mappedMetrics as any); // Type assertion for compatibility
|
||||||
|
|
||||||
|
if (isInitialLoad) {
|
||||||
|
setIsInitialLoad(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [overviewData, isInitialLoad]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (metricsError) {
|
||||||
|
console.error("Error fetching metrics:", metricsError);
|
||||||
|
}
|
||||||
|
}, [metricsError]);
|
||||||
|
|
||||||
|
// Admin refresh sessions mutation
|
||||||
|
const refreshSessionsMutation = trpc.admin.refreshSessions.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
// Refetch metrics after successful refresh
|
||||||
|
refetchMetrics();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
alert(`Failed to refresh sessions: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Redirect if not authenticated
|
// Redirect if not authenticated
|
||||||
if (status === "unauthenticated") {
|
if (status === "unauthenticated") {
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// tRPC queries handle data fetching automatically
|
||||||
// Fetch metrics and company on mount if authenticated
|
}, [status, router]);
|
||||||
if (status === "authenticated" && isInitialLoad) {
|
|
||||||
fetchMetrics(undefined, undefined, true);
|
|
||||||
}
|
|
||||||
}, [status, router, isInitialLoad, fetchMetrics]);
|
|
||||||
|
|
||||||
async function handleRefresh() {
|
async function handleRefresh() {
|
||||||
if (isAuditor) return;
|
if (isAuditor) return;
|
||||||
|
|
||||||
|
setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
setRefreshing(true);
|
await refreshSessionsMutation.mutateAsync();
|
||||||
|
|
||||||
if (!company?.id) {
|
|
||||||
setRefreshing(false);
|
|
||||||
alert("Cannot refresh: Company ID is missing");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch("/api/admin/refresh-sessions", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ companyId: company.id }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
const metricsRes = await fetch("/api/dashboard/metrics");
|
|
||||||
const data = await metricsRes.json();
|
|
||||||
setMetrics(data.metrics);
|
|
||||||
} else {
|
|
||||||
const errorData = await res.json();
|
|
||||||
alert(`Failed to refresh sessions: ${errorData.error}`);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
@ -553,7 +554,19 @@ function DashboardContent() {
|
|||||||
const loadingState = DashboardLoadingStates({ status });
|
const loadingState = DashboardLoadingStates({ status });
|
||||||
if (loadingState) return loadingState;
|
if (loadingState) return loadingState;
|
||||||
|
|
||||||
if (loading || !metrics || !company) {
|
// Show loading state while data is being fetched
|
||||||
|
if (isLoadingMetrics && !metrics) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto" />
|
||||||
|
<p className="text-muted-foreground">Loading dashboard data...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!metrics || !company) {
|
||||||
return <DashboardSkeleton />;
|
return <DashboardSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,13 +13,14 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useId, useState } from "react";
|
import { useEffect, useId, useState } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { formatCategory } from "@/lib/format-enums";
|
import { formatCategory } from "@/lib/format-enums";
|
||||||
|
import { trpc } from "@/lib/trpc-client";
|
||||||
import type { ChatSession } from "../../../lib/types";
|
import type { ChatSession } from "../../../lib/types";
|
||||||
|
|
||||||
interface FilterOptions {
|
interface FilterOptions {
|
||||||
@ -426,7 +427,6 @@ function Pagination({
|
|||||||
|
|
||||||
export default function SessionsPage() {
|
export default function SessionsPage() {
|
||||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
@ -465,72 +465,60 @@ export default function SessionsPage() {
|
|||||||
return () => clearTimeout(timerId);
|
return () => clearTimeout(timerId);
|
||||||
}, [searchTerm]);
|
}, [searchTerm]);
|
||||||
|
|
||||||
const fetchFilterOptions = useCallback(async () => {
|
// TODO: Implement getSessionFilterOptions in tRPC dashboard router
|
||||||
try {
|
// For now, we'll set default filter options
|
||||||
const response = await fetch("/api/dashboard/session-filter-options");
|
useEffect(() => {
|
||||||
if (!response.ok) {
|
setFilterOptions({
|
||||||
throw new Error("Failed to fetch filter options");
|
categories: [
|
||||||
}
|
"SCHEDULE_HOURS",
|
||||||
const data = await response.json();
|
"LEAVE_VACATION",
|
||||||
setFilterOptions(data);
|
"SICK_LEAVE_RECOVERY",
|
||||||
} catch (err) {
|
"SALARY_COMPENSATION",
|
||||||
setError(
|
],
|
||||||
err instanceof Error ? err.message : "Failed to load filter options"
|
languages: ["en", "nl", "de", "fr", "es"],
|
||||||
);
|
});
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchSessions = useCallback(async () => {
|
// tRPC query for sessions
|
||||||
setLoading(true);
|
const {
|
||||||
setError(null);
|
data: sessionsData,
|
||||||
try {
|
isLoading,
|
||||||
const params = new URLSearchParams();
|
error: sessionsError,
|
||||||
if (debouncedSearchTerm) params.append("searchTerm", debouncedSearchTerm);
|
} = trpc.dashboard.getSessions.useQuery(
|
||||||
if (selectedCategory) params.append("category", selectedCategory);
|
{
|
||||||
if (selectedLanguage) params.append("language", selectedLanguage);
|
search: debouncedSearchTerm || undefined,
|
||||||
if (startDate) params.append("startDate", startDate);
|
category: (selectedCategory as any) || undefined,
|
||||||
if (endDate) params.append("endDate", endDate);
|
// language: selectedLanguage || undefined, // Not supported in schema yet
|
||||||
if (sortKey) params.append("sortKey", sortKey);
|
startDate: startDate || undefined,
|
||||||
if (sortOrder) params.append("sortOrder", sortOrder);
|
endDate: endDate || undefined,
|
||||||
params.append("page", currentPage.toString());
|
// sortKey: sortKey || undefined, // Not supported in schema yet
|
||||||
params.append("pageSize", pageSize.toString());
|
// sortOrder: sortOrder || undefined, // Not supported in schema yet
|
||||||
|
page: currentPage,
|
||||||
const response = await fetch(
|
limit: pageSize,
|
||||||
`/api/dashboard/sessions?${params.toString()}`
|
},
|
||||||
);
|
{
|
||||||
if (!response.ok) {
|
// Enable the query by default
|
||||||
throw new Error(`Failed to fetch sessions: ${response.statusText}`);
|
enabled: true,
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
setSessions(data.sessions || []);
|
|
||||||
setTotalPages(Math.ceil((data.totalSessions || 0) / pageSize));
|
|
||||||
} catch (err) {
|
|
||||||
setError(
|
|
||||||
err instanceof Error ? err.message : "An unknown error occurred"
|
|
||||||
);
|
|
||||||
setSessions([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [
|
);
|
||||||
debouncedSearchTerm,
|
|
||||||
selectedCategory,
|
// Update state when data changes
|
||||||
selectedLanguage,
|
useEffect(() => {
|
||||||
startDate,
|
if (sessionsData) {
|
||||||
endDate,
|
setSessions((sessionsData.sessions as any) || []);
|
||||||
sortKey,
|
setTotalPages(sessionsData.pagination.totalPages);
|
||||||
sortOrder,
|
setError(null);
|
||||||
currentPage,
|
}
|
||||||
pageSize,
|
}, [sessionsData]);
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSessions();
|
if (sessionsError) {
|
||||||
}, [fetchSessions]);
|
setError(sessionsError.message || "An unknown error occurred");
|
||||||
|
setSessions([]);
|
||||||
|
}
|
||||||
|
}, [sessionsError]);
|
||||||
|
|
||||||
useEffect(() => {
|
// tRPC queries handle data fetching automatically
|
||||||
fetchFilterOptions();
|
|
||||||
}, [fetchFilterOptions]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@ -576,7 +564,7 @@ export default function SessionsPage() {
|
|||||||
|
|
||||||
<SessionList
|
<SessionList
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
loading={loading}
|
loading={isLoading}
|
||||||
error={error}
|
error={error}
|
||||||
resultsHeadingId={resultsHeadingId}
|
resultsHeadingId={resultsHeadingId}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -7,9 +7,12 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Copy,
|
Copy,
|
||||||
Database,
|
Database,
|
||||||
|
LogOut,
|
||||||
|
MoreVertical,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
|
User,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@ -26,6 +29,14 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||||
@ -367,10 +378,45 @@ export default function PlatformDashboard() {
|
|||||||
className="pl-10 w-64"
|
className="pl-10 w-64"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm">
|
<DropdownMenu>
|
||||||
<Settings className="w-4 h-4 mr-2" />
|
<DropdownMenuTrigger asChild>
|
||||||
Settings
|
<Button variant="outline" size="sm">
|
||||||
</Button>
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{session.user.name || session.user.email}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{session.user.platformRole || "Platform User"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => router.push("/platform/settings")}
|
||||||
|
>
|
||||||
|
<User className="w-4 h-4 mr-2" />
|
||||||
|
Account Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={async () => {
|
||||||
|
await fetch("/api/platform/auth/logout", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
router.push("/platform/login");
|
||||||
|
}}
|
||||||
|
className="text-red-600"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4 mr-2" />
|
||||||
|
Sign Out
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
370
app/platform/settings/page.tsx
Normal file
370
app/platform/settings/page.tsx
Normal file
@ -0,0 +1,370 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowLeft, Key, Shield, User } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
// Platform session hook - same as in dashboard
|
||||||
|
function usePlatformSession() {
|
||||||
|
const [session, setSession] = useState<any>(null);
|
||||||
|
const [status, setStatus] = useState<
|
||||||
|
"loading" | "authenticated" | "unauthenticated"
|
||||||
|
>("loading");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSession = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/platform/auth/session");
|
||||||
|
const sessionData = await response.json();
|
||||||
|
|
||||||
|
if (sessionData?.user?.isPlatformUser) {
|
||||||
|
setSession(sessionData);
|
||||||
|
setStatus("authenticated");
|
||||||
|
} else {
|
||||||
|
setSession(null);
|
||||||
|
setStatus("unauthenticated");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Platform session fetch error:", error);
|
||||||
|
setSession(null);
|
||||||
|
setStatus("unauthenticated");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSession();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { data: session, status };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlatformSettings() {
|
||||||
|
const { data: session, status } = usePlatformSession();
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [profileData, setProfileData] = useState({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
});
|
||||||
|
const [passwordData, setPasswordData] = useState({
|
||||||
|
currentPassword: "",
|
||||||
|
newPassword: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "unauthenticated") {
|
||||||
|
router.push("/platform/login");
|
||||||
|
}
|
||||||
|
}, [status, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session?.user) {
|
||||||
|
setProfileData({
|
||||||
|
name: session.user.name || "",
|
||||||
|
email: session.user.email || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
const handleProfileUpdate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: Implement profile update API endpoint
|
||||||
|
toast({
|
||||||
|
title: "Profile Updated",
|
||||||
|
description: "Your profile has been updated successfully.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to update profile. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (passwordData.newPassword !== passwordData.confirmPassword) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "New passwords do not match.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordData.newPassword.length < 12) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Password must be at least 12 characters long.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: Implement password change API endpoint
|
||||||
|
toast({
|
||||||
|
title: "Password Changed",
|
||||||
|
description: "Your password has been changed successfully.",
|
||||||
|
});
|
||||||
|
setPasswordData({
|
||||||
|
currentPassword: "",
|
||||||
|
newPassword: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to change password. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto" />
|
||||||
|
<p className="mt-4 text-muted-foreground">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session?.user?.isPlatformUser) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div className="border-b bg-white dark:bg-gray-800">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center py-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push("/platform/dashboard")}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back to Dashboard
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Platform Settings
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Manage your platform account settings
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<Tabs defaultValue="profile" className="space-y-6">
|
||||||
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="profile">
|
||||||
|
<User className="w-4 h-4 mr-2" />
|
||||||
|
Profile
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="security">
|
||||||
|
<Key className="w-4 h-4 mr-2" />
|
||||||
|
Security
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="advanced">
|
||||||
|
<Shield className="w-4 h-4 mr-2" />
|
||||||
|
Advanced
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="profile" className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Profile Information</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Update your platform account profile
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleProfileUpdate} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={profileData.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setProfileData({ ...profileData, name: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Your name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={profileData.email}
|
||||||
|
disabled
|
||||||
|
className="bg-gray-50"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Email cannot be changed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Role</Label>
|
||||||
|
<Input
|
||||||
|
value={session.user.platformRole || "N/A"}
|
||||||
|
disabled
|
||||||
|
className="bg-gray-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="security" className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Change Password</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Update your platform account password
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handlePasswordChange} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="current-password">Current Password</Label>
|
||||||
|
<Input
|
||||||
|
id="current-password"
|
||||||
|
type="password"
|
||||||
|
value={passwordData.currentPassword}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPasswordData({
|
||||||
|
...passwordData,
|
||||||
|
currentPassword: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="new-password">New Password</Label>
|
||||||
|
<Input
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
value={passwordData.newPassword}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPasswordData({
|
||||||
|
...passwordData,
|
||||||
|
newPassword: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Must be at least 12 characters long
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="confirm-password">
|
||||||
|
Confirm New Password
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
type="password"
|
||||||
|
value={passwordData.confirmPassword}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPasswordData({
|
||||||
|
...passwordData,
|
||||||
|
confirmPassword: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? "Changing..." : "Change Password"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="advanced" className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Advanced Settings</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Platform administration options
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h3 className="font-medium mb-2">Platform Role</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You are logged in as a{" "}
|
||||||
|
<strong>
|
||||||
|
{session.user.platformRole || "Platform User"}
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h3 className="font-medium mb-2">Session Information</h3>
|
||||||
|
<div className="space-y-1 text-sm text-muted-foreground">
|
||||||
|
<p>User ID: {session.user.id}</p>
|
||||||
|
<p>Session Type: Platform</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{session.user.platformRole === "SUPER_ADMIN" && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
|
||||||
|
<h3 className="font-medium mb-2 text-red-900">
|
||||||
|
Super Admin Options
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-red-700 mb-3">
|
||||||
|
Advanced administrative options are available in the
|
||||||
|
individual company management pages.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { SessionProvider } from "next-auth/react";
|
import { SessionProvider } from "next-auth/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { TRPCProvider } from "@/components/providers/TRPCProvider";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
|
||||||
export function Providers({ children }: { children: ReactNode }) {
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
@ -18,7 +19,7 @@ export function Providers({ children }: { children: ReactNode }) {
|
|||||||
refetchInterval={30 * 60}
|
refetchInterval={30 * 60}
|
||||||
refetchOnWindowFocus={false}
|
refetchOnWindowFocus={false}
|
||||||
>
|
>
|
||||||
{children}
|
<TRPCProvider>{children}</TRPCProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
import * as countryCoder from "@rapideditor/country-coder";
|
import * as countryCoder from "@rapideditor/country-coder";
|
||||||
|
|
||||||
@ -22,7 +22,9 @@ interface GeographicMapProps {
|
|||||||
* Get coordinates for a country using the country-coder library
|
* Get coordinates for a country using the country-coder library
|
||||||
* This automatically extracts coordinates from the country geometry
|
* This automatically extracts coordinates from the country geometry
|
||||||
*/
|
*/
|
||||||
function getCoordinatesFromCountryCoder(countryCode: string): [number, number] | undefined {
|
function getCoordinatesFromCountryCoder(
|
||||||
|
countryCode: string
|
||||||
|
): [number, number] | undefined {
|
||||||
try {
|
try {
|
||||||
const feature = countryCoder.feature(countryCode);
|
const feature = countryCoder.feature(countryCode);
|
||||||
if (!feature?.geometry) {
|
if (!feature?.geometry) {
|
||||||
@ -35,7 +37,10 @@ function getCoordinatesFromCountryCoder(countryCode: string): [number, number] |
|
|||||||
return [lat, lon]; // Leaflet expects [lat, lon]
|
return [lat, lon]; // Leaflet expects [lat, lon]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (feature.geometry.type === "Polygon" && feature.geometry.coordinates?.[0]?.[0]) {
|
if (
|
||||||
|
feature.geometry.type === "Polygon" &&
|
||||||
|
feature.geometry.coordinates?.[0]?.[0]
|
||||||
|
) {
|
||||||
// For polygons, calculate centroid from the first ring
|
// For polygons, calculate centroid from the first ring
|
||||||
const coordinates = feature.geometry.coordinates[0];
|
const coordinates = feature.geometry.coordinates[0];
|
||||||
let lat = 0;
|
let lat = 0;
|
||||||
@ -47,7 +52,10 @@ function getCoordinatesFromCountryCoder(countryCode: string): [number, number] |
|
|||||||
return [lat / coordinates.length, lon / coordinates.length];
|
return [lat / coordinates.length, lon / coordinates.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (feature.geometry.type === "MultiPolygon" && feature.geometry.coordinates?.[0]?.[0]?.[0]) {
|
if (
|
||||||
|
feature.geometry.type === "MultiPolygon" &&
|
||||||
|
feature.geometry.coordinates?.[0]?.[0]?.[0]
|
||||||
|
) {
|
||||||
// For multipolygons, use the first polygon's first ring for centroid
|
// For multipolygons, use the first polygon's first ring for centroid
|
||||||
const coordinates = feature.geometry.coordinates[0][0];
|
const coordinates = feature.geometry.coordinates[0][0];
|
||||||
let lat = 0;
|
let lat = 0;
|
||||||
@ -61,7 +69,10 @@ function getCoordinatesFromCountryCoder(countryCode: string): [number, number] |
|
|||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Failed to get coordinates for country ${countryCode}:`, error);
|
console.warn(
|
||||||
|
`Failed to get coordinates for country ${countryCode}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -90,7 +101,6 @@ export default function GeographicMap({
|
|||||||
setIsClient(true);
|
setIsClient(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get coordinates for a country code
|
* Get coordinates for a country code
|
||||||
*/
|
*/
|
||||||
@ -129,22 +139,25 @@ export default function GeographicMap({
|
|||||||
/**
|
/**
|
||||||
* Process all countries data into CountryData array
|
* Process all countries data into CountryData array
|
||||||
*/
|
*/
|
||||||
const processCountriesData = useCallback((
|
const processCountriesData = useCallback(
|
||||||
countries: Record<string, number>,
|
(
|
||||||
countryCoordinates: Record<string, [number, number]>
|
countries: Record<string, number>,
|
||||||
): CountryData[] => {
|
countryCoordinates: Record<string, [number, number]>
|
||||||
const data = Object.entries(countries || {})
|
): CountryData[] => {
|
||||||
.map(([code, count]) =>
|
const data = Object.entries(countries || {})
|
||||||
processCountryEntry(code, count, countryCoordinates)
|
.map(([code, count]) =>
|
||||||
)
|
processCountryEntry(code, count, countryCoordinates)
|
||||||
.filter((item): item is CountryData => item !== null);
|
)
|
||||||
|
.filter((item): item is CountryData => item !== null);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Found ${data.length} countries with coordinates out of ${Object.keys(countries).length} total countries`
|
`Found ${data.length} countries with coordinates out of ${Object.keys(countries).length} total countries`
|
||||||
);
|
);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}, []);
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
// Process country data when client is ready and dependencies change
|
// Process country data when client is ready and dependencies change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -71,8 +71,7 @@ export default function MessageViewer({ messages }: MessageViewerProps) {
|
|||||||
: "No timestamp"}
|
: "No timestamp"}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Last message:{" "}
|
Last message: {(() => {
|
||||||
{(() => {
|
|
||||||
const lastMessage = messages[messages.length - 1];
|
const lastMessage = messages[messages.length - 1];
|
||||||
return lastMessage.timestamp
|
return lastMessage.timestamp
|
||||||
? new Date(lastMessage.timestamp).toLocaleString()
|
? new Date(lastMessage.timestamp).toLocaleString()
|
||||||
|
|||||||
253
components/examples/TRPCDemo.tsx
Normal file
253
components/examples/TRPCDemo.tsx
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* tRPC Demo Component
|
||||||
|
*
|
||||||
|
* This component demonstrates how to use tRPC hooks for queries and mutations.
|
||||||
|
* Can be used as a reference for migrating existing components.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Loader2, RefreshCw } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { trpc } from "@/lib/trpc-client";
|
||||||
|
|
||||||
|
export function TRPCDemo() {
|
||||||
|
const [sessionFilters, setSessionFilters] = useState({
|
||||||
|
search: "",
|
||||||
|
page: 1,
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
const {
|
||||||
|
data: sessions,
|
||||||
|
isLoading: sessionsLoading,
|
||||||
|
error: sessionsError,
|
||||||
|
refetch: refetchSessions,
|
||||||
|
} = trpc.dashboard.getSessions.useQuery(sessionFilters);
|
||||||
|
|
||||||
|
const { data: overview, isLoading: overviewLoading } =
|
||||||
|
trpc.dashboard.getOverview.useQuery({});
|
||||||
|
|
||||||
|
const { data: topQuestions, isLoading: questionsLoading } =
|
||||||
|
trpc.dashboard.getTopQuestions.useQuery({ limit: 3 });
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const refreshSessionsMutation = trpc.dashboard.refreshSessions.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success(data.message);
|
||||||
|
// Invalidate and refetch sessions
|
||||||
|
refetchSessions();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(`Failed to refresh sessions: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRefreshSessions = () => {
|
||||||
|
refreshSessionsMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchChange = (search: string) => {
|
||||||
|
setSessionFilters((prev) => ({ ...prev, search, page: 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-bold">tRPC Demo</h2>
|
||||||
|
<Button
|
||||||
|
onClick={handleRefreshSessions}
|
||||||
|
disabled={refreshSessionsMutation.isPending}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{refreshSessionsMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Refresh Sessions
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Total Sessions
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{overviewLoading ? (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{overview?.totalSessions || 0}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium">Avg Messages</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{overviewLoading ? (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{Math.round(overview?.avgMessagesSent || 0)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Sentiment Distribution
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{overviewLoading ? (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{overview?.sentimentDistribution.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.sentiment}
|
||||||
|
className="flex justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span>{item.sentiment}</span>
|
||||||
|
<Badge variant="outline">{item.count}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Questions */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Top Questions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{questionsLoading ? (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
Loading questions...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{topQuestions?.map((item, index) => (
|
||||||
|
<div key={index} className="flex justify-between items-center">
|
||||||
|
<span className="text-sm">{item.question}</span>
|
||||||
|
<Badge>{item.count}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Sessions List */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
Sessions
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Search sessions..."
|
||||||
|
value={sessionFilters.search}
|
||||||
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
|
className="w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{sessionsError && (
|
||||||
|
<div className="text-red-600 mb-4">
|
||||||
|
Error loading sessions: {sessionsError.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sessionsLoading ? (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
Loading sessions...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sessions?.sessions.map((session) => (
|
||||||
|
<div key={session.id} className="border rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="font-medium">Session {session.id}</span>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
session.sentiment === "POSITIVE"
|
||||||
|
? "default"
|
||||||
|
: session.sentiment === "NEGATIVE"
|
||||||
|
? "destructive"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{session.sentiment}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{session.messagesSent} messages
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
{session.summary}
|
||||||
|
</p>
|
||||||
|
{session.questions && session.questions.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{session.questions.slice(0, 3).map((question, idx) => (
|
||||||
|
<Badge key={idx} variant="outline" className="text-xs">
|
||||||
|
{question.length > 50
|
||||||
|
? `${question.slice(0, 50)}...`
|
||||||
|
: question}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Pagination Info */}
|
||||||
|
{sessions && (
|
||||||
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
|
Showing {sessions.sessions.length} of{" "}
|
||||||
|
{sessions.pagination.totalCount} sessions (Page{" "}
|
||||||
|
{sessions.pagination.page} of {sessions.pagination.totalPages}
|
||||||
|
)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
components/providers/TRPCProvider.tsx
Normal file
42
components/providers/TRPCProvider.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* tRPC Provider Component
|
||||||
|
*
|
||||||
|
* Simplified provider for tRPC integration.
|
||||||
|
* The tRPC client is configured in trpc-client.ts and used directly in components.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface TRPCProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TRPCProvider({ children }: TRPCProviderProps) {
|
||||||
|
const [queryClient] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
// Disable automatic refetching for better UX
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: true,
|
||||||
|
staleTime: 30 * 1000, // 30 seconds
|
||||||
|
gcTime: 5 * 60 * 1000, // 5 minutes (was cacheTime)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
{process.env.NODE_ENV === "development" && (
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
)}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -10,8 +10,14 @@
|
|||||||
* - Improved error handling and retry mechanisms
|
* - Improved error handling and retry mechanisms
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
AIBatchRequestStatus,
|
||||||
|
type AIProcessingRequest,
|
||||||
|
AIRequestStatus,
|
||||||
|
} from "@prisma/client";
|
||||||
|
import { env } from "./env";
|
||||||
|
import { openAIMock } from "./mocks/openai-mock-server";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
import { AIBatchRequestStatus, AIRequestStatus, type AIProcessingRequest } from "@prisma/client";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for batch processing
|
* Configuration for batch processing
|
||||||
@ -61,7 +67,15 @@ interface OpenAIBatchResponse {
|
|||||||
};
|
};
|
||||||
input_file_id: string;
|
input_file_id: string;
|
||||||
completion_window: string;
|
completion_window: string;
|
||||||
status: "validating" | "failed" | "in_progress" | "finalizing" | "completed" | "expired" | "cancelling" | "cancelled";
|
status:
|
||||||
|
| "validating"
|
||||||
|
| "failed"
|
||||||
|
| "in_progress"
|
||||||
|
| "finalizing"
|
||||||
|
| "completed"
|
||||||
|
| "expired"
|
||||||
|
| "cancelling"
|
||||||
|
| "cancelled";
|
||||||
output_file_id?: string;
|
output_file_id?: string;
|
||||||
error_file_id?: string;
|
error_file_id?: string;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
@ -109,18 +123,20 @@ export async function getPendingBatchRequests(
|
|||||||
orderBy: {
|
orderBy: {
|
||||||
requestedAt: "asc",
|
requestedAt: "asc",
|
||||||
},
|
},
|
||||||
}) as Promise<(AIProcessingRequest & {
|
}) as Promise<
|
||||||
session: {
|
(AIProcessingRequest & {
|
||||||
id: string;
|
session: {
|
||||||
companyId: string;
|
|
||||||
messages: Array<{
|
|
||||||
id: string;
|
id: string;
|
||||||
role: string;
|
companyId: string;
|
||||||
content: string;
|
messages: Array<{
|
||||||
order: number;
|
id: string;
|
||||||
}>;
|
role: string;
|
||||||
} | null;
|
content: string;
|
||||||
})[]>;
|
order: number;
|
||||||
|
}>;
|
||||||
|
} | null;
|
||||||
|
})[]
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -135,7 +151,9 @@ export async function createBatchRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (requests.length > BATCH_CONFIG.MAX_REQUESTS_PER_BATCH) {
|
if (requests.length > BATCH_CONFIG.MAX_REQUESTS_PER_BATCH) {
|
||||||
throw new Error(`Batch size ${requests.length} exceeds maximum of ${BATCH_CONFIG.MAX_REQUESTS_PER_BATCH}`);
|
throw new Error(
|
||||||
|
`Batch size ${requests.length} exceeds maximum of ${BATCH_CONFIG.MAX_REQUESTS_PER_BATCH}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create batch requests in OpenAI format
|
// Create batch requests in OpenAI format
|
||||||
@ -152,7 +170,9 @@ export async function createBatchRequest(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: formatMessagesForProcessing((request as any).session?.messages || []),
|
content: formatMessagesForProcessing(
|
||||||
|
(request as any).session?.messages || []
|
||||||
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
@ -230,7 +250,9 @@ export async function checkBatchStatuses(companyId: string): Promise<void> {
|
|||||||
/**
|
/**
|
||||||
* Process completed batches and extract results
|
* Process completed batches and extract results
|
||||||
*/
|
*/
|
||||||
export async function processCompletedBatches(companyId: string): Promise<void> {
|
export async function processCompletedBatches(
|
||||||
|
companyId: string
|
||||||
|
): Promise<void> {
|
||||||
const completedBatches = await prisma.aIBatchRequest.findMany({
|
const completedBatches = await prisma.aIBatchRequest.findMany({
|
||||||
where: {
|
where: {
|
||||||
companyId,
|
companyId,
|
||||||
@ -262,17 +284,31 @@ export async function processCompletedBatches(companyId: string): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to upload file content to OpenAI
|
* Helper function to upload file content to OpenAI (real or mock)
|
||||||
*/
|
*/
|
||||||
async function uploadFileToOpenAI(content: string): Promise<{ id: string }> {
|
async function uploadFileToOpenAI(content: string): Promise<{ id: string }> {
|
||||||
|
if (env.OPENAI_MOCK_MODE) {
|
||||||
|
console.log(
|
||||||
|
`[OpenAI Mock] Uploading batch file with ${content.split("\n").length} requests`
|
||||||
|
);
|
||||||
|
return openAIMock.mockUploadFile({
|
||||||
|
file: content,
|
||||||
|
purpose: "batch",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", new Blob([content], { type: "application/jsonl" }), "batch_requests.jsonl");
|
formData.append(
|
||||||
|
"file",
|
||||||
|
new Blob([content], { type: "application/jsonl" }),
|
||||||
|
"batch_requests.jsonl"
|
||||||
|
);
|
||||||
formData.append("purpose", "batch");
|
formData.append("purpose", "batch");
|
||||||
|
|
||||||
const response = await fetch("https://api.openai.com/v1/files", {
|
const response = await fetch("https://api.openai.com/v1/files", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${process.env.OPENAI_API_KEY}`,
|
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
|
||||||
},
|
},
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@ -285,13 +321,24 @@ async function uploadFileToOpenAI(content: string): Promise<{ id: string }> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to create a batch request on OpenAI
|
* Helper function to create a batch request on OpenAI (real or mock)
|
||||||
*/
|
*/
|
||||||
async function createOpenAIBatch(inputFileId: string): Promise<OpenAIBatchResponse> {
|
async function createOpenAIBatch(
|
||||||
|
inputFileId: string
|
||||||
|
): Promise<OpenAIBatchResponse> {
|
||||||
|
if (env.OPENAI_MOCK_MODE) {
|
||||||
|
console.log(`[OpenAI Mock] Creating batch with input file ${inputFileId}`);
|
||||||
|
return openAIMock.mockCreateBatch({
|
||||||
|
input_file_id: inputFileId,
|
||||||
|
endpoint: "/v1/chat/completions",
|
||||||
|
completion_window: "24h",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch("https://api.openai.com/v1/batches", {
|
const response = await fetch("https://api.openai.com/v1/batches", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${process.env.OPENAI_API_KEY}`,
|
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@ -309,13 +356,20 @@ async function createOpenAIBatch(inputFileId: string): Promise<OpenAIBatchRespon
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to get batch status from OpenAI
|
* Helper function to get batch status from OpenAI (real or mock)
|
||||||
*/
|
*/
|
||||||
async function getOpenAIBatchStatus(batchId: string): Promise<OpenAIBatchResponse> {
|
async function getOpenAIBatchStatus(
|
||||||
|
batchId: string
|
||||||
|
): Promise<OpenAIBatchResponse> {
|
||||||
|
if (env.OPENAI_MOCK_MODE) {
|
||||||
|
console.log(`[OpenAI Mock] Getting batch status for ${batchId}`);
|
||||||
|
return openAIMock.mockGetBatch(batchId);
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`https://api.openai.com/v1/batches/${batchId}`, {
|
const response = await fetch(`https://api.openai.com/v1/batches/${batchId}`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${process.env.OPENAI_API_KEY}`,
|
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -329,7 +383,10 @@ async function getOpenAIBatchStatus(batchId: string): Promise<OpenAIBatchRespons
|
|||||||
/**
|
/**
|
||||||
* Update batch status in our database based on OpenAI response
|
* Update batch status in our database based on OpenAI response
|
||||||
*/
|
*/
|
||||||
async function updateBatchStatus(batchId: string, openAIResponse: OpenAIBatchResponse): Promise<void> {
|
async function updateBatchStatus(
|
||||||
|
batchId: string,
|
||||||
|
openAIResponse: OpenAIBatchResponse
|
||||||
|
): Promise<void> {
|
||||||
const statusMapping: Record<string, AIBatchRequestStatus> = {
|
const statusMapping: Record<string, AIBatchRequestStatus> = {
|
||||||
validating: AIBatchRequestStatus.VALIDATING,
|
validating: AIBatchRequestStatus.VALIDATING,
|
||||||
failed: AIBatchRequestStatus.FAILED,
|
failed: AIBatchRequestStatus.FAILED,
|
||||||
@ -340,7 +397,8 @@ async function updateBatchStatus(batchId: string, openAIResponse: OpenAIBatchRes
|
|||||||
cancelled: AIBatchRequestStatus.CANCELLED,
|
cancelled: AIBatchRequestStatus.CANCELLED,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ourStatus = statusMapping[openAIResponse.status] || AIBatchRequestStatus.FAILED;
|
const ourStatus =
|
||||||
|
statusMapping[openAIResponse.status] || AIBatchRequestStatus.FAILED;
|
||||||
|
|
||||||
await prisma.aIBatchRequest.update({
|
await prisma.aIBatchRequest.update({
|
||||||
where: { id: batchId },
|
where: { id: batchId },
|
||||||
@ -348,7 +406,9 @@ async function updateBatchStatus(batchId: string, openAIResponse: OpenAIBatchRes
|
|||||||
status: ourStatus,
|
status: ourStatus,
|
||||||
outputFileId: openAIResponse.output_file_id,
|
outputFileId: openAIResponse.output_file_id,
|
||||||
errorFileId: openAIResponse.error_file_id,
|
errorFileId: openAIResponse.error_file_id,
|
||||||
completedAt: openAIResponse.completed_at ? new Date(openAIResponse.completed_at * 1000) : null,
|
completedAt: openAIResponse.completed_at
|
||||||
|
? new Date(openAIResponse.completed_at * 1000)
|
||||||
|
: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -369,7 +429,7 @@ async function processBatchResults(batch: {
|
|||||||
const results = await downloadOpenAIFile(batch.outputFileId);
|
const results = await downloadOpenAIFile(batch.outputFileId);
|
||||||
|
|
||||||
// Parse JSONL results
|
// Parse JSONL results
|
||||||
const resultLines = results.split("\n").filter(line => line.trim());
|
const resultLines = results.split("\n").filter((line) => line.trim());
|
||||||
|
|
||||||
for (const line of resultLines) {
|
for (const line of resultLines) {
|
||||||
try {
|
try {
|
||||||
@ -378,10 +438,16 @@ async function processBatchResults(batch: {
|
|||||||
|
|
||||||
if (result.response?.body?.choices?.[0]?.message?.content) {
|
if (result.response?.body?.choices?.[0]?.message?.content) {
|
||||||
// Process successful result
|
// Process successful result
|
||||||
await updateProcessingRequestWithResult(requestId, result.response.body);
|
await updateProcessingRequestWithResult(
|
||||||
|
requestId,
|
||||||
|
result.response.body
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Handle error result
|
// Handle error result
|
||||||
await markProcessingRequestAsFailed(requestId, result.error?.message || "Unknown error");
|
await markProcessingRequestAsFailed(
|
||||||
|
requestId,
|
||||||
|
result.error?.message || "Unknown error"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to process batch result line:", error);
|
console.error("Failed to process batch result line:", error);
|
||||||
@ -399,15 +465,23 @@ async function processBatchResults(batch: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download file content from OpenAI
|
* Download file content from OpenAI (real or mock)
|
||||||
*/
|
*/
|
||||||
async function downloadOpenAIFile(fileId: string): Promise<string> {
|
async function downloadOpenAIFile(fileId: string): Promise<string> {
|
||||||
const response = await fetch(`https://api.openai.com/v1/files/${fileId}/content`, {
|
if (env.OPENAI_MOCK_MODE) {
|
||||||
method: "GET",
|
console.log(`[OpenAI Mock] Downloading file content for ${fileId}`);
|
||||||
headers: {
|
return openAIMock.mockGetFileContent(fileId);
|
||||||
"Authorization": `Bearer ${process.env.OPENAI_API_KEY}`,
|
}
|
||||||
},
|
|
||||||
});
|
const response = await fetch(
|
||||||
|
`https://api.openai.com/v1/files/${fileId}/content`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to download file: ${response.statusText}`);
|
throw new Error(`Failed to download file: ${response.statusText}`);
|
||||||
@ -419,18 +493,21 @@ async function downloadOpenAIFile(fileId: string): Promise<string> {
|
|||||||
/**
|
/**
|
||||||
* Update processing request with successful AI result
|
* Update processing request with successful AI result
|
||||||
*/
|
*/
|
||||||
async function updateProcessingRequestWithResult(requestId: string, aiResponse: {
|
async function updateProcessingRequestWithResult(
|
||||||
usage: {
|
requestId: string,
|
||||||
prompt_tokens: number;
|
aiResponse: {
|
||||||
completion_tokens: number;
|
usage: {
|
||||||
total_tokens: number;
|
prompt_tokens: number;
|
||||||
};
|
completion_tokens: number;
|
||||||
choices: Array<{
|
total_tokens: number;
|
||||||
message: {
|
|
||||||
content: string;
|
|
||||||
};
|
};
|
||||||
}>;
|
choices: Array<{
|
||||||
}): Promise<void> {
|
message: {
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
const usage = aiResponse.usage;
|
const usage = aiResponse.usage;
|
||||||
const content = aiResponse.choices[0].message.content;
|
const content = aiResponse.choices[0].message.content;
|
||||||
|
|
||||||
@ -469,14 +546,20 @@ async function updateProcessingRequestWithResult(requestId: string, aiResponse:
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to parse AI result for request ${requestId}:`, error);
|
console.error(`Failed to parse AI result for request ${requestId}:`, error);
|
||||||
await markProcessingRequestAsFailed(requestId, "Failed to parse AI response");
|
await markProcessingRequestAsFailed(
|
||||||
|
requestId,
|
||||||
|
"Failed to parse AI response"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark processing request as failed
|
* Mark processing request as failed
|
||||||
*/
|
*/
|
||||||
async function markProcessingRequestAsFailed(requestId: string, errorMessage: string): Promise<void> {
|
async function markProcessingRequestAsFailed(
|
||||||
|
requestId: string,
|
||||||
|
errorMessage: string
|
||||||
|
): Promise<void> {
|
||||||
await prisma.aIProcessingRequest.update({
|
await prisma.aIProcessingRequest.update({
|
||||||
where: { id: requestId },
|
where: { id: requestId },
|
||||||
data: {
|
data: {
|
||||||
@ -493,9 +576,12 @@ async function markProcessingRequestAsFailed(requestId: string, errorMessage: st
|
|||||||
*/
|
*/
|
||||||
function getSystemPromptForProcessingType(processingType: string): string {
|
function getSystemPromptForProcessingType(processingType: string): string {
|
||||||
const prompts = {
|
const prompts = {
|
||||||
sentiment_analysis: "Analyze the sentiment of this conversation and respond with JSON containing: {\"sentiment\": \"POSITIVE|NEUTRAL|NEGATIVE\"}",
|
sentiment_analysis:
|
||||||
categorization: "Categorize this conversation and respond with JSON containing: {\"category\": \"CATEGORY_NAME\"}",
|
'Analyze the sentiment of this conversation and respond with JSON containing: {"sentiment": "POSITIVE|NEUTRAL|NEGATIVE"}',
|
||||||
summary: "Summarize this conversation and respond with JSON containing: {\"summary\": \"Brief summary\"}",
|
categorization:
|
||||||
|
'Categorize this conversation and respond with JSON containing: {"category": "CATEGORY_NAME"}',
|
||||||
|
summary:
|
||||||
|
'Summarize this conversation and respond with JSON containing: {"summary": "Brief summary"}',
|
||||||
full_analysis: `Analyze this conversation for sentiment, category, and provide a summary. Respond with JSON:
|
full_analysis: `Analyze this conversation for sentiment, category, and provide a summary. Respond with JSON:
|
||||||
{
|
{
|
||||||
"sentiment": "POSITIVE|NEUTRAL|NEGATIVE",
|
"sentiment": "POSITIVE|NEUTRAL|NEGATIVE",
|
||||||
@ -505,19 +591,21 @@ function getSystemPromptForProcessingType(processingType: string): string {
|
|||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
return prompts[processingType as keyof typeof prompts] || prompts.full_analysis;
|
return (
|
||||||
|
prompts[processingType as keyof typeof prompts] || prompts.full_analysis
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format session messages for AI processing
|
* Format session messages for AI processing
|
||||||
*/
|
*/
|
||||||
function formatMessagesForProcessing(messages: Array<{
|
function formatMessagesForProcessing(
|
||||||
role: string;
|
messages: Array<{
|
||||||
content: string;
|
role: string;
|
||||||
}>): string {
|
content: string;
|
||||||
return messages
|
}>
|
||||||
.map((msg) => `${msg.role}: ${msg.content}`)
|
): string {
|
||||||
.join("\n");
|
return messages.map((msg) => `${msg.role}: ${msg.content}`).join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -538,10 +626,13 @@ export async function getBatchProcessingStats(companyId: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
batchStats: stats.reduce((acc, stat) => {
|
batchStats: stats.reduce(
|
||||||
acc[stat.status] = stat._count;
|
(acc, stat) => {
|
||||||
return acc;
|
acc[stat.status] = stat._count;
|
||||||
}, {} as Record<string, number>),
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, number>
|
||||||
|
),
|
||||||
pendingRequests,
|
pendingRequests,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -9,11 +9,11 @@
|
|||||||
|
|
||||||
import cron, { type ScheduledTask } from "node-cron";
|
import cron, { type ScheduledTask } from "node-cron";
|
||||||
import {
|
import {
|
||||||
getPendingBatchRequests,
|
|
||||||
createBatchRequest,
|
|
||||||
checkBatchStatuses,
|
checkBatchStatuses,
|
||||||
|
createBatchRequest,
|
||||||
|
getBatchProcessingStats,
|
||||||
|
getPendingBatchRequests,
|
||||||
processCompletedBatches,
|
processCompletedBatches,
|
||||||
getBatchProcessingStats
|
|
||||||
} from "./batchProcessor";
|
} from "./batchProcessor";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
import { getSchedulerConfig } from "./schedulerConfig";
|
import { getSchedulerConfig } from "./schedulerConfig";
|
||||||
@ -157,17 +157,24 @@ async function createBatchesForCompany(companyId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if we should create a batch
|
// Check if we should create a batch
|
||||||
const shouldCreateBatch = await shouldCreateBatchForCompany(companyId, pendingRequests.length);
|
const shouldCreateBatch = await shouldCreateBatchForCompany(
|
||||||
|
companyId,
|
||||||
|
pendingRequests.length
|
||||||
|
);
|
||||||
|
|
||||||
if (!shouldCreateBatch) {
|
if (!shouldCreateBatch) {
|
||||||
return; // Wait for more requests or more time
|
return; // Wait for more requests or more time
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Creating batch for company ${companyId} with ${pendingRequests.length} requests`);
|
console.log(
|
||||||
|
`Creating batch for company ${companyId} with ${pendingRequests.length} requests`
|
||||||
|
);
|
||||||
|
|
||||||
const batchId = await createBatchRequest(companyId, pendingRequests);
|
const batchId = await createBatchRequest(companyId, pendingRequests);
|
||||||
|
|
||||||
console.log(`Successfully created batch ${batchId} for company ${companyId}`);
|
console.log(
|
||||||
|
`Successfully created batch ${batchId} for company ${companyId}`
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to create batch for company ${companyId}:`, error);
|
console.error(`Failed to create batch for company ${companyId}:`, error);
|
||||||
}
|
}
|
||||||
@ -176,7 +183,10 @@ async function createBatchesForCompany(companyId: string): Promise<void> {
|
|||||||
/**
|
/**
|
||||||
* Determine if a batch should be created for a company
|
* Determine if a batch should be created for a company
|
||||||
*/
|
*/
|
||||||
async function shouldCreateBatchForCompany(companyId: string, pendingCount: number): Promise<boolean> {
|
async function shouldCreateBatchForCompany(
|
||||||
|
companyId: string,
|
||||||
|
pendingCount: number
|
||||||
|
): Promise<boolean> {
|
||||||
// Always create if we have enough requests
|
// Always create if we have enough requests
|
||||||
if (pendingCount >= SCHEDULER_CONFIG.MIN_BATCH_SIZE) {
|
if (pendingCount >= SCHEDULER_CONFIG.MIN_BATCH_SIZE) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
12
lib/env.ts
12
lib/env.ts
@ -81,6 +81,7 @@ export const env = {
|
|||||||
|
|
||||||
// OpenAI
|
// OpenAI
|
||||||
OPENAI_API_KEY: parseEnvValue(process.env.OPENAI_API_KEY) || "",
|
OPENAI_API_KEY: parseEnvValue(process.env.OPENAI_API_KEY) || "",
|
||||||
|
OPENAI_MOCK_MODE: parseEnvValue(process.env.OPENAI_MOCK_MODE) === "true",
|
||||||
|
|
||||||
// Scheduler Configuration
|
// Scheduler Configuration
|
||||||
SCHEDULER_ENABLED: parseEnvValue(process.env.SCHEDULER_ENABLED) === "true",
|
SCHEDULER_ENABLED: parseEnvValue(process.env.SCHEDULER_ENABLED) === "true",
|
||||||
@ -135,8 +136,14 @@ export function validateEnv(): { valid: boolean; errors: string[] } {
|
|||||||
errors.push("NEXTAUTH_SECRET is required");
|
errors.push("NEXTAUTH_SECRET is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!env.OPENAI_API_KEY && env.NODE_ENV === "production") {
|
if (
|
||||||
errors.push("OPENAI_API_KEY is required in production");
|
!env.OPENAI_API_KEY &&
|
||||||
|
env.NODE_ENV === "production" &&
|
||||||
|
!env.OPENAI_MOCK_MODE
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
"OPENAI_API_KEY is required in production (unless OPENAI_MOCK_MODE is enabled)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -174,6 +181,7 @@ export function logEnvConfig(): void {
|
|||||||
console.log(` NODE_ENV: ${env.NODE_ENV}`);
|
console.log(` NODE_ENV: ${env.NODE_ENV}`);
|
||||||
console.log(` NEXTAUTH_URL: ${env.NEXTAUTH_URL}`);
|
console.log(` NEXTAUTH_URL: ${env.NEXTAUTH_URL}`);
|
||||||
console.log(` SCHEDULER_ENABLED: ${env.SCHEDULER_ENABLED}`);
|
console.log(` SCHEDULER_ENABLED: ${env.SCHEDULER_ENABLED}`);
|
||||||
|
console.log(` OPENAI_MOCK_MODE: ${env.OPENAI_MOCK_MODE}`);
|
||||||
console.log(` PORT: ${env.PORT}`);
|
console.log(` PORT: ${env.PORT}`);
|
||||||
|
|
||||||
if (env.SCHEDULER_ENABLED) {
|
if (env.SCHEDULER_ENABLED) {
|
||||||
|
|||||||
208
lib/hooks/useTRPC.ts
Normal file
208
lib/hooks/useTRPC.ts
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* Custom hooks for tRPC usage
|
||||||
|
*
|
||||||
|
* This file provides convenient hooks for common tRPC operations
|
||||||
|
* with proper error handling and loading states.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { trpc } from "@/lib/trpc-client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for dashboard session management
|
||||||
|
*/
|
||||||
|
export function useDashboardSessions(filters?: {
|
||||||
|
search?: string;
|
||||||
|
sentiment?: string;
|
||||||
|
category?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}) {
|
||||||
|
return trpc.dashboard.getSessions.useQuery(
|
||||||
|
{
|
||||||
|
search: filters?.search,
|
||||||
|
sentiment: filters?.sentiment as
|
||||||
|
| "POSITIVE"
|
||||||
|
| "NEUTRAL"
|
||||||
|
| "NEGATIVE"
|
||||||
|
| undefined,
|
||||||
|
category: filters?.category as
|
||||||
|
| "SCHEDULE_HOURS"
|
||||||
|
| "LEAVE_VACATION"
|
||||||
|
| "SICK_LEAVE_RECOVERY"
|
||||||
|
| "SALARY_COMPENSATION"
|
||||||
|
| "CONTRACT_HOURS"
|
||||||
|
| "ONBOARDING"
|
||||||
|
| "OFFBOARDING"
|
||||||
|
| "WORKWEAR_STAFF_PASS"
|
||||||
|
| "TEAM_CONTACTS"
|
||||||
|
| "PERSONAL_QUESTIONS"
|
||||||
|
| "ACCESS_LOGIN"
|
||||||
|
| "SOCIAL_QUESTIONS"
|
||||||
|
| "UNRECOGNIZED_OTHER"
|
||||||
|
| undefined,
|
||||||
|
startDate: filters?.startDate,
|
||||||
|
endDate: filters?.endDate,
|
||||||
|
page: filters?.page || 1,
|
||||||
|
limit: filters?.limit || 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Cache for 30 seconds
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
// Keep in background for 5 minutes
|
||||||
|
gcTime: 5 * 60 * 1000,
|
||||||
|
// Refetch when component mounts if data is stale
|
||||||
|
refetchOnMount: true,
|
||||||
|
// Don't refetch on window focus to avoid excessive API calls
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for dashboard overview statistics
|
||||||
|
*/
|
||||||
|
export function useDashboardOverview(dateRange?: {
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}) {
|
||||||
|
return trpc.dashboard.getOverview.useQuery(
|
||||||
|
{
|
||||||
|
startDate: dateRange?.startDate,
|
||||||
|
endDate: dateRange?.endDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||||
|
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for top questions
|
||||||
|
*/
|
||||||
|
export function useTopQuestions(options?: {
|
||||||
|
limit?: number;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}) {
|
||||||
|
return trpc.dashboard.getTopQuestions.useQuery(
|
||||||
|
{
|
||||||
|
limit: options?.limit || 10,
|
||||||
|
startDate: options?.startDate,
|
||||||
|
endDate: options?.endDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
gcTime: 15 * 60 * 1000, // 15 minutes
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for geographic distribution
|
||||||
|
*/
|
||||||
|
export function useGeographicDistribution(dateRange?: {
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}) {
|
||||||
|
return trpc.dashboard.getGeographicDistribution.useQuery(
|
||||||
|
{
|
||||||
|
startDate: dateRange?.startDate,
|
||||||
|
endDate: dateRange?.endDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||||
|
gcTime: 30 * 60 * 1000, // 30 minutes
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for AI processing metrics
|
||||||
|
*/
|
||||||
|
export function useAIMetrics(dateRange?: {
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}) {
|
||||||
|
return trpc.dashboard.getAIMetrics.useQuery(
|
||||||
|
{
|
||||||
|
startDate: dateRange?.startDate,
|
||||||
|
endDate: dateRange?.endDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||||
|
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for user authentication profile
|
||||||
|
*/
|
||||||
|
export function useUserProfile() {
|
||||||
|
return trpc.auth.getProfile.useQuery(undefined, {
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
gcTime: 30 * 60 * 1000, // 30 minutes
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
// Only fetch if user is likely authenticated
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for admin user management
|
||||||
|
*/
|
||||||
|
export function useAdminUsers(options?: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
search?: string;
|
||||||
|
}) {
|
||||||
|
return trpc.admin.getUsers.useQuery(
|
||||||
|
{
|
||||||
|
page: options?.page || 1,
|
||||||
|
limit: options?.limit || 20,
|
||||||
|
search: options?.search,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
staleTime: 60 * 1000, // 1 minute
|
||||||
|
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for company settings
|
||||||
|
*/
|
||||||
|
export function useCompanySettings() {
|
||||||
|
return trpc.admin.getCompanySettings.useQuery(undefined, {
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
gcTime: 30 * 60 * 1000, // 30 minutes
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for system statistics
|
||||||
|
*/
|
||||||
|
export function useSystemStats() {
|
||||||
|
return trpc.admin.getSystemStats.useQuery(undefined, {
|
||||||
|
staleTime: 30 * 1000, // 30 seconds
|
||||||
|
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
416
lib/mocks/openai-mock-server.ts
Normal file
416
lib/mocks/openai-mock-server.ts
Normal file
@ -0,0 +1,416 @@
|
|||||||
|
/**
|
||||||
|
* OpenAI API Mock Server
|
||||||
|
*
|
||||||
|
* Provides a drop-in replacement for OpenAI API calls during development
|
||||||
|
* and testing to prevent unexpected costs and enable offline development.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
calculateMockCost,
|
||||||
|
generateBatchResponse,
|
||||||
|
generateSessionAnalysisResponse,
|
||||||
|
MOCK_RESPONSE_GENERATORS,
|
||||||
|
type MockBatchResponse,
|
||||||
|
type MockChatCompletion,
|
||||||
|
type MockResponseType,
|
||||||
|
} from "./openai-responses";
|
||||||
|
|
||||||
|
interface MockOpenAIConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
baseDelay: number; // Base delay in ms to simulate API latency
|
||||||
|
randomDelay: number; // Additional random delay (0 to this value)
|
||||||
|
errorRate: number; // Probability of simulated errors (0.0 to 1.0)
|
||||||
|
logRequests: boolean; // Whether to log mock requests
|
||||||
|
}
|
||||||
|
|
||||||
|
class OpenAIMockServer {
|
||||||
|
private config: MockOpenAIConfig;
|
||||||
|
private totalCost = 0;
|
||||||
|
private requestCount = 0;
|
||||||
|
private activeBatches: Map<string, MockBatchResponse> = new Map();
|
||||||
|
|
||||||
|
constructor(config: Partial<MockOpenAIConfig> = {}) {
|
||||||
|
this.config = {
|
||||||
|
enabled: process.env.OPENAI_MOCK_MODE === "true",
|
||||||
|
baseDelay: 500, // 500ms base delay
|
||||||
|
randomDelay: 1000, // 0-1000ms additional delay
|
||||||
|
errorRate: 0.02, // 2% error rate
|
||||||
|
logRequests: process.env.NODE_ENV === "development",
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if mock mode is enabled
|
||||||
|
*/
|
||||||
|
isEnabled(): boolean {
|
||||||
|
return this.config.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulate network delay
|
||||||
|
*/
|
||||||
|
private async simulateDelay(): Promise<void> {
|
||||||
|
const delay =
|
||||||
|
this.config.baseDelay + Math.random() * this.config.randomDelay;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulate random API errors
|
||||||
|
*/
|
||||||
|
private shouldSimulateError(): boolean {
|
||||||
|
return Math.random() < this.config.errorRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log mock requests for debugging
|
||||||
|
*/
|
||||||
|
private logRequest(endpoint: string, data: any): void {
|
||||||
|
if (this.config.logRequests) {
|
||||||
|
console.log(`[OpenAI Mock] ${endpoint}:`, JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is a session analysis request (comprehensive JSON format)
|
||||||
|
*/
|
||||||
|
private isSessionAnalysisRequest(prompt: string): boolean {
|
||||||
|
const promptLower = prompt.toLowerCase();
|
||||||
|
return (
|
||||||
|
promptLower.includes("session_id") &&
|
||||||
|
(promptLower.includes("sentiment") ||
|
||||||
|
promptLower.includes("category") ||
|
||||||
|
promptLower.includes("language"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract processing type from prompt
|
||||||
|
*/
|
||||||
|
private extractProcessingType(prompt: string): MockResponseType {
|
||||||
|
const promptLower = prompt.toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
promptLower.includes("sentiment") ||
|
||||||
|
promptLower.includes("positive") ||
|
||||||
|
promptLower.includes("negative")
|
||||||
|
) {
|
||||||
|
return "sentiment";
|
||||||
|
}
|
||||||
|
if (promptLower.includes("category") || promptLower.includes("classify")) {
|
||||||
|
return "category";
|
||||||
|
}
|
||||||
|
if (promptLower.includes("summary") || promptLower.includes("summarize")) {
|
||||||
|
return "summary";
|
||||||
|
}
|
||||||
|
if (promptLower.includes("question") || promptLower.includes("extract")) {
|
||||||
|
return "questions";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to sentiment analysis
|
||||||
|
return "sentiment";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock chat completions endpoint
|
||||||
|
*/
|
||||||
|
async mockChatCompletion(request: {
|
||||||
|
model: string;
|
||||||
|
messages: Array<{ role: string; content: string }>;
|
||||||
|
temperature?: number;
|
||||||
|
max_tokens?: number;
|
||||||
|
}): Promise<MockChatCompletion> {
|
||||||
|
this.requestCount++;
|
||||||
|
|
||||||
|
await this.simulateDelay();
|
||||||
|
|
||||||
|
if (this.shouldSimulateError()) {
|
||||||
|
throw new Error("Mock OpenAI API error: Rate limit exceeded");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logRequest("/v1/chat/completions", request);
|
||||||
|
|
||||||
|
// Extract the user content to analyze
|
||||||
|
const userMessage =
|
||||||
|
request.messages.find((msg) => msg.role === "user")?.content || "";
|
||||||
|
const systemMessage =
|
||||||
|
request.messages.find((msg) => msg.role === "system")?.content || "";
|
||||||
|
|
||||||
|
let response: MockChatCompletion;
|
||||||
|
let processingType: string;
|
||||||
|
|
||||||
|
// Check if this is a comprehensive session analysis request
|
||||||
|
if (this.isSessionAnalysisRequest(systemMessage)) {
|
||||||
|
// Extract session ID from system message for session analysis
|
||||||
|
const sessionIdMatch = systemMessage.match(/"session_id":\s*"([^"]+)"/);
|
||||||
|
const sessionId = sessionIdMatch?.[1] || `mock-session-${Date.now()}`;
|
||||||
|
response = generateSessionAnalysisResponse(userMessage, sessionId);
|
||||||
|
processingType = "session_analysis";
|
||||||
|
} else {
|
||||||
|
// Use simple response generators for other types
|
||||||
|
const detectedType = this.extractProcessingType(
|
||||||
|
systemMessage + " " + userMessage
|
||||||
|
);
|
||||||
|
response = MOCK_RESPONSE_GENERATORS[detectedType](userMessage);
|
||||||
|
processingType = detectedType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track costs
|
||||||
|
const cost = calculateMockCost(response.usage);
|
||||||
|
this.totalCost += cost;
|
||||||
|
|
||||||
|
if (this.config.logRequests) {
|
||||||
|
console.log(
|
||||||
|
`[OpenAI Mock] Generated ${processingType} response. Cost: $${cost.toFixed(6)}, Total: $${this.totalCost.toFixed(6)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock batch creation endpoint
|
||||||
|
*/
|
||||||
|
async mockCreateBatch(request: {
|
||||||
|
input_file_id: string;
|
||||||
|
endpoint: string;
|
||||||
|
completion_window: string;
|
||||||
|
metadata?: Record<string, string>;
|
||||||
|
}): Promise<MockBatchResponse> {
|
||||||
|
await this.simulateDelay();
|
||||||
|
|
||||||
|
if (this.shouldSimulateError()) {
|
||||||
|
throw new Error("Mock OpenAI API error: Invalid file format");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logRequest("/v1/batches", request);
|
||||||
|
|
||||||
|
const batch = generateBatchResponse("validating");
|
||||||
|
this.activeBatches.set(batch.id, batch);
|
||||||
|
|
||||||
|
// Simulate batch processing progression
|
||||||
|
this.simulateBatchProgression(batch.id);
|
||||||
|
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock batch retrieval endpoint
|
||||||
|
*/
|
||||||
|
async mockGetBatch(batchId: string): Promise<MockBatchResponse> {
|
||||||
|
await this.simulateDelay();
|
||||||
|
|
||||||
|
const batch = this.activeBatches.get(batchId);
|
||||||
|
if (!batch) {
|
||||||
|
throw new Error(`Mock OpenAI API error: Batch ${batchId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logRequest(`/v1/batches/${batchId}`, { batchId });
|
||||||
|
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock file upload endpoint
|
||||||
|
*/
|
||||||
|
async mockUploadFile(request: {
|
||||||
|
file: string; // File content
|
||||||
|
purpose: string;
|
||||||
|
}): Promise<{
|
||||||
|
id: string;
|
||||||
|
object: string;
|
||||||
|
purpose: string;
|
||||||
|
filename: string;
|
||||||
|
}> {
|
||||||
|
await this.simulateDelay();
|
||||||
|
|
||||||
|
if (this.shouldSimulateError()) {
|
||||||
|
throw new Error("Mock OpenAI API error: File too large");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileId = `file-mock-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
this.logRequest("/v1/files", {
|
||||||
|
purpose: request.purpose,
|
||||||
|
size: request.file.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: fileId,
|
||||||
|
object: "file",
|
||||||
|
purpose: request.purpose,
|
||||||
|
filename: "batch_input.jsonl",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock file content retrieval
|
||||||
|
*/
|
||||||
|
async mockGetFileContent(fileId: string): Promise<string> {
|
||||||
|
await this.simulateDelay();
|
||||||
|
|
||||||
|
// Find the batch that owns this file
|
||||||
|
const batch = Array.from(this.activeBatches.values()).find(
|
||||||
|
(b) => b.output_file_id === fileId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!batch) {
|
||||||
|
throw new Error(`Mock OpenAI API error: File ${fileId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate mock batch results
|
||||||
|
const results: any = [];
|
||||||
|
for (let i = 0; i < batch.request_counts.total; i++) {
|
||||||
|
const response = MOCK_RESPONSE_GENERATORS.sentiment(`Sample text ${i}`);
|
||||||
|
results.push({
|
||||||
|
id: `batch-req-${i}`,
|
||||||
|
custom_id: `req-${i}`,
|
||||||
|
response: {
|
||||||
|
status_code: 200,
|
||||||
|
request_id: `req-${Date.now()}-${i}`,
|
||||||
|
body: response,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.map((r) => JSON.stringify(r)).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulate batch processing progression over time
|
||||||
|
*/
|
||||||
|
private simulateBatchProgression(batchId: string): void {
|
||||||
|
const batch = this.activeBatches.get(batchId);
|
||||||
|
if (!batch) return;
|
||||||
|
|
||||||
|
// Validating -> In Progress (after 30 seconds)
|
||||||
|
setTimeout(() => {
|
||||||
|
const currentBatch = this.activeBatches.get(batchId);
|
||||||
|
if (currentBatch && currentBatch.status === "validating") {
|
||||||
|
currentBatch.status = "in_progress";
|
||||||
|
currentBatch.in_progress_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.activeBatches.set(batchId, currentBatch);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
// In Progress -> Finalizing (after 2 minutes)
|
||||||
|
setTimeout(() => {
|
||||||
|
const currentBatch = this.activeBatches.get(batchId);
|
||||||
|
if (currentBatch && currentBatch.status === "in_progress") {
|
||||||
|
currentBatch.status = "finalizing";
|
||||||
|
currentBatch.finalizing_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.activeBatches.set(batchId, currentBatch);
|
||||||
|
}
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
|
// Finalizing -> Completed (after 3 minutes)
|
||||||
|
setTimeout(() => {
|
||||||
|
const currentBatch = this.activeBatches.get(batchId);
|
||||||
|
if (currentBatch && currentBatch.status === "finalizing") {
|
||||||
|
currentBatch.status = "completed";
|
||||||
|
currentBatch.completed_at = Math.floor(Date.now() / 1000);
|
||||||
|
currentBatch.output_file_id = `file-mock-output-${batchId}`;
|
||||||
|
currentBatch.request_counts.completed =
|
||||||
|
currentBatch.request_counts.total;
|
||||||
|
this.activeBatches.set(batchId, currentBatch);
|
||||||
|
}
|
||||||
|
}, 180000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get mock statistics
|
||||||
|
*/
|
||||||
|
getStats(): {
|
||||||
|
totalCost: number;
|
||||||
|
requestCount: number;
|
||||||
|
activeBatches: number;
|
||||||
|
isEnabled: boolean;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
totalCost: this.totalCost,
|
||||||
|
requestCount: this.requestCount,
|
||||||
|
activeBatches: this.activeBatches.size,
|
||||||
|
isEnabled: this.config.enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset statistics (useful for tests)
|
||||||
|
*/
|
||||||
|
resetStats(): void {
|
||||||
|
this.totalCost = 0;
|
||||||
|
this.requestCount = 0;
|
||||||
|
this.activeBatches.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update configuration
|
||||||
|
*/
|
||||||
|
updateConfig(newConfig: Partial<MockOpenAIConfig>): void {
|
||||||
|
this.config = { ...this.config, ...newConfig };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global instance
|
||||||
|
export const openAIMock = new OpenAIMockServer();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop-in replacement for OpenAI client that uses mocks when enabled
|
||||||
|
*/
|
||||||
|
export class MockOpenAIClient {
|
||||||
|
private realClient: any;
|
||||||
|
|
||||||
|
constructor(realClient: any) {
|
||||||
|
this.realClient = realClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
get chat() {
|
||||||
|
return {
|
||||||
|
completions: {
|
||||||
|
create: async (params: any) => {
|
||||||
|
if (openAIMock.isEnabled()) {
|
||||||
|
return openAIMock.mockChatCompletion(params);
|
||||||
|
}
|
||||||
|
return this.realClient.chat.completions.create(params);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get batches() {
|
||||||
|
return {
|
||||||
|
create: async (params: any) => {
|
||||||
|
if (openAIMock.isEnabled()) {
|
||||||
|
return openAIMock.mockCreateBatch(params);
|
||||||
|
}
|
||||||
|
return this.realClient.batches.create(params);
|
||||||
|
},
|
||||||
|
retrieve: async (batchId: string) => {
|
||||||
|
if (openAIMock.isEnabled()) {
|
||||||
|
return openAIMock.mockGetBatch(batchId);
|
||||||
|
}
|
||||||
|
return this.realClient.batches.retrieve(batchId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get files() {
|
||||||
|
return {
|
||||||
|
create: async (params: any) => {
|
||||||
|
if (openAIMock.isEnabled()) {
|
||||||
|
return openAIMock.mockUploadFile(params);
|
||||||
|
}
|
||||||
|
return this.realClient.files.create(params);
|
||||||
|
},
|
||||||
|
content: async (fileId: string) => {
|
||||||
|
if (openAIMock.isEnabled()) {
|
||||||
|
return openAIMock.mockGetFileContent(fileId);
|
||||||
|
}
|
||||||
|
return this.realClient.files.content(fileId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default openAIMock;
|
||||||
583
lib/mocks/openai-responses.ts
Normal file
583
lib/mocks/openai-responses.ts
Normal file
@ -0,0 +1,583 @@
|
|||||||
|
/**
|
||||||
|
* OpenAI API Mock Response Templates
|
||||||
|
*
|
||||||
|
* Provides realistic response templates for cost-safe testing
|
||||||
|
* and development without actual API calls.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface MockChatCompletion {
|
||||||
|
id: string;
|
||||||
|
object: "chat.completion";
|
||||||
|
created: number;
|
||||||
|
model: string;
|
||||||
|
choices: Array<{
|
||||||
|
index: number;
|
||||||
|
message: {
|
||||||
|
role: "assistant";
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
finish_reason: "stop" | "length" | "content_filter";
|
||||||
|
}>;
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockBatchResponse {
|
||||||
|
id: string;
|
||||||
|
object: "batch";
|
||||||
|
endpoint: string;
|
||||||
|
errors: {
|
||||||
|
object: "list";
|
||||||
|
data: Array<{
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
param?: string;
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
input_file_id: string;
|
||||||
|
completion_window: string;
|
||||||
|
status:
|
||||||
|
| "validating"
|
||||||
|
| "in_progress"
|
||||||
|
| "finalizing"
|
||||||
|
| "completed"
|
||||||
|
| "failed"
|
||||||
|
| "expired"
|
||||||
|
| "cancelling"
|
||||||
|
| "cancelled";
|
||||||
|
output_file_id?: string;
|
||||||
|
error_file_id?: string;
|
||||||
|
created_at: number;
|
||||||
|
in_progress_at?: number;
|
||||||
|
expires_at?: number;
|
||||||
|
finalizing_at?: number;
|
||||||
|
completed_at?: number;
|
||||||
|
failed_at?: number;
|
||||||
|
expired_at?: number;
|
||||||
|
cancelling_at?: number;
|
||||||
|
cancelled_at?: number;
|
||||||
|
request_counts: {
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
failed: number;
|
||||||
|
};
|
||||||
|
metadata?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate realistic session analysis response matching the expected JSON schema
|
||||||
|
*/
|
||||||
|
export function generateSessionAnalysisResponse(
|
||||||
|
text: string,
|
||||||
|
sessionId: string
|
||||||
|
): MockChatCompletion {
|
||||||
|
// Extract session ID from the text if provided in system prompt
|
||||||
|
const sessionIdMatch = text.match(/"session_id":\s*"([^"]+)"/);
|
||||||
|
const extractedSessionId = sessionIdMatch?.[1] || sessionId;
|
||||||
|
|
||||||
|
// Simple sentiment analysis logic
|
||||||
|
const positiveWords = [
|
||||||
|
"good",
|
||||||
|
"great",
|
||||||
|
"excellent",
|
||||||
|
"happy",
|
||||||
|
"satisfied",
|
||||||
|
"wonderful",
|
||||||
|
"amazing",
|
||||||
|
"pleased",
|
||||||
|
"thanks",
|
||||||
|
];
|
||||||
|
const negativeWords = [
|
||||||
|
"bad",
|
||||||
|
"terrible",
|
||||||
|
"awful",
|
||||||
|
"unhappy",
|
||||||
|
"disappointed",
|
||||||
|
"frustrated",
|
||||||
|
"angry",
|
||||||
|
"upset",
|
||||||
|
"problem",
|
||||||
|
];
|
||||||
|
|
||||||
|
const words = text.toLowerCase().split(/\s+/);
|
||||||
|
const positiveCount = words.filter((word) =>
|
||||||
|
positiveWords.some((pos) => word.includes(pos))
|
||||||
|
).length;
|
||||||
|
const negativeCount = words.filter((word) =>
|
||||||
|
negativeWords.some((neg) => word.includes(neg))
|
||||||
|
).length;
|
||||||
|
|
||||||
|
let sentiment: "POSITIVE" | "NEUTRAL" | "NEGATIVE";
|
||||||
|
if (positiveCount > negativeCount) {
|
||||||
|
sentiment = "POSITIVE";
|
||||||
|
} else if (negativeCount > positiveCount) {
|
||||||
|
sentiment = "NEGATIVE";
|
||||||
|
} else {
|
||||||
|
sentiment = "NEUTRAL";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple category classification
|
||||||
|
const categories: Record<string, string[]> = {
|
||||||
|
SCHEDULE_HOURS: ["schedule", "hours", "time", "shift", "working", "clock"],
|
||||||
|
LEAVE_VACATION: [
|
||||||
|
"vacation",
|
||||||
|
"leave",
|
||||||
|
"time off",
|
||||||
|
"holiday",
|
||||||
|
"pto",
|
||||||
|
"days off",
|
||||||
|
],
|
||||||
|
SICK_LEAVE_RECOVERY: [
|
||||||
|
"sick",
|
||||||
|
"ill",
|
||||||
|
"medical",
|
||||||
|
"health",
|
||||||
|
"doctor",
|
||||||
|
"recovery",
|
||||||
|
],
|
||||||
|
SALARY_COMPENSATION: [
|
||||||
|
"salary",
|
||||||
|
"pay",
|
||||||
|
"compensation",
|
||||||
|
"money",
|
||||||
|
"wage",
|
||||||
|
"payment",
|
||||||
|
],
|
||||||
|
CONTRACT_HOURS: ["contract", "agreement", "terms", "conditions"],
|
||||||
|
ONBOARDING: [
|
||||||
|
"onboard",
|
||||||
|
"new",
|
||||||
|
"start",
|
||||||
|
"first day",
|
||||||
|
"welcome",
|
||||||
|
"orientation",
|
||||||
|
],
|
||||||
|
OFFBOARDING: ["leaving", "quit", "resign", "last day", "exit", "farewell"],
|
||||||
|
WORKWEAR_STAFF_PASS: [
|
||||||
|
"uniform",
|
||||||
|
"clothing",
|
||||||
|
"badge",
|
||||||
|
"pass",
|
||||||
|
"equipment",
|
||||||
|
"workwear",
|
||||||
|
],
|
||||||
|
TEAM_CONTACTS: ["contact", "phone", "email", "reach", "team", "colleague"],
|
||||||
|
PERSONAL_QUESTIONS: ["personal", "family", "life", "private"],
|
||||||
|
ACCESS_LOGIN: [
|
||||||
|
"login",
|
||||||
|
"password",
|
||||||
|
"access",
|
||||||
|
"account",
|
||||||
|
"system",
|
||||||
|
"username",
|
||||||
|
],
|
||||||
|
SOCIAL_QUESTIONS: ["social", "chat", "friendly", "casual", "weather"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const textLower = text.toLowerCase();
|
||||||
|
let bestCategory: keyof typeof categories | "UNRECOGNIZED_OTHER" =
|
||||||
|
"UNRECOGNIZED_OTHER";
|
||||||
|
let maxMatches = 0;
|
||||||
|
|
||||||
|
for (const [category, keywords] of Object.entries(categories)) {
|
||||||
|
const matches = keywords.filter((keyword) =>
|
||||||
|
textLower.includes(keyword)
|
||||||
|
).length;
|
||||||
|
if (matches > maxMatches) {
|
||||||
|
maxMatches = matches;
|
||||||
|
bestCategory = category as keyof typeof categories;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract questions (sentences ending with ?)
|
||||||
|
const questions = text
|
||||||
|
.split(/[.!]+/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.endsWith("?"))
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
// Generate summary (first sentence or truncated text)
|
||||||
|
const sentences = text.split(/[.!?]+/).filter((s) => s.trim().length > 0);
|
||||||
|
let summary = sentences[0]?.trim() || text.substring(0, 100);
|
||||||
|
if (summary.length > 150) {
|
||||||
|
summary = summary.substring(0, 147) + "...";
|
||||||
|
}
|
||||||
|
if (summary.length < 10) {
|
||||||
|
summary = "User inquiry regarding company policies";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect language (simple heuristic)
|
||||||
|
const dutchWords = [
|
||||||
|
"het",
|
||||||
|
"de",
|
||||||
|
"een",
|
||||||
|
"en",
|
||||||
|
"van",
|
||||||
|
"is",
|
||||||
|
"dat",
|
||||||
|
"te",
|
||||||
|
"met",
|
||||||
|
"voor",
|
||||||
|
];
|
||||||
|
const germanWords = [
|
||||||
|
"der",
|
||||||
|
"die",
|
||||||
|
"das",
|
||||||
|
"und",
|
||||||
|
"ist",
|
||||||
|
"mit",
|
||||||
|
"zu",
|
||||||
|
"auf",
|
||||||
|
"für",
|
||||||
|
"von",
|
||||||
|
];
|
||||||
|
const dutchCount = dutchWords.filter((word) =>
|
||||||
|
textLower.includes(word)
|
||||||
|
).length;
|
||||||
|
const germanCount = germanWords.filter((word) =>
|
||||||
|
textLower.includes(word)
|
||||||
|
).length;
|
||||||
|
|
||||||
|
let language = "en"; // default to English
|
||||||
|
if (dutchCount > 0 && dutchCount >= germanCount) {
|
||||||
|
language = "nl";
|
||||||
|
} else if (germanCount > 0) {
|
||||||
|
language = "de";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for escalation indicators
|
||||||
|
const escalated = /escalate|supervisor|manager|boss|higher up/i.test(text);
|
||||||
|
const forwardedHr = /hr|human resources|personnel|legal/i.test(text);
|
||||||
|
|
||||||
|
const analysisResult = {
|
||||||
|
language,
|
||||||
|
sentiment,
|
||||||
|
escalated,
|
||||||
|
forwarded_hr: forwardedHr,
|
||||||
|
category: bestCategory,
|
||||||
|
questions,
|
||||||
|
summary,
|
||||||
|
session_id: extractedSessionId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonContent = JSON.stringify(analysisResult);
|
||||||
|
const promptTokens = Math.ceil(text.length / 4);
|
||||||
|
const completionTokens = Math.ceil(jsonContent.length / 4);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `chatcmpl-mock-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
object: "chat.completion",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: "gpt-4o-mini",
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: jsonContent,
|
||||||
|
},
|
||||||
|
finish_reason: "stop",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: promptTokens,
|
||||||
|
completion_tokens: completionTokens,
|
||||||
|
total_tokens: promptTokens + completionTokens,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate realistic category classification response
|
||||||
|
*/
|
||||||
|
export function generateCategoryResponse(text: string): MockChatCompletion {
|
||||||
|
// Simple category classification logic
|
||||||
|
const categories: Record<string, string[]> = {
|
||||||
|
SCHEDULE_HOURS: ["schedule", "hours", "time", "shift", "working"],
|
||||||
|
LEAVE_VACATION: ["vacation", "leave", "time off", "holiday", "pto"],
|
||||||
|
SICK_LEAVE_RECOVERY: ["sick", "ill", "medical", "health", "doctor"],
|
||||||
|
SALARY_COMPENSATION: ["salary", "pay", "compensation", "money", "wage"],
|
||||||
|
CONTRACT_HOURS: ["contract", "agreement", "terms", "conditions"],
|
||||||
|
ONBOARDING: ["onboard", "new", "start", "first day", "welcome"],
|
||||||
|
OFFBOARDING: ["leaving", "quit", "resign", "last day", "exit"],
|
||||||
|
WORKWEAR_STAFF_PASS: ["uniform", "clothing", "badge", "pass", "equipment"],
|
||||||
|
TEAM_CONTACTS: ["contact", "phone", "email", "reach", "team"],
|
||||||
|
PERSONAL_QUESTIONS: ["personal", "family", "life", "private"],
|
||||||
|
ACCESS_LOGIN: ["login", "password", "access", "account", "system"],
|
||||||
|
SOCIAL_QUESTIONS: ["social", "chat", "friendly", "casual"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const textLower = text.toLowerCase();
|
||||||
|
let bestCategory = "UNRECOGNIZED_OTHER";
|
||||||
|
let maxMatches = 0;
|
||||||
|
|
||||||
|
for (const [category, keywords] of Object.entries(categories)) {
|
||||||
|
const matches = keywords.filter((keyword) =>
|
||||||
|
textLower.includes(keyword)
|
||||||
|
).length;
|
||||||
|
if (matches > maxMatches) {
|
||||||
|
maxMatches = matches;
|
||||||
|
bestCategory = category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptTokens = Math.ceil(text.length / 4);
|
||||||
|
const completionTokens = bestCategory.length / 4;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `chatcmpl-mock-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
object: "chat.completion",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: "gpt-4o-mini",
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: bestCategory,
|
||||||
|
},
|
||||||
|
finish_reason: "stop",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: promptTokens,
|
||||||
|
completion_tokens: completionTokens,
|
||||||
|
total_tokens: promptTokens + completionTokens,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate realistic summary response
|
||||||
|
*/
|
||||||
|
export function generateSummaryResponse(text: string): MockChatCompletion {
|
||||||
|
// Simple summarization - take first sentence or truncate
|
||||||
|
const sentences = text.split(/[.!?]+/).filter((s) => s.trim().length > 0);
|
||||||
|
let summary = sentences[0]?.trim() || text.substring(0, 100);
|
||||||
|
|
||||||
|
if (summary.length > 150) {
|
||||||
|
summary = summary.substring(0, 147) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptTokens = Math.ceil(text.length / 4);
|
||||||
|
const completionTokens = Math.ceil(summary.length / 4);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `chatcmpl-mock-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
object: "chat.completion",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: "gpt-4o-mini",
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: summary,
|
||||||
|
},
|
||||||
|
finish_reason: "stop",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: promptTokens,
|
||||||
|
completion_tokens: completionTokens,
|
||||||
|
total_tokens: promptTokens + completionTokens,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate realistic sentiment analysis response
|
||||||
|
*/
|
||||||
|
export function generateSentimentResponse(text: string): MockChatCompletion {
|
||||||
|
// Simple sentiment analysis logic
|
||||||
|
const positiveWords = [
|
||||||
|
"good",
|
||||||
|
"great",
|
||||||
|
"excellent",
|
||||||
|
"happy",
|
||||||
|
"satisfied",
|
||||||
|
"wonderful",
|
||||||
|
"amazing",
|
||||||
|
"pleased",
|
||||||
|
"thanks",
|
||||||
|
];
|
||||||
|
const negativeWords = [
|
||||||
|
"bad",
|
||||||
|
"terrible",
|
||||||
|
"awful",
|
||||||
|
"unhappy",
|
||||||
|
"disappointed",
|
||||||
|
"frustrated",
|
||||||
|
"angry",
|
||||||
|
"upset",
|
||||||
|
"problem",
|
||||||
|
];
|
||||||
|
|
||||||
|
const words = text.toLowerCase().split(/\s+/);
|
||||||
|
const positiveCount = words.filter((word) =>
|
||||||
|
positiveWords.some((pos) => word.includes(pos))
|
||||||
|
).length;
|
||||||
|
const negativeCount = words.filter((word) =>
|
||||||
|
negativeWords.some((neg) => word.includes(neg))
|
||||||
|
).length;
|
||||||
|
|
||||||
|
let sentiment: "POSITIVE" | "NEUTRAL" | "NEGATIVE";
|
||||||
|
if (positiveCount > negativeCount) {
|
||||||
|
sentiment = "POSITIVE";
|
||||||
|
} else if (negativeCount > positiveCount) {
|
||||||
|
sentiment = "NEGATIVE";
|
||||||
|
} else {
|
||||||
|
sentiment = "NEUTRAL";
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptTokens = Math.ceil(text.length / 4);
|
||||||
|
const completionTokens = Math.ceil(sentiment.length / 4);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `chatcmpl-mock-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
object: "chat.completion",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: "gpt-4o-mini",
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: sentiment,
|
||||||
|
},
|
||||||
|
finish_reason: "stop",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: promptTokens,
|
||||||
|
completion_tokens: completionTokens,
|
||||||
|
total_tokens: promptTokens + completionTokens,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate realistic question extraction response
|
||||||
|
*/
|
||||||
|
export function generateQuestionExtractionResponse(
|
||||||
|
text: string
|
||||||
|
): MockChatCompletion {
|
||||||
|
// Extract sentences that end with question marks
|
||||||
|
const questions = text
|
||||||
|
.split(/[.!]+/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.endsWith("?"))
|
||||||
|
.slice(0, 5); // Limit to 5 questions
|
||||||
|
|
||||||
|
const result =
|
||||||
|
questions.length > 0 ? questions.join("\n") : "No questions found.";
|
||||||
|
|
||||||
|
const promptTokens = Math.ceil(text.length / 4);
|
||||||
|
const completionTokens = Math.ceil(result.length / 4);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `chatcmpl-mock-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
object: "chat.completion",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: "gpt-4o-mini",
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: result,
|
||||||
|
},
|
||||||
|
finish_reason: "stop",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: promptTokens,
|
||||||
|
completion_tokens: completionTokens,
|
||||||
|
total_tokens: promptTokens + completionTokens,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate mock batch job response
|
||||||
|
*/
|
||||||
|
export function generateBatchResponse(
|
||||||
|
status: MockBatchResponse["status"] = "in_progress"
|
||||||
|
): MockBatchResponse {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const batchId = `batch_mock_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
const result: MockBatchResponse = {
|
||||||
|
id: batchId,
|
||||||
|
object: "batch",
|
||||||
|
endpoint: "/v1/chat/completions",
|
||||||
|
errors: {
|
||||||
|
object: "list",
|
||||||
|
data: [],
|
||||||
|
},
|
||||||
|
input_file_id: `file-mock-input-${batchId}`,
|
||||||
|
completion_window: "24h",
|
||||||
|
status,
|
||||||
|
created_at: now - 300, // 5 minutes ago
|
||||||
|
expires_at: now + 86400, // 24 hours from now
|
||||||
|
request_counts: {
|
||||||
|
total: 100,
|
||||||
|
completed:
|
||||||
|
status === "completed" ? 100 : status === "in_progress" ? 75 : 0,
|
||||||
|
failed: status === "failed" ? 25 : 0,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
company_id: "test-company",
|
||||||
|
batch_type: "ai_processing",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set optional fields based on status
|
||||||
|
if (status === "completed") {
|
||||||
|
result.output_file_id = `file-mock-output-${batchId}`;
|
||||||
|
result.completed_at = now - 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "failed") {
|
||||||
|
result.failed_at = now - 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status !== "validating") {
|
||||||
|
result.in_progress_at = now - 240; // 4 minutes ago
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "finalizing" || status === "completed") {
|
||||||
|
result.finalizing_at = now - 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock cost calculation for testing
|
||||||
|
*/
|
||||||
|
export function calculateMockCost(usage: {
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
}): number {
|
||||||
|
// Mock pricing: $0.15 per 1K prompt tokens, $0.60 per 1K completion tokens (gpt-4o-mini rates)
|
||||||
|
const promptCost = (usage.prompt_tokens / 1000) * 0.15;
|
||||||
|
const completionCost = (usage.completion_tokens / 1000) * 0.6;
|
||||||
|
return promptCost + completionCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response templates for different AI processing types
|
||||||
|
*/
|
||||||
|
export const MOCK_RESPONSE_GENERATORS = {
|
||||||
|
sentiment: generateSentimentResponse,
|
||||||
|
category: generateCategoryResponse,
|
||||||
|
summary: generateSummaryResponse,
|
||||||
|
questions: generateQuestionExtractionResponse,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type MockResponseType = keyof typeof MOCK_RESPONSE_GENERATORS;
|
||||||
@ -1,15 +1,17 @@
|
|||||||
// Enhanced session processing scheduler with AI cost tracking and question management
|
// Enhanced session processing scheduler with AI cost tracking and question management
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
type AIProcessingRequest,
|
||||||
|
AIRequestStatus,
|
||||||
ProcessingStage,
|
ProcessingStage,
|
||||||
type SentimentCategory,
|
type SentimentCategory,
|
||||||
type SessionCategory,
|
type SessionCategory,
|
||||||
AIRequestStatus,
|
|
||||||
type AIProcessingRequest,
|
|
||||||
} from "@prisma/client";
|
} from "@prisma/client";
|
||||||
import cron from "node-cron";
|
import cron from "node-cron";
|
||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
import { withRetry } from "./database-retry";
|
import { withRetry } from "./database-retry";
|
||||||
|
import { env } from "./env";
|
||||||
|
import { openAIMock } from "./mocks/openai-mock-server";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
import {
|
import {
|
||||||
completeStage,
|
completeStage,
|
||||||
@ -330,15 +332,17 @@ async function calculateEndTime(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes a session transcript using OpenAI API
|
* Processes a session transcript using OpenAI API (real or mock)
|
||||||
*/
|
*/
|
||||||
async function processTranscriptWithOpenAI(
|
async function processTranscriptWithOpenAI(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
transcript: string,
|
transcript: string,
|
||||||
companyId: string
|
companyId: string
|
||||||
): Promise<ProcessedData> {
|
): Promise<ProcessedData> {
|
||||||
if (!OPENAI_API_KEY) {
|
if (!OPENAI_API_KEY && !env.OPENAI_MOCK_MODE) {
|
||||||
throw new Error("OPENAI_API_KEY environment variable is not set");
|
throw new Error(
|
||||||
|
"OPENAI_API_KEY environment variable is not set (or enable OPENAI_MOCK_MODE for development)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get company's AI model
|
// Get company's AI model
|
||||||
@ -373,37 +377,49 @@ async function processTranscriptWithOpenAI(
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(OPENAI_API_URL, {
|
let openaiResponse: OpenAIResponse;
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${OPENAI_API_KEY}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: aiModel, // Use company's configured AI model
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "system",
|
|
||||||
content: systemMessage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: transcript,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
temperature: 0.3, // Lower temperature for more consistent results
|
|
||||||
response_format: { type: "json_object" },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
const requestParams = {
|
||||||
const errorText = await response.text();
|
model: aiModel, // Use company's configured AI model
|
||||||
throw new Error(`OpenAI API error: ${response.status} - ${errorText}`);
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: systemMessage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: transcript,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
temperature: 0.3, // Lower temperature for more consistent results
|
||||||
|
response_format: { type: "json_object" },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (env.OPENAI_MOCK_MODE) {
|
||||||
|
// Use mock OpenAI API for cost-safe development/testing
|
||||||
|
console.log(
|
||||||
|
`[OpenAI Mock] Processing session ${sessionId} with mock API`
|
||||||
|
);
|
||||||
|
openaiResponse = await openAIMock.mockChatCompletion(requestParams);
|
||||||
|
} else {
|
||||||
|
// Use real OpenAI API
|
||||||
|
const response = await fetch(OPENAI_API_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${OPENAI_API_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestParams),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`OpenAI API error: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
openaiResponse = (await response.json()) as OpenAIResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
const openaiResponse: OpenAIResponse =
|
|
||||||
(await response.json()) as OpenAIResponse;
|
|
||||||
|
|
||||||
// Record the AI processing request for cost tracking
|
// Record the AI processing request for cost tracking
|
||||||
await recordAIProcessingRequest(
|
await recordAIProcessingRequest(
|
||||||
sessionId,
|
sessionId,
|
||||||
@ -825,7 +841,9 @@ export function startProcessingScheduler(): void {
|
|||||||
/**
|
/**
|
||||||
* Create batch requests for sessions needing AI processing
|
* Create batch requests for sessions needing AI processing
|
||||||
*/
|
*/
|
||||||
async function createBatchRequestsForSessions(batchSize: number | null = null): Promise<void> {
|
async function createBatchRequestsForSessions(
|
||||||
|
batchSize: number | null = null
|
||||||
|
): Promise<void> {
|
||||||
// Get sessions that need AI processing using the new status system
|
// Get sessions that need AI processing using the new status system
|
||||||
const sessionsNeedingAI = await getSessionsNeedingProcessing(
|
const sessionsNeedingAI = await getSessionsNeedingProcessing(
|
||||||
ProcessingStage.AI_ANALYSIS,
|
ProcessingStage.AI_ANALYSIS,
|
||||||
@ -903,7 +921,10 @@ async function createBatchRequestsForSessions(batchSize: number | null = null):
|
|||||||
|
|
||||||
batchRequests.push(processingRequest);
|
batchRequests.push(processingRequest);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to create batch request for session ${session.id}:`, error);
|
console.error(
|
||||||
|
`Failed to create batch request for session ${session.id}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
await failStage(
|
await failStage(
|
||||||
session.id,
|
session.id,
|
||||||
ProcessingStage.AI_ANALYSIS,
|
ProcessingStage.AI_ANALYSIS,
|
||||||
|
|||||||
@ -68,7 +68,9 @@ export async function sendEmail(
|
|||||||
function getEmailConfig(): EmailConfig & { isConfigured: boolean } {
|
function getEmailConfig(): EmailConfig & { isConfigured: boolean } {
|
||||||
const config = {
|
const config = {
|
||||||
smtpHost: process.env.SMTP_HOST,
|
smtpHost: process.env.SMTP_HOST,
|
||||||
smtpPort: process.env.SMTP_PORT ? parseInt(process.env.SMTP_PORT) : 587,
|
smtpPort: process.env.SMTP_PORT
|
||||||
|
? Number.parseInt(process.env.SMTP_PORT)
|
||||||
|
: 587,
|
||||||
smtpUser: process.env.SMTP_USER,
|
smtpUser: process.env.SMTP_USER,
|
||||||
smtpPassword: process.env.SMTP_PASSWORD,
|
smtpPassword: process.env.SMTP_PASSWORD,
|
||||||
fromEmail: process.env.FROM_EMAIL || "noreply@livedash.app",
|
fromEmail: process.env.FROM_EMAIL || "noreply@livedash.app",
|
||||||
|
|||||||
100
lib/trpc-client.ts
Normal file
100
lib/trpc-client.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* tRPC Client Configuration
|
||||||
|
*
|
||||||
|
* This file sets up the tRPC client for use in React components.
|
||||||
|
* Provides type-safe API calls with automatic serialization.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { httpBatchLink } from "@trpc/client";
|
||||||
|
import { createTRPCNext } from "@trpc/next";
|
||||||
|
import superjson from "superjson";
|
||||||
|
import type { AppRouter } from "@/server/routers/_app";
|
||||||
|
|
||||||
|
function getBaseUrl() {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
// browser should use relative path
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.VERCEL_URL) {
|
||||||
|
// reference for vercel.com
|
||||||
|
return `https://${process.env.VERCEL_URL}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.RENDER_INTERNAL_HOSTNAME) {
|
||||||
|
// reference for render.com
|
||||||
|
return `http://${process.env.RENDER_INTERNAL_HOSTNAME}:${process.env.PORT}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// assume localhost
|
||||||
|
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main tRPC client instance
|
||||||
|
*/
|
||||||
|
export const trpc = createTRPCNext<AppRouter>({
|
||||||
|
config() {
|
||||||
|
return {
|
||||||
|
links: [
|
||||||
|
httpBatchLink({
|
||||||
|
/**
|
||||||
|
* If you want to use SSR, you need to use the server's full URL
|
||||||
|
* @link https://trpc.io/docs/ssr
|
||||||
|
**/
|
||||||
|
url: `${getBaseUrl()}/api/trpc`,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transformer for data serialization
|
||||||
|
*/
|
||||||
|
transformer: superjson,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set custom request headers on every request from tRPC
|
||||||
|
* @link https://trpc.io/docs/v10/header
|
||||||
|
*/
|
||||||
|
headers() {
|
||||||
|
return {
|
||||||
|
// Include credentials for authentication
|
||||||
|
credentials: "include",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
/**
|
||||||
|
* Query client configuration
|
||||||
|
* @link https://trpc.io/docs/v10/react-query-integration
|
||||||
|
*/
|
||||||
|
queryClientConfig: {
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
// Stale time of 30 seconds
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
// Cache time of 5 minutes
|
||||||
|
gcTime: 5 * 60 * 1000,
|
||||||
|
// Retry failed requests up to 3 times
|
||||||
|
retry: 3,
|
||||||
|
// Retry delay that increases exponentially
|
||||||
|
retryDelay: (attemptIndex) =>
|
||||||
|
Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
// Retry mutations once on network errors
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Whether tRPC should await queries when server rendering pages
|
||||||
|
* @link https://trpc.io/docs/nextjs#ssr-boolean-default-false
|
||||||
|
*/
|
||||||
|
ssr: false,
|
||||||
|
transformer: superjson,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type helper for tRPC router
|
||||||
|
*/
|
||||||
|
export type TRPCRouter = typeof trpc;
|
||||||
163
lib/trpc.ts
Normal file
163
lib/trpc.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* tRPC Server Configuration
|
||||||
|
*
|
||||||
|
* This file sets up the core tRPC configuration including:
|
||||||
|
* - Server context creation with authentication
|
||||||
|
* - Router initialization
|
||||||
|
* - Middleware for authentication and error handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { initTRPC, TRPCError } from "@trpc/server";
|
||||||
|
import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
|
||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import superjson from "superjson";
|
||||||
|
import type { z } from "zod";
|
||||||
|
import { authOptions } from "./auth";
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
import { validateInput } from "./validation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create context for tRPC requests
|
||||||
|
* This runs on every request and provides:
|
||||||
|
* - Database access
|
||||||
|
* - User session information
|
||||||
|
* - Request/response objects
|
||||||
|
*/
|
||||||
|
export async function createTRPCContext(opts: FetchCreateContextFnOptions) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
return {
|
||||||
|
prisma,
|
||||||
|
session,
|
||||||
|
req: opts.req,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Context = Awaited<ReturnType<typeof createTRPCContext>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize tRPC with superjson for date serialization
|
||||||
|
*/
|
||||||
|
const t = initTRPC.context<Context>().create({
|
||||||
|
transformer: superjson,
|
||||||
|
errorFormatter({ shape }) {
|
||||||
|
return shape;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base router and middleware exports
|
||||||
|
*/
|
||||||
|
export const router = t.router;
|
||||||
|
export const publicProcedure = t.procedure;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication middleware
|
||||||
|
* Throws error if user is not authenticated
|
||||||
|
*/
|
||||||
|
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
|
||||||
|
if (!ctx.session?.user?.email) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
return next({
|
||||||
|
ctx: {
|
||||||
|
...ctx,
|
||||||
|
session: { ...ctx.session, user: ctx.session.user },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Company access middleware
|
||||||
|
* Ensures user has access to their company's data
|
||||||
|
*/
|
||||||
|
const enforceCompanyAccess = t.middleware(async ({ ctx, next }) => {
|
||||||
|
if (!ctx.session?.user?.email) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await ctx.prisma.user.findUnique({
|
||||||
|
where: { email: ctx.session.user.email },
|
||||||
|
include: { company: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.company) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "User does not have company access",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return next({
|
||||||
|
ctx: {
|
||||||
|
...ctx,
|
||||||
|
user,
|
||||||
|
company: user.company,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin access middleware
|
||||||
|
* Ensures user has admin role
|
||||||
|
*/
|
||||||
|
const enforceAdminAccess = t.middleware(async ({ ctx, next }) => {
|
||||||
|
if (!ctx.session?.user?.email) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await ctx.prisma.user.findUnique({
|
||||||
|
where: { email: ctx.session.user.email },
|
||||||
|
include: { company: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || user.role !== "ADMIN") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Admin access required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return next({
|
||||||
|
ctx: {
|
||||||
|
...ctx,
|
||||||
|
user,
|
||||||
|
company: user.company,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input validation middleware
|
||||||
|
* Automatically validates inputs using Zod schemas
|
||||||
|
*/
|
||||||
|
const createValidatedProcedure = <T>(schema: z.ZodSchema<T>) =>
|
||||||
|
publicProcedure.input(schema).use(({ input, next }) => {
|
||||||
|
const validation = validateInput(schema, input);
|
||||||
|
if (!validation.success) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: validation.errors.join(", "),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next({ ctx: {}, input: validation.data });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Procedure variants for different access levels
|
||||||
|
*/
|
||||||
|
export const protectedProcedure = publicProcedure.use(enforceUserIsAuthed);
|
||||||
|
export const companyProcedure = publicProcedure.use(enforceCompanyAccess);
|
||||||
|
export const adminProcedure = publicProcedure.use(enforceAdminAccess);
|
||||||
|
export const validatedProcedure = createValidatedProcedure;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limiting middleware for sensitive operations
|
||||||
|
*/
|
||||||
|
export const rateLimitedProcedure = publicProcedure.use(
|
||||||
|
async ({ ctx, next }) => {
|
||||||
|
// Rate limiting logic would go here
|
||||||
|
// For now, just pass through
|
||||||
|
return next({ ctx });
|
||||||
|
}
|
||||||
|
);
|
||||||
@ -17,7 +17,10 @@ export function authRateLimitMiddleware(request: NextRequest) {
|
|||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
// Only apply to NextAuth signin endpoint
|
// Only apply to NextAuth signin endpoint
|
||||||
if (pathname.startsWith("/api/auth/signin") || pathname.startsWith("/api/auth/callback/credentials")) {
|
if (
|
||||||
|
pathname.startsWith("/api/auth/signin") ||
|
||||||
|
pathname.startsWith("/api/auth/callback/credentials")
|
||||||
|
) {
|
||||||
const ip = extractClientIP(request);
|
const ip = extractClientIP(request);
|
||||||
const rateLimitResult = loginRateLimiter.checkRateLimit(ip);
|
const rateLimitResult = loginRateLimiter.checkRateLimit(ip);
|
||||||
|
|
||||||
@ -30,7 +33,9 @@ export function authRateLimitMiddleware(request: NextRequest) {
|
|||||||
{
|
{
|
||||||
status: 429,
|
status: 429,
|
||||||
headers: {
|
headers: {
|
||||||
"Retry-After": String(Math.ceil((rateLimitResult.resetTime! - Date.now()) / 1000)),
|
"Retry-After": String(
|
||||||
|
Math.ceil((rateLimitResult.resetTime! - Date.now()) / 1000)
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@ -52,7 +52,12 @@
|
|||||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"@rapideditor/country-coder": "^5.4.0",
|
"@rapideditor/country-coder": "^5.4.0",
|
||||||
|
"@tanstack/react-query": "^5.81.5",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@trpc/client": "^11.4.3",
|
||||||
|
"@trpc/next": "^11.4.3",
|
||||||
|
"@trpc/react-query": "^11.4.3",
|
||||||
|
"@trpc/server": "^11.4.3",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/d3-cloud": "^1.2.9",
|
"@types/d3-cloud": "^1.2.9",
|
||||||
@ -88,6 +93,7 @@
|
|||||||
"recharts": "^3.0.2",
|
"recharts": "^3.0.2",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"sonner": "^2.0.5",
|
"sonner": "^2.0.5",
|
||||||
|
"superjson": "^2.2.2",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^3.25.67"
|
"zod": "^3.25.67"
|
||||||
@ -98,6 +104,7 @@
|
|||||||
"@next/eslint-plugin-next": "^15.3.4",
|
"@next/eslint-plugin-next": "^15.3.4",
|
||||||
"@playwright/test": "^1.53.1",
|
"@playwright/test": "^1.53.1",
|
||||||
"@tailwindcss/postcss": "^4.1.11",
|
"@tailwindcss/postcss": "^4.1.11",
|
||||||
|
"@tanstack/react-query-devtools": "^5.81.5",
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
|
|||||||
176
pnpm-lock.yaml
generated
176
pnpm-lock.yaml
generated
@ -64,9 +64,24 @@ importers:
|
|||||||
"@rapideditor/country-coder":
|
"@rapideditor/country-coder":
|
||||||
specifier: ^5.4.0
|
specifier: ^5.4.0
|
||||||
version: 5.4.0
|
version: 5.4.0
|
||||||
|
"@tanstack/react-query":
|
||||||
|
specifier: ^5.81.5
|
||||||
|
version: 5.81.5(react@19.1.0)
|
||||||
"@tanstack/react-table":
|
"@tanstack/react-table":
|
||||||
specifier: ^8.21.3
|
specifier: ^8.21.3
|
||||||
version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
"@trpc/client":
|
||||||
|
specifier: ^11.4.3
|
||||||
|
version: 11.4.3(@trpc/server@11.4.3(typescript@5.8.3))(typescript@5.8.3)
|
||||||
|
"@trpc/next":
|
||||||
|
specifier: ^11.4.3
|
||||||
|
version: 11.4.3(@tanstack/react-query@5.81.5(react@19.1.0))(@trpc/client@11.4.3(@trpc/server@11.4.3(typescript@5.8.3))(typescript@5.8.3))(@trpc/react-query@11.4.3(@tanstack/react-query@5.81.5(react@19.1.0))(@trpc/client@11.4.3(@trpc/server@11.4.3(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.4.3(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3))(@trpc/server@11.4.3(typescript@5.8.3))(next@15.3.4(@babel/core@7.27.7)(@playwright/test@1.53.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
|
||||||
|
"@trpc/react-query":
|
||||||
|
specifier: ^11.4.3
|
||||||
|
version: 11.4.3(@tanstack/react-query@5.81.5(react@19.1.0))(@trpc/client@11.4.3(@trpc/server@11.4.3(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.4.3(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
|
||||||
|
"@trpc/server":
|
||||||
|
specifier: ^11.4.3
|
||||||
|
version: 11.4.3(typescript@5.8.3)
|
||||||
"@types/canvas-confetti":
|
"@types/canvas-confetti":
|
||||||
specifier: ^1.9.0
|
specifier: ^1.9.0
|
||||||
version: 1.9.0
|
version: 1.9.0
|
||||||
@ -172,6 +187,9 @@ importers:
|
|||||||
sonner:
|
sonner:
|
||||||
specifier: ^2.0.5
|
specifier: ^2.0.5
|
||||||
version: 2.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 2.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
superjson:
|
||||||
|
specifier: ^2.2.2
|
||||||
|
version: 2.2.2
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.3.1
|
specifier: ^3.3.1
|
||||||
version: 3.3.1
|
version: 3.3.1
|
||||||
@ -197,6 +215,9 @@ importers:
|
|||||||
"@tailwindcss/postcss":
|
"@tailwindcss/postcss":
|
||||||
specifier: ^4.1.11
|
specifier: ^4.1.11
|
||||||
version: 4.1.11
|
version: 4.1.11
|
||||||
|
"@tanstack/react-query-devtools":
|
||||||
|
specifier: ^5.81.5
|
||||||
|
version: 5.81.5(@tanstack/react-query@5.81.5(react@19.1.0))(react@19.1.0)
|
||||||
"@testing-library/dom":
|
"@testing-library/dom":
|
||||||
specifier: ^10.4.0
|
specifier: ^10.4.0
|
||||||
version: 10.4.0
|
version: 10.4.0
|
||||||
@ -2339,6 +2360,35 @@ packages:
|
|||||||
integrity: sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA==,
|
integrity: sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA==,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"@tanstack/query-core@5.81.5":
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-ZJOgCy/z2qpZXWaj/oxvodDx07XcQa9BF92c0oINjHkoqUPsmm3uG08HpTaviviZ/N9eP1f9CM7mKSEkIo7O1Q==,
|
||||||
|
}
|
||||||
|
|
||||||
|
"@tanstack/query-devtools@5.81.2":
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-jCeJcDCwKfoyyBXjXe9+Lo8aTkavygHHsUHAlxQKKaDeyT0qyQNLKl7+UyqYH2dDF6UN/14873IPBHchcsU+Zg==,
|
||||||
|
}
|
||||||
|
|
||||||
|
"@tanstack/react-query-devtools@5.81.5":
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-lCGMu4RX0uGnlrlLeSckBfnW/UV+KMlTBVqa97cwK7Z2ED5JKnZRSjNXwoma6sQBTJrcULvzgx2K6jEPvNUpDw==,
|
||||||
|
}
|
||||||
|
peerDependencies:
|
||||||
|
"@tanstack/react-query": ^5.81.5
|
||||||
|
react: ^18 || ^19
|
||||||
|
|
||||||
|
"@tanstack/react-query@5.81.5":
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-lOf2KqRRiYWpQT86eeeftAGnjuTR35myTP8MXyvHa81VlomoAWNEd8x5vkcAfQefu0qtYCvyqLropFZqgI2EQw==,
|
||||||
|
}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18 || ^19
|
||||||
|
|
||||||
"@tanstack/react-table@8.21.3":
|
"@tanstack/react-table@8.21.3":
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -2388,6 +2438,56 @@ packages:
|
|||||||
"@types/react-dom":
|
"@types/react-dom":
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
"@trpc/client@11.4.3":
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-i2suttUCfColktXT8bqex5kHW5jpT15nwUh0hGSDiW1keN621kSUQKcLJ095blqQAUgB+lsmgSqSMmB4L9shQQ==,
|
||||||
|
}
|
||||||
|
peerDependencies:
|
||||||
|
"@trpc/server": 11.4.3
|
||||||
|
typescript: ">=5.7.2"
|
||||||
|
|
||||||
|
"@trpc/next@11.4.3":
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-/AqPpzlrQy8ylLEdBAemRU1xmdqJVaXrXI/ZUYl3Oz1Id36gvGMdn5uxm0wgKPpZneM2EICvYcrsLSsdtddW4w==,
|
||||||
|
}
|
||||||
|
peerDependencies:
|
||||||
|
"@tanstack/react-query": ^5.59.15
|
||||||
|
"@trpc/client": 11.4.3
|
||||||
|
"@trpc/react-query": 11.4.3
|
||||||
|
"@trpc/server": 11.4.3
|
||||||
|
next: "*"
|
||||||
|
react: ">=16.8.0"
|
||||||
|
react-dom: ">=16.8.0"
|
||||||
|
typescript: ">=5.7.2"
|
||||||
|
peerDependenciesMeta:
|
||||||
|
"@tanstack/react-query":
|
||||||
|
optional: true
|
||||||
|
"@trpc/react-query":
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
"@trpc/react-query@11.4.3":
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-z+jhAiOBD22NNhHtvF0iFp9hO36YFA7M8AiUu/XtNmMxyLd3Y9/d1SMjMwlTdnGqxEGPo41VEWBrdhDUGtUuHg==,
|
||||||
|
}
|
||||||
|
peerDependencies:
|
||||||
|
"@tanstack/react-query": ^5.80.3
|
||||||
|
"@trpc/client": 11.4.3
|
||||||
|
"@trpc/server": 11.4.3
|
||||||
|
react: ">=18.2.0"
|
||||||
|
react-dom: ">=18.2.0"
|
||||||
|
typescript: ">=5.7.2"
|
||||||
|
|
||||||
|
"@trpc/server@11.4.3":
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-wnWq3wiLlMOlYkaIZz+qbuYA5udPTLS4GVVRyFkr6aT83xpdCHyVtURT+u4hSoIrOXQM9OPCNXSXsAujWZDdaw==,
|
||||||
|
}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: ">=5.7.2"
|
||||||
|
|
||||||
"@tsconfig/node10@1.0.11":
|
"@tsconfig/node10@1.0.11":
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -3608,6 +3708,13 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">= 0.6" }
|
engines: { node: ">= 0.6" }
|
||||||
|
|
||||||
|
copy-anything@3.0.5:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==,
|
||||||
|
}
|
||||||
|
engines: { node: ">=12.13" }
|
||||||
|
|
||||||
create-require@1.1.1:
|
create-require@1.1.1:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -5181,6 +5288,13 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">= 0.4" }
|
engines: { node: ">= 0.4" }
|
||||||
|
|
||||||
|
is-what@4.1.16:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==,
|
||||||
|
}
|
||||||
|
engines: { node: ">=12.13" }
|
||||||
|
|
||||||
isarray@2.0.5:
|
isarray@2.0.5:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -7226,6 +7340,13 @@ packages:
|
|||||||
babel-plugin-macros:
|
babel-plugin-macros:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
superjson@2.2.2:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==,
|
||||||
|
}
|
||||||
|
engines: { node: ">=16" }
|
||||||
|
|
||||||
supports-color@7.2.0:
|
supports-color@7.2.0:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -9198,6 +9319,21 @@ snapshots:
|
|||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
tailwindcss: 4.1.11
|
tailwindcss: 4.1.11
|
||||||
|
|
||||||
|
"@tanstack/query-core@5.81.5": {}
|
||||||
|
|
||||||
|
"@tanstack/query-devtools@5.81.2": {}
|
||||||
|
|
||||||
|
"@tanstack/react-query-devtools@5.81.5(@tanstack/react-query@5.81.5(react@19.1.0))(react@19.1.0)":
|
||||||
|
dependencies:
|
||||||
|
"@tanstack/query-devtools": 5.81.2
|
||||||
|
"@tanstack/react-query": 5.81.5(react@19.1.0)
|
||||||
|
react: 19.1.0
|
||||||
|
|
||||||
|
"@tanstack/react-query@5.81.5(react@19.1.0)":
|
||||||
|
dependencies:
|
||||||
|
"@tanstack/query-core": 5.81.5
|
||||||
|
react: 19.1.0
|
||||||
|
|
||||||
"@tanstack/react-table@8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)":
|
"@tanstack/react-table@8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)":
|
||||||
dependencies:
|
dependencies:
|
||||||
"@tanstack/table-core": 8.21.3
|
"@tanstack/table-core": 8.21.3
|
||||||
@ -9237,6 +9373,36 @@ snapshots:
|
|||||||
"@types/react": 19.1.8
|
"@types/react": 19.1.8
|
||||||
"@types/react-dom": 19.1.6(@types/react@19.1.8)
|
"@types/react-dom": 19.1.6(@types/react@19.1.8)
|
||||||
|
|
||||||
|
"@trpc/client@11.4.3(@trpc/server@11.4.3(typescript@5.8.3))(typescript@5.8.3)":
|
||||||
|
dependencies:
|
||||||
|
"@trpc/server": 11.4.3(typescript@5.8.3)
|
||||||
|
typescript: 5.8.3
|
||||||
|
|
||||||
|
"@trpc/next@11.4.3(@tanstack/react-query@5.81.5(react@19.1.0))(@trpc/client@11.4.3(@trpc/server@11.4.3(typescript@5.8.3))(typescript@5.8.3))(@trpc/react-query@11.4.3(@tanstack/react-query@5.81.5(react@19.1.0))(@trpc/client@11.4.3(@trpc/server@11.4.3(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.4.3(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3))(@trpc/server@11.4.3(typescript@5.8.3))(next@15.3.4(@babel/core@7.27.7)(@playwright/test@1.53.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)":
|
||||||
|
dependencies:
|
||||||
|
"@trpc/client": 11.4.3(@trpc/server@11.4.3(typescript@5.8.3))(typescript@5.8.3)
|
||||||
|
"@trpc/server": 11.4.3(typescript@5.8.3)
|
||||||
|
next: 15.3.4(@babel/core@7.27.7)(@playwright/test@1.53.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
react: 19.1.0
|
||||||
|
react-dom: 19.1.0(react@19.1.0)
|
||||||
|
typescript: 5.8.3
|
||||||
|
optionalDependencies:
|
||||||
|
"@tanstack/react-query": 5.81.5(react@19.1.0)
|
||||||
|
"@trpc/react-query": 11.4.3(@tanstack/react-query@5.81.5(react@19.1.0))(@trpc/client@11.4.3(@trpc/server@11.4.3(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.4.3(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
|
||||||
|
|
||||||
|
"@trpc/react-query@11.4.3(@tanstack/react-query@5.81.5(react@19.1.0))(@trpc/client@11.4.3(@trpc/server@11.4.3(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.4.3(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)":
|
||||||
|
dependencies:
|
||||||
|
"@tanstack/react-query": 5.81.5(react@19.1.0)
|
||||||
|
"@trpc/client": 11.4.3(@trpc/server@11.4.3(typescript@5.8.3))(typescript@5.8.3)
|
||||||
|
"@trpc/server": 11.4.3(typescript@5.8.3)
|
||||||
|
react: 19.1.0
|
||||||
|
react-dom: 19.1.0(react@19.1.0)
|
||||||
|
typescript: 5.8.3
|
||||||
|
|
||||||
|
"@trpc/server@11.4.3(typescript@5.8.3)":
|
||||||
|
dependencies:
|
||||||
|
typescript: 5.8.3
|
||||||
|
|
||||||
"@tsconfig/node10@1.0.11": {}
|
"@tsconfig/node10@1.0.11": {}
|
||||||
|
|
||||||
"@tsconfig/node12@1.0.11": {}
|
"@tsconfig/node12@1.0.11": {}
|
||||||
@ -9983,6 +10149,10 @@ snapshots:
|
|||||||
|
|
||||||
cookie@0.7.2: {}
|
cookie@0.7.2: {}
|
||||||
|
|
||||||
|
copy-anything@3.0.5:
|
||||||
|
dependencies:
|
||||||
|
is-what: 4.1.16
|
||||||
|
|
||||||
create-require@1.1.1: {}
|
create-require@1.1.1: {}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
@ -11099,6 +11269,8 @@ snapshots:
|
|||||||
call-bound: 1.0.4
|
call-bound: 1.0.4
|
||||||
get-intrinsic: 1.3.0
|
get-intrinsic: 1.3.0
|
||||||
|
|
||||||
|
is-what@4.1.16: {}
|
||||||
|
|
||||||
isarray@2.0.5: {}
|
isarray@2.0.5: {}
|
||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
@ -12528,6 +12700,10 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
"@babel/core": 7.27.7
|
"@babel/core": 7.27.7
|
||||||
|
|
||||||
|
superjson@2.2.2:
|
||||||
|
dependencies:
|
||||||
|
copy-anything: 3.0.5
|
||||||
|
|
||||||
supports-color@7.2.0:
|
supports-color@7.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
has-flag: 4.0.0
|
has-flag: 4.0.0
|
||||||
|
|||||||
@ -2,11 +2,11 @@
|
|||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { parse } from "node:url";
|
import { parse } from "node:url";
|
||||||
import next from "next";
|
import next from "next";
|
||||||
|
import { startBatchScheduler } from "./lib/batchScheduler.js";
|
||||||
import { getSchedulerConfig, logEnvConfig, validateEnv } from "./lib/env.js";
|
import { getSchedulerConfig, logEnvConfig, validateEnv } from "./lib/env.js";
|
||||||
import { startImportProcessingScheduler } from "./lib/importProcessor.js";
|
import { startImportProcessingScheduler } from "./lib/importProcessor.js";
|
||||||
import { startProcessingScheduler } from "./lib/processingScheduler.js";
|
import { startProcessingScheduler } from "./lib/processingScheduler.js";
|
||||||
import { startCsvImportScheduler } from "./lib/scheduler.js";
|
import { startCsvImportScheduler } from "./lib/scheduler.js";
|
||||||
import { startBatchScheduler } from "./lib/batchScheduler.js";
|
|
||||||
|
|
||||||
const dev = process.env.NODE_ENV !== "production";
|
const dev = process.env.NODE_ENV !== "production";
|
||||||
const hostname = "localhost";
|
const hostname = "localhost";
|
||||||
|
|||||||
23
server/routers/_app.ts
Normal file
23
server/routers/_app.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Main tRPC Application Router
|
||||||
|
*
|
||||||
|
* This file combines all individual routers into a single app router.
|
||||||
|
* All tRPC endpoints are organized and exported from here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { router } from "@/lib/trpc";
|
||||||
|
import { authRouter } from "./auth";
|
||||||
|
import { dashboardRouter } from "./dashboard";
|
||||||
|
import { adminRouter } from "./admin";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main application router that combines all feature routers
|
||||||
|
*/
|
||||||
|
export const appRouter = router({
|
||||||
|
auth: authRouter,
|
||||||
|
dashboard: dashboardRouter,
|
||||||
|
admin: adminRouter,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export type definition for use in client
|
||||||
|
export type AppRouter = typeof appRouter;
|
||||||
399
server/routers/admin.ts
Normal file
399
server/routers/admin.ts
Normal file
@ -0,0 +1,399 @@
|
|||||||
|
/**
|
||||||
|
* Admin tRPC Router
|
||||||
|
*
|
||||||
|
* Handles administrative operations:
|
||||||
|
* - User management
|
||||||
|
* - Company settings
|
||||||
|
* - System administration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { router, adminProcedure } from "@/lib/trpc";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { companySettingsSchema, userUpdateSchema } from "@/lib/validation";
|
||||||
|
import { z } from "zod";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
export const adminRouter = router({
|
||||||
|
/**
|
||||||
|
* Get all users in the company
|
||||||
|
*/
|
||||||
|
getUsers: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
page: z.number().min(1).default(1),
|
||||||
|
limit: z.number().min(1).max(100).default(20),
|
||||||
|
search: z.string().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const { page, limit, search } = input;
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
companyId: ctx.company!.id,
|
||||||
|
...(search && {
|
||||||
|
OR: [
|
||||||
|
{ email: { contains: search, mode: "insensitive" as const } },
|
||||||
|
// For role, search by exact enum match
|
||||||
|
...(search.toUpperCase() === "ADMIN"
|
||||||
|
? [{ role: "ADMIN" as const }]
|
||||||
|
: []),
|
||||||
|
...(search.toUpperCase() === "USER"
|
||||||
|
? [{ role: "USER" as const }]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [users, totalCount] = await Promise.all([
|
||||||
|
ctx.prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
createdAt: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
ctx.prisma.user.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
users,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalCount,
|
||||||
|
totalPages: Math.ceil(totalCount / limit),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new user
|
||||||
|
*/
|
||||||
|
createUser: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(12),
|
||||||
|
role: z.enum(["ADMIN", "USER", "AUDITOR"]),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const { email, password, role } = input;
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const existingUser = await ctx.prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "User with this email already exists",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
const user = await ctx.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
password: hashedPassword,
|
||||||
|
role,
|
||||||
|
companyId: ctx.company!.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "User created successfully",
|
||||||
|
user,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user details
|
||||||
|
*/
|
||||||
|
updateUser: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
userId: z.string(),
|
||||||
|
updates: userUpdateSchema,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const { userId, updates } = input;
|
||||||
|
|
||||||
|
// Verify user belongs to same company
|
||||||
|
const targetUser = await ctx.prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
companyId: ctx.company!.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "User not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: any = {};
|
||||||
|
|
||||||
|
if (updates.email) {
|
||||||
|
// Check if new email is already taken
|
||||||
|
const existingUser = await ctx.prisma.user.findUnique({
|
||||||
|
where: { email: updates.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser && existingUser.id !== userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "Email is already taken",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData.email = updates.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.password) {
|
||||||
|
updateData.password = await bcrypt.hash(updates.password, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.role) {
|
||||||
|
updateData.role = updates.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await ctx.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: updateData,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "User updated successfully",
|
||||||
|
user: updatedUser,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a user
|
||||||
|
*/
|
||||||
|
deleteUser: adminProcedure
|
||||||
|
.input(z.object({ userId: z.string() }))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const { userId } = input;
|
||||||
|
|
||||||
|
// Verify user belongs to same company
|
||||||
|
const targetUser = await ctx.prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
companyId: ctx.company!.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "User not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent deleting the last admin
|
||||||
|
if (targetUser.role === "ADMIN") {
|
||||||
|
const adminCount = await ctx.prisma.user.count({
|
||||||
|
where: {
|
||||||
|
companyId: ctx.company!.id,
|
||||||
|
role: "ADMIN",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (adminCount <= 1) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Cannot delete the last admin user",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.prisma.user.delete({
|
||||||
|
where: { id: userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "User deleted successfully",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get company settings
|
||||||
|
*/
|
||||||
|
getCompanySettings: adminProcedure.query(async ({ ctx }) => {
|
||||||
|
const company = await ctx.prisma.company.findUnique({
|
||||||
|
where: { id: ctx.company!.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!company) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Company not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: company.id,
|
||||||
|
name: company.name,
|
||||||
|
csvUrl: company.csvUrl,
|
||||||
|
csvUsername: company.csvUsername,
|
||||||
|
dashboardOpts: company.dashboardOpts,
|
||||||
|
status: company.status,
|
||||||
|
maxUsers: company.maxUsers,
|
||||||
|
createdAt: company.createdAt,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update company settings
|
||||||
|
*/
|
||||||
|
updateCompanySettings: adminProcedure
|
||||||
|
.input(companySettingsSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const updateData: any = {
|
||||||
|
name: input.name,
|
||||||
|
csvUrl: input.csvUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (input.csvUsername !== undefined) {
|
||||||
|
updateData.csvUsername = input.csvUsername;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.csvPassword !== undefined) {
|
||||||
|
updateData.csvPassword = input.csvPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.sentimentAlert !== undefined) {
|
||||||
|
updateData.sentimentAlert = input.sentimentAlert;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.dashboardOpts !== undefined) {
|
||||||
|
updateData.dashboardOpts = input.dashboardOpts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedCompany = await ctx.prisma.company.update({
|
||||||
|
where: { id: ctx.company!.id },
|
||||||
|
data: updateData,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
csvUrl: true,
|
||||||
|
csvUsername: true,
|
||||||
|
dashboardOpts: true,
|
||||||
|
status: true,
|
||||||
|
maxUsers: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "Company settings updated successfully",
|
||||||
|
company: updatedCompany,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get system statistics
|
||||||
|
*/
|
||||||
|
getSystemStats: adminProcedure.query(async ({ ctx }) => {
|
||||||
|
const companyId = ctx.company!.id;
|
||||||
|
|
||||||
|
const [
|
||||||
|
totalSessions,
|
||||||
|
totalMessages,
|
||||||
|
totalAIRequests,
|
||||||
|
totalCost,
|
||||||
|
userCount,
|
||||||
|
] = await Promise.all([
|
||||||
|
ctx.prisma.session.count({
|
||||||
|
where: { companyId },
|
||||||
|
}),
|
||||||
|
ctx.prisma.message.count({
|
||||||
|
where: { session: { companyId } },
|
||||||
|
}),
|
||||||
|
ctx.prisma.aIProcessingRequest.count({
|
||||||
|
where: { session: { companyId } },
|
||||||
|
}),
|
||||||
|
ctx.prisma.aIProcessingRequest.aggregate({
|
||||||
|
where: { session: { companyId } },
|
||||||
|
_sum: { totalCostEur: true },
|
||||||
|
}),
|
||||||
|
ctx.prisma.user.count({
|
||||||
|
where: { companyId },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSessions,
|
||||||
|
totalMessages,
|
||||||
|
totalAIRequests,
|
||||||
|
totalCostEur: totalCost._sum.totalCostEur || 0,
|
||||||
|
userCount,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger session refresh/reprocessing
|
||||||
|
*/
|
||||||
|
refreshSessions: adminProcedure.mutation(async ({ ctx }) => {
|
||||||
|
// Mark all sessions for reprocessing by clearing AI analysis results
|
||||||
|
const updatedCount = await ctx.prisma.session.updateMany({
|
||||||
|
where: {
|
||||||
|
companyId: ctx.company!.id,
|
||||||
|
sentiment: { not: null },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
sentiment: null,
|
||||||
|
category: null,
|
||||||
|
summary: null,
|
||||||
|
language: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear related AI processing requests
|
||||||
|
await ctx.prisma.aIProcessingRequest.deleteMany({
|
||||||
|
where: {
|
||||||
|
session: {
|
||||||
|
companyId: ctx.company!.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear session questions
|
||||||
|
await ctx.prisma.sessionQuestion.deleteMany({
|
||||||
|
where: {
|
||||||
|
session: {
|
||||||
|
companyId: ctx.company!.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: `Marked ${updatedCount.count} sessions for reprocessing`,
|
||||||
|
sessionsMarked: updatedCount.count,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
328
server/routers/auth.ts
Normal file
328
server/routers/auth.ts
Normal file
@ -0,0 +1,328 @@
|
|||||||
|
/**
|
||||||
|
* Authentication tRPC Router
|
||||||
|
*
|
||||||
|
* Handles user authentication operations:
|
||||||
|
* - User registration
|
||||||
|
* - Login validation
|
||||||
|
* - Password reset requests
|
||||||
|
* - User profile management
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
router,
|
||||||
|
publicProcedure,
|
||||||
|
protectedProcedure,
|
||||||
|
rateLimitedProcedure,
|
||||||
|
} from "@/lib/trpc";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import {
|
||||||
|
registerSchema,
|
||||||
|
loginSchema,
|
||||||
|
forgotPasswordSchema,
|
||||||
|
userUpdateSchema,
|
||||||
|
} from "@/lib/validation";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const authRouter = router({
|
||||||
|
/**
|
||||||
|
* Register a new user
|
||||||
|
*/
|
||||||
|
register: rateLimitedProcedure
|
||||||
|
.input(registerSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const { email, password, company: companyName } = input;
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const existingUser = await ctx.prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "User with this email already exists",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
// Create or find company
|
||||||
|
let company = await ctx.prisma.company.findFirst({
|
||||||
|
where: {
|
||||||
|
name: {
|
||||||
|
equals: companyName,
|
||||||
|
mode: "insensitive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!company) {
|
||||||
|
company = await ctx.prisma.company.create({
|
||||||
|
data: {
|
||||||
|
name: companyName,
|
||||||
|
status: "ACTIVE",
|
||||||
|
csvUrl: `https://placeholder-${companyName.toLowerCase().replace(/\s+/g, "-")}.example.com/api/sessions.csv`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
const user = await ctx.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
password: hashedPassword,
|
||||||
|
companyId: company.id,
|
||||||
|
role: "ADMIN", // First user is admin
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
company: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "User registered successfully",
|
||||||
|
user,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate login credentials
|
||||||
|
*/
|
||||||
|
validateLogin: publicProcedure
|
||||||
|
.input(loginSchema)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const { email, password } = input;
|
||||||
|
|
||||||
|
const user = await ctx.prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
include: {
|
||||||
|
company: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !(await bcrypt.compare(password, user.password))) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Invalid email or password",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.company?.status !== "ACTIVE") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Company account is not active",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
company: user.company,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request password reset
|
||||||
|
*/
|
||||||
|
forgotPassword: rateLimitedProcedure
|
||||||
|
.input(forgotPasswordSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const { email } = input;
|
||||||
|
|
||||||
|
const user = await ctx.prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Don't reveal if email exists or not
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
"If an account with that email exists, you will receive a password reset link.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate reset token (in real implementation, this would be a secure token)
|
||||||
|
const resetToken = Math.random().toString(36).substring(2, 15);
|
||||||
|
const resetTokenExpiry = new Date(Date.now() + 3600000); // 1 hour
|
||||||
|
|
||||||
|
await ctx.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
resetToken,
|
||||||
|
resetTokenExpiry,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Send email with reset link
|
||||||
|
// For now, just log the token (remove in production)
|
||||||
|
console.log(`Password reset token for ${email}: ${resetToken}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
"If an account with that email exists, you will receive a password reset link.",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user profile
|
||||||
|
*/
|
||||||
|
getProfile: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const user = await ctx.prisma.user.findUnique({
|
||||||
|
where: { email: ctx.session.user.email! },
|
||||||
|
include: {
|
||||||
|
company: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "User not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
company: user.company,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user profile
|
||||||
|
*/
|
||||||
|
updateProfile: protectedProcedure
|
||||||
|
.input(userUpdateSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const updateData: any = {};
|
||||||
|
|
||||||
|
if (input.email) {
|
||||||
|
// Check if new email is already taken
|
||||||
|
const existingUser = await ctx.prisma.user.findUnique({
|
||||||
|
where: { email: input.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser && existingUser.email !== ctx.session.user.email) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "Email is already taken",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData.email = input.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.password) {
|
||||||
|
updateData.password = await bcrypt.hash(input.password, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.role) {
|
||||||
|
// Only admins can change roles
|
||||||
|
const currentUser = await ctx.prisma.user.findUnique({
|
||||||
|
where: { email: ctx.session.user.email! },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentUser?.role !== "ADMIN") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Only admins can change user roles",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData.role = input.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await ctx.prisma.user.update({
|
||||||
|
where: { email: ctx.session.user.email! },
|
||||||
|
data: updateData,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
company: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "Profile updated successfully",
|
||||||
|
user: updatedUser,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset password with token
|
||||||
|
*/
|
||||||
|
resetPassword: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
token: z.string().min(1, "Reset token is required"),
|
||||||
|
password: registerSchema.shape.password,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const { token, password } = input;
|
||||||
|
|
||||||
|
const user = await ctx.prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
resetToken: token,
|
||||||
|
resetTokenExpiry: {
|
||||||
|
gt: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Invalid or expired reset token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
await ctx.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
password: hashedPassword,
|
||||||
|
resetToken: null,
|
||||||
|
resetTokenExpiry: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "Password reset successfully",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
411
server/routers/dashboard.ts
Normal file
411
server/routers/dashboard.ts
Normal file
@ -0,0 +1,411 @@
|
|||||||
|
/**
|
||||||
|
* Dashboard tRPC Router
|
||||||
|
*
|
||||||
|
* Handles dashboard data operations:
|
||||||
|
* - Session management and filtering
|
||||||
|
* - Analytics and metrics
|
||||||
|
* - Overview statistics
|
||||||
|
* - Question management
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { router, companyProcedure } from "@/lib/trpc";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { sessionFilterSchema, metricsQuerySchema } from "@/lib/validation";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
export const dashboardRouter = router({
|
||||||
|
/**
|
||||||
|
* Get paginated sessions with filtering
|
||||||
|
*/
|
||||||
|
getSessions: companyProcedure
|
||||||
|
.input(sessionFilterSchema)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const { search, sentiment, category, startDate, endDate, page, limit } =
|
||||||
|
input;
|
||||||
|
|
||||||
|
// Build where clause
|
||||||
|
const where: Prisma.SessionWhereInput = {
|
||||||
|
companyId: ctx.company.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ summary: { contains: search, mode: "insensitive" } },
|
||||||
|
{ id: { contains: search, mode: "insensitive" } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sentiment) {
|
||||||
|
where.sentiment = sentiment;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
where.category = category;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate || endDate) {
|
||||||
|
where.startTime = {};
|
||||||
|
if (startDate) {
|
||||||
|
where.startTime.gte = new Date(startDate);
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
where.startTime.lte = new Date(endDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const totalCount = await ctx.prisma.session.count({ where });
|
||||||
|
|
||||||
|
// Get paginated sessions
|
||||||
|
const sessions = await ctx.prisma.session.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
messages: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
role: true,
|
||||||
|
content: true,
|
||||||
|
order: true,
|
||||||
|
},
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
},
|
||||||
|
sessionQuestions: {
|
||||||
|
include: {
|
||||||
|
question: {
|
||||||
|
select: {
|
||||||
|
content: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { startTime: "desc" },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions: sessions.map((session) => ({
|
||||||
|
...session,
|
||||||
|
questions: session.sessionQuestions.map((sq) => sq.question.content),
|
||||||
|
})),
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalCount,
|
||||||
|
totalPages: Math.ceil(totalCount / limit),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session by ID
|
||||||
|
*/
|
||||||
|
getSessionById: companyProcedure
|
||||||
|
.input(z.object({ sessionId: z.string() }))
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const session = await ctx.prisma.session.findFirst({
|
||||||
|
where: {
|
||||||
|
id: input.sessionId,
|
||||||
|
companyId: ctx.company.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
messages: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
},
|
||||||
|
sessionQuestions: {
|
||||||
|
include: {
|
||||||
|
question: {
|
||||||
|
select: {
|
||||||
|
content: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Session not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
questions: session.sessionQuestions.map((sq) => sq.question.content),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get dashboard overview statistics
|
||||||
|
*/
|
||||||
|
getOverview: companyProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
startDate: z.string().datetime().optional(),
|
||||||
|
endDate: z.string().datetime().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const { startDate, endDate } = input;
|
||||||
|
|
||||||
|
const dateFilter: Prisma.SessionWhereInput = {
|
||||||
|
companyId: ctx.company.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startDate || endDate) {
|
||||||
|
dateFilter.startTime = {};
|
||||||
|
if (startDate) {
|
||||||
|
dateFilter.startTime.gte = new Date(startDate);
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
dateFilter.startTime.lte = new Date(endDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get basic counts
|
||||||
|
const [
|
||||||
|
totalSessions,
|
||||||
|
avgMessagesSent,
|
||||||
|
sentimentDistribution,
|
||||||
|
categoryDistribution,
|
||||||
|
] = await Promise.all([
|
||||||
|
// Total sessions
|
||||||
|
ctx.prisma.session.count({ where: dateFilter }),
|
||||||
|
|
||||||
|
// Average messages sent
|
||||||
|
ctx.prisma.session.aggregate({
|
||||||
|
where: dateFilter,
|
||||||
|
_avg: { messagesSent: true },
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Sentiment distribution
|
||||||
|
ctx.prisma.session.groupBy({
|
||||||
|
by: ["sentiment"],
|
||||||
|
where: dateFilter,
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Category distribution
|
||||||
|
ctx.prisma.session.groupBy({
|
||||||
|
by: ["category"],
|
||||||
|
where: dateFilter,
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSessions,
|
||||||
|
avgMessagesSent: avgMessagesSent._avg.messagesSent || 0,
|
||||||
|
sentimentDistribution: sentimentDistribution.map((item) => ({
|
||||||
|
sentiment: item.sentiment,
|
||||||
|
count: item._count,
|
||||||
|
})),
|
||||||
|
categoryDistribution: categoryDistribution.map((item) => ({
|
||||||
|
category: item.category,
|
||||||
|
count: item._count,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get top questions
|
||||||
|
*/
|
||||||
|
getTopQuestions: companyProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.number().min(1).max(20).default(10),
|
||||||
|
startDate: z.string().datetime().optional(),
|
||||||
|
endDate: z.string().datetime().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const { limit, startDate, endDate } = input;
|
||||||
|
|
||||||
|
const dateFilter: Prisma.SessionWhereInput = {
|
||||||
|
companyId: ctx.company.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startDate || endDate) {
|
||||||
|
dateFilter.startTime = {};
|
||||||
|
if (startDate) {
|
||||||
|
dateFilter.startTime.gte = new Date(startDate);
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
dateFilter.startTime.lte = new Date(endDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const topQuestions = await ctx.prisma.question.findMany({
|
||||||
|
select: {
|
||||||
|
content: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
sessionQuestions: {
|
||||||
|
where: {
|
||||||
|
session: dateFilter,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
sessionQuestions: {
|
||||||
|
_count: "desc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return topQuestions.map((question) => ({
|
||||||
|
question: question.content,
|
||||||
|
count: question._count.sessionQuestions,
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get geographic distribution of sessions
|
||||||
|
*/
|
||||||
|
getGeographicDistribution: companyProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
startDate: z.string().datetime().optional(),
|
||||||
|
endDate: z.string().datetime().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const { startDate, endDate } = input;
|
||||||
|
|
||||||
|
const dateFilter: Prisma.SessionWhereInput = {
|
||||||
|
companyId: ctx.company.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startDate || endDate) {
|
||||||
|
dateFilter.startTime = {};
|
||||||
|
if (startDate) {
|
||||||
|
dateFilter.startTime.gte = new Date(startDate);
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
dateFilter.startTime.lte = new Date(endDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const geoDistribution = await ctx.prisma.session.groupBy({
|
||||||
|
by: ["language"],
|
||||||
|
where: dateFilter,
|
||||||
|
_count: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map language codes to country data (simplified mapping)
|
||||||
|
const languageToCountry: Record<
|
||||||
|
string,
|
||||||
|
{ name: string; lat: number; lng: number }
|
||||||
|
> = {
|
||||||
|
en: { name: "United Kingdom", lat: 55.3781, lng: -3.436 },
|
||||||
|
de: { name: "Germany", lat: 51.1657, lng: 10.4515 },
|
||||||
|
fr: { name: "France", lat: 46.2276, lng: 2.2137 },
|
||||||
|
es: { name: "Spain", lat: 40.4637, lng: -3.7492 },
|
||||||
|
nl: { name: "Netherlands", lat: 52.1326, lng: 5.2913 },
|
||||||
|
it: { name: "Italy", lat: 41.8719, lng: 12.5674 },
|
||||||
|
};
|
||||||
|
|
||||||
|
return geoDistribution.map((item) => ({
|
||||||
|
language: item.language,
|
||||||
|
count: item._count,
|
||||||
|
country: (item.language ? languageToCountry[item.language] : null) || {
|
||||||
|
name: "Unknown",
|
||||||
|
lat: 0,
|
||||||
|
lng: 0,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get AI processing metrics
|
||||||
|
*/
|
||||||
|
getAIMetrics: companyProcedure
|
||||||
|
.input(metricsQuerySchema)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const { startDate, endDate } = input;
|
||||||
|
|
||||||
|
const dateFilter: Prisma.AIProcessingRequestWhereInput = {
|
||||||
|
session: {
|
||||||
|
companyId: ctx.company.id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startDate || endDate) {
|
||||||
|
dateFilter.requestedAt = {};
|
||||||
|
if (startDate) {
|
||||||
|
dateFilter.requestedAt.gte = new Date(startDate);
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
dateFilter.requestedAt.lte = new Date(endDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [totalCosts, requestStats] = await Promise.all([
|
||||||
|
// Total AI costs
|
||||||
|
ctx.prisma.aIProcessingRequest.aggregate({
|
||||||
|
where: dateFilter,
|
||||||
|
_sum: {
|
||||||
|
totalCostEur: true,
|
||||||
|
promptTokens: true,
|
||||||
|
completionTokens: true,
|
||||||
|
},
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Success/failure stats
|
||||||
|
ctx.prisma.aIProcessingRequest.groupBy({
|
||||||
|
by: ["success"],
|
||||||
|
where: dateFilter,
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalCostEur: totalCosts._sum.totalCostEur || 0,
|
||||||
|
totalRequests: totalCosts._count,
|
||||||
|
totalTokens:
|
||||||
|
(totalCosts._sum.promptTokens || 0) +
|
||||||
|
(totalCosts._sum.completionTokens || 0),
|
||||||
|
successRate: requestStats.reduce(
|
||||||
|
(acc, stat) => {
|
||||||
|
if (stat.success) {
|
||||||
|
acc.successful = stat._count;
|
||||||
|
} else {
|
||||||
|
acc.failed = stat._count;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ successful: 0, failed: 0 }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh sessions (trigger reprocessing)
|
||||||
|
*/
|
||||||
|
refreshSessions: companyProcedure.mutation(async ({ ctx }) => {
|
||||||
|
// This would trigger the processing pipeline
|
||||||
|
// For now, just return a success message
|
||||||
|
|
||||||
|
const pendingSessions = await ctx.prisma.session.count({
|
||||||
|
where: {
|
||||||
|
companyId: ctx.company.id,
|
||||||
|
sentiment: null, // Sessions that haven't been processed
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: `Found ${pendingSessions} sessions that need processing`,
|
||||||
|
pendingSessions,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
@ -262,11 +262,14 @@ describe("Authentication API Routes", () => {
|
|||||||
resetTime: Date.now() + 60000,
|
resetTime: Date.now() + 60000,
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mocked(InMemoryRateLimiter).mockImplementation(() => ({
|
vi.mocked(InMemoryRateLimiter).mockImplementation(
|
||||||
checkRateLimit: mockCheckRateLimit,
|
() =>
|
||||||
cleanup: vi.fn(),
|
({
|
||||||
destroy: vi.fn(),
|
checkRateLimit: mockCheckRateLimit,
|
||||||
} as any));
|
cleanup: vi.fn(),
|
||||||
|
destroy: vi.fn(),
|
||||||
|
}) as any
|
||||||
|
);
|
||||||
|
|
||||||
const request = new NextRequest("http://localhost:3000/api/register", {
|
const request = new NextRequest("http://localhost:3000/api/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import { processUnprocessedSessions, getAIProcessingCosts } from "../../lib/processingScheduler";
|
import {
|
||||||
|
processUnprocessedSessions,
|
||||||
|
getAIProcessingCosts,
|
||||||
|
} from "../../lib/processingScheduler";
|
||||||
|
|
||||||
vi.mock("../../lib/prisma", () => ({
|
vi.mock("../../lib/prisma", () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
@ -85,7 +88,9 @@ describe("Processing Scheduler", () => {
|
|||||||
|
|
||||||
it("should handle errors gracefully", async () => {
|
it("should handle errors gracefully", async () => {
|
||||||
const { prisma } = await import("../../lib/prisma");
|
const { prisma } = await import("../../lib/prisma");
|
||||||
vi.mocked(prisma.session.findMany).mockRejectedValue(new Error("Database error"));
|
vi.mocked(prisma.session.findMany).mockRejectedValue(
|
||||||
|
new Error("Database error")
|
||||||
|
);
|
||||||
|
|
||||||
await expect(processUnprocessedSessions(1)).resolves.not.toThrow();
|
await expect(processUnprocessedSessions(1)).resolves.not.toThrow();
|
||||||
});
|
});
|
||||||
@ -95,7 +100,7 @@ describe("Processing Scheduler", () => {
|
|||||||
it("should calculate processing costs correctly", async () => {
|
it("should calculate processing costs correctly", async () => {
|
||||||
const mockAggregation = {
|
const mockAggregation = {
|
||||||
_sum: {
|
_sum: {
|
||||||
totalCostEur: 10.50,
|
totalCostEur: 10.5,
|
||||||
promptTokens: 1000,
|
promptTokens: 1000,
|
||||||
completionTokens: 500,
|
completionTokens: 500,
|
||||||
totalTokens: 1500,
|
totalTokens: 1500,
|
||||||
@ -106,12 +111,14 @@ describe("Processing Scheduler", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { prisma } = await import("../../lib/prisma");
|
const { prisma } = await import("../../lib/prisma");
|
||||||
vi.mocked(prisma.aIProcessingRequest.aggregate).mockResolvedValue(mockAggregation);
|
vi.mocked(prisma.aIProcessingRequest.aggregate).mockResolvedValue(
|
||||||
|
mockAggregation
|
||||||
|
);
|
||||||
|
|
||||||
const result = await getAIProcessingCosts();
|
const result = await getAIProcessingCosts();
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
totalCostEur: 10.50,
|
totalCostEur: 10.5,
|
||||||
totalRequests: 25,
|
totalRequests: 25,
|
||||||
totalPromptTokens: 1000,
|
totalPromptTokens: 1000,
|
||||||
totalCompletionTokens: 500,
|
totalCompletionTokens: 500,
|
||||||
@ -133,7 +140,9 @@ describe("Processing Scheduler", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { prisma } = await import("../../lib/prisma");
|
const { prisma } = await import("../../lib/prisma");
|
||||||
vi.mocked(prisma.aIProcessingRequest.aggregate).mockResolvedValue(mockAggregation);
|
vi.mocked(prisma.aIProcessingRequest.aggregate).mockResolvedValue(
|
||||||
|
mockAggregation
|
||||||
|
);
|
||||||
|
|
||||||
const result = await getAIProcessingCosts();
|
const result = await getAIProcessingCosts();
|
||||||
|
|
||||||
|
|||||||
@ -2,8 +2,8 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
|||||||
import { parseTranscriptToMessages } from "../../lib/transcriptParser";
|
import { parseTranscriptToMessages } from "../../lib/transcriptParser";
|
||||||
|
|
||||||
describe("Transcript Parser", () => {
|
describe("Transcript Parser", () => {
|
||||||
const startTime = new Date('2024-01-01T10:00:00Z');
|
const startTime = new Date("2024-01-01T10:00:00Z");
|
||||||
const endTime = new Date('2024-01-01T10:30:00Z');
|
const endTime = new Date("2024-01-01T10:30:00Z");
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@ -22,7 +22,9 @@ describe("Transcript Parser", () => {
|
|||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.messages).toHaveLength(3);
|
expect(result.messages).toHaveLength(3);
|
||||||
expect(result.messages![0].role).toBe("User");
|
expect(result.messages![0].role).toBe("User");
|
||||||
expect(result.messages![0].content).toBe("Hello, I need help with my account");
|
expect(result.messages![0].content).toBe(
|
||||||
|
"Hello, I need help with my account"
|
||||||
|
);
|
||||||
expect(result.messages![1].role).toBe("Assistant");
|
expect(result.messages![1].role).toBe("Assistant");
|
||||||
expect(result.messages![2].role).toBe("User");
|
expect(result.messages![2].role).toBe("User");
|
||||||
expect(result.messages![2].content).toBe("I can't log in to my account");
|
expect(result.messages![2].content).toBe("I can't log in to my account");
|
||||||
@ -42,7 +44,9 @@ User: I need support with my order
|
|||||||
expect(result.messages![0].role).toBe("User");
|
expect(result.messages![0].role).toBe("User");
|
||||||
expect(result.messages![0].content).toBe("Hello there");
|
expect(result.messages![0].content).toBe("Hello there");
|
||||||
expect(result.messages![1].role).toBe("Assistant");
|
expect(result.messages![1].role).toBe("Assistant");
|
||||||
expect(result.messages![1].content).toBe("Hello! How can I help you today?");
|
expect(result.messages![1].content).toBe(
|
||||||
|
"Hello! How can I help you today?"
|
||||||
|
);
|
||||||
expect(result.messages![2].role).toBe("User");
|
expect(result.messages![2].role).toBe("User");
|
||||||
expect(result.messages![2].content).toBe("I need support with my order");
|
expect(result.messages![2].content).toBe("I need support with my order");
|
||||||
});
|
});
|
||||||
@ -124,15 +128,17 @@ User: Third
|
|||||||
it("should handle empty content", () => {
|
it("should handle empty content", () => {
|
||||||
expect(parseTranscriptToMessages("", startTime, endTime)).toEqual({
|
expect(parseTranscriptToMessages("", startTime, endTime)).toEqual({
|
||||||
success: false,
|
success: false,
|
||||||
error: "Empty transcript content"
|
error: "Empty transcript content",
|
||||||
});
|
});
|
||||||
expect(parseTranscriptToMessages(" \n\n ", startTime, endTime)).toEqual({
|
expect(
|
||||||
|
parseTranscriptToMessages(" \n\n ", startTime, endTime)
|
||||||
|
).toEqual({
|
||||||
success: false,
|
success: false,
|
||||||
error: "Empty transcript content"
|
error: "Empty transcript content",
|
||||||
});
|
});
|
||||||
expect(parseTranscriptToMessages("\t\r\n", startTime, endTime)).toEqual({
|
expect(parseTranscriptToMessages("\t\r\n", startTime, endTime)).toEqual({
|
||||||
success: false,
|
success: false,
|
||||||
error: "Empty transcript content"
|
error: "Empty transcript content",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -79,22 +79,32 @@ describe("Dashboard Components", () => {
|
|||||||
render(<TopQuestionsChart data={mockQuestions} />);
|
render(<TopQuestionsChart data={mockQuestions} />);
|
||||||
|
|
||||||
expect(screen.getByTestId("card")).toBeInTheDocument();
|
expect(screen.getByTestId("card")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("card-title")).toHaveTextContent("Top 5 Asked Questions");
|
expect(screen.getByTestId("card-title")).toHaveTextContent(
|
||||||
expect(screen.getByText("How do I reset my password?")).toBeInTheDocument();
|
"Top 5 Asked Questions"
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText("How do I reset my password?")
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render with custom title", () => {
|
it("should render with custom title", () => {
|
||||||
render(<TopQuestionsChart data={mockQuestions} title="Custom Title" />);
|
render(<TopQuestionsChart data={mockQuestions} title="Custom Title" />);
|
||||||
|
|
||||||
expect(screen.getByTestId("card-title")).toHaveTextContent("Custom Title");
|
expect(screen.getByTestId("card-title")).toHaveTextContent(
|
||||||
|
"Custom Title"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle empty questions data", () => {
|
it("should handle empty questions data", () => {
|
||||||
render(<TopQuestionsChart data={[]} />);
|
render(<TopQuestionsChart data={[]} />);
|
||||||
|
|
||||||
expect(screen.getByTestId("card")).toBeInTheDocument();
|
expect(screen.getByTestId("card")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("card-title")).toHaveTextContent("Top 5 Asked Questions");
|
expect(screen.getByTestId("card-title")).toHaveTextContent(
|
||||||
expect(screen.getByText("No questions data available")).toBeInTheDocument();
|
"Top 5 Asked Questions"
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText("No questions data available")
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should display question counts as badges", () => {
|
it("should display question counts as badges", () => {
|
||||||
@ -108,7 +118,7 @@ describe("Dashboard Components", () => {
|
|||||||
render(<TopQuestionsChart data={mockQuestions} />);
|
render(<TopQuestionsChart data={mockQuestions} />);
|
||||||
|
|
||||||
// All questions should be rendered
|
// All questions should be rendered
|
||||||
mockQuestions.forEach(question => {
|
mockQuestions.forEach((question) => {
|
||||||
expect(screen.getByText(question.question)).toBeInTheDocument();
|
expect(screen.getByText(question.question)).toBeInTheDocument();
|
||||||
expect(screen.getByText(question.count.toString())).toBeInTheDocument();
|
expect(screen.getByText(question.count.toString())).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@ -140,7 +150,9 @@ Assistant: Let me help you with that. Can you tell me what error message you're
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("Session Transcript")).toBeInTheDocument();
|
expect(screen.getByText("Session Transcript")).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Hello, I need help with my account/)).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByText(/Hello, I need help with my account/)
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle empty transcript content", () => {
|
it("should handle empty transcript content", () => {
|
||||||
@ -151,19 +163,19 @@ Assistant: Let me help you with that. Can you tell me what error message you're
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("No transcript content available.")).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByText("No transcript content available.")
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render without transcript URL", () => {
|
it("should render without transcript URL", () => {
|
||||||
render(
|
render(<TranscriptViewer transcriptContent={mockTranscriptContent} />);
|
||||||
<TranscriptViewer
|
|
||||||
transcriptContent={mockTranscriptContent}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should still render content
|
// Should still render content
|
||||||
expect(screen.getByText("Session Transcript")).toBeInTheDocument();
|
expect(screen.getByText("Session Transcript")).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Hello, I need help with my account/)).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByText(/Hello, I need help with my account/)
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should toggle between formatted and raw view", () => {
|
it("should toggle between formatted and raw view", () => {
|
||||||
@ -196,7 +208,9 @@ Assistant: Let me help you with that. Can you tell me what error message you're
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Should show "No transcript content available" in formatted view for malformed content
|
// Should show "No transcript content available" in formatted view for malformed content
|
||||||
expect(screen.getByText("No transcript content available.")).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByText("No transcript content available.")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
// But should show the raw content when toggled to raw view
|
// But should show the raw content when toggled to raw view
|
||||||
const rawToggleButton = screen.getByText("Raw Text");
|
const rawToggleButton = screen.getByText("Raw Text");
|
||||||
@ -213,7 +227,9 @@ Assistant: Let me help you with that. Can you tell me what error message you're
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Check for message content
|
// Check for message content
|
||||||
expect(screen.getByText(/Hello, I need help with my account/)).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByText(/Hello, I need help with my account/)
|
||||||
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText(/I'd be happy to help you/)).toBeInTheDocument();
|
expect(screen.getByText(/I'd be happy to help you/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { InMemoryRateLimiter, extractClientIP } from "../../lib/rateLimiter";
|
import { InMemoryRateLimiter, extractClientIP } from "../../lib/rateLimiter";
|
||||||
import { validateInput, registerSchema, loginSchema, forgotPasswordSchema } from "../../lib/validation";
|
import {
|
||||||
|
validateInput,
|
||||||
|
registerSchema,
|
||||||
|
loginSchema,
|
||||||
|
forgotPasswordSchema,
|
||||||
|
} from "../../lib/validation";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
// Import password schema directly from validation file
|
// Import password schema directly from validation file
|
||||||
@ -63,7 +68,7 @@ describe("Security Tests", () => {
|
|||||||
expect(rateLimiter.checkRateLimit("test-ip").allowed).toBe(false);
|
expect(rateLimiter.checkRateLimit("test-ip").allowed).toBe(false);
|
||||||
|
|
||||||
// Wait for window to expire
|
// Wait for window to expire
|
||||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||||
|
|
||||||
// Should be allowed again
|
// Should be allowed again
|
||||||
expect(rateLimiter.checkRateLimit("test-ip").allowed).toBe(true);
|
expect(rateLimiter.checkRateLimit("test-ip").allowed).toBe(true);
|
||||||
@ -89,7 +94,7 @@ describe("Security Tests", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wait for entries to expire
|
// Wait for entries to expire
|
||||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||||
|
|
||||||
// Force cleanup by checking rate limit
|
// Force cleanup by checking rate limit
|
||||||
rateLimiter.checkRateLimit("cleanup-trigger");
|
rateLimiter.checkRateLimit("cleanup-trigger");
|
||||||
@ -163,7 +168,7 @@ describe("Security Tests", () => {
|
|||||||
"password123!", // Common password pattern
|
"password123!", // Common password pattern
|
||||||
];
|
];
|
||||||
|
|
||||||
weakPasswords.forEach(password => {
|
weakPasswords.forEach((password) => {
|
||||||
const result = validateInput(passwordSchema, password);
|
const result = validateInput(passwordSchema, password);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
@ -176,7 +181,7 @@ describe("Security Tests", () => {
|
|||||||
"MyS3cur3P@ssword!",
|
"MyS3cur3P@ssword!",
|
||||||
];
|
];
|
||||||
|
|
||||||
strongPasswords.forEach(password => {
|
strongPasswords.forEach((password) => {
|
||||||
const result = validateInput(passwordSchema, password);
|
const result = validateInput(passwordSchema, password);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user