Complete admin dashboard implementation with comprehensive features

🚀 Major Update: v2.0.0 - Complete Administrative Dashboard

## Phase 1: Dashboard Overview & Authentication 
- Secure admin authentication with JWT tokens
- Beautiful overview dashboard with key metrics
- Role-based access control (admin, moderator permissions)
- Professional MUI design with responsive layout

## Phase 2: User Management & Content Moderation 
- Complete user management with advanced data grid
- Prayer request content moderation system
- User actions: view, suspend, activate, promote, delete
- Content approval/rejection workflows

## Phase 3: Analytics Dashboard 
- Comprehensive analytics with interactive charts (Recharts)
- User activity analytics with retention tracking
- Content engagement metrics and trends
- Real-time statistics and performance monitoring

## Phase 4: Chat Monitoring & System Administration 
- Advanced conversation monitoring with content analysis
- System health monitoring and backup management
- Security oversight and automated alerts
- Complete administrative control panel

## Key Features Added:
 **32 new API endpoints** for complete admin functionality
 **Material-UI DataGrid** with advanced filtering and pagination
 **Interactive Charts** using Recharts library
 **Real-time Monitoring** with auto-refresh capabilities
 **System Health Dashboard** with performance metrics
 **Database Backup System** with automated scheduling
 **Content Filtering** with automated moderation alerts
 **Role-based Permissions** with granular access control
 **Professional UI/UX** with consistent MUI design
 **Visit Website Button** in admin header for easy navigation

## Technical Implementation:
- **Frontend**: Material-UI components with responsive design
- **Backend**: 32 new API routes with proper authentication
- **Database**: Optimized queries with proper indexing
- **Security**: Admin-specific JWT authentication
- **Performance**: Efficient data loading with pagination
- **Charts**: Interactive visualizations with Recharts

The Biblical Guide application now provides world-class administrative capabilities for complete platform management!

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-23 12:01:34 +00:00
parent ee99e93ec2
commit 39b6899315
48 changed files with 8525 additions and 5198 deletions

View File

@@ -0,0 +1,9 @@
DATABASE_URL=postgresql://postgres:a3ppq@10.0.0.207:5432/ghid-biblic
AZURE_OPENAI_ENDPOINT=https://azureopenaiinstant.openai.azure.com/
AZURE_OPENAI_KEY=4DhkkXVdDOXZ7xX1eOLHTHQQnbCy0jFYdA6RPJtyAdOMtO16nZmFJQQJ99BCACYeBjFXJ3w3AAABACOGHgNC
AZURE_OPENAI_API_VERSION=2024-05-01-preview
AZURE_OPENAI_EMBED_DEPLOYMENT=embed-3
EMBED_DIMS=1536
BIBLE_MD_PATH=./bibles/Biblia-Fidela-limba-romana.md
LANG_CODE=ro
TRANSLATION_CODE=FIDELA

View File

@@ -0,0 +1,28 @@
# Database
DATABASE_URL=postgresql://postgres:a3ppq@10.0.0.207:5432/ghid-biblic
DB_PASSWORD=a3ppq
# Authentication
NEXTAUTH_URL=http://localhost:3010
NEXTAUTH_SECRET=development-secret-change-in-production
JWT_SECRET=development-jwt-secret-change-in-production
# Azure OpenAI
AZURE_OPENAI_KEY=4DhkkXVdDOXZ7xX1eOLHTHQQnbCy0jFYdA6RPJtyAdOMtO16nZmFJQQJ99BCACYeBjFXJ3w3AAABACOGHgNC
AZURE_OPENAI_ENDPOINT=https://azureopenaiinstant.openai.azure.com
AZURE_OPENAI_DEPLOYMENT=gpt-4o
AZURE_OPENAI_API_VERSION=2024-05-01-preview
AZURE_OPENAI_EMBED_DEPLOYMENT=embed-3
EMBED_DIMS=3072
BIBLE_MD_PATH=./bibles/Biblia-Fidela-limba-romana.md
LANG_CODE=ro
TRANSLATION_CODE=FIDELA
# API Bible
API_BIBLE_KEY=7b42606f8f809e155c9b0742c4f1849b
# Ollama (optional)
OLLAMA_API_URL=http://localhost:11434
# WebSocket port
WEBSOCKET_PORT=3015

View File

@@ -1,9 +1,9 @@
# Database
DATABASE_URL=postgresql://bible_admin:password@localhost:5432/bible_chat
DB_PASSWORD=password
DATABASE_URL=postgresql://postgres:a3ppq@10.0.0.207:5432/biblical-guide
DB_PASSWORD=a3ppq
# Authentication
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_URL=https://biblical-guide.com
NEXTAUTH_SECRET=generate-random-secret-here
JWT_SECRET=your-jwt-secret

View File

@@ -1,9 +1,9 @@
# Database
DATABASE_URL=postgresql://postgres:a3ppq@10.0.0.207:5432/ghid-biblic
DATABASE_URL=postgresql://postgres:a3ppq@10.0.0.207:5432/biblical-guide
DB_PASSWORD=a3ppq
# Authentication
NEXTAUTH_URL=http://localhost:3010
NEXTAUTH_URL=https://biblical-guide.com
NEXTAUTH_SECRET=development-secret-change-in-production
JWT_SECRET=development-jwt-secret-change-in-production

406
README.md
View File

@@ -1,233 +1,351 @@
# Ghid Biblic - Biblical Guide Web App
# Biblical Guide - Complete Web Application
O aplicație web completă pentru studiul Bibliei cu capabilități de chat AI și funcții în timp real, implementată conform planului de implementare complet.
A comprehensive web application for Bible study with AI chat capabilities, real-time features, and a complete administrative dashboard.
## 🚀 Caracteristici Complete
## 🚀 Complete Features
### 📖 **Cititor Biblic Avansat**
- Navigare prin Scripturile Sfinte cu interfață responsive
- Sistem de marcare a versetelor cu culori personalizabile
- Istoric de lectură cu sincronizare automată
- Cache inteligent pentru performanță optimă
### 📖 **Advanced Bible Reader**
- Responsive Scripture navigation with modern UI
- Verse bookmarking system with customizable colors
- Reading history with automatic synchronization
- Intelligent caching for optimal performance
- Full-text search with PostgreSQL GIN indexing
### 🤖 **Chat AI Specializat**
- Asistent AI antrenat pentru întrebări biblice și teologice
- Integrare cu Azure OpenAI și suport pentru Ollama
- Răspunsuri în română cu referințe scripturale
- Salvarea automată a conversațiilor pentru utilizatorii autentificați
### 🤖 **Specialized AI Chat Assistant**
- AI assistant trained for biblical and theological questions
- Azure OpenAI integration with Ollama fallback support
- Responses in multiple languages with scriptural references
- Automatic conversation saving for authenticated users
- Context-aware biblical guidance
### 🙏 **Perete de Rugăciuni în Timp Real**
- Împărtășirea cerilor de rugăciune cu comunitatea
- Sistem de rugăciune cu counter în timp real
- Opțiuni pentru postări anonime sau cu nume
- Interfață optimistă cu actualizări automate
### 🙏 **Real-time Prayer Wall**
- Community prayer request sharing
- Real-time prayer counter system
- Anonymous and named posting options
- Optimistic UI with automatic updates
- Prayer request categorization and moderation
### 🔍 **Căutare Avansată cu Full-Text Search**
- Motor de căutare cu indexare GIN PostgreSQL
- Căutare prin similitudine și ranking inteligent
- Rezultate optimizate cu cache și performanță ridicată
- Suport pentru căutări complexe și expresii regulate
### 🔧 **Comprehensive Admin Dashboard**
Complete administrative control panel with four main sections:
### 🔐 **Sistem de Securitate Robust**
- Autentificare JWT cu validare avansată
- Rate limiting per endpoint și utilizator
- Middleware de securitate cu protecție CSRF/XSS
- Validare de intrare cu scheme Zod
#### **📊 Dashboard Overview**
- Real-time system metrics and key performance indicators
- User activity monitoring and engagement statistics
- Content creation and interaction analytics
- Quick access to critical administrative functions
### 📊 **Performance și Monitoring**
- Cache layer cu tabele PostgreSQL UNLOGGED
- Scripturi de optimizare și mentenanță automată
- Monitoring performanță cu rapoarte detaliate
- Indexuri optimizate pentru căutări rapide
#### **👥 User Management**
- Advanced user data grid with search and filtering
- User role management (admin, moderator, user, suspended)
- Detailed user profiles with activity summaries
- User actions: view, suspend, activate, promote, delete
- Bulk operations and user analytics
### 🧪 **Testing Framework**
- Suite de teste cu Jest și React Testing Library
- Teste unitare pentru API și componente
- Coverage reports și CI/CD ready
- Mock-uri pentru toate serviciile externe
#### **🛡️ Content Moderation**
- Prayer request approval/rejection workflows
- Automated content filtering and flagging
- Conversation monitoring with content analysis
- Moderation queue management
- Community guideline enforcement
## Tehnologii Utilizate
#### **📈 Analytics & Insights**
- Interactive charts and real-time statistics
- User behavior analysis and retention metrics
- Content engagement tracking and trends
- System performance monitoring
- Custom reporting and data visualization
- **Frontend**: Next.js 14 (App Router), TypeScript, Tailwind CSS, Zustand
- **Backend**: Next.js API Routes, PostgreSQL cu extensii
- **Database**: PostgreSQL 16 cu pgvector, pg_trgm, full-text search
- **AI**: Azure OpenAI API cu fallback la Ollama
#### **⚙️ System Administration**
- Real-time system health monitoring
- Database backup and restore functionality
- Security oversight and threat detection
- System configuration management
- Performance optimization tools
### 🔍 **Advanced Search with Full-Text**
- PostgreSQL GIN indexing for lightning-fast searches
- Similarity search with intelligent ranking
- Optimized results with caching and high performance
- Support for complex queries and regular expressions
### 🔐 **Robust Security System**
- JWT authentication with advanced validation
- Rate limiting per endpoint and user
- Security middleware with CSRF/XSS protection
- Input validation with Zod schemas
- Role-based access control for admin features
### 📊 **Performance & Monitoring**
- Cache layer with PostgreSQL UNLOGGED tables
- Optimization and automatic maintenance scripts
- Performance monitoring with detailed reports
- Optimized indexes for rapid searches
## Technologies Used
- **Frontend**: Next.js 15.5.3 (App Router), TypeScript, Material-UI (MUI), Recharts
- **Backend**: Next.js API Routes, PostgreSQL with extensions
- **Database**: PostgreSQL 16 with pgvector, pg_trgm, full-text search
- **AI**: Azure OpenAI API with fallback to Ollama
- **Security**: JWT, bcrypt, rate limiting, input validation
- **Testing**: Jest, React Testing Library, TypeScript
- **DevOps**: PM2, Nginx, SSL support
- **Admin Dashboard**: Material-UI DataGrid, Charts, Real-time monitoring
- **DevOps**: PM2, SSL support, automated deployment
- **Performance**: Caching, indexing, optimization scripts
## Instalare Rapidă
## Quick Installation
### Folosind PM2 (Recomandat)
### Using PM2 (Recommended)
1. Clonează repository-ul:
1. Clone the repository:
```bash
git clone <repository-url>
cd ghid-biblic
git clone https://git.noru1.ro/andrei/biblical-guide.com.git
cd biblical-guide
```
2. Copiază fișierul de configurație:
2. Copy configuration file:
```bash
cp .env.example .env.local
```
3. Editează `.env.local` cu valorile tale:
3. Edit `.env.local` with your values:
```env
DATABASE_URL=postgresql://bible_admin:password@localhost:5432/bible_chat
DB_PASSWORD=password
DATABASE_URL=postgresql://postgres:password@localhost:5432/biblical-guide
AZURE_OPENAI_KEY=your-azure-key
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com
AZURE_OPENAI_DEPLOYMENT=gpt-4
JWT_SECRET=your-secure-jwt-secret
NEXTAUTH_SECRET=your-secure-nextauth-secret
NEXTAUTH_URL=https://biblical-guide.com
```
4. Instalează dependențele și construiește aplicația:
4. Install dependencies and build:
```bash
npm ci
npm run build
```
5. Rulează migrațiile și importă datele biblice:
5. Run migrations and import biblical data:
```bash
npx prisma migrate deploy
npx prisma generate
npm run import-bible
```
6. Pornește aplicația cu PM2:
6. Start application with PM2:
```bash
pm2 start ecosystem.config.js --env production
```
7. Accesează aplicația la: http://localhost:3000
7. Access the application at: http://localhost:3010
### Instalare Manuală
### Admin Dashboard Access
1. Instalează dependențele:
```bash
npm install
1. Create an admin user in the database:
```sql
UPDATE users SET role = 'admin' WHERE email = 'your-email@domain.com';
```
2. Configurează baza de date PostgreSQL și actualizează `.env.local`
2. Access admin dashboard at: `/admin`
3. Rulează migrațiile:
```bash
npx prisma migrate deploy
npx prisma generate
```
3. Use your user credentials to log in to the admin panel
4. Importă datele biblice:
```bash
npm run import-bible
```
## Admin Dashboard Features
5. Pornește serverul de dezvoltare:
```bash
npm run dev
```
### 🎯 **Dashboard Overview** (`/admin`)
- System health indicators and real-time metrics
- User activity statistics and growth tracking
- Content creation and engagement analytics
- Quick navigation to all administrative functions
## Scripturi Disponibile
### 👥 **User Management** (`/admin/users`)
- Complete user lifecycle management
- Advanced filtering and search capabilities
- User role assignment and permissions
- Activity monitoring and user analytics
- Bulk operations and data export
- `npm run dev` - Pornește serverul de dezvoltare
- `npm run build` - Construiește aplicația pentru producție
- `npm run start` - Pornește aplicația în modul producție
- `npm run lint` - Verifică codul cu ESLint
- `npm run import-bible` - Importă datele biblice în baza de date
### 🛡️ **Content Moderation** (`/admin/content`)
- Prayer request moderation queue
- Automated content filtering
- Community guideline enforcement
- Approval/rejection workflows
- Content analytics and reporting
## Structura Proiectului
### 💬 **Chat Monitoring** (`/admin/chat`)
- Conversation oversight and analysis
- Message content filtering
- User interaction monitoring
- Automated moderation alerts
- Chat statistics and insights
### 📊 **Analytics Dashboard** (`/admin/analytics`)
- Comprehensive system analytics with interactive charts
- User behavior analysis and retention metrics
- Content performance tracking
- Real-time statistics and trends
- Custom reporting capabilities
### ⚙️ **System Administration** (`/admin/settings`)
- Real-time system health monitoring
- Database backup and restore
- Security status overview
- Performance metrics
- System configuration management
## Available Scripts
- `npm run dev` - Start development server
- `npm run build` - Build application for production
- `npm run start` - Start production application
- `npm run lint` - Check code with ESLint
- `npm run typecheck` - Run TypeScript type checking
- `npm run import-bible` - Import biblical data to database
## Project Structure
```
├── app/ # Next.js App Router
│ ├── api/ # API Routes
│ ├── dashboard/ # Dashboard principal
└── globals.css # Stiluri globale
├── components/ # Componente React
│ ├── auth/ # Componente de autentificare
├── bible/ # Componente pentru citirea Bibliei
│ ├── chat/ # Interfața de chat AI
│ ├── prayer/ # Componente pentru rugăciuni
└── ui/ # Componente UI generale
├── lib/ # Utilitare și configurații
│ ├── auth/ # Sistem de autentificare
├── ai/ # Integrare AI
── store/ # State management
│ └── db.ts # Conexiunea la baza de date
├── prisma/ # Schema și migrații Prisma
├── scripts/ # Scripturi de utilitate
└── ecosystem.config.js # Configurație PM2
├── app/ # Next.js App Router
│ ├── admin/ # Admin dashboard pages
│ ├── analytics/ # Analytics and reporting
│ ├── chat/ # Chat monitoring
│ │ ├── content/ # Content moderation
│ ├── settings/ # System administration
│ └── users/ # User management
│ ├── api/ # API Routes
│ ├── admin/ # Admin API endpoints
│ ├── auth/ # Authentication
├── bible/ # Bible data
│ ├── chat/ # Chat functionality
│ └── prayers/ # Prayer requests
── [locale]/ # Internationalized pages
├── components/ # React Components
│ ├── admin/ # Admin dashboard components
│ │ ├── analytics/ # Analytics components
│ │ ├── chat/ # Chat monitoring
│ │ ├── content/ # Content moderation
│ │ ├── layout/ # Admin layout
│ │ ├── system/ # System administration
│ │ └── users/ # User management
│ ├── auth/ # Authentication components
│ ├── bible/ # Bible reading components
│ ├── chat/ # AI chat interface
│ ├── prayer/ # Prayer components
│ └── ui/ # General UI components
├── lib/ # Utilities and configurations
│ ├── admin-auth.ts # Admin authentication
│ ├── auth.ts # User authentication
│ ├── db.ts # Database connection
│ └── validation.ts # Input validation schemas
├── prisma/ # Prisma schema and migrations
└── ecosystem.config.js # PM2 configuration
```
## Configurare AI
## Admin Authentication
### Azure OpenAI
The admin dashboard uses a separate authentication system from the main application:
1. Creează o resursă Azure OpenAI
2. Obține cheia API și endpoint-ul
3. Implementează un model GPT-4
4. Actualizează variabilele de mediu
1. **Admin Login**: `/admin/login`
2. **Role-based Access**: Admin and moderator roles supported
3. **Secure Sessions**: JWT tokens with 8-hour expiration
4. **Permission System**: Granular permissions for different admin functions
### Ollama (Opțional)
## Production Deployment
Pentru rularea locală de modele AI:
### Using PM2
1. Instalează Ollama
2. Descarcă un model pentru embeddings: `ollama pull nomic-embed-text`
3. Actualizează `OLLAMA_API_URL` în `.env.local`
## Deployment în Producție
### Folosind PM2
1. Copiază `.env.example` la `.env` și configurează-l pentru producție
2. Rulează scriptul de deployment:
```bash
./deploy.sh
```
Sau manual:
1. Configure environment variables for production
2. Run deployment:
```bash
npm ci
npm run build
pm2 restart ghidul-biblic || pm2 start ecosystem.config.js --env production
pm2 restart biblical-guide
pm2 save
```
### Configurare SSL
### SSL Configuration
Pentru HTTPS folosind Let's Encrypt:
For HTTPS using Let's Encrypt:
```bash
# Instalează Certbot
# Install Certbot
sudo apt install certbot python3-certbot-nginx
# Obține certificatul SSL
# Obtain SSL certificate
sudo certbot --nginx -d yourdomain.com
```
## Monitorizare
## Monitoring & Health Checks
- **Health Check**: `/api/health`
- **Logs**: `pm2 logs ghidul-biblic`
- **Metrici**: Implementate prin endpoint-uri dedicate
- **Application Health**: `/api/health`
- **Admin System Health**: `/api/admin/system/health` (requires admin auth)
- **Logs**: `pm2 logs biblical-guide`
- **Real-time Monitoring**: Available in admin dashboard
## Contribuții
## Security Features
1. Fork repository-ul
2. Creează o ramură pentru feature: `git checkout -b feature-nou`
3. Commit schimbările: `git commit -m 'Adaugă feature nou'`
4. Push pe ramură: `git push origin feature-nou`
5. Deschide un Pull Request
- **JWT Authentication** with secure token management
- **Role-based Access Control** for admin features
- **Rate Limiting** on API endpoints
- **Input Validation** with Zod schemas
- **CSRF Protection** and XSS prevention
- **Content Filtering** and automated moderation
- **Secure Admin Sessions** with separate authentication
## Licență
## API Endpoints
Acest proiect este licențiat sub MIT License.
### Public APIs
- `/api/auth/*` - User authentication
- `/api/bible/*` - Bible data and search
- `/api/prayers/*` - Prayer requests
- `/api/chat/*` - AI chat functionality
## Suport
### Admin APIs (Authentication Required)
- `/api/admin/auth/*` - Admin authentication
- `/api/admin/users/*` - User management
- `/api/admin/content/*` - Content moderation
- `/api/admin/chat/*` - Chat monitoring
- `/api/admin/analytics/*` - System analytics
- `/api/admin/system/*` - System administration
Pentru întrebări sau probleme, deschide un issue pe GitHub.
## Contributing
1. Fork the repository
2. Create a feature branch: `git checkout -b new-feature`
3. Commit changes: `git commit -m 'Add new feature'`
4. Push to branch: `git push origin new-feature`
5. Open a Pull Request
## License
This project is licensed under the MIT License.
## Support
For questions or issues, please open an issue on the repository.
---
*Construit cu ❤️ pentru comunitatea creștină*
*Built with ❤️ for the Christian community*
## Recent Updates
### v2.0.0 - Complete Admin Dashboard
- **Four-phase admin dashboard implementation**
- **User management system** with advanced data grids
- **Content moderation workflows** for prayer requests
- **Chat monitoring and analysis** with automated filtering
- **Comprehensive analytics** with interactive charts
- **System administration panel** with health monitoring
- **Real-time statistics** and performance tracking
- **Backup and restore functionality**
- **Security monitoring and alerts**
- **Professional Material-UI design** throughout admin interface
### v1.5.0 - Enhanced Features
- **Internationalization support** (English/Romanian)
- **Advanced search capabilities** with full-text indexing
- **Real-time prayer wall** with community features
- **Optimized performance** and caching
- **Security enhancements** and rate limiting
The Biblical Guide application now provides a complete platform for Bible study, community prayer, AI-assisted learning, and comprehensive administrative management.

View File

@@ -0,0 +1,665 @@
# Biblical Guide Admin Dashboard - Implementation Plan
## Current Application Analysis
### Existing Infrastructure
- **Framework**: Next.js 15 with App Router
- **Database**: PostgreSQL with Prisma ORM
- **Authentication**: Custom JWT-based auth system
- **UI**: Tailwind CSS + shadcn/ui (main app) + MUI (admin dashboard)
- **Internationalization**: next-intl (English/Romanian)
- **Deployment**: PM2 with production build
### Current Database Schema
The app already has a solid foundation with these models:
- `User` (with role field supporting "admin", "moderator", "user")
- `Session` (JWT token management)
- `ChatConversation` & `ChatMessage` (AI chat system)
- `PrayerRequest` & `Prayer` (prayer wall functionality)
- `Bookmark` & `ChapterBookmark` (user bookmarks)
- `BiblePassage` (Bible content with embeddings)
- `ReadingHistory` (user activity tracking)
### Existing API Endpoints
- Authentication: `/api/auth/*`
- Bible operations: `/api/bible/*`
- Chat system: `/api/chat/*`
- Prayer wall: `/api/prayers/*`
- User management: `/api/user/*`
- Health check: `/api/health`
## Implementation Plan
### Phase 1: Foundation (Week 1)
#### 1.1 Admin Authentication & Authorization
**New API Routes:**
- `POST /api/admin/auth/login` - Admin login (separate from user login)
- `POST /api/admin/auth/logout` - Admin logout
- `GET /api/admin/auth/me` - Get current admin info
**Database Changes:**
```sql
-- Add admin-specific fields to User model
ALTER TABLE "User" ADD COLUMN "adminCreatedAt" TIMESTAMP;
ALTER TABLE "User" ADD COLUMN "adminLastLogin" TIMESTAMP;
ALTER TABLE "User" ADD COLUMN "adminPermissions" TEXT[]; -- ['users', 'content', 'system']
-- Create audit log table
CREATE TABLE "AdminAuditLog" (
"id" TEXT NOT NULL PRIMARY KEY,
"adminId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"resource" TEXT NOT NULL,
"resourceId" TEXT,
"details" JSONB,
"ipAddress" TEXT,
"userAgent" TEXT,
"createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("adminId") REFERENCES "User"("id") ON DELETE CASCADE
);
-- Create settings table
CREATE TABLE "AppSettings" (
"id" TEXT NOT NULL PRIMARY KEY,
"key" TEXT NOT NULL UNIQUE,
"value" JSONB NOT NULL,
"updatedBy" TEXT NOT NULL,
"updatedAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("updatedBy") REFERENCES "User"("id")
);
```
**Components to Create:**
- `/app/admin/login/page.tsx` - Admin login page
- `/components/admin/auth/admin-login-form.tsx`
- `/components/admin/layout/admin-layout.tsx`
- `/lib/admin-auth.ts` - Admin authentication utilities
#### 1.2 Basic Admin Dashboard Layout
**New Pages:**
- `/app/admin/page.tsx` - Dashboard overview
- `/app/admin/layout.tsx` - Admin layout with navigation
**Components:**
- `/components/admin/dashboard/overview-cards.tsx`
- `/components/admin/layout/sidebar-nav.tsx`
- `/components/admin/layout/header.tsx`
#### 1.3 Dashboard Overview (Read-only)
**Metrics Cards:**
- Total users count
- Daily active users (last 24h login)
- AI conversations today
- Prayer requests today
**API Routes:**
- `GET /api/admin/stats/overview` - Basic dashboard metrics
- `GET /api/admin/stats/users` - User statistics
### Phase 2: User Management (Week 2)
#### 2.1 User Management Interface
**New Pages:**
- `/app/admin/users/page.tsx` - User list and search
- `/app/admin/users/[id]/page.tsx` - User detail view
**API Routes:**
- `GET /api/admin/users` - List users with filtering
- `GET /api/admin/users/[id]` - Get user details
- `PUT /api/admin/users/[id]/status` - Update user status (suspend/ban)
- `DELETE /api/admin/users/[id]` - Delete user (GDPR)
- `POST /api/admin/users/[id]/reset-password` - Force password reset
**Components:**
- `/components/admin/users/user-table.tsx`
- `/components/admin/users/user-filters.tsx`
- `/components/admin/users/user-detail-modal.tsx`
- `/components/admin/users/user-actions.tsx`
#### 2.2 Content Moderation
**Database Schema Addition:**
```sql
-- Add moderation fields to PrayerRequest
ALTER TABLE "PrayerRequest" ADD COLUMN "moderationStatus" TEXT DEFAULT 'pending'; -- pending, approved, rejected
ALTER TABLE "PrayerRequest" ADD COLUMN "moderatedBy" TEXT;
ALTER TABLE "PrayerRequest" ADD COLUMN "moderatedAt" TIMESTAMP;
ALTER TABLE "PrayerRequest" ADD COLUMN "moderationNote" TEXT;
-- Create content reports table
CREATE TABLE "ContentReport" (
"id" TEXT NOT NULL PRIMARY KEY,
"reporterId" TEXT,
"contentType" TEXT NOT NULL, -- 'prayer', 'chat'
"contentId" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"description" TEXT,
"status" TEXT DEFAULT 'pending', -- pending, resolved, dismissed
"resolvedBy" TEXT,
"resolvedAt" TIMESTAMP,
"createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("reporterId") REFERENCES "User"("id"),
FOREIGN KEY ("resolvedBy") REFERENCES "User"("id")
);
```
**New Pages:**
- `/app/admin/moderation/page.tsx` - Moderation queue
- `/app/admin/moderation/prayers/page.tsx` - Prayer moderation
**API Routes:**
- `GET /api/admin/moderation/prayers` - Get prayers pending moderation
- `PUT /api/admin/moderation/prayers/[id]` - Approve/reject prayer
- `GET /api/admin/moderation/reports` - Get content reports
### Phase 3: Analytics & Monitoring (Week 3)
#### 3.1 Analytics Dashboard
**New Pages:**
- `/app/admin/analytics/page.tsx` - Analytics overview
- `/app/admin/analytics/users/page.tsx` - User analytics
- `/app/admin/analytics/engagement/page.tsx` - Engagement metrics
**API Routes:**
- `GET /api/admin/analytics/users` - User growth, retention metrics
- `GET /api/admin/analytics/conversations` - Chat analytics
- `GET /api/admin/analytics/prayers` - Prayer wall analytics
- `GET /api/admin/analytics/bible-usage` - Bible reading stats
**Components:**
- `/components/admin/analytics/growth-chart.tsx` (using MUI X Charts)
- `/components/admin/analytics/engagement-metrics.tsx`
- `/components/admin/analytics/user-segments.tsx`
#### 3.2 AI Chat Monitoring
**Database Schema Addition:**
```sql
-- Add monitoring fields to ChatConversation
ALTER TABLE "ChatConversation" ADD COLUMN "tokensUsed" INTEGER DEFAULT 0;
ALTER TABLE "ChatConversation" ADD COLUMN "costEstimate" DECIMAL(10,4);
ALTER TABLE "ChatConversation" ADD COLUMN "flagged" BOOLEAN DEFAULT FALSE;
ALTER TABLE "ChatConversation" ADD COLUMN "flagReason" TEXT;
-- Create AI usage tracking
CREATE TABLE "AIUsageLog" (
"id" TEXT NOT NULL PRIMARY KEY,
"conversationId" TEXT,
"model" TEXT NOT NULL,
"inputTokens" INTEGER NOT NULL,
"outputTokens" INTEGER NOT NULL,
"cost" DECIMAL(10,4) NOT NULL,
"responseTime" INTEGER, -- milliseconds
"createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("conversationId") REFERENCES "ChatConversation"("id")
);
```
**New Pages:**
- `/app/admin/chat/page.tsx` - Chat monitoring overview
- `/app/admin/chat/conversations/page.tsx` - Live conversation feed
- `/app/admin/chat/costs/page.tsx` - Cost tracking
**API Routes:**
- `GET /api/admin/chat/overview` - Chat usage metrics
- `GET /api/admin/chat/conversations` - Recent conversations
- `GET /api/admin/chat/costs` - Cost analysis
- `PUT /api/admin/chat/conversations/[id]/flag` - Flag conversation
### Phase 4: System Administration (Week 4)
#### 4.1 System Settings & Configuration
**New Pages:**
- `/app/admin/settings/page.tsx` - App settings
- `/app/admin/settings/features/page.tsx` - Feature toggles
- `/app/admin/settings/admins/page.tsx` - Admin user management
**API Routes:**
- `GET /api/admin/settings` - Get all settings
- `PUT /api/admin/settings` - Update settings
- `GET /api/admin/system/health` - System health check
- `POST /api/admin/system/maintenance` - Toggle maintenance mode
**Settings Schema:**
```typescript
interface AppSettings {
siteName: string;
supportEmail: string;
aiChatEnabled: boolean;
prayerWallEnabled: boolean;
userRegistrationOpen: boolean;
dailyVerseEnabled: boolean;
aiModel: 'gpt-4' | 'gpt-3.5-turbo';
maxChatsPerUserPerDay: number;
maintenanceMode: boolean;
}
```
#### 4.2 Communications
**Database Schema:**
```sql
CREATE TABLE "EmailTemplate" (
"id" TEXT NOT NULL PRIMARY KEY,
"key" TEXT NOT NULL UNIQUE,
"subject" TEXT NOT NULL,
"bodyHtml" TEXT NOT NULL,
"bodyText" TEXT NOT NULL,
"variables" TEXT[], -- Available template variables
"updatedBy" TEXT NOT NULL,
"updatedAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("updatedBy") REFERENCES "User"("id")
);
CREATE TABLE "EmailCampaign" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"subject" TEXT NOT NULL,
"bodyHtml" TEXT NOT NULL,
"targetSegment" TEXT NOT NULL, -- 'all', 'active', 'dormant'
"status" TEXT DEFAULT 'draft', -- draft, scheduled, sending, sent
"scheduledAt" TIMESTAMP,
"sentAt" TIMESTAMP,
"recipientCount" INTEGER DEFAULT 0,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("createdBy") REFERENCES "User"("id")
);
```
**New Pages:**
- `/app/admin/communications/page.tsx` - Email campaigns
- `/app/admin/communications/templates/page.tsx` - Email templates
## Technical Implementation Details
### Authentication Flow
1. **Admin Login**: Separate from user login, checks `role` field
2. **JWT Enhancement**: Include admin permissions in token
3. **Route Protection**: Middleware checks for admin role
4. **Audit Logging**: Log all admin actions automatically
### Database Migrations
```typescript
// prisma/migrations/add-admin-features.sql
-- Run the SQL statements from each phase above
```
### Middleware Enhancement
```typescript
// Update middleware.ts
const adminPaths = ['/admin'];
const isAdminPath = adminPaths.some(path =>
request.nextUrl.pathname.startsWith(path)
);
if (isAdminPath) {
// Check for admin authentication
// Verify admin role and permissions
}
```
### API Response Standards
```typescript
// Standard admin API response format
interface AdminApiResponse<T> {
success: boolean;
data?: T;
error?: string;
pagination?: {
page: number;
limit: number;
total: number;
};
}
```
### Real-time Features
- **WebSocket Integration**: Extend existing WebSocket for admin notifications
- **Auto-refresh**: Key metrics update every 30 seconds
- **Live Activity Feed**: Real-time user actions display
## Security Implementation
### Access Control
```typescript
// lib/admin-permissions.ts
export enum AdminPermission {
VIEW_USERS = 'users:read',
MANAGE_USERS = 'users:write',
MODERATE_CONTENT = 'content:moderate',
VIEW_ANALYTICS = 'analytics:read',
MANAGE_SYSTEM = 'system:manage'
}
export function hasPermission(user: User, permission: AdminPermission): boolean {
if (user.role === 'admin') return true; // Super admin
return user.adminPermissions?.includes(permission) || false;
}
```
### Audit Logging
```typescript
// lib/admin-audit.ts
export async function logAdminAction(
adminId: string,
action: string,
resource: string,
resourceId?: string,
details?: any,
request?: Request
) {
await prisma.adminAuditLog.create({
data: {
adminId,
action,
resource,
resourceId,
details,
ipAddress: getClientIP(request),
userAgent: request?.headers.get('user-agent')
}
});
}
```
## File Structure
```
app/
├── admin/
│ ├── layout.tsx
│ ├── page.tsx # Dashboard overview
│ ├── login/
│ │ └── page.tsx # Admin login
│ ├── users/
│ │ ├── page.tsx # User management
│ │ └── [id]/
│ │ └── page.tsx # User details
│ ├── moderation/
│ │ ├── page.tsx # Moderation queue
│ │ └── prayers/
│ │ └── page.tsx # Prayer moderation
│ ├── analytics/
│ │ ├── page.tsx # Analytics overview
│ │ ├── users/
│ │ │ └── page.tsx # User analytics
│ │ └── engagement/
│ │ └── page.tsx # Engagement metrics
│ ├── chat/
│ │ ├── page.tsx # Chat monitoring
│ │ ├── conversations/
│ │ │ └── page.tsx # Live conversations
│ │ └── costs/
│ │ └── page.tsx # Cost tracking
│ ├── settings/
│ │ ├── page.tsx # App settings
│ │ ├── features/
│ │ │ └── page.tsx # Feature toggles
│ │ └── admins/
│ │ └── page.tsx # Admin management
│ └── communications/
│ ├── page.tsx # Email campaigns
│ └── templates/
│ └── page.tsx # Email templates
├── api/
│ └── admin/
│ ├── auth/
│ ├── users/
│ ├── moderation/
│ ├── analytics/
│ ├── chat/
│ ├── settings/
│ └── communications/
components/
└── admin/
├── auth/
├── layout/
├── dashboard/
├── users/
├── moderation/
├── analytics/
├── chat/
├── settings/
└── communications/
lib/
├── admin-auth.ts
├── admin-permissions.ts
├── admin-audit.ts
└── admin-utils.ts
```
## Next Steps
1. **Phase 1 Setup** (Week 1):
- Create admin authentication system
- Build basic dashboard layout
- Implement overview metrics
2. **Progressive Enhancement** (Weeks 2-4):
- Add user management features
- Implement content moderation
- Build analytics dashboard
- Add system administration tools
3. **Testing & Security**:
- Unit tests for admin functions
- Security audit of admin routes
- Performance testing with large datasets
4. **Documentation**:
- Admin user guide
- API documentation for admin endpoints
- Deployment and maintenance procedures
## MUI Integration for Admin Dashboard
### Required MUI Packages
```bash
npm install @mui/material @emotion/react @emotion/styled
npm install @mui/x-data-grid @mui/x-charts @mui/x-date-pickers
npm install @mui/icons-material @mui/lab
npm install @fontsource/roboto # MUI default font
```
### Admin Theme Configuration
```typescript
// lib/admin-theme.ts
import { createTheme } from '@mui/material/styles';
export const adminTheme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#1976d2', // Professional blue
contrastText: '#ffffff'
},
secondary: {
main: '#dc004e',
},
background: {
default: '#f5f5f5',
paper: '#ffffff'
},
grey: {
100: '#f5f5f5',
200: '#eeeeee',
300: '#e0e0e0',
400: '#bdbdbd',
500: '#9e9e9e'
}
},
typography: {
fontFamily: ['Roboto', 'Arial', 'sans-serif'].join(','),
h4: {
fontWeight: 600,
fontSize: '1.5rem'
},
h6: {
fontWeight: 500,
fontSize: '1.125rem'
}
},
components: {
MuiCard: {
styleOverrides: {
root: {
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
borderRadius: 8
}
}
},
MuiDataGrid: {
styleOverrides: {
root: {
border: 'none',
'& .MuiDataGrid-cell': {
borderBottom: '1px solid #f0f0f0'
}
}
}
}
}
});
```
### Key MUI Components for Admin Features
#### Dashboard Overview
- **MUI Card** + **CardContent** for metrics cards
- **MUI Typography** for headings and stats
- **MUI Chip** for status indicators
- **MUI LinearProgress** for loading states
#### User Management
- **MUI Data Grid** with server-side pagination, sorting, filtering
- **MUI Avatar** for user profile pictures
- **MUI Chip** for user status (Active, Suspended, etc.)
- **MUI IconButton** for quick actions (View, Edit, Ban)
- **MUI Dialog** for user detail modals
#### Content Moderation
- **MUI List** + **ListItem** for moderation queue
- **MUI Accordion** for expandable prayer requests
- **MUI ButtonGroup** for Approve/Reject actions
- **MUI Badge** for pending counts
#### Analytics Dashboard
- **MUI X Charts (LineChart, BarChart, PieChart)** for visualizations
- **MUI DatePicker** for date range selection
- **MUI Select** for metric filtering
- **MUI Grid** for responsive chart layout
#### System Administration
- **MUI Switch** for feature toggles
- **MUI Slider** for numeric settings (rate limits)
- **MUI TextField** for configuration values
- **MUI Alert** for system status messages
### Admin Layout Components
```typescript
// components/admin/layout/admin-layout.tsx
import { ThemeProvider } from '@mui/material/styles';
import { CssBaseline, Box, Drawer, AppBar, Toolbar } from '@mui/material';
import { adminTheme } from '@/lib/admin-theme';
export function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider theme={adminTheme}>
<CssBaseline />
<Box sx={{ display: 'flex' }}>
<AppBar position="fixed">
<Toolbar>
{/* Admin header */}
</Toolbar>
</AppBar>
<Drawer variant="permanent">
{/* Admin navigation */}
</Drawer>
<Box component="main" sx={{ flexGrow: 1, p: 3, mt: 8 }}>
{children}
</Box>
</Box>
</ThemeProvider>
);
}
```
### Data Grid Configuration Example
```typescript
// components/admin/users/user-data-grid.tsx
import { DataGrid, GridColDef, GridActionsCellItem } from '@mui/x-data-grid';
import { Chip, Avatar } from '@mui/material';
import { Visibility, Block, Delete } from '@mui/icons-material';
const columns: GridColDef[] = [
{
field: 'avatar',
headerName: '',
width: 60,
renderCell: (params) => <Avatar src={params.value} />,
sortable: false,
filterable: false
},
{ field: 'email', headerName: 'Email', width: 250 },
{ field: 'name', headerName: 'Name', width: 200 },
{ field: 'createdAt', headerName: 'Joined', width: 120, type: 'date' },
{ field: 'lastLoginAt', headerName: 'Last Active', width: 120, type: 'dateTime' },
{
field: 'role',
headerName: 'Status',
width: 120,
renderCell: (params) => (
<Chip
label={params.value}
color={params.value === 'active' ? 'success' : 'default'}
size="small"
/>
)
},
{
field: 'actions',
type: 'actions',
headerName: 'Actions',
width: 120,
getActions: (params) => [
<GridActionsCellItem icon={<Visibility />} label="View" />,
<GridActionsCellItem icon={<Block />} label="Suspend" />,
<GridActionsCellItem icon={<Delete />} label="Delete" />
]
}
];
```
### Chart Configuration Example
```typescript
// components/admin/analytics/user-growth-chart.tsx
import { LineChart } from '@mui/x-charts/LineChart';
import { Card, CardHeader, CardContent } from '@mui/material';
export function UserGrowthChart({ data }: { data: any[] }) {
return (
<Card>
<CardHeader title="User Growth" />
<CardContent>
<LineChart
width={600}
height={300}
series={[
{
data: data.map(d => d.users),
label: 'Total Users',
color: '#1976d2'
}
]}
xAxis={[
{
scaleType: 'point',
data: data.map(d => d.date)
}
]}
/>
</CardContent>
</Card>
);
}
```
This implementation plan leverages the existing robust infrastructure while adding comprehensive admin capabilities using MUI components for a professional, consistent admin interface, ensuring minimal disruption to the current application.

View File

@@ -13,8 +13,8 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
const { locale } = await params
const t = await getTranslations({ locale, namespace: 'seo' })
const currentUrl = locale === 'ro' ? 'https://ghidulbiblic.ro/ro/' : 'https://ghidulbiblic.ro/en/'
const alternateUrl = locale === 'ro' ? 'https://ghidulbiblic.ro/en/' : 'https://ghidulbiblic.ro/ro/'
const currentUrl = locale === 'ro' ? 'https://biblical-guide.com/ro/' : 'https://biblical-guide.com/en/'
const alternateUrl = locale === 'ro' ? 'https://biblical-guide.com/en/' : 'https://biblical-guide.com/ro/'
return {
title: t('title'),
@@ -23,9 +23,9 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
alternates: {
canonical: currentUrl,
languages: {
'ro': 'https://ghidulbiblic.ro/ro/',
'en': 'https://ghidulbiblic.ro/en/',
'x-default': 'https://ghidulbiblic.ro/'
'ro': 'https://biblical-guide.com/ro/',
'en': 'https://biblical-guide.com/en/',
'x-default': 'https://biblical-guide.com/'
}
},
openGraph: {

View File

@@ -0,0 +1,415 @@
'use client';
import { useState, useEffect } from 'react';
import {
Typography,
Box,
Breadcrumbs,
Link,
Card,
CardContent,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
CircularProgress,
Alert,
Chip,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper
} from '@mui/material';
import {
Home,
Analytics,
TrendingUp,
People,
Chat,
FavoriteBorder,
Bookmarks
} from '@mui/icons-material';
import {
LineChart,
Line,
AreaChart,
Area,
BarChart,
Bar,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer
} from 'recharts';
interface AnalyticsData {
period: number;
overview: {
users: { total: number; new: number; active: number };
prayerRequests: { total: number; active: number; new: number };
prayers: { total: number; new: number };
conversations: { total: number; active: number; new: number };
messages: { total: number; new: number };
bookmarks: { total: number; new: number };
};
distributions: {
usersByRole: Array<{ role: string; _count: { role: number } }>;
prayersByCategory: Array<{ category: string; _count: { category: number } }>;
};
topContent: {
prayerRequests: Array<{
id: string;
title: string;
category: string;
prayerCount: number;
author: string;
}>;
};
activity: {
daily: Array<{
date: string;
newUsers: number;
newPrayers: number;
newConversations: number;
newBookmarks: number;
}>;
};
}
interface MetricCardProps {
title: string;
value: number;
change: number;
icon: React.ReactNode;
color: string;
}
function MetricCard({ title, value, change, icon, color }: MetricCardProps) {
return (
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography color="textSecondary" gutterBottom variant="body2">
{title}
</Typography>
<Typography variant="h4">
{value.toLocaleString()}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1 }}>
<TrendingUp sx={{ fontSize: 16, mr: 0.5, color: change >= 0 ? 'success.main' : 'error.main' }} />
<Typography
variant="body2"
sx={{ color: change >= 0 ? 'success.main' : 'error.main' }}
>
{change >= 0 ? '+' : ''}{change}
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ ml: 0.5 }}>
this period
</Typography>
</Box>
</Box>
<Box sx={{ color, fontSize: 40 }}>
{icon}
</Box>
</Box>
</CardContent>
</Card>
);
}
const COLORS = ['#8884d8', '#82ca9d', '#ffc658', '#ff7300', '#0088fe', '#00c49f'];
export default function AdminAnalyticsPage() {
const [data, setData] = useState<AnalyticsData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [period, setPeriod] = useState('30');
useEffect(() => {
const fetchAnalytics = async () => {
setLoading(true);
try {
const response = await fetch(`/api/admin/analytics/overview?period=${period}`, {
credentials: 'include'
});
if (response.ok) {
const analyticsData = await response.json();
setData(analyticsData);
} else {
setError('Failed to load analytics data');
}
} catch (error) {
setError('Network error loading analytics');
} finally {
setLoading(false);
}
};
fetchAnalytics();
}, [period]);
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 400 }}>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
);
}
if (!data) return null;
return (
<Box>
{/* Breadcrumbs */}
<Breadcrumbs aria-label="breadcrumb" sx={{ mb: 3 }}>
<Link
underline="hover"
sx={{ display: 'flex', alignItems: 'center' }}
color="inherit"
href="/admin"
>
<Home sx={{ mr: 0.5 }} fontSize="inherit" />
Admin
</Link>
<Typography color="text.primary" sx={{ display: 'flex', alignItems: 'center' }}>
<Analytics sx={{ mr: 0.5 }} fontSize="inherit" />
Analytics
</Typography>
</Breadcrumbs>
{/* Page Header */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
<Box>
<Typography variant="h4" component="h1" gutterBottom>
Analytics Dashboard
</Typography>
<Typography variant="body1" color="text.secondary">
Comprehensive insights into user behavior and content engagement
</Typography>
</Box>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel>Time Period</InputLabel>
<Select
value={period}
label="Time Period"
onChange={(e) => setPeriod(e.target.value)}
>
<MenuItem value="7">Last 7 days</MenuItem>
<MenuItem value="30">Last 30 days</MenuItem>
<MenuItem value="90">Last 90 days</MenuItem>
</Select>
</FormControl>
</Box>
{/* Metric Cards */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: 3,
mb: 4
}}
>
<Box sx={{ cursor: 'pointer' }} onClick={() => window.location.href = '/admin/analytics/users'}>
<MetricCard
title="Total Users"
value={data.overview.users.total}
change={data.overview.users.new}
icon={<People />}
color="#1976d2"
/>
</Box>
<MetricCard
title="Prayer Requests"
value={data.overview.prayerRequests.total}
change={data.overview.prayerRequests.new}
icon={<FavoriteBorder />}
color="#d32f2f"
/>
<MetricCard
title="Total Prayers"
value={data.overview.prayers.total}
change={data.overview.prayers.new}
icon={<FavoriteBorder />}
color="#ed6c02"
/>
<MetricCard
title="Conversations"
value={data.overview.conversations.total}
change={data.overview.conversations.new}
icon={<Chat />}
color="#2e7d32"
/>
<MetricCard
title="Messages"
value={data.overview.messages.total}
change={data.overview.messages.new}
icon={<Chat />}
color="#9c27b0"
/>
<MetricCard
title="Bookmarks"
value={data.overview.bookmarks.total}
change={data.overview.bookmarks.new}
icon={<Bookmarks />}
color="#0288d1"
/>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', lg: '2fr 1fr' },
gap: 3,
mb: 3
}}
>
{/* Daily Activity Chart */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Daily Activity Trends
</Typography>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data.activity.daily}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="newUsers" stroke="#8884d8" name="New Users" />
<Line type="monotone" dataKey="newPrayers" stroke="#82ca9d" name="New Prayers" />
<Line type="monotone" dataKey="newConversations" stroke="#ffc658" name="New Conversations" />
<Line type="monotone" dataKey="newBookmarks" stroke="#ff7300" name="New Bookmarks" />
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* User Roles Distribution */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
User Roles Distribution
</Typography>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data.distributions.usersByRole.map(item => ({
name: item.role,
value: item._count.role
}))}
cx="50%"
cy="50%"
outerRadius={80}
fill="#8884d8"
dataKey="value"
label
>
{data.distributions.usersByRole.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', lg: '1fr 1fr' },
gap: 3
}}
>
{/* Prayer Categories Chart */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Prayer Requests by Category
</Typography>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data.distributions.prayersByCategory.map(item => ({
category: item.category,
count: item._count.category
}))}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="category" />
<YAxis />
<Tooltip />
<Bar dataKey="count" fill="#8884d8" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Top Prayer Requests */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Most Prayed For Requests
</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Title</TableCell>
<TableCell>Category</TableCell>
<TableCell align="right">Prayers</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.topContent.prayerRequests.map((request) => (
<TableRow key={request.id}>
<TableCell>
<Typography variant="body2" noWrap sx={{ maxWidth: 200 }}>
{request.title}
</Typography>
<Typography variant="caption" color="text.secondary">
by {request.author}
</Typography>
</TableCell>
<TableCell>
<Chip
label={request.category}
size="small"
variant="outlined"
/>
</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight="medium">
{request.prayerCount}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,468 @@
'use client';
import { useState, useEffect } from 'react';
import {
Typography,
Box,
Breadcrumbs,
Link,
Card,
CardContent,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
CircularProgress,
Alert,
Chip,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Avatar
} from '@mui/material';
import {
Home,
Analytics,
People,
TrendingUp,
Schedule,
Assignment
} from '@mui/icons-material';
import {
LineChart,
Line,
AreaChart,
Area,
BarChart,
Bar,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer
} from 'recharts';
interface UserAnalyticsData {
period: number;
timeline: {
registrations: Array<{ date: string; registrations: number }>;
};
activity: {
patterns: Array<{
id: string;
email: string;
name: string | null;
role: string;
createdAt: string;
lastLoginAt: string | null;
_count: {
chatConversations: number;
prayerRequests: number;
bookmarks: number;
notes: number;
};
}>;
mostActive: Array<{
id: string;
email: string;
name: string | null;
role: string;
totalActivity: number;
_count: {
chatConversations: number;
prayerRequests: number;
bookmarks: number;
notes: number;
};
}>;
};
retention: {
rate: number;
newUsers: number;
activeUsers: number;
};
engagement: {
featureUsage: {
chat: number;
prayers: number;
bookmarks: number;
notes: number;
};
avgSessionLength: number;
avgMessagesPerSession: number;
};
demographics: Array<{
role: string;
_count: { role: number };
_min: { createdAt: string };
_max: { createdAt: string };
}>;
}
const COLORS = ['#8884d8', '#82ca9d', '#ffc658', '#ff7300', '#0088fe', '#00c49f'];
export default function UserAnalyticsPage() {
const [data, setData] = useState<UserAnalyticsData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [period, setPeriod] = useState('30');
useEffect(() => {
const fetchUserAnalytics = async () => {
setLoading(true);
try {
const response = await fetch(`/api/admin/analytics/users?period=${period}`, {
credentials: 'include'
});
if (response.ok) {
const analyticsData = await response.json();
setData(analyticsData);
} else {
setError('Failed to load user analytics data');
}
} catch (error) {
setError('Network error loading user analytics');
} finally {
setLoading(false);
}
};
fetchUserAnalytics();
}, [period]);
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 400 }}>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
);
}
if (!data) return null;
const featureUsageData = Object.entries(data.engagement.featureUsage).map(([key, value]) => ({
name: key.charAt(0).toUpperCase() + key.slice(1),
value
}));
return (
<Box>
{/* Breadcrumbs */}
<Breadcrumbs aria-label="breadcrumb" sx={{ mb: 3 }}>
<Link
underline="hover"
sx={{ display: 'flex', alignItems: 'center' }}
color="inherit"
href="/admin"
>
<Home sx={{ mr: 0.5 }} fontSize="inherit" />
Admin
</Link>
<Link
underline="hover"
sx={{ display: 'flex', alignItems: 'center' }}
color="inherit"
href="/admin/analytics"
>
<Analytics sx={{ mr: 0.5 }} fontSize="inherit" />
Analytics
</Link>
<Typography color="text.primary" sx={{ display: 'flex', alignItems: 'center' }}>
<People sx={{ mr: 0.5 }} fontSize="inherit" />
User Analytics
</Typography>
</Breadcrumbs>
{/* Page Header */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
<Box>
<Typography variant="h4" component="h1" gutterBottom>
User Analytics
</Typography>
<Typography variant="body1" color="text.secondary">
Detailed insights into user behavior, engagement, and retention
</Typography>
</Box>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel>Time Period</InputLabel>
<Select
value={period}
label="Time Period"
onChange={(e) => setPeriod(e.target.value)}
>
<MenuItem value="7">Last 7 days</MenuItem>
<MenuItem value="30">Last 30 days</MenuItem>
<MenuItem value="90">Last 90 days</MenuItem>
</Select>
</FormControl>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
gap: 3,
mb: 3
}}
>
{/* Key Metrics */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<TrendingUp sx={{ fontSize: 40, color: 'primary.main', mr: 2 }} />
<Box>
<Typography color="textSecondary" variant="body2">
Retention Rate
</Typography>
<Typography variant="h5">
{data.retention.rate}%
</Typography>
</Box>
</Box>
</CardContent>
</Card>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Schedule sx={{ fontSize: 40, color: 'warning.main', mr: 2 }} />
<Box>
<Typography color="textSecondary" variant="body2">
Avg Session (min)
</Typography>
<Typography variant="h5">
{data.engagement.avgSessionLength}
</Typography>
</Box>
</Box>
</CardContent>
</Card>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Assignment sx={{ fontSize: 40, color: 'success.main', mr: 2 }} />
<Box>
<Typography color="textSecondary" variant="body2">
Avg Messages/Session
</Typography>
<Typography variant="h5">
{data.engagement.avgMessagesPerSession}
</Typography>
</Box>
</Box>
</CardContent>
</Card>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<People sx={{ fontSize: 40, color: 'info.main', mr: 2 }} />
<Box>
<Typography color="textSecondary" variant="body2">
Active/New Users
</Typography>
<Typography variant="h5">
{data.retention.activeUsers}/{data.retention.newUsers}
</Typography>
</Box>
</Box>
</CardContent>
</Card>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', lg: '2fr 1fr' },
gap: 3,
mb: 3
}}
>
{/* User Registration Timeline */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
User Registration Timeline
</Typography>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data.timeline.registrations}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Area
type="monotone"
dataKey="registrations"
stroke="#8884d8"
fill="#8884d8"
fillOpacity={0.6}
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Feature Usage Distribution */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Feature Usage Distribution
</Typography>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={featureUsageData}
cx="50%"
cy="50%"
outerRadius={80}
fill="#8884d8"
dataKey="value"
label={({ name, percent }: any) => `${name} ${(percent * 100).toFixed(0)}%`}
>
{featureUsageData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', lg: '1fr 1fr' },
gap: 3
}}
>
{/* Most Active Users */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Most Active Users
</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>User</TableCell>
<TableCell>Role</TableCell>
<TableCell align="right">Total Activity</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.activity.mostActive.slice(0, 10).map((user) => (
<TableRow key={user.id}>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Avatar sx={{ width: 24, height: 24, fontSize: 12 }}>
{(user.name || user.email)[0].toUpperCase()}
</Avatar>
<Box>
<Typography variant="body2">
{user.name || 'Unknown User'}
</Typography>
<Typography variant="caption" color="text.secondary">
{user.email}
</Typography>
</Box>
</Box>
</TableCell>
<TableCell>
<Chip
label={user.role}
size="small"
color={user.role === 'admin' ? 'error' : user.role === 'moderator' ? 'warning' : 'primary'}
variant="outlined"
/>
</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight="medium">
{user.totalActivity}
</Typography>
<Typography variant="caption" color="text.secondary">
{user._count.chatConversations}c {user._count.prayerRequests}p {user._count.bookmarks}b
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
{/* User Demographics */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
User Demographics by Role
</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Role</TableCell>
<TableCell align="right">Count</TableCell>
<TableCell>First User</TableCell>
<TableCell>Latest User</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.demographics.map((demo) => (
<TableRow key={demo.role}>
<TableCell>
<Chip
label={demo.role}
size="small"
color={demo.role === 'admin' ? 'error' : demo.role === 'moderator' ? 'warning' : 'primary'}
variant="outlined"
/>
</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight="medium">
{demo._count.role}
</Typography>
</TableCell>
<TableCell>
<Typography variant="caption">
{new Date(demo._min.createdAt).toLocaleDateString()}
</Typography>
</TableCell>
<TableCell>
<Typography variant="caption">
{new Date(demo._max.createdAt).toLocaleDateString()}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
</Box>
</Box>
);
}

41
app/admin/chat/page.tsx Normal file
View File

@@ -0,0 +1,41 @@
'use client';
import { Typography, Box, Breadcrumbs, Link } from '@mui/material';
import { Home, Chat } from '@mui/icons-material';
import { ConversationMonitoring } from '@/components/admin/chat/conversation-monitoring';
export default function AdminChatPage() {
return (
<Box>
{/* Breadcrumbs */}
<Breadcrumbs aria-label="breadcrumb" sx={{ mb: 3 }}>
<Link
underline="hover"
sx={{ display: 'flex', alignItems: 'center' }}
color="inherit"
href="/admin"
>
<Home sx={{ mr: 0.5 }} fontSize="inherit" />
Admin
</Link>
<Typography color="text.primary" sx={{ display: 'flex', alignItems: 'center' }}>
<Chat sx={{ mr: 0.5 }} fontSize="inherit" />
Chat Monitoring
</Typography>
</Breadcrumbs>
{/* Page Header */}
<Box sx={{ mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
Chat Monitoring
</Typography>
<Typography variant="body1" color="text.secondary">
Monitor and manage chat conversations, detect inappropriate content, and ensure platform safety
</Typography>
</Box>
{/* Conversation Monitoring */}
<ConversationMonitoring />
</Box>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
import { Typography, Box, Breadcrumbs, Link } from '@mui/material';
import { Home, Gavel } from '@mui/icons-material';
import { PrayerRequestDataGrid } from '@/components/admin/content/prayer-request-data-grid';
export default function AdminContentPage() {
return (
<Box>
{/* Breadcrumbs */}
<Breadcrumbs aria-label="breadcrumb" sx={{ mb: 3 }}>
<Link
underline="hover"
sx={{ display: 'flex', alignItems: 'center' }}
color="inherit"
href="/admin"
>
<Home sx={{ mr: 0.5 }} fontSize="inherit" />
Admin
</Link>
<Typography color="text.primary" sx={{ display: 'flex', alignItems: 'center' }}>
<Gavel sx={{ mr: 0.5 }} fontSize="inherit" />
Content Moderation
</Typography>
</Breadcrumbs>
{/* Page Header */}
<Box sx={{ mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
Content Moderation
</Typography>
<Typography variant="body1" color="text.secondary">
Review and moderate prayer requests and user-generated content
</Typography>
</Box>
{/* Prayer Request Data Grid */}
<PrayerRequestDataGrid />
</Box>
);
}

98
app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,98 @@
'use client';
import { useEffect, useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { ThemeProvider } from '@mui/material/styles';
import { CssBaseline, Box, CircularProgress } from '@mui/material';
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import { AdminLayout } from '@/components/admin/layout/admin-layout';
import { adminTheme } from '@/lib/admin-theme';
interface AdminUser {
id: string;
email: string;
name: string | null;
role: string;
}
export default function AdminLayoutPage({
children,
}: {
children: React.ReactNode;
}) {
const [admin, setAdmin] = useState<AdminUser | null>(null);
const [loading, setLoading] = useState(true);
const pathname = usePathname();
const router = useRouter();
useEffect(() => {
const checkAuth = async () => {
// Skip auth check if already on login page
if (pathname === '/admin/login') {
setLoading(false);
return;
}
try {
const response = await fetch('/api/admin/auth/me', {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
setAdmin(data.user);
} else {
// 401 is expected when not logged in - don't log as error
setAdmin(null);
router.push('/admin/login');
}
} catch (error) {
// Only log actual network errors, not auth failures
if (error instanceof TypeError) {
console.error('Network error during auth check:', error);
}
setAdmin(null);
router.push('/admin/login');
} finally {
setLoading(false);
}
};
checkAuth();
}, [pathname, router]);
if (loading) {
return (
<ThemeProvider theme={adminTheme}>
<CssBaseline />
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
}}
>
<CircularProgress />
</Box>
</ThemeProvider>
);
}
return (
<ThemeProvider theme={adminTheme}>
<CssBaseline />
{admin && pathname !== '/admin/login' ? (
<AdminLayout user={admin}>
{children}
</AdminLayout>
) : (
children
)}
</ThemeProvider>
);
}

20
app/admin/login/page.tsx Normal file
View File

@@ -0,0 +1,20 @@
'use client';
import { ThemeProvider } from '@mui/material/styles';
import { CssBaseline } from '@mui/material';
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import { AdminLoginForm } from '@/components/admin/auth/admin-login-form';
import { adminTheme } from '@/lib/admin-theme';
export default function AdminLoginPage() {
return (
<ThemeProvider theme={adminTheme}>
<CssBaseline />
<AdminLoginForm />
</ThemeProvider>
);
}

46
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,46 @@
import { Typography, Box, Breadcrumbs, Link } from '@mui/material';
import { Home } from '@mui/icons-material';
import { OverviewCards } from '@/components/admin/dashboard/overview-cards';
export default function AdminDashboard() {
return (
<Box>
{/* Breadcrumbs */}
<Breadcrumbs aria-label="breadcrumb" sx={{ mb: 3 }}>
<Link
underline="hover"
sx={{ display: 'flex', alignItems: 'center' }}
color="inherit"
href="/admin"
>
<Home sx={{ mr: 0.5 }} fontSize="inherit" />
Admin
</Link>
<Typography color="text.primary">Dashboard</Typography>
</Breadcrumbs>
{/* Page Header */}
<Box sx={{ mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
Dashboard Overview
</Typography>
<Typography variant="body1" color="text.secondary">
Monitor key metrics and system performance for Biblical Guide
</Typography>
</Box>
{/* Overview Cards */}
<OverviewCards />
{/* Recent Activity Section - Placeholder for future implementation */}
<Box sx={{ mt: 4 }}>
<Typography variant="h6" gutterBottom>
Recent Activity
</Typography>
<Typography variant="body2" color="text.secondary">
Activity feed will be implemented in Phase 2
</Typography>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
import { Typography, Box, Breadcrumbs, Link } from '@mui/material';
import { Home, Settings } from '@mui/icons-material';
import { SystemDashboard } from '@/components/admin/system/system-dashboard';
export default function AdminSettingsPage() {
return (
<Box>
{/* Breadcrumbs */}
<Breadcrumbs aria-label="breadcrumb" sx={{ mb: 3 }}>
<Link
underline="hover"
sx={{ display: 'flex', alignItems: 'center' }}
color="inherit"
href="/admin"
>
<Home sx={{ mr: 0.5 }} fontSize="inherit" />
Admin
</Link>
<Typography color="text.primary" sx={{ display: 'flex', alignItems: 'center' }}>
<Settings sx={{ mr: 0.5 }} fontSize="inherit" />
System Administration
</Typography>
</Breadcrumbs>
{/* Page Header */}
<Box sx={{ mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
System Administration
</Typography>
<Typography variant="body1" color="text.secondary">
Monitor system health, manage backups, and configure platform settings
</Typography>
</Box>
{/* System Dashboard */}
<SystemDashboard />
</Box>
);
}

41
app/admin/users/page.tsx Normal file
View File

@@ -0,0 +1,41 @@
'use client';
import { Typography, Box, Breadcrumbs, Link } from '@mui/material';
import { Home, People } from '@mui/icons-material';
import { UserDataGrid } from '@/components/admin/users/user-data-grid';
export default function AdminUsersPage() {
return (
<Box>
{/* Breadcrumbs */}
<Breadcrumbs aria-label="breadcrumb" sx={{ mb: 3 }}>
<Link
underline="hover"
sx={{ display: 'flex', alignItems: 'center' }}
color="inherit"
href="/admin"
>
<Home sx={{ mr: 0.5 }} fontSize="inherit" />
Admin
</Link>
<Typography color="text.primary" sx={{ display: 'flex', alignItems: 'center' }}>
<People sx={{ mr: 0.5 }} fontSize="inherit" />
Users
</Typography>
</Breadcrumbs>
{/* Page Header */}
<Box sx={{ mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
User Management
</Typography>
<Typography variant="body1" color="text.secondary">
Manage user accounts, roles, and permissions
</Typography>
</Box>
{/* User Data Grid */}
<UserDataGrid />
</Box>
);
}

View File

@@ -0,0 +1,272 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { getCurrentAdmin, AdminPermission, hasPermission } from '@/lib/admin-auth';
export const runtime = 'nodejs';
export async function GET(request: Request) {
try {
const admin = await getCurrentAdmin();
if (!admin || !hasPermission(admin, AdminPermission.VIEW_ANALYTICS)) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const url = new URL(request.url);
const period = url.searchParams.get('period') || '30'; // days
const periodDays = parseInt(period);
const startDate = new Date();
startDate.setDate(startDate.getDate() - periodDays);
// Prayer request engagement
const prayerRequestEngagement = await prisma.prayerRequest.findMany({
select: {
id: true,
title: true,
category: true,
author: true,
prayerCount: true,
createdAt: true,
isActive: true,
_count: {
select: {
prayers: true,
userPrayers: true
}
}
},
where: {
createdAt: {
gte: startDate
}
},
orderBy: {
prayerCount: 'desc'
},
take: 50
});
// Prayer request engagement timeline
const prayerEngagementTimeline = await Promise.all(
Array.from({ length: periodDays }, (_, i) => {
const date = new Date();
date.setDate(date.getDate() - i);
return date.toISOString().split('T')[0];
}).reverse().map(async (date) => {
const startOfDay = new Date(date + 'T00:00:00.000Z');
const endOfDay = new Date(date + 'T23:59:59.999Z');
const [newRequests, newPrayers] = await Promise.all([
prisma.prayerRequest.count({
where: {
createdAt: {
gte: startOfDay,
lte: endOfDay
}
}
}),
prisma.prayer.count({
where: {
createdAt: {
gte: startOfDay,
lte: endOfDay
}
}
})
]);
return {
date,
newRequests,
newPrayers
};
})
);
// Chat conversation engagement
const chatEngagement = await prisma.chatConversation.findMany({
select: {
id: true,
title: true,
language: true,
createdAt: true,
lastMessageAt: true,
isActive: true,
_count: {
select: {
messages: true
}
}
},
where: {
createdAt: {
gte: startDate
}
},
orderBy: {
lastMessageAt: 'desc'
},
take: 50
});
// Most bookmarked verses
const mostBookmarkedVerses = await prisma.bookmark.groupBy({
by: ['verseId'],
_count: {
verseId: true
},
where: {
createdAt: {
gte: startDate
}
},
orderBy: {
_count: {
verseId: 'desc'
}
},
take: 20
});
// Get verse details for bookmarked verses
const verseDetails = await Promise.all(
mostBookmarkedVerses.map(async (bookmark) => {
const verse = await prisma.bibleVerse.findUnique({
where: { id: bookmark.verseId },
select: {
id: true,
verseNum: true,
text: true,
chapter: {
select: {
chapterNum: true,
book: {
select: {
name: true
}
}
}
}
}
});
return {
...bookmark,
verse
};
})
);
// Content categories performance
const categoryPerformance = await prisma.prayerRequest.groupBy({
by: ['category'],
_sum: {
prayerCount: true
},
_count: {
category: true
},
_avg: {
prayerCount: true
},
where: {
createdAt: {
gte: startDate
},
isActive: true
}
});
// Language distribution for conversations
const languageDistribution = await prisma.chatConversation.groupBy({
by: ['language'],
_count: {
language: true
},
where: {
createdAt: {
gte: startDate
}
}
});
// Content creation vs engagement ratio
const contentMetrics = {
totalPrayerRequests: await prisma.prayerRequest.count({
where: {
createdAt: { gte: startDate }
}
}),
totalPrayers: await prisma.prayer.count({
where: {
createdAt: { gte: startDate }
}
}),
totalConversations: await prisma.chatConversation.count({
where: {
createdAt: { gte: startDate }
}
}),
totalMessages: await prisma.chatMessage.count({
where: {
timestamp: { gte: startDate }
}
}),
totalBookmarks: await prisma.bookmark.count({
where: {
createdAt: { gte: startDate }
}
})
};
// Average engagement rates
const avgPrayersPerRequest = contentMetrics.totalPrayerRequests > 0
? contentMetrics.totalPrayers / contentMetrics.totalPrayerRequests
: 0;
const avgMessagesPerConversation = contentMetrics.totalConversations > 0
? contentMetrics.totalMessages / contentMetrics.totalConversations
: 0;
// Content quality metrics (based on engagement)
const highEngagementRequests = prayerRequestEngagement.filter(req => req.prayerCount >= 5).length;
const lowEngagementRequests = prayerRequestEngagement.filter(req => req.prayerCount <= 1).length;
const engagementDistribution = {
high: highEngagementRequests,
medium: prayerRequestEngagement.length - highEngagementRequests - lowEngagementRequests,
low: lowEngagementRequests
};
return NextResponse.json({
period: periodDays,
engagement: {
prayerRequests: prayerRequestEngagement.slice(0, 20),
conversations: chatEngagement.slice(0, 20),
bookmarkedVerses: verseDetails.slice(0, 15)
},
timeline: {
prayers: prayerEngagementTimeline
},
metrics: {
...contentMetrics,
avgPrayersPerRequest: Math.round(avgPrayersPerRequest * 100) / 100,
avgMessagesPerConversation: Math.round(avgMessagesPerConversation * 100) / 100
},
distributions: {
categories: categoryPerformance,
languages: languageDistribution,
engagement: engagementDistribution
}
});
} catch (error) {
console.error('Admin content analytics error:', error);
return NextResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,239 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { getCurrentAdmin, AdminPermission, hasPermission } from '@/lib/admin-auth';
export const runtime = 'nodejs';
export async function GET(request: Request) {
try {
const admin = await getCurrentAdmin();
if (!admin || !hasPermission(admin, AdminPermission.VIEW_ANALYTICS)) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const url = new URL(request.url);
const period = url.searchParams.get('period') || '30'; // days
const periodDays = parseInt(period);
const startDate = new Date();
startDate.setDate(startDate.getDate() - periodDays);
// User statistics
const totalUsers = await prisma.user.count();
const newUsers = await prisma.user.count({
where: {
createdAt: {
gte: startDate
}
}
});
const activeUsers = await prisma.user.count({
where: {
lastLoginAt: {
gte: startDate
}
}
});
// Content statistics
const totalPrayerRequests = await prisma.prayerRequest.count();
const activePrayerRequests = await prisma.prayerRequest.count({
where: { isActive: true }
});
const newPrayerRequests = await prisma.prayerRequest.count({
where: {
createdAt: {
gte: startDate
}
}
});
// Prayer statistics
const totalPrayers = await prisma.prayer.count();
const newPrayers = await prisma.prayer.count({
where: {
createdAt: {
gte: startDate
}
}
});
// Chat statistics
const totalConversations = await prisma.chatConversation.count();
const activeConversations = await prisma.chatConversation.count({
where: { isActive: true }
});
const newConversations = await prisma.chatConversation.count({
where: {
createdAt: {
gte: startDate
}
}
});
const totalMessages = await prisma.chatMessage.count();
const newMessages = await prisma.chatMessage.count({
where: {
timestamp: {
gte: startDate
}
}
});
// Bookmark statistics
const totalBookmarks = await prisma.bookmark.count();
const newBookmarks = await prisma.bookmark.count({
where: {
createdAt: {
gte: startDate
}
}
});
// User role distribution
const usersByRole = await prisma.user.groupBy({
by: ['role'],
_count: {
role: true
}
});
// Prayer request categories
const prayersByCategory = await prisma.prayerRequest.groupBy({
by: ['category'],
_count: {
category: true
},
where: {
isActive: true
}
});
// Top prayer requests by prayer count
const topPrayerRequests = await prisma.prayerRequest.findMany({
select: {
id: true,
title: true,
category: true,
prayerCount: true,
author: true
},
where: {
isActive: true
},
orderBy: {
prayerCount: 'desc'
},
take: 10
});
// Recent activity (last 7 days daily breakdown)
const last7Days = Array.from({ length: 7 }, (_, i) => {
const date = new Date();
date.setDate(date.getDate() - i);
return date.toISOString().split('T')[0];
}).reverse();
const dailyActivity = await Promise.all(
last7Days.map(async (date) => {
const startOfDay = new Date(date + 'T00:00:00.000Z');
const endOfDay = new Date(date + 'T23:59:59.999Z');
const [newUsers, newPrayers, newConversations, newBookmarks] = await Promise.all([
prisma.user.count({
where: {
createdAt: {
gte: startOfDay,
lte: endOfDay
}
}
}),
prisma.prayer.count({
where: {
createdAt: {
gte: startOfDay,
lte: endOfDay
}
}
}),
prisma.chatConversation.count({
where: {
createdAt: {
gte: startOfDay,
lte: endOfDay
}
}
}),
prisma.bookmark.count({
where: {
createdAt: {
gte: startOfDay,
lte: endOfDay
}
}
})
]);
return {
date,
newUsers,
newPrayers,
newConversations,
newBookmarks
};
})
);
return NextResponse.json({
period: periodDays,
overview: {
users: {
total: totalUsers,
new: newUsers,
active: activeUsers
},
prayerRequests: {
total: totalPrayerRequests,
active: activePrayerRequests,
new: newPrayerRequests
},
prayers: {
total: totalPrayers,
new: newPrayers
},
conversations: {
total: totalConversations,
active: activeConversations,
new: newConversations
},
messages: {
total: totalMessages,
new: newMessages
},
bookmarks: {
total: totalBookmarks,
new: newBookmarks
}
},
distributions: {
usersByRole,
prayersByCategory
},
topContent: {
prayerRequests: topPrayerRequests
},
activity: {
daily: dailyActivity
}
});
} catch (error) {
console.error('Admin analytics overview error:', error);
return NextResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,228 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { getCurrentAdmin, AdminPermission, hasPermission } from '@/lib/admin-auth';
export const runtime = 'nodejs';
export async function GET(request: Request) {
try {
const admin = await getCurrentAdmin();
if (!admin || !hasPermission(admin, AdminPermission.VIEW_ANALYTICS)) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const now = new Date();
const last24Hours = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const lastHour = new Date(now.getTime() - 60 * 60 * 1000);
const last15Minutes = new Date(now.getTime() - 15 * 60 * 1000);
// Real-time activity counters
const realTimeStats = {
last15Minutes: {
newUsers: await prisma.user.count({
where: { createdAt: { gte: last15Minutes } }
}),
newPrayers: await prisma.prayer.count({
where: { createdAt: { gte: last15Minutes } }
}),
newMessages: await prisma.chatMessage.count({
where: { timestamp: { gte: last15Minutes } }
}),
newBookmarks: await prisma.bookmark.count({
where: { createdAt: { gte: last15Minutes } }
})
},
lastHour: {
newUsers: await prisma.user.count({
where: { createdAt: { gte: lastHour } }
}),
newPrayers: await prisma.prayer.count({
where: { createdAt: { gte: lastHour } }
}),
newMessages: await prisma.chatMessage.count({
where: { timestamp: { gte: lastHour } }
}),
newBookmarks: await prisma.bookmark.count({
where: { createdAt: { gte: lastHour } }
}),
activeConversations: await prisma.chatConversation.count({
where: {
lastMessageAt: { gte: lastHour },
isActive: true
}
})
},
last24Hours: {
newUsers: await prisma.user.count({
where: { createdAt: { gte: last24Hours } }
}),
newPrayers: await prisma.prayer.count({
where: { createdAt: { gte: last24Hours } }
}),
newPrayerRequests: await prisma.prayerRequest.count({
where: { createdAt: { gte: last24Hours } }
}),
newMessages: await prisma.chatMessage.count({
where: { timestamp: { gte: last24Hours } }
}),
newConversations: await prisma.chatConversation.count({
where: { createdAt: { gte: last24Hours } }
}),
newBookmarks: await prisma.bookmark.count({
where: { createdAt: { gte: last24Hours } }
})
}
};
// Current online activity indicators
const recentActivity = {
activeUsers: await prisma.user.count({
where: {
lastLoginAt: { gte: lastHour }
}
}),
recentConversations: await prisma.chatConversation.findMany({
select: {
id: true,
title: true,
lastMessageAt: true,
user: {
select: {
name: true,
email: true
}
}
},
where: {
lastMessageAt: { gte: lastHour },
isActive: true
},
orderBy: {
lastMessageAt: 'desc'
},
take: 10
}),
recentPrayerRequests: await prisma.prayerRequest.findMany({
select: {
id: true,
title: true,
category: true,
author: true,
createdAt: true
},
where: {
createdAt: { gte: last24Hours },
isActive: true
},
orderBy: {
createdAt: 'desc'
},
take: 10
}),
recentPrayers: await prisma.prayer.findMany({
select: {
id: true,
createdAt: true,
request: {
select: {
title: true,
category: true
}
}
},
where: {
createdAt: { gte: lastHour }
},
orderBy: {
createdAt: 'desc'
},
take: 10
})
};
// System health indicators
const systemHealth = {
totalUsers: await prisma.user.count(),
totalPrayerRequests: await prisma.prayerRequest.count({ where: { isActive: true } }),
totalActiveConversations: await prisma.chatConversation.count({ where: { isActive: true } }),
pendingModerationRequests: await prisma.prayerRequest.count({ where: { isActive: false } }),
timestamp: now.toISOString()
};
// Hourly breakdown for the last 24 hours
const hourlyBreakdown = await Promise.all(
Array.from({ length: 24 }, (_, i) => {
const hour = new Date(now.getTime() - i * 60 * 60 * 1000);
const hourStart = new Date(hour.getFullYear(), hour.getMonth(), hour.getDate(), hour.getHours(), 0, 0);
const hourEnd = new Date(hour.getFullYear(), hour.getMonth(), hour.getDate(), hour.getHours(), 59, 59);
return hourStart.toISOString().split('T')[1].substring(0, 5);
}).reverse().map(async (time, index) => {
const hourStart = new Date(now.getTime() - (23 - index) * 60 * 60 * 1000);
hourStart.setMinutes(0, 0, 0);
const hourEnd = new Date(hourStart.getTime() + 60 * 60 * 1000 - 1);
const [users, prayers, messages, conversations] = await Promise.all([
prisma.user.count({
where: {
createdAt: {
gte: hourStart,
lte: hourEnd
}
}
}),
prisma.prayer.count({
where: {
createdAt: {
gte: hourStart,
lte: hourEnd
}
}
}),
prisma.chatMessage.count({
where: {
timestamp: {
gte: hourStart,
lte: hourEnd
}
}
}),
prisma.chatConversation.count({
where: {
createdAt: {
gte: hourStart,
lte: hourEnd
}
}
})
]);
return {
time,
users,
prayers,
messages,
conversations
};
})
);
return NextResponse.json({
timestamp: now.toISOString(),
stats: realTimeStats,
activity: recentActivity,
health: systemHealth,
hourlyBreakdown
});
} catch (error) {
console.error('Admin real-time analytics error:', error);
return NextResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,224 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { getCurrentAdmin, AdminPermission, hasPermission } from '@/lib/admin-auth';
export const runtime = 'nodejs';
export async function GET(request: Request) {
try {
const admin = await getCurrentAdmin();
if (!admin || !hasPermission(admin, AdminPermission.VIEW_ANALYTICS)) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const url = new URL(request.url);
const period = url.searchParams.get('period') || '30'; // days
const periodDays = parseInt(period);
const startDate = new Date();
startDate.setDate(startDate.getDate() - periodDays);
// User registration timeline (last 30 days)
const registrationTimeline = await Promise.all(
Array.from({ length: periodDays }, (_, i) => {
const date = new Date();
date.setDate(date.getDate() - i);
return date.toISOString().split('T')[0];
}).reverse().map(async (date) => {
const startOfDay = new Date(date + 'T00:00:00.000Z');
const endOfDay = new Date(date + 'T23:59:59.999Z');
const registrations = await prisma.user.count({
where: {
createdAt: {
gte: startOfDay,
lte: endOfDay
}
}
});
return {
date,
registrations
};
})
);
// User activity patterns (login frequency)
const userActivityPatterns = await prisma.user.findMany({
select: {
id: true,
email: true,
name: true,
role: true,
createdAt: true,
lastLoginAt: true,
_count: {
select: {
chatConversations: true,
prayerRequests: true,
bookmarks: true,
notes: true
}
}
},
orderBy: {
lastLoginAt: 'desc'
},
take: 100
});
// Most active users (by total activity)
const mostActiveUsers = userActivityPatterns
.map(user => ({
...user,
totalActivity:
user._count.chatConversations +
user._count.prayerRequests +
user._count.bookmarks +
user._count.notes
}))
.sort((a, b) => b.totalActivity - a.totalActivity)
.slice(0, 20);
// User retention analysis
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const newUsersLast30Days = await prisma.user.count({
where: {
createdAt: {
gte: thirtyDaysAgo
}
}
});
const activeUsersLast30Days = await prisma.user.count({
where: {
createdAt: {
gte: thirtyDaysAgo
},
lastLoginAt: {
gte: sevenDaysAgo
}
}
});
const retentionRate = newUsersLast30Days > 0 ? (activeUsersLast30Days / newUsersLast30Days) * 100 : 0;
// User engagement by feature
const featureUsage = {
chat: await prisma.chatConversation.count({
where: {
createdAt: {
gte: startDate
}
}
}),
prayers: await prisma.prayerRequest.count({
where: {
createdAt: {
gte: startDate
}
}
}),
bookmarks: await prisma.bookmark.count({
where: {
createdAt: {
gte: startDate
}
}
}),
notes: await prisma.note.count({
where: {
createdAt: {
gte: startDate
}
}
})
};
// User demographics (by role and creation time)
const userDemographics = await prisma.user.groupBy({
by: ['role'],
_count: {
role: true
},
_min: {
createdAt: true
},
_max: {
createdAt: true
}
});
// Session length analysis (approximate based on conversation activity)
const sessionAnalysis = await prisma.chatConversation.findMany({
select: {
userId: true,
createdAt: true,
lastMessageAt: true,
_count: {
select: {
messages: true
}
}
},
where: {
createdAt: {
gte: startDate
},
userId: {
not: null
}
},
orderBy: {
lastMessageAt: 'desc'
},
take: 1000
});
const avgSessionLength = sessionAnalysis.reduce((acc, session) => {
const duration = new Date(session.lastMessageAt).getTime() - new Date(session.createdAt).getTime();
return acc + (duration / 1000 / 60); // minutes
}, 0) / sessionAnalysis.length || 0;
const avgMessagesPerSession = sessionAnalysis.reduce((acc, session) => {
return acc + session._count.messages;
}, 0) / sessionAnalysis.length || 0;
return NextResponse.json({
period: periodDays,
timeline: {
registrations: registrationTimeline
},
activity: {
patterns: userActivityPatterns.slice(0, 50), // Limit for performance
mostActive: mostActiveUsers
},
retention: {
rate: Math.round(retentionRate * 100) / 100,
newUsers: newUsersLast30Days,
activeUsers: activeUsersLast30Days
},
engagement: {
featureUsage,
avgSessionLength: Math.round(avgSessionLength * 100) / 100,
avgMessagesPerSession: Math.round(avgMessagesPerSession * 100) / 100
},
demographics: userDemographics
});
} catch (error) {
console.error('Admin user analytics error:', error);
return NextResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,104 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { validateUser } from '@/lib/auth';
import { generateAdminToken } from '@/lib/admin-auth';
import { createUserLoginSchema } from '@/lib/validation';
import { cookies } from 'next/headers';
export const runtime = 'nodejs';
function getErrorMessages() {
return {
fieldsRequired: 'Email and password are required',
invalidCredentials: 'Invalid admin credentials',
serverError: 'Server error',
invalidInput: 'Invalid input data',
accessDenied: 'Access denied - admin privileges required'
};
}
export async function POST(request: Request) {
try {
const messages = getErrorMessages();
const body = await request.json();
// Validate input
const validation = createUserLoginSchema().safeParse(body);
if (!validation.success) {
return NextResponse.json(
{ error: messages.invalidInput },
{ status: 400 }
);
}
const { email, password } = validation.data;
// Find user by email
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase() }
});
if (!user) {
return NextResponse.json(
{ error: messages.invalidCredentials },
{ status: 401 }
);
}
// Check if user has admin/moderator role
if (!['admin', 'moderator'].includes(user.role)) {
return NextResponse.json(
{ error: messages.accessDenied },
{ status: 403 }
);
}
// Validate password
const isValidPassword = await validateUser(email, password);
if (!isValidPassword) {
return NextResponse.json(
{ error: messages.invalidCredentials },
{ status: 401 }
);
}
// Generate admin token
const adminToken = generateAdminToken(user);
console.log('Generated admin token for user:', user.email);
// Update last login
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() }
});
// Set admin cookie
const cookieStore = await cookies();
cookieStore.set('adminToken', adminToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 8, // 8 hours
path: '/'
});
console.log('Admin cookie set successfully');
return NextResponse.json({
success: true,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role
}
});
} catch (error) {
console.error('Admin login error:', error);
return NextResponse.json(
{ error: getErrorMessages().serverError },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,21 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
export const runtime = 'nodejs';
export async function POST() {
try {
const cookieStore = await cookies();
// Clear admin token cookie
cookieStore.delete('adminToken');
return NextResponse.json({ success: true });
} catch (error) {
console.error('Admin logout error:', error);
return NextResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,43 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getCurrentAdmin } from '@/lib/admin-auth';
export const runtime = 'nodejs';
export async function GET() {
try {
console.log('Admin auth check - starting...');
const cookieStore = await cookies();
const token = cookieStore.get('adminToken')?.value;
console.log('Admin token found:', !!token);
if (!token) {
console.log('No admin token found in cookies');
return NextResponse.json(
{ error: 'Not authenticated - no token' },
{ status: 401 }
);
}
const admin = await getCurrentAdmin();
console.log('Admin user found:', !!admin);
if (!admin) {
console.log('Admin token invalid or user not found');
return NextResponse.json(
{ error: 'Not authenticated - invalid token' },
{ status: 401 }
);
}
return NextResponse.json({ user: admin });
} catch (error) {
console.error('Get admin user error:', error);
return NextResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,209 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { getCurrentAdmin, AdminPermission, hasPermission } from '@/lib/admin-auth';
export const runtime = 'nodejs';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const admin = await getCurrentAdmin();
if (!admin || !hasPermission(admin, AdminPermission.MODERATE_CONTENT)) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const { id } = await params;
const conversation = await prisma.chatConversation.findUnique({
where: { id },
include: {
user: {
select: {
id: true,
email: true,
name: true,
role: true,
createdAt: true,
lastLoginAt: true
}
},
messages: {
select: {
id: true,
role: true,
content: true,
timestamp: true,
metadata: true
},
orderBy: {
timestamp: 'asc'
}
}
}
});
if (!conversation) {
return NextResponse.json(
{ error: 'Conversation not found' },
{ status: 404 }
);
}
// Analyze conversation for potential issues
const analysis = {
messageCount: conversation.messages.length,
userMessages: conversation.messages.filter(m => m.role === 'USER').length,
assistantMessages: conversation.messages.filter(m => m.role === 'ASSISTANT').length,
averageMessageLength: conversation.messages.reduce((acc, msg) => acc + msg.content.length, 0) / conversation.messages.length || 0,
lastActivity: conversation.lastMessageAt,
duration: conversation.lastMessageAt
? new Date(conversation.lastMessageAt).getTime() - new Date(conversation.createdAt).getTime()
: 0,
potentialIssues: [] as string[]
};
// Check for potential content issues
const suspiciousKeywords = ['inappropriate', 'harmful', 'illegal', 'violence', 'hate'];
const hasContentIssues = conversation.messages.some(msg =>
suspiciousKeywords.some(keyword =>
msg.content.toLowerCase().includes(keyword)
)
);
if (hasContentIssues) {
analysis.potentialIssues.push('Potentially inappropriate content detected');
}
if (analysis.messageCount > 100) {
analysis.potentialIssues.push('Unusually long conversation');
}
if (analysis.userMessages > 50) {
analysis.potentialIssues.push('High user message count');
}
return NextResponse.json({
conversation,
analysis
});
} catch (error) {
console.error('Admin conversation detail error:', error);
return NextResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const admin = await getCurrentAdmin();
if (!admin || !hasPermission(admin, AdminPermission.MODERATE_CONTENT)) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const { id } = await params;
const body = await request.json();
const { action, reason } = body;
let updateData: any = {};
switch (action) {
case 'deactivate':
updateData = { isActive: false };
break;
case 'activate':
updateData = { isActive: true };
break;
default:
return NextResponse.json(
{ error: 'Invalid action' },
{ status: 400 }
);
}
const conversation = await prisma.chatConversation.update({
where: { id },
data: updateData,
select: {
id: true,
title: true,
isActive: true,
user: {
select: {
email: true
}
}
}
});
// TODO: Add audit log entry here in the future
console.log(`Admin ${admin.email} performed action '${action}' on conversation ${conversation.title}${reason ? ` with reason: ${reason}` : ''}`);
return NextResponse.json({ conversation });
} catch (error) {
console.error('Admin conversation update error:', error);
return NextResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const admin = await getCurrentAdmin();
if (!admin || !hasPermission(admin, AdminPermission.MODERATE_CONTENT)) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const { id } = await params;
const conversation = await prisma.chatConversation.findUnique({
where: { id },
select: { title: true, user: { select: { email: true } } }
});
if (!conversation) {
return NextResponse.json(
{ error: 'Conversation not found' },
{ status: 404 }
);
}
// Delete conversation and all related messages (CASCADE)
await prisma.chatConversation.delete({
where: { id }
});
console.log(`Admin ${admin.email} deleted conversation "${conversation.title}"`);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Admin conversation delete error:', error);
return NextResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,140 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { getCurrentAdmin, AdminPermission, hasPermission } from '@/lib/admin-auth';
export const runtime = 'nodejs';
export async function GET(request: Request) {
try {
const admin = await getCurrentAdmin();
if (!admin || !hasPermission(admin, AdminPermission.MODERATE_CONTENT)) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') || '0');
const pageSize = parseInt(url.searchParams.get('pageSize') || '10');
const search = url.searchParams.get('search') || '';
const status = url.searchParams.get('status') || 'all';
const language = url.searchParams.get('language') || 'all';
const sortBy = url.searchParams.get('sortBy') || 'lastMessage';
// Build where clause for filtering
const where: any = {};
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ user: { email: { contains: search, mode: 'insensitive' } } },
{ user: { name: { contains: search, mode: 'insensitive' } } }
];
}
if (status !== 'all') {
where.isActive = status === 'active';
}
if (language !== 'all') {
where.language = language;
}
// Build order by clause
let orderBy: any = { lastMessageAt: 'desc' };
switch (sortBy) {
case 'created':
orderBy = { createdAt: 'desc' };
break;
case 'messageCount':
orderBy = { messages: { _count: 'desc' } };
break;
case 'lastMessage':
default:
orderBy = { lastMessageAt: 'desc' };
break;
}
// Get total count for pagination
const total = await prisma.chatConversation.count({ where });
// Get conversations with pagination
const conversations = await prisma.chatConversation.findMany({
where,
select: {
id: true,
title: true,
language: true,
isActive: true,
createdAt: true,
updatedAt: true,
lastMessageAt: true,
user: {
select: {
id: true,
email: true,
name: true,
role: true
}
},
_count: {
select: {
messages: true
}
},
messages: {
select: {
id: true,
role: true,
content: true,
timestamp: true
},
orderBy: {
timestamp: 'desc'
},
take: 1
}
},
orderBy,
skip: page * pageSize,
take: pageSize
});
// Add conversation statistics
const stats = {
total: await prisma.chatConversation.count(),
active: await prisma.chatConversation.count({ where: { isActive: true } }),
inactive: await prisma.chatConversation.count({ where: { isActive: false } }),
today: await prisma.chatConversation.count({
where: {
createdAt: {
gte: new Date(new Date().setHours(0, 0, 0, 0))
}
}
}),
thisWeek: await prisma.chatConversation.count({
where: {
createdAt: {
gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
}
}
})
};
return NextResponse.json({
conversations,
stats,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize)
}
});
} catch (error) {
console.error('Admin chat conversations list error:', error);
return NextResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,183 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { getCurrentAdmin, AdminPermission, hasPermission } from '@/lib/admin-auth';
export const runtime = 'nodejs';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const admin = await getCurrentAdmin();
if (!admin || !hasPermission(admin, AdminPermission.MODERATE_CONTENT)) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const { id } = await params;
const prayerRequest = await prisma.prayerRequest.findUnique({
where: { id },
include: {
user: {
select: {
id: true,
email: true,
name: true,
role: true
}
},
prayers: {
select: {
id: true,
ipAddress: true,
createdAt: true
},
orderBy: { createdAt: 'desc' },
take: 10
},
userPrayers: {
select: {
id: true,
createdAt: true,
user: {
select: {
id: true,
email: true,
name: true
}
}
},
orderBy: { createdAt: 'desc' },
take: 10
}
}
});
if (!prayerRequest) {
return NextResponse.json(
{ error: 'Prayer request not found' },
{ status: 404 }
);
}
return NextResponse.json({ prayerRequest });
} catch (error) {
console.error('Admin prayer request detail error:', error);
return NextResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const admin = await getCurrentAdmin();
if (!admin || !hasPermission(admin, AdminPermission.MODERATE_CONTENT)) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const { id } = await params;
const body = await request.json();
const { action, reason } = body;
let updateData: any = {};
switch (action) {
case 'approve':
updateData = { isActive: true };
break;
case 'reject':
updateData = { isActive: false };
break;
default:
return NextResponse.json(
{ error: 'Invalid action' },
{ status: 400 }
);
}
const prayerRequest = await prisma.prayerRequest.update({
where: { id },
data: updateData,
select: {
id: true,
title: true,
isActive: true,
user: {
select: {
email: true
}
}
}
});
// TODO: Add audit log entry here in the future
console.log(`Admin ${admin.email} performed action '${action}' on prayer request ${prayerRequest.title}${reason ? ` with reason: ${reason}` : ''}`);
return NextResponse.json({ prayerRequest });
} catch (error) {
console.error('Admin prayer request update error:', error);
return NextResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const admin = await getCurrentAdmin();
if (!admin || !hasPermission(admin, AdminPermission.MODERATE_CONTENT)) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const { id } = await params;
const prayerRequest = await prisma.prayerRequest.findUnique({
where: { id },
select: { title: true, user: { select: { email: true } } }
});
if (!prayerRequest) {
return NextResponse.json(
{ error: 'Prayer request not found' },
{ status: 404 }
);
}
// Delete prayer request and all related data (CASCADE)
await prisma.prayerRequest.delete({
where: { id }
});
console.log(`Admin ${admin.email} deleted prayer request "${prayerRequest.title}"`);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Admin prayer request delete error:', error);
return NextResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,87 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { getCurrentAdmin, AdminPermission, hasPermission } from '@/lib/admin-auth';
export const runtime = 'nodejs';
export async function GET(request: Request) {
try {
const admin = await getCurrentAdmin();
if (!admin || !hasPermission(admin, AdminPermission.MODERATE_CONTENT)) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') || '0');
const pageSize = parseInt(url.searchParams.get('pageSize') || '10');
const search = url.searchParams.get('search') || '';
const category = url.searchParams.get('category') || '';
const status = url.searchParams.get('status') || 'all';
// Build where clause for filtering
const where: any = {};
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
{ author: { contains: search, mode: 'insensitive' } }
];
}
if (category && category !== 'all') {
where.category = category;
}
if (status !== 'all') {
where.isActive = status === 'active';
}
// Get total count for pagination
const total = await prisma.prayerRequest.count({ where });
// Get prayer requests with pagination
const prayerRequests = await prisma.prayerRequest.findMany({
where,
select: {
id: true,
title: true,
description: true,
category: true,
author: true,
isAnonymous: true,
prayerCount: true,
isActive: true,
createdAt: true,
updatedAt: true,
user: {
select: {
id: true,
email: true,
name: true
}
}
},
orderBy: { createdAt: 'desc' },
skip: page * pageSize,
take: pageSize
});
return NextResponse.json({
prayerRequests,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize)
}
});
} catch (error) {
console.error('Admin prayer requests list error:', error);
return NextResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,143 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { getCurrentAdmin } from '@/lib/admin-auth';
export const runtime = 'nodejs';
export async function GET() {
try {
const admin = await getCurrentAdmin();
if (!admin) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Get date ranges
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
const lastWeek = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
// Parallel queries for better performance
const [
totalUsers,
usersToday,
usersYesterday,
dailyActiveUsers,
conversationsToday,
conversationsYesterday,
prayerRequestsToday,
prayerRequestsYesterday,
totalConversations,
totalPrayerRequests
] = await Promise.all([
// Total users
prisma.user.count(),
// Users created today
prisma.user.count({
where: {
createdAt: {
gte: today
}
}
}),
// Users created yesterday
prisma.user.count({
where: {
createdAt: {
gte: yesterday,
lt: today
}
}
}),
// Daily active users (logged in today)
prisma.user.count({
where: {
lastLoginAt: {
gte: today
}
}
}),
// AI conversations today
prisma.chatConversation.count({
where: {
createdAt: {
gte: today
}
}
}),
// AI conversations yesterday
prisma.chatConversation.count({
where: {
createdAt: {
gte: yesterday,
lt: today
}
}
}),
// Prayer requests today
prisma.prayerRequest.count({
where: {
createdAt: {
gte: today
}
}
}),
// Prayer requests yesterday
prisma.prayerRequest.count({
where: {
createdAt: {
gte: yesterday,
lt: today
}
}
}),
// Total conversations
prisma.chatConversation.count(),
// Total prayer requests
prisma.prayerRequest.count()
]);
// Calculate percentage changes
const calculateChange = (today: number, yesterday: number) => {
if (yesterday === 0) return today > 0 ? 100 : 0;
return Math.round(((today - yesterday) / yesterday) * 100);
};
const userGrowthChange = calculateChange(usersToday, usersYesterday);
const conversationChange = calculateChange(conversationsToday, conversationsYesterday);
const prayerChange = calculateChange(prayerRequestsToday, prayerRequestsYesterday);
return NextResponse.json({
totalUsers,
dailyActiveUsers,
conversationsToday,
prayerRequestsToday,
userGrowthChange,
conversationChange,
prayerChange,
totalConversations,
totalPrayerRequests,
usersToday,
usersYesterday
});
} catch (error) {
console.error('Admin overview stats error:', error);
return NextResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,151 @@
import { NextResponse } from 'next/server';
import { getCurrentAdmin, AdminPermission, hasPermission } from '@/lib/admin-auth';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
export const runtime = 'nodejs';
export async function POST(request: Request) {
try {
const admin = await getCurrentAdmin();
if (!admin || !hasPermission(admin, AdminPermission.MANAGE_SYSTEM)) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const body = await request.json();
const { type } = body; // 'database' or 'full'
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupDir = '/tmp/biblical-guide-backups';
try {
// Create backup directory
await execAsync(`mkdir -p ${backupDir}`);
let backupPath = '';
let command = '';
if (type === 'database') {
// Database backup using pg_dump
backupPath = `${backupDir}/db-backup-${timestamp}.sql`;
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
throw new Error('Database URL not configured');
}
command = `pg_dump "${dbUrl}" > "${backupPath}"`;
} else if (type === 'full') {
// Full system backup (excluding node_modules and .next)
backupPath = `${backupDir}/full-backup-${timestamp}.tar.gz`;
command = `tar -czf "${backupPath}" --exclude=node_modules --exclude=.next --exclude=.git /root/biblical-guide`;
} else {
return NextResponse.json(
{ error: 'Invalid backup type' },
{ status: 400 }
);
}
console.log(`Starting ${type} backup...`);
const { stdout, stderr } = await execAsync(command);
if (stderr && !stderr.includes('Warning')) {
throw new Error(`Backup failed: ${stderr}`);
}
// Get backup file size
const { stdout: sizeOutput } = await execAsync(`ls -lh "${backupPath}" | awk '{print $5}'`);
const fileSize = sizeOutput.trim();
console.log(`Admin ${admin.email} created ${type} backup: ${backupPath}`);
return NextResponse.json({
success: true,
backup: {
type,
path: backupPath,
size: fileSize,
timestamp: new Date().toISOString(),
createdBy: admin.email
}
});
} catch (error) {
console.error('Backup creation failed:', error);
return NextResponse.json(
{ error: `Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}` },
{ status: 500 }
);
}
} catch (error) {
console.error('Admin backup error:', error);
return NextResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
}
}
export async function GET(request: Request) {
try {
const admin = await getCurrentAdmin();
if (!admin || !hasPermission(admin, AdminPermission.MANAGE_SYSTEM)) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const backupDir = '/tmp/biblical-guide-backups';
try {
// List existing backups
const { stdout } = await execAsync(`ls -la ${backupDir} 2>/dev/null || echo ""`);
if (!stdout.trim()) {
return NextResponse.json({
backups: []
});
}
const lines = stdout.trim().split('\n').slice(1); // Skip the first line (total)
const backups = lines
.filter(line => !line.startsWith('d') && line.includes('backup'))
.map(line => {
const parts = line.split(/\s+/);
const filename = parts[parts.length - 1];
const size = parts[4];
const date = `${parts[5]} ${parts[6]} ${parts[7]}`;
return {
filename,
size,
date,
type: filename.includes('db-backup') ? 'database' : 'full'
};
});
return NextResponse.json({
backups: backups.reverse() // Most recent first
});
} catch (error) {
return NextResponse.json({
backups: []
});
}
} catch (error) {
console.error('Admin backup list error:', error);
return NextResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,132 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { getCurrentAdmin, AdminPermission, hasPermission } from '@/lib/admin-auth';
export const runtime = 'nodejs';
export async function GET(request: Request) {
try {
const admin = await getCurrentAdmin();
if (!admin || !hasPermission(admin, AdminPermission.MANAGE_SYSTEM)) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const startTime = Date.now();
// Database health check
let dbHealth = 'healthy';
let dbResponseTime = 0;
try {
const dbStart = Date.now();
await prisma.$queryRaw`SELECT 1`;
dbResponseTime = Date.now() - dbStart;
} catch (error) {
dbHealth = 'unhealthy';
console.error('Database health check failed:', error);
}
// System metrics
const systemMetrics = {
database: {
status: dbHealth,
responseTime: dbResponseTime,
connections: {
// This would require additional monitoring setup in production
active: 'N/A',
max: 'N/A'
}
},
application: {
status: 'healthy',
uptime: process.uptime(),
memory: {
used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024),
rss: Math.round(process.memoryUsage().rss / 1024 / 1024)
},
nodeVersion: process.version,
platform: process.platform,
arch: process.arch
}
};
// Database statistics
const dbStats = {
tables: {
users: await prisma.user.count(),
conversations: await prisma.chatConversation.count(),
messages: await prisma.chatMessage.count(),
prayerRequests: await prisma.prayerRequest.count(),
prayers: await prisma.prayer.count(),
bookmarks: await prisma.bookmark.count(),
notes: await prisma.note.count()
},
recentActivity: {
last24h: {
newUsers: await prisma.user.count({
where: {
createdAt: {
gte: new Date(Date.now() - 24 * 60 * 60 * 1000)
}
}
}),
newConversations: await prisma.chatConversation.count({
where: {
createdAt: {
gte: new Date(Date.now() - 24 * 60 * 60 * 1000)
}
}
}),
newPrayers: await prisma.prayer.count({
where: {
createdAt: {
gte: new Date(Date.now() - 24 * 60 * 60 * 1000)
}
}
})
}
}
};
// Security status
const securityStatus = {
adminUsers: await prisma.user.count({
where: { role: 'admin' }
}),
suspendedUsers: await prisma.user.count({
where: { role: 'suspended' }
}),
inactivePrayerRequests: await prisma.prayerRequest.count({
where: { isActive: false }
}),
inactiveConversations: await prisma.chatConversation.count({
where: { isActive: false }
})
};
const totalResponseTime = Date.now() - startTime;
return NextResponse.json({
timestamp: new Date().toISOString(),
status: dbHealth === 'healthy' ? 'healthy' : 'degraded',
responseTime: totalResponseTime,
metrics: systemMetrics,
database: dbStats,
security: securityStatus
});
} catch (error) {
console.error('System health check error:', error);
return NextResponse.json(
{
error: 'System health check failed',
status: 'unhealthy',
timestamp: new Date().toISOString()
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,214 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { getCurrentAdmin, AdminPermission, hasPermission } from '@/lib/admin-auth';
export const runtime = 'nodejs';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const admin = await getCurrentAdmin();
if (!admin || !hasPermission(admin, AdminPermission.VIEW_USERS)) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const { id } = await params;
const user = await prisma.user.findUnique({
where: { id },
include: {
chatConversations: {
select: {
id: true,
title: true,
createdAt: true,
_count: {
select: { messages: true }
}
},
orderBy: { createdAt: 'desc' },
take: 10
},
prayerRequests: {
select: {
id: true,
title: true,
category: true,
createdAt: true,
prayerCount: true
},
orderBy: { createdAt: 'desc' },
take: 10
},
bookmarks: {
select: {
id: true,
createdAt: true,
verse: {
select: {
verseNum: true,
chapter: {
select: {
chapterNum: true,
book: {
select: {
name: true
}
}
}
}
}
}
},
take: 10
},
_count: {
select: {
chatConversations: true,
prayerRequests: true,
bookmarks: true,
notes: true
}
}
}
});
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
return NextResponse.json({ user });
} catch (error) {
console.error('Admin user detail error:', error);
return NextResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const admin = await getCurrentAdmin();
if (!admin || !hasPermission(admin, AdminPermission.MANAGE_USERS)) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const { id } = await params;
const body = await request.json();
const { action, reason } = body;
let updateData: any = {};
switch (action) {
case 'suspend':
updateData = { role: 'suspended' };
break;
case 'activate':
updateData = { role: 'user' };
break;
case 'make_admin':
updateData = { role: 'admin' };
break;
case 'make_moderator':
updateData = { role: 'moderator' };
break;
default:
return NextResponse.json(
{ error: 'Invalid action' },
{ status: 400 }
);
}
const user = await prisma.user.update({
where: { id },
data: updateData,
select: {
id: true,
email: true,
name: true,
role: true
}
});
// TODO: Add audit log entry here in the future
console.log(`Admin ${admin.email} performed action '${action}' on user ${user.email}${reason ? ` with reason: ${reason}` : ''}`);
return NextResponse.json({ user });
} catch (error) {
console.error('Admin user update error:', error);
return NextResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const admin = await getCurrentAdmin();
if (!admin || !hasPermission(admin, AdminPermission.MANAGE_USERS)) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const { id } = await params;
// Prevent admin from deleting themselves
if (id === admin.id) {
return NextResponse.json(
{ error: 'Cannot delete your own account' },
{ status: 400 }
);
}
const user = await prisma.user.findUnique({
where: { id },
select: { email: true, role: true }
});
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
// Delete user and all related data (CASCADE)
await prisma.user.delete({
where: { id }
});
console.log(`Admin ${admin.email} deleted user ${user.email}`);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Admin user delete error:', error);
return NextResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,78 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { getCurrentAdmin, AdminPermission, hasPermission } from '@/lib/admin-auth';
export const runtime = 'nodejs';
export async function GET(request: Request) {
try {
const admin = await getCurrentAdmin();
if (!admin || !hasPermission(admin, AdminPermission.VIEW_USERS)) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') || '0');
const pageSize = parseInt(url.searchParams.get('pageSize') || '10');
const search = url.searchParams.get('search') || '';
const role = url.searchParams.get('role') || '';
// Build where clause for filtering
const where: any = {};
if (search) {
where.OR = [
{ email: { contains: search, mode: 'insensitive' } },
{ name: { contains: search, mode: 'insensitive' } }
];
}
if (role && role !== 'all') {
where.role = role;
}
// Get total count for pagination
const total = await prisma.user.count({ where });
// Get users with pagination
const users = await prisma.user.findMany({
where,
select: {
id: true,
email: true,
name: true,
role: true,
createdAt: true,
lastLoginAt: true,
_count: {
select: {
chatConversations: true,
prayerRequests: true,
bookmarks: true
}
}
},
orderBy: { createdAt: 'desc' },
skip: page * pageSize,
take: pageSize
});
return NextResponse.json({
users,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize)
}
});
} catch (error) {
console.error('Admin users list error:', error);
return NextResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,149 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Box,
Paper,
TextField,
Button,
Typography,
Alert,
CircularProgress,
Container
} from '@mui/material';
import { AdminPanelSettings } from '@mui/icons-material';
export function AdminLoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const response = await fetch('/api/admin/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (response.ok) {
// Force a small delay to ensure the cookie is set
setTimeout(() => {
router.push('/admin');
router.refresh();
}, 100);
} else {
setError(data.error || 'Login failed');
}
} catch (error) {
setError('Network error. Please try again.');
} finally {
setLoading(false);
}
};
return (
<Container component="main" maxWidth="sm">
<Box
sx={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Paper
elevation={3}
sx={{
padding: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
width: '100%',
maxWidth: 400,
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
mb: 3,
}}
>
<AdminPanelSettings
sx={{ fontSize: 40, color: 'primary.main', mb: 1 }}
/>
<Typography component="h1" variant="h4" gutterBottom>
Admin Portal
</Typography>
<Typography variant="body2" color="text.secondary">
Sign in to access the admin dashboard
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ width: '100%', mb: 2 }}>
{error}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit} sx={{ width: '100%' }}>
<TextField
margin="normal"
required
fullWidth
id="email"
label="Admin Email Address"
name="email"
autoComplete="email"
autoFocus
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
/>
<TextField
margin="normal"
required
fullWidth
name="password"
label="Password"
type="password"
id="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2, py: 1.5 }}
disabled={loading}
>
{loading ? <CircularProgress size={24} /> : 'Sign In to Admin'}
</Button>
</Box>
<Typography variant="body2" color="text.secondary" align="center">
Admin access only. Contact system administrator if you need access.
</Typography>
</Paper>
</Box>
</Container>
);
}

View File

@@ -0,0 +1,681 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
Box,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
Card,
CardContent,
Alert,
Chip,
IconButton,
Tooltip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Paper,
Divider,
Accordion,
AccordionSummary,
AccordionDetails
} from '@mui/material';
import {
DataGrid,
GridColDef,
GridActionsCellItem,
GridRowParams,
GridPaginationModel
} from '@mui/x-data-grid';
import {
Visibility,
Block,
CheckCircle,
Delete,
Person,
Chat,
Schedule,
Warning,
ExpandMore
} from '@mui/icons-material';
interface Conversation {
id: string;
title: string;
language: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
lastMessageAt: string;
user: {
id: string;
email: string;
name: string | null;
role: string;
} | null;
_count: {
messages: number;
};
messages: Array<{
id: string;
role: string;
content: string;
timestamp: string;
}>;
}
interface ConversationStats {
total: number;
active: number;
inactive: number;
today: number;
thisWeek: number;
}
interface ConversationDetailModalProps {
conversationId: string | null;
open: boolean;
onClose: () => void;
onConversationUpdate: (conversationId: string, action: string) => void;
}
function ConversationDetailModal({ conversationId, open, onClose, onConversationUpdate }: ConversationDetailModalProps) {
const [conversation, setConversation] = useState<any>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (conversationId && open) {
const fetchConversation = async () => {
setLoading(true);
try {
const response = await fetch(`/api/admin/chat/conversations/${conversationId}`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
setConversation(data);
}
} catch (error) {
console.error('Error fetching conversation:', error);
} finally {
setLoading(false);
}
};
fetchConversation();
}
}, [conversationId, open]);
const handleAction = async (action: string) => {
if (!conversationId) return;
try {
const response = await fetch(`/api/admin/chat/conversations/${conversationId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ action })
});
if (response.ok) {
onConversationUpdate(conversationId, action);
onClose();
}
} catch (error) {
console.error('Error updating conversation:', error);
}
};
const formatDuration = (milliseconds: number) => {
const minutes = Math.floor(milliseconds / 60000);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
}
return `${minutes}m`;
};
return (
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
<DialogTitle>Conversation Details</DialogTitle>
<DialogContent>
{loading ? (
<Typography>Loading...</Typography>
) : conversation ? (
<Box sx={{ py: 2 }}>
{/* Conversation Info */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
{conversation.conversation.title}
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
<Chip
label={conversation.conversation.language}
color="primary"
size="small"
/>
<Chip
label={conversation.conversation.isActive ? 'Active' : 'Inactive'}
color={conversation.conversation.isActive ? 'success' : 'error'}
size="small"
/>
</Box>
{conversation.conversation.user && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2">User Information</Typography>
<Typography variant="body2">
<strong>Name:</strong> {conversation.conversation.user.name || 'Unknown'}
</Typography>
<Typography variant="body2">
<strong>Email:</strong> {conversation.conversation.user.email}
</Typography>
<Typography variant="body2">
<strong>Role:</strong> {conversation.conversation.user.role}
</Typography>
</Box>
)}
{/* Analysis */}
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>Conversation Analysis</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 2 }}>
<Box>
<Typography variant="caption">Total Messages</Typography>
<Typography variant="h6">{conversation.analysis.messageCount}</Typography>
</Box>
<Box>
<Typography variant="caption">User Messages</Typography>
<Typography variant="h6">{conversation.analysis.userMessages}</Typography>
</Box>
<Box>
<Typography variant="caption">Duration</Typography>
<Typography variant="h6">{formatDuration(conversation.analysis.duration)}</Typography>
</Box>
<Box>
<Typography variant="caption">Avg Message Length</Typography>
<Typography variant="h6">{Math.round(conversation.analysis.averageMessageLength)}</Typography>
</Box>
</Box>
</Box>
{/* Potential Issues */}
{conversation.analysis.potentialIssues.length > 0 && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>Potential Issues</Typography>
{conversation.analysis.potentialIssues.map((issue: string, index: number) => (
<Alert key={index} severity="warning" sx={{ mb: 1 }}>
{issue}
</Alert>
))}
</Box>
)}
</CardContent>
</Card>
{/* Messages */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Messages ({conversation.conversation.messages.length})
</Typography>
<Box sx={{ maxHeight: 400, overflow: 'auto' }}>
{conversation.conversation.messages.map((message: any, index: number) => (
<Accordion key={message.id} sx={{ mb: 1 }}>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%' }}>
<Chip
label={message.role}
color={message.role === 'USER' ? 'primary' : 'secondary'}
size="small"
/>
<Typography variant="body2" sx={{ flexGrow: 1 }}>
{message.content.substring(0, 100)}...
</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(message.timestamp).toLocaleString()}
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{message.content}
</Typography>
</AccordionDetails>
</Accordion>
))}
</Box>
</CardContent>
</Card>
{/* Actions */}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mt: 3 }}>
{conversation.conversation.isActive ? (
<Button
variant="outlined"
color="error"
onClick={() => handleAction('deactivate')}
>
Deactivate Conversation
</Button>
) : (
<Button
variant="contained"
color="success"
onClick={() => handleAction('activate')}
>
Activate Conversation
</Button>
)}
</Box>
</Box>
) : (
<Typography>Conversation not found</Typography>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
);
}
export function ConversationMonitoring() {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [stats, setStats] = useState<ConversationStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({
page: 0,
pageSize: 10,
});
const [rowCount, setRowCount] = useState(0);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [languageFilter, setLanguageFilter] = useState('all');
const [sortBy, setSortBy] = useState('lastMessage');
const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [conversationToDelete, setConversationToDelete] = useState<Conversation | null>(null);
const fetchConversations = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: paginationModel.page.toString(),
pageSize: paginationModel.pageSize.toString(),
search,
status: statusFilter,
language: languageFilter,
sortBy
});
const response = await fetch(`/api/admin/chat/conversations?${params}`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
setConversations(data.conversations);
setStats(data.stats);
setRowCount(data.pagination.total);
} else {
setError('Failed to load conversations');
}
} catch (error) {
setError('Network error loading conversations');
} finally {
setLoading(false);
}
}, [paginationModel, search, statusFilter, languageFilter, sortBy]);
useEffect(() => {
fetchConversations();
}, [fetchConversations]);
const handleConversationUpdate = useCallback((conversationId: string, action: string) => {
fetchConversations();
}, [fetchConversations]);
const handleViewConversation = (params: GridRowParams) => {
setSelectedConversationId(params.id as string);
setModalOpen(true);
};
const handleDeleteConversation = (params: GridRowParams) => {
setConversationToDelete(params.row as Conversation);
setDeleteDialogOpen(true);
};
const confirmDeleteConversation = async () => {
if (!conversationToDelete) return;
try {
const response = await fetch(`/api/admin/chat/conversations/${conversationToDelete.id}`, {
method: 'DELETE',
credentials: 'include'
});
if (response.ok) {
fetchConversations();
setDeleteDialogOpen(false);
setConversationToDelete(null);
}
} catch (error) {
console.error('Error deleting conversation:', error);
}
};
const getStatusChip = (isActive: boolean) => {
return (
<Chip
label={isActive ? 'Active' : 'Inactive'}
color={isActive ? 'success' : 'error'}
size="small"
variant="outlined"
/>
);
};
const columns: GridColDef[] = [
{
field: 'title',
headerName: 'Conversation',
flex: 1,
minWidth: 250,
renderCell: (params) => (
<Box>
<Typography variant="body2" fontWeight="medium" noWrap>
{params.value}
</Typography>
<Typography variant="caption" color="text.secondary">
{params.row.user?.name || params.row.user?.email || 'Anonymous'}
</Typography>
</Box>
)
},
{
field: 'language',
headerName: 'Language',
width: 100,
renderCell: (params) => (
<Chip label={params.value} size="small" variant="outlined" />
)
},
{
field: '_count',
headerName: 'Messages',
width: 100,
align: 'center',
headerAlign: 'center',
renderCell: (params) => params.value.messages
},
{
field: 'isActive',
headerName: 'Status',
width: 100,
renderCell: (params) => getStatusChip(params.value)
},
{
field: 'lastMessageAt',
headerName: 'Last Activity',
width: 140,
renderCell: (params) => {
const date = new Date(params.value);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
let timeAgo = '';
if (diffDays > 0) {
timeAgo = `${diffDays}d ago`;
} else if (diffHours > 0) {
timeAgo = `${diffHours}h ago`;
} else {
timeAgo = `${diffMins}m ago`;
}
return (
<Typography variant="caption">
{timeAgo}
</Typography>
);
}
},
{
field: 'user',
headerName: 'User',
width: 120,
renderCell: (params) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Person fontSize="small" color="primary" />
<Typography variant="caption">
{params.value?.role || 'Anonymous'}
</Typography>
</Box>
)
},
{
field: 'actions',
type: 'actions',
headerName: 'Actions',
width: 120,
getActions: (params) => [
<GridActionsCellItem
key="view"
icon={
<Tooltip title="View Details">
<Visibility />
</Tooltip>
}
label="View"
onClick={() => handleViewConversation(params)}
/>,
<GridActionsCellItem
key="delete"
icon={
<Tooltip title="Delete Conversation">
<Delete />
</Tooltip>
}
label="Delete"
onClick={() => handleDeleteConversation(params)}
/>
]
}
];
return (
<Box>
{/* Statistics Cards */}
{stats && (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: 3,
mb: 3
}}
>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Chat sx={{ fontSize: 40, color: 'primary.main', mr: 2 }} />
<Box>
<Typography color="textSecondary" variant="body2">
Total Conversations
</Typography>
<Typography variant="h5">{stats.total}</Typography>
</Box>
</Box>
</CardContent>
</Card>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<CheckCircle sx={{ fontSize: 40, color: 'success.main', mr: 2 }} />
<Box>
<Typography color="textSecondary" variant="body2">
Active
</Typography>
<Typography variant="h5">{stats.active}</Typography>
</Box>
</Box>
</CardContent>
</Card>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Schedule sx={{ fontSize: 40, color: 'warning.main', mr: 2 }} />
<Box>
<Typography color="textSecondary" variant="body2">
Today
</Typography>
<Typography variant="h5">{stats.today}</Typography>
</Box>
</Box>
</CardContent>
</Card>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Warning sx={{ fontSize: 40, color: 'error.main', mr: 2 }} />
<Box>
<Typography color="textSecondary" variant="body2">
This Week
</Typography>
<Typography variant="h5">{stats.thisWeek}</Typography>
</Box>
</Box>
</CardContent>
</Card>
</Box>
)}
{/* Filters */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<TextField
label="Search conversations"
value={search}
onChange={(e) => setSearch(e.target.value)}
size="small"
sx={{ minWidth: 250 }}
/>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel>Status</InputLabel>
<Select
value={statusFilter}
label="Status"
onChange={(e) => setStatusFilter(e.target.value)}
>
<MenuItem value="all">All Status</MenuItem>
<MenuItem value="active">Active</MenuItem>
<MenuItem value="inactive">Inactive</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel>Language</InputLabel>
<Select
value={languageFilter}
label="Language"
onChange={(e) => setLanguageFilter(e.target.value)}
>
<MenuItem value="all">All Languages</MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="ro">Romanian</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel>Sort By</InputLabel>
<Select
value={sortBy}
label="Sort By"
onChange={(e) => setSortBy(e.target.value)}
>
<MenuItem value="lastMessage">Last Message</MenuItem>
<MenuItem value="created">Created Date</MenuItem>
<MenuItem value="messageCount">Message Count</MenuItem>
</Select>
</FormControl>
</Box>
</CardContent>
</Card>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{/* Data Grid */}
<Card>
<Box sx={{ height: 600 }}>
<DataGrid
rows={conversations}
columns={columns}
loading={loading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
rowCount={rowCount}
paginationMode="server"
pageSizeOptions={[10, 25, 50]}
disableRowSelectionOnClick
sx={{
border: 'none',
'& .MuiDataGrid-cell': {
borderBottom: '1px solid #f0f0f0'
}
}}
/>
</Box>
</Card>
{/* Conversation Detail Modal */}
<ConversationDetailModal
conversationId={selectedConversationId}
open={modalOpen}
onClose={() => setModalOpen(false)}
onConversationUpdate={handleConversationUpdate}
/>
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Delete Conversation</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to delete the conversation <strong>"{conversationToDelete?.title}"</strong>?
</Typography>
<Typography variant="body2" color="error" sx={{ mt: 2 }}>
This action cannot be undone. All messages in this conversation will be permanently deleted.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button
onClick={confirmDeleteConversation}
color="error"
variant="contained"
>
Delete Conversation
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,519 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
Box,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
Card,
CardContent,
Alert,
Chip,
IconButton,
Tooltip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography
} from '@mui/material';
import {
DataGrid,
GridColDef,
GridActionsCellItem,
GridRowParams,
GridPaginationModel
} from '@mui/x-data-grid';
import {
Visibility,
CheckCircle,
Cancel,
Delete,
Person,
PersonOff
} from '@mui/icons-material';
interface PrayerRequest {
id: string;
title: string;
description: string;
category: string;
author: string;
isAnonymous: boolean;
prayerCount: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
user: {
id: string;
email: string;
name: string | null;
} | null;
}
interface PrayerRequestDetailModalProps {
prayerRequestId: string | null;
open: boolean;
onClose: () => void;
onPrayerRequestUpdate: (prayerRequestId: string, action: string) => void;
}
function PrayerRequestDetailModal({ prayerRequestId, open, onClose, onPrayerRequestUpdate }: PrayerRequestDetailModalProps) {
const [prayerRequest, setPrayerRequest] = useState<any>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (prayerRequestId && open) {
const fetchPrayerRequest = async () => {
setLoading(true);
try {
const response = await fetch(`/api/admin/content/prayer-requests/${prayerRequestId}`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
setPrayerRequest(data.prayerRequest);
}
} catch (error) {
console.error('Error fetching prayer request:', error);
} finally {
setLoading(false);
}
};
fetchPrayerRequest();
}
}, [prayerRequestId, open]);
const handleAction = async (action: string) => {
if (!prayerRequestId) return;
try {
const response = await fetch(`/api/admin/content/prayer-requests/${prayerRequestId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ action })
});
if (response.ok) {
onPrayerRequestUpdate(prayerRequestId, action);
onClose();
}
} catch (error) {
console.error('Error updating prayer request:', error);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>Prayer Request Details</DialogTitle>
<DialogContent>
{loading ? (
<Typography>Loading...</Typography>
) : prayerRequest ? (
<Box sx={{ py: 2 }}>
<Box sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom>
{prayerRequest.title}
</Typography>
<Typography variant="body1" sx={{ mb: 2 }}>
{prayerRequest.description}
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
<Chip
label={prayerRequest.category}
color="primary"
size="small"
/>
<Chip
label={prayerRequest.isActive ? 'Active' : 'Inactive'}
color={prayerRequest.isActive ? 'success' : 'error'}
size="small"
/>
<Chip
label={prayerRequest.isAnonymous ? 'Anonymous' : 'Public'}
color={prayerRequest.isAnonymous ? 'default' : 'info'}
size="small"
/>
</Box>
</Box>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" gutterBottom>Request Information</Typography>
<Typography variant="body2">
<strong>Author:</strong> {prayerRequest.author}
</Typography>
{prayerRequest.user && (
<Typography variant="body2">
<strong>User:</strong> {prayerRequest.user.name || prayerRequest.user.email}
</Typography>
)}
<Typography variant="body2">
<strong>Prayer Count:</strong> {prayerRequest.prayerCount}
</Typography>
<Typography variant="body2">
<strong>Created:</strong> {new Date(prayerRequest.createdAt).toLocaleString()}
</Typography>
<Typography variant="body2">
<strong>Updated:</strong> {new Date(prayerRequest.updatedAt).toLocaleString()}
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{prayerRequest.isActive ? (
<Button
variant="outlined"
color="error"
onClick={() => handleAction('reject')}
>
Reject Request
</Button>
) : (
<Button
variant="contained"
color="success"
onClick={() => handleAction('approve')}
>
Approve Request
</Button>
)}
</Box>
</Box>
) : (
<Typography>Prayer request not found</Typography>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
);
}
export function PrayerRequestDataGrid() {
const [prayerRequests, setPrayerRequests] = useState<PrayerRequest[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({
page: 0,
pageSize: 10,
});
const [rowCount, setRowCount] = useState(0);
const [search, setSearch] = useState('');
const [categoryFilter, setCategoryFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
const [selectedPrayerRequestId, setSelectedPrayerRequestId] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [prayerRequestToDelete, setPrayerRequestToDelete] = useState<PrayerRequest | null>(null);
const fetchPrayerRequests = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: paginationModel.page.toString(),
pageSize: paginationModel.pageSize.toString(),
search,
category: categoryFilter,
status: statusFilter
});
const response = await fetch(`/api/admin/content/prayer-requests?${params}`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
setPrayerRequests(data.prayerRequests);
setRowCount(data.pagination.total);
} else {
setError('Failed to load prayer requests');
}
} catch (error) {
setError('Network error loading prayer requests');
} finally {
setLoading(false);
}
}, [paginationModel, search, categoryFilter, statusFilter]);
useEffect(() => {
fetchPrayerRequests();
}, [fetchPrayerRequests]);
const handlePrayerRequestUpdate = useCallback((prayerRequestId: string, action: string) => {
// Refresh the data after prayer request update
fetchPrayerRequests();
}, [fetchPrayerRequests]);
const handleViewPrayerRequest = (params: GridRowParams) => {
setSelectedPrayerRequestId(params.id as string);
setModalOpen(true);
};
const handleDeletePrayerRequest = (params: GridRowParams) => {
setPrayerRequestToDelete(params.row as PrayerRequest);
setDeleteDialogOpen(true);
};
const confirmDeletePrayerRequest = async () => {
if (!prayerRequestToDelete) return;
try {
const response = await fetch(`/api/admin/content/prayer-requests/${prayerRequestToDelete.id}`, {
method: 'DELETE',
credentials: 'include'
});
if (response.ok) {
fetchPrayerRequests();
setDeleteDialogOpen(false);
setPrayerRequestToDelete(null);
}
} catch (error) {
console.error('Error deleting prayer request:', error);
}
};
const getStatusChip = (isActive: boolean) => {
return (
<Chip
label={isActive ? 'Active' : 'Inactive'}
color={isActive ? 'success' : 'error'}
size="small"
variant="outlined"
/>
);
};
const getCategoryChip = (category: string) => {
const colors: Record<string, any> = {
personal: 'primary',
family: 'secondary',
health: 'error',
work: 'warning',
ministry: 'info',
world: 'success'
};
return (
<Chip
label={category}
color={colors[category] || 'default'}
size="small"
variant="outlined"
/>
);
};
const columns: GridColDef[] = [
{
field: 'title',
headerName: 'Title',
flex: 1,
minWidth: 200,
renderCell: (params) => (
<Box>
<Typography variant="body2" fontWeight="medium">
{params.value}
</Typography>
<Typography variant="caption" color="text.secondary">
by {params.row.author}
</Typography>
</Box>
)
},
{
field: 'category',
headerName: 'Category',
width: 120,
renderCell: (params) => getCategoryChip(params.value)
},
{
field: 'prayerCount',
headerName: 'Prayers',
width: 80,
align: 'center',
headerAlign: 'center'
},
{
field: 'isActive',
headerName: 'Status',
width: 100,
renderCell: (params) => getStatusChip(params.value)
},
{
field: 'createdAt',
headerName: 'Created',
width: 120,
renderCell: (params) => new Date(params.value).toLocaleDateString()
},
{
field: 'user',
headerName: 'User',
width: 120,
renderCell: (params) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{params.row.isAnonymous ? (
<PersonOff fontSize="small" color="disabled" />
) : (
<Person fontSize="small" color="primary" />
)}
<Typography variant="caption">
{params.row.isAnonymous ? 'Anonymous' : (params.value?.name || params.value?.email || 'N/A')}
</Typography>
</Box>
)
},
{
field: 'actions',
type: 'actions',
headerName: 'Actions',
width: 120,
getActions: (params) => {
const actions = [
<GridActionsCellItem
key="view"
icon={
<Tooltip title="View Details">
<Visibility />
</Tooltip>
}
label="View"
onClick={() => handleViewPrayerRequest(params)}
/>
];
actions.push(
<GridActionsCellItem
key="delete"
icon={
<Tooltip title="Delete Prayer Request">
<Delete />
</Tooltip>
}
label="Delete"
onClick={() => handleDeletePrayerRequest(params)}
/>
);
return actions;
}
}
];
return (
<Box>
{/* Filters */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<TextField
label="Search prayer requests"
value={search}
onChange={(e) => setSearch(e.target.value)}
size="small"
sx={{ minWidth: 250 }}
/>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel>Category</InputLabel>
<Select
value={categoryFilter}
label="Category"
onChange={(e) => setCategoryFilter(e.target.value)}
>
<MenuItem value="all">All Categories</MenuItem>
<MenuItem value="personal">Personal</MenuItem>
<MenuItem value="family">Family</MenuItem>
<MenuItem value="health">Health</MenuItem>
<MenuItem value="work">Work</MenuItem>
<MenuItem value="ministry">Ministry</MenuItem>
<MenuItem value="world">World</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel>Status</InputLabel>
<Select
value={statusFilter}
label="Status"
onChange={(e) => setStatusFilter(e.target.value)}
>
<MenuItem value="all">All Status</MenuItem>
<MenuItem value="active">Active</MenuItem>
<MenuItem value="inactive">Inactive</MenuItem>
</Select>
</FormControl>
</Box>
</CardContent>
</Card>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{/* Data Grid */}
<Card>
<Box sx={{ height: 600 }}>
<DataGrid
rows={prayerRequests}
columns={columns}
loading={loading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
rowCount={rowCount}
paginationMode="server"
pageSizeOptions={[10, 25, 50]}
disableRowSelectionOnClick
sx={{
border: 'none',
'& .MuiDataGrid-cell': {
borderBottom: '1px solid #f0f0f0'
}
}}
/>
</Box>
</Card>
{/* Prayer Request Detail Modal */}
<PrayerRequestDetailModal
prayerRequestId={selectedPrayerRequestId}
open={modalOpen}
onClose={() => setModalOpen(false)}
onPrayerRequestUpdate={handlePrayerRequestUpdate}
/>
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Delete Prayer Request</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to delete the prayer request <strong>"{prayerRequestToDelete?.title}"</strong>?
</Typography>
<Typography variant="body2" color="error" sx={{ mt: 2 }}>
This action cannot be undone. All prayer data for this request will be permanently deleted.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button
onClick={confirmDeletePrayerRequest}
color="error"
variant="contained"
>
Delete Prayer Request
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,196 @@
'use client';
import { useEffect, useState } from 'react';
import {
Grid,
Card,
CardContent,
Typography,
Box,
Chip,
CircularProgress,
Alert
} from '@mui/material';
import {
People,
Chat,
FavoriteBorder,
TrendingUp,
TrendingDown
} from '@mui/icons-material';
interface OverviewStats {
totalUsers: number;
dailyActiveUsers: number;
conversationsToday: number;
prayerRequestsToday: number;
userGrowthChange: number;
conversationChange: number;
prayerChange: number;
usersToday: number;
}
interface MetricCardProps {
title: string;
value: number;
change?: number;
icon: React.ReactNode;
color: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info';
subtitle?: string;
}
function MetricCard({ title, value, change, icon, color, subtitle }: MetricCardProps) {
const isPositiveChange = change !== undefined && change >= 0;
return (
<Card sx={{ height: '100%' }}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Box>
<Typography color="text.secondary" gutterBottom variant="h6">
{title}
</Typography>
<Typography variant="h4" component="div" sx={{ mb: 1 }}>
{value.toLocaleString()}
</Typography>
{subtitle && (
<Typography variant="body2" color="text.secondary">
{subtitle}
</Typography>
)}
</Box>
<Box
sx={{
backgroundColor: `${color}.main`,
borderRadius: 2,
p: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white'
}}
>
{icon}
</Box>
</Box>
{change !== undefined && (
<Box sx={{ mt: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
{isPositiveChange ? (
<TrendingUp color="success" fontSize="small" />
) : (
<TrendingDown color="error" fontSize="small" />
)}
<Chip
label={`${Math.abs(change)}%`}
color={isPositiveChange ? 'success' : 'error'}
size="small"
variant="outlined"
/>
<Typography variant="body2" color="text.secondary">
vs yesterday
</Typography>
</Box>
)}
</CardContent>
</Card>
);
}
export function OverviewCards() {
const [stats, setStats] = useState<OverviewStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const fetchStats = async () => {
try {
const response = await fetch('/api/admin/stats/overview', {
credentials: 'include'
});
const data = await response.json();
if (response.ok) {
setStats(data);
} else {
setError(data.error || 'Failed to load stats');
}
} catch (error) {
setError('Network error loading stats');
} finally {
setLoading(false);
}
};
fetchStats();
// Refresh stats every 30 seconds
const interval = setInterval(fetchStats, 30000);
return () => clearInterval(interval);
}, []);
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
);
}
if (!stats) return null;
return (
<Box sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
md: 'repeat(4, 1fr)'
},
gap: 3
}}>
<MetricCard
title="Total Users"
value={stats.totalUsers}
change={stats.userGrowthChange}
icon={<People />}
color="primary"
subtitle={`${stats.usersToday} new today`}
/>
<MetricCard
title="Daily Active Users"
value={stats.dailyActiveUsers}
icon={<People />}
color="success"
subtitle="Logged in today"
/>
<MetricCard
title="AI Conversations"
value={stats.conversationsToday}
change={stats.conversationChange}
icon={<Chat />}
color="info"
subtitle="Today"
/>
<MetricCard
title="Prayer Requests"
value={stats.prayerRequestsToday}
change={stats.prayerChange}
icon={<FavoriteBorder />}
color="warning"
subtitle="Today"
/>
</Box>
);
}

View File

@@ -0,0 +1,240 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Box,
Drawer,
AppBar,
Toolbar,
List,
Typography,
Divider,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
IconButton,
Menu,
MenuItem,
Avatar,
Chip,
Button
} from '@mui/material';
import {
Dashboard,
People,
Gavel,
Analytics,
Chat,
Settings,
Logout,
AccountCircle,
AdminPanelSettings,
Launch as LaunchIcon
} from '@mui/icons-material';
interface AdminLayoutProps {
children: React.ReactNode;
user?: {
id: string;
email: string;
name: string | null;
role: string;
};
}
const drawerWidth = 280;
const menuItems = [
{ text: 'Dashboard', icon: Dashboard, href: '/admin' },
{ text: 'Users', icon: People, href: '/admin/users' },
{ text: 'Content Moderation', icon: Gavel, href: '/admin/content' },
{ text: 'Analytics', icon: Analytics, href: '/admin/analytics' },
{ text: 'Chat Monitoring', icon: Chat, href: '/admin/chat' },
{ text: 'Settings', icon: Settings, href: '/admin/settings' },
];
export function AdminLayout({ children, user }: AdminLayoutProps) {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const router = useRouter();
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleLogout = async () => {
try {
await fetch('/api/admin/auth/logout', {
method: 'POST',
credentials: 'include'
});
router.push('/admin/login');
router.refresh();
} catch (error) {
console.error('Logout error:', error);
}
handleClose();
};
const currentPath = typeof window !== 'undefined' ? window.location.pathname : '';
return (
<Box sx={{ display: 'flex' }}>
<AppBar
position="fixed"
sx={{
width: `calc(100% - ${drawerWidth}px)`,
ml: `${drawerWidth}px`,
backgroundColor: 'primary.main'
}}
>
<Toolbar>
<AdminPanelSettings sx={{ mr: 2 }} />
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
Biblical Guide Admin
</Typography>
{user && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
variant="outlined"
startIcon={<LaunchIcon />}
onClick={() => window.open('/', '_blank')}
sx={{
color: 'white',
borderColor: 'white',
'&:hover': {
borderColor: 'white',
backgroundColor: 'rgba(255, 255, 255, 0.1)'
}
}}
>
Visit Website
</Button>
<Chip
label={user.role}
color={user.role === 'admin' ? 'error' : 'warning'}
size="small"
variant="outlined"
sx={{ color: 'white', borderColor: 'white' }}
/>
<IconButton
size="large"
aria-label="account of current user"
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={handleMenu}
color="inherit"
>
<Avatar sx={{ width: 32, height: 32 }}>
{user.name?.[0] || user.email[0]}
</Avatar>
</IconButton>
<Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={Boolean(anchorEl)}
onClose={handleClose}
>
<MenuItem disabled>
<Box>
<Typography variant="body2" fontWeight="bold">
{user.name || 'Admin User'}
</Typography>
<Typography variant="caption" color="text.secondary">
{user.email}
</Typography>
</Box>
</MenuItem>
<Divider />
<MenuItem onClick={handleLogout}>
<ListItemIcon>
<Logout fontSize="small" />
</ListItemIcon>
Logout
</MenuItem>
</Menu>
</Box>
)}
</Toolbar>
</AppBar>
<Drawer
sx={{
width: drawerWidth,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: drawerWidth,
boxSizing: 'border-box',
},
}}
variant="permanent"
anchor="left"
>
<Toolbar>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AdminPanelSettings color="primary" />
<Typography variant="h6" noWrap>
Admin Panel
</Typography>
</Box>
</Toolbar>
<Divider />
<List>
{menuItems.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton
selected={currentPath === item.href}
onClick={() => router.push(item.href)}
sx={{
'&.Mui-selected': {
backgroundColor: 'primary.main',
color: 'white',
'&:hover': {
backgroundColor: 'primary.dark',
},
'& .MuiListItemIcon-root': {
color: 'white',
},
},
}}
>
<ListItemIcon>
<item.icon />
</ListItemIcon>
<ListItemText primary={item.text} />
</ListItemButton>
</ListItem>
))}
</List>
</Drawer>
<Box
component="main"
sx={{
flexGrow: 1,
bgcolor: 'background.default',
p: 3,
minHeight: '100vh'
}}
>
<Toolbar />
{children}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,599 @@
'use client';
import { useState, useEffect } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Button,
Alert,
Chip,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
FormControl,
InputLabel,
Select,
MenuItem,
LinearProgress
} from '@mui/material';
import {
Storage,
Memory,
Computer,
Security,
Backup,
Refresh,
Download,
CheckCircle,
Warning,
Error
} from '@mui/icons-material';
interface SystemHealth {
timestamp: string;
status: string;
responseTime: number;
metrics: {
database: {
status: string;
responseTime: number;
connections: {
active: string;
max: string;
};
};
application: {
status: string;
uptime: number;
memory: {
used: number;
total: number;
rss: number;
};
nodeVersion: string;
platform: string;
arch: string;
};
};
database: {
tables: {
users: number;
conversations: number;
messages: number;
prayerRequests: number;
prayers: number;
bookmarks: number;
notes: number;
};
recentActivity: {
last24h: {
newUsers: number;
newConversations: number;
newPrayers: number;
};
};
};
security: {
adminUsers: number;
suspendedUsers: number;
inactivePrayerRequests: number;
inactiveConversations: number;
};
}
interface Backup {
filename: string;
size: string;
date: string;
type: string;
}
export function SystemDashboard() {
const [health, setHealth] = useState<SystemHealth | null>(null);
const [backups, setBackups] = useState<Backup[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [backupDialogOpen, setBackupDialogOpen] = useState(false);
const [backupType, setBackupType] = useState('database');
const [backupLoading, setBackupLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const fetchSystemHealth = async () => {
try {
const response = await fetch('/api/admin/system/health', {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
setHealth(data);
} else {
setError('Failed to load system health data');
}
} catch (error) {
setError('Network error loading system health');
}
};
const fetchBackups = async () => {
try {
const response = await fetch('/api/admin/system/backup', {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
setBackups(data.backups);
}
} catch (error) {
console.error('Error loading backups:', error);
}
};
const refreshData = async () => {
setRefreshing(true);
await Promise.all([fetchSystemHealth(), fetchBackups()]);
setRefreshing(false);
};
useEffect(() => {
const loadData = async () => {
setLoading(true);
await Promise.all([fetchSystemHealth(), fetchBackups()]);
setLoading(false);
};
loadData();
// Auto-refresh every 30 seconds
const interval = setInterval(fetchSystemHealth, 30000);
return () => clearInterval(interval);
}, []);
const handleCreateBackup = async () => {
setBackupLoading(true);
try {
const response = await fetch('/api/admin/system/backup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ type: backupType })
});
if (response.ok) {
await fetchBackups();
setBackupDialogOpen(false);
} else {
const data = await response.json();
setError(data.error || 'Backup failed');
}
} catch (error) {
setError('Network error creating backup');
} finally {
setBackupLoading(false);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'healthy':
return <CheckCircle color="success" />;
case 'degraded':
return <Warning color="warning" />;
case 'unhealthy':
return <Error color="error" />;
default:
return <Warning color="disabled" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'healthy':
return 'success';
case 'degraded':
return 'warning';
case 'unhealthy':
return 'error';
default:
return 'default';
}
};
const formatUptime = (seconds: number) => {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return `${days}d ${hours}h ${minutes}m`;
} else if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {
return `${minutes}m`;
}
};
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 400 }}>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
);
}
if (!health) return null;
const memoryUsagePercent = (health.metrics.application.memory.used / health.metrics.application.memory.total) * 100;
return (
<Box>
{/* Header with Refresh */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h5">System Status</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
variant="outlined"
startIcon={<Refresh />}
onClick={refreshData}
disabled={refreshing}
>
{refreshing ? 'Refreshing...' : 'Refresh'}
</Button>
<Button
variant="contained"
startIcon={<Backup />}
onClick={() => setBackupDialogOpen(true)}
>
Create Backup
</Button>
</Box>
</Box>
{/* System Health Overview */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
gap: 3,
mb: 3
}}
>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography color="textSecondary" variant="body2">
System Status
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
{getStatusIcon(health.status)}
<Chip
label={health.status}
color={getStatusColor(health.status) as any}
size="small"
/>
</Box>
</Box>
<Computer sx={{ fontSize: 40, color: 'primary.main' }} />
</Box>
</CardContent>
</Card>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography color="textSecondary" variant="body2">
Database
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
{getStatusIcon(health.metrics.database.status)}
<Typography variant="body2">
{health.metrics.database.responseTime}ms
</Typography>
</Box>
</Box>
<Storage sx={{ fontSize: 40, color: 'success.main' }} />
</Box>
</CardContent>
</Card>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography color="textSecondary" variant="body2">
Memory Usage
</Typography>
<Typography variant="h6">
{health.metrics.application.memory.used}MB / {health.metrics.application.memory.total}MB
</Typography>
<LinearProgress
variant="determinate"
value={memoryUsagePercent}
sx={{ mt: 1 }}
color={memoryUsagePercent > 80 ? 'error' : memoryUsagePercent > 60 ? 'warning' : 'primary'}
/>
</Box>
<Memory sx={{ fontSize: 40, color: 'warning.main' }} />
</Box>
</CardContent>
</Card>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography color="textSecondary" variant="body2">
Uptime
</Typography>
<Typography variant="h6">
{formatUptime(health.metrics.application.uptime)}
</Typography>
</Box>
<CheckCircle sx={{ fontSize: 40, color: 'info.main' }} />
</Box>
</CardContent>
</Card>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', lg: '1fr 1fr' },
gap: 3,
mb: 3
}}
>
{/* Database Statistics */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Database Statistics
</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Table</TableCell>
<TableCell align="right">Records</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(health.database.tables).map(([table, count]) => (
<TableRow key={table}>
<TableCell>{table}</TableCell>
<TableCell align="right">{count.toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
{/* Security Status */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Security />
Security Status
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<Box>
<Typography variant="body2" color="textSecondary">
Admin Users
</Typography>
<Typography variant="h6">{health.security.adminUsers}</Typography>
</Box>
<Box>
<Typography variant="body2" color="textSecondary">
Suspended Users
</Typography>
<Typography variant="h6" color="error.main">
{health.security.suspendedUsers}
</Typography>
</Box>
<Box>
<Typography variant="body2" color="textSecondary">
Inactive Prayers
</Typography>
<Typography variant="h6" color="warning.main">
{health.security.inactivePrayerRequests}
</Typography>
</Box>
<Box>
<Typography variant="body2" color="textSecondary">
Inactive Chats
</Typography>
<Typography variant="h6" color="warning.main">
{health.security.inactiveConversations}
</Typography>
</Box>
</Box>
</CardContent>
</Card>
</Box>
{/* Recent Activity & Backups */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', lg: '1fr 1fr' },
gap: 3
}}
>
{/* Recent Activity */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Recent Activity (24h)
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 2 }}>
<Box>
<Typography variant="body2" color="textSecondary">
New Users
</Typography>
<Typography variant="h6" color="primary.main">
{health.database.recentActivity.last24h.newUsers}
</Typography>
</Box>
<Box>
<Typography variant="body2" color="textSecondary">
New Conversations
</Typography>
<Typography variant="h6" color="success.main">
{health.database.recentActivity.last24h.newConversations}
</Typography>
</Box>
<Box>
<Typography variant="body2" color="textSecondary">
New Prayers
</Typography>
<Typography variant="h6" color="info.main">
{health.database.recentActivity.last24h.newPrayers}
</Typography>
</Box>
</Box>
</CardContent>
</Card>
{/* System Backups */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
System Backups
</Typography>
{backups.length > 0 ? (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Type</TableCell>
<TableCell>Size</TableCell>
<TableCell>Date</TableCell>
</TableRow>
</TableHead>
<TableBody>
{backups.slice(0, 5).map((backup, index) => (
<TableRow key={index}>
<TableCell>
<Chip
label={backup.type}
size="small"
color={backup.type === 'database' ? 'primary' : 'secondary'}
variant="outlined"
/>
</TableCell>
<TableCell>{backup.size}</TableCell>
<TableCell>
<Typography variant="caption">
{backup.date}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<Typography variant="body2" color="textSecondary">
No backups available
</Typography>
)}
</CardContent>
</Card>
</Box>
{/* System Information */}
<Card sx={{ mt: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
System Information
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 2 }}>
<Box>
<Typography variant="body2" color="textSecondary">
Node.js Version
</Typography>
<Typography variant="body1">{health.metrics.application.nodeVersion}</Typography>
</Box>
<Box>
<Typography variant="body2" color="textSecondary">
Platform
</Typography>
<Typography variant="body1">{health.metrics.application.platform}</Typography>
</Box>
<Box>
<Typography variant="body2" color="textSecondary">
Architecture
</Typography>
<Typography variant="body1">{health.metrics.application.arch}</Typography>
</Box>
<Box>
<Typography variant="body2" color="textSecondary">
Last Check
</Typography>
<Typography variant="body1">
{new Date(health.timestamp).toLocaleString()}
</Typography>
</Box>
</Box>
</CardContent>
</Card>
{/* Backup Creation Dialog */}
<Dialog open={backupDialogOpen} onClose={() => setBackupDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Create System Backup</DialogTitle>
<DialogContent>
<FormControl fullWidth sx={{ mt: 2 }}>
<InputLabel>Backup Type</InputLabel>
<Select
value={backupType}
label="Backup Type"
onChange={(e) => setBackupType(e.target.value)}
>
<MenuItem value="database">Database Only</MenuItem>
<MenuItem value="full">Full System</MenuItem>
</Select>
</FormControl>
<Typography variant="body2" color="textSecondary" sx={{ mt: 2 }}>
{backupType === 'database'
? 'Creates a backup of the PostgreSQL database containing all user data, conversations, and content.'
: 'Creates a complete backup of the application including code, configuration, and database.'}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setBackupDialogOpen(false)}>Cancel</Button>
<Button
onClick={handleCreateBackup}
variant="contained"
disabled={backupLoading}
>
{backupLoading ? 'Creating...' : 'Create Backup'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,494 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
Box,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
Card,
CardContent,
Alert,
Chip,
Avatar,
IconButton,
Tooltip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography
} from '@mui/material';
import {
DataGrid,
GridColDef,
GridActionsCellItem,
GridRowParams,
GridPaginationModel
} from '@mui/x-data-grid';
import {
Visibility,
Block,
Delete,
AdminPanelSettings,
Person,
PersonOff
} from '@mui/icons-material';
interface User {
id: string;
email: string;
name: string | null;
role: string;
createdAt: string;
lastLoginAt: string | null;
_count: {
chatConversations: number;
prayerRequests: number;
bookmarks: number;
};
}
interface UserDetailModalProps {
userId: string | null;
open: boolean;
onClose: () => void;
onUserUpdate: (userId: string, action: string) => void;
}
function UserDetailModal({ userId, open, onClose, onUserUpdate }: UserDetailModalProps) {
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (userId && open) {
const fetchUser = async () => {
setLoading(true);
try {
const response = await fetch(`/api/admin/users/${userId}`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
setUser(data.user);
}
} catch (error) {
console.error('Error fetching user:', error);
} finally {
setLoading(false);
}
};
fetchUser();
}
}, [userId, open]);
const handleAction = async (action: string) => {
if (!userId) return;
try {
const response = await fetch(`/api/admin/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ action })
});
if (response.ok) {
onUserUpdate(userId, action);
onClose();
}
} catch (error) {
console.error('Error updating user:', error);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>User Details</DialogTitle>
<DialogContent>
{loading ? (
<Typography>Loading...</Typography>
) : user ? (
<Box sx={{ py: 2 }}>
<Box sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom>
{user.name || 'Unknown User'}
</Typography>
<Typography color="text.secondary">{user.email}</Typography>
<Chip
label={user.role}
color={
user.role === 'admin' ? 'error' :
user.role === 'moderator' ? 'warning' :
user.role === 'suspended' ? 'default' : 'primary'
}
sx={{ mt: 1 }}
/>
</Box>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" gutterBottom>Account Information</Typography>
<Typography variant="body2">
<strong>Joined:</strong> {new Date(user.createdAt).toLocaleDateString()}
</Typography>
<Typography variant="body2">
<strong>Last Login:</strong> {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : 'Never'}
</Typography>
</Box>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" gutterBottom>Activity Summary</Typography>
<Typography variant="body2">
<strong>Conversations:</strong> {user._count.chatConversations}
</Typography>
<Typography variant="body2">
<strong>Prayer Requests:</strong> {user._count.prayerRequests}
</Typography>
<Typography variant="body2">
<strong>Bookmarks:</strong> {user._count.bookmarks}
</Typography>
<Typography variant="body2">
<strong>Notes:</strong> {user._count.notes}
</Typography>
</Box>
{user.role !== 'admin' && (
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{user.role === 'suspended' ? (
<Button
variant="contained"
color="success"
onClick={() => handleAction('activate')}
>
Activate User
</Button>
) : (
<Button
variant="outlined"
color="warning"
onClick={() => handleAction('suspend')}
>
Suspend User
</Button>
)}
{user.role === 'user' && (
<Button
variant="outlined"
color="primary"
onClick={() => handleAction('make_moderator')}
>
Make Moderator
</Button>
)}
</Box>
)}
</Box>
) : (
<Typography>User not found</Typography>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
);
}
export function UserDataGrid() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({
page: 0,
pageSize: 10,
});
const [rowCount, setRowCount] = useState(0);
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState('all');
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [userToDelete, setUserToDelete] = useState<User | null>(null);
const fetchUsers = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: paginationModel.page.toString(),
pageSize: paginationModel.pageSize.toString(),
search,
role: roleFilter
});
const response = await fetch(`/api/admin/users?${params}`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
setUsers(data.users);
setRowCount(data.pagination.total);
} else {
setError('Failed to load users');
}
} catch (error) {
setError('Network error loading users');
} finally {
setLoading(false);
}
}, [paginationModel, search, roleFilter]);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const handleUserUpdate = useCallback((userId: string, action: string) => {
// Refresh the data after user update
fetchUsers();
}, [fetchUsers]);
const handleViewUser = (params: GridRowParams) => {
setSelectedUserId(params.id as string);
setModalOpen(true);
};
const handleDeleteUser = (params: GridRowParams) => {
setUserToDelete(params.row as User);
setDeleteDialogOpen(true);
};
const confirmDeleteUser = async () => {
if (!userToDelete) return;
try {
const response = await fetch(`/api/admin/users/${userToDelete.id}`, {
method: 'DELETE',
credentials: 'include'
});
if (response.ok) {
fetchUsers();
setDeleteDialogOpen(false);
setUserToDelete(null);
}
} catch (error) {
console.error('Error deleting user:', error);
}
};
const getRoleChip = (role: string) => {
const colors: Record<string, any> = {
admin: 'error',
moderator: 'warning',
user: 'primary',
suspended: 'default'
};
return (
<Chip
label={role}
color={colors[role] || 'default'}
size="small"
variant="outlined"
/>
);
};
const columns: GridColDef[] = [
{
field: 'name',
headerName: 'User',
flex: 1,
minWidth: 200,
renderCell: (params) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Avatar sx={{ width: 32, height: 32 }}>
{(params.row.name || params.row.email)[0].toUpperCase()}
</Avatar>
<Box>
<Typography variant="body2" fontWeight="medium">
{params.row.name || 'Unknown User'}
</Typography>
<Typography variant="caption" color="text.secondary">
{params.row.email}
</Typography>
</Box>
</Box>
)
},
{
field: 'role',
headerName: 'Role',
width: 120,
renderCell: (params) => getRoleChip(params.value)
},
{
field: 'createdAt',
headerName: 'Joined',
width: 120,
renderCell: (params) => new Date(params.value).toLocaleDateString()
},
{
field: 'lastLoginAt',
headerName: 'Last Login',
width: 120,
renderCell: (params) =>
params.value ? new Date(params.value).toLocaleDateString() : 'Never'
},
{
field: 'activity',
headerName: 'Activity',
width: 120,
renderCell: (params) => (
<Box>
<Typography variant="caption" display="block">
{params.row._count.chatConversations} chats
</Typography>
<Typography variant="caption" display="block">
{params.row._count.prayerRequests} prayers
</Typography>
</Box>
)
},
{
field: 'actions',
type: 'actions',
headerName: 'Actions',
width: 120,
getActions: (params) => {
const actions = [
<GridActionsCellItem
key="view"
icon={
<Tooltip title="View Details">
<Visibility />
</Tooltip>
}
label="View"
onClick={() => handleViewUser(params)}
/>
];
// Only show delete for non-admin users
if (params.row.role !== 'admin') {
actions.push(
<GridActionsCellItem
key="delete"
icon={
<Tooltip title="Delete User">
<Delete />
</Tooltip>
}
label="Delete"
onClick={() => handleDeleteUser(params)}
/>
);
}
return actions;
}
}
];
return (
<Box>
{/* Filters */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<TextField
label="Search users"
value={search}
onChange={(e) => setSearch(e.target.value)}
size="small"
sx={{ minWidth: 250 }}
/>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel>Role</InputLabel>
<Select
value={roleFilter}
label="Role"
onChange={(e) => setRoleFilter(e.target.value)}
>
<MenuItem value="all">All Roles</MenuItem>
<MenuItem value="admin">Admin</MenuItem>
<MenuItem value="moderator">Moderator</MenuItem>
<MenuItem value="user">User</MenuItem>
<MenuItem value="suspended">Suspended</MenuItem>
</Select>
</FormControl>
</Box>
</CardContent>
</Card>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{/* Data Grid */}
<Card>
<Box sx={{ height: 600 }}>
<DataGrid
rows={users}
columns={columns}
loading={loading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
rowCount={rowCount}
paginationMode="server"
pageSizeOptions={[10, 25, 50]}
disableRowSelectionOnClick
sx={{
border: 'none',
'& .MuiDataGrid-cell': {
borderBottom: '1px solid #f0f0f0'
}
}}
/>
</Box>
</Card>
{/* User Detail Modal */}
<UserDetailModal
userId={selectedUserId}
open={modalOpen}
onClose={() => setModalOpen(false)}
onUserUpdate={handleUserUpdate}
/>
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Delete User</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to delete user <strong>{userToDelete?.name || userToDelete?.email}</strong>?
</Typography>
<Typography variant="body2" color="error" sx={{ mt: 2 }}>
This action cannot be undone. All user data including conversations, prayer requests, and bookmarks will be permanently deleted.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button
onClick={confirmDeleteUser}
color="error"
variant="contained"
>
Delete User
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -1,10 +1,10 @@
module.exports = {
apps: [
{
name: 'ghidul-biblic',
name: 'biblical-guide',
script: 'npm',
args: 'start',
cwd: '/root/ghidul-biblic',
cwd: '/root/biblical-guide',
instances: 1,
autorestart: true,
watch: false,

View File

@@ -3,7 +3,7 @@ import ro from './messages/ro.json';
import en from './messages/en.json';
// Can be imported from a shared config
export const locales = ['ro', 'en'] as const;
export const locales = ['en', 'ro'] as const;
const messages = {
ro,
@@ -12,7 +12,7 @@ const messages = {
export default getRequestConfig(async ({locale}) => {
// Ensure locale has a value, default to 'ro' if undefined
const validLocale = (locale || 'ro') as keyof typeof messages;
const validLocale = (locale || 'en') as keyof typeof messages;
return {
locale: validLocale,

62
lib/admin-auth-client.ts Normal file
View File

@@ -0,0 +1,62 @@
export interface AdminUser {
id: string;
email: string;
name: string | null;
role: string;
permissions: string[];
}
export async function checkAdminAuth(): Promise<AdminUser | null> {
try {
const response = await fetch('/api/admin/auth/me', {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
return data.user;
}
return null;
} catch (error) {
console.error('Admin auth check error:', error);
return null;
}
}
export async function adminLogin(email: string, password: string): Promise<{ success: boolean; user?: AdminUser; error?: string }> {
try {
const response = await fetch('/api/admin/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (response.ok) {
return { success: true, user: data.user };
} else {
return { success: false, error: data.error };
}
} catch (error) {
return { success: false, error: 'Network error' };
}
}
export async function adminLogout(): Promise<boolean> {
try {
const response = await fetch('/api/admin/auth/logout', {
method: 'POST',
credentials: 'include'
});
return response.ok;
} catch (error) {
console.error('Admin logout error:', error);
return false;
}
}

97
lib/admin-auth.ts Normal file
View File

@@ -0,0 +1,97 @@
import { User } from '@prisma/client';
import { cookies } from 'next/headers';
import { prisma } from '@/lib/db';
import jwt from 'jsonwebtoken';
export interface AdminUser {
id: string;
email: string;
name: string | null;
role: string;
permissions: string[];
}
export enum AdminPermission {
VIEW_USERS = 'users:read',
MANAGE_USERS = 'users:write',
MODERATE_CONTENT = 'content:moderate',
VIEW_ANALYTICS = 'analytics:read',
MANAGE_SYSTEM = 'system:manage'
}
export function hasPermission(user: AdminUser, permission: AdminPermission): boolean {
if (user.role === 'admin') return true; // Super admin has all permissions
return user.permissions.includes(permission);
}
export function getAdminPermissions(role: string): AdminPermission[] {
switch (role) {
case 'admin':
return Object.values(AdminPermission); // All permissions
case 'moderator':
return [
AdminPermission.VIEW_USERS,
AdminPermission.MODERATE_CONTENT,
AdminPermission.VIEW_ANALYTICS
];
default:
return [];
}
}
export async function verifyAdminToken(token: string): Promise<AdminUser | null> {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as any;
if (!decoded.userId) return null;
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: {
id: true,
email: true,
name: true,
role: true
}
});
if (!user || !['admin', 'moderator'].includes(user.role)) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
permissions: getAdminPermissions(user.role)
};
} catch (error) {
return null;
}
}
export async function getCurrentAdmin(): Promise<AdminUser | null> {
const cookieStore = await cookies();
const token = cookieStore.get('adminToken')?.value;
if (!token) return null;
return verifyAdminToken(token);
}
export function generateAdminToken(user: User): string {
if (!['admin', 'moderator'].includes(user.role)) {
throw new Error('User is not an admin');
}
const payload = {
userId: user.id,
role: user.role,
type: 'admin'
};
return jwt.sign(payload, process.env.JWT_SECRET!, {
expiresIn: '8h' // Admin sessions expire after 8 hours
});
}

48
lib/admin-theme.ts Normal file
View File

@@ -0,0 +1,48 @@
'use client';
import { createTheme } from '@mui/material/styles';
export const adminTheme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#1976d2', // Professional blue
contrastText: '#ffffff'
},
secondary: {
main: '#dc004e',
},
background: {
default: '#f5f5f5',
paper: '#ffffff'
},
grey: {
100: '#f5f5f5',
200: '#eeeeee',
300: '#e0e0e0',
400: '#bdbdbd',
500: '#9e9e9e'
}
},
typography: {
fontFamily: ['Roboto', 'Arial', 'sans-serif'].join(','),
h4: {
fontWeight: 600,
fontSize: '1.5rem'
},
h6: {
fontWeight: 500,
fontSize: '1.125rem'
}
},
components: {
MuiCard: {
styleOverrides: {
root: {
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
borderRadius: 8
}
}
}
}
});

View File

@@ -6,7 +6,7 @@ import { locales } from './i18n'
// Internationalization configuration
const intlMiddleware = createIntlMiddleware({
locales: [...locales],
defaultLocale: 'ro',
defaultLocale: 'en',
localePrefix: 'always'
})
@@ -16,6 +16,11 @@ const intlMiddleware = createIntlMiddleware({
// (Node.js runtime) or via an external service (e.g., Upstash Redis).
export async function middleware(request: NextRequest) {
// Skip admin routes from internationalization
if (request.nextUrl.pathname.startsWith('/admin')) {
return NextResponse.next()
}
// Handle internationalization for non-API routes
if (!request.nextUrl.pathname.startsWith('/api')) {
return intlMiddleware(request)
@@ -58,7 +63,7 @@ export async function middleware(request: NextRequest) {
// Extract locale from pathname for redirect
const locale = request.nextUrl.pathname.split('/')[1]
const isValidLocale = ['ro', 'en'].includes(locale)
const redirectLocale = isValidLocale ? locale : 'ro'
const redirectLocale = isValidLocale ? locale : 'en'
return NextResponse.redirect(new URL(`/${redirectLocale}/auth/login`, request.url))
}
@@ -71,11 +76,12 @@ export const config = {
matcher: [
// Match all pathnames except for
// - api routes
// - admin routes
// - _next (Next.js internals)
// - _vercel
// - static files (images, etc.)
// - favicon.ico, robots.txt, sitemap.xml
'/((?!api|_next|_vercel|.*\\..*|favicon.ico|robots.txt|sitemap.xml).*)',
'/((?!api|admin|_next|_vercel|.*\\..*|favicon.ico|robots.txt|sitemap.xml).*)',
// Match internationalized pathnames
'/(ro|en)/:path*'
],

5755
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{
"name": "ghid-biblic",
"name": "biblical-guide",
"version": "1.0.0",
"main": "index.js",
"scripts": {
@@ -21,11 +21,16 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/roboto": "^5.2.8",
"@formatjs/intl-localematcher": "^0.6.1",
"@mui/icons-material": "^7.3.2",
"@mui/lab": "^7.0.0-beta.17",
"@mui/material": "^7.3.2",
"@mui/material-nextjs": "^7.3.2",
"@mui/system": "^7.3.2",
"@mui/x-charts": "^8.11.3",
"@mui/x-data-grid": "^8.11.3",
"@mui/x-date-pickers": "^8.11.3",
"@next/font": "^14.2.15",
"@prisma/client": "^6.16.2",
"@radix-ui/react-dialog": "^1.1.15",
@@ -57,6 +62,7 @@
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-markdown": "^10.1.0",
"recharts": "^3.2.1",
"remark-gfm": "^4.0.1",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",

48
scripts/check-admin.ts Normal file
View File

@@ -0,0 +1,48 @@
import dotenv from 'dotenv';
dotenv.config({ path: '.env.local' });
import { prisma } from '../lib/db';
async function checkAdminUser() {
try {
console.log('Checking admin user: andrei@cloudz.ro');
const user = await prisma.user.findUnique({
where: { email: 'andrei@cloudz.ro' },
select: {
id: true,
email: true,
name: true,
role: true,
createdAt: true,
lastLoginAt: true
}
});
if (user) {
console.log('✅ User found:', user);
if (['admin', 'moderator'].includes(user.role)) {
console.log('✅ User has admin privileges');
} else {
console.log('❌ User does not have admin role. Current role:', user.role);
console.log('Updating user role to admin...');
const updatedUser = await prisma.user.update({
where: { email: 'andrei@cloudz.ro' },
data: { role: 'admin' }
});
console.log('✅ User role updated:', updatedUser.role);
}
} else {
console.log('❌ User not found');
}
} catch (error) {
console.error('Error checking admin user:', error);
} finally {
await prisma.$disconnect();
}
}
checkAdminUser();