# 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 ` **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 ` **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 ` *(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 ` **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 ` **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 ` **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 ` *(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 ` *(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 ` *(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 ` **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: ` **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 ` **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 ` **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= APP_DEBUG=false API_PREFIX=/v1 # ── Database ────────────────────────────────────────── DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/skindetect # ── JWT ────────────────────────────────────────────── JWT_ACCESS_SECRET= JWT_REFRESH_SECRET= 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