Spaces:
Sleeping
Sleeping
|
Download API_SPECIFICATION.md from alihamzach/skinmodel: direct link, hf CLI and curl.
- Browser
- Download file 47.7 kB
-
https://huggingface.co/spaces/alihamzach/skinmodel/resolve/main/API_SPECIFICATION.md
- Command line
-
hf download hf://spaces/alihamzach/skinmodel/API_SPECIFICATION.md
-
curl -L -o API_SPECIFICATION.md https://huggingface.co/spaces/alihamzach/skinmodel/resolve/main/API_SPECIFICATION.md
47.7 kB
| # Skin Disease Detection API β Full Specification | |
| > **Base URL:** `https://alihamzach-skinmodel.hf.space` | |
| > **API Prefix:** `/v1` | |
| > **Model:** EfficientNetB0 (224Γ224, 22 disease classes) | |
| > **Auth:** JWT (access + refresh tokens) | |
| > **Rate Limiting:** IP-based + user-based tiered quotas | |
| > **Database:** PostgreSQL (Neon) via SQLAlchemy async + asyncpg | |
| --- | |
| ## Table of Contents | |
| 1. [Authentication & User Management](#1-authentication--user-management) | |
| 2. [Image Scanning & Detection](#2-image-scanning--detection) | |
| 3. [Recommendations & Personalization](#3-recommendations--personalization) | |
| 4. [Monetization & Subscriptions](#4-monetization--subscriptions) | |
| 5. [Database Schema](#5-database-schema) | |
| 6. [Rate Limiting Logic](#6-rate-limiting-logic) | |
| 7. [Error Handling](#7-error-handling) | |
| 8. [Middleware & Configuration](#8-middleware--configuration) | |
| --- | |
| ## 1. Authentication & User Management | |
| ### 1.1 Signup | |
| Creates a new user account with email + password. | |
| ``` | |
| POST /auth/signup | |
| ``` | |
| **Request Body:** | |
| ```json | |
| { | |
| "email": "user@example.com", | |
| "password": "Str0ng!Pass123", | |
| "full_name": "Jane Doe", | |
| "medical_history": { | |
| "allergies": ["penicillin", "sulfa"], | |
| "chronic_conditions": ["eczema"], | |
| "current_medications": ["cetirizine"] | |
| } | |
| } | |
| ``` | |
| **Validation Rules:** | |
| - `email`: valid email format, max 255 chars, unique in system | |
| - `password`: min 8 chars, at least 1 uppercase, 1 lowercase, 1 digit, 1 special char | |
| - `full_name`: 1β100 chars, letters/spaces/hyphens only | |
| - `medical_history`: optional JSON object | |
| **Response `201 Created`:** | |
| ```json | |
| { | |
| "id": "uuid", | |
| "email": "user@example.com", | |
| "full_name": "Jane Doe", | |
| "is_verified": false, | |
| "created_at": "2026-05-17T10:30:00Z" | |
| } | |
| ``` | |
| **Error `409 Conflict`:** | |
| ```json | |
| { | |
| "detail": "An account with this email already exists." | |
| } | |
| ``` | |
| **Error `422 Unprocessable Entity`:** | |
| ```json | |
| { | |
| "detail": [ | |
| { "loc": ["body", "password"], "msg": "Password must contain at least one uppercase letter" } | |
| ] | |
| } | |
| ``` | |
| --- | |
| ### 1.2 Login | |
| Authenticates user and returns JWT tokens. | |
| ``` | |
| POST /auth/login | |
| ``` | |
| **Request Body:** | |
| ```json | |
| { | |
| "email": "user@example.com", | |
| "password": "Str0ng!Pass123" | |
| } | |
| ``` | |
| **Response `200 OK`:** | |
| ```json | |
| { | |
| "access_token": "eyJhbGciOiJIUzI1NiIs...", | |
| "refresh_token": "eyJhbGciOiJIUzI1NiIs...", | |
| "token_type": "bearer", | |
| "expires_in": 3600, | |
| "user": { | |
| "id": "uuid", | |
| "email": "user@example.com", | |
| "full_name": "Jane Doe", | |
| "subscription_plan": "free", | |
| "scans_remaining_today": 5 | |
| } | |
| } | |
| ``` | |
| **Error `401 Unauthorized`:** | |
| ```json | |
| { | |
| "detail": "Invalid email or password." | |
| } | |
| ``` | |
| **Token Specs:** | |
| | Token | Lifetime | Payload | | |
| |-------|----------|---------| | |
| | Access | 1 hour | `sub` (user_id), `exp`, `iat`, `type: "access"` | | |
| | Refresh | 30 days | `sub` (user_id), `exp`, `iat`, `type: "refresh"` | | |
| --- | |
| ### 1.3 Social Login (OAuth 2.0) | |
| Initiates or completes social login via Google or Facebook. | |
| ``` | |
| POST /auth/social/{provider} | |
| ``` | |
| **Path Parameters:** | |
| - `provider`: `"google"` | `"facebook"` | |
| **Request Body:** | |
| ```json | |
| { | |
| "access_token": "ya29.a0AfH6SMC..." | |
| } | |
| ``` | |
| **Server-Side Flow:** | |
| 1. Receive OAuth access token from client | |
| 2. Verify token with Google/Facebook tokeninfo endpoint | |
| 3. Extract email, name, profile photo from provider | |
| 4. If email exists β link social account + login | |
| 5. If email new β create user with `auth_provider` = provider + `social_id` = provider user ID | |
| **Response `200 OK`** (same shape as login response) | |
| **Error `401 Unauthorized`:** | |
| ```json | |
| { | |
| "detail": "Invalid or expired social token." | |
| } | |
| ``` | |
| --- | |
| ### 1.4 Forgot Password | |
| Initiates password reset email flow. | |
| ``` | |
| POST /auth/forgot-password | |
| ``` | |
| **Request Body:** | |
| ```json | |
| { | |
| "email": "user@example.com" | |
| } | |
| ``` | |
| **Flow:** | |
| 1. Validate email exists (silent fail if not β security best practice) | |
| 2. Generate cryptographically secure 6-digit code (or UUIDv4 token) | |
| 3. Store hashed token in `password_reset_tokens` table with 15-min expiry | |
| 4. Send email with reset link/code | |
| **Response `200 OK` (always):** | |
| ```json | |
| { | |
| "message": "If an account with that email exists, a password reset link has been sent." | |
| } | |
| ``` | |
| --- | |
| ### 1.5 Reset Password | |
| Completes password reset using token from email. | |
| ``` | |
| POST /auth/reset-password | |
| ``` | |
| **Request Body:** | |
| ```json | |
| { | |
| "token": "a1b2c3d4-e5f6-...", | |
| "new_password": "NewStr0ng!Pass456" | |
| } | |
| ``` | |
| **Validation:** | |
| - Token must exist in DB, not expired, not used | |
| - Token is marked `is_used = true` after successful reset (one-time use) | |
| - Password follows same rules as signup | |
| **Response `200 OK`:** | |
| ```json | |
| { | |
| "message": "Password has been reset successfully." | |
| } | |
| ``` | |
| **Error `400 Bad Request`:** | |
| ```json | |
| { | |
| "detail": "Invalid or expired reset token." | |
| } | |
| ``` | |
| --- | |
| ### 1.6 Change Password (Authenticated) | |
| ``` | |
| PUT /auth/change-password | |
| ``` | |
| **Headers:** `Authorization: Bearer <access_token>` | |
| **Request Body:** | |
| ```json | |
| { | |
| "current_password": "OldPass123!", | |
| "new_password": "NewStr0ng!Pass456" | |
| } | |
| ``` | |
| **Response `200 OK`:** | |
| ```json | |
| { | |
| "message": "Password changed successfully." | |
| } | |
| ``` | |
| **Error `401 Unauthorized`:** | |
| ```json | |
| { | |
| "detail": "Current password is incorrect." | |
| } | |
| ``` | |
| --- | |
| ### 1.7 Refresh Token | |
| ``` | |
| POST /auth/refresh | |
| ``` | |
| **Request Body:** | |
| ```json | |
| { | |
| "refresh_token": "eyJhbGciOiJIUzI1NiIs..." | |
| } | |
| ``` | |
| **Response `200 OK`:** | |
| ```json | |
| { | |
| "access_token": "eyJhbGciOiJIUzI1NiIs...", | |
| "expires_in": 3600 | |
| } | |
| ``` | |
| --- | |
| ### 1.8 Update Profile (Authenticated) | |
| ``` | |
| PUT /auth/profile | |
| ``` | |
| **Headers:** `Authorization: Bearer <access_token>` | |
| **Request Body:** | |
| ```json | |
| { | |
| "full_name": "Jane Smith", | |
| "profile_photo": "data:image/png;base64,iVBOR...", | |
| "medical_history": { | |
| "allergies": ["penicillin"], | |
| "chronic_conditions": ["eczema", "psoriasis"], | |
| "current_medications": ["diphenhydramine"], | |
| "skin_type": "oily", | |
| "known_sensitivities": ["fragrance", "parabens"] | |
| } | |
| } | |
| ``` | |
| **Response `200 OK`:** | |
| ```json | |
| { | |
| "id": "uuid", | |
| "email": "user@example.com", | |
| "full_name": "Jane Smith", | |
| "profile_photo_url": "https://cdn.skindetect.com/users/uuid/photo.jpg", | |
| "medical_history": { ... }, | |
| "updated_at": "2026-05-17T11:00:00Z" | |
| } | |
| ``` | |
| --- | |
| ## 2. Image Scanning & Detection | |
| ### 2.1 Scan Image | |
| Upload a skin image for AI-based disease detection. | |
| ``` | |
| POST /scans | |
| ``` | |
| **Unauthenticated users:** IP-based rate limit of **3 total scans per IP**. | |
| **Authenticated users:** Daily limit based on subscription plan (default: **5/day** for free tier). | |
| **Headers:** `Authorization: Bearer <access_token>` *(optional for unauthenticated)* | |
| **Request Body** (`multipart/form-data`): | |
| | Field | Type | Required | Description | | |
| |-------|------|----------|-------------| | |
| | `image` | file | yes | JPEG or PNG, max 10MB, min 224Γ224px | | |
| | `location` | string | no | Lat,lng for geo-recommendations (e.g. `"40.7128,-74.0060"`) | | |
| **Processing Pipeline:** | |
| 1. Validate file type and size | |
| 2. Check rate limits (IP for unauth, user quota for auth) | |
| 3. Upload image to CDN (S3/GCS) with UUID filename | |
| 4. Preprocess: resize to 224Γ224, normalize to [0,1] | |
| 5. Run model inference β softmax probabilities over 22 classes | |
| 6. Apply confidence threshold (default: 15%) to filter low-confidence results | |
| 7. Log scan record in database | |
| 8. Increment quota counters | |
| 9. Return results | |
| **Response `200 OK`:** | |
| ```json | |
| { | |
| "items": [ | |
| { | |
| "scan_id": "uuid", | |
| "title": "skin_photo.jpg", | |
| "image_url": "https://cdn.skindetect.com/scans/uuid/thumb.jpg", | |
| "primary_detection": { | |
| "disease": "Eczema", | |
| "confidence": 87.32, | |
| "urgency_level": "consult_recommended" | |
| }, | |
| "all_results": [ | |
| { "disease": "Eczema", "confidence": 87.32, "severity": "moderate" }, | |
| { "disease": "Psoriasis", "confidence": 8.15 }, | |
| { "disease": "Acne", "confidence": 2.41 } | |
| ], | |
| "created_at": "2026-05-17T10:30:00Z" | |
| } | |
| ], | |
| "total": 42, | |
| "page": 1, | |
| "page_size": 20, | |
| "total_pages": 3 | |
| } | |
| ``` | |
| **Error `401 Unauthorized`:** | |
| ```json | |
| { | |
| "detail": "Authentication required to view scan history." | |
| } | |
| ``` | |
| --- | |
| ### 2.3 Get Single Scan Detail (Authenticated) | |
| ``` | |
| GET /scans/{scan_id} | |
| ``` | |
| **Headers:** `Authorization: Bearer <access_token>` | |
| **Response `200 OK`:** Full scan result (same shape as scan response) | |
| **Error `404 Not Found`:** | |
| ```json | |
| { | |
| "detail": "Scan not found." | |
| } | |
| ``` | |
| --- | |
| ### 2.4 Rename Scan (Authenticated) | |
| Updates the title of an existing scan. | |
| ``` | |
| PATCH /v1/scans/{scan_id} | |
| ``` | |
| **Headers:** `Authorization: Bearer <access_token>` | |
| **Request Body:** | |
| ```json | |
| { | |
| "title": "My updated scan name" | |
| } | |
| ``` | |
| **Response `200 OK`:** | |
| ```json | |
| { | |
| "scan_id": "uuid", | |
| "title": "My updated scan name" | |
| } | |
| ``` | |
| **Error `404 Not Found`:** | |
| ```json | |
| { | |
| "detail": "Scan not found." | |
| } | |
| ``` | |
| --- | |
| ### 2.5 Delete Scan (Authenticated) | |
| Permanently deletes a scan record. | |
| ``` | |
| DELETE /v1/scans/{scan_id} | |
| ``` | |
| **Headers:** `Authorization: Bearer <access_token>` | |
| **Response `204 No Content`** (no body) | |
| **Error `404 Not Found`:** | |
| ```json | |
| { | |
| "detail": "Scan not found." | |
| } | |
| ``` | |
| --- | |
| ## 3. Recommendations & Personalization | |
| ### 3.1 Dermatologist Recommendations | |
| Returns a list of dermatologists ranked by proximity to the user's IP location. | |
| ``` | |
| GET /recommendations/dermatologists | |
| ``` | |
| **Headers:** `Authorization: Bearer <access_token>` *(optional β wider results if authenticated)* | |
| **Query Parameters:** | |
| | Parameter | Type | Required | Default | Description | | |
| |-----------|------|----------|---------|-------------| | |
| | `lat` | float | no | IP geo-lookup | Latitude | | |
| | `lng` | float | no | IP geo-lookup | Longitude | | |
| | `radius_km` | int | no | 50 | Search radius | | |
| | `specialization` | string | no | β | Filter by specialty (e.g. "pediatric_dermatology") | | |
| | `page` | int | no | 1 | β | | |
| | `page_size` | int | no | 20 | β | | |
| **Response `200 OK`:** | |
| ```json | |
| { | |
| "items": [ | |
| { | |
| "id": "uuid", | |
| "name": "Dr. Sarah Johnson", | |
| "specialization": ["general_dermatology", "pediatric_dermatology"], | |
| "address": "123 Medical Plaza, Suite 200, New York, NY 10001", | |
| "phone": "+1-212-555-0198", | |
| "email": "dr.johnson@dermclinic.com", | |
| "website": "https://dermclinic.com/dr-johnson", | |
| "rating": 4.8, | |
| "review_count": 124, | |
| "distance_km": 2.3, | |
| "coordinates": { "lat": 40.7580, "lng": -73.9855 }, | |
| "available_slots": ["2026-05-18T09:00", "2026-05-18T10:30"], | |
| "insurance_accepted": ["Aetna", "Cigna", "UnitedHealthcare"] | |
| } | |
| ], | |
| "user_location": { "lat": 40.7484, "lng": -73.9967, "source": "ip" }, | |
| "total": 12, | |
| "page": 1, | |
| "page_size": 20 | |
| } | |
| ``` | |
| **Location Resolution Priority:** | |
| 1. Explicit `lat`/`lng` query params (highest priority) | |
| 2. User's saved location from profile (if authenticated) | |
| 3. IP geolocation lookup (MaxMind GeoIP or similar) | |
| --- | |
| ### 3.2 Skin Care Product Recommendations | |
| Returns product/medicine recommendations. May include affiliate links. | |
| ``` | |
| GET /recommendations/products | |
| ``` | |
| **Headers:** `Authorization: Bearer <access_token>` *(optional)* | |
| **Query Parameters:** | |
| | Parameter | Type | Required | Default | Description | | |
| |-----------|------|----------|---------|-------------| | |
| | `condition` | string | no | β | Target condition (e.g. "Eczema") | | |
| | `product_type` | string | no | β | `medicine` \| `moisturizer` \| `cleanser` \| `sunscreen` \| `treatment` | | |
| | `lat` | float | no | IP geo | For pharmacy availability | | |
| | `lng` | float | no | IP geo | β | | |
| | `page` | int | no | 1 | β | | |
| | `page_size` | int | no | 20 | β | | |
| **Response `200 OK`:** | |
| ```json | |
| { | |
| "items": [ | |
| { | |
| "id": "uuid", | |
| "name": "Cetaphil Moisturizing Cream", | |
| "brand": "Cetaphil", | |
| "category": "moisturizer", | |
| "type": "over_the_counter", | |
| "description": "Non-greasy, fragrance-free moisturizer for sensitive skin.", | |
| "key_ingredients": ["glycerin", "petrolatum", "dimethicone"], | |
| "recommended_for": ["Eczema", "Psoriasis", "dry_skin"], | |
| "average_rating": 4.6, | |
| "price": { | |
| "amount": 14.99, | |
| "currency": "USD", | |
| "retailer": "Walmart" | |
| }, | |
| "affiliate_link": "https://www.amazon.com/dp/B001B4...?tag=skindetect-20", | |
| "image_url": "https://cdn.skindetect.com/products/uuid/image.jpg", | |
| "medical_rating": 4.5, | |
| "side_effects": ["rare: mild irritation"], | |
| "prescription_required": false | |
| } | |
| ], | |
| "total": 8, | |
| "page": 1, | |
| "page_size": 20 | |
| } | |
| ``` | |
| **Affiliate Link Strategy:** | |
| - Append tracking tag `?tag=skindetect-{source}-{user_id_hash}` to all product links | |
| - Link to Amazon Pharmacy, Walmart, CVS, Walgreens affiliate programs | |
| - Track clicks via redirect service (`/r/{product_id}`) for analytics | |
| --- | |
| ### 3.3 Clinic Recommendations | |
| Returns nearby skin clinics/hospitals. | |
| ``` | |
| GET /recommendations/clinics | |
| ``` | |
| **Headers:** `Authorization: Bearer <access_token>` *(optional)* | |
| **Query Parameters:** (same location params as dermatologists) | |
| **Response `200 OK`:** | |
| ```json | |
| { | |
| "items": [ | |
| { | |
| "id": "uuid", | |
| "name": "Manhattan Dermatology Center", | |
| "type": "clinic", | |
| "address": "500 Fifth Avenue, Floor 15, New York, NY 10110", | |
| "phone": "+1-212-555-0246", | |
| "distance_km": 0.8, | |
| "rating": 4.5, | |
| "review_count": 89, | |
| "services": ["general_dermatology", "mohs_surgery", "cosmetic"], | |
| "open_hours": { | |
| "monday": "08:00-18:00", | |
| "tuesday": "08:00-18:00", | |
| "wednesday": "08:00-20:00", | |
| "thursday": "08:00-18:00", | |
| "friday": "08:00-16:00", | |
| "saturday": "09:00-13:00", | |
| "sunday": "closed" | |
| }, | |
| "accepts_insurance": true, | |
| "walkins_accepted": false, | |
| "coordinates": { "lat": 40.7527, "lng": -73.9822 } | |
| } | |
| ], | |
| "total": 5, | |
| "page": 1, | |
| "page_size": 20 | |
| } | |
| ``` | |
| --- | |
| ## 4. Monetization & Subscriptions | |
| ### 4.1 List Subscription Plans | |
| ``` | |
| GET /subscriptions/plans | |
| ``` | |
| **Response `200 OK`:** | |
| ```json | |
| { | |
| "plans": [ | |
| { | |
| "id": "plan_free", | |
| "name": "Free", | |
| "price_cents": 0, | |
| "currency": "USD", | |
| "billing_interval": "month", | |
| "features": { | |
| "daily_scans": 5, | |
| "scan_history_days": 30, | |
| "detailed_results": true, | |
| "product_recommendations": true, | |
| "dermatologist_referrals": false, | |
| "priority_support": false, | |
| "data_export": false | |
| } | |
| }, | |
| { | |
| "id": "plan_basic", | |
| "name": "Basic", | |
| "price_cents": 499, | |
| "currency": "USD", | |
| "billing_interval": "month", | |
| "features": { | |
| "daily_scans": 20, | |
| "scan_history_days": 365, | |
| "detailed_results": true, | |
| "product_recommendations": true, | |
| "dermatologist_referrals": true, | |
| "priority_support": false, | |
| "data_export": false | |
| }, | |
| "stripe_price_id": "price_1ABC2Def3Ghi4Jkl" | |
| }, | |
| { | |
| "id": "plan_premium", | |
| "name": "Premium", | |
| "price_cents": 999, | |
| "currency": "USD", | |
| "billing_interval": "month", | |
| "features": { | |
| "daily_scans": 100, | |
| "scan_history_days": null, | |
| "detailed_results": true, | |
| "product_recommendations": true, | |
| "dermatologist_referrals": true, | |
| "priority_support": true, | |
| "data_export": true | |
| }, | |
| "stripe_price_id": "price_5Mno6Pqr7Stu8Vwx" | |
| } | |
| ] | |
| } | |
| ``` | |
| --- | |
| ### 4.2 Create Checkout Session | |
| Initiates Stripe Checkout Session for subscription purchase. | |
| ``` | |
| POST /subscriptions/create-checkout-session | |
| ``` | |
| **Headers:** `Authorization: Bearer <access_token>` | |
| **Request Body:** | |
| ```json | |
| { | |
| "plan_id": "plan_basic", | |
| "success_url": "https://skindetect.com/subscriptions/success?session_id={CHECKOUT_SESSION_ID}", | |
| "cancel_url": "https://skindetect.com/subscriptions/cancel" | |
| } | |
| ``` | |
| **Response `200 OK`:** | |
| ```json | |
| { | |
| "checkout_url": "https://checkout.stripe.com/c/pay/cs_test_..." | |
| } | |
| ``` | |
| **Error `400 Bad Request`:** | |
| ```json | |
| { | |
| "detail": "Invalid plan ID." | |
| } | |
| ``` | |
| --- | |
| ### 4.3 Stripe Webhook | |
| Receives Stripe webhook events for subscription lifecycle management. | |
| ``` | |
| POST /subscriptions/webhook | |
| ``` | |
| **Headers:** `Stripe-Signature: <webhook_signature>` | |
| **No auth** (verified via Stripe signature). | |
| **Events Handled:** | |
| | Event | Action | | |
| |-------|--------| | |
| | `checkout.session.completed` | Activate subscription, set end_date | | |
| | `invoice.paid` | Renew subscription period | | |
| | `invoice.payment_failed` | Send payment failure email, mark subscription past_due | | |
| | `customer.subscription.updated` | Sync plan changes | | |
| | `customer.subscription.deleted` | Cancel subscription, revert to free plan | | |
| **Response `200 OK`:** | |
| ```json | |
| { | |
| "received": true | |
| } | |
| ``` | |
| --- | |
| ### 4.4 Get My Subscription | |
| Returns the authenticated user's current subscription details. | |
| ``` | |
| GET /subscriptions/my | |
| ``` | |
| **Headers:** `Authorization: Bearer <access_token>` | |
| **Response `200 OK`:** | |
| ```json | |
| { | |
| "user_id": "uuid", | |
| "plan": { | |
| "id": "plan_basic", | |
| "name": "Basic", | |
| "daily_scans": 20, | |
| "features": { ... } | |
| }, | |
| "status": "active", | |
| "current_period_start": "2026-05-01T00:00:00Z", | |
| "current_period_end": "2026-06-01T00:00:00Z", | |
| "cancel_at_period_end": false, | |
| "scans_used_today": 3, | |
| "scans_remaining_today": 17, | |
| "stripe_subscription_id": "sub_1ABC2Def3Ghi4Jkl" | |
| } | |
| ``` | |
| --- | |
| ### 4.5 Cancel Subscription | |
| ``` | |
| POST /subscriptions/cancel | |
| ``` | |
| **Headers:** `Authorization: Bearer <access_token>` | |
| **Response `200 OK`:** | |
| ```json | |
| { | |
| "message": "Subscription will be cancelled at the end of the current billing period.", | |
| "cancel_at_period_end": true, | |
| "current_period_end": "2026-06-01T00:00:00Z" | |
| } | |
| ``` | |
| --- | |
| ## 5. Database Schema | |
| ### 5.1 Entity-Relationship Diagram (Textual) | |
| ``` | |
| users ββ1:1ββ user_subscriptions ββN:1ββ subscription_plans | |
| users ββ1:Nββ scan_records | |
| users ββ1:Nββ scan_daily_usage | |
| users ββ1:Nββ password_reset_tokens | |
| users ββ1:Nββ payment_transactions | |
| scan_records ββN:1ββ users (nullable) | |
| ip_scan_tracking (standalone, keyed by IP) | |
| ``` | |
| ### 5.2 Table Definitions | |
| ```sql | |
| -- ============================================================ | |
| -- USERS | |
| -- ============================================================ | |
| CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; | |
| CREATE TABLE users ( | |
| id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), | |
| email VARCHAR(255) UNIQUE NOT NULL, | |
| password_hash VARCHAR(255), | |
| full_name VARCHAR(100) NOT NULL, | |
| profile_photo_url TEXT, | |
| medical_history JSONB DEFAULT '{}', | |
| auth_provider VARCHAR(20) DEFAULT 'email' | |
| CHECK (auth_provider IN ('email', 'google', 'facebook')), | |
| social_id VARCHAR(255), | |
| is_verified BOOLEAN DEFAULT false, | |
| is_active BOOLEAN DEFAULT true, | |
| created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), | |
| updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() | |
| ); | |
| CREATE INDEX idx_users_email ON users(email); | |
| CREATE INDEX idx_users_social ON users(auth_provider, social_id); | |
| -- ============================================================ | |
| -- SUBSCRIPTION PLANS (seed data) | |
| -- ============================================================ | |
| CREATE TABLE subscription_plans ( | |
| id VARCHAR(50) PRIMARY KEY, -- 'plan_free', 'plan_basic', 'plan_premium' | |
| name VARCHAR(100) NOT NULL, | |
| price_cents INTEGER NOT NULL, | |
| currency VARCHAR(3) DEFAULT 'USD', | |
| billing_interval VARCHAR(20) DEFAULT 'month', | |
| daily_scan_limit INTEGER NOT NULL, | |
| features JSONB NOT NULL DEFAULT '{}', | |
| stripe_price_id VARCHAR(255), | |
| is_active BOOLEAN DEFAULT true, | |
| sort_order INTEGER DEFAULT 0 | |
| ); | |
| -- Seed data | |
| INSERT INTO subscription_plans (id, name, price_cents, daily_scan_limit, features, sort_order) VALUES | |
| ('plan_free', 'Free', 0, 5, '{"scan_history_days": 30, "detailed_results": true, "product_recommendations": true, "dermatologist_referrals": false, "priority_support": false, "data_export": false}', 1), | |
| ('plan_basic', 'Basic', 499, 20, '{"scan_history_days": 365, "detailed_results": true, "product_recommendations": true, "dermatologist_referrals": true, "priority_support": false, "data_export": false}', 2), | |
| ('plan_premium', 'Premium', 999, 100,'{"scan_history_days": null, "detailed_results": true, "product_recommendations": true, "dermatologist_referrals": true, "priority_support": true, "data_export": true}', 3); | |
| -- ============================================================ | |
| -- USER SUBSCRIPTIONS | |
| -- ============================================================ | |
| CREATE TABLE user_subscriptions ( | |
| id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), | |
| user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| plan_id VARCHAR(50) NOT NULL REFERENCES subscription_plans(id), | |
| status VARCHAR(20) NOT NULL DEFAULT 'active' | |
| CHECK (status IN ('active', 'past_due', 'canceled', 'expired')), | |
| current_period_start TIMESTAMP WITH TIME ZONE DEFAULT NOW(), | |
| current_period_end TIMESTAMP WITH TIME ZONE, | |
| cancel_at_period_end BOOLEAN DEFAULT false, | |
| stripe_subscription_id VARCHAR(255), | |
| stripe_customer_id VARCHAR(255), | |
| created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), | |
| updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), | |
| UNIQUE(user_id) -- one active subscription per user | |
| ); | |
| CREATE INDEX idx_user_sub_status ON user_subscriptions(user_id, status); | |
| -- ============================================================ | |
| -- SCAN RECORDS | |
| -- ============================================================ | |
| CREATE TABLE scan_records ( | |
| id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), | |
| user_id UUID REFERENCES users(id) ON DELETE SET NULL, | |
| ip_address VARCHAR(45) NOT NULL, | |
| image_url TEXT NOT NULL, | |
| thumbnail_url TEXT, | |
| original_filename VARCHAR(255), | |
| image_size_bytes INTEGER, | |
| processing_time_ms INTEGER, | |
| results JSONB NOT NULL, | |
| primary_detection JSONB, | |
| detected_conditions TEXT[], | |
| model_version VARCHAR(50), | |
| is_flagged BOOLEAN DEFAULT false, | |
| created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() | |
| ); | |
| CREATE INDEX idx_scan_user ON scan_records(user_id, created_at DESC); | |
| CREATE INDEX idx_scan_ip ON scan_records(ip_address, created_at DESC); | |
| CREATE INDEX idx_scan_conditions ON scan_records USING GIN(detected_conditions); | |
| -- ============================================================ | |
| -- SCAN DAILY USAGE (per-user quota tracking) | |
| -- ============================================================ | |
| CREATE TABLE scan_daily_usage ( | |
| id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), | |
| user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| scan_date DATE NOT NULL DEFAULT CURRENT_DATE, | |
| scan_count INTEGER NOT NULL DEFAULT 0, | |
| PRIMARY KEY (user_id, scan_date) | |
| ); | |
| -- ============================================================ | |
| -- IP SCAN TRACKING (anonymous rate limiting) | |
| -- ============================================================ | |
| CREATE TABLE ip_scan_tracking ( | |
| id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), | |
| ip_address VARCHAR(45) UNIQUE NOT NULL, | |
| scan_count INTEGER NOT NULL DEFAULT 0, | |
| first_scan_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), | |
| last_scan_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() | |
| ); | |
| -- ============================================================ | |
| -- PASSWORD RESET TOKENS | |
| -- ============================================================ | |
| CREATE TABLE password_reset_tokens ( | |
| id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), | |
| user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| token_hash VARCHAR(255) NOT NULL, | |
| expires_at TIMESTAMP WITH TIME ZONE NOT NULL, | |
| is_used BOOLEAN DEFAULT false, | |
| created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() | |
| ); | |
| CREATE INDEX idx_reset_token ON password_reset_tokens(user_id, is_used); | |
| -- ============================================================ | |
| -- PAYMENT TRANSACTIONS | |
| -- ============================================================ | |
| CREATE TABLE payment_transactions ( | |
| id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), | |
| user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| amount_cents INTEGER NOT NULL, | |
| currency VARCHAR(3) DEFAULT 'USD', | |
| payment_provider VARCHAR(50) DEFAULT 'stripe', | |
| payment_provider_id VARCHAR(255), | |
| subscription_id UUID REFERENCES user_subscriptions(id), | |
| status VARCHAR(20) NOT NULL | |
| CHECK (status IN ('pending', 'completed', 'failed', 'refunded')), | |
| metadata JSONB DEFAULT '{}', | |
| created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() | |
| ); | |
| CREATE INDEX idx_payment_user ON payment_transactions(user_id, created_at DESC); | |
| -- ============================================================ | |
| -- DOCTORS / CLINICS / PRODUCTS (for recommendations) | |
| -- ============================================================ | |
| CREATE TABLE doctors ( | |
| id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), | |
| name VARCHAR(200) NOT NULL, | |
| specializations TEXT[], | |
| address TEXT, | |
| phone VARCHAR(50), | |
| email VARCHAR(255), | |
| website TEXT, | |
| rating DECIMAL(2,1) DEFAULT 0.0, | |
| review_count INTEGER DEFAULT 0, | |
| coordinates POINT, | |
| insurance_accepted TEXT[], | |
| is_active BOOLEAN DEFAULT true, | |
| created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() | |
| ); | |
| CREATE INDEX idx_doctor_location ON doctors USING GIST(coordinates); | |
| CREATE TABLE clinics ( | |
| id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), | |
| name VARCHAR(200) NOT NULL, | |
| type VARCHAR(50) DEFAULT 'clinic', | |
| address TEXT, | |
| phone VARCHAR(50), | |
| rating DECIMAL(2,1) DEFAULT 0.0, | |
| review_count INTEGER DEFAULT 0, | |
| services TEXT[], | |
| open_hours JSONB, | |
| accepts_insurance BOOLEAN, | |
| walkins_accepted BOOLEAN, | |
| coordinates POINT, | |
| is_active BOOLEAN DEFAULT true, | |
| created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() | |
| ); | |
| CREATE INDEX idx_clinic_location ON clinics USING GIST(coordinates); | |
| CREATE TABLE products ( | |
| id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), | |
| name VARCHAR(200) NOT NULL, | |
| brand VARCHAR(100), | |
| category VARCHAR(50), | |
| type VARCHAR(50) CHECK (type IN ('over_the_counter', 'prescription')), | |
| description TEXT, | |
| key_ingredients TEXT[], | |
| recommended_for TEXT[], | |
| average_rating DECIMAL(2,1), | |
| price_amount DECIMAL(10,2), | |
| price_currency VARCHAR(3) DEFAULT 'USD', | |
| retailer VARCHAR(100), | |
| affiliate_link TEXT, | |
| image_url TEXT, | |
| prescription_required BOOLEAN DEFAULT false, | |
| is_active BOOLEAN DEFAULT true, | |
| created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() | |
| ); | |
| CREATE INDEX idx_product_condition ON products USING GIN(recommended_for); | |
| ``` | |
| --- | |
| ## 6. Rate Limiting Logic | |
| ### 6.1 Architecture Overview | |
| ``` | |
| βββββββββββββββββββ | |
| β Client Request β | |
| ββββββββββ¬βββββββββ | |
| β | |
| ββββββββββΌβββββββββ | |
| β Rate Limit β | |
| β Middleware β | |
| ββββββββββ¬βββββββββ | |
| β | |
| ββββββββββββββββΌβββββββββββββββ | |
| β β | |
| βββββββββββΌββββββββββ ββββββββββββΌβββββββββββ | |
| β Has valid JWT? β β No JWT / Invalid β | |
| β => User-based β β => IP-based β | |
| β rate limiting β β rate limiting β | |
| βββββββββββ¬βββββββββββ ββββββββββββ¬βββββββββββ | |
| β β | |
| βββββββββββΌββββββββββ ββββββββββββΌβββββββββββ | |
| β Check scan_daily β β Check ip_scan_track β | |
| β _usage table β β _ing table β | |
| β WHERE user_id=X β β WHERE ip=X β | |
| β AND date=TODAY β β β | |
| βββββββββββ¬βββββββββββ ββββββββββββ¬βββββββββββ | |
| β β | |
| β ββββββββββββββββββββββββ | |
| β β | |
| βββββΌβββββββΌββββ | |
| β Under limit? ββββNoβββ 429 Too Many Requests | |
| βββββ¬βββββββ¬ββββ | |
| βYes βYes | |
| β β | |
| βββββββββββΌβ ββββΌβββββββββ | |
| β Incrementβ β Proceed β | |
| β counter β β to scan β | |
| ββββββββββββ βββββββββββββ | |
| ``` | |
| ### 6.2 Rate Limit Rules | |
| | Scope | Limit | Window | Storage | Reset | | |
| |-------|-------|--------|---------|-------| | |
| | Anonymous (per IP) | 3 scans total | Lifetime (per IP) | `ip_scan_tracking` table | Never (manual reset) | | |
| | Free tier (per user) | 5 scans | Daily (UTC) | `scan_daily_usage` table | Midnight UTC | | |
| | Basic plan (per user) | 20 scans | Daily (UTC) | `scan_daily_usage` table | Midnight UTC | | |
| | Premium plan (per user) | 100 scans | Daily (UTC) | `scan_daily_usage` table | Midnight UTC | | |
| ### 6.3 Quota Check Algorithm (Pseudocode) | |
| ```python | |
| async def check_scan_quota(request: Request, user: User | None) -> QuotaResult: | |
| if user: | |
| # ββ Authenticated user ββββββββββββββββββββββββββββββ | |
| plan = await get_user_plan(user.id) # default: free plan | |
| daily_limit = plan.daily_scan_limit | |
| today = date.today() | |
| usage = await ScanDailyUsage.objects.get_or_create( | |
| user_id=user.id, scan_date=today, | |
| defaults={"scan_count": 0} | |
| ) | |
| if usage.scan_count >= daily_limit: | |
| raise HTTPException( | |
| status_code=429, | |
| detail={ | |
| "message": "Daily scan limit reached.", | |
| "limit": daily_limit, | |
| "plan": plan.name, | |
| "reset_at": f"{today + timedelta(days=1)}T00:00:00Z", | |
| "upgrade_url": "/subscriptions/plans" | |
| } | |
| ) | |
| usage.scan_count += 1 | |
| await usage.save() | |
| return QuotaResult(remaining=daily_limit - usage.scan_count) | |
| else: | |
| # ββ Anonymous user βββββββββββββββββββββββββββββββββββ | |
| ip = get_client_ip(request) | |
| ANON_LIMIT = 3 | |
| tracking = await ip_scan_tracking.objects.get_or_create( | |
| ip_address=ip, | |
| defaults={"scan_count": 0, "first_scan_at": now()} | |
| ) | |
| if tracking.scan_count >= ANON_LIMIT: | |
| raise HTTPException( | |
| status_code=429, | |
| detail={ | |
| "message": "Anonymous scan limit reached (3 scans). Sign up to continue.", | |
| "limit": ANON_LIMIT, | |
| "reset_at": None # never resets for anonymous | |
| } | |
| ) | |
| tracking.scan_count += 1 | |
| tracking.last_scan_at = now() | |
| await tracking.save() | |
| return QuotaResult(remaining=ANON_LIMIT - tracking.scan_count) | |
| ``` | |
| ### 6.4 IP Address Detection | |
| ```python | |
| def get_client_ip(request: Request) -> str: | |
| # Priority: X-Forwarded-For > X-Real-IP > direct remote | |
| forwarded = request.headers.get("X-Forwarded-For") | |
| if forwarded: | |
| return forwarded.split(",")[0].strip() | |
| real_ip = request.headers.get("X-Real-IP") | |
| if real_ip: | |
| return real_ip | |
| return request.client.host | |
| ``` | |
| ### 6.5 IP Geolocation (for Recommendations) | |
| ```python | |
| # Use MaxMind GeoLite2 database | |
| from geoip2.database import Reader | |
| geo_reader = Reader("data/GeoLite2-City.mmdb") | |
| async def get_location_from_ip(ip: str) -> GeoLocation: | |
| if ip in ("127.0.0.1", "::1", "localhost"): | |
| return GeoLocation(lat=40.7128, lng=-74.0060) # default fallback | |
| response = geo_reader.city(ip) | |
| return GeoLocation( | |
| lat=response.location.latitude, | |
| lng=response.location.longitude, | |
| city=response.city.name, | |
| country=response.country.name | |
| ) | |
| ``` | |
| --- | |
| ## 7. Error Handling | |
| ### 7.1 Global Error Schema | |
| All error responses follow this structure: | |
| ```json | |
| { | |
| "detail": "Human-readable error message.", | |
| "code": "ERROR_CODE", | |
| "timestamp": "2026-05-17T10:30:00Z", | |
| "path": "/v1/scans", | |
| "extra": {} | |
| } | |
| ``` | |
| ### 7.2 Standard Error Codes | |
| | HTTP Code | Code String | When | | |
| |-----------|-------------|------| | |
| | 400 | `VALIDATION_ERROR` | Invalid input (Pydantic validation) | | |
| | 400 | `INVALID_IMAGE` | Bad image format/dimensions/size | | |
| | 401 | `UNAUTHORIZED` | Missing/invalid/expired JWT | | |
| | 403 | `FORBIDDEN` | Valid JWT but insufficient permissions | | |
| | 404 | `NOT_FOUND` | Resource doesn't exist | | |
| | 409 | `CONFLICT` | Duplicate email, etc. | | |
| | 422 | `UNPROCESSABLE_ENTITY` | Request body fails schema validation | | |
| | 429 | `RATE_LIMIT_EXCEEDED` | Quota exceeded | | |
| | 500 | `INTERNAL_ERROR` | Unexpected server error | | |
| | 502 | `AI_MODEL_ERROR` | Model inference failure | | |
| | 503 | `SERVICE_UNAVAILABLE` | Model not loaded / maintenance | | |
| ### 7.3 FastAPI Exception Handler Setup | |
| ```python | |
| @app.exception_handler(HTTPException) | |
| async def http_exception_handler(request, exc): | |
| return JSONResponse( | |
| status_code=exc.status_code, | |
| content={ | |
| "detail": exc.detail, | |
| "code": ERROR_CODE_MAP.get(exc.status_code, "UNKNOWN"), | |
| "timestamp": datetime.utcnow().isoformat() + "Z", | |
| "path": request.url.path, | |
| } | |
| ) | |
| @app.exception_handler(RequestValidationError) | |
| async def validation_exception_handler(request, exc): | |
| return JSONResponse( | |
| status_code=422, | |
| content={ | |
| "detail": exc.errors(), | |
| "code": "VALIDATION_ERROR", | |
| "timestamp": datetime.utcnow().isoformat() + "Z", | |
| "path": request.url.path, | |
| } | |
| ) | |
| ``` | |
| --- | |
| ## 8. Middleware & Configuration | |
| ### 8.1 CORS Configuration | |
| ```python | |
| origins = [ | |
| "https://skindetect.com", | |
| "https://app.skindetect.com", | |
| "https://admin.skindetect.com", | |
| "http://localhost:3000", # dev | |
| "http://localhost:5173", # vite dev | |
| ] | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=origins, | |
| allow_credentials=True, | |
| allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], | |
| allow_headers=["Authorization", "Content-Type", "X-Request-ID"], | |
| expose_headers=["X-RateLimit-Remaining", "X-RateLimit-Reset"], | |
| max_age=600, | |
| ) | |
| ``` | |
| ### 8.2 Audit Logging Middleware | |
| ```python | |
| @app.middleware("http") | |
| async def audit_log_middleware(request: Request, call_next): | |
| start_time = time.perf_counter() | |
| request_id = str(uuid4()) | |
| request.state.request_id = request_id | |
| response = await call_next(request) | |
| elapsed = (time.perf_counter() - start_time) * 1000 | |
| logger.info( | |
| "audit_log", | |
| extra={ | |
| "request_id": request_id, | |
| "method": request.method, | |
| "path": request.url.path, | |
| "status_code": response.status_code, | |
| "elapsed_ms": round(elapsed, 2), | |
| "ip": get_client_ip(request), | |
| "user_agent": request.headers.get("user-agent"), | |
| "user_id": getattr(request.state, "user_id", None), | |
| } | |
| ) | |
| return response | |
| ``` | |
| ### 8.3 Security Headers Middleware | |
| ```python | |
| @app.middleware("http") | |
| async def security_headers(request: Request, call_next): | |
| response = await call_next(request) | |
| response.headers["X-Content-Type-Options"] = "nosniff" | |
| response.headers["X-Frame-Options"] = "DENY" | |
| response.headers["X-XSS-Protection"] = "1; mode=block" | |
| response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" | |
| response.headers["Cache-Control"] = "no-store" | |
| return response | |
| ``` | |
| ### 8.4 Rate Limit Response Headers | |
| Every scan response includes these headers: | |
| ``` | |
| X-RateLimit-Limit: 5 # Total daily limit | |
| X-RateLimit-Remaining: 3 # Remaining scans today | |
| X-RateLimit-Reset: 1715904000 # Unix timestamp of reset (midnight UTC) | |
| ``` | |
| --- | |
| ## 9. Complete API Route Table | |
| | Method | Route | Auth | Rate Limited | Description | | |
| |--------|-------|------|-------------|-------------| | |
| | POST | `/auth/signup` | No | No | Create account | | |
| | POST | `/auth/login` | No | 10/min per IP | Login | | |
| | POST | `/auth/social/{provider}` | No | 10/min per IP | Social login | | |
| | POST | `/auth/forgot-password` | No | 3/hr per email | Request reset | | |
| | POST | `/auth/reset-password` | No | 5/hr per IP | Complete reset | | |
| | PUT | `/auth/change-password` | Yes | No | Change password | | |
| | POST | `/auth/refresh` | No | No | Refresh JWT | | |
| | PUT | `/auth/profile` | Yes | No | Update profile | | |
| | POST | `/scans` | Optional | Yes (IP/user) | Scan image | | |
| | GET | `/scans` | Yes | 60/min | Scan history | | |
| | GET | `/scans/{id}` | Yes | 60/min | Scan detail | | |
| | PATCH | `/scans/{id}` | Yes | 60/min | Rename scan | | |
| | DELETE | `/scans/{id}` | Yes | 60/min | Delete scan | | |
| | GET | `/recommendations/dermatologists` | Optional | 30/min | Find doctors | | |
| | GET | `/recommendations/products` | Optional | 30/min | Find products | | |
| | GET | `/recommendations/clinics` | Optional | 30/min | Find clinics | | |
| | GET | `/subscriptions/plans` | No | No | List plans | | |
| | POST | `/subscriptions/create-checkout-session` | Yes | 5/min | Start checkout | | |
| | POST | `/subscriptions/webhook` | No (sig) | No | Stripe webhook | | |
| | GET | `/subscriptions/my` | Yes | 30/min | My subscription | | |
| | POST | `/subscriptions/cancel` | Yes | 5/min | Cancel subscription | | |
| | GET | `/health` | No | No | Health check | | |
| --- | |
| ## 10. Environment Variables | |
| ```bash | |
| # ββ App ββββββββββββββββββββββββββββββββββββββββββββββ | |
| APP_ENV=production | |
| APP_SECRET_KEY=<random-64-char-hex> | |
| APP_DEBUG=false | |
| API_PREFIX=/v1 | |
| # ββ Database ββββββββββββββββββββββββββββββββββββββββββ | |
| DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/skindetect | |
| # ββ JWT ββββββββββββββββββββββββββββββββββββββββββββββ | |
| JWT_ACCESS_SECRET=<random-64-char-hex> | |
| JWT_REFRESH_SECRET=<different-random-64-char-hex> | |
| JWT_ACCESS_EXPIRE_MINUTES=60 | |
| JWT_REFRESH_EXPIRE_DAYS=30 | |
| JWT_ALGORITHM=HS256 | |
| # ββ AI Model βββββββββββββββββββββββββββββββββββββββββ | |
| MODEL_PATH=best_skin_model_3.keras | |
| CLASS_NAMES_PATH=class_names.json | |
| MODEL_INPUT_SIZE=224 | |
| CONFIDENCE_THRESHOLD=15.0 | |
| # ββ Storage (CDN) ββββββββββββββββββββββββββββββββββββ | |
| CDN_PROVIDER=s3 | |
| AWS_ACCESS_KEY_ID=... | |
| AWS_SECRET_ACCESS_KEY=... | |
| AWS_S3_BUCKET=skindetect-scans | |
| AWS_REGION=us-east-1 | |
| CDN_BASE_URL=https://cdn.skindetect.com | |
| # ββ OAuth ββββββββββββββββββββββββββββββββββββββββββββ | |
| GOOGLE_CLIENT_ID=... | |
| GOOGLE_CLIENT_SECRET=... | |
| FACEBOOK_APP_ID=... | |
| FACEBOOK_APP_SECRET=... | |
| # ββ Stripe βββββββββββββββββββββββββββββββββββββββββββ | |
| STRIPE_SECRET_KEY=sk_live_... | |
| STRIPE_WEBHOOK_SECRET=whsec_... | |
| STRIPE_PRICE_FREE=price_1... | |
| STRIPE_PRICE_BASIC=price_2... | |
| STRIPE_PRICE_PREMIUM=price_3... | |
| # ββ Rate Limiting ββββββββββββββββββββββββββββββββββββ | |
| ANONYMOUS_SCAN_LIMIT=3 | |
| FREE_TIER_DAILY_SCANS=5 | |
| BASIC_PLAN_DAILY_SCANS=20 | |
| PREMIUM_PLAN_DAILY_SCANS=100 | |
| # ββ Email ββββββββββββββββββββββββββββββββββββββββββββ | |
| SMTP_HOST=smtp.sendgrid.net | |
| SMTP_PORT=587 | |
| SMTP_USER=apikey | |
| SMTP_PASSWORD=... | |
| FROM_EMAIL=noreply@skindetect.com | |
| # ββ GeoIP ββββββββββββββββββββββββββββββββββββββββββββ | |
| GEOIP_DB_PATH=data/GeoLite2-City.mmdb | |
| # ββ Affiliate ββββββββββββββββββββββββββββββββββββββββ | |
| AMAZON_AFFILIATE_TAG=skindetect-20 | |
| WALMART_AFFILIATE_TAG=skindetect-20 | |
| CVS_AFFILIATE_TAG=skindetect-20 | |
| ``` | |
| --- | |
| ## 11. Pydantic Model Definitions | |
| ```python | |
| # ββ Schemas ββββββββββββββββββββββββββββββββββββββββββββββ | |
| class SignupRequest(BaseModel): | |
| email: EmailStr | |
| password: str = Field(..., min_length=8, max_length=128) | |
| full_name: str = Field(..., min_length=1, max_length=100, pattern=r"^[a-zA-Z\s\-]+$") | |
| medical_history: Optional[dict] = None | |
| class LoginRequest(BaseModel): | |
| email: EmailStr | |
| password: str | |
| class SocialLoginRequest(BaseModel): | |
| access_token: str | |
| class ForgotPasswordRequest(BaseModel): | |
| email: EmailStr | |
| class ResetPasswordRequest(BaseModel): | |
| token: str | |
| new_password: str = Field(..., min_length=8, max_length=128) | |
| class ChangePasswordRequest(BaseModel): | |
| current_password: str | |
| new_password: str = Field(..., min_length=8, max_length=128) | |
| class RefreshTokenRequest(BaseModel): | |
| refresh_token: str | |
| class UpdateProfileRequest(BaseModel): | |
| full_name: Optional[str] = Field(None, min_length=1, max_length=100) | |
| profile_photo: Optional[str] = None # base64 | |
| medical_history: Optional[dict] = None | |
| class ScanResponse(BaseModel): | |
| scan_id: str | |
| image_url: str | |
| results: list[DiseaseResult] | |
| primary_detection: PrimaryDetection | |
| processing_time_ms: int | |
| disclaimer: str | |
| quota_remaining: int | |
| class DiseaseResult(BaseModel): | |
| disease: str | |
| scientific_name: Optional[str] = None | |
| confidence: float # 0β100 | |
| severity: Optional[str] = None | |
| description: Optional[str] = None | |
| common_symptoms: list[str] = [] | |
| urgency_level: str # low | consult_recommended | urgent | emergency | |
| class PrimaryDetection(BaseModel): | |
| disease: str | |
| confidence: float | |
| severity: Optional[str] = None | |
| urgency_level: str | |
| class PaginatedResponse(BaseModel, Generic[T]): | |
| items: list[T] | |
| total: int | |
| page: int | |
| page_size: int | |
| total_pages: int | |
| class TokenResponse(BaseModel): | |
| access_token: str | |
| refresh_token: str | |
| token_type: str = "bearer" | |
| expires_in: int | |
| user: UserSummary | |
| class UserSummary(BaseModel): | |
| id: str | |
| email: str | |
| full_name: str | |
| subscription_plan: str | |
| scans_remaining_today: int | |
| class ErrorResponse(BaseModel): | |
| detail: str | |
| code: str | |
| timestamp: str | |
| path: str | |
| extra: dict = {} | |
| ``` | |
| --- | |
| ## 12. File Structure (Recommended) | |
| ``` | |
| app/ | |
| βββ main.py # FastAPI app creation, middleware, exception handlers | |
| βββ config.py # Settings from env vars (pydantic-settings) | |
| βββ database.py # SQLAlchemy async engine + session | |
| βββ models/ # SQLAlchemy ORM models | |
| β βββ __init__.py | |
| β βββ user.py | |
| β βββ scan.py | |
| β βββ subscription.py | |
| β βββ recommendation.py | |
| βββ schemas/ # Pydantic request/response schemas | |
| β βββ __init__.py | |
| β βββ auth.py | |
| β βββ user.py | |
| β βββ scan.py | |
| β βββ subscription.py | |
| β βββ recommendation.py | |
| βββ api/ | |
| β βββ __init__.py | |
| β βββ deps.py # Dependency injection (get_db, get_current_user, etc.) | |
| β βββ v1/ | |
| β βββ __init__.py | |
| β βββ auth.py # /auth/* routes | |
| β βββ users.py # /auth/profile route | |
| β βββ scans.py # /scans/* routes | |
| β βββ recommendations.py # /recommendations/* routes | |
| β βββ subscriptions.py # /subscriptions/* routes | |
| β βββ payments.py # payment webhook | |
| βββ core/ | |
| β βββ __init__.py | |
| β βββ security.py # JWT encode/decode, password hashing | |
| β βββ rate_limiter.py # Quota check logic | |
| β βββ ip_geo.py # IP geolocation | |
| β βββ logging.py # Logger setup | |
| βββ services/ | |
| β βββ __init__.py | |
| β βββ ai_detector.py # Model loading + inference | |
| β βββ payment_gateway.py # Stripe integration | |
| β βββ email_service.py # SendGrid/SMTP | |
| β βββ recommendation_engine.py # Doctor/product/clinic lookup | |
| βββ middleware/ | |
| β βββ __init__.py | |
| β βββ rate_limit.py # Rate limit middleware | |
| βββ tests/ | |
| βββ __init__.py | |
| βββ conftest.py | |
| βββ test_auth.py | |
| βββ test_scans.py | |
| βββ test_recommendations.py | |
| βββ test_subscriptions.py | |
| ``` | |
| --- | |
| ## 13. Security Checklist | |
| - [x] Passwords hashed with bcrypt (β₯12 rounds) | |
| - [x] JWT signed with separate secrets for access/refresh tokens | |
| - [x] Refresh tokens are single-use (rotate on each refresh) | |
| - [x] Rate limiting on auth endpoints (3/hr forgot password, 10/min login) | |
| - [x] CORS restricted to specific origins | |
| - [x] Security headers via middleware | |
| - [x] Stripe webhook verified via signature, not auth token | |
| - [x] Uploaded images scanned for malware (ClamAV) | |
| - [x] Image EXIF data stripped before storage | |
| - [x] Input validation on all endpoints (Pydantic) | |
| - [x] SQL injection prevention (parameterized queries via SQLAlchemy) | |
| - [x] No secrets in code (all via env vars) | |
| - [x] Rate limit headers exposed to clients | |
| - [x] Password reset tokens are one-time-use with short expiry (15 min) | |
| - [x] Silent fail on forgot-password email lookup (don't reveal if email exists) | |
| --- | |
| ## 14. Performance Considerations | |
| | Concern | Strategy | | |
| |---------|----------| | |
| | Model inference latency | Load model at startup, warm up with dummy input, use async executor | | |
| | Image upload | Upload to CDN (S3/GCS) directly via presigned URL, process async | | |
| | Scan history queries | Composite indexes on (user_id, created_at DESC), GIN on detected_conditions | | |
| | Rate limit counters | Use Redis for production; fallback to DB for simplicity | | |
| | Geo-recommendations | PostGIS for spatial queries, cache results (5-min TTL) | | |
| | Database connections | Async SQLAlchemy with connection pooling (min 5, max 20) | | |
| | CDN | Serve scan thumbnails (256Γ256 WebP) from CDN with 1-year cache | | |
| | Monitoring | Prometheus metrics: scan latency histogram, error rate, quota exhaustion rate | | |
| --- | |
| > **Document Version:** 1.0.0 | |
| > **Last Updated:** 2026-05-17 | |
| > **Author:** FastAPI Backend Architecture Team | |