Hydra-Bolt commited on
Commit
8de5b21
·
1 Parent(s): cc887ab
.env.example ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API Configuration
2
+ API_TITLE="Sanad LLM API"
3
+ API_DESCRIPTION="A FastAPI application for hadith narrator analysis"
4
+ API_VERSION="1.0.0"
5
+ DEBUG=True
6
+ PORT=8000
7
+ HOST="0.0.0.0"
8
+
9
+ # Allowed CORS origins (comma-separated)
10
+ ALLOWED_ORIGINS="http://localhost:3000,http://localhost:8000,http://localhost:5173"
11
+
12
+ # JWT Configuration
13
+ JWT_SECRET_KEY="your-super-secret-jwt-key-change-this-in-production"
14
+ JWT_ALGORITHM="HS256"
15
+ JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
16
+ JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
17
+
18
+ # Supabase Configuration
19
+ SUPABASE_URL="https://your-project.supabase.co"
20
+ SUPABASE_SERVICE_KEY="your-supabase-service-role-key"
21
+ SUPABASE_ANON_KEY="your-supabase-anon-key"
22
+
23
+ # Redis Configuration (for rate limiting)
24
+ REDIS_URL="redis://localhost:6379"
25
+ REDIS_HOST="localhost"
26
+ REDIS_PORT=6379
27
+ REDIS_DB=0
28
+ REDIS_PASSWORD=""
29
+
30
+ # Rate Limiting
31
+ RATE_LIMIT_REQUESTS_PER_MINUTE=60
32
+ RATE_LIMIT_BURST=10
33
+
34
+ # LLM Configuration
35
+ GOOGLE_API_KEY="your-google-api-key"
36
+ GROQ_API_KEY="your-groq-api-key"
37
+
38
+ # Database
39
+ DATABASE_URL="postgresql://user:password@localhost:5432/sanad_llm"
40
+
41
+ # Logging
42
+ LOG_LEVEL="INFO"
AUTHENTICATION.md ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SanadCheck LLM API - Authentication & Database Implementation
2
+
3
+ This document outlines the JWT authentication, rate limiting, and database storage features that have been implemented for the SanadCheck LLM API.
4
+
5
+ ## Features Implemented
6
+
7
+ ### 🔐 JWT Authentication with Supabase
8
+ - User registration and login using Supabase Auth
9
+ - JWT token generation and validation
10
+ - Session management with token refresh
11
+ - Role-based access control (User, Admin, Researcher)
12
+ - Token blacklisting for logout functionality
13
+
14
+ ### 🚀 Rate Limiting
15
+ - Redis-based rate limiting with SlowAPI
16
+ - Different limits for authenticated vs anonymous users
17
+ - IP-based and user-based rate limiting
18
+ - Burst protection and graceful degradation
19
+
20
+ ### 📊 Database Storage
21
+ - All narrator extractions and analyses are stored in Supabase
22
+ - User analytics and usage tracking
23
+ - Performance metrics and success rates
24
+ - Popular narrators tracking
25
+
26
+ ### 🔒 Protected Routes
27
+ - All API endpoints now require JWT authentication
28
+ - Rate limiting applied to all endpoints
29
+ - IP tracking for security and analytics
30
+
31
+ ## Setup Instructions
32
+
33
+ ### 1. Install Dependencies
34
+
35
+ ```bash
36
+ pip install -r requirements.txt
37
+ ```
38
+
39
+ ### 2. Supabase Setup
40
+
41
+ 1. Create a new Supabase project at [supabase.com](https://supabase.com)
42
+ 2. Copy your project URL and service role key
43
+ 3. Run the SQL scripts in the `sql/` directory in your Supabase SQL Editor:
44
+ - First: `sql/create_tables.sql`
45
+ - Then: `sql/sample_data.sql`
46
+
47
+ ### 3. Redis Setup (Optional)
48
+
49
+ For production, install and configure Redis:
50
+
51
+ ```bash
52
+ # On Ubuntu/Debian
53
+ sudo apt update
54
+ sudo apt install redis-server
55
+
56
+ # On Fedora
57
+ sudo dnf install redis
58
+
59
+ # Start Redis
60
+ sudo systemctl start redis
61
+ sudo systemctl enable redis
62
+ ```
63
+
64
+ For development, the API will work with in-memory rate limiting if Redis is not available.
65
+
66
+ ### 4. Environment Configuration
67
+
68
+ Copy `.env.example` to `.env` and update with your values:
69
+
70
+ ```bash
71
+ cp .env.example .env
72
+ ```
73
+
74
+ Required environment variables:
75
+ - `SUPABASE_URL`: Your Supabase project URL
76
+ - `SUPABASE_SERVICE_KEY`: Your Supabase service role key
77
+ - `JWT_SECRET_KEY`: A secure secret key for JWT signing
78
+ - `REDIS_URL`: Redis connection URL (optional)
79
+
80
+ ### 5. Run the Application
81
+
82
+ ```bash
83
+ # Development
84
+ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
85
+
86
+ # Production
87
+ uvicorn app.main:app --host 0.0.0.0 --port 8000
88
+ ```
89
+
90
+ ## API Usage
91
+
92
+ ### Authentication Flow
93
+
94
+ 1. **Register a new user:**
95
+ ```bash
96
+ curl -X POST "http://localhost:8000/auth/register" \
97
+ -H "Content-Type: application/json" \
98
+ -d '{
99
+ "email": "user@example.com",
100
+ "password": "securepassword",
101
+ "username": "username",
102
+ "full_name": "Full Name"
103
+ }'
104
+ ```
105
+
106
+ 2. **Login:**
107
+ ```bash
108
+ curl -X POST "http://localhost:8000/auth/login" \
109
+ -H "Content-Type: application/json" \
110
+ -d '{
111
+ "email": "user@example.com",
112
+ "password": "securepassword"
113
+ }'
114
+ ```
115
+
116
+ 3. **Use the access token for API calls:**
117
+ ```bash
118
+ curl -X POST "http://localhost:8000/api/v1/extract-narrators" \
119
+ -H "Content-Type: application/json" \
120
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
121
+ -d '{
122
+ "hadith_text": "حدثنا محمد بن إسماعيل..."
123
+ }'
124
+ ```
125
+
126
+ ### Available Endpoints
127
+
128
+ #### Authentication
129
+ - `POST /auth/register` - Register new user
130
+ - `POST /auth/login` - Login user
131
+ - `POST /auth/refresh` - Refresh access token
132
+ - `POST /auth/logout` - Logout user
133
+ - `GET /auth/me` - Get current user info
134
+ - `GET /auth/sessions` - Get user sessions
135
+
136
+ #### Hadith Analysis (Protected)
137
+ - `POST /api/v1/extract-narrators` - Extract narrators from hadith
138
+ - `POST /api/v1/analyze-narrator` - Analyze single narrator
139
+ - `POST /api/v1/analyze-narrator-chain` - Analyze narrator chain
140
+ - `POST /api/v1/extract-and-analyze` - Complete analysis workflow
141
+
142
+ #### Analytics (Mixed Access)
143
+ - `GET /api/v1/user/extractions` - Get user's extraction history (Protected)
144
+ - `GET /api/v1/user/analyses` - Get user's analysis history (Protected)
145
+ - `GET /api/v1/analytics/stats` - Get platform statistics (Public)
146
+ - `GET /api/v1/analytics/popular-narrators` - Get popular narrators (Public)
147
+
148
+ #### Utility
149
+ - `GET /api/v1/health` - Health check (Public)
150
+
151
+ ## Database Schema
152
+
153
+ ### Tables Created
154
+ 1. **users** - Extended user profiles linked to Supabase auth
155
+ 2. **user_sessions** - JWT session tracking
156
+ 3. **narrator_extractions** - Records of narrator extraction requests
157
+ 4. **narrator_analyses** - Records of narrator analysis requests
158
+
159
+ ### Security Features
160
+ - Row Level Security (RLS) enabled on all tables
161
+ - Users can only access their own data
162
+ - Service role has full access for admin operations
163
+ - Automatic user profile creation on signup
164
+
165
+ ## Rate Limiting
166
+
167
+ ### Default Limits
168
+ - **Anonymous users**: 60 requests/minute
169
+ - **Authenticated users**: 120 requests/minute
170
+ - **Admin users**: 300 requests/minute
171
+ - **Burst protection**: 10 requests/second
172
+
173
+ ### Rate Limit Headers
174
+ Responses include rate limit information:
175
+ - `X-RateLimit-Limit`: Request limit
176
+ - `X-RateLimit-Remaining`: Remaining requests
177
+ - `X-RateLimit-Reset`: Reset time
178
+
179
+ ## Analytics and Monitoring
180
+
181
+ ### User Analytics
182
+ - Track extraction and analysis usage per user
183
+ - Monitor success rates and processing times
184
+ - Popular narrator trends
185
+ - User activity patterns
186
+
187
+ ### Platform Statistics
188
+ - Overall success rates
189
+ - Average processing times
190
+ - Most analyzed narrators
191
+ - User growth metrics
192
+
193
+ ## Security Considerations
194
+
195
+ ### JWT Security
196
+ - Tokens have expiration times
197
+ - Refresh tokens for long-term access
198
+ - Token blacklisting on logout
199
+ - Secure secret key required
200
+
201
+ ### Database Security
202
+ - Row Level Security (RLS) enforced
203
+ - User data isolation
204
+ - SQL injection protection
205
+ - Parameterized queries
206
+
207
+ ### Rate Limiting
208
+ - Prevents abuse and DoS attacks
209
+ - IP-based and user-based tracking
210
+ - Graceful degradation when limits exceeded
211
+
212
+ ## Data Collection for NLP
213
+
214
+ All user interactions are stored for machine learning purposes:
215
+
216
+ ### Extraction Data
217
+ - Original hadith texts
218
+ - Extracted narrator names
219
+ - Extraction success/failure rates
220
+ - Processing times
221
+ - User feedback (future feature)
222
+
223
+ ### Analysis Data
224
+ - Narrator names analyzed
225
+ - Reliability grades assigned
226
+ - Confidence levels
227
+ - User preferences and patterns
228
+
229
+ This data can be used to:
230
+ - Improve narrator extraction accuracy
231
+ - Train better reliability assessment models
232
+ - Understand user behavior patterns
233
+ - Optimize processing performance
234
+
235
+ ## Development Notes
236
+
237
+ ### Code Structure
238
+ ```
239
+ app/
240
+ ├── auth/ # Authentication routes
241
+ ├── middleware/ # JWT auth and rate limiting
242
+ ├── services/ # Database and business logic
243
+ ├── api/ # Protected API routes
244
+ ├── db/ # Data models
245
+ └── config/ # Settings and configuration
246
+
247
+ sql/ # Database schema and setup
248
+ ├── create_tables.sql # Main schema
249
+ └── sample_data.sql # Functions and sample data
250
+ ```
251
+
252
+ ### Key Components
253
+ - **AuthMiddleware**: JWT token handling and validation
254
+ - **RateLimitMiddleware**: Redis-based rate limiting
255
+ - **DatabaseService**: Supabase database operations
256
+ - **Protected Routes**: All endpoints require authentication
257
+
258
+ ## Future Enhancements
259
+
260
+ 1. **User Feedback System**: Allow users to rate extraction/analysis quality
261
+ 2. **Advanced Analytics**: Machine learning insights from user data
262
+ 3. **Bulk Operations**: Support for batch processing
263
+ 4. **API Versioning**: Maintain backward compatibility
264
+ 5. **Caching Layer**: Redis caching for frequent narrator lookups
265
+ 6. **Audit Logging**: Detailed security and usage logs
266
+
267
+ ## Troubleshooting
268
+
269
+ ### Common Issues
270
+
271
+ 1. **Supabase Connection Failed**
272
+ - Check `SUPABASE_URL` and `SUPABASE_SERVICE_KEY` in `.env`
273
+ - Verify Supabase project is active
274
+ - Ensure SQL scripts were executed
275
+
276
+ 2. **Redis Connection Failed**
277
+ - Rate limiting will use in-memory storage
278
+ - Install and start Redis service
279
+ - Check `REDIS_URL` in `.env`
280
+
281
+ 3. **JWT Token Invalid**
282
+ - Check `JWT_SECRET_KEY` in `.env`
283
+ - Verify token hasn't expired
284
+ - Ensure token isn't blacklisted
285
+
286
+ 4. **Rate Limit Exceeded**
287
+ - Wait for rate limit window to reset
288
+ - Use authenticated requests for higher limits
289
+ - Contact admin for limit increases
290
+
291
+ ### Logs and Debugging
292
+ - Set `DEBUG=True` in `.env` for detailed error messages
293
+ - Check application logs for authentication errors
294
+ - Monitor Supabase logs for database issues
295
+ - Use `/health` endpoint to verify service status
NEXTJS_INTEGRATION_GUIDE.md ADDED
@@ -0,0 +1,960 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SanadCheck API Authentication Integration Guide for Next.js
2
+
3
+ This comprehensive guide shows how to integrate the SanadCheck LLM API authentication system into a Next.js application. The backend provides JWT-based authentication with Supabase, rate limiting, and comprehensive session management.
4
+
5
+ ## Table of Contents
6
+ 1. [Backend API Overview](#backend-api-overview)
7
+ 2. [Next.js Setup](#nextjs-setup)
8
+ 3. [Authentication Service](#authentication-service)
9
+ 4. [API Client Setup](#api-client-setup)
10
+ 5. [React Hooks for Authentication](#react-hooks-for-authentication)
11
+ 6. [Protected Routes & Middleware](#protected-routes--middleware)
12
+ 7. [Components](#components)
13
+ 8. [Usage Examples](#usage-examples)
14
+ 9. [Error Handling](#error-handling)
15
+ 10. [Best Practices](#best-practices)
16
+
17
+ ## Backend API Overview
18
+
19
+ The SanadCheck API provides the following authentication endpoints:
20
+
21
+ ### Authentication Endpoints
22
+ - `POST /auth/register` - User registration
23
+ - `POST /auth/login` - User login
24
+ - `POST /auth/refresh` - Token refresh
25
+ - `POST /auth/logout` - User logout
26
+ - `GET /auth/me` - Get current user info
27
+ - `GET /auth/sessions` - Get user sessions
28
+
29
+ ### Protected API Endpoints
30
+ - `POST /api/v1/extract-narrators` - Extract narrators from hadith
31
+ - `POST /api/v1/analyze-narrator` - Analyze individual narrator
32
+ - `POST /api/v1/analyze-chain` - Analyze narrator chain
33
+ - `POST /api/v1/extract-and-analyze` - Complete analysis
34
+
35
+ ### Rate Limiting
36
+ - Anonymous users: Limited requests per IP
37
+ - Authenticated users: Higher limits per user ID
38
+ - Redis-based with burst protection
39
+
40
+ ## Next.js Setup
41
+
42
+ ### 1. Install Dependencies
43
+
44
+ ```bash
45
+ npm install axios js-cookie next-auth
46
+ # or
47
+ yarn add axios js-cookie next-auth
48
+ ```
49
+
50
+ ### 2. Environment Variables
51
+
52
+ Create `.env.local`:
53
+
54
+ ```env
55
+ NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
56
+ NEXT_PUBLIC_API_VERSION=v1
57
+ NEXTAUTH_SECRET=your-nextauth-secret
58
+ NEXTAUTH_URL=http://localhost:3000
59
+ ```
60
+
61
+ ### 3. Project Structure
62
+
63
+ ```
64
+ src/
65
+ ├── lib/
66
+ │ ├── api.ts # API client configuration
67
+ │ ├── auth.ts # Authentication service
68
+ │ └── types.ts # TypeScript types
69
+ ├── hooks/
70
+ │ └── useAuth.ts # Authentication hooks
71
+ ├── middleware.ts # Next.js middleware for route protection
72
+ ├── components/
73
+ │ ├── auth/
74
+ │ │ ├── LoginForm.tsx
75
+ │ │ ├── RegisterForm.tsx
76
+ │ │ └── AuthProvider.tsx
77
+ │ └── ui/
78
+ │ └── ProtectedRoute.tsx
79
+ ├── pages/
80
+ │ ├── auth/
81
+ │ │ ├── login.tsx
82
+ │ │ └── register.tsx
83
+ │ ├── dashboard.tsx
84
+ │ └── api/
85
+ │ └── auth/
86
+ │ └── [...nextauth].ts
87
+ ```
88
+
89
+ ## Authentication Service
90
+
91
+ ### TypeScript Types (`src/lib/types.ts`)
92
+
93
+ ```typescript
94
+ export interface User {
95
+ id: string;
96
+ email: string;
97
+ username?: string;
98
+ full_name?: string;
99
+ role: 'user' | 'admin' | 'researcher';
100
+ is_active: boolean;
101
+ created_at?: string;
102
+ last_login?: string;
103
+ }
104
+
105
+ export interface AuthResponse {
106
+ access_token: string;
107
+ refresh_token?: string;
108
+ token_type: string;
109
+ expires_in: number;
110
+ user: User;
111
+ }
112
+
113
+ export interface LoginRequest {
114
+ email: string;
115
+ password: string;
116
+ }
117
+
118
+ export interface RegisterRequest {
119
+ email: string;
120
+ password: string;
121
+ username?: string;
122
+ full_name?: string;
123
+ }
124
+
125
+ export interface ApiError {
126
+ detail: string;
127
+ status_code?: number;
128
+ }
129
+
130
+ // Hadith Analysis Types
131
+ export interface HadithTextRequest {
132
+ text: string;
133
+ language?: string;
134
+ }
135
+
136
+ export interface NarratorExtractionResponse {
137
+ narrators: string[];
138
+ sanad_chain: string;
139
+ success: boolean;
140
+ metadata: {
141
+ processing_time_ms: number;
142
+ text_length: number;
143
+ extraction_method: string;
144
+ };
145
+ }
146
+
147
+ export interface NarratorAnalysisRequest {
148
+ narrator_name: string;
149
+ context?: string;
150
+ }
151
+
152
+ export interface NarratorAnalysisResponse {
153
+ narrator: string;
154
+ reliability_grade: string;
155
+ confidence_level: string;
156
+ reasoning: string;
157
+ scholarly_consensus: string;
158
+ known_issues?: string;
159
+ biographical_info: string;
160
+ recommendation: string;
161
+ success: boolean;
162
+ metadata: {
163
+ processing_time_ms: number;
164
+ analysis_method: string;
165
+ };
166
+ }
167
+ ```
168
+
169
+ ### API Client (`src/lib/api.ts`)
170
+
171
+ ```typescript
172
+ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
173
+ import Cookies from 'js-cookie';
174
+
175
+ class ApiClient {
176
+ private client: AxiosInstance;
177
+ private baseURL: string;
178
+
179
+ constructor() {
180
+ this.baseURL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000';
181
+
182
+ this.client = axios.create({
183
+ baseURL: this.baseURL,
184
+ timeout: 30000,
185
+ headers: {
186
+ 'Content-Type': 'application/json',
187
+ },
188
+ });
189
+
190
+ this.setupInterceptors();
191
+ }
192
+
193
+ private setupInterceptors() {
194
+ // Request interceptor to add auth token
195
+ this.client.interceptors.request.use(
196
+ (config) => {
197
+ const token = Cookies.get('access_token');
198
+ if (token) {
199
+ config.headers.Authorization = `Bearer ${token}`;
200
+ }
201
+ return config;
202
+ },
203
+ (error) => Promise.reject(error)
204
+ );
205
+
206
+ // Response interceptor for token refresh
207
+ this.client.interceptors.response.use(
208
+ (response) => response,
209
+ async (error) => {
210
+ const originalRequest = error.config;
211
+
212
+ if (error.response?.status === 401 && !originalRequest._retry) {
213
+ originalRequest._retry = true;
214
+
215
+ try {
216
+ const refreshToken = Cookies.get('refresh_token');
217
+ if (refreshToken) {
218
+ const response = await this.refreshToken(refreshToken);
219
+ const { access_token } = response.data;
220
+
221
+ Cookies.set('access_token', access_token, {
222
+ expires: 7,
223
+ secure: process.env.NODE_ENV === 'production'
224
+ });
225
+
226
+ originalRequest.headers.Authorization = `Bearer ${access_token}`;
227
+ return this.client(originalRequest);
228
+ }
229
+ } catch (refreshError) {
230
+ // Refresh failed, logout user
231
+ this.logout();
232
+ window.location.href = '/auth/login';
233
+ }
234
+ }
235
+
236
+ return Promise.reject(error);
237
+ }
238
+ );
239
+ }
240
+
241
+ // Auth methods
242
+ async login(credentials: LoginRequest): Promise<AxiosResponse<AuthResponse>> {
243
+ return this.client.post('/auth/login', credentials);
244
+ }
245
+
246
+ async register(userData: RegisterRequest): Promise<AxiosResponse<AuthResponse>> {
247
+ return this.client.post('/auth/register', userData);
248
+ }
249
+
250
+ async refreshToken(refresh_token: string): Promise<AxiosResponse<AuthResponse>> {
251
+ return this.client.post('/auth/refresh', { refresh_token });
252
+ }
253
+
254
+ async logout(): Promise<void> {
255
+ try {
256
+ await this.client.post('/auth/logout');
257
+ } catch (error) {
258
+ console.error('Logout error:', error);
259
+ } finally {
260
+ Cookies.remove('access_token');
261
+ Cookies.remove('refresh_token');
262
+ }
263
+ }
264
+
265
+ async getCurrentUser(): Promise<AxiosResponse<User>> {
266
+ return this.client.get('/auth/me');
267
+ }
268
+
269
+ async getUserSessions(): Promise<AxiosResponse<{ sessions: any[] }>> {
270
+ return this.client.get('/auth/sessions');
271
+ }
272
+
273
+ // Hadith Analysis API methods
274
+ async extractNarrators(data: HadithTextRequest): Promise<AxiosResponse<NarratorExtractionResponse>> {
275
+ return this.client.post(`/api/${process.env.NEXT_PUBLIC_API_VERSION}/extract-narrators`, data);
276
+ }
277
+
278
+ async analyzeNarrator(data: NarratorAnalysisRequest): Promise<AxiosResponse<NarratorAnalysisResponse>> {
279
+ return this.client.post(`/api/${process.env.NEXT_PUBLIC_API_VERSION}/analyze-narrator`, data);
280
+ }
281
+
282
+ async analyzeNarratorChain(data: { narrators: string[] }): Promise<AxiosResponse<any>> {
283
+ return this.client.post(`/api/${process.env.NEXT_PUBLIC_API_VERSION}/analyze-chain`, data);
284
+ }
285
+
286
+ async extractAndAnalyze(data: HadithTextRequest): Promise<AxiosResponse<any>> {
287
+ return this.client.post(`/api/${process.env.NEXT_PUBLIC_API_VERSION}/extract-and-analyze`, data);
288
+ }
289
+ }
290
+
291
+ export const apiClient = new ApiClient();
292
+ ```
293
+
294
+ ### Authentication Service (`src/lib/auth.ts`)
295
+
296
+ ```typescript
297
+ import { apiClient } from './api';
298
+ import { User, LoginRequest, RegisterRequest, AuthResponse } from './types';
299
+ import Cookies from 'js-cookie';
300
+
301
+ export class AuthService {
302
+ static async login(credentials: LoginRequest): Promise<{ user: User; tokens: AuthResponse }> {
303
+ try {
304
+ const response = await apiClient.login(credentials);
305
+ const authData = response.data;
306
+
307
+ // Store tokens in secure cookies
308
+ Cookies.set('access_token', authData.access_token, {
309
+ expires: 7,
310
+ secure: process.env.NODE_ENV === 'production',
311
+ sameSite: 'strict'
312
+ });
313
+
314
+ if (authData.refresh_token) {
315
+ Cookies.set('refresh_token', authData.refresh_token, {
316
+ expires: 30,
317
+ secure: process.env.NODE_ENV === 'production',
318
+ sameSite: 'strict'
319
+ });
320
+ }
321
+
322
+ return {
323
+ user: authData.user,
324
+ tokens: authData
325
+ };
326
+ } catch (error: any) {
327
+ throw new Error(error.response?.data?.detail || 'Login failed');
328
+ }
329
+ }
330
+
331
+ static async register(userData: RegisterRequest): Promise<{ user: User; tokens: AuthResponse }> {
332
+ try {
333
+ const response = await apiClient.register(userData);
334
+ const authData = response.data;
335
+
336
+ // Store tokens in secure cookies
337
+ Cookies.set('access_token', authData.access_token, {
338
+ expires: 7,
339
+ secure: process.env.NODE_ENV === 'production',
340
+ sameSite: 'strict'
341
+ });
342
+
343
+ if (authData.refresh_token) {
344
+ Cookies.set('refresh_token', authData.refresh_token, {
345
+ expires: 30,
346
+ secure: process.env.NODE_ENV === 'production',
347
+ sameSite: 'strict'
348
+ });
349
+ }
350
+
351
+ return {
352
+ user: authData.user,
353
+ tokens: authData
354
+ };
355
+ } catch (error: any) {
356
+ throw new Error(error.response?.data?.detail || 'Registration failed');
357
+ }
358
+ }
359
+
360
+ static async logout(): Promise<void> {
361
+ await apiClient.logout();
362
+ }
363
+
364
+ static async getCurrentUser(): Promise<User | null> {
365
+ try {
366
+ const token = Cookies.get('access_token');
367
+ if (!token) return null;
368
+
369
+ const response = await apiClient.getCurrentUser();
370
+ return response.data;
371
+ } catch (error) {
372
+ return null;
373
+ }
374
+ }
375
+
376
+ static isAuthenticated(): boolean {
377
+ return !!Cookies.get('access_token');
378
+ }
379
+
380
+ static getToken(): string | undefined {
381
+ return Cookies.get('access_token');
382
+ }
383
+ }
384
+ ```
385
+
386
+ ## React Hooks for Authentication
387
+
388
+ ### Authentication Hook (`src/hooks/useAuth.ts`)
389
+
390
+ ```typescript
391
+ 'use client';
392
+
393
+ import { useState, useEffect, useContext, createContext, ReactNode } from 'react';
394
+ import { User, LoginRequest, RegisterRequest } from '@/lib/types';
395
+ import { AuthService } from '@/lib/auth';
396
+
397
+ interface AuthContextType {
398
+ user: User | null;
399
+ loading: boolean;
400
+ error: string | null;
401
+ login: (credentials: LoginRequest) => Promise<void>;
402
+ register: (userData: RegisterRequest) => Promise<void>;
403
+ logout: () => Promise<void>;
404
+ clearError: () => void;
405
+ }
406
+
407
+ const AuthContext = createContext<AuthContextType | undefined>(undefined);
408
+
409
+ export function AuthProvider({ children }: { children: ReactNode }) {
410
+ const [user, setUser] = useState<User | null>(null);
411
+ const [loading, setLoading] = useState(true);
412
+ const [error, setError] = useState<string | null>(null);
413
+
414
+ useEffect(() => {
415
+ loadUser();
416
+ }, []);
417
+
418
+ const loadUser = async () => {
419
+ try {
420
+ setLoading(true);
421
+ const currentUser = await AuthService.getCurrentUser();
422
+ setUser(currentUser);
423
+ } catch (error) {
424
+ console.error('Failed to load user:', error);
425
+ } finally {
426
+ setLoading(false);
427
+ }
428
+ };
429
+
430
+ const login = async (credentials: LoginRequest) => {
431
+ try {
432
+ setLoading(true);
433
+ setError(null);
434
+ const { user } = await AuthService.login(credentials);
435
+ setUser(user);
436
+ } catch (error: any) {
437
+ setError(error.message);
438
+ throw error;
439
+ } finally {
440
+ setLoading(false);
441
+ }
442
+ };
443
+
444
+ const register = async (userData: RegisterRequest) => {
445
+ try {
446
+ setLoading(true);
447
+ setError(null);
448
+ const { user } = await AuthService.register(userData);
449
+ setUser(user);
450
+ } catch (error: any) {
451
+ setError(error.message);
452
+ throw error;
453
+ } finally {
454
+ setLoading(false);
455
+ }
456
+ };
457
+
458
+ const logout = async () => {
459
+ try {
460
+ setLoading(true);
461
+ await AuthService.logout();
462
+ setUser(null);
463
+ } catch (error) {
464
+ console.error('Logout error:', error);
465
+ } finally {
466
+ setLoading(false);
467
+ }
468
+ };
469
+
470
+ const clearError = () => setError(null);
471
+
472
+ return (
473
+ <AuthContext.Provider
474
+ value={{
475
+ user,
476
+ loading,
477
+ error,
478
+ login,
479
+ register,
480
+ logout,
481
+ clearError,
482
+ }}
483
+ >
484
+ {children}
485
+ </AuthContext.Provider>
486
+ );
487
+ }
488
+
489
+ export function useAuth() {
490
+ const context = useContext(AuthContext);
491
+ if (context === undefined) {
492
+ throw new Error('useAuth must be used within an AuthProvider');
493
+ }
494
+ return context;
495
+ }
496
+ ```
497
+
498
+ ## Protected Routes & Middleware
499
+
500
+ ### Next.js Middleware (`src/middleware.ts`)
501
+
502
+ ```typescript
503
+ import { NextResponse } from 'next/server';
504
+ import type { NextRequest } from 'next/server';
505
+
506
+ export function middleware(request: NextRequest) {
507
+ const token = request.cookies.get('access_token')?.value;
508
+ const { pathname } = request.nextUrl;
509
+
510
+ // Define protected routes
511
+ const protectedRoutes = ['/dashboard', '/profile', '/analysis'];
512
+ const authRoutes = ['/auth/login', '/auth/register'];
513
+
514
+ // Check if the current route is protected
515
+ const isProtectedRoute = protectedRoutes.some(route =>
516
+ pathname.startsWith(route)
517
+ );
518
+
519
+ // Check if the current route is an auth route
520
+ const isAuthRoute = authRoutes.some(route =>
521
+ pathname.startsWith(route)
522
+ );
523
+
524
+ // Redirect to login if accessing protected route without token
525
+ if (isProtectedRoute && !token) {
526
+ return NextResponse.redirect(new URL('/auth/login', request.url));
527
+ }
528
+
529
+ // Redirect to dashboard if accessing auth routes while logged in
530
+ if (isAuthRoute && token) {
531
+ return NextResponse.redirect(new URL('/dashboard', request.url));
532
+ }
533
+
534
+ return NextResponse.next();
535
+ }
536
+
537
+ export const config = {
538
+ matcher: [
539
+ '/((?!api|_next/static|_next/image|favicon.ico).*)',
540
+ ],
541
+ };
542
+ ```
543
+
544
+ ### Protected Route Component (`src/components/ui/ProtectedRoute.tsx`)
545
+
546
+ ```typescript
547
+ 'use client';
548
+
549
+ import { useAuth } from '@/hooks/useAuth';
550
+ import { useRouter } from 'next/navigation';
551
+ import { useEffect, ReactNode } from 'react';
552
+
553
+ interface ProtectedRouteProps {
554
+ children: ReactNode;
555
+ requiredRole?: 'user' | 'admin' | 'researcher';
556
+ }
557
+
558
+ export function ProtectedRoute({ children, requiredRole }: ProtectedRouteProps) {
559
+ const { user, loading } = useAuth();
560
+ const router = useRouter();
561
+
562
+ useEffect(() => {
563
+ if (!loading) {
564
+ if (!user) {
565
+ router.push('/auth/login');
566
+ return;
567
+ }
568
+
569
+ if (requiredRole && user.role !== requiredRole && user.role !== 'admin') {
570
+ router.push('/unauthorized');
571
+ return;
572
+ }
573
+ }
574
+ }, [user, loading, router, requiredRole]);
575
+
576
+ if (loading) {
577
+ return (
578
+ <div className="flex items-center justify-center min-h-screen">
579
+ <div className="animate-spin rounded-full h-32 w-32 border-b-2 border-gray-900"></div>
580
+ </div>
581
+ );
582
+ }
583
+
584
+ if (!user) {
585
+ return null;
586
+ }
587
+
588
+ if (requiredRole && user.role !== requiredRole && user.role !== 'admin') {
589
+ return null;
590
+ }
591
+
592
+ return <>{children}</>;
593
+ }
594
+ ```
595
+
596
+ ## Components
597
+
598
+ ### Login Form (`src/components/auth/LoginForm.tsx`)
599
+
600
+ ```typescript
601
+ 'use client';
602
+
603
+ import { useState } from 'react';
604
+ import { useAuth } from '@/hooks/useAuth';
605
+ import { useRouter } from 'next/navigation';
606
+ import Link from 'next/link';
607
+
608
+ export function LoginForm() {
609
+ const [email, setEmail] = useState('');
610
+ const [password, setPassword] = useState('');
611
+ const { login, loading, error, clearError } = useAuth();
612
+ const router = useRouter();
613
+
614
+ const handleSubmit = async (e: React.FormEvent) => {
615
+ e.preventDefault();
616
+ clearError();
617
+
618
+ try {
619
+ await login({ email, password });
620
+ router.push('/dashboard');
621
+ } catch (error) {
622
+ // Error is handled by the useAuth hook
623
+ }
624
+ };
625
+
626
+ return (
627
+ <div className="max-w-md mx-auto mt-8 p-6 bg-white rounded-lg shadow-md">
628
+ <h2 className="text-2xl font-bold mb-6 text-center">Login to SanadCheck</h2>
629
+
630
+ {error && (
631
+ <div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
632
+ {error}
633
+ </div>
634
+ )}
635
+
636
+ <form onSubmit={handleSubmit} className="space-y-4">
637
+ <div>
638
+ <label htmlFor="email" className="block text-sm font-medium text-gray-700">
639
+ Email
640
+ </label>
641
+ <input
642
+ type="email"
643
+ id="email"
644
+ value={email}
645
+ onChange={(e) => setEmail(e.target.value)}
646
+ required
647
+ className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
648
+ />
649
+ </div>
650
+
651
+ <div>
652
+ <label htmlFor="password" className="block text-sm font-medium text-gray-700">
653
+ Password
654
+ </label>
655
+ <input
656
+ type="password"
657
+ id="password"
658
+ value={password}
659
+ onChange={(e) => setPassword(e.target.value)}
660
+ required
661
+ minLength={6}
662
+ className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
663
+ />
664
+ </div>
665
+
666
+ <button
667
+ type="submit"
668
+ disabled={loading}
669
+ className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
670
+ >
671
+ {loading ? 'Signing in...' : 'Sign in'}
672
+ </button>
673
+ </form>
674
+
675
+ <p className="mt-4 text-center text-sm text-gray-600">
676
+ Don't have an account?{' '}
677
+ <Link href="/auth/register" className="font-medium text-indigo-600 hover:text-indigo-500">
678
+ Sign up
679
+ </Link>
680
+ </p>
681
+ </div>
682
+ );
683
+ }
684
+ ```
685
+
686
+ ### Hadith Analysis Component (`src/components/analysis/HadithAnalyzer.tsx`)
687
+
688
+ ```typescript
689
+ 'use client';
690
+
691
+ import { useState } from 'react';
692
+ import { apiClient } from '@/lib/api';
693
+ import { HadithTextRequest, NarratorExtractionResponse } from '@/lib/types';
694
+
695
+ export function HadithAnalyzer() {
696
+ const [text, setText] = useState('');
697
+ const [loading, setLoading] = useState(false);
698
+ const [result, setResult] = useState<NarratorExtractionResponse | null>(null);
699
+ const [error, setError] = useState<string | null>(null);
700
+
701
+ const handleAnalyze = async (e: React.FormEvent) => {
702
+ e.preventDefault();
703
+ if (!text.trim()) return;
704
+
705
+ setLoading(true);
706
+ setError(null);
707
+
708
+ try {
709
+ const response = await apiClient.extractNarrators({ text });
710
+ setResult(response.data);
711
+ } catch (err: any) {
712
+ setError(err.response?.data?.detail || 'Analysis failed');
713
+ } finally {
714
+ setLoading(false);
715
+ }
716
+ };
717
+
718
+ return (
719
+ <div className="max-w-4xl mx-auto p-6">
720
+ <h2 className="text-2xl font-bold mb-6">Hadith Narrator Analysis</h2>
721
+
722
+ <form onSubmit={handleAnalyze} className="mb-8">
723
+ <div>
724
+ <label htmlFor="hadith-text" className="block text-sm font-medium text-gray-700 mb-2">
725
+ Enter Hadith Text (Arabic)
726
+ </label>
727
+ <textarea
728
+ id="hadith-text"
729
+ value={text}
730
+ onChange={(e) => setText(e.target.value)}
731
+ rows={6}
732
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
733
+ placeholder="أخبرنا..."
734
+ required
735
+ />
736
+ </div>
737
+
738
+ <button
739
+ type="submit"
740
+ disabled={loading || !text.trim()}
741
+ className="mt-4 px-6 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
742
+ >
743
+ {loading ? 'Analyzing...' : 'Analyze Narrators'}
744
+ </button>
745
+ </form>
746
+
747
+ {error && (
748
+ <div className="mb-4 p-4 bg-red-100 border border-red-400 text-red-700 rounded">
749
+ Error: {error}
750
+ </div>
751
+ )}
752
+
753
+ {result && (
754
+ <div className="bg-white rounded-lg shadow-md p-6">
755
+ <h3 className="text-lg font-semibold mb-4">Analysis Results</h3>
756
+
757
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
758
+ <div>
759
+ <h4 className="font-medium text-gray-900 mb-2">Extracted Narrators</h4>
760
+ <ul className="space-y-1">
761
+ {result.narrators.map((narrator, index) => (
762
+ <li key={index} className="text-gray-700 bg-gray-50 px-3 py-1 rounded">
763
+ {narrator}
764
+ </li>
765
+ ))}
766
+ </ul>
767
+ </div>
768
+
769
+ <div>
770
+ <h4 className="font-medium text-gray-900 mb-2">Sanad Chain</h4>
771
+ <p className="text-gray-700 bg-gray-50 p-3 rounded">
772
+ {result.sanad_chain}
773
+ </p>
774
+ </div>
775
+ </div>
776
+
777
+ <div className="mt-4 text-sm text-gray-500">
778
+ Processing time: {result.metadata.processing_time_ms}ms
779
+ </div>
780
+ </div>
781
+ )}
782
+ </div>
783
+ );
784
+ }
785
+ ```
786
+
787
+ ## Usage Examples
788
+
789
+ ### App Layout with Authentication (`src/app/layout.tsx`)
790
+
791
+ ```typescript
792
+ import { AuthProvider } from '@/hooks/useAuth';
793
+ import type { Metadata } from 'next';
794
+
795
+ export const metadata: Metadata = {
796
+ title: 'SanadCheck - Hadith Narrator Analysis',
797
+ description: 'AI-powered hadith narrator analysis and validation',
798
+ };
799
+
800
+ export default function RootLayout({
801
+ children,
802
+ }: {
803
+ children: React.ReactNode;
804
+ }) {
805
+ return (
806
+ <html lang="en">
807
+ <body>
808
+ <AuthProvider>
809
+ {children}
810
+ </AuthProvider>
811
+ </body>
812
+ </html>
813
+ );
814
+ }
815
+ ```
816
+
817
+ ### Dashboard Page (`src/app/dashboard/page.tsx`)
818
+
819
+ ```typescript
820
+ 'use client';
821
+
822
+ import { ProtectedRoute } from '@/components/ui/ProtectedRoute';
823
+ import { HadithAnalyzer } from '@/components/analysis/HadithAnalyzer';
824
+ import { useAuth } from '@/hooks/useAuth';
825
+
826
+ export default function Dashboard() {
827
+ const { user, logout } = useAuth();
828
+
829
+ return (
830
+ <ProtectedRoute>
831
+ <div className="min-h-screen bg-gray-50">
832
+ <nav className="bg-white shadow-sm border-b">
833
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
834
+ <div className="flex justify-between h-16">
835
+ <div className="flex items-center">
836
+ <h1 className="text-xl font-semibold">SanadCheck Dashboard</h1>
837
+ </div>
838
+ <div className="flex items-center space-x-4">
839
+ <span className="text-gray-700">Welcome, {user?.username || user?.email}</span>
840
+ <button
841
+ onClick={logout}
842
+ className="text-gray-500 hover:text-gray-700"
843
+ >
844
+ Logout
845
+ </button>
846
+ </div>
847
+ </div>
848
+ </div>
849
+ </nav>
850
+
851
+ <main className="py-8">
852
+ <HadithAnalyzer />
853
+ </main>
854
+ </div>
855
+ </ProtectedRoute>
856
+ );
857
+ }
858
+ ```
859
+
860
+ ## Error Handling
861
+
862
+ ### Global Error Handler (`src/lib/errorHandler.ts`)
863
+
864
+ ```typescript
865
+ import { AxiosError } from 'axios';
866
+
867
+ export interface ApiErrorResponse {
868
+ detail: string;
869
+ status_code?: number;
870
+ }
871
+
872
+ export class AppError extends Error {
873
+ public statusCode: number;
874
+ public isOperational: boolean;
875
+
876
+ constructor(message: string, statusCode = 500, isOperational = true) {
877
+ super(message);
878
+ this.statusCode = statusCode;
879
+ this.isOperational = isOperational;
880
+
881
+ Error.captureStackTrace(this, this.constructor);
882
+ }
883
+ }
884
+
885
+ export function handleApiError(error: AxiosError<ApiErrorResponse>): AppError {
886
+ const message = error.response?.data?.detail || error.message || 'An unexpected error occurred';
887
+ const statusCode = error.response?.status || 500;
888
+
889
+ switch (statusCode) {
890
+ case 401:
891
+ return new AppError('Authentication required', 401);
892
+ case 403:
893
+ return new AppError('Access denied', 403);
894
+ case 404:
895
+ return new AppError('Resource not found', 404);
896
+ case 429:
897
+ return new AppError('Rate limit exceeded. Please try again later.', 429);
898
+ case 500:
899
+ return new AppError('Server error. Please try again later.', 500);
900
+ default:
901
+ return new AppError(message, statusCode);
902
+ }
903
+ }
904
+ ```
905
+
906
+ ## Best Practices
907
+
908
+ ### 1. Security
909
+ - Store tokens in secure, httpOnly cookies when possible
910
+ - Use HTTPS in production
911
+ - Implement proper CORS policies
912
+ - Validate all inputs on both client and server
913
+ - Use environment variables for sensitive data
914
+
915
+ ### 2. Performance
916
+ - Implement proper loading states
917
+ - Use React.memo for expensive components
918
+ - Debounce API calls for search functionality
919
+ - Cache user data appropriately
920
+
921
+ ### 3. Error Handling
922
+ - Provide meaningful error messages
923
+ - Implement retry logic for network failures
924
+ - Handle rate limiting gracefully
925
+ - Log errors for debugging
926
+
927
+ ### 4. User Experience
928
+ - Show loading indicators
929
+ - Implement optimistic updates where appropriate
930
+ - Provide clear feedback for all actions
931
+ - Handle offline scenarios
932
+
933
+ ### 5. Code Organization
934
+ - Separate API logic from UI components
935
+ - Use TypeScript for type safety
936
+ - Follow consistent naming conventions
937
+ - Implement proper error boundaries
938
+
939
+ ### 6. Testing
940
+ ```typescript
941
+ // Example test for authentication hook
942
+ import { renderHook, act } from '@testing-library/react';
943
+ import { useAuth } from '@/hooks/useAuth';
944
+
945
+ test('should login user successfully', async () => {
946
+ const { result } = renderHook(() => useAuth());
947
+
948
+ await act(async () => {
949
+ await result.current.login({
950
+ email: 'test@example.com',
951
+ password: 'password123'
952
+ });
953
+ });
954
+
955
+ expect(result.current.user).toBeTruthy();
956
+ expect(result.current.error).toBeNull();
957
+ });
958
+ ```
959
+
960
+ This comprehensive guide provides everything needed to integrate the SanadCheck API authentication system into a Next.js application. The implementation includes proper error handling, security practices, and a scalable architecture for hadith analysis features.
app/api/routes.py CHANGED
@@ -1,6 +1,8 @@
1
- from fastapi import APIRouter, HTTPException, status
2
  from fastapi.responses import JSONResponse
3
  from typing import List, Dict, Any
 
 
4
 
5
  from app.db.models import (
6
  HadithTextRequest,
@@ -14,8 +16,14 @@ from app.db.models import (
14
  ExtractionResult,
15
  ChainAnalysisResult,
16
  ExtractAndAnalyzeMetadata,
 
 
 
17
  )
18
  from app.agent.services import get_llm_service
 
 
 
19
 
20
  router = APIRouter(prefix="/api/v1", tags=["hadith-analysis"])
21
 
@@ -26,7 +34,12 @@ router = APIRouter(prefix="/api/v1", tags=["hadith-analysis"])
26
  summary="Extract narrators from hadith text",
27
  description="Analyzes Arabic hadith text and extracts the chain of narrators (sanad)",
28
  )
29
- async def extract_narrators(request: HadithTextRequest) -> NarratorExtractionResponse:
 
 
 
 
 
30
  """
31
  Extract narrators from hadith text.
32
 
@@ -36,6 +49,7 @@ async def extract_narrators(request: HadithTextRequest) -> NarratorExtractionRes
36
 
37
  Args:
38
  request: Contains the hadith text to analyze
 
39
 
40
  Returns:
41
  NarratorExtractionResponse with extracted narrator names and chain
@@ -43,9 +57,28 @@ async def extract_narrators(request: HadithTextRequest) -> NarratorExtractionRes
43
  Raises:
44
  HTTPException: If the analysis fails
45
  """
 
 
 
46
  try:
47
  llm_service = get_llm_service()
48
  result = await llm_service.extract_narrators(request.hadith_text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
  if not result.success:
51
  raise HTTPException(
@@ -58,6 +91,24 @@ async def extract_narrators(request: HadithTextRequest) -> NarratorExtractionRes
58
  except HTTPException:
59
  raise
60
  except Exception as e:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  raise HTTPException(
62
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
63
  detail=f"Internal server error during narrator extraction: {str(e)}",
@@ -70,8 +121,10 @@ async def extract_narrators(request: HadithTextRequest) -> NarratorExtractionRes
70
  summary="Analyze narrator reliability",
71
  description="Takes a narrator name and generates an AI-powered reliability assessment based on the model's knowledge",
72
  )
 
73
  async def analyze_narrator(
74
  request: NarratorAnalysisRequest,
 
75
  ) -> NarratorAnalysisResponse:
76
  """
77
  Analyze narrator reliability based on the model's internal knowledge.
@@ -82,6 +135,7 @@ async def analyze_narrator(
82
 
83
  Args:
84
  request: Contains the narrator name to analyze
 
85
 
86
  Returns:
87
  NarratorAnalysisResponse with reliability grade, biographical info, and detailed analysis
@@ -89,9 +143,32 @@ async def analyze_narrator(
89
  Raises:
90
  HTTPException: If the analysis fails
91
  """
 
 
 
92
  try:
93
  llm_service = get_llm_service()
94
  result = await llm_service.analyze_narrator(request.narrator_name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
  if not result.success:
97
  raise HTTPException(
@@ -116,8 +193,10 @@ async def analyze_narrator(
116
  summary="Analyze narrator chain",
117
  description="Analyzes a complete chain of narrators using enhanced Shamela data + LLM agent",
118
  )
 
119
  async def analyze_narrator_chain(
120
  narrator_names: List[str],
 
121
  ) -> NarratorChainAnalysisResponse:
122
  """
123
  Analyze a complete chain of narrators with enhanced data sources.
@@ -190,8 +269,10 @@ async def analyze_narrator_chain(
190
  summary="Extract narrators and analyze chain",
191
  description="Complete workflow: extract narrators from hadith text and analyze the complete chain",
192
  )
 
193
  async def extract_and_analyze_hadith(
194
  request: HadithTextRequest,
 
195
  ) -> ExtractAndAnalyzeResponse:
196
  """
197
  Complete hadith analysis workflow: extraction + chain analysis.
@@ -294,6 +375,65 @@ async def health_check():
294
  "Enhanced narrator analysis with Shamela.ws integration",
295
  "Narrator chain analysis",
296
  "Complete hadith workflow analysis",
297
- "AI-powered narrator extraction",
 
 
298
  ],
299
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, status, Depends, Request
2
  from fastapi.responses import JSONResponse
3
  from typing import List, Dict, Any
4
+ from datetime import datetime
5
+ import time
6
 
7
  from app.db.models import (
8
  HadithTextRequest,
 
16
  ExtractionResult,
17
  ChainAnalysisResult,
18
  ExtractAndAnalyzeMetadata,
19
+ User,
20
+ NarratorExtractionRecord,
21
+ NarratorAnalysisRecord,
22
  )
23
  from app.agent.services import get_llm_service
24
+ from app.middleware import get_current_active_user, get_user_ip
25
+ from app.middleware.rate_limit import limiter, authenticated_user_limit
26
+ from app.services.database import DatabaseService
27
 
28
  router = APIRouter(prefix="/api/v1", tags=["hadith-analysis"])
29
 
 
34
  summary="Extract narrators from hadith text",
35
  description="Analyzes Arabic hadith text and extracts the chain of narrators (sanad)",
36
  )
37
+ @authenticated_user_limit()
38
+ async def extract_narrators(
39
+ request: HadithTextRequest,
40
+ http_request: Request,
41
+ current_user: User = Depends(get_current_active_user)
42
+ ) -> NarratorExtractionResponse:
43
  """
44
  Extract narrators from hadith text.
45
 
 
49
 
50
  Args:
51
  request: Contains the hadith text to analyze
52
+ current_user: Current authenticated user
53
 
54
  Returns:
55
  NarratorExtractionResponse with extracted narrator names and chain
 
57
  Raises:
58
  HTTPException: If the analysis fails
59
  """
60
+ start_time = time.time()
61
+ db_service = DatabaseService()
62
+
63
  try:
64
  llm_service = get_llm_service()
65
  result = await llm_service.extract_narrators(request.hadith_text)
66
+
67
+ processing_time = int((time.time() - start_time) * 1000) # Convert to milliseconds
68
+
69
+ # Store extraction record in database
70
+ extraction_record = NarratorExtractionRecord(
71
+ user_id=current_user.id or "",
72
+ hadith_text=request.hadith_text,
73
+ extracted_narrators=result.narrators if result.success else [],
74
+ sanad_chain=result.sanad_chain if result.success else "",
75
+ success=result.success,
76
+ error_message=result.message if not result.success else None,
77
+ processing_time_ms=processing_time,
78
+ ip_address=get_user_ip(http_request)
79
+ )
80
+
81
+ await db_service.store_extraction_record(extraction_record)
82
 
83
  if not result.success:
84
  raise HTTPException(
 
91
  except HTTPException:
92
  raise
93
  except Exception as e:
94
+ # Store failed extraction record
95
+ processing_time = int((time.time() - start_time) * 1000)
96
+ extraction_record = NarratorExtractionRecord(
97
+ user_id=current_user.id or "",
98
+ hadith_text=request.hadith_text,
99
+ extracted_narrators=[],
100
+ sanad_chain="",
101
+ success=False,
102
+ error_message=str(e),
103
+ processing_time_ms=processing_time,
104
+ ip_address=get_user_ip(http_request)
105
+ )
106
+
107
+ try:
108
+ await db_service.store_extraction_record(extraction_record)
109
+ except:
110
+ pass # Don't fail if database storage fails
111
+
112
  raise HTTPException(
113
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
114
  detail=f"Internal server error during narrator extraction: {str(e)}",
 
121
  summary="Analyze narrator reliability",
122
  description="Takes a narrator name and generates an AI-powered reliability assessment based on the model's knowledge",
123
  )
124
+ @authenticated_user_limit()
125
  async def analyze_narrator(
126
  request: NarratorAnalysisRequest,
127
+ current_user: User = Depends(get_current_active_user)
128
  ) -> NarratorAnalysisResponse:
129
  """
130
  Analyze narrator reliability based on the model's internal knowledge.
 
135
 
136
  Args:
137
  request: Contains the narrator name to analyze
138
+ current_user: Current authenticated user
139
 
140
  Returns:
141
  NarratorAnalysisResponse with reliability grade, biographical info, and detailed analysis
 
143
  Raises:
144
  HTTPException: If the analysis fails
145
  """
146
+ start_time = time.time()
147
+ db_service = DatabaseService()
148
+
149
  try:
150
  llm_service = get_llm_service()
151
  result = await llm_service.analyze_narrator(request.narrator_name)
152
+
153
+ processing_time = int((time.time() - start_time) * 1000)
154
+
155
+ # Store analysis record in database
156
+ analysis_record = NarratorAnalysisRecord(
157
+ user_id=current_user.id or "",
158
+ narrator_name=request.narrator_name,
159
+ reliability_grade=result.reliability_grade if result.success else "",
160
+ confidence_level=result.confidence_level if result.success else "",
161
+ reasoning=result.reasoning if result.success else "",
162
+ scholarly_consensus=result.scholarly_consensus if result.success else "",
163
+ known_issues=result.known_issues if result.success else None,
164
+ biographical_info=result.biographical_info if result.success else "",
165
+ recommendation=result.recommendation if result.success else "",
166
+ success=result.success,
167
+ error_message=result.message if not result.success else None,
168
+ processing_time_ms=processing_time
169
+ )
170
+
171
+ await db_service.store_analysis_record(analysis_record)
172
 
173
  if not result.success:
174
  raise HTTPException(
 
193
  summary="Analyze narrator chain",
194
  description="Analyzes a complete chain of narrators using enhanced Shamela data + LLM agent",
195
  )
196
+ @authenticated_user_limit()
197
  async def analyze_narrator_chain(
198
  narrator_names: List[str],
199
+ current_user: User = Depends(get_current_active_user)
200
  ) -> NarratorChainAnalysisResponse:
201
  """
202
  Analyze a complete chain of narrators with enhanced data sources.
 
269
  summary="Extract narrators and analyze chain",
270
  description="Complete workflow: extract narrators from hadith text and analyze the complete chain",
271
  )
272
+ @authenticated_user_limit()
273
  async def extract_and_analyze_hadith(
274
  request: HadithTextRequest,
275
+ current_user: User = Depends(get_current_active_user)
276
  ) -> ExtractAndAnalyzeResponse:
277
  """
278
  Complete hadith analysis workflow: extraction + chain analysis.
 
375
  "Enhanced narrator analysis with Shamela.ws integration",
376
  "Narrator chain analysis",
377
  "Complete hadith workflow analysis",
378
+ "JWT Authentication",
379
+ "Rate limiting",
380
+ "Database storage"
381
  ],
382
  }
383
+
384
+
385
+ # Analytics and User Data Routes
386
+ @router.get(
387
+ "/user/extractions",
388
+ summary="Get user's extraction history",
389
+ description="Get the current user's narrator extraction history"
390
+ )
391
+ @authenticated_user_limit()
392
+ async def get_user_extractions(
393
+ current_user: User = Depends(get_current_active_user),
394
+ limit: int = 50
395
+ ):
396
+ """Get user's extraction history."""
397
+ db_service = DatabaseService()
398
+ extractions = await db_service.get_user_extractions(current_user.id or "", limit)
399
+ return {"extractions": extractions}
400
+
401
+
402
+ @router.get(
403
+ "/user/analyses",
404
+ summary="Get user's analysis history",
405
+ description="Get the current user's narrator analysis history"
406
+ )
407
+ @authenticated_user_limit()
408
+ async def get_user_analyses(
409
+ current_user: User = Depends(get_current_active_user),
410
+ limit: int = 50
411
+ ):
412
+ """Get user's analysis history."""
413
+ db_service = DatabaseService()
414
+ analyses = await db_service.get_user_analyses(current_user.id or "", limit)
415
+ return {"analyses": analyses}
416
+
417
+
418
+ @router.get(
419
+ "/analytics/stats",
420
+ summary="Get extraction statistics",
421
+ description="Get overall platform statistics (public)"
422
+ )
423
+ async def get_platform_stats():
424
+ """Get platform-wide extraction statistics."""
425
+ db_service = DatabaseService()
426
+ stats = await db_service.get_extraction_stats()
427
+ return {"stats": stats}
428
+
429
+
430
+ @router.get(
431
+ "/analytics/popular-narrators",
432
+ summary="Get popular narrators",
433
+ description="Get most frequently analyzed narrators (public)"
434
+ )
435
+ async def get_popular_narrators(limit: int = 10):
436
+ """Get most analyzed narrators."""
437
+ db_service = DatabaseService()
438
+ narrators = await db_service.get_popular_narrators(limit)
439
+ return {"popular_narrators": narrators}
app/auth/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Authentication module
app/auth/routes.py ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, status, Depends, Request
2
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
3
+ from supabase import create_client, Client
4
+ from datetime import datetime, timedelta
5
+ from typing import Optional
6
+ import bcrypt
7
+ import logging
8
+
9
+ from app.config.settings import settings
10
+ from app.db.models import (
11
+ LoginRequest,
12
+ RegisterRequest,
13
+ AuthResponse,
14
+ TokenRefreshRequest,
15
+ User,
16
+ UserSession,
17
+ UserRole
18
+ )
19
+ from app.middleware import auth_middleware, get_user_ip
20
+ from app.middleware.rate_limit import limiter, anonymous_user_limit
21
+
22
+
23
+ router = APIRouter(prefix="/auth", tags=["authentication"])
24
+ security = HTTPBearer()
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ # Initialize Supabase client
29
+ def get_supabase_client() -> Client:
30
+ """Get Supabase client instance."""
31
+ if not settings.SUPABASE_URL or not settings.SUPABASE_SERVICE_KEY:
32
+ raise HTTPException(
33
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
34
+ detail="Supabase configuration is missing"
35
+ )
36
+ return create_client(settings.SUPABASE_URL, settings.SUPABASE_SERVICE_KEY)
37
+
38
+
39
+ def hash_password(password: str) -> str:
40
+ """Hash password using bcrypt."""
41
+ salt = bcrypt.gensalt()
42
+ return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
43
+
44
+
45
+ def verify_password(password: str, hashed_password: str) -> bool:
46
+ """Verify password against hash."""
47
+ return bcrypt.checkpw(password.encode('utf-8'), hashed_password.encode('utf-8'))
48
+
49
+
50
+ async def create_user_session(
51
+ user_id: str,
52
+ access_token: str,
53
+ refresh_token: str,
54
+ request: Request
55
+ ) -> UserSession:
56
+ """Create user session record."""
57
+ supabase = get_supabase_client()
58
+
59
+ session_data = {
60
+ "user_id": user_id,
61
+ "access_token": access_token,
62
+ "refresh_token": refresh_token,
63
+ "expires_at": (datetime.utcnow() + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)).isoformat(),
64
+ "created_at": datetime.utcnow().isoformat(),
65
+ "last_used": datetime.utcnow().isoformat(),
66
+ "user_agent": request.headers.get("user-agent", ""),
67
+ "ip_address": get_user_ip(request)
68
+ }
69
+
70
+ try:
71
+ response = supabase.table("user_sessions").insert(session_data).execute()
72
+ if response.data:
73
+ return UserSession(**response.data[0])
74
+ raise HTTPException(
75
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
76
+ detail="Failed to create session"
77
+ )
78
+ except Exception as e:
79
+ logger.error(f"Error creating user session: {e}")
80
+ raise HTTPException(
81
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
82
+ detail="Failed to create session"
83
+ )
84
+
85
+
86
+ @router.post("/register", response_model=AuthResponse)
87
+ @anonymous_user_limit()
88
+ async def register(request: Request, user_data: RegisterRequest):
89
+ """Register a new user."""
90
+ supabase = get_supabase_client()
91
+
92
+ try:
93
+ # Check if user already exists
94
+ existing_user = supabase.table("users").select("id").eq("email", user_data.email).execute()
95
+ if existing_user.data:
96
+ raise HTTPException(
97
+ status_code=status.HTTP_400_BAD_REQUEST,
98
+ detail="User with this email already exists"
99
+ )
100
+
101
+ # Create user using Supabase Auth
102
+ auth_response = supabase.auth.sign_up({
103
+ "email": user_data.email,
104
+ "password": user_data.password,
105
+ "options": {
106
+ "data": {
107
+ "username": user_data.username,
108
+ "full_name": user_data.full_name
109
+ }
110
+ }
111
+ })
112
+
113
+ if not auth_response.user:
114
+ raise HTTPException(
115
+ status_code=status.HTTP_400_BAD_REQUEST,
116
+ detail="Failed to create user"
117
+ )
118
+
119
+ # Create user record in our custom table
120
+ user_record = {
121
+ "id": auth_response.user.id,
122
+ "email": user_data.email,
123
+ "username": user_data.username,
124
+ "full_name": user_data.full_name,
125
+ "role": UserRole.USER.value,
126
+ "is_active": True,
127
+ "created_at": datetime.utcnow().isoformat()
128
+ }
129
+
130
+ db_response = supabase.table("users").insert(user_record).execute()
131
+ if not db_response.data:
132
+ raise HTTPException(
133
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
134
+ detail="Failed to create user record"
135
+ )
136
+
137
+ user = User(**db_response.data[0])
138
+
139
+ # Create JWT tokens
140
+ access_token = auth_middleware.create_access_token(data={"sub": user.id})
141
+ refresh_token = auth_middleware.create_refresh_token(data={"sub": user.id})
142
+
143
+ # Create session
144
+ await create_user_session(user.id, access_token, refresh_token, request)
145
+
146
+ return AuthResponse(
147
+ access_token=access_token,
148
+ refresh_token=refresh_token,
149
+ token_type="bearer",
150
+ expires_in=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60,
151
+ user=user
152
+ )
153
+
154
+ except HTTPException:
155
+ raise
156
+ except Exception as e:
157
+ logger.error(f"Registration error: {e}")
158
+ raise HTTPException(
159
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
160
+ detail="Registration failed"
161
+ )
162
+
163
+
164
+ @router.post("/login", response_model=AuthResponse)
165
+ @anonymous_user_limit()
166
+ async def login(request: Request, credentials: LoginRequest):
167
+ """Authenticate user and return tokens."""
168
+ supabase = get_supabase_client()
169
+
170
+ try:
171
+ # Authenticate with Supabase
172
+ auth_response = supabase.auth.sign_in_with_password({
173
+ "email": credentials.email,
174
+ "password": credentials.password
175
+ })
176
+
177
+ if not auth_response.user:
178
+ raise HTTPException(
179
+ status_code=status.HTTP_401_UNAUTHORIZED,
180
+ detail="Invalid email or password"
181
+ )
182
+
183
+ # Get user from our custom table
184
+ user_response = supabase.table("users").select("*").eq("id", auth_response.user.id).execute()
185
+ if not user_response.data:
186
+ raise HTTPException(
187
+ status_code=status.HTTP_404_NOT_FOUND,
188
+ detail="User record not found"
189
+ )
190
+
191
+ user = User(**user_response.data[0])
192
+
193
+ if not user.is_active:
194
+ raise HTTPException(
195
+ status_code=status.HTTP_400_BAD_REQUEST,
196
+ detail="Account is disabled"
197
+ )
198
+
199
+ # Update last login
200
+ supabase.table("users").update({
201
+ "last_login": datetime.utcnow().isoformat()
202
+ }).eq("id", user.id).execute()
203
+
204
+ # Create JWT tokens
205
+ access_token = auth_middleware.create_access_token(data={"sub": user.id})
206
+ refresh_token = auth_middleware.create_refresh_token(data={"sub": user.id})
207
+
208
+ # Create session
209
+ await create_user_session(user.id, access_token, refresh_token, request)
210
+
211
+ return AuthResponse(
212
+ access_token=access_token,
213
+ refresh_token=refresh_token,
214
+ token_type="bearer",
215
+ expires_in=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60,
216
+ user=user
217
+ )
218
+
219
+ except HTTPException:
220
+ raise
221
+ except Exception as e:
222
+ logger.error(f"Login error: {e}")
223
+ raise HTTPException(
224
+ status_code=status.HTTP_401_UNAUTHORIZED,
225
+ detail="Authentication failed"
226
+ )
227
+
228
+
229
+ @router.post("/refresh", response_model=AuthResponse)
230
+ @anonymous_user_limit()
231
+ async def refresh_token(request: Request, token_data: TokenRefreshRequest):
232
+ """Refresh access token using refresh token."""
233
+ try:
234
+ # Verify refresh token
235
+ payload = auth_middleware.verify_token(token_data.refresh_token)
236
+
237
+ if payload.get("type") != "refresh":
238
+ raise HTTPException(
239
+ status_code=status.HTTP_401_UNAUTHORIZED,
240
+ detail="Invalid token type"
241
+ )
242
+
243
+ user_id = payload.get("sub")
244
+ if not user_id:
245
+ raise HTTPException(
246
+ status_code=status.HTTP_401_UNAUTHORIZED,
247
+ detail="Invalid token"
248
+ )
249
+
250
+ # Get user
251
+ user = await auth_middleware.get_user_from_supabase(user_id)
252
+ if not user or not user.is_active:
253
+ raise HTTPException(
254
+ status_code=status.HTTP_401_UNAUTHORIZED,
255
+ detail="User not found or inactive"
256
+ )
257
+
258
+ # Create new tokens
259
+ new_access_token = auth_middleware.create_access_token(data={"sub": user.id})
260
+ new_refresh_token = auth_middleware.create_refresh_token(data={"sub": user.id})
261
+
262
+ # Blacklist old refresh token
263
+ expires_at = datetime.fromtimestamp(payload.get("exp", 0))
264
+ auth_middleware.blacklist_token(token_data.refresh_token, expires_at)
265
+
266
+ # Create new session
267
+ await create_user_session(user.id, new_access_token, new_refresh_token, request)
268
+
269
+ return AuthResponse(
270
+ access_token=new_access_token,
271
+ refresh_token=new_refresh_token,
272
+ token_type="bearer",
273
+ expires_in=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60,
274
+ user=user
275
+ )
276
+
277
+ except HTTPException:
278
+ raise
279
+ except Exception as e:
280
+ logger.error(f"Token refresh error: {e}")
281
+ raise HTTPException(
282
+ status_code=status.HTTP_401_UNAUTHORIZED,
283
+ detail="Token refresh failed"
284
+ )
285
+
286
+
287
+ @router.post("/logout")
288
+ async def logout(credentials: HTTPAuthorizationCredentials = Depends(security)):
289
+ """Logout user and blacklist token."""
290
+ try:
291
+ token = credentials.credentials
292
+ payload = auth_middleware.verify_token(token)
293
+
294
+ # Blacklist the token
295
+ expires_at = datetime.fromtimestamp(payload.get("exp", 0))
296
+ auth_middleware.blacklist_token(token, expires_at)
297
+
298
+ # Update session as ended
299
+ user_id = payload.get("sub")
300
+ if user_id:
301
+ supabase = get_supabase_client()
302
+ supabase.table("user_sessions").update({
303
+ "ended_at": datetime.utcnow().isoformat()
304
+ }).eq("user_id", user_id).eq("access_token", token).execute()
305
+
306
+ return {"message": "Successfully logged out"}
307
+
308
+ except Exception as e:
309
+ logger.error(f"Logout error: {e}")
310
+ return {"message": "Logout completed"}
311
+
312
+
313
+ @router.get("/me", response_model=User)
314
+ async def get_current_user_info(current_user: User = Depends(auth_middleware.get_current_user)):
315
+ """Get current user information."""
316
+ return current_user
317
+
318
+
319
+ @router.get("/sessions")
320
+ async def get_user_sessions(current_user: User = Depends(auth_middleware.get_current_user)):
321
+ """Get user's active sessions."""
322
+ supabase = get_supabase_client()
323
+
324
+ try:
325
+ response = supabase.table("user_sessions").select("*").eq("user_id", current_user.id).is_("ended_at", "null").execute()
326
+ return {"sessions": response.data}
327
+ except Exception as e:
328
+ logger.error(f"Error fetching sessions: {e}")
329
+ raise HTTPException(
330
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
331
+ detail="Failed to fetch sessions"
332
+ )
app/config/settings.py CHANGED
@@ -1,5 +1,5 @@
1
  import os
2
- from typing import Optional
3
 
4
 
5
  class Settings:
@@ -13,19 +13,43 @@ class Settings:
13
  # Environment
14
  ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development")
15
  DEBUG: bool = os.getenv("DEBUG", "True").lower() == "true"
 
 
16
 
17
  # Google AI
18
  GOOGLE_API_KEY: Optional[str] = os.getenv("GOOGLE_API_KEY")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  # Rate Limiting
21
- RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
22
- RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "3600")) # 1 hour
 
 
 
23
 
24
  # CORS
25
- ALLOWED_ORIGINS: list = os.getenv("ALLOWED_ORIGINS", "*").split(",")
26
 
27
- class Config:
28
- env_file = ".env"
29
 
30
 
31
  settings = Settings()
 
1
  import os
2
+ from typing import Optional, List
3
 
4
 
5
  class Settings:
 
13
  # Environment
14
  ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development")
15
  DEBUG: bool = os.getenv("DEBUG", "True").lower() == "true"
16
+ HOST: str = os.getenv("HOST", "0.0.0.0")
17
+ PORT: int = int(os.getenv("PORT", "8000"))
18
 
19
  # Google AI
20
  GOOGLE_API_KEY: Optional[str] = os.getenv("GOOGLE_API_KEY")
21
+ GROQ_API_KEY: Optional[str] = os.getenv("GROQ_API_KEY")
22
+
23
+ # JWT Configuration
24
+ JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY", "your-super-secret-jwt-key-change-this-in-production")
25
+ JWT_ALGORITHM: str = os.getenv("JWT_ALGORITHM", "HS256")
26
+ JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
27
+ JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = int(os.getenv("JWT_REFRESH_TOKEN_EXPIRE_DAYS", "7"))
28
+
29
+ # Supabase Configuration
30
+ SUPABASE_URL: Optional[str] = os.getenv("SUPABASE_URL")
31
+ SUPABASE_SERVICE_KEY: Optional[str] = os.getenv("SUPABASE_SERVICE_KEY")
32
+ SUPABASE_ANON_KEY: Optional[str] = os.getenv("SUPABASE_ANON_KEY")
33
+
34
+ # Redis Configuration
35
+ REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379")
36
+ REDIS_HOST: str = os.getenv("REDIS_HOST", "localhost")
37
+ REDIS_PORT: int = int(os.getenv("REDIS_PORT", "6379"))
38
+ REDIS_DB: int = int(os.getenv("REDIS_DB", "0"))
39
+ REDIS_PASSWORD: str = os.getenv("REDIS_PASSWORD", "")
40
 
41
  # Rate Limiting
42
+ RATE_LIMIT_REQUESTS_PER_MINUTE: int = int(os.getenv("RATE_LIMIT_REQUESTS_PER_MINUTE", "60"))
43
+ RATE_LIMIT_BURST: int = int(os.getenv("RATE_LIMIT_BURST", "10"))
44
+
45
+ # Database
46
+ DATABASE_URL: Optional[str] = os.getenv("DATABASE_URL")
47
 
48
  # CORS
49
+ ALLOWED_ORIGINS: List[str] = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:8000,http://localhost:5173").split(",")
50
 
51
+ # Logging
52
+ LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
53
 
54
 
55
  settings = Settings()
app/db/models.py CHANGED
@@ -1,7 +1,106 @@
1
  from typing import List, Optional, Dict, Any
2
  from pydantic import BaseModel, Field
3
-
4
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  class NarratorGrade(BaseModel):
6
  """Grade for individual narrator in the chain."""
7
 
 
1
  from typing import List, Optional, Dict, Any
2
  from pydantic import BaseModel, Field
3
+ from datetime import datetime
4
+ from enum import Enum
5
+
6
+
7
+ # Authentication Models
8
+ class UserRole(str, Enum):
9
+ """User roles enum."""
10
+ USER = "user"
11
+ ADMIN = "admin"
12
+ RESEARCHER = "researcher"
13
+
14
+
15
+ class User(BaseModel):
16
+ """User model for authentication."""
17
+ id: Optional[str] = Field(default=None, description="User ID from Supabase")
18
+ email: str = Field(description="User email address")
19
+ username: Optional[str] = Field(default=None, description="Username")
20
+ full_name: Optional[str] = Field(default=None, description="Full name")
21
+ role: UserRole = Field(default=UserRole.USER, description="User role")
22
+ is_active: bool = Field(default=True, description="Whether user is active")
23
+ created_at: Optional[datetime] = Field(default=None, description="Account creation timestamp")
24
+ last_login: Optional[datetime] = Field(default=None, description="Last login timestamp")
25
+
26
+
27
+ class UserSession(BaseModel):
28
+ """User session model."""
29
+ id: Optional[str] = Field(default=None, description="Session ID")
30
+ user_id: str = Field(description="User ID")
31
+ access_token: str = Field(description="JWT access token")
32
+ refresh_token: Optional[str] = Field(default=None, description="JWT refresh token")
33
+ expires_at: datetime = Field(description="Token expiration time")
34
+ created_at: Optional[datetime] = Field(default=None, description="Session creation time")
35
+ last_used: Optional[datetime] = Field(default=None, description="Last used timestamp")
36
+ user_agent: Optional[str] = Field(default=None, description="User agent string")
37
+ ip_address: Optional[str] = Field(default=None, description="Client IP address")
38
+
39
+
40
+ class LoginRequest(BaseModel):
41
+ """Login request model."""
42
+ email: str = Field(description="User email")
43
+ password: str = Field(description="User password", min_length=6)
44
+
45
+
46
+ class RegisterRequest(BaseModel):
47
+ """Registration request model."""
48
+ email: str = Field(description="User email")
49
+ password: str = Field(description="User password", min_length=6)
50
+ username: Optional[str] = Field(default=None, description="Username")
51
+ full_name: Optional[str] = Field(default=None, description="Full name")
52
+
53
+
54
+ class AuthResponse(BaseModel):
55
+ """Authentication response model."""
56
+ access_token: str = Field(description="JWT access token")
57
+ refresh_token: Optional[str] = Field(default=None, description="JWT refresh token")
58
+ token_type: str = Field(default="bearer", description="Token type")
59
+ expires_in: int = Field(description="Token expiration time in seconds")
60
+ user: User = Field(description="User information")
61
+
62
+
63
+ class TokenRefreshRequest(BaseModel):
64
+ """Token refresh request model."""
65
+ refresh_token: str = Field(description="Refresh token")
66
+
67
+
68
+ # Database Storage Models
69
+ class NarratorExtractionRecord(BaseModel):
70
+ """Database record for narrator extraction."""
71
+ id: Optional[str] = Field(default=None, description="Record ID")
72
+ user_id: str = Field(description="User who made the request")
73
+ hadith_text: str = Field(description="Original hadith text")
74
+ extracted_narrators: List[str] = Field(description="Extracted narrator names")
75
+ sanad_chain: str = Field(description="Extracted sanad chain")
76
+ success: bool = Field(description="Whether extraction was successful")
77
+ error_message: Optional[str] = Field(default=None, description="Error message if failed")
78
+ processing_time_ms: Optional[int] = Field(default=None, description="Processing time in milliseconds")
79
+ created_at: Optional[datetime] = Field(default=None, description="Creation timestamp")
80
+ session_id: Optional[str] = Field(default=None, description="Session ID")
81
+ ip_address: Optional[str] = Field(default=None, description="Client IP address")
82
+
83
+
84
+ class NarratorAnalysisRecord(BaseModel):
85
+ """Database record for narrator analysis."""
86
+ id: Optional[str] = Field(default=None, description="Record ID")
87
+ user_id: str = Field(description="User who made the request")
88
+ extraction_id: Optional[str] = Field(default=None, description="Related extraction record ID")
89
+ narrator_name: str = Field(description="Analyzed narrator name")
90
+ reliability_grade: str = Field(description="Assigned reliability grade")
91
+ confidence_level: str = Field(description="Confidence level")
92
+ reasoning: str = Field(description="Analysis reasoning")
93
+ scholarly_consensus: str = Field(description="Scholarly consensus analysis")
94
+ known_issues: Optional[str] = Field(default=None, description="Known issues")
95
+ biographical_info: str = Field(description="Biographical information")
96
+ recommendation: str = Field(description="Usage recommendation")
97
+ success: bool = Field(description="Whether analysis was successful")
98
+ error_message: Optional[str] = Field(default=None, description="Error message if failed")
99
+ processing_time_ms: Optional[int] = Field(default=None, description="Processing time in milliseconds")
100
+ created_at: Optional[datetime] = Field(default=None, description="Creation timestamp")
101
+
102
+
103
+ # Existing Models (keeping them for compatibility)
104
  class NarratorGrade(BaseModel):
105
  """Grade for individual narrator in the chain."""
106
 
app/main.py CHANGED
@@ -1,4 +1,4 @@
1
- from fastapi import FastAPI, HTTPException
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from fastapi.responses import JSONResponse
4
  import uvicorn
@@ -6,6 +6,15 @@ import os
6
 
7
  from app.config.settings import settings
8
  from app.api.routes import router
 
 
 
 
 
 
 
 
 
9
 
10
 
11
  # Create FastAPI application
@@ -17,6 +26,23 @@ app = FastAPI(
17
  redoc_url="/redoc" if settings.DEBUG else None,
18
  )
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  # Add CORS middleware
21
  app.add_middleware(
22
  CORSMiddleware,
@@ -27,6 +53,7 @@ app.add_middleware(
27
  )
28
 
29
  # Include API routes
 
30
  app.include_router(router)
31
 
32
 
 
1
+ from fastapi import FastAPI, HTTPException, Request
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from fastapi.responses import JSONResponse
4
  import uvicorn
 
6
 
7
  from app.config.settings import settings
8
  from app.api.routes import router
9
+ from app.auth.routes import router as auth_router
10
+
11
+ try:
12
+ from slowapi import Limiter, _rate_limit_exceeded_handler
13
+ from slowapi.errors import RateLimitExceeded
14
+ from app.middleware.rate_limit import limiter
15
+ RATE_LIMITING_AVAILABLE = True
16
+ except ImportError:
17
+ RATE_LIMITING_AVAILABLE = False
18
 
19
 
20
  # Create FastAPI application
 
26
  redoc_url="/redoc" if settings.DEBUG else None,
27
  )
28
 
29
+ # Add rate limiting if available
30
+ if RATE_LIMITING_AVAILABLE:
31
+ app.state.limiter = limiter
32
+
33
+ @app.exception_handler(RateLimitExceeded)
34
+ async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
35
+ response = JSONResponse(
36
+ status_code=429,
37
+ content={
38
+ "error": "Rate limit exceeded",
39
+ "message": f"Too many requests. Limit: {exc.detail}",
40
+ "retry_after": exc.retry_after
41
+ }
42
+ )
43
+ response.headers["Retry-After"] = str(exc.retry_after)
44
+ return response
45
+
46
  # Add CORS middleware
47
  app.add_middleware(
48
  CORSMiddleware,
 
53
  )
54
 
55
  # Include API routes
56
+ app.include_router(auth_router)
57
  app.include_router(router)
58
 
59
 
app/middleware/__init__.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import HTTPException, status, Depends, Request
2
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
3
+ from jose import JWTError, jwt
4
+ from datetime import datetime, timedelta
5
+ from typing import Optional
6
+ import redis
7
+ from supabase import create_client, Client
8
+
9
+ from app.config.settings import settings
10
+ from app.db.models import User, UserSession
11
+
12
+
13
+ class AuthMiddleware:
14
+ """JWT Authentication middleware with Supabase integration."""
15
+
16
+ def __init__(self):
17
+ self.security = HTTPBearer()
18
+ self.supabase: Client = create_client(
19
+ settings.SUPABASE_URL,
20
+ settings.SUPABASE_SERVICE_KEY
21
+ ) if settings.SUPABASE_URL and settings.SUPABASE_SERVICE_KEY else None
22
+
23
+ # Redis client for token blacklisting
24
+ try:
25
+ self.redis_client = redis.Redis(
26
+ host=settings.REDIS_HOST,
27
+ port=settings.REDIS_PORT,
28
+ db=settings.REDIS_DB,
29
+ password=settings.REDIS_PASSWORD if settings.REDIS_PASSWORD else None,
30
+ decode_responses=True
31
+ )
32
+ except Exception:
33
+ self.redis_client = None
34
+
35
+ def create_access_token(self, data: dict, expires_delta: Optional[timedelta] = None) -> str:
36
+ """Create JWT access token."""
37
+ to_encode = data.copy()
38
+ if expires_delta:
39
+ expire = datetime.utcnow() + expires_delta
40
+ else:
41
+ expire = datetime.utcnow() + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
42
+
43
+ to_encode.update({"exp": expire, "type": "access"})
44
+ encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
45
+ return encoded_jwt
46
+
47
+ def create_refresh_token(self, data: dict, expires_delta: Optional[timedelta] = None) -> str:
48
+ """Create JWT refresh token."""
49
+ to_encode = data.copy()
50
+ if expires_delta:
51
+ expire = datetime.utcnow() + expires_delta
52
+ else:
53
+ expire = datetime.utcnow() + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
54
+
55
+ to_encode.update({"exp": expire, "type": "refresh"})
56
+ encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
57
+ return encoded_jwt
58
+
59
+ def verify_token(self, token: str) -> dict:
60
+ """Verify JWT token."""
61
+ try:
62
+ # Check if token is blacklisted
63
+ if self.redis_client and self.redis_client.get(f"blacklist:{token}"):
64
+ raise HTTPException(
65
+ status_code=status.HTTP_401_UNAUTHORIZED,
66
+ detail="Token has been revoked",
67
+ headers={"WWW-Authenticate": "Bearer"},
68
+ )
69
+
70
+ payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
71
+ return payload
72
+ except JWTError:
73
+ raise HTTPException(
74
+ status_code=status.HTTP_401_UNAUTHORIZED,
75
+ detail="Could not validate credentials",
76
+ headers={"WWW-Authenticate": "Bearer"},
77
+ )
78
+
79
+ def blacklist_token(self, token: str, expires_at: datetime):
80
+ """Add token to blacklist."""
81
+ if self.redis_client:
82
+ try:
83
+ # Calculate TTL until token expires
84
+ ttl = int((expires_at - datetime.utcnow()).total_seconds())
85
+ if ttl > 0:
86
+ self.redis_client.setex(f"blacklist:{token}", ttl, "1")
87
+ except Exception:
88
+ pass # If Redis is down, continue without blacklisting
89
+
90
+ async def get_user_from_supabase(self, user_id: str) -> Optional[User]:
91
+ """Get user from Supabase."""
92
+ if not self.supabase:
93
+ return None
94
+
95
+ try:
96
+ response = self.supabase.table("users").select("*").eq("id", user_id).execute()
97
+ if response.data:
98
+ user_data = response.data[0]
99
+ return User(**user_data)
100
+ return None
101
+ except Exception:
102
+ return None
103
+
104
+ async def get_current_user(self, credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer())) -> User:
105
+ """Get current authenticated user."""
106
+ token = credentials.credentials
107
+ payload = self.verify_token(token)
108
+
109
+ user_id: str = payload.get("sub")
110
+ if user_id is None:
111
+ raise HTTPException(
112
+ status_code=status.HTTP_401_UNAUTHORIZED,
113
+ detail="Could not validate credentials",
114
+ headers={"WWW-Authenticate": "Bearer"},
115
+ )
116
+
117
+ # Get user from Supabase
118
+ user = await self.get_user_from_supabase(user_id)
119
+ if user is None:
120
+ raise HTTPException(
121
+ status_code=status.HTTP_401_UNAUTHORIZED,
122
+ detail="User not found",
123
+ headers={"WWW-Authenticate": "Bearer"},
124
+ )
125
+
126
+ if not user.is_active:
127
+ raise HTTPException(
128
+ status_code=status.HTTP_400_BAD_REQUEST,
129
+ detail="Inactive user"
130
+ )
131
+
132
+ return user
133
+
134
+ async def get_current_active_user(self, current_user: User = Depends(lambda: auth_middleware.get_current_user)) -> User:
135
+ """Get current active user."""
136
+ if not current_user.is_active:
137
+ raise HTTPException(status_code=400, detail="Inactive user")
138
+ return current_user
139
+
140
+ async def get_admin_user(self, current_user: User = Depends(lambda: auth_middleware.get_current_user)) -> User:
141
+ """Get current user if they have admin role."""
142
+ if current_user.role != "admin":
143
+ raise HTTPException(
144
+ status_code=status.HTTP_403_FORBIDDEN,
145
+ detail="Not enough permissions"
146
+ )
147
+ return current_user
148
+
149
+
150
+ # Create global instance
151
+ auth_middleware = AuthMiddleware()
152
+
153
+ # Dependency functions for FastAPI
154
+ async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer())) -> User:
155
+ """FastAPI dependency to get current user."""
156
+ return await auth_middleware.get_current_user(credentials)
157
+
158
+ async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
159
+ """FastAPI dependency to get current active user."""
160
+ return await auth_middleware.get_current_active_user(current_user)
161
+
162
+ async def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
163
+ """FastAPI dependency to get admin user."""
164
+ return await auth_middleware.get_admin_user(current_user)
165
+
166
+ def get_user_ip(request: Request) -> str:
167
+ """Extract user IP address from request."""
168
+ forwarded = request.headers.get("X-Forwarded-For")
169
+ if forwarded:
170
+ return forwarded.split(",")[0].strip()
171
+ return request.client.host if request.client else "unknown"
app/middleware/rate_limit.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import Request, HTTPException, status
2
+ from slowapi import Limiter, _rate_limit_exceeded_handler
3
+ from slowapi.util import get_remote_address
4
+ from slowapi.errors import RateLimitExceeded
5
+ import redis
6
+ from typing import Optional
7
+
8
+ from app.config.settings import settings
9
+
10
+
11
+ def get_client_ip(request: Request) -> str:
12
+ """Get client IP address for rate limiting."""
13
+ # Check for forwarded headers first
14
+ forwarded = request.headers.get("X-Forwarded-For")
15
+ if forwarded:
16
+ return forwarded.split(",")[0].strip()
17
+
18
+ real_ip = request.headers.get("X-Real-IP")
19
+ if real_ip:
20
+ return real_ip
21
+
22
+ return get_remote_address(request)
23
+
24
+
25
+ def get_user_id_from_token(request: Request) -> Optional[str]:
26
+ """Extract user ID from JWT token for user-based rate limiting."""
27
+ try:
28
+ from app.middleware import auth_middleware
29
+ auth_header = request.headers.get("Authorization")
30
+ if auth_header and auth_header.startswith("Bearer "):
31
+ token = auth_header.split(" ")[1]
32
+ payload = auth_middleware.verify_token(token)
33
+ return payload.get("sub")
34
+ except Exception:
35
+ pass
36
+ return None
37
+
38
+
39
+ def rate_limit_key(request: Request) -> str:
40
+ """Generate rate limiting key based on user or IP."""
41
+ user_id = get_user_id_from_token(request)
42
+ if user_id:
43
+ return f"user:{user_id}"
44
+ return f"ip:{get_client_ip(request)}"
45
+
46
+
47
+ # Create Redis client for rate limiting
48
+ try:
49
+ redis_client = redis.Redis(
50
+ host=settings.REDIS_HOST,
51
+ port=settings.REDIS_PORT,
52
+ db=settings.REDIS_DB,
53
+ password=settings.REDIS_PASSWORD if settings.REDIS_PASSWORD else None,
54
+ decode_responses=True
55
+ )
56
+ # Test connection
57
+ redis_client.ping()
58
+ except Exception:
59
+ # Fallback to in-memory storage if Redis is not available
60
+ redis_client = None
61
+
62
+
63
+ # Create limiter instance
64
+ limiter = Limiter(
65
+ key_func=rate_limit_key,
66
+ storage_uri=settings.REDIS_URL if redis_client else "memory://",
67
+ default_limits=[f"{settings.RATE_LIMIT_REQUESTS_PER_MINUTE}/minute"]
68
+ )
69
+
70
+
71
+ # Custom rate limit exceeded handler
72
+ async def custom_rate_limit_handler(request: Request, exc: RateLimitExceeded):
73
+ """Custom handler for rate limit exceeded."""
74
+ response = HTTPException(
75
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
76
+ detail={
77
+ "error": "Rate limit exceeded",
78
+ "message": f"Too many requests. Limit: {exc.detail}",
79
+ "retry_after": exc.retry_after
80
+ }
81
+ )
82
+ return response
83
+
84
+
85
+ # Rate limiting decorators for different tiers
86
+ def authenticated_user_limit():
87
+ """Rate limit for authenticated users (higher limit)."""
88
+ return limiter.limit(f"{settings.RATE_LIMIT_REQUESTS_PER_MINUTE * 2}/minute")
89
+
90
+
91
+ def anonymous_user_limit():
92
+ """Rate limit for anonymous users (lower limit)."""
93
+ return limiter.limit(f"{settings.RATE_LIMIT_REQUESTS_PER_MINUTE}/minute")
94
+
95
+
96
+ def admin_user_limit():
97
+ """Rate limit for admin users (highest limit)."""
98
+ return limiter.limit(f"{settings.RATE_LIMIT_REQUESTS_PER_MINUTE * 5}/minute")
99
+
100
+
101
+ def burst_limit():
102
+ """Burst protection limit."""
103
+ return limiter.limit(f"{settings.RATE_LIMIT_BURST}/second")
104
+
105
+
106
+ # Middleware class for more granular control
107
+ class RateLimitMiddleware:
108
+ """Rate limiting middleware with user-aware limits."""
109
+
110
+ def __init__(self):
111
+ self.limiter = limiter
112
+ self.redis_client = redis_client
113
+
114
+ async def check_rate_limit(self, request: Request, limit: str) -> bool:
115
+ """Check if request exceeds rate limit."""
116
+ try:
117
+ key = rate_limit_key(request)
118
+
119
+ if self.redis_client:
120
+ # Use Redis for rate limiting
121
+ current_count = self.redis_client.incr(key)
122
+ if current_count == 1:
123
+ # Set expiration for new key
124
+ self.redis_client.expire(key, 60) # 1 minute window
125
+
126
+ # Parse limit (e.g., "60/minute")
127
+ limit_count = int(limit.split("/")[0])
128
+ if current_count > limit_count:
129
+ return False
130
+
131
+ return True
132
+ except Exception:
133
+ # If rate limiting fails, allow the request
134
+ return True
135
+
136
+ def get_remaining_requests(self, request: Request, limit: str) -> int:
137
+ """Get remaining requests for the current window."""
138
+ try:
139
+ if not self.redis_client:
140
+ return 0
141
+
142
+ key = rate_limit_key(request)
143
+ current_count = self.redis_client.get(key) or 0
144
+ limit_count = int(limit.split("/")[0])
145
+
146
+ return max(0, limit_count - int(current_count))
147
+ except Exception:
148
+ return 0
149
+
150
+
151
+ # Global instance
152
+ rate_limit_middleware = RateLimitMiddleware()
app/services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Services module
app/services/database.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from supabase import create_client, Client
2
+ from typing import Optional
3
+ import logging
4
+
5
+ from app.config.settings import settings
6
+ from app.db.models import NarratorExtractionRecord, NarratorAnalysisRecord
7
+
8
+
9
+ class DatabaseService:
10
+ """Service for database operations with Supabase."""
11
+
12
+ def __init__(self):
13
+ self.logger = logging.getLogger(__name__)
14
+ self.supabase: Optional[Client] = None
15
+
16
+ if settings.SUPABASE_URL and settings.SUPABASE_SERVICE_KEY:
17
+ try:
18
+ self.supabase = create_client(
19
+ settings.SUPABASE_URL,
20
+ settings.SUPABASE_SERVICE_KEY
21
+ )
22
+ except Exception as e:
23
+ self.logger.error(f"Failed to initialize Supabase client: {e}")
24
+
25
+ async def store_extraction_record(self, record: NarratorExtractionRecord) -> Optional[str]:
26
+ """Store narrator extraction record in database."""
27
+ if not self.supabase:
28
+ self.logger.warning("Supabase client not available, skipping database storage")
29
+ return None
30
+
31
+ try:
32
+ # Convert record to dict, excluding None id
33
+ record_dict = record.dict(exclude_none=True)
34
+ if "id" in record_dict and record_dict["id"] is None:
35
+ del record_dict["id"]
36
+
37
+ # Convert datetime objects to ISO strings
38
+ if record.created_at:
39
+ record_dict["created_at"] = record.created_at.isoformat()
40
+
41
+ response = self.supabase.table("narrator_extractions").insert(record_dict).execute()
42
+
43
+ if response.data:
44
+ return response.data[0]["id"]
45
+ else:
46
+ self.logger.error("No data returned from extraction record insertion")
47
+ return None
48
+
49
+ except Exception as e:
50
+ self.logger.error(f"Error storing extraction record: {e}")
51
+ return None
52
+
53
+ async def store_analysis_record(self, record: NarratorAnalysisRecord) -> Optional[str]:
54
+ """Store narrator analysis record in database."""
55
+ if not self.supabase:
56
+ self.logger.warning("Supabase client not available, skipping database storage")
57
+ return None
58
+
59
+ try:
60
+ # Convert record to dict, excluding None id
61
+ record_dict = record.dict(exclude_none=True)
62
+ if "id" in record_dict and record_dict["id"] is None:
63
+ del record_dict["id"]
64
+
65
+ # Convert datetime objects to ISO strings
66
+ if record.created_at:
67
+ record_dict["created_at"] = record.created_at.isoformat()
68
+
69
+ response = self.supabase.table("narrator_analyses").insert(record_dict).execute()
70
+
71
+ if response.data:
72
+ return response.data[0]["id"]
73
+ else:
74
+ self.logger.error("No data returned from analysis record insertion")
75
+ return None
76
+
77
+ except Exception as e:
78
+ self.logger.error(f"Error storing analysis record: {e}")
79
+ return None
80
+
81
+ async def get_user_extractions(self, user_id: str, limit: int = 50) -> list:
82
+ """Get user's extraction history."""
83
+ if not self.supabase:
84
+ return []
85
+
86
+ try:
87
+ response = self.supabase.table("narrator_extractions").select("*").eq("user_id", user_id).order("created_at", desc=True).limit(limit).execute()
88
+ return response.data or []
89
+ except Exception as e:
90
+ self.logger.error(f"Error fetching user extractions: {e}")
91
+ return []
92
+
93
+ async def get_user_analyses(self, user_id: str, limit: int = 50) -> list:
94
+ """Get user's analysis history."""
95
+ if not self.supabase:
96
+ return []
97
+
98
+ try:
99
+ response = self.supabase.table("narrator_analyses").select("*").eq("user_id", user_id).order("created_at", desc=True).limit(limit).execute()
100
+ return response.data or []
101
+ except Exception as e:
102
+ self.logger.error(f"Error fetching user analyses: {e}")
103
+ return []
104
+
105
+ async def get_extraction_stats(self) -> dict:
106
+ """Get extraction statistics."""
107
+ if not self.supabase:
108
+ return {}
109
+
110
+ try:
111
+ response = self.supabase.rpc("get_extraction_stats").execute()
112
+ return response.data[0] if response.data else {}
113
+ except Exception as e:
114
+ self.logger.error(f"Error fetching extraction stats: {e}")
115
+ return {}
116
+
117
+ async def get_popular_narrators(self, limit: int = 10) -> list:
118
+ """Get most analyzed narrators."""
119
+ if not self.supabase:
120
+ return []
121
+
122
+ try:
123
+ response = self.supabase.rpc("get_popular_narrators", {"limit_count": limit}).execute()
124
+ return response.data or []
125
+ except Exception as e:
126
+ self.logger.error(f"Error fetching popular narrators: {e}")
127
+ return []
requirements.txt CHANGED
@@ -25,7 +25,6 @@ grpcio-status==1.74.0
25
  h11==0.16.0
26
  httpcore==1.0.9
27
  httplib2==0.22.0
28
- httpx==0.28.1
29
  idna==3.10
30
  jsonpatch==1.33
31
  jsonpointer==3.0.0
@@ -64,3 +63,20 @@ urllib3==2.5.0
64
  uvicorn==0.35.0
65
  yarl==1.20.1
66
  zstandard==0.23.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  h11==0.16.0
26
  httpcore==1.0.9
27
  httplib2==0.22.0
 
28
  idna==3.10
29
  jsonpatch==1.33
30
  jsonpointer==3.0.0
 
63
  uvicorn==0.35.0
64
  yarl==1.20.1
65
  zstandard==0.23.0
66
+
67
+ # Authentication and JWT
68
+ python-jose[cryptography]==3.3.0
69
+ python-multipart==0.0.9
70
+ passlib[bcrypt]==1.7.4
71
+
72
+ # Supabase
73
+ supabase==2.7.4
74
+ postgrest==0.16.8
75
+
76
+ # Redis and Rate Limiting
77
+ redis==5.0.1
78
+ slowapi==0.1.9
79
+
80
+ # Additional dependencies for enhanced security
81
+ cryptography==42.0.5
82
+ bcrypt==4.1.2
sql/create_tables.sql ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Supabase Database Schema for SanadCheck LLM API
2
+ -- Execute these scripts in your Supabase SQL Editor
3
+
4
+ -- Enable necessary extensions
5
+ CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
6
+
7
+ -- Create custom user roles enum
8
+ CREATE TYPE user_role AS ENUM ('user', 'admin', 'researcher');
9
+
10
+ -- Create users table (extends Supabase auth.users)
11
+ CREATE TABLE IF NOT EXISTS public.users (
12
+ id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
13
+ email TEXT UNIQUE NOT NULL,
14
+ username TEXT UNIQUE,
15
+ full_name TEXT,
16
+ role user_role DEFAULT 'user'::user_role,
17
+ is_active BOOLEAN DEFAULT TRUE,
18
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
19
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
20
+ last_login TIMESTAMP WITH TIME ZONE,
21
+
22
+ -- Constraints
23
+ CONSTRAINT valid_email CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
24
+ CONSTRAINT username_length CHECK (length(username) >= 3 AND length(username) <= 50),
25
+ CONSTRAINT full_name_length CHECK (length(full_name) <= 100)
26
+ );
27
+
28
+ -- Create user_sessions table for JWT session management
29
+ CREATE TABLE IF NOT EXISTS public.user_sessions (
30
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
31
+ user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
32
+ access_token TEXT NOT NULL,
33
+ refresh_token TEXT,
34
+ expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
35
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
36
+ last_used TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
37
+ ended_at TIMESTAMP WITH TIME ZONE,
38
+ user_agent TEXT,
39
+ ip_address INET,
40
+
41
+ -- Indexes for performance
42
+ CONSTRAINT unique_access_token UNIQUE (access_token)
43
+ );
44
+
45
+ -- Create narrator_extractions table for storing extraction results
46
+ CREATE TABLE IF NOT EXISTS public.narrator_extractions (
47
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
48
+ user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
49
+ session_id UUID REFERENCES public.user_sessions(id) ON DELETE SET NULL,
50
+ hadith_text TEXT NOT NULL,
51
+ extracted_narrators TEXT[] NOT NULL DEFAULT '{}',
52
+ sanad_chain TEXT NOT NULL,
53
+ success BOOLEAN NOT NULL DEFAULT FALSE,
54
+ error_message TEXT,
55
+ processing_time_ms INTEGER,
56
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
57
+ ip_address INET,
58
+
59
+ -- Constraints
60
+ CONSTRAINT hadith_text_not_empty CHECK (length(trim(hadith_text)) > 0),
61
+ CONSTRAINT processing_time_positive CHECK (processing_time_ms IS NULL OR processing_time_ms >= 0)
62
+ );
63
+
64
+ -- Create narrator_analyses table for storing individual narrator analysis results
65
+ CREATE TABLE IF NOT EXISTS public.narrator_analyses (
66
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
67
+ user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
68
+ extraction_id UUID REFERENCES public.narrator_extractions(id) ON DELETE SET NULL,
69
+ narrator_name TEXT NOT NULL,
70
+ reliability_grade TEXT NOT NULL,
71
+ confidence_level TEXT NOT NULL,
72
+ reasoning TEXT NOT NULL,
73
+ scholarly_consensus TEXT NOT NULL,
74
+ known_issues TEXT,
75
+ biographical_info TEXT NOT NULL,
76
+ recommendation TEXT NOT NULL,
77
+ success BOOLEAN NOT NULL DEFAULT FALSE,
78
+ error_message TEXT,
79
+ processing_time_ms INTEGER,
80
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
81
+
82
+ -- Constraints
83
+ CONSTRAINT narrator_name_not_empty CHECK (length(trim(narrator_name)) > 0),
84
+ CONSTRAINT reliability_grade_valid CHECK (reliability_grade IN ('Thiqah', 'Saduq', 'Da''if', 'Matruk', 'Majhul')),
85
+ CONSTRAINT confidence_level_valid CHECK (confidence_level IN ('High', 'Medium', 'Low')),
86
+ CONSTRAINT processing_time_positive CHECK (processing_time_ms IS NULL OR processing_time_ms >= 0)
87
+ );
88
+
89
+ -- Create indexes for better performance
90
+ CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON public.user_sessions(user_id);
91
+ CREATE INDEX IF NOT EXISTS idx_user_sessions_access_token ON public.user_sessions(access_token);
92
+ CREATE INDEX IF NOT EXISTS idx_user_sessions_expires_at ON public.user_sessions(expires_at);
93
+ CREATE INDEX IF NOT EXISTS idx_user_sessions_active ON public.user_sessions(user_id) WHERE ended_at IS NULL;
94
+
95
+ CREATE INDEX IF NOT EXISTS idx_narrator_extractions_user_id ON public.narrator_extractions(user_id);
96
+ CREATE INDEX IF NOT EXISTS idx_narrator_extractions_created_at ON public.narrator_extractions(created_at);
97
+ CREATE INDEX IF NOT EXISTS idx_narrator_extractions_success ON public.narrator_extractions(success);
98
+
99
+ CREATE INDEX IF NOT EXISTS idx_narrator_analyses_user_id ON public.narrator_analyses(user_id);
100
+ CREATE INDEX IF NOT EXISTS idx_narrator_analyses_extraction_id ON public.narrator_analyses(extraction_id);
101
+ CREATE INDEX IF NOT EXISTS idx_narrator_analyses_narrator_name ON public.narrator_analyses(narrator_name);
102
+ CREATE INDEX IF NOT EXISTS idx_narrator_analyses_created_at ON public.narrator_analyses(created_at);
103
+
104
+ -- Create updated_at trigger function
105
+ CREATE OR REPLACE FUNCTION public.update_updated_at_column()
106
+ RETURNS TRIGGER AS $$
107
+ BEGIN
108
+ NEW.updated_at = NOW();
109
+ RETURN NEW;
110
+ END;
111
+ $$ language 'plpgsql';
112
+
113
+ -- Add updated_at trigger to users table
114
+ CREATE TRIGGER update_users_updated_at
115
+ BEFORE UPDATE ON public.users
116
+ FOR EACH ROW
117
+ EXECUTE FUNCTION public.update_updated_at_column();
118
+
119
+ -- Row Level Security (RLS) Policies
120
+
121
+ -- Enable RLS on all tables
122
+ ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
123
+ ALTER TABLE public.user_sessions ENABLE ROW LEVEL SECURITY;
124
+ ALTER TABLE public.narrator_extractions ENABLE ROW LEVEL SECURITY;
125
+ ALTER TABLE public.narrator_analyses ENABLE ROW LEVEL SECURITY;
126
+
127
+ -- Users table policies
128
+ CREATE POLICY "Users can view own profile" ON public.users
129
+ FOR SELECT USING (auth.uid() = id);
130
+
131
+ CREATE POLICY "Users can update own profile" ON public.users
132
+ FOR UPDATE USING (auth.uid() = id);
133
+
134
+ CREATE POLICY "Service role can manage all users" ON public.users
135
+ FOR ALL USING (auth.role() = 'service_role');
136
+
137
+ -- User sessions policies
138
+ CREATE POLICY "Users can view own sessions" ON public.user_sessions
139
+ FOR SELECT USING (auth.uid() = user_id);
140
+
141
+ CREATE POLICY "Service role can manage all sessions" ON public.user_sessions
142
+ FOR ALL USING (auth.role() = 'service_role');
143
+
144
+ -- Narrator extractions policies
145
+ CREATE POLICY "Users can view own extractions" ON public.narrator_extractions
146
+ FOR SELECT USING (auth.uid() = user_id);
147
+
148
+ CREATE POLICY "Users can insert own extractions" ON public.narrator_extractions
149
+ FOR INSERT WITH CHECK (auth.uid() = user_id);
150
+
151
+ CREATE POLICY "Service role can manage all extractions" ON public.narrator_extractions
152
+ FOR ALL USING (auth.role() = 'service_role');
153
+
154
+ -- Narrator analyses policies
155
+ CREATE POLICY "Users can view own analyses" ON public.narrator_analyses
156
+ FOR SELECT USING (auth.uid() = user_id);
157
+
158
+ CREATE POLICY "Users can insert own analyses" ON public.narrator_analyses
159
+ FOR INSERT WITH CHECK (auth.uid() = user_id);
160
+
161
+ CREATE POLICY "Service role can manage all analyses" ON public.narrator_analyses
162
+ FOR ALL USING (auth.role() = 'service_role');
163
+
164
+ -- Grant necessary permissions
165
+ GRANT USAGE ON SCHEMA public TO anon, authenticated;
166
+ GRANT ALL ON ALL TABLES IN SCHEMA public TO anon, authenticated;
167
+ GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO anon, authenticated;
168
+
169
+ -- Create a function to automatically create user profile on signup
170
+ CREATE OR REPLACE FUNCTION public.handle_new_user()
171
+ RETURNS TRIGGER AS $$
172
+ BEGIN
173
+ INSERT INTO public.users (id, email, username, full_name)
174
+ VALUES (
175
+ NEW.id,
176
+ NEW.email,
177
+ COALESCE(NEW.raw_user_meta_data->>'username', NULL),
178
+ COALESCE(NEW.raw_user_meta_data->>'full_name', NULL)
179
+ );
180
+ RETURN NEW;
181
+ END;
182
+ $$ LANGUAGE plpgsql SECURITY DEFINER;
183
+
184
+ -- Create trigger to run the function on new user signup
185
+ CREATE TRIGGER on_auth_user_created
186
+ AFTER INSERT ON auth.users
187
+ FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
188
+
189
+ -- Create a view for user analytics
190
+ CREATE VIEW public.user_analytics AS
191
+ SELECT
192
+ u.id,
193
+ u.email,
194
+ u.role,
195
+ u.created_at,
196
+ u.last_login,
197
+ COUNT(ne.id) as total_extractions,
198
+ COUNT(na.id) as total_analyses,
199
+ COUNT(us.id) as total_sessions
200
+ FROM public.users u
201
+ LEFT JOIN public.narrator_extractions ne ON u.id = ne.user_id
202
+ LEFT JOIN public.narrator_analyses na ON u.id = na.user_id
203
+ LEFT JOIN public.user_sessions us ON u.id = us.user_id
204
+ GROUP BY u.id, u.email, u.role, u.created_at, u.last_login;
205
+
206
+ -- Grant select on the view
207
+ GRANT SELECT ON public.user_analytics TO authenticated;
sql/sample_data.sql ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Sample data for testing SanadCheck LLM API
2
+ -- Execute after creating tables
3
+
4
+ -- Insert sample users (these will be created through the auth system)
5
+ -- This is just for reference - actual users will be created via the API
6
+
7
+ -- Sample narrator extractions for testing
8
+ INSERT INTO public.narrator_extractions (
9
+ id,
10
+ user_id,
11
+ hadith_text,
12
+ extracted_narrators,
13
+ sanad_chain,
14
+ success,
15
+ processing_time_ms,
16
+ created_at
17
+ ) VALUES
18
+ -- Note: Replace with actual user UUIDs after creating users through the API
19
+ -- (
20
+ -- uuid_generate_v4(),
21
+ -- 'user-uuid-here',
22
+ -- 'حدثنا محمد بن إسماعيل قال حدثنا عبد الله بن موسى قال أخبرنا إسرائيل عن أبي إسحاق عن البراء قال...',
23
+ -- ARRAY['محمد بن إسماعيل', 'عبد الله بن موسى', 'إسرائيل', 'أبو إسحاق', 'البراء'],
24
+ -- 'محمد بن إسماعيل ← عبد الله بن موسى ← إسرائيل ← أبي إسحاق ← البراء',
25
+ -- true,
26
+ -- 1500,
27
+ -- NOW() - INTERVAL '1 day'
28
+ -- );
29
+
30
+ -- Create some utility functions for data analysis
31
+
32
+ -- Function to get extraction statistics
33
+ CREATE OR REPLACE FUNCTION public.get_extraction_stats()
34
+ RETURNS TABLE (
35
+ total_extractions BIGINT,
36
+ successful_extractions BIGINT,
37
+ failed_extractions BIGINT,
38
+ success_rate NUMERIC,
39
+ avg_processing_time NUMERIC,
40
+ total_users BIGINT
41
+ ) AS $$
42
+ BEGIN
43
+ RETURN QUERY
44
+ SELECT
45
+ COUNT(*) as total_extractions,
46
+ COUNT(*) FILTER (WHERE success = true) as successful_extractions,
47
+ COUNT(*) FILTER (WHERE success = false) as failed_extractions,
48
+ ROUND(
49
+ (COUNT(*) FILTER (WHERE success = true)::NUMERIC / COUNT(*)::NUMERIC) * 100,
50
+ 2
51
+ ) as success_rate,
52
+ ROUND(AVG(processing_time_ms), 2) as avg_processing_time,
53
+ COUNT(DISTINCT user_id) as total_users
54
+ FROM public.narrator_extractions;
55
+ END;
56
+ $$ LANGUAGE plpgsql;
57
+
58
+ -- Function to get most analyzed narrators
59
+ CREATE OR REPLACE FUNCTION public.get_popular_narrators(limit_count INTEGER DEFAULT 10)
60
+ RETURNS TABLE (
61
+ narrator_name TEXT,
62
+ analysis_count BIGINT,
63
+ avg_reliability_grade TEXT,
64
+ most_common_grade TEXT
65
+ ) AS $$
66
+ BEGIN
67
+ RETURN QUERY
68
+ SELECT
69
+ na.narrator_name,
70
+ COUNT(*) as analysis_count,
71
+ MODE() WITHIN GROUP (ORDER BY na.reliability_grade) as most_common_grade,
72
+ ROUND(AVG(
73
+ CASE
74
+ WHEN na.reliability_grade = 'Thiqah' THEN 5
75
+ WHEN na.reliability_grade = 'Saduq' THEN 4
76
+ WHEN na.reliability_grade = 'Da''if' THEN 2
77
+ WHEN na.reliability_grade = 'Matruk' THEN 1
78
+ ELSE 0
79
+ END
80
+ ), 2)::TEXT as avg_reliability_grade
81
+ FROM public.narrator_analyses na
82
+ WHERE na.success = true
83
+ GROUP BY na.narrator_name
84
+ ORDER BY analysis_count DESC
85
+ LIMIT limit_count;
86
+ END;
87
+ $$ LANGUAGE plpgsql;
88
+
89
+ -- Function to get user activity summary
90
+ CREATE OR REPLACE FUNCTION public.get_user_activity(user_uuid UUID)
91
+ RETURNS TABLE (
92
+ total_extractions BIGINT,
93
+ successful_extractions BIGINT,
94
+ total_analyses BIGINT,
95
+ successful_analyses BIGINT,
96
+ first_activity TIMESTAMP WITH TIME ZONE,
97
+ last_activity TIMESTAMP WITH TIME ZONE,
98
+ avg_processing_time NUMERIC
99
+ ) AS $$
100
+ BEGIN
101
+ RETURN QUERY
102
+ SELECT
103
+ COUNT(ne.id) as total_extractions,
104
+ COUNT(ne.id) FILTER (WHERE ne.success = true) as successful_extractions,
105
+ COUNT(na.id) as total_analyses,
106
+ COUNT(na.id) FILTER (WHERE na.success = true) as successful_analyses,
107
+ MIN(LEAST(ne.created_at, na.created_at)) as first_activity,
108
+ MAX(GREATEST(ne.created_at, na.created_at)) as last_activity,
109
+ ROUND(AVG(COALESCE(ne.processing_time_ms, na.processing_time_ms)), 2) as avg_processing_time
110
+ FROM public.narrator_extractions ne
111
+ FULL OUTER JOIN public.narrator_analyses na ON ne.user_id = na.user_id
112
+ WHERE ne.user_id = user_uuid OR na.user_id = user_uuid;
113
+ END;
114
+ $$ LANGUAGE plpgsql;
115
+
116
+ -- Create indexes for common queries
117
+ CREATE INDEX IF NOT EXISTS idx_narrator_extractions_text_search ON public.narrator_extractions
118
+ USING gin(to_tsvector('arabic', hadith_text));
119
+
120
+ CREATE INDEX IF NOT EXISTS idx_narrator_analyses_name_search ON public.narrator_analyses
121
+ USING gin(to_tsvector('arabic', narrator_name));
122
+
123
+ -- Grant execute permissions on functions
124
+ GRANT EXECUTE ON FUNCTION public.get_extraction_stats() TO authenticated;
125
+ GRANT EXECUTE ON FUNCTION public.get_popular_narrators(INTEGER) TO authenticated;
126
+ GRANT EXECUTE ON FUNCTION public.get_user_activity(UUID) TO authenticated;
127
+
128
+ -- Create a cleanup function for old sessions
129
+ CREATE OR REPLACE FUNCTION public.cleanup_expired_sessions()
130
+ RETURNS INTEGER AS $$
131
+ DECLARE
132
+ deleted_count INTEGER;
133
+ BEGIN
134
+ -- Delete sessions that have been expired for more than 7 days
135
+ DELETE FROM public.user_sessions
136
+ WHERE expires_at < NOW() - INTERVAL '7 days';
137
+
138
+ GET DIAGNOSTICS deleted_count = ROW_COUNT;
139
+ RETURN deleted_count;
140
+ END;
141
+ $$ LANGUAGE plpgsql;
142
+
143
+ -- Grant execute permission
144
+ GRANT EXECUTE ON FUNCTION public.cleanup_expired_sessions() TO authenticated;
145
+
146
+ -- Comments for documentation
147
+ COMMENT ON TABLE public.users IS 'Extended user profiles linked to Supabase auth.users';
148
+ COMMENT ON TABLE public.user_sessions IS 'JWT session tracking for security and analytics';
149
+ COMMENT ON TABLE public.narrator_extractions IS 'Records of hadith narrator extraction requests and results';
150
+ COMMENT ON TABLE public.narrator_analyses IS 'Records of individual narrator analysis requests and results';
151
+
152
+ COMMENT ON FUNCTION public.get_extraction_stats() IS 'Returns overall statistics about extraction operations';
153
+ COMMENT ON FUNCTION public.get_popular_narrators(INTEGER) IS 'Returns most frequently analyzed narrators';
154
+ COMMENT ON FUNCTION public.get_user_activity(UUID) IS 'Returns activity summary for a specific user';
155
+ COMMENT ON FUNCTION public.cleanup_expired_sessions() IS 'Removes old expired sessions to keep the database clean';