skinmodel / API_SPECIFICATION.md
alihamzach
Update API docs with current HF deployment and new endpoints
603a537
|
Raw History Blame Contribute Delete
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