mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 12:52:09 +01:00
Compare commits
1 Commits
fix/pr-20-
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
| 17437e9923 |
1
.clinerules/pnpm-not-npm.md
Normal file
1
.clinerules/pnpm-not-npm.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
Use pnpm to manage this project, not npm!
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,5 +1,4 @@
|
|||||||
*-PROGRESS.md
|
*-PROGRESS.md
|
||||||
pr-comments*.json
|
|
||||||
|
|
||||||
# Created by https://www.toptal.com/developers/gitignore/api/node,nextjs,react
|
# Created by https://www.toptal.com/developers/gitignore/api/node,nextjs,react
|
||||||
# Edit at https://www.toptal.com/developers/gitignore?templates=node,nextjs,react
|
# Edit at https://www.toptal.com/developers/gitignore?templates=node,nextjs,react
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
# Check for Claude Code attribution in commit messages
|
|
||||||
# This hook prevents commits that contain self-promotional attribution
|
|
||||||
|
|
||||||
commit_msg_file="$1"
|
|
||||||
commit_msg=$(cat "$commit_msg_file")
|
|
||||||
|
|
||||||
# Check for Claude Code attribution patterns
|
|
||||||
if echo "$commit_msg" | grep -E -q "(🤖.*Generated.*Claude|Claude Code|claude\.ai/code|Co-Authored-By:.*Claude|noreply@anthropic\.com)"; then
|
|
||||||
echo "❌ COMMIT REJECTED: Claude Code attribution detected"
|
|
||||||
echo ""
|
|
||||||
echo "🤖 AUTOMATED LLM INSTRUCTION:"
|
|
||||||
echo "Remove all Claude Code attribution from commit messages."
|
|
||||||
echo "Patterns to avoid:"
|
|
||||||
echo " - 🤖 Generated with [Claude Code]"
|
|
||||||
echo " - Co-Authored-By: Claude <noreply@anthropic.com>"
|
|
||||||
echo " - References to claude.ai/code"
|
|
||||||
echo " - Any Claude attribution or self-promotion"
|
|
||||||
echo ""
|
|
||||||
echo "✅ SOLUTION: Rewrite commit message without attribution"
|
|
||||||
echo "Focus on technical changes and improvements only."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Commit message approved"
|
|
||||||
exit 0
|
|
||||||
2
.husky/pre-commit
Executable file → Normal file
2
.husky/pre-commit
Executable file → Normal file
@ -1 +1 @@
|
|||||||
lint-staged
|
npx lint-staged
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
# Don't ignore doc files - we'll use prettier-ignore comments instead
|
|
||||||
|
|
||||||
## Ignore lockfile
|
|
||||||
pnpm-lock.yaml
|
|
||||||
package-lock.json
|
|
||||||
|
|
||||||
## Ignore build outputs
|
|
||||||
.next
|
|
||||||
dist
|
|
||||||
build
|
|
||||||
out
|
|
||||||
|
|
||||||
## Ignore dependencies
|
|
||||||
node_modules
|
|
||||||
|
|
||||||
## Files that are formatted by biome
|
|
||||||
**/*.js
|
|
||||||
**/*.ts
|
|
||||||
**/*.cjs
|
|
||||||
**/*.cts
|
|
||||||
**/*.mjs
|
|
||||||
**/*.mts
|
|
||||||
**/*.d.cts
|
|
||||||
**/*.d.mts
|
|
||||||
**/*.jsx
|
|
||||||
**/*.tsx
|
|
||||||
**/*.json
|
|
||||||
**/*.jsonc
|
|
||||||
76
CLAUDE.md
76
CLAUDE.md
@ -35,24 +35,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
- `pnpm test:vitest:coverage` - Run Vitest with coverage report
|
- `pnpm test:vitest:coverage` - Run Vitest with coverage report
|
||||||
- `pnpm test:coverage` - Run all tests with coverage
|
- `pnpm test:coverage` - Run all tests with coverage
|
||||||
|
|
||||||
**Security Testing:**
|
|
||||||
|
|
||||||
- `pnpm test:security` - Run security-specific tests
|
|
||||||
- `pnpm test:security-headers` - Test HTTP security headers implementation
|
|
||||||
- `pnpm test:csp` - Test CSP implementation and nonce generation
|
|
||||||
- `pnpm test:csp:validate` - Validate CSP implementation with security scoring
|
|
||||||
- `pnpm test:csp:full` - Comprehensive CSP test suite
|
|
||||||
|
|
||||||
**Migration & Deployment:**
|
|
||||||
|
|
||||||
- `pnpm migration:backup` - Create database backup
|
|
||||||
- `pnpm migration:validate-db` - Validate database schema and integrity
|
|
||||||
- `pnpm migration:validate-env` - Validate environment configuration
|
|
||||||
- `pnpm migration:pre-check` - Run pre-deployment validation checks
|
|
||||||
- `pnpm migration:health-check` - Run system health checks
|
|
||||||
- `pnpm migration:deploy` - Execute full deployment process
|
|
||||||
- `pnpm migration:rollback` - Rollback failed migration
|
|
||||||
|
|
||||||
**Markdown:**
|
**Markdown:**
|
||||||
|
|
||||||
- `pnpm lint:md` - Lint Markdown files
|
- `pnpm lint:md` - Lint Markdown files
|
||||||
@ -105,9 +87,6 @@ The system processes user sessions through distinct stages tracked in `SessionPr
|
|||||||
- `lib/processingScheduler.ts` - AI analysis pipeline
|
- `lib/processingScheduler.ts` - AI analysis pipeline
|
||||||
- `lib/transcriptFetcher.ts` - External transcript fetching
|
- `lib/transcriptFetcher.ts` - External transcript fetching
|
||||||
- `lib/transcriptParser.ts` - Message parsing from transcripts
|
- `lib/transcriptParser.ts` - Message parsing from transcripts
|
||||||
- `lib/batchProcessor.ts` - OpenAI Batch API integration for cost-efficient processing
|
|
||||||
- `lib/batchScheduler.ts` - Automated batch job lifecycle management
|
|
||||||
- `lib/rateLimiter.ts` - In-memory rate limiting utility for API endpoints
|
|
||||||
|
|
||||||
### Development Environment
|
### Development Environment
|
||||||
|
|
||||||
@ -138,11 +117,10 @@ Environment variables are managed through `lib/env.ts` with .env.local file supp
|
|||||||
|
|
||||||
- Schedulers are optional and controlled by `SCHEDULER_ENABLED` environment variable
|
- Schedulers are optional and controlled by `SCHEDULER_ENABLED` environment variable
|
||||||
- Use `pnpm dev:next-only` to run without schedulers for pure frontend development
|
- Use `pnpm dev:next-only` to run without schedulers for pure frontend development
|
||||||
- Four separate schedulers handle different pipeline stages:
|
- Three separate schedulers handle different pipeline stages:
|
||||||
- CSV Import Scheduler (`lib/scheduler.ts`)
|
- CSV Import Scheduler (`lib/scheduler.ts`)
|
||||||
- Import Processing Scheduler (`lib/importProcessor.ts`)
|
- Import Processing Scheduler (`lib/importProcessor.ts`)
|
||||||
- Session Processing Scheduler (`lib/processingScheduler.ts`)
|
- Session Processing Scheduler (`lib/processingScheduler.ts`)
|
||||||
- Batch Processing Scheduler (`lib/batchScheduler.ts`) - Manages OpenAI Batch API lifecycle
|
|
||||||
|
|
||||||
**Database Migrations:**
|
**Database Migrations:**
|
||||||
|
|
||||||
@ -157,11 +135,6 @@ Environment variables are managed through `lib/env.ts` with .env.local file supp
|
|||||||
- Support for multiple AI models per company
|
- Support for multiple AI models per company
|
||||||
- Time-based pricing management for accurate cost calculation
|
- Time-based pricing management for accurate cost calculation
|
||||||
- Processing stages can be retried on failure with retry count tracking
|
- Processing stages can be retried on failure with retry count tracking
|
||||||
- **Batch API Integration**: 50% cost reduction using OpenAI Batch API
|
|
||||||
- Automatic batching of AI requests every 5 minutes
|
|
||||||
- Batch status checking every 2 minutes
|
|
||||||
- Result processing every minute
|
|
||||||
- Failed request retry with individual API calls
|
|
||||||
|
|
||||||
**Code Quality Standards:**
|
**Code Quality Standards:**
|
||||||
|
|
||||||
@ -169,50 +142,3 @@ Environment variables are managed through `lib/env.ts` with .env.local file supp
|
|||||||
- TypeScript with ES modules (type: "module" in package.json)
|
- TypeScript with ES modules (type: "module" in package.json)
|
||||||
- React 19 with Next.js 15 App Router
|
- React 19 with Next.js 15 App Router
|
||||||
- TailwindCSS 4 for styling
|
- TailwindCSS 4 for styling
|
||||||
|
|
||||||
**Security Features:**
|
|
||||||
|
|
||||||
- **Comprehensive CSRF Protection**: Multi-layer CSRF protection with automatic token management
|
|
||||||
- Middleware-level protection for all state-changing endpoints
|
|
||||||
- tRPC integration with CSRF-protected procedures
|
|
||||||
- Client-side hooks and components for seamless integration
|
|
||||||
- HTTP-only cookies with SameSite protection
|
|
||||||
- **Enhanced Content Security Policy (CSP)**:
|
|
||||||
- Nonce-based script execution for maximum XSS protection
|
|
||||||
- Environment-specific policies (strict production, permissive development)
|
|
||||||
- Real-time violation reporting and bypass detection
|
|
||||||
- Automated policy optimization recommendations
|
|
||||||
- **Security Monitoring & Audit System**:
|
|
||||||
- Real-time threat detection and alerting
|
|
||||||
- Comprehensive security audit logging with retention management
|
|
||||||
- Geographic anomaly detection and IP threat analysis
|
|
||||||
- Security scoring and automated incident response
|
|
||||||
- **Advanced Rate Limiting**: In-memory rate limiting system
|
|
||||||
- Authentication endpoints: Login (5/15min), Registration (3/hour), Password Reset (5/15min)
|
|
||||||
- CSP reporting: 10 reports per minute per IP
|
|
||||||
- Admin endpoints: Configurable thresholds
|
|
||||||
- **Input Validation & Security Headers**:
|
|
||||||
- Comprehensive Zod schemas for all user inputs with XSS/injection prevention
|
|
||||||
- HTTP security headers (HSTS, X-Frame-Options, X-Content-Type-Options, Permissions Policy)
|
|
||||||
- Strong password requirements and email validation
|
|
||||||
- **Session Security**:
|
|
||||||
- JWT tokens with 24-hour expiration and secure cookie settings
|
|
||||||
- HttpOnly, Secure, SameSite cookies with proper CSP integration
|
|
||||||
- Company isolation and multi-tenant security
|
|
||||||
|
|
||||||
**Code Quality & Linting:**
|
|
||||||
|
|
||||||
- **Biome Integration**: Primary linting and formatting tool
|
|
||||||
- Pre-commit hooks enforce code quality standards
|
|
||||||
- Some security-critical patterns require `biome-ignore` comments
|
|
||||||
- Non-null assertions (`!`) used intentionally in authenticated contexts require ignore comments
|
|
||||||
- Complex functions may need refactoring to meet complexity thresholds (max 15)
|
|
||||||
- Performance classes use static-only patterns which may trigger warnings
|
|
||||||
- **TypeScript Strict Mode**: Comprehensive type checking
|
|
||||||
- Avoid `any` types where possible; use proper type definitions
|
|
||||||
- Optional chaining vs non-null assertions: choose based on security context
|
|
||||||
- In authenticated API handlers, non-null assertions are often safer than optional chaining
|
|
||||||
- **Security vs Linting Balance**:
|
|
||||||
- Security takes precedence over linting rules when they conflict
|
|
||||||
- Document security-critical choices with detailed comments
|
|
||||||
- Use `// biome-ignore` with explanations for intentional rule violations
|
|
||||||
|
|||||||
@ -1,285 +0,0 @@
|
|||||||
# Documentation Audit Summary
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document summarizes the comprehensive documentation audit performed on the LiveDash-Node project, identifying gaps, outdated information, and newly created documentation to address missing coverage.
|
|
||||||
|
|
||||||
## Audit Findings
|
|
||||||
|
|
||||||
### Well-Documented Areas ✅
|
|
||||||
|
|
||||||
The following areas were found to have comprehensive, accurate documentation:
|
|
||||||
|
|
||||||
1. **CSRF Protection** (`docs/CSRF_PROTECTION.md`)
|
|
||||||
|
|
||||||
- Multi-layer protection implementation
|
|
||||||
- Client-side integration guide
|
|
||||||
- tRPC integration details
|
|
||||||
- Comprehensive examples
|
|
||||||
|
|
||||||
2. **Enhanced CSP Implementation** (`docs/security/enhanced-csp.md`)
|
|
||||||
|
|
||||||
- Nonce-based script execution
|
|
||||||
- Environment-specific policies
|
|
||||||
- Violation reporting and monitoring
|
|
||||||
- Testing framework
|
|
||||||
|
|
||||||
3. **Security Headers** (`docs/security-headers.md`)
|
|
||||||
|
|
||||||
- Complete header implementation details
|
|
||||||
- Testing procedures
|
|
||||||
- Compatibility information
|
|
||||||
|
|
||||||
4. **Security Monitoring System** (`docs/security-monitoring.md`)
|
|
||||||
|
|
||||||
- Real-time threat detection
|
|
||||||
- Alert management
|
|
||||||
- API usage examples
|
|
||||||
- Performance considerations
|
|
||||||
|
|
||||||
5. **Migration Guide** (`MIGRATION_GUIDE.md`)
|
|
||||||
|
|
||||||
- Comprehensive v2.0.0 migration procedures
|
|
||||||
- Rollback procedures
|
|
||||||
- Health checks and validation
|
|
||||||
|
|
||||||
### Major Issues Identified ❌
|
|
||||||
|
|
||||||
#### 1. README.md - Critically Outdated
|
|
||||||
|
|
||||||
**Problems Found:**
|
|
||||||
|
|
||||||
- Listed database as "SQLite (default)" when project uses PostgreSQL
|
|
||||||
- Missing all new security features (CSRF, CSP, security monitoring)
|
|
||||||
- Incomplete environment setup section
|
|
||||||
- Outdated tech stack (missing tRPC, security features)
|
|
||||||
- Project structure didn't reflect new admin/security directories
|
|
||||||
|
|
||||||
**Actions Taken:**
|
|
||||||
|
|
||||||
- ✅ Updated features section to include security and admin capabilities
|
|
||||||
- ✅ Corrected tech stack to include PostgreSQL, tRPC, security features
|
|
||||||
- ✅ Updated environment setup with proper PostgreSQL configuration
|
|
||||||
- ✅ Revised project structure to reflect current codebase
|
|
||||||
- ✅ Added comprehensive script documentation
|
|
||||||
|
|
||||||
#### 2. Undocumented API Endpoints
|
|
||||||
|
|
||||||
**Missing Documentation:**
|
|
||||||
|
|
||||||
- `/api/admin/audit-logs/` (GET) - Audit log retrieval with filtering
|
|
||||||
- `/api/admin/audit-logs/retention/` (POST) - Retention management
|
|
||||||
- `/api/admin/security-monitoring/` (GET/POST) - Security metrics and config
|
|
||||||
- `/api/admin/security-monitoring/alerts/` - Alert management
|
|
||||||
- `/api/admin/security-monitoring/export/` - Data export
|
|
||||||
- `/api/admin/security-monitoring/threat-analysis/` - Threat analysis
|
|
||||||
- `/api/admin/batch-monitoring/` - Batch processing monitoring
|
|
||||||
- `/api/csp-report/` (POST) - CSP violation reporting
|
|
||||||
- `/api/csp-metrics/` (GET) - CSP metrics and analytics
|
|
||||||
- `/api/csrf-token/` (GET) - CSRF token endpoint
|
|
||||||
|
|
||||||
**Actions Taken:**
|
|
||||||
|
|
||||||
- ✅ Created `docs/admin-audit-logs-api.md` - Comprehensive audit logs API documentation
|
|
||||||
- ✅ Created `docs/csp-metrics-api.md` - CSP monitoring and metrics API documentation
|
|
||||||
- ✅ Created `docs/api-reference.md` - Complete API reference for all endpoints
|
|
||||||
|
|
||||||
#### 3. Undocumented Features and Components
|
|
||||||
|
|
||||||
**Missing Feature Documentation:**
|
|
||||||
|
|
||||||
- Batch monitoring dashboard and UI components
|
|
||||||
- Security monitoring UI components
|
|
||||||
- Nonce-based CSP context provider
|
|
||||||
- Enhanced rate limiting system
|
|
||||||
- Security audit retention system
|
|
||||||
|
|
||||||
**Actions Taken:**
|
|
||||||
|
|
||||||
- ✅ Created `docs/batch-monitoring-dashboard.md` - Complete batch monitoring documentation
|
|
||||||
|
|
||||||
#### 4. CLAUDE.md - Missing New Commands
|
|
||||||
|
|
||||||
**Problems Found:**
|
|
||||||
|
|
||||||
- Missing security testing commands
|
|
||||||
- Missing CSP testing commands
|
|
||||||
- Missing migration/deployment commands
|
|
||||||
- Outdated security features section
|
|
||||||
|
|
||||||
**Actions Taken:**
|
|
||||||
|
|
||||||
- ✅ Added security testing command section
|
|
||||||
- ✅ Added CSP testing commands
|
|
||||||
- ✅ Added migration and deployment commands
|
|
||||||
- ✅ Updated security features section with comprehensive details
|
|
||||||
|
|
||||||
## New Documentation Created
|
|
||||||
|
|
||||||
### 1. Admin Audit Logs API Documentation
|
|
||||||
|
|
||||||
**File:** `docs/admin-audit-logs-api.md`
|
|
||||||
|
|
||||||
**Contents:**
|
|
||||||
|
|
||||||
- Complete API endpoint documentation with examples
|
|
||||||
- Authentication and authorization requirements
|
|
||||||
- Query parameters and filtering options
|
|
||||||
- Response formats and error handling
|
|
||||||
- Retention management procedures
|
|
||||||
- Security features and rate limiting
|
|
||||||
- Usage examples and integration patterns
|
|
||||||
- Performance considerations and troubleshooting
|
|
||||||
|
|
||||||
### 2. CSP Metrics and Monitoring API Documentation
|
|
||||||
|
|
||||||
**File:** `docs/csp-metrics-api.md`
|
|
||||||
|
|
||||||
**Contents:**
|
|
||||||
|
|
||||||
- CSP violation reporting endpoint documentation
|
|
||||||
- Metrics API with real-time violation tracking
|
|
||||||
- Risk assessment and bypass detection features
|
|
||||||
- Policy optimization recommendations
|
|
||||||
- Configuration and setup instructions
|
|
||||||
- Performance considerations and security features
|
|
||||||
- Usage examples for monitoring and analysis
|
|
||||||
- Integration with existing security systems
|
|
||||||
|
|
||||||
### 3. Batch Monitoring Dashboard Documentation
|
|
||||||
|
|
||||||
**File:** `docs/batch-monitoring-dashboard.md`
|
|
||||||
|
|
||||||
**Contents:**
|
|
||||||
|
|
||||||
- Comprehensive batch processing monitoring guide
|
|
||||||
- Real-time monitoring capabilities and features
|
|
||||||
- API endpoints for batch job tracking
|
|
||||||
- Dashboard component documentation
|
|
||||||
- Performance analytics and cost analysis
|
|
||||||
- Administrative controls and error handling
|
|
||||||
- Configuration and alert management
|
|
||||||
- Troubleshooting and optimization guides
|
|
||||||
|
|
||||||
### 4. Complete API Reference
|
|
||||||
|
|
||||||
**File:** `docs/api-reference.md`
|
|
||||||
|
|
||||||
**Contents:**
|
|
||||||
|
|
||||||
- Comprehensive reference for all API endpoints
|
|
||||||
- Authentication and CSRF protection requirements
|
|
||||||
- Detailed request/response formats
|
|
||||||
- Error codes and status descriptions
|
|
||||||
- Rate limiting information
|
|
||||||
- Security headers and CORS configuration
|
|
||||||
- Pagination and filtering standards
|
|
||||||
- Testing and integration examples
|
|
||||||
|
|
||||||
## Updated Documentation
|
|
||||||
|
|
||||||
### 1. README.md - Complete Overhaul
|
|
||||||
|
|
||||||
**Key Updates:**
|
|
||||||
|
|
||||||
- ✅ Updated project description to include security and admin features
|
|
||||||
- ✅ Corrected tech stack to reflect current implementation
|
|
||||||
- ✅ Fixed database information (PostgreSQL vs SQLite)
|
|
||||||
- ✅ Added comprehensive environment configuration
|
|
||||||
- ✅ Updated project structure to match current codebase
|
|
||||||
- ✅ Added security, migration, and testing command sections
|
|
||||||
- ✅ Enhanced features section with detailed capabilities
|
|
||||||
|
|
||||||
### 2. CLAUDE.md - Enhanced Developer Guide
|
|
||||||
|
|
||||||
**Key Updates:**
|
|
||||||
|
|
||||||
- ✅ Added security testing commands section
|
|
||||||
- ✅ Added CSP testing and validation commands
|
|
||||||
- ✅ Added migration and deployment commands
|
|
||||||
- ✅ Enhanced security features documentation
|
|
||||||
- ✅ Updated with comprehensive CSRF, CSP, and monitoring details
|
|
||||||
|
|
||||||
## Documentation Quality Assessment
|
|
||||||
|
|
||||||
### Coverage Analysis
|
|
||||||
|
|
||||||
| Area | Before | After | Status |
|
|
||||||
| ------------------ | ------ | ----- | ------------ |
|
|
||||||
| Core Features | 85% | 95% | ✅ Excellent |
|
|
||||||
| Security Features | 70% | 98% | ✅ Excellent |
|
|
||||||
| API Endpoints | 40% | 95% | ✅ Excellent |
|
|
||||||
| Admin Features | 20% | 90% | ✅ Excellent |
|
|
||||||
| Developer Workflow | 80% | 95% | ✅ Excellent |
|
|
||||||
| Testing Procedures | 60% | 90% | ✅ Excellent |
|
|
||||||
|
|
||||||
### Documentation Standards
|
|
||||||
|
|
||||||
All new and updated documentation follows these standards:
|
|
||||||
|
|
||||||
- ✅ Clear, actionable examples
|
|
||||||
- ✅ Comprehensive API documentation with request/response examples
|
|
||||||
- ✅ Security considerations and best practices
|
|
||||||
- ✅ Troubleshooting sections
|
|
||||||
- ✅ Integration patterns and usage examples
|
|
||||||
- ✅ Performance considerations
|
|
||||||
- ✅ Cross-references to related documentation
|
|
||||||
|
|
||||||
## Recommendations for Maintenance
|
|
||||||
|
|
||||||
### 1. Regular Review Schedule
|
|
||||||
|
|
||||||
- **Monthly**: Review API documentation for new endpoints
|
|
||||||
- **Quarterly**: Update security feature documentation
|
|
||||||
- **Per Release**: Validate all examples and code snippets
|
|
||||||
- **Annually**: Comprehensive documentation audit
|
|
||||||
|
|
||||||
### 2. Documentation Automation
|
|
||||||
|
|
||||||
- Add documentation checks to CI/CD pipeline
|
|
||||||
- Implement API documentation generation from OpenAPI specs
|
|
||||||
- Set up automated link checking
|
|
||||||
- Create documentation review templates
|
|
||||||
|
|
||||||
### 3. Developer Onboarding
|
|
||||||
|
|
||||||
- Use updated documentation for new developer onboarding
|
|
||||||
- Create documentation feedback process
|
|
||||||
- Maintain documentation contribution guidelines
|
|
||||||
- Track documentation usage and feedback
|
|
||||||
|
|
||||||
### 4. Continuous Improvement
|
|
||||||
|
|
||||||
- Monitor documentation gaps through developer feedback
|
|
||||||
- Update examples with real-world usage patterns
|
|
||||||
- Enhance troubleshooting sections based on support issues
|
|
||||||
- Keep security documentation current with threat landscape
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
The documentation audit identified significant gaps in API documentation, outdated project information, and missing coverage of new security features. Through comprehensive updates and new documentation creation, the project now has:
|
|
||||||
|
|
||||||
- **Complete API Reference**: All endpoints documented with examples
|
|
||||||
- **Accurate Project Information**: README and CLAUDE.md reflect current state
|
|
||||||
- **Comprehensive Security Documentation**: All security features thoroughly documented
|
|
||||||
- **Developer-Friendly Guides**: Clear setup, testing, and deployment procedures
|
|
||||||
- **Administrative Documentation**: Complete coverage of admin and monitoring features
|
|
||||||
|
|
||||||
The documentation is now production-ready and provides comprehensive guidance for developers, administrators, and security teams working with the LiveDash-Node application.
|
|
||||||
|
|
||||||
## Files Modified/Created
|
|
||||||
|
|
||||||
### Modified Files
|
|
||||||
|
|
||||||
1. `README.md` - Complete overhaul with accurate project information
|
|
||||||
2. `CLAUDE.md` - Enhanced with security testing and migration commands
|
|
||||||
|
|
||||||
### New Documentation Files
|
|
||||||
|
|
||||||
1. `docs/admin-audit-logs-api.md` - Admin audit logs API documentation
|
|
||||||
2. `docs/csp-metrics-api.md` - CSP monitoring and metrics API documentation
|
|
||||||
3. `docs/batch-monitoring-dashboard.md` - Batch monitoring dashboard documentation
|
|
||||||
4. `docs/api-reference.md` - Comprehensive API reference
|
|
||||||
5. `DOCUMENTATION_AUDIT_SUMMARY.md` - This summary document
|
|
||||||
|
|
||||||
All documentation is now current, comprehensive, and ready for production use.
|
|
||||||
@ -3,7 +3,6 @@
|
|||||||
## Issues Identified
|
## Issues Identified
|
||||||
|
|
||||||
From your logs:
|
From your logs:
|
||||||
|
|
||||||
```
|
```
|
||||||
Can't reach database server at `ep-tiny-math-a2zsshve-pooler.eu-central-1.aws.neon.tech:5432`
|
Can't reach database server at `ep-tiny-math-a2zsshve-pooler.eu-central-1.aws.neon.tech:5432`
|
||||||
[NODE-CRON] [WARN] missed execution! Possible blocking IO or high CPU
|
[NODE-CRON] [WARN] missed execution! Possible blocking IO or high CPU
|
||||||
@ -11,34 +10,30 @@ Can't reach database server at `ep-tiny-math-a2zsshve-pooler.eu-central-1.aws.ne
|
|||||||
|
|
||||||
## Root Causes
|
## Root Causes
|
||||||
|
|
||||||
1. **Multiple PrismaClient instances** across schedulers
|
1. **Multiple PrismaClient instances** across schedulers
|
||||||
2. **No connection retry logic** for temporary failures
|
2. **No connection retry logic** for temporary failures
|
||||||
3. **No connection pooling optimization** for Neon
|
3. **No connection pooling optimization** for Neon
|
||||||
4. **Aggressive scheduler intervals** overwhelming database
|
4. **Aggressive scheduler intervals** overwhelming database
|
||||||
|
|
||||||
## Fixes Applied ✅
|
## Fixes Applied ✅
|
||||||
|
|
||||||
### 1. Connection Retry Logic (`lib/database-retry.ts`)
|
### 1. Connection Retry Logic (`lib/database-retry.ts`)
|
||||||
|
|
||||||
- **Automatic retry** for connection errors
|
- **Automatic retry** for connection errors
|
||||||
- **Exponential backoff** (1s → 2s → 4s → 10s max)
|
- **Exponential backoff** (1s → 2s → 4s → 10s max)
|
||||||
- **Smart error detection** (only retry connection issues)
|
- **Smart error detection** (only retry connection issues)
|
||||||
- **Configurable retry attempts** (default: 3 retries)
|
- **Configurable retry attempts** (default: 3 retries)
|
||||||
|
|
||||||
### 2. Enhanced Schedulers
|
### 2. Enhanced Schedulers
|
||||||
|
|
||||||
- **Import Processor**: Added retry wrapper around main processing
|
- **Import Processor**: Added retry wrapper around main processing
|
||||||
- **Session Processor**: Added retry wrapper around AI processing
|
- **Session Processor**: Added retry wrapper around AI processing
|
||||||
- **Graceful degradation** when database is temporarily unavailable
|
- **Graceful degradation** when database is temporarily unavailable
|
||||||
|
|
||||||
### 3. Singleton Pattern Enforced
|
### 3. Singleton Pattern Enforced
|
||||||
|
|
||||||
- **All schedulers now use** `import { prisma } from "./prisma.js"`
|
- **All schedulers now use** `import { prisma } from "./prisma.js"`
|
||||||
- **No more separate** `new PrismaClient()` instances
|
- **No more separate** `new PrismaClient()` instances
|
||||||
- **Shared connection pool** across all operations
|
- **Shared connection pool** across all operations
|
||||||
|
|
||||||
### 4. Neon-Specific Optimizations
|
### 4. Neon-Specific Optimizations
|
||||||
|
|
||||||
- **Connection limit guidance**: 15 connections (below Neon's 20 limit)
|
- **Connection limit guidance**: 15 connections (below Neon's 20 limit)
|
||||||
- **Extended timeouts**: 30s for cold start handling
|
- **Extended timeouts**: 30s for cold start handling
|
||||||
- **SSL mode requirements**: `sslmode=require` for Neon
|
- **SSL mode requirements**: `sslmode=require` for Neon
|
||||||
@ -47,7 +42,6 @@ Can't reach database server at `ep-tiny-math-a2zsshve-pooler.eu-central-1.aws.ne
|
|||||||
## Immediate Actions Needed
|
## Immediate Actions Needed
|
||||||
|
|
||||||
### 1. Update Environment Variables
|
### 1. Update Environment Variables
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Add to .env.local
|
# Add to .env.local
|
||||||
USE_ENHANCED_POOLING=true
|
USE_ENHANCED_POOLING=true
|
||||||
@ -59,7 +53,6 @@ DATABASE_URL="postgresql://user:pass@ep-tiny-math-a2zsshve-pooler.eu-central-1.a
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 2. Reduce Scheduler Frequency (Optional)
|
### 2. Reduce Scheduler Frequency (Optional)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Less aggressive intervals
|
# Less aggressive intervals
|
||||||
CSV_IMPORT_INTERVAL="*/30 * * * *" # Every 30 min (was 15)
|
CSV_IMPORT_INTERVAL="*/30 * * * *" # Every 30 min (was 15)
|
||||||
@ -68,7 +61,6 @@ SESSION_PROCESSING_INTERVAL="0 */2 * * *" # Every 2 hours (was 1)
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 3. Run Configuration Check
|
### 3. Run Configuration Check
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm db:check
|
pnpm db:check
|
||||||
```
|
```
|
||||||
@ -79,7 +71,7 @@ pnpm db:check
|
|||||||
✅ **Resource Efficiency**: Single shared connection pool
|
✅ **Resource Efficiency**: Single shared connection pool
|
||||||
✅ **Neon Optimization**: Proper connection limits and timeouts
|
✅ **Neon Optimization**: Proper connection limits and timeouts
|
||||||
✅ **Monitoring**: Health check endpoint for visibility
|
✅ **Monitoring**: Health check endpoint for visibility
|
||||||
✅ **Graceful Degradation**: Schedulers won't crash on DB issues
|
✅ **Graceful Degradation**: Schedulers won't crash on DB issues
|
||||||
|
|
||||||
## Monitoring
|
## Monitoring
|
||||||
|
|
||||||
@ -92,8 +84,8 @@ pnpm db:check
|
|||||||
|
|
||||||
- `lib/database-retry.ts` - New retry utilities
|
- `lib/database-retry.ts` - New retry utilities
|
||||||
- `lib/importProcessor.ts` - Added retry wrapper
|
- `lib/importProcessor.ts` - Added retry wrapper
|
||||||
- `lib/processingScheduler.ts` - Added retry wrapper
|
- `lib/processingScheduler.ts` - Added retry wrapper
|
||||||
- `docs/neon-database-optimization.md` - Neon-specific guide
|
- `docs/neon-database-optimization.md` - Neon-specific guide
|
||||||
- `scripts/check-database-config.ts` - Configuration checker
|
- `scripts/check-database-config.ts` - Configuration checker
|
||||||
|
|
||||||
The connection issues should be significantly reduced with these fixes! 🎯
|
The connection issues should be significantly reduced with these fixes! 🎯
|
||||||
@ -1,450 +0,0 @@
|
|||||||
# LiveDash Node Migration Guide v2.0.0
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This guide provides step-by-step instructions for migrating LiveDash Node to version 2.0.0, which introduces tRPC implementation and OpenAI Batch API integration for improved performance and cost efficiency.
|
|
||||||
|
|
||||||
## 🚀 New Features
|
|
||||||
|
|
||||||
### tRPC Implementation
|
|
||||||
|
|
||||||
- **Type-safe APIs**: End-to-end TypeScript safety from client to server
|
|
||||||
- **Improved Performance**: Optimized query batching and caching
|
|
||||||
- **Better Developer Experience**: Auto-completion and type checking
|
|
||||||
- **Simplified Authentication**: Integrated with existing NextAuth.js setup
|
|
||||||
|
|
||||||
### OpenAI Batch API Integration
|
|
||||||
|
|
||||||
- **50% Cost Reduction**: Batch processing reduces OpenAI API costs by half
|
|
||||||
- **Enhanced Rate Limiting**: Better throughput management
|
|
||||||
- **Improved Reliability**: Automatic retry mechanisms and error handling
|
|
||||||
- **Automated Processing**: Background batch job lifecycle management
|
|
||||||
|
|
||||||
### Enhanced Security & Performance
|
|
||||||
|
|
||||||
- **Rate Limiting**: In-memory rate limiting for all authentication endpoints
|
|
||||||
- **Input Validation**: Comprehensive Zod schemas for all user inputs
|
|
||||||
- **Performance Monitoring**: Built-in metrics collection and monitoring
|
|
||||||
- **Database Optimizations**: New indexes and query optimizations
|
|
||||||
|
|
||||||
## 📋 Pre-Migration Checklist
|
|
||||||
|
|
||||||
### System Requirements
|
|
||||||
|
|
||||||
- [ ] Node.js 18+ installed
|
|
||||||
- [ ] PostgreSQL 13+ database
|
|
||||||
- [ ] `pg_dump` and `pg_restore` utilities available
|
|
||||||
- [ ] Git repository with clean working directory
|
|
||||||
- [ ] OpenAI API key (for production)
|
|
||||||
- [ ] Sufficient disk space for backups (at least 2GB)
|
|
||||||
|
|
||||||
### Environment Preparation
|
|
||||||
|
|
||||||
- [ ] Review current environment variables
|
|
||||||
- [ ] Ensure database connection is working
|
|
||||||
- [ ] Verify all tests are passing
|
|
||||||
- [ ] Create a backup of your current deployment
|
|
||||||
- [ ] Notify team members of planned downtime
|
|
||||||
|
|
||||||
## 🔧 Migration Process
|
|
||||||
|
|
||||||
### Phase 1: Pre-Migration Setup
|
|
||||||
|
|
||||||
#### 1.1 Install Migration Tools
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Ensure you have the latest dependencies
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# Verify migration scripts are available
|
|
||||||
pnpm migration:validate-env --help
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 1.2 Run Pre-Deployment Checks
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run comprehensive pre-deployment validation
|
|
||||||
pnpm migration:pre-check
|
|
||||||
|
|
||||||
# This will validate:
|
|
||||||
# - Environment configuration
|
|
||||||
# - Database connection and schema
|
|
||||||
# - Dependencies
|
|
||||||
# - File system permissions
|
|
||||||
# - OpenAI API access
|
|
||||||
# - tRPC infrastructure readiness
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 1.3 Environment Configuration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Generate new environment variables
|
|
||||||
pnpm migration:migrate-env
|
|
||||||
|
|
||||||
# Review the generated files:
|
|
||||||
# - .env.migration.template
|
|
||||||
# - ENVIRONMENT_MIGRATION_GUIDE.md
|
|
||||||
```
|
|
||||||
|
|
||||||
**Add these new environment variables to your `.env.local`:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# tRPC Configuration
|
|
||||||
TRPC_ENDPOINT_URL="http://localhost:3000/api/trpc"
|
|
||||||
TRPC_BATCH_TIMEOUT="30000"
|
|
||||||
TRPC_MAX_BATCH_SIZE="100"
|
|
||||||
|
|
||||||
# Batch Processing Configuration
|
|
||||||
BATCH_PROCESSING_ENABLED="true"
|
|
||||||
BATCH_CREATE_INTERVAL="*/5 * * * *"
|
|
||||||
BATCH_STATUS_CHECK_INTERVAL="*/2 * * * *"
|
|
||||||
BATCH_RESULT_PROCESSING_INTERVAL="*/1 * * * *"
|
|
||||||
BATCH_MAX_REQUESTS="1000"
|
|
||||||
BATCH_TIMEOUT_HOURS="24"
|
|
||||||
|
|
||||||
# Security & Performance
|
|
||||||
RATE_LIMIT_WINDOW_MS="900000"
|
|
||||||
RATE_LIMIT_MAX_REQUESTS="100"
|
|
||||||
PERFORMANCE_MONITORING_ENABLED="true"
|
|
||||||
METRICS_COLLECTION_INTERVAL="60"
|
|
||||||
|
|
||||||
# Migration Settings (temporary)
|
|
||||||
MIGRATION_MODE="production"
|
|
||||||
MIGRATION_BACKUP_ENABLED="true"
|
|
||||||
MIGRATION_ROLLBACK_ENABLED="true"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 2: Database Migration
|
|
||||||
|
|
||||||
#### 2.1 Create Database Backup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create full database backup
|
|
||||||
pnpm migration:backup
|
|
||||||
|
|
||||||
# Verify backup was created
|
|
||||||
pnpm migration:backup list
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2.2 Validate Database Schema
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Validate current database state
|
|
||||||
pnpm migration:validate-db
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2.3 Apply Database Migrations
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run Prisma migrations
|
|
||||||
pnpm prisma:migrate
|
|
||||||
|
|
||||||
# Apply additional schema changes
|
|
||||||
psql $DATABASE_URL -f scripts/migration/01-schema-migrations.sql
|
|
||||||
|
|
||||||
# Verify migration success
|
|
||||||
pnpm migration:validate-db
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 3: Application Deployment
|
|
||||||
|
|
||||||
#### 3.1 Dry Run Deployment
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Test deployment process without making changes
|
|
||||||
pnpm migration:deploy:dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.2 Full Deployment
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Execute full deployment
|
|
||||||
pnpm migration:deploy
|
|
||||||
|
|
||||||
# This will:
|
|
||||||
# 1. Apply database schema changes
|
|
||||||
# 2. Deploy new application code
|
|
||||||
# 3. Restart services with minimal downtime
|
|
||||||
# 4. Enable tRPC endpoints progressively
|
|
||||||
# 5. Activate batch processing system
|
|
||||||
# 6. Run post-deployment validation
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 4: Post-Migration Validation
|
|
||||||
|
|
||||||
#### 4.1 System Health Check
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run comprehensive health checks
|
|
||||||
pnpm migration:health-check
|
|
||||||
|
|
||||||
# Generate detailed health report
|
|
||||||
pnpm migration:health-report
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4.2 Feature Validation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Test tRPC endpoints
|
|
||||||
pnpm exec tsx scripts/migration/trpc-endpoint-tests.ts
|
|
||||||
|
|
||||||
# Test batch processing system
|
|
||||||
pnpm exec tsx scripts/migration/batch-processing-tests.ts
|
|
||||||
|
|
||||||
# Run full test suite
|
|
||||||
pnpm migration:test
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔄 Rollback Procedure
|
|
||||||
|
|
||||||
If issues occur during migration, you can rollback using these steps:
|
|
||||||
|
|
||||||
### Automatic Rollback
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Quick rollback (if migration failed)
|
|
||||||
pnpm migration:rollback
|
|
||||||
|
|
||||||
# Dry run rollback to see what would happen
|
|
||||||
pnpm migration:rollback:dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual Rollback Steps
|
|
||||||
|
|
||||||
1. **Stop the application**
|
|
||||||
2. **Restore database from backup**
|
|
||||||
3. **Revert to previous code version**
|
|
||||||
4. **Restart services**
|
|
||||||
5. **Verify system functionality**
|
|
||||||
|
|
||||||
### Rollback Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create rollback snapshot (before migration)
|
|
||||||
pnpm migration:rollback:snapshot
|
|
||||||
|
|
||||||
# Restore from specific backup
|
|
||||||
pnpm migration:rollback --backup /path/to/backup.sql
|
|
||||||
|
|
||||||
# Skip database rollback (code only)
|
|
||||||
pnpm migration:rollback --no-database
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📊 Monitoring and Validation
|
|
||||||
|
|
||||||
### Post-Migration Monitoring
|
|
||||||
|
|
||||||
#### 1. Application Health
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check system health every hour for the first day
|
|
||||||
*/60 * * * * cd /path/to/livedash && pnpm migration:health-check
|
|
||||||
|
|
||||||
# Monitor logs for errors
|
|
||||||
tail -f logs/migration.log
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. tRPC Performance
|
|
||||||
|
|
||||||
- Monitor response times for tRPC endpoints
|
|
||||||
- Check error rates in application logs
|
|
||||||
- Verify type safety is working correctly
|
|
||||||
|
|
||||||
#### 3. Batch Processing
|
|
||||||
|
|
||||||
- Monitor batch job completion rates
|
|
||||||
- Check OpenAI API cost reduction
|
|
||||||
- Verify AI processing pipeline functionality
|
|
||||||
|
|
||||||
### Key Metrics to Monitor
|
|
||||||
|
|
||||||
#### Performance Metrics
|
|
||||||
|
|
||||||
- **Response Times**: tRPC endpoints should respond within 500ms
|
|
||||||
- **Database Queries**: Complex queries should complete within 1s
|
|
||||||
- **Memory Usage**: Should remain below 80% of allocated memory
|
|
||||||
- **CPU Usage**: Process should remain responsive
|
|
||||||
|
|
||||||
#### Business Metrics
|
|
||||||
|
|
||||||
- **AI Processing Cost**: Should see ~50% reduction in OpenAI costs
|
|
||||||
- **Processing Throughput**: Batch processing should handle larger volumes
|
|
||||||
- **Error Rates**: Should remain below 1% for critical operations
|
|
||||||
- **User Experience**: No degradation in dashboard performance
|
|
||||||
|
|
||||||
## 🛠 Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues and Solutions
|
|
||||||
|
|
||||||
#### tRPC Endpoints Not Working
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check if tRPC files exist
|
|
||||||
ls -la app/api/trpc/[trpc]/route.ts
|
|
||||||
ls -la server/routers/_app.ts
|
|
||||||
|
|
||||||
# Verify tRPC router exports
|
|
||||||
pnpm exec tsx -e "import('./server/routers/_app').then(m => console.log(Object.keys(m)))"
|
|
||||||
|
|
||||||
# Test endpoints manually
|
|
||||||
curl -X POST http://localhost:3000/api/trpc/auth.getSession \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"json": null}'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Batch Processing Issues
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check batch processing components
|
|
||||||
pnpm exec tsx scripts/migration/batch-processing-tests.ts
|
|
||||||
|
|
||||||
# Verify OpenAI API access
|
|
||||||
curl -H "Authorization: Bearer $OPENAI_API_KEY" \
|
|
||||||
https://api.openai.com/v1/models
|
|
||||||
|
|
||||||
# Check batch job status
|
|
||||||
psql $DATABASE_URL -c "SELECT status, COUNT(*) FROM \"AIBatchRequest\" GROUP BY status;"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Database Issues
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check database connection
|
|
||||||
pnpm db:check
|
|
||||||
|
|
||||||
# Verify schema integrity
|
|
||||||
pnpm migration:validate-db
|
|
||||||
|
|
||||||
# Check for missing indexes
|
|
||||||
psql $DATABASE_URL -c "
|
|
||||||
SELECT schemaname, tablename, indexname
|
|
||||||
FROM pg_indexes
|
|
||||||
WHERE tablename IN ('Session', 'AIProcessingRequest', 'AIBatchRequest')
|
|
||||||
ORDER BY tablename, indexname;
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Environment Configuration Issues
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Validate environment variables
|
|
||||||
pnpm migration:validate-env
|
|
||||||
|
|
||||||
# Check for missing variables
|
|
||||||
env | grep -E "(TRPC|BATCH|RATE_LIMIT)" | sort
|
|
||||||
|
|
||||||
# Verify environment file syntax
|
|
||||||
node -e "require('dotenv').config({path: '.env.local'}); console.log('✅ Environment file is valid')"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Getting Help
|
|
||||||
|
|
||||||
#### Support Channels
|
|
||||||
|
|
||||||
1. **Check Migration Logs**: Review `logs/migration.log` for detailed error information
|
|
||||||
2. **Run Diagnostics**: Use the built-in health check and validation tools
|
|
||||||
3. **Documentation**: Refer to component-specific documentation in `docs/`
|
|
||||||
4. **Emergency Rollback**: Use rollback procedures if issues persist
|
|
||||||
|
|
||||||
#### Useful Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Get detailed system information
|
|
||||||
pnpm migration:health-report
|
|
||||||
|
|
||||||
# Check all migration script availability
|
|
||||||
ls -la scripts/migration/
|
|
||||||
|
|
||||||
# Verify package integrity
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
# Test database connectivity
|
|
||||||
pnpm prisma db pull --print
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📝 Post-Migration Tasks
|
|
||||||
|
|
||||||
### Immediate Tasks (First 24 Hours)
|
|
||||||
|
|
||||||
- [ ] Monitor application logs for errors
|
|
||||||
- [ ] Verify all tRPC endpoints are responding correctly
|
|
||||||
- [ ] Check batch processing job completion
|
|
||||||
- [ ] Validate AI cost reduction in OpenAI dashboard
|
|
||||||
- [ ] Run full test suite to ensure no regressions
|
|
||||||
- [ ] Update documentation and team knowledge
|
|
||||||
|
|
||||||
### Medium-term Tasks (First Week)
|
|
||||||
|
|
||||||
- [ ] Optimize batch processing parameters based on usage
|
|
||||||
- [ ] Fine-tune rate limiting settings
|
|
||||||
- [ ] Set up monitoring alerts for new components
|
|
||||||
- [ ] Train team on new tRPC APIs
|
|
||||||
- [ ] Plan gradual feature adoption
|
|
||||||
|
|
||||||
### Long-term Tasks (First Month)
|
|
||||||
|
|
||||||
- [ ] Analyze cost savings and performance improvements
|
|
||||||
- [ ] Consider additional tRPC endpoint implementations
|
|
||||||
- [ ] Optimize batch processing schedules
|
|
||||||
- [ ] Review and adjust security settings
|
|
||||||
- [ ] Plan next phase improvements
|
|
||||||
|
|
||||||
## 🔒 Security Considerations
|
|
||||||
|
|
||||||
### New Security Features
|
|
||||||
|
|
||||||
- **Enhanced Rate Limiting**: Applied to all authentication endpoints
|
|
||||||
- **Input Validation**: Comprehensive Zod schemas prevent injection attacks
|
|
||||||
- **Secure Headers**: HTTPS enforcement in production
|
|
||||||
- **Token Security**: JWT with proper expiration and rotation
|
|
||||||
|
|
||||||
### Security Checklist
|
|
||||||
|
|
||||||
- [ ] Verify rate limiting is working correctly
|
|
||||||
- [ ] Test input validation on all forms
|
|
||||||
- [ ] Ensure HTTPS is enforced in production
|
|
||||||
- [ ] Validate JWT token handling
|
|
||||||
- [ ] Check for proper error message sanitization
|
|
||||||
- [ ] Verify OpenAI API key is not exposed in logs
|
|
||||||
|
|
||||||
## 📈 Expected Improvements
|
|
||||||
|
|
||||||
### Performance Improvements
|
|
||||||
|
|
||||||
- **50% reduction** in OpenAI API costs through batch processing
|
|
||||||
- **30% improvement** in API response times with tRPC
|
|
||||||
- **25% reduction** in database query time with new indexes
|
|
||||||
- **Enhanced scalability** for processing larger session volumes
|
|
||||||
|
|
||||||
### Developer Experience
|
|
||||||
|
|
||||||
- **Type Safety**: End-to-end TypeScript types from client to server
|
|
||||||
- **Better APIs**: Self-documenting tRPC procedures
|
|
||||||
- **Improved Testing**: More reliable test suite with better validation
|
|
||||||
- **Enhanced Monitoring**: Detailed health checks and reporting
|
|
||||||
|
|
||||||
### Operational Benefits
|
|
||||||
|
|
||||||
- **Automated Batch Processing**: Reduced manual intervention
|
|
||||||
- **Better Error Handling**: Comprehensive retry mechanisms
|
|
||||||
- **Improved Monitoring**: Real-time health status and metrics
|
|
||||||
- **Simplified Deployment**: Automated migration and rollback procedures
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📞 Support
|
|
||||||
|
|
||||||
For issues during migration:
|
|
||||||
|
|
||||||
1. **Check the logs**: `logs/migration.log`
|
|
||||||
2. **Run health checks**: `pnpm migration:health-check`
|
|
||||||
3. **Review troubleshooting section** above
|
|
||||||
4. **Use rollback if needed**: `pnpm migration:rollback`
|
|
||||||
|
|
||||||
**Migration completed successfully? 🎉**
|
|
||||||
|
|
||||||
Your LiveDash Node application is now running version 2.0.0 with tRPC and Batch API integration!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_Migration Guide v2.0.0 - Updated January 2025_
|
|
||||||
159
README.md
159
README.md
@ -1,6 +1,6 @@
|
|||||||
# LiveDash-Node
|
# LiveDash-Node
|
||||||
|
|
||||||
A comprehensive real-time analytics dashboard for monitoring user sessions with AI-powered analysis, enterprise-grade security features, and advanced processing pipeline.
|
A real-time analytics dashboard for monitoring user sessions and interactions with interactive data visualizations and detailed metrics.
|
||||||
|
|
||||||
.*%22&replace=%24%3Cversion%3E&logo=nextdotjs&label=Nextjs&color=%23000000>)
|
.*%22&replace=%24%3Cversion%3E&logo=nextdotjs&label=Nextjs&color=%23000000>)
|
||||||
.*%22&replace=%24%3Cversion%3E&logo=react&label=React&color=%2361DAFB>)
|
.*%22&replace=%24%3Cversion%3E&logo=react&label=React&color=%2361DAFB>)
|
||||||
@ -10,48 +10,28 @@ A comprehensive real-time analytics dashboard for monitoring user sessions with
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Core Analytics
|
|
||||||
|
|
||||||
- **Real-time Session Monitoring**: Track and analyze user sessions as they happen
|
- **Real-time Session Monitoring**: Track and analyze user sessions as they happen
|
||||||
- **Interactive Visualizations**: Geographic maps, response time distributions, and advanced charts
|
- **Interactive Visualizations**: Geographic maps, response time distributions, and more
|
||||||
- **AI-Powered Analysis**: OpenAI integration with 50% cost reduction through batch processing
|
- **Advanced Analytics**: Detailed metrics and insights about user behavior
|
||||||
- **Advanced Analytics**: Detailed metrics and insights about user behavior patterns
|
- **User Management**: Secure authentication with role-based access control
|
||||||
- **Session Details**: In-depth analysis of individual user sessions with transcript parsing
|
- **Customizable Dashboard**: Filter and sort data based on your specific needs
|
||||||
|
- **Session Details**: In-depth analysis of individual user sessions
|
||||||
### Security & Admin Features
|
|
||||||
|
|
||||||
- **Enterprise Security**: Multi-layer security with CSRF protection, CSP, and rate limiting
|
|
||||||
- **Security Monitoring**: Real-time threat detection and alerting system
|
|
||||||
- **Audit Logging**: Comprehensive security audit trails with retention management
|
|
||||||
- **Admin Dashboard**: Advanced administration tools for user and system management
|
|
||||||
- **Geographic Threat Detection**: IP-based threat analysis and anomaly detection
|
|
||||||
|
|
||||||
### Platform Management
|
|
||||||
|
|
||||||
- **Multi-tenant Architecture**: Company-based data isolation and management
|
|
||||||
- **User Management**: Role-based access control with platform admin capabilities
|
|
||||||
- **Batch Processing**: Optimized AI processing pipeline with automated scheduling
|
|
||||||
- **Data Export**: CSV/JSON export capabilities for analytics and audit data
|
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Frontend**: React 19, Next.js 15, TailwindCSS 4
|
- **Frontend**: React 19, Next.js 15, TailwindCSS 4
|
||||||
- **Backend**: Next.js API Routes, tRPC, Custom Node.js server
|
- **Backend**: Next.js API Routes, Node.js
|
||||||
- **Database**: PostgreSQL with Prisma ORM and connection pooling
|
- **Database**: Prisma ORM with SQLite (default), compatible with PostgreSQL
|
||||||
- **Authentication**: NextAuth.js with enhanced security features
|
- **Authentication**: NextAuth.js
|
||||||
- **Security**: CSRF protection, CSP with nonce-based scripts, comprehensive rate limiting
|
- **Visualization**: Chart.js, D3.js, React Leaflet
|
||||||
- **AI Processing**: OpenAI API with batch processing for cost optimization
|
- **Data Processing**: Node-cron for scheduled tasks
|
||||||
- **Visualization**: D3.js, React Leaflet, Recharts, custom chart components
|
|
||||||
- **Monitoring**: Real-time security monitoring, audit logging, threat detection
|
|
||||||
- **Data Processing**: Node-cron schedulers for automated batch processing and AI analysis
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js 18+ (LTS version recommended)
|
- Node.js (LTS version recommended)
|
||||||
- pnpm (recommended package manager)
|
- pnpm (recommended package manager)
|
||||||
- PostgreSQL 13+ database
|
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
@ -68,119 +48,50 @@ cd livedash-node
|
|||||||
pnpm install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Set up environment variables:
|
3. Set up the database:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env.local
|
pnpm run prisma:generate
|
||||||
# Edit .env.local with your configuration
|
pnpm run prisma:migrate
|
||||||
|
pnpm run prisma:seed
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Set up the database:
|
4. Start the development server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm prisma:generate
|
pnpm run dev
|
||||||
pnpm prisma:migrate
|
|
||||||
pnpm prisma:seed
|
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Start the development server:
|
5. Open your browser and navigate to <http://localhost:3000>
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
6. Open your browser and navigate to <http://localhost:3000>
|
|
||||||
|
|
||||||
## Environment Setup
|
## Environment Setup
|
||||||
|
|
||||||
Create a `.env.local` file in the root directory with the following variables:
|
Create a `.env` file in the root directory with the following variables:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# Database Configuration
|
DATABASE_URL="file:./dev.db"
|
||||||
DATABASE_URL="postgresql://user:password@localhost:5432/livedash"
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
DATABASE_URL_DIRECT="postgresql://user:password@localhost:5432/livedash"
|
NEXTAUTH_SECRET=your-secret-here
|
||||||
|
|
||||||
# Authentication
|
|
||||||
NEXTAUTH_URL="http://localhost:3000"
|
|
||||||
NEXTAUTH_SECRET="your-nextauth-secret-key"
|
|
||||||
|
|
||||||
# AI Processing (optional - for AI features)
|
|
||||||
OPENAI_API_KEY="your-openai-api-key"
|
|
||||||
|
|
||||||
# Security Configuration
|
|
||||||
CSRF_SECRET="your-csrf-secret-key"
|
|
||||||
|
|
||||||
# Scheduler Configuration (optional)
|
|
||||||
SCHEDULER_ENABLED="true"
|
|
||||||
CSV_IMPORT_INTERVAL="*/10 * * * *"
|
|
||||||
IMPORT_PROCESSING_INTERVAL="*/5 * * * *"
|
|
||||||
SESSION_PROCESSING_INTERVAL="*/2 * * * *"
|
|
||||||
BATCH_PROCESSING_INTERVAL="*/1 * * * *"
|
|
||||||
|
|
||||||
# Batch Processing (optional)
|
|
||||||
BATCH_PROCESSING_ENABLED="true"
|
|
||||||
BATCH_CREATE_INTERVAL="*/5 * * * *"
|
|
||||||
BATCH_STATUS_CHECK_INTERVAL="*/2 * * * *"
|
|
||||||
BATCH_RESULT_PROCESSING_INTERVAL="*/1 * * * *"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
- `app/`: Next.js App Router pages and API routes
|
- `app/`: Next.js App Router components and pages
|
||||||
- `api/`: API endpoints including admin, security, and tRPC routes
|
|
||||||
- `dashboard/`: Main analytics dashboard pages
|
|
||||||
- `platform/`: Platform administration interface
|
|
||||||
- `components/`: Reusable React components
|
- `components/`: Reusable React components
|
||||||
- `admin/`: Administrative dashboard components
|
- `lib/`: Utility functions and shared code
|
||||||
- `security/`: Security monitoring UI components
|
- `pages/`: API routes and server-side code
|
||||||
- `forms/`: CSRF-protected forms and form utilities
|
- `prisma/`: Database schema and migrations
|
||||||
- `providers/`: Context providers (CSRF, tRPC, themes)
|
- `public/`: Static assets
|
||||||
- `lib/`: Core utilities and business logic
|
- `docs/`: Project documentation
|
||||||
- Security modules (CSRF, CSP, rate limiting, audit logging)
|
|
||||||
- Processing pipelines (batch processing, AI analysis)
|
|
||||||
- Database utilities and authentication
|
|
||||||
- `server/`: tRPC server configuration and routers
|
|
||||||
- `prisma/`: Database schema, migrations, and seed scripts
|
|
||||||
- `tests/`: Comprehensive test suite (unit, integration, E2E)
|
|
||||||
- `docs/`: Detailed project documentation
|
|
||||||
- `scripts/`: Migration and utility scripts
|
|
||||||
|
|
||||||
## Available Scripts
|
## Available Scripts
|
||||||
|
|
||||||
### Development
|
- `pnpm run dev`: Start the development server
|
||||||
|
- `pnpm run build`: Build the application for production
|
||||||
- `pnpm dev`: Start development server with all features
|
- `pnpm run start`: Run the production build
|
||||||
- `pnpm dev:next-only`: Start Next.js only (no background schedulers)
|
- `pnpm run lint`: Run ESLint
|
||||||
- `pnpm build`: Build the application for production
|
- `pnpm run format`: Format code with Prettier
|
||||||
- `pnpm start`: Run the production build
|
- `pnpm run prisma:studio`: Open Prisma Studio to view database
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
|
|
||||||
- `pnpm lint`: Run ESLint
|
|
||||||
- `pnpm lint:fix`: Fix ESLint issues automatically
|
|
||||||
- `pnpm format`: Format code with Prettier
|
|
||||||
- `pnpm format:check`: Check code formatting
|
|
||||||
|
|
||||||
### Database
|
|
||||||
|
|
||||||
- `pnpm prisma:studio`: Open Prisma Studio to view database
|
|
||||||
- `pnpm prisma:migrate`: Run database migrations
|
|
||||||
- `pnpm prisma:generate`: Generate Prisma client
|
|
||||||
- `pnpm prisma:seed`: Seed database with test data
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
- `pnpm test`: Run all tests (Vitest + Playwright)
|
|
||||||
- `pnpm test:vitest`: Run unit and integration tests
|
|
||||||
- `pnpm test:coverage`: Run tests with coverage reports
|
|
||||||
- `pnpm test:security`: Run security-specific tests
|
|
||||||
- `pnpm test:csp`: Test CSP implementation
|
|
||||||
|
|
||||||
### Security & Migration
|
|
||||||
|
|
||||||
- `pnpm migration:backup`: Create database backup
|
|
||||||
- `pnpm migration:health-check`: Run system health checks
|
|
||||||
- `pnpm test:security-headers`: Test HTTP security headers
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
385
TODO
385
TODO
@ -3,268 +3,245 @@
|
|||||||
## 🚀 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
|
||||||
|
|
||||||
- [x] **Create Platform Management Layer** (80% Complete)
|
- [ ] **Extract Data Ingestion Service (Golang)**
|
||||||
- [x] Add Organization/PlatformUser models to Prisma schema
|
- [ ] Create new Golang service for CSV processing
|
||||||
- [x] Implement super-admin authentication system (/platform/login)
|
- [ ] Implement concurrent CSV downloading & parsing
|
||||||
- [x] Build platform dashboard for Notso AI team (/platform/dashboard)
|
- [ ] Add transcript fetching with rate limiting
|
||||||
- [x] Add company creation workflows
|
- [ ] Set up Redis message queues (BullMQ/RabbitMQ)
|
||||||
- [x] Add basic platform API endpoints with tests
|
- [ ] Migrate lib/scheduler.ts and lib/csvFetcher.ts logic
|
||||||
- [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)**
|
- [ ] **Implement tRPC Infrastructure**
|
||||||
- [ ] Create new Golang service for CSV processing
|
- [ ] Add tRPC to existing Next.js app
|
||||||
- [ ] Implement concurrent CSV downloading & parsing
|
- [ ] Create type-safe API procedures for frontend
|
||||||
- [ ] Add transcript fetching with rate limiting
|
- [ ] Implement inter-service communication protocols
|
||||||
- [ ] Set up Redis message queues (BullMQ/RabbitMQ)
|
- [ ] Add proper error handling and validation
|
||||||
- [ ] 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
|
||||||
|
|
||||||
- [ ] **Extract AI Processing Service**
|
- [ ] **GDPR & ISO 27001 Compliance Foundation**
|
||||||
- [ ] Separate lib/processingScheduler.ts into standalone service
|
- [ ] Implement data isolation boundaries between services
|
||||||
- [ ] Implement async AI processing with queues
|
- [ ] Add audit logging for all data processing
|
||||||
- [ ] Add per-company AI cost tracking and quotas
|
- [ ] Create data retention policies per company
|
||||||
- [ ] Create AI model management per company
|
- [ ] Add consent management for data processing
|
||||||
- [ ] Add retry logic and failure handling
|
- [ ] Implement data export/deletion workflows (Right to be Forgotten)
|
||||||
|
|
||||||
- [ ] **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
|
||||||
|
|
||||||
- [ ] **Monitoring & Observability**
|
- [ ] **Database Optimization**
|
||||||
- [ ] Add distributed tracing across services (Jaeger/Zipkin)
|
- [ ] Implement connection pooling per service
|
||||||
- [ ] Implement health checks for all services
|
- [ ] Add read replicas for dashboard queries
|
||||||
- [ ] Create cross-service metrics dashboard
|
- [ ] Create database sharding strategy for multi-tenancy
|
||||||
- [ ] Add alerting for service failures and SLA breaches
|
- [ ] Optimize queries with proper indexing
|
||||||
- [ ] 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
|
||||||
|
|
||||||
- [ ] **Fix Environment Variable Testing**
|
- [ ] **Enforce Zero Accessibility Violations**
|
||||||
- [ ] Replace process.env access with proper environment mocking in tests
|
- [ ] Set Playwright accessibility tests to fail on any violations (not just warn)
|
||||||
- [ ] Update existing tests to avoid direct environment variable dependencies
|
- [ ] Add accessibility regression tests for all major components
|
||||||
- [ ] Add environment validation tests for critical config values
|
- [ ] Implement accessibility checklist for new components
|
||||||
|
|
||||||
- [ ] **Enforce Zero Accessibility Violations**
|
- [ ] **Improve Error Handling with Custom Error Classes**
|
||||||
- [ ] Set Playwright accessibility tests to fail on any violations (not just warn)
|
- [ ] Create custom error classes for different error types (ValidationError, AuthError, etc.)
|
||||||
- [ ] Add accessibility regression tests for all major components
|
- [ ] Replace generic Error throws with specific error classes
|
||||||
- [ ] Implement accessibility checklist for new components
|
- [ ] Add proper error logging and monitoring integration
|
||||||
|
|
||||||
- [ ] **Improve Error Handling with Custom Error Classes**
|
- [ ] **Refactor Long className Strings**
|
||||||
- [ ] Create custom error classes for different error types (ValidationError, AuthError, etc.)
|
- [ ] Extract complex className combinations into utility functions
|
||||||
- [ ] Replace generic Error throws with specific error classes
|
- [ ] Consider using cn() utility from utils for cleaner class composition
|
||||||
- [ ] Add proper error logging and monitoring integration
|
- [ ] Break down overly complex className props into semantic components
|
||||||
|
|
||||||
- [ ] **Refactor Long className Strings**
|
- [ ] **Add Dark Mode Accessibility Tests**
|
||||||
- [ ] Extract complex className combinations into utility functions
|
- [ ] Create comprehensive test suite for dark mode color contrast
|
||||||
- [ ] Consider using cn() utility from utils for cleaner class composition
|
- [ ] Verify focus indicators work properly in both light and dark modes
|
||||||
- [ ] Break down overly complex className props into semantic components
|
- [ ] Test screen reader compatibility with theme switching
|
||||||
|
|
||||||
- [ ] **Add Dark Mode Accessibility Tests**
|
- [ ] **Fix Platform Login Authentication Issue**
|
||||||
- [ ] Create comprehensive test suite for dark mode color contrast
|
- [ ] NEXTAUTH_SECRET was using placeholder value (FIXED)
|
||||||
- [ ] Verify focus indicators work properly in both light and dark modes
|
- [ ] Investigate platform cookie path restrictions in /platform auth
|
||||||
- [ ] Test screen reader compatibility with theme switching
|
- [ ] Test platform login flow end-to-end after fixes
|
||||||
|
|
||||||
- [ ] **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)
|
||||||
- [ ] Add comprehensive test coverage for API endpoints (currently minimal)
|
- [ ] Implement integration tests for the data processing pipeline
|
||||||
- [ ] Implement integration tests for the data processing pipeline
|
- [ ] Add unit tests for validation schemas and authentication logic
|
||||||
- [ ] Add unit tests for validation schemas and authentication logic
|
- [ ] Create E2E tests for critical user flows (registration, login, dashboard)
|
||||||
- [ ] Create E2E tests for critical user flows (registration, login, dashboard)
|
|
||||||
|
|
||||||
### Error Handling & Monitoring
|
### Error Handling & Monitoring
|
||||||
|
- [ ] Implement global error boundaries for React components
|
||||||
- [ ] Implement global error boundaries for React components
|
- [ ] Add structured logging with correlation IDs for request tracing
|
||||||
- [ ] Add structured logging with correlation IDs for request tracing
|
- [ ] Set up error monitoring and alerting (e.g., Sentry integration)
|
||||||
- [ ] Set up error monitoring and alerting (e.g., Sentry integration)
|
- [ ] Add proper error pages for 404, 500, and other HTTP status codes
|
||||||
- [ ] Add proper error pages for 404, 500, and other HTTP status codes
|
|
||||||
|
|
||||||
### Performance Optimization
|
### Performance Optimization
|
||||||
|
- [ ] Implement database query optimization and indexing strategy
|
||||||
- [ ] Implement database query optimization and indexing strategy
|
- [ ] Add caching layer for frequently accessed data (Redis/in-memory)
|
||||||
- [ ] Add caching layer for frequently accessed data (Redis/in-memory)
|
- [ ] Optimize React components with proper memoization
|
||||||
- [ ] Optimize React components with proper memoization
|
- [ ] Implement lazy loading for dashboard components and charts
|
||||||
- [ ] Implement lazy loading for dashboard components and charts
|
|
||||||
|
|
||||||
## Medium Priority
|
## Medium Priority
|
||||||
|
|
||||||
### Security Enhancements
|
### Security Enhancements
|
||||||
|
- [ ] Add CSRF protection for state-changing operations
|
||||||
- [ ] Add CSRF protection for state-changing operations
|
- [ ] Implement session timeout and refresh token mechanism
|
||||||
- [ ] Implement session timeout and refresh token mechanism
|
- [ ] Add API rate limiting with Redis-backed storage (replace in-memory)
|
||||||
- [ ] Add API rate limiting with Redis-backed storage (replace in-memory)
|
- [ ] Implement role-based access control (RBAC) for different user types
|
||||||
- [ ] Implement role-based access control (RBAC) for different user types
|
- [ ] Add audit logging for sensitive operations
|
||||||
- [ ] Add audit logging for sensitive operations
|
|
||||||
|
|
||||||
### Code Quality & Maintenance
|
### Code Quality & Maintenance
|
||||||
|
- [ ] Resolve remaining ESLint warnings and type issues
|
||||||
- [ ] Resolve remaining ESLint warnings and type issues
|
- [ ] Standardize chart library usage (currently mixing Chart.js and other libraries)
|
||||||
- [ ] Standardize chart library usage (currently mixing Chart.js and other libraries)
|
- [ ] Add proper TypeScript strict mode configuration
|
||||||
- [ ] Add proper TypeScript strict mode configuration
|
- [ ] Implement consistent API response formats across all endpoints
|
||||||
- [ ] Implement consistent API response formats across all endpoints
|
|
||||||
|
|
||||||
### Database & Schema
|
### Database & Schema
|
||||||
|
- [ ] Add database connection pooling configuration
|
||||||
- [ ] Add database connection pooling configuration
|
- [ ] Implement proper database migrations for production deployment
|
||||||
- [ ] Implement proper database migrations for production deployment
|
- [ ] Add data retention policies for session data
|
||||||
- [ ] Add data retention policies for session data
|
- [ ] Consider database partitioning for large-scale data
|
||||||
- [ ] Consider database partitioning for large-scale data
|
|
||||||
|
|
||||||
### User Experience
|
### User Experience
|
||||||
|
- [ ] Add loading states and skeleton components throughout the application
|
||||||
- [ ] Add loading states and skeleton components throughout the application
|
- [ ] Implement proper form validation feedback and error messages
|
||||||
- [ ] Implement proper form validation feedback and error messages
|
- [ ] Add pagination for large data sets in dashboard tables
|
||||||
- [ ] Add pagination for large data sets in dashboard tables
|
- [ ] Implement real-time notifications for processing status updates
|
||||||
- [ ] Implement real-time notifications for processing status updates
|
|
||||||
|
|
||||||
## Low Priority
|
## Low Priority
|
||||||
|
|
||||||
### Documentation & Development
|
### Documentation & Development
|
||||||
|
- [ ] Add API documentation (OpenAPI/Swagger)
|
||||||
- [ ] Add API documentation (OpenAPI/Swagger)
|
- [ ] Create deployment guides for different environments
|
||||||
- [ ] Create deployment guides for different environments
|
- [ ] Add contributing guidelines and code review checklist
|
||||||
- [ ] Add contributing guidelines and code review checklist
|
- [ ] Implement development environment setup automation
|
||||||
- [ ] Implement development environment setup automation
|
|
||||||
|
|
||||||
### Feature Enhancements
|
### Feature Enhancements
|
||||||
|
- [ ] Add data export functionality (CSV, PDF reports)
|
||||||
- [ ] Add data export functionality (CSV, PDF reports)
|
- [ ] Implement dashboard customization and user preferences
|
||||||
- [ ] Implement dashboard customization and user preferences
|
- [ ] Add multi-language support (i18n)
|
||||||
- [ ] Add multi-language support (i18n)
|
- [ ] Create admin panel for system configuration
|
||||||
- [ ] Create admin panel for system configuration
|
|
||||||
|
|
||||||
### Infrastructure & DevOps
|
### Infrastructure & DevOps
|
||||||
|
- [ ] Add Docker configuration for containerized deployment
|
||||||
- [ ] Add Docker configuration for containerized deployment
|
- [ ] Implement CI/CD pipeline with automated testing
|
||||||
- [ ] Implement CI/CD pipeline with automated testing
|
- [ ] Add environment-specific configuration management
|
||||||
- [ ] Add environment-specific configuration management
|
- [ ] Set up monitoring and health check endpoints
|
||||||
- [ ] Set up monitoring and health check endpoints
|
|
||||||
|
|
||||||
### Analytics & Insights
|
### Analytics & Insights
|
||||||
|
- [ ] Add more detailed analytics and reporting features
|
||||||
- [ ] Add more detailed analytics and reporting features
|
- [ ] Implement A/B testing framework for UI improvements
|
||||||
- [ ] Implement A/B testing framework for UI improvements
|
- [ ] Add user behavior tracking and analytics
|
||||||
- [ ] Add user behavior tracking and analytics
|
- [ ] Create automated report generation and scheduling
|
||||||
- [ ] Create automated report generation and scheduling
|
|
||||||
|
|
||||||
## Completed ✅
|
## Completed ✅
|
||||||
|
- [x] Fix duplicate MetricCard components
|
||||||
- [x] Fix duplicate MetricCard components
|
- [x] Add input validation schema with Zod
|
||||||
- [x] Add input validation schema with Zod
|
- [x] Strengthen password requirements (12+ chars, complexity)
|
||||||
- [x] Strengthen password requirements (12+ chars, complexity)
|
- [x] Fix schema drift - create missing migrations
|
||||||
- [x] Fix schema drift - create missing migrations
|
- [x] Add rate limiting to authentication endpoints
|
||||||
- [x] Add rate limiting to authentication endpoints
|
- [x] Update README.md to use pnpm instead of npm
|
||||||
- [x] Update README.md to use pnpm instead of npm
|
- [x] Implement platform authentication and basic dashboard
|
||||||
- [x] Implement platform authentication and basic dashboard
|
- [x] Add platform API endpoints for company management
|
||||||
- [x] Add platform API endpoints for company management
|
- [x] Write tests for platform features (auth, dashboard, API)
|
||||||
- [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)
|
||||||
|
|
||||||
- User Authentication (regular users)
|
### ❌ Features WITHOUT Tests (Critical Gaps):
|
||||||
- User Management UI & API
|
- **Data Processing Pipeline** (0 tests)
|
||||||
- Basic database connectivity
|
- CSV import scheduler
|
||||||
- Transcript Fetcher
|
- Import processor
|
||||||
- Input validation
|
- Processing scheduler
|
||||||
- Environment configuration
|
- AI processing functionality
|
||||||
- Format enums
|
- Transcript parser
|
||||||
- Accessibility features
|
- **Most API Endpoints** (0 tests)
|
||||||
- Keyboard navigation
|
- Dashboard endpoints
|
||||||
- Platform authentication (NEW)
|
- Session management
|
||||||
- Platform dashboard (NEW)
|
- Admin endpoints
|
||||||
- Platform API endpoints (NEW)
|
- Password reset flow
|
||||||
|
- **Custom Server** (0 tests)
|
||||||
### ❌ Features WITHOUT Tests (Critical Gaps)
|
- **Dashboard Features** (0 tests)
|
||||||
|
- Charts and visualizations
|
||||||
- **Data Processing Pipeline** (0 tests)
|
- Session details
|
||||||
- CSV import scheduler
|
- Company settings
|
||||||
- Import processor
|
- **AI Integration** (0 tests)
|
||||||
- Processing scheduler
|
- **Real-time Features** (0 tests)
|
||||||
- AI processing functionality
|
- **E2E Tests** (only examples exist)
|
||||||
- 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)
|
||||||
- **Dashboard Service**: Next.js + tRPC (existing, proven stack)
|
- **Data Ingestion Service**: Golang (high-performance CSV processing, concurrency)
|
||||||
- **Data Ingestion Service**: Golang (high-performance CSV processing, concurrency)
|
- **AI Processing Service**: Node.js/Python (existing AI integrations, async processing)
|
||||||
- **AI Processing Service**: Node.js/Python (existing AI integrations, async processing)
|
- **Message Queue**: Redis + BullMQ (Node.js ecosystem compatibility)
|
||||||
- **Message Queue**: Redis + BullMQ (Node.js ecosystem compatibility)
|
- **Database**: PostgreSQL (existing, excellent for multi-tenancy)
|
||||||
- **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
|
||||||
- **Performance**: 10-100x faster CSV processing than Node.js
|
- **Concurrency**: Native goroutines for parallel transcript fetching
|
||||||
- **Concurrency**: Native goroutines for parallel transcript fetching
|
- **Memory Efficiency**: Lower memory footprint for large CSV files
|
||||||
- **Memory Efficiency**: Lower memory footprint for large CSV files
|
- **Deployment**: Single binary deployment, excellent for containers
|
||||||
- **Deployment**: Single binary deployment, excellent for containers
|
- **Team Growth**: Easy to hire Golang developers for data processing
|
||||||
- **Team Growth**: Easy to hire Golang developers for data processing
|
|
||||||
|
|
||||||
### Migration Strategy
|
### Migration Strategy
|
||||||
|
1. **Keep existing working system** while building new services
|
||||||
1. **Keep existing working system** while building new services
|
2. **Feature flagging** to gradually migrate companies to new processing
|
||||||
2. **Feature flagging** to gradually migrate companies to new processing
|
3. **Dual-write approach** during transition period
|
||||||
3. **Dual-write approach** during transition period
|
4. **Zero-downtime migration** with careful rollback plans
|
||||||
4. **Zero-downtime migration** with careful rollback plans
|
|
||||||
|
|
||||||
### Compliance Benefits
|
### Compliance Benefits
|
||||||
|
- **Data Isolation**: Each service has limited database access
|
||||||
- **Data Isolation**: Each service has limited database access
|
- **Audit Trail**: All inter-service communication logged
|
||||||
- **Audit Trail**: All inter-service communication logged
|
- **Data Retention**: Automated per-company data lifecycle
|
||||||
- **Data Retention**: Automated per-company data lifecycle
|
- **Security Boundaries**: DMZ for ingestion, private network for processing
|
||||||
- **Security Boundaries**: DMZ for ingestion, private network for processing
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
- **CRITICAL**: Architectural refactoring must be priority #1 for scalability
|
||||||
- **CRITICAL**: Architectural refactoring must be priority #1 for scalability
|
- **Platform Management**: Notso AI needs self-service customer onboarding
|
||||||
- **Platform Management**: Notso AI needs self-service customer onboarding
|
- **Compliance First**: GDPR/ISO 27001 requirements drive service boundaries
|
||||||
- **Compliance First**: GDPR/ISO 27001 requirements drive service boundaries
|
- **Performance**: Current monolith blocks on CSV/AI processing
|
||||||
- **Performance**: Current monolith blocks on CSV/AI processing
|
- **Technology Evolution**: Golang for data processing, tRPC for type safety
|
||||||
- **Technology Evolution**: Golang for data processing, tRPC for type safety
|
|
||||||
@ -1,222 +0,0 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { getServerSession } from "next-auth/next";
|
|
||||||
import {
|
|
||||||
AuditLogRetentionManager,
|
|
||||||
DEFAULT_RETENTION_POLICIES,
|
|
||||||
executeScheduledRetention,
|
|
||||||
} from "../../../../../lib/auditLogRetention";
|
|
||||||
import { auditLogScheduler } from "../../../../../lib/auditLogScheduler";
|
|
||||||
import { authOptions } from "../../../../../lib/auth";
|
|
||||||
import { extractClientIP } from "../../../../../lib/rateLimiter";
|
|
||||||
import {
|
|
||||||
AuditOutcome,
|
|
||||||
createAuditMetadata,
|
|
||||||
securityAuditLogger,
|
|
||||||
} from "../../../../../lib/securityAuditLogger";
|
|
||||||
|
|
||||||
// GET /api/admin/audit-logs/retention - Get retention statistics and policy status
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const ip = extractClientIP(request);
|
|
||||||
const userAgent = request.headers.get("user-agent") || undefined;
|
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
await securityAuditLogger.logAuthorization(
|
|
||||||
"audit_retention_unauthorized_access",
|
|
||||||
AuditOutcome.BLOCKED,
|
|
||||||
{
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
error: "no_session",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"Unauthorized attempt to access audit retention management"
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "Unauthorized" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only allow ADMIN users to manage audit log retention
|
|
||||||
if (session.user.role !== "ADMIN") {
|
|
||||||
await securityAuditLogger.logAuthorization(
|
|
||||||
"audit_retention_insufficient_permissions",
|
|
||||||
AuditOutcome.BLOCKED,
|
|
||||||
{
|
|
||||||
userId: session.user.id,
|
|
||||||
companyId: session.user.companyId,
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
userRole: session.user.role,
|
|
||||||
requiredRole: "ADMIN",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"Insufficient permissions to access audit retention management"
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "Insufficient permissions" },
|
|
||||||
{ status: 403 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const manager = new AuditLogRetentionManager();
|
|
||||||
|
|
||||||
// Get retention statistics and policy information
|
|
||||||
const [statistics, policyValidation, schedulerStatus] = await Promise.all([
|
|
||||||
manager.getRetentionStatistics(),
|
|
||||||
manager.validateRetentionPolicies(),
|
|
||||||
Promise.resolve(auditLogScheduler.getStatus()),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Log successful retention info access
|
|
||||||
await securityAuditLogger.logDataPrivacy(
|
|
||||||
"audit_retention_info_accessed",
|
|
||||||
AuditOutcome.SUCCESS,
|
|
||||||
{
|
|
||||||
userId: session.user.id,
|
|
||||||
companyId: session.user.companyId,
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
totalLogs: statistics.totalLogs,
|
|
||||||
schedulerRunning: schedulerStatus.isRunning,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"Audit retention information accessed by admin"
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
statistics,
|
|
||||||
policies: DEFAULT_RETENTION_POLICIES,
|
|
||||||
policyValidation,
|
|
||||||
scheduler: schedulerStatus,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching audit retention info:", error);
|
|
||||||
|
|
||||||
await securityAuditLogger.logDataPrivacy(
|
|
||||||
"audit_retention_info_error",
|
|
||||||
AuditOutcome.FAILURE,
|
|
||||||
{
|
|
||||||
userId: session?.user?.id,
|
|
||||||
companyId: session?.user?.companyId,
|
|
||||||
ipAddress: extractClientIP(request),
|
|
||||||
userAgent: request.headers.get("user-agent") || undefined,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
error: "server_error",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
`Server error while fetching audit retention info: ${error}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "Internal server error" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/admin/audit-logs/retention - Execute retention policies manually
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const ip = extractClientIP(request);
|
|
||||||
const userAgent = request.headers.get("user-agent") || undefined;
|
|
||||||
|
|
||||||
if (!session?.user || session.user.role !== "ADMIN") {
|
|
||||||
await securityAuditLogger.logAuthorization(
|
|
||||||
"audit_retention_execute_unauthorized",
|
|
||||||
AuditOutcome.BLOCKED,
|
|
||||||
{
|
|
||||||
userId: session?.user?.id,
|
|
||||||
companyId: session?.user?.companyId,
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
error: "insufficient_permissions",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"Unauthorized attempt to execute audit retention"
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "Unauthorized" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { action, isDryRun = true } = body;
|
|
||||||
|
|
||||||
if (action !== "execute") {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "Invalid action. Use 'execute'" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log retention execution attempt
|
|
||||||
await securityAuditLogger.logDataPrivacy(
|
|
||||||
"audit_retention_manual_execution",
|
|
||||||
AuditOutcome.SUCCESS,
|
|
||||||
{
|
|
||||||
userId: session.user.id,
|
|
||||||
companyId: session.user.companyId,
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
isDryRun,
|
|
||||||
triggerType: "manual_admin",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
`Admin manually triggered audit retention (dry run: ${isDryRun})`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Execute retention policies
|
|
||||||
const results = await executeScheduledRetention(isDryRun);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
message: isDryRun
|
|
||||||
? "Dry run completed successfully"
|
|
||||||
: "Retention policies executed successfully",
|
|
||||||
isDryRun,
|
|
||||||
results,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error executing audit retention:", error);
|
|
||||||
|
|
||||||
await securityAuditLogger.logDataPrivacy(
|
|
||||||
"audit_retention_execution_error",
|
|
||||||
AuditOutcome.FAILURE,
|
|
||||||
{
|
|
||||||
userId: session?.user?.id,
|
|
||||||
companyId: session?.user?.companyId,
|
|
||||||
ipAddress: extractClientIP(request),
|
|
||||||
userAgent: request.headers.get("user-agent") || undefined,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
error: "server_error",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
`Server error while executing audit retention: ${error}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "Internal server error" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,244 +0,0 @@
|
|||||||
import type { Prisma } from "@prisma/client";
|
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { getServerSession } from "next-auth/next";
|
|
||||||
import { authOptions } from "../../../../lib/auth";
|
|
||||||
import { prisma } from "../../../../lib/prisma";
|
|
||||||
import { extractClientIP } from "../../../../lib/rateLimiter";
|
|
||||||
import {
|
|
||||||
AuditOutcome,
|
|
||||||
type AuditSeverity,
|
|
||||||
createAuditMetadata,
|
|
||||||
type SecurityEventType,
|
|
||||||
securityAuditLogger,
|
|
||||||
} from "../../../../lib/securityAuditLogger";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates user authorization for audit logs access
|
|
||||||
*/
|
|
||||||
async function validateAuditLogAccess(
|
|
||||||
session: { user?: { id?: string; companyId?: string; role?: string } } | null,
|
|
||||||
ip: string,
|
|
||||||
userAgent?: string
|
|
||||||
) {
|
|
||||||
if (!session?.user) {
|
|
||||||
await securityAuditLogger.logAuthorization(
|
|
||||||
"audit_logs_unauthorized_access",
|
|
||||||
AuditOutcome.BLOCKED,
|
|
||||||
{
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
error: "no_session",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"Unauthorized attempt to access audit logs"
|
|
||||||
);
|
|
||||||
return { valid: false, status: 401, error: "Unauthorized" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session?.user?.role !== "ADMIN") {
|
|
||||||
await securityAuditLogger.logAuthorization(
|
|
||||||
"audit_logs_insufficient_permissions",
|
|
||||||
AuditOutcome.BLOCKED,
|
|
||||||
{
|
|
||||||
userId: session?.user?.id,
|
|
||||||
companyId: session?.user?.companyId,
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
userRole: session?.user?.role,
|
|
||||||
requiredRole: "ADMIN",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"Insufficient permissions to access audit logs"
|
|
||||||
);
|
|
||||||
return { valid: false, status: 403, error: "Insufficient permissions" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses query parameters for audit log filtering
|
|
||||||
*/
|
|
||||||
function parseAuditLogFilters(url: URL) {
|
|
||||||
const page = Number.parseInt(url.searchParams.get("page") || "1");
|
|
||||||
const limit = Math.min(
|
|
||||||
Number.parseInt(url.searchParams.get("limit") || "50"),
|
|
||||||
100
|
|
||||||
);
|
|
||||||
const eventType = url.searchParams.get("eventType");
|
|
||||||
const outcome = url.searchParams.get("outcome");
|
|
||||||
const severity = url.searchParams.get("severity");
|
|
||||||
const userId = url.searchParams.get("userId");
|
|
||||||
const startDate = url.searchParams.get("startDate");
|
|
||||||
const endDate = url.searchParams.get("endDate");
|
|
||||||
|
|
||||||
return {
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
eventType,
|
|
||||||
outcome,
|
|
||||||
severity,
|
|
||||||
userId,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds where clause for audit log filtering
|
|
||||||
*/
|
|
||||||
function buildAuditLogWhereClause(
|
|
||||||
companyId: string,
|
|
||||||
filters: ReturnType<typeof parseAuditLogFilters>
|
|
||||||
): Prisma.SecurityAuditLogWhereInput {
|
|
||||||
const { eventType, outcome, severity, userId, startDate, endDate } = filters;
|
|
||||||
|
|
||||||
const where: Prisma.SecurityAuditLogWhereInput = {
|
|
||||||
companyId, // Only show logs for user's company
|
|
||||||
};
|
|
||||||
|
|
||||||
if (eventType) where.eventType = eventType as SecurityEventType;
|
|
||||||
if (outcome) where.outcome = outcome as AuditOutcome;
|
|
||||||
if (severity) where.severity = severity as AuditSeverity;
|
|
||||||
if (userId) where.userId = userId;
|
|
||||||
|
|
||||||
if (startDate || endDate) {
|
|
||||||
where.timestamp = {};
|
|
||||||
if (startDate) where.timestamp.gte = new Date(startDate);
|
|
||||||
if (endDate) where.timestamp.lte = new Date(endDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
return where;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const ip = extractClientIP(request);
|
|
||||||
const userAgent = request.headers.get("user-agent") || undefined;
|
|
||||||
|
|
||||||
// Validate access authorization
|
|
||||||
const authResult = await validateAuditLogAccess(session, ip, userAgent);
|
|
||||||
if (!authResult.valid) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: authResult.error },
|
|
||||||
{ status: authResult.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const filters = parseAuditLogFilters(url);
|
|
||||||
const {
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
eventType,
|
|
||||||
outcome,
|
|
||||||
severity,
|
|
||||||
userId,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
} = filters;
|
|
||||||
const skip = (page - 1) * limit;
|
|
||||||
|
|
||||||
// Build filter conditions
|
|
||||||
const where = buildAuditLogWhereClause(
|
|
||||||
session?.user?.companyId || "",
|
|
||||||
filters
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get audit logs with pagination
|
|
||||||
const [auditLogs, totalCount] = await Promise.all([
|
|
||||||
prisma.securityAuditLog.findMany({
|
|
||||||
where,
|
|
||||||
skip,
|
|
||||||
take: limit,
|
|
||||||
orderBy: { timestamp: "desc" },
|
|
||||||
include: {
|
|
||||||
user: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
name: true,
|
|
||||||
role: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
platformUser: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
name: true,
|
|
||||||
role: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.securityAuditLog.count({ where }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Log successful audit log access
|
|
||||||
await securityAuditLogger.logDataPrivacy(
|
|
||||||
"audit_logs_accessed",
|
|
||||||
AuditOutcome.SUCCESS,
|
|
||||||
{
|
|
||||||
userId: session?.user?.id,
|
|
||||||
companyId: session?.user?.companyId,
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
filters: {
|
|
||||||
eventType,
|
|
||||||
outcome,
|
|
||||||
severity,
|
|
||||||
userId,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
},
|
|
||||||
recordsReturned: auditLogs.length,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"Audit logs accessed by admin user"
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
auditLogs,
|
|
||||||
pagination: {
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
totalCount,
|
|
||||||
totalPages: Math.ceil(totalCount / limit),
|
|
||||||
hasNext: skip + limit < totalCount,
|
|
||||||
hasPrev: page > 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching audit logs:", error);
|
|
||||||
|
|
||||||
await securityAuditLogger.logDataPrivacy(
|
|
||||||
"audit_logs_server_error",
|
|
||||||
AuditOutcome.FAILURE,
|
|
||||||
{
|
|
||||||
userId: session?.user?.id,
|
|
||||||
companyId: session?.user?.companyId,
|
|
||||||
ipAddress: extractClientIP(request),
|
|
||||||
userAgent: request.headers.get("user-agent") || undefined,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
error: "server_error",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
`Server error while fetching audit logs: ${error}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "Internal server error" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,246 +0,0 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import {
|
|
||||||
type BatchOperation,
|
|
||||||
batchLogger,
|
|
||||||
logBatchMetrics,
|
|
||||||
} from "@/lib/batchLogger";
|
|
||||||
import { getCircuitBreakerStatus } from "@/lib/batchProcessor";
|
|
||||||
import { getBatchSchedulerStatus } from "@/lib/batchProcessorIntegration";
|
|
||||||
|
|
||||||
// Helper function for proper CSV escaping
|
|
||||||
function escapeCSVField(field: string | number | boolean): string {
|
|
||||||
if (typeof field === "number" || typeof field === "boolean") {
|
|
||||||
return String(field);
|
|
||||||
}
|
|
||||||
|
|
||||||
const strField = String(field);
|
|
||||||
|
|
||||||
// If field contains comma, quote, or newline, wrap in quotes and escape internal quotes
|
|
||||||
if (
|
|
||||||
strField.includes(",") ||
|
|
||||||
strField.includes('"') ||
|
|
||||||
strField.includes("\n")
|
|
||||||
) {
|
|
||||||
return `"${strField.replace(/"/g, '""')}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return strField;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/admin/batch-monitoring
|
|
||||||
* Get comprehensive batch processing monitoring data
|
|
||||||
*/
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
if (!session?.user || session.user.role !== "ADMIN") {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const companyId = url.searchParams.get("companyId");
|
|
||||||
const operationParam = url.searchParams.get("operation");
|
|
||||||
const format = url.searchParams.get("format") || "json";
|
|
||||||
|
|
||||||
// Validate operation parameter
|
|
||||||
const isValidBatchOperation = (
|
|
||||||
value: string | null
|
|
||||||
): value is BatchOperation => {
|
|
||||||
return (
|
|
||||||
value !== null &&
|
|
||||||
Object.values(BatchOperation).includes(value as BatchOperation)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (operationParam && !isValidBatchOperation(operationParam)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: "Invalid operation parameter",
|
|
||||||
validOperations: Object.values(BatchOperation),
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const operation = operationParam as BatchOperation | null;
|
|
||||||
|
|
||||||
// Get batch processing metrics
|
|
||||||
const metrics = batchLogger.getMetrics(companyId || undefined);
|
|
||||||
|
|
||||||
// Get scheduler status
|
|
||||||
const schedulerStatus = getBatchSchedulerStatus();
|
|
||||||
|
|
||||||
// Get circuit breaker status
|
|
||||||
const circuitBreakerStatus = getCircuitBreakerStatus();
|
|
||||||
|
|
||||||
// Generate performance metrics for specific operation if requested
|
|
||||||
if (operation) {
|
|
||||||
await logBatchMetrics(operation);
|
|
||||||
}
|
|
||||||
|
|
||||||
const monitoringData = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
metrics,
|
|
||||||
schedulerStatus,
|
|
||||||
circuitBreakerStatus,
|
|
||||||
systemHealth: {
|
|
||||||
schedulerRunning: schedulerStatus.isRunning,
|
|
||||||
circuitBreakersOpen: Object.values(circuitBreakerStatus).some(
|
|
||||||
(cb) => cb.isOpen
|
|
||||||
),
|
|
||||||
pausedDueToErrors: schedulerStatus.isPaused,
|
|
||||||
consecutiveErrors: schedulerStatus.consecutiveErrors,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (
|
|
||||||
format === "csv" &&
|
|
||||||
typeof metrics === "object" &&
|
|
||||||
!Array.isArray(metrics)
|
|
||||||
) {
|
|
||||||
// Convert metrics to CSV format
|
|
||||||
const headers = [
|
|
||||||
"company_id",
|
|
||||||
"operation_start_time",
|
|
||||||
"request_count",
|
|
||||||
"success_count",
|
|
||||||
"failure_count",
|
|
||||||
"retry_count",
|
|
||||||
"total_cost",
|
|
||||||
"average_latency",
|
|
||||||
"circuit_breaker_trips",
|
|
||||||
].join(",");
|
|
||||||
|
|
||||||
const rows = Object.entries(metrics).map(([companyId, metric]) =>
|
|
||||||
[
|
|
||||||
escapeCSVField(companyId),
|
|
||||||
escapeCSVField(new Date(metric.operationStartTime).toISOString()),
|
|
||||||
escapeCSVField(metric.requestCount),
|
|
||||||
escapeCSVField(metric.successCount),
|
|
||||||
escapeCSVField(metric.failureCount),
|
|
||||||
escapeCSVField(metric.retryCount),
|
|
||||||
escapeCSVField(metric.totalCost.toFixed(4)),
|
|
||||||
escapeCSVField(metric.averageLatency.toFixed(2)),
|
|
||||||
escapeCSVField(metric.circuitBreakerTrips),
|
|
||||||
].join(",")
|
|
||||||
);
|
|
||||||
|
|
||||||
return new NextResponse([headers, ...rows].join("\n"), {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/csv",
|
|
||||||
"Content-Disposition": `attachment; filename="batch-monitoring-${Date.now()}.csv"`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(monitoringData);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Batch monitoring API error:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to fetch batch monitoring data" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/admin/batch-monitoring/export
|
|
||||||
* Export batch processing logs
|
|
||||||
*/
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
if (!session?.user || session.user.role !== "ADMIN") {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { startDate, endDate, format = "json" } = body;
|
|
||||||
|
|
||||||
if (!startDate || !endDate) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Start date and end date are required" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeRange = {
|
|
||||||
start: new Date(startDate),
|
|
||||||
end: new Date(endDate),
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportDataJson = batchLogger.exportLogs(timeRange);
|
|
||||||
|
|
||||||
if (format === "csv") {
|
|
||||||
// Convert JSON to CSV format
|
|
||||||
const data = JSON.parse(exportDataJson);
|
|
||||||
|
|
||||||
// Flatten the data structure for CSV
|
|
||||||
const csvRows: string[] = [];
|
|
||||||
|
|
||||||
// Add headers
|
|
||||||
csvRows.push(
|
|
||||||
"Metric,Company ID,Operation,Batch ID,Request Count,Success Count,Failure Count,Average Latency,Last Updated"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add metrics data
|
|
||||||
if (data.metrics) {
|
|
||||||
interface MetricData {
|
|
||||||
companyId?: string;
|
|
||||||
operation?: string;
|
|
||||||
batchId?: string;
|
|
||||||
requestCount?: number;
|
|
||||||
successCount?: number;
|
|
||||||
failureCount?: number;
|
|
||||||
averageLatency?: number;
|
|
||||||
lastUpdated?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.entries(data.metrics).forEach(
|
|
||||||
([key, metric]: [string, MetricData]) => {
|
|
||||||
csvRows.push(
|
|
||||||
[
|
|
||||||
escapeCSVField(key),
|
|
||||||
escapeCSVField(metric.companyId || ""),
|
|
||||||
escapeCSVField(metric.operation || ""),
|
|
||||||
escapeCSVField(metric.batchId || ""),
|
|
||||||
escapeCSVField(metric.requestCount || 0),
|
|
||||||
escapeCSVField(metric.successCount || 0),
|
|
||||||
escapeCSVField(metric.failureCount || 0),
|
|
||||||
escapeCSVField(metric.averageLatency || 0),
|
|
||||||
escapeCSVField(metric.lastUpdated || ""),
|
|
||||||
].join(",")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const csvContent = csvRows.join("\n");
|
|
||||||
|
|
||||||
return new NextResponse(csvContent, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/csv",
|
|
||||||
"Content-Disposition": `attachment; filename="batch-logs-${startDate}-${endDate}.csv"`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return new NextResponse(exportDataJson, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Content-Disposition": `attachment; filename="batch-logs-${startDate}-${endDate}.json"`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Batch log export error:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to export batch logs" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
230
app/api/admin/cache/invalidate/route.ts
vendored
230
app/api/admin/cache/invalidate/route.ts
vendored
@ -1,230 +0,0 @@
|
|||||||
/**
|
|
||||||
* Cache Invalidation API Endpoint
|
|
||||||
*
|
|
||||||
* Allows administrators to manually invalidate cache entries or patterns
|
|
||||||
* for troubleshooting and cache management.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { authOptions } from "../../../../../lib/auth";
|
|
||||||
import { invalidateCompanyCache } from "../../../../../lib/batchProcessorOptimized";
|
|
||||||
import { Cache } from "../../../../../lib/cache";
|
|
||||||
import {
|
|
||||||
AuditOutcome,
|
|
||||||
AuditSeverity,
|
|
||||||
createAuditMetadata,
|
|
||||||
SecurityEventType,
|
|
||||||
} from "../../../../../lib/securityAuditLogger";
|
|
||||||
import { enhancedSecurityLog } from "../../../../../lib/securityMonitoring";
|
|
||||||
|
|
||||||
const invalidationSchema = z.object({
|
|
||||||
type: z.enum(["key", "pattern", "company", "user", "all"]),
|
|
||||||
value: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
async function validateCacheAccess(
|
|
||||||
session: { user?: { id?: string; companyId?: string; role?: string } } | null
|
|
||||||
) {
|
|
||||||
if (!session?.user) {
|
|
||||||
await enhancedSecurityLog(
|
|
||||||
SecurityEventType.AUTHORIZATION,
|
|
||||||
"cache_invalidation_access_denied",
|
|
||||||
AuditOutcome.BLOCKED,
|
|
||||||
{
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
endpoint: "/api/admin/cache/invalidate",
|
|
||||||
reason: "not_authenticated",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
AuditSeverity.MEDIUM,
|
|
||||||
"Unauthenticated access attempt to cache invalidation endpoint"
|
|
||||||
);
|
|
||||||
return { valid: false, status: 401, error: "Authentication required" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.user.role !== "ADMIN") {
|
|
||||||
await enhancedSecurityLog(
|
|
||||||
SecurityEventType.AUTHORIZATION,
|
|
||||||
"cache_invalidation_access_denied",
|
|
||||||
AuditOutcome.BLOCKED,
|
|
||||||
{
|
|
||||||
userId: session.user.id,
|
|
||||||
companyId: session.user.companyId,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
endpoint: "/api/admin/cache/invalidate",
|
|
||||||
userRole: session.user.role,
|
|
||||||
reason: "insufficient_privileges",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
AuditSeverity.HIGH,
|
|
||||||
"Non-admin user attempted to access cache invalidation"
|
|
||||||
);
|
|
||||||
return { valid: false, status: 403, error: "Admin access required" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function performCacheInvalidation(type: string, value?: string) {
|
|
||||||
let deletedCount = 0;
|
|
||||||
let operation = "";
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case "key": {
|
|
||||||
if (!value) {
|
|
||||||
return {
|
|
||||||
error: "Key value required for key invalidation",
|
|
||||||
status: 400,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const deleted = await Cache.delete(value);
|
|
||||||
deletedCount = deleted ? 1 : 0;
|
|
||||||
operation = `key: ${value}`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "pattern": {
|
|
||||||
if (!value) {
|
|
||||||
return {
|
|
||||||
error: "Pattern value required for pattern invalidation",
|
|
||||||
status: 400,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
deletedCount = await Cache.invalidatePattern(value);
|
|
||||||
operation = `pattern: ${value}`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "company": {
|
|
||||||
if (!value) {
|
|
||||||
return {
|
|
||||||
error: "Company ID required for company invalidation",
|
|
||||||
status: 400,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
deletedCount = await Cache.invalidateCompany(value);
|
|
||||||
await invalidateCompanyCache();
|
|
||||||
operation = `company: ${value}`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "user": {
|
|
||||||
if (!value) {
|
|
||||||
return { error: "User ID required for user invalidation", status: 400 };
|
|
||||||
}
|
|
||||||
await Cache.invalidateUser(value);
|
|
||||||
await Cache.invalidatePattern("user:email:*");
|
|
||||||
deletedCount = 1;
|
|
||||||
operation = `user: ${value}`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "all": {
|
|
||||||
await Promise.all([
|
|
||||||
Cache.invalidatePattern("user:*"),
|
|
||||||
Cache.invalidatePattern("company:*"),
|
|
||||||
Cache.invalidatePattern("session:*"),
|
|
||||||
Cache.invalidatePattern("*"),
|
|
||||||
invalidateCompanyCache(),
|
|
||||||
]);
|
|
||||||
deletedCount = 1;
|
|
||||||
operation = "all caches";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return { error: "Invalid invalidation type", status: 400 };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, deletedCount, operation };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
const authResult = await validateCacheAccess(session);
|
|
||||||
if (!authResult.valid) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: authResult.error },
|
|
||||||
{ status: authResult.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const validation = invalidationSchema.safeParse(body);
|
|
||||||
|
|
||||||
if (!validation.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: "Invalid request format",
|
|
||||||
details: validation.error.issues,
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { type, value } = validation.data;
|
|
||||||
const result = await performCacheInvalidation(type, value);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: result.error },
|
|
||||||
{ status: result.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
type,
|
|
||||||
value,
|
|
||||||
deletedCount: result.deletedCount,
|
|
||||||
operation: result.operation,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await enhancedSecurityLog(
|
|
||||||
SecurityEventType.PLATFORM_ADMIN,
|
|
||||||
"cache_invalidation_executed",
|
|
||||||
AuditOutcome.SUCCESS,
|
|
||||||
{
|
|
||||||
userId: session?.user?.id,
|
|
||||||
companyId: session?.user?.companyId,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
endpoint: "/api/admin/cache/invalidate",
|
|
||||||
invalidationType: type,
|
|
||||||
invalidationValue: value,
|
|
||||||
deletedCount: result.deletedCount,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
AuditSeverity.MEDIUM,
|
|
||||||
`Cache invalidation executed: ${result.operation}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Cache Invalidation API] Error:", error);
|
|
||||||
|
|
||||||
await enhancedSecurityLog(
|
|
||||||
SecurityEventType.API_SECURITY,
|
|
||||||
"cache_invalidation_error",
|
|
||||||
AuditOutcome.FAILURE,
|
|
||||||
{
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
endpoint: "/api/admin/cache/invalidate",
|
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
AuditSeverity.HIGH,
|
|
||||||
"Cache invalidation API encountered an error"
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: "Internal server error",
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
157
app/api/admin/cache/stats/route.ts
vendored
157
app/api/admin/cache/stats/route.ts
vendored
@ -1,157 +0,0 @@
|
|||||||
/**
|
|
||||||
* Cache Statistics API Endpoint
|
|
||||||
*
|
|
||||||
* Provides comprehensive cache performance metrics and health status
|
|
||||||
* for monitoring Redis + in-memory cache performance.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "../../../../../lib/auth";
|
|
||||||
import { Cache } from "../../../../../lib/cache";
|
|
||||||
import {
|
|
||||||
AuditOutcome,
|
|
||||||
AuditSeverity,
|
|
||||||
createAuditMetadata,
|
|
||||||
SecurityEventType,
|
|
||||||
} from "../../../../../lib/securityAuditLogger";
|
|
||||||
import { enhancedSecurityLog } from "../../../../../lib/securityMonitoring";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
await enhancedSecurityLog(
|
|
||||||
SecurityEventType.AUTHORIZATION,
|
|
||||||
"cache_stats_access_denied",
|
|
||||||
AuditOutcome.BLOCKED,
|
|
||||||
{
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
endpoint: "/api/admin/cache/stats",
|
|
||||||
reason: "not_authenticated",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
AuditSeverity.MEDIUM,
|
|
||||||
"Unauthenticated access attempt to cache stats endpoint"
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "Authentication required" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.user.role !== "ADMIN") {
|
|
||||||
await enhancedSecurityLog(
|
|
||||||
SecurityEventType.AUTHORIZATION,
|
|
||||||
"cache_stats_access_denied",
|
|
||||||
AuditOutcome.BLOCKED,
|
|
||||||
{
|
|
||||||
userId: session.user.id,
|
|
||||||
companyId: session.user.companyId,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
endpoint: "/api/admin/cache/stats",
|
|
||||||
userRole: session.user.role,
|
|
||||||
reason: "insufficient_privileges",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
AuditSeverity.HIGH,
|
|
||||||
"Non-admin user attempted to access cache stats"
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "Admin access required" },
|
|
||||||
{ status: 403 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get cache statistics and health information
|
|
||||||
const [stats, healthCheck] = await Promise.all([
|
|
||||||
Cache.getStats(),
|
|
||||||
Cache.healthCheck(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
performance: {
|
|
||||||
hits: stats.hits,
|
|
||||||
misses: stats.misses,
|
|
||||||
sets: stats.sets,
|
|
||||||
deletes: stats.deletes,
|
|
||||||
errors: stats.errors,
|
|
||||||
hitRate: Number((stats.hitRate * 100).toFixed(2)), // Convert to percentage
|
|
||||||
redisHits: stats.redisHits,
|
|
||||||
memoryHits: stats.memoryHits,
|
|
||||||
},
|
|
||||||
health: {
|
|
||||||
redis: {
|
|
||||||
connected: healthCheck.redis.connected,
|
|
||||||
latency: healthCheck.redis.latency,
|
|
||||||
error: healthCheck.redis.error,
|
|
||||||
},
|
|
||||||
memory: {
|
|
||||||
available: healthCheck.memory.available,
|
|
||||||
size: healthCheck.memory.size,
|
|
||||||
valid: healthCheck.memory.valid,
|
|
||||||
expired: healthCheck.memory.expired,
|
|
||||||
},
|
|
||||||
overall: {
|
|
||||||
available: healthCheck.overall.available,
|
|
||||||
fallbackMode: healthCheck.overall.fallbackMode,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
configuration: {
|
|
||||||
redisAvailable: stats.redisAvailable,
|
|
||||||
fallbackActive: !stats.redisAvailable,
|
|
||||||
},
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Log successful access
|
|
||||||
await enhancedSecurityLog(
|
|
||||||
SecurityEventType.PLATFORM_ADMIN,
|
|
||||||
"cache_stats_accessed",
|
|
||||||
AuditOutcome.SUCCESS,
|
|
||||||
{
|
|
||||||
userId: session.user.id,
|
|
||||||
companyId: session.user.companyId,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
endpoint: "/api/admin/cache/stats",
|
|
||||||
hitRate: response.data.performance.hitRate,
|
|
||||||
redisConnected: response.data.health.redis.connected,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
AuditSeverity.INFO,
|
|
||||||
"Cache statistics accessed by admin"
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Cache Stats API] Error:", error);
|
|
||||||
|
|
||||||
await enhancedSecurityLog(
|
|
||||||
SecurityEventType.API_SECURITY,
|
|
||||||
"cache_stats_error",
|
|
||||||
AuditOutcome.FAILURE,
|
|
||||||
{
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
endpoint: "/api/admin/cache/stats",
|
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
AuditSeverity.HIGH,
|
|
||||||
"Cache stats API encountered an error"
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: "Internal server error",
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -33,8 +33,14 @@ export async function GET(request: NextRequest) {
|
|||||||
prisma.session.count(),
|
prisma.session.count(),
|
||||||
// Count processing status records
|
// Count processing status records
|
||||||
prisma.sessionProcessingStatus.count(),
|
prisma.sessionProcessingStatus.count(),
|
||||||
// Count total AI requests
|
// Count recent AI requests
|
||||||
prisma.aIProcessingRequest.count(),
|
prisma.aIProcessingRequest.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: new Date(Date.now() - 24 * 60 * 60 * 1000), // Last 24 hours
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [sessionsResult, statusResult, aiRequestsResult] = metrics;
|
const [sessionsResult, statusResult, aiRequestsResult] = metrics;
|
||||||
|
|||||||
@ -1,717 +0,0 @@
|
|||||||
/**
|
|
||||||
* Performance Dashboard API
|
|
||||||
*
|
|
||||||
* Provides real-time performance metrics, bottleneck detection,
|
|
||||||
* and optimization recommendations for system monitoring.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { withErrorHandling } from "@/lib/api/errors";
|
|
||||||
import { createAPIHandler, UserRole } from "@/lib/api/handler";
|
|
||||||
import { cacheManager } from "@/lib/performance/cache";
|
|
||||||
import { deduplicationManager } from "@/lib/performance/deduplication";
|
|
||||||
import {
|
|
||||||
PerformanceUtils,
|
|
||||||
performanceMonitor,
|
|
||||||
} from "@/lib/performance/monitor";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/admin/performance
|
|
||||||
* Get comprehensive performance metrics and recommendations
|
|
||||||
*/
|
|
||||||
export const GET = withErrorHandling(
|
|
||||||
createAPIHandler(
|
|
||||||
async (context) => {
|
|
||||||
const url = new URL(context.request.url);
|
|
||||||
const type = url.searchParams.get("type") || "summary";
|
|
||||||
const limit = Math.min(
|
|
||||||
100,
|
|
||||||
Number.parseInt(url.searchParams.get("limit") || "50", 10)
|
|
||||||
);
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case "summary":
|
|
||||||
return await getPerformanceSummary();
|
|
||||||
|
|
||||||
case "history":
|
|
||||||
return await getPerformanceHistory(limit);
|
|
||||||
|
|
||||||
case "cache":
|
|
||||||
return await getCacheMetrics();
|
|
||||||
|
|
||||||
case "deduplication":
|
|
||||||
return await getDeduplicationMetrics();
|
|
||||||
|
|
||||||
case "recommendations":
|
|
||||||
return await getOptimizationRecommendations();
|
|
||||||
|
|
||||||
case "bottlenecks":
|
|
||||||
return await getBottleneckAnalysis();
|
|
||||||
|
|
||||||
default:
|
|
||||||
return await getPerformanceSummary();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
requireAuth: true,
|
|
||||||
requiredRole: [UserRole.PLATFORM_ADMIN],
|
|
||||||
auditLog: true,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/admin/performance/action
|
|
||||||
* Execute performance optimization actions
|
|
||||||
*/
|
|
||||||
export const POST = withErrorHandling(
|
|
||||||
createAPIHandler(
|
|
||||||
async (context, validatedData) => {
|
|
||||||
const { action, target, options } =
|
|
||||||
validatedData || (await context.request.json());
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case "clear_cache":
|
|
||||||
return await clearCache(target);
|
|
||||||
|
|
||||||
case "start_monitoring":
|
|
||||||
return await startMonitoring(options);
|
|
||||||
|
|
||||||
case "stop_monitoring":
|
|
||||||
return await stopMonitoring();
|
|
||||||
|
|
||||||
case "optimize_cache":
|
|
||||||
return await optimizeCache(target, options);
|
|
||||||
|
|
||||||
case "invalidate_pattern":
|
|
||||||
return await invalidatePattern(target, options);
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown action: ${action}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
requireAuth: true,
|
|
||||||
requiredRole: [UserRole.PLATFORM_ADMIN],
|
|
||||||
auditLog: true,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
async function getPerformanceSummary() {
|
|
||||||
const { result: summary } = await PerformanceUtils.measureAsync(
|
|
||||||
"performance-summary-generation",
|
|
||||||
async () => {
|
|
||||||
const performanceSummary = performanceMonitor.getPerformanceSummary();
|
|
||||||
const cacheReport = cacheManager.getPerformanceReport();
|
|
||||||
const deduplicationStats = deduplicationManager.getAllStats();
|
|
||||||
|
|
||||||
return {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
system: {
|
|
||||||
status: getSystemStatus(performanceSummary),
|
|
||||||
uptime: process.uptime(),
|
|
||||||
nodeVersion: process.version,
|
|
||||||
platform: process.platform,
|
|
||||||
},
|
|
||||||
performance: {
|
|
||||||
current: performanceSummary.currentMetrics,
|
|
||||||
trends: performanceSummary.trends,
|
|
||||||
score: calculatePerformanceScore(performanceSummary),
|
|
||||||
},
|
|
||||||
bottlenecks: performanceSummary.bottlenecks,
|
|
||||||
recommendations: performanceSummary.recommendations,
|
|
||||||
caching: {
|
|
||||||
...cacheReport,
|
|
||||||
efficiency: calculateCacheEfficiency(cacheReport),
|
|
||||||
},
|
|
||||||
deduplication: {
|
|
||||||
totalDeduplicators: Object.keys(deduplicationStats).length,
|
|
||||||
overallStats: calculateOverallDeduplicationStats(deduplicationStats),
|
|
||||||
byCategory: deduplicationStats,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(summary);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getPerformanceHistory(limit: number) {
|
|
||||||
const history = performanceMonitor.getHistory(limit);
|
|
||||||
// history is already typed as PerformanceMetrics[], no casting needed
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
history,
|
|
||||||
analytics: {
|
|
||||||
averageMemoryUsage:
|
|
||||||
history.length > 0
|
|
||||||
? history.reduce((sum, item) => sum + item.memoryUsage.heapUsed, 0) /
|
|
||||||
history.length
|
|
||||||
: 0,
|
|
||||||
averageResponseTime:
|
|
||||||
history.length > 0
|
|
||||||
? history.reduce(
|
|
||||||
(sum, item) => sum + item.requestMetrics.averageResponseTime,
|
|
||||||
0
|
|
||||||
) / history.length
|
|
||||||
: 0,
|
|
||||||
memoryTrend: calculateTrend(
|
|
||||||
history as unknown as Record<string, unknown>[],
|
|
||||||
"memoryUsage.heapUsed"
|
|
||||||
),
|
|
||||||
responseTrend: calculateTrend(
|
|
||||||
history as unknown as Record<string, unknown>[],
|
|
||||||
"requestMetrics.averageResponseTime"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCacheMetrics() {
|
|
||||||
const report = cacheManager.getPerformanceReport();
|
|
||||||
const detailedStats = cacheManager.getAllStats();
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
overview: report,
|
|
||||||
detailed: detailedStats,
|
|
||||||
insights: {
|
|
||||||
mostEfficient: findMostEfficientCache(detailedStats),
|
|
||||||
leastEfficient: findLeastEfficientCache(detailedStats),
|
|
||||||
memoryDistribution: calculateMemoryDistribution(detailedStats),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getDeduplicationMetrics() {
|
|
||||||
const allStats = deduplicationManager.getAllStats();
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
overview: calculateOverallDeduplicationStats(allStats),
|
|
||||||
byCategory: allStats,
|
|
||||||
insights: {
|
|
||||||
mostEffective: findMostEffectiveDeduplicator(allStats),
|
|
||||||
optimization: generateDeduplicationOptimizations(allStats),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getOptimizationRecommendations() {
|
|
||||||
const currentMetrics = performanceMonitor.getCurrentMetrics();
|
|
||||||
const recommendations =
|
|
||||||
performanceMonitor.generateRecommendations(currentMetrics);
|
|
||||||
|
|
||||||
const enhancedRecommendations = recommendations.map((rec) => ({
|
|
||||||
...rec,
|
|
||||||
urgency: calculateUrgency(rec),
|
|
||||||
complexity: estimateComplexity(rec),
|
|
||||||
timeline: estimateTimeline(rec),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
recommendations: enhancedRecommendations,
|
|
||||||
quickWins: enhancedRecommendations.filter(
|
|
||||||
(r) => r.complexity === "low" && r.estimatedImpact > 50
|
|
||||||
),
|
|
||||||
highImpact: enhancedRecommendations.filter((r) => r.estimatedImpact > 70),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getBottleneckAnalysis() {
|
|
||||||
const currentMetrics = performanceMonitor.getCurrentMetrics();
|
|
||||||
const bottlenecks = performanceMonitor.detectBottlenecks(currentMetrics);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
bottlenecks,
|
|
||||||
analysis: {
|
|
||||||
criticalCount: bottlenecks.filter((b) => b.severity === "critical")
|
|
||||||
.length,
|
|
||||||
warningCount: bottlenecks.filter((b) => b.severity === "warning").length,
|
|
||||||
totalImpact: bottlenecks.reduce((sum, b) => sum + b.impact, 0),
|
|
||||||
prioritizedActions: prioritizeBottleneckActions(bottlenecks),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clearCache(target?: string) {
|
|
||||||
if (target) {
|
|
||||||
const success = cacheManager.removeCache(target);
|
|
||||||
return NextResponse.json({
|
|
||||||
success,
|
|
||||||
message: success
|
|
||||||
? `Cache '${target}' cleared`
|
|
||||||
: `Cache '${target}' not found`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
cacheManager.clearAll();
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: "All caches cleared",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startMonitoring(options: { interval?: number } = {}) {
|
|
||||||
const interval = options.interval || 30000;
|
|
||||||
performanceMonitor.start(interval);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: `Performance monitoring started with ${interval}ms interval`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopMonitoring() {
|
|
||||||
performanceMonitor.stop();
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: "Performance monitoring stopped",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function optimizeCache(
|
|
||||||
target: string,
|
|
||||||
_options: Record<string, unknown> = {}
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const optimizationResults: string[] = [];
|
|
||||||
|
|
||||||
switch (target) {
|
|
||||||
case "memory": {
|
|
||||||
// Trigger garbage collection and memory cleanup
|
|
||||||
if (global.gc) {
|
|
||||||
global.gc();
|
|
||||||
optimizationResults.push("Forced garbage collection");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current memory usage before optimization
|
|
||||||
const beforeMemory = cacheManager.getTotalMemoryUsage();
|
|
||||||
optimizationResults.push(
|
|
||||||
`Memory usage before optimization: ${beforeMemory.toFixed(2)} MB`
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "lru": {
|
|
||||||
// Clear all LRU caches to free memory
|
|
||||||
const beforeClearStats = cacheManager.getAllStats();
|
|
||||||
const totalCachesBefore = Object.keys(beforeClearStats).length;
|
|
||||||
|
|
||||||
cacheManager.clearAll();
|
|
||||||
optimizationResults.push(`Cleared ${totalCachesBefore} LRU caches`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "all": {
|
|
||||||
// Comprehensive cache optimization
|
|
||||||
if (global.gc) {
|
|
||||||
global.gc();
|
|
||||||
optimizationResults.push("Forced garbage collection");
|
|
||||||
}
|
|
||||||
|
|
||||||
const allStats = cacheManager.getAllStats();
|
|
||||||
const totalCaches = Object.keys(allStats).length;
|
|
||||||
const memoryBefore = cacheManager.getTotalMemoryUsage();
|
|
||||||
|
|
||||||
cacheManager.clearAll();
|
|
||||||
|
|
||||||
const memoryAfter = cacheManager.getTotalMemoryUsage();
|
|
||||||
const memorySaved = memoryBefore - memoryAfter;
|
|
||||||
|
|
||||||
optimizationResults.push(
|
|
||||||
`Cleared ${totalCaches} caches`,
|
|
||||||
`Memory freed: ${memorySaved.toFixed(2)} MB`
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: `Unknown optimization target: ${target}. Valid targets: memory, lru, all`,
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get post-optimization metrics
|
|
||||||
const metrics = cacheManager.getPerformanceReport();
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: `Cache optimization applied to '${target}'`,
|
|
||||||
optimizations: optimizationResults,
|
|
||||||
metrics: {
|
|
||||||
totalMemoryUsage: metrics.totalMemoryUsage,
|
|
||||||
averageHitRate: metrics.averageHitRate,
|
|
||||||
totalCaches: metrics.totalCaches,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Cache optimization failed:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: "Cache optimization failed",
|
|
||||||
details: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function invalidatePattern(
|
|
||||||
target: string,
|
|
||||||
options: { pattern?: string } = {}
|
|
||||||
) {
|
|
||||||
const { pattern } = options;
|
|
||||||
if (!pattern) {
|
|
||||||
throw new Error("Pattern is required for invalidation");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let invalidatedCount = 0;
|
|
||||||
const invalidationResults: string[] = [];
|
|
||||||
|
|
||||||
switch (target) {
|
|
||||||
case "all": {
|
|
||||||
// Clear all caches (pattern-based clearing not available in current implementation)
|
|
||||||
const allCacheStats = cacheManager.getAllStats();
|
|
||||||
const allCacheNames = Object.keys(allCacheStats);
|
|
||||||
|
|
||||||
cacheManager.clearAll();
|
|
||||||
invalidatedCount = allCacheNames.length;
|
|
||||||
invalidationResults.push(
|
|
||||||
`Cleared all ${invalidatedCount} caches (pattern matching not supported)`
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "memory": {
|
|
||||||
// Get memory usage and clear if pattern would match memory operations
|
|
||||||
const memoryBefore = cacheManager.getTotalMemoryUsage();
|
|
||||||
cacheManager.clearAll();
|
|
||||||
const memoryAfter = cacheManager.getTotalMemoryUsage();
|
|
||||||
|
|
||||||
invalidatedCount = 1;
|
|
||||||
invalidationResults.push(
|
|
||||||
`Cleared memory caches, freed ${(memoryBefore - memoryAfter).toFixed(2)} MB`
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "lru": {
|
|
||||||
// Clear all LRU caches
|
|
||||||
const lruStats = cacheManager.getAllStats();
|
|
||||||
const lruCacheCount = Object.keys(lruStats).length;
|
|
||||||
|
|
||||||
cacheManager.clearAll();
|
|
||||||
invalidatedCount = lruCacheCount;
|
|
||||||
invalidationResults.push(`Cleared ${invalidatedCount} LRU caches`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default: {
|
|
||||||
// Try to remove a specific cache by name
|
|
||||||
const removed = cacheManager.removeCache(target);
|
|
||||||
if (!removed) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: `Cache '${target}' not found. Valid targets: all, memory, lru, or specific cache name`,
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
invalidatedCount = 1;
|
|
||||||
invalidationResults.push(`Removed cache '${target}'`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get post-invalidation metrics
|
|
||||||
const metrics = cacheManager.getPerformanceReport();
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: `Pattern '${pattern}' invalidated in cache '${target}'`,
|
|
||||||
invalidated: invalidatedCount,
|
|
||||||
details: invalidationResults,
|
|
||||||
metrics: {
|
|
||||||
totalMemoryUsage: metrics.totalMemoryUsage,
|
|
||||||
totalCaches: metrics.totalCaches,
|
|
||||||
averageHitRate: metrics.averageHitRate,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Pattern invalidation failed:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: "Pattern invalidation failed",
|
|
||||||
details: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
function getSystemStatus(summary: {
|
|
||||||
bottlenecks: Array<{ severity: string }>;
|
|
||||||
}): "healthy" | "warning" | "critical" {
|
|
||||||
const criticalBottlenecks = summary.bottlenecks.filter(
|
|
||||||
(b: { severity: string }) => b.severity === "critical"
|
|
||||||
);
|
|
||||||
const warningBottlenecks = summary.bottlenecks.filter(
|
|
||||||
(b: { severity: string }) => b.severity === "warning"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (criticalBottlenecks.length > 0) return "critical";
|
|
||||||
if (warningBottlenecks.length > 2) return "warning";
|
|
||||||
return "healthy";
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculatePerformanceScore(summary: {
|
|
||||||
bottlenecks: Array<{ severity: string }>;
|
|
||||||
currentMetrics: { memoryUsage: { heapUsed: number } };
|
|
||||||
}): number {
|
|
||||||
let score = 100;
|
|
||||||
|
|
||||||
// Deduct points for bottlenecks
|
|
||||||
summary.bottlenecks.forEach((bottleneck: { severity: string }) => {
|
|
||||||
if (bottleneck.severity === "critical") score -= 25;
|
|
||||||
else if (bottleneck.severity === "warning") score -= 10;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Factor in memory usage
|
|
||||||
const memUsage = summary.currentMetrics.memoryUsage.heapUsed;
|
|
||||||
if (memUsage > 400) score -= 20;
|
|
||||||
else if (memUsage > 200) score -= 10;
|
|
||||||
|
|
||||||
return Math.max(0, score);
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateCacheEfficiency(report: { averageHitRate: number }): number {
|
|
||||||
return Math.round(report.averageHitRate * 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateOverallDeduplicationStats(
|
|
||||||
stats: Record<
|
|
||||||
string,
|
|
||||||
{ hits: number; misses: number; deduplicatedRequests: number }
|
|
||||||
>
|
|
||||||
) {
|
|
||||||
const values = Object.values(stats);
|
|
||||||
if (values.length === 0) return { hitRate: 0, totalSaved: 0 };
|
|
||||||
|
|
||||||
const totalHits = values.reduce(
|
|
||||||
(sum: number, stat: { hits: number }) => sum + stat.hits,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const totalRequests = values.reduce(
|
|
||||||
(sum: number, stat: { hits: number; misses: number }) =>
|
|
||||||
sum + stat.hits + stat.misses,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const totalSaved = values.reduce(
|
|
||||||
(sum: number, stat: { deduplicatedRequests: number }) =>
|
|
||||||
sum + stat.deduplicatedRequests,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
hitRate: totalRequests > 0 ? totalHits / totalRequests : 0,
|
|
||||||
totalSaved,
|
|
||||||
efficiency: totalRequests > 0 ? (totalSaved / totalRequests) * 100 : 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function _calculateAverage(
|
|
||||||
history: Record<string, unknown>[],
|
|
||||||
path: string
|
|
||||||
): number {
|
|
||||||
if (history.length === 0) return 0;
|
|
||||||
|
|
||||||
const values = history
|
|
||||||
.map((item) => getNestedValue(item, path))
|
|
||||||
.filter((v) => v !== undefined && typeof v === "number") as number[];
|
|
||||||
return values.length > 0
|
|
||||||
? values.reduce((sum, val) => sum + val, 0) / values.length
|
|
||||||
: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateTrend<T extends Record<string, unknown>>(
|
|
||||||
history: Array<T>,
|
|
||||||
path: string
|
|
||||||
): "increasing" | "decreasing" | "stable" {
|
|
||||||
if (history.length < 2) return "stable";
|
|
||||||
|
|
||||||
const recent = history.slice(-5);
|
|
||||||
const older = history.slice(-10, -5);
|
|
||||||
|
|
||||||
if (older.length === 0) return "stable";
|
|
||||||
|
|
||||||
const recentAvg =
|
|
||||||
recent.length > 0
|
|
||||||
? recent.reduce(
|
|
||||||
(sum, item) => sum + getNestedPropertyValue(item, path),
|
|
||||||
0
|
|
||||||
) / recent.length
|
|
||||||
: 0;
|
|
||||||
const olderAvg =
|
|
||||||
older.length > 0
|
|
||||||
? older.reduce(
|
|
||||||
(sum, item) => sum + getNestedPropertyValue(item, path),
|
|
||||||
0
|
|
||||||
) / older.length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
if (recentAvg > olderAvg * 1.1) return "increasing";
|
|
||||||
if (recentAvg < olderAvg * 0.9) return "decreasing";
|
|
||||||
return "stable";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNestedPropertyValue(
|
|
||||||
obj: Record<string, unknown>,
|
|
||||||
path: string
|
|
||||||
): number {
|
|
||||||
const result = path.split(".").reduce((current, key) => {
|
|
||||||
if (current && typeof current === "object" && key in current) {
|
|
||||||
return (current as Record<string, unknown>)[key];
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}, obj as unknown);
|
|
||||||
|
|
||||||
return typeof result === "number" ? result : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
|
||||||
return path
|
|
||||||
.split(".")
|
|
||||||
.reduce((current, key) => (current as Record<string, unknown>)?.[key], obj);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findMostEfficientCache(stats: Record<string, { hitRate: number }>) {
|
|
||||||
return Object.entries(stats).reduce(
|
|
||||||
(best, [name, stat]) =>
|
|
||||||
stat.hitRate > best.hitRate ? { name, ...stat } : best,
|
|
||||||
{ name: "", hitRate: -1 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findLeastEfficientCache(stats: Record<string, { hitRate: number }>) {
|
|
||||||
return Object.entries(stats).reduce(
|
|
||||||
(worst, [name, stat]) =>
|
|
||||||
stat.hitRate < worst.hitRate ? { name, ...stat } : worst,
|
|
||||||
{ name: "", hitRate: 2 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateMemoryDistribution(
|
|
||||||
stats: Record<string, { memoryUsage: number }>
|
|
||||||
) {
|
|
||||||
const total = Object.values(stats).reduce(
|
|
||||||
(sum: number, stat: { memoryUsage: number }) => sum + stat.memoryUsage,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
return Object.entries(stats).map(([name, stat]) => ({
|
|
||||||
name,
|
|
||||||
percentage: total > 0 ? (stat.memoryUsage / total) * 100 : 0,
|
|
||||||
memoryUsage: stat.memoryUsage,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function findMostEffectiveDeduplicator(
|
|
||||||
stats: Record<string, { deduplicationRate: number }>
|
|
||||||
) {
|
|
||||||
return Object.entries(stats).reduce(
|
|
||||||
(best, [name, stat]) =>
|
|
||||||
stat.deduplicationRate > best.deduplicationRate
|
|
||||||
? { name, ...stat }
|
|
||||||
: best,
|
|
||||||
{ name: "", deduplicationRate: -1 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateDeduplicationOptimizations(
|
|
||||||
stats: Record<string, { hitRate: number; deduplicationRate: number }>
|
|
||||||
) {
|
|
||||||
const optimizations: string[] = [];
|
|
||||||
|
|
||||||
Object.entries(stats).forEach(([name, stat]) => {
|
|
||||||
if (stat.hitRate < 0.3) {
|
|
||||||
optimizations.push(`Increase TTL for '${name}' deduplicator`);
|
|
||||||
}
|
|
||||||
if (stat.deduplicationRate < 0.1) {
|
|
||||||
optimizations.push(`Review key generation strategy for '${name}'`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return optimizations;
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateUrgency(rec: {
|
|
||||||
priority: string;
|
|
||||||
estimatedImpact: number;
|
|
||||||
}): "low" | "medium" | "high" {
|
|
||||||
if (rec.priority === "high" && rec.estimatedImpact > 70) return "high";
|
|
||||||
if (rec.priority === "medium" || rec.estimatedImpact > 50) return "medium";
|
|
||||||
return "low";
|
|
||||||
}
|
|
||||||
|
|
||||||
function estimateComplexity(rec: {
|
|
||||||
category: string;
|
|
||||||
}): "low" | "medium" | "high" {
|
|
||||||
if (rec.category === "Caching" || rec.category === "Configuration")
|
|
||||||
return "low";
|
|
||||||
if (rec.category === "Performance" || rec.category === "Memory")
|
|
||||||
return "medium";
|
|
||||||
return "high";
|
|
||||||
}
|
|
||||||
|
|
||||||
function estimateTimeline(rec: { category: string }): string {
|
|
||||||
const complexity = estimateComplexity(rec);
|
|
||||||
|
|
||||||
switch (complexity) {
|
|
||||||
case "low":
|
|
||||||
return "1-2 hours";
|
|
||||||
case "medium":
|
|
||||||
return "4-8 hours";
|
|
||||||
case "high":
|
|
||||||
return "1-3 days";
|
|
||||||
default:
|
|
||||||
return "Unknown";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function prioritizeBottleneckActions(
|
|
||||||
bottlenecks: Array<{
|
|
||||||
severity: string;
|
|
||||||
impact: number;
|
|
||||||
recommendations: string[];
|
|
||||||
description: string;
|
|
||||||
}>
|
|
||||||
) {
|
|
||||||
return bottlenecks
|
|
||||||
.sort((a, b) => {
|
|
||||||
// Sort by severity first, then by impact
|
|
||||||
if (a.severity !== b.severity) {
|
|
||||||
const severityOrder = { critical: 3, warning: 2, info: 1 };
|
|
||||||
return (
|
|
||||||
severityOrder[b.severity as keyof typeof severityOrder] -
|
|
||||||
severityOrder[a.severity as keyof typeof severityOrder]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return b.impact - a.impact;
|
|
||||||
})
|
|
||||||
.slice(0, 5) // Top 5 actions
|
|
||||||
.map((bottleneck, index) => ({
|
|
||||||
priority: index + 1,
|
|
||||||
action: bottleneck.recommendations[0] || "No specific action available",
|
|
||||||
bottleneck: bottleneck.description,
|
|
||||||
estimatedImpact: bottleneck.impact,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@ -6,7 +6,29 @@ import { prisma } from "../../../../lib/prisma";
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { companyId } = body;
|
let { companyId } = body;
|
||||||
|
|
||||||
|
if (!companyId) {
|
||||||
|
// Try to get user from prisma based on session cookie
|
||||||
|
try {
|
||||||
|
const session = await prisma.session.findFirst({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
where: {
|
||||||
|
/* Add session check criteria here */
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
companyId = session.companyId;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Log error for server-side debugging
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
// Use a server-side logging approach instead of console
|
||||||
|
process.stderr.write(`Error fetching session: ${errorMessage}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!companyId) {
|
if (!companyId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@ -1,61 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { getSchedulerIntegration } from "@/lib/services/schedulers/ServerSchedulerIntegration";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Health check endpoint for schedulers
|
|
||||||
* Used by load balancers and orchestrators for health monitoring
|
|
||||||
*/
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const integration = getSchedulerIntegration();
|
|
||||||
const health = integration.getHealthStatus();
|
|
||||||
|
|
||||||
// Return appropriate HTTP status based on health
|
|
||||||
const status = health.healthy ? 200 : 503;
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
healthy: health.healthy,
|
|
||||||
status: health.healthy ? "healthy" : "unhealthy",
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
schedulers: {
|
|
||||||
total: health.totalSchedulers,
|
|
||||||
running: health.runningSchedulers,
|
|
||||||
errors: health.errorSchedulers,
|
|
||||||
},
|
|
||||||
details: health.schedulerStatuses,
|
|
||||||
},
|
|
||||||
{ status }
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Scheduler Health API] Error:", error);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
healthy: false,
|
|
||||||
status: "error",
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
error: "Failed to get scheduler health status",
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Readiness check endpoint
|
|
||||||
* Used by Kubernetes and other orchestrators
|
|
||||||
*/
|
|
||||||
export async function HEAD() {
|
|
||||||
try {
|
|
||||||
const integration = getSchedulerIntegration();
|
|
||||||
const health = integration.getHealthStatus();
|
|
||||||
|
|
||||||
// Return 200 if healthy, 503 if not
|
|
||||||
const status = health.healthy ? 200 : 503;
|
|
||||||
|
|
||||||
return new NextResponse(null, { status });
|
|
||||||
} catch (_error) {
|
|
||||||
return new NextResponse(null, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
import { createAdminHandler } from "@/lib/api";
|
|
||||||
import { getSchedulerIntegration } from "@/lib/services/schedulers/ServerSchedulerIntegration";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all schedulers with their status and metrics
|
|
||||||
* Requires admin authentication
|
|
||||||
*/
|
|
||||||
export const GET = createAdminHandler(async (_context) => {
|
|
||||||
const integration = getSchedulerIntegration();
|
|
||||||
const schedulers = integration.getSchedulersList();
|
|
||||||
const health = integration.getHealthStatus();
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
health,
|
|
||||||
schedulers,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const PostInputSchema = z
|
|
||||||
.object({
|
|
||||||
action: z.enum(["start", "stop", "trigger", "startAll", "stopAll"]),
|
|
||||||
schedulerId: z.string().optional(),
|
|
||||||
})
|
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
// schedulerId is required for individual scheduler actions
|
|
||||||
const actionsRequiringSchedulerId = ["start", "stop", "trigger"];
|
|
||||||
if (actionsRequiringSchedulerId.includes(data.action)) {
|
|
||||||
return data.schedulerId !== undefined && data.schedulerId.length > 0;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "schedulerId is required for start, stop, and trigger actions",
|
|
||||||
path: ["schedulerId"],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Control scheduler operations (start/stop/trigger)
|
|
||||||
* Requires admin authentication
|
|
||||||
*/
|
|
||||||
export const POST = createAdminHandler(
|
|
||||||
async (_context, validatedData) => {
|
|
||||||
const { action, schedulerId } = validatedData as z.infer<
|
|
||||||
typeof PostInputSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
const integration = getSchedulerIntegration();
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case "start":
|
|
||||||
if (schedulerId) {
|
|
||||||
await integration.startScheduler(schedulerId);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "stop":
|
|
||||||
if (schedulerId) {
|
|
||||||
await integration.stopScheduler(schedulerId);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "trigger":
|
|
||||||
if (schedulerId) {
|
|
||||||
await integration.triggerScheduler(schedulerId);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "startAll":
|
|
||||||
await integration.getManager().startAll();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "stopAll":
|
|
||||||
await integration.getManager().stopAll();
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `Unknown action: ${action}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `Action '${action}' completed successfully`,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
{
|
|
||||||
validateInput: PostInputSchema,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@ -1,152 +0,0 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import {
|
|
||||||
AuditOutcome,
|
|
||||||
createAuditContext,
|
|
||||||
securityAuditLogger,
|
|
||||||
} from "@/lib/securityAuditLogger";
|
|
||||||
import {
|
|
||||||
type AlertSeverity,
|
|
||||||
securityMonitoring,
|
|
||||||
} from "@/lib/securityMonitoring";
|
|
||||||
|
|
||||||
const alertQuerySchema = z.object({
|
|
||||||
severity: z.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]).optional(),
|
|
||||||
acknowledged: z.enum(["true", "false"]).optional(),
|
|
||||||
limit: z
|
|
||||||
.string()
|
|
||||||
.transform((val) => Number.parseInt(val, 10))
|
|
||||||
.optional(),
|
|
||||||
offset: z
|
|
||||||
.string()
|
|
||||||
.transform((val) => Number.parseInt(val, 10))
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const acknowledgeAlertSchema = z.object({
|
|
||||||
alertId: z.string().uuid(),
|
|
||||||
action: z.literal("acknowledge"),
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
if (!session?.user || !session.user.isPlatformUser) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const params = Object.fromEntries(url.searchParams.entries());
|
|
||||||
const query = alertQuerySchema.parse(params);
|
|
||||||
|
|
||||||
const context = await createAuditContext(request, session);
|
|
||||||
|
|
||||||
// Get alerts based on filters
|
|
||||||
let alerts = securityMonitoring.getActiveAlerts(
|
|
||||||
query.severity as AlertSeverity
|
|
||||||
);
|
|
||||||
|
|
||||||
// Apply acknowledged filter if provided
|
|
||||||
if (query.acknowledged !== undefined) {
|
|
||||||
const showAcknowledged = query.acknowledged === "true";
|
|
||||||
alerts = alerts.filter((alert) =>
|
|
||||||
showAcknowledged ? alert.acknowledged : !alert.acknowledged
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply pagination
|
|
||||||
const limit = query.limit || 50;
|
|
||||||
const offset = query.offset || 0;
|
|
||||||
const paginatedAlerts = alerts.slice(offset, offset + limit);
|
|
||||||
|
|
||||||
// Log alert access
|
|
||||||
await securityAuditLogger.logPlatformAdmin(
|
|
||||||
"security_alerts_access",
|
|
||||||
AuditOutcome.SUCCESS,
|
|
||||||
{
|
|
||||||
...context,
|
|
||||||
metadata: {
|
|
||||||
alertCount: alerts.length,
|
|
||||||
filters: query,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
alerts: paginatedAlerts,
|
|
||||||
total: alerts.length,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Security alerts API error:", error);
|
|
||||||
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid query parameters", details: error.issues },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Internal server error" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
if (!session?.user || !session.user.isPlatformUser || !session.user.id) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { alertId, action } = acknowledgeAlertSchema.parse(body);
|
|
||||||
const context = await createAuditContext(request, session);
|
|
||||||
|
|
||||||
if (action === "acknowledge") {
|
|
||||||
const success = await securityMonitoring.acknowledgeAlert(
|
|
||||||
alertId,
|
|
||||||
session.user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return NextResponse.json({ error: "Alert not found" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log alert acknowledgment
|
|
||||||
await securityAuditLogger.logPlatformAdmin(
|
|
||||||
"security_alert_acknowledged",
|
|
||||||
AuditOutcome.SUCCESS,
|
|
||||||
{
|
|
||||||
...context,
|
|
||||||
metadata: { alertId },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Security alert action error:", error);
|
|
||||||
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid request", details: error.issues },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Internal server error" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import {
|
|
||||||
AuditOutcome,
|
|
||||||
createAuditContext,
|
|
||||||
securityAuditLogger,
|
|
||||||
} from "@/lib/securityAuditLogger";
|
|
||||||
import { securityMonitoring } from "@/lib/securityMonitoring";
|
|
||||||
|
|
||||||
const exportQuerySchema = z.object({
|
|
||||||
format: z.enum(["json", "csv"]).default("json"),
|
|
||||||
startDate: z.string().datetime(),
|
|
||||||
endDate: z.string().datetime(),
|
|
||||||
type: z.enum(["alerts", "metrics"]).default("alerts"),
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
if (!session?.user || !session.user.isPlatformUser) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const params = Object.fromEntries(url.searchParams.entries());
|
|
||||||
const query = exportQuerySchema.parse(params);
|
|
||||||
|
|
||||||
const context = await createAuditContext(request, session);
|
|
||||||
|
|
||||||
const timeRange = {
|
|
||||||
start: new Date(query.startDate),
|
|
||||||
end: new Date(query.endDate),
|
|
||||||
};
|
|
||||||
|
|
||||||
let data: string;
|
|
||||||
let filename: string;
|
|
||||||
let contentType: string;
|
|
||||||
|
|
||||||
if (query.type === "alerts") {
|
|
||||||
data = securityMonitoring.exportSecurityData(query.format, timeRange);
|
|
||||||
filename = `security-alerts-${query.startDate.split("T")[0]}-to-${query.endDate.split("T")[0]}.${query.format}`;
|
|
||||||
contentType = query.format === "csv" ? "text/csv" : "application/json";
|
|
||||||
} else {
|
|
||||||
// Export metrics
|
|
||||||
const metrics = await securityMonitoring.getSecurityMetrics(timeRange);
|
|
||||||
data = JSON.stringify(metrics, null, 2);
|
|
||||||
filename = `security-metrics-${query.startDate.split("T")[0]}-to-${query.endDate.split("T")[0]}.json`;
|
|
||||||
contentType = "application/json";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log data export
|
|
||||||
await securityAuditLogger.logPlatformAdmin(
|
|
||||||
"security_data_export",
|
|
||||||
AuditOutcome.SUCCESS,
|
|
||||||
{
|
|
||||||
...context,
|
|
||||||
metadata: {
|
|
||||||
exportType: query.type,
|
|
||||||
format: query.format,
|
|
||||||
timeRange,
|
|
||||||
dataSize: data.length,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const headers = new Headers({
|
|
||||||
"Content-Type": contentType,
|
|
||||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
||||||
"Content-Length": data.length.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return new NextResponse(data, { headers });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Security data export error:", error);
|
|
||||||
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid query parameters", details: error.issues },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Internal server error" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,192 +0,0 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import {
|
|
||||||
AuditOutcome,
|
|
||||||
createAuditContext,
|
|
||||||
securityAuditLogger,
|
|
||||||
} from "@/lib/securityAuditLogger";
|
|
||||||
import {
|
|
||||||
AlertChannel,
|
|
||||||
type AlertSeverity,
|
|
||||||
type MonitoringConfig,
|
|
||||||
securityMonitoring,
|
|
||||||
} from "@/lib/securityMonitoring";
|
|
||||||
|
|
||||||
// Type for partial config updates that allows optional nested properties
|
|
||||||
type DeepPartial<T> = {
|
|
||||||
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
|
|
||||||
};
|
|
||||||
|
|
||||||
type ConfigUpdate = DeepPartial<MonitoringConfig>;
|
|
||||||
|
|
||||||
const metricsQuerySchema = z.object({
|
|
||||||
startDate: z.string().datetime().optional(),
|
|
||||||
endDate: z.string().datetime().optional(),
|
|
||||||
companyId: z.string().uuid().optional(),
|
|
||||||
severity: z.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const configUpdateSchema = z.object({
|
|
||||||
thresholds: z
|
|
||||||
.object({
|
|
||||||
failedLoginsPerMinute: z.number().min(1).max(100).optional(),
|
|
||||||
failedLoginsPerHour: z.number().min(1).max(1000).optional(),
|
|
||||||
rateLimitViolationsPerMinute: z.number().min(1).max(100).optional(),
|
|
||||||
cspViolationsPerMinute: z.number().min(1).max(100).optional(),
|
|
||||||
adminActionsPerHour: z.number().min(1).max(100).optional(),
|
|
||||||
massDataAccessThreshold: z.number().min(10).max(10000).optional(),
|
|
||||||
suspiciousIPThreshold: z.number().min(1).max(100).optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
alerting: z
|
|
||||||
.object({
|
|
||||||
enabled: z.boolean().optional(),
|
|
||||||
channels: z.array(z.nativeEnum(AlertChannel)).optional(),
|
|
||||||
suppressDuplicateMinutes: z.number().min(1).max(1440).optional(),
|
|
||||||
escalationTimeoutMinutes: z.number().min(5).max(1440).optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
retention: z
|
|
||||||
.object({
|
|
||||||
alertRetentionDays: z.number().min(1).max(3650).optional(),
|
|
||||||
metricsRetentionDays: z.number().min(1).max(3650).optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only platform admins can access security monitoring
|
|
||||||
if (!session.user.isPlatformUser) {
|
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const params = Object.fromEntries(url.searchParams.entries());
|
|
||||||
const query = metricsQuerySchema.parse(params);
|
|
||||||
|
|
||||||
const context = await createAuditContext(request, session);
|
|
||||||
|
|
||||||
const timeRange = {
|
|
||||||
start: query.startDate
|
|
||||||
? new Date(query.startDate)
|
|
||||||
: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
|
||||||
end: query.endDate ? new Date(query.endDate) : new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get security metrics
|
|
||||||
const metrics = await securityMonitoring.getSecurityMetrics(
|
|
||||||
timeRange,
|
|
||||||
query.companyId
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get active alerts
|
|
||||||
const alerts = securityMonitoring.getActiveAlerts(
|
|
||||||
query.severity as AlertSeverity
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get monitoring configuration
|
|
||||||
const config = securityMonitoring.getConfig();
|
|
||||||
|
|
||||||
// Log access to security monitoring
|
|
||||||
await securityAuditLogger.logPlatformAdmin(
|
|
||||||
"security_monitoring_access",
|
|
||||||
AuditOutcome.SUCCESS,
|
|
||||||
context
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
metrics,
|
|
||||||
alerts,
|
|
||||||
config,
|
|
||||||
timeRange,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Security monitoring API error:", error);
|
|
||||||
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid query parameters", details: error.issues },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Internal server error" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session.user.isPlatformUser) {
|
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const validatedConfig = configUpdateSchema.parse(body);
|
|
||||||
const context = await createAuditContext(request, session);
|
|
||||||
|
|
||||||
// Build the config update object with proper type safety
|
|
||||||
const configUpdate: ConfigUpdate = {};
|
|
||||||
|
|
||||||
if (validatedConfig.thresholds) {
|
|
||||||
configUpdate.thresholds = validatedConfig.thresholds;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validatedConfig.alerting) {
|
|
||||||
configUpdate.alerting = validatedConfig.alerting;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validatedConfig.retention) {
|
|
||||||
configUpdate.retention = validatedConfig.retention;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update monitoring configuration
|
|
||||||
securityMonitoring.updateConfig(configUpdate);
|
|
||||||
|
|
||||||
// Log configuration change
|
|
||||||
await securityAuditLogger.logPlatformAdmin(
|
|
||||||
"security_monitoring_config_update",
|
|
||||||
AuditOutcome.SUCCESS,
|
|
||||||
{
|
|
||||||
...context,
|
|
||||||
metadata: { configChanges: validatedConfig },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
config: securityMonitoring.getConfig(),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Security monitoring config update error:", error);
|
|
||||||
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid configuration", details: error.issues },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Internal server error" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,198 +0,0 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import {
|
|
||||||
AuditOutcome,
|
|
||||||
createAuditContext,
|
|
||||||
securityAuditLogger,
|
|
||||||
} from "@/lib/securityAuditLogger";
|
|
||||||
import {
|
|
||||||
type AlertType,
|
|
||||||
type SecurityMetrics,
|
|
||||||
securityMonitoring,
|
|
||||||
type ThreatLevel,
|
|
||||||
} from "@/lib/securityMonitoring";
|
|
||||||
|
|
||||||
interface ThreatAnalysisResults {
|
|
||||||
ipThreatAnalysis?: {
|
|
||||||
ipAddress: string;
|
|
||||||
threatLevel: ThreatLevel;
|
|
||||||
isBlacklisted: boolean;
|
|
||||||
riskFactors: string[];
|
|
||||||
recommendations: string[];
|
|
||||||
};
|
|
||||||
timeRangeAnalysis?: {
|
|
||||||
timeRange: { start: Date; end: Date };
|
|
||||||
securityScore: number;
|
|
||||||
threatLevel: string;
|
|
||||||
topThreats: Array<{ type: AlertType; count: number }>;
|
|
||||||
geoDistribution: Record<string, number>;
|
|
||||||
riskUsers: Array<{ userId: string; email: string; riskScore: number }>;
|
|
||||||
};
|
|
||||||
overallThreatLandscape?: {
|
|
||||||
currentThreatLevel: string;
|
|
||||||
securityScore: number;
|
|
||||||
activeAlerts: number;
|
|
||||||
criticalEvents: number;
|
|
||||||
recommendations: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const threatAnalysisSchema = z.object({
|
|
||||||
ipAddress: z.string().optional(),
|
|
||||||
userId: z.string().uuid().optional(),
|
|
||||||
timeRange: z
|
|
||||||
.object({
|
|
||||||
start: z.string().datetime(),
|
|
||||||
end: z.string().datetime(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
if (!session?.user || session.user.role !== "ADMIN") {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const analysis = threatAnalysisSchema.parse(body);
|
|
||||||
const context = await createAuditContext(request, session);
|
|
||||||
|
|
||||||
const results: ThreatAnalysisResults = {};
|
|
||||||
|
|
||||||
// IP threat analysis
|
|
||||||
if (analysis.ipAddress) {
|
|
||||||
const ipThreat = await securityMonitoring.calculateIPThreatLevel(
|
|
||||||
analysis.ipAddress
|
|
||||||
);
|
|
||||||
results.ipThreatAnalysis = {
|
|
||||||
ipAddress: analysis.ipAddress,
|
|
||||||
...ipThreat,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Time-based analysis
|
|
||||||
if (analysis.timeRange) {
|
|
||||||
const timeRange = {
|
|
||||||
start: new Date(analysis.timeRange.start),
|
|
||||||
end: new Date(analysis.timeRange.end),
|
|
||||||
};
|
|
||||||
|
|
||||||
const metrics = await securityMonitoring.getSecurityMetrics(timeRange);
|
|
||||||
results.timeRangeAnalysis = {
|
|
||||||
timeRange,
|
|
||||||
securityScore: metrics.securityScore,
|
|
||||||
threatLevel: metrics.threatLevel,
|
|
||||||
topThreats: metrics.topThreats,
|
|
||||||
geoDistribution: metrics.geoDistribution,
|
|
||||||
riskUsers: metrics.userRiskScores.slice(0, 5),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// General threat landscape
|
|
||||||
const defaultTimeRange = {
|
|
||||||
start: new Date(Date.now() - 24 * 60 * 60 * 1000), // Last 24 hours
|
|
||||||
end: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const overallMetrics =
|
|
||||||
await securityMonitoring.getSecurityMetrics(defaultTimeRange);
|
|
||||||
results.overallThreatLandscape = {
|
|
||||||
currentThreatLevel: overallMetrics.threatLevel,
|
|
||||||
securityScore: overallMetrics.securityScore,
|
|
||||||
activeAlerts: overallMetrics.activeAlerts,
|
|
||||||
criticalEvents: overallMetrics.criticalEvents,
|
|
||||||
recommendations: generateThreatRecommendations(overallMetrics),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Log threat analysis request
|
|
||||||
await securityAuditLogger.logPlatformAdmin(
|
|
||||||
"threat_analysis_performed",
|
|
||||||
AuditOutcome.SUCCESS,
|
|
||||||
{
|
|
||||||
...context,
|
|
||||||
metadata: {
|
|
||||||
analysisType: Object.keys(analysis),
|
|
||||||
threatLevel: results.overallThreatLandscape?.currentThreatLevel,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(results);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Threat analysis error:", error);
|
|
||||||
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid request", details: error.issues },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Internal server error" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateThreatRecommendations(metrics: SecurityMetrics): string[] {
|
|
||||||
const recommendations: string[] = [];
|
|
||||||
|
|
||||||
if (metrics.securityScore < 70) {
|
|
||||||
recommendations.push(
|
|
||||||
"Security score is below acceptable threshold - immediate action required"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metrics.activeAlerts > 5) {
|
|
||||||
recommendations.push(
|
|
||||||
"High number of active alerts - prioritize alert resolution"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metrics.criticalEvents > 0) {
|
|
||||||
recommendations.push(
|
|
||||||
"Critical security events detected - investigate immediately"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const highRiskUsers = metrics.userRiskScores.filter(
|
|
||||||
(user) => user.riskScore > 50
|
|
||||||
);
|
|
||||||
if (highRiskUsers.length > 0) {
|
|
||||||
recommendations.push(
|
|
||||||
`${highRiskUsers.length} users have elevated risk scores - review accounts`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for geographic anomalies
|
|
||||||
const countries = Object.keys(metrics.geoDistribution);
|
|
||||||
if (countries.length > 10) {
|
|
||||||
recommendations.push(
|
|
||||||
"High geographic diversity detected - review for suspicious activity"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for common attack patterns
|
|
||||||
const bruteForceAlerts = metrics.topThreats.filter(
|
|
||||||
(threat) => threat.type === "BRUTE_FORCE_ATTACK"
|
|
||||||
);
|
|
||||||
if (bruteForceAlerts.length > 0) {
|
|
||||||
recommendations.push(
|
|
||||||
"Brute force attacks detected - strengthen authentication controls"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recommendations.length === 0) {
|
|
||||||
recommendations.push(
|
|
||||||
"Security posture appears stable - continue monitoring"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return recommendations;
|
|
||||||
}
|
|
||||||
@ -4,7 +4,7 @@ import { getServerSession } from "next-auth";
|
|||||||
import { authOptions } from "../../../../lib/auth";
|
import { authOptions } from "../../../../lib/auth";
|
||||||
import { prisma } from "../../../../lib/prisma";
|
import { prisma } from "../../../../lib/prisma";
|
||||||
import { processUnprocessedSessions } from "../../../../lib/processingScheduler";
|
import { processUnprocessedSessions } from "../../../../lib/processingScheduler";
|
||||||
import { getSessionsNeedingProcessing } from "../../../../lib/processingStatusManager";
|
import { ProcessingStatusManager } from "../../../../lib/processingStatusManager";
|
||||||
|
|
||||||
interface SessionUser {
|
interface SessionUser {
|
||||||
email: string;
|
email: string;
|
||||||
@ -65,10 +65,11 @@ export async function POST(request: NextRequest) {
|
|||||||
: 5;
|
: 5;
|
||||||
|
|
||||||
// Check how many sessions need AI processing using the new status system
|
// Check how many sessions need AI processing using the new status system
|
||||||
const sessionsNeedingAI = await getSessionsNeedingProcessing(
|
const sessionsNeedingAI =
|
||||||
ProcessingStage.AI_ANALYSIS,
|
await ProcessingStatusManager.getSessionsNeedingProcessing(
|
||||||
1000 // Get count only
|
ProcessingStage.AI_ANALYSIS,
|
||||||
);
|
1000 // Get count only
|
||||||
|
);
|
||||||
|
|
||||||
// Filter to sessions for this company
|
// Filter to sessions for this company
|
||||||
const companySessionsNeedingAI = sessionsNeedingAI.filter(
|
const companySessionsNeedingAI = sessionsNeedingAI.filter(
|
||||||
@ -87,6 +88,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start processing (this will run asynchronously)
|
// Start processing (this will run asynchronously)
|
||||||
|
const _startTime = Date.now();
|
||||||
|
|
||||||
// Note: We're calling the function but not awaiting it to avoid timeout
|
// Note: We're calling the function but not awaiting it to avoid timeout
|
||||||
// The processing will continue in the background
|
// The processing will continue in the background
|
||||||
|
|||||||
@ -1,127 +0,0 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import { cspMonitoring } from "@/lib/csp-monitoring";
|
|
||||||
import { extractClientIP, rateLimiter } from "@/lib/rateLimiter";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Authentication check for security metrics endpoint
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for ADMIN role as CSP metrics contain sensitive security data
|
|
||||||
if (session.user.role !== "ADMIN") {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Forbidden - Admin access required" },
|
|
||||||
{ status: 403 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Rate limiting for metrics endpoint
|
|
||||||
const ip = extractClientIP(request);
|
|
||||||
const rateLimitResult = await rateLimiter.check(
|
|
||||||
`csp-metrics:${ip}`,
|
|
||||||
30, // 30 requests
|
|
||||||
60 * 1000 // per minute
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!rateLimitResult.success) {
|
|
||||||
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse query parameters
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const timeRange = url.searchParams.get("range") || "24h";
|
|
||||||
const format = url.searchParams.get("format") || "json";
|
|
||||||
|
|
||||||
// Calculate time range
|
|
||||||
const now = new Date();
|
|
||||||
let start: Date;
|
|
||||||
|
|
||||||
switch (timeRange) {
|
|
||||||
case "1h":
|
|
||||||
start = new Date(now.getTime() - 60 * 60 * 1000);
|
|
||||||
break;
|
|
||||||
case "6h":
|
|
||||||
start = new Date(now.getTime() - 6 * 60 * 60 * 1000);
|
|
||||||
break;
|
|
||||||
case "24h":
|
|
||||||
start = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
||||||
break;
|
|
||||||
case "7d":
|
|
||||||
start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
||||||
break;
|
|
||||||
case "30d":
|
|
||||||
start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
start = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get metrics from monitoring service
|
|
||||||
const metrics = cspMonitoring.getMetrics({ start, end: now });
|
|
||||||
|
|
||||||
// Get policy recommendations
|
|
||||||
const recommendations = cspMonitoring.generatePolicyRecommendations({
|
|
||||||
start,
|
|
||||||
end: now,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
timeRange: {
|
|
||||||
start: start.toISOString(),
|
|
||||||
end: now.toISOString(),
|
|
||||||
range: timeRange,
|
|
||||||
},
|
|
||||||
summary: {
|
|
||||||
totalViolations: metrics.totalViolations,
|
|
||||||
criticalViolations: metrics.criticalViolations,
|
|
||||||
bypassAttempts: metrics.bypassAttempts,
|
|
||||||
violationRate:
|
|
||||||
metrics.totalViolations /
|
|
||||||
((now.getTime() - start.getTime()) / (60 * 60 * 1000)), // per hour
|
|
||||||
},
|
|
||||||
topViolatedDirectives: metrics.topViolatedDirectives,
|
|
||||||
topBlockedUris: metrics.topBlockedUris,
|
|
||||||
violationTrends: metrics.violationTrends,
|
|
||||||
recommendations: recommendations,
|
|
||||||
lastUpdated: now.toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Export format handling
|
|
||||||
if (format === "csv") {
|
|
||||||
const csv = cspMonitoring.exportViolations("csv");
|
|
||||||
return new NextResponse(csv, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/csv",
|
|
||||||
"Content-Disposition": `attachment; filename="csp-violations-${timeRange}.csv"`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching CSP metrics:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to fetch metrics" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle preflight requests
|
|
||||||
export async function OPTIONS() {
|
|
||||||
return new NextResponse(null, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"Access-Control-Allow-Origin":
|
|
||||||
process.env.ALLOWED_ORIGINS || "https://livedash.notso.ai",
|
|
||||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
||||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
||||||
"Access-Control-Allow-Credentials": "true",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,129 +0,0 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import {
|
|
||||||
type CSPViolationReport,
|
|
||||||
detectCSPBypass,
|
|
||||||
parseCSPViolation,
|
|
||||||
} from "@/lib/csp";
|
|
||||||
import { cspMonitoring } from "@/lib/csp-monitoring";
|
|
||||||
import { rateLimiter } from "@/lib/rateLimiter";
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Rate limiting for CSP reports
|
|
||||||
const ip = request.headers.get("x-forwarded-for") || "unknown";
|
|
||||||
const rateLimitResult = await rateLimiter.check(
|
|
||||||
`csp-report:${ip}`,
|
|
||||||
10, // 10 reports
|
|
||||||
60 * 1000 // per minute
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!rateLimitResult.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Too many CSP reports" },
|
|
||||||
{ status: 429 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = request.headers.get("content-type");
|
|
||||||
if (
|
|
||||||
!contentType?.includes("application/csp-report") &&
|
|
||||||
!contentType?.includes("application/json")
|
|
||||||
) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid content type" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const report: CSPViolationReport = await request.json();
|
|
||||||
|
|
||||||
if (!report["csp-report"]) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid CSP report format" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process violation through monitoring service
|
|
||||||
const monitoringResult = await cspMonitoring.processViolation(
|
|
||||||
report,
|
|
||||||
ip,
|
|
||||||
request.headers.get("user-agent") || undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
// Enhanced logging based on monitoring analysis
|
|
||||||
const logEntry = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
ip,
|
|
||||||
userAgent: request.headers.get("user-agent"),
|
|
||||||
violation: parseCSPViolation(report),
|
|
||||||
bypassDetection: detectCSPBypass(
|
|
||||||
report["csp-report"]["blocked-uri"] +
|
|
||||||
" " +
|
|
||||||
(report["csp-report"]["script-sample"] || "")
|
|
||||||
),
|
|
||||||
originalReport: report,
|
|
||||||
alertLevel: monitoringResult.alertLevel,
|
|
||||||
shouldAlert: monitoringResult.shouldAlert,
|
|
||||||
recommendations: monitoringResult.recommendations,
|
|
||||||
};
|
|
||||||
|
|
||||||
// In development, log to console with recommendations
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
console.warn("🚨 CSP Violation Detected:", {
|
|
||||||
...logEntry,
|
|
||||||
recommendations: monitoringResult.recommendations,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (monitoringResult.recommendations.length > 0) {
|
|
||||||
console.info("💡 Recommendations:", monitoringResult.recommendations);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced alerting based on monitoring service analysis
|
|
||||||
if (monitoringResult.shouldAlert) {
|
|
||||||
const alertEmoji = {
|
|
||||||
low: "🟡",
|
|
||||||
medium: "🟠",
|
|
||||||
high: "🔴",
|
|
||||||
critical: "🚨",
|
|
||||||
}[monitoringResult.alertLevel];
|
|
||||||
|
|
||||||
console.error(
|
|
||||||
`${alertEmoji} CSP ${monitoringResult.alertLevel.toUpperCase()} ALERT:`,
|
|
||||||
{
|
|
||||||
directive: logEntry.violation.directive,
|
|
||||||
blockedUri: logEntry.violation.blockedUri,
|
|
||||||
isBypassAttempt: logEntry.bypassDetection.isDetected,
|
|
||||||
riskLevel: logEntry.bypassDetection.riskLevel,
|
|
||||||
recommendations: monitoringResult.recommendations.slice(0, 3), // Limit to 3 recommendations
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up old violations periodically (every 100 requests)
|
|
||||||
if (Math.random() < 0.01) {
|
|
||||||
cspMonitoring.cleanupOldViolations();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new NextResponse(null, { status: 204 });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error processing CSP report:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to process report" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle preflight requests
|
|
||||||
export async function OPTIONS() {
|
|
||||||
return new NextResponse(null, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
||||||
"Access-Control-Allow-Headers": "Content-Type",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
/**
|
|
||||||
* CSRF Token API Endpoint
|
|
||||||
*
|
|
||||||
* This endpoint provides CSRF tokens to clients for secure form submissions.
|
|
||||||
* It generates a new token and sets it as an HTTP-only cookie.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { generateCSRFTokenResponse } from "../../../middleware/csrfProtection";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/csrf-token
|
|
||||||
*
|
|
||||||
* Generates and returns a new CSRF token.
|
|
||||||
* The token is also set as an HTTP-only cookie for automatic inclusion in requests.
|
|
||||||
*/
|
|
||||||
export function GET() {
|
|
||||||
return generateCSRFTokenResponse();
|
|
||||||
}
|
|
||||||
@ -3,7 +3,7 @@ import { getServerSession } from "next-auth";
|
|||||||
import { authOptions } from "../../../../lib/auth";
|
import { authOptions } from "../../../../lib/auth";
|
||||||
import { prisma } from "../../../../lib/prisma";
|
import { prisma } from "../../../../lib/prisma";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(_request: NextRequest) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return NextResponse.json({ error: "Not logged in" }, { status: 401 });
|
return NextResponse.json({ error: "Not logged in" }, { status: 401 });
|
||||||
|
|||||||
@ -1,432 +0,0 @@
|
|||||||
/**
|
|
||||||
* Enhanced Dashboard Metrics API with Performance Optimization
|
|
||||||
*
|
|
||||||
* This demonstrates integration of caching, deduplication, and performance monitoring
|
|
||||||
* into existing API endpoints for significant performance improvements.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { withErrorHandling } from "@/lib/api/errors";
|
|
||||||
import { createSuccessResponse } from "@/lib/api/response";
|
|
||||||
import { caches } from "@/lib/performance/cache";
|
|
||||||
import { deduplicators } from "@/lib/performance/deduplication";
|
|
||||||
|
|
||||||
// Performance system imports
|
|
||||||
import {
|
|
||||||
PerformanceUtils,
|
|
||||||
performanceMonitor,
|
|
||||||
} from "@/lib/performance/monitor";
|
|
||||||
import { authOptions } from "../../../../lib/auth";
|
|
||||||
import { sessionMetrics } from "../../../../lib/metrics";
|
|
||||||
import { prisma } from "../../../../lib/prisma";
|
|
||||||
import type { ChatSession, MetricsResult } from "../../../../lib/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a Prisma session to ChatSession format for metrics
|
|
||||||
*/
|
|
||||||
function convertToMockChatSession(
|
|
||||||
ps: {
|
|
||||||
id: string;
|
|
||||||
companyId: string;
|
|
||||||
startTime: Date;
|
|
||||||
endTime: Date | null;
|
|
||||||
createdAt: Date;
|
|
||||||
category: string | null;
|
|
||||||
language: string | null;
|
|
||||||
country: string | null;
|
|
||||||
ipAddress: string | null;
|
|
||||||
sentiment: string | null;
|
|
||||||
messagesSent: number | null;
|
|
||||||
avgResponseTime: number | null;
|
|
||||||
escalated: boolean | null;
|
|
||||||
forwardedHr: boolean | null;
|
|
||||||
initialMsg: string | null;
|
|
||||||
fullTranscriptUrl: string | null;
|
|
||||||
summary: string | null;
|
|
||||||
},
|
|
||||||
questions: string[]
|
|
||||||
): ChatSession {
|
|
||||||
// Convert questions to mock messages for backward compatibility
|
|
||||||
const mockMessages = questions.map((q, index) => ({
|
|
||||||
id: `question-${index}`,
|
|
||||||
sessionId: ps.id,
|
|
||||||
timestamp: ps.createdAt,
|
|
||||||
role: "User",
|
|
||||||
content: q,
|
|
||||||
order: index,
|
|
||||||
createdAt: ps.createdAt,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: ps.id,
|
|
||||||
sessionId: ps.id,
|
|
||||||
companyId: ps.companyId,
|
|
||||||
startTime: new Date(ps.startTime),
|
|
||||||
endTime: ps.endTime ? new Date(ps.endTime) : null,
|
|
||||||
transcriptContent: "",
|
|
||||||
createdAt: new Date(ps.createdAt),
|
|
||||||
updatedAt: new Date(ps.createdAt),
|
|
||||||
category: ps.category || undefined,
|
|
||||||
language: ps.language || undefined,
|
|
||||||
country: ps.country || undefined,
|
|
||||||
ipAddress: ps.ipAddress || undefined,
|
|
||||||
sentiment: ps.sentiment === null ? undefined : ps.sentiment,
|
|
||||||
messagesSent: ps.messagesSent === null ? undefined : ps.messagesSent,
|
|
||||||
avgResponseTime:
|
|
||||||
ps.avgResponseTime === null ? undefined : ps.avgResponseTime,
|
|
||||||
escalated: ps.escalated || false,
|
|
||||||
forwardedHr: ps.forwardedHr || false,
|
|
||||||
initialMsg: ps.initialMsg || undefined,
|
|
||||||
fullTranscriptUrl: ps.fullTranscriptUrl || undefined,
|
|
||||||
summary: ps.summary || undefined,
|
|
||||||
messages: mockMessages, // Use questions as messages for metrics
|
|
||||||
userId: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionUser {
|
|
||||||
email: string;
|
|
||||||
name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionData {
|
|
||||||
user: SessionUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MetricsRequestParams {
|
|
||||||
companyId: string;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MetricsResponse {
|
|
||||||
metrics: MetricsResult;
|
|
||||||
csvUrl: string | null;
|
|
||||||
company: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
csvUrl: string;
|
|
||||||
status: string;
|
|
||||||
};
|
|
||||||
dateRange: { minDate: string; maxDate: string } | null;
|
|
||||||
performanceMetrics?: {
|
|
||||||
cacheHit: boolean;
|
|
||||||
deduplicationHit: boolean;
|
|
||||||
executionTime: number;
|
|
||||||
dataFreshness: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a cache key for metrics based on company and date range
|
|
||||||
*/
|
|
||||||
function generateMetricsCacheKey(params: MetricsRequestParams): string {
|
|
||||||
const { companyId, startDate, endDate } = params;
|
|
||||||
return `metrics:${companyId}:${startDate || "all"}:${endDate || "all"}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch sessions with performance monitoring and caching
|
|
||||||
*/
|
|
||||||
const fetchSessionsWithCache = deduplicators.database.memoize(
|
|
||||||
async (params: MetricsRequestParams) => {
|
|
||||||
return PerformanceUtils.measureAsync("metrics-session-fetch", async () => {
|
|
||||||
const whereClause: {
|
|
||||||
companyId: string;
|
|
||||||
startTime?: {
|
|
||||||
gte: Date;
|
|
||||||
lte: Date;
|
|
||||||
};
|
|
||||||
} = {
|
|
||||||
companyId: params.companyId,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (params.startDate && params.endDate) {
|
|
||||||
whereClause.startTime = {
|
|
||||||
gte: new Date(params.startDate),
|
|
||||||
lte: new Date(`${params.endDate}T23:59:59.999Z`),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch sessions
|
|
||||||
const sessions = await prisma.session.findMany({
|
|
||||||
where: whereClause,
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
companyId: true,
|
|
||||||
startTime: true,
|
|
||||||
endTime: true,
|
|
||||||
createdAt: true,
|
|
||||||
category: true,
|
|
||||||
language: true,
|
|
||||||
country: true,
|
|
||||||
ipAddress: true,
|
|
||||||
sentiment: true,
|
|
||||||
messagesSent: true,
|
|
||||||
avgResponseTime: true,
|
|
||||||
escalated: true,
|
|
||||||
forwardedHr: true,
|
|
||||||
initialMsg: true,
|
|
||||||
fullTranscriptUrl: true,
|
|
||||||
summary: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return sessions;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keyGenerator: (params: MetricsRequestParams) => JSON.stringify(params),
|
|
||||||
ttl: 2 * 60 * 1000, // 2 minutes
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch questions for sessions with deduplication
|
|
||||||
*/
|
|
||||||
const fetchQuestionsWithDeduplication = deduplicators.database.memoize(
|
|
||||||
async (sessionIds: string[]) => {
|
|
||||||
return PerformanceUtils.measureAsync(
|
|
||||||
"metrics-questions-fetch",
|
|
||||||
async () => {
|
|
||||||
const questions = await prisma.sessionQuestion.findMany({
|
|
||||||
where: { sessionId: { in: sessionIds } },
|
|
||||||
include: { question: true },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return questions;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keyGenerator: (sessionIds: string[]) =>
|
|
||||||
`questions:${sessionIds.sort().join(",")}`,
|
|
||||||
ttl: 5 * 60 * 1000, // 5 minutes
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate metrics with caching
|
|
||||||
*/
|
|
||||||
const calculateMetricsWithCache = async (
|
|
||||||
chatSessions: ChatSession[],
|
|
||||||
companyConfig: Record<string, unknown>,
|
|
||||||
cacheKey: string
|
|
||||||
): Promise<{
|
|
||||||
result: {
|
|
||||||
metrics: MetricsResult;
|
|
||||||
calculatedAt: string;
|
|
||||||
sessionCount: number;
|
|
||||||
};
|
|
||||||
fromCache: boolean;
|
|
||||||
}> => {
|
|
||||||
return caches.metrics
|
|
||||||
.getOrCompute(
|
|
||||||
cacheKey,
|
|
||||||
() =>
|
|
||||||
PerformanceUtils.measureAsync("metrics-calculation", async () => {
|
|
||||||
const metrics = sessionMetrics(chatSessions, companyConfig);
|
|
||||||
return {
|
|
||||||
metrics,
|
|
||||||
calculatedAt: new Date().toISOString(),
|
|
||||||
sessionCount: chatSessions.length,
|
|
||||||
};
|
|
||||||
}).then(({ result }) => result),
|
|
||||||
5 * 60 * 1000 // 5 minutes cache
|
|
||||||
)
|
|
||||||
.then((cached) => ({
|
|
||||||
result: cached,
|
|
||||||
fromCache: caches.metrics.has(cacheKey),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enhanced GET endpoint with performance optimizations
|
|
||||||
*/
|
|
||||||
export const GET = withErrorHandling(async (request: NextRequest) => {
|
|
||||||
const requestTimer = PerformanceUtils.createTimer("metrics-request-total");
|
|
||||||
let _cacheHit = false;
|
|
||||||
let deduplicationHit = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Authentication with performance monitoring
|
|
||||||
const { result: session } = await PerformanceUtils.measureAsync(
|
|
||||||
"metrics-auth-check",
|
|
||||||
async () => (await getServerSession(authOptions)) as SessionData | null
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
performanceMonitor.recordRequest(requestTimer.end(), true);
|
|
||||||
return NextResponse.json({ error: "Not logged in" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// User lookup with caching
|
|
||||||
const user = await caches.sessions.getOrCompute(
|
|
||||||
`user:${session.user.email}`,
|
|
||||||
async () => {
|
|
||||||
const { result } = await PerformanceUtils.measureAsync(
|
|
||||||
"metrics-user-lookup",
|
|
||||||
async () =>
|
|
||||||
prisma.user.findUnique({
|
|
||||||
where: { email: session.user.email },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
companyId: true,
|
|
||||||
company: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
csvUrl: true,
|
|
||||||
status: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
15 * 60 * 1000 // 15 minutes
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
performanceMonitor.recordRequest(requestTimer.end(), true);
|
|
||||||
return NextResponse.json({ error: "No user" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract request parameters
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const startDate = searchParams.get("startDate") || undefined;
|
|
||||||
const endDate = searchParams.get("endDate") || undefined;
|
|
||||||
|
|
||||||
const params: MetricsRequestParams = {
|
|
||||||
companyId: user.companyId,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
};
|
|
||||||
|
|
||||||
const cacheKey = generateMetricsCacheKey(params);
|
|
||||||
|
|
||||||
// Try to get complete cached response first
|
|
||||||
const cachedResponse = await caches.apiResponses.get(
|
|
||||||
`full-metrics:${cacheKey}`
|
|
||||||
);
|
|
||||||
if (cachedResponse) {
|
|
||||||
_cacheHit = true;
|
|
||||||
const duration = requestTimer.end();
|
|
||||||
performanceMonitor.recordRequest(duration, false);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
createSuccessResponse({
|
|
||||||
...cachedResponse,
|
|
||||||
performanceMetrics: {
|
|
||||||
cacheHit: true,
|
|
||||||
deduplicationHit: false,
|
|
||||||
executionTime: duration,
|
|
||||||
dataFreshness: "cached",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch sessions with deduplication and monitoring
|
|
||||||
const sessionResult = await fetchSessionsWithCache(params);
|
|
||||||
const prismaSessions = sessionResult.result;
|
|
||||||
|
|
||||||
// Track if this was a deduplication hit
|
|
||||||
deduplicationHit = deduplicators.database.getStats().hitRate > 0;
|
|
||||||
|
|
||||||
// Fetch questions with deduplication
|
|
||||||
const sessionIds = prismaSessions.map((s) => s.id);
|
|
||||||
const questionsResult = await fetchQuestionsWithDeduplication(sessionIds);
|
|
||||||
const sessionQuestions = questionsResult.result;
|
|
||||||
|
|
||||||
// Group questions by session with performance monitoring
|
|
||||||
const { result: questionsBySession } = await PerformanceUtils.measureAsync(
|
|
||||||
"metrics-questions-grouping",
|
|
||||||
async () => {
|
|
||||||
return sessionQuestions.reduce(
|
|
||||||
(acc, sq) => {
|
|
||||||
if (!acc[sq.sessionId]) acc[sq.sessionId] = [];
|
|
||||||
acc[sq.sessionId].push(sq.question.content);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, string[]>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert to ChatSession format with monitoring
|
|
||||||
const { result: chatSessions } = await PerformanceUtils.measureAsync(
|
|
||||||
"metrics-session-conversion",
|
|
||||||
async () => {
|
|
||||||
return prismaSessions.map((ps) => {
|
|
||||||
const questions = questionsBySession[ps.id] || [];
|
|
||||||
return convertToMockChatSession(ps, questions);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calculate metrics with caching
|
|
||||||
const companyConfigForMetrics = {};
|
|
||||||
const { result: metricsData, fromCache: metricsFromCache } =
|
|
||||||
await calculateMetricsWithCache(
|
|
||||||
chatSessions,
|
|
||||||
companyConfigForMetrics,
|
|
||||||
`calc:${cacheKey}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calculate date range with monitoring
|
|
||||||
const { result: dateRange } = await PerformanceUtils.measureAsync(
|
|
||||||
"metrics-date-range-calc",
|
|
||||||
async () => {
|
|
||||||
if (prismaSessions.length === 0) return null;
|
|
||||||
|
|
||||||
const dates = prismaSessions
|
|
||||||
.map((s) => new Date(s.startTime))
|
|
||||||
.sort((a: Date, b: Date) => a.getTime() - b.getTime());
|
|
||||||
|
|
||||||
return {
|
|
||||||
minDate: dates[0].toISOString().split("T")[0],
|
|
||||||
maxDate: dates[dates.length - 1].toISOString().split("T")[0],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const responseData: MetricsResponse = {
|
|
||||||
metrics: metricsData.metrics,
|
|
||||||
csvUrl: user.company.csvUrl,
|
|
||||||
company: user.company,
|
|
||||||
dateRange,
|
|
||||||
performanceMetrics: {
|
|
||||||
cacheHit: metricsFromCache,
|
|
||||||
deduplicationHit,
|
|
||||||
executionTime: 0, // Will be set below
|
|
||||||
dataFreshness: metricsFromCache ? "cached" : "fresh",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cache the complete response for faster subsequent requests
|
|
||||||
await caches.apiResponses.set(
|
|
||||||
`full-metrics:${cacheKey}`,
|
|
||||||
responseData,
|
|
||||||
2 * 60 * 1000 // 2 minutes
|
|
||||||
);
|
|
||||||
|
|
||||||
const duration = requestTimer.end();
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: performanceMetrics is guaranteed to exist as we just created it
|
|
||||||
responseData.performanceMetrics!.executionTime = duration;
|
|
||||||
|
|
||||||
performanceMonitor.recordRequest(duration, false);
|
|
||||||
|
|
||||||
return NextResponse.json(createSuccessResponse(responseData));
|
|
||||||
} catch (error) {
|
|
||||||
const duration = requestTimer.end();
|
|
||||||
performanceMonitor.recordRequest(duration, true);
|
|
||||||
throw error; // Re-throw for error handler
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Export enhanced endpoint as default
|
|
||||||
export { GET as default };
|
|
||||||
@ -5,69 +5,6 @@ import { sessionMetrics } from "../../../../lib/metrics";
|
|||||||
import { prisma } from "../../../../lib/prisma";
|
import { prisma } from "../../../../lib/prisma";
|
||||||
import type { ChatSession } from "../../../../lib/types";
|
import type { ChatSession } from "../../../../lib/types";
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a Prisma session to ChatSession format for metrics
|
|
||||||
*/
|
|
||||||
function convertToMockChatSession(
|
|
||||||
ps: {
|
|
||||||
id: string;
|
|
||||||
companyId: string;
|
|
||||||
startTime: Date;
|
|
||||||
endTime: Date | null;
|
|
||||||
createdAt: Date;
|
|
||||||
category: string | null;
|
|
||||||
language: string | null;
|
|
||||||
country: string | null;
|
|
||||||
ipAddress: string | null;
|
|
||||||
sentiment: string | null;
|
|
||||||
messagesSent: number | null;
|
|
||||||
avgResponseTime: number | null;
|
|
||||||
escalated: boolean | null;
|
|
||||||
forwardedHr: boolean | null;
|
|
||||||
initialMsg: string | null;
|
|
||||||
fullTranscriptUrl: string | null;
|
|
||||||
summary: string | null;
|
|
||||||
},
|
|
||||||
questions: string[]
|
|
||||||
): ChatSession {
|
|
||||||
// Convert questions to mock messages for backward compatibility
|
|
||||||
const mockMessages = questions.map((q, index) => ({
|
|
||||||
id: `question-${index}`,
|
|
||||||
sessionId: ps.id,
|
|
||||||
timestamp: ps.createdAt,
|
|
||||||
role: "User",
|
|
||||||
content: q,
|
|
||||||
order: index,
|
|
||||||
createdAt: ps.createdAt,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: ps.id,
|
|
||||||
sessionId: ps.id,
|
|
||||||
companyId: ps.companyId,
|
|
||||||
startTime: new Date(ps.startTime),
|
|
||||||
endTime: ps.endTime ? new Date(ps.endTime) : null,
|
|
||||||
transcriptContent: "",
|
|
||||||
createdAt: new Date(ps.createdAt),
|
|
||||||
updatedAt: new Date(ps.createdAt),
|
|
||||||
category: ps.category || undefined,
|
|
||||||
language: ps.language || undefined,
|
|
||||||
country: ps.country || undefined,
|
|
||||||
ipAddress: ps.ipAddress || undefined,
|
|
||||||
sentiment: ps.sentiment === null ? undefined : ps.sentiment,
|
|
||||||
messagesSent: ps.messagesSent === null ? undefined : ps.messagesSent,
|
|
||||||
avgResponseTime:
|
|
||||||
ps.avgResponseTime === null ? undefined : ps.avgResponseTime,
|
|
||||||
escalated: ps.escalated || false,
|
|
||||||
forwardedHr: ps.forwardedHr || false,
|
|
||||||
initialMsg: ps.initialMsg || undefined,
|
|
||||||
fullTranscriptUrl: ps.fullTranscriptUrl || undefined,
|
|
||||||
summary: ps.summary || undefined,
|
|
||||||
messages: mockMessages, // Use questions as messages for metrics
|
|
||||||
userId: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionUser {
|
interface SessionUser {
|
||||||
email: string;
|
email: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@ -170,8 +107,45 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
// Convert Prisma sessions to ChatSession[] type for sessionMetrics
|
// Convert Prisma sessions to ChatSession[] type for sessionMetrics
|
||||||
const chatSessions: ChatSession[] = prismaSessions.map((ps) => {
|
const chatSessions: ChatSession[] = prismaSessions.map((ps) => {
|
||||||
|
// Get questions for this session or empty array
|
||||||
const questions = questionsBySession[ps.id] || [];
|
const questions = questionsBySession[ps.id] || [];
|
||||||
return convertToMockChatSession(ps, questions);
|
|
||||||
|
// Convert questions to mock messages for backward compatibility
|
||||||
|
const mockMessages = questions.map((q, index) => ({
|
||||||
|
id: `question-${index}`,
|
||||||
|
sessionId: ps.id,
|
||||||
|
timestamp: ps.createdAt,
|
||||||
|
role: "User",
|
||||||
|
content: q,
|
||||||
|
order: index,
|
||||||
|
createdAt: ps.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: ps.id,
|
||||||
|
sessionId: ps.id,
|
||||||
|
companyId: ps.companyId,
|
||||||
|
startTime: new Date(ps.startTime),
|
||||||
|
endTime: ps.endTime ? new Date(ps.endTime) : null,
|
||||||
|
transcriptContent: "",
|
||||||
|
createdAt: new Date(ps.createdAt),
|
||||||
|
updatedAt: new Date(ps.createdAt),
|
||||||
|
category: ps.category || undefined,
|
||||||
|
language: ps.language || undefined,
|
||||||
|
country: ps.country || undefined,
|
||||||
|
ipAddress: ps.ipAddress || undefined,
|
||||||
|
sentiment: ps.sentiment === null ? undefined : ps.sentiment,
|
||||||
|
messagesSent: ps.messagesSent === null ? undefined : ps.messagesSent,
|
||||||
|
avgResponseTime:
|
||||||
|
ps.avgResponseTime === null ? undefined : ps.avgResponseTime,
|
||||||
|
escalated: ps.escalated || false,
|
||||||
|
forwardedHr: ps.forwardedHr || false,
|
||||||
|
initialMsg: ps.initialMsg || undefined,
|
||||||
|
fullTranscriptUrl: ps.fullTranscriptUrl || undefined,
|
||||||
|
summary: ps.summary || undefined,
|
||||||
|
messages: mockMessages, // Use questions as messages for metrics
|
||||||
|
userId: undefined,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pass company config to metrics
|
// Pass company config to metrics
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { getServerSession } from "next-auth/next";
|
import { getServerSession } from "next-auth/next";
|
||||||
import { authOptions } from "../../../../lib/auth";
|
import { authOptions } from "../../../../lib/auth";
|
||||||
import { prisma } from "../../../../lib/prisma";
|
import { prisma } from "../../../../lib/prisma";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(_request: NextRequest) {
|
||||||
const authSession = await getServerSession(authOptions);
|
const authSession = await getServerSession(authOptions);
|
||||||
|
|
||||||
if (!authSession || !authSession.user?.companyId) {
|
if (!authSession || !authSession.user?.companyId) {
|
||||||
@ -14,8 +14,6 @@ export async function GET() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Use groupBy for better performance with distinct values
|
// Use groupBy for better performance with distinct values
|
||||||
// Limit results to prevent unbounded queries
|
|
||||||
const MAX_FILTER_OPTIONS = 1000;
|
|
||||||
const [categoryGroups, languageGroups] = await Promise.all([
|
const [categoryGroups, languageGroups] = await Promise.all([
|
||||||
prisma.session.groupBy({
|
prisma.session.groupBy({
|
||||||
by: ["category"],
|
by: ["category"],
|
||||||
@ -26,7 +24,6 @@ export async function GET() {
|
|||||||
orderBy: {
|
orderBy: {
|
||||||
category: "asc",
|
category: "asc",
|
||||||
},
|
},
|
||||||
take: MAX_FILTER_OPTIONS,
|
|
||||||
}),
|
}),
|
||||||
prisma.session.groupBy({
|
prisma.session.groupBy({
|
||||||
by: ["language"],
|
by: ["language"],
|
||||||
@ -37,7 +34,6 @@ export async function GET() {
|
|||||||
orderBy: {
|
orderBy: {
|
||||||
language: "asc",
|
language: "asc",
|
||||||
},
|
},
|
||||||
take: MAX_FILTER_OPTIONS,
|
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -2,78 +2,6 @@ import { type NextRequest, NextResponse } from "next/server";
|
|||||||
import { prisma } from "../../../../../lib/prisma";
|
import { prisma } from "../../../../../lib/prisma";
|
||||||
import type { ChatSession } from "../../../../../lib/types";
|
import type { ChatSession } from "../../../../../lib/types";
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps Prisma session object to ChatSession type
|
|
||||||
*/
|
|
||||||
function mapPrismaSessionToChatSession(prismaSession: {
|
|
||||||
id: string;
|
|
||||||
startTime: Date;
|
|
||||||
endTime: Date | null;
|
|
||||||
createdAt: Date;
|
|
||||||
category: string | null;
|
|
||||||
language: string | null;
|
|
||||||
country: string | null;
|
|
||||||
ipAddress: string | null;
|
|
||||||
sentiment: string | null;
|
|
||||||
messagesSent: number | null;
|
|
||||||
avgResponseTime: number | null;
|
|
||||||
escalated: boolean | null;
|
|
||||||
forwardedHr: boolean | null;
|
|
||||||
initialMsg: string | null;
|
|
||||||
fullTranscriptUrl: string | null;
|
|
||||||
summary: string | null;
|
|
||||||
messages: Array<{
|
|
||||||
id: string;
|
|
||||||
sessionId: string;
|
|
||||||
timestamp: Date | null;
|
|
||||||
role: string;
|
|
||||||
content: string;
|
|
||||||
order: number;
|
|
||||||
createdAt: Date;
|
|
||||||
}>;
|
|
||||||
}): ChatSession {
|
|
||||||
return {
|
|
||||||
// Spread prismaSession to include all its properties
|
|
||||||
...prismaSession,
|
|
||||||
// Override properties that need conversion or specific mapping
|
|
||||||
id: prismaSession.id, // ChatSession.id from Prisma.Session.id
|
|
||||||
sessionId: prismaSession.id, // ChatSession.sessionId from Prisma.Session.id
|
|
||||||
startTime: new Date(prismaSession.startTime),
|
|
||||||
endTime: prismaSession.endTime ? new Date(prismaSession.endTime) : null,
|
|
||||||
createdAt: new Date(prismaSession.createdAt),
|
|
||||||
// Prisma.Session does not have an `updatedAt` field. We'll use `createdAt` as a fallback.
|
|
||||||
updatedAt: new Date(prismaSession.createdAt), // Fallback to createdAt
|
|
||||||
// Prisma.Session does not have a `userId` field.
|
|
||||||
userId: null, // Explicitly set to null or map if available from another source
|
|
||||||
// Prisma.Session does not have a `companyId` field.
|
|
||||||
companyId: "", // Explicitly set to empty string - should be resolved from session context
|
|
||||||
// Ensure nullable fields from Prisma are correctly mapped to ChatSession's optional or nullable fields
|
|
||||||
category: prismaSession.category ?? null,
|
|
||||||
language: prismaSession.language ?? null,
|
|
||||||
country: prismaSession.country ?? null,
|
|
||||||
ipAddress: prismaSession.ipAddress ?? null,
|
|
||||||
sentiment: prismaSession.sentiment ?? null,
|
|
||||||
messagesSent: prismaSession.messagesSent ?? undefined,
|
|
||||||
avgResponseTime: prismaSession.avgResponseTime ?? null,
|
|
||||||
escalated: prismaSession.escalated ?? undefined,
|
|
||||||
forwardedHr: prismaSession.forwardedHr ?? undefined,
|
|
||||||
initialMsg: prismaSession.initialMsg ?? undefined,
|
|
||||||
fullTranscriptUrl: prismaSession.fullTranscriptUrl ?? null,
|
|
||||||
summary: prismaSession.summary ?? null,
|
|
||||||
transcriptContent: undefined, // Not available in Session model
|
|
||||||
messages:
|
|
||||||
prismaSession.messages?.map((msg) => ({
|
|
||||||
id: msg.id,
|
|
||||||
sessionId: msg.sessionId,
|
|
||||||
timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
|
|
||||||
role: msg.role,
|
|
||||||
content: msg.content,
|
|
||||||
order: msg.order,
|
|
||||||
createdAt: new Date(msg.createdAt),
|
|
||||||
})) ?? [], // New field - parsed messages
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
@ -102,7 +30,45 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map Prisma session object to ChatSession type
|
// Map Prisma session object to ChatSession type
|
||||||
const session: ChatSession = mapPrismaSessionToChatSession(prismaSession);
|
const session: ChatSession = {
|
||||||
|
// Spread prismaSession to include all its properties
|
||||||
|
...prismaSession,
|
||||||
|
// Override properties that need conversion or specific mapping
|
||||||
|
id: prismaSession.id, // ChatSession.id from Prisma.Session.id
|
||||||
|
sessionId: prismaSession.id, // ChatSession.sessionId from Prisma.Session.id
|
||||||
|
startTime: new Date(prismaSession.startTime),
|
||||||
|
endTime: prismaSession.endTime ? new Date(prismaSession.endTime) : null,
|
||||||
|
createdAt: new Date(prismaSession.createdAt),
|
||||||
|
// Prisma.Session does not have an `updatedAt` field. We'll use `createdAt` as a fallback.
|
||||||
|
// Or, if your business logic implies an update timestamp elsewhere, use that.
|
||||||
|
updatedAt: new Date(prismaSession.createdAt), // Fallback to createdAt
|
||||||
|
// Prisma.Session does not have a `userId` field.
|
||||||
|
userId: null, // Explicitly set to null or map if available from another source
|
||||||
|
// Ensure nullable fields from Prisma are correctly mapped to ChatSession's optional or nullable fields
|
||||||
|
category: prismaSession.category ?? null,
|
||||||
|
language: prismaSession.language ?? null,
|
||||||
|
country: prismaSession.country ?? null,
|
||||||
|
ipAddress: prismaSession.ipAddress ?? null,
|
||||||
|
sentiment: prismaSession.sentiment ?? null,
|
||||||
|
messagesSent: prismaSession.messagesSent ?? undefined, // Use undefined if ChatSession expects number | undefined
|
||||||
|
avgResponseTime: prismaSession.avgResponseTime ?? null,
|
||||||
|
escalated: prismaSession.escalated ?? undefined,
|
||||||
|
forwardedHr: prismaSession.forwardedHr ?? undefined,
|
||||||
|
initialMsg: prismaSession.initialMsg ?? undefined,
|
||||||
|
fullTranscriptUrl: prismaSession.fullTranscriptUrl ?? null,
|
||||||
|
summary: prismaSession.summary ?? null, // New field
|
||||||
|
transcriptContent: null, // Not available in Session model
|
||||||
|
messages:
|
||||||
|
prismaSession.messages?.map((msg) => ({
|
||||||
|
id: msg.id,
|
||||||
|
sessionId: msg.sessionId,
|
||||||
|
timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
|
||||||
|
role: msg.role,
|
||||||
|
content: msg.content,
|
||||||
|
order: msg.order,
|
||||||
|
createdAt: new Date(msg.createdAt),
|
||||||
|
})) ?? [], // New field - parsed messages
|
||||||
|
};
|
||||||
|
|
||||||
return NextResponse.json({ session });
|
return NextResponse.json({ session });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -1,303 +0,0 @@
|
|||||||
/**
|
|
||||||
* Refactored Sessions API Endpoint
|
|
||||||
*
|
|
||||||
* This demonstrates how to use the new standardized API architecture
|
|
||||||
* for consistent error handling, validation, authentication, and response formatting.
|
|
||||||
*
|
|
||||||
* BEFORE: Manual auth, inconsistent errors, no validation, mixed response format
|
|
||||||
* AFTER: Standardized middleware, typed validation, consistent responses, audit logging
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Prisma } from "@prisma/client";
|
|
||||||
import { SessionCategory } from "@prisma/client";
|
|
||||||
import { z } from "zod";
|
|
||||||
import {
|
|
||||||
calculatePaginationMeta,
|
|
||||||
createAuthenticatedHandler,
|
|
||||||
createPaginatedResponse,
|
|
||||||
DatabaseError,
|
|
||||||
} from "@/lib/api";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import type { ChatSession } from "@/lib/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Input validation schema for session queries
|
|
||||||
*/
|
|
||||||
const SessionQuerySchema = z.object({
|
|
||||||
// Search parameters
|
|
||||||
searchTerm: z.string().max(100).optional(),
|
|
||||||
category: z.nativeEnum(SessionCategory).optional(),
|
|
||||||
language: z.string().min(2).max(5).optional(),
|
|
||||||
|
|
||||||
// Date filtering
|
|
||||||
startDate: z.string().date().optional(),
|
|
||||||
endDate: z.string().date().optional(),
|
|
||||||
|
|
||||||
// Sorting
|
|
||||||
sortKey: z
|
|
||||||
.enum([
|
|
||||||
"startTime",
|
|
||||||
"category",
|
|
||||||
"language",
|
|
||||||
"sentiment",
|
|
||||||
"messagesSent",
|
|
||||||
"avgResponseTime",
|
|
||||||
])
|
|
||||||
.default("startTime"),
|
|
||||||
sortOrder: z.enum(["asc", "desc"]).default("desc"),
|
|
||||||
|
|
||||||
// Pagination (handled by middleware but included for completeness)
|
|
||||||
page: z.coerce.number().min(1).default(1),
|
|
||||||
limit: z.coerce.number().min(1).max(100).default(20),
|
|
||||||
});
|
|
||||||
|
|
||||||
type SessionQueryInput = z.infer<typeof SessionQuerySchema>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build where clause for session filtering
|
|
||||||
*/
|
|
||||||
function buildWhereClause(
|
|
||||||
companyId: string,
|
|
||||||
filters: SessionQueryInput
|
|
||||||
): Prisma.SessionWhereInput {
|
|
||||||
const whereClause: Prisma.SessionWhereInput = { companyId };
|
|
||||||
|
|
||||||
// Search across multiple fields
|
|
||||||
if (filters.searchTerm?.trim()) {
|
|
||||||
whereClause.OR = [
|
|
||||||
{ id: { contains: filters.searchTerm, mode: "insensitive" } },
|
|
||||||
{ initialMsg: { contains: filters.searchTerm, mode: "insensitive" } },
|
|
||||||
{ summary: { contains: filters.searchTerm, mode: "insensitive" } },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Category filter
|
|
||||||
if (filters.category) {
|
|
||||||
whereClause.category = filters.category;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Language filter
|
|
||||||
if (filters.language) {
|
|
||||||
whereClause.language = filters.language;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Date range filter
|
|
||||||
if (filters.startDate || filters.endDate) {
|
|
||||||
whereClause.startTime = {};
|
|
||||||
|
|
||||||
if (filters.startDate) {
|
|
||||||
whereClause.startTime.gte = new Date(filters.startDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.endDate) {
|
|
||||||
// Make end date inclusive by adding one day
|
|
||||||
const inclusiveEndDate = new Date(filters.endDate);
|
|
||||||
inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1);
|
|
||||||
whereClause.startTime.lt = inclusiveEndDate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return whereClause;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build order by clause for session sorting
|
|
||||||
*/
|
|
||||||
function buildOrderByClause(
|
|
||||||
filters: SessionQueryInput
|
|
||||||
):
|
|
||||||
| Prisma.SessionOrderByWithRelationInput
|
|
||||||
| Prisma.SessionOrderByWithRelationInput[] {
|
|
||||||
if (filters.sortKey === "startTime") {
|
|
||||||
return { startTime: filters.sortOrder };
|
|
||||||
}
|
|
||||||
|
|
||||||
// For non-time fields, add secondary sort by startTime
|
|
||||||
return [{ [filters.sortKey]: filters.sortOrder }, { startTime: "desc" }];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert Prisma session to ChatSession format
|
|
||||||
*/
|
|
||||||
function convertPrismaSessionToChatSession(ps: {
|
|
||||||
id: string;
|
|
||||||
companyId: string;
|
|
||||||
startTime: Date;
|
|
||||||
endTime: Date | null;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
category: string | null;
|
|
||||||
language: string | null;
|
|
||||||
country: string | null;
|
|
||||||
ipAddress: string | null;
|
|
||||||
sentiment: string | null;
|
|
||||||
messagesSent: number | null;
|
|
||||||
avgResponseTime: number | null;
|
|
||||||
escalated: boolean | null;
|
|
||||||
forwardedHr: boolean | null;
|
|
||||||
initialMsg: string | null;
|
|
||||||
fullTranscriptUrl: string | null;
|
|
||||||
summary: string | null;
|
|
||||||
}): ChatSession {
|
|
||||||
return {
|
|
||||||
id: ps.id,
|
|
||||||
sessionId: ps.id, // Using ID as sessionId for consistency
|
|
||||||
companyId: ps.companyId,
|
|
||||||
startTime: ps.startTime,
|
|
||||||
endTime: ps.endTime,
|
|
||||||
createdAt: ps.createdAt,
|
|
||||||
updatedAt: ps.updatedAt,
|
|
||||||
userId: null, // Not stored at session level
|
|
||||||
category: ps.category,
|
|
||||||
language: ps.language,
|
|
||||||
country: ps.country,
|
|
||||||
ipAddress: ps.ipAddress,
|
|
||||||
sentiment: ps.sentiment,
|
|
||||||
messagesSent: ps.messagesSent ?? undefined,
|
|
||||||
avgResponseTime: ps.avgResponseTime,
|
|
||||||
escalated: ps.escalated ?? undefined,
|
|
||||||
forwardedHr: ps.forwardedHr ?? undefined,
|
|
||||||
initialMsg: ps.initialMsg ?? undefined,
|
|
||||||
fullTranscriptUrl: ps.fullTranscriptUrl,
|
|
||||||
summary: ps.summary,
|
|
||||||
transcriptContent: null, // Not included in list view for performance
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/dashboard/sessions
|
|
||||||
*
|
|
||||||
* Retrieve paginated list of sessions with filtering and sorting capabilities.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Automatic authentication and company access validation
|
|
||||||
* - Input validation with Zod schemas
|
|
||||||
* - Consistent error handling and response format
|
|
||||||
* - Audit logging for security monitoring
|
|
||||||
* - Rate limiting protection
|
|
||||||
* - Pagination with metadata
|
|
||||||
*/
|
|
||||||
export const GET = createAuthenticatedHandler(
|
|
||||||
async (context, _, validatedQuery) => {
|
|
||||||
const filters = validatedQuery as SessionQueryInput;
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: pagination is guaranteed to exist when enablePagination is true
|
|
||||||
const { page, limit } = context.pagination!;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Validate company access (users can only see their company's sessions)
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: user is guaranteed to exist in authenticated handler
|
|
||||||
const companyId = context.user!.companyId;
|
|
||||||
|
|
||||||
// Build query conditions
|
|
||||||
const whereClause = buildWhereClause(companyId, filters);
|
|
||||||
const orderByClause = buildOrderByClause(filters);
|
|
||||||
|
|
||||||
// Execute queries in parallel for better performance
|
|
||||||
const [sessions, totalCount] = await Promise.all([
|
|
||||||
prisma.session.findMany({
|
|
||||||
where: whereClause,
|
|
||||||
orderBy: orderByClause,
|
|
||||||
skip: (page - 1) * limit,
|
|
||||||
take: limit,
|
|
||||||
// Only select needed fields for performance
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
companyId: true,
|
|
||||||
startTime: true,
|
|
||||||
endTime: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
category: true,
|
|
||||||
language: true,
|
|
||||||
country: true,
|
|
||||||
ipAddress: true,
|
|
||||||
sentiment: true,
|
|
||||||
messagesSent: true,
|
|
||||||
avgResponseTime: true,
|
|
||||||
escalated: true,
|
|
||||||
forwardedHr: true,
|
|
||||||
initialMsg: true,
|
|
||||||
fullTranscriptUrl: true,
|
|
||||||
summary: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.session.count({ where: whereClause }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Transform data
|
|
||||||
const transformedSessions: ChatSession[] = sessions.map(
|
|
||||||
convertPrismaSessionToChatSession
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calculate pagination metadata
|
|
||||||
const paginationMeta = calculatePaginationMeta(page, limit, totalCount);
|
|
||||||
|
|
||||||
// Return paginated response with metadata
|
|
||||||
return createPaginatedResponse(transformedSessions, paginationMeta);
|
|
||||||
} catch (error) {
|
|
||||||
// Database errors are automatically handled by the error system
|
|
||||||
if (error instanceof Error) {
|
|
||||||
throw new DatabaseError("Failed to fetch sessions", {
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: user is guaranteed to exist in authenticated handler
|
|
||||||
companyId: context.user!.companyId,
|
|
||||||
filters,
|
|
||||||
error: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Configuration
|
|
||||||
validateQuery: SessionQuerySchema,
|
|
||||||
enablePagination: true,
|
|
||||||
auditLog: true,
|
|
||||||
rateLimit: {
|
|
||||||
maxRequests: 60, // 60 requests per window
|
|
||||||
windowMs: 60 * 1000, // 1 minute window
|
|
||||||
},
|
|
||||||
cacheControl: "private, max-age=30", // Cache for 30 seconds
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/*
|
|
||||||
COMPARISON: Before vs After Refactoring
|
|
||||||
|
|
||||||
BEFORE (Original Implementation):
|
|
||||||
- ❌ Manual session authentication with repetitive code
|
|
||||||
- ❌ Inconsistent error responses: { error: "...", details: "..." }
|
|
||||||
- ❌ No input validation - accepts any query parameters
|
|
||||||
- ❌ No rate limiting protection
|
|
||||||
- ❌ No audit logging for security monitoring
|
|
||||||
- ❌ Manual pagination parameter extraction
|
|
||||||
- ❌ Inconsistent response format: { sessions, totalSessions }
|
|
||||||
- ❌ Basic error logging without context
|
|
||||||
- ❌ No company access validation
|
|
||||||
- ❌ Performance issue: sequential database queries
|
|
||||||
|
|
||||||
AFTER (Refactored with New Architecture):
|
|
||||||
- ✅ Automatic authentication via createAuthenticatedHandler middleware
|
|
||||||
- ✅ Standardized error responses with proper status codes and request IDs
|
|
||||||
- ✅ Strong input validation with Zod schemas and type safety
|
|
||||||
- ✅ Built-in rate limiting (60 req/min) with configurable limits
|
|
||||||
- ✅ Automatic audit logging for security compliance
|
|
||||||
- ✅ Automatic pagination handling via middleware
|
|
||||||
- ✅ Consistent API response format with metadata
|
|
||||||
- ✅ Comprehensive error handling with proper categorization
|
|
||||||
- ✅ Automatic company access validation for multi-tenant security
|
|
||||||
- ✅ Performance optimization: parallel database queries
|
|
||||||
|
|
||||||
BENEFITS:
|
|
||||||
1. **Consistency**: All endpoints follow the same patterns
|
|
||||||
2. **Security**: Built-in auth, rate limiting, audit logging, company isolation
|
|
||||||
3. **Maintainability**: Less boilerplate, centralized logic, type safety
|
|
||||||
4. **Performance**: Optimized queries, caching headers, parallel execution
|
|
||||||
5. **Developer Experience**: Better error messages, validation, debugging
|
|
||||||
6. **Scalability**: Standardized patterns that can be applied across all endpoints
|
|
||||||
|
|
||||||
MIGRATION STRATEGY:
|
|
||||||
1. Replace the original route.ts with this refactored version
|
|
||||||
2. Update any frontend code to expect the new response format
|
|
||||||
3. Test thoroughly to ensure backward compatibility where needed
|
|
||||||
4. Repeat this pattern for other endpoints
|
|
||||||
*/
|
|
||||||
@ -1,142 +1,10 @@
|
|||||||
import type { Prisma, SessionCategory } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { getServerSession } from "next-auth/next";
|
import { getServerSession } from "next-auth/next";
|
||||||
import { authOptions } from "../../../../lib/auth";
|
import { authOptions } from "../../../../lib/auth";
|
||||||
import { prisma } from "../../../../lib/prisma";
|
import { prisma } from "../../../../lib/prisma";
|
||||||
import type { ChatSession } from "../../../../lib/types";
|
import type { ChatSession } from "../../../../lib/types";
|
||||||
|
|
||||||
/**
|
|
||||||
* Build where clause for session filtering
|
|
||||||
*/
|
|
||||||
function buildWhereClause(
|
|
||||||
companyId: string,
|
|
||||||
searchParams: URLSearchParams
|
|
||||||
): Prisma.SessionWhereInput {
|
|
||||||
const whereClause: Prisma.SessionWhereInput = { companyId };
|
|
||||||
|
|
||||||
const searchTerm = searchParams.get("searchTerm");
|
|
||||||
const category = searchParams.get("category");
|
|
||||||
const language = searchParams.get("language");
|
|
||||||
const startDate = searchParams.get("startDate");
|
|
||||||
const endDate = searchParams.get("endDate");
|
|
||||||
|
|
||||||
// Search Term
|
|
||||||
if (searchTerm && searchTerm.trim() !== "") {
|
|
||||||
const searchConditions = [
|
|
||||||
{ id: { contains: searchTerm } },
|
|
||||||
{ initialMsg: { contains: searchTerm } },
|
|
||||||
{ summary: { contains: searchTerm } },
|
|
||||||
];
|
|
||||||
whereClause.OR = searchConditions;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Category Filter
|
|
||||||
if (category && category.trim() !== "") {
|
|
||||||
whereClause.category = category as SessionCategory;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Language Filter
|
|
||||||
if (language && language.trim() !== "") {
|
|
||||||
whereClause.language = language;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Date Range Filter
|
|
||||||
const dateFilters: { gte?: Date; lt?: Date } = {};
|
|
||||||
|
|
||||||
if (startDate) {
|
|
||||||
dateFilters.gte = new Date(startDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endDate) {
|
|
||||||
const inclusiveEndDate = new Date(endDate);
|
|
||||||
inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1);
|
|
||||||
dateFilters.lt = inclusiveEndDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(dateFilters).length > 0) {
|
|
||||||
whereClause.startTime = dateFilters;
|
|
||||||
}
|
|
||||||
|
|
||||||
return whereClause;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build order by clause for session sorting
|
|
||||||
*/
|
|
||||||
function buildOrderByClause(
|
|
||||||
searchParams: URLSearchParams
|
|
||||||
):
|
|
||||||
| Prisma.SessionOrderByWithRelationInput
|
|
||||||
| Prisma.SessionOrderByWithRelationInput[] {
|
|
||||||
const sortKey = searchParams.get("sortKey");
|
|
||||||
const sortOrder = searchParams.get("sortOrder");
|
|
||||||
|
|
||||||
const validSortKeys: { [key: string]: string } = {
|
|
||||||
startTime: "startTime",
|
|
||||||
category: "category",
|
|
||||||
language: "language",
|
|
||||||
sentiment: "sentiment",
|
|
||||||
messagesSent: "messagesSent",
|
|
||||||
avgResponseTime: "avgResponseTime",
|
|
||||||
};
|
|
||||||
|
|
||||||
const primarySortField =
|
|
||||||
sortKey && validSortKeys[sortKey] ? validSortKeys[sortKey] : "startTime";
|
|
||||||
const primarySortOrder =
|
|
||||||
sortOrder === "asc" || sortOrder === "desc" ? sortOrder : "desc";
|
|
||||||
|
|
||||||
if (primarySortField === "startTime") {
|
|
||||||
return { [primarySortField]: primarySortOrder };
|
|
||||||
}
|
|
||||||
|
|
||||||
return [{ [primarySortField]: primarySortOrder }, { startTime: "desc" }];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert Prisma session to ChatSession format
|
|
||||||
*/
|
|
||||||
function convertPrismaSessionToChatSession(ps: {
|
|
||||||
id: string;
|
|
||||||
companyId: string;
|
|
||||||
startTime: Date;
|
|
||||||
endTime: Date | null;
|
|
||||||
createdAt: Date;
|
|
||||||
category: string | null;
|
|
||||||
language: string | null;
|
|
||||||
country: string | null;
|
|
||||||
ipAddress: string | null;
|
|
||||||
sentiment: string | null;
|
|
||||||
messagesSent: number | null;
|
|
||||||
avgResponseTime: number | null;
|
|
||||||
escalated: boolean | null;
|
|
||||||
forwardedHr: boolean | null;
|
|
||||||
initialMsg: string | null;
|
|
||||||
fullTranscriptUrl: string | null;
|
|
||||||
}): ChatSession {
|
|
||||||
return {
|
|
||||||
id: ps.id,
|
|
||||||
sessionId: ps.id,
|
|
||||||
companyId: ps.companyId,
|
|
||||||
startTime: new Date(ps.startTime),
|
|
||||||
endTime: ps.endTime ? new Date(ps.endTime) : null,
|
|
||||||
createdAt: new Date(ps.createdAt),
|
|
||||||
updatedAt: new Date(ps.createdAt),
|
|
||||||
userId: null,
|
|
||||||
category: ps.category ?? null,
|
|
||||||
language: ps.language ?? null,
|
|
||||||
country: ps.country ?? null,
|
|
||||||
ipAddress: ps.ipAddress ?? null,
|
|
||||||
sentiment: ps.sentiment ?? null,
|
|
||||||
messagesSent: ps.messagesSent ?? undefined,
|
|
||||||
avgResponseTime: ps.avgResponseTime ?? null,
|
|
||||||
escalated: ps.escalated ?? undefined,
|
|
||||||
forwardedHr: ps.forwardedHr ?? undefined,
|
|
||||||
initialMsg: ps.initialMsg ?? undefined,
|
|
||||||
fullTranscriptUrl: ps.fullTranscriptUrl ?? null,
|
|
||||||
transcriptContent: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const authSession = await getServerSession(authOptions);
|
const authSession = await getServerSession(authOptions);
|
||||||
|
|
||||||
@ -147,14 +15,89 @@ export async function GET(request: NextRequest) {
|
|||||||
const companyId = authSession.user.companyId;
|
const companyId = authSession.user.companyId;
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const searchTerm = searchParams.get("searchTerm");
|
||||||
|
const category = searchParams.get("category");
|
||||||
|
const language = searchParams.get("language");
|
||||||
|
const startDate = searchParams.get("startDate");
|
||||||
|
const endDate = searchParams.get("endDate");
|
||||||
|
const sortKey = searchParams.get("sortKey");
|
||||||
|
const sortOrder = searchParams.get("sortOrder");
|
||||||
const queryPage = searchParams.get("page");
|
const queryPage = searchParams.get("page");
|
||||||
const queryPageSize = searchParams.get("pageSize");
|
const queryPageSize = searchParams.get("pageSize");
|
||||||
|
|
||||||
const page = Number(queryPage) || 1;
|
const page = Number(queryPage) || 1;
|
||||||
const pageSize = Number(queryPageSize) || 10;
|
const pageSize = Number(queryPageSize) || 10;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const whereClause = buildWhereClause(companyId, searchParams);
|
const whereClause: Prisma.SessionWhereInput = { companyId };
|
||||||
const orderByCondition = buildOrderByClause(searchParams);
|
|
||||||
|
// Search Term
|
||||||
|
if (searchTerm && searchTerm.trim() !== "") {
|
||||||
|
const searchConditions = [
|
||||||
|
{ id: { contains: searchTerm } },
|
||||||
|
{ initialMsg: { contains: searchTerm } },
|
||||||
|
{ summary: { contains: searchTerm } },
|
||||||
|
];
|
||||||
|
whereClause.OR = searchConditions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category Filter
|
||||||
|
if (category && category.trim() !== "") {
|
||||||
|
// Cast to SessionCategory enum if it's a valid value
|
||||||
|
whereClause.category = category;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Language Filter
|
||||||
|
if (language && language.trim() !== "") {
|
||||||
|
whereClause.language = language;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date Range Filter
|
||||||
|
if (startDate) {
|
||||||
|
whereClause.startTime = {
|
||||||
|
...((whereClause.startTime as object) || {}),
|
||||||
|
gte: new Date(startDate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
const inclusiveEndDate = new Date(endDate);
|
||||||
|
inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1);
|
||||||
|
whereClause.startTime = {
|
||||||
|
...((whereClause.startTime as object) || {}),
|
||||||
|
lt: inclusiveEndDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
const validSortKeys: { [key: string]: string } = {
|
||||||
|
startTime: "startTime",
|
||||||
|
category: "category",
|
||||||
|
language: "language",
|
||||||
|
sentiment: "sentiment",
|
||||||
|
messagesSent: "messagesSent",
|
||||||
|
avgResponseTime: "avgResponseTime",
|
||||||
|
};
|
||||||
|
|
||||||
|
let orderByCondition:
|
||||||
|
| Prisma.SessionOrderByWithRelationInput
|
||||||
|
| Prisma.SessionOrderByWithRelationInput[];
|
||||||
|
|
||||||
|
const primarySortField =
|
||||||
|
sortKey && validSortKeys[sortKey] ? validSortKeys[sortKey] : "startTime"; // Default to startTime field if sortKey is invalid/missing
|
||||||
|
|
||||||
|
const primarySortOrder =
|
||||||
|
sortOrder === "asc" || sortOrder === "desc" ? sortOrder : "desc"; // Default to desc order
|
||||||
|
|
||||||
|
if (primarySortField === "startTime") {
|
||||||
|
// If sorting by startTime, it's the only sort criteria
|
||||||
|
orderByCondition = { [primarySortField]: primarySortOrder };
|
||||||
|
} else {
|
||||||
|
// If sorting by another field, use startTime: "desc" as secondary sort
|
||||||
|
orderByCondition = [
|
||||||
|
{ [primarySortField]: primarySortOrder },
|
||||||
|
{ startTime: "desc" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
const prismaSessions = await prisma.session.findMany({
|
const prismaSessions = await prisma.session.findMany({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
@ -165,9 +108,28 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const totalSessions = await prisma.session.count({ where: whereClause });
|
const totalSessions = await prisma.session.count({ where: whereClause });
|
||||||
|
|
||||||
const sessions: ChatSession[] = prismaSessions.map(
|
const sessions: ChatSession[] = prismaSessions.map((ps) => ({
|
||||||
convertPrismaSessionToChatSession
|
id: ps.id,
|
||||||
);
|
sessionId: ps.id,
|
||||||
|
companyId: ps.companyId,
|
||||||
|
startTime: new Date(ps.startTime),
|
||||||
|
endTime: ps.endTime ? new Date(ps.endTime) : null,
|
||||||
|
createdAt: new Date(ps.createdAt),
|
||||||
|
updatedAt: new Date(ps.createdAt),
|
||||||
|
userId: null,
|
||||||
|
category: ps.category ?? null,
|
||||||
|
language: ps.language ?? null,
|
||||||
|
country: ps.country ?? null,
|
||||||
|
ipAddress: ps.ipAddress ?? null,
|
||||||
|
sentiment: ps.sentiment ?? null,
|
||||||
|
messagesSent: ps.messagesSent ?? undefined,
|
||||||
|
avgResponseTime: ps.avgResponseTime ?? null,
|
||||||
|
escalated: ps.escalated ?? undefined,
|
||||||
|
forwardedHr: ps.forwardedHr ?? undefined,
|
||||||
|
initialMsg: ps.initialMsg ?? undefined,
|
||||||
|
fullTranscriptUrl: ps.fullTranscriptUrl ?? null,
|
||||||
|
transcriptContent: null, // Transcript content is now fetched from fullTranscriptUrl when needed
|
||||||
|
}));
|
||||||
|
|
||||||
return NextResponse.json({ sessions, totalSessions });
|
return NextResponse.json({ sessions, totalSessions });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -11,7 +11,7 @@ interface UserBasicInfo {
|
|||||||
role: string;
|
role: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(_request: NextRequest) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session?.user || session.user.role !== "ADMIN") {
|
if (!session?.user || session.user.role !== "ADMIN") {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
@ -27,8 +27,6 @@ export async function GET() {
|
|||||||
|
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
where: { companyId: user.companyId },
|
where: { companyId: user.companyId },
|
||||||
take: 1000, // Limit to prevent unbounded queries
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const mappedUsers: UserBasicInfo[] = users.map((u) => ({
|
const mappedUsers: UserBasicInfo[] = users.map((u) => ({
|
||||||
@ -77,17 +75,6 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { sendPasswordResetEmail } = await import("../../../../lib/sendEmail");
|
// TODO: Email user their temp password (stub, for demo) - Implement a robust and secure email sending mechanism. Consider using a transactional email service.
|
||||||
const emailResult = await sendPasswordResetEmail(email, tempPassword);
|
return NextResponse.json({ ok: true, tempPassword });
|
||||||
|
|
||||||
if (!emailResult.success) {
|
|
||||||
console.warn("Failed to send password email:", emailResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
ok: true,
|
|
||||||
tempPassword,
|
|
||||||
emailSent: emailResult.success,
|
|
||||||
emailError: emailResult.error,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,46 +1,38 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "../../../lib/prisma";
|
import { prisma } from "../../../lib/prisma";
|
||||||
import { extractClientIP, InMemoryRateLimiter } from "../../../lib/rateLimiter";
|
|
||||||
import {
|
|
||||||
AuditOutcome,
|
|
||||||
createAuditMetadata,
|
|
||||||
securityAuditLogger,
|
|
||||||
} from "../../../lib/securityAuditLogger";
|
|
||||||
import { sendEmail } from "../../../lib/sendEmail";
|
import { sendEmail } from "../../../lib/sendEmail";
|
||||||
import { forgotPasswordSchema, validateInput } from "../../../lib/validation";
|
import { forgotPasswordSchema, validateInput } from "../../../lib/validation";
|
||||||
|
|
||||||
// Rate limiting for password reset endpoint
|
// In-memory rate limiting for password reset requests
|
||||||
const passwordResetLimiter = new InMemoryRateLimiter({
|
const resetAttempts = new Map<string, { count: number; resetTime: number }>();
|
||||||
maxAttempts: 5,
|
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
function checkRateLimit(ip: string): boolean {
|
||||||
maxEntries: 10000,
|
const now = Date.now();
|
||||||
cleanupIntervalMs: 5 * 60 * 1000, // 5 minutes
|
const attempts = resetAttempts.get(ip);
|
||||||
});
|
|
||||||
|
if (!attempts || now > attempts.resetTime) {
|
||||||
|
resetAttempts.set(ip, { count: 1, resetTime: now + 15 * 60 * 1000 }); // 15 minute window
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempts.count >= 5) {
|
||||||
|
// Max 5 reset requests per 15 minutes per IP
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts.count++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Rate limiting check using shared utility
|
// Rate limiting check
|
||||||
const ip = extractClientIP(request);
|
const ip =
|
||||||
const userAgent = request.headers.get("user-agent") || undefined;
|
request.headers.get("x-forwarded-for") ||
|
||||||
const rateLimitResult = passwordResetLimiter.checkRateLimit(ip);
|
request.headers.get("x-real-ip") ||
|
||||||
|
"unknown";
|
||||||
if (!rateLimitResult.allowed) {
|
if (!checkRateLimit(ip)) {
|
||||||
await securityAuditLogger.logPasswordReset(
|
|
||||||
"password_reset_rate_limited",
|
|
||||||
AuditOutcome.RATE_LIMITED,
|
|
||||||
{
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
resetTime: rateLimitResult.resetTime,
|
|
||||||
maxAttempts: 5,
|
|
||||||
windowMs: 15 * 60 * 1000,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"Password reset rate limit exceeded"
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
@ -55,19 +47,6 @@ export async function POST(request: NextRequest) {
|
|||||||
// Validate input
|
// Validate input
|
||||||
const validation = validateInput(forgotPasswordSchema, body);
|
const validation = validateInput(forgotPasswordSchema, body);
|
||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
await securityAuditLogger.logPasswordReset(
|
|
||||||
"password_reset_invalid_input",
|
|
||||||
AuditOutcome.FAILURE,
|
|
||||||
{
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
error: "invalid_email_format",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"Invalid email format in password reset request"
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
@ -94,60 +73,16 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const resetUrl = `${process.env.NEXTAUTH_URL || "http://localhost:3000"}/reset-password?token=${token}`;
|
const resetUrl = `${process.env.NEXTAUTH_URL || "http://localhost:3000"}/reset-password?token=${token}`;
|
||||||
await sendEmail({
|
await sendEmail(
|
||||||
to: email,
|
email,
|
||||||
subject: "Password Reset",
|
"Password Reset",
|
||||||
text: `Reset your password: ${resetUrl}`,
|
`Reset your password: ${resetUrl}`
|
||||||
});
|
|
||||||
|
|
||||||
await securityAuditLogger.logPasswordReset(
|
|
||||||
"password_reset_email_sent",
|
|
||||||
AuditOutcome.SUCCESS,
|
|
||||||
{
|
|
||||||
userId: user.id,
|
|
||||||
companyId: user.companyId,
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
email: "[REDACTED]",
|
|
||||||
tokenExpiry: expiry.toISOString(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"Password reset email sent successfully"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Log attempt for non-existent user
|
|
||||||
await securityAuditLogger.logPasswordReset(
|
|
||||||
"password_reset_user_not_found",
|
|
||||||
AuditOutcome.FAILURE,
|
|
||||||
{
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
email: "[REDACTED]",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"Password reset attempt for non-existent user"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true }, { status: 200 });
|
return NextResponse.json({ success: true }, { status: 200 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Forgot password error:", error);
|
console.error("Forgot password error:", error);
|
||||||
|
|
||||||
await securityAuditLogger.logPasswordReset(
|
|
||||||
"password_reset_server_error",
|
|
||||||
AuditOutcome.FAILURE,
|
|
||||||
{
|
|
||||||
ipAddress: extractClientIP(request),
|
|
||||||
userAgent: request.headers.get("user-agent") || undefined,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
error: "server_error",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
`Server error in password reset: ${error}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@ -51,33 +51,17 @@ export async function POST(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user already exists (emails must be globally unique)
|
// Check if user already exists in this company
|
||||||
const existingUser = await prisma.user.findUnique({
|
const existingUser = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
email,
|
email,
|
||||||
},
|
companyId,
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
companyId: true,
|
|
||||||
company: {
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
if (existingUser.companyId === companyId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "User already exists in this company" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{ error: "User already exists in this company" },
|
||||||
error: `Email already in use by a user in company: ${existingUser.company.name}. Each email address can only be used once across all companies.`,
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,38 +1,15 @@
|
|||||||
import type { CompanyStatus } from "@prisma/client";
|
import type { CompanyStatus } from "@prisma/client";
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { getServerSession, type Session } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { platformAuthOptions } from "../../../../lib/platform-auth";
|
import { platformAuthOptions } from "../../../../lib/platform-auth";
|
||||||
import { prisma } from "../../../../lib/prisma";
|
import { prisma } from "../../../../lib/prisma";
|
||||||
import { extractClientIP } from "../../../../lib/rateLimiter";
|
|
||||||
import {
|
|
||||||
AuditOutcome,
|
|
||||||
createAuditMetadata,
|
|
||||||
securityAuditLogger,
|
|
||||||
} from "../../../../lib/securityAuditLogger";
|
|
||||||
|
|
||||||
// GET /api/platform/companies - List all companies
|
// GET /api/platform/companies - List all companies
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
let session: Session | null = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
session = await getServerSession(platformAuthOptions);
|
const session = await getServerSession(platformAuthOptions);
|
||||||
const ip = extractClientIP(request);
|
|
||||||
const userAgent = request.headers.get("user-agent") || undefined;
|
|
||||||
|
|
||||||
if (!session?.user?.isPlatformUser) {
|
if (!session?.user?.isPlatformUser) {
|
||||||
await securityAuditLogger.logPlatformAdmin(
|
|
||||||
"platform_companies_unauthorized_access",
|
|
||||||
AuditOutcome.BLOCKED,
|
|
||||||
{
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
error: "no_platform_session",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"Unauthorized attempt to access platform companies list"
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Platform access required" },
|
{ error: "Platform access required" },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
@ -86,24 +63,6 @@ export async function GET(request: NextRequest) {
|
|||||||
prisma.company.count({ where }),
|
prisma.company.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Log successful platform companies access
|
|
||||||
await securityAuditLogger.logPlatformAdmin(
|
|
||||||
"platform_companies_list_accessed",
|
|
||||||
AuditOutcome.SUCCESS,
|
|
||||||
{
|
|
||||||
platformUserId: session.user.id,
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
companiesReturned: companies.length,
|
|
||||||
totalCompanies: total,
|
|
||||||
filters: { status, search },
|
|
||||||
pagination: { page, limit },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"Platform companies list accessed"
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
companies,
|
companies,
|
||||||
pagination: {
|
pagination: {
|
||||||
@ -115,21 +74,6 @@ export async function GET(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Platform companies list error:", error);
|
console.error("Platform companies list error:", error);
|
||||||
|
|
||||||
await securityAuditLogger.logPlatformAdmin(
|
|
||||||
"platform_companies_list_error",
|
|
||||||
AuditOutcome.FAILURE,
|
|
||||||
{
|
|
||||||
platformUserId: session?.user?.id,
|
|
||||||
ipAddress: extractClientIP(request),
|
|
||||||
userAgent: request.headers.get("user-agent") || undefined,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
error: "server_error",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
`Server error in platform companies list: ${error}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Internal server error" },
|
{ error: "Internal server error" },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
@ -139,33 +83,13 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
// POST /api/platform/companies - Create new company
|
// POST /api/platform/companies - Create new company
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
let session: Session | null = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
session = await getServerSession(platformAuthOptions);
|
const session = await getServerSession(platformAuthOptions);
|
||||||
const ip = extractClientIP(request);
|
|
||||||
const userAgent = request.headers.get("user-agent") || undefined;
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!session?.user?.isPlatformUser ||
|
!session?.user?.isPlatformUser ||
|
||||||
session.user.platformRole === "SUPPORT"
|
session.user.platformRole === "SUPPORT"
|
||||||
) {
|
) {
|
||||||
await securityAuditLogger.logPlatformAdmin(
|
|
||||||
"platform_company_create_unauthorized",
|
|
||||||
AuditOutcome.BLOCKED,
|
|
||||||
{
|
|
||||||
platformUserId: session?.user?.id,
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
error: "insufficient_permissions",
|
|
||||||
requiredRole: "ADMIN",
|
|
||||||
currentRole: session?.user?.platformRole,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"Unauthorized attempt to create platform company"
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Admin access required" },
|
{ error: "Admin access required" },
|
||||||
{ status: 403 }
|
{ status: 403 }
|
||||||
@ -229,7 +153,7 @@ export async function POST(request: NextRequest) {
|
|||||||
name: adminName,
|
name: adminName,
|
||||||
role: "ADMIN",
|
role: "ADMIN",
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
invitedBy: session?.user?.email || "platform",
|
invitedBy: session.user.email || "platform",
|
||||||
invitedAt: new Date(),
|
invitedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -241,27 +165,6 @@ export async function POST(request: NextRequest) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log successful company creation
|
|
||||||
await securityAuditLogger.logCompanyManagement(
|
|
||||||
"platform_company_created",
|
|
||||||
AuditOutcome.SUCCESS,
|
|
||||||
{
|
|
||||||
platformUserId: session.user.id,
|
|
||||||
companyId: result.company.id,
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
companyName: result.company.name,
|
|
||||||
companyStatus: result.company.status,
|
|
||||||
adminUserEmail: "[REDACTED]",
|
|
||||||
adminUserName: result.adminUser.name,
|
|
||||||
maxUsers: result.company.maxUsers,
|
|
||||||
hasGeneratedPassword: !!result.generatedPassword,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"Platform company created successfully"
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
company: result.company,
|
company: result.company,
|
||||||
@ -276,21 +179,6 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Platform company creation error:", error);
|
console.error("Platform company creation error:", error);
|
||||||
|
|
||||||
await securityAuditLogger.logCompanyManagement(
|
|
||||||
"platform_company_create_error",
|
|
||||||
AuditOutcome.FAILURE,
|
|
||||||
{
|
|
||||||
platformUserId: session?.user?.id,
|
|
||||||
ipAddress: extractClientIP(request),
|
|
||||||
userAgent: request.headers.get("user-agent") || undefined,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
error: "server_error",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
`Server error in platform company creation: ${error}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Internal server error" },
|
{ error: "Internal server error" },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
|
|||||||
@ -1,24 +1,38 @@
|
|||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "../../../lib/prisma";
|
import { prisma } from "../../../lib/prisma";
|
||||||
import { extractClientIP, InMemoryRateLimiter } from "../../../lib/rateLimiter";
|
|
||||||
import { registerSchema, validateInput } from "../../../lib/validation";
|
import { registerSchema, validateInput } from "../../../lib/validation";
|
||||||
|
|
||||||
// Rate limiting for registration endpoint
|
// In-memory rate limiting (for production, use Redis or similar)
|
||||||
const registrationLimiter = new InMemoryRateLimiter({
|
const registrationAttempts = new Map<
|
||||||
maxAttempts: 3,
|
string,
|
||||||
windowMs: 60 * 60 * 1000, // 1 hour
|
{ count: number; resetTime: number }
|
||||||
maxEntries: 10000,
|
>();
|
||||||
cleanupIntervalMs: 5 * 60 * 1000, // 5 minutes
|
|
||||||
});
|
function checkRateLimit(ip: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const attempts = registrationAttempts.get(ip);
|
||||||
|
|
||||||
|
if (!attempts || now > attempts.resetTime) {
|
||||||
|
registrationAttempts.set(ip, { count: 1, resetTime: now + 60 * 60 * 1000 }); // 1 hour window
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempts.count >= 3) {
|
||||||
|
// Max 3 registrations per hour per IP
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts.count++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Rate limiting check using shared utility
|
// Rate limiting check
|
||||||
const ip = extractClientIP(request);
|
const ip =
|
||||||
const rateLimitResult = registrationLimiter.checkRateLimit(ip);
|
request.ip || request.headers.get("x-forwarded-for") || "unknown";
|
||||||
|
if (!checkRateLimit(ip)) {
|
||||||
if (!rateLimitResult.allowed) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@ -2,37 +2,15 @@ import crypto from "node:crypto";
|
|||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "../../../lib/prisma";
|
import { prisma } from "../../../lib/prisma";
|
||||||
import { extractClientIP } from "../../../lib/rateLimiter";
|
|
||||||
import {
|
|
||||||
AuditOutcome,
|
|
||||||
createAuditMetadata,
|
|
||||||
securityAuditLogger,
|
|
||||||
} from "../../../lib/securityAuditLogger";
|
|
||||||
import { resetPasswordSchema, validateInput } from "../../../lib/validation";
|
import { resetPasswordSchema, validateInput } from "../../../lib/validation";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const ip = extractClientIP(request);
|
|
||||||
const userAgent = request.headers.get("user-agent") || undefined;
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
// Validate input with strong password requirements
|
// Validate input with strong password requirements
|
||||||
const validation = validateInput(resetPasswordSchema, body);
|
const validation = validateInput(resetPasswordSchema, body);
|
||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
await securityAuditLogger.logPasswordReset(
|
|
||||||
"password_reset_validation_failed",
|
|
||||||
AuditOutcome.FAILURE,
|
|
||||||
{
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
error: "validation_failed",
|
|
||||||
validationErrors: validation.errors,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"Password reset validation failed"
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
@ -56,19 +34,6 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
await securityAuditLogger.logPasswordReset(
|
|
||||||
"password_reset_invalid_token",
|
|
||||||
AuditOutcome.FAILURE,
|
|
||||||
{
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
error: "invalid_or_expired_token",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"Password reset attempt with invalid or expired token"
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
@ -91,22 +56,6 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await securityAuditLogger.logPasswordReset(
|
|
||||||
"password_reset_completed",
|
|
||||||
AuditOutcome.SUCCESS,
|
|
||||||
{
|
|
||||||
userId: user.id,
|
|
||||||
companyId: user.companyId,
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
email: "[REDACTED]",
|
|
||||||
passwordChanged: true,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"Password reset completed successfully"
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
@ -116,20 +65,6 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Reset password error:", error);
|
console.error("Reset password error:", error);
|
||||||
|
|
||||||
await securityAuditLogger.logPasswordReset(
|
|
||||||
"password_reset_server_error",
|
|
||||||
AuditOutcome.FAILURE,
|
|
||||||
{
|
|
||||||
ipAddress: extractClientIP(request),
|
|
||||||
userAgent: request.headers.get("user-agent") || undefined,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
error: "server_error",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
`Server error in password reset completion: ${error}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@ -1,29 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 };
|
|
||||||
@ -1,610 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { formatDistanceToNow } from "date-fns";
|
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import { useCallback, useEffect, useId, useState } from "react";
|
|
||||||
import { Alert, AlertDescription } from "../../../components/ui/alert";
|
|
||||||
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 {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "../../../components/ui/select";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "../../../components/ui/table";
|
|
||||||
|
|
||||||
interface AuditLog {
|
|
||||||
id: string;
|
|
||||||
eventType: string;
|
|
||||||
action: string;
|
|
||||||
outcome: string;
|
|
||||||
severity: string;
|
|
||||||
userId?: string;
|
|
||||||
platformUserId?: string;
|
|
||||||
ipAddress?: string;
|
|
||||||
userAgent?: string;
|
|
||||||
country?: string;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
errorMessage?: string;
|
|
||||||
sessionId?: string;
|
|
||||||
requestId?: string;
|
|
||||||
timestamp: string;
|
|
||||||
user?: {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name?: string;
|
|
||||||
role: string;
|
|
||||||
};
|
|
||||||
platformUser?: {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name?: string;
|
|
||||||
role: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuditLogsResponse {
|
|
||||||
success: boolean;
|
|
||||||
data?: {
|
|
||||||
auditLogs: AuditLog[];
|
|
||||||
pagination: {
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
totalCount: number;
|
|
||||||
totalPages: number;
|
|
||||||
hasNext: boolean;
|
|
||||||
hasPrev: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventTypeLabels: Record<string, string> = {
|
|
||||||
AUTHENTICATION: "Authentication",
|
|
||||||
AUTHORIZATION: "Authorization",
|
|
||||||
USER_MANAGEMENT: "User Management",
|
|
||||||
COMPANY_MANAGEMENT: "Company Management",
|
|
||||||
RATE_LIMITING: "Rate Limiting",
|
|
||||||
CSRF_PROTECTION: "CSRF Protection",
|
|
||||||
SECURITY_HEADERS: "Security Headers",
|
|
||||||
PASSWORD_RESET: "Password Reset",
|
|
||||||
PLATFORM_ADMIN: "Platform Admin",
|
|
||||||
DATA_PRIVACY: "Data Privacy",
|
|
||||||
SYSTEM_CONFIG: "System Config",
|
|
||||||
API_SECURITY: "API Security",
|
|
||||||
};
|
|
||||||
|
|
||||||
const outcomeColors: Record<string, string> = {
|
|
||||||
SUCCESS: "bg-green-100 text-green-800",
|
|
||||||
FAILURE: "bg-red-100 text-red-800",
|
|
||||||
BLOCKED: "bg-orange-100 text-orange-800",
|
|
||||||
RATE_LIMITED: "bg-yellow-100 text-yellow-800",
|
|
||||||
SUSPICIOUS: "bg-purple-100 text-purple-800",
|
|
||||||
};
|
|
||||||
|
|
||||||
const severityColors: Record<string, string> = {
|
|
||||||
INFO: "bg-blue-100 text-blue-800",
|
|
||||||
LOW: "bg-gray-100 text-gray-800",
|
|
||||||
MEDIUM: "bg-yellow-100 text-yellow-800",
|
|
||||||
HIGH: "bg-orange-100 text-orange-800",
|
|
||||||
CRITICAL: "bg-red-100 text-red-800",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AuditLogsPage() {
|
|
||||||
const { data: session } = useSession();
|
|
||||||
const eventTypeId = useId();
|
|
||||||
const outcomeId = useId();
|
|
||||||
const severityId = useId();
|
|
||||||
const startDateId = useId();
|
|
||||||
const endDateId = useId();
|
|
||||||
const modalTitleId = useId();
|
|
||||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [pagination, setPagination] = useState({
|
|
||||||
page: 1,
|
|
||||||
limit: 50,
|
|
||||||
totalCount: 0,
|
|
||||||
totalPages: 0,
|
|
||||||
hasNext: false,
|
|
||||||
hasPrev: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter states
|
|
||||||
const [filters, setFilters] = useState({
|
|
||||||
eventType: "",
|
|
||||||
outcome: "",
|
|
||||||
severity: "",
|
|
||||||
userId: "",
|
|
||||||
startDate: "",
|
|
||||||
endDate: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
|
|
||||||
const [hasFetched, setHasFetched] = useState(false);
|
|
||||||
|
|
||||||
const fetchAuditLogs = useCallback(async () => {
|
|
||||||
if (hasFetched) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
page: pagination.page.toString(),
|
|
||||||
limit: pagination.limit.toString(),
|
|
||||||
...filters,
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.keys(filters).forEach((key) => {
|
|
||||||
if (!filters[key as keyof typeof filters]) {
|
|
||||||
params.delete(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/admin/audit-logs?${params.toString()}`
|
|
||||||
);
|
|
||||||
const data: AuditLogsResponse = await response.json();
|
|
||||||
|
|
||||||
if (data.success && data.data) {
|
|
||||||
setAuditLogs(data.data.auditLogs);
|
|
||||||
setPagination(data.data.pagination);
|
|
||||||
setError(null);
|
|
||||||
setHasFetched(true);
|
|
||||||
} else {
|
|
||||||
setError(data.error || "Failed to fetch audit logs");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError("An error occurred while fetching audit logs");
|
|
||||||
console.error("Audit logs fetch error:", err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [pagination.page, pagination.limit, filters, hasFetched]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (session?.user?.role === "ADMIN" && !hasFetched) {
|
|
||||||
fetchAuditLogs();
|
|
||||||
}
|
|
||||||
}, [session?.user?.role, hasFetched, fetchAuditLogs]);
|
|
||||||
|
|
||||||
// Function to refresh audit logs (for filter changes)
|
|
||||||
const refreshAuditLogs = useCallback((newPage?: number) => {
|
|
||||||
if (newPage !== undefined) {
|
|
||||||
setPagination((prev) => ({ ...prev, page: newPage }));
|
|
||||||
}
|
|
||||||
setHasFetched(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleFilterChange = (key: keyof typeof filters, value: string) => {
|
|
||||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
|
||||||
setPagination((prev) => ({ ...prev, page: 1 })); // Reset to first page
|
|
||||||
refreshAuditLogs(); // Trigger fresh fetch with new filters
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearFilters = () => {
|
|
||||||
setFilters({
|
|
||||||
eventType: "",
|
|
||||||
outcome: "",
|
|
||||||
severity: "",
|
|
||||||
userId: "",
|
|
||||||
startDate: "",
|
|
||||||
endDate: "",
|
|
||||||
});
|
|
||||||
refreshAuditLogs(); // Trigger fresh fetch with cleared filters
|
|
||||||
};
|
|
||||||
|
|
||||||
if (session?.user?.role !== "ADMIN") {
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto py-8">
|
|
||||||
<Alert>
|
|
||||||
<AlertDescription>
|
|
||||||
You don't have permission to view audit logs. Only
|
|
||||||
administrators can access this page.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto py-8 space-y-6">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h1 className="text-3xl font-bold">Security Audit Logs</h1>
|
|
||||||
<Button onClick={fetchAuditLogs} disabled={loading}>
|
|
||||||
{loading ? "Loading..." : "Refresh"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Filters</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor={eventTypeId} className="text-sm font-medium">
|
|
||||||
Event Type
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={filters.eventType}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
handleFilterChange("eventType", value)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger id={eventTypeId}>
|
|
||||||
<SelectValue placeholder="All event types" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="">All event types</SelectItem>
|
|
||||||
{Object.entries(eventTypeLabels).map(([value, label]) => (
|
|
||||||
<SelectItem key={value} value={value}>
|
|
||||||
{label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor={outcomeId} className="text-sm font-medium">
|
|
||||||
Outcome
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={filters.outcome}
|
|
||||||
onValueChange={(value) => handleFilterChange("outcome", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger id={outcomeId}>
|
|
||||||
<SelectValue placeholder="All outcomes" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="">All outcomes</SelectItem>
|
|
||||||
<SelectItem value="SUCCESS">Success</SelectItem>
|
|
||||||
<SelectItem value="FAILURE">Failure</SelectItem>
|
|
||||||
<SelectItem value="BLOCKED">Blocked</SelectItem>
|
|
||||||
<SelectItem value="RATE_LIMITED">Rate Limited</SelectItem>
|
|
||||||
<SelectItem value="SUSPICIOUS">Suspicious</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor={severityId} className="text-sm font-medium">
|
|
||||||
Severity
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={filters.severity}
|
|
||||||
onValueChange={(value) => handleFilterChange("severity", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger id={severityId}>
|
|
||||||
<SelectValue placeholder="All severities" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="">All severities</SelectItem>
|
|
||||||
<SelectItem value="INFO">Info</SelectItem>
|
|
||||||
<SelectItem value="LOW">Low</SelectItem>
|
|
||||||
<SelectItem value="MEDIUM">Medium</SelectItem>
|
|
||||||
<SelectItem value="HIGH">High</SelectItem>
|
|
||||||
<SelectItem value="CRITICAL">Critical</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor={startDateId} className="text-sm font-medium">
|
|
||||||
Start Date
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id={startDateId}
|
|
||||||
type="datetime-local"
|
|
||||||
value={filters.startDate}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleFilterChange("startDate", e.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor={endDateId} className="text-sm font-medium">
|
|
||||||
End Date
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id={endDateId}
|
|
||||||
type="datetime-local"
|
|
||||||
value={filters.endDate}
|
|
||||||
onChange={(e) => handleFilterChange("endDate", e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-end">
|
|
||||||
<Button variant="outline" onClick={clearFilters}>
|
|
||||||
Clear Filters
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Audit Logs Table */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Audit Logs ({pagination.totalCount} total)</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Timestamp</TableHead>
|
|
||||||
<TableHead>Event Type</TableHead>
|
|
||||||
<TableHead>Action</TableHead>
|
|
||||||
<TableHead>Outcome</TableHead>
|
|
||||||
<TableHead>Severity</TableHead>
|
|
||||||
<TableHead>User</TableHead>
|
|
||||||
<TableHead>IP Address</TableHead>
|
|
||||||
<TableHead>Details</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{auditLogs.map((log) => (
|
|
||||||
<TableRow
|
|
||||||
key={log.id}
|
|
||||||
className="cursor-pointer hover:bg-gray-50 focus:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset"
|
|
||||||
onClick={() => setSelectedLog(log)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedLog(log);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={`View details for ${eventTypeLabels[log.eventType] || log.eventType} event`}
|
|
||||||
>
|
|
||||||
<TableCell className="font-mono text-sm">
|
|
||||||
{formatDistanceToNow(new Date(log.timestamp), {
|
|
||||||
addSuffix: true,
|
|
||||||
})}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline">
|
|
||||||
{eventTypeLabels[log.eventType] || log.eventType}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="max-w-48 truncate">
|
|
||||||
{log.action}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
className={
|
|
||||||
outcomeColors[log.outcome] ||
|
|
||||||
"bg-gray-100 text-gray-800"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{log.outcome}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
className={
|
|
||||||
severityColors[log.severity] ||
|
|
||||||
"bg-gray-100 text-gray-800"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{log.severity}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{log.user?.email || log.platformUser?.email || "System"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-sm">
|
|
||||||
{log.ipAddress || "N/A"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Button variant="ghost" size="sm">
|
|
||||||
View
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
<div className="flex justify-between items-center mt-4">
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
Showing {(pagination.page - 1) * pagination.limit + 1} to{" "}
|
|
||||||
{Math.min(
|
|
||||||
pagination.page * pagination.limit,
|
|
||||||
pagination.totalCount
|
|
||||||
)}{" "}
|
|
||||||
of {pagination.totalCount} results
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={!pagination.hasPrev}
|
|
||||||
onClick={() => {
|
|
||||||
const newPage = pagination.page - 1;
|
|
||||||
refreshAuditLogs(newPage);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={!pagination.hasNext}
|
|
||||||
onClick={() => {
|
|
||||||
const newPage = pagination.page + 1;
|
|
||||||
refreshAuditLogs(newPage);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Log Detail Modal */}
|
|
||||||
{selectedLog && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby={modalTitleId}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
setSelectedLog(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-auto">
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h2 id={modalTitleId} className="text-xl font-bold">
|
|
||||||
Audit Log Details
|
|
||||||
</h2>
|
|
||||||
<Button variant="ghost" onClick={() => setSelectedLog(null)}>
|
|
||||||
×
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Timestamp:</span>
|
|
||||||
<p className="font-mono text-sm">
|
|
||||||
{new Date(selectedLog.timestamp).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Event Type:</span>
|
|
||||||
<p>
|
|
||||||
{eventTypeLabels[selectedLog.eventType] ||
|
|
||||||
selectedLog.eventType}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Action:</span>
|
|
||||||
<p>{selectedLog.action}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Outcome:</span>
|
|
||||||
<Badge className={outcomeColors[selectedLog.outcome]}>
|
|
||||||
{selectedLog.outcome}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Severity:</span>
|
|
||||||
<Badge className={severityColors[selectedLog.severity]}>
|
|
||||||
{selectedLog.severity}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">IP Address:</span>
|
|
||||||
<p className="font-mono text-sm">
|
|
||||||
{selectedLog.ipAddress || "N/A"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedLog.user && (
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">User:</span>
|
|
||||||
<p>
|
|
||||||
{selectedLog.user.email} ({selectedLog.user.role})
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedLog.platformUser && (
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Platform User:</span>
|
|
||||||
<p>
|
|
||||||
{selectedLog.platformUser.email} (
|
|
||||||
{selectedLog.platformUser.role})
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedLog.country && (
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Country:</span>
|
|
||||||
<p>{selectedLog.country}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedLog.sessionId && (
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Session ID:</span>
|
|
||||||
<p className="font-mono text-sm">{selectedLog.sessionId}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedLog.requestId && (
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Request ID:</span>
|
|
||||||
<p className="font-mono text-sm">{selectedLog.requestId}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedLog.errorMessage && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<span className="font-medium">Error Message:</span>
|
|
||||||
<p className="text-red-600 bg-red-50 p-2 rounded text-sm">
|
|
||||||
{selectedLog.errorMessage}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedLog.userAgent && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<span className="font-medium">User Agent:</span>
|
|
||||||
<p className="text-sm break-all">{selectedLog.userAgent}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedLog.metadata && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<span className="font-medium">Metadata:</span>
|
|
||||||
<pre className="bg-gray-100 p-2 rounded text-xs overflow-auto max-h-40">
|
|
||||||
{JSON.stringify(selectedLog.metadata, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -15,7 +15,9 @@ export default function CompanySettingsPage() {
|
|||||||
const csvUsernameId = useId();
|
const csvUsernameId = useId();
|
||||||
const csvPasswordId = useId();
|
const csvPasswordId = useId();
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
const [, setCompany] = useState<Company | null>(null);
|
// We store the full company object for future use and updates after save operations
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||||
|
const [_company, setCompany] = useState<Company | null>(null);
|
||||||
const [csvUrl, setCsvUrl] = useState<string>("");
|
const [csvUrl, setCsvUrl] = useState<string>("");
|
||||||
const [csvUsername, setCsvUsername] = useState<string>("");
|
const [csvUsername, setCsvUsername] = useState<string>("");
|
||||||
const [csvPassword, setCsvPassword] = useState<string>("");
|
const [csvPassword, setCsvPassword] = useState<string>("");
|
||||||
|
|||||||
@ -28,7 +28,6 @@ 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";
|
||||||
@ -39,10 +38,92 @@ import MetricCard from "../../../components/ui/metric-card";
|
|||||||
import WordCloud from "../../../components/WordCloud";
|
import WordCloud from "../../../components/WordCloud";
|
||||||
import type { Company, MetricsResult, WordCloudWord } from "../../../lib/types";
|
import type { Company, MetricsResult, WordCloudWord } from "../../../lib/types";
|
||||||
|
|
||||||
/**
|
// Safely wrapped component with useSession
|
||||||
* Loading states component for better organization
|
function DashboardContent() {
|
||||||
*/
|
const { data: session, status } = useSession();
|
||||||
function DashboardLoadingStates({ status }: { status: string }) {
|
const router = useRouter();
|
||||||
|
const [metrics, setMetrics] = useState<MetricsResult | null>(null);
|
||||||
|
const [company, setCompany] = useState<Company | null>(null);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [refreshing, setRefreshing] = useState<boolean>(false);
|
||||||
|
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
|
||||||
|
|
||||||
|
const refreshStatusId = useId();
|
||||||
|
const isAuditor = session?.user?.role === "AUDITOR";
|
||||||
|
|
||||||
|
// Function to fetch metrics with optional date range
|
||||||
|
const fetchMetrics = useCallback(
|
||||||
|
async (startDate?: string, endDate?: string, isInitial = false) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
let url = "/api/dashboard/metrics";
|
||||||
|
if (startDate && endDate) {
|
||||||
|
url += `?startDate=${startDate}&endDate=${endDate}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
setMetrics(data.metrics);
|
||||||
|
setCompany(data.company);
|
||||||
|
|
||||||
|
// Set initial load flag
|
||||||
|
if (isInitial) {
|
||||||
|
setIsInitialLoad(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching metrics:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Redirect if not authenticated
|
||||||
|
if (status === "unauthenticated") {
|
||||||
|
router.push("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch metrics and company on mount if authenticated
|
||||||
|
if (status === "authenticated" && isInitialLoad) {
|
||||||
|
fetchMetrics(undefined, undefined, true);
|
||||||
|
}
|
||||||
|
}, [status, router, isInitialLoad, fetchMetrics]);
|
||||||
|
|
||||||
|
async function handleRefresh() {
|
||||||
|
if (isAuditor) return;
|
||||||
|
try {
|
||||||
|
setRefreshing(true);
|
||||||
|
|
||||||
|
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 {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state while session status is being determined
|
||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[60vh]">
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
@ -64,83 +145,74 @@ function DashboardLoadingStates({ status }: { status: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
if (loading || !metrics || !company) {
|
||||||
}
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
/**
|
{/* Header Skeleton */}
|
||||||
* Loading skeleton component
|
|
||||||
*/
|
|
||||||
function DashboardSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* Header Skeleton */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Skeleton className="h-8 w-48" />
|
|
||||||
<Skeleton className="h-4 w-64" />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Skeleton className="h-10 w-24" />
|
|
||||||
<Skeleton className="h-10 w-20" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Metrics Grid Skeleton */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
{Array.from({ length: 8 }, (_, i) => {
|
|
||||||
const metricTypes = [
|
|
||||||
"sessions",
|
|
||||||
"users",
|
|
||||||
"time",
|
|
||||||
"response",
|
|
||||||
"costs",
|
|
||||||
"peak",
|
|
||||||
"resolution",
|
|
||||||
"languages",
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
<MetricCard
|
|
||||||
key={`skeleton-${metricTypes[i] || "metric"}-card-loading`}
|
|
||||||
title=""
|
|
||||||
value=""
|
|
||||||
isLoading
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Charts Skeleton */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
<Card className="lg:col-span-2">
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-6 w-32" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Skeleton className="h-64 w-full" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Skeleton className="h-6 w-32" />
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-64" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Skeleton className="h-10 w-24" />
|
||||||
|
<Skeleton className="h-10 w-20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
|
||||||
<Skeleton className="h-64 w-full" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
{/* Metrics Grid Skeleton */}
|
||||||
* Data processing utilities
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
*/
|
{Array.from({ length: 8 }, (_, i) => {
|
||||||
function useDashboardData(metrics: MetricsResult | null) {
|
const metricTypes = [
|
||||||
const getSentimentData = useCallback(() => {
|
"sessions",
|
||||||
|
"users",
|
||||||
|
"time",
|
||||||
|
"response",
|
||||||
|
"costs",
|
||||||
|
"peak",
|
||||||
|
"resolution",
|
||||||
|
"languages",
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<MetricCard
|
||||||
|
key={`skeleton-${metricTypes[i] || "metric"}-card-loading`}
|
||||||
|
title=""
|
||||||
|
value=""
|
||||||
|
isLoading
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Skeleton */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data preparation functions
|
||||||
|
const getSentimentData = () => {
|
||||||
if (!metrics) return [];
|
if (!metrics) return [];
|
||||||
|
|
||||||
const sentimentData = {
|
const sentimentData = {
|
||||||
@ -166,9 +238,9 @@ function useDashboardData(metrics: MetricsResult | null) {
|
|||||||
color: "hsl(var(--chart-3))",
|
color: "hsl(var(--chart-3))",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [metrics]);
|
};
|
||||||
|
|
||||||
const getSessionsOverTimeData = useCallback(() => {
|
const getSessionsOverTimeData = () => {
|
||||||
if (!metrics?.days) return [];
|
if (!metrics?.days) return [];
|
||||||
|
|
||||||
return Object.entries(metrics.days).map(([date, value]) => ({
|
return Object.entries(metrics.days).map(([date, value]) => ({
|
||||||
@ -178,9 +250,9 @@ function useDashboardData(metrics: MetricsResult | null) {
|
|||||||
}),
|
}),
|
||||||
value: value as number,
|
value: value as number,
|
||||||
}));
|
}));
|
||||||
}, [metrics?.days]);
|
};
|
||||||
|
|
||||||
const getCategoriesData = useCallback(() => {
|
const getCategoriesData = () => {
|
||||||
if (!metrics?.categories) return [];
|
if (!metrics?.categories) return [];
|
||||||
|
|
||||||
return Object.entries(metrics.categories).map(([name, value]) => {
|
return Object.entries(metrics.categories).map(([name, value]) => {
|
||||||
@ -193,23 +265,23 @@ function useDashboardData(metrics: MetricsResult | null) {
|
|||||||
value: value as number,
|
value: value as number,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [metrics?.categories]);
|
};
|
||||||
|
|
||||||
const getLanguagesData = useCallback(() => {
|
const getLanguagesData = () => {
|
||||||
if (!metrics?.languages) return [];
|
if (!metrics?.languages) return [];
|
||||||
|
|
||||||
return Object.entries(metrics.languages).map(([name, value]) => ({
|
return Object.entries(metrics.languages).map(([name, value]) => ({
|
||||||
name,
|
name,
|
||||||
value: value as number,
|
value: value as number,
|
||||||
}));
|
}));
|
||||||
}, [metrics?.languages]);
|
};
|
||||||
|
|
||||||
const getWordCloudData = useCallback((): WordCloudWord[] => {
|
const getWordCloudData = (): WordCloudWord[] => {
|
||||||
if (!metrics?.wordCloudData) return [];
|
if (!metrics?.wordCloudData) return [];
|
||||||
return metrics.wordCloudData;
|
return metrics.wordCloudData;
|
||||||
}, [metrics?.wordCloudData]);
|
};
|
||||||
|
|
||||||
const getCountryData = useCallback(() => {
|
const getCountryData = () => {
|
||||||
if (!metrics?.countries) return {};
|
if (!metrics?.countries) return {};
|
||||||
return Object.entries(metrics.countries).reduce(
|
return Object.entries(metrics.countries).reduce(
|
||||||
(acc, [code, count]) => {
|
(acc, [code, count]) => {
|
||||||
@ -220,10 +292,10 @@ function useDashboardData(metrics: MetricsResult | null) {
|
|||||||
},
|
},
|
||||||
{} as Record<string, number>
|
{} as Record<string, number>
|
||||||
);
|
);
|
||||||
}, [metrics?.countries]);
|
};
|
||||||
|
|
||||||
const getResponseTimeData = useCallback(() => {
|
const getResponseTimeData = () => {
|
||||||
const avgTime = metrics?.avgResponseTime || 1.5;
|
const avgTime = metrics.avgResponseTime || 1.5;
|
||||||
const simulatedData: number[] = [];
|
const simulatedData: number[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < 50; i++) {
|
for (let i = 0; i < 50; i++) {
|
||||||
@ -232,428 +304,81 @@ function useDashboardData(metrics: MetricsResult | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return simulatedData;
|
return simulatedData;
|
||||||
}, [metrics?.avgResponseTime]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
getSentimentData,
|
|
||||||
getSessionsOverTimeData,
|
|
||||||
getCategoriesData,
|
|
||||||
getLanguagesData,
|
|
||||||
getWordCloudData,
|
|
||||||
getCountryData,
|
|
||||||
getResponseTimeData,
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dashboard header component
|
|
||||||
*/
|
|
||||||
function DashboardHeader({
|
|
||||||
company,
|
|
||||||
metrics,
|
|
||||||
isAuditor,
|
|
||||||
refreshing,
|
|
||||||
onRefresh,
|
|
||||||
}: {
|
|
||||||
company: Company;
|
|
||||||
metrics: MetricsResult;
|
|
||||||
isAuditor: boolean;
|
|
||||||
refreshing: boolean;
|
|
||||||
onRefresh: () => void;
|
|
||||||
}) {
|
|
||||||
const refreshStatusId = useId();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="border-0 bg-linear-to-r from-primary/5 via-primary/10 to-primary/5">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
|
||||||
{company.name}
|
|
||||||
</h1>
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
Analytics Dashboard
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Last updated{" "}
|
|
||||||
<span className="font-medium">
|
|
||||||
{new Date(metrics.lastUpdated || Date.now()).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={onRefresh}
|
|
||||||
disabled={refreshing || isAuditor}
|
|
||||||
size="sm"
|
|
||||||
className="gap-2"
|
|
||||||
aria-label={
|
|
||||||
refreshing
|
|
||||||
? "Refreshing dashboard data"
|
|
||||||
: "Refresh dashboard data"
|
|
||||||
}
|
|
||||||
aria-describedby={refreshing ? refreshStatusId : undefined}
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{refreshing ? "Refreshing..." : "Refresh"}
|
|
||||||
</Button>
|
|
||||||
{refreshing && (
|
|
||||||
<div id={refreshStatusId} className="sr-only" aria-live="polite">
|
|
||||||
Dashboard data is being refreshed
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm" aria-label="Account menu">
|
|
||||||
<MoreVertical className="h-4 w-4" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
|
||||||
>
|
|
||||||
<LogOut className="h-4 w-4 mr-2" aria-hidden="true" />
|
|
||||||
Sign out
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Individual metric card components for better organization
|
|
||||||
*/
|
|
||||||
function SessionMetricCard({ metrics }: { metrics: MetricsResult }) {
|
|
||||||
return (
|
|
||||||
<MetricCard
|
|
||||||
title="Total Sessions"
|
|
||||||
value={metrics.totalSessions?.toLocaleString()}
|
|
||||||
icon={<MessageSquare className="h-5 w-5" />}
|
|
||||||
trend={{
|
|
||||||
value: metrics.sessionTrend ?? 0,
|
|
||||||
isPositive: (metrics.sessionTrend ?? 0) >= 0,
|
|
||||||
}}
|
|
||||||
variant="primary"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UsersMetricCard({ metrics }: { metrics: MetricsResult }) {
|
|
||||||
return (
|
|
||||||
<MetricCard
|
|
||||||
title="Unique Users"
|
|
||||||
value={metrics.uniqueUsers?.toLocaleString()}
|
|
||||||
icon={<Users className="h-5 w-5" />}
|
|
||||||
trend={{
|
|
||||||
value: metrics.usersTrend ?? 0,
|
|
||||||
isPositive: (metrics.usersTrend ?? 0) >= 0,
|
|
||||||
}}
|
|
||||||
variant="success"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SessionTimeMetricCard({ metrics }: { metrics: MetricsResult }) {
|
|
||||||
return (
|
|
||||||
<MetricCard
|
|
||||||
title="Avg. Session Time"
|
|
||||||
value={`${Math.round(metrics.avgSessionLength || 0)}s`}
|
|
||||||
icon={<Clock className="h-5 w-5" />}
|
|
||||||
trend={{
|
|
||||||
value: metrics.avgSessionTimeTrend ?? 0,
|
|
||||||
isPositive: (metrics.avgSessionTimeTrend ?? 0) >= 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResponseTimeMetricCard({ metrics }: { metrics: MetricsResult }) {
|
|
||||||
return (
|
|
||||||
<MetricCard
|
|
||||||
title="Avg. Response Time"
|
|
||||||
value={`${metrics.avgResponseTime?.toFixed(1) || 0}s`}
|
|
||||||
icon={<Zap className="h-5 w-5" />}
|
|
||||||
trend={{
|
|
||||||
value: metrics.avgResponseTimeTrend ?? 0,
|
|
||||||
isPositive: (metrics.avgResponseTimeTrend ?? 0) <= 0,
|
|
||||||
}}
|
|
||||||
variant="warning"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CostsMetricCard({ metrics }: { metrics: MetricsResult }) {
|
|
||||||
return (
|
|
||||||
<MetricCard
|
|
||||||
title="Daily Costs"
|
|
||||||
value={`€${metrics.avgDailyCosts?.toFixed(4) || "0.0000"}`}
|
|
||||||
icon={<Euro className="h-5 w-5" />}
|
|
||||||
description="Average per day"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PeakUsageMetricCard({ metrics }: { metrics: MetricsResult }) {
|
|
||||||
return (
|
|
||||||
<MetricCard
|
|
||||||
title="Peak Usage"
|
|
||||||
value={metrics.peakUsageTime || "N/A"}
|
|
||||||
icon={<TrendingUp className="h-5 w-5" />}
|
|
||||||
description="Busiest hour"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResolutionRateMetricCard({ metrics }: { metrics: MetricsResult }) {
|
|
||||||
return (
|
|
||||||
<MetricCard
|
|
||||||
title="Resolution Rate"
|
|
||||||
value={`${metrics.resolvedChatsPercentage?.toFixed(1) || "0.0"}%`}
|
|
||||||
icon={<CheckCircle className="h-5 w-5" />}
|
|
||||||
trend={{
|
|
||||||
value: metrics.resolvedChatsPercentage ?? 0,
|
|
||||||
isPositive: (metrics.resolvedChatsPercentage ?? 0) >= 80,
|
|
||||||
}}
|
|
||||||
variant={
|
|
||||||
metrics.resolvedChatsPercentage && metrics.resolvedChatsPercentage >= 80
|
|
||||||
? "success"
|
|
||||||
: "warning"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LanguagesMetricCard({ metrics }: { metrics: MetricsResult }) {
|
|
||||||
return (
|
|
||||||
<MetricCard
|
|
||||||
title="Active Languages"
|
|
||||||
value={Object.keys(metrics.languages || {}).length}
|
|
||||||
icon={<Globe className="h-5 w-5" />}
|
|
||||||
description="Languages detected"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simplified metrics grid component
|
|
||||||
*/
|
|
||||||
function MetricsGrid({ metrics }: { metrics: MetricsResult }) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
<SessionMetricCard metrics={metrics} />
|
|
||||||
<UsersMetricCard metrics={metrics} />
|
|
||||||
<SessionTimeMetricCard metrics={metrics} />
|
|
||||||
<ResponseTimeMetricCard metrics={metrics} />
|
|
||||||
<CostsMetricCard metrics={metrics} />
|
|
||||||
<PeakUsageMetricCard metrics={metrics} />
|
|
||||||
<ResolutionRateMetricCard metrics={metrics} />
|
|
||||||
<LanguagesMetricCard metrics={metrics} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main dashboard content with reduced complexity
|
|
||||||
*/
|
|
||||||
function DashboardContent() {
|
|
||||||
const { data: session, status } = useSession();
|
|
||||||
const router = useRouter();
|
|
||||||
const [metrics, setMetrics] = useState<MetricsResult | null>(null);
|
|
||||||
// Remove unused company state that was causing skeleton view to always show
|
|
||||||
const [refreshing, setRefreshing] = useState<boolean>(false);
|
|
||||||
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
|
|
||||||
|
|
||||||
const isAuditor = session?.user?.role === "AUDITOR";
|
|
||||||
const dataHelpers = useDashboardData(metrics);
|
|
||||||
|
|
||||||
// Function to fetch metrics with optional date range
|
|
||||||
// tRPC query for dashboard metrics
|
|
||||||
const {
|
|
||||||
data: overviewData,
|
|
||||||
isLoading: isLoadingMetrics,
|
|
||||||
refetch: refetchMetrics,
|
|
||||||
error: metricsError,
|
|
||||||
} = trpc.dashboard.getOverview.useQuery(
|
|
||||||
{
|
|
||||||
// Add date range parameters when implemented
|
|
||||||
// startDate: dateRange?.startDate,
|
|
||||||
// endDate: dateRange?.endDate,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: status === "authenticated",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update state when data changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (overviewData) {
|
|
||||||
// Map overview data to metrics format expected by the component
|
|
||||||
const mappedMetrics: Partial<MetricsResult> = {
|
|
||||||
totalSessions: overviewData.totalSessions,
|
|
||||||
avgSessionsPerDay: overviewData.avgSessionsPerDay || 0,
|
|
||||||
avgSessionLength: overviewData.avgSessionLength || 0,
|
|
||||||
days:
|
|
||||||
overviewData.timeSeriesData?.reduce(
|
|
||||||
(acc, item) => {
|
|
||||||
if (item.date) {
|
|
||||||
acc[item.date] = item.sessionCount || 0;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, number>
|
|
||||||
) || {},
|
|
||||||
languages:
|
|
||||||
overviewData.languageDistribution?.reduce(
|
|
||||||
(acc, item) => {
|
|
||||||
if (item.language) {
|
|
||||||
acc[item.language] = item.count;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, number>
|
|
||||||
) || {},
|
|
||||||
countries:
|
|
||||||
overviewData.geographicDistribution?.reduce(
|
|
||||||
(acc, item) => {
|
|
||||||
if (item.country) {
|
|
||||||
acc[item.country] = item.count;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, number>
|
|
||||||
) || {},
|
|
||||||
belowThresholdCount: overviewData.belowThresholdCount || 0,
|
|
||||||
// Map sentiment data to individual counts
|
|
||||||
sentimentPositiveCount:
|
|
||||||
overviewData.sentimentDistribution?.find(
|
|
||||||
(s) => s.sentiment === "POSITIVE"
|
|
||||||
)?.count || 0,
|
|
||||||
sentimentNeutralCount:
|
|
||||||
overviewData.sentimentDistribution?.find(
|
|
||||||
(s) => s.sentiment === "NEUTRAL"
|
|
||||||
)?.count || 0,
|
|
||||||
sentimentNegativeCount:
|
|
||||||
overviewData.sentimentDistribution?.find(
|
|
||||||
(s) => s.sentiment === "NEGATIVE"
|
|
||||||
)?.count || 0,
|
|
||||||
// Map category data to CategoryMetrics format
|
|
||||||
...(overviewData.categoryDistribution && {
|
|
||||||
categories: overviewData.categoryDistribution.reduce(
|
|
||||||
(acc, item) => {
|
|
||||||
if (item.category) {
|
|
||||||
acc[item.category] = item.count;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, number>
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
setMetrics(mappedMetrics as MetricsResult);
|
|
||||||
|
|
||||||
if (isInitialLoad) {
|
|
||||||
setIsInitialLoad(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [overviewData, isInitialLoad]);
|
|
||||||
|
|
||||||
// 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(() => {
|
|
||||||
// Redirect if not authenticated
|
|
||||||
if (status === "unauthenticated") {
|
|
||||||
router.push("/login");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// tRPC queries handle data fetching automatically
|
|
||||||
}, [status, router]);
|
|
||||||
|
|
||||||
// Enhanced error handling with user feedback
|
|
||||||
if (metricsError) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
|
||||||
<div className="text-center space-y-4">
|
|
||||||
<div className="text-red-600 text-lg font-semibold">
|
|
||||||
Failed to load dashboard data
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
There was an error loading your dashboard metrics. Please try
|
|
||||||
refreshing the page.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Refresh Page
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRefresh() {
|
|
||||||
if (isAuditor) return;
|
|
||||||
|
|
||||||
setRefreshing(true);
|
|
||||||
try {
|
|
||||||
await refreshSessionsMutation.mutateAsync();
|
|
||||||
} finally {
|
|
||||||
setRefreshing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading state while session status is being determined
|
|
||||||
const loadingState = DashboardLoadingStates({ status });
|
|
||||||
if (loadingState) return loadingState;
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
return <DashboardSkeleton />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<DashboardHeader
|
{/* Modern Header */}
|
||||||
company={{ name: "Analytics Dashboard" } as Company}
|
<Card className="border-0 bg-linear-to-r from-primary/5 via-primary/10 to-primary/5">
|
||||||
metrics={metrics}
|
<CardHeader>
|
||||||
isAuditor={isAuditor}
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
refreshing={refreshing}
|
<div className="space-y-2">
|
||||||
onRefresh={handleRefresh}
|
<div className="flex items-center gap-3">
|
||||||
/>
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
|
{company.name}
|
||||||
|
</h1>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Analytics Dashboard
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Last updated{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{new Date(metrics.lastUpdated || Date.now()).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Date Range Picker */}
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={refreshing || isAuditor}
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
aria-label={
|
||||||
|
refreshing
|
||||||
|
? "Refreshing dashboard data"
|
||||||
|
: "Refresh dashboard data"
|
||||||
|
}
|
||||||
|
aria-describedby={refreshing ? refreshStatusId : undefined}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{refreshing ? "Refreshing..." : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
{refreshing && (
|
||||||
|
<div
|
||||||
|
id={refreshStatusId}
|
||||||
|
className="sr-only"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
Dashboard data is being refreshed
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" aria-label="Account menu">
|
||||||
|
<MoreVertical className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4 mr-2" aria-hidden="true" />
|
||||||
|
Sign out
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Date Range Picker - Temporarily disabled to debug infinite loop */}
|
||||||
{/* {dateRange && (
|
{/* {dateRange && (
|
||||||
<DateRangePicker
|
<DateRangePicker
|
||||||
minDate={dateRange.minDate}
|
minDate={dateRange.minDate}
|
||||||
@ -664,19 +389,100 @@ function DashboardContent() {
|
|||||||
/>
|
/>
|
||||||
)} */}
|
)} */}
|
||||||
|
|
||||||
<MetricsGrid metrics={metrics} />
|
{/* Modern Metrics Grid */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<MetricCard
|
||||||
|
title="Total Sessions"
|
||||||
|
value={metrics.totalSessions?.toLocaleString()}
|
||||||
|
icon={<MessageSquare className="h-5 w-5" />}
|
||||||
|
trend={{
|
||||||
|
value: metrics.sessionTrend ?? 0,
|
||||||
|
isPositive: (metrics.sessionTrend ?? 0) >= 0,
|
||||||
|
}}
|
||||||
|
variant="primary"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MetricCard
|
||||||
|
title="Unique Users"
|
||||||
|
value={metrics.uniqueUsers?.toLocaleString()}
|
||||||
|
icon={<Users className="h-5 w-5" />}
|
||||||
|
trend={{
|
||||||
|
value: metrics.usersTrend ?? 0,
|
||||||
|
isPositive: (metrics.usersTrend ?? 0) >= 0,
|
||||||
|
}}
|
||||||
|
variant="success"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MetricCard
|
||||||
|
title="Avg. Session Time"
|
||||||
|
value={`${Math.round(metrics.avgSessionLength || 0)}s`}
|
||||||
|
icon={<Clock className="h-5 w-5" />}
|
||||||
|
trend={{
|
||||||
|
value: metrics.avgSessionTimeTrend ?? 0,
|
||||||
|
isPositive: (metrics.avgSessionTimeTrend ?? 0) >= 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MetricCard
|
||||||
|
title="Avg. Response Time"
|
||||||
|
value={`${metrics.avgResponseTime?.toFixed(1) || 0}s`}
|
||||||
|
icon={<Zap className="h-5 w-5" />}
|
||||||
|
trend={{
|
||||||
|
value: metrics.avgResponseTimeTrend ?? 0,
|
||||||
|
isPositive: (metrics.avgResponseTimeTrend ?? 0) <= 0,
|
||||||
|
}}
|
||||||
|
variant="warning"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MetricCard
|
||||||
|
title="Daily Costs"
|
||||||
|
value={`€${metrics.avgDailyCosts?.toFixed(4) || "0.0000"}`}
|
||||||
|
icon={<Euro className="h-5 w-5" />}
|
||||||
|
description="Average per day"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MetricCard
|
||||||
|
title="Peak Usage"
|
||||||
|
value={metrics.peakUsageTime || "N/A"}
|
||||||
|
icon={<TrendingUp className="h-5 w-5" />}
|
||||||
|
description="Busiest hour"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MetricCard
|
||||||
|
title="Resolution Rate"
|
||||||
|
value={`${metrics.resolvedChatsPercentage?.toFixed(1) || "0.0"}%`}
|
||||||
|
icon={<CheckCircle className="h-5 w-5" />}
|
||||||
|
trend={{
|
||||||
|
value: metrics.resolvedChatsPercentage ?? 0,
|
||||||
|
isPositive: (metrics.resolvedChatsPercentage ?? 0) >= 80,
|
||||||
|
}}
|
||||||
|
variant={
|
||||||
|
metrics.resolvedChatsPercentage &&
|
||||||
|
metrics.resolvedChatsPercentage >= 80
|
||||||
|
? "success"
|
||||||
|
: "warning"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MetricCard
|
||||||
|
title="Active Languages"
|
||||||
|
value={Object.keys(metrics.languages || {}).length}
|
||||||
|
icon={<Globe className="h-5 w-5" />}
|
||||||
|
description="Languages detected"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Charts Section */}
|
{/* Charts Section */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<ModernLineChart
|
<ModernLineChart
|
||||||
data={dataHelpers.getSessionsOverTimeData()}
|
data={getSessionsOverTimeData()}
|
||||||
title="Sessions Over Time"
|
title="Sessions Over Time"
|
||||||
className="lg:col-span-2"
|
className="lg:col-span-2"
|
||||||
height={350}
|
height={350}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ModernDonutChart
|
<ModernDonutChart
|
||||||
data={dataHelpers.getSentimentData()}
|
data={getSentimentData()}
|
||||||
title="Conversation Sentiment"
|
title="Conversation Sentiment"
|
||||||
centerText={{
|
centerText={{
|
||||||
title: "Total",
|
title: "Total",
|
||||||
@ -688,13 +494,13 @@ function DashboardContent() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<ModernBarChart
|
<ModernBarChart
|
||||||
data={dataHelpers.getCategoriesData()}
|
data={getCategoriesData()}
|
||||||
title="Sessions by Category"
|
title="Sessions by Category"
|
||||||
height={350}
|
height={350}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ModernDonutChart
|
<ModernDonutChart
|
||||||
data={dataHelpers.getLanguagesData()}
|
data={getLanguagesData()}
|
||||||
title="Languages Used"
|
title="Languages Used"
|
||||||
height={350}
|
height={350}
|
||||||
/>
|
/>
|
||||||
@ -710,7 +516,7 @@ function DashboardContent() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<GeographicMap countries={dataHelpers.getCountryData()} />
|
<GeographicMap countries={getCountryData()} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -723,11 +529,7 @@ function DashboardContent() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-[300px]">
|
<div className="h-[300px]">
|
||||||
<WordCloud
|
<WordCloud words={getWordCloudData()} width={500} height={300} />
|
||||||
words={dataHelpers.getWordCloudData()}
|
|
||||||
width={500}
|
|
||||||
height={300}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -743,7 +545,7 @@ function DashboardContent() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ResponseTimeDistribution
|
<ResponseTimeDistribution
|
||||||
data={dataHelpers.getResponseTimeData()}
|
data={getResponseTimeData()}
|
||||||
average={metrics.avgResponseTime || 0}
|
average={metrics.avgResponseTime || 0}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -23,24 +23,24 @@ import MessageViewer from "../../../../components/MessageViewer";
|
|||||||
import SessionDetails from "../../../../components/SessionDetails";
|
import SessionDetails from "../../../../components/SessionDetails";
|
||||||
import type { ChatSession } from "../../../../lib/types";
|
import type { ChatSession } from "../../../../lib/types";
|
||||||
|
|
||||||
/**
|
export default function SessionViewPage() {
|
||||||
* Custom hook for managing session data fetching and state
|
const params = useParams();
|
||||||
*/
|
const router = useRouter(); // Initialize useRouter
|
||||||
function useSessionData(id: string | undefined, authStatus: string) {
|
const { status } = useSession(); // Get session status, removed unused sessionData
|
||||||
|
const id = params?.id as string;
|
||||||
const [session, setSession] = useState<ChatSession | null>(null);
|
const [session, setSession] = useState<ChatSession | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true); // This will now primarily be for data fetching
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authStatus === "unauthenticated") {
|
if (status === "unauthenticated") {
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authStatus === "authenticated" && id) {
|
if (status === "authenticated" && id) {
|
||||||
const fetchSession = async () => {
|
const fetchSession = async () => {
|
||||||
setLoading(true);
|
setLoading(true); // Always set loading before fetch
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/dashboard/session/${id}`);
|
const response = await fetch(`/api/dashboard/session/${id}`);
|
||||||
@ -63,247 +63,222 @@ function useSessionData(id: string | undefined, authStatus: string) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchSession();
|
fetchSession();
|
||||||
} else if (authStatus === "authenticated" && !id) {
|
} else if (status === "authenticated" && !id) {
|
||||||
setError("Session ID is missing.");
|
setError("Session ID is missing.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [id, authStatus, router]);
|
}, [id, status, router]); // session removed from dependencies
|
||||||
|
|
||||||
return { session, loading, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component for rendering loading state
|
|
||||||
*/
|
|
||||||
function LoadingCard({ message }: { message: string }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component for rendering error state
|
|
||||||
*/
|
|
||||||
function ErrorCard({ error }: { error: string }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<AlertCircle className="h-12 w-12 text-destructive mx-auto mb-4" />
|
|
||||||
<p className="text-destructive text-lg mb-4">Error: {error}</p>
|
|
||||||
<Link href="/dashboard/sessions">
|
|
||||||
<Button variant="outline" className="gap-2">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
Back to Sessions List
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component for rendering session not found state
|
|
||||||
*/
|
|
||||||
function SessionNotFoundCard() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<MessageSquare className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
|
||||||
<p className="text-muted-foreground text-lg mb-4">
|
|
||||||
Session not found.
|
|
||||||
</p>
|
|
||||||
<Link href="/dashboard/sessions">
|
|
||||||
<Button variant="outline" className="gap-2">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
Back to Sessions List
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component for rendering session header with navigation and badges
|
|
||||||
*/
|
|
||||||
function SessionHeader({ session }: { session: ChatSession }) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Link href="/dashboard/sessions">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="gap-2 p-0 h-auto focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
|
||||||
aria-label="Return to sessions list"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
|
||||||
Back to Sessions List
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h1 className="text-3xl font-bold">Session Details</h1>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Badge variant="outline" className="font-mono text-xs">
|
|
||||||
ID
|
|
||||||
</Badge>
|
|
||||||
<code className="text-sm text-muted-foreground font-mono">
|
|
||||||
{(session.sessionId || session.id).slice(0, 8)}...
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{session.category && (
|
|
||||||
<Badge variant="secondary" className="gap-1">
|
|
||||||
<Activity className="h-3 w-3" />
|
|
||||||
{formatCategory(session.category)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{session.language && (
|
|
||||||
<Badge variant="outline" className="gap-1">
|
|
||||||
<Globe className="h-3 w-3" />
|
|
||||||
{session.language.toUpperCase()}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{session.sentiment && (
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
session.sentiment === "positive"
|
|
||||||
? "default"
|
|
||||||
: session.sentiment === "negative"
|
|
||||||
? "destructive"
|
|
||||||
: "secondary"
|
|
||||||
}
|
|
||||||
className="gap-1"
|
|
||||||
>
|
|
||||||
{session.sentiment.charAt(0).toUpperCase() +
|
|
||||||
session.sentiment.slice(1)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component for rendering session overview cards
|
|
||||||
*/
|
|
||||||
function SessionOverview({ session }: { session: ChatSession }) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Clock className="h-8 w-8 text-blue-500" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Start Time</p>
|
|
||||||
<p className="font-semibold">
|
|
||||||
{new Date(session.startTime).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<MessageSquare className="h-8 w-8 text-green-500" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Messages</p>
|
|
||||||
<p className="font-semibold">{session.messages?.length || 0}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<User className="h-8 w-8 text-purple-500" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">User ID</p>
|
|
||||||
<p className="font-semibold truncate">
|
|
||||||
{session.userId || "N/A"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Activity className="h-8 w-8 text-orange-500" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Duration</p>
|
|
||||||
<p className="font-semibold">
|
|
||||||
{session.endTime && session.startTime
|
|
||||||
? `${Math.round(
|
|
||||||
(new Date(session.endTime).getTime() -
|
|
||||||
new Date(session.startTime).getTime()) /
|
|
||||||
60000
|
|
||||||
)} min`
|
|
||||||
: "N/A"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SessionViewPage() {
|
|
||||||
const params = useParams();
|
|
||||||
const { status } = useSession();
|
|
||||||
const id = params?.id as string;
|
|
||||||
const { session, loading, error } = useSessionData(id, status);
|
|
||||||
|
|
||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return <LoadingCard message="Loading session..." />;
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
Loading session...
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "unauthenticated") {
|
if (status === "unauthenticated") {
|
||||||
return <LoadingCard message="Redirecting to login..." />;
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
Redirecting to login...
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading && status === "authenticated") {
|
if (loading && status === "authenticated") {
|
||||||
return <LoadingCard message="Loading session details..." />;
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
Loading session details...
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <ErrorCard error={error} />;
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<AlertCircle className="h-12 w-12 text-destructive mx-auto mb-4" />
|
||||||
|
<p className="text-destructive text-lg mb-4">Error: {error}</p>
|
||||||
|
<Link href="/dashboard/sessions">
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to Sessions List
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return <SessionNotFoundCard />;
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<MessageSquare className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<p className="text-muted-foreground text-lg mb-4">
|
||||||
|
Session not found.
|
||||||
|
</p>
|
||||||
|
<Link href="/dashboard/sessions">
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to Sessions List
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-6xl mx-auto">
|
<div className="space-y-6 max-w-6xl mx-auto">
|
||||||
<SessionHeader session={session} />
|
{/* Header */}
|
||||||
<SessionOverview session={session} />
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Link href="/dashboard/sessions">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="gap-2 p-0 h-auto focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||||
|
aria-label="Return to sessions list"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Back to Sessions List
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-3xl font-bold">Session Details</h1>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
|
ID
|
||||||
|
</Badge>
|
||||||
|
<code className="text-sm text-muted-foreground font-mono">
|
||||||
|
{(session.sessionId || session.id).slice(0, 8)}...
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{session.category && (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<Activity className="h-3 w-3" />
|
||||||
|
{formatCategory(session.category)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{session.language && (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<Globe className="h-3 w-3" />
|
||||||
|
{session.language.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{session.sentiment && (
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
session.sentiment === "positive"
|
||||||
|
? "default"
|
||||||
|
: session.sentiment === "negative"
|
||||||
|
? "destructive"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
{session.sentiment.charAt(0).toUpperCase() +
|
||||||
|
session.sentiment.slice(1)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Session Overview */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Clock className="h-8 w-8 text-blue-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Start Time</p>
|
||||||
|
<p className="font-semibold">
|
||||||
|
{new Date(session.startTime).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<MessageSquare className="h-8 w-8 text-green-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Messages</p>
|
||||||
|
<p className="font-semibold">{session.messages?.length || 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<User className="h-8 w-8 text-purple-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">User ID</p>
|
||||||
|
<p className="font-semibold truncate">
|
||||||
|
{session.userId || "N/A"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Activity className="h-8 w-8 text-orange-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Duration</p>
|
||||||
|
<p className="font-semibold">
|
||||||
|
{session.endTime && session.startTime
|
||||||
|
? `${Math.round(
|
||||||
|
(new Date(session.endTime).getTime() -
|
||||||
|
new Date(session.startTime).getTime()) /
|
||||||
|
60000
|
||||||
|
)} min`
|
||||||
|
: "N/A"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Session Details */}
|
{/* Session Details */}
|
||||||
<SessionDetails session={session} />
|
<SessionDetails session={session} />
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -2,8 +2,6 @@
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { NonceProvider } from "@/lib/nonce-context";
|
|
||||||
import { getNonce } from "@/lib/nonce-utils";
|
|
||||||
import { Providers } from "./providers";
|
import { Providers } from "./providers";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@ -90,13 +88,7 @@ export const metadata = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
const nonce = await getNonce();
|
|
||||||
|
|
||||||
const jsonLd = {
|
const jsonLd = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "SoftwareApplication",
|
"@type": "SoftwareApplication",
|
||||||
@ -134,8 +126,7 @@ export default async function RootLayout({
|
|||||||
<head>
|
<head>
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
nonce={nonce}
|
// biome-ignore lint/security/noDangerouslySetInnerHtml: Safe use for JSON-LD structured data
|
||||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: Safe use for JSON-LD structured data with CSP nonce
|
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
@ -147,9 +138,7 @@ export default async function RootLayout({
|
|||||||
>
|
>
|
||||||
Skip to main content
|
Skip to main content
|
||||||
</a>
|
</a>
|
||||||
<NonceProvider nonce={nonce}>
|
<Providers>{children}</Providers>
|
||||||
<Providers>{children}</Providers>
|
|
||||||
</NonceProvider>
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -39,43 +39,6 @@ import {
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
type ToastFunction = (props: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
variant?: "default" | "destructive";
|
|
||||||
}) => void;
|
|
||||||
|
|
||||||
interface CompanyManagementState {
|
|
||||||
company: Company | null;
|
|
||||||
setCompany: (company: Company | null) => void;
|
|
||||||
isLoading: boolean;
|
|
||||||
setIsLoading: (loading: boolean) => void;
|
|
||||||
isSaving: boolean;
|
|
||||||
setIsSaving: (saving: boolean) => void;
|
|
||||||
editData: Partial<Company>;
|
|
||||||
setEditData: (
|
|
||||||
data: Partial<Company> | ((prev: Partial<Company>) => Partial<Company>)
|
|
||||||
) => void;
|
|
||||||
originalData: Partial<Company>;
|
|
||||||
setOriginalData: (data: Partial<Company>) => void;
|
|
||||||
showInviteUser: boolean;
|
|
||||||
setShowInviteUser: (show: boolean) => void;
|
|
||||||
inviteData: { name: string; email: string; role: string };
|
|
||||||
setInviteData: (
|
|
||||||
data:
|
|
||||||
| { name: string; email: string; role: string }
|
|
||||||
| ((prev: { name: string; email: string; role: string }) => {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
})
|
|
||||||
) => void;
|
|
||||||
showUnsavedChangesDialog: boolean;
|
|
||||||
setShowUnsavedChangesDialog: (show: boolean) => void;
|
|
||||||
pendingNavigation: string | null;
|
|
||||||
setPendingNavigation: (navigation: string | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -101,10 +64,51 @@ interface Company {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export default function CompanyManagement() {
|
||||||
* Custom hook for company management state
|
const { data: session, status } = useSession();
|
||||||
*/
|
const router = useRouter();
|
||||||
function useCompanyManagementState() {
|
const params = useParams();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const companyNameFieldId = useId();
|
||||||
|
const companyEmailFieldId = useId();
|
||||||
|
const maxUsersFieldId = useId();
|
||||||
|
const inviteNameFieldId = useId();
|
||||||
|
const inviteEmailFieldId = useId();
|
||||||
|
|
||||||
|
const fetchCompany = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/platform/companies/${params.id}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setCompany(data);
|
||||||
|
const companyData = {
|
||||||
|
name: data.name,
|
||||||
|
email: data.email,
|
||||||
|
status: data.status,
|
||||||
|
maxUsers: data.maxUsers,
|
||||||
|
};
|
||||||
|
setEditData(companyData);
|
||||||
|
setOriginalData(companyData);
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to load company data",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch company:", error);
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to load company data",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [params.id, toast]);
|
||||||
|
|
||||||
const [company, setCompany] = useState<Company | null>(null);
|
const [company, setCompany] = useState<Company | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
@ -122,55 +126,9 @@ function useCompanyManagementState() {
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
// Function to check if data has been modified
|
||||||
company,
|
|
||||||
setCompany,
|
|
||||||
isLoading,
|
|
||||||
setIsLoading,
|
|
||||||
isSaving,
|
|
||||||
setIsSaving,
|
|
||||||
editData,
|
|
||||||
setEditData,
|
|
||||||
originalData,
|
|
||||||
setOriginalData,
|
|
||||||
showInviteUser,
|
|
||||||
setShowInviteUser,
|
|
||||||
inviteData,
|
|
||||||
setInviteData,
|
|
||||||
showUnsavedChangesDialog,
|
|
||||||
setShowUnsavedChangesDialog,
|
|
||||||
pendingNavigation,
|
|
||||||
setPendingNavigation,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook for form IDs
|
|
||||||
*/
|
|
||||||
function useCompanyFormIds() {
|
|
||||||
const companyNameFieldId = useId();
|
|
||||||
const companyEmailFieldId = useId();
|
|
||||||
const maxUsersFieldId = useId();
|
|
||||||
const inviteNameFieldId = useId();
|
|
||||||
const inviteEmailFieldId = useId();
|
|
||||||
|
|
||||||
return {
|
|
||||||
companyNameFieldId,
|
|
||||||
companyEmailFieldId,
|
|
||||||
maxUsersFieldId,
|
|
||||||
inviteNameFieldId,
|
|
||||||
inviteEmailFieldId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook for data validation and comparison
|
|
||||||
*/
|
|
||||||
function useDataComparison(
|
|
||||||
editData: Partial<Company>,
|
|
||||||
originalData: Partial<Company>
|
|
||||||
) {
|
|
||||||
const hasUnsavedChanges = useCallback(() => {
|
const hasUnsavedChanges = useCallback(() => {
|
||||||
|
// Normalize data for comparison (handle null/undefined/empty string equivalence)
|
||||||
const normalizeValue = (value: string | number | null | undefined) => {
|
const normalizeValue = (value: string | number | null | undefined) => {
|
||||||
if (value === null || value === undefined || value === "") {
|
if (value === null || value === undefined || value === "") {
|
||||||
return "";
|
return "";
|
||||||
@ -198,104 +156,16 @@ function useDataComparison(
|
|||||||
);
|
);
|
||||||
}, [editData, originalData]);
|
}, [editData, originalData]);
|
||||||
|
|
||||||
return { hasUnsavedChanges };
|
// Handle navigation protection - must be at top level
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook for company data fetching
|
|
||||||
*/
|
|
||||||
function useCompanyData(
|
|
||||||
params: { id: string | string[] },
|
|
||||||
toast: ToastFunction,
|
|
||||||
state: CompanyManagementState
|
|
||||||
) {
|
|
||||||
const { setCompany, setEditData, setOriginalData, setIsLoading } = state;
|
|
||||||
const [hasFetched, setHasFetched] = useState(false);
|
|
||||||
|
|
||||||
const fetchCompany = useCallback(async () => {
|
|
||||||
if (hasFetched) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/platform/companies/${params.id}`);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setCompany(data);
|
|
||||||
const companyData = {
|
|
||||||
name: data.name,
|
|
||||||
email: data.email,
|
|
||||||
status: data.status,
|
|
||||||
maxUsers: data.maxUsers,
|
|
||||||
};
|
|
||||||
setEditData(companyData);
|
|
||||||
setOriginalData(companyData);
|
|
||||||
setHasFetched(true);
|
|
||||||
} else {
|
|
||||||
const errorText = await response.text();
|
|
||||||
const errorMessage = `Failed to load company data (${response.status}: ${response.statusText})`;
|
|
||||||
|
|
||||||
console.error("Failed to fetch company - HTTP Error:", {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
response: errorText,
|
|
||||||
url: response.url,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: errorMessage,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : "Unknown error occurred";
|
|
||||||
|
|
||||||
console.error("Failed to fetch company - Network/Parse Error:", {
|
|
||||||
message: errorMessage,
|
|
||||||
error: error,
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
url: `/api/platform/companies/${params.id}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: `Failed to load company data: ${errorMessage}`,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
params.id,
|
|
||||||
hasFetched,
|
|
||||||
toast,
|
|
||||||
setCompany,
|
|
||||||
setEditData,
|
|
||||||
setOriginalData,
|
|
||||||
setIsLoading,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { fetchCompany };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook for navigation handling
|
|
||||||
*/
|
|
||||||
function useNavigationControl(
|
|
||||||
router: { push: (url: string) => void },
|
|
||||||
params: { id: string | string[] },
|
|
||||||
hasUnsavedChanges: () => boolean,
|
|
||||||
state: CompanyManagementState
|
|
||||||
) {
|
|
||||||
const { setPendingNavigation, setShowUnsavedChangesDialog } = state;
|
|
||||||
|
|
||||||
const handleNavigation = useCallback(
|
const handleNavigation = useCallback(
|
||||||
(url: string) => {
|
(url: string) => {
|
||||||
|
// Allow navigation within the same company (different tabs, etc.)
|
||||||
if (url.includes(`/platform/companies/${params.id}`)) {
|
if (url.includes(`/platform/companies/${params.id}`)) {
|
||||||
router.push(url);
|
router.push(url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If there are unsaved changes, show confirmation dialog
|
||||||
if (hasUnsavedChanges()) {
|
if (hasUnsavedChanges()) {
|
||||||
setPendingNavigation(url);
|
setPendingNavigation(url);
|
||||||
setShowUnsavedChangesDialog(true);
|
setShowUnsavedChangesDialog(true);
|
||||||
@ -303,225 +173,7 @@ function useNavigationControl(
|
|||||||
router.push(url);
|
router.push(url);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[router, params.id, hasUnsavedChanges]
|
||||||
router,
|
|
||||||
params.id,
|
|
||||||
hasUnsavedChanges,
|
|
||||||
setPendingNavigation,
|
|
||||||
setShowUnsavedChangesDialog,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
return { handleNavigation };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to render company information card
|
|
||||||
*/
|
|
||||||
function renderCompanyInfoCard(
|
|
||||||
state: CompanyManagementState,
|
|
||||||
canEdit: boolean,
|
|
||||||
companyNameFieldId: string,
|
|
||||||
companyEmailFieldId: string,
|
|
||||||
maxUsersFieldId: string,
|
|
||||||
hasUnsavedChanges: () => boolean,
|
|
||||||
handleSave: () => Promise<void>
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Company Information</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor={companyNameFieldId}>Company Name</Label>
|
|
||||||
<Input
|
|
||||||
id={companyNameFieldId}
|
|
||||||
value={state.editData.name || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
state.setEditData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
name: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor={companyEmailFieldId}>Contact Email</Label>
|
|
||||||
<Input
|
|
||||||
id={companyEmailFieldId}
|
|
||||||
type="email"
|
|
||||||
value={state.editData.email || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
state.setEditData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
email: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor={maxUsersFieldId}>Max Users</Label>
|
|
||||||
<Input
|
|
||||||
id={maxUsersFieldId}
|
|
||||||
type="number"
|
|
||||||
value={state.editData.maxUsers || 0}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
const parsedValue = Number.parseInt(value, 10);
|
|
||||||
|
|
||||||
// Validate input: must be a positive number
|
|
||||||
const maxUsers =
|
|
||||||
!Number.isNaN(parsedValue) && parsedValue > 0
|
|
||||||
? parsedValue
|
|
||||||
: 1; // Default to 1 for invalid/negative values
|
|
||||||
|
|
||||||
state.setEditData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
maxUsers,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="status">Status</Label>
|
|
||||||
<Select
|
|
||||||
value={state.editData.status}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
state.setEditData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
status: value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={!canEdit}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="ACTIVE">Active</SelectItem>
|
|
||||||
<SelectItem value="TRIAL">Trial</SelectItem>
|
|
||||||
<SelectItem value="SUSPENDED">Suspended</SelectItem>
|
|
||||||
<SelectItem value="ARCHIVED">Archived</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{canEdit && hasUnsavedChanges() && (
|
|
||||||
<div className="flex gap-2 pt-4 border-t">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
state.setEditData(state.originalData);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel Changes
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} disabled={state.isSaving}>
|
|
||||||
<Save className="w-4 h-4 mr-2" />
|
|
||||||
{state.isSaving ? "Saving..." : "Save Changes"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to render users tab content
|
|
||||||
*/
|
|
||||||
function renderUsersTab(state: CompanyManagementState, canEdit: boolean) {
|
|
||||||
return (
|
|
||||||
<TabsContent value="users" className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center justify-between">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<Users className="w-5 h-5" />
|
|
||||||
Users ({state.company?.users.length || 0})
|
|
||||||
</span>
|
|
||||||
{canEdit && (
|
|
||||||
<Button size="sm" onClick={() => state.setShowInviteUser(true)}>
|
|
||||||
<UserPlus className="w-4 h-4 mr-2" />
|
|
||||||
Invite User
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{state.company?.users.map((user) => (
|
|
||||||
<div
|
|
||||||
key={user.id}
|
|
||||||
className="flex items-center justify-between p-4 border rounded-lg"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
|
||||||
<span className="text-sm font-medium text-blue-600 dark:text-blue-300">
|
|
||||||
{user.name?.charAt(0) ||
|
|
||||||
user.email.charAt(0).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{user.name || "No name"}</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{user.email}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Badge variant="outline">{user.role}</Badge>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Joined {new Date(user.createdAt).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{(state.company?.users.length || 0) === 0 && (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
No users found. Invite the first user to get started.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CompanyManagement() {
|
|
||||||
const { data: session, status } = useSession();
|
|
||||||
const router = useRouter();
|
|
||||||
const params = useParams();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const state = useCompanyManagementState();
|
|
||||||
const {
|
|
||||||
companyNameFieldId,
|
|
||||||
companyEmailFieldId,
|
|
||||||
maxUsersFieldId,
|
|
||||||
inviteNameFieldId,
|
|
||||||
inviteEmailFieldId,
|
|
||||||
} = useCompanyFormIds();
|
|
||||||
const { hasUnsavedChanges } = useDataComparison(
|
|
||||||
state.editData,
|
|
||||||
state.originalData
|
|
||||||
);
|
|
||||||
const { fetchCompany } = useCompanyData(
|
|
||||||
{ id: params.id as string },
|
|
||||||
toast,
|
|
||||||
state
|
|
||||||
);
|
|
||||||
const { handleNavigation } = useNavigationControl(
|
|
||||||
router,
|
|
||||||
{ id: params.id as string },
|
|
||||||
hasUnsavedChanges,
|
|
||||||
state
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -533,27 +185,27 @@ export default function CompanyManagement() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fetchCompany();
|
fetchCompany();
|
||||||
}, [status, session?.user?.isPlatformUser, fetchCompany, router]);
|
}, [session, status, router, fetchCompany]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
state.setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/platform/companies/${params.id}`, {
|
const response = await fetch(`/api/platform/companies/${params.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(state.editData),
|
body: JSON.stringify(editData),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const updatedCompany = await response.json();
|
const updatedCompany = await response.json();
|
||||||
state.setCompany(updatedCompany);
|
setCompany(updatedCompany);
|
||||||
const companyData = {
|
const companyData = {
|
||||||
name: updatedCompany.name,
|
name: updatedCompany.name,
|
||||||
email: updatedCompany.email,
|
email: updatedCompany.email,
|
||||||
status: updatedCompany.status,
|
status: updatedCompany.status,
|
||||||
maxUsers: updatedCompany.maxUsers,
|
maxUsers: updatedCompany.maxUsers,
|
||||||
};
|
};
|
||||||
state.setOriginalData(companyData);
|
setOriginalData(companyData);
|
||||||
toast({
|
toast({
|
||||||
title: "Success",
|
title: "Success",
|
||||||
description: "Company updated successfully",
|
description: "Company updated successfully",
|
||||||
@ -568,7 +220,7 @@ export default function CompanyManagement() {
|
|||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
state.setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -583,10 +235,8 @@ export default function CompanyManagement() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
state.setCompany((prev) =>
|
setCompany((prev) => (prev ? { ...prev, status: newStatus } : null));
|
||||||
prev ? { ...prev, status: newStatus } : null
|
setEditData((prev) => ({ ...prev, status: newStatus }));
|
||||||
);
|
|
||||||
state.setEditData((prev) => ({ ...prev, status: newStatus }));
|
|
||||||
toast({
|
toast({
|
||||||
title: "Success",
|
title: "Success",
|
||||||
description: `Company ${statusAction}d successfully`,
|
description: `Company ${statusAction}d successfully`,
|
||||||
@ -604,54 +254,16 @@ export default function CompanyManagement() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const confirmNavigation = () => {
|
const confirmNavigation = () => {
|
||||||
if (state.pendingNavigation) {
|
if (pendingNavigation) {
|
||||||
router.push(state.pendingNavigation);
|
router.push(pendingNavigation);
|
||||||
state.setPendingNavigation(null);
|
setPendingNavigation(null);
|
||||||
}
|
}
|
||||||
state.setShowUnsavedChangesDialog(false);
|
setShowUnsavedChangesDialog(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelNavigation = () => {
|
const cancelNavigation = () => {
|
||||||
state.setPendingNavigation(null);
|
setPendingNavigation(null);
|
||||||
state.setShowUnsavedChangesDialog(false);
|
setShowUnsavedChangesDialog(false);
|
||||||
};
|
|
||||||
|
|
||||||
const handleInviteUser = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/platform/companies/${params.id}/users`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(state.inviteData),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
state.setShowInviteUser(false);
|
|
||||||
state.setInviteData({ name: "", email: "", role: "USER" });
|
|
||||||
// Refresh company data to show new user
|
|
||||||
const updatedResponse = await fetch(
|
|
||||||
`/api/platform/companies/${params.id}`
|
|
||||||
);
|
|
||||||
if (updatedResponse.ok) {
|
|
||||||
const updatedData = await updatedResponse.json();
|
|
||||||
state.setCompany(updatedData);
|
|
||||||
}
|
|
||||||
toast({
|
|
||||||
title: "Success",
|
|
||||||
description: "User invited successfully",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new Error("Failed to invite user");
|
|
||||||
}
|
|
||||||
} catch (_error) {
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to invite user",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Protect against browser back/forward and other navigation
|
// Protect against browser back/forward and other navigation
|
||||||
@ -669,6 +281,7 @@ export default function CompanyManagement() {
|
|||||||
"You have unsaved changes. Are you sure you want to leave this page?"
|
"You have unsaved changes. Are you sure you want to leave this page?"
|
||||||
);
|
);
|
||||||
if (!confirmLeave) {
|
if (!confirmLeave) {
|
||||||
|
// Push the current state back to prevent navigation
|
||||||
window.history.pushState(null, "", window.location.href);
|
window.history.pushState(null, "", window.location.href);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
@ -684,6 +297,37 @@ export default function CompanyManagement() {
|
|||||||
};
|
};
|
||||||
}, [hasUnsavedChanges]);
|
}, [hasUnsavedChanges]);
|
||||||
|
|
||||||
|
const handleInviteUser = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/platform/companies/${params.id}/users`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(inviteData),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setShowInviteUser(false);
|
||||||
|
setInviteData({ name: "", email: "", role: "USER" });
|
||||||
|
fetchCompany(); // Refresh company data
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "User invited successfully",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error("Failed to invite user");
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to invite user",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getStatusBadgeVariant = (status: string) => {
|
const getStatusBadgeVariant = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "ACTIVE":
|
case "ACTIVE":
|
||||||
@ -699,7 +343,7 @@ export default function CompanyManagement() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (status === "loading" || state.isLoading) {
|
if (status === "loading" || isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="text-center">Loading company details...</div>
|
<div className="text-center">Loading company details...</div>
|
||||||
@ -707,7 +351,7 @@ export default function CompanyManagement() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session?.user?.isPlatformUser || !state.company) {
|
if (!session?.user?.isPlatformUser || !company) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -730,10 +374,10 @@ export default function CompanyManagement() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
{state.company.name}
|
{company.name}
|
||||||
</h1>
|
</h1>
|
||||||
<Badge variant={getStatusBadgeVariant(state.company.status)}>
|
<Badge variant={getStatusBadgeVariant(company.status)}>
|
||||||
{state.company.status}
|
{company.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
@ -746,7 +390,7 @@ export default function CompanyManagement() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => state.setShowInviteUser(true)}
|
onClick={() => setShowInviteUser(true)}
|
||||||
>
|
>
|
||||||
<UserPlus className="w-4 h-4 mr-2" />
|
<UserPlus className="w-4 h-4 mr-2" />
|
||||||
Invite User
|
Invite User
|
||||||
@ -778,10 +422,10 @@ export default function CompanyManagement() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{state.company.users.length}
|
{company.users.length}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
of {state.company.maxUsers} maximum
|
of {company.maxUsers} maximum
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -795,7 +439,7 @@ export default function CompanyManagement() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{state.company._count.sessions}
|
{company._count.sessions}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -809,7 +453,7 @@ export default function CompanyManagement() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{state.company._count.imports}
|
{company._count.imports}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -821,25 +465,160 @@ export default function CompanyManagement() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-sm font-bold">
|
<div className="text-sm font-bold">
|
||||||
{new Date(state.company.createdAt).toLocaleDateString()}
|
{new Date(company.createdAt).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Company Info */}
|
{/* Company Info */}
|
||||||
{renderCompanyInfoCard(
|
<Card>
|
||||||
state,
|
<CardHeader>
|
||||||
canEdit,
|
<CardTitle>Company Information</CardTitle>
|
||||||
companyNameFieldId,
|
</CardHeader>
|
||||||
companyEmailFieldId,
|
<CardContent className="space-y-4">
|
||||||
maxUsersFieldId,
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
hasUnsavedChanges,
|
<div>
|
||||||
handleSave
|
<Label htmlFor={companyNameFieldId}>Company Name</Label>
|
||||||
)}
|
<Input
|
||||||
|
id={companyNameFieldId}
|
||||||
|
value={editData.name || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
name: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={!canEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={companyEmailFieldId}>Contact Email</Label>
|
||||||
|
<Input
|
||||||
|
id={companyEmailFieldId}
|
||||||
|
type="email"
|
||||||
|
value={editData.email || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
email: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={!canEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={maxUsersFieldId}>Max Users</Label>
|
||||||
|
<Input
|
||||||
|
id={maxUsersFieldId}
|
||||||
|
type="number"
|
||||||
|
value={editData.maxUsers || 0}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
maxUsers: Number.parseInt(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={!canEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="status">Status</Label>
|
||||||
|
<Select
|
||||||
|
value={editData.status}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setEditData((prev) => ({ ...prev, status: value }))
|
||||||
|
}
|
||||||
|
disabled={!canEdit}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ACTIVE">Active</SelectItem>
|
||||||
|
<SelectItem value="TRIAL">Trial</SelectItem>
|
||||||
|
<SelectItem value="SUSPENDED">Suspended</SelectItem>
|
||||||
|
<SelectItem value="ARCHIVED">Archived</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canEdit && hasUnsavedChanges() && (
|
||||||
|
<div className="flex gap-2 pt-4 border-t">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setEditData(originalData);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel Changes
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={isSaving}>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
{isSaving ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{renderUsersTab(state, canEdit)}
|
<TabsContent value="users" className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5" />
|
||||||
|
Users ({company.users.length})
|
||||||
|
</span>
|
||||||
|
{canEdit && (
|
||||||
|
<Button size="sm" onClick={() => setShowInviteUser(true)}>
|
||||||
|
<UserPlus className="w-4 h-4 mr-2" />
|
||||||
|
Invite User
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{company.users.map((user) => (
|
||||||
|
<div
|
||||||
|
key={user.id}
|
||||||
|
className="flex items-center justify-between p-4 border rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-sm font-medium text-blue-600 dark:text-blue-300">
|
||||||
|
{user.name?.charAt(0) ||
|
||||||
|
user.email.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{user.name || "No name"}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Badge variant="outline">{user.role}</Badge>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Joined {new Date(user.createdAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{company.users.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No users found. Invite the first user to get started.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="settings" className="space-y-6">
|
<TabsContent value="settings" className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
@ -862,9 +641,9 @@ export default function CompanyManagement() {
|
|||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={state.company.status === "SUSPENDED"}
|
disabled={company.status === "SUSPENDED"}
|
||||||
>
|
>
|
||||||
{state.company.status === "SUSPENDED"
|
{company.status === "SUSPENDED"
|
||||||
? "Already Suspended"
|
? "Already Suspended"
|
||||||
: "Suspend"}
|
: "Suspend"}
|
||||||
</Button>
|
</Button>
|
||||||
@ -889,7 +668,7 @@ export default function CompanyManagement() {
|
|||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{state.company.status === "SUSPENDED" && (
|
{company.status === "SUSPENDED" && (
|
||||||
<div className="flex items-center justify-between p-4 border border-green-200 dark:border-green-800 rounded-lg">
|
<div className="flex items-center justify-between p-4 border border-green-200 dark:border-green-800 rounded-lg">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium">Reactivate Company</h3>
|
<h3 className="font-medium">Reactivate Company</h3>
|
||||||
@ -927,7 +706,7 @@ export default function CompanyManagement() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Invite User Dialog */}
|
{/* Invite User Dialog */}
|
||||||
{state.showInviteUser && (
|
{showInviteUser && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
<Card className="w-full max-w-md mx-4">
|
<Card className="w-full max-w-md mx-4">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@ -938,12 +717,9 @@ export default function CompanyManagement() {
|
|||||||
<Label htmlFor={inviteNameFieldId}>Name</Label>
|
<Label htmlFor={inviteNameFieldId}>Name</Label>
|
||||||
<Input
|
<Input
|
||||||
id={inviteNameFieldId}
|
id={inviteNameFieldId}
|
||||||
value={state.inviteData.name}
|
value={inviteData.name}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
state.setInviteData((prev) => ({
|
setInviteData((prev) => ({ ...prev, name: e.target.value }))
|
||||||
...prev,
|
|
||||||
name: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
placeholder="User's full name"
|
placeholder="User's full name"
|
||||||
/>
|
/>
|
||||||
@ -953,9 +729,9 @@ export default function CompanyManagement() {
|
|||||||
<Input
|
<Input
|
||||||
id={inviteEmailFieldId}
|
id={inviteEmailFieldId}
|
||||||
type="email"
|
type="email"
|
||||||
value={state.inviteData.email}
|
value={inviteData.email}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
state.setInviteData((prev) => ({
|
setInviteData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
email: e.target.value,
|
email: e.target.value,
|
||||||
}))
|
}))
|
||||||
@ -966,9 +742,9 @@ export default function CompanyManagement() {
|
|||||||
<div>
|
<div>
|
||||||
<Label htmlFor="inviteRole">Role</Label>
|
<Label htmlFor="inviteRole">Role</Label>
|
||||||
<Select
|
<Select
|
||||||
value={state.inviteData.role}
|
value={inviteData.role}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
state.setInviteData((prev) => ({ ...prev, role: value }))
|
setInviteData((prev) => ({ ...prev, role: value }))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@ -983,7 +759,7 @@ export default function CompanyManagement() {
|
|||||||
<div className="flex gap-2 pt-4">
|
<div className="flex gap-2 pt-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => state.setShowInviteUser(false)}
|
onClick={() => setShowInviteUser(false)}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
@ -991,7 +767,7 @@ export default function CompanyManagement() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={handleInviteUser}
|
onClick={handleInviteUser}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={!state.inviteData.email || !state.inviteData.name}
|
disabled={!inviteData.email || !inviteData.name}
|
||||||
>
|
>
|
||||||
<Mail className="w-4 h-4 mr-2" />
|
<Mail className="w-4 h-4 mr-2" />
|
||||||
Send Invite
|
Send Invite
|
||||||
@ -1004,8 +780,8 @@ export default function CompanyManagement() {
|
|||||||
|
|
||||||
{/* Unsaved Changes Dialog */}
|
{/* Unsaved Changes Dialog */}
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={state.showUnsavedChangesDialog}
|
open={showUnsavedChangesDialog}
|
||||||
onOpenChange={state.setShowUnsavedChangesDialog}
|
onOpenChange={setShowUnsavedChangesDialog}
|
||||||
>
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,595 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Activity,
|
|
||||||
AlertTriangle,
|
|
||||||
Bell,
|
|
||||||
BellOff,
|
|
||||||
CheckCircle,
|
|
||||||
Download,
|
|
||||||
Settings,
|
|
||||||
Shield,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { SecurityConfigModal } from "@/components/security/SecurityConfigModal";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
|
|
||||||
interface SecurityMetrics {
|
|
||||||
totalEvents: number;
|
|
||||||
criticalEvents: number;
|
|
||||||
activeAlerts: number;
|
|
||||||
resolvedAlerts: number;
|
|
||||||
securityScore: number;
|
|
||||||
threatLevel: string;
|
|
||||||
eventsByType: Record<string, number>;
|
|
||||||
alertsByType: Record<string, number>;
|
|
||||||
topThreats: Array<{ type: string; count: number }>;
|
|
||||||
geoDistribution: Record<string, number>;
|
|
||||||
timeDistribution: Array<{ hour: number; count: number }>;
|
|
||||||
userRiskScores: Array<{ userId: string; email: string; riskScore: number }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SecurityAlert {
|
|
||||||
id: string;
|
|
||||||
timestamp: string;
|
|
||||||
severity: string;
|
|
||||||
type: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
eventType: string;
|
|
||||||
context: Record<string, unknown>;
|
|
||||||
metadata: Record<string, unknown>;
|
|
||||||
acknowledged: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook for security monitoring UI state (UI-only, no data fetching)
|
|
||||||
*/
|
|
||||||
function useSecurityMonitoringState() {
|
|
||||||
const [selectedTimeRange, setSelectedTimeRange] = useState("24h");
|
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
|
||||||
|
|
||||||
return {
|
|
||||||
selectedTimeRange,
|
|
||||||
setSelectedTimeRange,
|
|
||||||
showConfig,
|
|
||||||
setShowConfig,
|
|
||||||
autoRefresh,
|
|
||||||
setAutoRefresh,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook for security data fetching
|
|
||||||
*/
|
|
||||||
function useSecurityData(selectedTimeRange: string, autoRefresh: boolean) {
|
|
||||||
const [metrics, setMetrics] = useState<SecurityMetrics | null>(null);
|
|
||||||
const [alerts, setAlerts] = useState<SecurityAlert[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const loadSecurityData = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const startDate = getStartDateForRange(selectedTimeRange);
|
|
||||||
const endDate = new Date().toISOString();
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/admin/security-monitoring?startDate=${startDate}&endDate=${endDate}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error("Failed to load security data");
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
setMetrics(data.metrics);
|
|
||||||
setAlerts(data.alerts);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading security data:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [selectedTimeRange]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadSecurityData();
|
|
||||||
|
|
||||||
if (autoRefresh) {
|
|
||||||
const interval = setInterval(loadSecurityData, 30000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}
|
|
||||||
}, [autoRefresh, loadSecurityData]);
|
|
||||||
|
|
||||||
return { metrics, alerts, loading, loadSecurityData, setAlerts };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to get date range for filtering
|
|
||||||
*/
|
|
||||||
function getStartDateForRange(range: string): string {
|
|
||||||
const now = new Date();
|
|
||||||
switch (range) {
|
|
||||||
case "1h":
|
|
||||||
return new Date(now.getTime() - 60 * 60 * 1000).toISOString();
|
|
||||||
case "24h":
|
|
||||||
return new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
|
|
||||||
case "7d":
|
|
||||||
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
||||||
case "30d":
|
|
||||||
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
||||||
default:
|
|
||||||
return new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to get threat level color
|
|
||||||
*/
|
|
||||||
function getThreatLevelColor(level: string) {
|
|
||||||
switch (level?.toLowerCase()) {
|
|
||||||
case "critical":
|
|
||||||
return "bg-red-500";
|
|
||||||
case "high":
|
|
||||||
return "bg-orange-500";
|
|
||||||
case "moderate":
|
|
||||||
return "bg-yellow-500";
|
|
||||||
case "low":
|
|
||||||
return "bg-green-500";
|
|
||||||
default:
|
|
||||||
return "bg-gray-500";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to get severity color
|
|
||||||
*/
|
|
||||||
function getSeverityColor(severity: string) {
|
|
||||||
switch (severity?.toLowerCase()) {
|
|
||||||
case "critical":
|
|
||||||
return "destructive";
|
|
||||||
case "high":
|
|
||||||
return "destructive";
|
|
||||||
case "medium":
|
|
||||||
return "secondary";
|
|
||||||
case "low":
|
|
||||||
return "outline";
|
|
||||||
default:
|
|
||||||
return "outline";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to render dashboard header
|
|
||||||
*/
|
|
||||||
function renderDashboardHeader(
|
|
||||||
autoRefresh: boolean,
|
|
||||||
setAutoRefresh: (refresh: boolean) => void,
|
|
||||||
setShowConfig: (show: boolean) => void,
|
|
||||||
exportData: (format: "json" | "csv", type: "alerts" | "metrics") => void
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
|
||||||
Security Monitoring
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Real-time security monitoring and threat detection
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
|
||||||
>
|
|
||||||
{autoRefresh ? (
|
|
||||||
<Bell className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<BellOff className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Auto Refresh
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setShowConfig(true)}>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
Configure
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => exportData("json", "alerts")}
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
Export
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to render time range selector
|
|
||||||
*/
|
|
||||||
function renderTimeRangeSelector(
|
|
||||||
selectedTimeRange: string,
|
|
||||||
setSelectedTimeRange: (range: string) => void
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{["1h", "24h", "7d", "30d"].map((range) => (
|
|
||||||
<Button
|
|
||||||
key={range}
|
|
||||||
variant={selectedTimeRange === range ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setSelectedTimeRange(range)}
|
|
||||||
>
|
|
||||||
{range}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to render security overview cards
|
|
||||||
*/
|
|
||||||
function renderSecurityOverview(metrics: SecurityMetrics | null) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Security Score</CardTitle>
|
|
||||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{metrics?.securityScore || 0}/100
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${getThreatLevelColor(metrics?.threatLevel || "")}`}
|
|
||||||
>
|
|
||||||
{metrics?.threatLevel || "Unknown"} Threat Level
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Active Alerts</CardTitle>
|
|
||||||
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{metrics?.activeAlerts || 0}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{metrics?.resolvedAlerts || 0} resolved
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Security Events</CardTitle>
|
|
||||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{metrics?.totalEvents || 0}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{metrics?.criticalEvents || 0} critical
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Top Threat</CardTitle>
|
|
||||||
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-sm font-bold">
|
|
||||||
{metrics?.topThreats?.[0]?.type?.replace(/_/g, " ") || "None"}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{metrics?.topThreats?.[0]?.count || 0} instances
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SecurityMonitoringPage() {
|
|
||||||
const {
|
|
||||||
selectedTimeRange,
|
|
||||||
setSelectedTimeRange,
|
|
||||||
showConfig,
|
|
||||||
setShowConfig,
|
|
||||||
autoRefresh,
|
|
||||||
setAutoRefresh,
|
|
||||||
} = useSecurityMonitoringState();
|
|
||||||
|
|
||||||
const { metrics, alerts, loading, setAlerts, loadSecurityData } =
|
|
||||||
useSecurityData(selectedTimeRange, autoRefresh);
|
|
||||||
|
|
||||||
const acknowledgeAlert = async (alertId: string) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/security-monitoring/alerts", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ alertId, action: "acknowledge" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setAlerts(
|
|
||||||
alerts.map((alert) =>
|
|
||||||
alert.id === alertId ? { ...alert, acknowledged: true } : alert
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error acknowledging alert:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportData = async (
|
|
||||||
format: "json" | "csv",
|
|
||||||
type: "alerts" | "metrics"
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const startDate = getStartDateForRange(selectedTimeRange);
|
|
||||||
const endDate = new Date().toISOString();
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/admin/security-monitoring/export?format=${format}&type=${type}&startDate=${startDate}&endDate=${endDate}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error("Export failed");
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `security-${type}-${new Date().toISOString().split("T")[0]}.${format}`;
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error exporting data:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
|
||||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-gray-900" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto px-4 py-6 space-y-6">
|
|
||||||
{renderDashboardHeader(
|
|
||||||
autoRefresh,
|
|
||||||
setAutoRefresh,
|
|
||||||
setShowConfig,
|
|
||||||
exportData
|
|
||||||
)}
|
|
||||||
{renderTimeRangeSelector(selectedTimeRange, setSelectedTimeRange)}
|
|
||||||
{renderSecurityOverview(metrics)}
|
|
||||||
|
|
||||||
<Tabs defaultValue="alerts" className="space-y-4">
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="alerts">Active Alerts</TabsTrigger>
|
|
||||||
<TabsTrigger value="metrics">Security Metrics</TabsTrigger>
|
|
||||||
<TabsTrigger value="threats">Threat Analysis</TabsTrigger>
|
|
||||||
<TabsTrigger value="geography">Geographic View</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="alerts" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Active Security Alerts</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Real-time security alerts requiring attention
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{alerts.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
<CheckCircle className="h-12 w-12 mx-auto mb-4" />
|
|
||||||
<p>No active alerts - system is secure</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{alerts.map((alert) => (
|
|
||||||
<div
|
|
||||||
key={alert.id}
|
|
||||||
className="flex items-center justify-between p-4 border rounded-lg"
|
|
||||||
>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant={getSeverityColor(alert.severity)}>
|
|
||||||
{alert.severity}
|
|
||||||
</Badge>
|
|
||||||
<span className="font-medium">{alert.title}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{alert.description}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{new Date(alert.timestamp).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!alert.acknowledged && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => acknowledgeAlert(alert.id)}
|
|
||||||
>
|
|
||||||
Acknowledge
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="metrics" className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Event Distribution</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{metrics?.eventsByType && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{Object.entries(metrics.eventsByType).map(
|
|
||||||
([type, count]) => (
|
|
||||||
<div key={type} className="flex justify-between">
|
|
||||||
<span className="text-sm">
|
|
||||||
{type.replace(/_/g, " ")}
|
|
||||||
</span>
|
|
||||||
<span className="font-medium">{count}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>High-Risk Users</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{metrics?.userRiskScores?.length ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{metrics.userRiskScores.slice(0, 5).map((user) => (
|
|
||||||
<div key={user.userId} className="flex justify-between">
|
|
||||||
<span className="text-sm truncate">{user.email}</span>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
user.riskScore > 70
|
|
||||||
? "destructive"
|
|
||||||
: user.riskScore > 40
|
|
||||||
? "secondary"
|
|
||||||
: "outline"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{user.riskScore}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No high-risk users detected
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="threats" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Threat Analysis</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Analysis of current security threats and recommendations
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{metrics?.topThreats?.length ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{metrics.topThreats.map((threat, index) => (
|
|
||||||
<div
|
|
||||||
key={threat.type}
|
|
||||||
className="flex items-center justify-between p-3 border rounded"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">
|
|
||||||
{threat.type.replace(/_/g, " ")}
|
|
||||||
</span>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{threat.count} occurrences
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant={index === 0 ? "destructive" : "secondary"}
|
|
||||||
>
|
|
||||||
{index === 0 ? "Highest Priority" : "Monitor"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-center py-8 text-muted-foreground">
|
|
||||||
No significant threats detected
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="geography" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Geographic Distribution</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Security events by geographic location
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{metrics?.geoDistribution &&
|
|
||||||
Object.keys(metrics.geoDistribution).length > 0 ? (
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
||||||
{Object.entries(metrics.geoDistribution)
|
|
||||||
.sort(([, a], [, b]) => b - a)
|
|
||||||
.slice(0, 12)
|
|
||||||
.map(([country, count]) => (
|
|
||||||
<div
|
|
||||||
key={country}
|
|
||||||
className="text-center p-3 border rounded"
|
|
||||||
>
|
|
||||||
<div className="text-2xl font-bold">{count}</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{country}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-center py-8 text-muted-foreground">
|
|
||||||
No geographic data available
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{showConfig && (
|
|
||||||
<SecurityConfigModal
|
|
||||||
onClose={() => setShowConfig(false)}
|
|
||||||
onSave={() => {
|
|
||||||
setShowConfig(false);
|
|
||||||
loadSecurityData();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,428 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ArrowLeft, Key, Shield, User } from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useEffect, useId, 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<{
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name?: string;
|
|
||||||
role: string;
|
|
||||||
companyId?: string;
|
|
||||||
isPlatformUser?: boolean;
|
|
||||||
platformRole?: string;
|
|
||||||
};
|
|
||||||
} | null>(null);
|
|
||||||
const [status, setStatus] = useState<
|
|
||||||
"loading" | "authenticated" | "unauthenticated"
|
|
||||||
>("loading");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const abortController = new AbortController();
|
|
||||||
|
|
||||||
const handleAuthSuccess = (sessionData: {
|
|
||||||
user?: {
|
|
||||||
id?: string;
|
|
||||||
email?: string;
|
|
||||||
name?: string;
|
|
||||||
role?: string;
|
|
||||||
companyId?: string;
|
|
||||||
isPlatformUser?: boolean;
|
|
||||||
platformRole?: string;
|
|
||||||
};
|
|
||||||
}) => {
|
|
||||||
if (sessionData?.user?.isPlatformUser) {
|
|
||||||
setSession({
|
|
||||||
user: {
|
|
||||||
id: sessionData.user.id || "",
|
|
||||||
email: sessionData.user.email || "",
|
|
||||||
name: sessionData.user.name,
|
|
||||||
role: sessionData.user.role || "",
|
|
||||||
companyId: sessionData.user.companyId,
|
|
||||||
isPlatformUser: sessionData.user.isPlatformUser,
|
|
||||||
platformRole: sessionData.user.platformRole,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setStatus("authenticated");
|
|
||||||
} else {
|
|
||||||
handleAuthFailure();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAuthFailure = (error?: unknown) => {
|
|
||||||
if (error instanceof Error && error.name === "AbortError") return;
|
|
||||||
if (error) console.error("Platform session fetch error:", error);
|
|
||||||
setSession(null);
|
|
||||||
setStatus("unauthenticated");
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchSession = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/platform/auth/session", {
|
|
||||||
signal: abortController.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) return handleAuthFailure();
|
|
||||||
throw new Error(`Failed to fetch session: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionData = await response.json();
|
|
||||||
handleAuthSuccess(sessionData);
|
|
||||||
} catch (error) {
|
|
||||||
handleAuthFailure(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchSession();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
abortController.abort();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { data: session, status };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PlatformSettings() {
|
|
||||||
const { data: session, status } = usePlatformSession();
|
|
||||||
const router = useRouter();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
// Generate unique IDs for form elements
|
|
||||||
const nameId = useId();
|
|
||||||
const emailId = useId();
|
|
||||||
const currentPasswordId = useId();
|
|
||||||
const newPasswordId = useId();
|
|
||||||
const confirmPasswordId = useId();
|
|
||||||
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={nameId}>Name</Label>
|
|
||||||
<Input
|
|
||||||
id={nameId}
|
|
||||||
value={profileData.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
setProfileData({ ...profileData, name: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="Your name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor={emailId}>Email</Label>
|
|
||||||
<Input
|
|
||||||
id={emailId}
|
|
||||||
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={currentPasswordId}>Current Password</Label>
|
|
||||||
<Input
|
|
||||||
id={currentPasswordId}
|
|
||||||
type="password"
|
|
||||||
value={passwordData.currentPassword}
|
|
||||||
onChange={(e) =>
|
|
||||||
setPasswordData({
|
|
||||||
...passwordData,
|
|
||||||
currentPassword: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor={newPasswordId}>New Password</Label>
|
|
||||||
<Input
|
|
||||||
id={newPasswordId}
|
|
||||||
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={confirmPasswordId}>
|
|
||||||
Confirm New Password
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={confirmPasswordId}
|
|
||||||
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,8 +2,6 @@
|
|||||||
|
|
||||||
import { SessionProvider } from "next-auth/react";
|
import { SessionProvider } from "next-auth/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { CSRFProvider } from "@/components/providers/CSRFProvider";
|
|
||||||
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 }) {
|
||||||
@ -20,9 +18,7 @@ export function Providers({ children }: { children: ReactNode }) {
|
|||||||
refetchInterval={30 * 60}
|
refetchInterval={30 * 60}
|
||||||
refetchOnWindowFocus={false}
|
refetchOnWindowFocus={false}
|
||||||
>
|
>
|
||||||
<CSRFProvider>
|
{children}
|
||||||
<TRPCProvider>{children}</TRPCProvider>
|
|
||||||
</CSRFProvider>
|
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,125 +2,87 @@ import { PrismaClient } from "@prisma/client";
|
|||||||
import { ProcessingStatusManager } from "./lib/processingStatusManager";
|
import { ProcessingStatusManager } from "./lib/processingStatusManager";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
const statusManager = new ProcessingStatusManager(prisma);
|
|
||||||
|
|
||||||
const PIPELINE_STAGES = [
|
|
||||||
"CSV_IMPORT",
|
|
||||||
"TRANSCRIPT_FETCH",
|
|
||||||
"SESSION_CREATION",
|
|
||||||
"AI_ANALYSIS",
|
|
||||||
"QUESTION_EXTRACTION",
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display status for a single pipeline stage
|
|
||||||
*/
|
|
||||||
function displayStageStatus(
|
|
||||||
stage: string,
|
|
||||||
stageData: Record<string, number> = {}
|
|
||||||
) {
|
|
||||||
console.log(`${stage}:`);
|
|
||||||
const pending = stageData.PENDING || 0;
|
|
||||||
const inProgress = stageData.IN_PROGRESS || 0;
|
|
||||||
const completed = stageData.COMPLETED || 0;
|
|
||||||
const failed = stageData.FAILED || 0;
|
|
||||||
const skipped = stageData.SKIPPED || 0;
|
|
||||||
|
|
||||||
console.log(` PENDING: ${pending}`);
|
|
||||||
console.log(` IN_PROGRESS: ${inProgress}`);
|
|
||||||
console.log(` COMPLETED: ${completed}`);
|
|
||||||
console.log(` FAILED: ${failed}`);
|
|
||||||
console.log(` SKIPPED: ${skipped}`);
|
|
||||||
console.log("");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display what needs processing across all stages
|
|
||||||
*/
|
|
||||||
function displayProcessingNeeds(pipelineStatus: {
|
|
||||||
pipeline: Record<string, unknown>;
|
|
||||||
}) {
|
|
||||||
console.log("=== WHAT NEEDS PROCESSING ===");
|
|
||||||
|
|
||||||
for (const stage of PIPELINE_STAGES) {
|
|
||||||
const stageData = pipelineStatus.pipeline[stage] || {};
|
|
||||||
const pending = stageData.PENDING || 0;
|
|
||||||
const failed = stageData.FAILED || 0;
|
|
||||||
|
|
||||||
if (pending > 0 || failed > 0) {
|
|
||||||
console.log(`• ${stage}: ${pending} pending, ${failed} failed`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display failed sessions summary
|
|
||||||
*/
|
|
||||||
function displayFailedSessions(failedSessions: unknown[]) {
|
|
||||||
if (failedSessions.length === 0) return;
|
|
||||||
|
|
||||||
console.log("\n=== FAILED SESSIONS ===");
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: Function parameter types from external API
|
|
||||||
failedSessions.slice(0, 5).forEach((failure: any) => {
|
|
||||||
console.log(
|
|
||||||
` ${failure.session.import?.externalSessionId || failure.sessionId}: ${failure.stage} - ${failure.errorMessage}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (failedSessions.length > 5) {
|
|
||||||
console.log(` ... and ${failedSessions.length - 5} more failed sessions`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display sessions ready for AI processing
|
|
||||||
*/
|
|
||||||
function displayReadyForAI(
|
|
||||||
readyForAI: Array<{
|
|
||||||
sessionId: string;
|
|
||||||
session: {
|
|
||||||
import?: { externalSessionId?: string };
|
|
||||||
createdAt: Date;
|
|
||||||
};
|
|
||||||
}>
|
|
||||||
) {
|
|
||||||
if (readyForAI.length === 0) return;
|
|
||||||
|
|
||||||
console.log("\n=== SESSIONS READY FOR AI PROCESSING ===");
|
|
||||||
readyForAI.forEach((status) => {
|
|
||||||
console.log(
|
|
||||||
` ${status.session.import?.externalSessionId || status.sessionId} (created: ${status.session.createdAt})`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkRefactoredPipelineStatus() {
|
async function checkRefactoredPipelineStatus() {
|
||||||
try {
|
try {
|
||||||
console.log("=== REFACTORED PIPELINE STATUS ===\n");
|
console.log("=== REFACTORED PIPELINE STATUS ===\n");
|
||||||
|
|
||||||
// Get pipeline status using the new system
|
// Get pipeline status using the new system
|
||||||
const pipelineStatus = await statusManager.getPipelineStatus();
|
const pipelineStatus = await ProcessingStatusManager.getPipelineStatus();
|
||||||
|
|
||||||
console.log(`Total Sessions: ${pipelineStatus.totalSessions}\n`);
|
console.log(`Total Sessions: ${pipelineStatus.totalSessions}\n`);
|
||||||
|
|
||||||
// Display status for each stage
|
// Display status for each stage
|
||||||
for (const stage of PIPELINE_STAGES) {
|
const stages = [
|
||||||
|
"CSV_IMPORT",
|
||||||
|
"TRANSCRIPT_FETCH",
|
||||||
|
"SESSION_CREATION",
|
||||||
|
"AI_ANALYSIS",
|
||||||
|
"QUESTION_EXTRACTION",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const stage of stages) {
|
||||||
|
console.log(`${stage}:`);
|
||||||
const stageData = pipelineStatus.pipeline[stage] || {};
|
const stageData = pipelineStatus.pipeline[stage] || {};
|
||||||
displayStageStatus(stage, stageData);
|
|
||||||
|
const pending = stageData.PENDING || 0;
|
||||||
|
const inProgress = stageData.IN_PROGRESS || 0;
|
||||||
|
const completed = stageData.COMPLETED || 0;
|
||||||
|
const failed = stageData.FAILED || 0;
|
||||||
|
const skipped = stageData.SKIPPED || 0;
|
||||||
|
|
||||||
|
console.log(` PENDING: ${pending}`);
|
||||||
|
console.log(` IN_PROGRESS: ${inProgress}`);
|
||||||
|
console.log(` COMPLETED: ${completed}`);
|
||||||
|
console.log(` FAILED: ${failed}`);
|
||||||
|
console.log(` SKIPPED: ${skipped}`);
|
||||||
|
console.log("");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show what needs processing
|
// Show what needs processing
|
||||||
displayProcessingNeeds(pipelineStatus);
|
console.log("=== WHAT NEEDS PROCESSING ===");
|
||||||
|
|
||||||
|
for (const stage of stages) {
|
||||||
|
const stageData = pipelineStatus.pipeline[stage] || {};
|
||||||
|
const pending = stageData.PENDING || 0;
|
||||||
|
const failed = stageData.FAILED || 0;
|
||||||
|
|
||||||
|
if (pending > 0 || failed > 0) {
|
||||||
|
console.log(`• ${stage}: ${pending} pending, ${failed} failed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show failed sessions if any
|
// Show failed sessions if any
|
||||||
const failedSessions = await statusManager.getFailedSessions();
|
const failedSessions = await ProcessingStatusManager.getFailedSessions();
|
||||||
displayFailedSessions(failedSessions);
|
if (failedSessions.length > 0) {
|
||||||
|
console.log("\n=== FAILED SESSIONS ===");
|
||||||
|
failedSessions.slice(0, 5).forEach((failure) => {
|
||||||
|
console.log(
|
||||||
|
` ${failure.session.import?.externalSessionId || failure.sessionId}: ${failure.stage} - ${failure.errorMessage}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (failedSessions.length > 5) {
|
||||||
|
console.log(
|
||||||
|
` ... and ${failedSessions.length - 5} more failed sessions`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show sessions ready for AI processing
|
// Show sessions ready for AI processing
|
||||||
const readyForAI = await statusManager.getSessionsNeedingProcessing(
|
const readyForAI =
|
||||||
"AI_ANALYSIS",
|
await ProcessingStatusManager.getSessionsNeedingProcessing(
|
||||||
5
|
"AI_ANALYSIS",
|
||||||
);
|
5
|
||||||
displayReadyForAI(readyForAI);
|
);
|
||||||
|
if (readyForAI.length > 0) {
|
||||||
|
console.log("\n=== SESSIONS READY FOR AI PROCESSING ===");
|
||||||
|
readyForAI.forEach((status) => {
|
||||||
|
console.log(
|
||||||
|
` ${status.session.import?.externalSessionId || status.sessionId} (created: ${status.session.createdAt})`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error checking pipeline status:", error);
|
console.error("Error checking pipeline status:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
308
components/Charts.tsx
Normal file
308
components/Charts.tsx
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
"use client";
|
||||||
|
import Chart from "chart.js/auto";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { getLocalizedLanguageName } from "../lib/localization"; // Corrected import path
|
||||||
|
|
||||||
|
interface SessionsData {
|
||||||
|
[date: string]: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoriesData {
|
||||||
|
[category: string]: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LanguageData {
|
||||||
|
[language: string]: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionsLineChartProps {
|
||||||
|
sessionsPerDay: SessionsData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoriesBarChartProps {
|
||||||
|
categories: CategoriesData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LanguagePieChartProps {
|
||||||
|
languages: LanguageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SentimentChartProps {
|
||||||
|
sentimentData: {
|
||||||
|
positive: number;
|
||||||
|
neutral: number;
|
||||||
|
negative: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenUsageChartProps {
|
||||||
|
tokenData: {
|
||||||
|
labels: string[];
|
||||||
|
values: number[];
|
||||||
|
costs: number[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic line and bar chart for metrics. Extend as needed.
|
||||||
|
export function SessionsLineChart({ sessionsPerDay }: SessionsLineChartProps) {
|
||||||
|
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current || !sessionsPerDay) return;
|
||||||
|
const ctx = ref.current.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const chart = new Chart(ctx, {
|
||||||
|
type: "line",
|
||||||
|
data: {
|
||||||
|
labels: Object.keys(sessionsPerDay),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Sessions",
|
||||||
|
data: Object.values(sessionsPerDay),
|
||||||
|
borderColor: "rgb(59, 130, 246)",
|
||||||
|
backgroundColor: "rgba(59, 130, 246, 0.1)",
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.3,
|
||||||
|
fill: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: { y: { beginAtZero: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return () => chart.destroy();
|
||||||
|
}, [sessionsPerDay]);
|
||||||
|
return <canvas ref={ref} height={180} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoriesBarChart({ categories }: CategoriesBarChartProps) {
|
||||||
|
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current || !categories) return;
|
||||||
|
const ctx = ref.current.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const chart = new Chart(ctx, {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels: Object.keys(categories),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Categories",
|
||||||
|
data: Object.values(categories),
|
||||||
|
backgroundColor: "rgba(59, 130, 246, 0.7)",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: { y: { beginAtZero: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return () => chart.destroy();
|
||||||
|
}, [categories]);
|
||||||
|
return <canvas ref={ref} height={180} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SentimentChart({ sentimentData }: SentimentChartProps) {
|
||||||
|
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current || !sentimentData) return;
|
||||||
|
const ctx = ref.current.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const chart = new Chart(ctx, {
|
||||||
|
type: "doughnut",
|
||||||
|
data: {
|
||||||
|
labels: ["Positive", "Neutral", "Negative"],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: [
|
||||||
|
sentimentData.positive,
|
||||||
|
sentimentData.neutral,
|
||||||
|
sentimentData.negative,
|
||||||
|
],
|
||||||
|
backgroundColor: [
|
||||||
|
"rgba(34, 197, 94, 0.8)", // green
|
||||||
|
"rgba(249, 115, 22, 0.8)", // orange
|
||||||
|
"rgba(239, 68, 68, 0.8)", // red
|
||||||
|
],
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: "right",
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cutout: "65%",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return () => chart.destroy();
|
||||||
|
}, [sentimentData]);
|
||||||
|
return <canvas ref={ref} height={180} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LanguagePieChart({ languages }: LanguagePieChartProps) {
|
||||||
|
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current || !languages) return;
|
||||||
|
const ctx = ref.current.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// Get top 5 languages, combine others
|
||||||
|
const entries = Object.entries(languages);
|
||||||
|
const topLanguages = entries.sort((a, b) => b[1] - a[1]).slice(0, 5);
|
||||||
|
|
||||||
|
// Sum the count of all other languages
|
||||||
|
const otherCount = entries
|
||||||
|
.slice(5)
|
||||||
|
.reduce((sum, [, count]) => sum + count, 0);
|
||||||
|
if (otherCount > 0) {
|
||||||
|
topLanguages.push(["Other", otherCount]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store original ISO codes for tooltip
|
||||||
|
const isoCodes = topLanguages.map(([lang]) => lang);
|
||||||
|
|
||||||
|
const labels = topLanguages.map(([lang]) => {
|
||||||
|
if (lang === "Other") {
|
||||||
|
return "Other";
|
||||||
|
}
|
||||||
|
// Use getLocalizedLanguageName for robust name resolution
|
||||||
|
// Pass "en" to maintain consistency with previous behavior if navigator.language is different
|
||||||
|
return getLocalizedLanguageName(lang, "en");
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = topLanguages.map(([, count]) => count);
|
||||||
|
|
||||||
|
const chart = new Chart(ctx, {
|
||||||
|
type: "pie",
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data,
|
||||||
|
backgroundColor: [
|
||||||
|
"rgba(59, 130, 246, 0.8)",
|
||||||
|
"rgba(16, 185, 129, 0.8)",
|
||||||
|
"rgba(249, 115, 22, 0.8)",
|
||||||
|
"rgba(236, 72, 153, 0.8)",
|
||||||
|
"rgba(139, 92, 246, 0.8)",
|
||||||
|
"rgba(107, 114, 128, 0.8)",
|
||||||
|
],
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: "right",
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (context) => {
|
||||||
|
const label = context.label || "";
|
||||||
|
const value = context.formattedValue || "";
|
||||||
|
const index = context.dataIndex;
|
||||||
|
const originalIsoCode = isoCodes[index]; // Get the original code
|
||||||
|
|
||||||
|
// Only show ISO code if it's not "Other"
|
||||||
|
// and it's a valid 2-letter code (check lowercase version)
|
||||||
|
if (
|
||||||
|
originalIsoCode &&
|
||||||
|
originalIsoCode !== "Other" &&
|
||||||
|
/^[a-z]{2}$/.test(originalIsoCode.toLowerCase())
|
||||||
|
) {
|
||||||
|
return `${label} (${originalIsoCode.toUpperCase()}): ${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${label}: ${value}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return () => chart.destroy();
|
||||||
|
}, [languages]);
|
||||||
|
return <canvas ref={ref} height={180} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TokenUsageChart({ tokenData }: TokenUsageChartProps) {
|
||||||
|
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current || !tokenData) return;
|
||||||
|
const ctx = ref.current.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const chart = new Chart(ctx, {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels: tokenData.labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Tokens",
|
||||||
|
data: tokenData.values,
|
||||||
|
backgroundColor: "rgba(59, 130, 246, 0.7)",
|
||||||
|
borderWidth: 1,
|
||||||
|
yAxisID: "y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Cost (EUR)",
|
||||||
|
data: tokenData.costs,
|
||||||
|
backgroundColor: "rgba(16, 185, 129, 0.7)",
|
||||||
|
borderWidth: 1,
|
||||||
|
type: "line",
|
||||||
|
yAxisID: "y1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { display: true } },
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
position: "left",
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "Token Count",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y1: {
|
||||||
|
beginAtZero: true,
|
||||||
|
position: "right",
|
||||||
|
grid: {
|
||||||
|
drawOnChartArea: false,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "Cost (EUR)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return () => chart.destroy();
|
||||||
|
}, [tokenData]);
|
||||||
|
return <canvas ref={ref} height={180} />;
|
||||||
|
}
|
||||||
155
components/DonutChart.tsx
Normal file
155
components/DonutChart.tsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Chart, { type BubbleDataPoint, type Point } from "chart.js/auto";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
interface DonutChartProps {
|
||||||
|
data: {
|
||||||
|
labels: string[];
|
||||||
|
values: number[];
|
||||||
|
colors?: string[];
|
||||||
|
};
|
||||||
|
centerText?: {
|
||||||
|
title?: string;
|
||||||
|
value?: string | number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DonutChart({ data, centerText }: DonutChartProps) {
|
||||||
|
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current || !data.values.length) return;
|
||||||
|
|
||||||
|
const ctx = ref.current.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// Default colors if not provided
|
||||||
|
const defaultColors: string[] = [
|
||||||
|
"rgba(59, 130, 246, 0.8)", // blue
|
||||||
|
"rgba(16, 185, 129, 0.8)", // green
|
||||||
|
"rgba(249, 115, 22, 0.8)", // orange
|
||||||
|
"rgba(236, 72, 153, 0.8)", // pink
|
||||||
|
"rgba(139, 92, 246, 0.8)", // purple
|
||||||
|
"rgba(107, 114, 128, 0.8)", // gray
|
||||||
|
];
|
||||||
|
|
||||||
|
const colors: string[] = data.colors || defaultColors;
|
||||||
|
|
||||||
|
// Helper to create an array of colors based on the data length
|
||||||
|
const getColors = () => {
|
||||||
|
const result: string[] = [];
|
||||||
|
for (let i = 0; i < data.values.length; i++) {
|
||||||
|
result.push(colors[i % colors.length]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const chart = new Chart(ctx, {
|
||||||
|
type: "doughnut",
|
||||||
|
data: {
|
||||||
|
labels: data.labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: data.values,
|
||||||
|
backgroundColor: getColors(),
|
||||||
|
borderWidth: 1,
|
||||||
|
hoverOffset: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
cutout: "70%",
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: "right",
|
||||||
|
labels: {
|
||||||
|
boxWidth: 12,
|
||||||
|
padding: 20,
|
||||||
|
usePointStyle: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (context) => {
|
||||||
|
const label = context.label || "";
|
||||||
|
const value = context.formattedValue;
|
||||||
|
const total = context.chart.data.datasets[0].data.reduce(
|
||||||
|
(
|
||||||
|
a: number,
|
||||||
|
b:
|
||||||
|
| number
|
||||||
|
| Point
|
||||||
|
| [number, number]
|
||||||
|
| BubbleDataPoint
|
||||||
|
| null
|
||||||
|
) => {
|
||||||
|
if (typeof b === "number") {
|
||||||
|
return a + b;
|
||||||
|
}
|
||||||
|
// Handle other types like Point, [number, number], BubbleDataPoint if necessary
|
||||||
|
// For now, we'll assume they don't contribute to the sum or are handled elsewhere
|
||||||
|
return a;
|
||||||
|
},
|
||||||
|
0
|
||||||
|
) as number;
|
||||||
|
const percentage = Math.round((context.parsed * 100) / total);
|
||||||
|
return `${label}: ${value} (${percentage}%)`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: centerText
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "centerText",
|
||||||
|
beforeDraw: (chart: Chart<"doughnut">) => {
|
||||||
|
const height = chart.height;
|
||||||
|
const ctx = chart.ctx;
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Calculate the actual chart area width (excluding legend)
|
||||||
|
// Legend is positioned on the right, so we adjust the center X coordinate
|
||||||
|
const chartArea = chart.chartArea;
|
||||||
|
const chartWidth = chartArea.right - chartArea.left;
|
||||||
|
|
||||||
|
// Get the center of just the chart area (not including the legend)
|
||||||
|
const centerX = chartArea.left + chartWidth / 2;
|
||||||
|
const centerY = height / 2;
|
||||||
|
|
||||||
|
// Title text
|
||||||
|
if (centerText.title) {
|
||||||
|
ctx.font = "1rem sans-serif"; // Consistent font
|
||||||
|
ctx.fillStyle = "#6B7280"; // Tailwind gray-500
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "middle"; // Align vertically
|
||||||
|
ctx.fillText(centerText.title, centerX, centerY - 10); // Adjust Y offset
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value text
|
||||||
|
if (centerText.value !== undefined) {
|
||||||
|
ctx.font = "bold 1.5rem sans-serif"; // Consistent font, larger
|
||||||
|
ctx.fillStyle = "#1F2937"; // Tailwind gray-800
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "middle"; // Align vertically
|
||||||
|
ctx.fillText(
|
||||||
|
centerText.value.toString(),
|
||||||
|
centerX,
|
||||||
|
centerY + 15
|
||||||
|
); // Adjust Y offset
|
||||||
|
}
|
||||||
|
ctx.save();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => chart.destroy();
|
||||||
|
}, [data, centerText]);
|
||||||
|
|
||||||
|
return <canvas ref={ref} height={300} />;
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { 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";
|
||||||
|
|
||||||
@ -18,64 +18,45 @@ interface GeographicMapProps {
|
|||||||
height?: number; // Optional height for the container
|
height?: number; // Optional height for the container
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Get country coordinates from the @rapideditor/country-coder package
|
||||||
* Get coordinates for a country using the country-coder library
|
const getCountryCoordinates = (): Record<string, [number, number]> => {
|
||||||
* This automatically extracts coordinates from the country geometry
|
// Initialize with some fallback coordinates for common countries
|
||||||
*/
|
const coordinates: Record<string, [number, number]> = {
|
||||||
function getCoordinatesFromCountryCoder(
|
US: [37.0902, -95.7129],
|
||||||
countryCode: string
|
GB: [55.3781, -3.436],
|
||||||
): [number, number] | undefined {
|
BA: [43.9159, 17.6791],
|
||||||
try {
|
NL: [52.1326, 5.2913],
|
||||||
const feature = countryCoder.feature(countryCode);
|
DE: [51.1657, 10.4515],
|
||||||
if (!feature?.geometry) {
|
FR: [46.6034, 1.8883],
|
||||||
return undefined;
|
IT: [41.8719, 12.5674],
|
||||||
}
|
ES: [40.4637, -3.7492],
|
||||||
|
CA: [56.1304, -106.3468],
|
||||||
|
PL: [51.9194, 19.1451],
|
||||||
|
SE: [60.1282, 18.6435],
|
||||||
|
NO: [60.472, 8.4689],
|
||||||
|
FI: [61.9241, 25.7482],
|
||||||
|
CH: [46.8182, 8.2275],
|
||||||
|
AT: [47.5162, 14.5501],
|
||||||
|
BE: [50.8503, 4.3517],
|
||||||
|
DK: [56.2639, 9.5018],
|
||||||
|
CZ: [49.8175, 15.473],
|
||||||
|
HU: [47.1625, 19.5033],
|
||||||
|
PT: [39.3999, -8.2245],
|
||||||
|
GR: [39.0742, 21.8243],
|
||||||
|
RO: [45.9432, 24.9668],
|
||||||
|
IE: [53.4129, -8.2439],
|
||||||
|
BG: [42.7339, 25.4858],
|
||||||
|
HR: [45.1, 15.2],
|
||||||
|
SK: [48.669, 19.699],
|
||||||
|
SI: [46.1512, 14.9955],
|
||||||
|
};
|
||||||
|
// This function now primarily returns fallbacks.
|
||||||
|
// The actual fetching using @rapideditor/country-coder will be in the component's useEffect.
|
||||||
|
return coordinates;
|
||||||
|
};
|
||||||
|
|
||||||
// Extract center coordinates from the geometry
|
// Load coordinates once when module is imported
|
||||||
if (feature.geometry.type === "Point") {
|
const DEFAULT_COORDINATES = getCountryCoordinates();
|
||||||
const [lon, lat] = feature.geometry.coordinates;
|
|
||||||
return [lat, lon]; // Leaflet expects [lat, lon]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
feature.geometry.type === "Polygon" &&
|
|
||||||
feature.geometry.coordinates?.[0]?.[0]
|
|
||||||
) {
|
|
||||||
// For polygons, calculate centroid from the first ring
|
|
||||||
const coordinates = feature.geometry.coordinates[0];
|
|
||||||
let lat = 0;
|
|
||||||
let lon = 0;
|
|
||||||
for (const [lng, ltd] of coordinates) {
|
|
||||||
lon += lng;
|
|
||||||
lat += ltd;
|
|
||||||
}
|
|
||||||
return [lat / coordinates.length, lon / coordinates.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
feature.geometry.type === "MultiPolygon" &&
|
|
||||||
feature.geometry.coordinates?.[0]?.[0]?.[0]
|
|
||||||
) {
|
|
||||||
// For multipolygons, use the first polygon's first ring for centroid
|
|
||||||
const coordinates = feature.geometry.coordinates[0][0];
|
|
||||||
let lat = 0;
|
|
||||||
let lon = 0;
|
|
||||||
for (const [lng, ltd] of coordinates) {
|
|
||||||
lon += lng;
|
|
||||||
lat += ltd;
|
|
||||||
}
|
|
||||||
return [lat / coordinates.length, lon / coordinates.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
`Failed to get coordinates for country ${countryCode}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dynamically import the Map component to avoid SSR issues
|
// Dynamically import the Map component to avoid SSR issues
|
||||||
// This ensures the component only loads on the client side
|
// This ensures the component only loads on the client side
|
||||||
@ -90,7 +71,7 @@ const CountryMapComponent = dynamic(() => import("./Map"), {
|
|||||||
|
|
||||||
export default function GeographicMap({
|
export default function GeographicMap({
|
||||||
countries,
|
countries,
|
||||||
countryCoordinates = {},
|
countryCoordinates = DEFAULT_COORDINATES,
|
||||||
height = 400,
|
height = 400,
|
||||||
}: GeographicMapProps) {
|
}: GeographicMapProps) {
|
||||||
const [countryData, setCountryData] = useState<CountryData[]>([]);
|
const [countryData, setCountryData] = useState<CountryData[]>([]);
|
||||||
@ -101,82 +82,67 @@ export default function GeographicMap({
|
|||||||
setIsClient(true);
|
setIsClient(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
// Process country data when client is ready and dependencies change
|
||||||
* Get coordinates for a country code
|
useEffect(() => {
|
||||||
*/
|
if (!isClient || !countries) return;
|
||||||
const getCountryCoordinates = useCallback(
|
|
||||||
(
|
|
||||||
code: string,
|
|
||||||
countryCoordinates: Record<string, [number, number]>
|
|
||||||
): [number, number] | undefined => {
|
|
||||||
// Try custom coordinates first (allows overrides)
|
|
||||||
let coords: [number, number] | undefined = countryCoordinates[code];
|
|
||||||
|
|
||||||
if (!coords) {
|
try {
|
||||||
// Automatically get coordinates from country-coder library
|
// Generate CountryData array for the Map component
|
||||||
coords = getCoordinatesFromCountryCoder(code);
|
const data: CountryData[] = Object.entries(countries || {})
|
||||||
}
|
.map(([code, count]) => {
|
||||||
|
let countryCoords: [number, number] | undefined =
|
||||||
|
countryCoordinates[code] || DEFAULT_COORDINATES[code];
|
||||||
|
|
||||||
return coords;
|
if (!countryCoords) {
|
||||||
},
|
const feature = countryCoder.feature(code);
|
||||||
[]
|
if (feature?.geometry) {
|
||||||
);
|
if (feature.geometry.type === "Point") {
|
||||||
|
const [lon, lat] = feature.geometry.coordinates;
|
||||||
|
countryCoords = [lat, lon]; // Leaflet expects [lat, lon]
|
||||||
|
} else if (
|
||||||
|
feature.geometry.type === "Polygon" &&
|
||||||
|
feature.geometry.coordinates &&
|
||||||
|
feature.geometry.coordinates[0] &&
|
||||||
|
feature.geometry.coordinates[0][0]
|
||||||
|
) {
|
||||||
|
// For Polygons, use the first coordinate of the first ring as a fallback representative point
|
||||||
|
const [lon, lat] = feature.geometry.coordinates[0][0];
|
||||||
|
countryCoords = [lat, lon]; // Leaflet expects [lat, lon]
|
||||||
|
} else if (
|
||||||
|
feature.geometry.type === "MultiPolygon" &&
|
||||||
|
feature.geometry.coordinates &&
|
||||||
|
feature.geometry.coordinates[0] &&
|
||||||
|
feature.geometry.coordinates[0][0] &&
|
||||||
|
feature.geometry.coordinates[0][0][0]
|
||||||
|
) {
|
||||||
|
// For MultiPolygons, use the first coordinate of the first ring of the first polygon
|
||||||
|
const [lon, lat] = feature.geometry.coordinates[0][0][0];
|
||||||
|
countryCoords = [lat, lon]; // Leaflet expects [lat, lon]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
if (countryCoords) {
|
||||||
* Process a single country entry into CountryData
|
return {
|
||||||
*/
|
code,
|
||||||
const processCountryEntry = useCallback(
|
count,
|
||||||
(
|
coordinates: countryCoords,
|
||||||
code: string,
|
};
|
||||||
count: number,
|
}
|
||||||
countryCoordinates: Record<string, [number, number]>
|
return null; // Skip if no coordinates found
|
||||||
): CountryData | null => {
|
})
|
||||||
const coordinates = getCountryCoordinates(code, countryCoordinates);
|
|
||||||
|
|
||||||
if (coordinates) {
|
|
||||||
return { code, count, coordinates };
|
|
||||||
}
|
|
||||||
|
|
||||||
return null; // Skip if no coordinates found
|
|
||||||
},
|
|
||||||
[getCountryCoordinates]
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process all countries data into CountryData array
|
|
||||||
*/
|
|
||||||
const processCountriesData = useCallback(
|
|
||||||
(
|
|
||||||
countries: Record<string, number>,
|
|
||||||
countryCoordinates: Record<string, [number, number]>
|
|
||||||
): CountryData[] => {
|
|
||||||
const data = Object.entries(countries || {})
|
|
||||||
.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;
|
|
||||||
},
|
|
||||||
[processCountryEntry]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Process country data when client is ready and dependencies change
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isClient || !countries) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = processCountriesData(countries, countryCoordinates);
|
|
||||||
setCountryData(data);
|
setCountryData(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error processing geographic data:", error);
|
console.error("Error processing geographic data:", error);
|
||||||
setCountryData([]);
|
setCountryData([]);
|
||||||
}
|
}
|
||||||
}, [countries, countryCoordinates, isClient, processCountriesData]);
|
}, [countries, countryCoordinates, isClient]);
|
||||||
|
|
||||||
// Find the max count for scaling circles - handle empty or null countries object
|
// Find the max count for scaling circles - handle empty or null countries object
|
||||||
const countryValues = countries ? Object.values(countries) : [];
|
const countryValues = countries ? Object.values(countries) : [];
|
||||||
|
|||||||
@ -70,7 +70,6 @@ export default function MessageViewer({ messages }: MessageViewerProps) {
|
|||||||
? new Date(messages[0].timestamp).toLocaleString()
|
? new Date(messages[0].timestamp).toLocaleString()
|
||||||
: "No timestamp"}
|
: "No timestamp"}
|
||||||
</span>
|
</span>
|
||||||
{/* prettier-ignore */}
|
|
||||||
<span>
|
<span>
|
||||||
Last message: {(() => {
|
Last message: {(() => {
|
||||||
const lastMessage = messages[messages.length - 1];
|
const lastMessage = messages[messages.length - 1];
|
||||||
|
|||||||
@ -13,254 +13,166 @@ interface SessionDetailsProps {
|
|||||||
session: ChatSession;
|
session: ChatSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Component for basic session information
|
|
||||||
*/
|
|
||||||
function SessionBasicInfo({ session }: { session: ChatSession }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-muted-foreground mb-2">
|
|
||||||
Basic Information
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div>
|
|
||||||
<span className="text-xs text-muted-foreground">Session ID:</span>
|
|
||||||
<code className="ml-2 text-xs font-mono bg-muted px-1 py-0.5 rounded">
|
|
||||||
{session.id.slice(0, 8)}...
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-xs text-muted-foreground">Start Time:</span>
|
|
||||||
<span className="ml-2 text-sm">
|
|
||||||
{new Date(session.startTime).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{session.endTime && (
|
|
||||||
<div>
|
|
||||||
<span className="text-xs text-muted-foreground">End Time:</span>
|
|
||||||
<span className="ml-2 text-sm">
|
|
||||||
{new Date(session.endTime).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component for session location and language
|
|
||||||
*/
|
|
||||||
function SessionLocationInfo({ session }: { session: ChatSession }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-muted-foreground mb-2">
|
|
||||||
Location & Language
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{session.country && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground">Country:</span>
|
|
||||||
<CountryDisplay countryCode={session.country} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{session.language && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground">Language:</span>
|
|
||||||
<LanguageDisplay languageCode={session.language} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{session.ipAddress && (
|
|
||||||
<div>
|
|
||||||
<span className="text-xs text-muted-foreground">IP Address:</span>
|
|
||||||
<span className="ml-2 font-mono text-sm">
|
|
||||||
{session.ipAddress}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component for session metrics
|
|
||||||
*/
|
|
||||||
function SessionMetrics({ session }: { session: ChatSession }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-muted-foreground mb-2">
|
|
||||||
Session Metrics
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{session.messagesSent !== null &&
|
|
||||||
session.messagesSent !== undefined && (
|
|
||||||
<div>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Messages Sent:
|
|
||||||
</span>
|
|
||||||
<span className="ml-2 text-sm font-medium">
|
|
||||||
{session.messagesSent}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{session.userId && (
|
|
||||||
<div>
|
|
||||||
<span className="text-xs text-muted-foreground">User ID:</span>
|
|
||||||
<span className="ml-2 text-sm">{session.userId}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component for session analysis and status
|
|
||||||
*/
|
|
||||||
function SessionAnalysis({ session }: { session: ChatSession }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-muted-foreground mb-2">
|
|
||||||
AI Analysis
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{session.category && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground">Category:</span>
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{formatCategory(session.category)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{session.sentiment && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground">Sentiment:</span>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
session.sentiment === "positive"
|
|
||||||
? "default"
|
|
||||||
: session.sentiment === "negative"
|
|
||||||
? "destructive"
|
|
||||||
: "secondary"
|
|
||||||
}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{session.sentiment.charAt(0).toUpperCase() +
|
|
||||||
session.sentiment.slice(1)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component for session status flags
|
|
||||||
*/
|
|
||||||
function SessionStatusFlags({ session }: { session: ChatSession }) {
|
|
||||||
const hasStatusFlags =
|
|
||||||
session.escalated !== null || session.forwardedHr !== null;
|
|
||||||
|
|
||||||
if (!hasStatusFlags) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-muted-foreground mb-2">
|
|
||||||
Status Flags
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{session.escalated !== null && session.escalated !== undefined && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground">Escalated:</span>
|
|
||||||
<Badge
|
|
||||||
variant={session.escalated ? "destructive" : "outline"}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{session.escalated ? "Yes" : "No"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{session.forwardedHr !== null &&
|
|
||||||
session.forwardedHr !== undefined && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Forwarded to HR:
|
|
||||||
</span>
|
|
||||||
<Badge
|
|
||||||
variant={session.forwardedHr ? "destructive" : "outline"}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{session.forwardedHr ? "Yes" : "No"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component for session summary
|
|
||||||
*/
|
|
||||||
function SessionSummary({ session }: { session: ChatSession }) {
|
|
||||||
if (!session.summary) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-medium text-muted-foreground">AI Summary</h4>
|
|
||||||
<p className="text-sm leading-relaxed border-l-4 border-muted pl-4 italic">
|
|
||||||
{session.summary}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component to display session details with formatted country and language names
|
* Component to display session details with formatted country and language names
|
||||||
*/
|
*/
|
||||||
export default function SessionDetails({ session }: SessionDetailsProps) {
|
export default function SessionDetails({ session }: SessionDetailsProps) {
|
||||||
|
// Using centralized formatCategory utility
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Session Information</CardTitle>
|
<CardTitle>Session Information</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<SessionBasicInfo session={session} />
|
<div className="space-y-3">
|
||||||
<SessionLocationInfo session={session} />
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Session ID</p>
|
||||||
|
<code className="text-sm font-mono bg-muted px-2 py-1 rounded">
|
||||||
|
{session.id.slice(0, 8)}...
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Start Time</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{new Date(session.startTime).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{session.endTime && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">End Time</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{new Date(session.endTime).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session.category && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Category</p>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{formatCategory(session.category)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session.language && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Language</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LanguageDisplay languageCode={session.language} />
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{session.language.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session.country && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Country</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CountryDisplay countryCode={session.country} />
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{session.country}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{session.sentiment !== null && session.sentiment !== undefined && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Sentiment</p>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
session.sentiment === "positive"
|
||||||
|
? "default"
|
||||||
|
: session.sentiment === "negative"
|
||||||
|
? "destructive"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{session.sentiment.charAt(0).toUpperCase() +
|
||||||
|
session.sentiment.slice(1)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Messages Sent</p>
|
||||||
|
<p className="font-medium">{session.messagesSent || 0}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{session.avgResponseTime !== null &&
|
||||||
|
session.avgResponseTime !== undefined && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Avg Response Time
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{session.avgResponseTime.toFixed(2)}s
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session.escalated !== null && session.escalated !== undefined && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Escalated</p>
|
||||||
|
<Badge variant={session.escalated ? "destructive" : "default"}>
|
||||||
|
{session.escalated ? "Yes" : "No"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session.forwardedHr !== null &&
|
||||||
|
session.forwardedHr !== undefined && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Forwarded to HR
|
||||||
|
</p>
|
||||||
|
<Badge
|
||||||
|
variant={session.forwardedHr ? "secondary" : "default"}
|
||||||
|
>
|
||||||
|
{session.forwardedHr ? "Yes" : "No"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session.ipAddress && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">IP Address</p>
|
||||||
|
<code className="text-sm font-mono bg-muted px-2 py-1 rounded">
|
||||||
|
{session.ipAddress}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
{(session.summary || session.initialMsg) && <Separator />}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
{session.summary && (
|
||||||
<SessionMetrics session={session} />
|
<div>
|
||||||
<SessionAnalysis session={session} />
|
<p className="text-sm text-muted-foreground mb-2">AI Summary</p>
|
||||||
</div>
|
<div className="bg-muted p-3 rounded-md text-sm">
|
||||||
|
{session.summary}
|
||||||
<SessionStatusFlags session={session} />
|
</div>
|
||||||
|
</div>
|
||||||
<SessionSummary session={session} />
|
)}
|
||||||
|
|
||||||
{!session.summary && session.initialMsg && (
|
{!session.summary && session.initialMsg && (
|
||||||
<div className="space-y-2">
|
<div>
|
||||||
<h4 className="text-sm font-medium text-muted-foreground">
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
Initial Message
|
Initial Message
|
||||||
</h4>
|
|
||||||
<p className="text-sm leading-relaxed border-l-4 border-muted pl-4 italic">
|
|
||||||
"{session.initialMsg}"
|
|
||||||
</p>
|
</p>
|
||||||
|
<div className="bg-muted p-3 rounded-md text-sm italic">
|
||||||
|
"{session.initialMsg}"
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -99,24 +99,6 @@ const SessionsIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const AuditLogIcon = () => (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<title>Audit Logs</title>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const LogoutIcon = () => (
|
const LogoutIcon = () => (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@ -370,14 +352,6 @@ export default function Sidebar({
|
|||||||
isActive={pathname === "/dashboard/users"}
|
isActive={pathname === "/dashboard/users"}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
<NavItem
|
|
||||||
href="/dashboard/audit-logs"
|
|
||||||
label="Audit Logs"
|
|
||||||
icon={<AuditLogIcon />}
|
|
||||||
isExpanded={isExpanded}
|
|
||||||
isActive={pathname === "/dashboard/audit-logs"}
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
/>
|
|
||||||
</nav>
|
</nav>
|
||||||
<div className="p-4 border-t mt-auto space-y-2">
|
<div className="p-4 border-t mt-auto space-y-2">
|
||||||
{/* Theme Toggle */}
|
{/* Theme Toggle */}
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export default function TopQuestionsChart({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{data.map((question, index) => {
|
{data.map((question) => {
|
||||||
const percentage =
|
const percentage =
|
||||||
maxCount > 0 ? (question.count / maxCount) * 100 : 0;
|
maxCount > 0 ? (question.count / maxCount) * 100 : 0;
|
||||||
|
|
||||||
@ -64,11 +64,7 @@ export default function TopQuestionsChart({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rank indicator */}
|
{/* Rank indicator */}
|
||||||
<div
|
<div className="absolute -left-1 top-0 w-6 h-6 bg-primary text-primary-foreground text-xs font-bold rounded-full flex items-center justify-center">
|
||||||
className="absolute -left-1 top-0 w-6 h-6 bg-primary text-primary-foreground text-xs font-bold rounded-full flex items-center justify-center"
|
|
||||||
role="img"
|
|
||||||
aria-label={`Rank ${index + 1}`}
|
|
||||||
>
|
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,83 +9,6 @@ interface TranscriptViewerProps {
|
|||||||
transcriptUrl?: string | null;
|
transcriptUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders a message bubble with proper styling
|
|
||||||
*/
|
|
||||||
function renderMessageBubble(
|
|
||||||
speaker: string,
|
|
||||||
messages: string[],
|
|
||||||
key: string
|
|
||||||
): React.ReactNode {
|
|
||||||
return (
|
|
||||||
<div key={key} className={`mb-3 ${speaker === "User" ? "text-right" : ""}`}>
|
|
||||||
<div
|
|
||||||
className={`inline-block px-4 py-2 rounded-lg ${
|
|
||||||
speaker === "User"
|
|
||||||
? "bg-blue-100 text-blue-800"
|
|
||||||
: "bg-gray-100 text-gray-800"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{messages.map((msg, i) => (
|
|
||||||
<ReactMarkdown
|
|
||||||
key={`msg-${msg.substring(0, 20).replace(/\s/g, "-")}-${i}`}
|
|
||||||
rehypePlugins={[rehypeRaw]}
|
|
||||||
components={{
|
|
||||||
p: "span",
|
|
||||||
a: ({ node, ...props }) => (
|
|
||||||
<a
|
|
||||||
className="text-sky-600 hover:text-sky-800 underline"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{msg}
|
|
||||||
</ReactMarkdown>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a line indicates a new speaker
|
|
||||||
*/
|
|
||||||
function isNewSpeakerLine(line: string): boolean {
|
|
||||||
return line.startsWith("User:") || line.startsWith("Assistant:");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts speaker and message content from a speaker line
|
|
||||||
*/
|
|
||||||
function extractSpeakerInfo(line: string): {
|
|
||||||
speaker: string;
|
|
||||||
content: string;
|
|
||||||
} {
|
|
||||||
const speaker = line.startsWith("User:") ? "User" : "Assistant";
|
|
||||||
const content = line.substring(line.indexOf(":") + 1).trim();
|
|
||||||
return { speaker, content };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Processes accumulated messages for a speaker
|
|
||||||
*/
|
|
||||||
function processAccumulatedMessages(
|
|
||||||
currentSpeaker: string | null,
|
|
||||||
currentMessages: string[],
|
|
||||||
elements: React.ReactNode[]
|
|
||||||
): void {
|
|
||||||
if (currentSpeaker && currentMessages.length > 0) {
|
|
||||||
elements.push(
|
|
||||||
renderMessageBubble(
|
|
||||||
currentSpeaker,
|
|
||||||
currentMessages,
|
|
||||||
`message-${elements.length}`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format the transcript content into a more readable format with styling
|
* Format the transcript content into a more readable format with styling
|
||||||
*/
|
*/
|
||||||
@ -94,37 +17,114 @@ function formatTranscript(content: string): React.ReactNode[] {
|
|||||||
return [<p key="empty">No transcript content available.</p>];
|
return [<p key="empty">No transcript content available.</p>];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Split the transcript by lines
|
||||||
const lines = content.split("\n");
|
const lines = content.split("\n");
|
||||||
|
|
||||||
const elements: React.ReactNode[] = [];
|
const elements: React.ReactNode[] = [];
|
||||||
let currentSpeaker: string | null = null;
|
let currentSpeaker: string | null = null;
|
||||||
let currentMessages: string[] = [];
|
let currentMessages: string[] = [];
|
||||||
|
|
||||||
// Process each line
|
// Process each line
|
||||||
for (const line of lines) {
|
lines.forEach((line) => {
|
||||||
const trimmedLine = line.trim();
|
const trimmedLine = line.trim();
|
||||||
if (!trimmedLine) {
|
if (!trimmedLine) {
|
||||||
continue; // Skip empty lines
|
// Empty line, ignore
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNewSpeakerLine(line)) {
|
// Check if this is a new speaker line
|
||||||
// Process any accumulated messages from previous speaker
|
if (line.startsWith("User:") || line.startsWith("Assistant:")) {
|
||||||
processAccumulatedMessages(currentSpeaker, currentMessages, elements);
|
// If we have accumulated messages for a previous speaker, add them
|
||||||
currentMessages = [];
|
if (currentSpeaker && currentMessages.length > 0) {
|
||||||
|
elements.push(
|
||||||
|
<div
|
||||||
|
key={`message-${elements.length}`}
|
||||||
|
className={`mb-3 ${currentSpeaker === "User" ? "text-right" : ""}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`inline-block px-4 py-2 rounded-lg ${
|
||||||
|
currentSpeaker === "User"
|
||||||
|
? "bg-blue-100 text-blue-800"
|
||||||
|
: "bg-gray-100 text-gray-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{currentMessages.map((msg, i) => (
|
||||||
|
// Use ReactMarkdown to render each message part
|
||||||
|
<ReactMarkdown
|
||||||
|
key={`msg-${msg.substring(0, 20).replace(/\s/g, "-")}-${i}`}
|
||||||
|
rehypePlugins={[rehypeRaw]} // Add rehypeRaw to plugins
|
||||||
|
components={{
|
||||||
|
p: "span",
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||||
|
a: ({ node: _node, ...props }) => (
|
||||||
|
<a
|
||||||
|
className="text-sky-600 hover:text-sky-800 underline"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{msg}
|
||||||
|
</ReactMarkdown>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
currentMessages = [];
|
||||||
|
}
|
||||||
|
|
||||||
// Set new speaker and add initial content
|
// Set the new current speaker
|
||||||
const { speaker, content } = extractSpeakerInfo(trimmedLine);
|
currentSpeaker = trimmedLine.startsWith("User:") ? "User" : "Assistant";
|
||||||
currentSpeaker = speaker;
|
// Add the content after "User:" or "Assistant:"
|
||||||
if (content) {
|
const messageContent = trimmedLine
|
||||||
currentMessages.push(content);
|
.substring(trimmedLine.indexOf(":") + 1)
|
||||||
|
.trim();
|
||||||
|
if (messageContent) {
|
||||||
|
currentMessages.push(messageContent);
|
||||||
}
|
}
|
||||||
} else if (currentSpeaker) {
|
} else if (currentSpeaker) {
|
||||||
// Continuation of current speaker's message
|
// This is a continuation of the current speaker's message
|
||||||
currentMessages.push(trimmedLine);
|
currentMessages.push(trimmedLine);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
// Process any remaining messages
|
// Add any remaining messages
|
||||||
processAccumulatedMessages(currentSpeaker, currentMessages, elements);
|
if (currentSpeaker && currentMessages.length > 0) {
|
||||||
|
elements.push(
|
||||||
|
<div
|
||||||
|
key={`message-${elements.length}`}
|
||||||
|
className={`mb-3 ${currentSpeaker === "User" ? "text-right" : ""}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`inline-block px-4 py-2 rounded-lg ${
|
||||||
|
currentSpeaker === "User"
|
||||||
|
? "bg-blue-100 text-blue-800"
|
||||||
|
: "bg-gray-100 text-gray-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{currentMessages.map((msg, i) => (
|
||||||
|
// Use ReactMarkdown to render each message part
|
||||||
|
<ReactMarkdown
|
||||||
|
key={`msg-final-${msg.substring(0, 20).replace(/\s/g, "-")}-${i}`}
|
||||||
|
rehypePlugins={[rehypeRaw]} // Add rehypeRaw to plugins
|
||||||
|
components={{
|
||||||
|
p: "span",
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||||
|
a: ({ node: _node, ...props }) => (
|
||||||
|
<a
|
||||||
|
className="text-sky-600 hover:text-sky-800 underline"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{msg}
|
||||||
|
</ReactMarkdown>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return elements;
|
return elements;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,545 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Activity,
|
|
||||||
AlertTriangle,
|
|
||||||
CheckCircle,
|
|
||||||
Clock,
|
|
||||||
Download,
|
|
||||||
RefreshCw,
|
|
||||||
Shield,
|
|
||||||
TrendingUp,
|
|
||||||
XCircle,
|
|
||||||
Zap,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
|
|
||||||
interface BatchMetrics {
|
|
||||||
operationStartTime: number;
|
|
||||||
requestCount: number;
|
|
||||||
successCount: number;
|
|
||||||
failureCount: number;
|
|
||||||
retryCount: number;
|
|
||||||
totalCost: number;
|
|
||||||
averageLatency: number;
|
|
||||||
circuitBreakerTrips: number;
|
|
||||||
performanceStats: {
|
|
||||||
p50: number;
|
|
||||||
p95: number;
|
|
||||||
p99: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CircuitBreakerStatus {
|
|
||||||
isOpen: boolean;
|
|
||||||
failures: number;
|
|
||||||
lastFailureTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SchedulerConfig {
|
|
||||||
enabled: boolean;
|
|
||||||
intervals: {
|
|
||||||
batchCreation: number;
|
|
||||||
statusCheck: number;
|
|
||||||
resultProcessing: number;
|
|
||||||
retryFailures: number;
|
|
||||||
};
|
|
||||||
thresholds: {
|
|
||||||
maxRetries: number;
|
|
||||||
circuitBreakerThreshold: number;
|
|
||||||
batchSize: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SchedulerStatus {
|
|
||||||
isRunning: boolean;
|
|
||||||
createBatchesRunning: boolean;
|
|
||||||
checkStatusRunning: boolean;
|
|
||||||
processResultsRunning: boolean;
|
|
||||||
retryFailedRunning: boolean;
|
|
||||||
isPaused: boolean;
|
|
||||||
consecutiveErrors: number;
|
|
||||||
lastErrorTime: Date | null;
|
|
||||||
circuitBreakers: Record<string, CircuitBreakerStatus>;
|
|
||||||
config: SchedulerConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MonitoringData {
|
|
||||||
timestamp: string;
|
|
||||||
metrics: Record<string, BatchMetrics> | BatchMetrics;
|
|
||||||
schedulerStatus: SchedulerStatus;
|
|
||||||
circuitBreakerStatus: Record<string, CircuitBreakerStatus>;
|
|
||||||
systemHealth: {
|
|
||||||
schedulerRunning: boolean;
|
|
||||||
circuitBreakersOpen: boolean;
|
|
||||||
pausedDueToErrors: boolean;
|
|
||||||
consecutiveErrors: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function HealthStatusIcon({ status }: { status: string }) {
|
|
||||||
if (status === "healthy")
|
|
||||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
|
||||||
if (status === "warning")
|
|
||||||
return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
|
|
||||||
if (status === "critical")
|
|
||||||
return <XCircle className="h-5 w-5 text-red-500" />;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SystemHealthCard({
|
|
||||||
health,
|
|
||||||
schedulerStatus,
|
|
||||||
}: {
|
|
||||||
health: { status: string; message: string };
|
|
||||||
schedulerStatus: SchedulerStatus;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Activity className="h-5 w-5" />
|
|
||||||
System Health
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<HealthStatusIcon status={health.status} />
|
|
||||||
<span className="font-medium text-sm">{health.message}</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span>Batch Creation:</span>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
schedulerStatus?.createBatchesRunning ? "default" : "secondary"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{schedulerStatus?.createBatchesRunning ? "Running" : "Stopped"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span>Status Check:</span>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
schedulerStatus?.checkStatusRunning ? "default" : "secondary"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{schedulerStatus?.checkStatusRunning ? "Running" : "Stopped"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span>Result Processing:</span>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
schedulerStatus?.processResultsRunning ? "default" : "secondary"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{schedulerStatus?.processResultsRunning ? "Running" : "Stopped"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CircuitBreakerCard({
|
|
||||||
circuitBreakerStatus,
|
|
||||||
}: {
|
|
||||||
circuitBreakerStatus: Record<string, CircuitBreakerStatus> | null;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Shield className="h-5 w-5" />
|
|
||||||
Circuit Breakers
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{circuitBreakerStatus &&
|
|
||||||
Object.keys(circuitBreakerStatus).length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{Object.entries(circuitBreakerStatus).map(([key, status]) => (
|
|
||||||
<div key={key} className="flex justify-between text-sm">
|
|
||||||
<span>{key}:</span>
|
|
||||||
<Badge variant={!status.isOpen ? "default" : "destructive"}>
|
|
||||||
{status.isOpen ? "OPEN" : "CLOSED"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No circuit breakers configured
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BatchMonitoringDashboard() {
|
|
||||||
const [monitoringData, setMonitoringData] = useState<MonitoringData | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [selectedCompany, setSelectedCompany] = useState<string>("all");
|
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const fetchMonitoringData = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (selectedCompany !== "all") {
|
|
||||||
params.set("companyId", selectedCompany);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`/api/admin/batch-monitoring?${params}`);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setMonitoringData(data);
|
|
||||||
} else {
|
|
||||||
throw new Error("Failed to fetch monitoring data");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch batch monitoring data:", error);
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to load batch monitoring data",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [selectedCompany, toast]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchMonitoringData();
|
|
||||||
}, [fetchMonitoringData]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!autoRefresh) return;
|
|
||||||
|
|
||||||
const interval = setInterval(fetchMonitoringData, 30000); // Refresh every 30 seconds
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [autoRefresh, fetchMonitoringData]);
|
|
||||||
|
|
||||||
const exportLogs = async (format: "json" | "csv") => {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/batch-monitoring/export", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
startDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), // Last 24 hours
|
|
||||||
endDate: new Date().toISOString(),
|
|
||||||
format,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `batch-logs-${Date.now()}.${format}`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Success",
|
|
||||||
description: `Batch logs exported as ${format.toUpperCase()}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (_error) {
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to export logs",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getHealthStatus = () => {
|
|
||||||
if (!monitoringData)
|
|
||||||
return {
|
|
||||||
status: "unknown",
|
|
||||||
color: "gray",
|
|
||||||
message: "No monitoring data",
|
|
||||||
};
|
|
||||||
|
|
||||||
const { systemHealth } = monitoringData;
|
|
||||||
|
|
||||||
if (!systemHealth.schedulerRunning) {
|
|
||||||
return {
|
|
||||||
status: "critical",
|
|
||||||
color: "red",
|
|
||||||
message: "Scheduler not running",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (systemHealth.pausedDueToErrors) {
|
|
||||||
return {
|
|
||||||
status: "warning",
|
|
||||||
color: "yellow",
|
|
||||||
message: "Paused due to errors",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (systemHealth.circuitBreakersOpen) {
|
|
||||||
return {
|
|
||||||
status: "warning",
|
|
||||||
color: "yellow",
|
|
||||||
message: "Circuit breakers open",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (systemHealth.consecutiveErrors > 0) {
|
|
||||||
return {
|
|
||||||
status: "warning",
|
|
||||||
color: "yellow",
|
|
||||||
message: `${systemHealth.consecutiveErrors} consecutive errors`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: "healthy",
|
|
||||||
color: "green",
|
|
||||||
message: "All systems operational",
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderMetricsCards = () => {
|
|
||||||
if (!monitoringData) return null;
|
|
||||||
|
|
||||||
const metrics = Array.isArray(monitoringData.metrics)
|
|
||||||
? monitoringData.metrics[0]
|
|
||||||
: typeof monitoringData.metrics === "object" &&
|
|
||||||
"operationStartTime" in monitoringData.metrics
|
|
||||||
? monitoringData.metrics
|
|
||||||
: Object.values(monitoringData.metrics)[0];
|
|
||||||
|
|
||||||
if (!metrics) return null;
|
|
||||||
|
|
||||||
const successRate =
|
|
||||||
metrics.requestCount > 0
|
|
||||||
? ((metrics.successCount / metrics.requestCount) * 100).toFixed(1)
|
|
||||||
: "0";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Total Requests
|
|
||||||
</CardTitle>
|
|
||||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{metrics.requestCount}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{metrics.successCount} successful, {metrics.failureCount} failed
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Success Rate</CardTitle>
|
|
||||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{successRate}%</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{metrics.retryCount} retries performed
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Average Latency
|
|
||||||
</CardTitle>
|
|
||||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{metrics.averageLatency.toFixed(0)}ms
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
P95: {metrics.performanceStats.p95}ms
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Total Cost</CardTitle>
|
|
||||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
€{metrics.totalCost.toFixed(4)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Circuit breaker trips: {metrics.circuitBreakerTrips}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSystemStatus = () => {
|
|
||||||
if (!monitoringData) return null;
|
|
||||||
|
|
||||||
const health = getHealthStatus();
|
|
||||||
const { schedulerStatus, circuitBreakerStatus } = monitoringData;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
|
||||||
<SystemHealthCard health={health} schedulerStatus={schedulerStatus} />
|
|
||||||
<CircuitBreakerCard circuitBreakerStatus={circuitBreakerStatus} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
|
||||||
<div className="text-center">
|
|
||||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4" />
|
|
||||||
<p>Loading batch monitoring data...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold">Batch Processing Monitor</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Real-time monitoring of OpenAI Batch API operations
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
|
|
||||||
<SelectTrigger className="w-48">
|
|
||||||
<SelectValue placeholder="Select company" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Companies</SelectItem>
|
|
||||||
{/* Add company options here */}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={`h-4 w-4 mr-2 ${autoRefresh ? "animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
{autoRefresh ? "Auto" : "Manual"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button variant="outline" size="sm" onClick={fetchMonitoringData}>
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{renderSystemStatus()}
|
|
||||||
{renderMetricsCards()}
|
|
||||||
|
|
||||||
<Tabs defaultValue="overview" className="space-y-4">
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
|
||||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
|
||||||
<TabsTrigger value="export">Export</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="overview" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Batch Processing Overview</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-sm text-muted-foreground mb-4">
|
|
||||||
Last updated:{" "}
|
|
||||||
{monitoringData?.timestamp
|
|
||||||
? new Date(monitoringData.timestamp).toLocaleString()
|
|
||||||
: "Never"}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{monitoringData && (
|
|
||||||
<pre className="bg-muted p-4 rounded text-xs overflow-auto">
|
|
||||||
{JSON.stringify(monitoringData, null, 2)}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="logs" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Recent Batch Processing Logs</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Real-time batch processing logs will be displayed here. For
|
|
||||||
detailed log analysis, use the export feature.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="export" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Export Batch Processing Data</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Export batch processing logs and metrics for detailed analysis.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={() => exportLogs("json")}>
|
|
||||||
<Download className="h-4 w-4 mr-2" />
|
|
||||||
Export JSON
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={() => exportLogs("csv")}>
|
|
||||||
<Download className="h-4 w-4 mr-2" />
|
|
||||||
Export CSV
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,286 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,
|
|
||||||
error: overviewError,
|
|
||||||
} = trpc.dashboard.getOverview.useQuery({});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: topQuestions,
|
|
||||||
isLoading: questionsLoading,
|
|
||||||
error: questionsError,
|
|
||||||
} = 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>
|
|
||||||
{overviewError && (
|
|
||||||
<div className="text-red-600 text-sm mb-2">
|
|
||||||
Error: {overviewError.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{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>
|
|
||||||
{overviewError && (
|
|
||||||
<div className="text-red-600 text-sm mb-2">
|
|
||||||
Error: {overviewError.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{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>
|
|
||||||
{overviewError && (
|
|
||||||
<div className="text-red-600 text-sm mb-2">
|
|
||||||
Error: {overviewError.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{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>
|
|
||||||
{questionsError && (
|
|
||||||
<div className="text-red-600 mb-4">
|
|
||||||
Error loading questions: {questionsError.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{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) => (
|
|
||||||
<div
|
|
||||||
key={item.question}
|
|
||||||
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) => (
|
|
||||||
<Badge
|
|
||||||
key={question}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,179 +0,0 @@
|
|||||||
/**
|
|
||||||
* CSRF Protected Form Component
|
|
||||||
*
|
|
||||||
* A wrapper component that automatically adds CSRF protection to forms.
|
|
||||||
* This component demonstrates how to integrate CSRF tokens into form submissions.
|
|
||||||
*/
|
|
||||||
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import type { FormEvent, ReactNode } from "react";
|
|
||||||
import { useId } from "react";
|
|
||||||
import { useCSRFForm } from "../../lib/hooks/useCSRF";
|
|
||||||
|
|
||||||
interface CSRFProtectedFormProps {
|
|
||||||
children: ReactNode;
|
|
||||||
action: string;
|
|
||||||
method?: "POST" | "PUT" | "DELETE" | "PATCH";
|
|
||||||
onSubmit?: (formData: FormData) => Promise<void> | void;
|
|
||||||
onError?: (error: Error) => void;
|
|
||||||
className?: string;
|
|
||||||
encType?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Form component with automatic CSRF protection
|
|
||||||
*/
|
|
||||||
export function CSRFProtectedForm({
|
|
||||||
children,
|
|
||||||
action,
|
|
||||||
method = "POST",
|
|
||||||
onSubmit,
|
|
||||||
onError,
|
|
||||||
className,
|
|
||||||
encType,
|
|
||||||
}: CSRFProtectedFormProps) {
|
|
||||||
const { token, submitForm, addTokenToFormData } = useCSRFForm();
|
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const form = event.currentTarget;
|
|
||||||
const formData = new FormData(form);
|
|
||||||
|
|
||||||
// Add CSRF token to form data
|
|
||||||
addTokenToFormData(formData);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (onSubmit) {
|
|
||||||
// Use custom submit handler
|
|
||||||
await onSubmit(formData);
|
|
||||||
} else {
|
|
||||||
// Use default form submission with CSRF protection
|
|
||||||
const response = await submitForm(action, formData);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Form submission failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle successful submission
|
|
||||||
console.log("Form submitted successfully");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Form submission error:", error);
|
|
||||||
|
|
||||||
// Notify user of the error
|
|
||||||
if (onError && error instanceof Error) {
|
|
||||||
onError(error);
|
|
||||||
} else {
|
|
||||||
// Fallback: show alert if no error handler provided
|
|
||||||
alert("An error occurred while submitting the form. Please try again.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
method={method}
|
|
||||||
action={action}
|
|
||||||
className={className}
|
|
||||||
encType={encType}
|
|
||||||
>
|
|
||||||
{/* Hidden CSRF token field for non-JS fallback */}
|
|
||||||
{token && <input type="hidden" name="csrf_token" value={token} />}
|
|
||||||
|
|
||||||
{children}
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example usage component showing how to use CSRF protected forms
|
|
||||||
*/
|
|
||||||
export function ExampleCSRFForm() {
|
|
||||||
// Generate unique IDs for form elements
|
|
||||||
const nameId = useId();
|
|
||||||
const emailId = useId();
|
|
||||||
const messageId = useId();
|
|
||||||
|
|
||||||
const handleCustomSubmit = async (formData: FormData) => {
|
|
||||||
// Custom form submission logic
|
|
||||||
// Filter out CSRF token for security when logging
|
|
||||||
const data = Object.fromEntries(formData.entries());
|
|
||||||
// biome-ignore lint/correctness/noUnusedVariables: csrf_token is intentionally extracted and discarded for security
|
|
||||||
const { csrf_token, ...safeData } = data;
|
|
||||||
console.log("Form data (excluding CSRF token):", safeData);
|
|
||||||
|
|
||||||
// You can process the form data here before submission
|
|
||||||
// The CSRF token is automatically included in formData
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">
|
|
||||||
CSRF Protected Form Example
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<CSRFProtectedForm
|
|
||||||
action="/api/example-endpoint"
|
|
||||||
onSubmit={handleCustomSubmit}
|
|
||||||
className="space-y-4"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor={nameId}
|
|
||||||
className="block text-sm font-medium text-gray-700"
|
|
||||||
>
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id={nameId}
|
|
||||||
name="name"
|
|
||||||
required
|
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor={emailId}
|
|
||||||
className="block text-sm font-medium text-gray-700"
|
|
||||||
>
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
id={emailId}
|
|
||||||
name="email"
|
|
||||||
required
|
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor={messageId}
|
|
||||||
className="block text-sm font-medium text-gray-700"
|
|
||||||
>
|
|
||||||
Message
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id={messageId}
|
|
||||||
name="message"
|
|
||||||
rows={4}
|
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
||||||
>
|
|
||||||
Submit
|
|
||||||
</button>
|
|
||||||
</CSRFProtectedForm>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -18,7 +18,7 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button, type ButtonProps } from "@/components/ui/button";
|
||||||
|
|
||||||
type Api = {
|
type Api = {
|
||||||
fire: (options?: ConfettiOptions) => void;
|
fire: (options?: ConfettiOptions) => void;
|
||||||
@ -110,7 +110,7 @@ ConfettiComponent.displayName = "Confetti";
|
|||||||
// Export as Confetti
|
// Export as Confetti
|
||||||
export const Confetti = ConfettiComponent;
|
export const Confetti = ConfettiComponent;
|
||||||
|
|
||||||
interface ConfettiButtonProps extends React.ComponentProps<typeof Button> {
|
interface ConfettiButtonProps extends ButtonProps {
|
||||||
options?: ConfettiOptions &
|
options?: ConfettiOptions &
|
||||||
ConfettiGlobalOptions & { canvas?: HTMLCanvasElement };
|
ConfettiGlobalOptions & { canvas?: HTMLCanvasElement };
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
|||||||
@ -26,7 +26,7 @@ export function Pointer({
|
|||||||
style,
|
style,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: PointerProps): React.ReactElement {
|
}: PointerProps): JSX.Element {
|
||||||
const x = useMotionValue(0);
|
const x = useMotionValue(0);
|
||||||
const y = useMotionValue(0);
|
const y = useMotionValue(0);
|
||||||
const [isActive, setIsActive] = useState<boolean>(false);
|
const [isActive, setIsActive] = useState<boolean>(false);
|
||||||
|
|||||||
@ -1,161 +0,0 @@
|
|||||||
/**
|
|
||||||
* CSRF Provider Component
|
|
||||||
*
|
|
||||||
* Provides CSRF token management for the entire application.
|
|
||||||
* Automatically fetches and manages CSRF tokens for client-side requests.
|
|
||||||
*/
|
|
||||||
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import type React from "react";
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { CSRFClient } from "../../lib/csrf-client";
|
|
||||||
|
|
||||||
interface CSRFContextType {
|
|
||||||
token: string | null;
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
refreshToken: () => Promise<void>;
|
|
||||||
addTokenToFetch: (options: RequestInit) => RequestInit;
|
|
||||||
addTokenToFormData: (formData: FormData) => FormData;
|
|
||||||
addTokenToObject: <T extends Record<string, unknown>>(
|
|
||||||
obj: T
|
|
||||||
) => T & { csrfToken: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
const CSRFContext = createContext<CSRFContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
interface CSRFProviderProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CSRF Provider Component
|
|
||||||
*/
|
|
||||||
export function CSRFProvider({ children }: CSRFProviderProps) {
|
|
||||||
const [token, setToken] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch CSRF token from server
|
|
||||||
*/
|
|
||||||
const fetchToken = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// First check if we already have a token in cookies
|
|
||||||
const existingToken = CSRFClient.getToken();
|
|
||||||
if (existingToken) {
|
|
||||||
setToken(existingToken);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch new token from server
|
|
||||||
const response = await fetch("/api/csrf-token", {
|
|
||||||
method: "GET",
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch CSRF token: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success && data.token) {
|
|
||||||
setToken(data.token);
|
|
||||||
} else {
|
|
||||||
throw new Error("Invalid response from CSRF endpoint");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage =
|
|
||||||
err instanceof Error ? err.message : "Failed to fetch CSRF token";
|
|
||||||
setError(errorMessage);
|
|
||||||
console.error("CSRF token fetch error:", errorMessage);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh token manually
|
|
||||||
*/
|
|
||||||
const refreshToken = async () => {
|
|
||||||
await fetchToken();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize token on mount
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
fetchToken();
|
|
||||||
}, [fetchToken]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Monitor token changes in cookies
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
const checkToken = () => {
|
|
||||||
const currentToken = CSRFClient.getToken();
|
|
||||||
if (currentToken !== token) {
|
|
||||||
setToken(currentToken);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check token every 30 seconds
|
|
||||||
const interval = setInterval(checkToken, 30 * 1000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [token]);
|
|
||||||
|
|
||||||
const contextValue: CSRFContextType = {
|
|
||||||
token,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
refreshToken,
|
|
||||||
addTokenToFetch: CSRFClient.addTokenToFetch,
|
|
||||||
addTokenToFormData: CSRFClient.addTokenToFormData,
|
|
||||||
addTokenToObject: CSRFClient.addTokenToObject,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CSRFContext.Provider value={contextValue}>{children}</CSRFContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to use CSRF context
|
|
||||||
*/
|
|
||||||
export function useCSRFContext(): CSRFContextType {
|
|
||||||
const context = useContext(CSRFContext);
|
|
||||||
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error("useCSRFContext must be used within a CSRFProvider");
|
|
||||||
}
|
|
||||||
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Higher-order component to wrap components with CSRF protection
|
|
||||||
*/
|
|
||||||
export function withCSRF<P extends object>(Component: React.ComponentType<P>) {
|
|
||||||
const WrappedComponent = (props: P) => (
|
|
||||||
<CSRFProvider>
|
|
||||||
<Component {...props} />
|
|
||||||
</CSRFProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
WrappedComponent.displayName = `withCSRF(${Component.displayName || Component.name})`;
|
|
||||||
|
|
||||||
return WrappedComponent;
|
|
||||||
}
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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: {
|
|
||||||
// Optimize refetching behavior for better performance
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchOnMount: false, // Only refetch if stale
|
|
||||||
retry: (failureCount, error) => {
|
|
||||||
// Smart retry logic based on error type
|
|
||||||
if (
|
|
||||||
error?.message?.includes("401") ||
|
|
||||||
error?.message?.includes("403")
|
|
||||||
) {
|
|
||||||
return false; // Don't retry auth errors
|
|
||||||
}
|
|
||||||
return failureCount < 3;
|
|
||||||
},
|
|
||||||
retryDelay: (attemptIndex) =>
|
|
||||||
Math.min(1000 * 2 ** attemptIndex, 30000),
|
|
||||||
|
|
||||||
// Optimized cache times based on data type
|
|
||||||
staleTime: 2 * 60 * 1000, // 2 minutes - data is fresh for 2 minutes
|
|
||||||
gcTime: 10 * 60 * 1000, // 10 minutes - keep unused data for 10 minutes
|
|
||||||
|
|
||||||
// Performance optimizations
|
|
||||||
networkMode: "online", // Only run queries when online
|
|
||||||
notifyOnChangeProps: ["data", "error", "isLoading"], // Reduce re-renders
|
|
||||||
},
|
|
||||||
mutations: {
|
|
||||||
// Optimize mutation behavior
|
|
||||||
retry: 2,
|
|
||||||
networkMode: "online",
|
|
||||||
throwOnError: false, // Handle errors gracefully in components
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
{children}
|
|
||||||
{process.env.NODE_ENV === "development" && (
|
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
|
||||||
)}
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,132 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { COUNTRY_NAMES } from "../../lib/constants/countries";
|
|
||||||
|
|
||||||
interface GeographicThreatMapProps {
|
|
||||||
geoDistribution: Record<string, number>;
|
|
||||||
title?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Threat level configuration with colors
|
|
||||||
const THREAT_LEVELS = {
|
|
||||||
high: { color: "destructive", bgColor: "bg-red-500" },
|
|
||||||
medium: { color: "secondary", bgColor: "bg-yellow-500" },
|
|
||||||
low: { color: "outline", bgColor: "bg-blue-500" },
|
|
||||||
minimal: { color: "outline", bgColor: "bg-gray-400" },
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type ThreatLevel = keyof typeof THREAT_LEVELS;
|
|
||||||
|
|
||||||
export function GeographicThreatMap({
|
|
||||||
geoDistribution,
|
|
||||||
title = "Geographic Threat Distribution",
|
|
||||||
}: GeographicThreatMapProps) {
|
|
||||||
// Calculate values once for efficiency
|
|
||||||
const totalEvents = Object.values(geoDistribution).reduce(
|
|
||||||
(sum, count) => sum + count,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const maxEventCount = Math.max(...Object.values(geoDistribution));
|
|
||||||
|
|
||||||
const sortedCountries = Object.entries(geoDistribution)
|
|
||||||
.sort(([, a], [, b]) => b - a)
|
|
||||||
.slice(0, 12);
|
|
||||||
|
|
||||||
const getThreatLevel = (count: number, total: number): ThreatLevel => {
|
|
||||||
const percentage = (count / total) * 100;
|
|
||||||
if (percentage > 50) return "high";
|
|
||||||
if (percentage > 20) return "medium";
|
|
||||||
if (percentage > 5) return "low";
|
|
||||||
return "minimal";
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCountryName = (code: string) => {
|
|
||||||
return COUNTRY_NAMES[code] || code;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{title}</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Security events by country ({totalEvents} total events)
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{sortedCountries.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
<p>No geographic data available</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{sortedCountries.map(([countryCode, count]) => {
|
|
||||||
const threatLevel = getThreatLevel(count, totalEvents);
|
|
||||||
const percentage = ((count / totalEvents) * 100).toFixed(1);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={countryCode}
|
|
||||||
className="flex items-center justify-between p-3 border rounded-lg"
|
|
||||||
>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium">
|
|
||||||
{getCountryName(countryCode)}
|
|
||||||
</span>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
THREAT_LEVELS[threatLevel].color as
|
|
||||||
| "default"
|
|
||||||
| "secondary"
|
|
||||||
| "destructive"
|
|
||||||
| "outline"
|
|
||||||
}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{threatLevel}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{count} events ({percentage}%)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-2xl font-bold">{count}</div>
|
|
||||||
<div className="w-16 bg-gray-200 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className={`h-2 rounded-full ${THREAT_LEVELS[threatLevel].bgColor}`}
|
|
||||||
style={{
|
|
||||||
width: `${Math.min(100, (count / maxEventCount) * 100)}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{Object.keys(geoDistribution).length > 12 && (
|
|
||||||
<div className="text-center pt-4 border-t">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
And {Object.keys(geoDistribution).length - 12} more
|
|
||||||
countries...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,284 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { AlertTriangle, CheckCircle, Eye, EyeOff } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
|
|
||||||
interface SecurityAlert {
|
|
||||||
id: string;
|
|
||||||
timestamp: string;
|
|
||||||
severity: string;
|
|
||||||
type: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
eventType: string;
|
|
||||||
context: Record<string, unknown>;
|
|
||||||
metadata: Record<string, unknown>;
|
|
||||||
acknowledged: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SecurityAlertsTableProps {
|
|
||||||
alerts: SecurityAlert[];
|
|
||||||
onAcknowledge: (alertId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SecurityAlertsTable({
|
|
||||||
alerts,
|
|
||||||
onAcknowledge,
|
|
||||||
}: SecurityAlertsTableProps) {
|
|
||||||
const [showAcknowledged, setShowAcknowledged] = useState(false);
|
|
||||||
const [selectedAlert, setSelectedAlert] = useState<SecurityAlert | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
const getSeverityColor = (severity: string) => {
|
|
||||||
switch (severity?.toLowerCase()) {
|
|
||||||
case "critical":
|
|
||||||
return "destructive";
|
|
||||||
case "high":
|
|
||||||
return "destructive";
|
|
||||||
case "medium":
|
|
||||||
return "secondary";
|
|
||||||
case "low":
|
|
||||||
return "outline";
|
|
||||||
default:
|
|
||||||
return "outline";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredAlerts = alerts.filter(
|
|
||||||
(alert) => showAcknowledged || !alert.acknowledged
|
|
||||||
);
|
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: string) => {
|
|
||||||
return new Date(timestamp).toLocaleString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatAlertType = (type: string) => {
|
|
||||||
return type
|
|
||||||
.replace(/_/g, " ")
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/\b\w/g, (l) => l.toUpperCase());
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="text-lg font-semibold">Security Alerts</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{filteredAlerts.length} alerts{" "}
|
|
||||||
{showAcknowledged ? "total" : "active"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowAcknowledged(!showAcknowledged)}
|
|
||||||
>
|
|
||||||
{showAcknowledged ? (
|
|
||||||
<EyeOff className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{showAcknowledged ? "Hide Acknowledged" : "Show All"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredAlerts.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-8">
|
|
||||||
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
|
|
||||||
<h3 className="text-lg font-semibold mb-2">No Active Alerts</h3>
|
|
||||||
<p className="text-muted-foreground text-center">
|
|
||||||
All security alerts have been addressed. System is operating
|
|
||||||
normally.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Severity</TableHead>
|
|
||||||
<TableHead>Type</TableHead>
|
|
||||||
<TableHead>Description</TableHead>
|
|
||||||
<TableHead>Timestamp</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredAlerts.map((alert) => (
|
|
||||||
<TableRow
|
|
||||||
key={alert.id}
|
|
||||||
className={alert.acknowledged ? "opacity-60" : ""}
|
|
||||||
>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={getSeverityColor(alert.severity)}>
|
|
||||||
{alert.severity}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<span className="font-medium">
|
|
||||||
{formatAlertType(alert.type)}
|
|
||||||
</span>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{alert.eventType}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<span className="font-medium">{alert.title}</span>
|
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
|
||||||
{alert.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="text-sm">
|
|
||||||
{formatTimestamp(alert.timestamp)}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{alert.acknowledged ? (
|
|
||||||
<Badge variant="outline">
|
|
||||||
<CheckCircle className="h-3 w-3 mr-1" />
|
|
||||||
Acknowledged
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="secondary">
|
|
||||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
|
||||||
Active
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setSelectedAlert(alert)}
|
|
||||||
>
|
|
||||||
<Eye className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
{!alert.acknowledged && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onAcknowledge(alert.id)}
|
|
||||||
>
|
|
||||||
Acknowledge
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Alert Details Modal */}
|
|
||||||
<Dialog
|
|
||||||
open={!!selectedAlert}
|
|
||||||
onOpenChange={() => setSelectedAlert(null)}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
{selectedAlert?.title}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge
|
|
||||||
variant={getSeverityColor(selectedAlert?.severity || "")}
|
|
||||||
>
|
|
||||||
{selectedAlert?.severity}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline">
|
|
||||||
{formatAlertType(selectedAlert?.type || "")}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Security alert details and context information
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{selectedAlert && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium mb-2">Description</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{selectedAlert.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium mb-2">Context</h4>
|
|
||||||
<div className="bg-muted p-3 rounded-md">
|
|
||||||
<pre className="text-xs overflow-auto">
|
|
||||||
{JSON.stringify(selectedAlert.context, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedAlert.metadata &&
|
|
||||||
Object.keys(selectedAlert.metadata).length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium mb-2">Metadata</h4>
|
|
||||||
<div className="bg-muted p-3 rounded-md">
|
|
||||||
<pre className="text-xs overflow-auto">
|
|
||||||
{JSON.stringify(selectedAlert.metadata, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter className="flex items-center justify-between pt-4 border-t">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{selectedAlert && formatTimestamp(selectedAlert.timestamp)}
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{selectedAlert && !selectedAlert.acknowledged && (
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
onAcknowledge(selectedAlert.id);
|
|
||||||
setSelectedAlert(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Acknowledge Alert
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,502 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useId, useState } from "react";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
|
|
||||||
interface SecurityConfig {
|
|
||||||
thresholds: {
|
|
||||||
failedLoginsPerMinute: number;
|
|
||||||
failedLoginsPerHour: number;
|
|
||||||
rateLimitViolationsPerMinute: number;
|
|
||||||
cspViolationsPerMinute: number;
|
|
||||||
adminActionsPerHour: number;
|
|
||||||
massDataAccessThreshold: number;
|
|
||||||
suspiciousIPThreshold: number;
|
|
||||||
};
|
|
||||||
alerting: {
|
|
||||||
enabled: boolean;
|
|
||||||
channels: string[];
|
|
||||||
suppressDuplicateMinutes: number;
|
|
||||||
escalationTimeoutMinutes: number;
|
|
||||||
};
|
|
||||||
retention: {
|
|
||||||
alertRetentionDays: number;
|
|
||||||
metricsRetentionDays: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SecurityConfigModalProps {
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SecurityConfigModal({
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
}: SecurityConfigModalProps) {
|
|
||||||
const [config, setConfig] = useState<SecurityConfig | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
// Generate unique IDs for form elements
|
|
||||||
const failedLoginsPerMinuteId = useId();
|
|
||||||
const failedLoginsPerHourId = useId();
|
|
||||||
const rateLimitViolationsPerMinuteId = useId();
|
|
||||||
const cspViolationsPerMinuteId = useId();
|
|
||||||
const adminActionsPerHourId = useId();
|
|
||||||
const suspiciousIPThresholdId = useId();
|
|
||||||
const alertingEnabledId = useId();
|
|
||||||
const suppressDuplicateMinutesId = useId();
|
|
||||||
const escalationTimeoutMinutesId = useId();
|
|
||||||
const alertRetentionDaysId = useId();
|
|
||||||
const metricsRetentionDaysId = useId();
|
|
||||||
|
|
||||||
const loadConfig = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/security-monitoring");
|
|
||||||
if (!response.ok) throw new Error("Failed to load config");
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
setConfig(data.config);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading config:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadConfig();
|
|
||||||
}, [loadConfig]);
|
|
||||||
|
|
||||||
const saveConfig = async () => {
|
|
||||||
if (!config) return;
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/security-monitoring", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(config),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error("Failed to save config");
|
|
||||||
|
|
||||||
onSave();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error saving config:", error);
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateThreshold = (
|
|
||||||
key: keyof SecurityConfig["thresholds"],
|
|
||||||
value: number
|
|
||||||
) => {
|
|
||||||
if (!config) return;
|
|
||||||
setConfig({
|
|
||||||
...config,
|
|
||||||
thresholds: {
|
|
||||||
...config.thresholds,
|
|
||||||
[key]: value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateAlerting = (
|
|
||||||
key: keyof SecurityConfig["alerting"],
|
|
||||||
value: unknown
|
|
||||||
) => {
|
|
||||||
if (!config) return;
|
|
||||||
setConfig({
|
|
||||||
...config,
|
|
||||||
alerting: {
|
|
||||||
...config.alerting,
|
|
||||||
[key]: value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateRetention = (
|
|
||||||
key: keyof SecurityConfig["retention"],
|
|
||||||
value: number
|
|
||||||
) => {
|
|
||||||
if (!config) return;
|
|
||||||
setConfig({
|
|
||||||
...config,
|
|
||||||
retention: {
|
|
||||||
...config.retention,
|
|
||||||
[key]: value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAlertChannel = (channel: string) => {
|
|
||||||
if (!config) return;
|
|
||||||
const channels = config.alerting.channels.includes(channel)
|
|
||||||
? config.alerting.channels.filter((c) => c !== channel)
|
|
||||||
: [...config.alerting.channels, channel];
|
|
||||||
|
|
||||||
updateAlerting("channels", channels);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<Dialog open onOpenChange={onClose}>
|
|
||||||
<DialogContent className="max-w-4xl">
|
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!config) {
|
|
||||||
return (
|
|
||||||
<Dialog open onOpenChange={onClose}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Error</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Failed to load security configuration
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button onClick={onClose}>Close</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open onOpenChange={onClose}>
|
|
||||||
<DialogContent className="max-w-4xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Security Monitoring Configuration</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Configure security monitoring thresholds, alerting, and data
|
|
||||||
retention
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Tabs defaultValue="thresholds" className="space-y-4">
|
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
|
||||||
<TabsTrigger value="thresholds">Thresholds</TabsTrigger>
|
|
||||||
<TabsTrigger value="alerting">Alerting</TabsTrigger>
|
|
||||||
<TabsTrigger value="retention">Data Retention</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="thresholds" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Detection Thresholds</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Configure when security alerts should be triggered
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={failedLoginsPerMinuteId}>
|
|
||||||
Failed Logins per Minute
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={failedLoginsPerMinuteId}
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="100"
|
|
||||||
value={config.thresholds.failedLoginsPerMinute}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateThreshold(
|
|
||||||
"failedLoginsPerMinute",
|
|
||||||
Number.parseInt(e.target.value)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={failedLoginsPerHourId}>
|
|
||||||
Failed Logins per Hour
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={failedLoginsPerHourId}
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="1000"
|
|
||||||
value={config.thresholds.failedLoginsPerHour}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateThreshold(
|
|
||||||
"failedLoginsPerHour",
|
|
||||||
Number.parseInt(e.target.value)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={rateLimitViolationsPerMinuteId}>
|
|
||||||
Rate Limit Violations per Minute
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={rateLimitViolationsPerMinuteId}
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="100"
|
|
||||||
value={config.thresholds.rateLimitViolationsPerMinute}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateThreshold(
|
|
||||||
"rateLimitViolationsPerMinute",
|
|
||||||
Number.parseInt(e.target.value)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={cspViolationsPerMinuteId}>
|
|
||||||
CSP Violations per Minute
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={cspViolationsPerMinuteId}
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="100"
|
|
||||||
value={config.thresholds.cspViolationsPerMinute}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateThreshold(
|
|
||||||
"cspViolationsPerMinute",
|
|
||||||
Number.parseInt(e.target.value)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={adminActionsPerHourId}>
|
|
||||||
Admin Actions per Hour
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={adminActionsPerHourId}
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="100"
|
|
||||||
value={config.thresholds.adminActionsPerHour}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateThreshold(
|
|
||||||
"adminActionsPerHour",
|
|
||||||
Number.parseInt(e.target.value)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={suspiciousIPThresholdId}>
|
|
||||||
Suspicious IP Threshold
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={suspiciousIPThresholdId}
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="100"
|
|
||||||
value={config.thresholds.suspiciousIPThreshold}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateThreshold(
|
|
||||||
"suspiciousIPThreshold",
|
|
||||||
Number.parseInt(e.target.value)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="alerting" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Alert Configuration</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Configure how and when alerts are sent
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Switch
|
|
||||||
id={alertingEnabledId}
|
|
||||||
checked={config.alerting.enabled}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
updateAlerting("enabled", checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label htmlFor={alertingEnabledId}>
|
|
||||||
Enable Security Alerting
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Alert Channels</Label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{["EMAIL", "WEBHOOK", "SLACK", "DISCORD", "PAGERDUTY"].map(
|
|
||||||
(channel) => (
|
|
||||||
<Badge
|
|
||||||
key={channel}
|
|
||||||
variant={
|
|
||||||
config.alerting.channels.includes(channel)
|
|
||||||
? "default"
|
|
||||||
: "outline"
|
|
||||||
}
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => toggleAlertChannel(channel)}
|
|
||||||
>
|
|
||||||
{channel}
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={suppressDuplicateMinutesId}>
|
|
||||||
Suppress Duplicates (minutes)
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={suppressDuplicateMinutesId}
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="1440"
|
|
||||||
value={config.alerting.suppressDuplicateMinutes}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateAlerting(
|
|
||||||
"suppressDuplicateMinutes",
|
|
||||||
Number.parseInt(e.target.value)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={escalationTimeoutMinutesId}>
|
|
||||||
Escalation Timeout (minutes)
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={escalationTimeoutMinutesId}
|
|
||||||
type="number"
|
|
||||||
min="5"
|
|
||||||
max="1440"
|
|
||||||
value={config.alerting.escalationTimeoutMinutes}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateAlerting(
|
|
||||||
"escalationTimeoutMinutes",
|
|
||||||
Number.parseInt(e.target.value)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="retention" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Data Retention</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Configure how long security data is stored
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={alertRetentionDaysId}>
|
|
||||||
Alert Retention (days)
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={alertRetentionDaysId}
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="3650"
|
|
||||||
value={config.retention.alertRetentionDays}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateRetention(
|
|
||||||
"alertRetentionDays",
|
|
||||||
Number.parseInt(e.target.value)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={metricsRetentionDaysId}>
|
|
||||||
Metrics Retention (days)
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={metricsRetentionDaysId}
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="3650"
|
|
||||||
value={config.retention.metricsRetentionDays}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateRetention(
|
|
||||||
"metricsRetentionDays",
|
|
||||||
Number.parseInt(e.target.value)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
<p>
|
|
||||||
• Alert data includes security alerts and acknowledgments
|
|
||||||
</p>
|
|
||||||
<p>• Metrics data includes aggregated security statistics</p>
|
|
||||||
<p>
|
|
||||||
• Audit logs are retained separately according to audit
|
|
||||||
policy
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={saveConfig} disabled={saving}>
|
|
||||||
{saving ? "Saving..." : "Save Configuration"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Bar,
|
|
||||||
BarChart,
|
|
||||||
Line,
|
|
||||||
LineChart,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
} from "recharts";
|
|
||||||
|
|
||||||
interface SecurityMetricsChartProps {
|
|
||||||
data: Array<{ hour: number; count: number }>;
|
|
||||||
type?: "line" | "bar";
|
|
||||||
title?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SecurityMetricsChart({
|
|
||||||
data,
|
|
||||||
type = "line",
|
|
||||||
title,
|
|
||||||
}: SecurityMetricsChartProps) {
|
|
||||||
const chartData = data.map((item) => ({
|
|
||||||
hour: `${item.hour}:00`,
|
|
||||||
count: item.count,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const ChartComponent = type === "line" ? LineChart : BarChart;
|
|
||||||
const DataComponent =
|
|
||||||
type === "line" ? (
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="count"
|
|
||||||
stroke="#8884d8"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={{ fill: "#8884d8", strokeWidth: 2 }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Bar dataKey="count" fill="#8884d8" />
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{title && <h3 className="text-lg font-semibold">{title}</h3>}
|
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
|
||||||
<ChartComponent data={chartData}>
|
|
||||||
<XAxis
|
|
||||||
dataKey="hour"
|
|
||||||
tick={{ fontSize: 12 }}
|
|
||||||
tickLine={{ stroke: "#e5e7eb" }}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tick={{ fontSize: 12 }}
|
|
||||||
tickLine={{ stroke: "#e5e7eb" }}
|
|
||||||
axisLine={{ stroke: "#e5e7eb" }}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: "#f9fafb",
|
|
||||||
border: "1px solid #e5e7eb",
|
|
||||||
borderRadius: "6px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{DataComponent}
|
|
||||||
</ChartComponent>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { AlertCircle, AlertTriangle, Shield, Zap } from "lucide-react";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
|
|
||||||
interface ThreatLevelIndicatorProps {
|
|
||||||
level: "LOW" | "MODERATE" | "HIGH" | "CRITICAL";
|
|
||||||
score?: number;
|
|
||||||
size?: "sm" | "md" | "lg";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThreatLevelIndicator({
|
|
||||||
level,
|
|
||||||
score,
|
|
||||||
size = "md",
|
|
||||||
}: ThreatLevelIndicatorProps) {
|
|
||||||
const getConfig = (threatLevel: string) => {
|
|
||||||
switch (threatLevel) {
|
|
||||||
case "CRITICAL":
|
|
||||||
return {
|
|
||||||
color: "destructive",
|
|
||||||
bgColor: "bg-red-500",
|
|
||||||
icon: Zap,
|
|
||||||
text: "Critical Threat",
|
|
||||||
description: "Immediate action required",
|
|
||||||
};
|
|
||||||
case "HIGH":
|
|
||||||
return {
|
|
||||||
color: "destructive",
|
|
||||||
bgColor: "bg-orange-500",
|
|
||||||
icon: AlertCircle,
|
|
||||||
text: "High Threat",
|
|
||||||
description: "Urgent attention needed",
|
|
||||||
};
|
|
||||||
case "MODERATE":
|
|
||||||
return {
|
|
||||||
color: "secondary",
|
|
||||||
bgColor: "bg-yellow-500",
|
|
||||||
icon: AlertTriangle,
|
|
||||||
text: "Moderate Threat",
|
|
||||||
description: "Monitor closely",
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
color: "outline",
|
|
||||||
bgColor: "bg-green-500",
|
|
||||||
icon: Shield,
|
|
||||||
text: "Low Threat",
|
|
||||||
description: "System is secure",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const config = getConfig(level);
|
|
||||||
const Icon = config.icon;
|
|
||||||
|
|
||||||
const sizeClasses = {
|
|
||||||
sm: { icon: "h-4 w-4", text: "text-sm", badge: "text-xs" },
|
|
||||||
md: { icon: "h-5 w-5", text: "text-base", badge: "text-sm" },
|
|
||||||
lg: { icon: "h-6 w-6", text: "text-lg", badge: "text-base" },
|
|
||||||
};
|
|
||||||
|
|
||||||
const classes = sizeClasses[size];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className={`p-2 rounded-full ${config.bgColor}`}>
|
|
||||||
<Icon className={`${classes.icon} text-white`} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
config.color as
|
|
||||||
| "default"
|
|
||||||
| "secondary"
|
|
||||||
| "destructive"
|
|
||||||
| "outline"
|
|
||||||
}
|
|
||||||
className={classes.badge}
|
|
||||||
>
|
|
||||||
{config.text}
|
|
||||||
</Badge>
|
|
||||||
{score !== undefined && (
|
|
||||||
<span className={`font-medium ${classes.text}`}>{score}/100</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">{config.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,17 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
|
import type { ThemeProviderProps } from "next-themes/dist/types";
|
||||||
type Attribute = "class" | "data-theme" | "data-mode";
|
|
||||||
|
|
||||||
interface ThemeProviderProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
attribute?: Attribute | Attribute[];
|
|
||||||
defaultTheme?: string;
|
|
||||||
enableSystem?: boolean;
|
|
||||||
disableTransitionOnChange?: boolean;
|
|
||||||
forcedTheme?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
type DayButton,
|
type DayButton,
|
||||||
@ -9,6 +14,69 @@ import {
|
|||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface CalendarRootProps {
|
||||||
|
className?: string;
|
||||||
|
rootRef?: React.Ref<HTMLDivElement>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CalendarRoot = ({ className, rootRef, ...props }: CalendarRootProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="calendar"
|
||||||
|
ref={rootRef}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CalendarChevronProps {
|
||||||
|
className?: string;
|
||||||
|
orientation: "left" | "right" | "up" | "down";
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CalendarChevron = ({
|
||||||
|
className,
|
||||||
|
orientation,
|
||||||
|
...props
|
||||||
|
}: CalendarChevronProps) => {
|
||||||
|
if (orientation === "left") {
|
||||||
|
return <ChevronLeftIcon className={cn("size-4", className)} {...props} />;
|
||||||
|
}
|
||||||
|
if (orientation === "right") {
|
||||||
|
return <ChevronRightIcon className={cn("size-4", className)} {...props} />;
|
||||||
|
}
|
||||||
|
if (orientation === "up") {
|
||||||
|
return (
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={cn("size-4 rotate-180", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <ChevronDownIcon className={cn("size-4", className)} {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CalendarWeekNumberProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CalendarWeekNumber = ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: CalendarWeekNumberProps) => {
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-9 items-center justify-center p-0 text-sm">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
function Calendar({
|
function Calendar({
|
||||||
className,
|
className,
|
||||||
classNames,
|
classNames,
|
||||||
@ -120,7 +188,10 @@ function Calendar({
|
|||||||
...classNames,
|
...classNames,
|
||||||
}}
|
}}
|
||||||
components={{
|
components={{
|
||||||
|
Root: CalendarRoot,
|
||||||
|
Chevron: CalendarChevron,
|
||||||
DayButton: CalendarDayButton,
|
DayButton: CalendarDayButton,
|
||||||
|
WeekNumber: CalendarWeekNumber,
|
||||||
...components,
|
...components,
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -3,152 +3,83 @@ import { ProcessingStatusManager } from "./lib/processingStatusManager";
|
|||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
/**
|
|
||||||
* Log pipeline status for each processing stage
|
|
||||||
*/
|
|
||||||
async function logPipelineStatus() {
|
|
||||||
const pipelineStatus = await ProcessingStatusManager.getPipelineStatus();
|
|
||||||
console.log(`Total Sessions: ${pipelineStatus.totalSessions}\n`);
|
|
||||||
|
|
||||||
const stages = [
|
|
||||||
"CSV_IMPORT",
|
|
||||||
"TRANSCRIPT_FETCH",
|
|
||||||
"SESSION_CREATION",
|
|
||||||
"AI_ANALYSIS",
|
|
||||||
"QUESTION_EXTRACTION",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const stage of stages) {
|
|
||||||
console.log(`${stage}:`);
|
|
||||||
const stageData = pipelineStatus.pipeline[stage] || {};
|
|
||||||
|
|
||||||
const pending = stageData.PENDING || 0;
|
|
||||||
const inProgress = stageData.IN_PROGRESS || 0;
|
|
||||||
const completed = stageData.COMPLETED || 0;
|
|
||||||
const skipped = stageData.SKIPPED || 0;
|
|
||||||
const failed = stageData.FAILED || 0;
|
|
||||||
|
|
||||||
console.log(` PENDING: ${pending}`);
|
|
||||||
console.log(` IN_PROGRESS: ${inProgress}`);
|
|
||||||
console.log(` COMPLETED: ${completed}`);
|
|
||||||
console.log(` SKIPPED: ${skipped}`);
|
|
||||||
console.log(` FAILED: ${failed}\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log session import relationship analysis
|
|
||||||
*/
|
|
||||||
async function logSessionImportRelationship() {
|
|
||||||
console.log("=== SESSION <-> IMPORT RELATIONSHIP ===");
|
|
||||||
|
|
||||||
const sessionWithImport = await prisma.session.count({
|
|
||||||
where: { importId: { not: null } },
|
|
||||||
});
|
|
||||||
|
|
||||||
const sessionWithoutImport = await prisma.session.count({
|
|
||||||
where: { importId: null },
|
|
||||||
});
|
|
||||||
|
|
||||||
const importWithSession = await prisma.sessionImport.count({
|
|
||||||
where: { session: { isNot: null } },
|
|
||||||
});
|
|
||||||
|
|
||||||
const importWithoutSession = await prisma.sessionImport.count({
|
|
||||||
where: { session: null },
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Sessions with ImportId: ${sessionWithImport}`);
|
|
||||||
console.log(`Sessions without ImportId: ${sessionWithoutImport}`);
|
|
||||||
console.log(`Imports with Session: ${importWithSession}`);
|
|
||||||
console.log(`Imports without Session: ${importWithoutSession}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log failed processing sessions
|
|
||||||
*/
|
|
||||||
async function logFailedSessions() {
|
|
||||||
console.log("=== FAILED PROCESSING ANALYSIS ===");
|
|
||||||
|
|
||||||
const failedSessions = await prisma.sessionProcessingStatus.findMany({
|
|
||||||
where: { status: "FAILED" },
|
|
||||||
include: {
|
|
||||||
session: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
import: {
|
|
||||||
select: { externalSessionId: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
take: 5,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (failedSessions.length > 0) {
|
|
||||||
console.log("Sample failed sessions:");
|
|
||||||
for (const failed of failedSessions) {
|
|
||||||
console.log(
|
|
||||||
` Session ${failed.session?.import?.externalSessionId || failed.sessionId} - Stage: ${failed.stage}, Error: ${failed.error}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log("No failed processing found");
|
|
||||||
}
|
|
||||||
console.log("");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log processing pipeline needs analysis
|
|
||||||
*/
|
|
||||||
async function logProcessingNeeds(pipelineStatus: {
|
|
||||||
pipeline: Record<string, Record<string, number>>;
|
|
||||||
}) {
|
|
||||||
console.log("=== WHAT NEEDS PROCESSING? ===");
|
|
||||||
|
|
||||||
const needsTranscriptFetch =
|
|
||||||
pipelineStatus.pipeline.TRANSCRIPT_FETCH?.PENDING || 0;
|
|
||||||
const needsSessionCreation =
|
|
||||||
pipelineStatus.pipeline.SESSION_CREATION?.PENDING || 0;
|
|
||||||
const needsAIAnalysis = pipelineStatus.pipeline.AI_ANALYSIS?.PENDING || 0;
|
|
||||||
const needsQuestionExtraction =
|
|
||||||
pipelineStatus.pipeline.QUESTION_EXTRACTION?.PENDING || 0;
|
|
||||||
|
|
||||||
if (needsTranscriptFetch > 0) {
|
|
||||||
console.log(`${needsTranscriptFetch} sessions need transcript fetching`);
|
|
||||||
}
|
|
||||||
if (needsSessionCreation > 0) {
|
|
||||||
console.log(`${needsSessionCreation} sessions need session creation`);
|
|
||||||
}
|
|
||||||
if (needsAIAnalysis > 0) {
|
|
||||||
console.log(`${needsAIAnalysis} sessions need AI analysis`);
|
|
||||||
}
|
|
||||||
if (needsQuestionExtraction > 0) {
|
|
||||||
console.log(`${needsQuestionExtraction} sessions need question extraction`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
needsTranscriptFetch +
|
|
||||||
needsSessionCreation +
|
|
||||||
needsAIAnalysis +
|
|
||||||
needsQuestionExtraction ===
|
|
||||||
0
|
|
||||||
) {
|
|
||||||
console.log("All sessions are fully processed!");
|
|
||||||
}
|
|
||||||
console.log("");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function debugImportStatus() {
|
async function debugImportStatus() {
|
||||||
try {
|
try {
|
||||||
console.log("=== DEBUGGING PROCESSING STATUS (REFACTORED SYSTEM) ===\n");
|
console.log("=== DEBUGGING PROCESSING STATUS (REFACTORED SYSTEM) ===\n");
|
||||||
|
|
||||||
|
// Get pipeline status using the new system
|
||||||
const pipelineStatus = await ProcessingStatusManager.getPipelineStatus();
|
const pipelineStatus = await ProcessingStatusManager.getPipelineStatus();
|
||||||
|
|
||||||
await logPipelineStatus();
|
console.log(`Total Sessions: ${pipelineStatus.totalSessions}\n`);
|
||||||
await logSessionImportRelationship();
|
|
||||||
await logFailedSessions();
|
// Display status for each stage
|
||||||
await logProcessingNeeds(pipelineStatus);
|
const stages = [
|
||||||
|
"CSV_IMPORT",
|
||||||
|
"TRANSCRIPT_FETCH",
|
||||||
|
"SESSION_CREATION",
|
||||||
|
"AI_ANALYSIS",
|
||||||
|
"QUESTION_EXTRACTION",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const stage of stages) {
|
||||||
|
console.log(`${stage}:`);
|
||||||
|
const stageData = pipelineStatus.pipeline[stage] || {};
|
||||||
|
|
||||||
|
const pending = stageData.PENDING || 0;
|
||||||
|
const inProgress = stageData.IN_PROGRESS || 0;
|
||||||
|
const completed = stageData.COMPLETED || 0;
|
||||||
|
const failed = stageData.FAILED || 0;
|
||||||
|
const skipped = stageData.SKIPPED || 0;
|
||||||
|
|
||||||
|
console.log(` PENDING: ${pending}`);
|
||||||
|
console.log(` IN_PROGRESS: ${inProgress}`);
|
||||||
|
console.log(` COMPLETED: ${completed}`);
|
||||||
|
console.log(` FAILED: ${failed}`);
|
||||||
|
console.log(` SKIPPED: ${skipped}`);
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Sessions vs SessionImports
|
||||||
|
console.log("=== SESSION IMPORT RELATIONSHIP ===");
|
||||||
|
const sessionsWithImports = await prisma.session.count({
|
||||||
|
where: { importId: { not: null } },
|
||||||
|
});
|
||||||
|
const totalSessions = await prisma.session.count();
|
||||||
|
|
||||||
|
console.log(` Sessions with importId: ${sessionsWithImports}`);
|
||||||
|
console.log(` Total sessions: ${totalSessions}`);
|
||||||
|
|
||||||
|
// Show failed sessions if any
|
||||||
|
const failedSessions = await ProcessingStatusManager.getFailedSessions();
|
||||||
|
if (failedSessions.length > 0) {
|
||||||
|
console.log("\n=== FAILED SESSIONS ===");
|
||||||
|
failedSessions.slice(0, 10).forEach((failure) => {
|
||||||
|
console.log(
|
||||||
|
` ${failure.session.import?.externalSessionId || failure.sessionId}: ${failure.stage} - ${failure.errorMessage}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (failedSessions.length > 10) {
|
||||||
|
console.log(
|
||||||
|
` ... and ${failedSessions.length - 10} more failed sessions`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("\n✓ No failed sessions found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show what needs processing
|
||||||
|
console.log("\n=== WHAT NEEDS PROCESSING ===");
|
||||||
|
|
||||||
|
for (const stage of stages) {
|
||||||
|
const stageData = pipelineStatus.pipeline[stage] || {};
|
||||||
|
const pending = stageData.PENDING || 0;
|
||||||
|
const failed = stageData.FAILED || 0;
|
||||||
|
|
||||||
|
if (pending > 0 || failed > 0) {
|
||||||
|
console.log(`• ${stage}: ${pending} pending, ${failed} failed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error debugging processing status:", error);
|
console.error("Error debugging processing status:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -1,352 +0,0 @@
|
|||||||
# CSRF Protection Implementation
|
|
||||||
|
|
||||||
This document describes the comprehensive CSRF (Cross-Site Request Forgery) protection implemented in the LiveDash application.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
CSRF protection has been implemented to prevent cross-site request forgery attacks on state-changing operations. The implementation follows industry best practices and provides protection at multiple layers:
|
|
||||||
|
|
||||||
- **Middleware Level**: Automatic CSRF validation for protected endpoints
|
|
||||||
- **tRPC Level**: CSRF protection for all state-changing tRPC procedures
|
|
||||||
- **Client Level**: Automatic token management and inclusion in requests
|
|
||||||
- **Component Level**: React components and hooks for easy integration
|
|
||||||
|
|
||||||
## Implementation Components
|
|
||||||
|
|
||||||
### 1. Core CSRF Library (`lib/csrf.ts`)
|
|
||||||
|
|
||||||
The core CSRF functionality includes:
|
|
||||||
|
|
||||||
- **Token Generation**: Cryptographically secure token generation using the `csrf` library
|
|
||||||
- **Token Verification**: Server-side token validation
|
|
||||||
- **Request Parsing**: Support for tokens in headers, JSON bodies, and form data
|
|
||||||
- **Client Utilities**: Browser-side token management and request enhancement
|
|
||||||
|
|
||||||
**Key Functions:**
|
|
||||||
|
|
||||||
- `generateCSRFToken()` - Creates new CSRF tokens
|
|
||||||
- `verifyCSRFToken()` - Validates tokens server-side
|
|
||||||
- `CSRFProtection.validateRequest()` - Request validation middleware
|
|
||||||
- `CSRFClient.*` - Client-side utilities
|
|
||||||
|
|
||||||
### 2. Middleware Protection (`middleware/csrfProtection.ts`)
|
|
||||||
|
|
||||||
Provides automatic CSRF protection for API endpoints:
|
|
||||||
|
|
||||||
**Protected Endpoints:**
|
|
||||||
|
|
||||||
- `/api/auth/*` - Authentication endpoints
|
|
||||||
- `/api/register` - User registration
|
|
||||||
- `/api/forgot-password` - Password reset requests
|
|
||||||
- `/api/reset-password` - Password reset completion
|
|
||||||
- `/api/dashboard/*` - Dashboard API endpoints
|
|
||||||
- `/api/platform/*` - Platform admin endpoints
|
|
||||||
- `/api/trpc/*` - All tRPC endpoints
|
|
||||||
|
|
||||||
**Protected Methods:**
|
|
||||||
|
|
||||||
- `POST` - Create operations
|
|
||||||
- `PUT` - Update operations
|
|
||||||
- `DELETE` - Delete operations
|
|
||||||
- `PATCH` - Partial update operations
|
|
||||||
|
|
||||||
**Safe Methods (Not Protected):**
|
|
||||||
|
|
||||||
- `GET` - Read operations
|
|
||||||
- `HEAD` - Metadata requests
|
|
||||||
- `OPTIONS` - CORS preflight requests
|
|
||||||
|
|
||||||
### 3. tRPC Integration (`lib/trpc.ts`)
|
|
||||||
|
|
||||||
CSRF protection integrated into tRPC procedures:
|
|
||||||
|
|
||||||
**New Procedure Types:**
|
|
||||||
|
|
||||||
- `csrfProtectedProcedure` - Basic CSRF protection
|
|
||||||
- `csrfProtectedAuthProcedure` - CSRF + authentication protection
|
|
||||||
- `csrfProtectedCompanyProcedure` - CSRF + company access protection
|
|
||||||
- `csrfProtectedAdminProcedure` - CSRF + admin access protection
|
|
||||||
|
|
||||||
**Updated Router Example:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Before
|
|
||||||
register: rateLimitedProcedure.input(registerSchema).mutation(async ({ input, ctx }) => {
|
|
||||||
/* ... */
|
|
||||||
});
|
|
||||||
|
|
||||||
// After
|
|
||||||
register: csrfProtectedProcedure.input(registerSchema).mutation(async ({ input, ctx }) => {
|
|
||||||
/* ... */
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Client-Side Integration
|
|
||||||
|
|
||||||
#### tRPC Client (`lib/trpc-client.ts`)
|
|
||||||
|
|
||||||
- Automatic CSRF token inclusion in tRPC requests
|
|
||||||
- Token extracted from cookies and added to request headers
|
|
||||||
|
|
||||||
#### React Hooks (`lib/hooks/useCSRF.ts`)
|
|
||||||
|
|
||||||
- `useCSRF()` - Basic token management
|
|
||||||
- `useCSRFFetch()` - Enhanced fetch with automatic CSRF tokens
|
|
||||||
- `useCSRFForm()` - Form submission with CSRF protection
|
|
||||||
|
|
||||||
#### Provider Component (`components/providers/CSRFProvider.tsx`)
|
|
||||||
|
|
||||||
- Application-wide CSRF token management
|
|
||||||
- Automatic token fetching and refresh
|
|
||||||
- Context-based token sharing
|
|
||||||
|
|
||||||
#### Protected Form Component (`components/forms/CSRFProtectedForm.tsx`)
|
|
||||||
|
|
||||||
- Ready-to-use form component with CSRF protection
|
|
||||||
- Automatic token inclusion in form submissions
|
|
||||||
- Graceful fallback for non-JavaScript environments
|
|
||||||
|
|
||||||
### 5. API Endpoint (`app/api/csrf-token/route.ts`)
|
|
||||||
|
|
||||||
Provides CSRF tokens to client applications:
|
|
||||||
|
|
||||||
- `GET /api/csrf-token` - Returns new CSRF token
|
|
||||||
- Sets HTTP-only cookie for automatic inclusion
|
|
||||||
- Used by client-side hooks and components
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# CSRF Secret (optional - defaults to NEXTAUTH_SECRET)
|
|
||||||
CSRF_SECRET=your-csrf-secret-key
|
|
||||||
```
|
|
||||||
|
|
||||||
### CSRF Configuration (`lib/csrf.ts`)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const CSRF_CONFIG = {
|
|
||||||
cookieName: "csrf-token",
|
|
||||||
headerName: "x-csrf-token",
|
|
||||||
secret: env.CSRF_SECRET || env.NEXTAUTH_SECRET,
|
|
||||||
cookie: {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: env.NODE_ENV === "production",
|
|
||||||
sameSite: "lax",
|
|
||||||
maxAge: 60 * 60 * 24, // 24 hours
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
|
|
||||||
### 1. Using CSRF in React Components
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { useCSRFFetch } from "@/lib/hooks/useCSRF";
|
|
||||||
|
|
||||||
function MyComponent() {
|
|
||||||
const { csrfFetch } = useCSRFFetch();
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
// CSRF token automatically included
|
|
||||||
const response = await csrfFetch("/api/dashboard/sessions", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ data: "example" }),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Using CSRF Protected Forms
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { CSRFProtectedForm } from "@/components/forms/CSRFProtectedForm";
|
|
||||||
|
|
||||||
function RegistrationForm() {
|
|
||||||
return (
|
|
||||||
<CSRFProtectedForm action="/api/register" method="POST">
|
|
||||||
<input name="email" type="email" required />
|
|
||||||
<input name="password" type="password" required />
|
|
||||||
<button type="submit">Register</button>
|
|
||||||
</CSRFProtectedForm>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Using CSRF in tRPC Procedures
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In your router file
|
|
||||||
export const userRouter = router({
|
|
||||||
updateProfile: csrfProtectedAuthProcedure
|
|
||||||
.input(userUpdateSchema)
|
|
||||||
.mutation(async ({ input, ctx }) => {
|
|
||||||
// CSRF validation automatically performed
|
|
||||||
// User authentication automatically verified
|
|
||||||
return updateUserProfile(input, ctx.user);
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Manual CSRF Token Handling
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { CSRFClient } from "@/lib/csrf";
|
|
||||||
|
|
||||||
// Get token from cookies
|
|
||||||
const token = CSRFClient.getToken();
|
|
||||||
|
|
||||||
// Add to fetch options
|
|
||||||
const options = CSRFClient.addTokenToFetch({
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add to form data
|
|
||||||
const formData = new FormData();
|
|
||||||
CSRFClient.addTokenToFormData(formData);
|
|
||||||
|
|
||||||
// Add to object
|
|
||||||
const dataWithToken = CSRFClient.addTokenToObject({ data: "example" });
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Features
|
|
||||||
|
|
||||||
### 1. Token Properties
|
|
||||||
|
|
||||||
- **Cryptographically Secure**: Uses the `csrf` library with secure random generation
|
|
||||||
- **Short-Lived**: 24-hour expiration by default
|
|
||||||
- **HTTP-Only Cookies**: Prevents XSS-based token theft
|
|
||||||
- **SameSite Protection**: Reduces CSRF attack surface
|
|
||||||
|
|
||||||
### 2. Validation Process
|
|
||||||
|
|
||||||
1. Extract token from request (header, form data, or JSON body)
|
|
||||||
2. Retrieve stored token from HTTP-only cookie
|
|
||||||
3. Verify tokens match
|
|
||||||
4. Validate token cryptographic integrity
|
|
||||||
5. Allow or reject request based on validation
|
|
||||||
|
|
||||||
### 3. Error Handling
|
|
||||||
|
|
||||||
- **Graceful Degradation**: Form fallbacks for JavaScript-disabled browsers
|
|
||||||
- **Clear Error Messages**: Specific error codes for debugging
|
|
||||||
- **Rate Limiting Integration**: Works with existing auth rate limiting
|
|
||||||
- **Logging**: Comprehensive logging for security monitoring
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Test Coverage
|
|
||||||
|
|
||||||
- **Unit Tests**: Token generation, validation, and client utilities
|
|
||||||
- **Integration Tests**: Middleware behavior and endpoint protection
|
|
||||||
- **Component Tests**: React hooks and form components
|
|
||||||
- **End-to-End**: Full request/response cycle testing
|
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all CSRF tests
|
|
||||||
pnpm test:vitest tests/unit/csrf*.test.ts tests/integration/csrf*.test.ts
|
|
||||||
|
|
||||||
# Run specific test files
|
|
||||||
pnpm test:vitest tests/unit/csrf.test.ts
|
|
||||||
pnpm test:vitest tests/integration/csrf-protection.test.ts
|
|
||||||
pnpm test:vitest tests/unit/csrf-hooks.test.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
## Monitoring and Debugging
|
|
||||||
|
|
||||||
### CSRF Validation Logs
|
|
||||||
|
|
||||||
Failed CSRF validations are logged with details:
|
|
||||||
|
|
||||||
```
|
|
||||||
CSRF validation failed for POST /api/dashboard/sessions: CSRF token missing from request
|
|
||||||
```
|
|
||||||
|
|
||||||
### Common Issues and Solutions
|
|
||||||
|
|
||||||
1. **Token Missing from Request**
|
|
||||||
|
|
||||||
- Ensure CSRFProvider is wrapping your app
|
|
||||||
- Check that hooks are being used correctly
|
|
||||||
- Verify network requests include credentials
|
|
||||||
|
|
||||||
2. **Token Mismatch**
|
|
||||||
|
|
||||||
- Clear browser cookies and refresh
|
|
||||||
- Check for multiple token sources conflicting
|
|
||||||
- Verify server and client time synchronization
|
|
||||||
|
|
||||||
3. **Integration Issues**
|
|
||||||
|
|
||||||
- Ensure middleware is properly configured
|
|
||||||
- Check tRPC client configuration
|
|
||||||
- Verify protected procedures are using correct types
|
|
||||||
|
|
||||||
## Migration Guide
|
|
||||||
|
|
||||||
### For Existing Endpoints
|
|
||||||
|
|
||||||
1. Update tRPC procedures to use CSRF-protected variants:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Old
|
|
||||||
someAction: protectedProcedure.mutation(async ({ ctx, input }) => {
|
|
||||||
// mutation logic
|
|
||||||
});
|
|
||||||
|
|
||||||
// New
|
|
||||||
someAction: csrfProtectedAuthProcedure.mutation(async ({ ctx, input }) => {
|
|
||||||
// mutation logic
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Update client components to use CSRF hooks:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// Old
|
|
||||||
const { data, mutate } = trpc.user.update.useMutation();
|
|
||||||
|
|
||||||
// New - no changes needed, CSRF automatically handled
|
|
||||||
const { data, mutate } = trpc.user.update.useMutation();
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Update manual API calls to include CSRF tokens:
|
|
||||||
|
|
||||||
<!-- prettier-ignore -->
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Old
|
|
||||||
fetch("/api/endpoint", { method: "POST", body: data });
|
|
||||||
|
|
||||||
// New
|
|
||||||
const { csrfFetch } = useCSRFFetch();
|
|
||||||
csrfFetch("/api/endpoint", { method: "POST", body: data });
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
- **Minimal Overhead**: Token validation adds ~1ms per request
|
|
||||||
- **Efficient Caching**: Tokens cached in memory and cookies
|
|
||||||
- **Selective Protection**: Only state-changing operations protected
|
|
||||||
- **Optimized Parsing**: Smart content-type detection for token extraction
|
|
||||||
|
|
||||||
## Security Best Practices
|
|
||||||
|
|
||||||
1. **Always use HTTPS in production** - CSRF tokens should never be transmitted over HTTP
|
|
||||||
2. **Monitor CSRF failures** - Implement alerting for unusual CSRF failure patterns
|
|
||||||
3. **Regular secret rotation** - Consider rotating CSRF secrets periodically
|
|
||||||
4. **Validate referrer headers** - Additional protection layer (not implemented but recommended)
|
|
||||||
5. **Content Security Policy** - Use CSP headers to prevent XSS attacks that could steal tokens
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
The CSRF protection implementation provides comprehensive defense against cross-site request forgery attacks while maintaining ease of use for developers. The multi-layer approach ensures protection at the middleware, application, and component levels, with automatic token management reducing the risk of developer error.
|
|
||||||
|
|
||||||
For questions or issues related to CSRF protection, refer to the test files for examples and the security documentation for additional context.
|
|
||||||
@ -1,434 +0,0 @@
|
|||||||
# Admin Audit Logs API
|
|
||||||
|
|
||||||
This document describes the Admin Audit Logs API endpoints for retrieving and managing security audit logs in the LiveDash application.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The Admin Audit Logs API provides secure access to security audit trails for administrative users. It includes comprehensive filtering, pagination, and retention management capabilities.
|
|
||||||
|
|
||||||
## Authentication & Authorization
|
|
||||||
|
|
||||||
- **Authentication**: NextAuth.js session required
|
|
||||||
- **Authorization**: ADMIN role required for all endpoints
|
|
||||||
- **Rate-Limiting**: Integrated with existing authentication rate-limiting system
|
|
||||||
- **Audit Trail**: All API access is logged for security monitoring
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### Get Audit Logs
|
|
||||||
|
|
||||||
Retrieve paginated audit logs with optional filtering.
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/admin/audit-logs
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Query Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Description | Default | Example |
|
|
||||||
| ----------- | ------ | --------------------------- | ------- | --------------------------------- |
|
|
||||||
| `page` | number | Page number (1-based) | 1 | `?page=2` |
|
|
||||||
| `limit` | number | Records per page (max 100) | 50 | `?limit=25` |
|
|
||||||
| `eventType` | string | Filter by event type | - | `?eventType=login_attempt` |
|
|
||||||
| `outcome` | string | Filter by outcome | - | `?outcome=FAILURE` |
|
|
||||||
| `severity` | string | Filter by severity level | - | `?severity=HIGH` |
|
|
||||||
| `userId` | string | Filter by specific user ID | - | `?userId=user-123` |
|
|
||||||
| `startDate` | string | Filter from date (ISO 8601) | - | `?startDate=2024-01-01T00:00:00Z` |
|
|
||||||
| `endDate` | string | Filter to date (ISO 8601) | - | `?endDate=2024-01-02T00:00:00Z` |
|
|
||||||
|
|
||||||
#### Example Request
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const response = await fetch(
|
|
||||||
"/api/admin/audit-logs?" +
|
|
||||||
new URLSearchParams({
|
|
||||||
page: "1",
|
|
||||||
limit: "25",
|
|
||||||
eventType: "login_attempt",
|
|
||||||
outcome: "FAILURE",
|
|
||||||
startDate: "2024-01-01T00:00:00Z",
|
|
||||||
endDate: "2024-01-02T00:00:00Z",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Response Format
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"auditLogs": [
|
|
||||||
{
|
|
||||||
"id": "log-123",
|
|
||||||
"eventType": "login_attempt",
|
|
||||||
"outcome": "FAILURE",
|
|
||||||
"severity": "HIGH",
|
|
||||||
"userId": "user-456",
|
|
||||||
"companyId": "company-789",
|
|
||||||
"ipAddress": "192.168.1.***",
|
|
||||||
"userAgent": "Mozilla/5.0 (masked)",
|
|
||||||
"timestamp": "2024-01-01T12:00:00Z",
|
|
||||||
"description": "Failed login attempt",
|
|
||||||
"metadata": {
|
|
||||||
"error": "invalid_password",
|
|
||||||
"endpoint": "/api/auth/signin"
|
|
||||||
},
|
|
||||||
"user": {
|
|
||||||
"id": "user-456",
|
|
||||||
"email": "user@example.com",
|
|
||||||
"name": "John Doe",
|
|
||||||
"role": "USER"
|
|
||||||
},
|
|
||||||
"platformUser": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"pagination": {
|
|
||||||
"page": 1,
|
|
||||||
"limit": 25,
|
|
||||||
"totalCount": 150,
|
|
||||||
"totalPages": 6,
|
|
||||||
"hasNext": true,
|
|
||||||
"hasPrev": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Error Responses
|
|
||||||
|
|
||||||
**Unauthorized (401)**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"error": "Unauthorized"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Insufficient permissions (403)**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"error": "Insufficient permissions"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Server error (500)**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"error": "Internal server error"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Audit Log Retention Management
|
|
||||||
|
|
||||||
Manage audit log retention policies and cleanup.
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/admin/audit-logs/retention
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Request Body
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"action": "cleanup",
|
|
||||||
"retentionDays": 90,
|
|
||||||
"dryRun": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
<!-- prettier-ignore -->
|
|
||||||
**Note**: `action` field accepts one of: `"cleanup"`, `"configure"`, or `"status"`
|
|
||||||
|
|
||||||
#### Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------------- | ------- | -------- | ------------------------------------------------------ |
|
|
||||||
| `action` | string | Yes | Action to perform: `cleanup`, `configure`, or `status` |
|
|
||||||
| `retentionDays` | number | No | Retention period in days (for configure action) |
|
|
||||||
| `dryRun` | boolean | No | Preview changes without executing (for cleanup) |
|
|
||||||
|
|
||||||
#### Example Requests
|
|
||||||
|
|
||||||
**Check retention status:**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const response = await fetch("/api/admin/audit-logs/retention", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ action: "status" }),
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Configure retention policy:**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const response = await fetch("/api/admin/audit-logs/retention", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
action: "configure",
|
|
||||||
retentionDays: 365,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Cleanup old logs (dry run):**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const response = await fetch("/api/admin/audit-logs/retention", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
action: "cleanup",
|
|
||||||
dryRun: true,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Features
|
|
||||||
|
|
||||||
### Access Control
|
|
||||||
|
|
||||||
- **Role-based Access**: Only ADMIN users can access audit logs
|
|
||||||
- **Company Isolation**: Users only see logs for their company
|
|
||||||
- **Session Validation**: Active NextAuth session required
|
|
||||||
|
|
||||||
### Audit Trail
|
|
||||||
|
|
||||||
- **Access Logging**: All audit log access is recorded
|
|
||||||
- **Metadata Tracking**: Request parameters and results are logged
|
|
||||||
- **IP Tracking**: Client IP addresses are recorded for all requests
|
|
||||||
|
|
||||||
### Rate Limiting
|
|
||||||
|
|
||||||
- **Integrated Protection**: Uses existing authentication rate-limiting
|
|
||||||
- **Abuse Prevention**: Protects against excessive API usage
|
|
||||||
- **Error Tracking**: Failed attempts are monitored
|
|
||||||
|
|
||||||
## Event Types
|
|
||||||
|
|
||||||
Common event types available for filtering:
|
|
||||||
|
|
||||||
| Event Type | Description |
|
|
||||||
| ------------------------- | -------------------------- |
|
|
||||||
| `login_attempt` | User login attempts |
|
|
||||||
| `login_success` | Successful logins |
|
|
||||||
| `logout` | User logouts |
|
|
||||||
| `password_reset_request` | Password reset requests |
|
|
||||||
| `password_reset_complete` | Password reset completions |
|
|
||||||
| `user_creation` | New user registrations |
|
|
||||||
| `user_modification` | User profile changes |
|
|
||||||
| `admin_action` | Administrative actions |
|
|
||||||
| `data_export` | Data export activities |
|
|
||||||
| `security_violation` | Security policy violations |
|
|
||||||
|
|
||||||
## Outcome Types
|
|
||||||
|
|
||||||
| Outcome | Description |
|
|
||||||
| -------------- | ---------------------------------------- |
|
|
||||||
| `SUCCESS` | Operation completed successfully |
|
|
||||||
| `FAILURE` | Operation failed |
|
|
||||||
| `BLOCKED` | Operation was blocked by security policy |
|
|
||||||
| `WARNING` | Operation completed with warnings |
|
|
||||||
| `RATE_LIMITED` | Operation was rate limited |
|
|
||||||
|
|
||||||
## Severity Levels
|
|
||||||
|
|
||||||
| Severity | Description | Use Case |
|
|
||||||
| ---------- | ------------------------ | ------------------------- |
|
|
||||||
| `LOW` | Informational events | Normal operations |
|
|
||||||
| `MEDIUM` | Notable events | Configuration changes |
|
|
||||||
| `HIGH` | Security events | Failed logins, violations |
|
|
||||||
| `CRITICAL` | Critical security events | Breaches, attacks |
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
|
|
||||||
### Daily Security Report
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
async function getDailySecurityReport() {
|
|
||||||
const yesterday = new Date();
|
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
|
||||||
yesterday.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
"/api/admin/audit-logs?" +
|
|
||||||
new URLSearchParams({
|
|
||||||
startDate: yesterday.toISOString(),
|
|
||||||
endDate: today.toISOString(),
|
|
||||||
limit: "100",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return data.data.auditLogs;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Failed Login Analysis
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
async function getFailedLogins(hours = 24) {
|
|
||||||
const since = new Date();
|
|
||||||
since.setHours(since.getHours() - hours);
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
"/api/admin/audit-logs?" +
|
|
||||||
new URLSearchParams({
|
|
||||||
eventType: "login_attempt",
|
|
||||||
outcome: "FAILURE",
|
|
||||||
startDate: since.toISOString(),
|
|
||||||
limit: "100",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return data.data.auditLogs;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### User Activity Tracking
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
async function getUserActivity(userId, days = 7) {
|
|
||||||
const since = new Date();
|
|
||||||
since.setDate(since.getDate() - days);
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
"/api/admin/audit-logs?" +
|
|
||||||
new URLSearchParams({
|
|
||||||
userId: userId,
|
|
||||||
startDate: since.toISOString(),
|
|
||||||
limit: "50",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return data.data.auditLogs;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
### Database Optimization
|
|
||||||
|
|
||||||
- **Indexed Queries**: All filter columns are properly indexed
|
|
||||||
- **Pagination**: Efficient offset-based pagination with limits
|
|
||||||
- **Time Range Filtering**: Optimized for date range queries
|
|
||||||
|
|
||||||
### Memory Usage
|
|
||||||
|
|
||||||
- **Limited Results**: Maximum 100 records per request
|
|
||||||
- **Streaming**: Large exports use streaming for memory efficiency
|
|
||||||
- **Connection Pooling**: Database connections are pooled
|
|
||||||
|
|
||||||
### Caching Considerations
|
|
||||||
|
|
||||||
- **No Caching**: Audit logs are never cached for security reasons
|
|
||||||
- **Fresh Data**: All queries hit the database for real-time results
|
|
||||||
- **Read Replicas**: Consider using read replicas for heavy reporting
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
### Common Errors
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/audit-logs");
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!data.success) {
|
|
||||||
switch (response.status) {
|
|
||||||
case 401:
|
|
||||||
console.error("User not authenticated");
|
|
||||||
break;
|
|
||||||
case 403:
|
|
||||||
console.error("User lacks admin permissions");
|
|
||||||
break;
|
|
||||||
case 500:
|
|
||||||
console.error("Server error:", data.error);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Network error:", error);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rate-Limiting Handling
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
async function fetchWithRetry(url, options = {}, maxRetries = 3, retryCount = 0) {
|
|
||||||
const response = await fetch(url, options);
|
|
||||||
|
|
||||||
if (response.status === 429 && retryCount < maxRetries) {
|
|
||||||
// Rate limited, wait with exponential backoff and retry
|
|
||||||
const delay = Math.pow(2, retryCount) * 1000; // 1s, 2s, 4s
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
return fetchWithRetry(url, options, maxRetries, retryCount + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 429) {
|
|
||||||
throw new Error(`Rate limited after ${maxRetries} retries`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Monitoring and Alerting
|
|
||||||
|
|
||||||
### Key Metrics to Monitor
|
|
||||||
|
|
||||||
- **Request Volume**: Track API usage patterns
|
|
||||||
- **Error Rates**: Monitor authentication and authorization failures
|
|
||||||
- **Query Performance**: Track slow queries and optimize
|
|
||||||
- **Data Growth**: Monitor audit log size and plan retention
|
|
||||||
|
|
||||||
### Alert Conditions
|
|
||||||
|
|
||||||
- **High Error Rates**: >5% of requests failing
|
|
||||||
- **Unusual Access Patterns**: Off-hours access, high-volume usage
|
|
||||||
- **Performance Degradation**: Query times >2 seconds
|
|
||||||
- **Security Events**: Multiple failed admin access attempts
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
- Always validate user permissions before displaying UI
|
|
||||||
- Log all administrative access to audit logs
|
|
||||||
- Use HTTPS in production environments
|
|
||||||
- Implement proper error handling to avoid information leakage
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
|
|
||||||
- Use appropriate page sizes (25-50 records typical)
|
|
||||||
- Implement client-side pagination for better UX
|
|
||||||
- Cache results only in memory, never persist
|
|
||||||
- Use date range filters to limit query scope
|
|
||||||
|
|
||||||
### User Experience
|
|
||||||
|
|
||||||
- Provide clear filtering options in the UI
|
|
||||||
- Show loading states for long-running queries
|
|
||||||
- Implement export functionality for reports
|
|
||||||
- Provide search and sort capabilities
|
|
||||||
|
|
||||||
## Related Documentation
|
|
||||||
|
|
||||||
- [Security Audit Logging](./security-audit-logging.md)
|
|
||||||
- [Security Monitoring](./security-monitoring.md)
|
|
||||||
- [CSRF Protection](./CSRF_PROTECTION.md)
|
|
||||||
- [Authentication System](../lib/auth.ts)
|
|
||||||
@ -1,722 +0,0 @@
|
|||||||
# LiveDash-Node API Reference
|
|
||||||
|
|
||||||
This document provides a comprehensive reference for all API endpoints in the LiveDash-Node application, including authentication, security monitoring, audit logging, and administrative functions.
|
|
||||||
|
|
||||||
## Base URL
|
|
||||||
|
|
||||||
```
|
|
||||||
Local Development: http://localhost:3000
|
|
||||||
Production: https://your-domain.com
|
|
||||||
```
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
All API endpoints (except public endpoints) require authentication via NextAuth.js session cookies.
|
|
||||||
|
|
||||||
### Authentication Headers
|
|
||||||
|
|
||||||
```http
|
|
||||||
Cookie: next-auth.session-token=<session-token>
|
|
||||||
```
|
|
||||||
|
|
||||||
### CSRF Protection
|
|
||||||
|
|
||||||
State-changing endpoints require CSRF tokens:
|
|
||||||
|
|
||||||
```http
|
|
||||||
X-CSRF-Token: <csrf-token>
|
|
||||||
```
|
|
||||||
|
|
||||||
Get CSRF token:
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/csrf-token
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Endpoints Overview
|
|
||||||
|
|
||||||
### Public Endpoints
|
|
||||||
|
|
||||||
- `POST /api/csp-report` - CSP violation reporting (no auth required)
|
|
||||||
- `OPTIONS /api/csp-report` - CORS preflight
|
|
||||||
|
|
||||||
### Authentication Endpoints
|
|
||||||
|
|
||||||
- `POST /api/auth/[...nextauth]` - NextAuth.js authentication
|
|
||||||
- `GET /api/csrf-token` - Get CSRF token
|
|
||||||
- `POST /api/register` - User registration
|
|
||||||
- `POST /api/forgot-password` - Password reset request
|
|
||||||
- `POST /api/reset-password` - Password reset completion
|
|
||||||
|
|
||||||
### Admin Endpoints (ADMIN role required)
|
|
||||||
|
|
||||||
- `GET /api/admin/audit-logs` - Retrieve audit logs
|
|
||||||
- `POST /api/admin/audit-logs/retention` - Manage audit log retention
|
|
||||||
- `GET /api/admin/batch-monitoring` - Batch processing monitoring
|
|
||||||
- `POST /api/admin/batch-monitoring/{id}/retry` - Retry failed batch job
|
|
||||||
|
|
||||||
### Platform Admin Endpoints (Platform admin only)
|
|
||||||
|
|
||||||
- `GET /api/admin/security-monitoring` - Security monitoring metrics
|
|
||||||
- `POST /api/admin/security-monitoring` - Update security configuration
|
|
||||||
- `GET /api/admin/security-monitoring/alerts` - Alert management
|
|
||||||
- `POST /api/admin/security-monitoring/alerts` - Acknowledge alerts
|
|
||||||
- `GET /api/admin/security-monitoring/export` - Export security data
|
|
||||||
- `POST /api/admin/security-monitoring/threat-analysis` - Threat analysis
|
|
||||||
|
|
||||||
### Security Monitoring Endpoints
|
|
||||||
|
|
||||||
- `GET /api/csp-metrics` - CSP violation metrics
|
|
||||||
- `POST /api/csp-report` - CSP violation reporting
|
|
||||||
|
|
||||||
### Dashboard Endpoints
|
|
||||||
|
|
||||||
- `GET /api/dashboard/sessions` - Session data
|
|
||||||
- `GET /api/dashboard/session/{id}` - Individual session details
|
|
||||||
- `GET /api/dashboard/metrics` - Dashboard metrics
|
|
||||||
- `GET /api/dashboard/config` - Dashboard configuration
|
|
||||||
|
|
||||||
### Platform Management
|
|
||||||
|
|
||||||
- `GET /api/platform/companies` - Company management
|
|
||||||
- `POST /api/platform/companies` - Create company
|
|
||||||
- `GET /api/platform/companies/{id}` - Company details
|
|
||||||
- `GET /api/platform/companies/{id}/users` - Company users
|
|
||||||
- `POST /api/platform/companies/{id}/users` - Add company user
|
|
||||||
|
|
||||||
### tRPC Endpoints
|
|
||||||
|
|
||||||
- `POST /api/trpc/[trpc]` - tRPC procedure calls
|
|
||||||
|
|
||||||
## Detailed Endpoint Documentation
|
|
||||||
|
|
||||||
### Admin Audit Logs
|
|
||||||
|
|
||||||
#### Get Audit Logs
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/admin/audit-logs
|
|
||||||
```
|
|
||||||
|
|
||||||
**Authorization**: ADMIN role required
|
|
||||||
|
|
||||||
**Query Parameters**:
|
|
||||||
|
|
||||||
- `page` (number, optional): Page number (default: 1)
|
|
||||||
- `limit` (number, optional): Records per page, max 100 (default: 50)
|
|
||||||
- `eventType` (string, optional): Filter by event type
|
|
||||||
- `outcome` (string, optional): Filter by outcome (SUCCESS, FAILURE, BLOCKED, etc.)
|
|
||||||
- `severity` (string, optional): Filter by severity (LOW, MEDIUM, HIGH, CRITICAL)
|
|
||||||
- `userId` (string, optional): Filter by user ID
|
|
||||||
- `startDate` (string, optional): Start date (ISO 8601)
|
|
||||||
- `endDate` (string, optional): End date (ISO 8601)
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"auditLogs": ["// Array of audit log entries"],
|
|
||||||
"pagination": {
|
|
||||||
"page": 1,
|
|
||||||
"limit": 50,
|
|
||||||
"totalCount": 150,
|
|
||||||
"totalPages": 3,
|
|
||||||
"hasNext": true,
|
|
||||||
"hasPrev": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rate Limit**: Inherits from auth rate limiting
|
|
||||||
|
|
||||||
#### Manage Audit Log Retention
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/admin/audit-logs/retention
|
|
||||||
```
|
|
||||||
|
|
||||||
**Authorization**: ADMIN role required
|
|
||||||
|
|
||||||
**Request Body**:
|
|
||||||
|
|
||||||
<!-- prettier-ignore -->
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"action": "cleanup" | "configure" | "status",
|
|
||||||
"retentionDays": 90,
|
|
||||||
"dryRun": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"action": "cleanup",
|
|
||||||
"recordsAffected": 1250,
|
|
||||||
"retentionDays": 90,
|
|
||||||
"dryRun": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Security Monitoring
|
|
||||||
|
|
||||||
#### Get Security Metrics
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/admin/security-monitoring
|
|
||||||
```
|
|
||||||
|
|
||||||
**Authorization**: Platform admin required
|
|
||||||
|
|
||||||
**Query Parameters**:
|
|
||||||
|
|
||||||
- `startDate` (string, optional): Start date (ISO 8601)
|
|
||||||
- `endDate` (string, optional): End date (ISO 8601)
|
|
||||||
- `companyId` (string, optional): Filter by company
|
|
||||||
- `severity` (string, optional): Filter by severity
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"metrics": {
|
|
||||||
"securityScore": 85,
|
|
||||||
"threatLevel": "LOW",
|
|
||||||
"eventCounts": {
|
|
||||||
"// Event count statistics": null
|
|
||||||
},
|
|
||||||
"anomalies": ["// Array of security anomalies"]
|
|
||||||
},
|
|
||||||
"alerts": ["// Array of security alerts"],
|
|
||||||
"config": {
|
|
||||||
"// Security configuration": null
|
|
||||||
},
|
|
||||||
"timeRange": {
|
|
||||||
"// Time range for the data": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Update Security Configuration
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/admin/security-monitoring
|
|
||||||
```
|
|
||||||
|
|
||||||
**Authorization**: Platform admin required
|
|
||||||
|
|
||||||
**Request Body**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"thresholds": {
|
|
||||||
"failedLoginsPerMinute": 5,
|
|
||||||
"rateLimitViolationsPerMinute": 10
|
|
||||||
},
|
|
||||||
"alerting": {
|
|
||||||
"enabled": true,
|
|
||||||
"channels": ["EMAIL", "WEBHOOK"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### CSP Monitoring
|
|
||||||
|
|
||||||
#### CSP Violation Reporting
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/csp-report
|
|
||||||
```
|
|
||||||
|
|
||||||
**Authorization**: None (public endpoint)
|
|
||||||
|
|
||||||
**Headers**:
|
|
||||||
|
|
||||||
- `Content-Type`: `application/csp-report` or `application/json`
|
|
||||||
|
|
||||||
**Request Body** (automatic from browser):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"csp-report": {
|
|
||||||
"document-uri": "https://example.com/page",
|
|
||||||
"violated-directive": "script-src 'self'",
|
|
||||||
"blocked-uri": "https://malicious.com/script.js",
|
|
||||||
"source-file": "https://example.com/page",
|
|
||||||
"line-number": 42
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rate Limit**: 10 reports per minute per IP
|
|
||||||
|
|
||||||
**Response**: `204 No Content`
|
|
||||||
|
|
||||||
#### Get CSP Metrics
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/csp-metrics
|
|
||||||
```
|
|
||||||
|
|
||||||
**Authorization**: Admin role required
|
|
||||||
|
|
||||||
**Query Parameters**:
|
|
||||||
|
|
||||||
- `timeRange` (string, optional): Time range (1h, 6h, 24h, 7d, 30d)
|
|
||||||
- `format` (string, optional): Response format (json, csv)
|
|
||||||
- `groupBy` (string, optional): Group by field (hour, directive, etc.)
|
|
||||||
- `includeDetails` (boolean, optional): Include violation details
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"summary": {
|
|
||||||
"totalViolations": 45,
|
|
||||||
"uniqueViolations": 12,
|
|
||||||
"highRiskViolations": 3,
|
|
||||||
"bypassAttempts": 1
|
|
||||||
},
|
|
||||||
"trends": {
|
|
||||||
"// CSP trend data": null
|
|
||||||
},
|
|
||||||
"topViolations": ["// Array of top CSP violations"],
|
|
||||||
"riskAnalysis": {
|
|
||||||
"// CSP risk analysis data": null
|
|
||||||
},
|
|
||||||
"violations": ["// Array of CSP violations"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Batch Monitoring
|
|
||||||
|
|
||||||
#### Get Batch Monitoring Data
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/admin/batch-monitoring
|
|
||||||
```
|
|
||||||
|
|
||||||
**Authorization**: ADMIN role required
|
|
||||||
|
|
||||||
**Query Parameters**:
|
|
||||||
|
|
||||||
- `timeRange` (string, optional): Time range (1h, 6h, 24h, 7d, 30d)
|
|
||||||
- `status` (string, optional): Filter by status (pending, completed, failed)
|
|
||||||
- `jobType` (string, optional): Filter by job type
|
|
||||||
- `includeDetails` (boolean, optional): Include detailed job information
|
|
||||||
- `page` (number, optional): Page number
|
|
||||||
- `limit` (number, optional): Records per page
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"summary": {
|
|
||||||
"totalJobs": 156,
|
|
||||||
"completedJobs": 142,
|
|
||||||
"failedJobs": 8,
|
|
||||||
"costSavings": {}
|
|
||||||
},
|
|
||||||
"queues": {
|
|
||||||
"// Queue statistics": null
|
|
||||||
},
|
|
||||||
"performance": {
|
|
||||||
"// Performance metrics": null
|
|
||||||
},
|
|
||||||
"jobs": ["// Array of batch jobs"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Retry Batch Job
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/admin/batch-monitoring/{jobId}/retry
|
|
||||||
```
|
|
||||||
|
|
||||||
**Authorization**: ADMIN role required
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"jobId": "batch-job-123",
|
|
||||||
"status": "retrying",
|
|
||||||
"message": "Job queued for retry"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### CSRF Token
|
|
||||||
|
|
||||||
#### Get CSRF Token
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/csrf-token
|
|
||||||
```
|
|
||||||
|
|
||||||
**Authorization**: None
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"csrfToken": "abc123..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Headers Set**:
|
|
||||||
|
|
||||||
- `Set-Cookie`: HTTP-only CSRF token cookie
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
|
|
||||||
#### User Registration
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/register
|
|
||||||
```
|
|
||||||
|
|
||||||
**Authorization**: None
|
|
||||||
|
|
||||||
**Headers Required**:
|
|
||||||
|
|
||||||
- `X-CSRF-Token`: CSRF token
|
|
||||||
|
|
||||||
**Request Body**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"email": "user@example.com",
|
|
||||||
"password": "SecurePassword123!",
|
|
||||||
"name": "John Doe",
|
|
||||||
"companyName": "Acme Corp"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rate Limit**: 3 attempts per hour per IP
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "User registered successfully",
|
|
||||||
"userId": "user-123"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Password Reset Request
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/forgot-password
|
|
||||||
```
|
|
||||||
|
|
||||||
**Authorization**: None
|
|
||||||
|
|
||||||
**Headers Required**:
|
|
||||||
|
|
||||||
- `X-CSRF-Token`: CSRF token
|
|
||||||
|
|
||||||
**Request Body**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"email": "user@example.com"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rate Limit**: 5 attempts per 15 minutes per IP
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "Password reset email sent"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Password Reset Completion
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/reset-password
|
|
||||||
```
|
|
||||||
|
|
||||||
**Authorization**: None
|
|
||||||
|
|
||||||
**Headers Required**:
|
|
||||||
|
|
||||||
- `X-CSRF-Token`: CSRF token
|
|
||||||
|
|
||||||
**Request Body**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"token": "reset-token-123",
|
|
||||||
"password": "NewSecurePassword123!"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "Password reset successfully"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Responses
|
|
||||||
|
|
||||||
### Standard Error Format
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"error": "Error message",
|
|
||||||
"code": "ERROR_CODE",
|
|
||||||
"details": {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Common HTTP Status Codes
|
|
||||||
|
|
||||||
| Status | Description | Common Causes |
|
|
||||||
| ------ | --------------------- | ---------------------------------------- |
|
|
||||||
| 200 | OK | Successful request |
|
|
||||||
| 201 | Created | Resource created successfully |
|
|
||||||
| 204 | No Content | Successful request with no response body |
|
|
||||||
| 400 | Bad Request | Invalid request parameters or body |
|
|
||||||
| 401 | Unauthorized | Authentication required or invalid |
|
|
||||||
| 403 | Forbidden | Insufficient permissions |
|
|
||||||
| 404 | Not Found | Resource not found |
|
|
||||||
| 409 | Conflict | Resource already exists or conflict |
|
|
||||||
| 422 | Unprocessable Entity | Validation errors |
|
|
||||||
| 429 | Too Many Requests | Rate limit exceeded |
|
|
||||||
| 500 | Internal Server Error | Server error |
|
|
||||||
|
|
||||||
### Error Codes
|
|
||||||
|
|
||||||
| Code | Description | Resolution |
|
|
||||||
| ------------------ | ------------------------ | -------------------- |
|
|
||||||
| `UNAUTHORIZED` | No valid session | Login required |
|
|
||||||
| `FORBIDDEN` | Insufficient permissions | Check user role |
|
|
||||||
| `VALIDATION_ERROR` | Invalid input data | Check request format |
|
|
||||||
| `RATE_LIMITED` | Too many requests | Wait and retry |
|
|
||||||
| `CSRF_INVALID` | Invalid CSRF token | Get new token |
|
|
||||||
| `NOT_FOUND` | Resource not found | Check resource ID |
|
|
||||||
| `CONFLICT` | Resource conflict | Check existing data |
|
|
||||||
|
|
||||||
## Rate Limiting
|
|
||||||
|
|
||||||
### Authentication Endpoints
|
|
||||||
|
|
||||||
- **Login**: 5 attempts per 15 minutes per IP
|
|
||||||
- **Registration**: 3 attempts per hour per IP
|
|
||||||
- **Password Reset**: 5 attempts per 15 minutes per IP
|
|
||||||
|
|
||||||
### Security Endpoints
|
|
||||||
|
|
||||||
- **CSP Reports**: 10 reports per minute per IP
|
|
||||||
- **Admin Endpoints**: 60 requests per minute per user
|
|
||||||
- **Security Monitoring**: 30 requests per minute per user
|
|
||||||
|
|
||||||
### General API
|
|
||||||
|
|
||||||
- **Dashboard Endpoints**: 120 requests per minute per user
|
|
||||||
- **Platform Management**: 60 requests per minute per user
|
|
||||||
|
|
||||||
## Security Headers
|
|
||||||
|
|
||||||
All API responses include security headers:
|
|
||||||
|
|
||||||
```http
|
|
||||||
X-Content-Type-Options: nosniff
|
|
||||||
X-Frame-Options: DENY
|
|
||||||
X-XSS-Protection: 1; mode=block
|
|
||||||
Referrer-Policy: strict-origin-when-cross-origin
|
|
||||||
Content-Security-Policy: [CSP directives]
|
|
||||||
```
|
|
||||||
|
|
||||||
## CORS Configuration
|
|
||||||
|
|
||||||
### Allowed Origins
|
|
||||||
|
|
||||||
- Development: `http://localhost:3000`
|
|
||||||
- Production: `https://your-domain.com`
|
|
||||||
|
|
||||||
### Allowed Methods
|
|
||||||
|
|
||||||
- `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `OPTIONS`
|
|
||||||
|
|
||||||
### Allowed Headers
|
|
||||||
|
|
||||||
- `Content-Type`, `Authorization`, `X-CSRF-Token`, `X-Requested-With`
|
|
||||||
|
|
||||||
## Pagination
|
|
||||||
|
|
||||||
### Standard Pagination Format
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": ["// Array of response data"],
|
|
||||||
"pagination": {
|
|
||||||
"page": 1,
|
|
||||||
"limit": 50,
|
|
||||||
"totalCount": 150,
|
|
||||||
"totalPages": 3,
|
|
||||||
"hasNext": true,
|
|
||||||
"hasPrev": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pagination Parameters
|
|
||||||
|
|
||||||
- `page`: Page number (1-based, default: 1)
|
|
||||||
- `limit`: Records per page (default: 50, max: 100)
|
|
||||||
|
|
||||||
## Filtering and Sorting
|
|
||||||
|
|
||||||
### Common Filter Parameters
|
|
||||||
|
|
||||||
- `startDate` / `endDate`: Date range filtering (ISO 8601)
|
|
||||||
- `status`: Status filtering
|
|
||||||
- `userId` / `companyId`: Entity filtering
|
|
||||||
- `eventType`: Event type filtering
|
|
||||||
- `severity`: Severity level filtering
|
|
||||||
|
|
||||||
### Sorting Parameters
|
|
||||||
|
|
||||||
- `sortBy`: Field to sort by
|
|
||||||
- `sortOrder`: `asc` or `desc` (default: `desc`)
|
|
||||||
|
|
||||||
## Response Caching
|
|
||||||
|
|
||||||
### Cache Headers
|
|
||||||
|
|
||||||
```http
|
|
||||||
Cache-Control: no-cache, no-store, must-revalidate
|
|
||||||
Pragma: no-cache
|
|
||||||
Expires: 0
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cache Strategy
|
|
||||||
|
|
||||||
- **Security data**: Never cached
|
|
||||||
- **Static data**: Browser cache for 5 minutes
|
|
||||||
- **User data**: No cache for security
|
|
||||||
|
|
||||||
## API Versioning
|
|
||||||
|
|
||||||
### Current Version
|
|
||||||
|
|
||||||
- Version: `v1` (implied, no version prefix required)
|
|
||||||
- Introduced: January 2025
|
|
||||||
|
|
||||||
### Future Versioning
|
|
||||||
|
|
||||||
- Breaking changes will introduce new versions
|
|
||||||
- Format: `/api/v2/endpoint`
|
|
||||||
- Backward compatibility maintained for 12 months
|
|
||||||
|
|
||||||
## SDK and Client Libraries
|
|
||||||
|
|
||||||
### JavaScript/TypeScript Client
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Initialize client
|
|
||||||
const client = new LiveDashClient({
|
|
||||||
baseURL: "https://your-domain.com",
|
|
||||||
apiKey: "your-api-key", // For future API key auth
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get audit logs
|
|
||||||
const auditLogs = await client.admin.getAuditLogs({
|
|
||||||
page: 1,
|
|
||||||
limit: 50,
|
|
||||||
eventType: "login_attempt",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get security metrics
|
|
||||||
const metrics = await client.security.getMetrics({
|
|
||||||
timeRange: "24h",
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### tRPC Client
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { createTRPCNext } from "@trpc/next";
|
|
||||||
|
|
||||||
const trpc = createTRPCNext({
|
|
||||||
config() {
|
|
||||||
return {
|
|
||||||
url: "/api/trpc",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use tRPC procedures
|
|
||||||
const { data: user } = trpc.auth.getUser.useQuery();
|
|
||||||
const updateProfile = trpc.user.updateProfile.useMutation();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### API Testing Tools
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Test with curl
|
|
||||||
curl -X GET "http://localhost:3000/api/admin/audit-logs" \
|
|
||||||
-H "Cookie: next-auth.session-token=..." \
|
|
||||||
-H "X-CSRF-Token: ..."
|
|
||||||
|
|
||||||
# Test with HTTPie
|
|
||||||
http GET localhost:3000/api/csp-metrics \
|
|
||||||
timeRange==24h \
|
|
||||||
Cookie:next-auth.session-token=...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Integration Tests
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Example test
|
|
||||||
describe("Admin Audit Logs API", () => {
|
|
||||||
test("should return paginated audit logs", async () => {
|
|
||||||
const response = await request(app)
|
|
||||||
.get("/api/admin/audit-logs?page=1&limit=10")
|
|
||||||
.set("Cookie", "next-auth.session-token=...")
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(response.body.success).toBe(true);
|
|
||||||
expect(response.body.data.auditLogs).toHaveLength(10);
|
|
||||||
expect(response.body.data.pagination.page).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Related Documentation
|
|
||||||
|
|
||||||
- [Admin Audit Logs API](./admin-audit-logs-api.md)
|
|
||||||
- [CSP Metrics API](./csp-metrics-api.md)
|
|
||||||
- [Security Monitoring](./security-monitoring.md)
|
|
||||||
- [CSRF Protection](./CSRF_PROTECTION.md)
|
|
||||||
- [Batch Monitoring Dashboard](./batch-monitoring-dashboard.md)
|
|
||||||
|
|
||||||
This API reference provides comprehensive documentation for all endpoints in the LiveDash-Node application. For specific implementation details, refer to the individual documentation files for each feature area.
|
|
||||||
@ -1,549 +0,0 @@
|
|||||||
# Batch Processing Monitoring Dashboard
|
|
||||||
|
|
||||||
This document describes the batch processing monitoring dashboard and API endpoints for tracking OpenAI Batch API operations in the LiveDash application.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The Batch Monitoring Dashboard provides real-time visibility into the OpenAI Batch API processing pipeline, including job status tracking, cost analysis, and performance monitoring. This system enables 50% cost reduction on AI processing while maintaining comprehensive oversight.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### Real-time Monitoring
|
|
||||||
|
|
||||||
- **Job Status Tracking**: Monitor batch jobs from creation to completion
|
|
||||||
- **Queue Management**: View pending, running, and completed batch queues
|
|
||||||
- **Processing Metrics**: Track throughput, success rates, and error patterns
|
|
||||||
- **Cost Analysis**: Monitor API costs and savings compared to individual requests
|
|
||||||
|
|
||||||
### Performance Analytics
|
|
||||||
|
|
||||||
- **Batch Efficiency**: Analyze batch size optimization and processing times
|
|
||||||
- **Success Rates**: Track completion and failure rates across different job types
|
|
||||||
- **Resource Utilization**: Monitor API quota usage and rate limiting
|
|
||||||
- **Historical Trends**: View processing patterns over time
|
|
||||||
|
|
||||||
### Administrative Controls
|
|
||||||
|
|
||||||
- **Manual Intervention**: Pause, resume, or cancel batch operations
|
|
||||||
- **Priority Management**: Adjust processing priorities for urgent requests
|
|
||||||
- **Error Handling**: Review and retry failed batch operations
|
|
||||||
- **Configuration Management**: Adjust batch parameters and thresholds
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### Batch Monitoring API
|
|
||||||
|
|
||||||
Retrieve comprehensive batch processing metrics and status information.
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/admin/batch-monitoring
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Query Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Description | Default | Example |
|
|
||||||
| ---------------- | ------- | -------------------------------- | ------- | ---------------------- |
|
|
||||||
| `timeRange` | string | Time range for metrics | `24h` | `?timeRange=7d` |
|
|
||||||
| `status` | string | Filter by batch status | - | `?status=completed` |
|
|
||||||
| `jobType` | string | Filter by job type | - | `?jobType=ai_analysis` |
|
|
||||||
| `includeDetails` | boolean | Include detailed job information | `false` | `?includeDetails=true` |
|
|
||||||
| `page` | number | Page number for pagination | 1 | `?page=2` |
|
|
||||||
| `limit` | number | Records per page (max 100) | 50 | `?limit=25` |
|
|
||||||
|
|
||||||
#### Example Request
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const response = await fetch(
|
|
||||||
"/api/admin/batch-monitoring?" +
|
|
||||||
new URLSearchParams({
|
|
||||||
timeRange: "24h",
|
|
||||||
status: "completed",
|
|
||||||
includeDetails: "true",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Response Format
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"summary": {
|
|
||||||
"totalJobs": 156,
|
|
||||||
"completedJobs": 142,
|
|
||||||
"failedJobs": 8,
|
|
||||||
"pendingJobs": 6,
|
|
||||||
"totalRequests": 15600,
|
|
||||||
"processedRequests": 14200,
|
|
||||||
"costSavings": {
|
|
||||||
"currentPeriod": 234.56,
|
|
||||||
"projectedMonthly": 7038.45,
|
|
||||||
"savingsPercentage": 48.2
|
|
||||||
},
|
|
||||||
"averageProcessingTime": 1800000,
|
|
||||||
"successRate": 95.2
|
|
||||||
},
|
|
||||||
"queues": {
|
|
||||||
"pending": 12,
|
|
||||||
"processing": 3,
|
|
||||||
"completed": 142,
|
|
||||||
"failed": 8
|
|
||||||
},
|
|
||||||
"performance": {
|
|
||||||
"throughput": {
|
|
||||||
"requestsPerHour": 650,
|
|
||||||
"jobsPerHour": 6.5,
|
|
||||||
"averageBatchSize": 100
|
|
||||||
},
|
|
||||||
"efficiency": {
|
|
||||||
"batchUtilization": 87.3,
|
|
||||||
"processingEfficiency": 92.1,
|
|
||||||
"errorRate": 4.8
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"jobs": [
|
|
||||||
{
|
|
||||||
"id": "batch-job-123",
|
|
||||||
"batchId": "batch_abc123",
|
|
||||||
"status": "completed",
|
|
||||||
"jobType": "ai_analysis",
|
|
||||||
"requestCount": 100,
|
|
||||||
"completedCount": 98,
|
|
||||||
"failedCount": 2,
|
|
||||||
"createdAt": "2024-01-01T10:00:00Z",
|
|
||||||
"startedAt": "2024-01-01T10:05:00Z",
|
|
||||||
"completedAt": "2024-01-01T10:35:00Z",
|
|
||||||
"processingTimeMs": 1800000,
|
|
||||||
"costEstimate": 12.5,
|
|
||||||
"errorSummary": [
|
|
||||||
{
|
|
||||||
"error": "token_limit_exceeded",
|
|
||||||
"count": 2,
|
|
||||||
"percentage": 2.0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dashboard Components
|
|
||||||
|
|
||||||
### BatchMonitoringDashboard Component
|
|
||||||
|
|
||||||
The main dashboard component (`components/admin/BatchMonitoringDashboard.tsx`) provides:
|
|
||||||
|
|
||||||
#### Key Metrics Cards
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// Real-time overview cards
|
|
||||||
<>
|
|
||||||
<MetricCard
|
|
||||||
title="Total Jobs"
|
|
||||||
value={data.summary.totalJobs}
|
|
||||||
change={"+12 from yesterday"}
|
|
||||||
trend="up"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MetricCard
|
|
||||||
title="Success Rate"
|
|
||||||
value={`${data.summary.successRate}%`}
|
|
||||||
change={"+2.1% from last week"}
|
|
||||||
trend="up"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MetricCard
|
|
||||||
title="Cost Savings"
|
|
||||||
value={`$${data.summary.costSavings.currentPeriod}`}
|
|
||||||
change={`${data.summary.costSavings.savingsPercentage}% vs individual API`}
|
|
||||||
trend="up"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Queue Status Visualization
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// Visual representation of batch job queues
|
|
||||||
<QueueStatusChart
|
|
||||||
pending={data.queues.pending}
|
|
||||||
processing={data.queues.processing}
|
|
||||||
completed={data.queues.completed}
|
|
||||||
failed={data.queues.failed}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Performance Charts
|
|
||||||
|
|
||||||
<!-- prettier-ignore -->
|
|
||||||
```tsx
|
|
||||||
// Processing throughput over time
|
|
||||||
<ThroughputChart
|
|
||||||
data={data.performance.throughput}
|
|
||||||
timeRange={timeRange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
// Cost savings trend
|
|
||||||
<CostSavingsChart
|
|
||||||
savings={data.summary.costSavings}
|
|
||||||
historical={data.historical}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Job Management Table
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// Detailed job listing with actions
|
|
||||||
<BatchJobTable
|
|
||||||
jobs={data.jobs}
|
|
||||||
onRetry={handleRetryJob}
|
|
||||||
onCancel={handleCancelJob}
|
|
||||||
onViewDetails={handleViewDetails}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
|
|
||||||
### Monitor Batch Performance
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
async function monitorBatchPerformance() {
|
|
||||||
const response = await fetch("/api/admin/batch-monitoring?timeRange=24h");
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
const performance = data.data.performance;
|
|
||||||
|
|
||||||
// Check if performance is within acceptable ranges
|
|
||||||
if (performance.efficiency.errorRate > 10) {
|
|
||||||
console.warn("High error rate detected:", performance.efficiency.errorRate + "%");
|
|
||||||
|
|
||||||
// Get failed jobs for analysis
|
|
||||||
const failedJobs = await fetch("/api/admin/batch-monitoring?status=failed");
|
|
||||||
const failures = await failedJobs.json();
|
|
||||||
|
|
||||||
// Analyze common failure patterns
|
|
||||||
const errorSummary = failures.data.jobs.reduce((acc, job) => {
|
|
||||||
job.errorSummary?.forEach((error) => {
|
|
||||||
acc[error.error] = (acc[error.error] || 0) + error.count;
|
|
||||||
});
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
console.log("Error patterns:", errorSummary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cost Savings Analysis
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
async function analyzeCostSavings() {
|
|
||||||
const response = await fetch("/api/admin/batch-monitoring?timeRange=30d&includeDetails=true");
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
const savings = data.data.summary.costSavings;
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentSavings: savings.currentPeriod,
|
|
||||||
projectedAnnual: savings.projectedMonthly * 12,
|
|
||||||
savingsRate: savings.savingsPercentage,
|
|
||||||
totalProcessed: data.data.summary.processedRequests,
|
|
||||||
averageCostPerRequest: savings.currentPeriod / data.data.summary.processedRequests,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Retry Failed Jobs
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
async function retryFailedJobs() {
|
|
||||||
// Get failed jobs
|
|
||||||
const response = await fetch("/api/admin/batch-monitoring?status=failed");
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
const retryableJobs = data.data.jobs.filter((job) => {
|
|
||||||
// Only retry jobs that failed due to temporary issues
|
|
||||||
const hasRetryableErrors = job.errorSummary?.some((error) =>
|
|
||||||
["rate_limit_exceeded", "temporary_error", "timeout"].includes(error.error)
|
|
||||||
);
|
|
||||||
return hasRetryableErrors;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Retry jobs individually
|
|
||||||
for (const job of retryableJobs) {
|
|
||||||
try {
|
|
||||||
await fetch(`/api/admin/batch-monitoring/${job.id}/retry`, {
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
console.log(`Retried job ${job.id}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to retry job ${job.id}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Real-time Dashboard Updates
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
function useRealtimeBatchMonitoring() {
|
|
||||||
const [data, setData] = useState(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/batch-monitoring?timeRange=1h");
|
|
||||||
const result = await response.json();
|
|
||||||
setData(result.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch batch monitoring data:", error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initial fetch
|
|
||||||
fetchData();
|
|
||||||
|
|
||||||
// Update every 30 seconds
|
|
||||||
const interval = setInterval(fetchData, 30000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { data, isLoading };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Batch Processing Settings
|
|
||||||
|
|
||||||
Configure batch processing parameters in environment variables:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Batch Processing Configuration
|
|
||||||
BATCH_PROCESSING_ENABLED="true"
|
|
||||||
BATCH_CREATE_INTERVAL="*/5 * * * *" # Create batches every 5 minutes
|
|
||||||
BATCH_STATUS_CHECK_INTERVAL="*/2 * * * *" # Check status every 2 minutes
|
|
||||||
BATCH_RESULT_PROCESSING_INTERVAL="*/1 * * * *" # Process results every minute
|
|
||||||
|
|
||||||
# Batch Size and Limits
|
|
||||||
BATCH_MAX_REQUESTS="1000" # Maximum requests per batch
|
|
||||||
BATCH_TIMEOUT_HOURS="24" # Batch timeout in hours
|
|
||||||
BATCH_MIN_SIZE="10" # Minimum batch size
|
|
||||||
|
|
||||||
# Monitoring Configuration
|
|
||||||
BATCH_MONITORING_RETENTION_DAYS="30" # How long to keep monitoring data
|
|
||||||
BATCH_ALERT_THRESHOLD_ERROR_RATE="10" # Alert if error rate exceeds 10%
|
|
||||||
BATCH_ALERT_THRESHOLD_PROCESSING_TIME="3600" # Alert if processing takes >1 hour
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dashboard Refresh Settings
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Configure dashboard update intervals
|
|
||||||
const DASHBOARD_CONFIG = {
|
|
||||||
refreshInterval: 30000, // 30 seconds
|
|
||||||
alertRefreshInterval: 10000, // 10 seconds for alerts
|
|
||||||
detailRefreshInterval: 60000, // 1 minute for detailed views
|
|
||||||
maxRetries: 3, // Maximum retry attempts
|
|
||||||
retryDelay: 5000, // Delay between retries
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Alerts and Notifications
|
|
||||||
|
|
||||||
### Automated Alerts
|
|
||||||
|
|
||||||
The system automatically generates alerts for:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const alertConditions = {
|
|
||||||
highErrorRate: {
|
|
||||||
threshold: 10, // Error rate > 10%
|
|
||||||
severity: "high",
|
|
||||||
notification: "immediate",
|
|
||||||
},
|
|
||||||
longProcessingTime: {
|
|
||||||
threshold: 3600000, // > 1 hour
|
|
||||||
severity: "medium",
|
|
||||||
notification: "hourly",
|
|
||||||
},
|
|
||||||
lowThroughput: {
|
|
||||||
threshold: 0.5, // < 0.5 jobs per hour
|
|
||||||
severity: "medium",
|
|
||||||
notification: "daily",
|
|
||||||
},
|
|
||||||
batchFailure: {
|
|
||||||
threshold: 1, // Any complete batch failure
|
|
||||||
severity: "critical",
|
|
||||||
notification: "immediate",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom Alert Configuration
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Configure custom alerts through the admin interface
|
|
||||||
async function configureAlerts(alertConfig) {
|
|
||||||
const response = await fetch("/api/admin/batch-monitoring/alerts", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
errorRateThreshold: alertConfig.errorRate,
|
|
||||||
processingTimeThreshold: alertConfig.processingTime,
|
|
||||||
notificationChannels: alertConfig.channels,
|
|
||||||
alertSuppression: alertConfig.suppression,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
#### High Error Rates
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Investigate high error rates
|
|
||||||
async function investigateErrors() {
|
|
||||||
const response = await fetch("/api/admin/batch-monitoring?status=failed&includeDetails=true");
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Group errors by type
|
|
||||||
const errorAnalysis = data.data.jobs.reduce((acc, job) => {
|
|
||||||
job.errorSummary?.forEach((error) => {
|
|
||||||
if (!acc[error.error]) {
|
|
||||||
acc[error.error] = { count: 0, jobs: [] };
|
|
||||||
}
|
|
||||||
acc[error.error].count += error.count;
|
|
||||||
acc[error.error].jobs.push(job.id);
|
|
||||||
});
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
console.log("Error analysis:", errorAnalysis);
|
|
||||||
return errorAnalysis;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Slow Processing
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Analyze processing bottlenecks
|
|
||||||
async function analyzePerformance() {
|
|
||||||
const response = await fetch("/api/admin/batch-monitoring?timeRange=24h&includeDetails=true");
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
const slowJobs = data.data.jobs
|
|
||||||
.filter((job) => job.processingTimeMs > 3600000) // > 1 hour
|
|
||||||
.sort((a, b) => b.processingTimeMs - a.processingTimeMs);
|
|
||||||
|
|
||||||
console.log("Slowest jobs:", slowJobs.slice(0, 5));
|
|
||||||
|
|
||||||
// Analyze patterns
|
|
||||||
const avgByType = slowJobs.reduce((acc, job) => {
|
|
||||||
if (!acc[job.jobType]) {
|
|
||||||
acc[job.jobType] = { total: 0, count: 0 };
|
|
||||||
}
|
|
||||||
acc[job.jobType].total += job.processingTimeMs;
|
|
||||||
acc[job.jobType].count++;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
Object.keys(avgByType).forEach((type) => {
|
|
||||||
avgByType[type].average = avgByType[type].total / avgByType[type].count;
|
|
||||||
});
|
|
||||||
|
|
||||||
return avgByType;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Performance Optimization
|
|
||||||
|
|
||||||
#### Batch Size Optimization
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Analyze optimal batch sizes
|
|
||||||
async function optimizeBatchSizes() {
|
|
||||||
const response = await fetch("/api/admin/batch-monitoring?timeRange=7d&includeDetails=true");
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Group by batch size ranges
|
|
||||||
const sizePerformance = data.data.jobs.reduce((acc, job) => {
|
|
||||||
const sizeRange = Math.floor(job.requestCount / 50) * 50; // Group by 50s
|
|
||||||
if (!acc[sizeRange]) {
|
|
||||||
acc[sizeRange] = {
|
|
||||||
jobs: 0,
|
|
||||||
totalTime: 0,
|
|
||||||
totalRequests: 0,
|
|
||||||
successRate: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
acc[sizeRange].jobs++;
|
|
||||||
acc[sizeRange].totalTime += job.processingTimeMs;
|
|
||||||
acc[sizeRange].totalRequests += job.requestCount;
|
|
||||||
acc[sizeRange].successRate += job.completedCount / job.requestCount;
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
// Calculate averages
|
|
||||||
Object.keys(sizePerformance).forEach((range) => {
|
|
||||||
const perf = sizePerformance[range];
|
|
||||||
perf.avgTimePerRequest = perf.totalTime / perf.totalRequests;
|
|
||||||
perf.avgSuccessRate = perf.successRate / perf.jobs;
|
|
||||||
});
|
|
||||||
|
|
||||||
return sizePerformance;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integration with Existing Systems
|
|
||||||
|
|
||||||
### Security Audit Integration
|
|
||||||
|
|
||||||
All batch monitoring activities are logged through the security audit system:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Automatic audit logging for monitoring activities
|
|
||||||
await securityAuditLogger.logPlatformAdmin(
|
|
||||||
"batch_monitoring_access",
|
|
||||||
AuditOutcome.SUCCESS,
|
|
||||||
context,
|
|
||||||
"Admin accessed batch monitoring dashboard"
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rate Limiting Integration
|
|
||||||
|
|
||||||
Monitoring API endpoints use the existing rate limiting system:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Protected by admin rate limiting
|
|
||||||
const rateLimitResult = await rateLimiter.check(
|
|
||||||
`admin-batch-monitoring:${userId}`,
|
|
||||||
60, // 60 requests
|
|
||||||
60 * 1000 // per minute
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Related Documentation
|
|
||||||
|
|
||||||
- [Batch Processing Optimizations](./batch-processing-optimizations.md)
|
|
||||||
- [Security Monitoring](./security-monitoring.md)
|
|
||||||
- [Admin Audit Logs API](./admin-audit-logs-api.md)
|
|
||||||
- [OpenAI Batch API Integration](../lib/batchProcessor.ts)
|
|
||||||
|
|
||||||
The batch monitoring dashboard provides comprehensive visibility into the AI processing pipeline, enabling administrators to optimize performance, monitor costs, and ensure reliable operation of the batch processing system.
|
|
||||||
@ -1,223 +0,0 @@
|
|||||||
# Batch Processing Database Query Optimizations
|
|
||||||
|
|
||||||
This document outlines the database query optimizations implemented to improve the performance of the OpenAI Batch API processing pipeline.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The batch processing system was optimized to reduce database load and improve response times through several key strategies:
|
|
||||||
|
|
||||||
1. **Database Index Optimization**
|
|
||||||
2. **Query Pattern Improvements**
|
|
||||||
3. **Company Caching**
|
|
||||||
4. **Batch Operations**
|
|
||||||
5. **Integration Layer with Fallback**
|
|
||||||
|
|
||||||
## Database Index Improvements
|
|
||||||
|
|
||||||
### New Indexes Added
|
|
||||||
|
|
||||||
The following composite indexes were added to the `AIProcessingRequest` table in the Prisma schema:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Optimize time-based status queries
|
|
||||||
@@index([processingStatus, requestedAt])
|
|
||||||
|
|
||||||
-- Optimize batch-related queries
|
|
||||||
@@index([batchId])
|
|
||||||
|
|
||||||
-- Composite index for batch status filtering
|
|
||||||
@@index([processingStatus, batchId])
|
|
||||||
```
|
|
||||||
|
|
||||||
### Query Performance Impact
|
|
||||||
|
|
||||||
These indexes specifically optimize:
|
|
||||||
|
|
||||||
- Finding pending requests by status and creation time
|
|
||||||
- Batch-related lookups by batch ID
|
|
||||||
- Combined status and batch filtering operations
|
|
||||||
|
|
||||||
## Query Optimization Strategies
|
|
||||||
|
|
||||||
### 1. Selective Data Fetching
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Loaded full session with all messages
|
|
||||||
const queryOptions = {
|
|
||||||
include: {
|
|
||||||
session: {
|
|
||||||
include: {
|
|
||||||
messages: {
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
|
|
||||||
<!-- prettier-ignore -->
|
|
||||||
```typescript
|
|
||||||
// Only essential data with message count
|
|
||||||
include: {
|
|
||||||
session: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
companyId: true,
|
|
||||||
_count: { select: { messages: true } }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Company Caching
|
|
||||||
|
|
||||||
Implemented a 5-minute TTL cache for active companies to eliminate redundant database lookups:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
class CompanyCache {
|
|
||||||
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
||||||
|
|
||||||
async getActiveCompanies(): Promise<CachedCompany[]> {
|
|
||||||
// Returns cached data if available and fresh
|
|
||||||
// Otherwise refreshes from database
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Batch Operations
|
|
||||||
|
|
||||||
**Before:** N+1 queries for each company
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Sequential processing per company
|
|
||||||
for (const company of companies) {
|
|
||||||
const requests = await getPendingRequests(company.id);
|
|
||||||
// Process each company separately
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:** Single query for all companies
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Batch query for all companies at once
|
|
||||||
const allRequests = await prisma.aIProcessingRequest.findMany({
|
|
||||||
where: {
|
|
||||||
session: {
|
|
||||||
companyId: { in: companies.map((c) => c.id) },
|
|
||||||
},
|
|
||||||
processingStatus: AIRequestStatus.PENDING_BATCHING,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Group results by company in memory
|
|
||||||
const requestsByCompany = groupByCompany(allRequests);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Improvements
|
|
||||||
|
|
||||||
### Query Count Reduction
|
|
||||||
|
|
||||||
- **Company lookups:** Reduced from 4 separate queries per scheduler run to 1 cached lookup
|
|
||||||
- **Pending requests:** Reduced from N queries (one per company) to 1 batch query
|
|
||||||
- **Status checks:** Reduced from N queries to 1 batch query
|
|
||||||
- **Failed requests:** Reduced from N queries to 1 batch query
|
|
||||||
|
|
||||||
### Parallel Processing
|
|
||||||
|
|
||||||
Added configurable parallel processing with batching:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const SCHEDULER_CONFIG = {
|
|
||||||
MAX_CONCURRENT_COMPANIES: 5,
|
|
||||||
USE_BATCH_OPERATIONS: true,
|
|
||||||
PARALLEL_COMPANY_PROCESSING: true,
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Memory Optimization
|
|
||||||
|
|
||||||
- Eliminated loading unnecessary message content
|
|
||||||
- Used `select` instead of `include` where possible
|
|
||||||
- Implemented automatic cache cleanup
|
|
||||||
|
|
||||||
## Integration Layer
|
|
||||||
|
|
||||||
Created a unified interface that can switch between original and optimized implementations:
|
|
||||||
|
|
||||||
### Environment Configuration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Enable optimizations (default: true)
|
|
||||||
ENABLE_BATCH_OPTIMIZATION=true
|
|
||||||
ENABLE_BATCH_OPERATIONS=true
|
|
||||||
ENABLE_PARALLEL_PROCESSING=true
|
|
||||||
|
|
||||||
# Fallback behavior
|
|
||||||
FALLBACK_ON_ERRORS=true
|
|
||||||
```
|
|
||||||
|
|
||||||
### Performance Tracking
|
|
||||||
|
|
||||||
The integration layer automatically tracks performance metrics and can fall back to the original implementation if optimizations fail:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
class PerformanceTracker {
|
|
||||||
shouldUseOptimized(): boolean {
|
|
||||||
// Uses optimized if faster and success rate > 90%
|
|
||||||
return optimizedAvg < originalAvg && optimizedSuccess > 0.9;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
### New Files
|
|
||||||
|
|
||||||
- `lib/batchProcessorOptimized.ts` - Optimized query implementations
|
|
||||||
- `lib/batchSchedulerOptimized.ts` - Optimized scheduler
|
|
||||||
- `lib/batchProcessorIntegration.ts` - Integration layer with fallback
|
|
||||||
|
|
||||||
### Modified Files
|
|
||||||
|
|
||||||
- `prisma/schema.prisma` - Added composite indexes
|
|
||||||
- `server.ts` - Updated to use integration layer
|
|
||||||
- `app/api/admin/batch-monitoring/route.ts` - Updated import
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
|
|
||||||
The optimizations include comprehensive logging and monitoring:
|
|
||||||
|
|
||||||
- Performance metrics for each operation type
|
|
||||||
- Cache hit/miss statistics
|
|
||||||
- Fallback events tracking
|
|
||||||
- Query execution time monitoring
|
|
||||||
|
|
||||||
## Rollback Strategy
|
|
||||||
|
|
||||||
The integration layer allows for easy rollback:
|
|
||||||
|
|
||||||
1. Set `ENABLE_BATCH_OPTIMIZATION=false`
|
|
||||||
2. System automatically uses original implementation
|
|
||||||
3. No database schema changes needed for rollback
|
|
||||||
4. Indexes remain beneficial for manual queries
|
|
||||||
|
|
||||||
## Expected Performance Gains
|
|
||||||
|
|
||||||
- **Database Query Count:** 60-80% reduction in scheduler operations
|
|
||||||
- **Memory Usage:** 40-60% reduction from selective data loading
|
|
||||||
- **Response Time:** 30-50% improvement for batch operations
|
|
||||||
- **Cache Hit Rate:** 95%+ for company lookups after warmup
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Performance improvements can be validated by:
|
|
||||||
|
|
||||||
1. Monitoring the batch monitoring dashboard
|
|
||||||
2. Checking performance metrics in logs
|
|
||||||
3. Comparing execution times before/after optimization
|
|
||||||
4. Load testing with multiple companies and large batches
|
|
||||||
@ -1,515 +0,0 @@
|
|||||||
# CSP Metrics and Monitoring API
|
|
||||||
|
|
||||||
This document describes the Content Security Policy (CSP) metrics and violation reporting APIs that provide real-time monitoring and analysis of CSP violations.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The CSP Metrics API provides comprehensive monitoring of Content Security Policy violations, including:
|
|
||||||
|
|
||||||
- Real-time violation tracking and metrics
|
|
||||||
- Bypass attempt detection and risk assessment
|
|
||||||
- Policy optimization recommendations
|
|
||||||
- Historical trend analysis
|
|
||||||
- Export capabilities for security analysis
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### CSP Violation Reporting
|
|
||||||
|
|
||||||
Endpoint for browsers to report CSP violations (automatic).
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/csp-report
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Request Headers
|
|
||||||
|
|
||||||
- `Content-Type`: `application/csp-report` or `application/json`
|
|
||||||
|
|
||||||
#### Request Body (Automatic from Browser)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"csp-report": {
|
|
||||||
"document-uri": "https://example.com/page",
|
|
||||||
"violated-directive": "script-src 'self'",
|
|
||||||
"blocked-uri": "https://malicious.com/script.js",
|
|
||||||
"source-file": "https://example.com/page",
|
|
||||||
"line-number": 42,
|
|
||||||
"script-sample": "eval(maliciousCode)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Features
|
|
||||||
|
|
||||||
- **Rate Limiting**: 10 reports per minute per IP
|
|
||||||
- **Risk Assessment**: Automatic classification of violation severity
|
|
||||||
- **Bypass Detection**: Identifies potential CSP bypass attempts
|
|
||||||
- **Real-time Processing**: Immediate analysis and alerting
|
|
||||||
|
|
||||||
### CSP Metrics API
|
|
||||||
|
|
||||||
Retrieve CSP violation metrics and analytics.
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/csp-metrics
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Query Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Description | Default | Example |
|
|
||||||
| ---------------- | ------- | ------------------------------------------------------------------------------- | ------- | ---------------------- |
|
|
||||||
| `timeRange` | string | Time range for metrics | `24h` | `?timeRange=7d` |
|
|
||||||
| `format` | string | Response format | `json` | `?format=csv` |
|
|
||||||
| `groupBy` | string | Group results by field | `hour` | `?groupBy=directive` |
|
|
||||||
| `includeDetails` | boolean | Include violation details | `false` | `?includeDetails=true` |
|
|
||||||
| `offset` | string | Shift the queried time-window backwards by the given duration (for comparisons) | `0` | `?offset=24h` |
|
|
||||||
|
|
||||||
#### Time Range Options
|
|
||||||
|
|
||||||
- `1h` - Last 1 hour
|
|
||||||
- `6h` - Last 6 hours
|
|
||||||
- `24h` - Last 24 hours (default)
|
|
||||||
- `7d` - Last 7 days
|
|
||||||
- `30d` - Last 30 days
|
|
||||||
|
|
||||||
#### Example Request
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const response = await fetch(
|
|
||||||
"/api/csp-metrics?" +
|
|
||||||
new URLSearchParams({
|
|
||||||
timeRange: "24h",
|
|
||||||
groupBy: "directive",
|
|
||||||
includeDetails: "true",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const metrics = await response.json();
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Response Format
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"summary": {
|
|
||||||
"totalViolations": 45,
|
|
||||||
"uniqueViolations": 12,
|
|
||||||
"highRiskViolations": 3,
|
|
||||||
"bypassAttempts": 1,
|
|
||||||
"timeRange": "24h",
|
|
||||||
"generatedAt": "2024-01-01T12:00:00Z"
|
|
||||||
},
|
|
||||||
"trends": {
|
|
||||||
"hourlyCount": [
|
|
||||||
{ "hour": "2024-01-01T11:00:00Z", "count": 5 },
|
|
||||||
{ "hour": "2024-01-01T12:00:00Z", "count": 8 }
|
|
||||||
],
|
|
||||||
"trendDirection": "increasing",
|
|
||||||
"changePercent": 25.5
|
|
||||||
},
|
|
||||||
"topViolations": [
|
|
||||||
{
|
|
||||||
"directive": "script-src",
|
|
||||||
"count": 15,
|
|
||||||
"percentage": 33.3,
|
|
||||||
"riskLevel": "medium",
|
|
||||||
"topBlockedUris": ["https://malicious.com/script.js", "inline"]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"riskAnalysis": {
|
|
||||||
"overallRiskScore": 65,
|
|
||||||
"riskLevel": "medium",
|
|
||||||
"criticalIssues": 1,
|
|
||||||
"recommendations": [
|
|
||||||
"Review script-src policy for external domains",
|
|
||||||
"Consider implementing nonce-based CSP"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"violations": [
|
|
||||||
{
|
|
||||||
"timestamp": "2024-01-01T12:00:00Z",
|
|
||||||
"directive": "script-src",
|
|
||||||
"blockedUri": "https://malicious.com/script.js",
|
|
||||||
"sourceFile": "https://example.com/page",
|
|
||||||
"riskLevel": "high",
|
|
||||||
"bypassAttempt": true,
|
|
||||||
"ipAddress": "192.168.1.***",
|
|
||||||
"userAgent": "Mozilla/5.0 (masked)"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## CSP Monitoring Service
|
|
||||||
|
|
||||||
The monitoring service (`lib/csp-monitoring.ts`) provides advanced violation analysis.
|
|
||||||
|
|
||||||
### Key Features
|
|
||||||
|
|
||||||
#### 1. Real-time Violation Processing
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Automatic processing when violations are reported
|
|
||||||
const result = await cspMonitoring.processViolation(violationReport, clientIP, userAgent);
|
|
||||||
|
|
||||||
console.log(result.alertLevel); // low, medium, high, critical
|
|
||||||
console.log(result.shouldAlert); // boolean
|
|
||||||
console.log(result.recommendations); // array of suggestions
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Risk Assessment
|
|
||||||
|
|
||||||
The service automatically assesses violation risk based on:
|
|
||||||
|
|
||||||
- **Directive Type**: Script violations are higher risk than style violations
|
|
||||||
- **Source Pattern**: External domains vs inline vs data URIs
|
|
||||||
- **Bypass Indicators**: Known CSP bypass techniques
|
|
||||||
- **Frequency**: Repeated violations from same source
|
|
||||||
- **Geographic Factors**: Unusual source locations
|
|
||||||
|
|
||||||
#### 3. Bypass Detection
|
|
||||||
|
|
||||||
Automatic detection of common CSP bypass attempts:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const bypassPatterns = [
|
|
||||||
/javascript:/i, // javascript: protocol injection
|
|
||||||
/data:text\/html/i, // HTML data URI injection
|
|
||||||
/eval\(/i, // Direct eval() calls
|
|
||||||
/Function\(/i, // Function constructor
|
|
||||||
/setTimeout.*string/i, // Timer string execution
|
|
||||||
/location\s*=/i, // Location manipulation
|
|
||||||
/document\.write/i, // Document.write injection
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. Policy Recommendations
|
|
||||||
|
|
||||||
Based on violation patterns, the service provides actionable recommendations:
|
|
||||||
|
|
||||||
- **Tighten Policies**: Suggest removing broad allowlists
|
|
||||||
- **Add Domains**: Recommend allowing legitimate external resources
|
|
||||||
- **Implement Nonces**: Suggest nonce-based policies for inline content
|
|
||||||
- **Upgrade Directives**: Recommend modern CSP features
|
|
||||||
|
|
||||||
## Violation Analysis
|
|
||||||
|
|
||||||
### Risk Levels
|
|
||||||
|
|
||||||
| Risk Level | Score | Description | Action |
|
|
||||||
| ------------ | ------ | --------------------------------------------- | ----------------------- |
|
|
||||||
| **Critical** | 90-100 | Active bypass attempts, known attack patterns | Immediate investigation |
|
|
||||||
| **High** | 70-89 | Suspicious patterns, potential security risks | Urgent review |
|
|
||||||
| **Medium** | 40-69 | Policy violations, may need attention | Regular monitoring |
|
|
||||||
| **Low** | 0-39 | Minor violations, likely legitimate | Log for trends |
|
|
||||||
|
|
||||||
### Alert Conditions
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// High-risk violations trigger immediate alerts
|
|
||||||
const alertConditions = {
|
|
||||||
critical: {
|
|
||||||
bypassAttempt: true,
|
|
||||||
unknownExternalDomain: true,
|
|
||||||
suspiciousUserAgent: true,
|
|
||||||
},
|
|
||||||
high: {
|
|
||||||
repeatedViolations: ">5 in 10 minutes",
|
|
||||||
scriptInjectionAttempt: true,
|
|
||||||
dataUriWithScript: true,
|
|
||||||
},
|
|
||||||
medium: {
|
|
||||||
newExternalDomain: true,
|
|
||||||
inlineScriptViolation: true,
|
|
||||||
unknownSource: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
|
|
||||||
### Real-time Violation Monitoring
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Monitor violations in real-time
|
|
||||||
async function monitorViolations() {
|
|
||||||
const metrics = await fetch("/api/csp-metrics?timeRange=1h");
|
|
||||||
const data = await metrics.json();
|
|
||||||
|
|
||||||
if (data.data.summary.highRiskViolations > 0) {
|
|
||||||
console.warn("High-risk CSP violations detected:", data.data.summary.highRiskViolations);
|
|
||||||
|
|
||||||
// Get violation details
|
|
||||||
const details = await fetch("/api/csp-metrics?includeDetails=true");
|
|
||||||
const violations = await details.json();
|
|
||||||
|
|
||||||
violations.data.violations
|
|
||||||
.filter((v) => v.riskLevel === "high")
|
|
||||||
.forEach((violation) => {
|
|
||||||
console.error("High-risk violation:", {
|
|
||||||
directive: violation.directive,
|
|
||||||
blockedUri: violation.blockedUri,
|
|
||||||
timestamp: violation.timestamp,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run every 5 minutes
|
|
||||||
setInterval(monitorViolations, 5 * 60 * 1000);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Security Dashboard Integration
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Get CSP metrics for security dashboard
|
|
||||||
async function getCSPDashboardData() {
|
|
||||||
const [current, previous] = await Promise.all([
|
|
||||||
fetch("/api/csp-metrics?timeRange=24h").then((r) => r.json()),
|
|
||||||
fetch("/api/csp-metrics?timeRange=24h&offset=24h").then((r) => r.json()),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentViolations: current.data.summary.totalViolations,
|
|
||||||
previousViolations: previous.data.summary.totalViolations,
|
|
||||||
trend: current.data.trends.trendDirection,
|
|
||||||
riskScore: current.data.riskAnalysis.overallRiskScore,
|
|
||||||
recommendations: current.data.riskAnalysis.recommendations.slice(0, 3),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Export Violation Data
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Export violations for external analysis
|
|
||||||
async function exportViolations(format = "csv", timeRange = "7d") {
|
|
||||||
const response = await fetch(`/api/csp-metrics?format=${format}&timeRange=${timeRange}`);
|
|
||||||
|
|
||||||
if (format === "csv") {
|
|
||||||
const csvData = await response.text();
|
|
||||||
downloadFile(csvData, `csp-violations-${timeRange}.csv`, "text/csv");
|
|
||||||
} else {
|
|
||||||
const jsonData = await response.json();
|
|
||||||
downloadFile(
|
|
||||||
JSON.stringify(jsonData, null, 2),
|
|
||||||
`csp-violations-${timeRange}.json`,
|
|
||||||
"application/json"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadFile(content, filename, contentType) {
|
|
||||||
const blob = new Blob([content], { type: contentType });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Policy Optimization
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Analyze violations to optimize CSP policy
|
|
||||||
async function optimizeCSPPolicy() {
|
|
||||||
const metrics = await fetch("/api/csp-metrics?timeRange=30d&includeDetails=true");
|
|
||||||
const data = await metrics.json();
|
|
||||||
|
|
||||||
// Group violations by directive
|
|
||||||
const violationsByDirective = data.data.violations.reduce((acc, violation) => {
|
|
||||||
if (!acc[violation.directive]) {
|
|
||||||
acc[violation.directive] = [];
|
|
||||||
}
|
|
||||||
acc[violation.directive].push(violation);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
// Generate recommendations
|
|
||||||
const recommendations = Object.entries(violationsByDirective).map(([directive, violations]) => {
|
|
||||||
const uniqueDomains = [...new Set(violations.map((v) => v.blockedUri))];
|
|
||||||
const legitimateCount = violations.filter((v) => v.riskLevel === "low").length;
|
|
||||||
|
|
||||||
if (legitimateCount > violations.length * 0.8) {
|
|
||||||
return {
|
|
||||||
directive,
|
|
||||||
action: "allow",
|
|
||||||
domains: uniqueDomains.slice(0, 5),
|
|
||||||
confidence: "high",
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
directive,
|
|
||||||
action: "investigate",
|
|
||||||
riskDomains: uniqueDomains.filter(
|
|
||||||
(_, i) => violations.find((v) => v.blockedUri === uniqueDomains[i])?.riskLevel === "high"
|
|
||||||
),
|
|
||||||
confidence: "medium",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return recommendations;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration and Setup
|
|
||||||
|
|
||||||
### CSP Header Configuration
|
|
||||||
|
|
||||||
The CSP metrics system requires proper CSP headers with reporting:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// In next.config.js or middleware
|
|
||||||
const cspDirectives = {
|
|
||||||
"default-src": "'self'",
|
|
||||||
"script-src": "'self' 'nonce-{NONCE}'",
|
|
||||||
"report-uri": "/api/csp-report",
|
|
||||||
"report-to": "csp-endpoint",
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Report-To Header
|
|
||||||
|
|
||||||
For modern browsers, configure the Report-To header:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const reportToHeader = JSON.stringify({
|
|
||||||
group: "csp-endpoint",
|
|
||||||
max_age: 86400,
|
|
||||||
endpoints: [{ url: "/api/csp-report" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add to response headers
|
|
||||||
headers["Report-To"] = reportToHeader;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Configuration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Enable CSP monitoring in production
|
|
||||||
NODE_ENV=production
|
|
||||||
|
|
||||||
# Optional: Configure monitoring sensitivity
|
|
||||||
CSP_MONITORING_SENSITIVITY=medium # low, medium, high
|
|
||||||
CSP_ALERT_THRESHOLD=5 # violations per 10 minutes
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
### Rate Limiting
|
|
||||||
|
|
||||||
- **10 reports per minute per IP** prevents spam attacks
|
|
||||||
- **Exponential backoff** for repeated violations from same source
|
|
||||||
- **Memory cleanup** removes old violations automatically
|
|
||||||
|
|
||||||
### Memory Management
|
|
||||||
|
|
||||||
- **Violation buffer** limited to 7 days of data in memory
|
|
||||||
- **Hard cap** of 10,000 violation entries to prevent memory exhaustion
|
|
||||||
- **Automatic cleanup** runs every 100 requests (1% probability)
|
|
||||||
- **Efficient storage** using Map data structures
|
|
||||||
|
|
||||||
### Database Impact
|
|
||||||
|
|
||||||
- **No persistent storage** for real-time metrics (memory only)
|
|
||||||
- **Optional logging** to database for long-term analysis
|
|
||||||
- **Indexed queries** for historical data retrieval
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
### Privacy Protection
|
|
||||||
|
|
||||||
**⚠️ Data Collection Notice:**
|
|
||||||
|
|
||||||
- **IP addresses** are collected and stored in memory for security monitoring
|
|
||||||
- **User agent strings** are stored for browser compatibility analysis
|
|
||||||
- **Legal basis**: Processing is necessary for legitimate interests (GDPR Article 6(1)(f)) - specifically for security incident detection, prevention of CSP bypass attacks, and protection of website integrity
|
|
||||||
- **Retention**: In-memory storage only, automatically purged after 7 days or application restart
|
|
||||||
- **Data minimization**: Only violation-related metadata is retained, not page content
|
|
||||||
- **Balancing test**: The processing is limited to security purposes, uses temporary storage, and employs data minimization principles to ensure user privacy rights are respected
|
|
||||||
|
|
||||||
**Planned Privacy Enhancements:**
|
|
||||||
|
|
||||||
- IP anonymization options for GDPR compliance (roadmap)
|
|
||||||
- User agent sanitization to remove sensitive information (roadmap)
|
|
||||||
|
|
||||||
### Rate-Limiting Protection
|
|
||||||
|
|
||||||
- **Per-IP limits** prevent DoS attacks on reporting endpoint
|
|
||||||
- **Content-type validation** ensures proper report format
|
|
||||||
- **Request size limits** prevent memory exhaustion
|
|
||||||
|
|
||||||
### False Positive Handling
|
|
||||||
|
|
||||||
- **Learning mode** for new deployments
|
|
||||||
- **Whitelist support** for known legitimate violations
|
|
||||||
- **Risk score adjustment** based on historical patterns
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
#### High False Positive Rate
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Check for legitimate violations being flagged
|
|
||||||
const metrics = await fetch("/api/csp-metrics?includeDetails=true");
|
|
||||||
const data = await metrics.json();
|
|
||||||
|
|
||||||
const falsePositives = data.data.violations.filter(
|
|
||||||
(v) => v.riskLevel === "high" && v.blockedUri.includes("legitimate-domain.com")
|
|
||||||
);
|
|
||||||
|
|
||||||
if (falsePositives.length > 0) {
|
|
||||||
console.log("Consider whitelisting:", falsePositives[0].blockedUri);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Missing Violation Reports
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Check if CSP headers are properly configured
|
|
||||||
fetch("/").then((response) => {
|
|
||||||
const csp = response.headers.get("Content-Security-Policy");
|
|
||||||
if (!csp.includes("report-uri")) {
|
|
||||||
console.error("CSP report-uri directive missing");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Performance Issues
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Monitor API response times
|
|
||||||
const start = performance.now();
|
|
||||||
const response = await fetch("/api/csp-metrics");
|
|
||||||
const duration = performance.now() - start;
|
|
||||||
|
|
||||||
if (duration > 2000) {
|
|
||||||
console.warn("CSP metrics API slow response:", duration + "ms");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Related Documentation
|
|
||||||
|
|
||||||
- [Enhanced CSP Implementation](./security/enhanced-csp.md)
|
|
||||||
- [Security Monitoring](./security-monitoring.md)
|
|
||||||
- [Security Headers](./security-headers.md)
|
|
||||||
- [Rate Limiting](../lib/rateLimiter.ts)
|
|
||||||
|
|
||||||
## API Reference Summary
|
|
||||||
|
|
||||||
| Endpoint | Method | Purpose | Auth Required |
|
|
||||||
| ------------------ | ------ | ----------------------------------- | ------------- |
|
|
||||||
| `/api/csp-report` | POST | Receive CSP violation reports | No (public) |
|
|
||||||
| `/api/csp-metrics` | GET | Get violation metrics and analytics | Admin |
|
|
||||||
|
|
||||||
Both APIs are production-ready and provide comprehensive CSP monitoring capabilities for enterprise security requirements.
|
|
||||||
@ -6,8 +6,8 @@ This document explains how to optimize database connection pooling for better pe
|
|||||||
|
|
||||||
The application now supports two connection pooling modes:
|
The application now supports two connection pooling modes:
|
||||||
|
|
||||||
1. **Standard Pooling**: Default Prisma client connection pooling
|
1. **Standard Pooling**: Default Prisma client connection pooling
|
||||||
2. **Enhanced Pooling**: Advanced PostgreSQL connection pooling with custom configuration
|
2. **Enhanced Pooling**: Advanced PostgreSQL connection pooling with custom configuration
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@ -44,20 +44,17 @@ DATABASE_URL="postgresql://user:pass@host:5432/db?connection_limit=20&pool_timeo
|
|||||||
|
|
||||||
### Fixed Issues
|
### Fixed Issues
|
||||||
|
|
||||||
1. **Multiple PrismaClient Instances**:
|
1. **Multiple PrismaClient Instances**:
|
||||||
|
- ❌ Before: Each scheduler created its own PrismaClient
|
||||||
|
- ✅ After: All modules use singleton pattern from `lib/prisma.ts`
|
||||||
|
|
||||||
- ❌ Before: Each scheduler created its own PrismaClient
|
2. **No Connection Management**:
|
||||||
- ✅ After: All modules use singleton pattern from `lib/prisma.ts`
|
- ❌ Before: No graceful shutdown or connection cleanup
|
||||||
|
- ✅ After: Proper cleanup on process termination
|
||||||
|
|
||||||
2. **No Connection Management**:
|
3. **No Monitoring**:
|
||||||
|
- ❌ Before: No visibility into connection usage
|
||||||
- ❌ Before: No graceful shutdown or connection cleanup
|
- ✅ After: Health check endpoint and connection metrics
|
||||||
- ✅ After: Proper cleanup on process termination
|
|
||||||
|
|
||||||
3. **No Monitoring**:
|
|
||||||
|
|
||||||
- ❌ Before: No visibility into connection usage
|
|
||||||
- ✅ After: Health check endpoint and connection metrics
|
|
||||||
|
|
||||||
### Key Files Modified
|
### Key Files Modified
|
||||||
|
|
||||||
@ -145,40 +142,40 @@ USE_ENHANCED_POOLING=true
|
|||||||
|
|
||||||
If you see "too many connections" errors:
|
If you see "too many connections" errors:
|
||||||
|
|
||||||
1. Increase `DATABASE_CONNECTION_LIMIT`
|
1. Increase `DATABASE_CONNECTION_LIMIT`
|
||||||
2. Check for connection leaks in application code
|
2. Check for connection leaks in application code
|
||||||
3. Monitor the health endpoint for pool statistics
|
3. Monitor the health endpoint for pool statistics
|
||||||
|
|
||||||
### Slow Database Queries
|
### Slow Database Queries
|
||||||
|
|
||||||
If queries are timing out:
|
If queries are timing out:
|
||||||
|
|
||||||
1. Decrease `DATABASE_POOL_TIMEOUT`
|
1. Decrease `DATABASE_POOL_TIMEOUT`
|
||||||
2. Check database query performance
|
2. Check database query performance
|
||||||
3. Consider connection pooling at the infrastructure level (PgBouncer)
|
3. Consider connection pooling at the infrastructure level (PgBouncer)
|
||||||
|
|
||||||
### Memory Usage
|
### Memory Usage
|
||||||
|
|
||||||
If memory usage is high:
|
If memory usage is high:
|
||||||
|
|
||||||
1. Decrease `DATABASE_CONNECTION_LIMIT`
|
1. Decrease `DATABASE_CONNECTION_LIMIT`
|
||||||
2. Enable enhanced pooling for better resource management
|
2. Enable enhanced pooling for better resource management
|
||||||
3. Monitor idle connection cleanup
|
3. Monitor idle connection cleanup
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
1. **Always use the singleton**: Import `prisma` from `lib/prisma.ts`
|
1. **Always use the singleton**: Import `prisma` from `lib/prisma.ts`
|
||||||
2. **Monitor connection usage**: Use the health endpoint regularly
|
2. **Monitor connection usage**: Use the health endpoint regularly
|
||||||
3. **Set appropriate limits**: Don't over-provision connections
|
3. **Set appropriate limits**: Don't over-provision connections
|
||||||
4. **Enable enhanced pooling in production**: Better resource management
|
4. **Enable enhanced pooling in production**: Better resource management
|
||||||
5. **Implement graceful shutdown**: Ensure connections are properly closed
|
5. **Implement graceful shutdown**: Ensure connections are properly closed
|
||||||
6. **Log connection events**: Monitor for issues and optimize accordingly
|
6. **Log connection events**: Monitor for issues and optimize accordingly
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
Consider implementing:
|
Consider implementing:
|
||||||
|
|
||||||
1. **Connection pooling middleware**: PgBouncer or similar
|
1. **Connection pooling middleware**: PgBouncer or similar
|
||||||
2. **Read replicas**: For read-heavy workloads
|
2. **Read replicas**: For read-heavy workloads
|
||||||
3. **Connection retry logic**: For handling temporary failures
|
3. **Connection retry logic**: For handling temporary failures
|
||||||
4. **Metrics collection**: Prometheus/Grafana for detailed monitoring
|
4. **Metrics collection**: Prometheus/Grafana for detailed monitoring
|
||||||
|
|||||||
@ -1,255 +0,0 @@
|
|||||||
# Database Performance Optimizations
|
|
||||||
|
|
||||||
This document outlines the comprehensive database performance optimizations implemented for the LiveDash application, including strategic composite indexes and query optimization strategies.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The optimization focuses on the most frequently queried patterns in the application, particularly around:
|
|
||||||
|
|
||||||
- AI processing request tracking and batching
|
|
||||||
- Session analytics and filtering
|
|
||||||
- Security audit log analysis
|
|
||||||
- Multi-tenant data isolation performance
|
|
||||||
|
|
||||||
## Applied Optimizations
|
|
||||||
|
|
||||||
### 1. AI Processing Request Optimizations
|
|
||||||
|
|
||||||
**Problem**: Heavy queries for batch processing and cost analysis
|
|
||||||
**Solution**: Strategic composite indexes with covering columns
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Query pattern: companyId + processingStatus + requestedAt
|
|
||||||
CREATE INDEX "AIProcessingRequest_companyId_processingStatus_requestedAt_idx"
|
|
||||||
ON "AIProcessingRequest" ("companyId", "processingStatus", "requestedAt");
|
|
||||||
|
|
||||||
-- Covering index for batch processing
|
|
||||||
CREATE INDEX "AIProcessingRequest_companyId_processingStatus_covering_idx"
|
|
||||||
ON "AIProcessingRequest" ("companyId")
|
|
||||||
INCLUDE ("processingStatus", "batchId", "requestedAt", "sessionId");
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impact**:
|
|
||||||
|
|
||||||
- ~70% faster batch job queries
|
|
||||||
- Reduced I/O for cost analysis reports
|
|
||||||
- Improved scheduler performance
|
|
||||||
|
|
||||||
### 2. Session Analytics Optimizations
|
|
||||||
|
|
||||||
**Problem**: Dashboard queries scanning large session tables
|
|
||||||
**Solution**: Composite indexes for common filtering patterns
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Time-range queries with sentiment filtering
|
|
||||||
CREATE INDEX "Session_companyId_startTime_sentiment_covering_idx"
|
|
||||||
ON "Session" ("companyId", "startTime", "sentiment")
|
|
||||||
INCLUDE ("endTime", "category", "escalated", "messagesSent");
|
|
||||||
|
|
||||||
-- Performance analysis queries
|
|
||||||
CREATE INDEX "Session_companyId_performance_idx"
|
|
||||||
ON "Session" ("companyId", "avgResponseTime", "escalated")
|
|
||||||
INCLUDE ("startTime", "messagesSent");
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impact**:
|
|
||||||
|
|
||||||
- ~85% faster dashboard load times
|
|
||||||
- Efficient date range filtering
|
|
||||||
- Optimized sentiment analysis queries
|
|
||||||
|
|
||||||
### 3. Security Audit Log Optimizations
|
|
||||||
|
|
||||||
**Problem**: Slow security monitoring and compliance queries
|
|
||||||
**Solution**: Specialized indexes for audit patterns
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Admin security dashboard
|
|
||||||
CREATE INDEX "SecurityAuditLog_companyId_eventType_outcome_timestamp_idx"
|
|
||||||
ON "SecurityAuditLog" ("companyId", "eventType", "outcome", "timestamp");
|
|
||||||
|
|
||||||
-- Threat detection queries
|
|
||||||
CREATE INDEX "SecurityAuditLog_geographic_threat_idx"
|
|
||||||
ON "SecurityAuditLog" ("ipAddress", "country", "timestamp")
|
|
||||||
WHERE "outcome" IN ('FAILURE', 'BLOCKED', 'SUSPICIOUS')
|
|
||||||
INCLUDE ("eventType", "severity", "userId", "companyId");
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impact**:
|
|
||||||
|
|
||||||
- ~90% faster security monitoring
|
|
||||||
- Efficient threat detection
|
|
||||||
- Improved compliance reporting
|
|
||||||
|
|
||||||
### 4. Message Processing Optimizations
|
|
||||||
|
|
||||||
**Problem**: Slow conversation timeline queries
|
|
||||||
**Solution**: Covering indexes for message retrieval
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Message timeline with role filtering
|
|
||||||
CREATE INDEX "Message_sessionId_timestamp_role_covering_idx"
|
|
||||||
ON "Message" ("sessionId", "timestamp", "role")
|
|
||||||
INCLUDE ("content");
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impact**:
|
|
||||||
|
|
||||||
- ~60% faster conversation loading
|
|
||||||
- Reduced memory usage for message queries
|
|
||||||
|
|
||||||
### 5. Processing Pipeline Optimizations
|
|
||||||
|
|
||||||
**Problem**: Inefficient status tracking for processing stages
|
|
||||||
**Solution**: Stage-specific indexes with error analysis
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Processing pipeline monitoring
|
|
||||||
CREATE INDEX "SessionProcessingStatus_stage_status_startedAt_idx"
|
|
||||||
ON "SessionProcessingStatus" ("stage", "status", "startedAt")
|
|
||||||
INCLUDE ("sessionId", "completedAt", "retryCount");
|
|
||||||
|
|
||||||
-- Error analysis (partial index)
|
|
||||||
CREATE INDEX "SessionProcessingStatus_error_analysis_idx"
|
|
||||||
ON "SessionProcessingStatus" ("status", "stage")
|
|
||||||
WHERE "status" IN ('FAILED', 'RETRY_PENDING')
|
|
||||||
INCLUDE ("sessionId", "errorMessage", "retryCount", "startedAt");
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impact**:
|
|
||||||
|
|
||||||
- ~75% faster processing monitoring
|
|
||||||
- Efficient error tracking
|
|
||||||
- Improved retry logic performance
|
|
||||||
|
|
||||||
## Index Strategy Principles
|
|
||||||
|
|
||||||
### 1. Composite Index Design
|
|
||||||
|
|
||||||
- **Leading column**: Most selective filter (usually companyId for multi-tenancy)
|
|
||||||
- **Secondary columns**: Common WHERE clause filters
|
|
||||||
- **Covering columns**: SELECT list columns via INCLUDE
|
|
||||||
|
|
||||||
### 2. Partial Indexes
|
|
||||||
|
|
||||||
- Used for error analysis and specific status filtering
|
|
||||||
- Reduces index size and maintenance overhead
|
|
||||||
- Improves write performance
|
|
||||||
|
|
||||||
### 3. Covering Indexes
|
|
||||||
|
|
||||||
- Include frequently accessed columns to avoid table lookups
|
|
||||||
- Reduces I/O for read-heavy operations
|
|
||||||
- Particularly effective for dashboard queries
|
|
||||||
|
|
||||||
## Query Pattern Analysis
|
|
||||||
|
|
||||||
### Most Optimized Patterns
|
|
||||||
|
|
||||||
1. **Multi-tenant filtering**: `companyId + filter + timestamp`
|
|
||||||
2. **Status tracking**: `processingStatus + entity + timestamp`
|
|
||||||
3. **Time-range analysis**: `timestamp + entity + filters`
|
|
||||||
4. **Geographic analysis**: `ipAddress + country + timestamp`
|
|
||||||
5. **Error tracking**: `status + stage + timestamp`
|
|
||||||
|
|
||||||
### Before vs After Performance
|
|
||||||
|
|
||||||
| Query Type | Before (ms) | After (ms) | Improvement |
|
|
||||||
| ------------------- | ----------- | ---------- | ----------- |
|
|
||||||
| Dashboard load | 2,500 | 375 | 85% |
|
|
||||||
| Batch queries | 1,800 | 540 | 70% |
|
|
||||||
| Security monitoring | 3,200 | 320 | 90% |
|
|
||||||
| Message timeline | 800 | 320 | 60% |
|
|
||||||
| Processing status | 1,200 | 300 | 75% |
|
|
||||||
|
|
||||||
## Maintenance Considerations
|
|
||||||
|
|
||||||
### Index Monitoring
|
|
||||||
|
|
||||||
- Monitor index usage with `pg_stat_user_indexes`
|
|
||||||
- Track bloat with `pg_stat_user_tables`
|
|
||||||
- Regular ANALYZE after bulk operations
|
|
||||||
|
|
||||||
### Write Performance Impact
|
|
||||||
|
|
||||||
- Composite indexes add ~15% write overhead
|
|
||||||
- Offset by dramatic read performance gains
|
|
||||||
- Monitored via slow query logs
|
|
||||||
|
|
||||||
### Storage Impact
|
|
||||||
|
|
||||||
- Indexes add ~25% to total storage
|
|
||||||
- Covering indexes reduce need for table scans
|
|
||||||
- Partial indexes minimize storage overhead
|
|
||||||
|
|
||||||
## Migration Safety
|
|
||||||
|
|
||||||
### CONCURRENTLY Operations
|
|
||||||
|
|
||||||
- All indexes created with `CREATE INDEX CONCURRENTLY`
|
|
||||||
- No table locks during creation
|
|
||||||
- Production-safe deployment
|
|
||||||
|
|
||||||
### Rollback Strategy
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- If performance degrades, indexes can be dropped individually
|
|
||||||
DROP INDEX CONCURRENTLY "specific_index_name";
|
|
||||||
```
|
|
||||||
|
|
||||||
### Monitoring Commands
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Check index usage
|
|
||||||
SELECT schemaname, tablename, attname, n_distinct, correlation
|
|
||||||
FROM pg_stats
|
|
||||||
WHERE tablename IN ('Session', 'AIProcessingRequest', 'SecurityAuditLog');
|
|
||||||
|
|
||||||
-- Monitor query performance
|
|
||||||
SELECT query, mean_exec_time, calls
|
|
||||||
FROM pg_stat_statements
|
|
||||||
ORDER BY mean_exec_time DESC
|
|
||||||
LIMIT 10;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation Guidelines
|
|
||||||
|
|
||||||
### Development Environment
|
|
||||||
|
|
||||||
1. Apply migration: `pnpm prisma migrate deploy`
|
|
||||||
2. Run ANALYZE: `psql -c "ANALYZE;"`
|
|
||||||
3. Monitor performance: Enable slow query logging
|
|
||||||
|
|
||||||
### Production Environment
|
|
||||||
|
|
||||||
1. Apply during low-traffic window
|
|
||||||
2. Monitor index creation progress
|
|
||||||
3. Verify performance improvements
|
|
||||||
4. Update query plans via ANALYZE
|
|
||||||
|
|
||||||
## Future Optimizations
|
|
||||||
|
|
||||||
### Potential Improvements
|
|
||||||
|
|
||||||
1. **Partitioning**: Time-based partitioning for large audit logs
|
|
||||||
2. **Materialized views**: Pre-computed analytics for dashboards
|
|
||||||
3. **Query optimization**: Additional covering indexes based on usage patterns
|
|
||||||
4. **Connection pooling**: Enhanced database connection management
|
|
||||||
|
|
||||||
### Monitoring Strategy
|
|
||||||
|
|
||||||
- Set up automated index usage monitoring
|
|
||||||
- Track slow query evolution
|
|
||||||
- Monitor storage growth patterns
|
|
||||||
- Implement performance alerting
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
These database optimizations provide:
|
|
||||||
|
|
||||||
- **70-90% improvement** in query performance
|
|
||||||
- **Reduced server load** through efficient indexing
|
|
||||||
- **Better user experience** with faster dashboards
|
|
||||||
- **Scalable foundation** for future growth
|
|
||||||
|
|
||||||
The optimizations are designed to be production-safe and monitoring-friendly, ensuring both immediate performance gains and long-term maintainability.
|
|
||||||
@ -5,8 +5,7 @@ This document provides specific recommendations for optimizing database connecti
|
|||||||
## Current Issues Observed
|
## Current Issues Observed
|
||||||
|
|
||||||
From your logs, we can see:
|
From your logs, we can see:
|
||||||
|
```
|
||||||
```bash
|
|
||||||
Can't reach database server at `ep-tiny-math-a2zsshve-pooler.eu-central-1.aws.neon.tech:5432`
|
Can't reach database server at `ep-tiny-math-a2zsshve-pooler.eu-central-1.aws.neon.tech:5432`
|
||||||
[NODE-CRON] [WARN] missed execution at Sun Jun 29 2025 12:00:00 GMT+0200! Possible blocking IO or high CPU
|
[NODE-CRON] [WARN] missed execution at Sun Jun 29 2025 12:00:00 GMT+0200! Possible blocking IO or high CPU
|
||||||
```
|
```
|
||||||
@ -14,19 +13,16 @@ Can't reach database server at `ep-tiny-math-a2zsshve-pooler.eu-central-1.aws.ne
|
|||||||
## Root Causes
|
## Root Causes
|
||||||
|
|
||||||
### 1. Neon Connection Limits
|
### 1. Neon Connection Limits
|
||||||
|
|
||||||
- **Free Tier**: 20 concurrent connections
|
- **Free Tier**: 20 concurrent connections
|
||||||
- **Pro Tier**: 100 concurrent connections
|
- **Pro Tier**: 100 concurrent connections
|
||||||
- **Multiple schedulers** can quickly exhaust connections
|
- **Multiple schedulers** can quickly exhaust connections
|
||||||
|
|
||||||
### 2. Connection Pooling Issues
|
### 2. Connection Pooling Issues
|
||||||
|
|
||||||
- Each scheduler was creating separate PrismaClient instances
|
- Each scheduler was creating separate PrismaClient instances
|
||||||
- No connection reuse between operations
|
- No connection reuse between operations
|
||||||
- No retry logic for temporary failures
|
- No retry logic for temporary failures
|
||||||
|
|
||||||
### 3. Neon-Specific Challenges
|
### 3. Neon-Specific Challenges
|
||||||
|
|
||||||
- **Auto-pause**: Databases pause after inactivity
|
- **Auto-pause**: Databases pause after inactivity
|
||||||
- **Cold starts**: First connection after pause takes longer
|
- **Cold starts**: First connection after pause takes longer
|
||||||
- **Regional latency**: eu-central-1 may have variable latency
|
- **Regional latency**: eu-central-1 may have variable latency
|
||||||
@ -34,7 +30,6 @@ Can't reach database server at `ep-tiny-math-a2zsshve-pooler.eu-central-1.aws.ne
|
|||||||
## Solutions Implemented
|
## Solutions Implemented
|
||||||
|
|
||||||
### 1. Fixed Multiple PrismaClient Instances ✅
|
### 1. Fixed Multiple PrismaClient Instances ✅
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Before: Each file created its own client
|
// Before: Each file created its own client
|
||||||
const prisma = new PrismaClient(); // ❌
|
const prisma = new PrismaClient(); // ❌
|
||||||
@ -44,30 +39,30 @@ import { prisma } from "./prisma.js"; // ✅
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 2. Added Connection Retry Logic ✅
|
### 2. Added Connection Retry Logic ✅
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Automatic retry for connection errors
|
// Automatic retry for connection errors
|
||||||
await withRetry(async () => await databaseOperation(), {
|
await withRetry(
|
||||||
maxRetries: 3,
|
async () => await databaseOperation(),
|
||||||
initialDelay: 2000,
|
{
|
||||||
maxDelay: 10000,
|
maxRetries: 3,
|
||||||
backoffMultiplier: 2,
|
initialDelay: 2000,
|
||||||
});
|
maxDelay: 10000,
|
||||||
|
backoffMultiplier: 2,
|
||||||
|
}
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Enhanced Connection Pooling ✅
|
### 3. Enhanced Connection Pooling ✅
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Production-ready pooling with @prisma/adapter-pg
|
// Production-ready pooling with @prisma/adapter-pg
|
||||||
USE_ENHANCED_POOLING = true;
|
USE_ENHANCED_POOLING=true
|
||||||
DATABASE_CONNECTION_LIMIT = 20;
|
DATABASE_CONNECTION_LIMIT=20
|
||||||
DATABASE_POOL_TIMEOUT = 10;
|
DATABASE_POOL_TIMEOUT=10
|
||||||
```
|
```
|
||||||
|
|
||||||
## Neon-Specific Configuration
|
## Neon-Specific Configuration
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Optimized for Neon
|
# Optimized for Neon
|
||||||
DATABASE_URL="postgresql://user:pass@ep-tiny-math-a2zsshve-pooler.eu-central-1.aws.neon.tech:5432/db?sslmode=require&connection_limit=15"
|
DATABASE_URL="postgresql://user:pass@ep-tiny-math-a2zsshve-pooler.eu-central-1.aws.neon.tech:5432/db?sslmode=require&connection_limit=15"
|
||||||
@ -84,7 +79,6 @@ SESSION_PROCESSING_INTERVAL="0 */2 * * *" # Every 2 hours instead of 1
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Connection String Optimization
|
### Connection String Optimization
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Add these parameters to your DATABASE_URL
|
# Add these parameters to your DATABASE_URL
|
||||||
?sslmode=require # Required for Neon
|
?sslmode=require # Required for Neon
|
||||||
@ -97,21 +91,18 @@ SESSION_PROCESSING_INTERVAL="0 */2 * * *" # Every 2 hours instead of 1
|
|||||||
## Monitoring & Troubleshooting
|
## Monitoring & Troubleshooting
|
||||||
|
|
||||||
### 1. Health Check Endpoint
|
### 1. Health Check Endpoint
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check connection health
|
# Check connection health
|
||||||
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
|
curl -H "Authorization: Bearer your-token" \
|
||||||
http://localhost:3000/api/admin/database-health
|
http://localhost:3000/api/admin/database-health
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Neon Dashboard Monitoring
|
### 2. Neon Dashboard Monitoring
|
||||||
|
|
||||||
- Monitor "Active connections" in Neon dashboard
|
- Monitor "Active connections" in Neon dashboard
|
||||||
- Check for connection spikes during scheduler runs
|
- Check for connection spikes during scheduler runs
|
||||||
- Review query performance and slow queries
|
- Review query performance and slow queries
|
||||||
|
|
||||||
### 3. Application Logs
|
### 3. Application Logs
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Look for connection patterns
|
# Look for connection patterns
|
||||||
grep "Database connection" logs/*.log
|
grep "Database connection" logs/*.log
|
||||||
@ -122,85 +113,72 @@ grep "retry" logs/*.log
|
|||||||
## Performance Optimizations
|
## Performance Optimizations
|
||||||
|
|
||||||
### 1. Reduce Scheduler Frequency
|
### 1. Reduce Scheduler Frequency
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Current intervals may be too aggressive
|
// Current intervals may be too aggressive
|
||||||
CSV_IMPORT_INTERVAL = "*/15 * * * *"; // ➜ "*/30 * * * *"
|
CSV_IMPORT_INTERVAL="*/15 * * * *" // ➜ "*/30 * * * *"
|
||||||
IMPORT_PROCESSING_INTERVAL = "*/5 * * * *"; // ➜ "*/10 * * * *"
|
IMPORT_PROCESSING_INTERVAL="*/5 * * * *" // ➜ "*/10 * * * *"
|
||||||
SESSION_PROCESSING_INTERVAL = "0 * * * *"; // ➜ "0 */2 * * *"
|
SESSION_PROCESSING_INTERVAL="0 * * * *" // ➜ "0 */2 * * *"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Batch Size Optimization
|
### 2. Batch Size Optimization
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Reduce batch sizes to avoid long-running transactions
|
// Reduce batch sizes to avoid long-running transactions
|
||||||
CSV_IMPORT_BATCH_SIZE = 50; // ➜ 25
|
CSV_IMPORT_BATCH_SIZE=50 // ➜ 25
|
||||||
IMPORT_PROCESSING_BATCH_SIZE = 50; // ➜ 25
|
IMPORT_PROCESSING_BATCH_SIZE=50 // ➜ 25
|
||||||
SESSION_PROCESSING_BATCH_SIZE = 20; // ➜ 10
|
SESSION_PROCESSING_BATCH_SIZE=20 // ➜ 10
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Connection Keepalive
|
### 3. Connection Keepalive
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Keep connections warm to avoid cold starts
|
// Keep connections warm to avoid cold starts
|
||||||
const prisma = new PrismaClient({
|
const prisma = new PrismaClient({
|
||||||
datasources: {
|
datasources: {
|
||||||
db: {
|
db: {
|
||||||
url: process.env.DATABASE_URL + "&keepalive=true",
|
url: process.env.DATABASE_URL + "&keepalive=true"
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting Common Issues
|
## Troubleshooting Common Issues
|
||||||
|
|
||||||
### "Can't reach database server"
|
### "Can't reach database server"
|
||||||
|
|
||||||
**Causes:**
|
**Causes:**
|
||||||
|
|
||||||
- Neon database auto-paused
|
- Neon database auto-paused
|
||||||
- Connection limit exceeded
|
- Connection limit exceeded
|
||||||
- Network issues
|
- Network issues
|
||||||
|
|
||||||
**Solutions:**
|
**Solutions:**
|
||||||
|
1. Enable enhanced pooling: `USE_ENHANCED_POOLING=true`
|
||||||
1. Enable enhanced pooling: `USE_ENHANCED_POOLING=true`
|
2. Reduce connection limit: `DATABASE_CONNECTION_LIMIT=15`
|
||||||
2. Reduce connection limit: `DATABASE_CONNECTION_LIMIT=15`
|
3. Implement retry logic (already done)
|
||||||
3. Implement retry logic (already done)
|
4. Check Neon dashboard for database status
|
||||||
4. Check Neon dashboard for database status
|
|
||||||
|
|
||||||
### "Connection terminated"
|
### "Connection terminated"
|
||||||
|
|
||||||
**Causes:**
|
**Causes:**
|
||||||
|
|
||||||
- Idle connection timeout
|
- Idle connection timeout
|
||||||
- Neon maintenance
|
- Neon maintenance
|
||||||
- Long-running transactions
|
- Long-running transactions
|
||||||
|
|
||||||
**Solutions:**
|
**Solutions:**
|
||||||
|
1. Increase pool timeout: `DATABASE_POOL_TIMEOUT=30`
|
||||||
1. Increase pool timeout: `DATABASE_POOL_TIMEOUT=30`
|
2. Add connection cycling
|
||||||
2. Add connection cycling
|
3. Break large operations into smaller batches
|
||||||
3. Break large operations into smaller batches
|
|
||||||
|
|
||||||
### "Missed cron execution"
|
### "Missed cron execution"
|
||||||
|
|
||||||
**Causes:**
|
**Causes:**
|
||||||
|
|
||||||
- Blocking database operations
|
- Blocking database operations
|
||||||
- Scheduler overlap
|
- Scheduler overlap
|
||||||
- High CPU usage
|
- High CPU usage
|
||||||
|
|
||||||
**Solutions:**
|
**Solutions:**
|
||||||
|
1. Reduce scheduler frequency
|
||||||
1. Reduce scheduler frequency
|
2. Add concurrency limits
|
||||||
2. Add concurrency limits
|
3. Monitor scheduler execution time
|
||||||
3. Monitor scheduler execution time
|
|
||||||
|
|
||||||
## Recommended Production Settings
|
## Recommended Production Settings
|
||||||
|
|
||||||
### For Neon Free Tier (20 connections)
|
### For Neon Free Tier (20 connections)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DATABASE_CONNECTION_LIMIT=15
|
DATABASE_CONNECTION_LIMIT=15
|
||||||
DATABASE_POOL_TIMEOUT=30
|
DATABASE_POOL_TIMEOUT=30
|
||||||
@ -211,7 +189,6 @@ SESSION_PROCESSING_INTERVAL="0 */3 * * *"
|
|||||||
```
|
```
|
||||||
|
|
||||||
### For Neon Pro Tier (100 connections)
|
### For Neon Pro Tier (100 connections)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DATABASE_CONNECTION_LIMIT=50
|
DATABASE_CONNECTION_LIMIT=50
|
||||||
DATABASE_POOL_TIMEOUT=20
|
DATABASE_POOL_TIMEOUT=20
|
||||||
@ -223,10 +200,10 @@ SESSION_PROCESSING_INTERVAL="0 */2 * * *"
|
|||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
1. **Immediate**: Apply the new environment variables
|
1. **Immediate**: Apply the new environment variables
|
||||||
2. **Short-term**: Monitor connection usage via health endpoint
|
2. **Short-term**: Monitor connection usage via health endpoint
|
||||||
3. **Long-term**: Consider upgrading to Neon Pro for more connections
|
3. **Long-term**: Consider upgrading to Neon Pro for more connections
|
||||||
4. **Optional**: Implement read replicas for analytics queries
|
4. **Optional**: Implement read replicas for analytics queries
|
||||||
|
|
||||||
## Monitoring Checklist
|
## Monitoring Checklist
|
||||||
|
|
||||||
@ -236,4 +213,4 @@ SESSION_PROCESSING_INTERVAL="0 */2 * * *"
|
|||||||
- [ ] Test health endpoint regularly
|
- [ ] Test health endpoint regularly
|
||||||
- [ ] Set up alerts for connection failures
|
- [ ] Set up alerts for connection failures
|
||||||
|
|
||||||
With these optimizations, your Neon database connections should be much more stable and efficient!
|
With these optimizations, your Neon database connections should be much more stable and efficient!
|
||||||
@ -7,25 +7,21 @@ Successfully refactored the session processing pipeline from a simple status-bas
|
|||||||
## Problems Solved
|
## Problems Solved
|
||||||
|
|
||||||
### Original Issues
|
### Original Issues
|
||||||
|
1. **Inconsistent Status Tracking**: The old system used a simple enum on SessionImport that didn't properly track the multi-stage processing pipeline
|
||||||
1. **Inconsistent Status Tracking**: The old system used a simple enum on SessionImport that didn't properly track the multi-stage processing pipeline
|
2. **Poor Error Visibility**: Error messages were buried in the SessionImport table and not easily accessible
|
||||||
2. **Poor Error Visibility**: Error messages were buried in the SessionImport table and not easily accessible
|
3. **No Stage-Specific Tracking**: The system couldn't track which specific stage of processing failed
|
||||||
3. **No Stage-Specific Tracking**: The system couldn't track which specific stage of processing failed
|
4. **Difficult Recovery**: Failed sessions were hard to identify and retry
|
||||||
4. **Difficult Recovery**: Failed sessions were hard to identify and retry
|
5. **Linting Errors**: Multiple TypeScript files referencing removed database fields
|
||||||
5. **Linting Errors**: Multiple TypeScript files referencing removed database fields
|
|
||||||
|
|
||||||
### Schema Changes Made
|
### Schema Changes Made
|
||||||
|
|
||||||
- **Removed** old `status`, `errorMsg`, and `processedAt` columns from SessionImport
|
- **Removed** old `status`, `errorMsg`, and `processedAt` columns from SessionImport
|
||||||
- **Removed** `processed` field from Session
|
- **Removed** `processed` field from Session
|
||||||
- **Added** new `SessionProcessingStatus` table with granular stage tracking
|
- **Added** new `SessionProcessingStatus` table with granular stage tracking
|
||||||
- **Added** `ProcessingStage` and `ProcessingStatus` enums
|
- **Added** `ProcessingStage` and `ProcessingStatus` enums
|
||||||
|
|
||||||
## New Processing Pipeline
|
## New Processing Pipeline
|
||||||
|
|
||||||
### Processing Stages
|
### Processing Stages
|
||||||
|
|
||||||
<!-- prettier-ignore -->
|
|
||||||
```typescript
|
```typescript
|
||||||
enum ProcessingStage {
|
enum ProcessingStage {
|
||||||
CSV_IMPORT // SessionImport created
|
CSV_IMPORT // SessionImport created
|
||||||
@ -43,9 +39,7 @@ enum ProcessingStatus {
|
|||||||
### Key Components
|
### Key Components
|
||||||
|
|
||||||
#### 1. ProcessingStatusManager
|
#### 1. ProcessingStatusManager
|
||||||
|
|
||||||
Centralized class for managing processing status with methods:
|
Centralized class for managing processing status with methods:
|
||||||
|
|
||||||
- `initializeSession()` - Set up processing status for new sessions
|
- `initializeSession()` - Set up processing status for new sessions
|
||||||
- `startStage()`, `completeStage()`, `failStage()`, `skipStage()` - Stage management
|
- `startStage()`, `completeStage()`, `failStage()`, `skipStage()` - Stage management
|
||||||
- `getSessionsNeedingProcessing()` - Query sessions by stage and status
|
- `getSessionsNeedingProcessing()` - Query sessions by stage and status
|
||||||
@ -54,14 +48,12 @@ Centralized class for managing processing status with methods:
|
|||||||
- `resetStageForRetry()` - Reset failed stages
|
- `resetStageForRetry()` - Reset failed stages
|
||||||
|
|
||||||
#### 2. Updated Processing Scheduler
|
#### 2. Updated Processing Scheduler
|
||||||
|
|
||||||
- Integrated with new `ProcessingStatusManager`
|
- Integrated with new `ProcessingStatusManager`
|
||||||
- Tracks AI analysis and question extraction stages
|
- Tracks AI analysis and question extraction stages
|
||||||
- Records detailed processing metadata
|
- Records detailed processing metadata
|
||||||
- Proper error handling and retry capabilities
|
- Proper error handling and retry capabilities
|
||||||
|
|
||||||
#### 3. Migration System
|
#### 3. Migration System
|
||||||
|
|
||||||
- Successfully migrated all 109 existing sessions
|
- Successfully migrated all 109 existing sessions
|
||||||
- Determined current state based on existing data
|
- Determined current state based on existing data
|
||||||
- Preserved all existing functionality
|
- Preserved all existing functionality
|
||||||
@ -69,9 +61,8 @@ Centralized class for managing processing status with methods:
|
|||||||
## Current Pipeline Status
|
## Current Pipeline Status
|
||||||
|
|
||||||
After migration and refactoring:
|
After migration and refactoring:
|
||||||
|
|
||||||
- **CSV_IMPORT**: 109 completed
|
- **CSV_IMPORT**: 109 completed
|
||||||
- **TRANSCRIPT_FETCH**: 109 completed
|
- **TRANSCRIPT_FETCH**: 109 completed
|
||||||
- **SESSION_CREATION**: 109 completed
|
- **SESSION_CREATION**: 109 completed
|
||||||
- **AI_ANALYSIS**: 16 completed, 93 pending
|
- **AI_ANALYSIS**: 16 completed, 93 pending
|
||||||
- **QUESTION_EXTRACTION**: 11 completed, 98 pending
|
- **QUESTION_EXTRACTION**: 11 completed, 98 pending
|
||||||
@ -79,65 +70,58 @@ After migration and refactoring:
|
|||||||
## Files Updated/Created
|
## Files Updated/Created
|
||||||
|
|
||||||
### New Files
|
### New Files
|
||||||
|
|
||||||
- `lib/processingStatusManager.ts` - Core processing status management
|
- `lib/processingStatusManager.ts` - Core processing status management
|
||||||
- `check-refactored-pipeline-status.ts` - New pipeline status checker
|
- `check-refactored-pipeline-status.ts` - New pipeline status checker
|
||||||
- `migrate-to-refactored-system.ts` - Migration script
|
- `migrate-to-refactored-system.ts` - Migration script
|
||||||
- `docs/processing-system-refactor.md` - This documentation
|
- `docs/processing-system-refactor.md` - This documentation
|
||||||
|
|
||||||
### Updated Files
|
### Updated Files
|
||||||
|
|
||||||
- `prisma/schema.prisma` - Added new processing status tables
|
- `prisma/schema.prisma` - Added new processing status tables
|
||||||
- `lib/processingScheduler.ts` - Integrated with new status system
|
- `lib/processingScheduler.ts` - Integrated with new status system
|
||||||
- `debug-import-status.ts` - Updated to use new system
|
- `debug-import-status.ts` - Updated to use new system
|
||||||
- `fix-import-status.ts` - Updated to use new system
|
- `fix-import-status.ts` - Updated to use new system
|
||||||
|
|
||||||
### Removed Files
|
### Removed Files
|
||||||
|
|
||||||
- `check-pipeline-status.ts` - Replaced by refactored version
|
- `check-pipeline-status.ts` - Replaced by refactored version
|
||||||
|
|
||||||
## Benefits Achieved
|
## Benefits Achieved
|
||||||
|
|
||||||
1. **Clear Pipeline Visibility**: Can see exactly which stage each session is in
|
1. **Clear Pipeline Visibility**: Can see exactly which stage each session is in
|
||||||
2. **Better Error Tracking**: Failed stages include specific error messages and retry counts
|
2. **Better Error Tracking**: Failed stages include specific error messages and retry counts
|
||||||
3. **Efficient Processing**: Can query sessions needing specific stage processing
|
3. **Efficient Processing**: Can query sessions needing specific stage processing
|
||||||
4. **Metadata Support**: Each stage can store relevant metadata (costs, token usage, etc.)
|
4. **Metadata Support**: Each stage can store relevant metadata (costs, token usage, etc.)
|
||||||
5. **Easy Recovery**: Failed sessions can be easily identified and retried
|
5. **Easy Recovery**: Failed sessions can be easily identified and retried
|
||||||
6. **Scalable**: System can handle new processing stages without schema changes
|
6. **Scalable**: System can handle new processing stages without schema changes
|
||||||
7. **No Linting Errors**: All TypeScript compilation issues resolved
|
7. **No Linting Errors**: All TypeScript compilation issues resolved
|
||||||
|
|
||||||
## Usage Examples
|
## Usage Examples
|
||||||
|
|
||||||
### Check Pipeline Status
|
### Check Pipeline Status
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx tsx check-refactored-pipeline-status.ts
|
npx tsx check-refactored-pipeline-status.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
### Debug Processing Issues
|
### Debug Processing Issues
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx tsx debug-import-status.ts
|
npx tsx debug-import-status.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
### Fix/Retry Failed Sessions
|
### Fix/Retry Failed Sessions
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx tsx fix-import-status.ts
|
npx tsx fix-import-status.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
### Process Sessions
|
### Process Sessions
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx tsx test-ai-processing.ts
|
npx tsx test-ai-processing.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
1. **Test AI Processing**: Run AI processing on pending sessions
|
1. **Test AI Processing**: Run AI processing on pending sessions
|
||||||
2. **Monitor Performance**: Watch for any issues with the new system
|
2. **Monitor Performance**: Watch for any issues with the new system
|
||||||
3. **Update Dashboard**: Modify any UI components that might reference old fields
|
3. **Update Dashboard**: Modify any UI components that might reference old fields
|
||||||
4. **Documentation**: Update any API documentation that references the old system
|
4. **Documentation**: Update any API documentation that references the old system
|
||||||
|
|
||||||
## Migration Notes
|
## Migration Notes
|
||||||
|
|
||||||
|
|||||||
@ -1,428 +0,0 @@
|
|||||||
# Scheduler Architecture for Horizontal Scaling
|
|
||||||
|
|
||||||
This document describes the extracted scheduler architecture that enables horizontal scaling of background processing tasks.
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
The scheduler system has been refactored from a monolithic approach to a service-oriented architecture that supports:
|
|
||||||
|
|
||||||
- **Individual Scheduler Services** - Each scheduler runs as a separate service
|
|
||||||
- **Horizontal Scaling** - Multiple instances of the same scheduler can run across different machines
|
|
||||||
- **Health Monitoring** - Built-in health checks for load balancers and orchestrators
|
|
||||||
- **Graceful Shutdown** - Proper handling of shutdown signals for zero-downtime deployments
|
|
||||||
- **Centralized Management** - Optional scheduler manager for coordinated operations
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
### 1. BaseSchedulerService
|
|
||||||
|
|
||||||
Abstract base class providing common functionality:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export abstract class BaseSchedulerService extends EventEmitter {
|
|
||||||
// Common scheduler functionality
|
|
||||||
protected abstract executeTask(): Promise<void>;
|
|
||||||
|
|
||||||
async start(): Promise<void>;
|
|
||||||
async stop(): Promise<void>;
|
|
||||||
pause(): void;
|
|
||||||
resume(): void;
|
|
||||||
getHealthStatus(): HealthStatus;
|
|
||||||
getMetrics(): SchedulerMetrics;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
|
|
||||||
- Status management (STOPPED, STARTING, RUNNING, PAUSED, ERROR)
|
|
||||||
- Metrics collection (run counts, timing, success/failure rates)
|
|
||||||
- Event emission for monitoring
|
|
||||||
- Configurable intervals and timeouts
|
|
||||||
- Automatic retry handling
|
|
||||||
|
|
||||||
### 2. Individual Scheduler Services
|
|
||||||
|
|
||||||
#### CsvImportSchedulerService
|
|
||||||
|
|
||||||
Handles periodic CSV data import from companies:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const csvScheduler = new CsvImportSchedulerService({
|
|
||||||
interval: "*/10 * * * *", // Every 10 minutes
|
|
||||||
batchSize: 10,
|
|
||||||
maxConcurrentImports: 5,
|
|
||||||
timeout: 300000, // 5 minutes
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
|
|
||||||
- Batch processing with configurable concurrency
|
|
||||||
- Duplicate detection
|
|
||||||
- Company-specific error handling
|
|
||||||
- Progress monitoring
|
|
||||||
|
|
||||||
#### Additional Schedulers (To Be Implemented)
|
|
||||||
|
|
||||||
- `ImportProcessingSchedulerService` - Process imported CSV data into sessions
|
|
||||||
- `SessionProcessingSchedulerService` - AI analysis and categorization
|
|
||||||
- `BatchProcessingSchedulerService` - OpenAI Batch API integration
|
|
||||||
|
|
||||||
### 3. SchedulerManager
|
|
||||||
|
|
||||||
Orchestrates multiple schedulers in a single process:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const manager = new SchedulerManager();
|
|
||||||
|
|
||||||
manager.registerScheduler({
|
|
||||||
id: "csv-import",
|
|
||||||
name: "CSV Import Scheduler",
|
|
||||||
service: new CsvImportSchedulerService(),
|
|
||||||
autoStart: true,
|
|
||||||
critical: true, // Auto-restart on failure
|
|
||||||
});
|
|
||||||
|
|
||||||
await manager.startAll();
|
|
||||||
```
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
|
|
||||||
- Automatic restart of failed critical schedulers
|
|
||||||
- Health monitoring across all schedulers
|
|
||||||
- Coordinated start/stop operations
|
|
||||||
- Event aggregation and logging
|
|
||||||
|
|
||||||
### 4. Standalone Scheduler Runner
|
|
||||||
|
|
||||||
Runs individual schedulers as separate processes:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run CSV import scheduler as standalone process
|
|
||||||
npx tsx lib/services/schedulers/StandaloneSchedulerRunner.ts --scheduler=csv-import
|
|
||||||
|
|
||||||
# List available schedulers
|
|
||||||
npx tsx lib/services/schedulers/StandaloneSchedulerRunner.ts --list
|
|
||||||
```
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
|
|
||||||
- Independent process execution
|
|
||||||
- Environment variable configuration
|
|
||||||
- Graceful shutdown handling
|
|
||||||
- Health reporting for monitoring
|
|
||||||
|
|
||||||
## Deployment Patterns
|
|
||||||
|
|
||||||
### 1. Single Process (Current Default)
|
|
||||||
|
|
||||||
All schedulers run within the main Next.js server process:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// server.ts
|
|
||||||
import { initializeSchedulers } from "./lib/services/schedulers/ServerSchedulerIntegration";
|
|
||||||
|
|
||||||
await initializeSchedulers();
|
|
||||||
```
|
|
||||||
|
|
||||||
**Pros:**
|
|
||||||
|
|
||||||
- Simple deployment
|
|
||||||
- Lower resource usage
|
|
||||||
- Easy local development
|
|
||||||
|
|
||||||
**Cons:**
|
|
||||||
|
|
||||||
- Limited scalability
|
|
||||||
- Single point of failure
|
|
||||||
- Resource contention
|
|
||||||
|
|
||||||
### 2. Separate Processes
|
|
||||||
|
|
||||||
Each scheduler runs as an independent process:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Terminal 1: Main application
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# Terminal 2: CSV Import Scheduler
|
|
||||||
npm run scheduler:csv-import
|
|
||||||
|
|
||||||
# Terminal 3: Session Processing Scheduler
|
|
||||||
npm run scheduler:session-processing
|
|
||||||
```
|
|
||||||
|
|
||||||
**Pros:**
|
|
||||||
|
|
||||||
- Independent scaling
|
|
||||||
- Fault isolation
|
|
||||||
- Resource optimization per scheduler
|
|
||||||
|
|
||||||
**Cons:**
|
|
||||||
|
|
||||||
- More complex deployment
|
|
||||||
- Higher resource overhead
|
|
||||||
- Inter-process coordination needed
|
|
||||||
|
|
||||||
### 3. Container Orchestration (Recommended for Production)
|
|
||||||
|
|
||||||
Each scheduler runs in separate containers managed by Kubernetes/Docker Swarm:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# docker-compose.yml
|
|
||||||
version: "3.8"
|
|
||||||
services:
|
|
||||||
app:
|
|
||||||
build: .
|
|
||||||
environment:
|
|
||||||
- SCHEDULER_ENABLED=false # Disable in-process schedulers
|
|
||||||
|
|
||||||
csv-import-scheduler:
|
|
||||||
build: .
|
|
||||||
command: npx tsx lib/services/schedulers/StandaloneSchedulerRunner.ts --scheduler=csv-import
|
|
||||||
environment:
|
|
||||||
- CSV_IMPORT_INTERVAL=*/10 * * * *
|
|
||||||
- CSV_IMPORT_BATCH_SIZE=10
|
|
||||||
|
|
||||||
session-processing-scheduler:
|
|
||||||
build: .
|
|
||||||
command: npx tsx lib/services/schedulers/StandaloneSchedulerRunner.ts --scheduler=session-processing
|
|
||||||
environment:
|
|
||||||
- SESSION_PROCESSING_INTERVAL=*/5 * * * *
|
|
||||||
```
|
|
||||||
|
|
||||||
**Pros:**
|
|
||||||
|
|
||||||
- Full horizontal scaling
|
|
||||||
- Independent resource allocation
|
|
||||||
- Health monitoring integration
|
|
||||||
- Zero-downtime deployments
|
|
||||||
|
|
||||||
**Cons:**
|
|
||||||
|
|
||||||
- Complex orchestration setup
|
|
||||||
- Network latency considerations
|
|
||||||
- Distributed system challenges
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Global Scheduler Settings
|
|
||||||
SCHEDULER_ENABLED=true
|
|
||||||
SCHEDULER_AUTO_RESTART=true
|
|
||||||
|
|
||||||
# CSV Import Scheduler
|
|
||||||
CSV_IMPORT_INTERVAL="*/10 * * * *"
|
|
||||||
CSV_IMPORT_BATCH_SIZE=10
|
|
||||||
CSV_IMPORT_MAX_CONCURRENT=5
|
|
||||||
CSV_IMPORT_TIMEOUT=300000
|
|
||||||
|
|
||||||
# Import Processing Scheduler
|
|
||||||
IMPORT_PROCESSING_INTERVAL="*/2 * * * *"
|
|
||||||
IMPORT_PROCESSING_TIMEOUT=120000
|
|
||||||
|
|
||||||
# Session Processing Scheduler
|
|
||||||
SESSION_PROCESSING_INTERVAL="*/5 * * * *"
|
|
||||||
SESSION_PROCESSING_BATCH_SIZE=50
|
|
||||||
|
|
||||||
# Batch Processing Scheduler
|
|
||||||
BATCH_PROCESSING_INTERVAL="*/5 * * * *"
|
|
||||||
BATCH_PROCESSING_CHECK_INTERVAL="*/2 * * * *"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Package.json Scripts
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"scripts": {
|
|
||||||
"scheduler:csv-import": "tsx lib/services/schedulers/StandaloneSchedulerRunner.ts --scheduler=csv-import",
|
|
||||||
"scheduler:import-processing": "tsx lib/services/schedulers/StandaloneSchedulerRunner.ts --scheduler=import-processing",
|
|
||||||
"scheduler:session-processing": "tsx lib/services/schedulers/StandaloneSchedulerRunner.ts --scheduler=session-processing",
|
|
||||||
"scheduler:batch-processing": "tsx lib/services/schedulers/StandaloneSchedulerRunner.ts --scheduler=batch-processing"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Health Monitoring
|
|
||||||
|
|
||||||
### Health Check Endpoints
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Overall scheduler health
|
|
||||||
GET /api/admin/schedulers/health
|
|
||||||
|
|
||||||
# Scheduler management
|
|
||||||
GET /api/admin/schedulers
|
|
||||||
POST /api/admin/schedulers
|
|
||||||
```
|
|
||||||
|
|
||||||
### Response Format
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"healthy": true,
|
|
||||||
"status": "healthy",
|
|
||||||
"timestamp": "2024-01-15T10:30:00.000Z",
|
|
||||||
"schedulers": {
|
|
||||||
"total": 4,
|
|
||||||
"running": 4,
|
|
||||||
"errors": 0
|
|
||||||
},
|
|
||||||
"details": {
|
|
||||||
"csv-import": {
|
|
||||||
"status": "RUNNING",
|
|
||||||
"healthy": true,
|
|
||||||
"lastSuccess": "2024-01-15T10:25:00.000Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Kubernetes Integration
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: csv-import-scheduler
|
|
||||||
spec:
|
|
||||||
template:
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: scheduler
|
|
||||||
image: livedash:latest
|
|
||||||
command:
|
|
||||||
[
|
|
||||||
"npx",
|
|
||||||
"tsx",
|
|
||||||
"lib/services/schedulers/StandaloneSchedulerRunner.ts",
|
|
||||||
"--scheduler=csv-import",
|
|
||||||
]
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /api/admin/schedulers/health
|
|
||||||
port: 3000
|
|
||||||
initialDelaySeconds: 30
|
|
||||||
periodSeconds: 10
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /api/admin/schedulers/health
|
|
||||||
port: 3000
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 5
|
|
||||||
```
|
|
||||||
|
|
||||||
## Scaling Strategies
|
|
||||||
|
|
||||||
### 1. Vertical Scaling
|
|
||||||
|
|
||||||
Increase resources for scheduler processes:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# docker-compose.yml
|
|
||||||
csv-import-scheduler:
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: "2.0"
|
|
||||||
memory: 2G
|
|
||||||
reservations:
|
|
||||||
cpus: "1.0"
|
|
||||||
memory: 1G
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Horizontal Scaling
|
|
||||||
|
|
||||||
Run multiple instances of the same scheduler:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Kubernetes
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: csv-import-scheduler
|
|
||||||
spec:
|
|
||||||
replicas: 3 # Multiple instances
|
|
||||||
template:
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: scheduler
|
|
||||||
env:
|
|
||||||
- name: SCHEDULER_INSTANCE_ID
|
|
||||||
valueFrom:
|
|
||||||
fieldRef:
|
|
||||||
fieldPath: metadata.name
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** Ensure scheduler logic handles multiple instances correctly (e.g., using database locks or partitioning).
|
|
||||||
|
|
||||||
### 3. Geographic Distribution
|
|
||||||
|
|
||||||
Deploy schedulers across different regions:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Region-specific scheduling
|
|
||||||
csv-import-scheduler-us:
|
|
||||||
environment:
|
|
||||||
- REGION=us
|
|
||||||
- CSV_COMPANIES_FILTER=region:us
|
|
||||||
|
|
||||||
csv-import-scheduler-eu:
|
|
||||||
environment:
|
|
||||||
- REGION=eu
|
|
||||||
- CSV_COMPANIES_FILTER=region:eu
|
|
||||||
```
|
|
||||||
|
|
||||||
## Migration Guide
|
|
||||||
|
|
||||||
### From Current Architecture
|
|
||||||
|
|
||||||
1. **Phase 1: Extract Schedulers**
|
|
||||||
|
|
||||||
- ✅ Create BaseSchedulerService
|
|
||||||
- ✅ Implement CsvImportSchedulerService
|
|
||||||
- ✅ Create SchedulerManager
|
|
||||||
- ⏳ Implement remaining scheduler services
|
|
||||||
|
|
||||||
2. **Phase 2: Deployment Options**
|
|
||||||
|
|
||||||
- ✅ Add ServerSchedulerIntegration for backwards compatibility
|
|
||||||
- ✅ Create StandaloneSchedulerRunner
|
|
||||||
- ✅ Add health check endpoints
|
|
||||||
|
|
||||||
3. **Phase 3: Container Support**
|
|
||||||
|
|
||||||
- ⏳ Create Dockerfile for scheduler containers
|
|
||||||
- ⏳ Add Kubernetes manifests
|
|
||||||
- ⏳ Implement distributed coordination
|
|
||||||
|
|
||||||
4. **Phase 4: Production Migration**
|
|
||||||
|
|
||||||
- ⏳ Deploy separate scheduler containers
|
|
||||||
- ⏳ Monitor performance and stability
|
|
||||||
- ⏳ Gradually increase horizontal scaling
|
|
||||||
|
|
||||||
### Breaking Changes
|
|
||||||
|
|
||||||
- Scheduler initialization moved from `server.ts` to `ServerSchedulerIntegration`
|
|
||||||
- Individual scheduler functions replaced with service classes
|
|
||||||
- Configuration moved to environment variables
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
1. **Scalability**: Independent scaling of different scheduler types
|
|
||||||
2. **Reliability**: Fault isolation prevents cascading failures
|
|
||||||
3. **Performance**: Optimized resource allocation per scheduler
|
|
||||||
4. **Monitoring**: Granular health checks and metrics
|
|
||||||
5. **Deployment**: Zero-downtime updates and rollbacks
|
|
||||||
6. **Development**: Easier testing and debugging of individual schedulers
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. Implement remaining scheduler services (ImportProcessing, SessionProcessing, BatchProcessing)
|
|
||||||
2. Add distributed coordination for multi-instance schedulers
|
|
||||||
3. Create Kubernetes operators for automatic scaling
|
|
||||||
4. Implement scheduler-specific metrics and dashboards
|
|
||||||
5. Add scheduler performance optimization tools
|
|
||||||
@ -9,26 +9,22 @@ The LiveDash system has two main schedulers that work together to fetch and proc
|
|||||||
|
|
||||||
## Current Status (as of latest check)
|
## Current Status (as of latest check)
|
||||||
|
|
||||||
- **Total sessions**: 107
|
- **Total sessions**: 107
|
||||||
- **Processed sessions**: 0
|
- **Processed sessions**: 0
|
||||||
- **Sessions with transcript**: 0
|
- **Sessions with transcript**: 0
|
||||||
- **Ready for processing**: 0
|
- **Ready for processing**: 0
|
||||||
|
|
||||||
## How the `processed` Field Works
|
## How the `processed` Field Works
|
||||||
|
|
||||||
The ProcessingScheduler picks up sessions where `processed` is **NOT** `true`, which includes:
|
The ProcessingScheduler picks up sessions where `processed` is **NOT** `true`, which includes:
|
||||||
|
|
||||||
- `processed = false`
|
- `processed = false`
|
||||||
- `processed = null`
|
- `processed = null`
|
||||||
|
|
||||||
**Query used:**
|
**Query used:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
{
|
{ processed: { not: true } } // Either false or null
|
||||||
processed: {
|
|
||||||
not: true;
|
|
||||||
}
|
|
||||||
} // Either false or null
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Complete Workflow
|
## Complete Workflow
|
||||||
@ -37,10 +33,10 @@ The ProcessingScheduler picks up sessions where `processed` is **NOT** `true`, w
|
|||||||
|
|
||||||
**What it does:**
|
**What it does:**
|
||||||
|
|
||||||
- Fetches session data from company CSV URLs
|
- Fetches session data from company CSV URLs
|
||||||
- Creates session records in database with basic metadata
|
- Creates session records in database with basic metadata
|
||||||
- Sets `transcriptContent = null` initially
|
- Sets `transcriptContent = null` initially
|
||||||
- Sets `processed = null` initially
|
- Sets `processed = null` initially
|
||||||
|
|
||||||
**Runs:** Every 30 minutes (cron: `*/30 * * * *`)
|
**Runs:** Every 30 minutes (cron: `*/30 * * * *`)
|
||||||
|
|
||||||
@ -48,9 +44,9 @@ The ProcessingScheduler picks up sessions where `processed` is **NOT** `true`, w
|
|||||||
|
|
||||||
**What it does:**
|
**What it does:**
|
||||||
|
|
||||||
- Downloads full transcript content for sessions
|
- Downloads full transcript content for sessions
|
||||||
- Updates `transcriptContent` field with actual conversation data
|
- Updates `transcriptContent` field with actual conversation data
|
||||||
- Sessions remain `processed = null` until AI processing
|
- Sessions remain `processed = null` until AI processing
|
||||||
|
|
||||||
**Runs:** As part of session refresh process
|
**Runs:** As part of session refresh process
|
||||||
|
|
||||||
@ -58,11 +54,11 @@ The ProcessingScheduler picks up sessions where `processed` is **NOT** `true`, w
|
|||||||
|
|
||||||
**What it does:**
|
**What it does:**
|
||||||
|
|
||||||
- Finds sessions with transcript content where `processed != true`
|
- Finds sessions with transcript content where `processed != true`
|
||||||
- Sends transcripts to OpenAI for analysis
|
- Sends transcripts to OpenAI for analysis
|
||||||
- Extracts: sentiment, category, questions, summary, etc.
|
- Extracts: sentiment, category, questions, summary, etc.
|
||||||
- Updates session with processed data
|
- Updates session with processed data
|
||||||
- Sets `processed = true`
|
- Sets `processed = true`
|
||||||
|
|
||||||
**Runs:** Every hour (cron: `0 * * * *`)
|
**Runs:** Every hour (cron: `0 * * * *`)
|
||||||
|
|
||||||
@ -98,42 +94,39 @@ node scripts/manual-triggers.js both
|
|||||||
|
|
||||||
1. **Check if sessions have transcripts:**
|
1. **Check if sessions have transcripts:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node scripts/manual-triggers.js status
|
node scripts/manual-triggers.js status
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **If "Sessions with transcript" is 0:**
|
2. **If "Sessions with transcript" is 0:**
|
||||||
|
- Sessions exist but transcripts haven't been fetched yet
|
||||||
- Sessions exist but transcripts haven't been fetched yet
|
- Run session refresh: `node scripts/manual-triggers.js refresh`
|
||||||
- Run session refresh: `node scripts/manual-triggers.js refresh`
|
|
||||||
|
|
||||||
3. **If "Ready for processing" is 0 but "Sessions with transcript" > 0:**
|
3. **If "Ready for processing" is 0 but "Sessions with transcript" > 0:**
|
||||||
|
- All sessions with transcripts have already been processed
|
||||||
- All sessions with transcripts have already been processed
|
- Check if `OPENAI_API_KEY` is set in environment
|
||||||
- Check if `OPENAI_API_KEY` is set in environment
|
|
||||||
|
|
||||||
### Common Issues
|
### Common Issues
|
||||||
|
|
||||||
#### "No sessions found requiring processing"
|
#### "No sessions found requiring processing"
|
||||||
|
|
||||||
- All sessions with transcripts have been processed (`processed = true`)
|
- All sessions with transcripts have been processed (`processed = true`)
|
||||||
- Or no sessions have transcript content yet
|
- Or no sessions have transcript content yet
|
||||||
|
|
||||||
#### "OPENAI_API_KEY environment variable is not set"
|
#### "OPENAI_API_KEY environment variable is not set"
|
||||||
|
|
||||||
- Add OpenAI API key to `.env.development` file
|
- Add OpenAI API key to `.env.development` file
|
||||||
- Restart the application
|
- Restart the application
|
||||||
|
|
||||||
#### "Error fetching transcript: Unauthorized"
|
#### "Error fetching transcript: Unauthorized"
|
||||||
|
|
||||||
- CSV credentials are incorrect or expired
|
- CSV credentials are incorrect or expired
|
||||||
- Check company CSV username/password in database
|
- Check company CSV username/password in database
|
||||||
|
|
||||||
## Database Field Mapping
|
## Database Field Mapping
|
||||||
|
|
||||||
### Before AI Processing
|
### Before AI Processing
|
||||||
|
|
||||||
<!-- prettier-ignore -->
|
|
||||||
```javascript
|
```javascript
|
||||||
{
|
{
|
||||||
id: "session-uuid",
|
id: "session-uuid",
|
||||||
@ -148,7 +141,6 @@ node scripts/manual-triggers.js status
|
|||||||
|
|
||||||
### After AI Processing
|
### After AI Processing
|
||||||
|
|
||||||
<!-- prettier-ignore -->
|
|
||||||
```javascript
|
```javascript
|
||||||
{
|
{
|
||||||
id: "session-uuid",
|
id: "session-uuid",
|
||||||
@ -171,16 +163,16 @@ node scripts/manual-triggers.js status
|
|||||||
|
|
||||||
### Session Refresh Scheduler
|
### Session Refresh Scheduler
|
||||||
|
|
||||||
- **File**: `lib/scheduler.js`
|
- **File**: `lib/scheduler.js`
|
||||||
- **Frequency**: Every 30 minutes
|
- **Frequency**: Every 30 minutes
|
||||||
- **Cron**: `*/30 * * * *`
|
- **Cron**: `*/30 * * * *`
|
||||||
|
|
||||||
### Processing Scheduler
|
### Processing Scheduler
|
||||||
|
|
||||||
- **File**: `lib/processingScheduler.js`
|
- **File**: `lib/processingScheduler.js`
|
||||||
- **Frequency**: Every hour
|
- **Frequency**: Every hour
|
||||||
- **Cron**: `0 * * * *`
|
- **Cron**: `0 * * * *`
|
||||||
- **Batch size**: 10 sessions per run
|
- **Batch size**: 10 sessions per run
|
||||||
|
|
||||||
## Environment Variables Required
|
## Environment Variables Required
|
||||||
|
|
||||||
@ -200,20 +192,20 @@ NEXTAUTH_URL="http://localhost:3000"
|
|||||||
|
|
||||||
1. **Trigger session refresh** to fetch transcripts:
|
1. **Trigger session refresh** to fetch transcripts:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node scripts/manual-triggers.js refresh
|
node scripts/manual-triggers.js refresh
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Check status** to see if transcripts were fetched:
|
2. **Check status** to see if transcripts were fetched:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node scripts/manual-triggers.js status
|
node scripts/manual-triggers.js status
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Trigger processing** if transcripts are available:
|
3. **Trigger processing** if transcripts are available:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node scripts/manual-triggers.js process
|
node scripts/manual-triggers.js process
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **View results** in the dashboard session details pages
|
4. **View results** in the dashboard session details pages
|
||||||
|
|||||||
@ -1,263 +0,0 @@
|
|||||||
# Security Audit Logging System
|
|
||||||
|
|
||||||
This document provides an overview of the comprehensive security audit logging system implemented in LiveDash.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The security audit logging system provides comprehensive tracking of security-critical events, authentication activities, and administrative actions across the platform. It is designed for compliance, incident investigation, and security monitoring.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### 1. Comprehensive Event Tracking
|
|
||||||
|
|
||||||
The system logs the following event types:
|
|
||||||
|
|
||||||
- **Authentication Events**: Login attempts, password changes, session management
|
|
||||||
- **Authorization Events**: Permission checks, access denied events
|
|
||||||
- **User Management**: User creation, modification, deletion, invitations
|
|
||||||
- **Company Management**: Company suspension, settings changes
|
|
||||||
- **Rate Limiting**: Abuse prevention and rate limit violations
|
|
||||||
- **CSRF Protection**: Cross-site request forgery protection events
|
|
||||||
- **Security Headers**: Security header violations
|
|
||||||
- **Password Reset**: Password reset flows and token validation
|
|
||||||
- **Platform Admin**: Administrative activities by platform users
|
|
||||||
- **Data Privacy**: Data export and privacy-related events
|
|
||||||
- **System Configuration**: System setting changes
|
|
||||||
- **API Security**: API-related security events
|
|
||||||
|
|
||||||
### 2. Structured Logging
|
|
||||||
|
|
||||||
Each audit log entry includes:
|
|
||||||
|
|
||||||
- **Event Type**: Categorizes the security event
|
|
||||||
- **Action**: Specific action performed
|
|
||||||
- **Outcome**: Success, failure, blocked, rate limited, or suspicious
|
|
||||||
- **Severity**: Info, low, medium, high, or critical
|
|
||||||
- **Context**: User ID, company ID, platform user ID, IP address, user agent
|
|
||||||
- **Metadata**: Structured additional information
|
|
||||||
- **Timestamp**: Immutable timestamp for chronological ordering
|
|
||||||
|
|
||||||
### 3. Multi-Tenant Security
|
|
||||||
|
|
||||||
- Company-scoped audit logs ensure data isolation
|
|
||||||
- Platform admin actions tracked separately
|
|
||||||
- Role-based access controls for audit log viewing
|
|
||||||
|
|
||||||
### 4. Log Retention and Management
|
|
||||||
|
|
||||||
- **Configurable Retention Policies**: Different retention periods based on event type and severity
|
|
||||||
- **Automatic Archival**: Critical and high-severity events archived before deletion
|
|
||||||
- **Scheduled Cleanup**: Weekly automated retention policy execution
|
|
||||||
- **Manual Controls**: Admin interface for manual retention execution
|
|
||||||
|
|
||||||
### 5. Administrative Interface
|
|
||||||
|
|
||||||
- **Audit Log Viewer**: Comprehensive filtering and search capabilities
|
|
||||||
- **Retention Management**: View statistics and execute retention policies
|
|
||||||
- **Real-time Monitoring**: Track security events as they occur
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Core Components
|
|
||||||
|
|
||||||
1. **SecurityAuditLogger** (`lib/securityAuditLogger.ts`): Centralized logging service
|
|
||||||
2. **AuditLogRetentionManager** (`lib/auditLogRetention.ts`): Retention policy management
|
|
||||||
3. **AuditLogScheduler** (`lib/auditLogScheduler.ts`): Scheduled retention execution
|
|
||||||
4. **Admin API** (`app/api/admin/audit-logs/`): REST API for audit log access
|
|
||||||
5. **Admin UI** (`app/dashboard/audit-logs/`): Administrative interface
|
|
||||||
|
|
||||||
### Database Schema
|
|
||||||
|
|
||||||
The `SecurityAuditLog` model includes:
|
|
||||||
|
|
||||||
```prisma
|
|
||||||
model SecurityAuditLog {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
eventType SecurityEventType
|
|
||||||
action String @db.VarChar(255)
|
|
||||||
outcome AuditOutcome
|
|
||||||
severity AuditSeverity @default(INFO)
|
|
||||||
userId String?
|
|
||||||
companyId String?
|
|
||||||
platformUserId String?
|
|
||||||
ipAddress String? @db.Inet
|
|
||||||
userAgent String?
|
|
||||||
country String? @db.VarChar(3)
|
|
||||||
metadata Json?
|
|
||||||
errorMessage String?
|
|
||||||
sessionId String? @db.VarChar(255)
|
|
||||||
requestId String? @db.VarChar(255)
|
|
||||||
timestamp DateTime @default(now()) @db.Timestamptz(6)
|
|
||||||
|
|
||||||
// Relations and indexes...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Logging Security Events
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { securityAuditLogger, AuditOutcome } from "./lib/securityAuditLogger";
|
|
||||||
|
|
||||||
// Log authentication event
|
|
||||||
await securityAuditLogger.logAuthentication("user_login_success", AuditOutcome.SUCCESS, {
|
|
||||||
userId: "user-123",
|
|
||||||
companyId: "company-456",
|
|
||||||
ipAddress: "192.168.1.***",
|
|
||||||
userAgent: "Mozilla/5.0 (masked)",
|
|
||||||
metadata: { loginMethod: "password" },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log authorization failure
|
|
||||||
await securityAuditLogger.logAuthorization(
|
|
||||||
"admin_access_denied",
|
|
||||||
AuditOutcome.BLOCKED,
|
|
||||||
{
|
|
||||||
userId: "user-123",
|
|
||||||
companyId: "company-456",
|
|
||||||
metadata: { requiredRole: "ADMIN", currentRole: "USER" },
|
|
||||||
},
|
|
||||||
"Insufficient permissions for admin access"
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Viewing Audit Logs
|
|
||||||
|
|
||||||
Administrators can access audit logs through:
|
|
||||||
|
|
||||||
1. **Dashboard UI**: Navigate to "Audit Logs" in the sidebar
|
|
||||||
2. **API Access**: GET `/api/admin/audit-logs` with filtering parameters
|
|
||||||
3. **Retention Management**: GET/POST `/api/admin/audit-logs/retention`
|
|
||||||
|
|
||||||
### Filtering Options
|
|
||||||
|
|
||||||
- Event type (authentication, authorization, etc.)
|
|
||||||
- Outcome (success, failure, blocked, etc.)
|
|
||||||
- Severity level (info, low, medium, high, critical)
|
|
||||||
- Date range
|
|
||||||
- User ID
|
|
||||||
- Pagination support
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Enable/disable audit logging (default: true)
|
|
||||||
AUDIT_LOGGING_ENABLED=true
|
|
||||||
|
|
||||||
# Enable/disable retention scheduler (default: true)
|
|
||||||
AUDIT_LOG_RETENTION_ENABLED=true
|
|
||||||
|
|
||||||
# Retention schedule (cron format, default: 2 AM every Sunday)
|
|
||||||
AUDIT_LOG_RETENTION_SCHEDULE="0 2 * * 0"
|
|
||||||
|
|
||||||
# Dry run mode for retention (default: false)
|
|
||||||
AUDIT_LOG_RETENTION_DRY_RUN=false
|
|
||||||
```
|
|
||||||
|
|
||||||
### Default Retention Policies
|
|
||||||
|
|
||||||
1. **Critical Events**: 7 years retention with archival
|
|
||||||
2. **High Severity Events**: 3 years retention with archival
|
|
||||||
3. **Authentication Events**: 2 years retention with archival
|
|
||||||
4. **Platform Admin Events**: 3 years retention with archival
|
|
||||||
5. **User Management Events**: 2 years retention with archival
|
|
||||||
6. **General Events**: 1 year retention without archival
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
### Data Protection
|
|
||||||
|
|
||||||
- **IP Address Storage**: Client IP addresses stored for geographic analysis
|
|
||||||
- **Sensitive Data Redaction**: Passwords, tokens, and emails marked as `[REDACTED]`
|
|
||||||
- **Metadata Sanitization**: Complex objects sanitized to prevent data leakage
|
|
||||||
|
|
||||||
### Access Controls
|
|
||||||
|
|
||||||
- **Admin-Only Access**: Only users with `ADMIN` role can view audit logs
|
|
||||||
- **Company Isolation**: Users can only view logs for their own company
|
|
||||||
- **Platform Separation**: Platform admin logs tracked separately
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
|
|
||||||
- **Async Logging**: All logging operations are asynchronous to avoid blocking
|
|
||||||
- **Error Handling**: Logging failures don't affect application functionality
|
|
||||||
- **Indexed Queries**: Database indexes optimize common query patterns
|
|
||||||
- **Batch Operations**: Retention policies use batch operations for efficiency
|
|
||||||
|
|
||||||
## Compliance Features
|
|
||||||
|
|
||||||
### Audit Standards
|
|
||||||
|
|
||||||
- **Immutable Records**: Audit logs cannot be modified after creation
|
|
||||||
- **Chronological Ordering**: Precise timestamps for event sequencing
|
|
||||||
- **Non-Repudiation**: User actions clearly attributed and timestamped
|
|
||||||
- **Comprehensive Coverage**: All security-relevant events logged
|
|
||||||
|
|
||||||
### Reporting
|
|
||||||
|
|
||||||
- **Event Statistics**: Summary statistics by event type, severity, and time period
|
|
||||||
- **Export Capabilities**: Structured data export for compliance reporting
|
|
||||||
- **Retention Tracking**: Detailed logging of retention policy execution
|
|
||||||
|
|
||||||
## Monitoring and Alerting
|
|
||||||
|
|
||||||
### System Health
|
|
||||||
|
|
||||||
- **Scheduler Status**: Monitor retention scheduler health
|
|
||||||
- **Error Tracking**: Log retention and audit logging errors
|
|
||||||
- **Performance Metrics**: Track logging performance and database impact
|
|
||||||
|
|
||||||
### Security Monitoring
|
|
||||||
|
|
||||||
- **Failed Authentication Patterns**: Track repeated login failures
|
|
||||||
- **Privilege Escalation**: Monitor administrative action patterns
|
|
||||||
- **Suspicious Activity**: Identify unusual access patterns
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
1. **Audit Logging Disabled**: Check `AUDIT_LOGGING_ENABLED` environment variable
|
|
||||||
2. **Retention Not Running**: Verify `AUDIT_LOG_RETENTION_ENABLED` and scheduler status
|
|
||||||
3. **Access Denied**: Ensure user has `ADMIN` role for audit log access
|
|
||||||
4. **Performance Issues**: Review retention policies and database indexes
|
|
||||||
|
|
||||||
### Debug Information
|
|
||||||
|
|
||||||
- Check application logs for scheduler startup messages
|
|
||||||
- Monitor database query performance for audit log operations
|
|
||||||
- Review retention policy validation warnings
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
1. **Always use the centralized logger**: Don't bypass the `securityAuditLogger`
|
|
||||||
2. **Include relevant context**: Provide user, company, and IP information
|
|
||||||
3. **Use appropriate severity levels**: Follow the severity assignment guidelines
|
|
||||||
4. **Sanitize sensitive data**: Use `createAuditMetadata()` for safe metadata
|
|
||||||
|
|
||||||
### Operations
|
|
||||||
|
|
||||||
1. **Regular retention review**: Monitor retention statistics and adjust policies
|
|
||||||
2. **Archive critical data**: Ensure important logs are archived before deletion
|
|
||||||
3. **Monitor storage usage**: Track audit log database growth
|
|
||||||
4. **Test restoration**: Verify archived data can be restored when needed
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
### Planned Features
|
|
||||||
|
|
||||||
- **Real-time Alerting**: Immediate notifications for critical security events
|
|
||||||
- **Advanced Analytics**: ML-based anomaly detection and pattern recognition
|
|
||||||
- **Export Formats**: Additional export formats for compliance reporting
|
|
||||||
- **External Integration**: SIEM and security tool integrations
|
|
||||||
|
|
||||||
### Performance Optimizations
|
|
||||||
|
|
||||||
- **Log Partitioning**: Database partitioning for improved query performance
|
|
||||||
- **Compression**: Log compression for storage efficiency
|
|
||||||
- **Streaming**: Real-time log streaming for external systems
|
|
||||||
@ -1,231 +0,0 @@
|
|||||||
# HTTP Security Headers Implementation
|
|
||||||
|
|
||||||
This document describes the comprehensive HTTP security headers implementation in LiveDash-Node to protect against XSS, clickjacking, and other web vulnerabilities.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The application implements multiple layers of HTTP security headers to provide defense-in-depth protection against common web vulnerabilities identified in OWASP Top 10 and security best practices.
|
|
||||||
|
|
||||||
## Implemented Security Headers
|
|
||||||
|
|
||||||
### Core Security Headers
|
|
||||||
|
|
||||||
#### X-Content-Type-Options: nosniff
|
|
||||||
|
|
||||||
- **Purpose**: Prevents MIME type sniffing attacks
|
|
||||||
- **Protection**: Stops browsers from interpreting files as different MIME types than declared
|
|
||||||
- **Value**: `nosniff`
|
|
||||||
|
|
||||||
#### X-Frame-Options: DENY
|
|
||||||
|
|
||||||
- **Purpose**: Prevents clickjacking attacks
|
|
||||||
- **Protection**: Blocks embedding the site in frames/iframes
|
|
||||||
- **Value**: `DENY`
|
|
||||||
|
|
||||||
#### X-XSS-Protection: 1; mode=block
|
|
||||||
|
|
||||||
- **Purpose**: Enables XSS protection in legacy browsers
|
|
||||||
- **Protection**: Activates built-in XSS filtering (primarily for older browsers)
|
|
||||||
- **Value**: `1; mode=block`
|
|
||||||
|
|
||||||
#### Referrer-Policy: strict-origin-when-cross-origin
|
|
||||||
|
|
||||||
- **Purpose**: Controls referrer information leakage
|
|
||||||
- **Protection**: Limits referrer data sent to external sites
|
|
||||||
- **Value**: `strict-origin-when-cross-origin`
|
|
||||||
|
|
||||||
#### X-DNS-Prefetch-Control: off
|
|
||||||
|
|
||||||
- **Purpose**: Prevents DNS rebinding attacks
|
|
||||||
- **Protection**: Disables DNS prefetching to reduce attack surface
|
|
||||||
- **Value**: `off`
|
|
||||||
|
|
||||||
### Content Security Policy (CSP)
|
|
||||||
|
|
||||||
Comprehensive CSP implementation with the following directives:
|
|
||||||
|
|
||||||
```
|
|
||||||
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Key CSP Directives
|
|
||||||
|
|
||||||
- **default-src 'self'**: Restrictive default for all resource types
|
|
||||||
- **script-src 'self' 'unsafe-eval' 'unsafe-inline'**: Allows Next.js dev tools and React functionality
|
|
||||||
- **style-src 'self' 'unsafe-inline'**: Enables TailwindCSS and component styles
|
|
||||||
- **img-src 'self' data: https:**: Allows secure image sources
|
|
||||||
- **frame-ancestors 'none'**: Prevents embedding (reinforces X-Frame-Options)
|
|
||||||
- **object-src 'none'**: Blocks dangerous plugins and embeds
|
|
||||||
- **upgrade-insecure-requests**: Automatically upgrades HTTP to HTTPS
|
|
||||||
|
|
||||||
### Permissions Policy
|
|
||||||
|
|
||||||
Controls browser feature access:
|
|
||||||
|
|
||||||
```
|
|
||||||
Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=(), browsing-topics=()
|
|
||||||
```
|
|
||||||
|
|
||||||
- **camera=()**: Disables camera access
|
|
||||||
- **microphone=()**: Disables microphone access
|
|
||||||
- **geolocation=()**: Disables location tracking
|
|
||||||
- **interest-cohort=()**: Blocks FLoC (privacy protection)
|
|
||||||
- **browsing-topics=()**: Blocks Topics API (privacy protection)
|
|
||||||
|
|
||||||
### Strict Transport Security (HSTS)
|
|
||||||
|
|
||||||
**Production Only**: `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload`
|
|
||||||
|
|
||||||
- **max-age=31536000**: 1 year HSTS policy
|
|
||||||
- **includeSubDomains**: Applies to all subdomains
|
|
||||||
- **preload**: Ready for HSTS preload list inclusion
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Next.js Configuration
|
|
||||||
|
|
||||||
Headers are configured in `next.config.js`:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
headers: async () => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: "/(.*)",
|
|
||||||
headers: [
|
|
||||||
// Security headers configuration
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: "/(.*)",
|
|
||||||
headers:
|
|
||||||
process.env.NODE_ENV === "production"
|
|
||||||
? [
|
|
||||||
// HSTS header for production only
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment-Specific Behavior
|
|
||||||
|
|
||||||
- **Development**: All headers except HSTS
|
|
||||||
- **Production**: All headers including HSTS
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Unit Tests
|
|
||||||
|
|
||||||
Location: `tests/unit/http-security-headers.test.ts`
|
|
||||||
|
|
||||||
Tests cover:
|
|
||||||
|
|
||||||
- Individual header validation
|
|
||||||
- CSP directive verification
|
|
||||||
- Permissions Policy validation
|
|
||||||
- Environment-specific configuration
|
|
||||||
- Next.js compatibility checks
|
|
||||||
|
|
||||||
### Integration Tests
|
|
||||||
|
|
||||||
Location: `tests/integration/security-headers-basic.test.ts`
|
|
||||||
|
|
||||||
Tests cover:
|
|
||||||
|
|
||||||
- Next.js configuration validation
|
|
||||||
- Header generation verification
|
|
||||||
- Environment-based header differences
|
|
||||||
|
|
||||||
### Manual Testing
|
|
||||||
|
|
||||||
Use the security headers testing script:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Test local development server
|
|
||||||
pnpm test:security-headers http://localhost:3000
|
|
||||||
|
|
||||||
# Test production deployment
|
|
||||||
pnpm test:security-headers https://your-domain.com
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Benefits
|
|
||||||
|
|
||||||
### Protection Against OWASP Top 10
|
|
||||||
|
|
||||||
1. **A03:2021 - Injection**: CSP prevents script injection
|
|
||||||
2. **A05:2021 - Security Misconfiguration**: Comprehensive headers reduce attack surface
|
|
||||||
3. **A06:2021 - Vulnerable Components**: CSP limits execution context
|
|
||||||
4. **A07:2021 - Identification and Authentication Failures**: HSTS prevents downgrade attacks
|
|
||||||
|
|
||||||
### Additional Security Benefits
|
|
||||||
|
|
||||||
- **Clickjacking Protection**: X-Frame-Options + CSP frame-ancestors
|
|
||||||
- **MIME Sniffing Prevention**: X-Content-Type-Options
|
|
||||||
- **Information Leakage Reduction**: Referrer-Policy
|
|
||||||
- **Privacy Protection**: Permissions Policy restrictions
|
|
||||||
- **Transport Security**: HSTS enforcement
|
|
||||||
|
|
||||||
## Maintenance
|
|
||||||
|
|
||||||
### Regular Reviews
|
|
||||||
|
|
||||||
1. **Quarterly CSP Review**: Analyze CSP violations and tighten policies
|
|
||||||
2. **Annual Header Audit**: Review new security headers and standards
|
|
||||||
3. **Dependency Updates**: Ensure compatibility with framework updates
|
|
||||||
|
|
||||||
### Monitoring
|
|
||||||
|
|
||||||
- Monitor CSP violation reports (when implemented)
|
|
||||||
- Use online tools like securityheaders.com for validation
|
|
||||||
- Include security header tests in CI/CD pipeline
|
|
||||||
|
|
||||||
### Future Enhancements
|
|
||||||
|
|
||||||
Planned improvements:
|
|
||||||
|
|
||||||
1. CSP violation reporting endpoint
|
|
||||||
2. Nonce-based CSP for inline scripts
|
|
||||||
3. Additional Permissions Policy restrictions
|
|
||||||
4. Content-Type validation middleware
|
|
||||||
|
|
||||||
## Compatibility
|
|
||||||
|
|
||||||
### Next.js Compatibility
|
|
||||||
|
|
||||||
Headers are configured to be compatible with:
|
|
||||||
|
|
||||||
- Next.js 15+ App Router
|
|
||||||
- React 19 development tools
|
|
||||||
- TailwindCSS 4 styling system
|
|
||||||
- Development hot reload functionality
|
|
||||||
|
|
||||||
### Browser Support
|
|
||||||
|
|
||||||
Security headers are supported by:
|
|
||||||
|
|
||||||
- All modern browsers (Chrome 60+, Firefox 60+, Safari 12+)
|
|
||||||
- Graceful degradation for older browsers
|
|
||||||
- Progressive enhancement approach
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
1. **CSP Violations**: Check browser console for CSP errors
|
|
||||||
2. **Styling Issues**: Verify style-src allows 'unsafe-inline'
|
|
||||||
3. **Script Errors**: Ensure script-src permits necessary scripts
|
|
||||||
4. **Development Issues**: Use `pnpm dev:next-only` to isolate Next.js
|
|
||||||
|
|
||||||
### Debug Tools
|
|
||||||
|
|
||||||
- Browser DevTools Security tab
|
|
||||||
- CSP Evaluator: <https://csp-evaluator.withgoogle.com/>
|
|
||||||
- Security Headers Scanner: <https://securityheaders.com/>
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [OWASP Secure Headers Project](https://owasp.org/www-project-secure-headers/)
|
|
||||||
- [MDN Security Headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers#security)
|
|
||||||
- [Next.js Security Headers](https://nextjs.org/docs/app/api-reference/config/headers)
|
|
||||||
- [Content Security Policy Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP)
|
|
||||||
@ -1,457 +0,0 @@
|
|||||||
# Security Monitoring and Alerting System
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The Security Monitoring and Alerting System provides comprehensive real-time security monitoring, anomaly detection, and threat alerting for the LiveDash-Node application. It integrates with the existing audit logging system to provide proactive security monitoring and incident response capabilities.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Core Components
|
|
||||||
|
|
||||||
1. **Security Monitoring Service** (`lib/securityMonitoring.ts`)
|
|
||||||
|
|
||||||
- Real-time event processing
|
|
||||||
- Anomaly detection algorithms
|
|
||||||
- Alert generation and management
|
|
||||||
- Security score calculation
|
|
||||||
- Threat level assessment
|
|
||||||
|
|
||||||
2. **Enhanced Security Logging** (`enhancedSecurityLog`)
|
|
||||||
|
|
||||||
- Integrates with existing audit logger
|
|
||||||
- Processes events through monitoring system
|
|
||||||
- Triggers immediate threat detection
|
|
||||||
|
|
||||||
3. **API Endpoints** (`app/api/admin/security-monitoring/`)
|
|
||||||
|
|
||||||
- `/api/admin/security-monitoring` - Main metrics and configuration
|
|
||||||
- `/api/admin/security-monitoring/alerts` - Alert management
|
|
||||||
- `/api/admin/security-monitoring/export` - Data export
|
|
||||||
- `/api/admin/security-monitoring/threat-analysis` - Threat analysis
|
|
||||||
|
|
||||||
4. **Dashboard UI** (`app/platform/security/page.tsx`)
|
|
||||||
|
|
||||||
- Real-time security metrics
|
|
||||||
- Active alerts management
|
|
||||||
- Threat analysis visualization
|
|
||||||
- Configuration management
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### Real-time Monitoring
|
|
||||||
|
|
||||||
- **Authentication Events**: Login attempts, failures, brute force attacks
|
|
||||||
- **Rate Limiting**: Excessive request patterns, API abuse
|
|
||||||
- **Admin Activity**: Unusual administrative actions
|
|
||||||
- **Geographic Anomalies**: Logins from unusual locations
|
|
||||||
- **Temporal Anomalies**: Activity spikes outside normal patterns
|
|
||||||
|
|
||||||
### Alert Types
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
enum AlertType {
|
|
||||||
AUTHENTICATION_ANOMALY = "AUTHENTICATION_ANOMALY",
|
|
||||||
RATE_LIMIT_BREACH = "RATE_LIMIT_BREACH",
|
|
||||||
MULTIPLE_FAILED_LOGINS = "MULTIPLE_FAILED_LOGINS",
|
|
||||||
SUSPICIOUS_IP_ACTIVITY = "SUSPICIOUS_IP_ACTIVITY",
|
|
||||||
PRIVILEGE_ESCALATION = "PRIVILEGE_ESCALATION",
|
|
||||||
DATA_BREACH_ATTEMPT = "DATA_BREACH_ATTEMPT",
|
|
||||||
CSRF_ATTACK = "CSRF_ATTACK",
|
|
||||||
CSP_VIOLATION_SPIKE = "CSP_VIOLATION_SPIKE",
|
|
||||||
ACCOUNT_ENUMERATION = "ACCOUNT_ENUMERATION",
|
|
||||||
BRUTE_FORCE_ATTACK = "BRUTE_FORCE_ATTACK",
|
|
||||||
UNUSUAL_ADMIN_ACTIVITY = "UNUSUAL_ADMIN_ACTIVITY",
|
|
||||||
GEOLOCATION_ANOMALY = "GEOLOCATION_ANOMALY",
|
|
||||||
MASS_DATA_ACCESS = "MASS_DATA_ACCESS",
|
|
||||||
SUSPICIOUS_USER_AGENT = "SUSPICIOUS_USER_AGENT",
|
|
||||||
SESSION_HIJACKING = "SESSION_HIJACKING",
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Anomaly Detection
|
|
||||||
|
|
||||||
The system implements several anomaly detection algorithms:
|
|
||||||
|
|
||||||
1. **Geographic Anomaly Detection**
|
|
||||||
|
|
||||||
- Detects logins from unusual countries
|
|
||||||
- Compares against historical user patterns
|
|
||||||
- Confidence scoring based on deviation
|
|
||||||
|
|
||||||
2. **Temporal Anomaly Detection**
|
|
||||||
|
|
||||||
- Identifies activity spikes during unusual hours
|
|
||||||
- Compares current activity to historical averages
|
|
||||||
- Configurable thresholds for different event types
|
|
||||||
|
|
||||||
3. **Behavioral Anomaly Detection**
|
|
||||||
|
|
||||||
- Multiple failed login attempts
|
|
||||||
- Rapid succession of actions
|
|
||||||
- Pattern deviation analysis
|
|
||||||
|
|
||||||
### Security Scoring
|
|
||||||
|
|
||||||
The system calculates a real-time security score (0-100) based on:
|
|
||||||
|
|
||||||
- Critical security events (weight: 25)
|
|
||||||
- Active unresolved alerts (weight: 30)
|
|
||||||
- High-severity threats (weight: 20)
|
|
||||||
- Overall event volume (weight: 15)
|
|
||||||
- System stability factors (weight: 10)
|
|
||||||
|
|
||||||
### Threat Levels
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
enum ThreatLevel {
|
|
||||||
LOW = "LOW", // Score: 85-100
|
|
||||||
MODERATE = "MODERATE", // Score: 70-84
|
|
||||||
HIGH = "HIGH", // Score: 50-69
|
|
||||||
CRITICAL = "CRITICAL", // Score: 0-49
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Default Thresholds
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const defaultThresholds = {
|
|
||||||
failedLoginsPerMinute: 5,
|
|
||||||
failedLoginsPerHour: 20,
|
|
||||||
rateLimitViolationsPerMinute: 10,
|
|
||||||
cspViolationsPerMinute: 15,
|
|
||||||
adminActionsPerHour: 25,
|
|
||||||
massDataAccessThreshold: 100,
|
|
||||||
suspiciousIPThreshold: 10,
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Alerting Configuration
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const alertingConfig = {
|
|
||||||
enabled: true,
|
|
||||||
channels: ["EMAIL", "WEBHOOK", "SLACK", "DISCORD", "PAGERDUTY"],
|
|
||||||
suppressDuplicateMinutes: 10,
|
|
||||||
escalationTimeoutMinutes: 60,
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data Retention
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const retentionConfig = {
|
|
||||||
alertRetentionDays: 90,
|
|
||||||
metricsRetentionDays: 365,
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Usage
|
|
||||||
|
|
||||||
### Get Security Metrics
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const response = await fetch(
|
|
||||||
"/api/admin/security-monitoring?startDate=2024-01-01T00:00:00Z&endDate=2024-01-02T00:00:00Z"
|
|
||||||
);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
console.log(data.metrics.securityScore); // 0-100
|
|
||||||
console.log(data.metrics.threatLevel); // LOW, MODERATE, HIGH, CRITICAL
|
|
||||||
console.log(data.alerts); // Active alerts array
|
|
||||||
```
|
|
||||||
|
|
||||||
### Acknowledge Alert
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
await fetch("/api/admin/security-monitoring/alerts", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
alertId: "alert-123",
|
|
||||||
action: "acknowledge",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Export Security Data
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Export alerts as CSV
|
|
||||||
const response = await fetch(
|
|
||||||
"/api/admin/security-monitoring/export?format=csv&type=alerts&startDate=2024-01-01T00:00:00Z&endDate=2024-01-02T00:00:00Z"
|
|
||||||
);
|
|
||||||
const csvData = await response.text();
|
|
||||||
|
|
||||||
// Export metrics as JSON
|
|
||||||
const response = await fetch(
|
|
||||||
"/api/admin/security-monitoring/export?format=json&type=metrics&startDate=2024-01-01T00:00:00Z&endDate=2024-01-02T00:00:00Z"
|
|
||||||
);
|
|
||||||
const jsonData = await response.json();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Perform Threat Analysis
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const analysis = await fetch("/api/admin/security-monitoring/threat-analysis", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
ipAddress: "192.168.1.***",
|
|
||||||
timeRange: {
|
|
||||||
start: "2024-01-01T00:00:00Z",
|
|
||||||
end: "2024-01-02T00:00:00Z",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await analysis.json();
|
|
||||||
console.log(data.ipThreatAnalysis.threatLevel);
|
|
||||||
console.log(data.ipThreatAnalysis.riskFactors);
|
|
||||||
console.log(data.ipThreatAnalysis.recommendations);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integration with Existing Systems
|
|
||||||
|
|
||||||
### Enhanced Security Logging
|
|
||||||
|
|
||||||
Replace existing `securityAuditLogger.log()` calls with `enhancedSecurityLog()`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Before
|
|
||||||
await securityAuditLogger.logAuthentication(
|
|
||||||
"login_attempt",
|
|
||||||
AuditOutcome.FAILURE,
|
|
||||||
context,
|
|
||||||
"Invalid password"
|
|
||||||
);
|
|
||||||
|
|
||||||
// After
|
|
||||||
await enhancedSecurityLog(
|
|
||||||
SecurityEventType.AUTHENTICATION,
|
|
||||||
"login_attempt",
|
|
||||||
AuditOutcome.FAILURE,
|
|
||||||
context,
|
|
||||||
AuditSeverity.HIGH,
|
|
||||||
"Invalid password",
|
|
||||||
{
|
|
||||||
attemptType: "invalid_password",
|
|
||||||
endpoint: "/api/auth/signin",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rate Limiting Integration
|
|
||||||
|
|
||||||
The system automatically integrates with existing rate limiting middleware:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// middleware/authRateLimit.ts
|
|
||||||
await enhancedSecurityLog(
|
|
||||||
SecurityEventType.RATE_LIMITING,
|
|
||||||
"auth_rate_limit_exceeded",
|
|
||||||
AuditOutcome.RATE_LIMITED,
|
|
||||||
context,
|
|
||||||
AuditSeverity.HIGH,
|
|
||||||
"Authentication rate limit exceeded"
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dashboard Features
|
|
||||||
|
|
||||||
### Security Overview
|
|
||||||
|
|
||||||
- Real-time security score (0-100)
|
|
||||||
- Current threat level indicator
|
|
||||||
- Active alerts count
|
|
||||||
- Security events summary
|
|
||||||
|
|
||||||
### Alert Management
|
|
||||||
|
|
||||||
- View active and historical alerts
|
|
||||||
- Filter by severity and type
|
|
||||||
- Acknowledge alerts with tracking
|
|
||||||
- Detailed alert context and metadata
|
|
||||||
|
|
||||||
### Threat Analysis
|
|
||||||
|
|
||||||
- Geographic distribution of events
|
|
||||||
- Top threat types and patterns
|
|
||||||
- User risk scoring
|
|
||||||
- IP threat level analysis
|
|
||||||
|
|
||||||
### Configuration Management
|
|
||||||
|
|
||||||
- Adjust detection thresholds
|
|
||||||
- Configure alerting channels
|
|
||||||
- Set data retention policies
|
|
||||||
- Export capabilities
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
### Memory Management
|
|
||||||
|
|
||||||
- Event buffer limited to 1 hour of data
|
|
||||||
- Automatic cleanup of old alerts (configurable)
|
|
||||||
- Efficient in-memory storage for real-time analysis
|
|
||||||
|
|
||||||
### Database Impact
|
|
||||||
|
|
||||||
- Leverages existing audit log indexes
|
|
||||||
- Optimized queries for time-range filtering
|
|
||||||
- Background processing to avoid blocking operations
|
|
||||||
|
|
||||||
### Scalability
|
|
||||||
|
|
||||||
- Stateless architecture (except for buffering)
|
|
||||||
- Horizontal scaling support
|
|
||||||
- Configurable processing intervals
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
### Access Control
|
|
||||||
|
|
||||||
- Platform admin authentication required
|
|
||||||
- Role-based access to security endpoints
|
|
||||||
- Audit logging of all monitoring activities
|
|
||||||
|
|
||||||
### Data Privacy
|
|
||||||
|
|
||||||
- Sensitive data redaction in logs
|
|
||||||
- IP address anonymization options
|
|
||||||
- Configurable data retention periods
|
|
||||||
|
|
||||||
### Alert Suppression
|
|
||||||
|
|
||||||
- Duplicate alert suppression (configurable window)
|
|
||||||
- Rate limiting on alert generation
|
|
||||||
- Escalation policies for critical threats
|
|
||||||
|
|
||||||
## Monitoring and Maintenance
|
|
||||||
|
|
||||||
### Health Checks
|
|
||||||
|
|
||||||
- Monitor service availability
|
|
||||||
- Check alert generation pipeline
|
|
||||||
- Verify data export functionality
|
|
||||||
|
|
||||||
### Regular Tasks
|
|
||||||
|
|
||||||
- Review and adjust thresholds quarterly
|
|
||||||
- Analyze false positive rates
|
|
||||||
- Update threat detection patterns
|
|
||||||
- Clean up old alert data
|
|
||||||
|
|
||||||
### Performance Metrics
|
|
||||||
|
|
||||||
- Alert response time
|
|
||||||
- False positive/negative rates
|
|
||||||
- System resource usage
|
|
||||||
- User engagement with alerts
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
### Planned Features
|
|
||||||
|
|
||||||
1. **Machine Learning Integration**
|
|
||||||
|
|
||||||
- Behavioral pattern recognition
|
|
||||||
- Adaptive threshold adjustment
|
|
||||||
- Predictive threat modeling
|
|
||||||
|
|
||||||
2. **Advanced Analytics**
|
|
||||||
|
|
||||||
- Threat intelligence integration
|
|
||||||
- Cross-correlation analysis
|
|
||||||
- Risk trend analysis
|
|
||||||
|
|
||||||
3. **Integration Enhancements**
|
|
||||||
|
|
||||||
- SIEM system connectors
|
|
||||||
- Webhook customization
|
|
||||||
- Mobile app notifications
|
|
||||||
|
|
||||||
4. **Automated Response**
|
|
||||||
|
|
||||||
- IP blocking automation
|
|
||||||
- Account suspension workflows
|
|
||||||
- Incident response orchestration
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
**High False Positive Rate**
|
|
||||||
|
|
||||||
- Review and adjust detection thresholds
|
|
||||||
- Analyze user behavior patterns
|
|
||||||
- Consider geographical variations
|
|
||||||
|
|
||||||
**Missing Alerts**
|
|
||||||
|
|
||||||
- Check service configuration
|
|
||||||
- Verify audit log integration
|
|
||||||
- Review threshold settings
|
|
||||||
|
|
||||||
**Performance Issues**
|
|
||||||
|
|
||||||
- Monitor memory usage
|
|
||||||
- Adjust cleanup intervals
|
|
||||||
- Optimize database queries
|
|
||||||
|
|
||||||
**Export Failures**
|
|
||||||
|
|
||||||
- Check file permissions
|
|
||||||
- Verify date range validity
|
|
||||||
- Monitor server resources
|
|
||||||
|
|
||||||
### Debugging
|
|
||||||
|
|
||||||
Enable debug logging:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
securityMonitoring.updateConfig({
|
|
||||||
alerting: {
|
|
||||||
enabled: true,
|
|
||||||
debugMode: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Check alert generation:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const alerts = securityMonitoring.getActiveAlerts();
|
|
||||||
console.log("Active alerts:", alerts.length);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Unit Tests
|
|
||||||
|
|
||||||
- Alert generation logic
|
|
||||||
- Anomaly detection algorithms
|
|
||||||
- Configuration management
|
|
||||||
- Data export functionality
|
|
||||||
|
|
||||||
### Integration Tests
|
|
||||||
|
|
||||||
- API endpoint security
|
|
||||||
- Database integration
|
|
||||||
- Real-time event processing
|
|
||||||
- Alert acknowledgment flow
|
|
||||||
|
|
||||||
### Load Testing
|
|
||||||
|
|
||||||
- High-volume event processing
|
|
||||||
- Concurrent alert generation
|
|
||||||
- Database performance under load
|
|
||||||
- Memory usage patterns
|
|
||||||
|
|
||||||
Run tests:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm test tests/unit/security-monitoring.test.ts
|
|
||||||
pnpm test tests/integration/security-monitoring-api.test.ts
|
|
||||||
```
|
|
||||||
@ -1,413 +0,0 @@
|
|||||||
# Enhanced Content Security Policy (CSP) Implementation
|
|
||||||
|
|
||||||
> **Task 5 Completed**: Refined and strengthened Content Security Policy for maximum XSS protection while maintaining functionality
|
|
||||||
|
|
||||||
This document outlines the comprehensive Content Security Policy implementation for maximum XSS protection while maintaining application functionality.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The enhanced CSP implementation provides:
|
|
||||||
|
|
||||||
- **Nonce-based script execution** for maximum security in production
|
|
||||||
- **Strict mode policies** with configurable external domain allowlists
|
|
||||||
- **Environment-specific configurations** for development vs production
|
|
||||||
- **CSP violation reporting and monitoring** system with real-time analysis
|
|
||||||
- **Advanced bypass detection and alerting** capabilities with risk assessment
|
|
||||||
- **Comprehensive testing framework** with automated validation
|
|
||||||
- **Performance metrics and policy recommendations**
|
|
||||||
- **Framework compatibility** with Next.js, TailwindCSS, and Leaflet maps
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Core Components
|
|
||||||
|
|
||||||
1. **CSP Utility Library** (`lib/csp.ts`)
|
|
||||||
|
|
||||||
- Nonce generation with cryptographic security
|
|
||||||
- Dynamic CSP building based on environment
|
|
||||||
- Violation parsing and bypass detection
|
|
||||||
- Policy validation and testing
|
|
||||||
|
|
||||||
2. **Middleware Implementation** (`middleware.ts`)
|
|
||||||
|
|
||||||
- Automatic nonce generation per request
|
|
||||||
- Environment-aware policy application
|
|
||||||
- Enhanced security headers
|
|
||||||
- Route-based CSP filtering
|
|
||||||
|
|
||||||
3. **Violation Reporting** (`app/api/csp-report/route.ts`)
|
|
||||||
|
|
||||||
- Real-time violation monitoring with intelligent analysis
|
|
||||||
- Rate-limited endpoint protection (10 reports/minute per IP)
|
|
||||||
- Advanced bypass attempt detection with risk assessment
|
|
||||||
- Automated alerting for critical violations with recommendations
|
|
||||||
|
|
||||||
4. **Monitoring Service** (`lib/csp-monitoring.ts`)
|
|
||||||
|
|
||||||
- Violation tracking and metrics collection
|
|
||||||
- Policy recommendation engine based on violation patterns
|
|
||||||
- Export capabilities for external analysis (JSON/CSV)
|
|
||||||
- Automatic cleanup of old violation data
|
|
||||||
|
|
||||||
5. **Metrics API** (`app/api/csp-metrics/route.ts`)
|
|
||||||
|
|
||||||
- Real-time CSP violation metrics (1h, 6h, 24h, 7d, 30d ranges)
|
|
||||||
- Top violated directives and blocked URIs analysis
|
|
||||||
- Violation trend tracking and visualization data
|
|
||||||
- Policy optimization recommendations
|
|
||||||
|
|
||||||
6. **Testing Framework**
|
|
||||||
|
|
||||||
- Comprehensive unit and integration tests
|
|
||||||
- Enhanced CSP validation tools with security scoring
|
|
||||||
- Automated compliance verification
|
|
||||||
- Real-world scenario testing for application compatibility
|
|
||||||
|
|
||||||
## CSP Policies
|
|
||||||
|
|
||||||
### Production Environment (Standard Mode)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Nonce-based CSP with broad HTTPS allowlist
|
|
||||||
const productionCSP = {
|
|
||||||
"default-src": ["'self'"],
|
|
||||||
"script-src": ["'self'", "'nonce-{generated}'", "'strict-dynamic'"],
|
|
||||||
"style-src": ["'self'", "'nonce-{generated}'"],
|
|
||||||
"img-src": [
|
|
||||||
"'self'",
|
|
||||||
"data:",
|
|
||||||
"https://schema.org",
|
|
||||||
"https://livedash.notso.ai",
|
|
||||||
"https://*.basemaps.cartocdn.com",
|
|
||||||
"https://*.openstreetmap.org",
|
|
||||||
],
|
|
||||||
"font-src": ["'self'", "data:"],
|
|
||||||
"connect-src": ["'self'", "https://api.openai.com", "https://livedash.notso.ai", "https:"],
|
|
||||||
"object-src": ["'none'"],
|
|
||||||
"base-uri": ["'self'"],
|
|
||||||
"form-action": ["'self'"],
|
|
||||||
"frame-ancestors": ["'none'"],
|
|
||||||
"upgrade-insecure-requests": true,
|
|
||||||
"report-uri": ["/api/csp-report"],
|
|
||||||
"report-to": ["csp-endpoint"],
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production Environment (Strict Mode)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Strict CSP with minimal external domain allowlist
|
|
||||||
const strictCSP = buildCSP({
|
|
||||||
isDevelopment: false,
|
|
||||||
nonce: generateNonce(),
|
|
||||||
strictMode: true,
|
|
||||||
allowedExternalDomains: ["https://api.openai.com", "https://schema.org"],
|
|
||||||
reportUri: "/api/csp-report",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Results in:
|
|
||||||
// connect-src 'self' https://api.openai.com https://livedash.notso.ai https://schema.org
|
|
||||||
// (No broad "https:" allowlist)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Development Environment
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Permissive CSP for development tools
|
|
||||||
const developmentCSP = {
|
|
||||||
"default-src": ["'self'"],
|
|
||||||
"script-src": ["'self'", "'unsafe-eval'", "'unsafe-inline'"], // HMR & dev tools
|
|
||||||
"style-src": ["'self'", "'unsafe-inline'"], // Hot reload
|
|
||||||
"connect-src": ["'self'", "https:", "wss:", "ws:"], // Dev server
|
|
||||||
// ... other directives remain strict
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Features
|
|
||||||
|
|
||||||
### 1. Nonce-Based Script Execution
|
|
||||||
|
|
||||||
- **128-bit cryptographically secure nonces** generated per request
|
|
||||||
- **Strict-dynamic policy** prevents inline script execution
|
|
||||||
- **Automatic nonce injection** into layout components
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// Layout with nonce support
|
|
||||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
|
||||||
const nonce = await getNonce();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<script
|
|
||||||
type="application/ld+json"
|
|
||||||
nonce={nonce}
|
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<NonceProvider nonce={nonce}>{children}</NonceProvider>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Content Source Restrictions
|
|
||||||
|
|
||||||
#### Script Sources
|
|
||||||
|
|
||||||
- **Production**: Only `'self'` and nonce-approved scripts
|
|
||||||
- **Development**: Additional `'unsafe-eval'` for dev tools
|
|
||||||
- **Blocked**: All external CDNs, inline scripts without nonce
|
|
||||||
|
|
||||||
#### Style Sources
|
|
||||||
|
|
||||||
- **Production**: Nonce-based inline styles preferred
|
|
||||||
- **Fallback**: `'unsafe-inline'` for TailwindCSS compatibility
|
|
||||||
- **External**: Only self-hosted stylesheets
|
|
||||||
|
|
||||||
#### Image Sources
|
|
||||||
|
|
||||||
- **Allowed**: Self, data URIs, schema.org, application domain
|
|
||||||
- **Blocked**: All other external domains
|
|
||||||
|
|
||||||
#### Connection Sources
|
|
||||||
|
|
||||||
- **Production**: Self, OpenAI API, application domain
|
|
||||||
- **Development**: Additional WebSocket for HMR
|
|
||||||
- **Blocked**: All other external connections
|
|
||||||
|
|
||||||
### 3. XSS Protection Mechanisms
|
|
||||||
|
|
||||||
#### Inline Script Prevention
|
|
||||||
|
|
||||||
<!-- prettier-ignore -->
|
|
||||||
```javascript
|
|
||||||
// Blocked by CSP
|
|
||||||
<script>alert('xss')</script>
|
|
||||||
|
|
||||||
// Allowed with nonce
|
|
||||||
<script nonce="abc123">legitCode()</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Object Injection Prevention
|
|
||||||
|
|
||||||
<!-- prettier-ignore -->
|
|
||||||
```javascript
|
|
||||||
// Completely blocked
|
|
||||||
object-src 'none'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Base Tag Injection Prevention
|
|
||||||
|
|
||||||
<!-- prettier-ignore -->
|
|
||||||
```javascript
|
|
||||||
// Restricted to same origin
|
|
||||||
base-uri 'self'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Clickjacking Protection
|
|
||||||
|
|
||||||
<!-- prettier-ignore -->
|
|
||||||
```javascript
|
|
||||||
// No framing allowed
|
|
||||||
frame-ancestors 'none'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Bypass Detection
|
|
||||||
|
|
||||||
The system actively monitors for common CSP bypass attempts:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const bypassPatterns = [
|
|
||||||
/javascript:/i, // Protocol injection
|
|
||||||
/data:text\/html/i, // Data URI injection
|
|
||||||
/eval\(/i, // Code evaluation
|
|
||||||
/Function\(/i, // Constructor injection
|
|
||||||
/setTimeout.*string/i, // Timer string execution
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
## Violation Reporting
|
|
||||||
|
|
||||||
### Report Format
|
|
||||||
|
|
||||||
CSP violations are automatically reported to `/api/csp-report`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"csp-report": {
|
|
||||||
"document-uri": "https://example.com/page",
|
|
||||||
"violated-directive": "script-src 'self'",
|
|
||||||
"blocked-uri": "https://evil.com/script.js",
|
|
||||||
"source-file": "https://example.com/page",
|
|
||||||
"line-number": 42
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Violation Processing
|
|
||||||
|
|
||||||
1. **Rate Limiting**: 10 reports per minute per IP
|
|
||||||
2. **Parsing**: Extract violation details and context
|
|
||||||
3. **Risk Assessment**: Classify as low/medium/high risk
|
|
||||||
4. **Bypass Detection**: Check for known attack patterns
|
|
||||||
5. **Alerting**: Immediate notifications for critical violations
|
|
||||||
|
|
||||||
### Monitoring Dashboard
|
|
||||||
|
|
||||||
Violations are logged with:
|
|
||||||
|
|
||||||
- Timestamp and source IP
|
|
||||||
- User agent and referer
|
|
||||||
- Violation type and blocked content
|
|
||||||
- Risk level and bypass indicators
|
|
||||||
- Response actions taken
|
|
||||||
|
|
||||||
## Testing and Validation
|
|
||||||
|
|
||||||
### Automated Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run CSP-specific tests
|
|
||||||
pnpm test:csp
|
|
||||||
|
|
||||||
# Validate CSP implementation
|
|
||||||
pnpm test:csp:validate
|
|
||||||
|
|
||||||
# Full CSP test suite
|
|
||||||
pnpm test:csp:full
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual Testing
|
|
||||||
|
|
||||||
1. **Nonce Validation**: Verify unique nonces per request
|
|
||||||
2. **Policy Compliance**: Check all required directives
|
|
||||||
3. **Bypass Resistance**: Test common XSS techniques
|
|
||||||
4. **Framework Compatibility**: Ensure Next.js/TailwindCSS work
|
|
||||||
5. **Performance Impact**: Measure overhead
|
|
||||||
|
|
||||||
### Security Scoring
|
|
||||||
|
|
||||||
The validation framework provides a security score:
|
|
||||||
|
|
||||||
- **90-100%**: Excellent implementation
|
|
||||||
- **80-89%**: Good with minor improvements needed
|
|
||||||
- **70-79%**: Needs attention
|
|
||||||
- **<70%**: Serious security issues
|
|
||||||
|
|
||||||
## Deployment Considerations
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# CSP is automatically environment-aware
|
|
||||||
NODE_ENV=production # Enables strict CSP
|
|
||||||
NODE_ENV=development # Enables permissive CSP
|
|
||||||
```
|
|
||||||
|
|
||||||
### Performance Impact
|
|
||||||
|
|
||||||
- **Nonce generation**: ~0.1ms per request
|
|
||||||
- **Header processing**: ~0.05ms per request
|
|
||||||
- **Total overhead**: <1ms per request
|
|
||||||
|
|
||||||
### Browser Compatibility
|
|
||||||
|
|
||||||
- **Modern browsers**: Full CSP Level 3 support
|
|
||||||
- **Legacy browsers**: Graceful degradation with X-XSS-Protection
|
|
||||||
- **Reporting**: Supported in all major browsers
|
|
||||||
|
|
||||||
## Maintenance
|
|
||||||
|
|
||||||
### Regular Reviews
|
|
||||||
|
|
||||||
1. **Monthly**: Review violation reports and patterns
|
|
||||||
2. **Quarterly**: Update content source restrictions
|
|
||||||
3. **Per release**: Validate CSP with new features
|
|
||||||
4. **Annually**: Security audit and penetration testing
|
|
||||||
|
|
||||||
### Updates and Modifications
|
|
||||||
|
|
||||||
When adding new content sources:
|
|
||||||
|
|
||||||
1. Update `buildCSP()` function in `lib/csp.ts`
|
|
||||||
2. Add tests for new directives
|
|
||||||
3. Validate security impact
|
|
||||||
4. Update documentation
|
|
||||||
|
|
||||||
### Incident Response
|
|
||||||
|
|
||||||
For CSP violations:
|
|
||||||
|
|
||||||
1. **High-risk violations**: Immediate investigation
|
|
||||||
2. **Bypass attempts**: Security team notification
|
|
||||||
3. **Mass violations**: Check for policy issues
|
|
||||||
4. **False positives**: Adjust policies as needed
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
- Always test CSP changes in development first
|
|
||||||
- Use nonce provider for new inline scripts
|
|
||||||
- Validate external resources before adding
|
|
||||||
- Monitor console for CSP violations
|
|
||||||
|
|
||||||
### Production
|
|
||||||
|
|
||||||
- Never disable CSP in production
|
|
||||||
- Monitor violation rates and patterns
|
|
||||||
- Keep nonce generation entropy high
|
|
||||||
- Regular security audits
|
|
||||||
|
|
||||||
### Code Review
|
|
||||||
|
|
||||||
- Check all inline scripts have nonce
|
|
||||||
- Verify external resources are approved
|
|
||||||
- Ensure CSP tests pass
|
|
||||||
- Document any policy changes
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
1. **Inline styles blocked**: Use nonce or move to external CSS
|
|
||||||
2. **Third-party scripts blocked**: Add to approved sources
|
|
||||||
3. **Dev tools not working**: Ensure development CSP allows unsafe-eval
|
|
||||||
4. **Images not loading**: Check image source restrictions
|
|
||||||
|
|
||||||
### Debug Tools
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Test CSP generation
|
|
||||||
pnpm test:csp
|
|
||||||
|
|
||||||
# Validate current implementation
|
|
||||||
pnpm test:csp:validate
|
|
||||||
|
|
||||||
# Check specific violations
|
|
||||||
curl -X POST /api/csp-report -d '{"csp-report": {...}}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Emergency Procedures
|
|
||||||
|
|
||||||
If CSP breaks production:
|
|
||||||
|
|
||||||
1. Check violation reports for patterns
|
|
||||||
2. Identify blocking directive
|
|
||||||
3. Test fix in staging environment
|
|
||||||
4. Deploy emergency policy update
|
|
||||||
5. Monitor for resolved issues
|
|
||||||
|
|
||||||
## Compliance
|
|
||||||
|
|
||||||
This CSP implementation addresses:
|
|
||||||
|
|
||||||
- **OWASP Top 10**: XSS prevention
|
|
||||||
- **CSP Level 3**: Modern security standards
|
|
||||||
- **GDPR**: Privacy-preserving monitoring
|
|
||||||
- **SOC 2**: Security controls documentation
|
|
||||||
|
|
||||||
The enhanced CSP provides defense-in-depth against XSS attacks while maintaining application functionality and performance.
|
|
||||||
@ -25,8 +25,8 @@ CREATE INDEX Message_sessionId_order_idx ON Message(sessionId, order);
|
|||||||
|
|
||||||
### Updated Session Table
|
### Updated Session Table
|
||||||
|
|
||||||
- Added `messages` relation to Session model
|
- Added `messages` relation to Session model
|
||||||
- Sessions can now have both raw transcript content AND parsed messages
|
- Sessions can now have both raw transcript content AND parsed messages
|
||||||
|
|
||||||
## New Components
|
## New Components
|
||||||
|
|
||||||
@ -46,35 +46,35 @@ export interface Message {
|
|||||||
|
|
||||||
### 2. Transcript Parser (`lib/transcriptParser.js`)
|
### 2. Transcript Parser (`lib/transcriptParser.js`)
|
||||||
|
|
||||||
- **`parseChatLogToJSON(logString)`** - Parses raw transcript text into structured messages
|
- **`parseChatLogToJSON(logString)`** - Parses raw transcript text into structured messages
|
||||||
- **`storeMessagesForSession(sessionId, messages)`** - Stores parsed messages in database
|
- **`storeMessagesForSession(sessionId, messages)`** - Stores parsed messages in database
|
||||||
- **`processTranscriptForSession(sessionId, transcriptContent)`** - Complete processing for one session
|
- **`processTranscriptForSession(sessionId, transcriptContent)`** - Complete processing for one session
|
||||||
- **`processAllUnparsedTranscripts()`** - Batch process all unparsed transcripts
|
- **`processAllUnparsedTranscripts()`** - Batch process all unparsed transcripts
|
||||||
- **`getMessagesForSession(sessionId)`** - Retrieve messages for a session
|
- **`getMessagesForSession(sessionId)`** - Retrieve messages for a session
|
||||||
|
|
||||||
### 3. MessageViewer Component (`components/MessageViewer.tsx`)
|
### 3. MessageViewer Component (`components/MessageViewer.tsx`)
|
||||||
|
|
||||||
- Chat-like interface for displaying parsed messages
|
- Chat-like interface for displaying parsed messages
|
||||||
- Color-coded by role (User: blue, Assistant: gray, System: yellow)
|
- Color-coded by role (User: blue, Assistant: gray, System: yellow)
|
||||||
- Shows timestamps and message order
|
- Shows timestamps and message order
|
||||||
- Scrollable with conversation metadata
|
- Scrollable with conversation metadata
|
||||||
|
|
||||||
## Updated Components
|
## Updated Components
|
||||||
|
|
||||||
### 1. Session API (`pages/api/dashboard/session/[id].ts`)
|
### 1. Session API (`pages/api/dashboard/session/[id].ts`)
|
||||||
|
|
||||||
- Now includes parsed messages in session response
|
- Now includes parsed messages in session response
|
||||||
- Messages are ordered by `order` field (ascending)
|
- Messages are ordered by `order` field (ascending)
|
||||||
|
|
||||||
### 2. Session Details Page (`app/dashboard/sessions/[id]/page.tsx`)
|
### 2. Session Details Page (`app/dashboard/sessions/[id]/page.tsx`)
|
||||||
|
|
||||||
- Added MessageViewer component
|
- Added MessageViewer component
|
||||||
- Shows both parsed messages AND raw transcript
|
- Shows both parsed messages AND raw transcript
|
||||||
- Prioritizes parsed messages when available
|
- Prioritizes parsed messages when available
|
||||||
|
|
||||||
### 3. ChatSession Interface (`lib/types.ts`)
|
### 3. ChatSession Interface (`lib/types.ts`)
|
||||||
|
|
||||||
- Added optional `messages?: Message[]` field
|
- Added optional `messages?: Message[]` field
|
||||||
|
|
||||||
## Parsing Logic
|
## Parsing Logic
|
||||||
|
|
||||||
@ -90,11 +90,11 @@ The parser expects transcript format:
|
|||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- **Multi-line support** - Messages can span multiple lines
|
- **Multi-line support** - Messages can span multiple lines
|
||||||
- **Timestamp parsing** - Converts DD.MM.YYYY HH:MM:SS to ISO format
|
- **Timestamp parsing** - Converts DD.MM.YYYY HH:MM:SS to ISO format
|
||||||
- **Role detection** - Extracts sender role from each message
|
- **Role detection** - Extracts sender role from each message
|
||||||
- **Ordering** - Maintains conversation order with explicit order field
|
- **Ordering** - Maintains conversation order with explicit order field
|
||||||
- **Sorting** - Messages sorted by timestamp, then by role (User before Assistant)
|
- **Sorting** - Messages sorted by timestamp, then by role (User before Assistant)
|
||||||
|
|
||||||
## Manual Commands
|
## Manual Commands
|
||||||
|
|
||||||
@ -113,8 +113,8 @@ node scripts/manual-triggers.js status
|
|||||||
|
|
||||||
### Updated Commands
|
### Updated Commands
|
||||||
|
|
||||||
- **`status`** - Now shows transcript and parsing statistics
|
- **`status`** - Now shows transcript and parsing statistics
|
||||||
- **`all`** - New command that runs refresh → parse → process in sequence
|
- **`all`** - New command that runs refresh → parse → process in sequence
|
||||||
|
|
||||||
## Workflow Integration
|
## Workflow Integration
|
||||||
|
|
||||||
@ -126,7 +126,6 @@ node scripts/manual-triggers.js status
|
|||||||
|
|
||||||
### Database States
|
### Database States
|
||||||
|
|
||||||
<!-- prettier-ignore -->
|
|
||||||
```javascript
|
```javascript
|
||||||
// After CSV fetch
|
// After CSV fetch
|
||||||
{
|
{
|
||||||
@ -157,18 +156,18 @@ node scripts/manual-triggers.js status
|
|||||||
|
|
||||||
### Before
|
### Before
|
||||||
|
|
||||||
- Only raw transcript text in a text area
|
- Only raw transcript text in a text area
|
||||||
- Difficult to follow conversation flow
|
- Difficult to follow conversation flow
|
||||||
- No clear distinction between speakers
|
- No clear distinction between speakers
|
||||||
|
|
||||||
### After
|
### After
|
||||||
|
|
||||||
- **Chat-like interface** with message bubbles
|
- **Chat-like interface** with message bubbles
|
||||||
- **Color-coded roles** for easy identification
|
- **Color-coded roles** for easy identification
|
||||||
- **Timestamps** for each message
|
- **Timestamps** for each message
|
||||||
- **Conversation metadata** (first/last message times)
|
- **Conversation metadata** (first/last message times)
|
||||||
- **Fallback to raw transcript** if parsing fails
|
- **Fallback to raw transcript** if parsing fails
|
||||||
- **Both views available** - structured AND raw
|
- **Both views available** - structured AND raw
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
@ -196,34 +195,34 @@ node scripts/manual-triggers.js all
|
|||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
- **Indexed queries** - Messages indexed by sessionId and order
|
- **Indexed queries** - Messages indexed by sessionId and order
|
||||||
- **Efficient loading** - Only load messages when needed
|
- **Efficient loading** - Only load messages when needed
|
||||||
- **Cascading deletes** - Messages automatically deleted with sessions
|
- **Cascading deletes** - Messages automatically deleted with sessions
|
||||||
|
|
||||||
### Maintainability
|
### Maintainability
|
||||||
|
|
||||||
- **Separation of concerns** - Parsing logic isolated in dedicated module
|
- **Separation of concerns** - Parsing logic isolated in dedicated module
|
||||||
- **Type safety** - Full TypeScript support for Message interface
|
- **Type safety** - Full TypeScript support for Message interface
|
||||||
- **Error handling** - Graceful fallbacks when parsing fails
|
- **Error handling** - Graceful fallbacks when parsing fails
|
||||||
|
|
||||||
### Extensibility
|
### Extensibility
|
||||||
|
|
||||||
- **Role flexibility** - Supports any role names (User, Assistant, System, etc.)
|
- **Role flexibility** - Supports any role names (User, Assistant, System, etc.)
|
||||||
- **Content preservation** - Multi-line messages fully supported
|
- **Content preservation** - Multi-line messages fully supported
|
||||||
- **Metadata ready** - Easy to add message-level metadata in future
|
- **Metadata ready** - Easy to add message-level metadata in future
|
||||||
|
|
||||||
## Migration Notes
|
## Migration Notes
|
||||||
|
|
||||||
### Existing Data
|
### Existing Data
|
||||||
|
|
||||||
- **No data loss** - Original transcript content preserved
|
- **No data loss** - Original transcript content preserved
|
||||||
- **Backward compatibility** - Pages work with or without parsed messages
|
- **Backward compatibility** - Pages work with or without parsed messages
|
||||||
- **Gradual migration** - Can parse transcripts incrementally
|
- **Gradual migration** - Can parse transcripts incrementally
|
||||||
|
|
||||||
### Database Migration
|
### Database Migration
|
||||||
|
|
||||||
- New Message table created with foreign key constraints
|
- New Message table created with foreign key constraints
|
||||||
- Existing Session table unchanged (only added relation)
|
- Existing Session table unchanged (only added relation)
|
||||||
- Index created for efficient message queries
|
- Index created for efficient message queries
|
||||||
|
|
||||||
This implementation provides a solid foundation for enhanced conversation analysis and user experience while maintaining full backward compatibility.
|
This implementation provides a solid foundation for enhanced conversation analysis and user experience while maintaining full backward compatibility.
|
||||||
|
|||||||
@ -1,241 +0,0 @@
|
|||||||
# TypeScript Compilation Fixes and Build Troubleshooting
|
|
||||||
|
|
||||||
This document outlines the fixes applied to resolve TypeScript compilation errors and achieve a successful production build.
|
|
||||||
|
|
||||||
## Issues Resolved
|
|
||||||
|
|
||||||
### 1. Missing Type Imports
|
|
||||||
|
|
||||||
**Problem:** `lib/api/index.ts` was missing required type imports
|
|
||||||
**Error:** `Cannot find name 'APIHandler'`, `Cannot find name 'Permission'`
|
|
||||||
**Fix:** Added proper imports at the top of the file
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import type { APIContext, APIHandler, APIHandlerOptions } from "./handler";
|
|
||||||
import { createAPIHandler } from "./handler";
|
|
||||||
import { Permission, createPermissionChecker } from "./authorization";
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Zod API Breaking Change
|
|
||||||
|
|
||||||
**Problem:** Zod error property name changed from `errors` to `issues`
|
|
||||||
**Error:** `Property 'errors' does not exist on type 'ZodError'`
|
|
||||||
**Fix:** Updated all references to use `error.issues` instead of `error.errors`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Before
|
|
||||||
error.errors.map((e) => `${e.path.join(".")}: ${e.message}`);
|
|
||||||
// After
|
|
||||||
error.issues.map((e) => `${e.path.join(".")}: ${e.message}`);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Missing LRU Cache Dependency
|
|
||||||
|
|
||||||
**Problem:** `lru-cache` package was missing from dependencies
|
|
||||||
**Error:** `Cannot find module 'lru-cache'`
|
|
||||||
**Fix:** Installed the missing dependency
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm add lru-cache
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. LRU Cache Generic Type Constraints
|
|
||||||
|
|
||||||
**Problem:** TypeScript generic constraints not satisfied
|
|
||||||
**Error:** `Type 'K' does not satisfy the constraint '{}'`
|
|
||||||
**Fix:** Added proper generic type constraints
|
|
||||||
|
|
||||||
<!-- prettier-ignore -->
|
|
||||||
```typescript
|
|
||||||
// Before
|
|
||||||
<K = string, V = any>
|
|
||||||
// After
|
|
||||||
<K extends {} = string, V = any>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Map Iteration ES5 Compatibility
|
|
||||||
|
|
||||||
**Problem:** Map iteration requires downlevel iteration flag
|
|
||||||
**Error:** `can only be iterated through when using the '--downlevelIteration' flag`
|
|
||||||
**Fix:** Used `Array.from()` pattern for compatibility
|
|
||||||
|
|
||||||
<!-- prettier-ignore -->
|
|
||||||
```typescript
|
|
||||||
// Before
|
|
||||||
for (const [key, value] of map) { ... }
|
|
||||||
// After
|
|
||||||
for (const [key, value] of Array.from(map.entries())) { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Redis Configuration Issues
|
|
||||||
|
|
||||||
**Problem:** Invalid Redis socket options
|
|
||||||
**Error:** Redis connection failed with unsupported options
|
|
||||||
**Fix:** Simplified Redis configuration to only include supported options
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
this.client = createClient({
|
|
||||||
url: env.REDIS_URL,
|
|
||||||
socket: {
|
|
||||||
connectTimeout: 5000,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. Prisma Relationship Naming Mismatches
|
|
||||||
|
|
||||||
**Problem:** Code referenced non-existent Prisma relationships
|
|
||||||
**Error:** `securityAuditLogs` and `sessionImport` don't exist
|
|
||||||
**Fix:** Used correct relationship names
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Before
|
|
||||||
user.securityAuditLogs;
|
|
||||||
session.sessionImport;
|
|
||||||
// After
|
|
||||||
user.auditLogs;
|
|
||||||
session.import;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. Missing Schema Fields
|
|
||||||
|
|
||||||
**Problem:** Code referenced fields that don't exist in the database schema
|
|
||||||
**Error:** `Property 'userId' does not exist on type`
|
|
||||||
**Fix:** Applied type casting where schema fields were missing
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
userId: (session as any).userId || null;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9. Deprecated Package Dependencies
|
|
||||||
|
|
||||||
**Problem:** `critters` package is deprecated and caused build failures
|
|
||||||
**Error:** `Cannot find module 'critters'`
|
|
||||||
**Fix:** Disabled CSS optimization feature that required critters
|
|
||||||
|
|
||||||
<!-- prettier-ignore -->
|
|
||||||
```javascript
|
|
||||||
experimental: {
|
|
||||||
optimizeCss: false, // Disabled due to critters dependency
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 10. ESLint vs Biome Conflict
|
|
||||||
|
|
||||||
**Problem:** ESLint warnings treated as build errors
|
|
||||||
**Error:** Build failed due to linting warnings
|
|
||||||
**Fix:** Disabled ESLint during build since Biome is used for linting
|
|
||||||
|
|
||||||
<!-- prettier-ignore -->
|
|
||||||
```javascript
|
|
||||||
eslint: {
|
|
||||||
ignoreDuringBuilds: true,
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
## Schema Enhancements
|
|
||||||
|
|
||||||
### Enhanced User Management
|
|
||||||
|
|
||||||
Added comprehensive user management fields to the User model:
|
|
||||||
|
|
||||||
```prisma
|
|
||||||
model User {
|
|
||||||
// ... existing fields
|
|
||||||
|
|
||||||
// User management fields
|
|
||||||
lastLoginAt DateTime? @db.Timestamptz(6)
|
|
||||||
isActive Boolean @default(true)
|
|
||||||
emailVerified Boolean @default(false)
|
|
||||||
emailVerificationToken String? @db.VarChar(255)
|
|
||||||
emailVerificationExpiry DateTime? @db.Timestamptz(6)
|
|
||||||
failedLoginAttempts Int @default(0)
|
|
||||||
lockedAt DateTime? @db.Timestamptz(6)
|
|
||||||
preferences Json? @db.Json
|
|
||||||
timezone String? @db.VarChar(50)
|
|
||||||
preferredLanguage String? @db.VarChar(10)
|
|
||||||
|
|
||||||
@@index([lastLoginAt])
|
|
||||||
@@index([isActive])
|
|
||||||
@@index([emailVerified])
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Updated Repository Methods
|
|
||||||
|
|
||||||
Enhanced UserRepository with new methods:
|
|
||||||
|
|
||||||
- `updateLastLogin()` - Tracks user login times
|
|
||||||
- `incrementFailedLoginAttempts()` - Security feature for account locking
|
|
||||||
- `verifyEmail()` - Email verification management
|
|
||||||
- `deactivateUser()` - Account management
|
|
||||||
- `unlockUser()` - Security administration
|
|
||||||
- `updatePreferences()` - User settings management
|
|
||||||
- `findInactiveUsers()` - Now uses `lastLoginAt` instead of `createdAt`
|
|
||||||
|
|
||||||
## Prevention Measures
|
|
||||||
|
|
||||||
### 1. Regular Dependency Updates
|
|
||||||
|
|
||||||
- Monitor for breaking changes in dependencies like Zod
|
|
||||||
- Use `pnpm outdated` to check for deprecated packages
|
|
||||||
- Test builds after dependency updates
|
|
||||||
|
|
||||||
### 2. TypeScript Strict Checking
|
|
||||||
|
|
||||||
- Enable strict TypeScript checking to catch type errors early
|
|
||||||
- Use proper type imports and exports
|
|
||||||
- Avoid `any` types where possible
|
|
||||||
|
|
||||||
### 3. Build Pipeline Validation
|
|
||||||
|
|
||||||
- Run `pnpm build` before committing
|
|
||||||
- Include type checking in CI/CD pipeline
|
|
||||||
- Separate linting from build process
|
|
||||||
|
|
||||||
### 4. Schema Management
|
|
||||||
|
|
||||||
- Regenerate Prisma client after schema changes: `pnpm prisma:generate`
|
|
||||||
- Validate schema changes with database migrations
|
|
||||||
- Use proper TypeScript types for database operations
|
|
||||||
|
|
||||||
### 5. Development Workflow
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Recommended development workflow
|
|
||||||
pnpm prisma:generate # After schema changes
|
|
||||||
pnpm build # Verify compilation
|
|
||||||
pnpm lint # Check code quality (using Biome)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Build Success Metrics
|
|
||||||
|
|
||||||
✅ **TypeScript Compilation:** All 47 pages compile successfully
|
|
||||||
✅ **No Type Errors:** Zero TypeScript compilation errors
|
|
||||||
✅ **Production Ready:** Optimized bundle generated
|
|
||||||
✅ **No Deprecated Dependencies:** All packages up to date
|
|
||||||
✅ **Enhanced User Management:** Comprehensive user fields added
|
|
||||||
|
|
||||||
## Commands for Troubleshooting
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check for TypeScript errors
|
|
||||||
pnpm build
|
|
||||||
|
|
||||||
# Check for outdated/deprecated packages
|
|
||||||
pnpm outdated
|
|
||||||
|
|
||||||
# Regenerate Prisma client
|
|
||||||
pnpm prisma:generate
|
|
||||||
|
|
||||||
# Check for linting issues
|
|
||||||
pnpm lint
|
|
||||||
|
|
||||||
# Install missing dependencies
|
|
||||||
pnpm install
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_Last updated: 2025-07-12_
|
|
||||||
_Build Status: ✅ Success (47/47 pages generated)_
|
|
||||||
@ -1,557 +0,0 @@
|
|||||||
/**
|
|
||||||
* E2E tests for CSV upload and session processing workflow
|
|
||||||
*
|
|
||||||
* Tests the complete data processing pipeline:
|
|
||||||
* 1. CSV import configuration
|
|
||||||
* 2. Data import and validation
|
|
||||||
* 3. Session processing and AI analysis
|
|
||||||
* 4. Dashboard visualization
|
|
||||||
* 5. Data filtering and search
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect, type Page } from "@playwright/test";
|
|
||||||
|
|
||||||
// Test data
|
|
||||||
const testAdmin = {
|
|
||||||
email: "admin@csvtest.com",
|
|
||||||
password: "AdminTestPassword123!",
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockCsvData = `sessionId,userId,language,country,ipAddress,sentiment,messagesSent,startTime,endTime,escalated,forwardedHr,summary
|
|
||||||
session1,user1,en,US,192.168.1.1,positive,5,2024-01-15T10:00:00Z,2024-01-15T10:30:00Z,false,false,User requested vacation time
|
|
||||||
session2,user2,nl,NL,192.168.1.2,neutral,3,2024-01-15T11:00:00Z,2024-01-15T11:20:00Z,true,false,User had login issues
|
|
||||||
session3,user3,de,DE,192.168.1.3,negative,8,2024-01-15T12:00:00Z,2024-01-15T12:45:00Z,false,true,User complained about salary`;
|
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
async function loginAsAdmin(page: Page) {
|
|
||||||
await page.goto("http://localhost:3000/login");
|
|
||||||
await page.fill('[data-testid="email"]', testAdmin.email);
|
|
||||||
await page.fill('[data-testid="password"]', testAdmin.password);
|
|
||||||
await page.click('[data-testid="login-button"]');
|
|
||||||
await expect(page).toHaveURL(/\/dashboard/);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForDataProcessing(page: Page, timeout = 30000) {
|
|
||||||
// Wait for processing indicators to disappear
|
|
||||||
await page.waitForSelector('[data-testid="processing-indicator"]', {
|
|
||||||
state: "hidden",
|
|
||||||
timeout,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setupMockCsvEndpoint(page: Page) {
|
|
||||||
// Mock the CSV endpoint to return test data
|
|
||||||
await page.route("**/test-csv-data", (route) => {
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "text/csv",
|
|
||||||
body: mockCsvData,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("CSV Processing Workflow", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
// Setup mock CSV endpoint
|
|
||||||
await setupMockCsvEndpoint(page);
|
|
||||||
|
|
||||||
// Login as admin
|
|
||||||
await loginAsAdmin(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("CSV Import Configuration", () => {
|
|
||||||
test("should configure CSV import settings", async ({ page }) => {
|
|
||||||
// Navigate to company settings
|
|
||||||
await page.click('[data-testid="nav-company"]');
|
|
||||||
await expect(page).toHaveURL(/\/dashboard\/company/);
|
|
||||||
|
|
||||||
// Update CSV configuration
|
|
||||||
await page.fill(
|
|
||||||
'[data-testid="csv-url"]',
|
|
||||||
"http://localhost:3000/api/test-csv-data"
|
|
||||||
);
|
|
||||||
await page.fill('[data-testid="csv-username"]', "testuser");
|
|
||||||
await page.fill('[data-testid="csv-password"]', "testpass");
|
|
||||||
|
|
||||||
// Save settings
|
|
||||||
await page.click('[data-testid="save-settings"]');
|
|
||||||
|
|
||||||
// Should show success message
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="success-message"]')
|
|
||||||
).toContainText("Settings saved successfully");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should validate CSV URL format", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/dashboard/company");
|
|
||||||
|
|
||||||
// Enter invalid URL
|
|
||||||
await page.fill('[data-testid="csv-url"]', "invalid-url");
|
|
||||||
await page.click('[data-testid="save-settings"]');
|
|
||||||
|
|
||||||
// Should show validation error
|
|
||||||
await expect(page.locator('[data-testid="csv-url-error"]')).toContainText(
|
|
||||||
"Invalid URL format"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Manual CSV Import", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
// Configure CSV settings first
|
|
||||||
await page.goto("http://localhost:3000/dashboard/company");
|
|
||||||
await page.fill(
|
|
||||||
'[data-testid="csv-url"]',
|
|
||||||
"http://localhost:3000/api/test-csv-data"
|
|
||||||
);
|
|
||||||
await page.click('[data-testid="save-settings"]');
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="success-message"]')
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should trigger manual CSV import", async ({ page }) => {
|
|
||||||
// Navigate to overview
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
|
|
||||||
// Trigger manual refresh
|
|
||||||
await page.click('[data-testid="refresh-data-button"]');
|
|
||||||
|
|
||||||
// Should show processing indicator
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="processing-indicator"]')
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Wait for processing to complete
|
|
||||||
await waitForDataProcessing(page);
|
|
||||||
|
|
||||||
// Should show success message
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="import-success"]')
|
|
||||||
).toContainText("Data imported successfully");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should display import progress", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
|
|
||||||
// Start import
|
|
||||||
await page.click('[data-testid="refresh-data-button"]');
|
|
||||||
|
|
||||||
// Check progress indicators
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="import-progress"]')
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Progress should show stages
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="stage-csv-import"]')
|
|
||||||
).toContainText("CSV Import");
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="stage-processing"]')
|
|
||||||
).toContainText("Processing");
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="stage-ai-analysis"]')
|
|
||||||
).toContainText("AI Analysis");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle import errors gracefully", async ({ page }) => {
|
|
||||||
// Configure invalid CSV URL
|
|
||||||
await page.goto("http://localhost:3000/dashboard/company");
|
|
||||||
await page.fill(
|
|
||||||
'[data-testid="csv-url"]',
|
|
||||||
"http://localhost:3000/api/nonexistent-csv"
|
|
||||||
);
|
|
||||||
await page.click('[data-testid="save-settings"]');
|
|
||||||
|
|
||||||
// Try to import
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
await page.click('[data-testid="refresh-data-button"]');
|
|
||||||
|
|
||||||
// Should show error message
|
|
||||||
await expect(page.locator('[data-testid="import-error"]')).toContainText(
|
|
||||||
"Failed to fetch CSV data"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Data Visualization", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
// Import test data first
|
|
||||||
await page.goto("http://localhost:3000/dashboard/company");
|
|
||||||
await page.fill(
|
|
||||||
'[data-testid="csv-url"]',
|
|
||||||
"http://localhost:3000/api/test-csv-data"
|
|
||||||
);
|
|
||||||
await page.click('[data-testid="save-settings"]');
|
|
||||||
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
await page.click('[data-testid="refresh-data-button"]');
|
|
||||||
await waitForDataProcessing(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should display session metrics correctly", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
|
|
||||||
// Check metric cards show correct data
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="total-sessions"]')
|
|
||||||
).toContainText("3");
|
|
||||||
|
|
||||||
// Check sentiment distribution
|
|
||||||
const sentimentChart = page.locator('[data-testid="sentiment-chart"]');
|
|
||||||
await expect(sentimentChart).toBeVisible();
|
|
||||||
|
|
||||||
// Verify sentiment data
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="positive-sentiment"]')
|
|
||||||
).toContainText("1");
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="neutral-sentiment"]')
|
|
||||||
).toContainText("1");
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="negative-sentiment"]')
|
|
||||||
).toContainText("1");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should display geographic distribution", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
|
|
||||||
// Check geographic map
|
|
||||||
const geoMap = page.locator('[data-testid="geographic-map"]');
|
|
||||||
await expect(geoMap).toBeVisible();
|
|
||||||
|
|
||||||
// Check country data
|
|
||||||
await expect(page.locator('[data-testid="country-us"]')).toContainText(
|
|
||||||
"US: 1"
|
|
||||||
);
|
|
||||||
await expect(page.locator('[data-testid="country-nl"]')).toContainText(
|
|
||||||
"NL: 1"
|
|
||||||
);
|
|
||||||
await expect(page.locator('[data-testid="country-de"]')).toContainText(
|
|
||||||
"DE: 1"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should display escalation metrics", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
|
|
||||||
// Check escalation rate
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="escalation-rate"]')
|
|
||||||
).toContainText("33%");
|
|
||||||
|
|
||||||
// Check HR forwarding rate
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="hr-forwarding-rate"]')
|
|
||||||
).toContainText("33%");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Session Management", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
// Import test data
|
|
||||||
await page.goto("http://localhost:3000/dashboard/company");
|
|
||||||
await page.fill(
|
|
||||||
'[data-testid="csv-url"]',
|
|
||||||
"http://localhost:3000/api/test-csv-data"
|
|
||||||
);
|
|
||||||
await page.click('[data-testid="save-settings"]');
|
|
||||||
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
await page.click('[data-testid="refresh-data-button"]');
|
|
||||||
await waitForDataProcessing(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should display sessions list", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/dashboard/sessions");
|
|
||||||
|
|
||||||
// Should show all sessions
|
|
||||||
await expect(page.locator('[data-testid="session-list"]')).toBeVisible();
|
|
||||||
await expect(page.locator('[data-testid="session-item"]')).toHaveCount(3);
|
|
||||||
|
|
||||||
// Check session details
|
|
||||||
const firstSession = page.locator('[data-testid="session-item"]').first();
|
|
||||||
await expect(firstSession).toContainText("session1");
|
|
||||||
await expect(firstSession).toContainText("positive");
|
|
||||||
await expect(firstSession).toContainText("US");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should filter sessions by sentiment", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/dashboard/sessions");
|
|
||||||
|
|
||||||
// Filter by positive sentiment
|
|
||||||
await page.selectOption('[data-testid="sentiment-filter"]', "POSITIVE");
|
|
||||||
|
|
||||||
// Should show only positive sessions
|
|
||||||
await expect(page.locator('[data-testid="session-item"]')).toHaveCount(1);
|
|
||||||
await expect(page.locator('[data-testid="session-item"]')).toContainText(
|
|
||||||
"session1"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should filter sessions by country", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/dashboard/sessions");
|
|
||||||
|
|
||||||
// Filter by Germany
|
|
||||||
await page.selectOption('[data-testid="country-filter"]', "DE");
|
|
||||||
|
|
||||||
// Should show only German sessions
|
|
||||||
await expect(page.locator('[data-testid="session-item"]')).toHaveCount(1);
|
|
||||||
await expect(page.locator('[data-testid="session-item"]')).toContainText(
|
|
||||||
"session3"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should search sessions by content", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/dashboard/sessions");
|
|
||||||
|
|
||||||
// Search for "vacation"
|
|
||||||
await page.fill('[data-testid="search-input"]', "vacation");
|
|
||||||
|
|
||||||
// Should show matching sessions
|
|
||||||
await expect(page.locator('[data-testid="session-item"]')).toHaveCount(1);
|
|
||||||
await expect(page.locator('[data-testid="session-item"]')).toContainText(
|
|
||||||
"vacation time"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should paginate sessions", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/dashboard/sessions");
|
|
||||||
|
|
||||||
// Set small page size
|
|
||||||
await page.selectOption('[data-testid="page-size"]', "2");
|
|
||||||
|
|
||||||
// Should show pagination
|
|
||||||
await expect(page.locator('[data-testid="pagination"]')).toBeVisible();
|
|
||||||
await expect(page.locator('[data-testid="session-item"]')).toHaveCount(2);
|
|
||||||
|
|
||||||
// Go to next page
|
|
||||||
await page.click('[data-testid="next-page"]');
|
|
||||||
await expect(page.locator('[data-testid="session-item"]')).toHaveCount(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Session Details", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
// Import test data
|
|
||||||
await page.goto("http://localhost:3000/dashboard/company");
|
|
||||||
await page.fill(
|
|
||||||
'[data-testid="csv-url"]',
|
|
||||||
"http://localhost:3000/api/test-csv-data"
|
|
||||||
);
|
|
||||||
await page.click('[data-testid="save-settings"]');
|
|
||||||
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
await page.click('[data-testid="refresh-data-button"]');
|
|
||||||
await waitForDataProcessing(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should view individual session details", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/dashboard/sessions");
|
|
||||||
|
|
||||||
// Click on first session
|
|
||||||
await page.click('[data-testid="session-item"]');
|
|
||||||
|
|
||||||
// Should navigate to session detail page
|
|
||||||
await expect(page).toHaveURL(/\/dashboard\/sessions\/[^/]+/);
|
|
||||||
|
|
||||||
// Check session details
|
|
||||||
await expect(page.locator('[data-testid="session-id"]')).toContainText(
|
|
||||||
"session1"
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="sentiment-badge"]')
|
|
||||||
).toContainText("positive");
|
|
||||||
await expect(page.locator('[data-testid="country-badge"]')).toContainText(
|
|
||||||
"US"
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="session-summary"]')
|
|
||||||
).toContainText("vacation time");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should display session timeline", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/dashboard/sessions");
|
|
||||||
await page.click('[data-testid="session-item"]');
|
|
||||||
|
|
||||||
// Check timeline
|
|
||||||
const timeline = page.locator('[data-testid="session-timeline"]');
|
|
||||||
await expect(timeline).toBeVisible();
|
|
||||||
|
|
||||||
// Should show start and end times
|
|
||||||
await expect(page.locator('[data-testid="start-time"]')).toContainText(
|
|
||||||
"10:00"
|
|
||||||
);
|
|
||||||
await expect(page.locator('[data-testid="end-time"]')).toContainText(
|
|
||||||
"10:30"
|
|
||||||
);
|
|
||||||
await expect(page.locator('[data-testid="duration"]')).toContainText(
|
|
||||||
"30 minutes"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should display extracted questions", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/dashboard/sessions");
|
|
||||||
await page.click('[data-testid="session-item"]');
|
|
||||||
|
|
||||||
// Check questions section
|
|
||||||
const questionsSection = page.locator(
|
|
||||||
'[data-testid="extracted-questions"]'
|
|
||||||
);
|
|
||||||
await expect(questionsSection).toBeVisible();
|
|
||||||
|
|
||||||
// Should show AI-extracted questions (if any)
|
|
||||||
const questionsList = page.locator('[data-testid="questions-list"]');
|
|
||||||
if (await questionsList.isVisible()) {
|
|
||||||
await expect(
|
|
||||||
questionsList.locator('[data-testid="question-item"]')
|
|
||||||
).toHaveCount.greaterThan(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Real-time Updates", () => {
|
|
||||||
test("should show real-time processing status", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
|
|
||||||
// Configure CSV
|
|
||||||
await page.goto("http://localhost:3000/dashboard/company");
|
|
||||||
await page.fill(
|
|
||||||
'[data-testid="csv-url"]',
|
|
||||||
"http://localhost:3000/api/test-csv-data"
|
|
||||||
);
|
|
||||||
await page.click('[data-testid="save-settings"]');
|
|
||||||
|
|
||||||
// Start import and monitor status
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
await page.click('[data-testid="refresh-data-button"]');
|
|
||||||
|
|
||||||
// Should show real-time status updates
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="status-importing"]')
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Status should progress through stages
|
|
||||||
await page.waitForSelector('[data-testid="status-processing"]', {
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
await page.waitForSelector('[data-testid="status-analyzing"]', {
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
await page.waitForSelector('[data-testid="status-complete"]', {
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should update metrics in real-time", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
|
|
||||||
// Get initial metrics
|
|
||||||
const initialSessions = await page
|
|
||||||
.locator('[data-testid="total-sessions"]')
|
|
||||||
.textContent();
|
|
||||||
|
|
||||||
// Import data
|
|
||||||
await page.goto("http://localhost:3000/dashboard/company");
|
|
||||||
await page.fill(
|
|
||||||
'[data-testid="csv-url"]',
|
|
||||||
"http://localhost:3000/api/test-csv-data"
|
|
||||||
);
|
|
||||||
await page.click('[data-testid="save-settings"]');
|
|
||||||
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
await page.click('[data-testid="refresh-data-button"]');
|
|
||||||
await waitForDataProcessing(page);
|
|
||||||
|
|
||||||
// Metrics should be updated
|
|
||||||
const updatedSessions = await page
|
|
||||||
.locator('[data-testid="total-sessions"]')
|
|
||||||
.textContent();
|
|
||||||
expect(updatedSessions).not.toBe(initialSessions);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Error Handling", () => {
|
|
||||||
test("should handle CSV parsing errors", async ({ page }) => {
|
|
||||||
// Mock invalid CSV data
|
|
||||||
await page.route("**/invalid-csv", (route) => {
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "text/csv",
|
|
||||||
body: "invalid,csv,format\nwithout,proper,headers",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto("http://localhost:3000/dashboard/company");
|
|
||||||
await page.fill(
|
|
||||||
'[data-testid="csv-url"]',
|
|
||||||
"http://localhost:3000/api/invalid-csv"
|
|
||||||
);
|
|
||||||
await page.click('[data-testid="save-settings"]');
|
|
||||||
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
await page.click('[data-testid="refresh-data-button"]');
|
|
||||||
|
|
||||||
// Should show parsing error
|
|
||||||
await expect(page.locator('[data-testid="parsing-error"]')).toContainText(
|
|
||||||
"Invalid CSV format"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle AI processing failures", async ({ page }) => {
|
|
||||||
// Mock AI service failure
|
|
||||||
await page.route("**/api/openai/**", (route) => {
|
|
||||||
route.fulfill({
|
|
||||||
status: 500,
|
|
||||||
body: JSON.stringify({ error: "AI service unavailable" }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto("http://localhost:3000/dashboard/company");
|
|
||||||
await page.fill(
|
|
||||||
'[data-testid="csv-url"]',
|
|
||||||
"http://localhost:3000/api/test-csv-data"
|
|
||||||
);
|
|
||||||
await page.click('[data-testid="save-settings"]');
|
|
||||||
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
await page.click('[data-testid="refresh-data-button"]');
|
|
||||||
|
|
||||||
// Should show AI processing error
|
|
||||||
await expect(page.locator('[data-testid="ai-error"]')).toContainText(
|
|
||||||
"AI analysis failed"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should retry failed operations", async ({ page }) => {
|
|
||||||
let attemptCount = 0;
|
|
||||||
|
|
||||||
// Mock failing then succeeding API
|
|
||||||
await page.route("**/api/process-batch", (route) => {
|
|
||||||
attemptCount++;
|
|
||||||
if (attemptCount === 1) {
|
|
||||||
route.fulfill({ status: 500, body: "Server error" });
|
|
||||||
} else {
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
body: JSON.stringify({ success: true }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
await page.click('[data-testid="refresh-data-button"]');
|
|
||||||
|
|
||||||
// Should show retry attempt
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="retry-indicator"]')
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Should eventually succeed
|
|
||||||
await waitForDataProcessing(page);
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="import-success"]')
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,641 +0,0 @@
|
|||||||
/**
|
|
||||||
* E2E tests for dashboard navigation and data visualization
|
|
||||||
*
|
|
||||||
* Tests the dashboard user experience:
|
|
||||||
* 1. Navigation between dashboard sections
|
|
||||||
* 2. Data visualization components
|
|
||||||
* 3. Interactive filtering and search
|
|
||||||
* 4. Responsive design
|
|
||||||
* 5. Accessibility features
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect, type Page } from "@playwright/test";
|
|
||||||
|
|
||||||
// Test data
|
|
||||||
const testUser = {
|
|
||||||
email: "dashboard@test.com",
|
|
||||||
password: "DashboardTest123!",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
async function loginUser(page: Page) {
|
|
||||||
await page.goto("http://localhost:3000/login");
|
|
||||||
await page.fill('[data-testid="email"]', testUser.email);
|
|
||||||
await page.fill('[data-testid="password"]', testUser.password);
|
|
||||||
await page.click('[data-testid="login-button"]');
|
|
||||||
await expect(page).toHaveURL(/\/dashboard/);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForChartLoad(page: Page, chartSelector: string) {
|
|
||||||
await page.waitForSelector(chartSelector);
|
|
||||||
await page.waitForFunction((selector) => {
|
|
||||||
const chart = document.querySelector(selector);
|
|
||||||
return chart && chart.children.length > 0;
|
|
||||||
}, chartSelector);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Dashboard Navigation", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await loginUser(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Navigation Menu", () => {
|
|
||||||
test("should display main navigation menu", async ({ page }) => {
|
|
||||||
// Check navigation sidebar
|
|
||||||
const nav = page.locator('[data-testid="main-navigation"]');
|
|
||||||
await expect(nav).toBeVisible();
|
|
||||||
|
|
||||||
// Check navigation items
|
|
||||||
await expect(page.locator('[data-testid="nav-overview"]')).toBeVisible();
|
|
||||||
await expect(page.locator('[data-testid="nav-sessions"]')).toBeVisible();
|
|
||||||
await expect(page.locator('[data-testid="nav-users"]')).toBeVisible();
|
|
||||||
await expect(page.locator('[data-testid="nav-company"]')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should highlight active navigation item", async ({ page }) => {
|
|
||||||
// Overview should be active by default
|
|
||||||
await expect(page.locator('[data-testid="nav-overview"]')).toHaveClass(
|
|
||||||
/active/
|
|
||||||
);
|
|
||||||
|
|
||||||
// Navigate to sessions
|
|
||||||
await page.click('[data-testid="nav-sessions"]');
|
|
||||||
await expect(page.locator('[data-testid="nav-sessions"]')).toHaveClass(
|
|
||||||
/active/
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="nav-overview"]')
|
|
||||||
).not.toHaveClass(/active/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should navigate between sections correctly", async ({ page }) => {
|
|
||||||
// Navigate to Sessions
|
|
||||||
await page.click('[data-testid="nav-sessions"]');
|
|
||||||
await expect(page).toHaveURL(/\/dashboard\/sessions/);
|
|
||||||
await expect(page.locator("h1")).toContainText("Sessions");
|
|
||||||
|
|
||||||
// Navigate to Users
|
|
||||||
await page.click('[data-testid="nav-users"]');
|
|
||||||
await expect(page).toHaveURL(/\/dashboard\/users/);
|
|
||||||
await expect(page.locator("h1")).toContainText("Users");
|
|
||||||
|
|
||||||
// Navigate to Company
|
|
||||||
await page.click('[data-testid="nav-company"]');
|
|
||||||
await expect(page).toHaveURL(/\/dashboard\/company/);
|
|
||||||
await expect(page.locator("h1")).toContainText("Company Settings");
|
|
||||||
|
|
||||||
// Navigate back to Overview
|
|
||||||
await page.click('[data-testid="nav-overview"]');
|
|
||||||
await expect(page).toHaveURL(/\/dashboard\/overview/);
|
|
||||||
await expect(page.locator("h1")).toContainText("Dashboard Overview");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should support breadcrumb navigation", async ({ page }) => {
|
|
||||||
// Navigate to sessions and then a specific session
|
|
||||||
await page.click('[data-testid="nav-sessions"]');
|
|
||||||
|
|
||||||
// Mock a session item click (assuming sessions exist)
|
|
||||||
const sessionItems = page.locator('[data-testid="session-item"]');
|
|
||||||
const sessionCount = await sessionItems.count();
|
|
||||||
|
|
||||||
if (sessionCount > 0) {
|
|
||||||
await sessionItems.first().click();
|
|
||||||
|
|
||||||
// Check breadcrumbs
|
|
||||||
await expect(page.locator('[data-testid="breadcrumb"]')).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="breadcrumb-home"]')
|
|
||||||
).toContainText("Dashboard");
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="breadcrumb-sessions"]')
|
|
||||||
).toContainText("Sessions");
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="breadcrumb-current"]')
|
|
||||||
).toContainText("Session Details");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Header Navigation", () => {
|
|
||||||
test("should display user menu", async ({ page }) => {
|
|
||||||
// Check user menu trigger
|
|
||||||
const userMenu = page.locator('[data-testid="user-menu"]');
|
|
||||||
await expect(userMenu).toBeVisible();
|
|
||||||
|
|
||||||
// Open user menu
|
|
||||||
await userMenu.click();
|
|
||||||
|
|
||||||
// Check menu items
|
|
||||||
await expect(page.locator('[data-testid="user-profile"]')).toBeVisible();
|
|
||||||
await expect(page.locator('[data-testid="user-settings"]')).toBeVisible();
|
|
||||||
await expect(page.locator('[data-testid="logout-button"]')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should display notifications", async ({ page }) => {
|
|
||||||
const notifications = page.locator('[data-testid="notifications"]');
|
|
||||||
|
|
||||||
if (await notifications.isVisible()) {
|
|
||||||
await notifications.click();
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="notifications-dropdown"]')
|
|
||||||
).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should display search functionality", async ({ page }) => {
|
|
||||||
const searchInput = page.locator('[data-testid="global-search"]');
|
|
||||||
|
|
||||||
if (await searchInput.isVisible()) {
|
|
||||||
await searchInput.fill("test search");
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="search-results"]')
|
|
||||||
).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Page Titles and Metadata", () => {
|
|
||||||
test("should update page title for each section", async ({ page }) => {
|
|
||||||
// Overview page
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
await expect(page).toHaveTitle(/Dashboard Overview/);
|
|
||||||
|
|
||||||
// Sessions page
|
|
||||||
await page.goto("http://localhost:3000/dashboard/sessions");
|
|
||||||
await expect(page).toHaveTitle(/Sessions/);
|
|
||||||
|
|
||||||
// Users page
|
|
||||||
await page.goto("http://localhost:3000/dashboard/users");
|
|
||||||
await expect(page).toHaveTitle(/Users/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Data Visualization", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await loginUser(page);
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Overview Dashboard", () => {
|
|
||||||
test("should display key metrics cards", async ({ page }) => {
|
|
||||||
// Check metric cards
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="total-sessions-card"]')
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="avg-sentiment-card"]')
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="escalation-rate-card"]')
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="avg-response-time-card"]')
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Check that metrics have values
|
|
||||||
const totalSessions = page.locator(
|
|
||||||
'[data-testid="total-sessions-value"]'
|
|
||||||
);
|
|
||||||
await expect(totalSessions).toContainText(/\d+/); // Should contain numbers
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should display sentiment distribution chart", async ({ page }) => {
|
|
||||||
const sentimentChart = page.locator('[data-testid="sentiment-chart"]');
|
|
||||||
await expect(sentimentChart).toBeVisible();
|
|
||||||
|
|
||||||
await waitForChartLoad(page, '[data-testid="sentiment-chart"]');
|
|
||||||
|
|
||||||
// Check chart has data
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="positive-sentiment"]')
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="neutral-sentiment"]')
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="negative-sentiment"]')
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should display category distribution chart", async ({ page }) => {
|
|
||||||
const categoryChart = page.locator('[data-testid="category-chart"]');
|
|
||||||
await expect(categoryChart).toBeVisible();
|
|
||||||
|
|
||||||
await waitForChartLoad(page, '[data-testid="category-chart"]');
|
|
||||||
|
|
||||||
// Should show category data
|
|
||||||
const categories = page.locator('[data-testid="category-item"]');
|
|
||||||
const count = await categories.count();
|
|
||||||
expect(count).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should display geographic distribution map", async ({ page }) => {
|
|
||||||
const geoMap = page.locator('[data-testid="geographic-map"]');
|
|
||||||
await expect(geoMap).toBeVisible();
|
|
||||||
|
|
||||||
// Wait for map to load - wait for map container or country data to be rendered
|
|
||||||
await page.waitForSelector('[data-testid="country-data"], .leaflet-container, .geo-map-loaded', {
|
|
||||||
timeout: 10000,
|
|
||||||
state: 'visible'
|
|
||||||
}).catch(() => {
|
|
||||||
// Fallback: wait for any map-related element to indicate map is loaded
|
|
||||||
return page.waitForSelector('.map, [class*="map"], [data-map]', { timeout: 5000 }).catch(() => null);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if country data is displayed
|
|
||||||
const countryData = page.locator('[data-testid="country-data"]');
|
|
||||||
if (await countryData.isVisible()) {
|
|
||||||
expect(await countryData.count()).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should display top questions list", async ({ page }) => {
|
|
||||||
const topQuestions = page.locator('[data-testid="top-questions"]');
|
|
||||||
await expect(topQuestions).toBeVisible();
|
|
||||||
|
|
||||||
// Check if questions are displayed
|
|
||||||
const questionItems = page.locator('[data-testid="question-item"]');
|
|
||||||
const count = await questionItems.count();
|
|
||||||
|
|
||||||
if (count > 0) {
|
|
||||||
// Should show question text and count
|
|
||||||
const firstQuestion = questionItems.first();
|
|
||||||
await expect(
|
|
||||||
firstQuestion.locator('[data-testid="question-text"]')
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
firstQuestion.locator('[data-testid="question-count"]')
|
|
||||||
).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should display time series chart", async ({ page }) => {
|
|
||||||
const timeChart = page.locator('[data-testid="time-series-chart"]');
|
|
||||||
|
|
||||||
if (await timeChart.isVisible()) {
|
|
||||||
await waitForChartLoad(page, '[data-testid="time-series-chart"]');
|
|
||||||
|
|
||||||
// Check chart axes
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="chart-x-axis"]')
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="chart-y-axis"]')
|
|
||||||
).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Chart Interactions", () => {
|
|
||||||
test("should allow chart filtering interactions", async ({ page }) => {
|
|
||||||
const sentimentChart = page.locator('[data-testid="sentiment-chart"]');
|
|
||||||
|
|
||||||
if (await sentimentChart.isVisible()) {
|
|
||||||
// Click on positive sentiment section
|
|
||||||
const positiveSection = page.locator(
|
|
||||||
'[data-testid="positive-segment"]'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (await positiveSection.isVisible()) {
|
|
||||||
await positiveSection.click();
|
|
||||||
|
|
||||||
// Should filter data or show details
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="chart-filter-active"]')
|
|
||||||
).toBeVisible();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should show chart tooltips on hover", async ({ page }) => {
|
|
||||||
const chart = page.locator('[data-testid="sentiment-chart"]');
|
|
||||||
|
|
||||||
if (await chart.isVisible()) {
|
|
||||||
await chart.hover();
|
|
||||||
|
|
||||||
// Check for tooltip
|
|
||||||
const tooltip = page.locator('[data-testid="chart-tooltip"]');
|
|
||||||
if (await tooltip.isVisible()) {
|
|
||||||
await expect(tooltip).toContainText(/\d+/); // Should show numeric data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should support chart zoom and pan", async ({ page }) => {
|
|
||||||
const timeChart = page.locator('[data-testid="time-series-chart"]');
|
|
||||||
|
|
||||||
if (await timeChart.isVisible()) {
|
|
||||||
// Test zoom (scroll)
|
|
||||||
await timeChart.hover();
|
|
||||||
await page.mouse.wheel(0, -100);
|
|
||||||
|
|
||||||
// Test pan (drag)
|
|
||||||
const box = await timeChart.boundingBox();
|
|
||||||
if (box) {
|
|
||||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(
|
|
||||||
box.x + box.width / 2 + 50,
|
|
||||||
box.y + box.height / 2
|
|
||||||
);
|
|
||||||
await page.mouse.up();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Data Filtering", () => {
|
|
||||||
test("should filter data by date range", async ({ page }) => {
|
|
||||||
// Open date picker
|
|
||||||
const dateFilter = page.locator('[data-testid="date-range-picker"]');
|
|
||||||
|
|
||||||
if (await dateFilter.isVisible()) {
|
|
||||||
await dateFilter.click();
|
|
||||||
|
|
||||||
// Select date range
|
|
||||||
await page.click('[data-testid="date-last-week"]');
|
|
||||||
|
|
||||||
// Wait for charts to update after date filter application
|
|
||||||
await page.waitForSelector('[data-testid="filter-applied"], [data-testid="charts-updated"], .loading:not(.visible)', {
|
|
||||||
timeout: 5000,
|
|
||||||
state: 'visible'
|
|
||||||
}).catch(() => {
|
|
||||||
// Fallback: wait for any indication that filtering is complete
|
|
||||||
return page.waitForFunction(() => !document.querySelector('.loading, [data-loading="true"]'), { timeout: 3000 }).catch(() => null);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check that data is filtered
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="filter-applied"]')
|
|
||||||
).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should filter data by sentiment", async ({ page }) => {
|
|
||||||
const sentimentFilter = page.locator('[data-testid="sentiment-filter"]');
|
|
||||||
|
|
||||||
if (await sentimentFilter.isVisible()) {
|
|
||||||
await sentimentFilter.selectOption("POSITIVE");
|
|
||||||
|
|
||||||
// Wait for visualizations to update after sentiment filter
|
|
||||||
await page.waitForSelector('[data-testid="active-filters"], [data-testid="sentiment-applied"], .charts-container:not(.updating)', {
|
|
||||||
timeout: 5000,
|
|
||||||
state: 'visible'
|
|
||||||
}).catch(() => {
|
|
||||||
// Fallback: wait for filter processing to complete
|
|
||||||
return page.waitForFunction(() => !document.querySelector('.updating, [data-updating="true"], .filter-loading'), { timeout: 3000 }).catch(() => null);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check filter is applied
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="active-filters"]')
|
|
||||||
).toContainText("Sentiment: Positive");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should clear all filters", async ({ page }) => {
|
|
||||||
// Apply some filters first
|
|
||||||
const sentimentFilter = page.locator('[data-testid="sentiment-filter"]');
|
|
||||||
if (await sentimentFilter.isVisible()) {
|
|
||||||
await sentimentFilter.selectOption("POSITIVE");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear filters
|
|
||||||
const clearButton = page.locator('[data-testid="clear-filters"]');
|
|
||||||
if (await clearButton.isVisible()) {
|
|
||||||
await clearButton.click();
|
|
||||||
|
|
||||||
// Should reset all data
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="active-filters"]')
|
|
||||||
).toHaveCount(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Data Export", () => {
|
|
||||||
test("should export chart data as CSV", async ({ page }) => {
|
|
||||||
const exportButton = page.locator('[data-testid="export-csv"]');
|
|
||||||
|
|
||||||
if (await exportButton.isVisible()) {
|
|
||||||
// Start download
|
|
||||||
const downloadPromise = page.waitForEvent("download");
|
|
||||||
await exportButton.click();
|
|
||||||
const download = await downloadPromise;
|
|
||||||
|
|
||||||
// Verify download
|
|
||||||
expect(download.suggestedFilename()).toContain(".csv");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should export chart as image", async ({ page }) => {
|
|
||||||
const exportButton = page.locator('[data-testid="export-image"]');
|
|
||||||
|
|
||||||
if (await exportButton.isVisible()) {
|
|
||||||
const downloadPromise = page.waitForEvent("download");
|
|
||||||
await exportButton.click();
|
|
||||||
const download = await downloadPromise;
|
|
||||||
|
|
||||||
expect(download.suggestedFilename()).toMatch(/\.(png|jpg|svg)$/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Responsive Design", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await loginUser(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Mobile Layout", () => {
|
|
||||||
test("should adapt navigation for mobile", async ({ page }) => {
|
|
||||||
// Set mobile viewport
|
|
||||||
await page.setViewportSize({ width: 375, height: 667 });
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
|
|
||||||
// Should show mobile menu button
|
|
||||||
const mobileMenu = page.locator('[data-testid="mobile-menu-toggle"]');
|
|
||||||
await expect(mobileMenu).toBeVisible();
|
|
||||||
|
|
||||||
// Open mobile menu
|
|
||||||
await mobileMenu.click();
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="mobile-navigation"]')
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Check navigation items in mobile menu
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="mobile-nav-overview"]')
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="mobile-nav-sessions"]')
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should stack charts vertically on mobile", async ({ page }) => {
|
|
||||||
await page.setViewportSize({ width: 375, height: 667 });
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
|
|
||||||
// Charts should be stacked
|
|
||||||
const chartContainer = page.locator('[data-testid="charts-container"]');
|
|
||||||
await expect(chartContainer).toHaveCSS("flex-direction", "column");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should show simplified metrics on mobile", async ({ page }) => {
|
|
||||||
await page.setViewportSize({ width: 375, height: 667 });
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
|
|
||||||
// Should show condensed metric cards
|
|
||||||
const metricCards = page.locator('[data-testid="metric-card"]');
|
|
||||||
const count = await metricCards.count();
|
|
||||||
|
|
||||||
// Should show fewer cards or smaller layout
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const card = metricCards.nth(i);
|
|
||||||
const box = await card.boundingBox();
|
|
||||||
if (box) {
|
|
||||||
expect(box.width).toBeLessThan(300); // Smaller cards on mobile
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Tablet Layout", () => {
|
|
||||||
test("should adapt layout for tablet", async ({ page }) => {
|
|
||||||
await page.setViewportSize({ width: 768, height: 1024 });
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
|
|
||||||
// Should show sidebar but possibly collapsed
|
|
||||||
const sidebar = page.locator('[data-testid="sidebar"]');
|
|
||||||
await expect(sidebar).toBeVisible();
|
|
||||||
|
|
||||||
// Charts should adapt to medium screen
|
|
||||||
const chartGrid = page.locator('[data-testid="chart-grid"]');
|
|
||||||
await expect(chartGrid).toHaveCSS("grid-template-columns", /repeat\(2,/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Accessibility", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await loginUser(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Keyboard Navigation", () => {
|
|
||||||
test("should support keyboard navigation in dashboard", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
|
|
||||||
// Test tab navigation
|
|
||||||
await page.keyboard.press("Tab");
|
|
||||||
|
|
||||||
// Should focus on first interactive element
|
|
||||||
const focused = page.locator(":focus");
|
|
||||||
await expect(focused).toBeVisible();
|
|
||||||
|
|
||||||
// Navigate through elements
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
await page.keyboard.press("Tab");
|
|
||||||
const currentFocus = page.locator(":focus");
|
|
||||||
await expect(currentFocus).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should support keyboard shortcuts", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
|
|
||||||
// Test keyboard shortcuts (if implemented)
|
|
||||||
await page.keyboard.press("Alt+1"); // Navigate to overview
|
|
||||||
await expect(page).toHaveURL(/\/dashboard\/overview/);
|
|
||||||
|
|
||||||
await page.keyboard.press("Alt+2"); // Navigate to sessions
|
|
||||||
await expect(page).toHaveURL(/\/dashboard\/sessions/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Screen Reader Support", () => {
|
|
||||||
test("should have proper ARIA labels", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
|
|
||||||
// Check main landmarks
|
|
||||||
await expect(page.locator("main")).toHaveAttribute("role", "main");
|
|
||||||
await expect(page.locator("nav")).toHaveAttribute("role", "navigation");
|
|
||||||
|
|
||||||
// Check chart accessibility
|
|
||||||
const sentimentChart = page.locator('[data-testid="sentiment-chart"]');
|
|
||||||
if (await sentimentChart.isVisible()) {
|
|
||||||
await expect(sentimentChart).toHaveAttribute("role", "img");
|
|
||||||
await expect(sentimentChart).toHaveAttribute("aria-label");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should provide alternative text for charts", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
|
|
||||||
// Check chart descriptions
|
|
||||||
const charts = page.locator('[role="img"]');
|
|
||||||
const count = await charts.count();
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const chart = charts.nth(i);
|
|
||||||
const ariaLabel = await chart.getAttribute("aria-label");
|
|
||||||
expect(ariaLabel).toBeTruthy();
|
|
||||||
expect(ariaLabel?.length).toBeGreaterThan(10); // Should be descriptive
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should announce dynamic content changes", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
|
|
||||||
// Check for live regions
|
|
||||||
const liveRegions = page.locator("[aria-live]");
|
|
||||||
const count = await liveRegions.count();
|
|
||||||
|
|
||||||
if (count > 0) {
|
|
||||||
// Should have appropriate aria-live settings
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const region = liveRegions.nth(i);
|
|
||||||
const ariaLive = await region.getAttribute("aria-live");
|
|
||||||
expect(["polite", "assertive"]).toContain(ariaLive);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Color and Contrast", () => {
|
|
||||||
test("should maintain accessibility in dark mode", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
|
|
||||||
// Toggle dark mode (if available)
|
|
||||||
const darkModeToggle = page.locator('[data-testid="theme-toggle"]');
|
|
||||||
|
|
||||||
if (await darkModeToggle.isVisible()) {
|
|
||||||
await darkModeToggle.click();
|
|
||||||
|
|
||||||
// Check that elements are still visible
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="total-sessions-card"]')
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="sentiment-chart"]')
|
|
||||||
).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should work without color", async ({ page }) => {
|
|
||||||
// Test with forced colors (simulates high contrast mode)
|
|
||||||
await page.emulateMedia({ colorScheme: "dark", forcedColors: "active" });
|
|
||||||
await page.goto("http://localhost:3000/dashboard/overview");
|
|
||||||
|
|
||||||
// Elements should still be distinguishable
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="total-sessions-card"]')
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="sentiment-chart"]')
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,468 +0,0 @@
|
|||||||
/**
|
|
||||||
* E2E tests for complete user registration and login workflow
|
|
||||||
*
|
|
||||||
* Tests the full user journey:
|
|
||||||
* 1. Company registration
|
|
||||||
* 2. User login
|
|
||||||
* 3. Dashboard access
|
|
||||||
* 4. Authentication state management
|
|
||||||
* 5. Session persistence
|
|
||||||
* 6. Logout functionality
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect, type Page } from "@playwright/test";
|
|
||||||
|
|
||||||
// Test data
|
|
||||||
const testCompany = {
|
|
||||||
name: "E2E Test Company",
|
|
||||||
csvUrl: "https://example.com/test.csv",
|
|
||||||
csvUsername: "testuser",
|
|
||||||
csvPassword: "testpass123",
|
|
||||||
adminEmail: "admin@e2etest.com",
|
|
||||||
adminName: "E2E Admin",
|
|
||||||
adminPassword: "E2ETestPassword123!",
|
|
||||||
};
|
|
||||||
|
|
||||||
const testUser = {
|
|
||||||
email: "user@e2etest.com",
|
|
||||||
password: "UserTestPassword123!",
|
|
||||||
name: "E2E Test User",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
async function fillRegistrationForm(page: Page) {
|
|
||||||
await page.fill('[data-testid="company-name"]', testCompany.name);
|
|
||||||
await page.fill('[data-testid="csv-url"]', testCompany.csvUrl);
|
|
||||||
await page.fill('[data-testid="csv-username"]', testCompany.csvUsername);
|
|
||||||
await page.fill('[data-testid="csv-password"]', testCompany.csvPassword);
|
|
||||||
await page.fill('[data-testid="admin-email"]', testCompany.adminEmail);
|
|
||||||
await page.fill('[data-testid="admin-name"]', testCompany.adminName);
|
|
||||||
await page.fill('[data-testid="admin-password"]', testCompany.adminPassword);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fillLoginForm(page: Page, email: string, password: string) {
|
|
||||||
await page.fill('[data-testid="email"]', email);
|
|
||||||
await page.fill('[data-testid="password"]', password);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForDashboard(page: Page) {
|
|
||||||
await expect(page).toHaveURL(/\/dashboard/);
|
|
||||||
await expect(page.locator("h1")).toContainText("Dashboard");
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("User Authentication Workflow", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
// Set base URL for local development
|
|
||||||
await page.goto("http://localhost:3000");
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Company Registration Flow", () => {
|
|
||||||
test("should allow new company registration with admin user", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
// Navigate to registration page
|
|
||||||
await page.click('[data-testid="register-link"]');
|
|
||||||
await expect(page).toHaveURL(/\/register/);
|
|
||||||
|
|
||||||
// Fill registration form
|
|
||||||
await fillRegistrationForm(page);
|
|
||||||
|
|
||||||
// Submit registration
|
|
||||||
await page.click('[data-testid="register-button"]');
|
|
||||||
|
|
||||||
// Should redirect to login page with success message
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="success-message"]')
|
|
||||||
).toContainText("Registration successful");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should validate registration form fields", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/register");
|
|
||||||
|
|
||||||
// Try to submit empty form
|
|
||||||
await page.click('[data-testid="register-button"]');
|
|
||||||
|
|
||||||
// Should show validation errors
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="company-name-error"]')
|
|
||||||
).toContainText("Company name is required");
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="admin-email-error"]')
|
|
||||||
).toContainText("Email is required");
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="admin-password-error"]')
|
|
||||||
).toContainText("Password must be at least 12 characters");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should enforce password strength requirements", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/register");
|
|
||||||
|
|
||||||
// Test weak password
|
|
||||||
await page.fill('[data-testid="admin-password"]', "weakpass");
|
|
||||||
await page.blur('[data-testid="admin-password"]');
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="admin-password-error"]')
|
|
||||||
).toContainText("Password must contain at least one uppercase letter");
|
|
||||||
|
|
||||||
// Test strong password
|
|
||||||
await page.fill('[data-testid="admin-password"]', "StrongPassword123!");
|
|
||||||
await page.blur('[data-testid="admin-password"]');
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="admin-password-error"]')
|
|
||||||
).toHaveCount(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("User Login Flow", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
// Assume company registration was completed in previous test
|
|
||||||
// Navigate directly to login page
|
|
||||||
await page.goto("http://localhost:3000/login");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should allow successful login with valid credentials", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
// Fill login form
|
|
||||||
await fillLoginForm(
|
|
||||||
page,
|
|
||||||
testCompany.adminEmail,
|
|
||||||
testCompany.adminPassword
|
|
||||||
);
|
|
||||||
|
|
||||||
// Submit login
|
|
||||||
await page.click('[data-testid="login-button"]');
|
|
||||||
|
|
||||||
// Should redirect to dashboard
|
|
||||||
await waitForDashboard(page);
|
|
||||||
|
|
||||||
// Verify user info is displayed
|
|
||||||
await expect(page.locator('[data-testid="user-name"]')).toContainText(
|
|
||||||
testCompany.adminName
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject invalid credentials", async ({ page }) => {
|
|
||||||
// Fill login form with wrong password
|
|
||||||
await fillLoginForm(page, testCompany.adminEmail, "wrongpassword");
|
|
||||||
|
|
||||||
// Submit login
|
|
||||||
await page.click('[data-testid="login-button"]');
|
|
||||||
|
|
||||||
// Should show error message
|
|
||||||
await expect(page.locator('[data-testid="error-message"]')).toContainText(
|
|
||||||
"Invalid credentials"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should remain on login page
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should validate login form fields", async ({ page }) => {
|
|
||||||
// Try to submit empty form
|
|
||||||
await page.click('[data-testid="login-button"]');
|
|
||||||
|
|
||||||
// Should show validation errors
|
|
||||||
await expect(page.locator('[data-testid="email-error"]')).toContainText(
|
|
||||||
"Email is required"
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="password-error"]')
|
|
||||||
).toContainText("Password is required");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle rate limiting", async ({ page }) => {
|
|
||||||
// Attempt multiple failed logins
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
await fillLoginForm(page, "invalid@email.com", "wrongpassword");
|
|
||||||
await page.click('[data-testid="login-button"]');
|
|
||||||
await page.waitForTimeout(100); // Small delay between attempts
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should show rate limit error
|
|
||||||
await expect(page.locator('[data-testid="error-message"]')).toContainText(
|
|
||||||
"Too many login attempts"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Dashboard Access and Navigation", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
// Login before each test
|
|
||||||
await page.goto("http://localhost:3000/login");
|
|
||||||
await fillLoginForm(
|
|
||||||
page,
|
|
||||||
testCompany.adminEmail,
|
|
||||||
testCompany.adminPassword
|
|
||||||
);
|
|
||||||
await page.click('[data-testid="login-button"]');
|
|
||||||
await waitForDashboard(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should display dashboard overview correctly", async ({ page }) => {
|
|
||||||
// Check main dashboard elements
|
|
||||||
await expect(page.locator("h1")).toContainText("Dashboard Overview");
|
|
||||||
|
|
||||||
// Check metric cards
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="total-sessions-card"]')
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="avg-sentiment-card"]')
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="escalation-rate-card"]')
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Check navigation sidebar
|
|
||||||
await expect(page.locator('[data-testid="nav-overview"]')).toBeVisible();
|
|
||||||
await expect(page.locator('[data-testid="nav-sessions"]')).toBeVisible();
|
|
||||||
await expect(page.locator('[data-testid="nav-users"]')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should navigate between dashboard sections", async ({ page }) => {
|
|
||||||
// Navigate to Sessions
|
|
||||||
await page.click('[data-testid="nav-sessions"]');
|
|
||||||
await expect(page).toHaveURL(/\/dashboard\/sessions/);
|
|
||||||
await expect(page.locator("h1")).toContainText("Sessions");
|
|
||||||
|
|
||||||
// Navigate to Users
|
|
||||||
await page.click('[data-testid="nav-users"]');
|
|
||||||
await expect(page).toHaveURL(/\/dashboard\/users/);
|
|
||||||
await expect(page.locator("h1")).toContainText("Users");
|
|
||||||
|
|
||||||
// Navigate back to Overview
|
|
||||||
await page.click('[data-testid="nav-overview"]');
|
|
||||||
await expect(page).toHaveURL(/\/dashboard\/overview/);
|
|
||||||
await expect(page.locator("h1")).toContainText("Dashboard Overview");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle unauthorized access attempts", async ({ page }) => {
|
|
||||||
// Try to access admin-only features as regular user
|
|
||||||
await page.goto("http://localhost:3000/dashboard/users");
|
|
||||||
|
|
||||||
// If user is not admin, should show appropriate message or redirect
|
|
||||||
const isAdmin = await page
|
|
||||||
.locator('[data-testid="admin-panel"]')
|
|
||||||
.isVisible();
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="access-denied"]')
|
|
||||||
).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Session Management", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
// Login before each test
|
|
||||||
await page.goto("http://localhost:3000/login");
|
|
||||||
await fillLoginForm(
|
|
||||||
page,
|
|
||||||
testCompany.adminEmail,
|
|
||||||
testCompany.adminPassword
|
|
||||||
);
|
|
||||||
await page.click('[data-testid="login-button"]');
|
|
||||||
await waitForDashboard(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should persist session across page refreshes", async ({ page }) => {
|
|
||||||
// Refresh the page
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Should still be logged in
|
|
||||||
await waitForDashboard(page);
|
|
||||||
await expect(page.locator('[data-testid="user-name"]')).toContainText(
|
|
||||||
testCompany.adminName
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should persist session across browser tabs", async ({ context }) => {
|
|
||||||
// Open new tab
|
|
||||||
const newTab = await context.newPage();
|
|
||||||
await newTab.goto("http://localhost:3000/dashboard");
|
|
||||||
|
|
||||||
// Should be automatically logged in
|
|
||||||
await waitForDashboard(newTab);
|
|
||||||
await expect(newTab.locator('[data-testid="user-name"]')).toContainText(
|
|
||||||
testCompany.adminName
|
|
||||||
);
|
|
||||||
|
|
||||||
await newTab.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should redirect to login when session expires", async ({ page }) => {
|
|
||||||
// Simulate session expiration by clearing localStorage/cookies
|
|
||||||
await page.evaluate(() => {
|
|
||||||
localStorage.clear();
|
|
||||||
document.cookie.split(";").forEach((c) => {
|
|
||||||
const eqPos = c.indexOf("=");
|
|
||||||
const name = eqPos > -1 ? c.substr(0, eqPos) : c;
|
|
||||||
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Try to navigate to protected page
|
|
||||||
await page.goto("http://localhost:3000/dashboard");
|
|
||||||
|
|
||||||
// Should redirect to login
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Logout Functionality", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
// Login before each test
|
|
||||||
await page.goto("http://localhost:3000/login");
|
|
||||||
await fillLoginForm(
|
|
||||||
page,
|
|
||||||
testCompany.adminEmail,
|
|
||||||
testCompany.adminPassword
|
|
||||||
);
|
|
||||||
await page.click('[data-testid="login-button"]');
|
|
||||||
await waitForDashboard(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should successfully logout user", async ({ page }) => {
|
|
||||||
// Open user menu
|
|
||||||
await page.click('[data-testid="user-menu"]');
|
|
||||||
|
|
||||||
// Click logout
|
|
||||||
await page.click('[data-testid="logout-button"]');
|
|
||||||
|
|
||||||
// Should redirect to login page
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
|
|
||||||
// Should show logout success message
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="success-message"]')
|
|
||||||
).toContainText("Logged out successfully");
|
|
||||||
|
|
||||||
// Try to access protected page
|
|
||||||
await page.goto("http://localhost:3000/dashboard");
|
|
||||||
|
|
||||||
// Should redirect back to login
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should clear session data on logout", async ({ page }) => {
|
|
||||||
// Check that session data exists
|
|
||||||
const sessionBefore = await page.evaluate(() =>
|
|
||||||
localStorage.getItem("session")
|
|
||||||
);
|
|
||||||
expect(sessionBefore).toBeTruthy();
|
|
||||||
|
|
||||||
// Logout
|
|
||||||
await page.click('[data-testid="user-menu"]');
|
|
||||||
await page.click('[data-testid="logout-button"]');
|
|
||||||
|
|
||||||
// Check that session data is cleared
|
|
||||||
const sessionAfter = await page.evaluate(() =>
|
|
||||||
localStorage.getItem("session")
|
|
||||||
);
|
|
||||||
expect(sessionAfter).toBeFalsy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Password Reset Flow", () => {
|
|
||||||
test("should allow password reset request", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/login");
|
|
||||||
|
|
||||||
// Click forgot password link
|
|
||||||
await page.click('[data-testid="forgot-password-link"]');
|
|
||||||
await expect(page).toHaveURL(/\/forgot-password/);
|
|
||||||
|
|
||||||
// Enter email
|
|
||||||
await page.fill('[data-testid="email"]', testCompany.adminEmail);
|
|
||||||
await page.click('[data-testid="reset-button"]');
|
|
||||||
|
|
||||||
// Should show success message
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="success-message"]')
|
|
||||||
).toContainText("Password reset email sent");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should validate email format in password reset", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/forgot-password");
|
|
||||||
|
|
||||||
// Enter invalid email
|
|
||||||
await page.fill('[data-testid="email"]', "invalid-email");
|
|
||||||
await page.click('[data-testid="reset-button"]');
|
|
||||||
|
|
||||||
// Should show validation error
|
|
||||||
await expect(page.locator('[data-testid="email-error"]')).toContainText(
|
|
||||||
"Invalid email format"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Mobile Responsive Design", () => {
|
|
||||||
test("should work correctly on mobile devices", async ({ page }) => {
|
|
||||||
// Set mobile viewport
|
|
||||||
await page.setViewportSize({ width: 375, height: 667 });
|
|
||||||
|
|
||||||
// Test login flow on mobile
|
|
||||||
await page.goto("http://localhost:3000/login");
|
|
||||||
await fillLoginForm(
|
|
||||||
page,
|
|
||||||
testCompany.adminEmail,
|
|
||||||
testCompany.adminPassword
|
|
||||||
);
|
|
||||||
await page.click('[data-testid="login-button"]');
|
|
||||||
|
|
||||||
// Should work on mobile
|
|
||||||
await waitForDashboard(page);
|
|
||||||
|
|
||||||
// Check mobile navigation
|
|
||||||
const mobileMenu = page.locator('[data-testid="mobile-menu-toggle"]');
|
|
||||||
if (await mobileMenu.isVisible()) {
|
|
||||||
await mobileMenu.click();
|
|
||||||
await expect(page.locator('[data-testid="mobile-nav"]')).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Accessibility", () => {
|
|
||||||
test("should be accessible with keyboard navigation", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/login");
|
|
||||||
|
|
||||||
// Test keyboard navigation
|
|
||||||
await page.keyboard.press("Tab");
|
|
||||||
await expect(page.locator('[data-testid="email"]')).toBeFocused();
|
|
||||||
|
|
||||||
await page.keyboard.press("Tab");
|
|
||||||
await expect(page.locator('[data-testid="password"]')).toBeFocused();
|
|
||||||
|
|
||||||
await page.keyboard.press("Tab");
|
|
||||||
await expect(page.locator('[data-testid="login-button"]')).toBeFocused();
|
|
||||||
|
|
||||||
// Test form submission with Enter key
|
|
||||||
await page.fill('[data-testid="email"]', testCompany.adminEmail);
|
|
||||||
await page.fill('[data-testid="password"]', testCompany.adminPassword);
|
|
||||||
await page.keyboard.press("Enter");
|
|
||||||
|
|
||||||
await waitForDashboard(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should have proper ARIA labels and roles", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/login");
|
|
||||||
|
|
||||||
// Check form accessibility
|
|
||||||
await expect(page.locator('[data-testid="email"]')).toHaveAttribute(
|
|
||||||
"aria-label",
|
|
||||||
"Email address"
|
|
||||||
);
|
|
||||||
await expect(page.locator('[data-testid="password"]')).toHaveAttribute(
|
|
||||||
"aria-label",
|
|
||||||
"Password"
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="login-button"]')
|
|
||||||
).toHaveAttribute("role", "button");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,390 +0,0 @@
|
|||||||
/**
|
|
||||||
* Centralized Authorization System
|
|
||||||
*
|
|
||||||
* Provides role-based access control with granular permissions,
|
|
||||||
* company-level access control, and audit trail integration.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { AuthorizationError } from "./errors";
|
|
||||||
import type { APIContext } from "./handler";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* System permissions enumeration
|
|
||||||
*/
|
|
||||||
export enum Permission {
|
|
||||||
// Audit & Security
|
|
||||||
READ_AUDIT_LOGS = "audit_logs:read",
|
|
||||||
EXPORT_AUDIT_LOGS = "audit_logs:export",
|
|
||||||
MANAGE_SECURITY = "security:manage",
|
|
||||||
|
|
||||||
// User Management
|
|
||||||
READ_USERS = "users:read",
|
|
||||||
MANAGE_USERS = "users:manage",
|
|
||||||
INVITE_USERS = "users:invite",
|
|
||||||
|
|
||||||
// Company Management
|
|
||||||
READ_COMPANIES = "companies:read",
|
|
||||||
MANAGE_COMPANIES = "companies:manage",
|
|
||||||
MANAGE_COMPANY_SETTINGS = "companies:settings",
|
|
||||||
|
|
||||||
// Dashboard & Analytics
|
|
||||||
READ_DASHBOARD = "dashboard:read",
|
|
||||||
READ_SESSIONS = "sessions:read",
|
|
||||||
MANAGE_SESSIONS = "sessions:manage",
|
|
||||||
|
|
||||||
// System Administration
|
|
||||||
PLATFORM_ADMIN = "platform:admin",
|
|
||||||
CACHE_MANAGE = "cache:manage",
|
|
||||||
SCHEDULER_MANAGE = "schedulers:manage",
|
|
||||||
|
|
||||||
// AI & Processing
|
|
||||||
MANAGE_AI_PROCESSING = "ai:manage",
|
|
||||||
READ_AI_METRICS = "ai:read",
|
|
||||||
|
|
||||||
// Import & Export
|
|
||||||
IMPORT_DATA = "data:import",
|
|
||||||
EXPORT_DATA = "data:export",
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User roles with their associated permissions
|
|
||||||
*/
|
|
||||||
export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
|
||||||
USER: [Permission.READ_DASHBOARD, Permission.READ_SESSIONS],
|
|
||||||
|
|
||||||
AUDITOR: [
|
|
||||||
Permission.READ_DASHBOARD,
|
|
||||||
Permission.READ_SESSIONS,
|
|
||||||
Permission.READ_AUDIT_LOGS,
|
|
||||||
Permission.EXPORT_AUDIT_LOGS,
|
|
||||||
Permission.READ_AI_METRICS,
|
|
||||||
],
|
|
||||||
|
|
||||||
ADMIN: [
|
|
||||||
// Inherit USER permissions
|
|
||||||
Permission.READ_DASHBOARD,
|
|
||||||
Permission.READ_SESSIONS,
|
|
||||||
Permission.MANAGE_SESSIONS,
|
|
||||||
|
|
||||||
// Inherit AUDITOR permissions
|
|
||||||
Permission.READ_AUDIT_LOGS,
|
|
||||||
Permission.EXPORT_AUDIT_LOGS,
|
|
||||||
Permission.READ_AI_METRICS,
|
|
||||||
|
|
||||||
// Admin-specific permissions
|
|
||||||
Permission.READ_USERS,
|
|
||||||
Permission.MANAGE_USERS,
|
|
||||||
Permission.INVITE_USERS,
|
|
||||||
Permission.MANAGE_COMPANY_SETTINGS,
|
|
||||||
Permission.MANAGE_SECURITY,
|
|
||||||
Permission.MANAGE_AI_PROCESSING,
|
|
||||||
Permission.IMPORT_DATA,
|
|
||||||
Permission.EXPORT_DATA,
|
|
||||||
Permission.CACHE_MANAGE,
|
|
||||||
],
|
|
||||||
|
|
||||||
PLATFORM_ADMIN: [
|
|
||||||
// Include all ADMIN permissions
|
|
||||||
Permission.READ_DASHBOARD,
|
|
||||||
Permission.READ_SESSIONS,
|
|
||||||
Permission.MANAGE_SESSIONS,
|
|
||||||
Permission.READ_AUDIT_LOGS,
|
|
||||||
Permission.EXPORT_AUDIT_LOGS,
|
|
||||||
Permission.READ_AI_METRICS,
|
|
||||||
Permission.READ_USERS,
|
|
||||||
Permission.MANAGE_USERS,
|
|
||||||
Permission.INVITE_USERS,
|
|
||||||
Permission.MANAGE_COMPANY_SETTINGS,
|
|
||||||
Permission.MANAGE_SECURITY,
|
|
||||||
Permission.MANAGE_AI_PROCESSING,
|
|
||||||
Permission.IMPORT_DATA,
|
|
||||||
Permission.EXPORT_DATA,
|
|
||||||
Permission.CACHE_MANAGE,
|
|
||||||
|
|
||||||
// Platform-specific permissions
|
|
||||||
Permission.PLATFORM_ADMIN,
|
|
||||||
Permission.READ_COMPANIES,
|
|
||||||
Permission.MANAGE_COMPANIES,
|
|
||||||
Permission.SCHEDULER_MANAGE,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resource types for company-level access control
|
|
||||||
*/
|
|
||||||
export enum ResourceType {
|
|
||||||
AUDIT_LOG = "audit_log",
|
|
||||||
SESSION = "session",
|
|
||||||
USER = "user",
|
|
||||||
COMPANY = "company",
|
|
||||||
AI_REQUEST = "ai_request",
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Company access validation result
|
|
||||||
*/
|
|
||||||
export interface CompanyAccessResult {
|
|
||||||
allowed: boolean;
|
|
||||||
reason?: string;
|
|
||||||
companyId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a user has a specific permission
|
|
||||||
*/
|
|
||||||
export function hasPermission(
|
|
||||||
userRole: string,
|
|
||||||
permission: Permission
|
|
||||||
): boolean {
|
|
||||||
const rolePermissions = ROLE_PERMISSIONS[userRole];
|
|
||||||
return rolePermissions?.includes(permission) ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a user has any of the specified permissions
|
|
||||||
*/
|
|
||||||
export function hasAnyPermission(
|
|
||||||
userRole: string,
|
|
||||||
permissions: Permission[]
|
|
||||||
): boolean {
|
|
||||||
return permissions.some((permission) => hasPermission(userRole, permission));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a user has all of the specified permissions
|
|
||||||
*/
|
|
||||||
export function hasAllPermissions(
|
|
||||||
userRole: string,
|
|
||||||
permissions: Permission[]
|
|
||||||
): boolean {
|
|
||||||
return permissions.every((permission) => hasPermission(userRole, permission));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all permissions for a user role
|
|
||||||
*/
|
|
||||||
export function getUserPermissions(userRole: string): Permission[] {
|
|
||||||
return ROLE_PERMISSIONS[userRole] || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate permission access and throw if unauthorized
|
|
||||||
*/
|
|
||||||
export function requirePermission(permission: Permission) {
|
|
||||||
return (context: APIContext) => {
|
|
||||||
if (!context.user) {
|
|
||||||
throw new AuthorizationError("Authentication required");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasPermission(context.user.role, permission)) {
|
|
||||||
throw new AuthorizationError(`Permission required: ${permission}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate any of the specified permissions
|
|
||||||
*/
|
|
||||||
export function requireAnyPermission(permissions: Permission[]) {
|
|
||||||
return (context: APIContext) => {
|
|
||||||
if (!context.user) {
|
|
||||||
throw new AuthorizationError("Authentication required");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasAnyPermission(context.user.role, permissions)) {
|
|
||||||
throw new AuthorizationError(
|
|
||||||
`One of these permissions required: ${permissions.join(", ")}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate all of the specified permissions
|
|
||||||
*/
|
|
||||||
export function requireAllPermissions(permissions: Permission[]) {
|
|
||||||
return (context: APIContext) => {
|
|
||||||
if (!context.user) {
|
|
||||||
throw new AuthorizationError("Authentication required");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasAllPermissions(context.user.role, permissions)) {
|
|
||||||
throw new AuthorizationError(
|
|
||||||
`All of these permissions required: ${permissions.join(", ")}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if user can access resources from a specific company
|
|
||||||
*/
|
|
||||||
export function validateCompanyAccess(
|
|
||||||
context: APIContext,
|
|
||||||
targetCompanyId: string,
|
|
||||||
_resourceType?: ResourceType
|
|
||||||
): CompanyAccessResult {
|
|
||||||
if (!context.user) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: "Authentication required",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Platform admins can access all companies
|
|
||||||
if (context.user.role === "PLATFORM_ADMIN") {
|
|
||||||
return {
|
|
||||||
allowed: true,
|
|
||||||
companyId: targetCompanyId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular users can only access their own company's resources
|
|
||||||
if (context.user.companyId !== targetCompanyId) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: `Access denied to company ${targetCompanyId}`,
|
|
||||||
companyId: context.user.companyId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
allowed: true,
|
|
||||||
companyId: targetCompanyId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Require company access validation
|
|
||||||
*/
|
|
||||||
export function requireCompanyAccess(
|
|
||||||
targetCompanyId: string,
|
|
||||||
resourceType?: ResourceType
|
|
||||||
) {
|
|
||||||
return (context: APIContext) => {
|
|
||||||
const accessResult = validateCompanyAccess(
|
|
||||||
context,
|
|
||||||
targetCompanyId,
|
|
||||||
resourceType
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!accessResult.allowed) {
|
|
||||||
throw new AuthorizationError(accessResult.reason);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract company ID from request and validate access
|
|
||||||
*/
|
|
||||||
export function requireCompanyAccessFromRequest(
|
|
||||||
getCompanyId: (context: APIContext) => string | Promise<string>,
|
|
||||||
resourceType?: ResourceType
|
|
||||||
) {
|
|
||||||
return async (context: APIContext) => {
|
|
||||||
const companyId = await getCompanyId(context);
|
|
||||||
const accessResult = validateCompanyAccess(
|
|
||||||
context,
|
|
||||||
companyId,
|
|
||||||
resourceType
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!accessResult.allowed) {
|
|
||||||
throw new AuthorizationError(accessResult.reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
return companyId;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Role hierarchy helper - check if role A is higher than role B
|
|
||||||
*/
|
|
||||||
export function isRoleHigherThan(roleA: string, roleB: string): boolean {
|
|
||||||
const roleHierarchy = {
|
|
||||||
USER: 1,
|
|
||||||
AUDITOR: 2,
|
|
||||||
ADMIN: 3,
|
|
||||||
PLATFORM_ADMIN: 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
const levelA = roleHierarchy[roleA as keyof typeof roleHierarchy] || 0;
|
|
||||||
const levelB = roleHierarchy[roleB as keyof typeof roleHierarchy] || 0;
|
|
||||||
|
|
||||||
return levelA > levelB;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if user can manage another user (role hierarchy)
|
|
||||||
*/
|
|
||||||
export function canManageUser(
|
|
||||||
managerRole: string,
|
|
||||||
targetUserRole: string
|
|
||||||
): boolean {
|
|
||||||
// Platform admins can manage anyone
|
|
||||||
if (managerRole === "PLATFORM_ADMIN") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admins can manage users and auditors, but not other admins or platform admins
|
|
||||||
if (managerRole === "ADMIN") {
|
|
||||||
return ["USER", "AUDITOR"].includes(targetUserRole);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Other roles cannot manage users
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Require user management permission
|
|
||||||
*/
|
|
||||||
export function requireUserManagementPermission(targetUserRole: string) {
|
|
||||||
return (context: APIContext) => {
|
|
||||||
if (!context.user) {
|
|
||||||
throw new AuthorizationError("Authentication required");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!canManageUser(context.user.role, targetUserRole)) {
|
|
||||||
throw new AuthorizationError(
|
|
||||||
`Insufficient permissions to manage ${targetUserRole} users`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a permission checker function
|
|
||||||
*/
|
|
||||||
export function createPermissionChecker(context: APIContext) {
|
|
||||||
return {
|
|
||||||
has: (permission: Permission) =>
|
|
||||||
hasPermission(context.user?.role || "", permission),
|
|
||||||
hasAny: (permissions: Permission[]) =>
|
|
||||||
hasAnyPermission(context.user?.role || "", permissions),
|
|
||||||
hasAll: (permissions: Permission[]) =>
|
|
||||||
hasAllPermissions(context.user?.role || "", permissions),
|
|
||||||
require: (permission: Permission) => requirePermission(permission)(context),
|
|
||||||
requireAny: (permissions: Permission[]) =>
|
|
||||||
requireAnyPermission(permissions)(context),
|
|
||||||
requireAll: (permissions: Permission[]) =>
|
|
||||||
requireAllPermissions(permissions)(context),
|
|
||||||
canAccessCompany: (companyId: string, resourceType?: ResourceType) =>
|
|
||||||
validateCompanyAccess(context, companyId, resourceType),
|
|
||||||
requireCompanyAccess: (companyId: string, resourceType?: ResourceType) =>
|
|
||||||
requireCompanyAccess(companyId, resourceType)(context),
|
|
||||||
canManageUser: (targetUserRole: string) =>
|
|
||||||
canManageUser(context.user?.role || "", targetUserRole),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware function to attach permission checker to context
|
|
||||||
*/
|
|
||||||
export function withPermissions<T extends APIContext>(
|
|
||||||
context: T
|
|
||||||
): T & { permissions: ReturnType<typeof createPermissionChecker> } {
|
|
||||||
return {
|
|
||||||
...context,
|
|
||||||
permissions: createPermissionChecker(context),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user