mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-17 03:32:08 +01:00
Compare commits
2 Commits
fix/pr-20-
...
71c8aff125
| Author | SHA1 | Date | |
|---|---|---|---|
|
71c8aff125
|
|||
|
0c18e8be57
|
10
.biomeignore
10
.biomeignore
@ -1,10 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
.next/
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
coverage/
|
|
||||||
.git/
|
|
||||||
*.min.js
|
|
||||||
public/
|
|
||||||
prisma/migrations/
|
|
||||||
.claude/
|
|
||||||
9
.env.development
Normal file
9
.env.development
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Development environment settings
|
||||||
|
# This file ensures NextAuth always has necessary environment variables in development
|
||||||
|
|
||||||
|
# NextAuth.js configuration
|
||||||
|
NEXTAUTH_URL=http://192.168.1.2:3000
|
||||||
|
NEXTAUTH_SECRET=this_is_a_fixed_secret_for_development_only
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Database connection - already configured in your prisma/schema.prisma
|
||||||
26
.env.example
26
.env.example
@ -1,26 +0,0 @@
|
|||||||
# Development environment settings
|
|
||||||
# This file ensures NextAuth always has necessary environment variables in development
|
|
||||||
|
|
||||||
# NextAuth.js configuration
|
|
||||||
NEXTAUTH_URL="http://localhost:3000"
|
|
||||||
NEXTAUTH_SECRET="this_is_a_fixed_secret_for_development_only"
|
|
||||||
NODE_ENV="development"
|
|
||||||
|
|
||||||
# OpenAI API key for session processing
|
|
||||||
# Add your API key here: OPENAI_API_KEY=sk-...
|
|
||||||
OPENAI_API_KEY="your_openai_api_key_here"
|
|
||||||
|
|
||||||
# Database connection - already configured in your prisma/schema.prisma
|
|
||||||
|
|
||||||
# Scheduler Configuration
|
|
||||||
SCHEDULER_ENABLED="false" # Enable/disable all schedulers (false for dev, true for production)
|
|
||||||
CSV_IMPORT_INTERVAL="*/15 * * * *" # Cron expression for CSV imports (every 15 minutes)
|
|
||||||
IMPORT_PROCESSING_INTERVAL="*/5 * * * *" # Cron expression for processing imports to sessions (every 5 minutes)
|
|
||||||
IMPORT_PROCESSING_BATCH_SIZE="50" # Number of imports to process at once
|
|
||||||
SESSION_PROCESSING_INTERVAL="0 * * * *" # Cron expression for AI session processing (every hour)
|
|
||||||
SESSION_PROCESSING_BATCH_SIZE="0" # 0 = unlimited sessions, >0 = specific limit
|
|
||||||
SESSION_PROCESSING_CONCURRENCY="5" # How many sessions to process in parallel
|
|
||||||
|
|
||||||
# Postgres Database Configuration
|
|
||||||
DATABASE_URL_TEST="postgresql://"
|
|
||||||
DATABASE_URL="postgresql://"
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
# Copy this file to .env.local and configure as needed
|
|
||||||
|
|
||||||
# NextAuth.js configuration
|
|
||||||
NEXTAUTH_URL="http://localhost:3000"
|
|
||||||
NEXTAUTH_SECRET="your_secret_key_here"
|
|
||||||
NODE_ENV="development"
|
|
||||||
|
|
||||||
# OpenAI API key for session processing
|
|
||||||
OPENAI_API_KEY="your_openai_api_key_here"
|
|
||||||
|
|
||||||
# Scheduler Configuration
|
|
||||||
SCHEDULER_ENABLED="true" # Set to false to disable all schedulers during development
|
|
||||||
CSV_IMPORT_INTERVAL="*/15 * * * *" # Every 15 minutes (cron format)
|
|
||||||
IMPORT_PROCESSING_INTERVAL="*/5 * * * *" # Every 5 minutes (cron format) - converts imports to sessions
|
|
||||||
IMPORT_PROCESSING_BATCH_SIZE="50" # Number of imports to process at once
|
|
||||||
SESSION_PROCESSING_INTERVAL="0 * * * *" # Every hour (cron format) - AI processing
|
|
||||||
SESSION_PROCESSING_BATCH_SIZE="0" # 0 = process all sessions, >0 = limit number
|
|
||||||
SESSION_PROCESSING_CONCURRENCY="5" # Number of sessions to process in parallel
|
|
||||||
|
|
||||||
# Postgres Database Configuration
|
|
||||||
DATABASE_URL_TEST="postgresql://"
|
|
||||||
DATABASE_URL="postgresql://"
|
|
||||||
|
|
||||||
# Example configurations:
|
|
||||||
# - For development (no schedulers): SCHEDULER_ENABLED=false
|
|
||||||
# - For testing (every 5 minutes): CSV_IMPORT_INTERVAL=*/5 * * * *
|
|
||||||
# - For faster import processing: IMPORT_PROCESSING_INTERVAL=*/2 * * * *
|
|
||||||
# - For limited processing: SESSION_PROCESSING_BATCH_SIZE=10
|
|
||||||
# - For high concurrency: SESSION_PROCESSING_CONCURRENCY=10
|
|
||||||
8
.github/workflows/playwright.yml
vendored
8
.github/workflows/playwright.yml
vendored
@ -14,11 +14,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: lts/*
|
node-version: lts/*
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install -g pnpm && pnpm install
|
run: npm ci
|
||||||
|
- name: Build dashboard
|
||||||
|
run: npm run build
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: pnpm exec playwright install --with-deps
|
run: npx playwright install --with-deps
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: pnpm exec playwright test
|
run: npx playwright test
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
with:
|
with:
|
||||||
|
|||||||
191
.gitignore
vendored
191
.gitignore
vendored
@ -1,6 +1,3 @@
|
|||||||
*-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
|
||||||
|
|
||||||
@ -264,7 +261,189 @@ Thumbs.db
|
|||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
|
# Created by https://www.toptal.com/developers/gitignore/api/node,macos
|
||||||
|
# Edit at https://www.toptal.com/developers/gitignore?templates=node,macos
|
||||||
|
|
||||||
# OpenAI API request samples
|
### macOS ###
|
||||||
sample-openai-request.json
|
# General
|
||||||
admin-user.txt
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# Icon must end with two \r
|
||||||
|
Icon
|
||||||
|
|
||||||
|
# Thumbnails
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Files that might appear in the root of a volume
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
|
||||||
|
# Directories potentially created on remote AFP share
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
### Node ###
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Moved from ./templates for ignoring all locks in templates
|
||||||
|
templates/**/*-lock.*
|
||||||
|
templates/**/*.lock
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
### Node Patch ###
|
||||||
|
# Serverless Webpack directories
|
||||||
|
.webpack/
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
|
||||||
|
# SvelteKit build / generate output
|
||||||
|
.svelte-kit
|
||||||
|
|
||||||
|
# End of https://www.toptal.com/developers/gitignore/api/node,macos
|
||||||
|
|
||||||
|
# Wrangler output
|
||||||
|
.wrangler/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Turbo output
|
||||||
|
.turbo/
|
||||||
|
|
||||||
|
.dev.vars*
|
||||||
|
test-transcript-format.js
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -1 +0,0 @@
|
|||||||
lint-staged
|
|
||||||
@ -1,28 +1,54 @@
|
|||||||
# Don't ignore doc files - we'll use prettier-ignore comments instead
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
prisma/migrations/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
|
||||||
|
# Coverage reports
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
playwright/.cache/
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
*.generated.*
|
||||||
|
|
||||||
## Ignore lockfile
|
|
||||||
pnpm-lock.yaml
|
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
|
|
||||||
|
|||||||
218
CLAUDE.md
218
CLAUDE.md
@ -1,218 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Development Commands
|
|
||||||
|
|
||||||
**Core Development:**
|
|
||||||
|
|
||||||
- `pnpm dev` - Start development server (runs custom server.ts with schedulers)
|
|
||||||
- `pnpm dev:next-only` - Start Next.js only with Turbopack (no schedulers)
|
|
||||||
- `pnpm build` - Build production application
|
|
||||||
- `pnpm start` - Run production server
|
|
||||||
|
|
||||||
**Code Quality:**
|
|
||||||
|
|
||||||
- `pnpm lint` - Run ESLint
|
|
||||||
- `pnpm lint:fix` - Fix ESLint issues automatically
|
|
||||||
- `pnpm format` - Format code with Prettier
|
|
||||||
- `pnpm format:check` - Check formatting without fixing
|
|
||||||
|
|
||||||
**Database:**
|
|
||||||
|
|
||||||
- `pnpm prisma:generate` - Generate Prisma client
|
|
||||||
- `pnpm prisma:migrate` - Run database migrations
|
|
||||||
- `pnpm prisma:push` - Push schema changes to database
|
|
||||||
- `pnpm prisma:push:force` - Force reset database and push schema
|
|
||||||
- `pnpm prisma:seed` - Seed database with initial data
|
|
||||||
- `pnpm prisma:studio` - Open Prisma Studio database viewer
|
|
||||||
|
|
||||||
**Testing:**
|
|
||||||
|
|
||||||
- `pnpm test` - Run both Vitest and Playwright tests concurrently
|
|
||||||
- `pnpm test:vitest` - Run Vitest tests only
|
|
||||||
- `pnpm test:vitest:watch` - Run Vitest in watch mode
|
|
||||||
- `pnpm test:vitest:coverage` - Run Vitest with coverage report
|
|
||||||
- `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:**
|
|
||||||
|
|
||||||
- `pnpm lint:md` - Lint Markdown files
|
|
||||||
- `pnpm lint:md:fix` - Fix Markdown linting issues
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
**LiveDash-Node** is a real-time analytics dashboard for monitoring user sessions with AI-powered analysis and processing pipeline.
|
|
||||||
|
|
||||||
### Tech Stack
|
|
||||||
|
|
||||||
- **Frontend:** Next.js 15 + React 19 + TailwindCSS 4
|
|
||||||
- **Backend:** Next.js API Routes + Custom Node.js server
|
|
||||||
- **Database:** PostgreSQL with Prisma ORM
|
|
||||||
- **Authentication:** NextAuth.js
|
|
||||||
- **AI Processing:** OpenAI API integration
|
|
||||||
- **Visualization:** D3.js, React Leaflet, Recharts
|
|
||||||
- **Scheduling:** Node-cron for background processing
|
|
||||||
|
|
||||||
### Key Architecture Components
|
|
||||||
|
|
||||||
**1. Multi-Stage Processing Pipeline**
|
|
||||||
The system processes user sessions through distinct stages tracked in `SessionProcessingStatus`:
|
|
||||||
|
|
||||||
- `CSV_IMPORT` - Import raw CSV data into `SessionImport`
|
|
||||||
- `TRANSCRIPT_FETCH` - Fetch transcript content from URLs
|
|
||||||
- `SESSION_CREATION` - Create normalized `Session` and `Message` records
|
|
||||||
- `AI_ANALYSIS` - AI processing for sentiment, categorization, summaries
|
|
||||||
- `QUESTION_EXTRACTION` - Extract questions from conversations
|
|
||||||
|
|
||||||
**2. Database Architecture**
|
|
||||||
|
|
||||||
- **Multi-tenant design** with `Company` as root entity
|
|
||||||
- **Dual storage pattern**: Raw CSV data in `SessionImport`, processed data in `Session`
|
|
||||||
- **1-to-1 relationship** between `SessionImport` and `Session` via `importId`
|
|
||||||
- **Message parsing** into individual `Message` records with order tracking
|
|
||||||
- **AI cost tracking** via `AIProcessingRequest` with detailed token usage
|
|
||||||
- **Flexible AI model management** through `AIModel`, `AIModelPricing`, and `CompanyAIModel`
|
|
||||||
|
|
||||||
**3. Custom Server Architecture**
|
|
||||||
|
|
||||||
- `server.ts` - Custom Next.js server with configurable scheduler initialization
|
|
||||||
- Three main schedulers: CSV import, import processing, and session processing
|
|
||||||
- Environment-based configuration via `lib/env.ts`
|
|
||||||
|
|
||||||
**4. Key Processing Libraries**
|
|
||||||
|
|
||||||
- `lib/scheduler.ts` - CSV import scheduling
|
|
||||||
- `lib/importProcessor.ts` - Raw data to Session conversion
|
|
||||||
- `lib/processingScheduler.ts` - AI analysis pipeline
|
|
||||||
- `lib/transcriptFetcher.ts` - External transcript fetching
|
|
||||||
- `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
|
|
||||||
|
|
||||||
**Environment Configuration:**
|
|
||||||
Environment variables are managed through `lib/env.ts` with .env.local file support:
|
|
||||||
|
|
||||||
- Database: PostgreSQL via `DATABASE_URL` and `DATABASE_URL_DIRECT`
|
|
||||||
- Authentication: `NEXTAUTH_SECRET`, `NEXTAUTH_URL`
|
|
||||||
- AI Processing: `OPENAI_API_KEY`
|
|
||||||
- Schedulers: `SCHEDULER_ENABLED`, various interval configurations
|
|
||||||
|
|
||||||
**Key Files to Understand:**
|
|
||||||
|
|
||||||
- `prisma/schema.prisma` - Complete database schema with enums and relationships
|
|
||||||
- `server.ts` - Custom server entry point
|
|
||||||
- `lib/env.ts` - Environment variable management and validation
|
|
||||||
- `app/` - Next.js App Router structure
|
|
||||||
|
|
||||||
**Testing:**
|
|
||||||
|
|
||||||
- Uses Vitest for unit testing
|
|
||||||
- Playwright for E2E testing
|
|
||||||
- Test files in `tests/` directory
|
|
||||||
|
|
||||||
### Important Notes
|
|
||||||
|
|
||||||
**Scheduler System:**
|
|
||||||
|
|
||||||
- Schedulers are optional and controlled by `SCHEDULER_ENABLED` environment variable
|
|
||||||
- Use `pnpm dev:next-only` to run without schedulers for pure frontend development
|
|
||||||
- Four separate schedulers handle different pipeline stages:
|
|
||||||
- CSV Import Scheduler (`lib/scheduler.ts`)
|
|
||||||
- Import Processing Scheduler (`lib/importProcessor.ts`)
|
|
||||||
- Session Processing Scheduler (`lib/processingScheduler.ts`)
|
|
||||||
- Batch Processing Scheduler (`lib/batchScheduler.ts`) - Manages OpenAI Batch API lifecycle
|
|
||||||
|
|
||||||
**Database Migrations:**
|
|
||||||
|
|
||||||
- Always run `pnpm prisma:generate` after schema changes
|
|
||||||
- Use `pnpm prisma:migrate` for production-ready migrations
|
|
||||||
- Use `pnpm prisma:push` for development schema changes
|
|
||||||
- Database uses PostgreSQL with Prisma's driver adapter for connection pooling
|
|
||||||
|
|
||||||
**AI Processing:**
|
|
||||||
|
|
||||||
- All AI requests are tracked for cost analysis
|
|
||||||
- Support for multiple AI models per company
|
|
||||||
- Time-based pricing management for accurate cost calculation
|
|
||||||
- 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:**
|
|
||||||
|
|
||||||
- Run `pnpm lint` and `pnpm format:check` before committing
|
|
||||||
- TypeScript with ES modules (type: "module" in package.json)
|
|
||||||
- React 19 with Next.js 15 App Router
|
|
||||||
- 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.
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
# 🚨 Database Connection Issues - Fixes Applied
|
|
||||||
|
|
||||||
## Issues Identified
|
|
||||||
|
|
||||||
From your logs:
|
|
||||||
|
|
||||||
```
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Root Causes
|
|
||||||
|
|
||||||
1. **Multiple PrismaClient instances** across schedulers
|
|
||||||
2. **No connection retry logic** for temporary failures
|
|
||||||
3. **No connection pooling optimization** for Neon
|
|
||||||
4. **Aggressive scheduler intervals** overwhelming database
|
|
||||||
|
|
||||||
## Fixes Applied ✅
|
|
||||||
|
|
||||||
### 1. Connection Retry Logic (`lib/database-retry.ts`)
|
|
||||||
|
|
||||||
- **Automatic retry** for connection errors
|
|
||||||
- **Exponential backoff** (1s → 2s → 4s → 10s max)
|
|
||||||
- **Smart error detection** (only retry connection issues)
|
|
||||||
- **Configurable retry attempts** (default: 3 retries)
|
|
||||||
|
|
||||||
### 2. Enhanced Schedulers
|
|
||||||
|
|
||||||
- **Import Processor**: Added retry wrapper around main processing
|
|
||||||
- **Session Processor**: Added retry wrapper around AI processing
|
|
||||||
- **Graceful degradation** when database is temporarily unavailable
|
|
||||||
|
|
||||||
### 3. Singleton Pattern Enforced
|
|
||||||
|
|
||||||
- **All schedulers now use** `import { prisma } from "./prisma.js"`
|
|
||||||
- **No more separate** `new PrismaClient()` instances
|
|
||||||
- **Shared connection pool** across all operations
|
|
||||||
|
|
||||||
### 4. Neon-Specific Optimizations
|
|
||||||
|
|
||||||
- **Connection limit guidance**: 15 connections (below Neon's 20 limit)
|
|
||||||
- **Extended timeouts**: 30s for cold start handling
|
|
||||||
- **SSL mode requirements**: `sslmode=require` for Neon
|
|
||||||
- **Application naming**: For better monitoring
|
|
||||||
|
|
||||||
## Immediate Actions Needed
|
|
||||||
|
|
||||||
### 1. Update Environment Variables
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Add to .env.local
|
|
||||||
USE_ENHANCED_POOLING=true
|
|
||||||
DATABASE_CONNECTION_LIMIT=15
|
|
||||||
DATABASE_POOL_TIMEOUT=30
|
|
||||||
|
|
||||||
# Update your DATABASE_URL to include:
|
|
||||||
DATABASE_URL="postgresql://user:pass@ep-tiny-math-a2zsshve-pooler.eu-central-1.aws.neon.tech:5432/db?sslmode=require&connection_limit=15&pool_timeout=30"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Reduce Scheduler Frequency (Optional)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Less aggressive intervals
|
|
||||||
CSV_IMPORT_INTERVAL="*/30 * * * *" # Every 30 min (was 15)
|
|
||||||
IMPORT_PROCESSING_INTERVAL="*/10 * * * *" # Every 10 min (was 5)
|
|
||||||
SESSION_PROCESSING_INTERVAL="0 */2 * * *" # Every 2 hours (was 1)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Run Configuration Check
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm db:check
|
|
||||||
```
|
|
||||||
|
|
||||||
## Expected Results
|
|
||||||
|
|
||||||
✅ **Connection Stability**: Automatic retry on temporary failures
|
|
||||||
✅ **Resource Efficiency**: Single shared connection pool
|
|
||||||
✅ **Neon Optimization**: Proper connection limits and timeouts
|
|
||||||
✅ **Monitoring**: Health check endpoint for visibility
|
|
||||||
✅ **Graceful Degradation**: Schedulers won't crash on DB issues
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
|
|
||||||
- **Health Endpoint**: `/api/admin/database-health`
|
|
||||||
- **Connection Logs**: Enhanced logging for pool events
|
|
||||||
- **Retry Logs**: Detailed retry attempt logging
|
|
||||||
- **Error Classification**: Retryable vs non-retryable errors
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
- `lib/database-retry.ts` - New retry utilities
|
|
||||||
- `lib/importProcessor.ts` - Added retry wrapper
|
|
||||||
- `lib/processingScheduler.ts` - Added retry wrapper
|
|
||||||
- `docs/neon-database-optimization.md` - Neon-specific guide
|
|
||||||
- `scripts/check-database-config.ts` - Configuration checker
|
|
||||||
|
|
||||||
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_
|
|
||||||
163
README.md
163
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)
|
- npm or yarn
|
||||||
- PostgreSQL 13+ database
|
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
@ -65,122 +45,53 @@ cd livedash-node
|
|||||||
2. Install dependencies:
|
2. Install dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Set up environment variables:
|
3. Set up the database:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env.local
|
npm run prisma:generate
|
||||||
# Edit .env.local with your configuration
|
npm run prisma:migrate
|
||||||
|
npm run prisma:seed
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Set up the database:
|
4. Start the development server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm prisma:generate
|
npm 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
|
- `npm run dev`: Start the development server
|
||||||
|
- `npm run build`: Build the application for production
|
||||||
- `pnpm dev`: Start development server with all features
|
- `npm run start`: Run the production build
|
||||||
- `pnpm dev:next-only`: Start Next.js only (no background schedulers)
|
- `npm run lint`: Run ESLint
|
||||||
- `pnpm build`: Build the application for production
|
- `npm run format`: Format code with Prettier
|
||||||
- `pnpm start`: Run the production build
|
- `npm 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
|
||||||
|
|
||||||
|
|||||||
270
TODO
270
TODO
@ -1,270 +0,0 @@
|
|||||||
# TODO - LiveDash Architecture Evolution & Improvements
|
|
||||||
|
|
||||||
## 🚀 CRITICAL PRIORITY - Architectural Refactoring
|
|
||||||
|
|
||||||
### Phase 1: Service Decomposition & Platform Management (Weeks 1-4)
|
|
||||||
|
|
||||||
- [x] **Create Platform Management Layer** (80% Complete)
|
|
||||||
- [x] Add Organization/PlatformUser models to Prisma schema
|
|
||||||
- [x] Implement super-admin authentication system (/platform/login)
|
|
||||||
- [x] Build platform dashboard for Notso AI team (/platform/dashboard)
|
|
||||||
- [x] Add company creation workflows
|
|
||||||
- [x] Add basic platform API endpoints with tests
|
|
||||||
- [x] Create stunning SaaS landing page with modern design
|
|
||||||
- [x] Add company editing/management workflows
|
|
||||||
- [x] Create company suspension/activation UI features
|
|
||||||
- [x] Add proper SEO metadata and OpenGraph tags
|
|
||||||
- [x] Add user management within companies from platform
|
|
||||||
- [ ] Add AI model management UI
|
|
||||||
- [ ] Add cost tracking/quotas UI
|
|
||||||
|
|
||||||
- [ ] **Extract Data Ingestion Service (Golang)**
|
|
||||||
- [ ] Create new Golang service for CSV processing
|
|
||||||
- [ ] Implement concurrent CSV downloading & parsing
|
|
||||||
- [ ] Add transcript fetching with rate limiting
|
|
||||||
- [ ] Set up Redis message queues (BullMQ/RabbitMQ)
|
|
||||||
- [ ] Migrate lib/scheduler.ts and lib/csvFetcher.ts logic
|
|
||||||
|
|
||||||
- [ ] **Implement tRPC Infrastructure**
|
|
||||||
- [ ] Add tRPC to existing Next.js app
|
|
||||||
- [ ] Create type-safe API procedures for frontend
|
|
||||||
- [ ] Implement inter-service communication protocols
|
|
||||||
- [ ] Add proper error handling and validation
|
|
||||||
|
|
||||||
### Phase 2: AI Service Separation & Compliance (Weeks 5-8)
|
|
||||||
|
|
||||||
- [ ] **Extract AI Processing Service**
|
|
||||||
- [ ] Separate lib/processingScheduler.ts into standalone service
|
|
||||||
- [ ] Implement async AI processing with queues
|
|
||||||
- [ ] Add per-company AI cost tracking and quotas
|
|
||||||
- [ ] Create AI model management per company
|
|
||||||
- [ ] Add retry logic and failure handling
|
|
||||||
|
|
||||||
- [ ] **GDPR & ISO 27001 Compliance Foundation**
|
|
||||||
- [ ] 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)
|
|
||||||
|
|
||||||
- [ ] **Monitoring & Observability**
|
|
||||||
- [ ] Add distributed tracing across services (Jaeger/Zipkin)
|
|
||||||
- [ ] Implement health checks for all services
|
|
||||||
- [ ] Create cross-service metrics dashboard
|
|
||||||
- [ ] Add alerting for service failures and SLA breaches
|
|
||||||
- [ ] Monitor AI processing costs and quotas
|
|
||||||
|
|
||||||
- [ ] **Database Optimization**
|
|
||||||
- [ ] 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
|
|
||||||
|
|
||||||
### PR #20 Feedback Actions (Code Review)
|
|
||||||
|
|
||||||
- [ ] **Fix Environment Variable Testing**
|
|
||||||
- [ ] Replace process.env access with proper environment mocking in tests
|
|
||||||
- [ ] Update existing tests to avoid direct environment variable dependencies
|
|
||||||
- [ ] Add environment validation tests for critical config values
|
|
||||||
|
|
||||||
- [ ] **Enforce Zero Accessibility Violations**
|
|
||||||
- [ ] Set Playwright accessibility tests to fail on any violations (not just warn)
|
|
||||||
- [ ] Add accessibility regression tests for all major components
|
|
||||||
- [ ] Implement accessibility checklist for new components
|
|
||||||
|
|
||||||
- [ ] **Improve Error Handling with Custom Error Classes**
|
|
||||||
- [ ] Create custom error classes for different error types (ValidationError, AuthError, etc.)
|
|
||||||
- [ ] Replace generic Error throws with specific error classes
|
|
||||||
- [ ] Add proper error logging and monitoring integration
|
|
||||||
|
|
||||||
- [ ] **Refactor Long className Strings**
|
|
||||||
- [ ] Extract complex className combinations into utility functions
|
|
||||||
- [ ] Consider using cn() utility from utils for cleaner class composition
|
|
||||||
- [ ] Break down overly complex className props into semantic components
|
|
||||||
|
|
||||||
- [ ] **Add Dark Mode Accessibility Tests**
|
|
||||||
- [ ] Create comprehensive test suite for dark mode color contrast
|
|
||||||
- [ ] Verify focus indicators work properly in both light and dark modes
|
|
||||||
- [ ] Test screen reader compatibility with theme switching
|
|
||||||
|
|
||||||
- [ ] **Fix Platform Login Authentication Issue**
|
|
||||||
- [ ] NEXTAUTH_SECRET was using placeholder value (FIXED)
|
|
||||||
- [ ] Investigate platform cookie path restrictions in /platform auth
|
|
||||||
- [ ] Test platform login flow end-to-end after fixes
|
|
||||||
|
|
||||||
### Testing & Quality Assurance
|
|
||||||
|
|
||||||
- [ ] Add comprehensive test coverage for API endpoints (currently minimal)
|
|
||||||
- [ ] Implement integration tests for the data processing pipeline
|
|
||||||
- [ ] Add unit tests for validation schemas and authentication logic
|
|
||||||
- [ ] Create E2E tests for critical user flows (registration, login, dashboard)
|
|
||||||
|
|
||||||
### Error Handling & Monitoring
|
|
||||||
|
|
||||||
- [ ] Implement global error boundaries for React components
|
|
||||||
- [ ] Add structured logging with correlation IDs for request tracing
|
|
||||||
- [ ] Set up error monitoring and alerting (e.g., Sentry integration)
|
|
||||||
- [ ] Add proper error pages for 404, 500, and other HTTP status codes
|
|
||||||
|
|
||||||
### Performance Optimization
|
|
||||||
|
|
||||||
- [ ] Implement database query optimization and indexing strategy
|
|
||||||
- [ ] Add caching layer for frequently accessed data (Redis/in-memory)
|
|
||||||
- [ ] Optimize React components with proper memoization
|
|
||||||
- [ ] Implement lazy loading for dashboard components and charts
|
|
||||||
|
|
||||||
## Medium Priority
|
|
||||||
|
|
||||||
### Security Enhancements
|
|
||||||
|
|
||||||
- [ ] Add CSRF protection for state-changing operations
|
|
||||||
- [ ] Implement session timeout and refresh token mechanism
|
|
||||||
- [ ] Add API rate limiting with Redis-backed storage (replace in-memory)
|
|
||||||
- [ ] Implement role-based access control (RBAC) for different user types
|
|
||||||
- [ ] Add audit logging for sensitive operations
|
|
||||||
|
|
||||||
### Code Quality & Maintenance
|
|
||||||
|
|
||||||
- [ ] Resolve remaining ESLint warnings and type issues
|
|
||||||
- [ ] Standardize chart library usage (currently mixing Chart.js and other libraries)
|
|
||||||
- [ ] Add proper TypeScript strict mode configuration
|
|
||||||
- [ ] Implement consistent API response formats across all endpoints
|
|
||||||
|
|
||||||
### Database & Schema
|
|
||||||
|
|
||||||
- [ ] Add database connection pooling configuration
|
|
||||||
- [ ] Implement proper database migrations for production deployment
|
|
||||||
- [ ] Add data retention policies for session data
|
|
||||||
- [ ] Consider database partitioning for large-scale data
|
|
||||||
|
|
||||||
### User Experience
|
|
||||||
|
|
||||||
- [ ] Add loading states and skeleton components throughout the application
|
|
||||||
- [ ] Implement proper form validation feedback and error messages
|
|
||||||
- [ ] Add pagination for large data sets in dashboard tables
|
|
||||||
- [ ] Implement real-time notifications for processing status updates
|
|
||||||
|
|
||||||
## Low Priority
|
|
||||||
|
|
||||||
### Documentation & Development
|
|
||||||
|
|
||||||
- [ ] Add API documentation (OpenAPI/Swagger)
|
|
||||||
- [ ] Create deployment guides for different environments
|
|
||||||
- [ ] Add contributing guidelines and code review checklist
|
|
||||||
- [ ] Implement development environment setup automation
|
|
||||||
|
|
||||||
### Feature Enhancements
|
|
||||||
|
|
||||||
- [ ] Add data export functionality (CSV, PDF reports)
|
|
||||||
- [ ] Implement dashboard customization and user preferences
|
|
||||||
- [ ] Add multi-language support (i18n)
|
|
||||||
- [ ] Create admin panel for system configuration
|
|
||||||
|
|
||||||
### Infrastructure & DevOps
|
|
||||||
|
|
||||||
- [ ] Add Docker configuration for containerized deployment
|
|
||||||
- [ ] Implement CI/CD pipeline with automated testing
|
|
||||||
- [ ] Add environment-specific configuration management
|
|
||||||
- [ ] Set up monitoring and health check endpoints
|
|
||||||
|
|
||||||
### Analytics & Insights
|
|
||||||
|
|
||||||
- [ ] Add more detailed analytics and reporting features
|
|
||||||
- [ ] Implement A/B testing framework for UI improvements
|
|
||||||
- [ ] Add user behavior tracking and analytics
|
|
||||||
- [ ] Create automated report generation and scheduling
|
|
||||||
|
|
||||||
## Completed ✅
|
|
||||||
|
|
||||||
- [x] Fix duplicate MetricCard components
|
|
||||||
- [x] Add input validation schema with Zod
|
|
||||||
- [x] Strengthen password requirements (12+ chars, complexity)
|
|
||||||
- [x] Fix schema drift - create missing migrations
|
|
||||||
- [x] Add rate limiting to authentication endpoints
|
|
||||||
- [x] Update README.md to use pnpm instead of npm
|
|
||||||
- [x] Implement platform authentication and basic dashboard
|
|
||||||
- [x] Add platform API endpoints for company management
|
|
||||||
- [x] Write tests for platform features (auth, dashboard, API)
|
|
||||||
|
|
||||||
## 📊 Test Coverage Status (< 30% Overall)
|
|
||||||
|
|
||||||
### ✅ Features WITH Tests
|
|
||||||
|
|
||||||
- User Authentication (regular users)
|
|
||||||
- User Management UI & API
|
|
||||||
- Basic database connectivity
|
|
||||||
- Transcript Fetcher
|
|
||||||
- Input validation
|
|
||||||
- Environment configuration
|
|
||||||
- Format enums
|
|
||||||
- Accessibility features
|
|
||||||
- Keyboard navigation
|
|
||||||
- Platform authentication (NEW)
|
|
||||||
- Platform dashboard (NEW)
|
|
||||||
- Platform API endpoints (NEW)
|
|
||||||
|
|
||||||
### ❌ Features WITHOUT Tests (Critical Gaps)
|
|
||||||
|
|
||||||
- **Data Processing Pipeline** (0 tests)
|
|
||||||
- CSV import scheduler
|
|
||||||
- Import processor
|
|
||||||
- Processing scheduler
|
|
||||||
- AI processing functionality
|
|
||||||
- Transcript parser
|
|
||||||
- **Most API Endpoints** (0 tests)
|
|
||||||
- Dashboard endpoints
|
|
||||||
- Session management
|
|
||||||
- Admin endpoints
|
|
||||||
- Password reset flow
|
|
||||||
- **Custom Server** (0 tests)
|
|
||||||
- **Dashboard Features** (0 tests)
|
|
||||||
- Charts and visualizations
|
|
||||||
- Session details
|
|
||||||
- Company settings
|
|
||||||
- **AI Integration** (0 tests)
|
|
||||||
- **Real-time Features** (0 tests)
|
|
||||||
- **E2E Tests** (only examples exist)
|
|
||||||
|
|
||||||
## 🏛️ Architectural Decisions & Rationale
|
|
||||||
|
|
||||||
### Service Technology Choices
|
|
||||||
|
|
||||||
- **Dashboard Service**: Next.js + tRPC (existing, proven stack)
|
|
||||||
- **Data Ingestion Service**: Golang (high-performance CSV processing, concurrency)
|
|
||||||
- **AI Processing Service**: Node.js/Python (existing AI integrations, async processing)
|
|
||||||
- **Message Queue**: Redis + BullMQ (Node.js ecosystem compatibility)
|
|
||||||
- **Database**: PostgreSQL (existing, excellent for multi-tenancy)
|
|
||||||
|
|
||||||
### Why Golang for Data Ingestion?
|
|
||||||
|
|
||||||
- **Performance**: 10-100x faster CSV processing than Node.js
|
|
||||||
- **Concurrency**: Native goroutines for parallel transcript fetching
|
|
||||||
- **Memory Efficiency**: Lower memory footprint for large CSV files
|
|
||||||
- **Deployment**: Single binary deployment, excellent for containers
|
|
||||||
- **Team Growth**: Easy to hire Golang developers for data processing
|
|
||||||
|
|
||||||
### Migration Strategy
|
|
||||||
|
|
||||||
1. **Keep existing working system** while building new services
|
|
||||||
2. **Feature flagging** to gradually migrate companies to new processing
|
|
||||||
3. **Dual-write approach** during transition period
|
|
||||||
4. **Zero-downtime migration** with careful rollback plans
|
|
||||||
|
|
||||||
### Compliance Benefits
|
|
||||||
|
|
||||||
- **Data Isolation**: Each service has limited database access
|
|
||||||
- **Audit Trail**: All inter-service communication logged
|
|
||||||
- **Data Retention**: Automated per-company data lifecycle
|
|
||||||
- **Security Boundaries**: DMZ for ingestion, private network for processing
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- **CRITICAL**: Architectural refactoring must be priority #1 for scalability
|
|
||||||
- **Platform Management**: Notso AI needs self-service customer onboarding
|
|
||||||
- **Compliance First**: GDPR/ISO 27001 requirements drive service boundaries
|
|
||||||
- **Performance**: Current monolith blocks on CSV/AI processing
|
|
||||||
- **Technology Evolution**: Golang for data processing, tRPC for type safety
|
|
||||||
108
TODO.md
Normal file
108
TODO.md
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# TODO.md
|
||||||
|
|
||||||
|
## Dashboard Integration
|
||||||
|
|
||||||
|
- [ ] **Resolve `GeographicMap.tsx` and `ResponseTimeDistribution.tsx` data simulation**
|
||||||
|
- Investigate integrating real data sources with server-side analytics
|
||||||
|
- Replace simulated data mentioned in `docs/dashboard-components.md`
|
||||||
|
|
||||||
|
## Component Specific
|
||||||
|
|
||||||
|
- [ ] **Implement robust emailing of temporary passwords**
|
||||||
|
|
||||||
|
- File: `pages/api/dashboard/users.ts`
|
||||||
|
- Set up proper email service integration
|
||||||
|
|
||||||
|
- [x] **Session page improvements** ✅
|
||||||
|
- File: `app/dashboard/sessions/page.tsx`
|
||||||
|
- Implemented pagination, advanced filtering, and sorting
|
||||||
|
|
||||||
|
## File Cleanup
|
||||||
|
|
||||||
|
- [x] **Remove backup files** ✅
|
||||||
|
- Reviewed and removed `.bak` and `.new` files after integration
|
||||||
|
- Cleaned up `GeographicMap.tsx.bak`, `SessionDetails.tsx.bak`, `SessionDetails.tsx.new`
|
||||||
|
|
||||||
|
## Database Schema Improvements
|
||||||
|
|
||||||
|
- [ ] **Update EndTime field**
|
||||||
|
|
||||||
|
- Make `endTime` field nullable in Prisma schema to match TypeScript interfaces
|
||||||
|
|
||||||
|
- [ ] **Add database indices**
|
||||||
|
|
||||||
|
- Add appropriate indices to improve query performance
|
||||||
|
- Focus on dashboard metrics and session listing queries
|
||||||
|
|
||||||
|
- [ ] **Implement production email service**
|
||||||
|
- Replace console logging in `lib/sendEmail.ts`
|
||||||
|
- Consider providers: Nodemailer, SendGrid, AWS SES
|
||||||
|
|
||||||
|
## General Enhancements & Features
|
||||||
|
|
||||||
|
- [ ] **Real-time updates**
|
||||||
|
|
||||||
|
- Implement for dashboard and session list
|
||||||
|
- Consider WebSockets or Server-Sent Events
|
||||||
|
|
||||||
|
- [ ] **Data export functionality**
|
||||||
|
|
||||||
|
- Allow users (especially admins) to export session data
|
||||||
|
- Support CSV format initially
|
||||||
|
|
||||||
|
- [ ] **Customizable dashboard**
|
||||||
|
- Allow users to customize dashboard view
|
||||||
|
- Let users choose which metrics/charts are most important
|
||||||
|
|
||||||
|
## Testing & Quality Assurance
|
||||||
|
|
||||||
|
- [ ] **Comprehensive testing suite**
|
||||||
|
|
||||||
|
- [ ] Unit tests for utility functions and API logic
|
||||||
|
- [ ] Integration tests for API endpoints with database
|
||||||
|
- [ ] End-to-end tests for user flows (Playwright or Cypress)
|
||||||
|
|
||||||
|
- [ ] **Error monitoring and logging**
|
||||||
|
|
||||||
|
- Integrate robust error monitoring service (Sentry)
|
||||||
|
- Enhance server-side logging
|
||||||
|
|
||||||
|
- [ ] **Accessibility improvements**
|
||||||
|
- Review application against WCAG guidelines
|
||||||
|
- Improve keyboard navigation and screen reader compatibility
|
||||||
|
- Check color contrast ratios
|
||||||
|
|
||||||
|
## Security Enhancements
|
||||||
|
|
||||||
|
- [x] **Password reset functionality** ✅
|
||||||
|
|
||||||
|
- Implemented secure password reset mechanism
|
||||||
|
- Files: `app/forgot-password/page.tsx`, `app/reset-password/page.tsx`, `pages/api/forgot-password.ts`, `pages/api/reset-password.ts`
|
||||||
|
|
||||||
|
- [ ] **Two-Factor Authentication (2FA)**
|
||||||
|
|
||||||
|
- Consider adding 2FA, especially for admin accounts
|
||||||
|
|
||||||
|
- [ ] **Input validation and sanitization**
|
||||||
|
- Review all user inputs (API request bodies, query parameters)
|
||||||
|
- Ensure proper validation and sanitization
|
||||||
|
|
||||||
|
## Code Quality & Development
|
||||||
|
|
||||||
|
- [ ] **Code review process**
|
||||||
|
|
||||||
|
- Enforce code reviews for all changes
|
||||||
|
|
||||||
|
- [ ] **Environment configuration**
|
||||||
|
|
||||||
|
- Ensure secure management of environment-specific configurations
|
||||||
|
|
||||||
|
- [ ] **Dependency management**
|
||||||
|
|
||||||
|
- Periodically review dependencies for vulnerabilities
|
||||||
|
- Keep dependencies updated
|
||||||
|
|
||||||
|
- [ ] **Documentation updates**
|
||||||
|
- [ ] Ensure `docs/dashboard-components.md` reflects actual implementations
|
||||||
|
- [ ] Verify "Dashboard Enhancements" are consistently applied
|
||||||
|
- [ ] Update documentation for improved layout and visual hierarchies
|
||||||
@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
// Database connection health monitoring endpoint
|
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { checkDatabaseConnection, prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Check if user has admin access (you may want to add proper auth here)
|
|
||||||
const authHeader = request.headers.get("authorization");
|
|
||||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic database connectivity check
|
|
||||||
const isConnected = await checkDatabaseConnection();
|
|
||||||
|
|
||||||
if (!isConnected) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
status: "unhealthy",
|
|
||||||
database: {
|
|
||||||
connected: false,
|
|
||||||
error: "Database connection failed",
|
|
||||||
},
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
{ status: 503 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get basic metrics
|
|
||||||
const metrics = await Promise.allSettled([
|
|
||||||
// Count total sessions
|
|
||||||
prisma.session.count(),
|
|
||||||
// Count processing status records
|
|
||||||
prisma.sessionProcessingStatus.count(),
|
|
||||||
// Count total AI requests
|
|
||||||
prisma.aIProcessingRequest.count(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [sessionsResult, statusResult, aiRequestsResult] = metrics;
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
status: "healthy",
|
|
||||||
database: {
|
|
||||||
connected: true,
|
|
||||||
connectionType:
|
|
||||||
process.env.USE_ENHANCED_POOLING === "true"
|
|
||||||
? "enhanced_pooling"
|
|
||||||
: "standard",
|
|
||||||
},
|
|
||||||
metrics: {
|
|
||||||
totalSessions:
|
|
||||||
sessionsResult.status === "fulfilled"
|
|
||||||
? sessionsResult.value
|
|
||||||
: "error",
|
|
||||||
processingRecords:
|
|
||||||
statusResult.status === "fulfilled" ? statusResult.value : "error",
|
|
||||||
recentAIRequests:
|
|
||||||
aiRequestsResult.status === "fulfilled"
|
|
||||||
? aiRequestsResult.value
|
|
||||||
: "error",
|
|
||||||
},
|
|
||||||
environment: {
|
|
||||||
nodeEnv: process.env.NODE_ENV,
|
|
||||||
enhancedPooling: process.env.USE_ENHANCED_POOLING === "true",
|
|
||||||
connectionLimit: process.env.DATABASE_CONNECTION_LIMIT || "default",
|
|
||||||
poolTimeout: process.env.DATABASE_POOL_TIMEOUT || "default",
|
|
||||||
},
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Database health check failed:", error);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
status: "error",
|
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@ -1,124 +0,0 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { fetchAndParseCsv } from "../../../../lib/csvFetcher";
|
|
||||||
import { processQueuedImports } from "../../../../lib/importProcessor";
|
|
||||||
import { prisma } from "../../../../lib/prisma";
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
const { companyId } = body;
|
|
||||||
|
|
||||||
if (!companyId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Company ID is required" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const company = await prisma.company.findUnique({
|
|
||||||
where: { id: companyId },
|
|
||||||
});
|
|
||||||
if (!company) {
|
|
||||||
return NextResponse.json({ error: "Company not found" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if company is active and can process data
|
|
||||||
if (company.status !== "ACTIVE") {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: `Data processing is disabled for ${company.status.toLowerCase()} companies`,
|
|
||||||
companyStatus: company.status,
|
|
||||||
},
|
|
||||||
{ status: 403 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawSessionData = await fetchAndParseCsv(
|
|
||||||
company.csvUrl,
|
|
||||||
company.csvUsername as string | undefined,
|
|
||||||
company.csvPassword as string | undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
let importedCount = 0;
|
|
||||||
|
|
||||||
// Create SessionImport records for new data
|
|
||||||
for (const rawSession of rawSessionData) {
|
|
||||||
try {
|
|
||||||
// Use upsert to handle duplicates gracefully
|
|
||||||
await prisma.sessionImport.upsert({
|
|
||||||
where: {
|
|
||||||
companyId_externalSessionId: {
|
|
||||||
companyId: company.id,
|
|
||||||
externalSessionId: rawSession.externalSessionId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
// Update existing record with latest data
|
|
||||||
startTimeRaw: rawSession.startTimeRaw,
|
|
||||||
endTimeRaw: rawSession.endTimeRaw,
|
|
||||||
ipAddress: rawSession.ipAddress,
|
|
||||||
countryCode: rawSession.countryCode,
|
|
||||||
language: rawSession.language,
|
|
||||||
messagesSent: rawSession.messagesSent,
|
|
||||||
sentimentRaw: rawSession.sentimentRaw,
|
|
||||||
escalatedRaw: rawSession.escalatedRaw,
|
|
||||||
forwardedHrRaw: rawSession.forwardedHrRaw,
|
|
||||||
fullTranscriptUrl: rawSession.fullTranscriptUrl,
|
|
||||||
avgResponseTimeSeconds: rawSession.avgResponseTimeSeconds,
|
|
||||||
tokens: rawSession.tokens,
|
|
||||||
tokensEur: rawSession.tokensEur,
|
|
||||||
category: rawSession.category,
|
|
||||||
initialMessage: rawSession.initialMessage,
|
|
||||||
// Status tracking now handled by ProcessingStatusManager
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
companyId: company.id,
|
|
||||||
externalSessionId: rawSession.externalSessionId,
|
|
||||||
startTimeRaw: rawSession.startTimeRaw,
|
|
||||||
endTimeRaw: rawSession.endTimeRaw,
|
|
||||||
ipAddress: rawSession.ipAddress,
|
|
||||||
countryCode: rawSession.countryCode,
|
|
||||||
language: rawSession.language,
|
|
||||||
messagesSent: rawSession.messagesSent,
|
|
||||||
sentimentRaw: rawSession.sentimentRaw,
|
|
||||||
escalatedRaw: rawSession.escalatedRaw,
|
|
||||||
forwardedHrRaw: rawSession.forwardedHrRaw,
|
|
||||||
fullTranscriptUrl: rawSession.fullTranscriptUrl,
|
|
||||||
avgResponseTimeSeconds: rawSession.avgResponseTimeSeconds,
|
|
||||||
tokens: rawSession.tokens,
|
|
||||||
tokensEur: rawSession.tokensEur,
|
|
||||||
category: rawSession.category,
|
|
||||||
initialMessage: rawSession.initialMessage,
|
|
||||||
// Status tracking now handled by ProcessingStatusManager
|
|
||||||
},
|
|
||||||
});
|
|
||||||
importedCount++;
|
|
||||||
} catch (error) {
|
|
||||||
// Log individual session import errors but continue processing
|
|
||||||
process.stderr.write(
|
|
||||||
`Failed to import session ${rawSession.externalSessionId}: ${error}\n`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Immediately process the queued imports to create Session records
|
|
||||||
console.log("[Refresh API] Processing queued imports...");
|
|
||||||
await processQueuedImports(100); // Process up to 100 imports immediately
|
|
||||||
|
|
||||||
// Count how many sessions were created
|
|
||||||
const sessionCount = await prisma.session.count({
|
|
||||||
where: { companyId: company.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
ok: true,
|
|
||||||
imported: importedCount,
|
|
||||||
total: rawSessionData.length,
|
|
||||||
sessions: sessionCount,
|
|
||||||
message: `Successfully imported ${importedCount} records and processed them into sessions. Total sessions: ${sessionCount}`,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
const error = e instanceof Error ? e.message : "An unknown error occurred";
|
|
||||||
return NextResponse.json({ error }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -1,124 +0,0 @@
|
|||||||
import { ProcessingStage } from "@prisma/client";
|
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "../../../../lib/auth";
|
|
||||||
import { prisma } from "../../../../lib/prisma";
|
|
||||||
import { processUnprocessedSessions } from "../../../../lib/processingScheduler";
|
|
||||||
import { getSessionsNeedingProcessing } from "../../../../lib/processingStatusManager";
|
|
||||||
|
|
||||||
interface SessionUser {
|
|
||||||
email: string;
|
|
||||||
name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionData {
|
|
||||||
user: SessionUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const session = (await getServerSession(authOptions)) as SessionData | null;
|
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
return NextResponse.json({ error: "Not logged in" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { email: session.user.email },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
role: true,
|
|
||||||
companyId: true,
|
|
||||||
company: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
status: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: "No user found" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user has ADMIN role
|
|
||||||
if (user.role !== "ADMIN") {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Admin access required" },
|
|
||||||
{ status: 403 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get optional parameters from request body
|
|
||||||
const body = await request.json();
|
|
||||||
const { batchSize, maxConcurrency } = body;
|
|
||||||
|
|
||||||
// Validate parameters
|
|
||||||
const validatedBatchSize =
|
|
||||||
batchSize && batchSize > 0 ? Number.parseInt(batchSize) : null;
|
|
||||||
const validatedMaxConcurrency =
|
|
||||||
maxConcurrency && maxConcurrency > 0
|
|
||||||
? Number.parseInt(maxConcurrency)
|
|
||||||
: 5;
|
|
||||||
|
|
||||||
// Check how many sessions need AI processing using the new status system
|
|
||||||
const sessionsNeedingAI = await getSessionsNeedingProcessing(
|
|
||||||
ProcessingStage.AI_ANALYSIS,
|
|
||||||
1000 // Get count only
|
|
||||||
);
|
|
||||||
|
|
||||||
// Filter to sessions for this company
|
|
||||||
const companySessionsNeedingAI = sessionsNeedingAI.filter(
|
|
||||||
(statusRecord) => statusRecord.session.companyId === user.companyId
|
|
||||||
);
|
|
||||||
|
|
||||||
const unprocessedCount = companySessionsNeedingAI.length;
|
|
||||||
|
|
||||||
if (unprocessedCount === 0) {
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: "No sessions requiring AI processing found",
|
|
||||||
unprocessedCount: 0,
|
|
||||||
processedCount: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start processing (this will run asynchronously)
|
|
||||||
|
|
||||||
// Note: We're calling the function but not awaiting it to avoid timeout
|
|
||||||
// The processing will continue in the background
|
|
||||||
processUnprocessedSessions(validatedBatchSize, validatedMaxConcurrency)
|
|
||||||
.then(() => {
|
|
||||||
console.log(
|
|
||||||
`[Manual Trigger] Processing completed for company ${user.companyId}`
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(
|
|
||||||
`[Manual Trigger] Processing failed for company ${user.companyId}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: `Started processing ${unprocessedCount} unprocessed sessions`,
|
|
||||||
unprocessedCount,
|
|
||||||
batchSize: validatedBatchSize || unprocessedCount,
|
|
||||||
maxConcurrency: validatedMaxConcurrency,
|
|
||||||
startedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Manual Trigger] Error:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: "Failed to trigger processing",
|
|
||||||
details: error instanceof Error ? error.message : String(error),
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
import NextAuth from "next-auth";
|
|
||||||
import { authOptions } from "../../../../lib/auth";
|
|
||||||
|
|
||||||
const handler = NextAuth(authOptions);
|
|
||||||
|
|
||||||
export { handler as GET, handler as POST };
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "../../../../lib/auth";
|
|
||||||
import { prisma } from "../../../../lib/prisma";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session?.user) {
|
|
||||||
return NextResponse.json({ error: "Not logged in" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { email: session.user.email as string },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: "No user" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get company data
|
|
||||||
const company = await prisma.company.findUnique({
|
|
||||||
where: { id: user.companyId },
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ company });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session?.user) {
|
|
||||||
return NextResponse.json({ error: "Not logged in" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { email: session.user.email as string },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: "No user" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { csvUrl } = body;
|
|
||||||
|
|
||||||
await prisma.company.update({
|
|
||||||
where: { id: user.companyId },
|
|
||||||
data: { csvUrl },
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
@ -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 };
|
|
||||||
@ -1,202 +0,0 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "../../../../lib/auth";
|
|
||||||
import { sessionMetrics } from "../../../../lib/metrics";
|
|
||||||
import { prisma } from "../../../../lib/prisma";
|
|
||||||
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 {
|
|
||||||
email: string;
|
|
||||||
name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionData {
|
|
||||||
user: SessionUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const session = (await getServerSession(authOptions)) as SessionData | null;
|
|
||||||
if (!session?.user) {
|
|
||||||
return NextResponse.json({ error: "Not logged in" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { email: session.user.email },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
companyId: true,
|
|
||||||
company: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
csvUrl: true,
|
|
||||||
status: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: "No user" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get date range from query parameters
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const startDate = searchParams.get("startDate");
|
|
||||||
const endDate = searchParams.get("endDate");
|
|
||||||
|
|
||||||
// Build where clause with optional date filtering
|
|
||||||
const whereClause: {
|
|
||||||
companyId: string;
|
|
||||||
startTime?: {
|
|
||||||
gte: Date;
|
|
||||||
lte: Date;
|
|
||||||
};
|
|
||||||
} = {
|
|
||||||
companyId: user.companyId,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (startDate && endDate) {
|
|
||||||
whereClause.startTime = {
|
|
||||||
gte: new Date(startDate),
|
|
||||||
lte: new Date(`${endDate}T23:59:59.999Z`), // Include full end date
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch sessions without messages first for better performance
|
|
||||||
const prismaSessions = 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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Batch fetch questions for all sessions at once if needed for metrics
|
|
||||||
const sessionIds = prismaSessions.map((s) => s.id);
|
|
||||||
const sessionQuestions = await prisma.sessionQuestion.findMany({
|
|
||||||
where: { sessionId: { in: sessionIds } },
|
|
||||||
include: { question: true },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Group questions by session
|
|
||||||
const questionsBySession = 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 Prisma sessions to ChatSession[] type for sessionMetrics
|
|
||||||
const chatSessions: ChatSession[] = prismaSessions.map((ps) => {
|
|
||||||
const questions = questionsBySession[ps.id] || [];
|
|
||||||
return convertToMockChatSession(ps, questions);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pass company config to metrics
|
|
||||||
const companyConfigForMetrics = {
|
|
||||||
// Add company-specific configuration here in the future
|
|
||||||
};
|
|
||||||
|
|
||||||
const metrics = sessionMetrics(chatSessions, companyConfigForMetrics);
|
|
||||||
|
|
||||||
// Calculate date range from sessions
|
|
||||||
let dateRange: { minDate: string; maxDate: string } | null = null;
|
|
||||||
if (prismaSessions.length > 0) {
|
|
||||||
const dates = prismaSessions
|
|
||||||
.map((s) => new Date(s.startTime))
|
|
||||||
.sort((a, b) => a.getTime() - b.getTime());
|
|
||||||
dateRange = {
|
|
||||||
minDate: dates[0].toISOString().split("T")[0], // First session date
|
|
||||||
maxDate: dates[dates.length - 1].toISOString().split("T")[0], // Last session date
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
metrics,
|
|
||||||
csvUrl: user.company.csvUrl,
|
|
||||||
company: user.company,
|
|
||||||
dateRange,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { getServerSession } from "next-auth/next";
|
|
||||||
import { authOptions } from "../../../../lib/auth";
|
|
||||||
import { prisma } from "../../../../lib/prisma";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const authSession = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
if (!authSession || !authSession.user?.companyId) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const companyId = authSession.user.companyId;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 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([
|
|
||||||
prisma.session.groupBy({
|
|
||||||
by: ["category"],
|
|
||||||
where: {
|
|
||||||
companyId,
|
|
||||||
category: { not: null },
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
category: "asc",
|
|
||||||
},
|
|
||||||
take: MAX_FILTER_OPTIONS,
|
|
||||||
}),
|
|
||||||
prisma.session.groupBy({
|
|
||||||
by: ["language"],
|
|
||||||
where: {
|
|
||||||
companyId,
|
|
||||||
language: { not: null },
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
language: "asc",
|
|
||||||
},
|
|
||||||
take: MAX_FILTER_OPTIONS,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const distinctCategories = categoryGroups
|
|
||||||
.map((g) => g.category)
|
|
||||||
.filter(Boolean) as string[];
|
|
||||||
|
|
||||||
const distinctLanguages = languageGroups
|
|
||||||
.map((g) => g.language)
|
|
||||||
.filter(Boolean) as string[];
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
categories: distinctCategories,
|
|
||||||
languages: distinctLanguages,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : "An unknown error occurred";
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: "Failed to fetch filter options",
|
|
||||||
details: errorMessage,
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { prisma } from "../../../../../lib/prisma";
|
|
||||||
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(
|
|
||||||
_request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Session ID is required" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const prismaSession = await prisma.session.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: {
|
|
||||||
messages: {
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!prismaSession) {
|
|
||||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map Prisma session object to ChatSession type
|
|
||||||
const session: ChatSession = mapPrismaSessionToChatSession(prismaSession);
|
|
||||||
|
|
||||||
return NextResponse.json({ session });
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : "An unknown error occurred";
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to fetch session", details: errorMessage },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,181 +0,0 @@
|
|||||||
import type { Prisma, SessionCategory } 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 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) {
|
|
||||||
const authSession = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
if (!authSession || !authSession.user?.companyId) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const companyId = authSession.user.companyId;
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
|
|
||||||
const queryPage = searchParams.get("page");
|
|
||||||
const queryPageSize = searchParams.get("pageSize");
|
|
||||||
const page = Number(queryPage) || 1;
|
|
||||||
const pageSize = Number(queryPageSize) || 10;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const whereClause = buildWhereClause(companyId, searchParams);
|
|
||||||
const orderByCondition = buildOrderByClause(searchParams);
|
|
||||||
|
|
||||||
const prismaSessions = await prisma.session.findMany({
|
|
||||||
where: whereClause,
|
|
||||||
orderBy: orderByCondition,
|
|
||||||
skip: (page - 1) * pageSize,
|
|
||||||
take: pageSize,
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalSessions = await prisma.session.count({ where: whereClause });
|
|
||||||
|
|
||||||
const sessions: ChatSession[] = prismaSessions.map(
|
|
||||||
convertPrismaSessionToChatSession
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({ sessions, totalSessions });
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : "An unknown error occurred";
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to fetch sessions", details: errorMessage },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "../../../../lib/auth";
|
|
||||||
import { prisma } from "../../../../lib/prisma";
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session?.user || session.user.role !== "ADMIN") {
|
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { email: session.user.email as string },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: "No user" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { csvUrl, csvUsername, csvPassword } = body;
|
|
||||||
|
|
||||||
await prisma.company.update({
|
|
||||||
where: { id: user.companyId },
|
|
||||||
data: {
|
|
||||||
csvUrl,
|
|
||||||
csvUsername,
|
|
||||||
...(csvPassword ? { csvPassword } : {}),
|
|
||||||
// Remove sentimentAlert field - not in current schema
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
import crypto from "node:crypto";
|
|
||||||
import bcrypt from "bcryptjs";
|
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "../../../../lib/auth";
|
|
||||||
import { prisma } from "../../../../lib/prisma";
|
|
||||||
|
|
||||||
interface UserBasicInfo {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session?.user || session.user.role !== "ADMIN") {
|
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { email: session.user.email as string },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: "No user" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const users = await prisma.user.findMany({
|
|
||||||
where: { companyId: user.companyId },
|
|
||||||
take: 1000, // Limit to prevent unbounded queries
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const mappedUsers: UserBasicInfo[] = users.map((u) => ({
|
|
||||||
id: u.id,
|
|
||||||
email: u.email,
|
|
||||||
role: u.role,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return NextResponse.json({ users: mappedUsers });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session?.user || session.user.role !== "ADMIN") {
|
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { email: session.user.email as string },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: "No user" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { email, role } = body;
|
|
||||||
|
|
||||||
if (!email || !role) {
|
|
||||||
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const exists = await prisma.user.findUnique({ where: { email } });
|
|
||||||
if (exists) {
|
|
||||||
return NextResponse.json({ error: "Email exists" }, { status: 409 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempPassword = crypto.randomBytes(12).toString("base64").slice(0, 12); // secure random initial password
|
|
||||||
|
|
||||||
await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
email,
|
|
||||||
password: await bcrypt.hash(tempPassword, 10),
|
|
||||||
companyId: user.companyId,
|
|
||||||
role,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { sendPasswordResetEmail } = await import("../../../../lib/sendEmail");
|
|
||||||
const emailResult = await sendPasswordResetEmail(email, 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,159 +0,0 @@
|
|||||||
import crypto from "node:crypto";
|
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { prisma } from "../../../lib/prisma";
|
|
||||||
import { extractClientIP, InMemoryRateLimiter } from "../../../lib/rateLimiter";
|
|
||||||
import {
|
|
||||||
AuditOutcome,
|
|
||||||
createAuditMetadata,
|
|
||||||
securityAuditLogger,
|
|
||||||
} from "../../../lib/securityAuditLogger";
|
|
||||||
import { sendEmail } from "../../../lib/sendEmail";
|
|
||||||
import { forgotPasswordSchema, validateInput } from "../../../lib/validation";
|
|
||||||
|
|
||||||
// Rate limiting for password reset endpoint
|
|
||||||
const passwordResetLimiter = new InMemoryRateLimiter({
|
|
||||||
maxAttempts: 5,
|
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
||||||
maxEntries: 10000,
|
|
||||||
cleanupIntervalMs: 5 * 60 * 1000, // 5 minutes
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Rate limiting check using shared utility
|
|
||||||
const ip = extractClientIP(request);
|
|
||||||
const userAgent = request.headers.get("user-agent") || undefined;
|
|
||||||
const rateLimitResult = passwordResetLimiter.checkRateLimit(ip);
|
|
||||||
|
|
||||||
if (!rateLimitResult.allowed) {
|
|
||||||
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(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: "Too many password reset attempts. Please try again later.",
|
|
||||||
},
|
|
||||||
{ status: 429 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
// Validate input
|
|
||||||
const validation = validateInput(forgotPasswordSchema, body);
|
|
||||||
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(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: "Invalid email format",
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { email } = validation.data;
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { email } });
|
|
||||||
|
|
||||||
// Always return success for privacy (don't reveal if email exists)
|
|
||||||
// But only send email if user exists
|
|
||||||
if (user) {
|
|
||||||
const token = crypto.randomBytes(32).toString("hex");
|
|
||||||
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
|
|
||||||
const expiry = new Date(Date.now() + 1000 * 60 * 30); // 30 min expiry
|
|
||||||
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { email },
|
|
||||||
data: { resetToken: tokenHash, resetTokenExpiry: expiry },
|
|
||||||
});
|
|
||||||
|
|
||||||
const resetUrl = `${process.env.NEXTAUTH_URL || "http://localhost:3000"}/reset-password?token=${token}`;
|
|
||||||
await sendEmail({
|
|
||||||
to: email,
|
|
||||||
subject: "Password Reset",
|
|
||||||
text: `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 });
|
|
||||||
} catch (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(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: "Internal server error",
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
import NextAuth from "next-auth";
|
|
||||||
import { platformAuthOptions } from "../../../../../lib/platform-auth";
|
|
||||||
|
|
||||||
const handler = NextAuth(platformAuthOptions);
|
|
||||||
|
|
||||||
export { handler as GET, handler as POST };
|
|
||||||
@ -1,163 +0,0 @@
|
|||||||
import { CompanyStatus } from "@prisma/client";
|
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { platformAuthOptions } from "../../../../../lib/platform-auth";
|
|
||||||
import { prisma } from "../../../../../lib/prisma";
|
|
||||||
|
|
||||||
interface PlatformSession {
|
|
||||||
user: {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
email?: string;
|
|
||||||
isPlatformUser?: boolean;
|
|
||||||
platformRole?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /api/platform/companies/[id] - Get company details
|
|
||||||
export async function GET(
|
|
||||||
_request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const session = (await getServerSession(
|
|
||||||
platformAuthOptions
|
|
||||||
)) as PlatformSession | null;
|
|
||||||
|
|
||||||
if (!session?.user?.isPlatformUser) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Platform access required" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
const company = await prisma.company.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: {
|
|
||||||
users: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
email: true,
|
|
||||||
role: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
invitedBy: true,
|
|
||||||
invitedAt: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
sessions: true,
|
|
||||||
imports: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!company) {
|
|
||||||
return NextResponse.json({ error: "Company not found" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(company);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Platform company details error:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Internal server error" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PATCH /api/platform/companies/[id] - Update company
|
|
||||||
export async function PATCH(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const session = await getServerSession(platformAuthOptions);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!session?.user?.isPlatformUser ||
|
|
||||||
session.user.platformRole === "SUPPORT"
|
|
||||||
) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Admin access required" },
|
|
||||||
{ status: 403 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await params;
|
|
||||||
const body = await request.json();
|
|
||||||
const { name, email, maxUsers, csvUrl, csvUsername, csvPassword, status } =
|
|
||||||
body;
|
|
||||||
|
|
||||||
const updateData: {
|
|
||||||
name?: string;
|
|
||||||
email?: string;
|
|
||||||
maxUsers?: number;
|
|
||||||
csvUrl?: string;
|
|
||||||
csvUsername?: string;
|
|
||||||
csvPassword?: string;
|
|
||||||
status?: CompanyStatus;
|
|
||||||
} = {};
|
|
||||||
if (name !== undefined) updateData.name = name;
|
|
||||||
if (email !== undefined) updateData.email = email;
|
|
||||||
if (maxUsers !== undefined) updateData.maxUsers = maxUsers;
|
|
||||||
if (csvUrl !== undefined) updateData.csvUrl = csvUrl;
|
|
||||||
if (csvUsername !== undefined) updateData.csvUsername = csvUsername;
|
|
||||||
if (csvPassword !== undefined) updateData.csvPassword = csvPassword;
|
|
||||||
if (status !== undefined) updateData.status = status;
|
|
||||||
|
|
||||||
const company = await prisma.company.update({
|
|
||||||
where: { id },
|
|
||||||
data: updateData,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ company });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Platform company update error:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Internal server error" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE /api/platform/companies/[id] - Delete company (archives instead)
|
|
||||||
export async function DELETE(
|
|
||||||
_request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const session = await getServerSession(platformAuthOptions);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!session?.user?.isPlatformUser ||
|
|
||||||
session.user.platformRole !== "SUPER_ADMIN"
|
|
||||||
) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Super admin access required" },
|
|
||||||
{ status: 403 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
// Archive instead of delete to preserve data integrity
|
|
||||||
const company = await prisma.company.update({
|
|
||||||
where: { id },
|
|
||||||
data: { status: CompanyStatus.ARCHIVED },
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ company });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Platform company archive error:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Internal server error" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,167 +0,0 @@
|
|||||||
import { hash } from "bcryptjs";
|
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { platformAuthOptions } from "../../../../../../lib/platform-auth";
|
|
||||||
import { prisma } from "../../../../../../lib/prisma";
|
|
||||||
|
|
||||||
// POST /api/platform/companies/[id]/users - Invite user to company
|
|
||||||
export async function POST(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const session = await getServerSession(platformAuthOptions);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!session?.user?.isPlatformUser ||
|
|
||||||
session.user.platformRole === "SUPPORT"
|
|
||||||
) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Admin access required" },
|
|
||||||
{ status: 403 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id: companyId } = await params;
|
|
||||||
const body = await request.json();
|
|
||||||
const { name, email, role = "USER" } = body;
|
|
||||||
|
|
||||||
if (!name || !email) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Name and email are required" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if company exists
|
|
||||||
const company = await prisma.company.findUnique({
|
|
||||||
where: { id: companyId },
|
|
||||||
include: { _count: { select: { users: true } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!company) {
|
|
||||||
return NextResponse.json({ error: "Company not found" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user limit would be exceeded
|
|
||||||
if (company._count.users >= company.maxUsers) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Company has reached maximum user limit" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user already exists (emails must be globally unique)
|
|
||||||
const existingUser = await prisma.user.findUnique({
|
|
||||||
where: {
|
|
||||||
email,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
companyId: true,
|
|
||||||
company: {
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingUser) {
|
|
||||||
if (existingUser.companyId === companyId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "User already exists in this company" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a temporary password (in a real app, you'd send an invitation email)
|
|
||||||
const tempPassword = `temp${Math.random().toString(36).slice(-8)}`;
|
|
||||||
const hashedPassword = await hash(tempPassword, 10);
|
|
||||||
|
|
||||||
// Create the user
|
|
||||||
const user = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
password: hashedPassword,
|
|
||||||
role,
|
|
||||||
companyId,
|
|
||||||
invitedBy: session.user.email,
|
|
||||||
invitedAt: new Date(),
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
email: true,
|
|
||||||
role: true,
|
|
||||||
createdAt: true,
|
|
||||||
invitedBy: true,
|
|
||||||
invitedAt: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// In a real application, you would send an email with login credentials
|
|
||||||
// For now, we'll return the temporary password
|
|
||||||
return NextResponse.json({
|
|
||||||
user,
|
|
||||||
tempPassword, // Remove this in production and send via email
|
|
||||||
message:
|
|
||||||
"User invited successfully. In production, credentials would be sent via email.",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Platform user invitation error:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Internal server error" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /api/platform/companies/[id]/users - Get company users
|
|
||||||
export async function GET(
|
|
||||||
_request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const session = await getServerSession(platformAuthOptions);
|
|
||||||
|
|
||||||
if (!session?.user?.isPlatformUser) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Platform access required" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id: companyId } = await params;
|
|
||||||
|
|
||||||
const users = await prisma.user.findMany({
|
|
||||||
where: { companyId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
email: true,
|
|
||||||
role: true,
|
|
||||||
createdAt: true,
|
|
||||||
invitedBy: true,
|
|
||||||
invitedAt: true,
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ users });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Platform users list error:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Internal server error" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,299 +0,0 @@
|
|||||||
import type { CompanyStatus } from "@prisma/client";
|
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { getServerSession, type Session } from "next-auth";
|
|
||||||
import { platformAuthOptions } from "../../../../lib/platform-auth";
|
|
||||||
import { prisma } from "../../../../lib/prisma";
|
|
||||||
import { extractClientIP } from "../../../../lib/rateLimiter";
|
|
||||||
import {
|
|
||||||
AuditOutcome,
|
|
||||||
createAuditMetadata,
|
|
||||||
securityAuditLogger,
|
|
||||||
} from "../../../../lib/securityAuditLogger";
|
|
||||||
|
|
||||||
// GET /api/platform/companies - List all companies
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
let session: Session | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
session = await getServerSession(platformAuthOptions);
|
|
||||||
const ip = extractClientIP(request);
|
|
||||||
const userAgent = request.headers.get("user-agent") || undefined;
|
|
||||||
|
|
||||||
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(
|
|
||||||
{ error: "Platform access required" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const status = searchParams.get("status") as CompanyStatus | null;
|
|
||||||
const search = searchParams.get("search");
|
|
||||||
const page = Number.parseInt(searchParams.get("page") || "1");
|
|
||||||
const limit = Number.parseInt(searchParams.get("limit") || "20");
|
|
||||||
const offset = (page - 1) * limit;
|
|
||||||
|
|
||||||
const where: {
|
|
||||||
status?: CompanyStatus;
|
|
||||||
name?: {
|
|
||||||
contains: string;
|
|
||||||
mode: "insensitive";
|
|
||||||
};
|
|
||||||
} = {};
|
|
||||||
if (status) where.status = status;
|
|
||||||
if (search) {
|
|
||||||
where.name = {
|
|
||||||
contains: search,
|
|
||||||
mode: "insensitive",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const [companies, total] = await Promise.all([
|
|
||||||
prisma.company.findMany({
|
|
||||||
where,
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
status: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
maxUsers: true,
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
sessions: true,
|
|
||||||
imports: true,
|
|
||||||
users: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
skip: offset,
|
|
||||||
take: limit,
|
|
||||||
}),
|
|
||||||
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({
|
|
||||||
companies,
|
|
||||||
pagination: {
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
total,
|
|
||||||
pages: Math.ceil(total / limit),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (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(
|
|
||||||
{ error: "Internal server error" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/platform/companies - Create new company
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
let session: Session | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
session = await getServerSession(platformAuthOptions);
|
|
||||||
const ip = extractClientIP(request);
|
|
||||||
const userAgent = request.headers.get("user-agent") || undefined;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!session?.user?.isPlatformUser ||
|
|
||||||
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(
|
|
||||||
{ error: "Admin access required" },
|
|
||||||
{ status: 403 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const {
|
|
||||||
name,
|
|
||||||
csvUrl,
|
|
||||||
csvUsername,
|
|
||||||
csvPassword,
|
|
||||||
adminEmail,
|
|
||||||
adminName,
|
|
||||||
adminPassword,
|
|
||||||
maxUsers = 10,
|
|
||||||
status = "TRIAL",
|
|
||||||
} = body;
|
|
||||||
|
|
||||||
if (!name || !csvUrl) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Name and CSV URL required" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!adminEmail || !adminName) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Admin email and name required" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate password if not provided
|
|
||||||
const finalAdminPassword =
|
|
||||||
adminPassword || `Temp${Math.random().toString(36).slice(2, 8)}!`;
|
|
||||||
|
|
||||||
// Hash the admin password
|
|
||||||
const bcrypt = await import("bcryptjs");
|
|
||||||
const hashedPassword = await bcrypt.hash(finalAdminPassword, 12);
|
|
||||||
|
|
||||||
// Create company and admin user in a transaction
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
|
||||||
// Create the company
|
|
||||||
const company = await tx.company.create({
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
csvUrl,
|
|
||||||
csvUsername: csvUsername || null,
|
|
||||||
csvPassword: csvPassword || null,
|
|
||||||
maxUsers,
|
|
||||||
status,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create the admin user
|
|
||||||
const adminUser = await tx.user.create({
|
|
||||||
data: {
|
|
||||||
email: adminEmail,
|
|
||||||
password: hashedPassword,
|
|
||||||
name: adminName,
|
|
||||||
role: "ADMIN",
|
|
||||||
companyId: company.id,
|
|
||||||
invitedBy: session?.user?.email || "platform",
|
|
||||||
invitedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
company,
|
|
||||||
adminUser,
|
|
||||||
generatedPassword: adminPassword ? null : finalAdminPassword,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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(
|
|
||||||
{
|
|
||||||
company: result.company,
|
|
||||||
adminUser: {
|
|
||||||
email: result.adminUser.email,
|
|
||||||
name: result.adminUser.name,
|
|
||||||
role: result.adminUser.role,
|
|
||||||
},
|
|
||||||
generatedPassword: result.generatedPassword,
|
|
||||||
},
|
|
||||||
{ status: 201 }
|
|
||||||
);
|
|
||||||
} catch (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(
|
|
||||||
{ error: "Internal server error" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
import bcrypt from "bcryptjs";
|
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { prisma } from "../../../lib/prisma";
|
|
||||||
import { extractClientIP, InMemoryRateLimiter } from "../../../lib/rateLimiter";
|
|
||||||
import { registerSchema, validateInput } from "../../../lib/validation";
|
|
||||||
|
|
||||||
// Rate limiting for registration endpoint
|
|
||||||
const registrationLimiter = new InMemoryRateLimiter({
|
|
||||||
maxAttempts: 3,
|
|
||||||
windowMs: 60 * 60 * 1000, // 1 hour
|
|
||||||
maxEntries: 10000,
|
|
||||||
cleanupIntervalMs: 5 * 60 * 1000, // 5 minutes
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Rate limiting check using shared utility
|
|
||||||
const ip = extractClientIP(request);
|
|
||||||
const rateLimitResult = registrationLimiter.checkRateLimit(ip);
|
|
||||||
|
|
||||||
if (!rateLimitResult.allowed) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: "Too many registration attempts. Please try again later.",
|
|
||||||
},
|
|
||||||
{ status: 429 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
// Validate input with Zod schema
|
|
||||||
const validation = validateInput(registerSchema, body);
|
|
||||||
if (!validation.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: "Validation failed",
|
|
||||||
details: validation.errors,
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { email, password, company } = validation.data;
|
|
||||||
|
|
||||||
// Check if email exists
|
|
||||||
const existingUser = await prisma.user.findUnique({
|
|
||||||
where: { email },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingUser) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: "Email already exists",
|
|
||||||
},
|
|
||||||
{ status: 409 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if company name already exists
|
|
||||||
const existingCompany = await prisma.company.findFirst({
|
|
||||||
where: { name: company },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingCompany) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: "Company name already exists",
|
|
||||||
},
|
|
||||||
{ status: 409 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create company and user in a transaction
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
|
||||||
const newCompany = await tx.company.create({
|
|
||||||
data: {
|
|
||||||
name: company,
|
|
||||||
csvUrl: "", // Empty by default, can be set later in settings
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 12); // Increased rounds for better security
|
|
||||||
|
|
||||||
const newUser = await tx.user.create({
|
|
||||||
data: {
|
|
||||||
email,
|
|
||||||
password: hashedPassword,
|
|
||||||
companyId: newCompany.id,
|
|
||||||
role: "USER", // Changed from ADMIN - users should be promoted by existing admins
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { company: newCompany, user: newUser };
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
message: "Registration successful",
|
|
||||||
userId: result.user.id,
|
|
||||||
companyId: result.company.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ status: 201 }
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Registration error:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: "Internal server error",
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
import crypto from "node:crypto";
|
|
||||||
import bcrypt from "bcryptjs";
|
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { prisma } from "../../../lib/prisma";
|
|
||||||
import { extractClientIP } from "../../../lib/rateLimiter";
|
|
||||||
import {
|
|
||||||
AuditOutcome,
|
|
||||||
createAuditMetadata,
|
|
||||||
securityAuditLogger,
|
|
||||||
} from "../../../lib/securityAuditLogger";
|
|
||||||
import { resetPasswordSchema, validateInput } from "../../../lib/validation";
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const ip = extractClientIP(request);
|
|
||||||
const userAgent = request.headers.get("user-agent") || undefined;
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
// Validate input with strong password requirements
|
|
||||||
const validation = validateInput(resetPasswordSchema, body);
|
|
||||||
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(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: "Validation failed",
|
|
||||||
details: validation.errors,
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { token, password } = validation.data;
|
|
||||||
|
|
||||||
// Hash the token to compare with stored hash
|
|
||||||
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
|
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
|
||||||
where: {
|
|
||||||
resetToken: tokenHash,
|
|
||||||
resetTokenExpiry: { gte: new Date() },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error:
|
|
||||||
"Invalid or expired token. Please request a new password reset.",
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hash password with higher rounds for better security
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 12);
|
|
||||||
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: user.id },
|
|
||||||
data: {
|
|
||||||
password: hashedPassword,
|
|
||||||
resetToken: null,
|
|
||||||
resetTokenExpiry: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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(
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
message: "Password has been reset successfully.",
|
|
||||||
},
|
|
||||||
{ status: 200 }
|
|
||||||
);
|
|
||||||
} catch (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(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: "An internal server error occurred. Please try again later.",
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,24 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Database, Save, Settings, ShieldX } from "lucide-react";
|
import { useState, useEffect } from "react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { useEffect, useId, useState } from "react";
|
import { Company } from "../../../lib/types";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { Button } from "@/components/ui/button";
|
interface CompanyConfigResponse {
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
company: Company;
|
||||||
import { Input } from "@/components/ui/input";
|
}
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import type { Company } from "../../../lib/types";
|
|
||||||
|
|
||||||
export default function CompanySettingsPage() {
|
export default function CompanySettingsPage() {
|
||||||
const csvUrlId = useId();
|
|
||||||
const csvUsernameId = 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>("");
|
||||||
|
const [sentimentThreshold, setSentimentThreshold] = useState<string>("");
|
||||||
const [message, setMessage] = useState<string>("");
|
const [message, setMessage] = useState<string>("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@ -28,10 +26,11 @@ export default function CompanySettingsPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/dashboard/config");
|
const res = await fetch("/api/dashboard/config");
|
||||||
const data = await res.json();
|
const data = (await res.json()) as CompanyConfigResponse;
|
||||||
setCompany(data.company);
|
setCompany(data.company);
|
||||||
setCsvUrl(data.company.csvUrl || "");
|
setCsvUrl(data.company.csvUrl || "");
|
||||||
setCsvUsername(data.company.csvUsername || "");
|
setCsvUsername(data.company.csvUsername || "");
|
||||||
|
setSentimentThreshold(data.company.sentimentAlert?.toString() || "");
|
||||||
if (data.company.csvPassword) {
|
if (data.company.csvPassword) {
|
||||||
setCsvPassword(data.company.csvPassword);
|
setCsvPassword(data.company.csvPassword);
|
||||||
}
|
}
|
||||||
@ -56,16 +55,17 @@ export default function CompanySettingsPage() {
|
|||||||
csvUrl,
|
csvUrl,
|
||||||
csvUsername,
|
csvUsername,
|
||||||
csvPassword,
|
csvPassword,
|
||||||
|
sentimentThreshold,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setMessage("Settings saved successfully!");
|
setMessage("Settings saved successfully!");
|
||||||
// Update local state if needed
|
// Update local state if needed
|
||||||
const data = await res.json();
|
const data = (await res.json()) as CompanyConfigResponse;
|
||||||
setCompany(data.company);
|
setCompany(data.company);
|
||||||
} else {
|
} else {
|
||||||
const error = await res.json();
|
const error = (await res.json()) as { message?: string; };
|
||||||
setMessage(
|
setMessage(
|
||||||
`Failed to save settings: ${error.message || "Unknown error"}`
|
`Failed to save settings: ${error.message || "Unknown error"}`
|
||||||
);
|
);
|
||||||
@ -78,89 +78,49 @@ export default function CompanySettingsPage() {
|
|||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <div className="text-center py-10">Loading settings...</div>;
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Settings className="h-6 w-6" />
|
|
||||||
<CardTitle>Company Settings</CardTitle>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
Loading settings...
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for ADMIN access
|
// Check for admin access
|
||||||
if (session?.user?.role !== "ADMIN") {
|
if (session?.user?.role !== "admin") {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="text-center py-10 bg-white rounded-xl shadow p-6">
|
||||||
<Card>
|
<h2 className="font-bold text-xl text-red-600 mb-2">Access Denied</h2>
|
||||||
<CardHeader>
|
<p>You don't have permission to view company settings.</p>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<ShieldX className="h-6 w-6 text-destructive" />
|
|
||||||
<CardTitle className="text-destructive">Access Denied</CardTitle>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
You don't have permission to view company settings.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
<CardHeader>
|
<h1 className="text-2xl font-bold text-gray-800 mb-6">
|
||||||
<div className="flex items-center gap-3">
|
Company Settings
|
||||||
<Settings className="h-6 w-6" />
|
</h1>
|
||||||
<CardTitle>Company Settings</CardTitle>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{message && (
|
{message && (
|
||||||
<Alert
|
<div
|
||||||
variant={message.includes("Failed") ? "destructive" : "default"}
|
className={`p-4 rounded mb-6 ${message.includes("Failed") ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700"}`}
|
||||||
>
|
>
|
||||||
<AlertDescription>{message}</AlertDescription>
|
{message}
|
||||||
</Alert>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="space-y-6"
|
className="grid gap-6"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSave();
|
handleSave();
|
||||||
}}
|
}}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
>
|
>
|
||||||
<Card>
|
<div className="grid gap-2">
|
||||||
<CardHeader>
|
<label className="font-medium text-gray-700">
|
||||||
<div className="flex items-center gap-2">
|
CSV Data Source URL
|
||||||
<Database className="h-5 w-5" />
|
</label>
|
||||||
<CardTitle className="text-lg">
|
<input
|
||||||
Data Source Configuration
|
|
||||||
</CardTitle>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={csvUrlId}>CSV Data Source URL</Label>
|
|
||||||
<Input
|
|
||||||
id={csvUrlId}
|
|
||||||
type="text"
|
type="text"
|
||||||
|
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||||
value={csvUrl}
|
value={csvUrl}
|
||||||
onChange={(e) => setCsvUrl(e.target.value)}
|
onChange={(e) => setCsvUrl(e.target.value)}
|
||||||
placeholder="https://example.com/data.csv"
|
placeholder="https://example.com/data.csv"
|
||||||
@ -168,11 +128,11 @@ export default function CompanySettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor={csvUsernameId}>CSV Username</Label>
|
<label className="font-medium text-gray-700">CSV Username</label>
|
||||||
<Input
|
<input
|
||||||
id={csvUsernameId}
|
|
||||||
type="text"
|
type="text"
|
||||||
|
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||||
value={csvUsername}
|
value={csvUsername}
|
||||||
onChange={(e) => setCsvUsername(e.target.value)}
|
onChange={(e) => setCsvUsername(e.target.value)}
|
||||||
placeholder="Username for CSV access (if needed)"
|
placeholder="Username for CSV access (if needed)"
|
||||||
@ -180,32 +140,48 @@ export default function CompanySettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor={csvPasswordId}>CSV Password</Label>
|
<label className="font-medium text-gray-700">CSV Password</label>
|
||||||
<Input
|
<input
|
||||||
id={csvPasswordId}
|
|
||||||
type="password"
|
type="password"
|
||||||
|
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||||
value={csvPassword}
|
value={csvPassword}
|
||||||
onChange={(e) => setCsvPassword(e.target.value)}
|
onChange={(e) => setCsvPassword(e.target.value)}
|
||||||
placeholder="Password will be updated only if provided"
|
placeholder="Password will be updated only if provided"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-gray-500">
|
||||||
Leave blank to keep current password
|
Leave blank to keep current password
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="grid gap-2">
|
||||||
<Button type="submit" className="gap-2">
|
<label className="font-medium text-gray-700">
|
||||||
<Save className="h-4 w-4" />
|
Sentiment Alert Threshold
|
||||||
Save Settings
|
</label>
|
||||||
</Button>
|
<input
|
||||||
|
type="number"
|
||||||
|
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||||
|
value={sentimentThreshold}
|
||||||
|
onChange={(e) => setSentimentThreshold(e.target.value)}
|
||||||
|
placeholder="Threshold value (0-100)"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Percentage of negative sentiment sessions to trigger alert (0-100)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-sky-600 hover:bg-sky-700 text-white py-2 px-4 rounded-lg shadow transition-colors w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Save Settings
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { ReactNode, useState, useEffect, useCallback } from "react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { type ReactNode, useCallback, useEffect, useId, useState } from "react";
|
import { useRouter } from "next/navigation";
|
||||||
import Sidebar from "../../components/Sidebar";
|
import Sidebar from "../../components/Sidebar";
|
||||||
|
|
||||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||||
const mainContentId = useId();
|
|
||||||
const { status } = useSession();
|
const { status } = useSession();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@ -58,7 +57,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background">
|
<div className="flex h-screen bg-gray-100">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
isExpanded={isSidebarExpanded}
|
isExpanded={isSidebarExpanded}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
@ -66,8 +65,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
onNavigate={collapseSidebar}
|
onNavigate={collapseSidebar}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main
|
<div
|
||||||
id={mainContentId}
|
|
||||||
className={`flex-1 overflow-auto transition-all duration-300 py-4 pr-4
|
className={`flex-1 overflow-auto transition-all duration-300 py-4 pr-4
|
||||||
${
|
${
|
||||||
isSidebarExpanded
|
isSidebarExpanded
|
||||||
@ -78,7 +76,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
>
|
>
|
||||||
{/* <div className="w-full mx-auto">{children}</div> */}
|
{/* <div className="w-full mx-auto">{children}</div> */}
|
||||||
<div className="max-w-7xl mx-auto">{children}</div>
|
<div className="max-w-7xl mx-auto">{children}</div>
|
||||||
</main>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,21 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
|
||||||
ArrowRight,
|
|
||||||
BarChart3,
|
|
||||||
MessageSquare,
|
|
||||||
Settings,
|
|
||||||
Shield,
|
|
||||||
TrendingUp,
|
|
||||||
Users,
|
|
||||||
Zap,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { type FC, useEffect, useState } from "react";
|
import { useRouter } from "next/navigation";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { FC } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
|
|
||||||
const DashboardPage: FC = () => {
|
const DashboardPage: FC = () => {
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
@ -33,244 +21,82 @@ const DashboardPage: FC = () => {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[60vh]">
|
<div className="flex items-center justify-center min-h-[40vh]">
|
||||||
<div className="text-center space-y-4">
|
<div className="text-center">
|
||||||
<div className="relative">
|
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-sky-500 mx-auto mb-4"></div>
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-2 border-muted border-t-primary mx-auto" />
|
<p className="text-lg text-gray-600">Loading dashboard...</p>
|
||||||
<div className="absolute inset-0 animate-ping rounded-full h-12 w-12 border border-primary opacity-20 mx-auto" />
|
|
||||||
</div>
|
|
||||||
<p className="text-lg text-muted-foreground animate-pulse">
|
|
||||||
Loading dashboard...
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigationCards = [
|
|
||||||
{
|
|
||||||
title: "Analytics Overview",
|
|
||||||
description:
|
|
||||||
"View comprehensive metrics, charts, and insights from your chat sessions",
|
|
||||||
icon: <BarChart3 className="h-6 w-6" />,
|
|
||||||
href: "/dashboard/overview",
|
|
||||||
variant: "primary" as const,
|
|
||||||
features: ["Real-time metrics", "Interactive charts", "Trend analysis"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Session Browser",
|
|
||||||
description:
|
|
||||||
"Browse, search, and analyze individual conversation sessions",
|
|
||||||
icon: <MessageSquare className="h-6 w-6" />,
|
|
||||||
href: "/dashboard/sessions",
|
|
||||||
variant: "success" as const,
|
|
||||||
features: ["Session search", "Conversation details", "Export data"],
|
|
||||||
},
|
|
||||||
...(session?.user?.role === "ADMIN"
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
title: "Company Settings",
|
|
||||||
description:
|
|
||||||
"Configure company settings, integrations, and API connections",
|
|
||||||
icon: <Settings className="h-6 w-6" />,
|
|
||||||
href: "/dashboard/company",
|
|
||||||
variant: "warning" as const,
|
|
||||||
features: [
|
|
||||||
"API configuration",
|
|
||||||
"Integration settings",
|
|
||||||
"Data management",
|
|
||||||
],
|
|
||||||
adminOnly: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "User Management",
|
|
||||||
description:
|
|
||||||
"Invite team members and manage user accounts and permissions",
|
|
||||||
icon: <Users className="h-6 w-6" />,
|
|
||||||
href: "/dashboard/users",
|
|
||||||
variant: "default" as const,
|
|
||||||
features: ["User invitations", "Role management", "Access control"],
|
|
||||||
adminOnly: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
];
|
|
||||||
|
|
||||||
const getCardClasses = (variant: string) => {
|
|
||||||
switch (variant) {
|
|
||||||
case "primary":
|
|
||||||
return "border-primary/20 bg-linear-to-br from-primary/5 to-primary/10 hover:from-primary/10 hover:to-primary/15";
|
|
||||||
case "success":
|
|
||||||
return "border-green-200 bg-linear-to-br from-green-50 to-green-100 hover:from-green-100 hover:to-green-150 dark:border-green-800 dark:from-green-950 dark:to-green-900";
|
|
||||||
case "warning":
|
|
||||||
return "border-amber-200 bg-linear-to-br from-amber-50 to-amber-100 hover:from-amber-100 hover:to-amber-150 dark:border-amber-800 dark:from-amber-950 dark:to-amber-900";
|
|
||||||
default:
|
|
||||||
return "border-border bg-linear-to-br from-card to-muted/20 hover:from-muted/30 hover:to-muted/40";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getIconClasses = (variant: string) => {
|
|
||||||
switch (variant) {
|
|
||||||
case "primary":
|
|
||||||
return "bg-primary/10 text-primary border-primary/20";
|
|
||||||
case "success":
|
|
||||||
return "bg-green-100 text-green-600 border-green-200 dark:bg-green-900 dark:text-green-400 dark:border-green-800";
|
|
||||||
case "warning":
|
|
||||||
return "bg-amber-100 text-amber-600 border-amber-200 dark:bg-amber-900 dark:text-amber-400 dark:border-amber-800";
|
|
||||||
default:
|
|
||||||
return "bg-muted text-muted-foreground border-border";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-6">
|
||||||
{/* Welcome Header */}
|
<div className="bg-white rounded-xl shadow p-6">
|
||||||
<div className="relative overflow-hidden rounded-xl bg-linear-to-r from-primary/10 via-primary/5 to-transparent p-8 border border-primary/10">
|
<h1 className="text-2xl font-bold mb-4">Dashboard</h1>
|
||||||
<div className="absolute inset-0 bg-linear-to-br from-primary/5 to-transparent" />
|
|
||||||
<div className="absolute -top-24 -right-24 h-64 w-64 rounded-full bg-primary/10 blur-3xl" />
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<div className="relative">
|
<div className="bg-gradient-to-br from-sky-50 to-sky-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
<h2 className="text-lg font-semibold text-sky-700">Analytics</h2>
|
||||||
<div className="space-y-3">
|
<p className="text-gray-600 mt-2 mb-4">
|
||||||
<div className="flex items-center gap-3">
|
View your chat session metrics and analytics
|
||||||
<h1 className="text-4xl font-bold tracking-tight bg-clip-text text-transparent bg-linear-to-r from-foreground to-foreground/70">
|
|
||||||
Welcome back, {session?.user?.name || "User"}!
|
|
||||||
</h1>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="text-xs px-3 py-1 bg-primary/10 text-primary border-primary/20"
|
|
||||||
>
|
|
||||||
{session?.user?.role}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-lg">
|
|
||||||
Choose a section below to explore your analytics dashboard
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<button
|
||||||
|
onClick={() => router.push("/dashboard/overview")}
|
||||||
<div className="flex items-center gap-3 px-4 py-2 rounded-full bg-muted/50 backdrop-blur-sm">
|
className="bg-sky-500 hover:bg-sky-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||||
<Shield className="h-4 w-4 text-green-600" />
|
|
||||||
<span className="text-sm font-medium">Secure Dashboard</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation Cards */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
{navigationCards.map((card) => (
|
|
||||||
<Card
|
|
||||||
key={card.href}
|
|
||||||
className={`relative overflow-hidden transition-all duration-300 hover:shadow-2xl hover:-translate-y-1 cursor-pointer group ${getCardClasses(
|
|
||||||
card.variant
|
|
||||||
)}`}
|
|
||||||
onClick={() => router.push(card.href)}
|
|
||||||
>
|
>
|
||||||
{/* Subtle gradient overlay */}
|
View Analytics
|
||||||
<div className="absolute inset-0 bg-linear-to-br from-white/50 to-transparent dark:from-white/5 pointer-events-none" />
|
</button>
|
||||||
|
</div>
|
||||||
<CardHeader className="relative">
|
|
||||||
<div className="flex items-start justify-between">
|
<div className="bg-gradient-to-br from-emerald-50 to-emerald-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
||||||
<div className="space-y-3">
|
<h2 className="text-lg font-semibold text-emerald-700">Sessions</h2>
|
||||||
<div className="flex items-center gap-3">
|
<p className="text-gray-600 mt-2 mb-4">
|
||||||
<div
|
Browse and analyze conversation sessions
|
||||||
className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-full border transition-all duration-300 group-hover:scale-110 ${getIconClasses(
|
</p>
|
||||||
card.variant
|
<button
|
||||||
)}`}
|
onClick={() => router.push("/dashboard/sessions")}
|
||||||
>
|
className="bg-emerald-500 hover:bg-emerald-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||||
<span className="transition-transform duration-300 group-hover:scale-110">
|
>
|
||||||
{card.icon}
|
View Sessions
|
||||||
</span>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{session?.user?.role === "admin" && (
|
||||||
|
<div className="bg-gradient-to-br from-purple-50 to-purple-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<h2 className="text-lg font-semibold text-purple-700">
|
||||||
|
Company Settings
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mt-2 mb-4">
|
||||||
|
Configure company settings and integrations
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/dashboard/company")}
|
||||||
|
className="bg-purple-500 hover:bg-purple-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Manage Settings
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<CardTitle className="text-xl font-semibold flex items-center gap-2">
|
|
||||||
{card.title}
|
|
||||||
{card.adminOnly && (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
Admin
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</CardTitle>
|
|
||||||
</div>
|
{session?.user?.role === "admin" && (
|
||||||
</div>
|
<div className="bg-gradient-to-br from-amber-50 to-amber-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
||||||
<p className="text-muted-foreground leading-relaxed">
|
<h2 className="text-lg font-semibold text-amber-700">
|
||||||
{card.description}
|
User Management
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mt-2 mb-4">
|
||||||
|
Invite and manage user accounts
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<button
|
||||||
</div>
|
onClick={() => router.push("/dashboard/users")}
|
||||||
</CardHeader>
|
className="bg-amber-500 hover:bg-amber-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
|
||||||
<CardContent className="relative space-y-4">
|
|
||||||
{/* Features List */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{card.features.map((feature) => (
|
|
||||||
<div
|
|
||||||
key={feature}
|
|
||||||
className="flex items-center gap-2 text-sm"
|
|
||||||
>
|
>
|
||||||
<Zap className="h-3 w-3 text-primary/60" />
|
Manage Users
|
||||||
<span className="text-muted-foreground">{feature}</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Button */}
|
|
||||||
<Button
|
|
||||||
className="w-full gap-2 mt-4 group-hover:gap-3 transition-all duration-300"
|
|
||||||
variant={card.variant === "primary" ? "default" : "outline"}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
router.push(card.href);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{card.title === "Analytics Overview" && "View Analytics"}
|
|
||||||
{card.title === "Session Browser" && "Browse Sessions"}
|
|
||||||
{card.title === "Company Settings" && "Manage Settings"}
|
|
||||||
{card.title === "User Management" && "Manage Users"}
|
|
||||||
</span>
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Stats */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<TrendingUp className="h-5 w-5" />
|
|
||||||
Quick Stats
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
|
||||||
<div className="text-center space-y-2">
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<Zap className="h-5 w-5 text-primary" />
|
|
||||||
<span className="text-2xl font-bold">Real-time</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">Data updates</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center space-y-2">
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<Shield className="h-5 w-5 text-green-600" />
|
|
||||||
<span className="text-2xl font-bold">Secure</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">Data protection</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center space-y-2">
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<BarChart3 className="h-5 w-5 text-blue-600" />
|
|
||||||
<span className="text-2xl font-bold">Advanced</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">Analytics</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,57 +1,46 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
|
||||||
Activity,
|
|
||||||
AlertCircle,
|
|
||||||
ArrowLeft,
|
|
||||||
Clock,
|
|
||||||
ExternalLink,
|
|
||||||
FileText,
|
|
||||||
Globe,
|
|
||||||
MessageSquare,
|
|
||||||
User,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { useParams, useRouter } from "next/navigation"; // Import useRouter
|
||||||
import { Button } from "@/components/ui/button";
|
import { useSession } from "next-auth/react"; // Import useSession
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { formatCategory } from "@/lib/format-enums";
|
|
||||||
import MessageViewer from "../../../../components/MessageViewer";
|
|
||||||
import SessionDetails from "../../../../components/SessionDetails";
|
import SessionDetails from "../../../../components/SessionDetails";
|
||||||
import type { ChatSession } from "../../../../lib/types";
|
import TranscriptViewer from "../../../../components/TranscriptViewer";
|
||||||
|
import { ChatSession } from "../../../../lib/types";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
/**
|
interface SessionApiResponse {
|
||||||
* Custom hook for managing session data fetching and state
|
session: ChatSession;
|
||||||
*/
|
}
|
||||||
function useSessionData(id: string | undefined, authStatus: string) {
|
|
||||||
|
export default function SessionViewPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter(); // Initialize useRouter
|
||||||
|
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}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
const errorData = (await response.json()) as { error: string; };
|
||||||
throw new Error(
|
throw new Error(
|
||||||
errorData.error ||
|
errorData.error ||
|
||||||
`Failed to fetch session: ${response.statusText}`
|
`Failed to fetch session: ${response.statusText}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = (await response.json()) as SessionApiResponse;
|
||||||
setSession(data.session);
|
setSession(data.session);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
@ -63,289 +52,123 @@ 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="p-4 md:p-6 flex justify-center items-center min-h-screen">
|
||||||
|
<p className="text-gray-600 text-lg">Loading session...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "unauthenticated") {
|
if (status === "unauthenticated") {
|
||||||
return <LoadingCard message="Redirecting to login..." />;
|
return (
|
||||||
|
<div className="p-4 md:p-6 flex justify-center items-center min-h-screen">
|
||||||
|
<p className="text-gray-600 text-lg">Redirecting to login...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading && status === "authenticated") {
|
if (loading && status === "authenticated") {
|
||||||
return <LoadingCard message="Loading session details..." />;
|
return (
|
||||||
|
<div className="p-4 md:p-6 flex justify-center items-center min-h-screen">
|
||||||
|
<p className="text-gray-600 text-lg">Loading session details...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <ErrorCard error={error} />;
|
return (
|
||||||
|
<div className="p-4 md:p-6 min-h-screen">
|
||||||
|
<p className="text-red-500 text-lg mb-4">Error: {error}</p>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/sessions"
|
||||||
|
className="text-sky-600 hover:underline"
|
||||||
|
>
|
||||||
|
Back to Sessions List
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return <SessionNotFoundCard />;
|
return (
|
||||||
|
<div className="p-4 md:p-6 min-h-screen">
|
||||||
|
<p className="text-gray-600 text-lg mb-4">Session not found.</p>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/sessions"
|
||||||
|
className="text-sky-600 hover:underline"
|
||||||
|
>
|
||||||
|
Back to Sessions List
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-6xl mx-auto">
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-sky-100 p-4 md:p-6">
|
||||||
<SessionHeader session={session} />
|
<div className="max-w-4xl mx-auto">
|
||||||
<SessionOverview session={session} />
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
{/* Session Details */}
|
href="/dashboard/sessions"
|
||||||
|
className="text-sky-700 hover:text-sky-900 hover:underline flex items-center"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5 mr-1"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Back to Sessions List
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-800 mb-6">
|
||||||
|
Session: {session.sessionId || session.id}
|
||||||
|
</h1>
|
||||||
|
<div className="grid grid-cols-1 gap-6">
|
||||||
|
<div>
|
||||||
<SessionDetails session={session} />
|
<SessionDetails session={session} />
|
||||||
|
</div>
|
||||||
{/* Messages */}
|
{session.transcriptContent &&
|
||||||
{session.messages && session.messages.length > 0 && (
|
session.transcriptContent.trim() !== "" ? (
|
||||||
<Card>
|
<div className="mt-0">
|
||||||
<CardHeader>
|
<TranscriptViewer
|
||||||
<CardTitle className="flex items-center gap-2">
|
transcriptContent={session.transcriptContent}
|
||||||
<MessageSquare className="h-5 w-5" />
|
transcriptUrl={session.fullTranscriptUrl}
|
||||||
Conversation ({session.messages.length} messages)
|
/>
|
||||||
</CardTitle>
|
</div>
|
||||||
</CardHeader>
|
) : (
|
||||||
<CardContent>
|
<div className="bg-white p-4 rounded-lg shadow">
|
||||||
<MessageViewer messages={session.messages} />
|
<h3 className="font-bold text-lg mb-3">Transcript</h3>
|
||||||
</CardContent>
|
<p className="text-gray-600">
|
||||||
</Card>
|
No transcript content available for this session.
|
||||||
)}
|
</p>
|
||||||
|
{session.fullTranscriptUrl &&
|
||||||
{/* Transcript URL */}
|
process.env.NODE_ENV !== "production" && (
|
||||||
{session.fullTranscriptUrl && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<FileText className="h-5 w-5" />
|
|
||||||
Source Transcript
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<a
|
<a
|
||||||
href={session.fullTranscriptUrl}
|
href={session.fullTranscriptUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-2 text-primary hover:underline focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
className="text-sky-600 hover:underline mt-2 inline-block"
|
||||||
aria-label="Open original transcript in new tab"
|
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
View Source Transcript URL
|
||||||
View Original Transcript
|
|
||||||
</a>
|
</a>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,599 +1,352 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { useState, useEffect, useCallback } from "react";
|
||||||
ChevronDown,
|
import { ChatSession } from "../../../lib/types";
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
ChevronUp,
|
|
||||||
Clock,
|
|
||||||
Eye,
|
|
||||||
Filter,
|
|
||||||
Globe,
|
|
||||||
MessageSquare,
|
|
||||||
Search,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useId, useState } from "react";
|
|
||||||
import type { z } from "zod";
|
|
||||||
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 { Label } from "@/components/ui/label";
|
|
||||||
import { formatCategory } from "@/lib/format-enums";
|
|
||||||
import { trpc } from "@/lib/trpc-client";
|
|
||||||
import type { sessionFilterSchema } from "@/lib/validation";
|
|
||||||
import type { ChatSession } from "../../../lib/types";
|
|
||||||
|
|
||||||
|
// Placeholder for a SessionListItem component to be created later
|
||||||
|
// For now, we'll display some basic info directly.
|
||||||
|
// import SessionListItem from "../../../components/SessionListItem";
|
||||||
|
|
||||||
|
// TODO: Consider moving filter/sort types to lib/types.ts if they become complex
|
||||||
interface FilterOptions {
|
interface FilterOptions {
|
||||||
categories: string[];
|
categories: string[];
|
||||||
languages: string[];
|
languages: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilterSectionProps {
|
interface SessionsApiResponse {
|
||||||
filtersExpanded: boolean;
|
|
||||||
setFiltersExpanded: (expanded: boolean) => void;
|
|
||||||
searchTerm: string;
|
|
||||||
setSearchTerm: (term: string) => void;
|
|
||||||
selectedCategory: string;
|
|
||||||
setSelectedCategory: (category: string) => void;
|
|
||||||
selectedLanguage: string;
|
|
||||||
setSelectedLanguage: (language: string) => void;
|
|
||||||
startDate: string;
|
|
||||||
setStartDate: (date: string) => void;
|
|
||||||
endDate: string;
|
|
||||||
setEndDate: (date: string) => void;
|
|
||||||
sortKey: string;
|
|
||||||
setSortKey: (key: string) => void;
|
|
||||||
sortOrder: string;
|
|
||||||
setSortOrder: (order: string) => void;
|
|
||||||
filterOptions: FilterOptions;
|
|
||||||
searchHeadingId: string;
|
|
||||||
searchId: string;
|
|
||||||
filtersHeadingId: string;
|
|
||||||
filterContentId: string;
|
|
||||||
categoryFilterId: string;
|
|
||||||
categoryHelpId: string;
|
|
||||||
languageFilterId: string;
|
|
||||||
languageHelpId: string;
|
|
||||||
startDateId: string;
|
|
||||||
endDateId: string;
|
|
||||||
sortById: string;
|
|
||||||
sortOrderId: string;
|
|
||||||
sortOrderHelpId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FilterSection({
|
|
||||||
filtersExpanded,
|
|
||||||
setFiltersExpanded,
|
|
||||||
searchTerm,
|
|
||||||
setSearchTerm,
|
|
||||||
selectedCategory,
|
|
||||||
setSelectedCategory,
|
|
||||||
selectedLanguage,
|
|
||||||
setSelectedLanguage,
|
|
||||||
startDate,
|
|
||||||
setStartDate,
|
|
||||||
endDate,
|
|
||||||
setEndDate,
|
|
||||||
sortKey,
|
|
||||||
setSortKey,
|
|
||||||
sortOrder,
|
|
||||||
setSortOrder,
|
|
||||||
filterOptions,
|
|
||||||
searchHeadingId,
|
|
||||||
searchId,
|
|
||||||
filtersHeadingId,
|
|
||||||
filterContentId,
|
|
||||||
categoryFilterId,
|
|
||||||
categoryHelpId,
|
|
||||||
languageFilterId,
|
|
||||||
languageHelpId,
|
|
||||||
startDateId,
|
|
||||||
endDateId,
|
|
||||||
sortById,
|
|
||||||
sortOrderId,
|
|
||||||
sortOrderHelpId,
|
|
||||||
}: FilterSectionProps) {
|
|
||||||
return (
|
|
||||||
<section aria-labelledby={searchHeadingId}>
|
|
||||||
<h2 id={searchHeadingId} className="sr-only">
|
|
||||||
Search and Filter Sessions
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="relative">
|
|
||||||
<Label htmlFor={searchId} className="sr-only">
|
|
||||||
Search sessions
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
id={searchId}
|
|
||||||
type="text"
|
|
||||||
placeholder="Search sessions..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setFiltersExpanded(!filtersExpanded)}
|
|
||||||
className="w-full justify-between"
|
|
||||||
aria-expanded={filtersExpanded}
|
|
||||||
aria-controls={filterContentId}
|
|
||||||
aria-describedby={filtersHeadingId}
|
|
||||||
>
|
|
||||||
<span id={filtersHeadingId}>Advanced Filters</span>
|
|
||||||
{filtersExpanded ? (
|
|
||||||
<ChevronUp className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{filtersExpanded && (
|
|
||||||
<CardContent id={filterContentId}>
|
|
||||||
<fieldset>
|
|
||||||
<legend className="sr-only">Filter and sort options</legend>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor={categoryFilterId}>Category</Label>
|
|
||||||
<select
|
|
||||||
id={categoryFilterId}
|
|
||||||
value={selectedCategory}
|
|
||||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
|
||||||
className="w-full mt-1 p-2 border border-gray-300 rounded-md"
|
|
||||||
aria-describedby={categoryHelpId}
|
|
||||||
>
|
|
||||||
<option value="">All Categories</option>
|
|
||||||
{filterOptions.categories.map((category) => (
|
|
||||||
<option key={category} value={category}>
|
|
||||||
{formatCategory(category)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<div id={categoryHelpId} className="sr-only">
|
|
||||||
Filter sessions by category
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor={languageFilterId}>Language</Label>
|
|
||||||
<select
|
|
||||||
id={languageFilterId}
|
|
||||||
value={selectedLanguage}
|
|
||||||
onChange={(e) => setSelectedLanguage(e.target.value)}
|
|
||||||
className="w-full mt-1 p-2 border border-gray-300 rounded-md"
|
|
||||||
aria-describedby={languageHelpId}
|
|
||||||
>
|
|
||||||
<option value="">All Languages</option>
|
|
||||||
{filterOptions.languages.map((language) => (
|
|
||||||
<option key={language} value={language}>
|
|
||||||
{language.toUpperCase()}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<div id={languageHelpId} className="sr-only">
|
|
||||||
Filter sessions by language
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor={startDateId}>Start Date</Label>
|
|
||||||
<Input
|
|
||||||
id={startDateId}
|
|
||||||
type="date"
|
|
||||||
value={startDate}
|
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor={endDateId}>End Date</Label>
|
|
||||||
<Input
|
|
||||||
id={endDateId}
|
|
||||||
type="date"
|
|
||||||
value={endDate}
|
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor={sortById}>Sort By</Label>
|
|
||||||
<select
|
|
||||||
id={sortById}
|
|
||||||
value={sortKey}
|
|
||||||
onChange={(e) => setSortKey(e.target.value)}
|
|
||||||
className="w-full mt-1 p-2 border border-gray-300 rounded-md"
|
|
||||||
>
|
|
||||||
<option value="startTime">Start Time</option>
|
|
||||||
<option value="sessionId">Session ID</option>
|
|
||||||
<option value="category">Category</option>
|
|
||||||
<option value="language">Language</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor={sortOrderId}>Sort Order</Label>
|
|
||||||
<select
|
|
||||||
id={sortOrderId}
|
|
||||||
value={sortOrder}
|
|
||||||
onChange={(e) => setSortOrder(e.target.value)}
|
|
||||||
className="w-full mt-1 p-2 border border-gray-300 rounded-md"
|
|
||||||
aria-describedby={sortOrderHelpId}
|
|
||||||
>
|
|
||||||
<option value="desc">Newest First</option>
|
|
||||||
<option value="asc">Oldest First</option>
|
|
||||||
</select>
|
|
||||||
<div id={sortOrderHelpId} className="sr-only">
|
|
||||||
Choose ascending or descending order
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionListProps {
|
|
||||||
sessions: ChatSession[];
|
sessions: ChatSession[];
|
||||||
loading: boolean;
|
totalSessions: number;
|
||||||
error: string | null;
|
|
||||||
resultsHeadingId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SessionList({
|
|
||||||
sessions,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
resultsHeadingId,
|
|
||||||
}: SessionListProps) {
|
|
||||||
return (
|
|
||||||
<section aria-labelledby={resultsHeadingId}>
|
|
||||||
<h2 id={resultsHeadingId} className="sr-only">
|
|
||||||
Session Results
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<output aria-live="polite" className="sr-only">
|
|
||||||
{loading && "Loading sessions..."}
|
|
||||||
{error && `Error loading sessions: ${error}`}
|
|
||||||
{!loading &&
|
|
||||||
!error &&
|
|
||||||
sessions.length > 0 &&
|
|
||||||
`Found ${sessions.length} sessions`}
|
|
||||||
{!loading && !error && sessions.length === 0 && "No sessions found"}
|
|
||||||
</output>
|
|
||||||
|
|
||||||
{loading && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div
|
|
||||||
className="text-center py-8 text-muted-foreground"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
Loading sessions...
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div
|
|
||||||
className="text-center py-8 text-destructive"
|
|
||||||
role="alert"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
Error loading sessions: {error}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && sessions.length === 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
No sessions found. Try adjusting your search criteria.
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && sessions.length > 0 && (
|
|
||||||
<ul className="space-y-4">
|
|
||||||
{sessions.map((session) => (
|
|
||||||
<li key={session.id}>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<article>
|
|
||||||
<header className="flex justify-between items-start mb-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium text-base mb-1">
|
|
||||||
Session{" "}
|
|
||||||
{session.sessionId ||
|
|
||||||
`${session.id.substring(0, 8)}...`}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
<Clock
|
|
||||||
className="h-3 w-3 mr-1"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{new Date(session.startTime).toLocaleDateString()}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{new Date(session.startTime).toLocaleTimeString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Link href={`/dashboard/sessions/${session.id}`}>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="gap-2"
|
|
||||||
aria-label={`View details for session ${session.sessionId || session.id}`}
|
|
||||||
>
|
|
||||||
<Eye className="h-4 w-4" aria-hidden="true" />
|
|
||||||
<span className="hidden sm:inline">View Details</span>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mb-3">
|
|
||||||
{session.category && (
|
|
||||||
<Badge variant="secondary" className="gap-1">
|
|
||||||
<Filter className="h-3 w-3" aria-hidden="true" />
|
|
||||||
{formatCategory(session.category)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{session.language && (
|
|
||||||
<Badge variant="outline" className="gap-1">
|
|
||||||
<Globe className="h-3 w-3" aria-hidden="true" />
|
|
||||||
{session.language.toUpperCase()}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{session.summary ? (
|
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
|
||||||
{session.summary}
|
|
||||||
</p>
|
|
||||||
) : session.initialMsg ? (
|
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
|
||||||
{session.initialMsg}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</article>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PaginationProps {
|
|
||||||
currentPage: number;
|
|
||||||
totalPages: number;
|
|
||||||
setCurrentPage: (page: number | ((prev: number) => number)) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Pagination({
|
|
||||||
currentPage,
|
|
||||||
totalPages,
|
|
||||||
setCurrentPage,
|
|
||||||
}: PaginationProps) {
|
|
||||||
if (totalPages === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex justify-center items-center gap-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Page {currentPage} of {totalPages}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
|
|
||||||
}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SessionsPage() {
|
export default function SessionsPage() {
|
||||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
const searchHeadingId = useId();
|
// Filter states
|
||||||
const searchId = useId();
|
|
||||||
const filtersHeadingId = useId();
|
|
||||||
const filterContentId = useId();
|
|
||||||
const categoryFilterId = useId();
|
|
||||||
const categoryHelpId = useId();
|
|
||||||
const languageFilterId = useId();
|
|
||||||
const languageHelpId = useId();
|
|
||||||
const startDateId = useId();
|
|
||||||
const endDateId = useId();
|
|
||||||
const sortById = useId();
|
|
||||||
const sortOrderId = useId();
|
|
||||||
const sortOrderHelpId = useId();
|
|
||||||
const resultsHeadingId = useId();
|
|
||||||
|
|
||||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState("");
|
|
||||||
const [selectedLanguage, setSelectedLanguage] = useState("");
|
|
||||||
const [startDate, setStartDate] = useState("");
|
|
||||||
const [endDate, setEndDate] = useState("");
|
|
||||||
const [sortKey, setSortKey] = useState("startTime");
|
|
||||||
const [sortOrder, setSortOrder] = useState("desc");
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [totalPages, setTotalPages] = useState(0);
|
|
||||||
const [pageSize] = useState(10);
|
|
||||||
const [filtersExpanded, setFiltersExpanded] = useState(false);
|
|
||||||
|
|
||||||
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
|
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
|
||||||
categories: [],
|
categories: [],
|
||||||
languages: [],
|
languages: [],
|
||||||
});
|
});
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string>("");
|
||||||
|
const [selectedLanguage, setSelectedLanguage] = useState<string>("");
|
||||||
|
const [startDate, setStartDate] = useState<string>("");
|
||||||
|
const [endDate, setEndDate] = useState<string>("");
|
||||||
|
|
||||||
|
// Sort states
|
||||||
|
const [sortKey, setSortKey] = useState<string>("startTime"); // Default sort key
|
||||||
|
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); // Default sort order
|
||||||
|
|
||||||
|
// Debounce search term to avoid excessive API calls
|
||||||
|
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm);
|
||||||
|
|
||||||
|
// Pagination states
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||||
|
const [pageSize, setPageSize] = useState(10); // Or make this configurable
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timerId = setTimeout(() => {
|
const timerId = setTimeout(() => {
|
||||||
setDebouncedSearchTerm(searchTerm);
|
setDebouncedSearchTerm(searchTerm);
|
||||||
}, 500);
|
}, 500); // 500ms delay
|
||||||
return () => clearTimeout(timerId);
|
return () => {
|
||||||
|
clearTimeout(timerId);
|
||||||
|
};
|
||||||
}, [searchTerm]);
|
}, [searchTerm]);
|
||||||
|
|
||||||
// TODO: Implement getSessionFilterOptions in tRPC dashboard router
|
const fetchFilterOptions = useCallback(async () => {
|
||||||
// For now, we'll set default filter options
|
try {
|
||||||
useEffect(() => {
|
const response = await fetch("/api/dashboard/session-filter-options");
|
||||||
setFilterOptions({
|
if (!response.ok) {
|
||||||
categories: [
|
throw new Error("Failed to fetch filter options");
|
||||||
"SCHEDULE_HOURS",
|
}
|
||||||
"LEAVE_VACATION",
|
const data = (await response.json()) as FilterOptions;
|
||||||
"SICK_LEAVE_RECOVERY",
|
setFilterOptions(data);
|
||||||
"SALARY_COMPENSATION",
|
} catch (err) {
|
||||||
],
|
setError(
|
||||||
languages: ["en", "nl", "de", "fr", "es"],
|
err instanceof Error ? err.message : "Failed to load filter options"
|
||||||
});
|
);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// tRPC query for sessions
|
const fetchSessions = useCallback(async () => {
|
||||||
const {
|
setLoading(true);
|
||||||
data: sessionsData,
|
|
||||||
isLoading,
|
|
||||||
error: sessionsError,
|
|
||||||
} = trpc.dashboard.getSessions.useQuery(
|
|
||||||
{
|
|
||||||
search: debouncedSearchTerm || undefined,
|
|
||||||
category: selectedCategory
|
|
||||||
? (selectedCategory as z.infer<typeof sessionFilterSchema>["category"])
|
|
||||||
: undefined,
|
|
||||||
language: selectedLanguage || undefined,
|
|
||||||
startDate: startDate || undefined,
|
|
||||||
endDate: endDate || undefined,
|
|
||||||
sortKey: sortKey || undefined,
|
|
||||||
sortOrder: sortOrder || undefined,
|
|
||||||
page: currentPage,
|
|
||||||
limit: pageSize,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Enable the query by default
|
|
||||||
enabled: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update state when data changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (sessionsData) {
|
|
||||||
setSessions(sessionsData.sessions || []);
|
|
||||||
setTotalPages(sessionsData.pagination.totalPages);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (debouncedSearchTerm) params.append("searchTerm", debouncedSearchTerm);
|
||||||
|
if (selectedCategory) params.append("category", selectedCategory);
|
||||||
|
if (selectedLanguage) params.append("language", selectedLanguage);
|
||||||
|
if (startDate) params.append("startDate", startDate);
|
||||||
|
if (endDate) params.append("endDate", endDate);
|
||||||
|
if (sortKey) params.append("sortKey", sortKey);
|
||||||
|
if (sortOrder) params.append("sortOrder", sortOrder);
|
||||||
|
params.append("page", currentPage.toString());
|
||||||
|
params.append("pageSize", pageSize.toString());
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/dashboard/sessions?${params.toString()}`
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch sessions: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
}, [sessionsData]);
|
const data = (await response.json()) as SessionsApiResponse;
|
||||||
|
setSessions(data.sessions || []);
|
||||||
|
setTotalPages(Math.ceil((data.totalSessions || 0) / pageSize));
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : "An unknown error occurred"
|
||||||
|
);
|
||||||
|
setSessions([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
debouncedSearchTerm,
|
||||||
|
selectedCategory,
|
||||||
|
selectedLanguage,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
sortKey,
|
||||||
|
sortOrder,
|
||||||
|
currentPage,
|
||||||
|
pageSize,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionsError) {
|
fetchSessions();
|
||||||
setError(sessionsError.message || "An unknown error occurred");
|
}, [fetchSessions]);
|
||||||
setSessions([]);
|
|
||||||
}
|
|
||||||
}, [sessionsError]);
|
|
||||||
|
|
||||||
// tRPC queries handle data fetching automatically
|
useEffect(() => {
|
||||||
|
fetchFilterOptions();
|
||||||
|
}, [fetchFilterOptions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="p-4 md:p-6">
|
||||||
<h1 className="sr-only">Sessions Management</h1>
|
<h1 className="text-2xl font-semibold text-gray-800 mb-6">
|
||||||
|
Chat Sessions
|
||||||
|
</h1>
|
||||||
|
|
||||||
<Card>
|
{/* Search Input */}
|
||||||
<CardHeader>
|
<div className="mb-4">
|
||||||
<div className="flex items-center gap-3">
|
<input
|
||||||
<MessageSquare className="h-6 w-6" />
|
type="text"
|
||||||
<CardTitle>Chat Sessions</CardTitle>
|
placeholder="Search sessions (ID, category, initial message...)"
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-sky-500 focus:border-sky-500"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<FilterSection
|
{/* Filter and Sort Controls */}
|
||||||
filtersExpanded={filtersExpanded}
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6 p-4 bg-gray-50 rounded-lg shadow">
|
||||||
setFiltersExpanded={setFiltersExpanded}
|
{/* Category Filter */}
|
||||||
searchTerm={searchTerm}
|
<div>
|
||||||
setSearchTerm={setSearchTerm}
|
<label
|
||||||
selectedCategory={selectedCategory}
|
htmlFor="category-filter"
|
||||||
setSelectedCategory={setSelectedCategory}
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
selectedLanguage={selectedLanguage}
|
>
|
||||||
setSelectedLanguage={setSelectedLanguage}
|
Category
|
||||||
startDate={startDate}
|
</label>
|
||||||
setStartDate={setStartDate}
|
<select
|
||||||
endDate={endDate}
|
id="category-filter"
|
||||||
setEndDate={setEndDate}
|
className="w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-sky-500 focus:border-sky-500"
|
||||||
sortKey={sortKey}
|
value={selectedCategory}
|
||||||
setSortKey={setSortKey}
|
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||||
sortOrder={sortOrder}
|
>
|
||||||
setSortOrder={setSortOrder}
|
<option value="">All Categories</option>
|
||||||
filterOptions={filterOptions}
|
{filterOptions.categories.map((cat) => (
|
||||||
searchHeadingId={searchHeadingId}
|
<option key={cat} value={cat}>
|
||||||
searchId={searchId}
|
{cat}
|
||||||
filtersHeadingId={filtersHeadingId}
|
</option>
|
||||||
filterContentId={filterContentId}
|
))}
|
||||||
categoryFilterId={categoryFilterId}
|
</select>
|
||||||
categoryHelpId={categoryHelpId}
|
</div>
|
||||||
languageFilterId={languageFilterId}
|
|
||||||
languageHelpId={languageHelpId}
|
|
||||||
startDateId={startDateId}
|
|
||||||
endDateId={endDateId}
|
|
||||||
sortById={sortById}
|
|
||||||
sortOrderId={sortOrderId}
|
|
||||||
sortOrderHelpId={sortOrderHelpId}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SessionList
|
{/* Language Filter */}
|
||||||
sessions={sessions}
|
<div>
|
||||||
loading={isLoading}
|
<label
|
||||||
error={error}
|
htmlFor="language-filter"
|
||||||
resultsHeadingId={resultsHeadingId}
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
/>
|
>
|
||||||
|
Language
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="language-filter"
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-sky-500 focus:border-sky-500"
|
||||||
|
value={selectedLanguage}
|
||||||
|
onChange={(e) => setSelectedLanguage(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All Languages</option>
|
||||||
|
{filterOptions.languages.map((lang) => (
|
||||||
|
<option key={lang} value={lang}>
|
||||||
|
{lang.toUpperCase()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Pagination
|
{/* Start Date Filter */}
|
||||||
currentPage={currentPage}
|
<div>
|
||||||
totalPages={totalPages}
|
<label
|
||||||
setCurrentPage={setCurrentPage}
|
htmlFor="start-date-filter"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Start Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="start-date-filter"
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-sky-500 focus:border-sky-500"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* End Date Filter */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="end-date-filter"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
End Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="end-date-filter"
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-sky-500 focus:border-sky-500"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort Key */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="sort-key"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Sort By
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="sort-key"
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-sky-500 focus:border-sky-500"
|
||||||
|
value={sortKey}
|
||||||
|
onChange={(e) => setSortKey(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="startTime">Start Time</option>
|
||||||
|
<option value="category">Category</option>
|
||||||
|
<option value="language">Language</option>
|
||||||
|
<option value="sentiment">Sentiment</option>
|
||||||
|
<option value="messagesSent">Messages Sent</option>
|
||||||
|
<option value="avgResponseTime">Avg. Response Time</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort Order */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="sort-order"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Order
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="sort-order"
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-sky-500 focus:border-sky-500"
|
||||||
|
value={sortOrder}
|
||||||
|
onChange={(e) => setSortOrder(e.target.value as "asc" | "desc")}
|
||||||
|
>
|
||||||
|
<option value="desc">Descending</option>
|
||||||
|
<option value="asc">Ascending</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <p className="text-gray-600">Loading sessions...</p>}
|
||||||
|
{error && <p className="text-red-500">Error: {error}</p>}
|
||||||
|
|
||||||
|
{!loading && !error && sessions.length === 0 && (
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{debouncedSearchTerm
|
||||||
|
? `No sessions found for "${debouncedSearchTerm}".`
|
||||||
|
: "No sessions found."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && sessions.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
className="bg-white p-4 rounded-lg shadow hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold text-sky-700 mb-1">
|
||||||
|
Session ID: {session.sessionId || session.id}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mb-1">
|
||||||
|
Start Time{/* (Local) */}:{" "}
|
||||||
|
{new Date(session.startTime).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
{/* <p className="text-xs text-gray-400 mb-1">
|
||||||
|
Start Time (Raw API): {session.startTime.toString()}
|
||||||
|
</p> */}
|
||||||
|
{session.category && (
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
Category:{" "}
|
||||||
|
<span className="font-medium">{session.category}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{session.language && (
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
Language:{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{session.language.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{session.initialMsg && (
|
||||||
|
<p className="text-sm text-gray-600 mt-1 truncate">
|
||||||
|
Initial Message: {session.initialMsg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/sessions/${session.id}`}
|
||||||
|
className="mt-2 text-sm text-sky-600 hover:text-sky-800 hover:underline inline-block"
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalPages > 0 && (
|
||||||
|
<div className="mt-6 flex justify-center items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-700">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
|
||||||
|
}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import type { Session } from "next-auth";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { Company } from "../../lib/types";
|
import { Company } from "../../lib/types";
|
||||||
|
import { Session } from "next-auth";
|
||||||
|
|
||||||
interface DashboardSettingsProps {
|
interface DashboardSettingsProps {
|
||||||
company: Company;
|
company: Company;
|
||||||
@ -37,7 +37,7 @@ export default function DashboardSettings({
|
|||||||
else setMessage("Failed.");
|
else setMessage("Failed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.user.role !== "ADMIN") return null;
|
if (session.user.role !== "admin") return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-6 rounded-xl shadow mb-6">
|
<div className="bg-white p-6 rounded-xl shadow mb-6">
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import type { UserSession } from "../../lib/types";
|
import { UserSession } from "../../lib/types";
|
||||||
|
|
||||||
interface UserItem {
|
interface UserItem {
|
||||||
id: string;
|
id: string;
|
||||||
@ -12,6 +12,10 @@ interface UserManagementProps {
|
|||||||
session: UserSession;
|
session: UserSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UsersApiResponse {
|
||||||
|
users: UserItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export default function UserManagement({ session }: UserManagementProps) {
|
export default function UserManagement({ session }: UserManagementProps) {
|
||||||
const [users, setUsers] = useState<UserItem[]>([]);
|
const [users, setUsers] = useState<UserItem[]>([]);
|
||||||
const [email, setEmail] = useState<string>("");
|
const [email, setEmail] = useState<string>("");
|
||||||
@ -21,7 +25,7 @@ export default function UserManagement({ session }: UserManagementProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/dashboard/users")
|
fetch("/api/dashboard/users")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => setUsers(data.users));
|
.then((data) => setUsers((data as UsersApiResponse).users));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function inviteUser() {
|
async function inviteUser() {
|
||||||
@ -34,7 +38,7 @@ export default function UserManagement({ session }: UserManagementProps) {
|
|||||||
else setMsg("Failed.");
|
else setMsg("Failed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.user.role !== "ADMIN") return null;
|
if (session.user.role !== "admin") return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-6 rounded-xl shadow mb-6">
|
<div className="bg-white p-6 rounded-xl shadow mb-6">
|
||||||
@ -52,11 +56,10 @@ export default function UserManagement({ session }: UserManagementProps) {
|
|||||||
onChange={(e) => setRole(e.target.value)}
|
onChange={(e) => setRole(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="user">User</option>
|
<option value="user">User</option>
|
||||||
<option value="ADMIN">Admin</option>
|
<option value="admin">Admin</option>
|
||||||
<option value="AUDITOR">Auditor</option>
|
<option value="auditor">Auditor</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
className="bg-blue-600 text-white rounded px-4 py-2 sm:py-0 w-full sm:w-auto"
|
className="bg-blue-600 text-white rounded px-4 py-2 sm:py-0 w-full sm:w-auto"
|
||||||
onClick={inviteUser}
|
onClick={inviteUser}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,29 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AlertCircle, Eye, Shield, UserPlus, Users } from "lucide-react";
|
import { useState, useEffect } from "react";
|
||||||
import { useSession } from "next-auth/react";
|
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 { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
|
|
||||||
interface UserItem {
|
interface UserItem {
|
||||||
id: string;
|
id: string;
|
||||||
@ -31,20 +9,29 @@ interface UserItem {
|
|||||||
role: string;
|
role: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UsersApiResponse {
|
||||||
|
users: UserItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export default function UserManagementPage() {
|
export default function UserManagementPage() {
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
const [users, setUsers] = useState<UserItem[]>([]);
|
const [users, setUsers] = useState<UserItem[]>([]);
|
||||||
const [email, setEmail] = useState<string>("");
|
const [email, setEmail] = useState<string>("");
|
||||||
const [role, setRole] = useState<string>("USER");
|
const [role, setRole] = useState<string>("user");
|
||||||
const [message, setMessage] = useState<string>("");
|
const [message, setMessage] = useState<string>("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const emailId = useId();
|
|
||||||
|
|
||||||
const fetchUsers = useCallback(async () => {
|
useEffect(() => {
|
||||||
|
if (status === "authenticated") {
|
||||||
|
fetchUsers();
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/dashboard/users");
|
const res = await fetch("/api/dashboard/users");
|
||||||
const data = await res.json();
|
const data = (await res.json()) as UsersApiResponse;
|
||||||
setUsers(data.users);
|
setUsers(data.users);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch users:", error);
|
console.error("Failed to fetch users:", error);
|
||||||
@ -52,19 +39,7 @@ export default function UserManagementPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (status === "authenticated") {
|
|
||||||
if (session?.user?.role === "ADMIN") {
|
|
||||||
fetchUsers();
|
|
||||||
} else {
|
|
||||||
setLoading(false); // Stop loading for non-admin users
|
|
||||||
}
|
|
||||||
} else if (status === "unauthenticated") {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [status, session?.user?.role, fetchUsers]);
|
|
||||||
|
|
||||||
async function inviteUser() {
|
async function inviteUser() {
|
||||||
setMessage("");
|
setMessage("");
|
||||||
@ -81,7 +56,7 @@ export default function UserManagementPage() {
|
|||||||
// Refresh the user list
|
// Refresh the user list
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} else {
|
} else {
|
||||||
const error = await res.json();
|
const error = (await res.json()) as { message?: string; };
|
||||||
setMessage(
|
setMessage(
|
||||||
`Failed to invite user: ${error.message || "Unknown error"}`
|
`Failed to invite user: ${error.message || "Unknown error"}`
|
||||||
);
|
);
|
||||||
@ -94,180 +69,148 @@ export default function UserManagementPage() {
|
|||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <div className="text-center py-10">Loading users...</div>;
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
Loading users...
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for admin access
|
// Check for admin access
|
||||||
if (session?.user?.role !== "ADMIN") {
|
if (session?.user?.role !== "admin") {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="text-center py-10 bg-white rounded-xl shadow p-6">
|
||||||
<Card>
|
<h2 className="font-bold text-xl text-red-600 mb-2">Access Denied</h2>
|
||||||
<CardContent className="pt-6">
|
<p>You don't have permission to view user management.</p>
|
||||||
<div className="text-center py-8">
|
|
||||||
<AlertCircle className="h-12 w-12 text-destructive mx-auto mb-4" />
|
|
||||||
<h2 className="font-bold text-xl text-destructive mb-2">
|
|
||||||
Access Denied
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
You don't have permission to view user management.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6" data-testid="user-management-page">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
<Card>
|
<h1 className="text-2xl font-bold text-gray-800 mb-6">
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Users className="h-6 w-6" />
|
|
||||||
User Management
|
User Management
|
||||||
</CardTitle>
|
</h1>
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Message Alert */}
|
|
||||||
{message && (
|
{message && (
|
||||||
<Alert variant={message.includes("Failed") ? "destructive" : "default"}>
|
<div
|
||||||
<AlertDescription>{message}</AlertDescription>
|
className={`p-4 rounded mb-6 ${message.includes("Failed") ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700"}`}
|
||||||
</Alert>
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Invite New User */}
|
<div className="mb-8">
|
||||||
<Card>
|
<h2 className="text-lg font-semibold mb-4">Invite New User</h2>
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<UserPlus className="h-5 w-5" />
|
|
||||||
Invite New User
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form
|
<form
|
||||||
className="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end"
|
className="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
inviteUser();
|
inviteUser();
|
||||||
}}
|
}}
|
||||||
autoComplete="off"
|
autoComplete="off" // Disable autofill for the form
|
||||||
data-testid="invite-form"
|
|
||||||
>
|
>
|
||||||
<div className="space-y-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor={emailId}>Email</Label>
|
<label className="font-medium text-gray-700">Email</label>
|
||||||
<Input
|
<input
|
||||||
id={emailId}
|
|
||||||
type="email"
|
type="email"
|
||||||
|
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||||
placeholder="user@example.com"
|
placeholder="user@example.com"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
autoComplete="off"
|
autoComplete="off" // Disable autofill for this input
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="role">Role</Label>
|
<label className="font-medium text-gray-700">Role</label>
|
||||||
<Select value={role} onValueChange={setRole}>
|
<select
|
||||||
<SelectTrigger>
|
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500 bg-white"
|
||||||
<SelectValue placeholder="Select role" />
|
value={role}
|
||||||
</SelectTrigger>
|
onChange={(e) => setRole(e.target.value)}
|
||||||
<SelectContent>
|
>
|
||||||
<SelectItem value="USER">User</SelectItem>
|
<option value="user">User</option>
|
||||||
<SelectItem value="ADMIN">Admin</SelectItem>
|
<option value="admin">Admin</option>
|
||||||
<SelectItem value="AUDITOR">Auditor</SelectItem>
|
<option value="auditor">Auditor</option>
|
||||||
</SelectContent>
|
</select>
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="gap-2">
|
<button
|
||||||
<UserPlus className="h-4 w-4" />
|
type="submit"
|
||||||
|
className="bg-sky-600 hover:bg-sky-700 text-white py-2 px-4 rounded-lg shadow transition-colors"
|
||||||
|
>
|
||||||
Invite User
|
Invite User
|
||||||
</Button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Current Users */}
|
<div>
|
||||||
<Card>
|
<h2 className="text-lg font-semibold mb-4">Current Users</h2>
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Users className="h-5 w-5" />
|
|
||||||
Current Users ({users?.length || 0})
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<TableHeader>
|
<thead className="bg-gray-50">
|
||||||
<TableRow>
|
<tr>
|
||||||
<TableHead>Email</TableHead>
|
<th
|
||||||
<TableHead>Role</TableHead>
|
scope="col"
|
||||||
<TableHead>Actions</TableHead>
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
</TableRow>
|
>
|
||||||
</TableHeader>
|
Email
|
||||||
<TableBody>
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Role
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{users.length === 0 ? (
|
{users.length === 0 ? (
|
||||||
<TableRow>
|
<tr>
|
||||||
<TableCell
|
<td
|
||||||
colSpan={3}
|
colSpan={3}
|
||||||
className="text-center text-muted-foreground"
|
className="px-6 py-4 text-center text-sm text-gray-500"
|
||||||
>
|
>
|
||||||
No users found
|
No users found
|
||||||
</TableCell>
|
</td>
|
||||||
</TableRow>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
users.map((user) => (
|
users.map((user) => (
|
||||||
<TableRow key={user.id}>
|
<tr key={user.id}>
|
||||||
<TableCell className="font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
{user.email}
|
{user.email}
|
||||||
</TableCell>
|
</td>
|
||||||
<TableCell>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
<Badge
|
<span
|
||||||
variant={
|
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||||
user.role === "ADMIN"
|
user.role === "admin"
|
||||||
? "default"
|
? "bg-purple-100 text-purple-800"
|
||||||
: user.role === "AUDITOR"
|
: user.role === "auditor"
|
||||||
? "secondary"
|
? "bg-blue-100 text-blue-800"
|
||||||
: "outline"
|
: "bg-green-100 text-green-800"
|
||||||
}
|
}`}
|
||||||
className="gap-1"
|
|
||||||
data-testid="role-badge"
|
|
||||||
>
|
>
|
||||||
{user.role === "ADMIN" && (
|
|
||||||
<Shield className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
{user.role === "AUDITOR" && (
|
|
||||||
<Eye className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
{user.role}
|
{user.role}
|
||||||
</Badge>
|
</span>
|
||||||
</TableCell>
|
</td>
|
||||||
<TableCell>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
<span className="text-muted-foreground text-sm">
|
{/* For future: Add actions like edit, delete, etc. */}
|
||||||
|
<span className="text-gray-400">
|
||||||
No actions available
|
No actions available
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</td>
|
||||||
</TableRow>
|
</tr>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</tbody>
|
||||||
</Table>
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
198
app/globals.css
198
app/globals.css
@ -1,199 +1 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--color-card: var(--card);
|
|
||||||
--color-card-foreground: var(--card-foreground);
|
|
||||||
--color-popover: var(--popover);
|
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
|
||||||
--color-primary: var(--primary);
|
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
|
||||||
--color-secondary: var(--secondary);
|
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
|
||||||
--color-muted: var(--muted);
|
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
|
||||||
--color-accent: var(--accent);
|
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
|
||||||
--color-destructive: var(--destructive);
|
|
||||||
--color-border: var(--border);
|
|
||||||
--color-input: var(--input);
|
|
||||||
--color-ring: var(--ring);
|
|
||||||
--color-chart-1: var(--chart-1);
|
|
||||||
--color-chart-2: var(--chart-2);
|
|
||||||
--color-chart-3: var(--chart-3);
|
|
||||||
--color-chart-4: var(--chart-4);
|
|
||||||
--color-chart-5: var(--chart-5);
|
|
||||||
--color-sidebar: var(--sidebar);
|
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
|
||||||
--animate-shine: shine var(--duration) infinite linear;
|
|
||||||
@keyframes shine {
|
|
||||||
0% {
|
|
||||||
background-position: 0% 0%;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
background-position: 100% 100%;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
background-position: 0% 0%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
--animate-meteor: meteor 5s linear infinite;
|
|
||||||
@keyframes meteor {
|
|
||||||
0% {
|
|
||||||
transform: rotate(var(--angle)) translateX(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
70% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: rotate(var(--angle)) translateX(-500px);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
--animate-background-position-spin: background-position-spin 3000ms infinite
|
|
||||||
alternate;
|
|
||||||
@keyframes background-position-spin {
|
|
||||||
0% {
|
|
||||||
background-position: top center;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: bottom center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
--animate-aurora: aurora 8s ease-in-out infinite alternate;
|
|
||||||
@keyframes aurora {
|
|
||||||
0% {
|
|
||||||
background-position: 0% 50%;
|
|
||||||
transform: rotate(-5deg) scale(0.9);
|
|
||||||
}
|
|
||||||
25% {
|
|
||||||
background-position: 50% 100%;
|
|
||||||
transform: rotate(5deg) scale(1.1);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
background-position: 100% 50%;
|
|
||||||
transform: rotate(-3deg) scale(0.95);
|
|
||||||
}
|
|
||||||
75% {
|
|
||||||
background-position: 50% 0%;
|
|
||||||
transform: rotate(3deg) scale(1.05);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: 0% 50%;
|
|
||||||
transform: rotate(-5deg) scale(0.9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
--animate-shiny-text: shiny-text 8s infinite;
|
|
||||||
@keyframes shiny-text {
|
|
||||||
0%,
|
|
||||||
90%,
|
|
||||||
100% {
|
|
||||||
background-position: calc(-100% - var(--shiny-width)) 0;
|
|
||||||
}
|
|
||||||
30%,
|
|
||||||
60% {
|
|
||||||
background-position: calc(100% + var(--shiny-width)) 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--radius: 0.625rem;
|
|
||||||
--background: oklch(1 0 0);
|
|
||||||
--foreground: oklch(0.145 0 0);
|
|
||||||
--card: oklch(1 0 0);
|
|
||||||
--card-foreground: oklch(0.145 0 0);
|
|
||||||
--popover: oklch(1 0 0);
|
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
|
||||||
--primary: oklch(0.5 0.2 240);
|
|
||||||
--primary-foreground: oklch(0.98 0 0);
|
|
||||||
--secondary: oklch(0.97 0 0);
|
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
|
||||||
--muted: oklch(0.97 0 0);
|
|
||||||
--muted-foreground: oklch(0.45 0 0);
|
|
||||||
--accent: oklch(0.97 0 0);
|
|
||||||
--accent-foreground: oklch(0.15 0 0);
|
|
||||||
--destructive: oklch(0.55 0.245 27.325);
|
|
||||||
--border: oklch(0.85 0 0);
|
|
||||||
--input: oklch(0.85 0 0);
|
|
||||||
--ring: oklch(0.6 0 0);
|
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
|
||||||
--sidebar: oklch(0.985 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: oklch(0.145 0 0);
|
|
||||||
--foreground: oklch(0.985 0 0);
|
|
||||||
--card: oklch(0.205 0 0);
|
|
||||||
--card-foreground: oklch(0.985 0 0);
|
|
||||||
--popover: oklch(0.205 0 0);
|
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
|
||||||
--primary: oklch(0.7 0.2 240);
|
|
||||||
--primary-foreground: oklch(0.15 0 0);
|
|
||||||
--secondary: oklch(0.269 0 0);
|
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
|
||||||
--muted: oklch(0.269 0 0);
|
|
||||||
--muted-foreground: oklch(0.75 0 0);
|
|
||||||
--accent: oklch(0.269 0 0);
|
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
|
||||||
--destructive: oklch(0.75 0.191 22.216);
|
|
||||||
--border: oklch(1 0 0 / 25%);
|
|
||||||
--input: oklch(1 0 0 / 30%);
|
|
||||||
--ring: oklch(0.75 0 0);
|
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
|
||||||
--sidebar: oklch(0.205 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
@apply border-border outline-ring/50 selection:bg-primary/30 selection:text-primary-foreground;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Line clamp utility */
|
|
||||||
.line-clamp-2 {
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
141
app/layout.tsx
141
app/layout.tsx
@ -1,81 +1,12 @@
|
|||||||
// Main app layout with basic global style
|
// Main app layout with basic global style
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import type { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
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 = {
|
||||||
title: "LiveDash - AI-Powered Customer Conversation Analytics",
|
title: "LiveDash-Node",
|
||||||
description:
|
description:
|
||||||
"Transform customer conversations into actionable insights with advanced AI sentiment analysis, automated categorization, and real-time analytics. Turn every conversation into competitive intelligence.",
|
"Multi-tenant dashboard system for tracking chat session metrics",
|
||||||
keywords: [
|
|
||||||
"customer analytics",
|
|
||||||
"AI sentiment analysis",
|
|
||||||
"conversation intelligence",
|
|
||||||
"customer support analytics",
|
|
||||||
"chat analytics",
|
|
||||||
"customer insights",
|
|
||||||
"conversation analytics",
|
|
||||||
"customer experience analytics",
|
|
||||||
"sentiment tracking",
|
|
||||||
"AI customer intelligence",
|
|
||||||
"automated categorization",
|
|
||||||
"real-time analytics",
|
|
||||||
"customer conversation dashboard",
|
|
||||||
],
|
|
||||||
authors: [{ name: "Notso AI" }],
|
|
||||||
creator: "Notso AI",
|
|
||||||
publisher: "Notso AI",
|
|
||||||
formatDetection: {
|
|
||||||
email: false,
|
|
||||||
address: false,
|
|
||||||
telephone: false,
|
|
||||||
},
|
|
||||||
metadataBase: new URL(
|
|
||||||
process.env.NEXTAUTH_URL || "https://livedash.notso.ai"
|
|
||||||
),
|
|
||||||
alternates: {
|
|
||||||
canonical: "/",
|
|
||||||
},
|
|
||||||
openGraph: {
|
|
||||||
title: "LiveDash - AI-Powered Customer Conversation Analytics",
|
|
||||||
description:
|
|
||||||
"Transform customer conversations into actionable insights with advanced AI sentiment analysis, automated categorization, and real-time analytics. Turn every conversation into competitive intelligence.",
|
|
||||||
type: "website",
|
|
||||||
siteName: "LiveDash",
|
|
||||||
url: "/",
|
|
||||||
locale: "en_US",
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: "/og-image.png",
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
alt: "LiveDash - AI-Powered Customer Conversation Analytics Platform",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
twitter: {
|
|
||||||
card: "summary_large_image",
|
|
||||||
title: "LiveDash - AI-Powered Customer Conversation Analytics",
|
|
||||||
description:
|
|
||||||
"Transform customer conversations into actionable insights with advanced AI sentiment analysis, automated categorization, and real-time analytics.",
|
|
||||||
creator: "@notsoai",
|
|
||||||
site: "@notsoai",
|
|
||||||
images: ["/og-image.png"],
|
|
||||||
},
|
|
||||||
robots: {
|
|
||||||
index: true,
|
|
||||||
follow: true,
|
|
||||||
googleBot: {
|
|
||||||
index: true,
|
|
||||||
follow: true,
|
|
||||||
"max-video-preview": -1,
|
|
||||||
"max-image-preview": "large",
|
|
||||||
"max-snippet": -1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
icons: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
{ url: "/favicon.ico", sizes: "32x32", type: "image/x-icon" },
|
{ url: "/favicon.ico", sizes: "32x32", type: "image/x-icon" },
|
||||||
@ -84,73 +15,13 @@ export const metadata = {
|
|||||||
apple: "/icon-192.svg",
|
apple: "/icon-192.svg",
|
||||||
},
|
},
|
||||||
manifest: "/manifest.json",
|
manifest: "/manifest.json",
|
||||||
other: {
|
|
||||||
"msapplication-TileColor": "#2563eb",
|
|
||||||
"theme-color": "#ffffff",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
const nonce = await getNonce();
|
|
||||||
|
|
||||||
const jsonLd = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "SoftwareApplication",
|
|
||||||
name: "LiveDash",
|
|
||||||
description:
|
|
||||||
"Transform customer conversations into actionable insights with advanced AI sentiment analysis, automated categorization, and real-time analytics.",
|
|
||||||
url: process.env.NEXTAUTH_URL || "https://livedash.notso.ai",
|
|
||||||
author: {
|
|
||||||
"@type": "Organization",
|
|
||||||
name: "Notso AI",
|
|
||||||
},
|
|
||||||
applicationCategory: "Business Analytics Software",
|
|
||||||
operatingSystem: "Web Browser",
|
|
||||||
offers: {
|
|
||||||
"@type": "Offer",
|
|
||||||
category: "SaaS",
|
|
||||||
},
|
|
||||||
aggregateRating: {
|
|
||||||
"@type": "AggregateRating",
|
|
||||||
ratingValue: "4.8",
|
|
||||||
ratingCount: "150",
|
|
||||||
},
|
|
||||||
featureList: [
|
|
||||||
"AI-powered sentiment analysis",
|
|
||||||
"Automated conversation categorization",
|
|
||||||
"Real-time analytics dashboard",
|
|
||||||
"Multi-language support",
|
|
||||||
"Custom AI model integration",
|
|
||||||
"Enterprise-grade security",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en">
|
||||||
<head>
|
<body className="bg-gray-100 min-h-screen font-sans">
|
||||||
<script
|
|
||||||
type="application/ld+json"
|
|
||||||
nonce={nonce}
|
|
||||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: Safe use for JSON-LD structured data with CSP nonce
|
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body className="bg-background text-foreground min-h-screen font-sans antialiased">
|
|
||||||
{/* Skip navigation link for keyboard users */}
|
|
||||||
<a
|
|
||||||
href="#main-content"
|
|
||||||
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
>
|
|
||||||
Skip to main content
|
|
||||||
</a>
|
|
||||||
<NonceProvider nonce={nonce}>
|
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
</NonceProvider>
|
|
||||||
<Toaster />
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,270 +1,59 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { BarChart3, Loader2, Shield, Zap } from "lucide-react";
|
import { useState } from "react";
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { useId, useState } from "react";
|
import { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
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 { ThemeToggle } from "@/components/ui/theme-toggle";
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const emailId = useId();
|
|
||||||
const emailHelpId = useId();
|
|
||||||
const passwordId = useId();
|
|
||||||
const passwordHelpId = useId();
|
|
||||||
const loadingStatusId = useId();
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
async function handleLogin(e: React.FormEvent) {
|
async function handleLogin(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
|
||||||
setError("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await signIn("credentials", {
|
const res = await signIn("credentials", {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
redirect: false,
|
redirect: false,
|
||||||
});
|
});
|
||||||
|
if (res?.ok) router.push("/dashboard");
|
||||||
if (res?.ok) {
|
else setError("Invalid credentials.");
|
||||||
toast.success("Login successful! Redirecting...");
|
|
||||||
router.push("/dashboard");
|
|
||||||
} else {
|
|
||||||
setError("Invalid email or password. Please try again.");
|
|
||||||
toast.error("Login failed. Please check your credentials.");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setError("An error occurred. Please try again.");
|
|
||||||
toast.error("An unexpected error occurred.");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex">
|
<div className="max-w-md mx-auto mt-24 bg-white rounded-xl p-8 shadow">
|
||||||
{/* Left side - Branding and Features */}
|
<h1 className="text-2xl font-bold mb-6">Login</h1>
|
||||||
<div className="hidden lg:flex lg:flex-1 bg-linear-to-br from-primary/10 via-primary/5 to-background relative overflow-hidden">
|
{error && <div className="text-red-600 mb-3">{error}</div>}
|
||||||
<div className="absolute inset-0 bg-linear-to-br from-primary/5 to-transparent" />
|
<form onSubmit={handleLogin} className="flex flex-col gap-4">
|
||||||
<div className="absolute -top-24 -left-24 h-96 w-96 rounded-full bg-primary/10 blur-3xl" />
|
<input
|
||||||
<div className="absolute -bottom-24 -right-24 h-96 w-96 rounded-full bg-primary/5 blur-3xl" />
|
className="border px-3 py-2 rounded"
|
||||||
|
|
||||||
<div className="relative flex flex-col justify-center px-12 py-24">
|
|
||||||
<div className="max-w-md">
|
|
||||||
<Link href="/" className="flex items-center gap-3 mb-8">
|
|
||||||
<div className="relative w-12 h-12">
|
|
||||||
<Image
|
|
||||||
src="/favicon.svg"
|
|
||||||
alt="LiveDash Logo"
|
|
||||||
fill
|
|
||||||
className="object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl font-bold text-primary">LiveDash</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<h1 className="text-4xl font-bold tracking-tight mb-6">
|
|
||||||
Welcome back to your analytics dashboard
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-muted-foreground mb-8">
|
|
||||||
Monitor, analyze, and optimize your customer conversations with
|
|
||||||
AI-powered insights.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2 rounded-lg bg-primary/10 text-primary">
|
|
||||||
<BarChart3 className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
Real-time analytics and insights
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2 rounded-lg bg-green-500/10 text-green-600">
|
|
||||||
<Shield className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
Enterprise-grade security
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-600">
|
|
||||||
<Zap className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
AI-powered conversation analysis
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right side - Login Form */}
|
|
||||||
<div className="flex-1 flex flex-col justify-center px-8 py-12 lg:px-12">
|
|
||||||
<div className="absolute top-4 right-4">
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mx-auto w-full max-w-sm">
|
|
||||||
{/* Mobile logo */}
|
|
||||||
<div className="lg:hidden flex justify-center mb-8">
|
|
||||||
<Link href="/" className="flex items-center gap-3">
|
|
||||||
<div className="relative w-10 h-10">
|
|
||||||
<Image
|
|
||||||
src="/favicon.svg"
|
|
||||||
alt="LiveDash Logo"
|
|
||||||
fill
|
|
||||||
className="object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-xl font-bold text-primary">LiveDash</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="border-border/50 shadow-xl">
|
|
||||||
<CardHeader className="space-y-1">
|
|
||||||
<CardTitle className="text-2xl font-bold">Sign in</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Enter your email and password to access your dashboard
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{/* Live region for screen reader announcements */}
|
|
||||||
<output aria-live="polite" className="sr-only">
|
|
||||||
{isLoading && "Signing in, please wait..."}
|
|
||||||
{error && `Error: ${error}`}
|
|
||||||
</output>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive" className="mb-6" role="alert">
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleLogin} className="space-y-4" noValidate>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={emailId}>Email</Label>
|
|
||||||
<Input
|
|
||||||
id={emailId}
|
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="name@company.com"
|
placeholder="Email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
disabled={isLoading}
|
|
||||||
required
|
required
|
||||||
aria-describedby={emailHelpId}
|
|
||||||
aria-invalid={!!error}
|
|
||||||
className="transition-all duration-200 focus:ring-2 focus:ring-primary/20"
|
|
||||||
/>
|
/>
|
||||||
<div id={emailHelpId} className="sr-only">
|
<input
|
||||||
Enter your company email address
|
className="border px-3 py-2 rounded"
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={passwordId}>Password</Label>
|
|
||||||
<Input
|
|
||||||
id={passwordId}
|
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter your password"
|
placeholder="Password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
disabled={isLoading}
|
|
||||||
required
|
required
|
||||||
aria-describedby={passwordHelpId}
|
|
||||||
aria-invalid={!!error}
|
|
||||||
className="transition-all duration-200 focus:ring-2 focus:ring-primary/20"
|
|
||||||
/>
|
/>
|
||||||
<div id={passwordHelpId} className="sr-only">
|
<button className="bg-blue-600 text-white rounded py-2" type="submit">
|
||||||
Enter your account password
|
Login
|
||||||
</div>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="w-full mt-6 h-11 bg-linear-to-r from-primary to-primary/90 hover:from-primary/90 hover:to-primary/80 transition-all duration-200"
|
|
||||||
disabled={isLoading || !email || !password}
|
|
||||||
aria-describedby={isLoading ? "loading-status" : undefined}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<>
|
|
||||||
<Loader2
|
|
||||||
className="mr-2 h-4 w-4 animate-spin"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
Signing in...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Sign in"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
{isLoading && (
|
|
||||||
<div
|
|
||||||
id={loadingStatusId}
|
|
||||||
className="sr-only"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
Authentication in progress, please wait
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
|
<div className="mt-4 text-center">
|
||||||
<div className="mt-6 space-y-4">
|
<a href="/register" className="text-blue-600 underline">
|
||||||
<div className="text-center">
|
Register company
|
||||||
<Link
|
</a>
|
||||||
href="/register"
|
|
||||||
className="text-sm text-primary hover:underline transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
|
||||||
>
|
|
||||||
Don't have a company account? Register here
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<Link
|
|
||||||
href="/forgot-password"
|
|
||||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
|
||||||
>
|
|
||||||
Forgot your password?
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<p className="mt-8 text-center text-xs text-muted-foreground">
|
|
||||||
By signing in, you agree to our{" "}
|
|
||||||
<Link
|
|
||||||
href="/terms"
|
|
||||||
className="text-primary hover:underline focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
|
||||||
>
|
|
||||||
Terms of Service
|
|
||||||
</Link>{" "}
|
|
||||||
and{" "}
|
|
||||||
<Link
|
|
||||||
href="/privacy"
|
|
||||||
className="text-primary hover:underline focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
|
||||||
>
|
|
||||||
Privacy Policy
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-2 text-center">
|
||||||
|
<a href="/forgot-password" className="text-blue-600 underline">
|
||||||
|
Forgot password?
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
471
app/page.tsx
471
app/page.tsx
@ -1,466 +1,9 @@
|
|||||||
"use client";
|
import { getServerSession } from "next-auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { authOptions } from "../pages/api/auth/[...nextauth]";
|
||||||
|
|
||||||
import {
|
export default async function HomePage() {
|
||||||
ArrowRight,
|
const session = await getServerSession(authOptions);
|
||||||
BarChart3,
|
if (session?.user) redirect("/dashboard");
|
||||||
Brain,
|
else redirect("/login");
|
||||||
Globe,
|
|
||||||
MessageCircle,
|
|
||||||
Shield,
|
|
||||||
Sparkles,
|
|
||||||
TrendingUp,
|
|
||||||
Zap,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
export default function LandingPage() {
|
|
||||||
const { data: session, status } = useSession();
|
|
||||||
const router = useRouter();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (session?.user) {
|
|
||||||
router.push("/dashboard");
|
|
||||||
}
|
|
||||||
}, [session, router]);
|
|
||||||
|
|
||||||
const handleGetStarted = () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
router.push("/login");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRequestDemo = () => {
|
|
||||||
// For now, redirect to contact - can be enhanced later
|
|
||||||
window.open("mailto:demo@notso.ai?subject=LiveDash Demo Request", "_blank");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (status === "loading") {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="relative z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm border-b">
|
|
||||||
<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-3">
|
|
||||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-600 to-purple-600 rounded-lg flex items-center justify-center">
|
|
||||||
<BarChart3 className="w-6 h-6 text-white" />
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
|
||||||
LiveDash
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" onClick={handleRequestDemo}>
|
|
||||||
Request Demo
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleGetStarted} disabled={isLoading}>
|
|
||||||
Get Started
|
|
||||||
<ArrowRight className="w-4 h-4 ml-2" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Hero Section */}
|
|
||||||
<section className="relative py-20 lg:py-32">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<Badge className="mb-8 bg-gradient-to-r from-blue-100 to-purple-100 text-blue-800 dark:from-blue-900 dark:to-purple-900 dark:text-blue-200">
|
|
||||||
<Sparkles className="w-4 h-4 mr-2" />
|
|
||||||
AI-Powered Analytics Platform
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
<h1 className="text-5xl lg:text-7xl font-bold mb-8 bg-gradient-to-r from-gray-900 via-blue-800 to-purple-800 dark:from-white dark:via-blue-200 dark:to-purple-200 bg-clip-text text-transparent leading-tight">
|
|
||||||
Transform Customer
|
|
||||||
<br />
|
|
||||||
Conversations into
|
|
||||||
<br />
|
|
||||||
<span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
|
||||||
Actionable Insights
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="text-xl lg:text-2xl text-gray-600 dark:text-gray-300 mb-12 max-w-4xl mx-auto leading-relaxed">
|
|
||||||
LiveDash analyzes your customer support conversations with
|
|
||||||
advanced AI to deliver real-time sentiment analysis, automated
|
|
||||||
categorization, and powerful analytics that drive better business
|
|
||||||
decisions.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white px-8 py-4 text-lg"
|
|
||||||
onClick={handleGetStarted}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
Start Free Trial
|
|
||||||
<ArrowRight className="w-5 h-5 ml-2" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
variant="outline"
|
|
||||||
className="px-8 py-4 text-lg"
|
|
||||||
onClick={handleRequestDemo}
|
|
||||||
>
|
|
||||||
Watch Demo
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Features Section */}
|
|
||||||
<section className="py-20 bg-white/50 dark:bg-gray-800/50">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="text-center mb-16">
|
|
||||||
<h2 className="text-4xl font-bold mb-6 text-gray-900 dark:text-white">
|
|
||||||
Powerful Features for Modern Teams
|
|
||||||
</h2>
|
|
||||||
<p className="text-xl text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
|
|
||||||
Everything you need to understand and optimize your customer
|
|
||||||
interactions
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-4xl mx-auto space-y-8">
|
|
||||||
{/* Feature Stack */}
|
|
||||||
<div className="relative">
|
|
||||||
{/* Connection Lines */}
|
|
||||||
<div className="absolute left-1/2 top-0 bottom-0 w-px bg-gradient-to-b from-blue-200 via-purple-200 to-transparent dark:from-blue-800 dark:via-purple-800 transform -translate-x-1/2 z-0" />
|
|
||||||
|
|
||||||
{/* Feature Cards */}
|
|
||||||
<div className="space-y-16 relative z-10">
|
|
||||||
{/* AI Sentiment Analysis */}
|
|
||||||
<div className="flex items-center gap-8 group">
|
|
||||||
<div className="flex-1 text-right">
|
|
||||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
|
|
||||||
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">
|
|
||||||
AI Sentiment Analysis
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300 text-lg">
|
|
||||||
Automatically analyze customer emotions and satisfaction
|
|
||||||
levels across all conversations with 99.9% accuracy
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
|
|
||||||
<Brain className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Smart Categorization */}
|
|
||||||
<div className="flex items-center gap-8 group">
|
|
||||||
<div className="flex-1" />
|
|
||||||
<div className="w-16 h-16 bg-gradient-to-br from-purple-500 to-purple-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
|
|
||||||
<MessageCircle className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
|
|
||||||
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">
|
|
||||||
Smart Categorization
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300 text-lg">
|
|
||||||
Intelligently categorize conversations by topic,
|
|
||||||
urgency, and department automatically using advanced ML
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Real-time Analytics */}
|
|
||||||
<div className="flex items-center gap-8 group">
|
|
||||||
<div className="flex-1 text-right">
|
|
||||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
|
|
||||||
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">
|
|
||||||
Real-time Analytics
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300 text-lg">
|
|
||||||
Get instant insights with beautiful dashboards and
|
|
||||||
real-time performance metrics that update live
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-16 h-16 bg-gradient-to-br from-green-500 to-green-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
|
|
||||||
<TrendingUp className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Enterprise Security */}
|
|
||||||
<div className="flex items-center gap-8 group">
|
|
||||||
<div className="flex-1" />
|
|
||||||
<div className="w-16 h-16 bg-gradient-to-br from-orange-500 to-orange-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
|
|
||||||
<Shield className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
|
|
||||||
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">
|
|
||||||
Enterprise Security
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300 text-lg">
|
|
||||||
Bank-grade security with GDPR compliance, SOC 2
|
|
||||||
certification, and end-to-end encryption
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lightning Fast */}
|
|
||||||
<div className="flex items-center gap-8 group">
|
|
||||||
<div className="flex-1 text-right">
|
|
||||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
|
|
||||||
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">
|
|
||||||
Lightning Fast
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300 text-lg">
|
|
||||||
Process thousands of conversations in seconds with our
|
|
||||||
optimized AI pipeline and global CDN
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-16 h-16 bg-gradient-to-br from-yellow-500 to-yellow-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
|
|
||||||
<Zap className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Global Scale */}
|
|
||||||
<div className="flex items-center gap-8 group">
|
|
||||||
<div className="flex-1" />
|
|
||||||
<div className="w-16 h-16 bg-gradient-to-br from-indigo-500 to-indigo-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
|
|
||||||
<Globe className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
|
|
||||||
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">
|
|
||||||
Global Scale
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300 text-lg">
|
|
||||||
Multi-language support with global infrastructure for
|
|
||||||
teams worldwide, serving 50+ countries
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Social Proof */}
|
|
||||||
<section className="py-20">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
|
||||||
<h2 className="text-3xl font-bold mb-12 text-gray-900 dark:text-white">
|
|
||||||
Trusted by Growing Companies
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-8 mb-16">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-4xl font-bold text-blue-600 mb-2">
|
|
||||||
10,000+
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-600 dark:text-gray-300">
|
|
||||||
Conversations Analyzed Daily
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-4xl font-bold text-purple-600 mb-2">
|
|
||||||
99.9%
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-600 dark:text-gray-300">
|
|
||||||
Accuracy Rate
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-4xl font-bold text-green-600 mb-2">50+</div>
|
|
||||||
<div className="text-gray-600 dark:text-gray-300">
|
|
||||||
Enterprise Customers
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* CTA Section */}
|
|
||||||
<section className="py-20 bg-gradient-to-r from-blue-600 to-purple-600">
|
|
||||||
<div className="max-w-4xl mx-auto text-center px-4 sm:px-6 lg:px-8">
|
|
||||||
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
|
|
||||||
Ready to Transform Your Customer Insights?
|
|
||||||
</h2>
|
|
||||||
<p className="text-xl text-blue-100 mb-8 max-w-2xl mx-auto">
|
|
||||||
Join thousands of teams already using LiveDash to make data-driven
|
|
||||||
decisions and improve customer satisfaction.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
className="bg-white text-blue-600 hover:bg-gray-100 px-8 py-4 text-lg font-semibold"
|
|
||||||
onClick={handleGetStarted}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
Start Free Trial
|
|
||||||
<ArrowRight className="w-5 h-5 ml-2" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
variant="outline"
|
|
||||||
className="border-white text-white hover:bg-white/10 px-8 py-4 text-lg"
|
|
||||||
onClick={handleRequestDemo}
|
|
||||||
>
|
|
||||||
Schedule Demo
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="bg-gray-900 text-white py-12">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="grid md:grid-cols-4 gap-8">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-600 to-purple-600 rounded-lg flex items-center justify-center">
|
|
||||||
<BarChart3 className="w-5 h-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<span className="text-xl font-bold">LiveDash</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
AI-powered customer conversation analytics for modern teams.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold mb-4">Product</h3>
|
|
||||||
<ul className="space-y-2 text-gray-400">
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/features"
|
|
||||||
className="hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Features
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/pricing"
|
|
||||||
className="hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Pricing
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="/api" className="hover:text-white transition-colors">
|
|
||||||
API
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/integrations"
|
|
||||||
className="hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Integrations
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold mb-4">Company</h3>
|
|
||||||
<ul className="space-y-2 text-gray-400">
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/about"
|
|
||||||
className="hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
About
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/blog"
|
|
||||||
className="hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Blog
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/careers"
|
|
||||||
className="hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Careers
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/contact"
|
|
||||||
className="hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Contact
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold mb-4">Support</h3>
|
|
||||||
<ul className="space-y-2 text-gray-400">
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/docs"
|
|
||||||
className="hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/help"
|
|
||||||
className="hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Help Center
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/privacy"
|
|
||||||
className="hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Privacy
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/terms"
|
|
||||||
className="hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Terms
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-gray-800 mt-12 pt-8 text-center text-gray-400">
|
|
||||||
<p>© 2024 Notso AI. All rights reserved.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,25 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { SessionProvider } from "next-auth/react";
|
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
|
||||||
|
|
||||||
export default function PlatformLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ThemeProvider
|
|
||||||
attribute="class"
|
|
||||||
defaultTheme="system"
|
|
||||||
enableSystem
|
|
||||||
disableTransitionOnChange
|
|
||||||
>
|
|
||||||
<SessionProvider basePath="/api/platform/auth">
|
|
||||||
{children}
|
|
||||||
<Toaster />
|
|
||||||
</SessionProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { signIn } from "next-auth/react";
|
|
||||||
import { useId, useState } from "react";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
|
||||||
|
|
||||||
export default function PlatformLoginPage() {
|
|
||||||
const emailId = useId();
|
|
||||||
const passwordId = useId();
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsLoading(true);
|
|
||||||
setError("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await signIn("credentials", {
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
redirect: false,
|
|
||||||
callbackUrl: "/platform/dashboard",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result?.error) {
|
|
||||||
setError("Invalid credentials");
|
|
||||||
} else if (result?.ok) {
|
|
||||||
// Login successful, redirect to dashboard
|
|
||||||
router.push("/platform/dashboard");
|
|
||||||
}
|
|
||||||
} catch (_error) {
|
|
||||||
setError("An error occurred during login");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 relative">
|
|
||||||
<div className="absolute top-4 right-4">
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
<Card className="w-full max-w-md">
|
|
||||||
<CardHeader className="text-center">
|
|
||||||
<CardTitle className="text-2xl font-bold">Platform Login</CardTitle>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Sign in to the Notso AI platform management dashboard
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={emailId}>Email</Label>
|
|
||||||
<Input
|
|
||||||
id={emailId}
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
autoComplete="email"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={passwordId}>Password</Label>
|
|
||||||
<Input
|
|
||||||
id={passwordId}
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
|
||||||
{isLoading ? "Signing in..." : "Sign In"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
export default function PlatformIndexPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Redirect to platform dashboard
|
|
||||||
router.replace("/platform/dashboard");
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Redirecting to platform dashboard...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,29 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { SessionProvider } from "next-auth/react";
|
import { SessionProvider } from "next-auth/react";
|
||||||
import type { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { CSRFProvider } from "@/components/providers/CSRFProvider";
|
|
||||||
import { TRPCProvider } from "@/components/providers/TRPCProvider";
|
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
|
||||||
|
|
||||||
export function Providers({ children }: { children: ReactNode }) {
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
// Including error handling and refetch interval for better user experience
|
// Including error handling and refetch interval for better user experience
|
||||||
return (
|
return (
|
||||||
<ThemeProvider
|
|
||||||
attribute="class"
|
|
||||||
defaultTheme="system"
|
|
||||||
enableSystem
|
|
||||||
disableTransitionOnChange
|
|
||||||
>
|
|
||||||
<SessionProvider
|
<SessionProvider
|
||||||
// Re-fetch session every 30 minutes (reduced from 10)
|
// Re-fetch session every 10 minutes
|
||||||
refetchInterval={30 * 60}
|
refetchInterval={10 * 60}
|
||||||
refetchOnWindowFocus={false}
|
refetchOnWindowFocus={true}
|
||||||
>
|
>
|
||||||
<CSRFProvider>
|
{children}
|
||||||
<TRPCProvider>{children}</TRPCProvider>
|
|
||||||
</CSRFProvider>
|
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
</ThemeProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const [email, setEmail] = useState<string>("");
|
const [email, setEmail] = useState<string>("");
|
||||||
const [company, setCompany] = useState<string>("");
|
const [company, setCompany] = useState<string>("");
|
||||||
const [password, setPassword] = useState<string>("");
|
const [password, setPassword] = useState<string>("");
|
||||||
const [csvUrl, setCsvUrl] = useState<string>("");
|
const [csvUrl, setCsvUrl] = useState<string>("");
|
||||||
const [role, setRole] = useState<string>("ADMIN"); // Default to ADMIN for company registration
|
const [role, setRole] = useState<string>("admin"); // Default to admin for company registration
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState<string>("");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@ -66,7 +66,7 @@ export default function RegisterPage() {
|
|||||||
>
|
>
|
||||||
<option value="admin">Admin</option>
|
<option value="admin">Admin</option>
|
||||||
<option value="user">User</option>
|
<option value="user">User</option>
|
||||||
<option value="AUDITOR">Auditor</option>
|
<option value="auditor">Auditor</option>
|
||||||
</select>
|
</select>
|
||||||
<button className="bg-blue-600 text-white rounded py-2" type="submit">
|
<button className="bg-blue-600 text-white rounded py-2" type="submit">
|
||||||
Register & Continue
|
Register & Continue
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useState, Suspense } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Suspense, useState } from "react";
|
|
||||||
|
|
||||||
// Component that uses useSearchParams wrapped in Suspense
|
// Component that uses useSearchParams wrapped in Suspense
|
||||||
function ResetPasswordForm() {
|
function ResetPasswordForm() {
|
||||||
|
|||||||
74
biome.json
74
biome.json
@ -1,74 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://biomejs.dev/schemas/2.0.5/schema.json",
|
|
||||||
"linter": {
|
|
||||||
"enabled": true,
|
|
||||||
"rules": {
|
|
||||||
"recommended": true,
|
|
||||||
"correctness": {
|
|
||||||
"noUnusedVariables": "error",
|
|
||||||
"noUnusedImports": "error"
|
|
||||||
},
|
|
||||||
"style": {
|
|
||||||
"useConst": "error",
|
|
||||||
"useTemplate": "error",
|
|
||||||
"noParameterAssign": "error",
|
|
||||||
"useAsConstAssertion": "error",
|
|
||||||
"useDefaultParameterLast": "error",
|
|
||||||
"useEnumInitializers": "error",
|
|
||||||
"useSelfClosingElements": "error",
|
|
||||||
"useSingleVarDeclarator": "error",
|
|
||||||
"noUnusedTemplateLiteral": "error",
|
|
||||||
"useNumberNamespace": "error",
|
|
||||||
"noInferrableTypes": "error",
|
|
||||||
"noUselessElse": "error"
|
|
||||||
},
|
|
||||||
"suspicious": {
|
|
||||||
"noExplicitAny": "warn",
|
|
||||||
"noArrayIndexKey": "warn"
|
|
||||||
},
|
|
||||||
"complexity": {
|
|
||||||
"noForEach": "off",
|
|
||||||
"noExcessiveCognitiveComplexity": {
|
|
||||||
"level": "error",
|
|
||||||
"options": { "maxAllowedComplexity": 15 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"formatter": {
|
|
||||||
"enabled": true,
|
|
||||||
"formatWithErrors": false,
|
|
||||||
"indentStyle": "space",
|
|
||||||
"indentWidth": 2,
|
|
||||||
"lineEnding": "lf",
|
|
||||||
"lineWidth": 80
|
|
||||||
},
|
|
||||||
"javascript": {
|
|
||||||
"formatter": {
|
|
||||||
"jsxQuoteStyle": "double",
|
|
||||||
"quoteProperties": "asNeeded",
|
|
||||||
"trailingCommas": "es5",
|
|
||||||
"semicolons": "always",
|
|
||||||
"arrowParentheses": "always",
|
|
||||||
"bracketSpacing": true,
|
|
||||||
"bracketSameLine": false,
|
|
||||||
"quoteStyle": "double"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"json": {
|
|
||||||
"formatter": {
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"files": {
|
|
||||||
"includes": [
|
|
||||||
"app/**",
|
|
||||||
"lib/**",
|
|
||||||
"components/**",
|
|
||||||
"*.ts",
|
|
||||||
"*.tsx",
|
|
||||||
"*.js",
|
|
||||||
"*.jsx"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import { ProcessingStatusManager } from "./lib/processingStatusManager";
|
|
||||||
|
|
||||||
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() {
|
|
||||||
try {
|
|
||||||
console.log("=== REFACTORED PIPELINE STATUS ===\n");
|
|
||||||
|
|
||||||
// Get pipeline status using the new system
|
|
||||||
const pipelineStatus = await statusManager.getPipelineStatus();
|
|
||||||
console.log(`Total Sessions: ${pipelineStatus.totalSessions}\n`);
|
|
||||||
|
|
||||||
// Display status for each stage
|
|
||||||
for (const stage of PIPELINE_STAGES) {
|
|
||||||
const stageData = pipelineStatus.pipeline[stage] || {};
|
|
||||||
displayStageStatus(stage, stageData);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show what needs processing
|
|
||||||
displayProcessingNeeds(pipelineStatus);
|
|
||||||
|
|
||||||
// Show failed sessions if any
|
|
||||||
const failedSessions = await statusManager.getFailedSessions();
|
|
||||||
displayFailedSessions(failedSessions);
|
|
||||||
|
|
||||||
// Show sessions ready for AI processing
|
|
||||||
const readyForAI = await statusManager.getSessionsNeedingProcessing(
|
|
||||||
"AI_ANALYSIS",
|
|
||||||
5
|
|
||||||
);
|
|
||||||
displayReadyForAI(readyForAI);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error checking pipeline status:", error);
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkRefactoredPipelineStatus();
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
|
||||||
"style": "new-york",
|
|
||||||
"rsc": true,
|
|
||||||
"tsx": true,
|
|
||||||
"tailwind": {
|
|
||||||
"config": "",
|
|
||||||
"css": "app/globals.css",
|
|
||||||
"baseColor": "neutral",
|
|
||||||
"cssVariables": true,
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"aliases": {
|
|
||||||
"components": "@/components",
|
|
||||||
"utils": "@/lib/utils",
|
|
||||||
"ui": "@/components/ui",
|
|
||||||
"lib": "@/lib",
|
|
||||||
"hooks": "@/hooks"
|
|
||||||
},
|
|
||||||
"iconLibrary": "lucide"
|
|
||||||
}
|
|
||||||
308
components/Charts.tsx
Normal file
308
components/Charts.tsx
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import Chart from "chart.js/auto";
|
||||||
|
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: function (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} />;
|
||||||
|
}
|
||||||
@ -1,164 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useId, useState } from "react";
|
|
||||||
|
|
||||||
interface DateRangePickerProps {
|
|
||||||
minDate: string;
|
|
||||||
maxDate: string;
|
|
||||||
onDateRangeChange: (startDate: string, endDate: string) => void;
|
|
||||||
initialStartDate?: string;
|
|
||||||
initialEndDate?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DateRangePicker({
|
|
||||||
minDate,
|
|
||||||
maxDate,
|
|
||||||
onDateRangeChange,
|
|
||||||
initialStartDate,
|
|
||||||
initialEndDate,
|
|
||||||
}: DateRangePickerProps) {
|
|
||||||
const startDateId = useId();
|
|
||||||
const endDateId = useId();
|
|
||||||
const [startDate, setStartDate] = useState(initialStartDate || minDate);
|
|
||||||
const [endDate, setEndDate] = useState(initialEndDate || maxDate);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Only notify parent component when dates change, not when the callback changes
|
|
||||||
onDateRangeChange(startDate, endDate);
|
|
||||||
}, [
|
|
||||||
startDate,
|
|
||||||
endDate, // Only notify parent component when dates change, not when the callback changes
|
|
||||||
onDateRangeChange,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleStartDateChange = (newStartDate: string) => {
|
|
||||||
// Ensure start date is not before min date
|
|
||||||
if (newStartDate < minDate) {
|
|
||||||
setStartDate(minDate);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure start date is not after end date
|
|
||||||
if (newStartDate > endDate) {
|
|
||||||
setEndDate(newStartDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
setStartDate(newStartDate);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEndDateChange = (newEndDate: string) => {
|
|
||||||
// Ensure end date is not after max date
|
|
||||||
if (newEndDate > maxDate) {
|
|
||||||
setEndDate(maxDate);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure end date is not before start date
|
|
||||||
if (newEndDate < startDate) {
|
|
||||||
setStartDate(newEndDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
setEndDate(newEndDate);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetToFullRange = () => {
|
|
||||||
setStartDate(minDate);
|
|
||||||
setEndDate(maxDate);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setLast30Days = () => {
|
|
||||||
const thirtyDaysAgo = new Date();
|
|
||||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
||||||
const thirtyDaysAgoStr = thirtyDaysAgo.toISOString().split("T")[0];
|
|
||||||
|
|
||||||
// Use the later of 30 days ago or minDate
|
|
||||||
const newStartDate =
|
|
||||||
thirtyDaysAgoStr > minDate ? thirtyDaysAgoStr : minDate;
|
|
||||||
setStartDate(newStartDate);
|
|
||||||
setEndDate(maxDate);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setLast7Days = () => {
|
|
||||||
const sevenDaysAgo = new Date();
|
|
||||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
|
||||||
const sevenDaysAgoStr = sevenDaysAgo.toISOString().split("T")[0];
|
|
||||||
|
|
||||||
// Use the later of 7 days ago or minDate
|
|
||||||
const newStartDate = sevenDaysAgoStr > minDate ? sevenDaysAgoStr : minDate;
|
|
||||||
setStartDate(newStartDate);
|
|
||||||
setEndDate(maxDate);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200">
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
|
|
||||||
<span className="text-sm font-medium text-gray-700 whitespace-nowrap">
|
|
||||||
Date Range:
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2 items-start sm:items-center">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label htmlFor={startDateId} className="text-sm text-gray-600">
|
|
||||||
From:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id={startDateId}
|
|
||||||
type="date"
|
|
||||||
value={startDate}
|
|
||||||
min={minDate}
|
|
||||||
max={maxDate}
|
|
||||||
onChange={(e) => handleStartDateChange(e.target.value)}
|
|
||||||
className="px-3 py-1.5 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label htmlFor={endDateId} className="text-sm text-gray-600">
|
|
||||||
To:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id={endDateId}
|
|
||||||
type="date"
|
|
||||||
value={endDate}
|
|
||||||
min={minDate}
|
|
||||||
max={maxDate}
|
|
||||||
onChange={(e) => handleEndDateChange(e.target.value)}
|
|
||||||
className="px-3 py-1.5 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={setLast7Days}
|
|
||||||
className="px-3 py-1.5 text-xs font-medium text-sky-600 bg-sky-50 border border-sky-200 rounded-md hover:bg-sky-100 transition-colors"
|
|
||||||
>
|
|
||||||
Last 7 days
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={setLast30Days}
|
|
||||||
className="px-3 py-1.5 text-xs font-medium text-sky-600 bg-sky-50 border border-sky-200 rounded-md hover:bg-sky-100 transition-colors"
|
|
||||||
>
|
|
||||||
Last 30 days
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={resetToFullRange}
|
|
||||||
className="px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-50 border border-gray-200 rounded-md hover:bg-gray-100 transition-colors"
|
|
||||||
>
|
|
||||||
All time
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 text-xs text-gray-500">
|
|
||||||
Available data: {new Date(minDate).toLocaleDateString()} -{" "}
|
|
||||||
{new Date(maxDate).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
155
components/DonutChart.tsx
Normal file
155
components/DonutChart.tsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useEffect } from "react";
|
||||||
|
import Chart, { Point, BubbleDataPoint } from "chart.js/auto";
|
||||||
|
|
||||||
|
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: function (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: function (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 { useEffect, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
import * as countryCoder from "@rapideditor/country-coder";
|
import * as countryCoder from "@rapideditor/country-coder";
|
||||||
|
|
||||||
@ -18,71 +18,28 @@ 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 {
|
};
|
||||||
const feature = countryCoder.feature(countryCode);
|
// This function now primarily returns fallbacks.
|
||||||
if (!feature?.geometry) {
|
// The actual fetching using @rapideditor/country-coder will be in the component's useEffect.
|
||||||
return undefined;
|
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
|
||||||
const CountryMapComponent = dynamic(() => import("./Map"), {
|
const Map = dynamic(() => import("./Map"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<div className="h-full w-full bg-muted flex items-center justify-center text-muted-foreground">
|
<div className="h-full w-full bg-gray-100 flex items-center justify-center">
|
||||||
Loading map...
|
Loading map...
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -90,7 +47,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 +58,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];
|
||||||
|
|
||||||
|
if (!countryCoords) {
|
||||||
|
const feature = countryCoder.feature(code);
|
||||||
|
if (feature && 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]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return coords;
|
if (countryCoords) {
|
||||||
},
|
return {
|
||||||
[]
|
code,
|
||||||
);
|
count,
|
||||||
|
coordinates: countryCoords,
|
||||||
/**
|
};
|
||||||
* Process a single country entry into CountryData
|
|
||||||
*/
|
|
||||||
const processCountryEntry = useCallback(
|
|
||||||
(
|
|
||||||
code: string,
|
|
||||||
count: number,
|
|
||||||
countryCoordinates: Record<string, [number, number]>
|
|
||||||
): CountryData | null => {
|
|
||||||
const coordinates = getCountryCoordinates(code, countryCoordinates);
|
|
||||||
|
|
||||||
if (coordinates) {
|
|
||||||
return { code, count, coordinates };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null; // Skip if no coordinates found
|
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) : [];
|
||||||
@ -185,7 +127,7 @@ export default function GeographicMap({
|
|||||||
// Show loading state during SSR or until client-side rendering takes over
|
// Show loading state during SSR or until client-side rendering takes over
|
||||||
if (!isClient) {
|
if (!isClient) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full bg-muted flex items-center justify-center text-muted-foreground">
|
<div className="h-full w-full bg-gray-100 flex items-center justify-center">
|
||||||
Loading map...
|
Loading map...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -194,9 +136,9 @@ export default function GeographicMap({
|
|||||||
return (
|
return (
|
||||||
<div style={{ height: `${height}px`, width: "100%" }} className="relative">
|
<div style={{ height: `${height}px`, width: "100%" }} className="relative">
|
||||||
{countryData.length > 0 ? (
|
{countryData.length > 0 ? (
|
||||||
<CountryMapComponent countryData={countryData} maxCount={maxCount} />
|
<Map countryData={countryData} maxCount={maxCount} />
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full w-full bg-muted flex items-center justify-center text-muted-foreground">
|
<div className="h-full w-full bg-gray-100 flex items-center justify-center text-gray-500">
|
||||||
No geographic data available
|
No geographic data available
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CircleMarker, MapContainer, TileLayer, Tooltip } from "react-leaflet";
|
import { MapContainer, TileLayer, CircleMarker, Tooltip } from "react-leaflet";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { getLocalizedCountryName } from "../lib/localization";
|
import { getLocalizedCountryName } from "../lib/localization";
|
||||||
|
|
||||||
interface CountryData {
|
interface CountryData {
|
||||||
@ -17,30 +15,7 @@ interface MapProps {
|
|||||||
maxCount: number;
|
maxCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CountryMap = ({ countryData, maxCount }: MapProps) => {
|
const Map = ({ countryData, maxCount }: MapProps) => {
|
||||||
const { theme } = useTheme();
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Don't render until mounted to avoid hydration mismatch
|
|
||||||
if (!mounted) {
|
|
||||||
return <div className="h-full w-full bg-muted animate-pulse rounded-lg" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDark = theme === "dark";
|
|
||||||
|
|
||||||
// Use different tile layers based on theme
|
|
||||||
const tileLayerUrl = isDark
|
|
||||||
? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
|
||||||
: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";
|
|
||||||
|
|
||||||
const tileLayerAttribution = isDark
|
|
||||||
? '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'
|
|
||||||
: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={[30, 0]}
|
center={[30, 0]}
|
||||||
@ -49,28 +24,29 @@ const CountryMap = ({ countryData, maxCount }: MapProps) => {
|
|||||||
scrollWheelZoom={false}
|
scrollWheelZoom={false}
|
||||||
style={{ height: "100%", width: "100%", borderRadius: "0.5rem" }}
|
style={{ height: "100%", width: "100%", borderRadius: "0.5rem" }}
|
||||||
>
|
>
|
||||||
<TileLayer attribution={tileLayerAttribution} url={tileLayerUrl} />
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
{countryData.map((country) => (
|
{countryData.map((country) => (
|
||||||
<CircleMarker
|
<CircleMarker
|
||||||
key={country.code}
|
key={country.code}
|
||||||
center={country.coordinates}
|
center={country.coordinates}
|
||||||
radius={5 + (country.count / maxCount) * 20}
|
radius={5 + (country.count / maxCount) * 20}
|
||||||
pathOptions={{
|
pathOptions={{
|
||||||
fillColor: "hsl(var(--primary))",
|
fillColor: "#3B82F6",
|
||||||
color: "hsl(var(--primary))",
|
color: "#1E40AF",
|
||||||
weight: 2,
|
weight: 1,
|
||||||
opacity: 0.9,
|
opacity: 0.8,
|
||||||
fillOpacity: 0.6,
|
fillOpacity: 0.6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<div className="p-2 bg-background border border-border rounded-md shadow-md">
|
<div className="p-1">
|
||||||
<div className="font-medium text-foreground">
|
<div className="font-medium">
|
||||||
{getLocalizedCountryName(country.code)}
|
{getLocalizedCountryName(country.code)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm">Sessions: {country.count}</div>
|
||||||
Sessions: {country.count}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</CircleMarker>
|
</CircleMarker>
|
||||||
@ -79,4 +55,4 @@ const CountryMap = ({ countryData, maxCount }: MapProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CountryMap;
|
export default Map;
|
||||||
|
|||||||
@ -1,86 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Message } from "../lib/types";
|
|
||||||
|
|
||||||
interface MessageViewerProps {
|
|
||||||
messages: Message[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component to display parsed messages in a chat-like format
|
|
||||||
*/
|
|
||||||
export default function MessageViewer({ messages }: MessageViewerProps) {
|
|
||||||
if (!messages || messages.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white p-4 rounded-lg shadow">
|
|
||||||
<h3 className="font-bold text-lg mb-3">Conversation</h3>
|
|
||||||
<p className="text-gray-500 italic">No parsed messages available</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white p-4 rounded-lg shadow">
|
|
||||||
<h3 className="font-bold text-lg mb-3">
|
|
||||||
Conversation ({messages.length} messages)
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
|
||||||
{messages.map((message) => (
|
|
||||||
<div
|
|
||||||
key={message.id}
|
|
||||||
className={`flex ${
|
|
||||||
message.role.toLowerCase() === "user"
|
|
||||||
? "justify-end"
|
|
||||||
: "justify-start"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`max-w-xs lg:max-w-md px-4 py-2 rounded-lg ${
|
|
||||||
message.role.toLowerCase() === "user"
|
|
||||||
? "bg-blue-500 text-white"
|
|
||||||
: message.role.toLowerCase() === "assistant"
|
|
||||||
? "bg-gray-200 text-gray-800"
|
|
||||||
: "bg-yellow-100 text-yellow-800"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<span className="text-xs font-medium opacity-75 mr-2">
|
|
||||||
{message.role}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs opacity-75 ml-2">
|
|
||||||
{message.timestamp
|
|
||||||
? new Date(message.timestamp).toLocaleTimeString()
|
|
||||||
: "No timestamp"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm whitespace-pre-wrap">
|
|
||||||
{message.content}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 pt-3 border-t text-sm text-gray-500">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>
|
|
||||||
First message:{" "}
|
|
||||||
{messages[0].timestamp
|
|
||||||
? new Date(messages[0].timestamp).toLocaleString()
|
|
||||||
: "No timestamp"}
|
|
||||||
</span>
|
|
||||||
{/* prettier-ignore */}
|
|
||||||
<span>
|
|
||||||
Last message: {(() => {
|
|
||||||
const lastMessage = messages[messages.length - 1];
|
|
||||||
return lastMessage.timestamp
|
|
||||||
? new Date(lastMessage.timestamp).toLocaleString()
|
|
||||||
: "No timestamp";
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
88
components/MetricCard.tsx
Normal file
88
components/MetricCard.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
interface MetricCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number | null | undefined;
|
||||||
|
description?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
trend?: {
|
||||||
|
value: number;
|
||||||
|
label?: string;
|
||||||
|
isPositive?: boolean;
|
||||||
|
};
|
||||||
|
variant?: "default" | "primary" | "success" | "warning" | "danger";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MetricCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
trend,
|
||||||
|
variant = "default",
|
||||||
|
}: MetricCardProps) {
|
||||||
|
// Determine background and text colors based on variant
|
||||||
|
const getVariantClasses = () => {
|
||||||
|
switch (variant) {
|
||||||
|
case "primary":
|
||||||
|
return "bg-blue-50 border-blue-200";
|
||||||
|
case "success":
|
||||||
|
return "bg-green-50 border-green-200";
|
||||||
|
case "warning":
|
||||||
|
return "bg-amber-50 border-amber-200";
|
||||||
|
case "danger":
|
||||||
|
return "bg-red-50 border-red-200";
|
||||||
|
default:
|
||||||
|
return "bg-white border-gray-200";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIconClasses = () => {
|
||||||
|
switch (variant) {
|
||||||
|
case "primary":
|
||||||
|
return "bg-blue-100 text-blue-600";
|
||||||
|
case "success":
|
||||||
|
return "bg-green-100 text-green-600";
|
||||||
|
case "warning":
|
||||||
|
return "bg-amber-100 text-amber-600";
|
||||||
|
case "danger":
|
||||||
|
return "bg-red-100 text-red-600";
|
||||||
|
default:
|
||||||
|
return "bg-gray-100 text-gray-600";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border shadow-sm p-6 ${getVariantClasses()}`}>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">{title}</p>
|
||||||
|
<div className="mt-2 flex items-baseline">
|
||||||
|
<p className="text-2xl font-semibold">{value ?? "-"}</p>
|
||||||
|
{trend && (
|
||||||
|
<span
|
||||||
|
className={`ml-2 text-sm font-medium ${
|
||||||
|
trend.isPositive !== false ? "text-green-600" : "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{trend.isPositive !== false ? "↑" : "↓"}{" "}
|
||||||
|
{Math.abs(trend.value).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{icon && (
|
||||||
|
<div
|
||||||
|
className={`flex h-12 w-12 rounded-full ${getIconClasses()} items-center justify-center`}
|
||||||
|
>
|
||||||
|
<span className="text-xl">{icon}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,15 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { useRef, useEffect } from "react";
|
||||||
Bar,
|
import Chart from "chart.js/auto";
|
||||||
BarChart,
|
import annotationPlugin from "chartjs-plugin-annotation";
|
||||||
CartesianGrid,
|
|
||||||
ReferenceLine,
|
Chart.register(annotationPlugin);
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
} from "recharts";
|
|
||||||
|
|
||||||
interface ResponseTimeDistributionProps {
|
interface ResponseTimeDistributionProps {
|
||||||
data: number[];
|
data: number[];
|
||||||
@ -17,41 +12,18 @@ interface ResponseTimeDistributionProps {
|
|||||||
targetResponseTime?: number;
|
targetResponseTime?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TooltipProps {
|
|
||||||
active?: boolean;
|
|
||||||
payload?: Array<{ value: number; payload: { label: string; count: number } }>;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border bg-background p-3 shadow-md">
|
|
||||||
<p className="text-sm font-medium">{label}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
{payload[0].value}
|
|
||||||
</span>{" "}
|
|
||||||
responses
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ResponseTimeDistribution({
|
export default function ResponseTimeDistribution({
|
||||||
data,
|
data,
|
||||||
average,
|
average,
|
||||||
targetResponseTime,
|
targetResponseTime,
|
||||||
}: ResponseTimeDistributionProps) {
|
}: ResponseTimeDistributionProps) {
|
||||||
if (!data || !data.length) {
|
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
useEffect(() => {
|
||||||
No response time data available
|
if (!ref.current || !data || !data.length) return;
|
||||||
</div>
|
|
||||||
);
|
const ctx = ref.current.getContext("2d");
|
||||||
}
|
if (!ctx) return;
|
||||||
|
|
||||||
// Create bins for the histogram (0-1s, 1-2s, 2-3s, etc.)
|
// Create bins for the histogram (0-1s, 1-2s, 2-3s, etc.)
|
||||||
const maxTime = Math.ceil(Math.max(...data));
|
const maxTime = Math.ceil(Math.max(...data));
|
||||||
@ -63,111 +35,91 @@ export default function ResponseTimeDistribution({
|
|||||||
bins[binIndex]++;
|
bins[binIndex]++;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create chart data
|
// Create labels for each bin
|
||||||
const chartData = bins.map((count, i) => {
|
const labels = bins.map((_, i) => {
|
||||||
let label: string;
|
|
||||||
if (i === bins.length - 1 && bins.length < maxTime + 1) {
|
if (i === bins.length - 1 && bins.length < maxTime + 1) {
|
||||||
label = `${i}+ sec`;
|
return `${i}+ seconds`;
|
||||||
} else {
|
|
||||||
label = `${i}-${i + 1} sec`;
|
|
||||||
}
|
}
|
||||||
|
return `${i}-${i + 1} seconds`;
|
||||||
// Determine color based on response time
|
|
||||||
let color: string;
|
|
||||||
if (i <= 2)
|
|
||||||
color = "hsl(var(--chart-1))"; // Green for fast
|
|
||||||
else if (i <= 5)
|
|
||||||
color = "hsl(var(--chart-4))"; // Yellow for medium
|
|
||||||
else color = "hsl(var(--chart-3))"; // Red for slow
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: label,
|
|
||||||
value: count,
|
|
||||||
color,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
const chart = new Chart(ctx, {
|
||||||
<div className="h-64">
|
type: "bar",
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
data: {
|
||||||
<BarChart
|
labels,
|
||||||
data={chartData}
|
datasets: [
|
||||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
{
|
||||||
>
|
label: "Responses",
|
||||||
<CartesianGrid
|
data: bins,
|
||||||
strokeDasharray="3 3"
|
backgroundColor: bins.map((_, i) => {
|
||||||
stroke="hsl(var(--border))"
|
// Green for fast, yellow for medium, red for slow
|
||||||
strokeOpacity={0.3}
|
if (i <= 2) return "rgba(34, 197, 94, 0.7)"; // Green
|
||||||
/>
|
if (i <= 5) return "rgba(250, 204, 21, 0.7)"; // Yellow
|
||||||
<XAxis
|
return "rgba(239, 68, 68, 0.7)"; // Red
|
||||||
dataKey="name"
|
}),
|
||||||
stroke="hsl(var(--muted-foreground))"
|
borderWidth: 1,
|
||||||
fontSize={12}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
stroke="hsl(var(--muted-foreground))"
|
|
||||||
fontSize={12}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
label={{
|
|
||||||
value: "Number of Responses",
|
|
||||||
angle: -90,
|
|
||||||
position: "insideLeft",
|
|
||||||
style: { textAnchor: "middle" },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
|
|
||||||
<Bar
|
|
||||||
dataKey="value"
|
|
||||||
radius={[4, 4, 0, 0]}
|
|
||||||
fill="hsl(var(--chart-1))"
|
|
||||||
maxBarSize={60}
|
|
||||||
>
|
|
||||||
{chartData.map((entry, index) => (
|
|
||||||
<Bar key={`cell-${entry.name}-${index}`} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
|
|
||||||
{/* Average line */}
|
|
||||||
<ReferenceLine
|
|
||||||
x={Math.floor(average)}
|
|
||||||
stroke="hsl(var(--primary))"
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeDasharray="5 5"
|
|
||||||
label={{
|
|
||||||
value: `Avg: ${average.toFixed(1)}s`,
|
|
||||||
position: "top" as const,
|
|
||||||
style: {
|
|
||||||
fill: "hsl(var(--primary))",
|
|
||||||
fontSize: "12px",
|
|
||||||
fontWeight: "500",
|
|
||||||
},
|
},
|
||||||
}}
|
],
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Target line (if provided) */}
|
|
||||||
{targetResponseTime && (
|
|
||||||
<ReferenceLine
|
|
||||||
x={Math.floor(targetResponseTime)}
|
|
||||||
stroke="hsl(var(--chart-2))"
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeDasharray="3 3"
|
|
||||||
label={{
|
|
||||||
value: `Target: ${targetResponseTime}s`,
|
|
||||||
position: "top" as const,
|
|
||||||
style: {
|
|
||||||
fill: "hsl(var(--chart-2))",
|
|
||||||
fontSize: "12px",
|
|
||||||
fontWeight: "500",
|
|
||||||
},
|
},
|
||||||
}}
|
options: {
|
||||||
/>
|
responsive: true,
|
||||||
)}
|
plugins: {
|
||||||
</BarChart>
|
legend: { display: false },
|
||||||
</ResponsiveContainer>
|
annotation: {
|
||||||
</div>
|
annotations: {
|
||||||
);
|
averageLine: {
|
||||||
|
type: "line",
|
||||||
|
yMin: 0,
|
||||||
|
yMax: Math.max(...bins),
|
||||||
|
xMin: average,
|
||||||
|
xMax: average,
|
||||||
|
borderColor: "rgba(75, 192, 192, 1)",
|
||||||
|
borderWidth: 2,
|
||||||
|
label: {
|
||||||
|
display: true,
|
||||||
|
content: "Avg: " + average.toFixed(1) + "s",
|
||||||
|
position: "start",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
targetLine: targetResponseTime
|
||||||
|
? {
|
||||||
|
type: "line",
|
||||||
|
yMin: 0,
|
||||||
|
yMax: Math.max(...bins),
|
||||||
|
xMin: targetResponseTime,
|
||||||
|
xMax: targetResponseTime,
|
||||||
|
borderColor: "rgba(75, 192, 192, 0.7)",
|
||||||
|
borderWidth: 2,
|
||||||
|
label: {
|
||||||
|
display: true,
|
||||||
|
content: "Target",
|
||||||
|
position: "end",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "Number of Responses",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "Response Time",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => chart.destroy();
|
||||||
|
}, [data, average, targetResponseTime]);
|
||||||
|
|
||||||
|
return <canvas ref={ref} height={180} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,287 +1,166 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ChatSession } from "../lib/types";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { formatCategory } from "@/lib/format-enums";
|
|
||||||
import type { ChatSession } from "../lib/types";
|
|
||||||
import CountryDisplay from "./CountryDisplay";
|
|
||||||
import LanguageDisplay from "./LanguageDisplay";
|
import LanguageDisplay from "./LanguageDisplay";
|
||||||
|
import CountryDisplay from "./CountryDisplay";
|
||||||
|
|
||||||
interface SessionDetailsProps {
|
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) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="bg-white p-4 rounded-lg shadow">
|
||||||
<CardHeader>
|
<h3 className="font-bold text-lg mb-3">Session Details</h3>
|
||||||
<CardTitle>Session Information</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<SessionBasicInfo session={session} />
|
|
||||||
<SessionLocationInfo session={session} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<SessionMetrics session={session} />
|
|
||||||
<SessionAnalysis session={session} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SessionStatusFlags session={session} />
|
|
||||||
|
|
||||||
<SessionSummary session={session} />
|
|
||||||
|
|
||||||
{!session.summary && session.initialMsg && (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="text-sm font-medium text-muted-foreground">
|
<div className="flex justify-between border-b pb-2">
|
||||||
Initial Message
|
<span className="text-gray-600">Session ID:</span>
|
||||||
</h4>
|
<span className="font-medium">{session.sessionId || session.id}</span>
|
||||||
<p className="text-sm leading-relaxed border-l-4 border-muted pl-4 italic">
|
</div>
|
||||||
"{session.initialMsg}"
|
|
||||||
</p>
|
<div className="flex justify-between border-b pb-2">
|
||||||
|
<span className="text-gray-600">Start Time:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{new Date(session.startTime).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{session.endTime && (
|
||||||
|
<div className="flex justify-between border-b pb-2">
|
||||||
|
<span className="text-gray-600">End Time:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{new Date(session.endTime).toLocaleString()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{session.fullTranscriptUrl && (
|
{session.category && (
|
||||||
<>
|
<div className="flex justify-between border-b pb-2">
|
||||||
<Separator />
|
<span className="text-gray-600">Category:</span>
|
||||||
<div>
|
<span className="font-medium">{session.category}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session.language && (
|
||||||
|
<div className="flex justify-between border-b pb-2">
|
||||||
|
<span className="text-gray-600">Language:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
<LanguageDisplay languageCode={session.language} />
|
||||||
|
<span className="text-gray-400 text-xs ml-1">
|
||||||
|
({session.language.toUpperCase()})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session.country && (
|
||||||
|
<div className="flex justify-between border-b pb-2">
|
||||||
|
<span className="text-gray-600">Country:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
<CountryDisplay countryCode={session.country} />
|
||||||
|
<span className="text-gray-400 text-xs ml-1">
|
||||||
|
({session.country})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session.sentiment !== null && session.sentiment !== undefined && (
|
||||||
|
<div className="flex justify-between border-b pb-2">
|
||||||
|
<span className="text-gray-600">Sentiment:</span>
|
||||||
|
<span
|
||||||
|
className={`font-medium ${
|
||||||
|
session.sentiment > 0.3
|
||||||
|
? "text-green-500"
|
||||||
|
: session.sentiment < -0.3
|
||||||
|
? "text-red-500"
|
||||||
|
: "text-orange-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{session.sentiment > 0.3
|
||||||
|
? "Positive"
|
||||||
|
: session.sentiment < -0.3
|
||||||
|
? "Negative"
|
||||||
|
: "Neutral"}{" "}
|
||||||
|
({session.sentiment.toFixed(2)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between border-b pb-2">
|
||||||
|
<span className="text-gray-600">Messages Sent:</span>
|
||||||
|
<span className="font-medium">{session.messagesSent || 0}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{typeof session.tokens === "number" && (
|
||||||
|
<div className="flex justify-between border-b pb-2">
|
||||||
|
<span className="text-gray-600">Tokens:</span>
|
||||||
|
<span className="font-medium">{session.tokens}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{typeof session.tokensEur === "number" && (
|
||||||
|
<div className="flex justify-between border-b pb-2">
|
||||||
|
<span className="text-gray-600">Cost:</span>
|
||||||
|
<span className="font-medium">€{session.tokensEur.toFixed(4)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session.avgResponseTime !== null &&
|
||||||
|
session.avgResponseTime !== undefined && (
|
||||||
|
<div className="flex justify-between border-b pb-2">
|
||||||
|
<span className="text-gray-600">Avg Response Time:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{session.avgResponseTime.toFixed(2)}s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session.escalated !== null && session.escalated !== undefined && (
|
||||||
|
<div className="flex justify-between border-b pb-2">
|
||||||
|
<span className="text-gray-600">Escalated:</span>
|
||||||
|
<span
|
||||||
|
className={`font-medium ${session.escalated ? "text-red-500" : "text-green-500"}`}
|
||||||
|
>
|
||||||
|
{session.escalated ? "Yes" : "No"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session.forwardedHr !== null && session.forwardedHr !== undefined && (
|
||||||
|
<div className="flex justify-between border-b pb-2">
|
||||||
|
<span className="text-gray-600">Forwarded to HR:</span>
|
||||||
|
<span
|
||||||
|
className={`font-medium ${session.forwardedHr ? "text-amber-500" : "text-green-500"}`}
|
||||||
|
>
|
||||||
|
{session.forwardedHr ? "Yes" : "No"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transcript rendering is now handled by the parent page (app/dashboard/sessions/[id]/page.tsx) */}
|
||||||
|
{/* Fallback to link only if we only have the URL but no content - this might also be redundant if parent handles all transcript display */}
|
||||||
|
{(!session.transcriptContent ||
|
||||||
|
session.transcriptContent.length === 0) &&
|
||||||
|
session.fullTranscriptUrl &&
|
||||||
|
process.env.NODE_ENV !== "production" && (
|
||||||
|
<div className="flex justify-between pt-2">
|
||||||
|
<span className="text-gray-600">Transcript:</span>
|
||||||
<a
|
<a
|
||||||
href={session.fullTranscriptUrl}
|
href={session.fullTranscriptUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-2 text-primary hover:underline focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
className="text-blue-500 hover:text-blue-700 underline"
|
||||||
aria-label="Open full transcript in new tab"
|
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
|
||||||
View Full Transcript
|
View Full Transcript
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import React from "react"; // No hooks needed since state is now managed by parent
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
import type React from "react"; // No hooks needed since state is now managed by parent
|
|
||||||
import { useId } from "react";
|
|
||||||
import { SimpleThemeToggle } from "@/components/ui/theme-toggle";
|
|
||||||
|
|
||||||
// Icons for the sidebar
|
// Icons for the sidebar
|
||||||
const DashboardIcon = () => (
|
const DashboardIcon = () => (
|
||||||
@ -17,7 +15,6 @@ const DashboardIcon = () => (
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
>
|
>
|
||||||
<title>Dashboard</title>
|
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@ -53,7 +50,6 @@ const CompanyIcon = () => (
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
>
|
>
|
||||||
<title>Company</title>
|
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@ -71,7 +67,6 @@ const UsersIcon = () => (
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
>
|
>
|
||||||
<title>Users</title>
|
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@ -89,7 +84,6 @@ const SessionsIcon = () => (
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
>
|
>
|
||||||
<title>Sessions</title>
|
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@ -99,24 +93,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"
|
||||||
@ -125,7 +101,6 @@ const LogoutIcon = () => (
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
>
|
>
|
||||||
<title>Logout</title>
|
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@ -143,7 +118,6 @@ const MinimalToggleIcon = ({ isExpanded }: { isExpanded: boolean }) => (
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
>
|
>
|
||||||
<title>{isExpanded ? "Collapse sidebar" : "Expand sidebar"}</title>
|
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
) : (
|
) : (
|
||||||
@ -184,8 +158,8 @@ const NavItem: React.FC<NavItemProps> = ({
|
|||||||
href={href}
|
href={href}
|
||||||
className={`relative flex items-center p-3 my-1 rounded-lg transition-all group ${
|
className={`relative flex items-center p-3 my-1 rounded-lg transition-all group ${
|
||||||
isActive
|
isActive
|
||||||
? "bg-primary/10 text-primary font-medium border border-primary/20"
|
? "bg-sky-100 text-sky-800 font-medium"
|
||||||
: "hover:bg-muted text-muted-foreground hover:text-foreground"
|
: "hover:bg-gray-100 text-gray-700 hover:text-gray-900"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (onNavigate) {
|
if (onNavigate) {
|
||||||
@ -193,7 +167,7 @@ const NavItem: React.FC<NavItemProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className={`shrink-0 ${isExpanded ? "mr-3" : "mx-auto"}`}>
|
<span className={`flex-shrink-0 ${isExpanded ? "mr-3" : "mx-auto"}`}>
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
@ -201,7 +175,7 @@ const NavItem: React.FC<NavItemProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="fixed ml-6 w-auto p-2 min-w-max rounded-md shadow-md text-xs font-medium
|
className="fixed ml-6 w-auto p-2 min-w-max rounded-md shadow-md text-xs font-medium
|
||||||
text-popover-foreground bg-popover border border-border z-50
|
text-white bg-gray-800 z-50
|
||||||
invisible opacity-0 -translate-x-3 transition-all
|
invisible opacity-0 -translate-x-3 transition-all
|
||||||
group-hover:visible group-hover:opacity-100 group-hover:translate-x-0"
|
group-hover:visible group-hover:opacity-100 group-hover:translate-x-0"
|
||||||
>
|
>
|
||||||
@ -217,7 +191,6 @@ export default function Sidebar({
|
|||||||
isMobile = false,
|
isMobile = false,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const sidebarId = useId();
|
|
||||||
const pathname = usePathname() || "";
|
const pathname = usePathname() || "";
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
@ -229,22 +202,13 @@ export default function Sidebar({
|
|||||||
{/* Backdrop overlay when sidebar is expanded on mobile */}
|
{/* Backdrop overlay when sidebar is expanded on mobile */}
|
||||||
{isExpanded && isMobile && (
|
{isExpanded && isMobile && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-10 transition-all duration-300"
|
className="fixed inset-0 bg-gray-900 bg-opacity-50 z-10 transition-opacity duration-300"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
onToggle();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label="Close sidebar"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id={sidebarId}
|
className={`fixed md:relative h-screen bg-white shadow-md transition-all duration-300
|
||||||
className={`fixed md:relative h-screen bg-card border-r border-border shadow-lg transition-all duration-300
|
|
||||||
${
|
${
|
||||||
isExpanded ? (isMobile ? "w-full sm:w-80" : "w-56") : "w-16"
|
isExpanded ? (isMobile ? "w-full sm:w-80" : "w-56") : "w-16"
|
||||||
} flex flex-col overflow-visible z-20`}
|
} flex flex-col overflow-visible z-20`}
|
||||||
@ -254,15 +218,12 @@ export default function Sidebar({
|
|||||||
{!isExpanded && (
|
{!isExpanded && (
|
||||||
<div className="absolute top-1 left-1/2 transform -translate-x-1/2 z-30">
|
<div className="absolute top-1 left-1/2 transform -translate-x-1/2 z-30">
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault(); // Prevent any navigation
|
e.preventDefault(); // Prevent any navigation
|
||||||
onToggle();
|
onToggle();
|
||||||
}}
|
}}
|
||||||
className="p-1.5 rounded-md hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary transition-colors group"
|
className="p-1.5 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors group"
|
||||||
aria-label="Expand sidebar"
|
title="Expand sidebar"
|
||||||
aria-expanded={isExpanded}
|
|
||||||
aria-controls={sidebarId}
|
|
||||||
>
|
>
|
||||||
<MinimalToggleIcon isExpanded={isExpanded} />
|
<MinimalToggleIcon isExpanded={isExpanded} />
|
||||||
</button>
|
</button>
|
||||||
@ -287,7 +248,7 @@ export default function Sidebar({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<span className="text-lg font-bold text-primary mt-1 transition-opacity duration-300">
|
<span className="text-lg font-bold text-sky-700 mt-1 transition-opacity duration-300">
|
||||||
LiveDash
|
LiveDash
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -296,22 +257,18 @@ export default function Sidebar({
|
|||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="absolute top-3 right-3 z-30">
|
<div className="absolute top-3 right-3 z-30">
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault(); // Prevent any navigation
|
e.preventDefault(); // Prevent any navigation
|
||||||
onToggle();
|
onToggle();
|
||||||
}}
|
}}
|
||||||
className="p-1.5 rounded-md hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary transition-colors group"
|
className="p-1.5 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors group"
|
||||||
aria-label="Collapse sidebar"
|
title="Collapse sidebar"
|
||||||
aria-expanded={isExpanded}
|
|
||||||
aria-controls="main-sidebar"
|
|
||||||
>
|
>
|
||||||
<MinimalToggleIcon isExpanded={isExpanded} />
|
<MinimalToggleIcon isExpanded={isExpanded} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<nav
|
<nav
|
||||||
aria-label="Main navigation"
|
|
||||||
className={`flex-1 py-4 px-2 overflow-y-auto overflow-x-visible ${isExpanded ? "pt-12" : "pt-4"}`}
|
className={`flex-1 py-4 px-2 overflow-y-auto overflow-x-visible ${isExpanded ? "pt-12" : "pt-4"}`}
|
||||||
>
|
>
|
||||||
<NavItem
|
<NavItem
|
||||||
@ -333,7 +290,6 @@ export default function Sidebar({
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
>
|
>
|
||||||
<title>Analytics Chart</title>
|
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@ -370,37 +326,15 @@ 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">
|
||||||
{/* Theme Toggle */}
|
|
||||||
<div
|
|
||||||
className={`flex items-center ${isExpanded ? "justify-between" : "justify-center"}`}
|
|
||||||
>
|
|
||||||
{isExpanded && (
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">
|
|
||||||
Theme
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<SimpleThemeToggle />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Logout Button */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className={`relative flex items-center p-3 w-full rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground transition-all group ${
|
className={`relative flex items-center p-3 w-full rounded-lg text-gray-700 hover:bg-gray-100 hover:text-gray-900 transition-all group ${
|
||||||
isExpanded ? "" : "justify-center"
|
isExpanded ? "" : "justify-center"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`shrink-0 ${isExpanded ? "mr-3" : ""}`}>
|
<span className={`flex-shrink-0 ${isExpanded ? "mr-3" : ""}`}>
|
||||||
<LogoutIcon />
|
<LogoutIcon />
|
||||||
</span>
|
</span>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
@ -408,7 +342,7 @@ export default function Sidebar({
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="fixed ml-6 w-auto p-2 min-w-max rounded-md shadow-md text-xs font-medium
|
className="fixed ml-6 w-auto p-2 min-w-max rounded-md shadow-md text-xs font-medium
|
||||||
text-popover-foreground bg-popover border border-border z-50
|
text-white bg-gray-800 z-50
|
||||||
invisible opacity-0 -translate-x-3 transition-all
|
invisible opacity-0 -translate-x-3 transition-all
|
||||||
group-hover:visible group-hover:opacity-100 group-hover:translate-x-0"
|
group-hover:visible group-hover:opacity-100 group-hover:translate-x-0"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,91 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import type { TopQuestion } from "../lib/types";
|
|
||||||
|
|
||||||
interface TopQuestionsChartProps {
|
|
||||||
data: TopQuestion[];
|
|
||||||
title?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TopQuestionsChart({
|
|
||||||
data,
|
|
||||||
title = "Top 5 Asked Questions",
|
|
||||||
}: TopQuestionsChartProps) {
|
|
||||||
if (!data || data.length === 0) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
No questions data available
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the maximum count to calculate relative bar widths
|
|
||||||
const maxCount = Math.max(...data.map((q) => q.count));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{data.map((question, index) => {
|
|
||||||
const percentage =
|
|
||||||
maxCount > 0 ? (question.count / maxCount) * 100 : 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={question.question} className="relative pl-8">
|
|
||||||
{/* Question text */}
|
|
||||||
<div className="flex justify-between items-start mb-2">
|
|
||||||
<p className="text-sm font-medium leading-tight pr-4 flex-1 text-foreground">
|
|
||||||
{question.question}
|
|
||||||
</p>
|
|
||||||
<Badge variant="secondary" className="whitespace-nowrap">
|
|
||||||
{question.count}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress bar */}
|
|
||||||
<div className="w-full bg-muted rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-primary h-2 rounded-full transition-all duration-300 ease-in-out"
|
|
||||||
style={{ width: `${percentage}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rank indicator */}
|
|
||||||
<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"
|
|
||||||
role="img"
|
|
||||||
aria-label={`Rank ${index + 1}`}
|
|
||||||
>
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className="my-6" />
|
|
||||||
|
|
||||||
{/* Summary */}
|
|
||||||
<div className="flex justify-between text-sm text-muted-foreground">
|
|
||||||
<span>Total questions analyzed</span>
|
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
{data.reduce((sum, q) => sum + q.count, 0)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import rehypeRaw from "rehype-raw"; // Import rehype-raw
|
import rehypeRaw from "rehype-raw";
|
||||||
|
|
||||||
interface TranscriptViewerProps {
|
interface TranscriptViewerProps {
|
||||||
transcriptContent: string;
|
transcriptContent: string;
|
||||||
@ -10,29 +10,66 @@ interface TranscriptViewerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a message bubble with proper styling
|
* Format the transcript content into a more readable format with styling
|
||||||
*/
|
*/
|
||||||
function renderMessageBubble(
|
function formatTranscript(content: string): React.ReactNode[] {
|
||||||
speaker: string,
|
if (!content.trim()) {
|
||||||
messages: string[],
|
return [<p key="empty">No transcript content available.</p>];
|
||||||
key: string
|
}
|
||||||
): React.ReactNode {
|
|
||||||
return (
|
// Split the transcript by lines
|
||||||
<div key={key} className={`mb-3 ${speaker === "User" ? "text-right" : ""}`}>
|
const lines = content.split("\n");
|
||||||
|
|
||||||
|
const elements: React.ReactNode[] = [];
|
||||||
|
let currentSpeaker: string | null = null;
|
||||||
|
let currentMessages: string[] = [];
|
||||||
|
let currentTimestamp: string | null = null;
|
||||||
|
|
||||||
|
// Process each line
|
||||||
|
lines.forEach((line) => {
|
||||||
|
line = line.trim();
|
||||||
|
if (!line) {
|
||||||
|
// Empty line, ignore
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a new speaker line with or without datetime
|
||||||
|
// Format 1: [29.05.2025 21:26:44] User: message
|
||||||
|
// Format 2: User: message
|
||||||
|
const datetimeMatch = line.match(
|
||||||
|
/^\[([^\]]+)\]\s*(User|Assistant):\s*(.*)$/
|
||||||
|
);
|
||||||
|
const simpleMatch = line.match(/^(User|Assistant):\s*(.*)$/);
|
||||||
|
|
||||||
|
if (datetimeMatch || simpleMatch) {
|
||||||
|
// If we have accumulated messages for a previous speaker, add them
|
||||||
|
if (currentSpeaker && currentMessages.length > 0) {
|
||||||
|
elements.push(
|
||||||
|
<div
|
||||||
|
key={`message-${elements.length}`}
|
||||||
|
className={`mb-3 ${currentSpeaker === "User" ? "text-right" : ""}`}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={`inline-block px-4 py-2 rounded-lg ${
|
className={`inline-block px-4 py-2 rounded-lg ${
|
||||||
speaker === "User"
|
currentSpeaker === "User"
|
||||||
? "bg-blue-100 text-blue-800"
|
? "bg-blue-100 text-blue-800"
|
||||||
: "bg-gray-100 text-gray-800"
|
: "bg-gray-100 text-gray-800"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{messages.map((msg, i) => (
|
{currentTimestamp && (
|
||||||
|
<div className="text-xs opacity-60 mb-1">
|
||||||
|
{currentTimestamp}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{currentMessages.map((msg, i) => (
|
||||||
|
// Use ReactMarkdown to render each message part
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
key={`msg-${msg.substring(0, 20).replace(/\s/g, "-")}-${i}`}
|
key={i}
|
||||||
rehypePlugins={[rehypeRaw]}
|
rehypePlugins={[rehypeRaw]} // Add rehypeRaw to plugins
|
||||||
components={{
|
components={{
|
||||||
p: "span",
|
p: "span",
|
||||||
a: ({ node, ...props }) => (
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||||
|
a: ({ node: _node, ...props }) => (
|
||||||
<a
|
<a
|
||||||
className="text-sky-600 hover:text-sky-800 underline"
|
className="text-sky-600 hover:text-sky-800 underline"
|
||||||
{...props}
|
{...props}
|
||||||
@ -46,85 +83,72 @@ function renderMessageBubble(
|
|||||||
</div>
|
</div>
|
||||||
</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
|
|
||||||
*/
|
|
||||||
function formatTranscript(content: string): React.ReactNode[] {
|
|
||||||
if (!content.trim()) {
|
|
||||||
return [<p key="empty">No transcript content available.</p>];
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = content.split("\n");
|
|
||||||
const elements: React.ReactNode[] = [];
|
|
||||||
let currentSpeaker: string | null = null;
|
|
||||||
let currentMessages: string[] = [];
|
|
||||||
|
|
||||||
// Process each line
|
|
||||||
for (const line of lines) {
|
|
||||||
const trimmedLine = line.trim();
|
|
||||||
if (!trimmedLine) {
|
|
||||||
continue; // Skip empty lines
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNewSpeakerLine(line)) {
|
|
||||||
// Process any accumulated messages from previous speaker
|
|
||||||
processAccumulatedMessages(currentSpeaker, currentMessages, elements);
|
|
||||||
currentMessages = [];
|
currentMessages = [];
|
||||||
|
}
|
||||||
|
|
||||||
// Set new speaker and add initial content
|
if (datetimeMatch) {
|
||||||
const { speaker, content } = extractSpeakerInfo(trimmedLine);
|
// Format with datetime: [29.05.2025 21:26:44] User: message
|
||||||
currentSpeaker = speaker;
|
currentTimestamp = datetimeMatch[1];
|
||||||
if (content) {
|
currentSpeaker = datetimeMatch[2];
|
||||||
currentMessages.push(content);
|
const messageContent = datetimeMatch[3].trim();
|
||||||
|
if (messageContent) {
|
||||||
|
currentMessages.push(messageContent);
|
||||||
|
}
|
||||||
|
} else if (simpleMatch) {
|
||||||
|
// Format without datetime: User: message
|
||||||
|
currentTimestamp = null;
|
||||||
|
currentSpeaker = simpleMatch[1];
|
||||||
|
const messageContent = simpleMatch[2].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(line);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{currentTimestamp && (
|
||||||
|
<div className="text-xs opacity-60 mb-1">{currentTimestamp}</div>
|
||||||
|
)}
|
||||||
|
{currentMessages.map((msg, i) => (
|
||||||
|
// Use ReactMarkdown to render each message part
|
||||||
|
<ReactMarkdown
|
||||||
|
key={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;
|
||||||
}
|
}
|
||||||
@ -140,6 +164,9 @@ export default function TranscriptViewer({
|
|||||||
|
|
||||||
const formattedElements = formatTranscript(transcriptContent);
|
const formattedElements = formatTranscript(transcriptContent);
|
||||||
|
|
||||||
|
// Hide "View Full Raw" button in production environment
|
||||||
|
const isProduction = process.env.NODE_ENV === "production";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white shadow-lg rounded-lg p-4 md:p-6 mt-6">
|
<div className="bg-white shadow-lg rounded-lg p-4 md:p-6 mt-6">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
@ -147,7 +174,7 @@ export default function TranscriptViewer({
|
|||||||
Session Transcript
|
Session Transcript
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
{transcriptUrl && (
|
{transcriptUrl && !isProduction && (
|
||||||
<a
|
<a
|
||||||
href={transcriptUrl}
|
href={transcriptUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@ -159,7 +186,6 @@ export default function TranscriptViewer({
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={() => setShowRaw(!showRaw)}
|
onClick={() => setShowRaw(!showRaw)}
|
||||||
className="text-sm text-sky-600 hover:text-sky-800 hover:underline"
|
className="text-sm text-sky-600 hover:text-sky-800 hover:underline"
|
||||||
title={
|
title={
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export default function WelcomeBanner({ companyName }: WelcomeBannerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-linear-to-r from-blue-600 to-indigo-700 text-white p-6 rounded-xl shadow-lg mb-8">
|
<div className="bg-gradient-to-r from-blue-600 to-indigo-700 text-white p-6 rounded-xl shadow-lg mb-8">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">
|
<h1 className="text-3xl font-bold">
|
||||||
@ -48,7 +48,7 @@ export default function WelcomeBanner({ companyName }: WelcomeBannerProps) {
|
|||||||
<div className="bg-white/20 backdrop-blur-sm p-4 rounded-lg">
|
<div className="bg-white/20 backdrop-blur-sm p-4 rounded-lg">
|
||||||
<div className="text-sm opacity-75">Current Status</div>
|
<div className="text-sm opacity-75">Current Status</div>
|
||||||
<div className="text-xl font-semibold flex items-center">
|
<div className="text-xl font-semibold flex items-center">
|
||||||
<span className="inline-block w-2 h-2 bg-green-400 rounded-full mr-2" />
|
<span className="inline-block w-2 h-2 bg-green-400 rounded-full mr-2"></span>
|
||||||
All Systems Operational
|
All Systems Operational
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import cloud, { type Word } from "d3-cloud";
|
import { useRef, useEffect, useState } from "react";
|
||||||
import { select } from "d3-selection";
|
import { select } from "d3-selection";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import cloud, { Word } from "d3-cloud";
|
||||||
|
|
||||||
interface WordCloudProps {
|
interface WordCloudProps {
|
||||||
words: {
|
words: {
|
||||||
|
|||||||
@ -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,120 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Bar,
|
|
||||||
BarChart,
|
|
||||||
CartesianGrid,
|
|
||||||
Cell,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
} from "recharts";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
|
|
||||||
interface BarChartData {
|
|
||||||
name: string;
|
|
||||||
value: number;
|
|
||||||
[key: string]: string | number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BarChartProps {
|
|
||||||
data: BarChartData[];
|
|
||||||
title?: string;
|
|
||||||
dataKey?: string;
|
|
||||||
colors?: string[];
|
|
||||||
height?: number;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TooltipProps {
|
|
||||||
active?: boolean;
|
|
||||||
payload?: Array<{ value: number; name?: string }>;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border bg-background p-3 shadow-md">
|
|
||||||
<p className="text-sm font-medium">{label}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
{payload[0].value}
|
|
||||||
</span>{" "}
|
|
||||||
sessions
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ModernBarChart({
|
|
||||||
data,
|
|
||||||
title,
|
|
||||||
dataKey = "value",
|
|
||||||
colors = [
|
|
||||||
"hsl(var(--chart-1))",
|
|
||||||
"hsl(var(--chart-2))",
|
|
||||||
"hsl(var(--chart-3))",
|
|
||||||
"hsl(var(--chart-4))",
|
|
||||||
"hsl(var(--chart-5))",
|
|
||||||
],
|
|
||||||
height = 300,
|
|
||||||
className,
|
|
||||||
}: BarChartProps) {
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
{title && (
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
)}
|
|
||||||
<CardContent>
|
|
||||||
<ResponsiveContainer width="100%" height={height}>
|
|
||||||
<BarChart
|
|
||||||
data={data}
|
|
||||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid
|
|
||||||
strokeDasharray="3 3"
|
|
||||||
stroke="hsl(var(--border))"
|
|
||||||
strokeOpacity={0.3}
|
|
||||||
/>
|
|
||||||
<XAxis
|
|
||||||
dataKey="name"
|
|
||||||
stroke="hsl(var(--muted-foreground))"
|
|
||||||
fontSize={12}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
angle={-45}
|
|
||||||
textAnchor="end"
|
|
||||||
height={80}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
stroke="hsl(var(--muted-foreground))"
|
|
||||||
fontSize={12}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
/>
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
<Bar
|
|
||||||
dataKey={dataKey}
|
|
||||||
radius={[4, 4, 0, 0]}
|
|
||||||
className="transition-all duration-200"
|
|
||||||
>
|
|
||||||
{data.map((entry, index) => (
|
|
||||||
<Cell
|
|
||||||
key={`cell-${entry.name}-${index}`}
|
|
||||||
fill={colors[index % colors.length]}
|
|
||||||
className="hover:opacity-80"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,165 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Cell,
|
|
||||||
Legend,
|
|
||||||
Pie,
|
|
||||||
PieChart,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
|
||||||
} from "recharts";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
|
|
||||||
interface DonutChartProps {
|
|
||||||
data: Array<{ name: string; value: number; color?: string }>;
|
|
||||||
title?: string;
|
|
||||||
centerText?: {
|
|
||||||
title: string;
|
|
||||||
value: string | number;
|
|
||||||
};
|
|
||||||
colors?: string[];
|
|
||||||
height?: number;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TooltipProps {
|
|
||||||
active?: boolean;
|
|
||||||
payload?: Array<{
|
|
||||||
name: string;
|
|
||||||
value: number;
|
|
||||||
payload: { total: number };
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload }: TooltipProps) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
const data = payload[0];
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border bg-background p-3 shadow-md">
|
|
||||||
<p className="text-sm font-medium">{data.name}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
<span className="font-medium text-foreground">{data.value}</span>{" "}
|
|
||||||
sessions ({((data.value / data.payload.total) * 100).toFixed(1)}%)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface LegendProps {
|
|
||||||
payload?: Array<{
|
|
||||||
value: string;
|
|
||||||
color: string;
|
|
||||||
type?: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CustomLegend = ({ payload }: LegendProps) => {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap justify-center gap-4 mt-4">
|
|
||||||
{payload?.map((entry, index) => (
|
|
||||||
<div
|
|
||||||
key={`legend-${entry.value}-${index}`}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-3 h-3 rounded-full"
|
|
||||||
style={{ backgroundColor: entry.color }}
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-muted-foreground">{entry.value}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface CenterLabelProps {
|
|
||||||
centerText?: {
|
|
||||||
title: string;
|
|
||||||
value: string | number;
|
|
||||||
};
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CenterLabel = ({ centerText }: CenterLabelProps) => {
|
|
||||||
if (!centerText) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-2xl font-bold">{centerText.value}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{centerText.title}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ModernDonutChart({
|
|
||||||
data,
|
|
||||||
title,
|
|
||||||
centerText,
|
|
||||||
colors = [
|
|
||||||
"hsl(var(--chart-1))",
|
|
||||||
"hsl(var(--chart-2))",
|
|
||||||
"hsl(var(--chart-3))",
|
|
||||||
"hsl(var(--chart-4))",
|
|
||||||
"hsl(var(--chart-5))",
|
|
||||||
],
|
|
||||||
height = 300,
|
|
||||||
className,
|
|
||||||
}: DonutChartProps) {
|
|
||||||
const total = data.reduce((sum, item) => sum + item.value, 0);
|
|
||||||
const dataWithTotal = data.map((item) => ({ ...item, total }));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
{title && (
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
)}
|
|
||||||
<CardContent>
|
|
||||||
<div
|
|
||||||
className="relative"
|
|
||||||
role="img"
|
|
||||||
aria-label={`${title || "Chart"} - ${data.length} segments`}
|
|
||||||
>
|
|
||||||
<ResponsiveContainer width="100%" height={height}>
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={dataWithTotal}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius={60}
|
|
||||||
outerRadius={100}
|
|
||||||
paddingAngle={2}
|
|
||||||
dataKey="value"
|
|
||||||
className="transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dataWithTotal.map((entry, index) => (
|
|
||||||
<Cell
|
|
||||||
key={`cell-${entry.name}-${index}`}
|
|
||||||
fill={entry.color || colors[index % colors.length]}
|
|
||||||
className="hover:opacity-80 cursor-pointer focus:opacity-80"
|
|
||||||
stroke="hsl(var(--background))"
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
<Legend content={<CustomLegend />} />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
<CenterLabel centerText={centerText} total={total} />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,134 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useId } from "react";
|
|
||||||
import {
|
|
||||||
Area,
|
|
||||||
AreaChart,
|
|
||||||
CartesianGrid,
|
|
||||||
Line,
|
|
||||||
LineChart,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
} from "recharts";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
|
|
||||||
interface LineChartData {
|
|
||||||
date: string;
|
|
||||||
value: number;
|
|
||||||
[key: string]: string | number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LineChartProps {
|
|
||||||
data: LineChartData[];
|
|
||||||
title?: string;
|
|
||||||
dataKey?: string;
|
|
||||||
color?: string;
|
|
||||||
gradient?: boolean;
|
|
||||||
height?: number;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TooltipProps {
|
|
||||||
active?: boolean;
|
|
||||||
payload?: Array<{ value: number; name?: string }>;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border bg-background p-3 shadow-md">
|
|
||||||
<p className="text-sm font-medium">{label}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
{payload[0].value}
|
|
||||||
</span>{" "}
|
|
||||||
sessions
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ModernLineChart({
|
|
||||||
data,
|
|
||||||
title,
|
|
||||||
dataKey = "value",
|
|
||||||
color = "hsl(var(--primary))",
|
|
||||||
gradient = true,
|
|
||||||
height = 300,
|
|
||||||
className,
|
|
||||||
}: LineChartProps) {
|
|
||||||
const gradientId = useId();
|
|
||||||
const ChartComponent = gradient ? AreaChart : LineChart;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
{title && (
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
)}
|
|
||||||
<CardContent>
|
|
||||||
<ResponsiveContainer width="100%" height={height}>
|
|
||||||
<ChartComponent
|
|
||||||
data={data}
|
|
||||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
{gradient && (
|
|
||||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
|
||||||
<stop offset="95%" stopColor={color} stopOpacity={0.05} />
|
|
||||||
</linearGradient>
|
|
||||||
)}
|
|
||||||
</defs>
|
|
||||||
<CartesianGrid
|
|
||||||
strokeDasharray="3 3"
|
|
||||||
stroke="hsl(var(--border))"
|
|
||||||
strokeOpacity={0.3}
|
|
||||||
/>
|
|
||||||
<XAxis
|
|
||||||
dataKey="date"
|
|
||||||
stroke="hsl(var(--muted-foreground))"
|
|
||||||
fontSize={12}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
stroke="hsl(var(--muted-foreground))"
|
|
||||||
fontSize={12}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
/>
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
|
|
||||||
{gradient ? (
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey={dataKey}
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth={2}
|
|
||||||
fill={`url(#${gradientId})`}
|
|
||||||
dot={{ fill: color, strokeWidth: 2, r: 4 }}
|
|
||||||
activeDot={{ r: 6, stroke: color, strokeWidth: 2 }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey={dataKey}
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={{ fill: color, strokeWidth: 2, r: 4 }}
|
|
||||||
activeDot={{ r: 6, stroke: color, strokeWidth: 2 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ChartComponent>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,185 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
import { type RefObject, useEffect, useId, useState } from "react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export interface AnimatedBeamProps {
|
|
||||||
className?: string;
|
|
||||||
containerRef: RefObject<HTMLElement | null>; // Container ref
|
|
||||||
fromRef: RefObject<HTMLElement | null>;
|
|
||||||
toRef: RefObject<HTMLElement | null>;
|
|
||||||
curvature?: number;
|
|
||||||
reverse?: boolean;
|
|
||||||
pathColor?: string;
|
|
||||||
pathWidth?: number;
|
|
||||||
pathOpacity?: number;
|
|
||||||
gradientStartColor?: string;
|
|
||||||
gradientStopColor?: string;
|
|
||||||
delay?: number;
|
|
||||||
duration?: number;
|
|
||||||
startXOffset?: number;
|
|
||||||
startYOffset?: number;
|
|
||||||
endXOffset?: number;
|
|
||||||
endYOffset?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AnimatedBeam: React.FC<AnimatedBeamProps> = ({
|
|
||||||
className,
|
|
||||||
containerRef,
|
|
||||||
fromRef,
|
|
||||||
toRef,
|
|
||||||
curvature = 0,
|
|
||||||
reverse = false, // Include the reverse prop
|
|
||||||
duration = Math.random() * 3 + 4,
|
|
||||||
delay = 0,
|
|
||||||
pathColor = "gray",
|
|
||||||
pathWidth = 2,
|
|
||||||
pathOpacity = 0.2,
|
|
||||||
gradientStartColor = "#ffaa40",
|
|
||||||
gradientStopColor = "#9c40ff",
|
|
||||||
startXOffset = 0,
|
|
||||||
startYOffset = 0,
|
|
||||||
endXOffset = 0,
|
|
||||||
endYOffset = 0,
|
|
||||||
}) => {
|
|
||||||
const id = useId();
|
|
||||||
const [pathD, setPathD] = useState("");
|
|
||||||
const [svgDimensions, setSvgDimensions] = useState({ width: 0, height: 0 });
|
|
||||||
|
|
||||||
// Calculate the gradient coordinates based on the reverse prop
|
|
||||||
const gradientCoordinates = reverse
|
|
||||||
? {
|
|
||||||
x1: ["90%", "-10%"],
|
|
||||||
x2: ["100%", "0%"],
|
|
||||||
y1: ["0%", "0%"],
|
|
||||||
y2: ["0%", "0%"],
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
x1: ["10%", "110%"],
|
|
||||||
x2: ["0%", "100%"],
|
|
||||||
y1: ["0%", "0%"],
|
|
||||||
y2: ["0%", "0%"],
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const updatePath = () => {
|
|
||||||
if (containerRef.current && fromRef.current && toRef.current) {
|
|
||||||
const containerRect = containerRef.current.getBoundingClientRect();
|
|
||||||
const rectA = fromRef.current.getBoundingClientRect();
|
|
||||||
const rectB = toRef.current.getBoundingClientRect();
|
|
||||||
|
|
||||||
const svgWidth = containerRect.width;
|
|
||||||
const svgHeight = containerRect.height;
|
|
||||||
setSvgDimensions({ width: svgWidth, height: svgHeight });
|
|
||||||
|
|
||||||
const startX =
|
|
||||||
rectA.left - containerRect.left + rectA.width / 2 + startXOffset;
|
|
||||||
const startY =
|
|
||||||
rectA.top - containerRect.top + rectA.height / 2 + startYOffset;
|
|
||||||
const endX =
|
|
||||||
rectB.left - containerRect.left + rectB.width / 2 + endXOffset;
|
|
||||||
const endY =
|
|
||||||
rectB.top - containerRect.top + rectB.height / 2 + endYOffset;
|
|
||||||
|
|
||||||
const controlY = startY - curvature;
|
|
||||||
const d = `M ${startX},${startY} Q ${
|
|
||||||
(startX + endX) / 2
|
|
||||||
},${controlY} ${endX},${endY}`;
|
|
||||||
setPathD(d);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize ResizeObserver
|
|
||||||
const resizeObserver = new ResizeObserver((entries) => {
|
|
||||||
// For all entries, recalculate the path
|
|
||||||
for (const _entry of entries) {
|
|
||||||
updatePath();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Observe the container element
|
|
||||||
if (containerRef.current) {
|
|
||||||
resizeObserver.observe(containerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call the updatePath initially to set the initial path
|
|
||||||
updatePath();
|
|
||||||
|
|
||||||
// Clean up the observer on component unmount
|
|
||||||
return () => {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
containerRef,
|
|
||||||
fromRef,
|
|
||||||
toRef,
|
|
||||||
curvature,
|
|
||||||
startXOffset,
|
|
||||||
startYOffset,
|
|
||||||
endXOffset,
|
|
||||||
endYOffset,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
fill="none"
|
|
||||||
width={svgDimensions.width}
|
|
||||||
height={svgDimensions.height}
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className={cn(
|
|
||||||
"pointer-events-none absolute left-0 top-0 transform-gpu stroke-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
viewBox={`0 0 ${svgDimensions.width} ${svgDimensions.height}`}
|
|
||||||
>
|
|
||||||
<title>Animated connection beam</title>
|
|
||||||
<path
|
|
||||||
d={pathD}
|
|
||||||
stroke={pathColor}
|
|
||||||
strokeWidth={pathWidth}
|
|
||||||
strokeOpacity={pathOpacity}
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d={pathD}
|
|
||||||
strokeWidth={pathWidth}
|
|
||||||
stroke={`url(#${id})`}
|
|
||||||
strokeOpacity="1"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
<defs>
|
|
||||||
<motion.linearGradient
|
|
||||||
className="transform-gpu"
|
|
||||||
id={id}
|
|
||||||
gradientUnits={"userSpaceOnUse"}
|
|
||||||
initial={{
|
|
||||||
x1: "0%",
|
|
||||||
x2: "0%",
|
|
||||||
y1: "0%",
|
|
||||||
y2: "0%",
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
x1: gradientCoordinates.x1,
|
|
||||||
x2: gradientCoordinates.x2,
|
|
||||||
y1: gradientCoordinates.y1,
|
|
||||||
y2: gradientCoordinates.y2,
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
delay,
|
|
||||||
duration,
|
|
||||||
ease: [0.16, 1, 0.3, 1], // https://easings.net/#easeOutExpo
|
|
||||||
repeat: Number.POSITIVE_INFINITY,
|
|
||||||
repeatDelay: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<stop stopColor={gradientStartColor} stopOpacity="0" />
|
|
||||||
<stop stopColor={gradientStartColor} />
|
|
||||||
<stop offset="32.5%" stopColor={gradientStopColor} />
|
|
||||||
<stop offset="100%" stopColor={gradientStopColor} stopOpacity="0" />
|
|
||||||
</motion.linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user