Arslan1997 commited on
Commit
17847d4
·
0 Parent(s):

made it faster and added color picker

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.dockerignore ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__
2
+ *.pyc
3
+ *.pyo
4
+ *.pyd
5
+ .Python
6
+ *.so
7
+ *.egg
8
+ *.egg-info
9
+ dist
10
+ build
11
+ .venv
12
+ venv
13
+ env
14
+ ENV
15
+ .env
16
+ *.db
17
+ *.sqlite
18
+ *.sqlite3
19
+ chat.db
20
+ node_modules
21
+ .git
22
+ .gitignore
23
+ README.md
24
+ *.md
25
+ .vscode
26
+ .idea
27
+ *.log
28
+ .pytest_cache
29
+ .coverage
30
+ htmlcov
.gitignore ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+ MANIFEST
23
+
24
+ # Virtual environments
25
+ venv/
26
+ env/
27
+ ENV/
28
+ .venv/
29
+ env.bak/
30
+ venv.bak/
31
+
32
+ # Database
33
+ *.db
34
+ *.sqlite
35
+ *.sqlite3
36
+ chat.db
37
+ *.db-journal
38
+
39
+ # IDE
40
+ .vscode/
41
+ .idea/
42
+ *.swp
43
+ *.swo
44
+ *~
45
+
46
+ # Environment variables
47
+ .env
48
+ .env.local
49
+ .env.*.local
50
+
51
+ # Logs
52
+ *.log
53
+ logs/
54
+
55
+ # Node modules
56
+ node_modules/
57
+ npm-debug.log*
58
+ yarn-debug.log*
59
+ yarn-error.log*
60
+
61
+ # User uploaded images (exclude, keep only logos)
62
+ images/*.png
63
+ !images/AutoForm.png
64
+ !images/favicon.png
65
+ images/*.jpg
66
+ images/*.jpeg
67
+ images/*.gif
68
+
69
+ # Testing
70
+ .pytest_cache/
71
+ .coverage
72
+ htmlcov/
73
+ .tox/
74
+
75
+ # OS
76
+ .DS_Store
77
+ Thumbs.db
78
+
79
+ # Keep these logo files
80
+ !images/AutoForm.svg
DEPLOY_HF.md ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deploying AutoForm Backend to Hugging Face Spaces
2
+
3
+ Follow these steps to deploy your backend to: https://huggingface.co/spaces/FireBird-Tech/autoform-backend
4
+
5
+ ## Step 1: Clone the Hugging Face Space Repository
6
+
7
+ ```bash
8
+ # Install Hugging Face CLI if you haven't already
9
+ curl -LsSf https://hf.co/cli/install.sh | bash
10
+
11
+ # Clone the Space repository
12
+ # You'll need an access token with write permissions
13
+ # Generate one from: https://huggingface.co/settings/tokens
14
+ git clone https://huggingface.co/spaces/FireBird-Tech/autoform-backend
15
+ cd autoform-backend
16
+ ```
17
+
18
+ Or use HTTPS with token:
19
+ ```bash
20
+ git clone https://YOUR_USERNAME:YOUR_TOKEN@huggingface.co/spaces/FireBird-Tech/autoform-backend
21
+ cd autoform-backend
22
+ ```
23
+
24
+ ## Step 2: Copy Your Backend Files
25
+
26
+ Copy the following files from your local `backend/` directory to the cloned repository:
27
+
28
+ ```bash
29
+ # From your project root, copy files to the HF Space directory
30
+ cp backend/Dockerfile autoform-backend/
31
+ cp backend/requirements.txt autoform-backend/
32
+ cp -r backend/app autoform-backend/
33
+ cp -r backend/images autoform-backend/
34
+ ```
35
+
36
+ Or manually copy:
37
+ - `backend/Dockerfile` → `autoform-backend/Dockerfile`
38
+ - `backend/requirements.txt` → `autoform-backend/requirements.txt`
39
+ - `backend/app/` → `autoform-backend/app/`
40
+ - `backend/images/` → `autoform-backend/images/`
41
+
42
+ ## Step 3: Create README.md (Optional but Recommended)
43
+
44
+ Create a `README.md` file in the Space root:
45
+
46
+ ```markdown
47
+ ---
48
+ title: AutoForm Backend API
49
+ emoji: 📝
50
+ colorFrom: purple
51
+ colorTo: pink
52
+ sdk: docker
53
+ sdk_version: 4.0.0
54
+ app_port: 7860
55
+ ---
56
+
57
+ # AutoForm Backend API
58
+
59
+ FastAPI backend for AutoForm - AI-powered form builder.
60
+
61
+ ## API Endpoints
62
+
63
+ - `/api/health` - Health check
64
+ - `/docs` - Interactive API documentation
65
+ - `/api/forms` - Form management endpoints
66
+ - `/api/public/forms/{token}` - Public form endpoints
67
+
68
+ ## Environment Variables
69
+
70
+ Set these in your Space settings:
71
+
72
+ - `DEFAULT_MODEL` - LLM model to use (e.g., `openai/gpt-4o-mini`)
73
+ - `OPENAI_API_KEY` - OpenAI API key (if using OpenAI)
74
+ - `ANTHROPIC_API_KEY` - Anthropic API key (if using Anthropic)
75
+ - `GEMINI_API_KEY` - Google Gemini API key (if using Gemini)
76
+ - `DATABASE_URL` - Database connection string (default: SQLite)
77
+ - `JWT_SECRET` - Secret for JWT tokens
78
+ - `FRONTEND_URL` - Frontend URL for CORS
79
+ - `SESSION_SECRET` - Session secret key
80
+ ```
81
+
82
+ ## Step 4: Configure Environment Variables
83
+
84
+ 1. Go to your Space settings: https://huggingface.co/spaces/FireBird-Tech/autoform-backend/settings
85
+ 2. Scroll down to "Variables and secrets"
86
+ 3. Add these environment variables:
87
+
88
+ ```
89
+ DEFAULT_MODEL=openai/gpt-4o-mini
90
+ OPENAI_API_KEY=your_openai_key
91
+ DATABASE_URL=sqlite:///./data/app.db
92
+ JWT_SECRET=your-jwt-secret-key-here
93
+ FRONTEND_URL=https://your-frontend-url.com
94
+ SESSION_SECRET=your-session-secret-here
95
+ ```
96
+
97
+ **Note:** Add API keys as **secrets** (they'll be encrypted), and other config as **variables**.
98
+
99
+ ## Step 5: Commit and Push
100
+
101
+ ```bash
102
+ cd autoform-backend
103
+
104
+ # Add all files
105
+ git add Dockerfile requirements.txt app/ images/ README.md
106
+
107
+ # Commit
108
+ git commit -m "Add AutoForm backend application"
109
+
110
+ # Push to Hugging Face
111
+ git push
112
+ ```
113
+
114
+ ## Step 6: Wait for Build
115
+
116
+ Hugging Face Spaces will automatically:
117
+ 1. Build your Docker image
118
+ 2. Deploy the container
119
+ 3. Start the application
120
+
121
+ You can monitor the build logs in the Space's "Logs" tab.
122
+
123
+ ## Step 7: Access Your API
124
+
125
+ Once deployed, your API will be available at:
126
+ - **API Base URL**: `https://firebird-tech-autoform-backend.hf.space`
127
+ - **API Docs**: `https://firebird-tech-autoform-backend.hf.space/docs`
128
+ - **Health Check**: `https://firebird-tech-autoform-backend.hf.space/api/health`
129
+
130
+ ## Troubleshooting
131
+
132
+ ### Build Fails
133
+ - Check the "Logs" tab in your Space
134
+ - Verify all files were copied correctly
135
+ - Ensure `requirements.txt` is valid
136
+
137
+ ### Application Crashes
138
+ - Check environment variables are set correctly
139
+ - Review application logs in the Space
140
+ - Verify database URL format
141
+
142
+ ### Port Issues
143
+ - Hugging Face Spaces **must** use port 7860
144
+ - The Dockerfile is already configured for this
145
+
146
+ ## Updating the Deployment
147
+
148
+ To update your deployment:
149
+
150
+ ```bash
151
+ cd autoform-backend
152
+ # Make changes or copy new files
153
+ git add .
154
+ git commit -m "Update backend"
155
+ git push
156
+ ```
157
+
158
+ Hugging Face will automatically rebuild and redeploy.
Dockerfile ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use Python 3.11 (Hugging Face supports 3.9+)
2
+ FROM python:3.11
3
+
4
+ # Create a non-root user (Hugging Face recommendation)
5
+ RUN useradd -m -u 1000 user
6
+ USER user
7
+ ENV PATH="/home/user/.local/bin:$PATH"
8
+
9
+ # Set working directory
10
+ WORKDIR /app
11
+
12
+ # Install system dependencies as root, then switch back
13
+ USER root
14
+ RUN apt-get update && apt-get install -y \
15
+ gcc \
16
+ g++ \
17
+ postgresql-client \
18
+ && rm -rf /var/lib/apt/lists/*
19
+
20
+ # Switch back to user
21
+ USER user
22
+
23
+ # Copy requirements first for better caching
24
+ COPY --chown=user:user requirements.txt requirements.txt
25
+
26
+ # Install Python dependencies
27
+ RUN pip install --no-cache-dir --upgrade pip && \
28
+ pip install --no-cache-dir --user -r requirements.txt
29
+
30
+ # Copy application code
31
+ COPY --chown=user:user app/ ./app/
32
+ COPY --chown=user:user images/ ./images/
33
+
34
+ # Create necessary directories
35
+ RUN mkdir -p /app/data
36
+
37
+ # Environment variables
38
+ ENV PYTHONUNBUFFERED=1
39
+ ENV PYTHONPATH=/app
40
+
41
+ # Expose port (Hugging Face Spaces uses port 7860)
42
+ EXPOSE 7860
43
+
44
+ # Run the application on port 7860 (Hugging Face requirement)
45
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
HF_DEPLOY_GUIDE.md ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deploying to Hugging Face Spaces - Correct Method
2
+
3
+ ## The Problem
4
+
5
+ Hugging Face Spaces rejects binary files and development files like:
6
+ - `.venv/` (virtual environment)
7
+ - `node_modules/`
8
+ - Database files (`.db`, `.sqlite`)
9
+ - User-uploaded images
10
+
11
+ ## Solution: Copy Only Required Files
12
+
13
+ **Don't push your entire backend directory!** Only copy the files needed for deployment.
14
+
15
+ ## Step-by-Step Instructions
16
+
17
+ ### 1. Clone the Space Repository
18
+
19
+ ```bash
20
+ git clone https://huggingface.co/spaces/FireBird-Tech/autoform-backend
21
+ cd autoform-backend
22
+ ```
23
+
24
+ ### 2. Copy ONLY Required Files
25
+
26
+ Copy only these files (NOT the entire backend directory):
27
+
28
+ ```bash
29
+ # From your project root
30
+ cd autoform-backend
31
+
32
+ # Copy Dockerfile
33
+ cp ../backend/Dockerfile .
34
+
35
+ # Copy requirements
36
+ cp ../backend/requirements.txt .
37
+
38
+ # Copy application code (this excludes venv, db files, etc.)
39
+ cp -r ../backend/app .
40
+
41
+ # Copy ONLY logo images (not user-uploaded content)
42
+ mkdir -p images
43
+ cp ../backend/images/AutoForm.png images/
44
+ cp ../backend/images/AutoForm.svg images/
45
+ cp ../backend/images/favicon.png images/
46
+ ```
47
+
48
+ ### 3. Create README.md
49
+
50
+ Create `README.md` in the Space root:
51
+
52
+ ```markdown
53
+ ---
54
+ title: AutoForm Backend API
55
+ emoji: 📝
56
+ colorFrom: purple
57
+ colorTo: pink
58
+ sdk: docker
59
+ sdk_version: 4.0.0
60
+ app_port: 7860
61
+ ---
62
+
63
+ # AutoForm Backend API
64
+
65
+ FastAPI backend for AutoForm.
66
+ ```
67
+
68
+ ### 4. Verify Files
69
+
70
+ Make sure you DON'T have:
71
+ - ❌ `.venv/` directory
72
+ - ❌ `venv/` directory
73
+ - ❌ `node_modules/` directory
74
+ - ❌ `*.db` files
75
+ - ❌ `chat.db`
76
+ - ❌ User-uploaded images (keep only logos)
77
+
78
+ You SHOULD have:
79
+ - ✅ `Dockerfile`
80
+ - ✅ `requirements.txt`
81
+ - ✅ `app/` directory (with Python code)
82
+ - ✅ `images/` directory (with only logo files)
83
+ - ✅ `README.md`
84
+
85
+ ### 5. Commit and Push
86
+
87
+ ```bash
88
+ git add Dockerfile requirements.txt app/ images/ README.md
89
+ git commit -m "Add AutoForm backend"
90
+ git push
91
+ ```
92
+
93
+ ## What Files to Include
94
+
95
+ **Include:**
96
+ - `Dockerfile`
97
+ - `requirements.txt`
98
+ - `app/` directory (all Python source files)
99
+ - `images/AutoForm.png`
100
+ - `images/AutoForm.svg`
101
+ - `images/favicon.png`
102
+
103
+ **Exclude:**
104
+ - `.venv/` or `venv/`
105
+ - `node_modules/`
106
+ - `*.db` files
107
+ - `chat.db`
108
+ - User-uploaded images (anything in `images/` that's not a logo)
109
+ - `__pycache__/` directories
110
+ - `.env` files
111
+
112
+ ## Quick Copy Script
113
+
114
+ Create a script to copy only the right files:
115
+
116
+ ```bash
117
+ #!/bin/bash
118
+ # copy_to_hf.sh
119
+
120
+ HF_DIR="../autoform-backend" # Adjust path as needed
121
+
122
+ # Copy essential files
123
+ cp Dockerfile "$HF_DIR/"
124
+ cp requirements.txt "$HF_DIR/"
125
+ cp -r app "$HF_DIR/"
126
+
127
+ # Copy only logo images
128
+ mkdir -p "$HF_DIR/images"
129
+ cp images/AutoForm.png "$HF_DIR/images/"
130
+ cp images/AutoForm.svg "$HF_DIR/images/"
131
+ cp images/favicon.png "$HF_DIR/images/"
132
+
133
+ echo "Files copied successfully!"
134
+ ```
README.md ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Backend (FastAPI)
2
+
3
+ Run locally:
4
+
5
+ ```bash
6
+ python -m venv .venv && .venv\Scripts\activate
7
+ pip install -r requirements.txt
8
+ uvicorn app.main:app --reload --port 8000
9
+ ```
10
+
11
+ Routes:
12
+
13
+ - `GET /api/health`
14
+ - `POST /api/auth/login`
15
+ - `POST /api/auth/logout`
16
+ - `GET /api/auth/me`
17
+ - `POST /api/chat/send`
18
+ - `POST /api/payment/create-checkout-session`
19
+ - `POST /api/payment/webhook`
20
+
21
+
README_HUGGINGFACE.md ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AutoForm Backend on Hugging Face Spaces
2
+
3
+ This Dockerfile allows you to deploy the AutoForm backend API on Hugging Face Spaces.
4
+
5
+ ## Setup Instructions
6
+
7
+ 1. **Create a new Space on Hugging Face**
8
+ - Go to https://huggingface.co/spaces
9
+ - Create a new Space
10
+ - Select **Docker** as the SDK
11
+
12
+ 2. **Configure Environment Variables**
13
+
14
+ In your Space settings, add these secrets/environment variables:
15
+
16
+ ```
17
+ DEFAULT_MODEL=openai/gpt-4o-mini # or anthropic/claude-3-5-sonnet-20241022, etc.
18
+ OPENAI_API_KEY=your_key_here # if using OpenAI
19
+ ANTHROPIC_API_KEY=your_key_here # if using Anthropic
20
+ GEMINI_API_KEY=your_key_here # if using Google Gemini
21
+ DATABASE_URL=sqlite:///./data/app.db
22
+ JWT_SECRET=your-secret-key-here
23
+ FRONTEND_URL=https://your-frontend-url.com
24
+ SESSION_SECRET=your-session-secret-here
25
+ ```
26
+
27
+ 3. **Upload Files**
28
+
29
+ Upload the following files to your Space:
30
+ - `Dockerfile` (this file should be in the root)
31
+ - `requirements.txt`
32
+ - `app/` directory (entire directory)
33
+ - `images/` directory (entire directory)
34
+
35
+ 4. **Build and Deploy**
36
+
37
+ Hugging Face Spaces will automatically build and deploy your Docker container.
38
+
39
+ ## Port Configuration
40
+
41
+ Hugging Face Spaces automatically sets the `PORT` environment variable. The Dockerfile is configured to use port 7860 by default, which is the standard port for Hugging Face Spaces.
42
+
43
+ ## Database
44
+
45
+ By default, the app uses SQLite stored in `/app/data/app.db`. For production, you may want to use PostgreSQL by setting the `DATABASE_URL` environment variable.
46
+
47
+ ## Health Check
48
+
49
+ The container includes a health check endpoint at `/api/health` that Hugging Face Spaces can use to monitor your application.
50
+
51
+ ## API Documentation
52
+
53
+ Once deployed, you can access:
54
+ - API docs: `https://your-space.hf.space/docs`
55
+ - Health check: `https://your-space.hf.space/api/health`
56
+
57
+ ## Notes
58
+
59
+ - The application runs on port 7860 (Hugging Face standard)
60
+ - Database migrations run automatically on startup (`AUTO_MIGRATE=1`)
61
+ - CORS is configured based on the `FRONTEND_URL` environment variable
62
+ - All logs are output to stdout/stderr for Hugging Face monitoring
STRIPE_SETUP.md ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stripe Subscription System Setup Guide
2
+
3
+ This guide will help you set up Stripe for the AutoDash subscription system.
4
+
5
+ ## Overview
6
+
7
+ The AutoDash subscription system includes three tiers:
8
+ - **Free Tier**: $0/month, 25 credits
9
+ - **Pro Tier**: $20/month, 500 credits
10
+ - **Ultra Tier**: Custom pricing, 1000 credits
11
+
12
+ ## Prerequisites
13
+
14
+ - A Stripe account (create one at https://stripe.com)
15
+ - Stripe CLI for webhook testing (optional but recommended)
16
+
17
+ ## Step 1: Create Products and Prices in Stripe
18
+
19
+ ### 1.1 Log in to Stripe Dashboard
20
+
21
+ Go to https://dashboard.stripe.com and log in to your account.
22
+
23
+ ### 1.2 Create Products
24
+
25
+ Navigate to **Products** → **Add Product** and create three products:
26
+
27
+ #### Free Tier
28
+ - **Name**: AutoDash Free
29
+ - **Description**: Free tier with 25 credits per month
30
+ - **Pricing**:
31
+ - Price: $0.00
32
+ - Billing period: Monthly
33
+ - Note: This is for tracking only, users won't be charged
34
+
35
+ #### Pro Tier
36
+ - **Name**: AutoDash Pro
37
+ - **Description**: Professional tier with 500 credits per month
38
+ - **Pricing**:
39
+ - Price: $20.00
40
+ - Billing period: Monthly (recurring)
41
+
42
+ #### Ultra Tier
43
+ - **Name**: AutoDash Ultra
44
+ - **Description**: Ultra tier with 1000 credits per month
45
+ - **Pricing**:
46
+ - Price: $29.99 (or your preferred amount)
47
+ - Billing period: Monthly (recurring)
48
+
49
+ ### 1.3 Get Price IDs
50
+
51
+ After creating each product, Stripe will assign a **Price ID** to each. They look like:
52
+ - `price_1234567890abcdef` (this is an example)
53
+
54
+ Copy these Price IDs - you'll need them for the environment variables.
55
+
56
+ ### 1.4 Get Product IDs (Optional)
57
+
58
+ You can also copy the **Product IDs** if you want to store them. They look like:
59
+ - `prod_1234567890abcdef`
60
+
61
+ ## Step 2: Get API Keys
62
+
63
+ ### 2.1 Get Secret Key
64
+
65
+ 1. Navigate to **Developers** → **API Keys**
66
+ 2. Copy your **Secret key** (starts with `sk_test_` for test mode or `sk_live_` for live mode)
67
+ 3. Keep this key secure - never commit it to version control!
68
+
69
+ ### 2.2 Get Webhook Secret
70
+
71
+ 1. Navigate to **Developers** → **Webhooks**
72
+ 2. Click **Add endpoint**
73
+ 3. Set the endpoint URL to: `https://your-domain.com/api/payment/webhook`
74
+ - For local testing, you can use the Stripe CLI (see Step 3)
75
+ 4. Select the following events to listen for:
76
+ - `checkout.session.completed`
77
+ - `customer.subscription.updated`
78
+ - `customer.subscription.deleted`
79
+ - `invoice.payment_succeeded`
80
+ - `invoice.payment_failed`
81
+ 5. After creating the endpoint, click to view it and copy the **Signing secret** (starts with `whsec_`)
82
+
83
+ ## Step 3: Configure Environment Variables
84
+
85
+ Create or update your `backend/.env` file with the following variables:
86
+
87
+ ```env
88
+ # Stripe Configuration
89
+ STRIPE_SECRET_KEY=sk_test_your_secret_key_here
90
+ STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
91
+
92
+ # Stripe Price IDs
93
+ STRIPE_FREE_PRICE_ID=price_free_tier_id
94
+ STRIPE_PRO_PRICE_ID=price_pro_tier_id
95
+ STRIPE_ULTRA_PRICE_ID=price_ultra_tier_id
96
+
97
+ # Stripe Product IDs (optional)
98
+ STRIPE_FREE_PRODUCT_ID=prod_free_tier_id
99
+ STRIPE_PRO_PRODUCT_ID=prod_pro_tier_id
100
+ STRIPE_ULTRA_PRODUCT_ID=prod_ultra_tier_id
101
+
102
+ # Frontend URL for redirects
103
+ FRONTEND_URL=http://localhost:5173
104
+ ```
105
+
106
+ ### Example format (replace with your actual values from Stripe Dashboard):
107
+
108
+ ```env
109
+ STRIPE_SECRET_KEY=sk_test_YOUR_ACTUAL_SECRET_KEY_FROM_STRIPE_DASHBOARD
110
+ STRIPE_WEBHOOK_SECRET=whsec_YOUR_ACTUAL_WEBHOOK_SECRET_FROM_STRIPE_DASHBOARD
111
+
112
+ STRIPE_FREE_PRICE_ID=price_YOUR_FREE_PRICE_ID
113
+ STRIPE_PRO_PRICE_ID=price_YOUR_PRO_PRICE_ID
114
+ STRIPE_ULTRA_PRICE_ID=price_YOUR_ULTRA_PRICE_ID
115
+
116
+ FRONTEND_URL=http://localhost:5173
117
+ ```
118
+
119
+ ## Step 4: Initialize Database and Plans
120
+
121
+ ### 4.1 Automatic Initialization (Recommended)
122
+
123
+ The application will automatically create database tables and initialize plans on startup if `AUTO_MIGRATE=1` in your `.env` file.
124
+
125
+ Just start your backend server:
126
+
127
+ ```bash
128
+ cd backend
129
+ uvicorn app.main:app --reload
130
+ ```
131
+
132
+ ### 4.2 Manual Initialization
133
+
134
+ You can also manually initialize plans using the provided script:
135
+
136
+ ```bash
137
+ cd backend
138
+ python -m app.scripts.init_plans --create-tables
139
+ ```
140
+
141
+ Options:
142
+ - `--create-tables`: Create database tables if they don't exist
143
+ - `--force`: Force update of existing plans with new values
144
+
145
+ ## Step 5: Test with Stripe CLI (Local Development)
146
+
147
+ For local testing, use the Stripe CLI to forward webhook events:
148
+
149
+ ### 5.1 Install Stripe CLI
150
+
151
+ Follow instructions at: https://stripe.com/docs/stripe-cli
152
+
153
+ ### 5.2 Login to Stripe CLI
154
+
155
+ ```bash
156
+ stripe login
157
+ ```
158
+
159
+ ### 5.3 Forward Webhooks to Local Server
160
+
161
+ ```bash
162
+ stripe listen --forward-to localhost:3001/api/payment/webhook
163
+ ```
164
+
165
+ This will give you a webhook signing secret (starts with `whsec_`). Use this in your `.env` file for local testing.
166
+
167
+ ### 5.4 Test a Payment
168
+
169
+ ```bash
170
+ stripe trigger checkout.session.completed
171
+ ```
172
+
173
+ ## Step 6: Verify Setup
174
+
175
+ ### 6.1 Check Plans Endpoint
176
+
177
+ Visit: `http://localhost:3001/api/plans`
178
+
179
+ You should see all three plans with their configurations.
180
+
181
+ ### 6.2 Check Credit Balance (Authenticated)
182
+
183
+ Create a test user by logging in via Google OAuth, then check:
184
+
185
+ `GET http://localhost:3001/api/credits/balance`
186
+
187
+ Headers:
188
+ ```
189
+ Authorization: Bearer YOUR_JWT_TOKEN
190
+ ```
191
+
192
+ ### 6.3 Test Checkout Flow
193
+
194
+ 1. Log in to your application
195
+ 2. Navigate to the subscription/pricing page
196
+ 3. Click on a plan (Pro or Ultra)
197
+ 4. Complete the checkout using Stripe's test card: `4242 4242 4242 4242`
198
+ - Use any future expiration date
199
+ - Use any 3-digit CVC
200
+ - Use any ZIP code
201
+
202
+ ## Step 7: Go Live
203
+
204
+ When ready to go live:
205
+
206
+ 1. Switch to **Live mode** in Stripe Dashboard
207
+ 2. Create new products and prices in live mode
208
+ 3. Get new live API keys (`sk_live_...`)
209
+ 4. Create a new webhook endpoint for your production URL
210
+ 5. Update your production `.env` with live keys
211
+ 6. Test thoroughly before announcing!
212
+
213
+ ## Troubleshooting
214
+
215
+ ### Webhook Events Not Received
216
+
217
+ 1. Check that your webhook endpoint is accessible from the internet
218
+ 2. Verify the webhook signing secret in your `.env` file
219
+ 3. Check Stripe Dashboard → Webhooks → Your endpoint for delivery logs
220
+ 4. For local testing, ensure Stripe CLI is running
221
+
222
+ ### Credits Not Resetting
223
+
224
+ - Check webhook logs for `invoice.payment_succeeded` events
225
+ - Verify the event is being handled correctly in logs
226
+ - Check credit transaction history via `/api/credits/history`
227
+
228
+ ### Plans Not Showing
229
+
230
+ - Run the initialization script: `python -m app.scripts.init_plans`
231
+ - Check database for subscription_plans table
232
+ - Verify AUTO_MIGRATE is enabled
233
+
234
+ ## Environment Variables Reference
235
+
236
+ | Variable | Required | Description | Example |
237
+ |----------|----------|-------------|---------|
238
+ | `STRIPE_SECRET_KEY` | Yes | Stripe API secret key | `sk_test_...` or `sk_live_...` |
239
+ | `STRIPE_WEBHOOK_SECRET` | Yes | Webhook signing secret | `whsec_...` |
240
+ | `STRIPE_FREE_PRICE_ID` | Yes | Price ID for Free tier | `price_...` |
241
+ | `STRIPE_PRO_PRICE_ID` | Yes | Price ID for Pro tier | `price_...` |
242
+ | `STRIPE_ULTRA_PRICE_ID` | Yes | Price ID for Ultra tier | `price_...` |
243
+ | `STRIPE_FREE_PRODUCT_ID` | No | Product ID for Free tier | `prod_...` |
244
+ | `STRIPE_PRO_PRODUCT_ID` | No | Product ID for Pro tier | `prod_...` |
245
+ | `STRIPE_ULTRA_PRODUCT_ID` | No | Product ID for Ultra tier | `prod_...` |
246
+ | `FRONTEND_URL` | Yes | Frontend URL for redirects | `http://localhost:5173` |
247
+ | `AUTO_MIGRATE` | No | Auto-create DB tables on startup | `1` (default) |
248
+
249
+ ## Support
250
+
251
+ For issues or questions:
252
+ 1. Check Stripe Dashboard logs
253
+ 2. Check application logs
254
+ 3. Review webhook event details in Stripe Dashboard
255
+ 4. Consult Stripe documentation: https://stripe.com/docs
256
+
257
+ ## Security Notes
258
+
259
+ ⚠️ **Important Security Reminders:**
260
+
261
+ - Never commit API keys to version control
262
+ - Use environment variables for all sensitive data
263
+ - Always verify webhook signatures
264
+ - Use HTTPS in production
265
+ - Rotate keys if compromised
266
+ - Use different keys for test/live environments
267
+ - Implement rate limiting on payment endpoints
268
+ - Monitor webhook delivery for anomalies
269
+
app/SAMPLE_DATA_README.md ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Sample Housing Dataset - Comprehensive Real Estate Analytics
2
+
3
+ ## Overview
4
+ This enhanced sample dataset contains **500 realistic property listings** with **33 features** designed to enable comprehensive real estate market analysis and insightful dashboard creation.
5
+
6
+ ## Dataset Features
7
+
8
+ ### Basic Property Information (9 columns)
9
+ - **price**: Property sale price ($361K - $5.08M)
10
+ - **property_type**: Single Family, Condo, Townhouse, or Multi-Family
11
+ - **neighborhood**: Downtown, Suburbs, Waterfront, Historic District, or Business District
12
+ - **bedrooms**: Number of bedrooms (1-6)
13
+ - **bathrooms**: Number of bathrooms (1.0-4.5)
14
+ - **sqft_living**: Interior living space in square feet (800-5,500)
15
+ - **sqft_lot**: Lot size in square feet (2,000-20,000)
16
+ - **floors**: Number of floors (1.0-3.0)
17
+ - **year_built**: Year the property was built (1950-2023)
18
+
19
+ ### Property Characteristics (7 columns)
20
+ - **year_renovated**: Year of last major renovation (0 if never renovated)
21
+ - **property_age**: Age of the property in years
22
+ - **waterfront**: Boolean - waterfront property (12% have this premium feature)
23
+ - **view_quality**: View rating from 0-4 (higher is better)
24
+ - **condition**: Overall condition rating 1-5
25
+ - **grade**: Construction quality grade 3-12 (7-8 is average)
26
+ - **price_segment**: Categorized as Budget, Mid-Range, Premium, or Luxury
27
+
28
+ ### Amenities & Features (4 columns)
29
+ - **parking_spaces**: Number of parking spaces (0-4)
30
+ - **has_pool**: Boolean - property has a swimming pool (15%)
31
+ - **has_fireplace**: Boolean - property has fireplace(s) (35%)
32
+ - **has_basement**: Boolean - property has a basement (45%)
33
+
34
+ ### Neighborhood Quality (3 columns)
35
+ - **school_rating**: School district rating (3-10, higher is better)
36
+ - **crime_index**: Neighborhood crime index (15-85, lower is better)
37
+ - **walkability_score**: Walkability score (30-100, higher is better)
38
+
39
+ ### Market Metrics (3 columns)
40
+ - **price_per_sqft**: Price per square foot (calculated)
41
+ - **days_on_market**: Days the property was listed (1-180)
42
+ - **hoa_fees**: Monthly HOA fees ($0-$800, ~40% of properties)
43
+
44
+ ### Temporal Data (4 columns)
45
+ - **sale_date**: Date of sale (2022-01-01 to 2024-01-01)
46
+ - **sale_month**: Month of sale (1-12)
47
+ - **sale_quarter**: Quarter of sale (Q1-Q4)
48
+ - **sale_year**: Year of sale (2022-2023)
49
+
50
+ ### Location Data (3 columns)
51
+ - **zipcode**: ZIP code (10 different Seattle-area ZIP codes)
52
+ - **lat**: Latitude coordinate
53
+ - **long**: Longitude coordinate
54
+
55
+ ## Key Features for Analysis
56
+
57
+ ### 1. Price Drivers
58
+ The dataset includes realistic relationships between price and features:
59
+ - **Waterfront premium**: +30% average
60
+ - **View quality**: +8% per rating point
61
+ - **Property type**: Single Family and Multi-Family command premiums
62
+ - **Neighborhood**: Waterfront (+25%) and Downtown (+15%) areas are most expensive
63
+ - **School ratings**: Better schools correlate with higher prices
64
+ - **Recent renovations**: +12% for properties renovated after 2015
65
+
66
+ ### 2. Market Segmentation
67
+ Properties are naturally segmented into:
68
+ - **Budget**: < $750K (25%)
69
+ - **Mid-Range**: $750K - $1.2M (30%)
70
+ - **Premium**: $1.2M - $1.8M (25%)
71
+ - **Luxury**: > $1.8M (20%)
72
+
73
+ ### 3. Temporal Trends
74
+ - Sales data spans 2 years (2022-2024)
75
+ - Enables quarter-over-quarter and year-over-year analysis
76
+ - Market velocity metrics through "days_on_market"
77
+
78
+ ### 4. Geographic Analysis
79
+ - 10 different ZIP codes representing diverse neighborhoods
80
+ - Lat/long coordinates for mapping and spatial analysis
81
+ - Neighborhood-level characteristics (crime, walkability, schools)
82
+
83
+ ## Suggested Analysis & Visualizations
84
+
85
+ ### 1. Price Distribution & Trends
86
+ - Price distribution by property type and neighborhood
87
+ - Price trends over time (quarterly/yearly)
88
+ - Price per square foot analysis
89
+
90
+ ### 2. Feature Impact Analysis
91
+ - Impact of bedrooms, bathrooms on price
92
+ - Premium analysis for waterfront, pool, renovations
93
+ - School rating vs. price correlation
94
+
95
+ ### 3. Market Velocity
96
+ - Days on market by price segment
97
+ - Seasonal patterns in sales activity
98
+ - HOA fees impact on marketability
99
+
100
+ ### 4. Geographic Insights
101
+ - Price heatmaps by location
102
+ - Neighborhood quality metrics visualization
103
+ - ZIP code-level market analysis
104
+
105
+ ### 5. Property Characteristics
106
+ - Condition vs. grade distribution
107
+ - Age distribution and renovation patterns
108
+ - Amenity combinations most common in different price ranges
109
+
110
+ ### 6. Comparative Analysis
111
+ - Property type performance
112
+ - Neighborhood comparisons (crime, schools, walkability)
113
+ - Market segment characteristics
114
+
115
+ ## Default Dashboard Query
116
+ The sample data comes with a pre-configured query that generates a comprehensive dashboard showcasing:
117
+ 1. KPI cards for key metrics (avg price, total listings)
118
+ 2. Price distribution by property type and neighborhood
119
+ 3. Quarterly price trends showing market evolution
120
+ 4. Feature impact analysis (bedrooms, waterfront, schools)
121
+ 5. Geographic price visualization
122
+ 6. Condition vs. grade matrix
123
+ 7. Market velocity by price segment
124
+
125
+ ## Data Quality
126
+ - **No missing values**: All records are complete
127
+ - **Realistic relationships**: Price adjustments based on actual market factors
128
+ - **Diverse distribution**: Balanced representation across categories
129
+ - **Time-series ready**: Proper date handling for temporal analysis
130
+ - **GIS-ready**: Includes coordinates for mapping
131
+
132
+ ## Use Cases
133
+ This dataset is ideal for demonstrating:
134
+ - Real estate market analysis
135
+ - Predictive pricing models
136
+ - Market segmentation strategies
137
+ - Investment opportunity identification
138
+ - Neighborhood analysis and comparison
139
+ - Temporal market trend analysis
140
+ - Geographic price pattern detection
141
+
app/core/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+
2
+
app/core/db.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from ..db import Base, engine, SessionLocal, get_db
2
+
3
+ __all__ = ["Base", "engine", "SessionLocal", "get_db"]
4
+
5
+
app/core/security.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from ..security import create_access_token, get_current_subject
2
+ from ..models import User
3
+ from ..core.db import get_db
4
+ from sqlalchemy.orm import Session
5
+ from fastapi import Depends, HTTPException, status
6
+
7
+ __all__ = ["create_access_token", "get_current_subject", "get_current_user"]
8
+
9
+
10
+ def get_current_user(
11
+ subject: str = Depends(get_current_subject),
12
+ db: Session = Depends(get_db)
13
+ ) -> User:
14
+ """
15
+ Get the current user from the JWT token.
16
+ The subject is the user's ID (as string).
17
+ """
18
+ user = None
19
+
20
+ # Try to find user by ID first (primary method since JWT sub = user.id)
21
+ try:
22
+ user_id = int(subject)
23
+ user = db.query(User).filter(User.id == user_id).first()
24
+ except (ValueError, TypeError):
25
+ # Fallback: try to find by email if subject is not numeric
26
+ user = db.query(User).filter(User.email == subject).first()
27
+
28
+ if not user:
29
+ raise HTTPException(
30
+ status_code=status.HTTP_401_UNAUTHORIZED,
31
+ detail="User not found"
32
+ )
33
+
34
+ if not user.is_active:
35
+ raise HTTPException(
36
+ status_code=status.HTTP_403_FORBIDDEN,
37
+ detail="User is inactive"
38
+ )
39
+
40
+ return user
41
+
42
+
app/db.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from sqlalchemy import create_engine
3
+ from sqlalchemy.orm import sessionmaker, DeclarativeBase
4
+
5
+
6
+ DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./app.db")
7
+
8
+
9
+ class Base(DeclarativeBase):
10
+ pass
11
+
12
+
13
+ if DATABASE_URL.startswith("sqlite"):
14
+ engine = create_engine(
15
+ DATABASE_URL,
16
+ connect_args={"check_same_thread": False},
17
+ )
18
+ else:
19
+ engine = create_engine(
20
+ DATABASE_URL,
21
+ pool_pre_ping=True, # validate connections before use to avoid stale sockets
22
+ pool_recycle=280, # recycle before Neon's ~5 min idle timeout
23
+ pool_size=5,
24
+ max_overflow=10,
25
+ )
26
+
27
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
28
+
29
+
30
+ def get_db():
31
+ db = SessionLocal()
32
+ try:
33
+ yield db
34
+ finally:
35
+ db.close()
36
+
37
+
app/main.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import logging
3
+ from dotenv import load_dotenv
4
+ import dspy
5
+ from pathlib import Path
6
+ from fastapi import FastAPI
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from starlette.middleware.sessions import SessionMiddleware
9
+
10
+ # Load environment from backend/.env BEFORE importing modules that read env
11
+ _ENV_PATH = Path(__file__).resolve().parent.parent / ".env"
12
+ load_dotenv(dotenv_path=_ENV_PATH, override=False)
13
+
14
+ logging.basicConfig(
15
+ level=logging.WARNING, # Changed from INFO to WARNING to reduce logging
16
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
17
+ )
18
+
19
+ from .core.db import Base, engine
20
+ from .routes.health import router as health_router
21
+ from .routes.auth import router as auth_router
22
+ from .routes.google import router as google_router
23
+ from .routes.payment import router as stripe_router
24
+ from .routes.export import router as export_router
25
+ from .routes.credits import router as credits_router
26
+ from .routes.plans import router as plans_router
27
+ from .routes.forms import router as forms_router
28
+ from .routes.responses import router as responses_router
29
+ from .routes.analytics import router as analytics_router
30
+ from .routes.users import router as users_router
31
+
32
+ app = FastAPI(title="Backend", version="0.1.0")
33
+
34
+ default_model = os.getenv("DEFAULT_MODEL", "").lower()
35
+ if "anthropic" in default_model:
36
+ provider = "ANTHROPIC"
37
+ elif "openai" in default_model:
38
+ provider = "OPENAI"
39
+ elif "gemini" in default_model:
40
+ provider = "GEMINI"
41
+ else:
42
+ provider = "UNKNOWN"
43
+
44
+
45
+
46
+
47
+ default_lm = dspy.LM(default_model, max_tokens=3200,api_key=os.getenv(provider+'_API_KEY'), temperature=1, cache=False)
48
+
49
+ dspy.configure(lm=default_lm)
50
+
51
+ # CORS middleware - allow frontend to access backend
52
+ frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
53
+ app.add_middleware(
54
+ CORSMiddleware,
55
+ allow_origins=[frontend_url],
56
+ allow_credentials=True,
57
+ allow_methods=["*"],
58
+ allow_headers=["*"],
59
+ )
60
+
61
+ # Session support for OAuth (Authlib requires request.session)
62
+ app.add_middleware(
63
+ SessionMiddleware,
64
+ secret_key=os.getenv("SESSION_SECRET", "change-this-session-secret"),
65
+ same_site="lax",
66
+ https_only=bool(int(os.getenv("SESSION_HTTPS_ONLY", "0"))),
67
+ )
68
+
69
+
70
+ from .schemas.auth import LoginRequest
71
+
72
+ # Optional debug route to verify OAuth env at runtime (masked)
73
+ @app.get("/api/auth/debug")
74
+ def auth_debug():
75
+ cid = os.getenv("GOOGLE_CLIENT_ID", "")
76
+ rid = os.getenv("GOOGLE_REDIRECT_URI", "")
77
+ return {
78
+ "GOOGLE_CLIENT_ID_present": bool(cid),
79
+ "GOOGLE_CLIENT_ID_prefix": cid[:10] + ("..." if cid else ""),
80
+ "GOOGLE_REDIRECT_URI": rid,
81
+ }
82
+
83
+
84
+ app.include_router(health_router)
85
+
86
+
87
+ # Auth routes
88
+ app.include_router(auth_router)
89
+
90
+ # Payment routes
91
+ app.include_router(google_router)
92
+ app.include_router(stripe_router)
93
+
94
+ # Export routes
95
+ app.include_router(export_router)
96
+
97
+ # Credits routes
98
+ app.include_router(credits_router)
99
+
100
+ # Plans routes
101
+ app.include_router(plans_router)
102
+
103
+ # Form routes
104
+ app.include_router(forms_router)
105
+ app.include_router(responses_router)
106
+ app.include_router(analytics_router)
107
+ app.include_router(users_router)
108
+
109
+ # Initialize DB
110
+ if os.getenv("AUTO_MIGRATE", "1") == "1":
111
+ Base.metadata.create_all(bind=engine)
112
+
113
+ # Initialize default subscription plans
114
+ from .services.plan_service import plan_service
115
+ from .core.db import SessionLocal
116
+ try:
117
+ db = SessionLocal()
118
+ plan_service.initialize_default_plans(db, force=False)
119
+ db.close()
120
+ logging.info("Default subscription plans initialized")
121
+ except Exception as e:
122
+ logging.error(f"Failed to initialize subscription plans: {e}")
123
+
124
+
app/managers/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+
2
+
app/managers/users.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy.orm import Session
2
+ from ..models import User
3
+
4
+
5
+ def get_or_create_user_by_email(db: Session, *, email: str, name: str | None = None, picture: str | None = None,
6
+ provider: str | None = None, provider_id: str | None = None) -> User:
7
+ user = db.query(User).filter(User.email == email).first()
8
+ if user:
9
+ return user
10
+ user = User(email=email, name=name, picture=picture, provider=provider, provider_id=provider_id)
11
+ db.add(user)
12
+ db.commit()
13
+ db.refresh(user)
14
+ return user
15
+
16
+
app/middleware/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """
2
+ Middleware package
3
+ """
4
+ from .credit_check import require_credits, CreditCheckResult
5
+
6
+ __all__ = ["require_credits", "CreditCheckResult"]
7
+
app/middleware/credit_check.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Credit check middleware for protecting routes with credit requirements
3
+ """
4
+ from typing import Callable
5
+ from fastapi import Depends, HTTPException, status
6
+ from sqlalchemy.orm import Session
7
+ from pydantic import BaseModel
8
+
9
+ from ..core.db import get_db
10
+ from ..core.security import get_current_user
11
+ from ..models import User
12
+ from ..services.credit_service import credit_service
13
+ from ..services.plan_service import plan_service
14
+
15
+
16
+ class CreditCheckResult:
17
+ """
18
+ Result of a credit check, passed to route handlers
19
+ Contains information about the user's credit status
20
+ """
21
+ def __init__(self, user: User, balance: int, cost: int, plan_name: str = None):
22
+ self.user = user
23
+ self.balance = balance
24
+ self.cost = cost
25
+ self.plan_name = plan_name
26
+
27
+
28
+ def require_credits(amount: int) -> Callable:
29
+ """
30
+ Dependency factory for checking if user has sufficient credits
31
+
32
+ Usage in routes:
33
+ @router.post("/analyze")
34
+ async def analyze_data(
35
+ credits: CreditCheckResult = Depends(require_credits(5)),
36
+ db: Session = Depends(get_db)
37
+ ):
38
+ # Your route logic here
39
+ # After successful operation, deduct credits:
40
+ credit_service.deduct_credits(db, credits.user.id, 5, "Data analysis")
41
+
42
+ Args:
43
+ amount: Number of credits required
44
+
45
+ Returns:
46
+ Dependency function that checks credits
47
+ """
48
+ def credit_checker(
49
+ current_user: User = Depends(get_current_user),
50
+ db: Session = Depends(get_db)
51
+ ) -> CreditCheckResult:
52
+ """
53
+ Check if the current user has sufficient credits
54
+
55
+ Args:
56
+ current_user: Authenticated user
57
+ db: Database session
58
+
59
+ Returns:
60
+ CreditCheckResult with user and balance info
61
+
62
+ Raises:
63
+ HTTPException: If user has insufficient credits
64
+ """
65
+ # Get user's credit balance
66
+ user_credits = credit_service.get_user_credits(db, current_user.id)
67
+
68
+ if not user_credits:
69
+ raise HTTPException(
70
+ status_code=status.HTTP_402_PAYMENT_REQUIRED,
71
+ detail={
72
+ "error": "no_credits_initialized",
73
+ "message": "Credit account not initialized. Please contact support.",
74
+ "required": amount,
75
+ "balance": 0
76
+ }
77
+ )
78
+
79
+ # Check if sufficient credits
80
+ if user_credits.balance < amount:
81
+ # Get plan name for better error message
82
+ plan_name = None
83
+ if user_credits.plan_id:
84
+ plan = plan_service.get_plan_by_id(db, user_credits.plan_id)
85
+ plan_name = plan.name if plan else None
86
+
87
+ raise HTTPException(
88
+ status_code=status.HTTP_402_PAYMENT_REQUIRED,
89
+ detail={
90
+ "error": "insufficient_credits",
91
+ "message": f"Insufficient credits. Required: {amount}, Available: {user_credits.balance}",
92
+ "required": amount,
93
+ "balance": user_credits.balance,
94
+ "plan": plan_name
95
+ }
96
+ )
97
+
98
+ # Get plan name for result
99
+ plan_name = None
100
+ if user_credits.plan_id:
101
+ plan = plan_service.get_plan_by_id(db, user_credits.plan_id)
102
+ plan_name = plan.name if plan else None
103
+
104
+ return CreditCheckResult(
105
+ user=current_user,
106
+ balance=user_credits.balance,
107
+ cost=amount,
108
+ plan_name=plan_name
109
+ )
110
+
111
+ return credit_checker
112
+
113
+
114
+ class OptionalCreditCheck:
115
+ """
116
+ Optional credit check that doesn't raise an error
117
+ Useful for routes that want to check credits but have fallback behavior
118
+ """
119
+ def __init__(self, amount: int):
120
+ self.amount = amount
121
+
122
+ def __call__(
123
+ self,
124
+ current_user: User = Depends(get_current_user),
125
+ db: Session = Depends(get_db)
126
+ ) -> dict:
127
+ """
128
+ Check credits without raising an error
129
+
130
+ Returns:
131
+ Dictionary with has_credits, balance, required, etc.
132
+ """
133
+ user_credits = credit_service.get_user_credits(db, current_user.id)
134
+
135
+ if not user_credits:
136
+ return {
137
+ "has_credits": False,
138
+ "balance": 0,
139
+ "required": self.amount,
140
+ "user_id": current_user.id
141
+ }
142
+
143
+ has_sufficient = user_credits.balance >= self.amount
144
+
145
+ # Get plan info
146
+ plan_name = None
147
+ if user_credits.plan_id:
148
+ plan = plan_service.get_plan_by_id(db, user_credits.plan_id)
149
+ plan_name = plan.name if plan else None
150
+
151
+ return {
152
+ "has_credits": has_sufficient,
153
+ "balance": user_credits.balance,
154
+ "required": self.amount,
155
+ "user_id": current_user.id,
156
+ "plan": plan_name
157
+ }
158
+
app/middleware/rate_limiter.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Rate limiting and spam protection middleware
3
+ """
4
+ from sqlalchemy.orm import Session
5
+ from typing import Optional
6
+ from datetime import datetime, timedelta
7
+ import hashlib
8
+ import json
9
+ import logging
10
+
11
+ from ..models import SubmissionRateLimit, Form
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class SubmissionRateLimiter:
17
+ """Rate limiting for form submissions"""
18
+
19
+ def __init__(self):
20
+ self.salt = "autoform_rate_limit_salt"
21
+
22
+ def check_rate_limit(
23
+ self,
24
+ db: Session,
25
+ form_id: int,
26
+ identifier: str,
27
+ window_minutes: int = 60,
28
+ max_submissions: int = 5
29
+ ) -> bool:
30
+ """
31
+ Check if submission rate limit exceeded.
32
+
33
+ Rules:
34
+ - Max 5 submissions per hour per IP for same form (configurable)
35
+ - Sliding window implementation
36
+
37
+ Args:
38
+ db: Database session
39
+ form_id: ID of the form
40
+ identifier: IP hash or device fingerprint
41
+ window_minutes: Time window in minutes
42
+ max_submissions: Maximum submissions allowed in window
43
+
44
+ Returns:
45
+ True if rate limit is OK (allowed), False if exceeded
46
+ """
47
+ try:
48
+ # Calculate window start time
49
+ window_start = datetime.utcnow() - timedelta(minutes=window_minutes)
50
+
51
+ # Check if there's an existing rate limit record
52
+ rate_limit = db.query(SubmissionRateLimit).filter(
53
+ SubmissionRateLimit.identifier == identifier,
54
+ SubmissionRateLimit.form_id == form_id,
55
+ SubmissionRateLimit.window_start >= window_start
56
+ ).first()
57
+
58
+ if rate_limit:
59
+ # Check if blocked
60
+ if rate_limit.is_blocked:
61
+ logger.warning(f"Rate limit blocked: {identifier} for form {form_id}")
62
+ return False
63
+
64
+ # Check submission count
65
+ if rate_limit.submission_count >= max_submissions:
66
+ # Block this identifier
67
+ rate_limit.is_blocked = True
68
+ db.commit()
69
+ logger.warning(f"Rate limit exceeded: {identifier} for form {form_id}")
70
+ return False
71
+
72
+ # Increment count
73
+ rate_limit.submission_count += 1
74
+ db.commit()
75
+ return True
76
+ else:
77
+ # Create new rate limit record
78
+ new_rate_limit = SubmissionRateLimit(
79
+ identifier=identifier,
80
+ form_id=form_id,
81
+ submission_count=1,
82
+ window_start=datetime.utcnow(),
83
+ is_blocked=False
84
+ )
85
+ db.add(new_rate_limit)
86
+ db.commit()
87
+ return True
88
+
89
+ except Exception as e:
90
+ logger.error(f"Error checking rate limit: {e}")
91
+ # On error, allow submission (fail open)
92
+ return True
93
+
94
+ def check_duplicate(
95
+ self,
96
+ db: Session,
97
+ form_id: int,
98
+ answers_hash: str,
99
+ window_minutes: int = 5
100
+ ) -> bool:
101
+ """
102
+ Check for duplicate submissions (same answers within time window).
103
+
104
+ Args:
105
+ db: Database session
106
+ form_id: ID of the form
107
+ answers_hash: SHA256 hash of sorted answers
108
+ window_minutes: Time window to check for duplicates
109
+
110
+ Returns:
111
+ True if duplicate found, False otherwise
112
+ """
113
+ # TODO: Implement duplicate detection
114
+ # Store hashes in rate_limits table or separate duplicate_submissions table
115
+ # For now, return False (no duplicate)
116
+ return False
117
+
118
+ def hash_identifier(self, identifier: str) -> str:
119
+ """Hash identifier (IP address) for storage"""
120
+ if not identifier:
121
+ return "unknown"
122
+
123
+ salted = f"{identifier}{self.salt}"
124
+ return hashlib.sha256(salted.encode()).hexdigest()
125
+
126
+ def hash_answers(self, answers: list) -> str:
127
+ """Hash answers for duplicate detection"""
128
+ # Sort and serialize answers for consistent hashing
129
+ sorted_answers = sorted(
130
+ answers,
131
+ key=lambda x: x.get('question_id', 0)
132
+ )
133
+
134
+ # Create string representation
135
+ answer_str = json.dumps(sorted_answers, sort_keys=True)
136
+
137
+ return hashlib.sha256(answer_str.encode()).hexdigest()
138
+
139
+ def cleanup_old_records(
140
+ self,
141
+ db: Session,
142
+ days_old: int = 7
143
+ ):
144
+ """
145
+ Clean up old rate limit records.
146
+
147
+ Args:
148
+ db: Database session
149
+ days_old: Delete records older than this many days
150
+ """
151
+ try:
152
+ cutoff_date = datetime.utcnow() - timedelta(days=days_old)
153
+
154
+ deleted_count = db.query(SubmissionRateLimit).filter(
155
+ SubmissionRateLimit.window_start < cutoff_date
156
+ ).delete()
157
+
158
+ db.commit()
159
+
160
+ if deleted_count > 0:
161
+ logger.info(f"Cleaned up {deleted_count} old rate limit records")
162
+
163
+ except Exception as e:
164
+ logger.error(f"Error cleaning up rate limit records: {e}")
165
+ db.rollback()
166
+
167
+
168
+ # Singleton instance
169
+ rate_limiter = SubmissionRateLimiter()
app/models.py ADDED
@@ -0,0 +1,541 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import String, Integer, Boolean, ForeignKey, DateTime, Text, JSON, Numeric, Enum, Float
2
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
3
+ from datetime import datetime
4
+ from decimal import Decimal
5
+ import enum
6
+ from .db import Base
7
+
8
+
9
+ class TransactionType(str, enum.Enum):
10
+ """Credit transaction types"""
11
+ RESET = "reset"
12
+ DEDUCT = "deduct"
13
+ REFUND = "refund"
14
+ ADJUSTMENT = "adjustment"
15
+
16
+
17
+ class QuestionType(str, enum.Enum):
18
+ """Form question types"""
19
+ SHORT_ANSWER = "short_answer"
20
+ LONG_ANSWER = "long_answer"
21
+ MULTIPLE_CHOICE = "multiple_choice"
22
+ CHECKBOXES = "checkboxes"
23
+ DROPDOWN = "dropdown"
24
+ MULTI_SELECT = "multi_select"
25
+ NUMBER = "number"
26
+ EMAIL = "email"
27
+ PHONE = "phone"
28
+ LINK = "link"
29
+ FILE_UPLOAD = "file_upload"
30
+ DATE = "date"
31
+ TIME = "time"
32
+ LINEAR_SCALE = "linear_scale"
33
+ MATRIX = "matrix"
34
+ RATING = "rating"
35
+ PAYMENT = "payment"
36
+ SIGNATURE = "signature"
37
+ RANKING = "ranking"
38
+ WALLET_CONNECT = "wallet_connect"
39
+
40
+
41
+ class ConditionType(str, enum.Enum):
42
+ """Conditional logic condition types"""
43
+ EQUALS = "equals"
44
+ NOT_EQUALS = "not_equals"
45
+ CONTAINS = "contains"
46
+ NOT_CONTAINS = "not_contains"
47
+ GREATER_THAN = "greater_than"
48
+ LESS_THAN = "less_than"
49
+ IS_EMPTY = "is_empty"
50
+ IS_NOT_EMPTY = "is_not_empty"
51
+
52
+
53
+ class SubmissionStatus(str, enum.Enum):
54
+ """Form submission status types"""
55
+ IN_PROGRESS = "in_progress"
56
+ PARTIAL = "partial"
57
+ COMPLETE = "complete"
58
+ ABANDONED = "abandoned"
59
+
60
+
61
+ class AnalyticsEventType(str, enum.Enum):
62
+ """Analytics event types"""
63
+ FORM_VIEWED = "form_viewed"
64
+ FORM_STARTED = "form_started"
65
+ QUESTION_VIEWED = "question_viewed"
66
+ QUESTION_ANSWERED = "question_answered"
67
+ QUESTION_SKIPPED = "question_skipped"
68
+ FORM_ABANDONED = "form_abandoned"
69
+ FORM_SUBMITTED_PARTIAL = "form_submitted_partial"
70
+ FORM_SUBMITTED_COMPLETE = "form_submitted_complete"
71
+
72
+
73
+ class User(Base):
74
+ __tablename__ = "users"
75
+
76
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
77
+ email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
78
+ name: Mapped[str | None] = mapped_column(String(255), nullable=True)
79
+ picture: Mapped[str | None] = mapped_column(String(512), nullable=True)
80
+ provider: Mapped[str | None] = mapped_column(String(50), nullable=True)
81
+ provider_id: Mapped[str | None] = mapped_column(String(255), index=True)
82
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
83
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
84
+
85
+ subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="user")
86
+ datasets: Mapped[list["Dataset"]] = relationship(back_populates="user")
87
+ credits: Mapped["UserCredits"] = relationship(back_populates="user", uselist=False)
88
+ credit_transactions: Mapped[list["CreditTransaction"]] = relationship(back_populates="user")
89
+ dashboard_queries: Mapped[list["DashboardQuery"]] = relationship(back_populates="user", cascade="all, delete-orphan")
90
+ chat_messages: Mapped[list["ChatMessage"]] = relationship(back_populates="user", cascade="all, delete-orphan")
91
+ public_dashboards: Mapped[list["PublicDashboard"]] = relationship(back_populates="user", cascade="all, delete-orphan")
92
+ forms: Mapped[list["Form"]] = relationship(back_populates="user", cascade="all, delete-orphan")
93
+ public_forms: Mapped[list["PublicForm"]] = relationship(back_populates="user", cascade="all, delete-orphan")
94
+
95
+
96
+ class Subscription(Base):
97
+ __tablename__ = "subscriptions"
98
+
99
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
100
+ user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
101
+ plan_id: Mapped[int | None] = mapped_column(ForeignKey("subscription_plans.id"), nullable=True)
102
+ status: Mapped[str] = mapped_column(String(50), default="inactive")
103
+ stripe_customer_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
104
+ stripe_subscription_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
105
+ current_period_start: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
106
+ current_period_end: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
107
+ cancel_at_period_end: Mapped[bool] = mapped_column(Boolean, default=False)
108
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
109
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
110
+
111
+ user: Mapped[User] = relationship(back_populates="subscriptions")
112
+ plan: Mapped["SubscriptionPlan"] = relationship(back_populates="subscriptions")
113
+
114
+
115
+ class Dataset(Base):
116
+ __tablename__ = "datasets"
117
+
118
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
119
+ user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
120
+ dataset_id: Mapped[str] = mapped_column(String(100), unique=True, index=True)
121
+
122
+ # Basic metadata
123
+ filename: Mapped[str] = mapped_column(String(255))
124
+ row_count: Mapped[int] = mapped_column(Integer)
125
+ column_count: Mapped[int] = mapped_column(Integer)
126
+ file_size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
127
+
128
+ # Dataset context from DSPy (rich description)
129
+ context: Mapped[str | None] = mapped_column(Text, nullable=True)
130
+ context_generated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
131
+
132
+ # Column metadata as JSON
133
+ columns_info: Mapped[dict | None] = mapped_column(JSON, nullable=True)
134
+ # Store: {"columns": [...], "dtypes": {...}, "statistics": {...}}
135
+
136
+ # Timestamps
137
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
138
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
139
+
140
+ # Status tracking
141
+ context_status: Mapped[str] = mapped_column(String(50), default="pending")
142
+ # Values: "pending", "generating", "completed", "failed"
143
+
144
+ user: Mapped[User] = relationship(back_populates="datasets")
145
+ dashboard_queries: Mapped[list["DashboardQuery"]] = relationship(back_populates="dataset", cascade="all, delete-orphan")
146
+ chat_messages: Mapped[list["ChatMessage"]] = relationship(back_populates="dataset", cascade="all, delete-orphan")
147
+ public_dashboards: Mapped[list["PublicDashboard"]] = relationship(back_populates="dataset", cascade="all, delete-orphan")
148
+
149
+
150
+ class DashboardQuery(Base):
151
+ __tablename__ = "dashboard_queries"
152
+
153
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
154
+ dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
155
+ user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
156
+
157
+ query: Mapped[str] = mapped_column(Text) # User's request
158
+ query_type: Mapped[str] = mapped_column(String(50)) # "analyze", "edit", "add"
159
+ dashboard_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
160
+ charts_data: Mapped[list[dict] | None] = mapped_column(JSON, nullable=True) # Array of chart objects with code, figure, etc.
161
+ background_color: Mapped[str | None] = mapped_column(String(7), nullable=True, default="#ffffff") # Hex color code
162
+ text_color: Mapped[str | None] = mapped_column(String(7), nullable=True, default="#1a1a1a") # Hex color code for text
163
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
164
+
165
+ dataset: Mapped["Dataset"] = relationship(back_populates="dashboard_queries")
166
+ user: Mapped["User"] = relationship(back_populates="dashboard_queries")
167
+
168
+
169
+ class ChatMessage(Base):
170
+ __tablename__ = "chat_messages"
171
+
172
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
173
+ dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
174
+ user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
175
+
176
+ role: Mapped[str] = mapped_column(String(20)) # "user", "assistant"
177
+ content: Mapped[str] = mapped_column(Text)
178
+ query_type: Mapped[str | None] = mapped_column(String(50), nullable=True) # "edit", "add", "data_analysis", "general_qa", "need_clarity", etc.
179
+ code: Mapped[str | None] = mapped_column(Text, nullable=True) # Executable code if applicable
180
+ chart_index: Mapped[int | None] = mapped_column(Integer, nullable=True) # If related to a chart
181
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
182
+
183
+ dataset: Mapped["Dataset"] = relationship(back_populates="chat_messages")
184
+ user: Mapped["User"] = relationship(back_populates="chat_messages")
185
+
186
+
187
+ class PublicDashboard(Base):
188
+ __tablename__ = "public_dashboards"
189
+
190
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
191
+ dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
192
+ user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
193
+
194
+ share_token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
195
+ figures_data: Mapped[list[dict] | None] = mapped_column(JSON, nullable=True) # Array of chart figures
196
+ dashboard_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
197
+ background_color: Mapped[str | None] = mapped_column(String(7), nullable=True, default="#ffffff") # Hex color code
198
+ text_color: Mapped[str | None] = mapped_column(String(7), nullable=True, default="#1a1a1a") # Hex color code for text
199
+ background_opacity: Mapped[float | None] = mapped_column(Float, nullable=True, default=1.0) # Background opacity
200
+ use_gradient: Mapped[bool | None] = mapped_column(Boolean, nullable=True, default=False) # Use gradient background
201
+ gradient_color_2: Mapped[str | None] = mapped_column(String(7), nullable=True, default="#e5e7eb") # Second gradient color
202
+ container_colors: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Container-specific colors
203
+ chart_colors: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Chart trace colors
204
+ chart_opacities: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Chart trace opacities
205
+ apply_to_containers: Mapped[bool | None] = mapped_column(Boolean, nullable=True, default=True) # Apply colors to all containers
206
+ is_public: Mapped[bool] = mapped_column(Boolean, default=True)
207
+ expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
208
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
209
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
210
+
211
+ dataset: Mapped["Dataset"] = relationship(back_populates="public_dashboards")
212
+ user: Mapped["User"] = relationship(back_populates="public_dashboards")
213
+
214
+
215
+ class SubscriptionPlan(Base):
216
+ __tablename__ = "subscription_plans"
217
+
218
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
219
+ name: Mapped[str] = mapped_column(String(100), unique=True, index=True)
220
+ stripe_price_id: Mapped[str | None] = mapped_column(String(255), nullable=True) # Legacy field, use stripe_price_id_monthly
221
+ stripe_price_id_monthly: Mapped[str | None] = mapped_column(String(255), nullable=True)
222
+ stripe_price_id_yearly: Mapped[str | None] = mapped_column(String(255), nullable=True)
223
+ stripe_product_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
224
+ price_monthly: Mapped[Decimal] = mapped_column(Numeric(10, 2), default=0.0)
225
+ price_yearly: Mapped[Decimal | None] = mapped_column(Numeric(10, 2), nullable=True)
226
+
227
+ # Credit configuration
228
+ credits_per_month: Mapped[int] = mapped_column(Integer, default=0)
229
+ credits_per_analyze: Mapped[int] = mapped_column(Integer, default=5)
230
+ credits_per_edit: Mapped[int] = mapped_column(Integer, default=2)
231
+
232
+ # Extensible features as JSON
233
+ features: Mapped[dict | None] = mapped_column(JSON, nullable=True)
234
+
235
+ # Plan management
236
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
237
+ sort_order: Mapped[int] = mapped_column(Integer, default=0)
238
+
239
+ # Timestamps
240
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
241
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
242
+
243
+ # Relationships
244
+ subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="plan")
245
+ user_credits: Mapped[list["UserCredits"]] = relationship(back_populates="plan")
246
+
247
+
248
+ class UserCredits(Base):
249
+ __tablename__ = "user_credits"
250
+
251
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
252
+ user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), unique=True, index=True)
253
+ plan_id: Mapped[int | None] = mapped_column(ForeignKey("subscription_plans.id"), nullable=True)
254
+ balance: Mapped[int] = mapped_column(Integer, default=0)
255
+ last_reset_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
256
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
257
+
258
+ # Relationships
259
+ user: Mapped[User] = relationship(back_populates="credits")
260
+ plan: Mapped["SubscriptionPlan"] = relationship(back_populates="user_credits")
261
+
262
+
263
+ class CreditTransaction(Base):
264
+ __tablename__ = "credit_transactions"
265
+
266
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
267
+ user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
268
+ amount: Mapped[int] = mapped_column(Integer) # Can be negative for deductions
269
+ transaction_type: Mapped[TransactionType] = mapped_column(Enum(TransactionType), index=True)
270
+ description: Mapped[str] = mapped_column(Text)
271
+ transaction_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
272
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
273
+
274
+ # Relationships
275
+ user: Mapped[User] = relationship(back_populates="credit_transactions")
276
+
277
+
278
+ class Form(Base):
279
+ """Form table for AI-generated forms"""
280
+ __tablename__ = "forms"
281
+
282
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
283
+ user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
284
+
285
+ title: Mapped[str] = mapped_column(String(255))
286
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
287
+ settings: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Theme colors, submit button text, etc.
288
+
289
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
290
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
291
+
292
+ # Relationships
293
+ user: Mapped[User] = relationship(back_populates="forms")
294
+ questions: Mapped[list["FormQuestion"]] = relationship(back_populates="form", cascade="all, delete-orphan", order_by="FormQuestion.question_order")
295
+ conditional_rules: Mapped[list["ConditionalRule"]] = relationship(back_populates="form", cascade="all, delete-orphan")
296
+ responses: Mapped[list["FormResponse"]] = relationship(back_populates="form", cascade="all, delete-orphan")
297
+ chat_messages_form: Mapped[list["ChatMessageForm"]] = relationship(back_populates="form", cascade="all, delete-orphan")
298
+ versions: Mapped[list["FormVersion"]] = relationship(back_populates="form", cascade="all, delete-orphan")
299
+ analytics_events: Mapped[list["FormAnalyticsEvent"]] = relationship(back_populates="form", cascade="all, delete-orphan")
300
+
301
+
302
+ class FormQuestion(Base):
303
+ """Form questions table"""
304
+ __tablename__ = "form_questions"
305
+
306
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
307
+ form_id: Mapped[int] = mapped_column(ForeignKey("forms.id"), index=True)
308
+
309
+ question_order: Mapped[int] = mapped_column(Integer)
310
+ question_type: Mapped[QuestionType] = mapped_column(Enum(QuestionType))
311
+ question_text: Mapped[str] = mapped_column(Text)
312
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
313
+ required: Mapped[bool] = mapped_column(Boolean, default=False)
314
+ settings: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Type-specific options (choices, validation, etc.)
315
+
316
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
317
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
318
+
319
+ # Relationships
320
+ form: Mapped[Form] = relationship(back_populates="questions")
321
+ trigger_rules: Mapped[list["ConditionalRule"]] = relationship(foreign_keys="ConditionalRule.trigger_question_id", back_populates="trigger_question", cascade="all, delete-orphan")
322
+ target_rules: Mapped[list["ConditionalRule"]] = relationship(foreign_keys="ConditionalRule.target_question_id", back_populates="target_question", cascade="all, delete-orphan")
323
+ answers: Mapped[list["ResponseAnswer"]] = relationship(back_populates="question", cascade="all, delete-orphan")
324
+ analytics_events: Mapped[list["FormAnalyticsEvent"]] = relationship(back_populates="question", cascade="all, delete-orphan")
325
+
326
+
327
+ class ConditionalRule(Base):
328
+ """Conditional logic rules for showing/hiding questions"""
329
+ __tablename__ = "conditional_rules"
330
+
331
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
332
+ form_id: Mapped[int] = mapped_column(ForeignKey("forms.id"), index=True)
333
+ trigger_question_id: Mapped[int] = mapped_column(ForeignKey("form_questions.id"), index=True)
334
+ target_question_id: Mapped[int] = mapped_column(ForeignKey("form_questions.id"), index=True)
335
+
336
+ condition_type: Mapped[ConditionType] = mapped_column(Enum(ConditionType))
337
+ condition_value: Mapped[str | None] = mapped_column(Text, nullable=True) # The value to compare against
338
+ action: Mapped[str] = mapped_column(String(20), default="show") # "show" or "hide"
339
+
340
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
341
+
342
+ # Relationships
343
+ form: Mapped[Form] = relationship(back_populates="conditional_rules")
344
+ trigger_question: Mapped[FormQuestion] = relationship(foreign_keys=[trigger_question_id], back_populates="trigger_rules")
345
+ target_question: Mapped[FormQuestion] = relationship(foreign_keys=[target_question_id], back_populates="target_rules")
346
+
347
+
348
+ class FormResponse(Base):
349
+ """Form submission responses"""
350
+ __tablename__ = "form_responses"
351
+
352
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
353
+ form_id: Mapped[int] = mapped_column(ForeignKey("forms.id"), index=True)
354
+
355
+ # Enhanced tracking fields
356
+ status: Mapped[str] = mapped_column(String(20), default="complete", index=True)
357
+ form_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
358
+ session_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
359
+
360
+ # Timestamps
361
+ submitted_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
362
+ started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
363
+ last_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, onupdate=datetime.utcnow)
364
+
365
+ # IP and geolocation
366
+ ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True) # IPv6 compatible (legacy)
367
+ ip_address_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
368
+ country: Mapped[str | None] = mapped_column(String(100), nullable=True)
369
+ city: Mapped[str | None] = mapped_column(String(100), nullable=True)
370
+
371
+ # Traffic source tracking
372
+ user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
373
+ referrer: Mapped[str | None] = mapped_column(Text, nullable=True)
374
+ utm_source: Mapped[str | None] = mapped_column(String(255), nullable=True)
375
+ utm_medium: Mapped[str | None] = mapped_column(String(255), nullable=True)
376
+ utm_campaign: Mapped[str | None] = mapped_column(String(255), nullable=True)
377
+
378
+ submission_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Additional submission metadata
379
+
380
+ # Relationships
381
+ form: Mapped[Form] = relationship(back_populates="responses")
382
+ answers: Mapped[list["ResponseAnswer"]] = relationship(back_populates="response", cascade="all, delete-orphan")
383
+ analytics_events: Mapped[list["FormAnalyticsEvent"]] = relationship(back_populates="submission", cascade="all, delete-orphan")
384
+
385
+
386
+ class ResponseAnswer(Base):
387
+ """Individual answers within a form response"""
388
+ __tablename__ = "response_answers"
389
+
390
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
391
+ form_response_id: Mapped[int] = mapped_column(ForeignKey("form_responses.id"), index=True)
392
+ form_question_id: Mapped[int] = mapped_column(ForeignKey("form_questions.id"), index=True)
393
+
394
+ answer_value: Mapped[dict | None] = mapped_column(JSON, nullable=True) # JSON to handle all answer types
395
+
396
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
397
+
398
+ # Relationships
399
+ response: Mapped[FormResponse] = relationship(back_populates="answers")
400
+ question: Mapped[FormQuestion] = relationship(back_populates="answers")
401
+
402
+
403
+ class PublicForm(Base):
404
+ """Public shareable forms"""
405
+ __tablename__ = "public_forms"
406
+
407
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
408
+ form_id: Mapped[int] = mapped_column(ForeignKey("forms.id"), index=True)
409
+ user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
410
+
411
+ share_token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
412
+ is_public: Mapped[bool] = mapped_column(Boolean, default=True)
413
+ expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
414
+
415
+ # Settings
416
+ allow_multiple_submissions: Mapped[bool] = mapped_column(Boolean, default=True)
417
+ collect_email: Mapped[bool] = mapped_column(Boolean, default=False)
418
+ custom_thank_you_message: Mapped[str | None] = mapped_column(Text, nullable=True)
419
+
420
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
421
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
422
+
423
+ # Relationships
424
+ form: Mapped[Form] = relationship()
425
+ user: Mapped[User] = relationship(back_populates="public_forms")
426
+
427
+
428
+ class ChatMessageForm(Base):
429
+ """Chat messages for form editing context"""
430
+ __tablename__ = "chat_messages_form"
431
+
432
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
433
+ form_id: Mapped[int] = mapped_column(ForeignKey("forms.id"), index=True)
434
+ user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
435
+
436
+ role: Mapped[str] = mapped_column(String(20)) # "user", "assistant"
437
+ content: Mapped[str] = mapped_column(Text)
438
+ query_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
439
+ question_index: Mapped[int | None] = mapped_column(Integer, nullable=True) # If related to a specific question
440
+
441
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
442
+
443
+ # Relationships
444
+ form: Mapped[Form] = relationship(back_populates="chat_messages_form")
445
+ user: Mapped[User] = relationship()
446
+
447
+
448
+ class FormVersion(Base):
449
+ """Form version snapshots for validation and history"""
450
+ __tablename__ = "form_versions"
451
+
452
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
453
+ form_id: Mapped[int] = mapped_column(ForeignKey("forms.id"), index=True)
454
+
455
+ version: Mapped[int] = mapped_column(Integer, nullable=False)
456
+ schema_snapshot: Mapped[dict] = mapped_column(JSON, nullable=False) # Full form + questions structure
457
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
458
+
459
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
460
+
461
+ # Relationships
462
+ form: Mapped[Form] = relationship(back_populates="versions")
463
+
464
+
465
+ class FormAnalyticsEvent(Base):
466
+ """Analytics event tracking for forms"""
467
+ __tablename__ = "form_analytics_events"
468
+
469
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
470
+ event_id: Mapped[str] = mapped_column(String(36), unique=True, nullable=False, index=True) # UUID
471
+
472
+ # Foreign keys
473
+ form_id: Mapped[int] = mapped_column(ForeignKey("forms.id"), index=True)
474
+ submission_id: Mapped[int | None] = mapped_column(ForeignKey("form_responses.id"), nullable=True, index=True)
475
+ question_id: Mapped[int | None] = mapped_column(ForeignKey("form_questions.id"), nullable=True, index=True)
476
+
477
+ # Event details
478
+ event_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
479
+ session_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
480
+
481
+ # IP and geolocation
482
+ ip_address_hash: Mapped[str] = mapped_column(String(64), nullable=False)
483
+ ip_address_raw: Mapped[str | None] = mapped_column(String(45), nullable=True) # Only if user opts in
484
+ country: Mapped[str | None] = mapped_column(String(100), nullable=True)
485
+ city: Mapped[str | None] = mapped_column(String(100), nullable=True)
486
+
487
+ # Traffic source
488
+ user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
489
+ referrer: Mapped[str | None] = mapped_column(Text, nullable=True)
490
+ utm_source: Mapped[str | None] = mapped_column(String(255), nullable=True)
491
+ utm_medium: Mapped[str | None] = mapped_column(String(255), nullable=True)
492
+ utm_campaign: Mapped[str | None] = mapped_column(String(255), nullable=True)
493
+
494
+ # Event-specific data
495
+ time_spent_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
496
+ event_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
497
+
498
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
499
+
500
+ # Relationships
501
+ form: Mapped[Form] = relationship(back_populates="analytics_events")
502
+ submission: Mapped["FormResponse"] = relationship(back_populates="analytics_events")
503
+ question: Mapped["FormQuestion"] = relationship(back_populates="analytics_events")
504
+
505
+
506
+ class SubmissionRateLimit(Base):
507
+ """Rate limiting for form submissions"""
508
+ __tablename__ = "submission_rate_limits"
509
+
510
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
511
+ identifier: Mapped[str] = mapped_column(String(64), nullable=False, index=True) # IP hash or device fingerprint
512
+ form_id: Mapped[int] = mapped_column(ForeignKey("forms.id"), nullable=False, index=True)
513
+
514
+ submission_count: Mapped[int] = mapped_column(Integer, default=0)
515
+ window_start: Mapped[datetime] = mapped_column(DateTime, nullable=False)
516
+ is_blocked: Mapped[bool] = mapped_column(Boolean, default=False)
517
+
518
+ # Relationships
519
+ form: Mapped[Form] = relationship()
520
+
521
+
522
+ class WebhookConfig(Base):
523
+ """Webhook configuration for forms"""
524
+ __tablename__ = "webhook_configs"
525
+
526
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
527
+ form_id: Mapped[int] = mapped_column(ForeignKey("forms.id"), unique=True, nullable=False, index=True)
528
+ user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
529
+
530
+ webhook_url: Mapped[str] = mapped_column(Text, nullable=False)
531
+ secret: Mapped[str] = mapped_column(String(128), nullable=False)
532
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
533
+ events: Mapped[list] = mapped_column(JSON, nullable=False) # Array of event types to trigger on
534
+
535
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
536
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
537
+
538
+ # Relationships
539
+ form: Mapped[Form] = relationship()
540
+ user: Mapped[User] = relationship()
541
+
app/oauth_google.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import logging
3
+ from urllib.parse import urlencode
4
+ from fastapi import APIRouter, Request, Depends
5
+ from authlib.integrations.starlette_client import OAuth
6
+ from starlette.responses import RedirectResponse
7
+ from sqlalchemy.orm import Session
8
+ from .db import get_db
9
+ from .models import User
10
+ from .security import create_access_token
11
+ from .services.subscription_service import subscription_service
12
+
13
+ logger = logging.getLogger(__name__)
14
+ router = APIRouter(prefix="/api/auth/google", tags=["auth"])
15
+ FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173")
16
+
17
+
18
+ GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "")
19
+ GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "")
20
+ GOOGLE_REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI", "http://localhost:8000/api/auth/google/callback")
21
+
22
+ oauth = OAuth()
23
+ oauth.register(
24
+ name="google",
25
+ client_id=GOOGLE_CLIENT_ID,
26
+ client_secret=GOOGLE_CLIENT_SECRET,
27
+ server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
28
+ client_kwargs={"scope": "openid email profile"},
29
+ )
30
+
31
+
32
+ @router.get("/login")
33
+ async def login(request: Request):
34
+ redirect_uri = GOOGLE_REDIRECT_URI
35
+ return await oauth.google.authorize_redirect(request, redirect_uri)
36
+
37
+
38
+ @router.get("/callback")
39
+ async def callback(request: Request, db: Session = Depends(get_db)):
40
+ try:
41
+ token = await oauth.google.authorize_access_token(request)
42
+ userinfo = token.get("userinfo") or await oauth.google.parse_id_token(request, token)
43
+
44
+ email = userinfo.get("email")
45
+ if not email:
46
+ logger.error("No email in Google OAuth response")
47
+ return RedirectResponse(url=f"{FRONTEND_URL}/?error=missing_email")
48
+
49
+ user = db.query(User).filter(User.email == email).first()
50
+ is_new_user = not user
51
+
52
+ if is_new_user:
53
+ user = User(
54
+ email=email,
55
+ name=userinfo.get("name"),
56
+ picture=userinfo.get("picture"),
57
+ provider="google",
58
+ provider_id=userinfo.get("sub")
59
+ )
60
+ db.add(user)
61
+ db.commit()
62
+ db.refresh(user)
63
+
64
+ # Assign free tier (non-blocking)
65
+ try:
66
+ subscription_service.assign_free_tier(db, user)
67
+ except Exception as e:
68
+ logger.error(f"Failed to assign free tier to user {user.id}: {e}")
69
+
70
+ jwt_token = create_access_token(str(user.id), {"email": user.email})
71
+ return RedirectResponse(url=f"{FRONTEND_URL}/?{urlencode({'token': jwt_token})}")
72
+
73
+ except Exception as e:
74
+ logger.error(f"OAuth callback error: {e}", exc_info=True)
75
+ return RedirectResponse(url=f"{FRONTEND_URL}/?error=login_failed")
76
+
77
+
app/package-lock.json ADDED
@@ -0,0 +1,448 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "app",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {
6
+ "": {
7
+ "dependencies": {
8
+ "recharts": "^3.6.0"
9
+ }
10
+ },
11
+ "node_modules/@reduxjs/toolkit": {
12
+ "version": "2.11.2",
13
+ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
14
+ "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
15
+ "license": "MIT",
16
+ "dependencies": {
17
+ "@standard-schema/spec": "^1.0.0",
18
+ "@standard-schema/utils": "^0.3.0",
19
+ "immer": "^11.0.0",
20
+ "redux": "^5.0.1",
21
+ "redux-thunk": "^3.1.0",
22
+ "reselect": "^5.1.0"
23
+ },
24
+ "peerDependencies": {
25
+ "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
26
+ "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
27
+ },
28
+ "peerDependenciesMeta": {
29
+ "react": {
30
+ "optional": true
31
+ },
32
+ "react-redux": {
33
+ "optional": true
34
+ }
35
+ }
36
+ },
37
+ "node_modules/@reduxjs/toolkit/node_modules/immer": {
38
+ "version": "11.1.3",
39
+ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
40
+ "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
41
+ "license": "MIT",
42
+ "funding": {
43
+ "type": "opencollective",
44
+ "url": "https://opencollective.com/immer"
45
+ }
46
+ },
47
+ "node_modules/@standard-schema/spec": {
48
+ "version": "1.1.0",
49
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
50
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
51
+ "license": "MIT"
52
+ },
53
+ "node_modules/@standard-schema/utils": {
54
+ "version": "0.3.0",
55
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
56
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
57
+ "license": "MIT"
58
+ },
59
+ "node_modules/@types/d3-array": {
60
+ "version": "3.2.2",
61
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
62
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
63
+ "license": "MIT"
64
+ },
65
+ "node_modules/@types/d3-color": {
66
+ "version": "3.1.3",
67
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
68
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
69
+ "license": "MIT"
70
+ },
71
+ "node_modules/@types/d3-ease": {
72
+ "version": "3.0.2",
73
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
74
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
75
+ "license": "MIT"
76
+ },
77
+ "node_modules/@types/d3-interpolate": {
78
+ "version": "3.0.4",
79
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
80
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
81
+ "license": "MIT",
82
+ "dependencies": {
83
+ "@types/d3-color": "*"
84
+ }
85
+ },
86
+ "node_modules/@types/d3-path": {
87
+ "version": "3.1.1",
88
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
89
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
90
+ "license": "MIT"
91
+ },
92
+ "node_modules/@types/d3-scale": {
93
+ "version": "4.0.9",
94
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
95
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
96
+ "license": "MIT",
97
+ "dependencies": {
98
+ "@types/d3-time": "*"
99
+ }
100
+ },
101
+ "node_modules/@types/d3-shape": {
102
+ "version": "3.1.8",
103
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
104
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
105
+ "license": "MIT",
106
+ "dependencies": {
107
+ "@types/d3-path": "*"
108
+ }
109
+ },
110
+ "node_modules/@types/d3-time": {
111
+ "version": "3.0.4",
112
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
113
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
114
+ "license": "MIT"
115
+ },
116
+ "node_modules/@types/d3-timer": {
117
+ "version": "3.0.2",
118
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
119
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
120
+ "license": "MIT"
121
+ },
122
+ "node_modules/@types/use-sync-external-store": {
123
+ "version": "0.0.6",
124
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
125
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
126
+ "license": "MIT"
127
+ },
128
+ "node_modules/clsx": {
129
+ "version": "2.1.1",
130
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
131
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
132
+ "license": "MIT",
133
+ "engines": {
134
+ "node": ">=6"
135
+ }
136
+ },
137
+ "node_modules/d3-array": {
138
+ "version": "3.2.4",
139
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
140
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
141
+ "license": "ISC",
142
+ "dependencies": {
143
+ "internmap": "1 - 2"
144
+ },
145
+ "engines": {
146
+ "node": ">=12"
147
+ }
148
+ },
149
+ "node_modules/d3-color": {
150
+ "version": "3.1.0",
151
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
152
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
153
+ "license": "ISC",
154
+ "engines": {
155
+ "node": ">=12"
156
+ }
157
+ },
158
+ "node_modules/d3-ease": {
159
+ "version": "3.0.1",
160
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
161
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
162
+ "license": "BSD-3-Clause",
163
+ "engines": {
164
+ "node": ">=12"
165
+ }
166
+ },
167
+ "node_modules/d3-format": {
168
+ "version": "3.1.0",
169
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
170
+ "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
171
+ "license": "ISC",
172
+ "engines": {
173
+ "node": ">=12"
174
+ }
175
+ },
176
+ "node_modules/d3-interpolate": {
177
+ "version": "3.0.1",
178
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
179
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
180
+ "license": "ISC",
181
+ "dependencies": {
182
+ "d3-color": "1 - 3"
183
+ },
184
+ "engines": {
185
+ "node": ">=12"
186
+ }
187
+ },
188
+ "node_modules/d3-path": {
189
+ "version": "3.1.0",
190
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
191
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
192
+ "license": "ISC",
193
+ "engines": {
194
+ "node": ">=12"
195
+ }
196
+ },
197
+ "node_modules/d3-scale": {
198
+ "version": "4.0.2",
199
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
200
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
201
+ "license": "ISC",
202
+ "dependencies": {
203
+ "d3-array": "2.10.0 - 3",
204
+ "d3-format": "1 - 3",
205
+ "d3-interpolate": "1.2.0 - 3",
206
+ "d3-time": "2.1.1 - 3",
207
+ "d3-time-format": "2 - 4"
208
+ },
209
+ "engines": {
210
+ "node": ">=12"
211
+ }
212
+ },
213
+ "node_modules/d3-shape": {
214
+ "version": "3.2.0",
215
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
216
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
217
+ "license": "ISC",
218
+ "dependencies": {
219
+ "d3-path": "^3.1.0"
220
+ },
221
+ "engines": {
222
+ "node": ">=12"
223
+ }
224
+ },
225
+ "node_modules/d3-time": {
226
+ "version": "3.1.0",
227
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
228
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
229
+ "license": "ISC",
230
+ "dependencies": {
231
+ "d3-array": "2 - 3"
232
+ },
233
+ "engines": {
234
+ "node": ">=12"
235
+ }
236
+ },
237
+ "node_modules/d3-time-format": {
238
+ "version": "4.1.0",
239
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
240
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
241
+ "license": "ISC",
242
+ "dependencies": {
243
+ "d3-time": "1 - 3"
244
+ },
245
+ "engines": {
246
+ "node": ">=12"
247
+ }
248
+ },
249
+ "node_modules/d3-timer": {
250
+ "version": "3.0.1",
251
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
252
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
253
+ "license": "ISC",
254
+ "engines": {
255
+ "node": ">=12"
256
+ }
257
+ },
258
+ "node_modules/decimal.js-light": {
259
+ "version": "2.5.1",
260
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
261
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
262
+ "license": "MIT"
263
+ },
264
+ "node_modules/es-toolkit": {
265
+ "version": "1.43.0",
266
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
267
+ "integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==",
268
+ "license": "MIT",
269
+ "workspaces": [
270
+ "docs",
271
+ "benchmarks"
272
+ ]
273
+ },
274
+ "node_modules/eventemitter3": {
275
+ "version": "5.0.1",
276
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
277
+ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
278
+ "license": "MIT"
279
+ },
280
+ "node_modules/immer": {
281
+ "version": "10.2.0",
282
+ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
283
+ "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
284
+ "license": "MIT",
285
+ "funding": {
286
+ "type": "opencollective",
287
+ "url": "https://opencollective.com/immer"
288
+ }
289
+ },
290
+ "node_modules/internmap": {
291
+ "version": "2.0.3",
292
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
293
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
294
+ "license": "ISC",
295
+ "engines": {
296
+ "node": ">=12"
297
+ }
298
+ },
299
+ "node_modules/react": {
300
+ "version": "19.2.3",
301
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
302
+ "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
303
+ "license": "MIT",
304
+ "peer": true,
305
+ "engines": {
306
+ "node": ">=0.10.0"
307
+ }
308
+ },
309
+ "node_modules/react-dom": {
310
+ "version": "19.2.3",
311
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
312
+ "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
313
+ "license": "MIT",
314
+ "peer": true,
315
+ "dependencies": {
316
+ "scheduler": "^0.27.0"
317
+ },
318
+ "peerDependencies": {
319
+ "react": "^19.2.3"
320
+ }
321
+ },
322
+ "node_modules/react-is": {
323
+ "version": "19.2.3",
324
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",
325
+ "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==",
326
+ "license": "MIT",
327
+ "peer": true
328
+ },
329
+ "node_modules/react-redux": {
330
+ "version": "9.2.0",
331
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
332
+ "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
333
+ "license": "MIT",
334
+ "dependencies": {
335
+ "@types/use-sync-external-store": "^0.0.6",
336
+ "use-sync-external-store": "^1.4.0"
337
+ },
338
+ "peerDependencies": {
339
+ "@types/react": "^18.2.25 || ^19",
340
+ "react": "^18.0 || ^19",
341
+ "redux": "^5.0.0"
342
+ },
343
+ "peerDependenciesMeta": {
344
+ "@types/react": {
345
+ "optional": true
346
+ },
347
+ "redux": {
348
+ "optional": true
349
+ }
350
+ }
351
+ },
352
+ "node_modules/recharts": {
353
+ "version": "3.6.0",
354
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz",
355
+ "integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==",
356
+ "license": "MIT",
357
+ "workspaces": [
358
+ "www"
359
+ ],
360
+ "dependencies": {
361
+ "@reduxjs/toolkit": "1.x.x || 2.x.x",
362
+ "clsx": "^2.1.1",
363
+ "decimal.js-light": "^2.5.1",
364
+ "es-toolkit": "^1.39.3",
365
+ "eventemitter3": "^5.0.1",
366
+ "immer": "^10.1.1",
367
+ "react-redux": "8.x.x || 9.x.x",
368
+ "reselect": "5.1.1",
369
+ "tiny-invariant": "^1.3.3",
370
+ "use-sync-external-store": "^1.2.2",
371
+ "victory-vendor": "^37.0.2"
372
+ },
373
+ "engines": {
374
+ "node": ">=18"
375
+ },
376
+ "peerDependencies": {
377
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
378
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
379
+ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
380
+ }
381
+ },
382
+ "node_modules/redux": {
383
+ "version": "5.0.1",
384
+ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
385
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
386
+ "license": "MIT"
387
+ },
388
+ "node_modules/redux-thunk": {
389
+ "version": "3.1.0",
390
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
391
+ "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
392
+ "license": "MIT",
393
+ "peerDependencies": {
394
+ "redux": "^5.0.0"
395
+ }
396
+ },
397
+ "node_modules/reselect": {
398
+ "version": "5.1.1",
399
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
400
+ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
401
+ "license": "MIT"
402
+ },
403
+ "node_modules/scheduler": {
404
+ "version": "0.27.0",
405
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
406
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
407
+ "license": "MIT",
408
+ "peer": true
409
+ },
410
+ "node_modules/tiny-invariant": {
411
+ "version": "1.3.3",
412
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
413
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
414
+ "license": "MIT"
415
+ },
416
+ "node_modules/use-sync-external-store": {
417
+ "version": "1.6.0",
418
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
419
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
420
+ "license": "MIT",
421
+ "peerDependencies": {
422
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
423
+ }
424
+ },
425
+ "node_modules/victory-vendor": {
426
+ "version": "37.3.6",
427
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
428
+ "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
429
+ "license": "MIT AND ISC",
430
+ "dependencies": {
431
+ "@types/d3-array": "^3.0.3",
432
+ "@types/d3-ease": "^3.0.0",
433
+ "@types/d3-interpolate": "^3.0.1",
434
+ "@types/d3-scale": "^4.0.2",
435
+ "@types/d3-shape": "^3.1.0",
436
+ "@types/d3-time": "^3.0.0",
437
+ "@types/d3-timer": "^3.0.0",
438
+ "d3-array": "^3.1.6",
439
+ "d3-ease": "^3.0.1",
440
+ "d3-interpolate": "^3.0.1",
441
+ "d3-scale": "^4.0.2",
442
+ "d3-shape": "^3.1.0",
443
+ "d3-time": "^3.0.0",
444
+ "d3-timer": "^3.0.1"
445
+ }
446
+ }
447
+ }
448
+ }
app/package.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "dependencies": {
3
+ "recharts": "^3.6.0"
4
+ }
5
+ }
app/routes/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+
2
+
app/routes/analytics.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Analytics API Routes
3
+ ====================
4
+
5
+ Routes for form analytics and tracking.
6
+ """
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, status, Request
9
+ from sqlalchemy.orm import Session
10
+ from typing import Optional
11
+ from datetime import datetime
12
+
13
+ from ..core.db import get_db
14
+ from ..core.security import get_current_user
15
+ from ..models import User, Form, PublicForm
16
+ from ..services.analytics_service import analytics_service
17
+
18
+ import logging
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ router = APIRouter(prefix="/api", tags=["analytics"])
23
+
24
+
25
+ @router.post("/public/forms/{token}/track")
26
+ async def track_public_event(
27
+ token: str,
28
+ event_type: str,
29
+ session_id: str,
30
+ request: Request,
31
+ question_id: Optional[int] = None,
32
+ time_spent: Optional[int] = None,
33
+ db: Session = Depends(get_db)
34
+ ):
35
+ """
36
+ Public endpoint for client-side analytics tracking.
37
+ No authentication required.
38
+ """
39
+ # Find public form by token
40
+ public_form = db.query(PublicForm).filter(
41
+ PublicForm.share_token == token,
42
+ PublicForm.is_public == True
43
+ ).first()
44
+
45
+ if not public_form:
46
+ # Silently fail for analytics - don't break form
47
+ logger.warning(f"Analytics tracking: form not found for token {token}")
48
+ return {"success": False, "message": "Form not found"}
49
+
50
+ try:
51
+ # Track event
52
+ await analytics_service.track_event(
53
+ db=db,
54
+ event_type=event_type,
55
+ form_id=public_form.form_id,
56
+ session_id=session_id,
57
+ request=request,
58
+ question_id=question_id,
59
+ time_spent_seconds=time_spent
60
+ )
61
+
62
+ return {"success": True, "message": "Event tracked"}
63
+
64
+ except Exception as e:
65
+ logger.error(f"Analytics tracking failed: {e}")
66
+ # Silently fail - analytics shouldn't break form
67
+ return {"success": False, "message": "Tracking failed"}
68
+
69
+
70
+ @router.get("/forms/{form_id}/analytics/funnel")
71
+ async def get_form_funnel(
72
+ form_id: int,
73
+ current_user: User = Depends(get_current_user),
74
+ db: Session = Depends(get_db),
75
+ start_date: Optional[str] = None,
76
+ end_date: Optional[str] = None,
77
+ question_ids: Optional[str] = None,
78
+ countries: Optional[str] = None,
79
+ utm_source: Optional[str] = None
80
+ ):
81
+ """
82
+ Get funnel analytics for form owner with advanced filtering.
83
+ Requires authentication.
84
+ """
85
+ # Verify form ownership
86
+ form = db.query(Form).filter(
87
+ Form.id == form_id,
88
+ Form.user_id == current_user.id
89
+ ).first()
90
+
91
+ if not form:
92
+ raise HTTPException(
93
+ status_code=status.HTTP_404_NOT_FOUND,
94
+ detail="Form not found"
95
+ )
96
+
97
+ # Parse dates
98
+ start_dt = datetime.fromisoformat(start_date) if start_date else None
99
+ end_dt = datetime.fromisoformat(end_date) if end_date else None
100
+
101
+ # Parse question IDs
102
+ question_id_list = [int(x) for x in question_ids.split(',')] if question_ids else None
103
+
104
+ # Parse countries
105
+ country_list = [x.strip() for x in countries.split(',')] if countries else None
106
+
107
+ # Get funnel analytics
108
+ funnel_data = await analytics_service.get_funnel_analytics(
109
+ db=db,
110
+ form_id=form_id,
111
+ start_date=start_dt,
112
+ end_date=end_dt,
113
+ question_ids=question_id_list,
114
+ countries=country_list,
115
+ utm_source=utm_source
116
+ )
117
+
118
+ return funnel_data
119
+
120
+
121
+ @router.get("/forms/{form_id}/analytics/timeseries")
122
+ async def get_time_series(
123
+ form_id: int,
124
+ current_user: User = Depends(get_current_user),
125
+ db: Session = Depends(get_db),
126
+ start_date: Optional[str] = None,
127
+ end_date: Optional[str] = None,
128
+ question_ids: Optional[str] = None,
129
+ countries: Optional[str] = None,
130
+ utm_source: Optional[str] = None
131
+ ):
132
+ """
133
+ Get time-series data for views and submissions with filtering.
134
+ Requires authentication.
135
+ """
136
+ # Verify form ownership
137
+ form = db.query(Form).filter(
138
+ Form.id == form_id,
139
+ Form.user_id == current_user.id
140
+ ).first()
141
+
142
+ if not form:
143
+ raise HTTPException(
144
+ status_code=status.HTTP_404_NOT_FOUND,
145
+ detail="Form not found"
146
+ )
147
+
148
+ # Parse dates
149
+ start_dt = datetime.fromisoformat(start_date) if start_date else None
150
+ end_dt = datetime.fromisoformat(end_date) if end_date else None
151
+
152
+ # Parse question IDs
153
+ question_id_list = [int(x) for x in question_ids.split(',')] if question_ids else None
154
+
155
+ # Parse countries
156
+ country_list = [x.strip() for x in countries.split(',')] if countries else None
157
+
158
+ # Get time-series data
159
+ time_series_data = await analytics_service.get_time_series_data(
160
+ db=db,
161
+ form_id=form_id,
162
+ start_date=start_dt,
163
+ end_date=end_dt,
164
+ question_ids=question_id_list,
165
+ countries=country_list,
166
+ utm_source=utm_source
167
+ )
168
+
169
+ return time_series_data
170
+
171
+
172
+ @router.get("/forms/{form_id}/analytics/summary")
173
+ async def get_analytics_summary(
174
+ form_id: int,
175
+ current_user: User = Depends(get_current_user),
176
+ db: Session = Depends(get_db)
177
+ ):
178
+ """
179
+ Get high-level analytics summary.
180
+ Requires authentication.
181
+ """
182
+ # Verify form ownership
183
+ form = db.query(Form).filter(
184
+ Form.id == form_id,
185
+ Form.user_id == current_user.id
186
+ ).first()
187
+
188
+ if not form:
189
+ raise HTTPException(
190
+ status_code=status.HTTP_404_NOT_FOUND,
191
+ detail="Form not found"
192
+ )
193
+
194
+ # Get summary analytics
195
+ summary_data = await analytics_service.get_summary_analytics(
196
+ db=db,
197
+ form_id=form_id
198
+ )
199
+
200
+ return summary_data
app/routes/auth.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from sqlalchemy.orm import Session
3
+ from pydantic import BaseModel
4
+ from ..core.security import create_access_token, get_current_subject, get_current_user
5
+ from ..core.db import get_db
6
+ from ..models import User
7
+ from ..schemas.auth import LoginRequest, TokenResponse
8
+
9
+
10
+ router = APIRouter(prefix="/api/auth", tags=["auth"])
11
+
12
+
13
+ @router.post("/login", response_model=TokenResponse)
14
+ def login(payload: LoginRequest, db: Session = Depends(get_db)):
15
+ """
16
+ Demo/testing login endpoint. For production, use Google OAuth at /api/auth/google/login
17
+ This creates or gets a test user from the database.
18
+ """
19
+ # Find or create test user
20
+ user = db.query(User).filter(User.email == payload.email).first()
21
+ if not user:
22
+ user = User(
23
+ email=payload.email,
24
+ name="Test User",
25
+ provider="test",
26
+ is_active=True
27
+ )
28
+ db.add(user)
29
+ db.commit()
30
+ db.refresh(user)
31
+
32
+ token = create_access_token(str(user.id), {"email": user.email})
33
+ return TokenResponse(token=token, user={"id": str(user.id), "email": user.email})
34
+
35
+
36
+ @router.post("/logout")
37
+ def logout():
38
+ return {"success": True, "message": "Logged out successfully"}
39
+
40
+
41
+ @router.get("/me")
42
+ def get_current_user_info(
43
+ current_user: User = Depends(get_current_user),
44
+ db: Session = Depends(get_db)
45
+ ):
46
+ """Get current authenticated user's profile information"""
47
+ # Get user's subscription info
48
+ from ..models import Subscription, Dataset
49
+ from datetime import datetime, timezone
50
+
51
+ subscription = db.query(Subscription).filter(
52
+ Subscription.user_id == current_user.id
53
+ ).order_by(Subscription.created_at.desc()).first()
54
+
55
+ subscription_info = None
56
+ if subscription:
57
+ subscription_info = {
58
+ "tier": subscription.status, # e.g., "free", "pro", "enterprise"
59
+ "status": subscription.status,
60
+ "stripe_customer_id": subscription.stripe_customer_id,
61
+ "stripe_subscription_id": subscription.stripe_subscription_id,
62
+ "created_at": subscription.created_at.isoformat()
63
+ }
64
+
65
+ # Calculate dashboards created this month
66
+ now = datetime.now(timezone.utc)
67
+ start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
68
+
69
+ dashboards_this_month = db.query(Dataset).filter(
70
+ Dataset.user_id == current_user.id,
71
+ Dataset.created_at >= start_of_month
72
+ ).count()
73
+
74
+ return {
75
+ "id": current_user.id,
76
+ "email": current_user.email,
77
+ "name": current_user.name,
78
+ "picture": current_user.picture,
79
+ "provider": current_user.provider,
80
+ "is_active": current_user.is_active,
81
+ "created_at": current_user.created_at.isoformat(),
82
+ "dashboards_this_month": dashboards_this_month,
83
+ "subscription": subscription_info or {
84
+ "tier": "free",
85
+ "status": "inactive",
86
+ "stripe_customer_id": None,
87
+ "stripe_subscription_id": None,
88
+ "created_at": None
89
+ }
90
+ }
91
+
92
+
93
+ class UpdateProfileRequest(BaseModel):
94
+ name: str | None = None
95
+
96
+
97
+ @router.patch("/me")
98
+ def update_user_profile(
99
+ payload: UpdateProfileRequest,
100
+ current_user: User = Depends(get_current_user),
101
+ db: Session = Depends(get_db)
102
+ ):
103
+ """Update current user's profile"""
104
+ if payload.name is not None:
105
+ current_user.name = payload.name
106
+
107
+ db.commit()
108
+ db.refresh(current_user)
109
+
110
+ return {
111
+ "message": "Profile updated successfully",
112
+ "user": {
113
+ "id": current_user.id,
114
+ "email": current_user.email,
115
+ "name": current_user.name,
116
+ "picture": current_user.picture,
117
+ }
118
+ }
119
+
120
+
121
+ @router.delete("/me")
122
+ def deactivate_account(
123
+ current_user: User = Depends(get_current_user),
124
+ db: Session = Depends(get_db)
125
+ ):
126
+ """Deactivate user account (soft delete)"""
127
+ current_user.is_active = False
128
+ db.commit()
129
+
130
+ return {
131
+ "message": "Account deactivated successfully",
132
+ "success": True
133
+ }
134
+
135
+
app/routes/credits.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Credit management routes
3
+ """
4
+ from fastapi import APIRouter, Depends, HTTPException
5
+ from sqlalchemy.orm import Session
6
+ from typing import List, Dict, Any
7
+ from pydantic import BaseModel
8
+
9
+ from ..core.db import get_db
10
+ from ..core.security import get_current_user
11
+ from ..models import User
12
+ from ..services.credit_service import credit_service
13
+
14
+ router = APIRouter(prefix="/api/credits", tags=["credits"])
15
+
16
+
17
+ class CreditBalanceResponse(BaseModel):
18
+ """Response model for credit balance"""
19
+ balance: int
20
+ plan_name: str | None
21
+ plan_id: int | None
22
+ credits_per_analyze: int
23
+ credits_per_edit: int
24
+ last_reset_at: str | None
25
+ updated_at: str
26
+
27
+
28
+ class CreditTransactionResponse(BaseModel):
29
+ """Response model for a credit transaction"""
30
+ id: int
31
+ amount: int
32
+ transaction_type: str
33
+ description: str
34
+ transaction_metadata: Dict[str, Any] | None
35
+ created_at: str
36
+
37
+
38
+ @router.get("/balance", response_model=CreditBalanceResponse)
39
+ def get_credit_balance(
40
+ current_user: User = Depends(get_current_user),
41
+ db: Session = Depends(get_db)
42
+ ):
43
+ """
44
+ Get the current user's credit balance and plan information
45
+
46
+ Returns:
47
+ Credit balance and plan details
48
+ """
49
+ try:
50
+ # Ensure credits record exists (auto-create if missing)
51
+ credit_service.get_or_create_user_credits(db, current_user.id)
52
+ balance_info = credit_service.get_balance_info(db, current_user.id)
53
+ return balance_info
54
+ except Exception as e:
55
+ raise HTTPException(status_code=500, detail=str(e))
56
+
57
+
58
+ @router.get("/history")
59
+ def get_credit_history(
60
+ limit: int = 50,
61
+ offset: int = 0,
62
+ current_user: User = Depends(get_current_user),
63
+ db: Session = Depends(get_db)
64
+ ):
65
+ """
66
+ Get credit transaction history for the current user
67
+
68
+ Args:
69
+ limit: Maximum number of transactions to return (default 50)
70
+ offset: Number of transactions to skip (default 0)
71
+
72
+ Returns:
73
+ List of credit transactions
74
+ """
75
+ try:
76
+ transactions = credit_service.get_credit_history(
77
+ db, current_user.id, limit=limit, offset=offset
78
+ )
79
+
80
+ # Convert to response format
81
+ return [
82
+ {
83
+ "id": t.id,
84
+ "amount": t.amount,
85
+ "transaction_type": t.transaction_type.value,
86
+ "description": t.description,
87
+ "transaction_metadata": t.transaction_metadata,
88
+ "created_at": t.created_at.isoformat()
89
+ }
90
+ for t in transactions
91
+ ]
92
+ except Exception as e:
93
+ raise HTTPException(status_code=500, detail=str(e))
94
+
app/routes/export.py ADDED
@@ -0,0 +1,504 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Export routes for downloading charts as PNG, ZIP, or PDF
3
+ """
4
+ from fastapi import APIRouter, HTTPException
5
+ from fastapi.responses import Response
6
+ from pydantic import BaseModel
7
+ from typing import List, Dict, Any, Optional
8
+ import plotly.graph_objects as go
9
+ import os
10
+ import tempfile
11
+ import zipfile
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ import io
15
+ from PIL import Image
16
+ import base64
17
+
18
+ # ReportLab imports
19
+ from reportlab.lib.pagesizes import letter
20
+ from reportlab.pdfgen import canvas as pdf_canvas
21
+ from reportlab.lib.utils import ImageReader
22
+
23
+ router = APIRouter(prefix="/api/export", tags=["export"])
24
+
25
+ # Path to logo
26
+ LOGO_PATH = Path(__file__).parent.parent.parent / "images" / "AutoForm.png"
27
+
28
+
29
+ class ChartExportRequest(BaseModel):
30
+ """Request model for chart export"""
31
+ charts: List[Dict[str, Any]] # List of chart specs with figure data
32
+ titles: Optional[List[str]] = None # Optional custom titles
33
+
34
+
35
+ class ChartImageRequest(BaseModel):
36
+ """Request with pre-rendered chart images (avoids kaleido issues)"""
37
+ charts: List[Dict[str, str]] # [{"title": "...", "imageData": "data:image/png;base64,..."}]
38
+
39
+
40
+ @router.post("/charts-zip-from-images")
41
+ async def export_charts_zip_from_images(request: ChartImageRequest):
42
+ """
43
+ Create ZIP from pre-rendered chart images (no kaleido needed!)
44
+ Frontend exports charts using Plotly.toImage() and sends base64 data
45
+ """
46
+ try:
47
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
48
+
49
+ with tempfile.TemporaryDirectory() as temp_dir:
50
+ png_files = []
51
+
52
+ for idx, chart in enumerate(request.charts, 1):
53
+ try:
54
+ title = chart.get('title', f'chart_{idx}')
55
+ image_data = chart.get('imageData', '')
56
+
57
+ # Remove data URL prefix if present
58
+ if 'base64,' in image_data:
59
+ image_data = image_data.split('base64,')[1]
60
+
61
+ # Decode base64 to bytes
62
+ img_bytes = base64.b64decode(image_data)
63
+
64
+ # Sanitize filename
65
+ safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).rstrip()
66
+ safe_title = safe_title.replace(' ', '_').lower()
67
+ filename = f"{idx:02d}_{safe_title}.png"
68
+ filepath = os.path.join(temp_dir, filename)
69
+
70
+ # Write image
71
+ with open(filepath, 'wb') as f:
72
+ f.write(img_bytes)
73
+
74
+ png_files.append(filepath)
75
+ print(f"[SUCCESS] Added chart {idx} to ZIP: {filename}")
76
+
77
+ except Exception as e:
78
+ print(f"[ERROR] Error processing chart {idx}: {e}")
79
+ import traceback
80
+ traceback.print_exc()
81
+ continue
82
+
83
+ if not png_files:
84
+ raise HTTPException(status_code=400, detail="No charts could be processed")
85
+
86
+ # Create ZIP
87
+ zip_path = os.path.join(temp_dir, f"charts_{timestamp}.zip")
88
+ with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
89
+ for filepath in png_files:
90
+ zipf.write(filepath, os.path.basename(filepath))
91
+
92
+ print(f"[SUCCESS] Created ZIP with {len(png_files)} charts")
93
+
94
+ with open(zip_path, 'rb') as f:
95
+ zip_data = f.read()
96
+
97
+ return Response(
98
+ content=zip_data,
99
+ media_type="application/zip",
100
+ headers={"Content-Disposition": f"attachment; filename=charts_{timestamp}.zip"}
101
+ )
102
+
103
+ except Exception as e:
104
+ print(f"[ERROR] Error creating ZIP: {e}")
105
+ import traceback
106
+ traceback.print_exc()
107
+ raise HTTPException(status_code=500, detail=str(e))
108
+
109
+
110
+ @router.post("/dashboard-pdf-from-images")
111
+ async def export_pdf_from_images(request: ChartImageRequest):
112
+ """
113
+ Create PDF from pre-rendered chart images (no kaleido needed!)
114
+ Includes Auto-Dash logo on every page
115
+ """
116
+ try:
117
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
118
+
119
+ with tempfile.TemporaryDirectory() as temp_dir:
120
+ pdf_path = os.path.join(temp_dir, f"report_{timestamp}.pdf")
121
+
122
+ c = pdf_canvas.Canvas(pdf_path, pagesize=letter)
123
+ page_width, page_height = letter
124
+
125
+ # Load logo
126
+ logo_img = None
127
+ if LOGO_PATH.exists():
128
+ try:
129
+ logo_img = Image.open(LOGO_PATH)
130
+ except Exception as e:
131
+ print(f"Could not load logo: {e}")
132
+
133
+ for idx, chart in enumerate(request.charts, 1):
134
+ try:
135
+ print(f"[INFO] Adding chart {idx} to PDF...")
136
+
137
+ title = chart.get('title', f'Chart {idx}')
138
+ image_data = chart.get('imageData', '')
139
+
140
+ # Remove data URL prefix
141
+ if 'base64,' in image_data:
142
+ image_data = image_data.split('base64,')[1]
143
+
144
+ # Decode and load image
145
+ img_bytes = base64.b64decode(image_data)
146
+ chart_img = Image.open(io.BytesIO(img_bytes))
147
+
148
+ # === ADD LOGO (CENTERED AT TOP) ===
149
+ if logo_img:
150
+ # Smaller logo size
151
+ logo_height = 25
152
+ logo_aspect = logo_img.width / logo_img.height
153
+ logo_width = logo_height * logo_aspect
154
+
155
+ # Center logo horizontally
156
+ logo_x = (page_width - logo_width) / 2
157
+ logo_y = page_height - logo_height - 15
158
+
159
+ logo_buffer = io.BytesIO()
160
+ logo_img.save(logo_buffer, format='PNG')
161
+ logo_buffer.seek(0)
162
+
163
+ c.drawImage(ImageReader(logo_buffer), logo_x, logo_y,
164
+ width=logo_width, height=logo_height,
165
+ preserveAspectRatio=True, mask='auto')
166
+
167
+ # Center "Auto-Dash" text below logo
168
+ c.setFont("Helvetica-Bold", 12)
169
+ c.setFillColorRGB(0.2, 0.2, 0.2)
170
+ c.drawCentredString(page_width / 2, logo_y - 15, "Auto-Dash")
171
+
172
+ # Center subtitle below
173
+ c.setFont("Helvetica", 9)
174
+ c.setFillColorRGB(0.5, 0.5, 0.5)
175
+ c.drawCentredString(page_width / 2, logo_y - 28, "Analytics Dashboard")
176
+
177
+ # === ADD CHART ===
178
+ img_width, img_height = chart_img.size
179
+ aspect_ratio = img_width / img_height
180
+ margin = 36
181
+ top_margin = 70 if logo_img else 36 # Smaller margin for centered compact logo
182
+ bottom_margin = 60
183
+ available_width = page_width - (2 * margin)
184
+ available_height = page_height - top_margin - bottom_margin
185
+
186
+ if available_width / available_height > aspect_ratio:
187
+ scaled_height = available_height
188
+ scaled_width = scaled_height * aspect_ratio
189
+ else:
190
+ scaled_width = available_width
191
+ scaled_height = scaled_width / aspect_ratio
192
+
193
+ # Center chart horizontally and vertically
194
+ chart_x = (page_width - scaled_width) / 2
195
+ chart_y = bottom_margin + (available_height - scaled_height) / 2
196
+
197
+ chart_buffer = io.BytesIO()
198
+ chart_img.save(chart_buffer, format='PNG')
199
+ chart_buffer.seek(0)
200
+
201
+ c.drawImage(ImageReader(chart_buffer), chart_x, chart_y,
202
+ width=scaled_width, height=scaled_height)
203
+
204
+ # === ADD FOOTER ===
205
+ c.setFont("Helvetica", 10)
206
+ c.setFillColorRGB(0.4, 0.4, 0.4)
207
+ c.drawCentredString(page_width / 2, 25, f"Page {idx} of {len(request.charts)}")
208
+ c.setFont("Helvetica", 9)
209
+ c.drawString(margin, 25, title)
210
+ c.setFont("Helvetica", 8)
211
+ c.drawRightString(page_width - margin, 25,
212
+ datetime.now().strftime("%B %d, %Y %I:%M %p"))
213
+
214
+ if idx < len(request.charts):
215
+ c.showPage()
216
+
217
+ print(f"[SUCCESS] Chart {idx} added to PDF successfully")
218
+
219
+ except Exception as e:
220
+ print(f"[ERROR] Error adding chart {idx}: {e}")
221
+ import traceback
222
+ traceback.print_exc()
223
+ continue
224
+
225
+ c.save()
226
+ print(f"[SUCCESS] PDF created with {len(request.charts)} pages")
227
+
228
+ with open(pdf_path, 'rb') as f:
229
+ pdf_data = f.read()
230
+
231
+ return Response(
232
+ content=pdf_data,
233
+ media_type="application/pdf",
234
+ headers={"Content-Disposition": f"attachment; filename=report_{timestamp}.pdf"}
235
+ )
236
+
237
+ except Exception as e:
238
+ print(f"[ERROR] Error creating PDF: {e}")
239
+ import traceback
240
+ traceback.print_exc()
241
+ raise HTTPException(status_code=500, detail=str(e))
242
+
243
+
244
+ @router.post("/charts-zip")
245
+ async def export_charts_as_zip(request: ChartExportRequest):
246
+ """
247
+ Export all charts as individual PNG files bundled in a ZIP
248
+
249
+ Returns: ZIP file containing all chart PNGs
250
+ """
251
+ try:
252
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
253
+
254
+ # Create temporary directory
255
+ with tempfile.TemporaryDirectory() as temp_dir:
256
+ png_files = []
257
+
258
+ # Export each chart as PNG
259
+ for idx, chart_data in enumerate(request.charts, 1):
260
+ try:
261
+ print(f"[INFO] Processing chart {idx}...")
262
+
263
+ # Get figure data
264
+ figure = chart_data.get('figure')
265
+ if not figure:
266
+ print(f"[WARNING] Chart {idx}: No figure data, skipping")
267
+ continue
268
+
269
+ # Create Plotly figure
270
+ fig = go.Figure(figure)
271
+ print(f"[SUCCESS] Chart {idx}: Created Plotly figure")
272
+
273
+ # Generate filename
274
+ title = request.titles[idx-1] if request.titles and len(request.titles) >= idx else f"chart_{idx}"
275
+ # Sanitize title for filename
276
+ safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).rstrip()
277
+ safe_title = safe_title.replace(' ', '_').lower()
278
+
279
+ filename = f"{idx:02d}_{safe_title}.png"
280
+ filepath = os.path.join(temp_dir, filename)
281
+
282
+ print(f"[INFO] Exporting chart {idx} to bytes...")
283
+
284
+ # Export to PNG using to_image (doesn't hang like write_image)
285
+ img_bytes = fig.to_image(
286
+ format='png',
287
+ width=1000,
288
+ height=800,
289
+ scale=2,
290
+ engine='kaleido'
291
+ )
292
+
293
+ # Write bytes to file
294
+ with open(filepath, 'wb') as f:
295
+ f.write(img_bytes)
296
+
297
+ png_files.append(filepath)
298
+ print(f"[SUCCESS] Chart {idx} exported successfully: {filename}")
299
+
300
+ except Exception as e:
301
+ print(f"[ERROR] Error exporting chart {idx}: {str(e)}")
302
+ import traceback
303
+ traceback.print_exc()
304
+ continue
305
+
306
+ if not png_files:
307
+ raise HTTPException(status_code=400, detail="No charts could be exported")
308
+
309
+ # Create ZIP file
310
+ zip_path = os.path.join(temp_dir, f"charts_{timestamp}.zip")
311
+ with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
312
+ for filepath in png_files:
313
+ zipf.write(filepath, os.path.basename(filepath))
314
+
315
+ # Read ZIP file into memory
316
+ with open(zip_path, 'rb') as f:
317
+ zip_data = f.read()
318
+
319
+ # Return ZIP file
320
+ return Response(
321
+ content=zip_data,
322
+ media_type="application/zip",
323
+ headers={
324
+ "Content-Disposition": f"attachment; filename=charts_{timestamp}.zip"
325
+ }
326
+ )
327
+
328
+ except Exception as e:
329
+ print(f"Error creating ZIP: {e}")
330
+ raise HTTPException(status_code=500, detail=f"Failed to create ZIP: {str(e)}")
331
+
332
+
333
+ @router.post("/dashboard-pdf")
334
+ async def export_dashboard_as_pdf(request: ChartExportRequest):
335
+ """
336
+ Export all charts as a single PDF with each chart on a separate page.
337
+ Includes Auto-Dash logo on every page.
338
+
339
+ Returns: PDF file
340
+ """
341
+ try:
342
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
343
+
344
+ # Create temporary directory
345
+ with tempfile.TemporaryDirectory() as temp_dir:
346
+ pdf_path = os.path.join(temp_dir, f"report_{timestamp}.pdf")
347
+
348
+ # Create PDF
349
+ c = pdf_canvas.Canvas(pdf_path, pagesize=letter)
350
+ page_width, page_height = letter # 612 x 792 points
351
+
352
+ # Load logo
353
+ logo_img = None
354
+ if LOGO_PATH.exists():
355
+ try:
356
+ logo_img = Image.open(LOGO_PATH)
357
+ except Exception as e:
358
+ print(f"Could not load logo: {e}")
359
+
360
+ # Process each chart
361
+ for idx, chart_data in enumerate(request.charts, 1):
362
+ try:
363
+ print(f"[INFO] Processing PDF page {idx}...")
364
+
365
+ # Get figure data
366
+ figure = chart_data.get('figure')
367
+ if not figure:
368
+ print(f"[WARNING] Chart {idx}: No figure data, skipping")
369
+ continue
370
+
371
+ # Create Plotly figure
372
+ fig = go.Figure(figure)
373
+ print(f"[SUCCESS] Chart {idx}: Created Plotly figure for PDF")
374
+
375
+ # Get chart title
376
+ chart_title = request.titles[idx-1] if request.titles and len(request.titles) >= idx else f"Chart {idx}"
377
+
378
+ # === ADD LOGO (CENTERED AT TOP) ===
379
+ if logo_img:
380
+ # Smaller logo size
381
+ logo_height = 25
382
+ logo_aspect = logo_img.width / logo_img.height
383
+ logo_width = logo_height * logo_aspect
384
+
385
+ # Center logo horizontally
386
+ logo_x = (page_width - logo_width) / 2
387
+ logo_y = page_height - logo_height - 15
388
+
389
+ logo_buffer = io.BytesIO()
390
+ logo_img.save(logo_buffer, format='PNG')
391
+ logo_buffer.seek(0)
392
+ logo_reader = ImageReader(logo_buffer)
393
+
394
+ c.drawImage(
395
+ logo_reader,
396
+ logo_x, logo_y,
397
+ width=logo_width,
398
+ height=logo_height,
399
+ preserveAspectRatio=True,
400
+ mask='auto'
401
+ )
402
+
403
+ # Center "Auto-Dash" text below logo
404
+ # c.setFont("Helvetica-Bold", 12)
405
+ # c.setFillColorRGB(0.2, 0.2, 0.2)
406
+ # c.drawCentredString(page_width / 2, logo_y - 15, "Auto-Dash")
407
+
408
+ # # Center subtitle below
409
+ # c.setFont("Helvetica", 9)
410
+ # c.setFillColorRGB(0.5, 0.5, 0.5)
411
+ # c.drawCentredString(page_width / 2, logo_y - 28, "Analytics Dashboard")
412
+
413
+ # === ADD CHART ===
414
+ # Export chart to PNG bytes
415
+ print(f"[INFO] Exporting chart {idx} to bytes for PDF...")
416
+ img_bytes = fig.to_image(format='png', width=1000, height=800, scale=2, engine='kaleido')
417
+ chart_img = Image.open(io.BytesIO(img_bytes))
418
+ print(f"[SUCCESS] Chart {idx} exported to bytes successfully")
419
+
420
+ img_width, img_height = chart_img.size
421
+ aspect_ratio = img_width / img_height
422
+
423
+ # Calculate available space
424
+ margin = 36
425
+ top_margin = 70 if logo_img else 36 # Smaller margin for centered compact logo
426
+ bottom_margin = 60
427
+
428
+ available_width = page_width - (2 * margin)
429
+ available_height = page_height - top_margin - bottom_margin
430
+
431
+ # Scale to fit
432
+ if available_width / available_height > aspect_ratio:
433
+ scaled_height = available_height
434
+ scaled_width = scaled_height * aspect_ratio
435
+ else:
436
+ scaled_width = available_width
437
+ scaled_height = scaled_width / aspect_ratio
438
+
439
+ # Center chart
440
+ # Center chart horizontally and vertically
441
+ chart_x = (page_width - scaled_width) / 2
442
+ chart_y = bottom_margin + (available_height - scaled_height) / 2
443
+
444
+ # Draw chart
445
+ chart_buffer = io.BytesIO()
446
+ chart_img.save(chart_buffer, format='PNG')
447
+ chart_buffer.seek(0)
448
+ chart_reader = ImageReader(chart_buffer)
449
+
450
+ c.drawImage(
451
+ chart_reader,
452
+ chart_x, chart_y,
453
+ width=scaled_width,
454
+ height=scaled_height,
455
+ preserveAspectRatio=True
456
+ )
457
+
458
+ # === ADD FOOTER ===
459
+ c.setFont("Helvetica", 10)
460
+ c.setFillColorRGB(0.4, 0.4, 0.4)
461
+ c.drawCentredString(
462
+ page_width / 2, 25,
463
+ f"Page {idx} of {len(request.charts)}"
464
+ )
465
+
466
+ c.setFont("Helvetica", 9)
467
+ c.drawString(margin, 25, chart_title)
468
+
469
+ c.setFont("Helvetica", 8)
470
+ c.drawRightString(
471
+ page_width - margin, 25,
472
+ datetime.now().strftime("%B %d, %Y %I:%M %p")
473
+ )
474
+
475
+ # New page if not last
476
+ if idx < len(request.charts):
477
+ c.showPage()
478
+
479
+ except Exception as e:
480
+ print(f"[ERROR] Error adding chart {idx} to PDF: {str(e)}")
481
+ import traceback
482
+ traceback.print_exc()
483
+ continue
484
+
485
+ # Save PDF
486
+ c.save()
487
+
488
+ # Read PDF into memory
489
+ with open(pdf_path, 'rb') as f:
490
+ pdf_data = f.read()
491
+
492
+ # Return PDF
493
+ return Response(
494
+ content=pdf_data,
495
+ media_type="application/pdf",
496
+ headers={
497
+ "Content-Disposition": f"attachment; filename=report_{timestamp}.pdf"
498
+ }
499
+ )
500
+
501
+ except Exception as e:
502
+ print(f"Error creating PDF: {e}")
503
+ raise HTTPException(status_code=500, detail=f"Failed to create PDF: {str(e)}")
504
+
app/routes/forms.py ADDED
@@ -0,0 +1,789 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Forms API Routes
3
+ ================
4
+
5
+ Routes for form CRUD operations, questions, and conditional logic.
6
+ """
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, status
9
+ from sqlalchemy.orm import Session
10
+ from sqlalchemy import desc
11
+ from typing import List
12
+ import secrets
13
+ from datetime import datetime
14
+
15
+ from ..core.db import get_db
16
+ from ..core.security import get_current_user
17
+ from ..models import User, Form, FormQuestion, ConditionalRule, PublicForm, QuestionType, ConditionType
18
+ from ..schemas.form import (
19
+ FormCreate, FormUpdate, FormResponse, FormGenerationResponse,
20
+ QuestionCreate, QuestionUpdate, QuestionResponse,
21
+ ConditionalRuleCreate, ConditionalRuleResponse,
22
+ PublicFormCreate, PublicFormResponse,
23
+ ChatMessage, ChatResponse
24
+ )
25
+ from ..services.form_creator import generate_form_spec, edit_form_spec, validate_question_type, validate_condition_type
26
+ from ..services.credit_service import credit_service
27
+
28
+ import logging
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ router = APIRouter(prefix="/api/forms", tags=["forms"])
33
+
34
+
35
+ @router.post("/generate", response_model=FormGenerationResponse)
36
+ async def generate_form(
37
+ form_data: FormCreate,
38
+ current_user: User = Depends(get_current_user),
39
+ db: Session = Depends(get_db)
40
+ ):
41
+ """
42
+ Generate a new form from natural language description using AI.
43
+ Costs 5 credits.
44
+ """
45
+ # Check credits
46
+ if not credit_service.check_sufficient_credits(db, current_user.id, 5):
47
+ raise HTTPException(
48
+ status_code=status.HTTP_402_PAYMENT_REQUIRED,
49
+ detail="Insufficient credits. Please upgrade your plan."
50
+ )
51
+
52
+ try:
53
+ # Generate form using AI
54
+ form_spec, questions_spec, rules_spec = await generate_form_spec(
55
+ user_query=form_data.user_query,
56
+ user_id=current_user.id
57
+ )
58
+
59
+ # Create form in database
60
+ new_form = Form(
61
+ user_id=current_user.id,
62
+ title=form_spec.get("title", "New Form"),
63
+ description=form_spec.get("description"),
64
+ settings=form_spec.get("settings", {})
65
+ )
66
+ db.add(new_form)
67
+ db.flush() # Get form ID
68
+
69
+ # Create questions
70
+ question_id_map = {} # Map order to actual ID
71
+ for q_spec in questions_spec:
72
+ # Final safety check: ensure matrix/ranking settings are lists
73
+ settings = q_spec.get("settings", {})
74
+ question_type = q_spec.get("question_type", "")
75
+
76
+ if question_type == "matrix":
77
+ if isinstance(settings.get("rows"), (int, float)):
78
+ num_rows = max(1, int(settings["rows"]))
79
+ settings["rows"] = [f"Row {i+1}" for i in range(num_rows)]
80
+ if isinstance(settings.get("columns"), (int, float)):
81
+ num_cols = max(1, int(settings["columns"]))
82
+ settings["columns"] = [f"Column {i+1}" for i in range(num_cols)]
83
+
84
+ if question_type == "ranking":
85
+ if isinstance(settings.get("ranking_items"), (int, float)):
86
+ num_items = max(2, int(settings["ranking_items"]))
87
+ settings["ranking_items"] = [f"Item {i+1}" for i in range(num_items)]
88
+
89
+ question = FormQuestion(
90
+ form_id=new_form.id,
91
+ question_order=q_spec["question_order"],
92
+ question_type=QuestionType(q_spec["question_type"]),
93
+ question_text=q_spec["question_text"],
94
+ description=q_spec.get("description"),
95
+ required=q_spec.get("required", False),
96
+ settings=settings
97
+ )
98
+ db.add(question)
99
+ db.flush()
100
+ question_id_map[q_spec["question_order"]] = question.id
101
+
102
+ # Create conditional rules
103
+ for rule_spec in rules_spec:
104
+ trigger_idx = rule_spec.get("trigger_question_index")
105
+ target_idx = rule_spec.get("target_question_index")
106
+
107
+ if trigger_idx in question_id_map and target_idx in question_id_map:
108
+ rule = ConditionalRule(
109
+ form_id=new_form.id,
110
+ trigger_question_id=question_id_map[trigger_idx],
111
+ target_question_id=question_id_map[target_idx],
112
+ condition_type=ConditionType(rule_spec.get("condition_type", "equals")),
113
+ condition_value=rule_spec.get("condition_value"),
114
+ action=rule_spec.get("action", "show")
115
+ )
116
+ db.add(rule)
117
+
118
+ db.commit()
119
+ db.refresh(new_form)
120
+
121
+ # Deduct credits
122
+ credit_service.deduct_credits(
123
+ user_id=current_user.id,
124
+ amount=5,
125
+ description=f"Generated form: {new_form.title}",
126
+ db=db
127
+ )
128
+
129
+ return FormGenerationResponse(
130
+ form=FormResponse.model_validate(new_form),
131
+ message="Form generated successfully"
132
+ )
133
+
134
+ except Exception as e:
135
+ db.rollback()
136
+ logger.error(f"Form generation failed: {e}")
137
+ raise HTTPException(
138
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
139
+ detail=f"Form generation failed: {str(e)}"
140
+ )
141
+
142
+
143
+ @router.get("", response_model=List[FormResponse])
144
+ async def list_forms(
145
+ current_user: User = Depends(get_current_user),
146
+ db: Session = Depends(get_db),
147
+ limit: int = 50,
148
+ offset: int = 0,
149
+ sort: str = "created_at"
150
+ ):
151
+ """Get all forms for the current user, optionally sorted by created_at or updated_at"""
152
+ query = db.query(Form).filter(Form.user_id == current_user.id)
153
+
154
+ # Apply sorting
155
+ if sort == "updated_at":
156
+ query = query.order_by(desc(Form.updated_at))
157
+ elif sort == "created_at":
158
+ query = query.order_by(desc(Form.created_at))
159
+ else:
160
+ # Default to created_at if invalid sort parameter
161
+ query = query.order_by(desc(Form.created_at))
162
+
163
+ forms = query.limit(limit).offset(offset).all()
164
+
165
+ return [FormResponse.model_validate(form) for form in forms]
166
+
167
+
168
+ @router.get("/{form_id}", response_model=FormResponse)
169
+ async def get_form(
170
+ form_id: int,
171
+ current_user: User = Depends(get_current_user),
172
+ db: Session = Depends(get_db)
173
+ ):
174
+ """Get a specific form with all questions and rules"""
175
+ form = db.query(Form).filter(
176
+ Form.id == form_id,
177
+ Form.user_id == current_user.id
178
+ ).first()
179
+
180
+ if not form:
181
+ raise HTTPException(
182
+ status_code=status.HTTP_404_NOT_FOUND,
183
+ detail="Form not found"
184
+ )
185
+
186
+ return FormResponse.model_validate(form)
187
+
188
+
189
+ @router.put("/{form_id}", response_model=FormResponse)
190
+ async def update_form(
191
+ form_id: int,
192
+ form_update: FormUpdate,
193
+ current_user: User = Depends(get_current_user),
194
+ db: Session = Depends(get_db)
195
+ ):
196
+ """Update form metadata (title, description, settings)"""
197
+ form = db.query(Form).filter(
198
+ Form.id == form_id,
199
+ Form.user_id == current_user.id
200
+ ).first()
201
+
202
+ if not form:
203
+ raise HTTPException(
204
+ status_code=status.HTTP_404_NOT_FOUND,
205
+ detail="Form not found"
206
+ )
207
+
208
+ # Update fields
209
+ if form_update.title is not None:
210
+ form.title = form_update.title
211
+ if form_update.description is not None:
212
+ form.description = form_update.description
213
+ if form_update.settings is not None:
214
+ form.settings = form_update.settings.model_dump()
215
+
216
+ form.updated_at = datetime.utcnow()
217
+
218
+ db.commit()
219
+ db.refresh(form)
220
+
221
+ return FormResponse.model_validate(form)
222
+
223
+
224
+ @router.delete("/{form_id}", status_code=status.HTTP_204_NO_CONTENT)
225
+ async def delete_form(
226
+ form_id: int,
227
+ current_user: User = Depends(get_current_user),
228
+ db: Session = Depends(get_db)
229
+ ):
230
+ """Delete a form and all associated data"""
231
+ form = db.query(Form).filter(
232
+ Form.id == form_id,
233
+ Form.user_id == current_user.id
234
+ ).first()
235
+
236
+ if not form:
237
+ raise HTTPException(
238
+ status_code=status.HTTP_404_NOT_FOUND,
239
+ detail="Form not found"
240
+ )
241
+
242
+ db.delete(form)
243
+ db.commit()
244
+
245
+ return None
246
+
247
+
248
+ # Question endpoints
249
+ @router.post("/{form_id}/questions", response_model=QuestionResponse)
250
+ async def add_question(
251
+ form_id: int,
252
+ question_data: QuestionCreate,
253
+ current_user: User = Depends(get_current_user),
254
+ db: Session = Depends(get_db)
255
+ ):
256
+ """Add a new question to a form"""
257
+ form = db.query(Form).filter(
258
+ Form.id == form_id,
259
+ Form.user_id == current_user.id
260
+ ).first()
261
+
262
+ if not form:
263
+ raise HTTPException(
264
+ status_code=status.HTTP_404_NOT_FOUND,
265
+ detail="Form not found"
266
+ )
267
+
268
+ question = FormQuestion(
269
+ form_id=form_id,
270
+ question_order=question_data.question_order,
271
+ question_type=question_data.question_type,
272
+ question_text=question_data.question_text,
273
+ description=question_data.description,
274
+ required=question_data.required,
275
+ settings=question_data.settings.model_dump() if question_data.settings else {}
276
+ )
277
+
278
+ db.add(question)
279
+ db.commit()
280
+ db.refresh(question)
281
+
282
+ return QuestionResponse.model_validate(question)
283
+
284
+
285
+ @router.put("/{form_id}/questions/{question_id}", response_model=QuestionResponse)
286
+ async def update_question(
287
+ form_id: int,
288
+ question_id: int,
289
+ question_update: QuestionUpdate,
290
+ current_user: User = Depends(get_current_user),
291
+ db: Session = Depends(get_db)
292
+ ):
293
+ """Update a question"""
294
+ form = db.query(Form).filter(
295
+ Form.id == form_id,
296
+ Form.user_id == current_user.id
297
+ ).first()
298
+
299
+ if not form:
300
+ raise HTTPException(
301
+ status_code=status.HTTP_404_NOT_FOUND,
302
+ detail="Form not found"
303
+ )
304
+
305
+ question = db.query(FormQuestion).filter(
306
+ FormQuestion.id == question_id,
307
+ FormQuestion.form_id == form_id
308
+ ).first()
309
+
310
+ if not question:
311
+ raise HTTPException(
312
+ status_code=status.HTTP_404_NOT_FOUND,
313
+ detail="Question not found"
314
+ )
315
+
316
+ # Update fields
317
+ if question_update.question_text is not None:
318
+ question.question_text = question_update.question_text
319
+ if question_update.question_type is not None:
320
+ question.question_type = question_update.question_type
321
+ if question_update.description is not None:
322
+ question.description = question_update.description
323
+ if question_update.required is not None:
324
+ question.required = question_update.required
325
+ if question_update.question_order is not None:
326
+ question.question_order = question_update.question_order
327
+ if question_update.settings is not None:
328
+ question.settings = question_update.settings.model_dump()
329
+
330
+ question.updated_at = datetime.utcnow()
331
+
332
+ db.commit()
333
+ db.refresh(question)
334
+
335
+ return QuestionResponse.model_validate(question)
336
+
337
+
338
+ @router.delete("/{form_id}/questions/{question_id}", status_code=status.HTTP_204_NO_CONTENT)
339
+ async def delete_question(
340
+ form_id: int,
341
+ question_id: int,
342
+ current_user: User = Depends(get_current_user),
343
+ db: Session = Depends(get_db)
344
+ ):
345
+ """Delete a question"""
346
+ form = db.query(Form).filter(
347
+ Form.id == form_id,
348
+ Form.user_id == current_user.id
349
+ ).first()
350
+
351
+ if not form:
352
+ raise HTTPException(
353
+ status_code=status.HTTP_404_NOT_FOUND,
354
+ detail="Form not found"
355
+ )
356
+
357
+ question = db.query(FormQuestion).filter(
358
+ FormQuestion.id == question_id,
359
+ FormQuestion.form_id == form_id
360
+ ).first()
361
+
362
+ if not question:
363
+ raise HTTPException(
364
+ status_code=status.HTTP_404_NOT_FOUND,
365
+ detail="Question not found"
366
+ )
367
+
368
+ db.delete(question)
369
+ db.commit()
370
+
371
+ return None
372
+
373
+
374
+ @router.post("/{form_id}/questions/{question_id}/regenerate")
375
+ async def regenerate_question(
376
+ form_id: int,
377
+ question_id: int,
378
+ context: dict,
379
+ current_user: User = Depends(get_current_user),
380
+ db: Session = Depends(get_db)
381
+ ):
382
+ """
383
+ Regenerate/edit a question using AI based on user's prompt.
384
+ """
385
+ # Get the form
386
+ form = db.query(Form).filter(
387
+ Form.id == form_id,
388
+ Form.user_id == current_user.id
389
+ ).first()
390
+
391
+ if not form:
392
+ raise HTTPException(
393
+ status_code=status.HTTP_404_NOT_FOUND,
394
+ detail="Form not found"
395
+ )
396
+
397
+ # Get the question
398
+ question = db.query(FormQuestion).filter(
399
+ FormQuestion.id == question_id,
400
+ FormQuestion.form_id == form_id
401
+ ).first()
402
+
403
+ if not question:
404
+ raise HTTPException(
405
+ status_code=status.HTTP_404_NOT_FOUND,
406
+ detail="Question not found"
407
+ )
408
+
409
+ try:
410
+ # Get user's edit prompt
411
+ user_prompt = context.get("prompt", context.get("context", "Improve this question"))
412
+
413
+ # Import the signature for question generation
414
+ from ..services.agents import FormQuestionGeneratorSignature
415
+ import dspy
416
+
417
+ # Create context for regeneration with user's instructions
418
+ question_context = {
419
+ "brief": f"Edit this question based on user instructions: '{user_prompt}'. Current question: '{question.question_text}'. Current type: {question.question_type}. Current description: '{question.description or 'None'}'",
420
+ "index": question.question_order,
421
+ "form_context": {
422
+ "title": form.title,
423
+ "description": form.description,
424
+ "existing_questions": [q.question_text for q in form.questions if q.id != question_id],
425
+ "user_edit_instructions": user_prompt
426
+ }
427
+ }
428
+
429
+ # Generate new question spec
430
+ generator = dspy.Predict(FormQuestionGeneratorSignature)
431
+ result = generator(
432
+ question_context=str(question_context),
433
+ form_title=form.title
434
+ )
435
+
436
+ # Parse and validate the result
437
+ import json
438
+ question_spec = json.loads(result.question_spec)
439
+
440
+ # Update the question
441
+ question.question_text = question_spec.get("text", question.question_text)
442
+ question.description = question_spec.get("description")
443
+ question.required = question_spec.get("required", question.required)
444
+
445
+ # Update question type if specified
446
+ if "question_type" in question_spec:
447
+ try:
448
+ question.question_type = QuestionType(question_spec["question_type"])
449
+ except ValueError:
450
+ pass # Keep existing type if invalid
451
+
452
+ # Update settings if provided
453
+ if "settings" in question_spec:
454
+ question.settings = question_spec["settings"]
455
+
456
+ db.commit()
457
+ db.refresh(question)
458
+
459
+ return {"question": QuestionResponse.model_validate(question), "message": "Question updated successfully"}
460
+
461
+ except Exception as e:
462
+ logger.error(f"Question regeneration error: {str(e)}")
463
+ raise HTTPException(
464
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
465
+ detail=f"Failed to regenerate question: {str(e)}"
466
+ )
467
+
468
+
469
+ # Conditional logic endpoints
470
+ @router.post("/{form_id}/conditional", response_model=ConditionalRuleResponse)
471
+ async def add_conditional_rule(
472
+ form_id: int,
473
+ rule_data: ConditionalRuleCreate,
474
+ current_user: User = Depends(get_current_user),
475
+ db: Session = Depends(get_db)
476
+ ):
477
+ """Add a conditional logic rule"""
478
+ form = db.query(Form).filter(
479
+ Form.id == form_id,
480
+ Form.user_id == current_user.id
481
+ ).first()
482
+
483
+ if not form:
484
+ raise HTTPException(
485
+ status_code=status.HTTP_404_NOT_FOUND,
486
+ detail="Form not found"
487
+ )
488
+
489
+ rule = ConditionalRule(
490
+ form_id=form_id,
491
+ trigger_question_id=rule_data.trigger_question_id,
492
+ target_question_id=rule_data.target_question_id,
493
+ condition_type=rule_data.condition_type,
494
+ condition_value=rule_data.condition_value,
495
+ action=rule_data.action
496
+ )
497
+
498
+ db.add(rule)
499
+ db.commit()
500
+ db.refresh(rule)
501
+
502
+ return ConditionalRuleResponse.model_validate(rule)
503
+
504
+
505
+ @router.delete("/{form_id}/conditional/{rule_id}", status_code=status.HTTP_204_NO_CONTENT)
506
+ async def delete_conditional_rule(
507
+ form_id: int,
508
+ rule_id: int,
509
+ current_user: User = Depends(get_current_user),
510
+ db: Session = Depends(get_db)
511
+ ):
512
+ """Delete a conditional logic rule"""
513
+ form = db.query(Form).filter(
514
+ Form.id == form_id,
515
+ Form.user_id == current_user.id
516
+ ).first()
517
+
518
+ if not form:
519
+ raise HTTPException(
520
+ status_code=status.HTTP_404_NOT_FOUND,
521
+ detail="Form not found"
522
+ )
523
+
524
+ rule = db.query(ConditionalRule).filter(
525
+ ConditionalRule.id == rule_id,
526
+ ConditionalRule.form_id == form_id
527
+ ).first()
528
+
529
+ if not rule:
530
+ raise HTTPException(
531
+ status_code=status.HTTP_404_NOT_FOUND,
532
+ detail="Rule not found"
533
+ )
534
+
535
+ db.delete(rule)
536
+ db.commit()
537
+
538
+ return None
539
+
540
+
541
+ # Public sharing endpoint
542
+ @router.post("/{form_id}/share", response_model=PublicFormResponse)
543
+ async def share_form(
544
+ form_id: int,
545
+ share_data: PublicFormCreate,
546
+ current_user: User = Depends(get_current_user),
547
+ db: Session = Depends(get_db)
548
+ ):
549
+ """Create a public shareable link for a form"""
550
+ form = db.query(Form).filter(
551
+ Form.id == form_id,
552
+ Form.user_id == current_user.id
553
+ ).first()
554
+
555
+ if not form:
556
+ raise HTTPException(
557
+ status_code=status.HTTP_404_NOT_FOUND,
558
+ detail="Form not found"
559
+ )
560
+
561
+ # Check if public form already exists
562
+ existing = db.query(PublicForm).filter(
563
+ PublicForm.form_id == form_id
564
+ ).first()
565
+
566
+ if existing:
567
+ return PublicFormResponse.model_validate(existing)
568
+
569
+ # Generate unique share token
570
+ share_token = secrets.token_urlsafe(32)
571
+
572
+ public_form = PublicForm(
573
+ form_id=form_id,
574
+ user_id=current_user.id,
575
+ share_token=share_token,
576
+ is_public=True,
577
+ expires_at=share_data.expires_at,
578
+ allow_multiple_submissions=share_data.allow_multiple_submissions,
579
+ collect_email=share_data.collect_email,
580
+ custom_thank_you_message=share_data.custom_thank_you_message
581
+ )
582
+
583
+ db.add(public_form)
584
+ db.commit()
585
+ db.refresh(public_form)
586
+
587
+ return PublicFormResponse.model_validate(public_form)
588
+
589
+
590
+ @router.get("/{form_id}/share", response_model=PublicFormResponse)
591
+ async def get_share_info(
592
+ form_id: int,
593
+ current_user: User = Depends(get_current_user),
594
+ db: Session = Depends(get_db)
595
+ ):
596
+ """Get public share information for a form"""
597
+ form = db.query(Form).filter(
598
+ Form.id == form_id,
599
+ Form.user_id == current_user.id
600
+ ).first()
601
+
602
+ if not form:
603
+ raise HTTPException(
604
+ status_code=status.HTTP_404_NOT_FOUND,
605
+ detail="Form not found"
606
+ )
607
+
608
+ public_form = db.query(PublicForm).filter(
609
+ PublicForm.form_id == form_id
610
+ ).first()
611
+
612
+ if not public_form:
613
+ raise HTTPException(
614
+ status_code=status.HTTP_404_NOT_FOUND,
615
+ detail="Form is not shared"
616
+ )
617
+
618
+ return PublicFormResponse.model_validate(public_form)
619
+
620
+
621
+ # Chat editing endpoint
622
+ @router.post("/{form_id}/chat", response_model=ChatResponse)
623
+ async def chat_edit_form(
624
+ form_id: int,
625
+ chat_data: ChatMessage,
626
+ current_user: User = Depends(get_current_user),
627
+ db: Session = Depends(get_db)
628
+ ):
629
+ """
630
+ Edit form via natural language chat (add/edit components).
631
+ Uses AI to understand and apply changes to the form.
632
+ """
633
+ from sqlalchemy import func
634
+ import json
635
+
636
+ # Get the form
637
+ form = db.query(Form).filter(
638
+ Form.id == form_id,
639
+ Form.user_id == current_user.id
640
+ ).first()
641
+
642
+ if not form:
643
+ raise HTTPException(
644
+ status_code=status.HTTP_404_NOT_FOUND,
645
+ detail="Form not found"
646
+ )
647
+
648
+ try:
649
+ # Prepare current form structure
650
+ current_form_structure = {
651
+ "title": form.title,
652
+ "description": form.description,
653
+ "questions": [
654
+ {
655
+ "id": q.id,
656
+ "question_order": q.question_order,
657
+ "question_type": q.question_type.value,
658
+ "question_text": q.question_text,
659
+ "description": q.description,
660
+ "required": q.required,
661
+ "settings": q.settings
662
+ }
663
+ for q in form.questions
664
+ ],
665
+ "settings": form.settings
666
+ }
667
+
668
+ # Use edit_form_spec to process the chat message
669
+ result = await edit_form_spec(
670
+ current_form=current_form_structure,
671
+ edit_request=chat_data.message,
672
+ user_id=current_user.id
673
+ )
674
+
675
+ route = result.get("route", "unknown")
676
+ response = result.get("response")
677
+ changes_made = result.get("changes_made")
678
+
679
+ # If route is add_component, add the new component
680
+ if route == "add_component" and response:
681
+ # Extract component spec from response
682
+ component_spec = None
683
+ if hasattr(response, 'component_spec'):
684
+ try:
685
+ component_spec = json.loads(response.component_spec) if isinstance(response.component_spec, str) else response.component_spec
686
+ except:
687
+ component_spec = None
688
+
689
+ if component_spec:
690
+ # Get the next question order
691
+ max_order = db.query(func.max(FormQuestion.question_order)).filter(
692
+ FormQuestion.form_id == form_id
693
+ ).scalar() or -1
694
+
695
+ # Create new question
696
+ new_question = FormQuestion(
697
+ form_id=form_id,
698
+ question_order=max_order + 1,
699
+ question_type=QuestionType(component_spec.get("question_type", "short_answer")),
700
+ question_text=component_spec.get("question_text", "New Question"),
701
+ description=component_spec.get("description"),
702
+ required=component_spec.get("required", False),
703
+ settings=component_spec.get("settings", {})
704
+ )
705
+ db.add(new_question)
706
+ db.commit()
707
+ db.refresh(new_question)
708
+
709
+ return ChatResponse(
710
+ route=route,
711
+ response={
712
+ "new_question": QuestionResponse.model_validate(new_question).model_dump(),
713
+ "message": "Component added successfully"
714
+ },
715
+ changes_made=f"Added new {component_spec.get('question_type')} component"
716
+ )
717
+
718
+ # If route is edit_component, apply changes
719
+ elif route == "edit_component" and response:
720
+ # Extract updated form from response
721
+ updated_form = None
722
+ if hasattr(response, 'updated_form'):
723
+ try:
724
+ updated_form = json.loads(response.updated_form) if isinstance(response.updated_form, str) else response.updated_form
725
+ except:
726
+ updated_form = None
727
+
728
+ if updated_form and "components" in updated_form:
729
+ # Apply updates to existing questions
730
+ for component in updated_form.get("components", []):
731
+ comp_id = component.get("component_id")
732
+ if comp_id and comp_id.startswith("comp_"):
733
+ # Extract order from component_id (e.g., comp_1 -> 0)
734
+ try:
735
+ order_idx = int(comp_id.split("_")[1]) - 1
736
+ question = db.query(FormQuestion).filter(
737
+ FormQuestion.form_id == form_id,
738
+ FormQuestion.question_order == order_idx
739
+ ).first()
740
+
741
+ if question:
742
+ if "question_text" in component:
743
+ question.question_text = component["question_text"]
744
+ if "question_type" in component:
745
+ question.question_type = QuestionType(component["question_type"])
746
+ if "description" in component:
747
+ question.description = component["description"]
748
+ if "required" in component:
749
+ question.required = component["required"]
750
+ if "settings" in component:
751
+ question.settings = component["settings"]
752
+
753
+ question.updated_at = datetime.utcnow()
754
+ except (ValueError, IndexError):
755
+ continue
756
+
757
+ db.commit()
758
+
759
+ return ChatResponse(
760
+ route=route,
761
+ response={
762
+ "message": "Form updated successfully",
763
+ "form": FormResponse.model_validate(form).model_dump()
764
+ },
765
+ changes_made=changes_made or "Form updated based on your request"
766
+ )
767
+
768
+ # For general queries or unclear requests, return the response as-is
769
+ response_data = response
770
+ if hasattr(response, 'answer'):
771
+ response_data = {"answer": response.answer}
772
+ elif hasattr(response, 'model_dump'):
773
+ response_data = response.model_dump()
774
+ elif not isinstance(response, dict):
775
+ response_data = {"message": str(response)}
776
+
777
+ return ChatResponse(
778
+ route=route,
779
+ response=response_data,
780
+ changes_made=changes_made
781
+ )
782
+
783
+ except Exception as e:
784
+ logger.error(f"Chat edit failed: {e}", exc_info=True)
785
+ raise HTTPException(
786
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
787
+ detail=f"Chat processing failed: {str(e)}"
788
+ )
789
+
app/routes/google.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from ..oauth_google import router as google_router
2
+
3
+ router = google_router
4
+
5
+
app/routes/health.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+
3
+
4
+ router = APIRouter(prefix="/api", tags=["health"])
5
+
6
+
7
+ @router.get("/health")
8
+ def health():
9
+ return {"ok": True}
10
+
11
+
app/routes/payment.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from ..stripe_routes import router as stripe_router
2
+
3
+ # Re-export stripe router under routes package for consistency
4
+ router = stripe_router
5
+
6
+
app/routes/plans.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Subscription plan routes
3
+ """
4
+ from fastapi import APIRouter, Depends, HTTPException
5
+ from sqlalchemy.orm import Session
6
+ from typing import List, Dict, Any
7
+ from pydantic import BaseModel
8
+ from decimal import Decimal
9
+
10
+ from ..core.db import get_db
11
+ from ..services.plan_service import plan_service
12
+
13
+ router = APIRouter(prefix="/api/plans", tags=["plans"])
14
+
15
+
16
+ class PlanFeatures(BaseModel):
17
+ """Plan features model"""
18
+ max_datasets: int | None = None
19
+ max_file_size_mb: int | None = None
20
+ export_formats: List[str] | None = None
21
+ priority_support: bool | None = None
22
+ custom_branding: bool | None = None
23
+ api_access: bool | None = None
24
+
25
+
26
+ class PlanResponse(BaseModel):
27
+ """Response model for a subscription plan"""
28
+ id: int
29
+ name: str
30
+ price_monthly: float
31
+ price_yearly: float | None = None
32
+ credits_per_month: int
33
+ credits_per_analyze: int
34
+ credits_per_edit: int
35
+ features: Dict[str, Any]
36
+ stripe_price_id: str | None # Legacy field
37
+ stripe_price_id_monthly: str | None = None
38
+ stripe_price_id_yearly: str | None = None
39
+ is_active: bool
40
+ sort_order: int
41
+
42
+
43
+ @router.get("", response_model=List[PlanResponse])
44
+ def get_all_plans(db: Session = Depends(get_db)):
45
+ """
46
+ Get all active subscription plans
47
+
48
+ Returns:
49
+ List of active plans sorted by display order
50
+ """
51
+ try:
52
+ plans = plan_service.get_all_active_plans(db)
53
+
54
+ return [
55
+ {
56
+ "id": plan.id,
57
+ "name": plan.name,
58
+ "price_monthly": float(plan.price_monthly),
59
+ "price_yearly": float(plan.price_yearly) if plan.price_yearly else None,
60
+ "credits_per_month": plan.credits_per_month,
61
+ "credits_per_analyze": plan.credits_per_analyze,
62
+ "credits_per_edit": plan.credits_per_edit,
63
+ "features": plan.features or {},
64
+ "stripe_price_id": plan.stripe_price_id, # Legacy
65
+ "stripe_price_id_monthly": plan.stripe_price_id_monthly,
66
+ "stripe_price_id_yearly": plan.stripe_price_id_yearly,
67
+ "is_active": plan.is_active,
68
+ "sort_order": plan.sort_order
69
+ }
70
+ for plan in plans
71
+ ]
72
+ except Exception as e:
73
+ raise HTTPException(status_code=500, detail=str(e))
74
+
75
+
76
+ @router.get("/{plan_id}", response_model=PlanResponse)
77
+ def get_plan(plan_id: int, db: Session = Depends(get_db)):
78
+ """
79
+ Get details of a specific plan
80
+
81
+ Args:
82
+ plan_id: Plan ID
83
+
84
+ Returns:
85
+ Plan details
86
+ """
87
+ try:
88
+ plan = plan_service.get_plan_by_id(db, plan_id)
89
+
90
+ if not plan:
91
+ raise HTTPException(status_code=404, detail="Plan not found")
92
+
93
+ return {
94
+ "id": plan.id,
95
+ "name": plan.name,
96
+ "price_monthly": float(plan.price_monthly),
97
+ "price_yearly": float(plan.price_yearly) if plan.price_yearly else None,
98
+ "credits_per_month": plan.credits_per_month,
99
+ "credits_per_analyze": plan.credits_per_analyze,
100
+ "credits_per_edit": plan.credits_per_edit,
101
+ "features": plan.features or {},
102
+ "stripe_price_id": plan.stripe_price_id, # Legacy
103
+ "stripe_price_id_monthly": plan.stripe_price_id_monthly,
104
+ "stripe_price_id_yearly": plan.stripe_price_id_yearly,
105
+ "is_active": plan.is_active,
106
+ "sort_order": plan.sort_order
107
+ }
108
+ except HTTPException:
109
+ raise
110
+ except Exception as e:
111
+ raise HTTPException(status_code=500, detail=str(e))
112
+
app/routes/responses.py ADDED
@@ -0,0 +1,594 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Form Responses API Routes
3
+ ==========================
4
+
5
+ Routes for form submission and response management.
6
+ """
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, status, Request
9
+ from sqlalchemy.orm import Session
10
+ from sqlalchemy import desc, func
11
+ from typing import List
12
+ import json
13
+ import csv
14
+ import io
15
+ from datetime import datetime
16
+
17
+ from ..core.db import get_db
18
+ from ..core.security import get_current_user
19
+ from ..models import User, Form, FormQuestion, FormResponse, ResponseAnswer, PublicForm
20
+ from ..schemas.form import (
21
+ SubmissionCreate, SubmissionResponse,
22
+ FormResponseDetail, FormResponsesList, ResponseAnswerDetail,
23
+ PublicFormDetail, FormResponse as FormSchema
24
+ )
25
+ from ..schemas.validation import PartialSubmissionCreate, AutoSaveResponse
26
+ from ..services.validation_service import validation_service
27
+ from ..services.analytics_service import analytics_service
28
+ from ..services.webhook_service import webhook_service
29
+ from ..middleware.rate_limiter import rate_limiter
30
+
31
+ import logging
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ router = APIRouter(prefix="/api", tags=["responses"])
36
+
37
+
38
+ @router.post("/public/forms/{token}/submit", response_model=SubmissionResponse)
39
+ async def submit_form(
40
+ token: str,
41
+ submission: SubmissionCreate,
42
+ request: Request,
43
+ db: Session = Depends(get_db)
44
+ ):
45
+ """
46
+ Public endpoint for submitting a form response.
47
+ No authentication required.
48
+ """
49
+ # Find public form by token
50
+ public_form = db.query(PublicForm).filter(
51
+ PublicForm.share_token == token,
52
+ PublicForm.is_public == True
53
+ ).first()
54
+
55
+ if not public_form:
56
+ raise HTTPException(
57
+ status_code=status.HTTP_404_NOT_FOUND,
58
+ detail="Form not found or no longer accepting responses"
59
+ )
60
+
61
+ # Check if expired
62
+ if public_form.expires_at and public_form.expires_at < datetime.utcnow():
63
+ raise HTTPException(
64
+ status_code=status.HTTP_410_GONE,
65
+ detail="This form has expired"
66
+ )
67
+
68
+ # Get the form
69
+ form = db.query(Form).filter(Form.id == public_form.form_id).first()
70
+
71
+ if not form:
72
+ raise HTTPException(
73
+ status_code=status.HTTP_404_NOT_FOUND,
74
+ detail="Form not found"
75
+ )
76
+
77
+ # Get client IP
78
+ client_ip = request.client.host if request.client else None
79
+
80
+ try:
81
+ # Create form response
82
+ form_response = FormResponse(
83
+ form_id=form.id,
84
+ ip_address=client_ip,
85
+ submission_metadata=submission.metadata or {}
86
+ )
87
+ db.add(form_response)
88
+ db.flush()
89
+
90
+ # Create answers
91
+ for answer_data in submission.answers:
92
+ answer = ResponseAnswer(
93
+ form_response_id=form_response.id,
94
+ form_question_id=answer_data.question_id,
95
+ answer_value=answer_data.answer_value.model_dump()
96
+ )
97
+ db.add(answer)
98
+
99
+ db.commit()
100
+ db.refresh(form_response)
101
+
102
+ return SubmissionResponse(
103
+ id=form_response.id,
104
+ form_id=form.id,
105
+ submitted_at=form_response.submitted_at,
106
+ message=public_form.custom_thank_you_message or "Thank you for your submission!"
107
+ )
108
+
109
+ except Exception as e:
110
+ db.rollback()
111
+ logger.error(f"Form submission failed: {e}")
112
+ raise HTTPException(
113
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
114
+ detail="Failed to submit form"
115
+ )
116
+
117
+
118
+ @router.post("/public/forms/{token}/autosave", response_model=AutoSaveResponse)
119
+ async def autosave_submission(
120
+ token: str,
121
+ submission: PartialSubmissionCreate,
122
+ request: Request,
123
+ db: Session = Depends(get_db)
124
+ ):
125
+ """
126
+ Auto-save partial submission after each question.
127
+ No authentication required.
128
+ """
129
+ # Find public form by token
130
+ public_form = db.query(PublicForm).filter(
131
+ PublicForm.share_token == token,
132
+ PublicForm.is_public == True
133
+ ).first()
134
+
135
+ if not public_form:
136
+ raise HTTPException(
137
+ status_code=status.HTTP_404_NOT_FOUND,
138
+ detail="Form not found"
139
+ )
140
+
141
+ # Get form
142
+ form = db.query(Form).filter(Form.id == public_form.form_id).first()
143
+ if not form:
144
+ raise HTTPException(
145
+ status_code=status.HTTP_404_NOT_FOUND,
146
+ detail="Form not found"
147
+ )
148
+
149
+ # Get client IP
150
+ client_ip = request.client.host if request.client else None
151
+ ip_hash = rate_limiter.hash_identifier(client_ip) if client_ip else "unknown"
152
+
153
+ try:
154
+ # Find existing in-progress submission for this session
155
+ existing_response = db.query(FormResponse).filter(
156
+ FormResponse.form_id == form.id,
157
+ FormResponse.session_id == submission.session_id,
158
+ FormResponse.status.in_(["in_progress", "partial"])
159
+ ).first()
160
+
161
+ if existing_response:
162
+ # Update existing response
163
+ existing_response.last_updated_at = datetime.utcnow()
164
+ existing_response.status = "in_progress"
165
+
166
+ # Update answers
167
+ for answer_data in submission.answers:
168
+ # Check if answer exists
169
+ existing_answer = db.query(ResponseAnswer).filter(
170
+ ResponseAnswer.form_response_id == existing_response.id,
171
+ ResponseAnswer.form_question_id == answer_data.get('question_id')
172
+ ).first()
173
+
174
+ if existing_answer:
175
+ # Update existing answer
176
+ existing_answer.answer_value = answer_data.get('answer_value', {})
177
+ else:
178
+ # Create new answer
179
+ new_answer = ResponseAnswer(
180
+ form_response_id=existing_response.id,
181
+ form_question_id=answer_data.get('question_id'),
182
+ answer_value=answer_data.get('answer_value', {})
183
+ )
184
+ db.add(new_answer)
185
+
186
+ form_response = existing_response
187
+ else:
188
+ # Create new form response
189
+ form_response = FormResponse(
190
+ form_id=form.id,
191
+ session_id=submission.session_id,
192
+ status="in_progress",
193
+ form_version=submission.form_version,
194
+ started_at=datetime.utcnow(),
195
+ last_updated_at=datetime.utcnow(),
196
+ ip_address=client_ip,
197
+ ip_address_hash=ip_hash,
198
+ utm_source=submission.utm_source,
199
+ utm_medium=submission.utm_medium,
200
+ utm_campaign=submission.utm_campaign,
201
+ submission_metadata=submission.metadata or {}
202
+ )
203
+ db.add(form_response)
204
+ db.flush()
205
+
206
+ # Create answers
207
+ for answer_data in submission.answers:
208
+ answer = ResponseAnswer(
209
+ form_response_id=form_response.id,
210
+ form_question_id=answer_data.get('question_id'),
211
+ answer_value=answer_data.get('answer_value', {})
212
+ )
213
+ db.add(answer)
214
+
215
+ db.commit()
216
+ db.refresh(form_response)
217
+
218
+ # Validate answers (non-blocking)
219
+ validation_result = validation_service.validate_submission(
220
+ db=db,
221
+ form_id=form.id,
222
+ answers=submission.answers,
223
+ is_complete=False
224
+ )
225
+
226
+ return AutoSaveResponse(
227
+ success=True,
228
+ submission_id=form_response.id,
229
+ validation=validation_result,
230
+ message="Auto-saved successfully"
231
+ )
232
+
233
+ except Exception as e:
234
+ db.rollback()
235
+ logger.error(f"Auto-save failed: {e}")
236
+ raise HTTPException(
237
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
238
+ detail="Failed to auto-save"
239
+ )
240
+
241
+
242
+ @router.get("/public/forms/{token}", response_model=PublicFormDetail)
243
+ async def get_public_form(
244
+ token: str,
245
+ db: Session = Depends(get_db)
246
+ ):
247
+ """
248
+ Get public form structure for filling out.
249
+ No authentication required.
250
+ """
251
+ public_form = db.query(PublicForm).filter(
252
+ PublicForm.share_token == token,
253
+ PublicForm.is_public == True
254
+ ).first()
255
+
256
+ if not public_form:
257
+ raise HTTPException(
258
+ status_code=status.HTTP_404_NOT_FOUND,
259
+ detail="Form not found"
260
+ )
261
+
262
+ # Check if expired
263
+ if public_form.expires_at and public_form.expires_at < datetime.utcnow():
264
+ raise HTTPException(
265
+ status_code=status.HTTP_410_GONE,
266
+ detail="This form has expired"
267
+ )
268
+
269
+ form = db.query(Form).filter(Form.id == public_form.form_id).first()
270
+
271
+ if not form:
272
+ raise HTTPException(
273
+ status_code=status.HTTP_404_NOT_FOUND,
274
+ detail="Form not found"
275
+ )
276
+
277
+ return PublicFormDetail(
278
+ form=FormSchema.model_validate(form),
279
+ public_settings=public_form
280
+ )
281
+
282
+
283
+ @router.get("/forms/{form_id}/responses", response_model=FormResponsesList)
284
+ async def get_form_responses(
285
+ form_id: int,
286
+ current_user: User = Depends(get_current_user),
287
+ db: Session = Depends(get_db),
288
+ limit: int = 100,
289
+ offset: int = 0
290
+ ):
291
+ """
292
+ Get all responses for a form.
293
+ Requires authentication and form ownership.
294
+ """
295
+ # Verify form ownership
296
+ form = db.query(Form).filter(
297
+ Form.id == form_id,
298
+ Form.user_id == current_user.id
299
+ ).first()
300
+
301
+ if not form:
302
+ raise HTTPException(
303
+ status_code=status.HTTP_404_NOT_FOUND,
304
+ detail="Form not found"
305
+ )
306
+
307
+ # Get total count
308
+ total_count = db.query(func.count(FormResponse.id)).filter(
309
+ FormResponse.form_id == form_id
310
+ ).scalar()
311
+
312
+ # Get responses with answers
313
+ responses = db.query(FormResponse).filter(
314
+ FormResponse.form_id == form_id
315
+ ).order_by(desc(FormResponse.submitted_at)).limit(limit).offset(offset).all()
316
+
317
+ # Build detailed response list
318
+ response_details = []
319
+ for response in responses:
320
+ answers = []
321
+ for answer in response.answers:
322
+ question = db.query(FormQuestion).filter(
323
+ FormQuestion.id == answer.form_question_id
324
+ ).first()
325
+
326
+ if question:
327
+ answers.append(ResponseAnswerDetail(
328
+ question_id=question.id,
329
+ question_text=question.question_text,
330
+ question_type=question.question_type,
331
+ answer_value=answer.answer_value or {}
332
+ ))
333
+
334
+ response_details.append(FormResponseDetail(
335
+ id=response.id,
336
+ form_id=response.form_id,
337
+ submitted_at=response.submitted_at,
338
+ ip_address=response.ip_address,
339
+ answers=answers
340
+ ))
341
+
342
+ return FormResponsesList(
343
+ total_count=total_count,
344
+ responses=response_details
345
+ )
346
+
347
+
348
+ @router.get("/forms/{form_id}/responses/{response_id}", response_model=FormResponseDetail)
349
+ async def get_single_response(
350
+ form_id: int,
351
+ response_id: int,
352
+ current_user: User = Depends(get_current_user),
353
+ db: Session = Depends(get_db)
354
+ ):
355
+ """Get a single form response by ID"""
356
+ # Verify form ownership
357
+ form = db.query(Form).filter(
358
+ Form.id == form_id,
359
+ Form.user_id == current_user.id
360
+ ).first()
361
+
362
+ if not form:
363
+ raise HTTPException(
364
+ status_code=status.HTTP_404_NOT_FOUND,
365
+ detail="Form not found"
366
+ )
367
+
368
+ response = db.query(FormResponse).filter(
369
+ FormResponse.id == response_id,
370
+ FormResponse.form_id == form_id
371
+ ).first()
372
+
373
+ if not response:
374
+ raise HTTPException(
375
+ status_code=status.HTTP_404_NOT_FOUND,
376
+ detail="Response not found"
377
+ )
378
+
379
+ # Build answer details
380
+ answers = []
381
+ for answer in response.answers:
382
+ question = db.query(FormQuestion).filter(
383
+ FormQuestion.id == answer.form_question_id
384
+ ).first()
385
+
386
+ if question:
387
+ answers.append(ResponseAnswerDetail(
388
+ question_id=question.id,
389
+ question_text=question.question_text,
390
+ question_type=question.question_type,
391
+ answer_value=answer.answer_value or {}
392
+ ))
393
+
394
+ return FormResponseDetail(
395
+ id=response.id,
396
+ form_id=response.form_id,
397
+ submitted_at=response.submitted_at,
398
+ ip_address=response.ip_address,
399
+ answers=answers
400
+ )
401
+
402
+
403
+ @router.get("/forms/{form_id}/responses/export/csv")
404
+ async def export_responses_csv(
405
+ form_id: int,
406
+ current_user: User = Depends(get_current_user),
407
+ db: Session = Depends(get_db)
408
+ ):
409
+ """Export form responses as CSV"""
410
+ from fastapi.responses import StreamingResponse
411
+
412
+ # Verify form ownership
413
+ form = db.query(Form).filter(
414
+ Form.id == form_id,
415
+ Form.user_id == current_user.id
416
+ ).first()
417
+
418
+ if not form:
419
+ raise HTTPException(
420
+ status_code=status.HTTP_404_NOT_FOUND,
421
+ detail="Form not found"
422
+ )
423
+
424
+ # Get all questions for headers
425
+ questions = db.query(FormQuestion).filter(
426
+ FormQuestion.form_id == form_id
427
+ ).order_by(FormQuestion.question_order).all()
428
+
429
+ # Get all responses
430
+ responses = db.query(FormResponse).filter(
431
+ FormResponse.form_id == form_id
432
+ ).order_by(desc(FormResponse.submitted_at)).all()
433
+
434
+ # Create CSV in memory
435
+ output = io.StringIO()
436
+ writer = csv.writer(output)
437
+
438
+ # Write headers
439
+ headers = ["Response ID", "Submitted At", "IP Address"]
440
+ headers.extend([q.question_text for q in questions])
441
+ writer.writerow(headers)
442
+
443
+ # Write data rows
444
+ for response in responses:
445
+ row = [
446
+ response.id,
447
+ response.submitted_at.isoformat(),
448
+ response.ip_address or ""
449
+ ]
450
+
451
+ # Create answer map
452
+ answer_map = {
453
+ answer.form_question_id: answer.answer_value
454
+ for answer in response.answers
455
+ }
456
+
457
+ # Add answers in question order
458
+ for question in questions:
459
+ answer_value = answer_map.get(question.id, {})
460
+
461
+ # Format answer based on type
462
+ if isinstance(answer_value, dict):
463
+ # Extract the most relevant field
464
+ if "text" in answer_value:
465
+ row.append(answer_value["text"])
466
+ elif "number" in answer_value:
467
+ row.append(str(answer_value["number"]))
468
+ elif "choices" in answer_value:
469
+ row.append(", ".join(answer_value["choices"]))
470
+ elif "date" in answer_value:
471
+ row.append(answer_value["date"])
472
+ elif "rating" in answer_value:
473
+ row.append(str(answer_value["rating"]))
474
+ else:
475
+ row.append(json.dumps(answer_value))
476
+ else:
477
+ row.append(str(answer_value))
478
+
479
+ writer.writerow(row)
480
+
481
+ # Prepare response
482
+ output.seek(0)
483
+
484
+ return StreamingResponse(
485
+ iter([output.getvalue()]),
486
+ media_type="text/csv",
487
+ headers={
488
+ "Content-Disposition": f"attachment; filename=form_{form_id}_responses.csv"
489
+ }
490
+ )
491
+
492
+
493
+ @router.get("/forms/{form_id}/responses/export/json")
494
+ async def export_responses_json(
495
+ form_id: int,
496
+ current_user: User = Depends(get_current_user),
497
+ db: Session = Depends(get_db)
498
+ ):
499
+ """Export form responses as JSON"""
500
+ from fastapi.responses import JSONResponse
501
+
502
+ # Verify form ownership
503
+ form = db.query(Form).filter(
504
+ Form.id == form_id,
505
+ Form.user_id == current_user.id
506
+ ).first()
507
+
508
+ if not form:
509
+ raise HTTPException(
510
+ status_code=status.HTTP_404_NOT_FOUND,
511
+ detail="Form not found"
512
+ )
513
+
514
+ # Get all responses
515
+ responses = db.query(FormResponse).filter(
516
+ FormResponse.form_id == form_id
517
+ ).order_by(desc(FormResponse.submitted_at)).all()
518
+
519
+ # Build export data
520
+ export_data = {
521
+ "form_id": form_id,
522
+ "form_title": form.title,
523
+ "exported_at": datetime.utcnow().isoformat(),
524
+ "total_responses": len(responses),
525
+ "responses": []
526
+ }
527
+
528
+ for response in responses:
529
+ response_data = {
530
+ "id": response.id,
531
+ "submitted_at": response.submitted_at.isoformat(),
532
+ "ip_address": response.ip_address,
533
+ "answers": []
534
+ }
535
+
536
+ for answer in response.answers:
537
+ question = db.query(FormQuestion).filter(
538
+ FormQuestion.id == answer.form_question_id
539
+ ).first()
540
+
541
+ if question:
542
+ response_data["answers"].append({
543
+ "question_id": question.id,
544
+ "question_text": question.question_text,
545
+ "question_type": question.question_type.value,
546
+ "answer_value": answer.answer_value
547
+ })
548
+
549
+ export_data["responses"].append(response_data)
550
+
551
+ return JSONResponse(
552
+ content=export_data,
553
+ headers={
554
+ "Content-Disposition": f"attachment; filename=form_{form_id}_responses.json"
555
+ }
556
+ )
557
+
558
+
559
+ @router.delete("/forms/{form_id}/responses/{response_id}", status_code=status.HTTP_204_NO_CONTENT)
560
+ async def delete_response(
561
+ form_id: int,
562
+ response_id: int,
563
+ current_user: User = Depends(get_current_user),
564
+ db: Session = Depends(get_db)
565
+ ):
566
+ """Delete a specific form response"""
567
+ # Verify form ownership
568
+ form = db.query(Form).filter(
569
+ Form.id == form_id,
570
+ Form.user_id == current_user.id
571
+ ).first()
572
+
573
+ if not form:
574
+ raise HTTPException(
575
+ status_code=status.HTTP_404_NOT_FOUND,
576
+ detail="Form not found"
577
+ )
578
+
579
+ response = db.query(FormResponse).filter(
580
+ FormResponse.id == response_id,
581
+ FormResponse.form_id == form_id
582
+ ).first()
583
+
584
+ if not response:
585
+ raise HTTPException(
586
+ status_code=status.HTTP_404_NOT_FOUND,
587
+ detail="Response not found"
588
+ )
589
+
590
+ db.delete(response)
591
+ db.commit()
592
+
593
+ return None
594
+
app/routes/users.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ User Context API Routes
3
+ ========================
4
+
5
+ Routes for user context and onboarding.
6
+ """
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, status
9
+ from sqlalchemy.orm import Session
10
+ from sqlalchemy import func, desc
11
+
12
+ from ..core.db import get_db
13
+ from ..core.security import get_current_user
14
+ from ..models import User, Form, Subscription
15
+
16
+ import logging
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ router = APIRouter(prefix="/api/me", tags=["users"])
21
+
22
+
23
+ @router.get("/context")
24
+ async def get_user_context(
25
+ current_user: User = Depends(get_current_user),
26
+ db: Session = Depends(get_db)
27
+ ):
28
+ """
29
+ Get user context for UI decisions.
30
+
31
+ Returns information about user state, forms, and subscription.
32
+ """
33
+ # Count user's forms
34
+ form_count = db.query(func.count(Form.id)).filter(
35
+ Form.user_id == current_user.id
36
+ ).scalar() or 0
37
+
38
+ # Get last form
39
+ last_form = db.query(Form).filter(
40
+ Form.user_id == current_user.id
41
+ ).order_by(desc(Form.created_at)).first()
42
+
43
+ # Get subscription info
44
+ subscription = db.query(Subscription).filter(
45
+ Subscription.user_id == current_user.id
46
+ ).order_by(desc(Subscription.created_at)).first()
47
+
48
+ has_active_subscription = False
49
+ plan_name = "Free"
50
+
51
+ if subscription and subscription.status == "active":
52
+ has_active_subscription = True
53
+ if subscription.plan:
54
+ plan_name = subscription.plan.name
55
+
56
+ return {
57
+ "is_new_user": form_count == 0,
58
+ "form_count": form_count,
59
+ "last_form_id": last_form.id if last_form else None,
60
+ "has_active_subscription": has_active_subscription,
61
+ "plan_name": plan_name,
62
+ "onboarding_completed": form_count > 0, # User has created at least one form
63
+ "user": {
64
+ "id": current_user.id,
65
+ "email": current_user.email,
66
+ "name": current_user.name,
67
+ "picture": current_user.picture
68
+ }
69
+ }
app/schemas/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+
2
+
app/schemas/auth.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, EmailStr
2
+
3
+
4
+ class LoginRequest(BaseModel):
5
+ email: EmailStr
6
+ password: str
7
+
8
+
9
+ class TokenResponse(BaseModel):
10
+ token: str
11
+ token_type: str = "bearer"
12
+ user: dict
13
+
14
+
app/schemas/chat.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Optional, Dict, Any, Literal
3
+
4
+
5
+ class ChatRequest(BaseModel):
6
+ message: str
7
+ dataset_id: Optional[str] = None
8
+ fig_data: Optional[Dict[str, Any]] = None
9
+ plotly_code: Optional[str] = None
10
+
11
+
12
+ class ChatResponse(BaseModel):
13
+ reply: str
14
+ user: str | None = None
15
+ matched_chart: Optional[Dict[str, Any]] = None
16
+ code_type: Optional[Literal['plotly_edit', 'analysis', 'add_chart_query']] = None
17
+ executable_code: Optional[str] = None
18
+ query_type: Optional[str] = None # The detected query type (for showing action buttons on need_clarity)
19
+
20
+
21
+ class FixVisualizationRequest(BaseModel):
22
+ plotly_code: str
23
+ error_message: str
24
+ dataset_id: Optional[str] = None
25
+
26
+
27
+ class RetryCodeRequest(BaseModel):
28
+ user_query: str
29
+ dataset_id: str
30
+ code_type: str # 'plotly_edit' or 'analysis'
31
+ plotly_code: Optional[str] = None # Only for plotly_edit
32
+ data_context: str
33
+
34
+
app/schemas/form.py ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pydantic schemas for form-related operations
3
+ """
4
+ from pydantic import BaseModel, Field, field_validator
5
+ from typing import Optional, List, Dict, Any, Union
6
+ from datetime import datetime
7
+ from ..models import QuestionType, ConditionType
8
+
9
+
10
+ # Question schemas
11
+ class QuestionSettings(BaseModel):
12
+ """Settings for different question types"""
13
+ choices: Optional[List[str]] = None # For multiple choice, checkboxes, dropdown
14
+ min_value: Optional[int] = None # For number, linear scale
15
+ max_value: Optional[int] = None
16
+ min_length: Optional[int] = None # For text inputs
17
+ max_length: Optional[int] = None
18
+ placeholder: Optional[str] = None
19
+ scale_min_label: Optional[str] = None # For linear scale
20
+ scale_max_label: Optional[str] = None
21
+ rows: Optional[Union[int, List[str]]] = None # For matrix questions - accepts int or list
22
+ columns: Optional[Union[int, List[str]]] = None # For matrix questions - accepts int or list
23
+ file_types: Optional[List[str]] = None # For file upload
24
+ max_file_size: Optional[int] = None
25
+ payment_amount: Optional[float] = None # For payment questions
26
+ currency: Optional[str] = None
27
+ ranking_items: Optional[Union[int, List[str]]] = None # For ranking questions - accepts int or list
28
+
29
+ class Config:
30
+ extra = "allow" # Allow additional fields
31
+
32
+
33
+ class QuestionBase(BaseModel):
34
+ question_text: str
35
+ question_type: QuestionType
36
+ description: Optional[str] = None
37
+ required: bool = False
38
+ settings: Optional[QuestionSettings] = None
39
+
40
+
41
+ class QuestionCreate(QuestionBase):
42
+ question_order: int
43
+ form_id: int
44
+
45
+
46
+ class QuestionUpdate(BaseModel):
47
+ question_text: Optional[str] = None
48
+ question_type: Optional[QuestionType] = None
49
+ description: Optional[str] = None
50
+ required: Optional[bool] = None
51
+ question_order: Optional[int] = None
52
+ settings: Optional[QuestionSettings] = None
53
+
54
+
55
+ class QuestionResponse(QuestionBase):
56
+ id: int
57
+ form_id: int
58
+ question_order: int
59
+ created_at: datetime
60
+ updated_at: datetime
61
+
62
+ class Config:
63
+ from_attributes = True
64
+
65
+
66
+ # Conditional logic schemas
67
+ class ConditionalRuleBase(BaseModel):
68
+ trigger_question_id: int
69
+ target_question_id: int
70
+ condition_type: ConditionType
71
+ condition_value: Optional[str] = None
72
+ action: str = "show" # "show" or "hide"
73
+
74
+ @field_validator('action')
75
+ @classmethod
76
+ def validate_action(cls, v):
77
+ if v not in ["show", "hide"]:
78
+ raise ValueError('Action must be "show" or "hide"')
79
+ return v
80
+
81
+
82
+ class ConditionalRuleCreate(ConditionalRuleBase):
83
+ form_id: int
84
+
85
+
86
+ class ConditionalRuleResponse(ConditionalRuleBase):
87
+ id: int
88
+ form_id: int
89
+ created_at: datetime
90
+
91
+ class Config:
92
+ from_attributes = True
93
+
94
+
95
+ # Form schemas
96
+ class FormSettings(BaseModel):
97
+ """Form-level settings"""
98
+ background_color: str = "#ffffff"
99
+ text_color: str = "#000000"
100
+ accent_color: str = "#9333ea" # Purple
101
+ bold_text_color: Optional[str] = "#9333ea" # Bold text and buttons
102
+ submit_button_text: str = "Submit"
103
+ show_progress_bar: bool = True
104
+
105
+ class Config:
106
+ extra = "allow"
107
+
108
+
109
+ class FormBase(BaseModel):
110
+ title: str
111
+ description: Optional[str] = None
112
+ settings: Optional[FormSettings] = None
113
+
114
+
115
+ class FormCreate(BaseModel):
116
+ """Schema for creating a form from AI generation"""
117
+ user_query: str # The natural language description
118
+ title: Optional[str] = None # Optional - will be generated by AI if not provided
119
+ description: Optional[str] = None # Optional - will be generated by AI if not provided
120
+ settings: Optional[FormSettings] = None # Optional - defaults applied if not provided
121
+
122
+
123
+ class FormUpdate(BaseModel):
124
+ title: Optional[str] = None
125
+ description: Optional[str] = None
126
+ settings: Optional[FormSettings] = None
127
+
128
+
129
+ class FormResponse(FormBase):
130
+ id: int
131
+ user_id: int
132
+ created_at: datetime
133
+ updated_at: datetime
134
+ questions: List[QuestionResponse] = []
135
+ conditional_rules: List[ConditionalRuleResponse] = []
136
+
137
+ class Config:
138
+ from_attributes = True
139
+
140
+
141
+ # Form generation response
142
+ class FormGenerationResponse(BaseModel):
143
+ """Response from AI form generation"""
144
+ form: FormResponse
145
+ message: str = "Form generated successfully"
146
+
147
+
148
+ # Answer and submission schemas
149
+ class AnswerValue(BaseModel):
150
+ """Answer value for different question types"""
151
+ text: Optional[str] = None # For text inputs
152
+ number: Optional[float] = None # For number inputs
153
+ date: Optional[str] = None # For date/time
154
+ choices: Optional[List[str]] = None # For multiple choice, checkboxes
155
+ file_url: Optional[str] = None # For file uploads
156
+ rating: Optional[int] = None # For ratings
157
+ signature: Optional[str] = None # Base64 signature image
158
+ wallet_address: Optional[str] = None # For wallet connect
159
+ matrix_answers: Optional[Dict[str, str]] = None # For matrix questions
160
+ ranked_items: Optional[List[str]] = None # For ranking questions
161
+
162
+ class Config:
163
+ extra = "allow"
164
+
165
+
166
+ class SubmitAnswer(BaseModel):
167
+ question_id: int
168
+ answer_value: AnswerValue
169
+
170
+
171
+ class SubmissionCreate(BaseModel):
172
+ """Schema for submitting a form response"""
173
+ answers: List[SubmitAnswer]
174
+ metadata: Optional[Dict[str, Any]] = None
175
+
176
+
177
+ class SubmissionResponse(BaseModel):
178
+ """Response after successful submission"""
179
+ id: int
180
+ form_id: int
181
+ submitted_at: datetime
182
+ message: str = "Form submitted successfully"
183
+
184
+ class Config:
185
+ from_attributes = True
186
+
187
+
188
+ class ResponseAnswerDetail(BaseModel):
189
+ """Detailed answer in a response"""
190
+ question_id: int
191
+ question_text: str
192
+ question_type: QuestionType
193
+ answer_value: Dict[str, Any]
194
+
195
+ class Config:
196
+ from_attributes = True
197
+
198
+
199
+ class FormResponseDetail(BaseModel):
200
+ """Detailed view of a single form response"""
201
+ id: int
202
+ form_id: int
203
+ submitted_at: datetime
204
+ ip_address: Optional[str]
205
+ answers: List[ResponseAnswerDetail]
206
+
207
+ class Config:
208
+ from_attributes = True
209
+
210
+
211
+ class FormResponsesList(BaseModel):
212
+ """List of form responses with summary"""
213
+ total_count: int
214
+ responses: List[FormResponseDetail]
215
+
216
+
217
+ # Public form schemas
218
+ class PublicFormCreate(BaseModel):
219
+ form_id: int
220
+ expires_at: Optional[datetime] = None
221
+ allow_multiple_submissions: bool = True
222
+ collect_email: bool = False
223
+ custom_thank_you_message: Optional[str] = None
224
+
225
+
226
+ class PublicFormResponse(BaseModel):
227
+ id: int
228
+ form_id: int
229
+ share_token: str
230
+ is_public: bool
231
+ expires_at: Optional[datetime]
232
+ allow_multiple_submissions: bool
233
+ collect_email: bool
234
+ custom_thank_you_message: Optional[str]
235
+ created_at: datetime
236
+
237
+ class Config:
238
+ from_attributes = True
239
+
240
+
241
+ class PublicFormDetail(BaseModel):
242
+ """Public form structure for submissions"""
243
+ form: FormResponse
244
+ public_settings: PublicFormResponse
245
+
246
+ class Config:
247
+ from_attributes = True
248
+
249
+
250
+ # Export schemas
251
+ class ExportFormat(BaseModel):
252
+ format: str = Field(..., pattern="^(csv|json)$")
253
+
254
+
255
+ class ExportResponse(BaseModel):
256
+ download_url: str
257
+ format: str
258
+ total_responses: int
259
+
260
+
261
+ # Chat editing schemas
262
+ class ChatMessage(BaseModel):
263
+ """Schema for chat-based form editing"""
264
+ message: str = Field(..., min_length=1, max_length=1000)
265
+
266
+
267
+ class ChatResponse(BaseModel):
268
+ """Response from chat-based form editing"""
269
+ route: str # add_component, edit_component, general_form_query, need_more_clarity
270
+ response: Dict[str, Any] # Updated component or form structure or answer text
271
+ changes_made: Optional[str] = None # Summary of changes if applicable
272
+
273
+ class Config:
274
+ from_attributes = True
app/schemas/validation.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Validation schemas for form submissions
3
+ """
4
+ from pydantic import BaseModel, Field
5
+ from typing import Optional, List, Dict, Any
6
+ from datetime import datetime
7
+
8
+
9
+ class FieldValidationError(BaseModel):
10
+ """Individual field validation error"""
11
+ question_id: int
12
+ field: str
13
+ message: str
14
+ error_type: str # "required", "invalid_format", "out_of_range", "invalid_choice", etc.
15
+
16
+
17
+ class ValidationResponse(BaseModel):
18
+ """Structured validation response"""
19
+ is_valid: bool
20
+ errors: List[FieldValidationError] = []
21
+ warnings: List[FieldValidationError] = []
22
+
23
+
24
+ class PartialSubmissionCreate(BaseModel):
25
+ """Schema for auto-save partial submissions"""
26
+ session_id: str = Field(..., min_length=1, max_length=64)
27
+ form_version: Optional[int] = None
28
+ answers: List[Dict[str, Any]] # Flexible for partial data
29
+ metadata: Optional[Dict[str, Any]] = None
30
+
31
+ # UTM parameters
32
+ utm_source: Optional[str] = None
33
+ utm_medium: Optional[str] = None
34
+ utm_campaign: Optional[str] = None
35
+
36
+
37
+ class CompleteSubmissionCreate(BaseModel):
38
+ """Schema for final submission (inherits from existing SubmissionCreate)"""
39
+ session_id: str = Field(..., min_length=1, max_length=64)
40
+ form_version: Optional[int] = None
41
+ answers: List[Dict[str, Any]]
42
+ metadata: Optional[Dict[str, Any]] = None
43
+ honeypot: Optional[str] = None # Should be empty
44
+
45
+ # UTM parameters
46
+ utm_source: Optional[str] = None
47
+ utm_medium: Optional[str] = None
48
+ utm_campaign: Optional[str] = None
49
+
50
+
51
+ class ValidationContext(BaseModel):
52
+ """Context for validation including anti-tampering token"""
53
+ form_id: int
54
+ session_id: str
55
+ validation_token: str
56
+ timestamp: datetime
57
+
58
+
59
+ class AutoSaveResponse(BaseModel):
60
+ """Response from auto-save endpoint"""
61
+ success: bool
62
+ submission_id: Optional[int] = None
63
+ validation: ValidationResponse
64
+ message: str = "Auto-saved successfully"
app/scripts/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """
2
+ Scripts package
3
+ """
4
+
app/scripts/init_plans.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Initialization script for default subscription plans
3
+ Run this script to create/update the default Free, Pro, and Ultra plans
4
+ """
5
+ import sys
6
+ import os
7
+ from pathlib import Path
8
+
9
+ # Add parent directory to path to allow imports
10
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
11
+
12
+ from sqlalchemy.orm import Session
13
+ from app.db import SessionLocal, engine, Base
14
+ from app.services.plan_service import plan_service
15
+ from app.models import SubscriptionPlan
16
+ import logging
17
+
18
+ logging.basicConfig(level=logging.INFO)
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def init_database():
23
+ """Create all database tables"""
24
+ logger.info("Creating database tables...")
25
+ Base.metadata.create_all(bind=engine)
26
+ logger.info("Database tables created successfully")
27
+
28
+
29
+ def init_plans(db: Session, force: bool = False):
30
+ """
31
+ Initialize default subscription plans
32
+
33
+ Args:
34
+ db: Database session
35
+ force: If True, update existing plans with new values
36
+ """
37
+ logger.info("Initializing subscription plans...")
38
+
39
+ plans = plan_service.initialize_default_plans(db, force=force)
40
+
41
+ logger.info(f"Successfully initialized {len(plans)} plans:")
42
+ for plan in plans:
43
+ logger.info(f" - {plan.name}: ${plan.price_monthly}/month, {plan.credits_per_month} credits")
44
+
45
+ return plans
46
+
47
+
48
+ def main():
49
+ """Main initialization function"""
50
+ import argparse
51
+
52
+ parser = argparse.ArgumentParser(description="Initialize subscription plans")
53
+ parser.add_argument(
54
+ "--force",
55
+ action="store_true",
56
+ help="Force update of existing plans"
57
+ )
58
+ parser.add_argument(
59
+ "--create-tables",
60
+ action="store_true",
61
+ help="Create database tables if they don't exist"
62
+ )
63
+
64
+ args = parser.parse_args()
65
+
66
+ # Create tables if requested
67
+ if args.create_tables:
68
+ init_database()
69
+
70
+ # Initialize plans
71
+ db = SessionLocal()
72
+ try:
73
+ plans = init_plans(db, force=args.force)
74
+ logger.info("✓ Plan initialization complete!")
75
+
76
+ # Display plan details
77
+ print("\n" + "="*60)
78
+ print("SUBSCRIPTION PLANS")
79
+ print("="*60)
80
+ for plan in plans:
81
+ print(f"\n{plan.name} Tier:")
82
+ print(f" Price: ${plan.price_monthly}/month")
83
+ print(f" Credits: {plan.credits_per_month}/month")
84
+ print(f" Per Dashboard: {plan.credits_per_analyze} credits")
85
+ print(f" Per Edit: {plan.credits_per_edit} credits")
86
+ print(f" Stripe Price ID (Monthly): {plan.stripe_price_id_monthly or plan.stripe_price_id or 'Not set'}")
87
+ print(f" Stripe Price ID (Yearly): {plan.stripe_price_id_yearly or 'Not set'}")
88
+ if plan.features:
89
+ print(f" Features: {', '.join(str(k) + '=' + str(v) for k, v in plan.features.items())}")
90
+ print("\n" + "="*60)
91
+
92
+ except Exception as e:
93
+ logger.error(f"Error initializing plans: {e}")
94
+ import traceback
95
+ traceback.print_exc()
96
+ sys.exit(1)
97
+ finally:
98
+ db.close()
99
+
100
+
101
+ if __name__ == "__main__":
102
+ main()
103
+
app/security.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from datetime import datetime, timedelta, timezone
3
+ from typing import Optional
4
+ from jose import jwt, JWTError
5
+ from fastapi import Depends, HTTPException, status
6
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
7
+
8
+
9
+ JWT_SECRET = os.getenv("JWT_SECRET", "dev-secret-change-me")
10
+ JWT_ALG = "HS256"
11
+ JWT_TTL_MIN = int(os.getenv("JWT_TTL_MIN", "60"))
12
+
13
+
14
+ def create_access_token(sub: str, extra: Optional[dict] = None) -> str:
15
+ now = datetime.now(tz=timezone.utc)
16
+ payload = {
17
+ "sub": sub,
18
+ "iat": int(now.timestamp()),
19
+ "exp": int((now + timedelta(minutes=JWT_TTL_MIN)).timestamp()),
20
+ }
21
+ if extra:
22
+ payload.update(extra)
23
+ return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALG)
24
+
25
+
26
+ bearer_scheme = HTTPBearer(auto_error=False)
27
+
28
+
29
+ def get_current_subject(creds: HTTPAuthorizationCredentials = Depends(bearer_scheme)) -> str:
30
+ if not creds or creds.scheme.lower() != "bearer":
31
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
32
+ try:
33
+ payload = jwt.decode(creds.credentials, JWT_SECRET, algorithms=[JWT_ALG])
34
+ return str(payload.get("sub"))
35
+ except JWTError:
36
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
37
+
38
+
app/services/__init__.py ADDED
File without changes
app/services/agents.py ADDED
@@ -0,0 +1,532 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DSPy-based Form Generation System
3
+ ==================================
4
+
5
+ This module uses DSPy to generate forms from natural language queries.
6
+ It includes a multi-stage pipeline:
7
+ 1. Planner: Converts user query to form plan with components
8
+ 2. Component Signature: Generates renderable form components with metadata
9
+ 3. Conditional Logic: Defines relationships between components
10
+
11
+ FRONTEND TECH STACK:
12
+ - React with TypeScript
13
+ - Headless UI (@headlessui/react) for accessible dialogs, modals, and interactive components
14
+ - React Markdown (react-markdown) with remark-gfm for markdown rendering
15
+ - Tally-inspired minimal design with inline styles (no CSS frameworks)
16
+ - Purple accent color (#9333ea) throughout
17
+ - Clean, generous whitespace (48px between questions)
18
+ - Subtle borders and transitions
19
+ """
20
+
21
+ import dspy
22
+ import json
23
+ import logging
24
+ import os
25
+ from typing import Dict, Any, List, Optional
26
+
27
+ # Set up logger for the module
28
+ logger = logging.getLogger("dspy_forms")
29
+ logger.setLevel(logging.WARNING)
30
+ if not logger.handlers:
31
+ ch = logging.StreamHandler()
32
+ formatter = logging.Formatter('[%(asctime)s][%(levelname)s] %(message)s')
33
+ ch.setFormatter(formatter)
34
+ logger.addHandler(ch)
35
+
36
+
37
+ # ============================================================================
38
+ # FORM GENERATION SIGNATURES
39
+ # ============================================================================
40
+
41
+ class FormPlannerSignature(dspy.Signature):
42
+ """
43
+ You are an AI form designer that creates comprehensive form plans from user descriptions.
44
+
45
+ TASK:
46
+ Convert a natural language form request into a structured JSON form plan with component specifications.
47
+
48
+ OUTPUT FORMAT (JSON):
49
+ {
50
+ "title": "Form Title",
51
+ "description": "Brief description of form purpose",
52
+ "submit_button_text": "Submit" or custom text,
53
+ "components": [
54
+ {
55
+ "component_id": "comp_1",
56
+ "type": "question_type",
57
+ "brief": "Brief description of what this component captures",
58
+ "required": true/false,
59
+ "order": 0
60
+ }
61
+ ],
62
+ "conditional_logic": [
63
+ {
64
+ "trigger_component_id": "comp_1",
65
+ "target_component_id": "comp_3",
66
+ "condition_type": "equals",
67
+ "condition_value": "Yes",
68
+ "action": "show"
69
+ }
70
+ ]
71
+ }
72
+
73
+ COMPONENT TYPES (use exact strings):
74
+ - short_answer: Single line text
75
+ - long_answer: Multi-line text
76
+ - multiple_choice: Radio buttons (one selection)
77
+ - checkboxes: Multiple selections
78
+ - dropdown: Select dropdown
79
+ - multi_select: Multi-select dropdown
80
+ - number: Numeric input
81
+ - email: Email validation
82
+ - phone: Phone number
83
+ - link: URL input
84
+ - file_upload: File attachment
85
+ - date: Date picker
86
+ - time: Time picker
87
+ - linear_scale: Scale (1-10)
88
+ - matrix: Grid questions
89
+ - rating: Star rating
90
+ - payment: Payment field
91
+ - signature: Digital signature
92
+ - ranking: Rank items
93
+ - wallet_connect: Web3 wallet
94
+ - button: Custom action button
95
+
96
+ CONDITIONAL LOGIC RULES:
97
+ - condition_type: equals, not_equals, contains, not_contains, greater_than, less_than, is_empty, is_not_empty
98
+ - action: show, hide
99
+
100
+ CRITICAL GUIDELINES:
101
+ 1. **FOLLOW USER'S EXACT SPECIFICATIONS** - If user says "Valima, Nikkah, Barat", use those EXACT terms, not substitutes
102
+ 2. **DO NOT SUBSTITUTE** terms - Use the exact names, labels, and options the user provides
103
+ 3. **USE DIVERSE QUESTION TYPES** - Mix short_answer, multiple_choice, checkboxes, dropdown, date, rating, etc.
104
+ 4. **USE CHECKBOXES for multiple selections** - When users can select multiple items (e.g., "Which events will you attend?")
105
+ 5. **USE MULTIPLE_CHOICE for single selection** - When users pick one option (e.g., "Are you attending? Yes/No")
106
+ 6. Choose appropriate component types based on the data being collected
107
+ 7. Mark essential components as required
108
+ 8. Order components logically (general to specific)
109
+ 9. Include 3-5 components typically
110
+ 10. Add conditional logic where it makes sense (e.g., show follow-up based on previous answer)
111
+ 11. Generate component_ids like "comp_1", "comp_2", etc.
112
+ 12. **PREFER STRUCTURED INPUTS** over text when possible (use checkboxes, dropdowns, ratings instead of open text)
113
+
114
+ **IMPORTANT: PRESERVE USER'S TERMINOLOGY**
115
+ - If user mentions specific event names (Valima, Nikkah, Barat, etc.), use those EXACT names
116
+ - If user mentions specific options or choices, use those EXACT options
117
+ - Do NOT replace cultural, local, or specific terms with generic alternatives
118
+ - Do NOT assume Western/US conventions unless specifically requested
119
+
120
+ COMMON FORM PATTERNS (adapt to user's context):
121
+ - Contact forms: name (short_answer), email, phone, message (long_answer)
122
+ - Registration forms: name, email, password, interests (checkboxes), preferences (dropdown)
123
+ - Feedback forms: name (optional), rating, satisfaction (linear_scale), comments (long_answer)
124
+ - Applications: name, email, skills (checkboxes), experience level (dropdown)
125
+ - Event registration: name, email, events attending (checkboxes), meal preference (dropdown), dietary restrictions (checkboxes)
126
+ - Survey forms: demographics (dropdowns), agreement scales (linear_scale), multiple choice questions, open-ended responses (long_answer)
127
+ - RSVP forms: attendance (multiple_choice: Yes/No), guest count (number), dietary needs (checkboxes)
128
+
129
+ FORM TITLE BEST PRACTICES:
130
+ - **ALWAYS wrap title in double asterisks** for bold formatting: "**Your Form Title**"
131
+ - Be specific and clear (e.g., "**Customer Feedback Survey**" not "Form")
132
+ - Use action words when appropriate (e.g., "**Submit Your Application**", "**Register for Event**")
133
+ - Keep under 60 characters for readability
134
+ - Match the tone to the form purpose (professional for applications, friendly for feedback)
135
+
136
+ CRITICAL: The title field MUST always start and end with ** to render as bold markdown.
137
+ Example: "title": "**Wedding RSVP**" NOT "title": "Wedding RSVP"
138
+
139
+ Always include a meaningful form title and description that clearly explains the purpose.
140
+ """
141
+ user_query = dspy.InputField(desc="Natural language description of the form needed")
142
+ form_plan = dspy.OutputField(desc="JSON form plan with title, description, components list, and conditional_logic")
143
+
144
+
145
+ class ComponentSignatureGenerator(dspy.Signature):
146
+ """
147
+ Generate detailed component specifications that the frontend can render.
148
+
149
+ FRONTEND CAPABILITIES:
150
+ - Headless UI for modals and interactive components
151
+ - Markdown support in question text and descriptions
152
+ - Inline editing with QuestionEditor component
153
+ - Tally-inspired minimal design with clean typography
154
+ - Purple accent (#9333ea) for focused/selected states
155
+
156
+ INPUT:
157
+ - component_brief: Brief description from planner
158
+ - component_type: Type of component
159
+ - component_id: Unique identifier
160
+ - form_context: Overall form context
161
+
162
+ OUTPUT FORMAT (JSON):
163
+ {
164
+ "component_id": "comp_1",
165
+ "question_type": "type from list",
166
+ "question_text": "Clear question text (markdown supported)",
167
+ "description": "Optional helper text (markdown supported)",
168
+ "required": true/false,
169
+ "settings": {
170
+ // Type-specific settings
171
+ "choices": ["Option 1", "Option 2"], // For multiple_choice, checkboxes, dropdown
172
+ "min_value": 1, // For number, linear_scale
173
+ "max_value": 10,
174
+ "placeholder": "Enter text here", // For text inputs
175
+ "scale_min_label": "Not at all", // For linear_scale
176
+ "scale_max_label": "Extremely",
177
+ "rows": ["Row 1", "Row 2"], // For matrix (MUST be array of strings)
178
+ "columns": ["Col 1", "Col 2"], // For matrix (MUST be array of strings)
179
+ "file_types": [".pdf", ".doc"], // For file_upload
180
+ "max_file_size": 5242880, // bytes
181
+ "ranking_items": ["Item 1", "Item 2"] // For ranking (MUST be array of strings)
182
+ },
183
+ "validation_rules": {
184
+ "min_length": 5, // For text inputs
185
+ "max_length": 100,
186
+ "pattern": "regex_pattern", // For custom validation
187
+ "error_message": "Custom error message"
188
+ }
189
+ }
190
+
191
+ RULES:
192
+ 1. **USE USER'S EXACT TERMINOLOGY** - If the brief mentions specific terms (e.g., "Valima", "Nikkah", "Barat"), use those EXACT terms
193
+ 2. **DO NOT SUBSTITUTE** cultural, local, or specific terms with generic alternatives
194
+ 3. **MATCH COMPONENT TYPE TO DATA** - For multiple selections, use checkboxes; for single selection, use multiple_choice or dropdown
195
+ 4. Provide choices exactly as described by user (or 3-5 sensible options if not specified)
196
+ 5. Use descriptive labels for scales (e.g., "Not at all" to "Extremely")
197
+ 6. Set reasonable limits for numbers and file sizes
198
+ 7. Include helpful placeholder text for text inputs
199
+ 8. Add description for complex components (matrix, ranking, file uploads)
200
+ 9. Include validation rules where appropriate
201
+ 10. CRITICAL: rows, columns, and ranking_items MUST be arrays of strings, never integers
202
+ 11. Question text and descriptions support markdown (use **bold**, *italic*, lists, etc.)
203
+ 12. Keep text clear and conversational - the design is minimal and clean
204
+ 13. **GENERATE COMPLETE CHOICES** - Always provide actual choice options, not placeholders like "Option 1", "Option 2"
205
+
206
+ EXAMPLES OF PRESERVING USER TERMINOLOGY:
207
+ - User says "Valima, Nikkah, Barat" → choices: ["Valima", "Nikkah", "Barat"] (NOT "Reception, Ceremony, etc.")
208
+ - User says "yes/no" → choices: ["Yes", "No"] (NOT "Agree/Disagree" unless asked)
209
+ - User mentions specific names/terms → use them exactly as written
210
+ """
211
+ component_brief = dspy.InputField(desc="Brief description from planner")
212
+ component_type = dspy.InputField(desc="Component type")
213
+ component_id = dspy.InputField(desc="Unique component identifier")
214
+ form_context = dspy.InputField(desc="Overall form context for better component generation")
215
+ component_spec = dspy.OutputField(desc="Complete JSON component specification ready for frontend rendering")
216
+
217
+
218
+ class FormEditorSignature(dspy.Signature):
219
+ """
220
+ Edit an existing form based on user requests.
221
+
222
+ FRONTEND CAPABILITIES:
223
+ - Headless UI for dialogs and modals
224
+ - Markdown support in all text fields
225
+ - Inline editing with QuestionEditor component
226
+ - Tally-inspired minimal design
227
+ - Purple accent color throughout
228
+
229
+ INPUT:
230
+ - edit_request: Natural language edit instruction
231
+ - current_form: JSON of current form structure
232
+
233
+ EDIT TYPES:
234
+ 1. Add components: Insert new components at appropriate position
235
+ 2. Remove components: Delete specified components
236
+ 3. Modify components: Change text, type, or settings
237
+ 4. Reorder components: Change component sequence
238
+ 5. Update form metadata: Change title, description, settings
239
+ 6. Update conditional logic: Add/remove/modify rules
240
+
241
+ OUTPUT FORMAT (JSON):
242
+ {
243
+ "title": "Updated title",
244
+ "description": "Updated description",
245
+ "components": [
246
+ // Updated components array with component_id, type, settings, etc.
247
+ ],
248
+ "conditional_logic": [
249
+ // Updated conditional logic rules
250
+ ],
251
+ "submit_button_text": "Submit"
252
+ }
253
+
254
+ RULES:
255
+ 1. Preserve existing components unless explicitly asked to change
256
+ 2. Maintain component_id consistency
257
+ 3. Update conditional logic if component_ids change
258
+ 4. Apply changes precisely as requested
259
+ 5. Return complete updated form structure
260
+ """
261
+ edit_request = dspy.InputField(desc="User's edit instruction")
262
+ current_form = dspy.InputField(desc="Current form structure as JSON")
263
+ updated_form = dspy.OutputField(desc="Complete updated form structure as JSON")
264
+ changes_made = dspy.OutputField(desc="Summary of changes applied")
265
+
266
+
267
+ class ComponentMatcherSignature(dspy.Signature):
268
+ """Match user query to a specific component in the form. Return component_id or null."""
269
+ query = dspy.InputField(desc="user query about a component")
270
+ components = dspy.InputField(desc="JSON: [{'id':'comp_1','type':'short_answer','text':'Question text'},...]")
271
+ component_id = dspy.OutputField(desc="Matching component_id or null if no match")
272
+
273
+
274
+ class FormChatRouterSignature(dspy.Signature):
275
+ """Route user queries for form editing/addition ONLY.
276
+
277
+ ROUTING RULES:
278
+
279
+ - **add_component**: User wants to ADD a new form component/question/field.
280
+ Keywords: "add", "include", "insert", "create field", "add question"
281
+ Examples: "Add an email field", "Include a phone number question", "Add a rating field"
282
+
283
+ - **edit_component**: User wants to MODIFY an existing component.
284
+ Keywords: "change", "modify", "update", "edit", "make it", "adjust"
285
+ Examples: "Change the email field to required", "Update the rating scale to 1-5"
286
+
287
+ - **general_form_query**: General questions about the form structure or capabilities, or questions that may be irrelevant to form editing.
288
+ Examples: "What fields are in this form?", "How does conditional logic work?", "What time is it?", "Who won the game last night?"
289
+
290
+ - **need_more_clarity**: Query is ambiguous, unclear, or possibly irrelevant.
291
+
292
+ IMPORTANT: This router is ONLY for form editing/building. NO data analysis.
293
+ """
294
+ user_query = dspy.InputField(desc="The user's query about the form")
295
+ form_context = dspy.InputField(desc="Context about the form structure")
296
+ query_type = dspy.OutputField(desc="One of: 'add_component', 'edit_component', 'general_form_query', 'need_more_clarity'")
297
+ reasoning = dspy.OutputField(desc="Brief explanation of why this route was chosen")
298
+
299
+
300
+ CLARITY_RESPONSE = (
301
+ "I'm sorry, I couldn't understand your request.\n\n"
302
+ "#### Here's what I can help you with:\n"
303
+ "- **Add** a new component or field to your form\n"
304
+ "- **Edit** or **modify** an existing component\n"
305
+ "- Answer **general questions** about your form\n\n"
306
+ "Please clarify what you'd like to do, or ask for help with one of the options above!"
307
+ )
308
+
309
+
310
+ class FormChatFunction(dspy.Module):
311
+ """Chat functionality for form editing - ONLY add or edit components."""
312
+
313
+ def __init__(self):
314
+ self.add_component_mod = dspy.Predict(ComponentSignatureGenerator)
315
+ self.edit_form_mod = dspy.Predict(FormEditorSignature)
316
+ self.general_qa = dspy.Predict("user_query, form_context -> answer")
317
+ self.router = dspy.Predict(FormChatRouterSignature)
318
+ self.recheck_router = dspy.Predict(FormChatRouterSignature)
319
+ self.CLARITY_RESPONSE = CLARITY_RESPONSE
320
+
321
+ async def aforward(self, user_query: str, form_context: str, current_form: dict = None):
322
+ """
323
+ Handle user queries for form editing/addition.
324
+
325
+ Args:
326
+ user_query: User's natural language request
327
+ form_context: JSON string of current form structure
328
+ current_form: Dict of current form structure (optional)
329
+
330
+ Returns:
331
+ dict with 'route' and 'response' keys
332
+ """
333
+ with dspy.context(lm=dspy.LM('openai/gpt-4o-mini', api_key=os.getenv('OPENAI_API_KEY'), max_tokens=1500, temperature=1)):
334
+ route = self.router(user_query=user_query, form_context=form_context)
335
+ query_type = route.query_type
336
+
337
+ # If unclear, try recheck router
338
+ if 'need_more_clarity' in query_type:
339
+ recheck = self.recheck_router(user_query=user_query, form_context=form_context)
340
+ if 'need_more_clarity' not in recheck.query_type:
341
+ query_type = recheck.query_type
342
+
343
+ if 'add_component' in query_type:
344
+ # Extract component details from query
345
+ # Generate a new component_id
346
+ existing_ids = []
347
+ if current_form and 'components' in current_form:
348
+ existing_ids = [c.get('component_id', '') for c in current_form.get('components', [])]
349
+
350
+ new_id = f"comp_{len(existing_ids) + 1}"
351
+
352
+ # CRITICAL: Include the original user query in the form context and brief
353
+ if form_context:
354
+ # Parse form_context if it's a string
355
+ if isinstance(form_context, str):
356
+ parsed_context = json.loads(form_context)
357
+ else:
358
+ parsed_context = form_context
359
+
360
+ # Merge with original user request
361
+ enhanced_form_context = json.dumps({
362
+ **parsed_context,
363
+ 'ORIGINAL_USER_REQUEST': user_query,
364
+ 'IMPORTANT': 'Use the EXACT terms from ORIGINAL_USER_REQUEST. Do NOT substitute cultural, local, or specific terms.'
365
+ })
366
+ else:
367
+ enhanced_form_context = json.dumps({'ORIGINAL_USER_REQUEST': user_query})
368
+
369
+ enhanced_brief = f"{user_query}. CRITICAL: Use the exact terms mentioned by the user. Do NOT substitute any terms like 'Valima', 'Nikkah', 'Barat' with generic alternatives."
370
+
371
+ response = self.add_component_mod(
372
+ component_brief=enhanced_brief,
373
+ component_type="short_answer", # Default, will be inferred
374
+ component_id=new_id,
375
+ form_context=enhanced_form_context
376
+ )
377
+ elif 'edit_component' in query_type:
378
+ if current_form:
379
+ response = self.edit_form_mod(
380
+ edit_request=user_query,
381
+ current_form=json.dumps(current_form)
382
+ )
383
+ else:
384
+ response = "No form available to edit."
385
+ elif 'general_form_query' in query_type:
386
+ response = self.general_qa(user_query=user_query, form_context=form_context)
387
+ elif 'need_more_clarity' in query_type:
388
+ response = self.CLARITY_RESPONSE
389
+ else:
390
+ response = self.general_qa(user_query=user_query, form_context=form_context)
391
+
392
+ route.query_type = query_type
393
+ return_dict = {'route': route, 'response': response}
394
+
395
+ return return_dict
396
+
397
+
398
+ # ============================================================================
399
+ # FORM GENERATION MODULE
400
+ # ============================================================================
401
+
402
+ class FormGenerationModule(dspy.Module):
403
+ """Main module for generating forms from natural language."""
404
+
405
+ def __init__(self):
406
+ self.planner = dspy.Predict(FormPlannerSignature)
407
+ self.component_generator = dspy.Predict(ComponentSignatureGenerator)
408
+
409
+ async def aforward(self, user_query: str):
410
+ """
411
+ Generate a complete form from a user query.
412
+
413
+ Args:
414
+ user_query: Natural language description of the form
415
+
416
+ Returns:
417
+ dict with complete form structure including components and conditional logic
418
+ """
419
+ # Step 1: Generate form plan with component briefs and conditional logic
420
+ with dspy.context(lm=dspy.LM("openai/gpt-4o-mini", api_key=os.getenv('OPENAI_API_KEY'), max_tokens=2000)):
421
+ plan = self.planner(user_query=user_query)
422
+
423
+ form_plan = plan.form_plan
424
+ if isinstance(form_plan, str):
425
+ try:
426
+ form_plan = json.loads(form_plan)
427
+ except json.JSONDecodeError:
428
+ logger.error("Failed to parse form plan JSON")
429
+ return {'error': 'Failed to generate form plan'}
430
+
431
+ logger.info(f"Form plan generated: {form_plan.get('title', 'Untitled')}")
432
+
433
+ # Step 2: Generate detailed component specifications
434
+ components = form_plan.get('components', [])
435
+ detailed_components = []
436
+
437
+ # CRITICAL: Include original user_query so components use exact terminology
438
+ form_context = json.dumps({
439
+ 'title': form_plan.get('title'),
440
+ 'description': form_plan.get('description'),
441
+ 'total_components': len(components),
442
+ 'ORIGINAL_USER_REQUEST': user_query, # Pass the exact user request
443
+ 'IMPORTANT': 'Use the EXACT terms from ORIGINAL_USER_REQUEST. Do NOT substitute cultural or local terms.'
444
+ })
445
+
446
+ for comp in components:
447
+ try:
448
+ # Include original user query in component brief for exact terminology
449
+ enhanced_brief = f"{comp.get('brief', '')}. IMPORTANT: Use exact terms from user's original request: '{user_query}'. Do NOT substitute any terms."
450
+
451
+ with dspy.context(lm=dspy.LM("openai/gpt-4o-mini", api_key=os.getenv('OPENAI_API_KEY'), max_tokens=1000)):
452
+ component_spec = self.component_generator(
453
+ component_brief=enhanced_brief,
454
+ component_type=comp.get('type', 'short_answer'),
455
+ component_id=comp.get('component_id', f"comp_{len(detailed_components) + 1}"),
456
+ form_context=form_context
457
+ )
458
+
459
+ comp_spec = component_spec.component_spec
460
+ if isinstance(comp_spec, str):
461
+ try:
462
+ comp_spec = json.loads(comp_spec)
463
+ except json.JSONDecodeError:
464
+ logger.error(f"Failed to parse component spec for {comp.get('component_id')}")
465
+ continue
466
+
467
+ # Ensure order is preserved
468
+ comp_spec['order'] = comp.get('order', len(detailed_components))
469
+ detailed_components.append(comp_spec)
470
+
471
+ except Exception as e:
472
+ logger.error(f"Error generating component {comp.get('component_id')}: {e}")
473
+ continue
474
+
475
+ # Step 3: Build final form structure
476
+ final_form = {
477
+ 'title': form_plan.get('title', 'Untitled Form'),
478
+ 'description': form_plan.get('description', ''),
479
+ 'submit_button_text': form_plan.get('submit_button_text', 'Submit'),
480
+ 'components': detailed_components,
481
+ 'conditional_logic': form_plan.get('conditional_logic', []),
482
+ 'settings': form_plan.get('settings', {})
483
+ }
484
+
485
+ return final_form
486
+
487
+
488
+ # ============================================================================
489
+ # UTILITY FUNCTIONS
490
+ # ============================================================================
491
+
492
+ def validate_form_structure(form_data: dict) -> tuple[bool, str]:
493
+ """
494
+ Validate form structure.
495
+
496
+ Args:
497
+ form_data: Form dictionary to validate
498
+
499
+ Returns:
500
+ (is_valid, error_message)
501
+ """
502
+ if not isinstance(form_data, dict):
503
+ return False, "Form data must be a dictionary"
504
+
505
+ if 'title' not in form_data:
506
+ return False, "Form must have a title"
507
+
508
+ if 'components' not in form_data or not isinstance(form_data['components'], list):
509
+ return False, "Form must have a components list"
510
+
511
+ if len(form_data['components']) == 0:
512
+ return False, "Form must have at least one component"
513
+
514
+ # Validate each component has required fields
515
+ for i, comp in enumerate(form_data['components']):
516
+ if 'component_id' not in comp:
517
+ return False, f"Component {i} missing component_id"
518
+ if 'question_type' not in comp:
519
+ return False, f"Component {comp.get('component_id')} missing question_type"
520
+ if 'question_text' not in comp:
521
+ return False, f"Component {comp.get('component_id')} missing question_text"
522
+
523
+ return True, ""
524
+
525
+
526
+ def extract_component_ids(form_data: dict) -> list:
527
+ """Extract all component IDs from form structure."""
528
+ if not isinstance(form_data, dict):
529
+ return []
530
+
531
+ components = form_data.get('components', [])
532
+ return [comp.get('component_id') for comp in components if 'component_id' in comp]
app/services/analytics_service.py ADDED
@@ -0,0 +1,450 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Analytics service for form tracking and insights
3
+ """
4
+ from sqlalchemy.orm import Session
5
+ from sqlalchemy import func, desc, and_
6
+ from typing import Optional, Dict, Any, List
7
+ from datetime import datetime, timedelta
8
+ from fastapi import Request
9
+ import hashlib
10
+ import uuid
11
+ import logging
12
+
13
+ from ..models import FormAnalyticsEvent, Form, FormQuestion, FormResponse, AnalyticsEventType
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class AnalyticsService:
19
+ """Service for tracking and analyzing form usage"""
20
+
21
+ def __init__(self):
22
+ self.ip_salt = "autoform_salt_2026" # Use environment variable in production
23
+
24
+ async def track_event(
25
+ self,
26
+ db: Session,
27
+ event_type: str,
28
+ form_id: int,
29
+ session_id: str,
30
+ request: Request,
31
+ submission_id: Optional[int] = None,
32
+ question_id: Optional[int] = None,
33
+ time_spent_seconds: Optional[int] = None,
34
+ metadata: Optional[Dict] = None
35
+ ):
36
+ """
37
+ Track analytics event with automatic enrichment.
38
+
39
+ Args:
40
+ db: Database session
41
+ event_type: Type of event (form_viewed, question_answered, etc.)
42
+ form_id: ID of the form
43
+ session_id: Session ID
44
+ request: FastAPI Request object
45
+ submission_id: Optional submission ID
46
+ question_id: Optional question ID
47
+ time_spent_seconds: Optional time spent
48
+ metadata: Optional additional metadata
49
+ """
50
+ try:
51
+ # Extract IP address
52
+ client_ip = None
53
+ if request.client:
54
+ client_ip = request.client.host
55
+
56
+ # Hash IP for privacy
57
+ ip_hash = self._hash_ip(client_ip) if client_ip else "unknown"
58
+
59
+ # Extract user agent
60
+ user_agent = request.headers.get("user-agent")
61
+
62
+ # Extract referrer
63
+ referrer = request.headers.get("referer") or request.headers.get("referrer")
64
+
65
+ # Extract UTM parameters from query params or referrer
66
+ utm_source = None
67
+ utm_medium = None
68
+ utm_campaign = None
69
+
70
+ if hasattr(request, 'query_params'):
71
+ utm_source = request.query_params.get('utm_source')
72
+ utm_medium = request.query_params.get('utm_medium')
73
+ utm_campaign = request.query_params.get('utm_campaign')
74
+
75
+ # Perform IP geolocation (basic - can be enhanced with MaxMind)
76
+ country, city = await self._get_geo_location(client_ip)
77
+
78
+ # Create event
79
+ event = FormAnalyticsEvent(
80
+ event_id=str(uuid.uuid4()),
81
+ form_id=form_id,
82
+ submission_id=submission_id,
83
+ question_id=question_id,
84
+ event_type=event_type,
85
+ session_id=session_id,
86
+ ip_address_hash=ip_hash,
87
+ ip_address_raw=None, # Only store if user opts in
88
+ country=country,
89
+ city=city,
90
+ user_agent=user_agent,
91
+ referrer=referrer,
92
+ utm_source=utm_source,
93
+ utm_medium=utm_medium,
94
+ utm_campaign=utm_campaign,
95
+ time_spent_seconds=time_spent_seconds,
96
+ event_metadata=metadata or {}
97
+ )
98
+
99
+ db.add(event)
100
+ db.commit()
101
+
102
+ logger.info(f"Tracked event: {event_type} for form {form_id}, session {session_id}")
103
+
104
+ except Exception as e:
105
+ logger.error(f"Failed to track analytics event: {e}")
106
+ # Don't raise - analytics failures shouldn't break form submission
107
+ db.rollback()
108
+
109
+ async def get_time_series_data(
110
+ self,
111
+ db: Session,
112
+ form_id: int,
113
+ start_date: Optional[datetime] = None,
114
+ end_date: Optional[datetime] = None,
115
+ question_ids: Optional[List[int]] = None,
116
+ countries: Optional[List[str]] = None,
117
+ utm_source: Optional[str] = None
118
+ ) -> Dict[str, Any]:
119
+ """
120
+ Get time-series data for views and submissions.
121
+
122
+ Returns daily counts of views and submissions within the date range.
123
+ """
124
+ # Default date range: last 30 days
125
+ if not end_date:
126
+ end_date = datetime.utcnow()
127
+ if not start_date:
128
+ start_date = end_date - timedelta(days=30)
129
+
130
+ # Build base filter
131
+ filters = [
132
+ FormAnalyticsEvent.form_id == form_id,
133
+ FormAnalyticsEvent.created_at >= start_date,
134
+ FormAnalyticsEvent.created_at <= end_date
135
+ ]
136
+
137
+ if question_ids:
138
+ filters.append(FormAnalyticsEvent.question_id.in_(question_ids))
139
+ if countries:
140
+ filters.append(FormAnalyticsEvent.country.in_(countries))
141
+ if utm_source:
142
+ filters.append(FormAnalyticsEvent.utm_source == utm_source)
143
+
144
+ # Get daily views
145
+ views_query = db.query(
146
+ func.date(FormAnalyticsEvent.created_at).label('date'),
147
+ func.count(FormAnalyticsEvent.id).label('count')
148
+ ).filter(
149
+ and_(*filters),
150
+ FormAnalyticsEvent.event_type == AnalyticsEventType.FORM_VIEWED.value
151
+ ).group_by(func.date(FormAnalyticsEvent.created_at)).all()
152
+
153
+ # Get daily submissions
154
+ submissions_query = db.query(
155
+ func.date(FormAnalyticsEvent.created_at).label('date'),
156
+ func.count(FormAnalyticsEvent.id).label('count')
157
+ ).filter(
158
+ and_(*filters),
159
+ FormAnalyticsEvent.event_type == AnalyticsEventType.FORM_SUBMITTED_COMPLETE.value
160
+ ).group_by(func.date(FormAnalyticsEvent.created_at)).all()
161
+
162
+ # Build complete date range
163
+ current_date = start_date.date()
164
+ end = end_date.date()
165
+ time_series = []
166
+
167
+ views_dict = {str(row.date): row.count for row in views_query}
168
+ submissions_dict = {str(row.date): row.count for row in submissions_query}
169
+
170
+ while current_date <= end:
171
+ date_str = str(current_date)
172
+ time_series.append({
173
+ "date": date_str,
174
+ "views": views_dict.get(date_str, 0),
175
+ "submissions": submissions_dict.get(date_str, 0)
176
+ })
177
+ current_date += timedelta(days=1)
178
+
179
+ return {
180
+ "time_series": time_series,
181
+ "total_views": sum(v["views"] for v in time_series),
182
+ "total_submissions": sum(v["submissions"] for v in time_series)
183
+ }
184
+
185
+ async def get_funnel_analytics(
186
+ self,
187
+ db: Session,
188
+ form_id: int,
189
+ start_date: Optional[datetime] = None,
190
+ end_date: Optional[datetime] = None,
191
+ question_ids: Optional[List[int]] = None,
192
+ countries: Optional[List[str]] = None,
193
+ utm_source: Optional[str] = None
194
+ ) -> Dict[str, Any]:
195
+ """
196
+ Generate funnel analytics for a form.
197
+
198
+ Args:
199
+ db: Database session
200
+ form_id: ID of the form
201
+ start_date: Optional start date for filtering
202
+ end_date: Optional end date for filtering
203
+
204
+ Returns:
205
+ Dictionary with funnel analytics data
206
+ """
207
+ # Default date range: last 30 days
208
+ if not end_date:
209
+ end_date = datetime.utcnow()
210
+ if not start_date:
211
+ start_date = end_date - timedelta(days=30)
212
+
213
+ # Base query filter
214
+ filters = [
215
+ FormAnalyticsEvent.form_id == form_id,
216
+ FormAnalyticsEvent.created_at >= start_date,
217
+ FormAnalyticsEvent.created_at <= end_date
218
+ ]
219
+
220
+ if question_ids:
221
+ filters.append(FormAnalyticsEvent.question_id.in_(question_ids))
222
+ if countries:
223
+ filters.append(FormAnalyticsEvent.country.in_(countries))
224
+ if utm_source:
225
+ filters.append(FormAnalyticsEvent.utm_source == utm_source)
226
+
227
+ base_filter = and_(*filters)
228
+
229
+ # Count events by type
230
+ total_views = db.query(func.count(FormAnalyticsEvent.id)).filter(
231
+ base_filter,
232
+ FormAnalyticsEvent.event_type == AnalyticsEventType.FORM_VIEWED.value
233
+ ).scalar() or 0
234
+
235
+ total_starts = db.query(func.count(FormAnalyticsEvent.id)).filter(
236
+ base_filter,
237
+ FormAnalyticsEvent.event_type == AnalyticsEventType.FORM_STARTED.value
238
+ ).scalar() or 0
239
+
240
+ total_completes = db.query(func.count(FormAnalyticsEvent.id)).filter(
241
+ base_filter,
242
+ FormAnalyticsEvent.event_type == AnalyticsEventType.FORM_SUBMITTED_COMPLETE.value
243
+ ).scalar() or 0
244
+
245
+ # Calculate completion rate
246
+ completion_rate = (total_completes / total_views * 100) if total_views > 0 else 0
247
+
248
+ # Get form questions
249
+ form = db.query(Form).filter(Form.id == form_id).first()
250
+ if not form:
251
+ return {}
252
+
253
+ # Build question funnel
254
+ question_funnel = []
255
+ for question in sorted(form.questions, key=lambda q: q.question_order):
256
+ viewed = db.query(func.count(FormAnalyticsEvent.id)).filter(
257
+ base_filter,
258
+ FormAnalyticsEvent.question_id == question.id,
259
+ FormAnalyticsEvent.event_type == AnalyticsEventType.QUESTION_VIEWED.value
260
+ ).scalar() or 0
261
+
262
+ answered = db.query(func.count(FormAnalyticsEvent.id)).filter(
263
+ base_filter,
264
+ FormAnalyticsEvent.question_id == question.id,
265
+ FormAnalyticsEvent.event_type == AnalyticsEventType.QUESTION_ANSWERED.value
266
+ ).scalar() or 0
267
+
268
+ skipped = db.query(func.count(FormAnalyticsEvent.id)).filter(
269
+ base_filter,
270
+ FormAnalyticsEvent.question_id == question.id,
271
+ FormAnalyticsEvent.event_type == AnalyticsEventType.QUESTION_SKIPPED.value
272
+ ).scalar() or 0
273
+
274
+ # Calculate average time spent
275
+ avg_time_result = db.query(
276
+ func.avg(FormAnalyticsEvent.time_spent_seconds)
277
+ ).filter(
278
+ base_filter,
279
+ FormAnalyticsEvent.question_id == question.id,
280
+ FormAnalyticsEvent.time_spent_seconds.isnot(None)
281
+ ).scalar()
282
+
283
+ avg_time_spent = float(avg_time_result) if avg_time_result else 0.0
284
+
285
+ # Calculate drop-off rate
286
+ drop_off_rate = (skipped / viewed * 100) if viewed > 0 else 0
287
+
288
+ question_funnel.append({
289
+ "question_id": question.id,
290
+ "question_text": question.question_text,
291
+ "question_order": question.question_order,
292
+ "viewed": viewed,
293
+ "answered": answered,
294
+ "skipped": skipped,
295
+ "drop_off_rate": round(drop_off_rate, 2),
296
+ "avg_time_spent": round(avg_time_spent, 2)
297
+ })
298
+
299
+ # Traffic sources analysis
300
+ traffic_sources = {}
301
+ utm_source_data = db.query(
302
+ FormAnalyticsEvent.utm_source,
303
+ func.count(FormAnalyticsEvent.id).label('count')
304
+ ).filter(
305
+ base_filter,
306
+ FormAnalyticsEvent.utm_source.isnot(None)
307
+ ).group_by(FormAnalyticsEvent.utm_source).all()
308
+
309
+ for source, count in utm_source_data:
310
+ # Calculate completion rate for this source
311
+ source_completes = db.query(func.count(FormAnalyticsEvent.id)).filter(
312
+ base_filter,
313
+ FormAnalyticsEvent.utm_source == source,
314
+ FormAnalyticsEvent.event_type == AnalyticsEventType.FORM_SUBMITTED_COMPLETE.value
315
+ ).scalar() or 0
316
+
317
+ completion_rate_source = (source_completes / count * 100) if count > 0 else 0
318
+
319
+ traffic_sources[source] = {
320
+ "count": count,
321
+ "completion_rate": round(completion_rate_source, 2)
322
+ }
323
+
324
+ # Geographic distribution
325
+ geographic_distribution = {}
326
+ country_data = db.query(
327
+ FormAnalyticsEvent.country,
328
+ func.count(FormAnalyticsEvent.id).label('views')
329
+ ).filter(
330
+ base_filter,
331
+ FormAnalyticsEvent.country.isnot(None)
332
+ ).group_by(FormAnalyticsEvent.country).all()
333
+
334
+ for country, views in country_data:
335
+ country_completes = db.query(func.count(FormAnalyticsEvent.id)).filter(
336
+ base_filter,
337
+ FormAnalyticsEvent.country == country,
338
+ FormAnalyticsEvent.event_type == AnalyticsEventType.FORM_SUBMITTED_COMPLETE.value
339
+ ).scalar() or 0
340
+
341
+ geographic_distribution[country] = {
342
+ "views": views,
343
+ "completes": country_completes
344
+ }
345
+
346
+ return {
347
+ "total_views": total_views,
348
+ "total_starts": total_starts,
349
+ "total_completes": total_completes,
350
+ "completion_rate": round(completion_rate, 2),
351
+ "question_funnel": question_funnel,
352
+ "traffic_sources": traffic_sources,
353
+ "geographic_distribution": geographic_distribution,
354
+ "date_range": {
355
+ "start": start_date.isoformat(),
356
+ "end": end_date.isoformat()
357
+ }
358
+ }
359
+
360
+ async def get_summary_analytics(
361
+ self,
362
+ db: Session,
363
+ form_id: int
364
+ ) -> Dict[str, Any]:
365
+ """
366
+ Get high-level analytics summary for a form.
367
+
368
+ Args:
369
+ db: Database session
370
+ form_id: ID of the form
371
+
372
+ Returns:
373
+ Dictionary with summary analytics
374
+ """
375
+ # Total responses
376
+ total_responses = db.query(func.count(FormResponse.id)).filter(
377
+ FormResponse.form_id == form_id
378
+ ).scalar() or 0
379
+
380
+ # Complete vs partial
381
+ complete_responses = db.query(func.count(FormResponse.id)).filter(
382
+ FormResponse.form_id == form_id,
383
+ FormResponse.status == "complete"
384
+ ).scalar() or 0
385
+
386
+ # Recent activity (last 7 days)
387
+ seven_days_ago = datetime.utcnow() - timedelta(days=7)
388
+ recent_responses = db.query(func.count(FormResponse.id)).filter(
389
+ FormResponse.form_id == form_id,
390
+ FormResponse.submitted_at >= seven_days_ago
391
+ ).scalar() or 0
392
+
393
+ # Average completion time (if tracked)
394
+ avg_completion_seconds = db.query(
395
+ func.avg(
396
+ func.extract('epoch', FormResponse.submitted_at - FormResponse.started_at)
397
+ )
398
+ ).filter(
399
+ FormResponse.form_id == form_id,
400
+ FormResponse.started_at.isnot(None),
401
+ FormResponse.status == "complete"
402
+ ).scalar()
403
+
404
+ avg_completion_minutes = (avg_completion_seconds / 60) if avg_completion_seconds else None
405
+
406
+ return {
407
+ "total_responses": total_responses,
408
+ "complete_responses": complete_responses,
409
+ "partial_responses": total_responses - complete_responses,
410
+ "recent_responses_7d": recent_responses,
411
+ "avg_completion_minutes": round(avg_completion_minutes, 2) if avg_completion_minutes else None
412
+ }
413
+
414
+ def _hash_ip(self, ip_address: str) -> str:
415
+ """Hash IP address for privacy"""
416
+ if not ip_address:
417
+ return "unknown"
418
+
419
+ # SHA256 hash with salt
420
+ salted = f"{ip_address}{self.ip_salt}"
421
+ return hashlib.sha256(salted.encode()).hexdigest()
422
+
423
+ async def _get_geo_location(self, ip_address: Optional[str]) -> tuple:
424
+ """
425
+ Get geographic location from IP address.
426
+
427
+ For now, returns None. In production:
428
+ - Use MaxMind GeoLite2 database (free, local lookup)
429
+ - Or use ipapi.co API (rate-limited)
430
+
431
+ Args:
432
+ ip_address: IP address to lookup
433
+
434
+ Returns:
435
+ Tuple of (country, city)
436
+ """
437
+ if not ip_address:
438
+ return (None, None)
439
+
440
+ # TODO: Implement with MaxMind GeoLite2 or ipapi.co
441
+ # For localhost/development, return None
442
+ if ip_address in ['127.0.0.1', 'localhost', '::1']:
443
+ return ('Local', 'Development')
444
+
445
+ # Placeholder - implement actual geolocation
446
+ return (None, None)
447
+
448
+
449
+ # Singleton instance
450
+ analytics_service = AnalyticsService()
app/services/credit_service.py ADDED
@@ -0,0 +1,392 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Credit management service for handling user credit operations
3
+ """
4
+ from sqlalchemy.orm import Session
5
+ from sqlalchemy import and_
6
+ from datetime import datetime
7
+ from typing import Optional, Dict, Any
8
+ import logging
9
+
10
+ from ..models import User, UserCredits, CreditTransaction, TransactionType, SubscriptionPlan, Subscription
11
+ from .plan_service import plan_service
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class CreditService:
17
+ """Service for managing user credits"""
18
+
19
+ def get_user_credits(self, db: Session, user_id: int) -> Optional[UserCredits]:
20
+ """
21
+ Get the current credit balance for a user
22
+
23
+ Args:
24
+ db: Database session
25
+ user_id: User ID
26
+
27
+ Returns:
28
+ UserCredits object or None if not found
29
+ """
30
+ return db.query(UserCredits).filter(UserCredits.user_id == user_id).first()
31
+
32
+ def get_or_create_user_credits(self, db: Session, user_id: int, plan_id: Optional[int] = None) -> UserCredits:
33
+ """
34
+ Get or create credit record for a user. If plan_id is null, assigns free tier.
35
+
36
+ Args:
37
+ db: Database session
38
+ user_id: User ID
39
+ plan_id: Optional plan ID (if None and credits don't exist, assigns free tier)
40
+
41
+ Returns:
42
+ UserCredits object
43
+ """
44
+ credits = self.get_user_credits(db, user_id)
45
+ if not credits:
46
+ # If no plan_id provided, get free tier
47
+ if plan_id is None:
48
+ free_plan = plan_service.get_plan_by_name(db, "Free")
49
+ if free_plan:
50
+ plan_id = free_plan.id
51
+ # Initialize with free tier credits
52
+ credits = UserCredits(
53
+ user_id=user_id,
54
+ plan_id=plan_id,
55
+ balance=free_plan.credits_per_month,
56
+ last_reset_at=datetime.utcnow()
57
+ )
58
+ db.add(credits)
59
+
60
+ # Also create subscription record
61
+ subscription = db.query(Subscription).filter(
62
+ Subscription.user_id == user_id
63
+ ).order_by(Subscription.created_at.desc()).first()
64
+
65
+ if not subscription:
66
+ subscription = Subscription(
67
+ user_id=user_id,
68
+ plan_id=free_plan.id,
69
+ status="active"
70
+ )
71
+ db.add(subscription)
72
+ logger.info(f"Created subscription record for user {user_id} with free tier")
73
+
74
+ # Log credit transaction for initial free tier assignment
75
+ transaction = CreditTransaction(
76
+ user_id=user_id,
77
+ amount=free_plan.credits_per_month,
78
+ transaction_type=TransactionType.RESET,
79
+ description="Initial free tier assignment",
80
+ transaction_metadata={"plan_id": free_plan.id, "plan_name": free_plan.name}
81
+ )
82
+ db.add(transaction)
83
+ else:
84
+ # Fallback if free plan doesn't exist
85
+ credits = UserCredits(
86
+ user_id=user_id,
87
+ plan_id=None,
88
+ balance=0,
89
+ last_reset_at=None
90
+ )
91
+ db.add(credits)
92
+ else:
93
+ credits = UserCredits(
94
+ user_id=user_id,
95
+ plan_id=plan_id,
96
+ balance=0,
97
+ last_reset_at=None
98
+ )
99
+ db.add(credits)
100
+ db.commit()
101
+ db.refresh(credits)
102
+ logger.info(f"Created credit record for user {user_id} with plan_id={plan_id}")
103
+ elif credits.plan_id is None:
104
+ # Existing credits but no plan_id - assign free tier
105
+ # Only reset balance if user was never assigned credits before (last_reset_at is None)
106
+ # This prevents users who exhausted credits from getting free credits again
107
+ free_plan = plan_service.get_plan_by_name(db, "Free")
108
+ if free_plan:
109
+ old_balance = credits.balance
110
+ credits.plan_id = free_plan.id
111
+ # Only reset balance if user was never assigned credits before
112
+ # (last_reset_at is None means they never had credits initialized)
113
+ if credits.balance == 0 and credits.last_reset_at is None:
114
+ credits.balance = free_plan.credits_per_month
115
+ credits.last_reset_at = datetime.utcnow()
116
+ credits.updated_at = datetime.utcnow()
117
+
118
+ # Also ensure subscription record exists
119
+ subscription = db.query(Subscription).filter(
120
+ Subscription.user_id == user_id
121
+ ).order_by(Subscription.created_at.desc()).first()
122
+
123
+ if not subscription:
124
+ # Create subscription record
125
+ subscription = Subscription(
126
+ user_id=user_id,
127
+ plan_id=free_plan.id,
128
+ status="active"
129
+ )
130
+ db.add(subscription)
131
+ logger.info(f"Created subscription record for user {user_id} with free tier")
132
+ elif subscription.plan_id != free_plan.id:
133
+ # Update existing subscription to free tier
134
+ subscription.plan_id = free_plan.id
135
+ subscription.status = "active"
136
+ logger.info(f"Updated subscription for user {user_id} to free tier")
137
+
138
+ # Log credit transaction if balance changed
139
+ if old_balance != credits.balance:
140
+ transaction = CreditTransaction(
141
+ user_id=user_id,
142
+ amount=credits.balance - old_balance,
143
+ transaction_type=TransactionType.RESET,
144
+ description="Free tier assignment - balance reset",
145
+ transaction_metadata={"plan_id": free_plan.id, "plan_name": free_plan.name, "old_balance": old_balance}
146
+ )
147
+ db.add(transaction)
148
+
149
+ db.commit()
150
+ db.refresh(credits)
151
+ logger.info(f"Assigned free tier to user {user_id} (existing credits record)")
152
+ return credits
153
+
154
+ def check_sufficient_credits(self, db: Session, user_id: int, amount: int) -> bool:
155
+ """
156
+ Check if user has sufficient credits (pre-check before operation)
157
+
158
+ Args:
159
+ db: Database session
160
+ user_id: User ID
161
+ amount: Required credit amount
162
+
163
+ Returns:
164
+ True if user has enough credits, False otherwise
165
+ """
166
+ credits = self.get_user_credits(db, user_id)
167
+ if not credits:
168
+ logger.warning(f"No credit record found for user {user_id}")
169
+ return False
170
+
171
+ has_sufficient = credits.balance >= amount
172
+ logger.info(f"User {user_id} credit check: balance={credits.balance}, required={amount}, sufficient={has_sufficient}")
173
+ return has_sufficient
174
+
175
+ def deduct_credits(
176
+ self,
177
+ db: Session,
178
+ user_id: int,
179
+ amount: int,
180
+ description: str,
181
+ metadata: Optional[Dict[str, Any]] = None
182
+ ) -> UserCredits:
183
+ """
184
+ Deduct credits from user account (post-operation deduction)
185
+
186
+ Args:
187
+ db: Database session
188
+ user_id: User ID
189
+ amount: Amount to deduct (positive number)
190
+ description: Description of the transaction
191
+ metadata: Optional metadata dictionary
192
+
193
+ Returns:
194
+ Updated UserCredits object
195
+
196
+ Raises:
197
+ ValueError: If user has insufficient credits
198
+ """
199
+ credits = self.get_user_credits(db, user_id)
200
+ if not credits:
201
+ raise ValueError(f"No credit record found for user {user_id}")
202
+
203
+ if credits.balance < amount:
204
+ raise ValueError(f"Insufficient credits: balance={credits.balance}, required={amount}")
205
+
206
+ # Deduct credits
207
+ credits.balance -= amount
208
+ credits.updated_at = datetime.utcnow()
209
+
210
+ # Log transaction
211
+ transaction = CreditTransaction(
212
+ user_id=user_id,
213
+ amount=-amount, # Negative for deduction
214
+ transaction_type=TransactionType.DEDUCT,
215
+ description=description,
216
+ transaction_metadata=metadata or {}
217
+ )
218
+ db.add(transaction)
219
+ db.commit()
220
+ db.refresh(credits)
221
+
222
+ logger.info(f"Deducted {amount} credits from user {user_id}. New balance: {credits.balance}")
223
+ return credits
224
+
225
+ def add_credits(
226
+ self,
227
+ db: Session,
228
+ user_id: int,
229
+ amount: int,
230
+ description: str,
231
+ transaction_type: TransactionType = TransactionType.ADJUSTMENT,
232
+ metadata: Optional[Dict[str, Any]] = None
233
+ ) -> UserCredits:
234
+ """
235
+ Add credits to user account (for adjustments, refunds, etc.)
236
+
237
+ Args:
238
+ db: Database session
239
+ user_id: User ID
240
+ amount: Amount to add (positive number)
241
+ description: Description of the transaction
242
+ transaction_type: Type of transaction (ADJUSTMENT, REFUND, etc.)
243
+ metadata: Optional metadata dictionary
244
+
245
+ Returns:
246
+ Updated UserCredits object
247
+ """
248
+ credits = self.get_or_create_user_credits(db, user_id)
249
+
250
+ # Add credits
251
+ credits.balance += amount
252
+ credits.updated_at = datetime.utcnow()
253
+
254
+ # Log transaction
255
+ transaction = CreditTransaction(
256
+ user_id=user_id,
257
+ amount=amount, # Positive for addition
258
+ transaction_type=transaction_type,
259
+ description=description,
260
+ transaction_metadata=metadata or {}
261
+ )
262
+ db.add(transaction)
263
+ db.commit()
264
+ db.refresh(credits)
265
+
266
+ logger.info(f"Added {amount} credits to user {user_id}. New balance: {credits.balance}")
267
+ return credits
268
+
269
+ def reset_credits(
270
+ self,
271
+ db: Session,
272
+ user_id: int,
273
+ plan_id: Optional[int] = None,
274
+ description: str = "Monthly credit reset"
275
+ ) -> UserCredits:
276
+ """
277
+ Reset user credits to their plan limit
278
+
279
+ Args:
280
+ db: Database session
281
+ user_id: User ID
282
+ plan_id: Optional plan ID (if None, uses current plan)
283
+ description: Description for the transaction
284
+
285
+ Returns:
286
+ Updated UserCredits object
287
+ """
288
+ credits = self.get_or_create_user_credits(db, user_id, plan_id)
289
+
290
+ # Get plan details
291
+ if plan_id:
292
+ plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first()
293
+ elif credits.plan_id:
294
+ plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == credits.plan_id).first()
295
+ else:
296
+ # Default to free tier if no plan
297
+ plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.name == "Free").first()
298
+
299
+ if not plan:
300
+ raise ValueError(f"No plan found for user {user_id}")
301
+
302
+ # Reset to plan limit
303
+ old_balance = credits.balance
304
+ credits.balance = plan.credits_per_month
305
+ credits.plan_id = plan.id
306
+ credits.last_reset_at = datetime.utcnow()
307
+ credits.updated_at = datetime.utcnow()
308
+
309
+ # Log transaction
310
+ transaction = CreditTransaction(
311
+ user_id=user_id,
312
+ amount=credits.balance - old_balance, # Can be positive or negative
313
+ transaction_type=TransactionType.RESET,
314
+ description=description,
315
+ transaction_metadata={"plan_id": plan.id, "plan_name": plan.name, "old_balance": old_balance}
316
+ )
317
+ db.add(transaction)
318
+ db.commit()
319
+ db.refresh(credits)
320
+
321
+ logger.info(f"Reset credits for user {user_id} to {credits.balance} (plan: {plan.name})")
322
+ return credits
323
+
324
+ def get_credit_history(
325
+ self,
326
+ db: Session,
327
+ user_id: int,
328
+ limit: int = 50,
329
+ offset: int = 0
330
+ ) -> list[CreditTransaction]:
331
+ """
332
+ Get credit transaction history for a user
333
+
334
+ Args:
335
+ db: Database session
336
+ user_id: User ID
337
+ limit: Number of transactions to return
338
+ offset: Number of transactions to skip
339
+
340
+ Returns:
341
+ List of CreditTransaction objects
342
+ """
343
+ return (
344
+ db.query(CreditTransaction)
345
+ .filter(CreditTransaction.user_id == user_id)
346
+ .order_by(CreditTransaction.created_at.desc())
347
+ .limit(limit)
348
+ .offset(offset)
349
+ .all()
350
+ )
351
+
352
+ def get_balance_info(self, db: Session, user_id: int) -> Dict[str, Any]:
353
+ """
354
+ Get comprehensive balance information for a user
355
+
356
+ Args:
357
+ db: Database session
358
+ user_id: User ID
359
+
360
+ Returns:
361
+ Dictionary with balance, plan info, and limits
362
+ """
363
+ credits = self.get_user_credits(db, user_id)
364
+ if not credits:
365
+ return {
366
+ "balance": 0,
367
+ "plan_name": None,
368
+ "plan_id": None,
369
+ "credits_per_analyze": 0,
370
+ "credits_per_edit": 0,
371
+ "last_reset_at": None,
372
+ "updated_at": datetime.utcnow().isoformat()
373
+ }
374
+
375
+ plan = None
376
+ if credits.plan_id:
377
+ plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == credits.plan_id).first()
378
+
379
+ return {
380
+ "balance": credits.balance,
381
+ "plan_name": plan.name if plan else None,
382
+ "plan_id": credits.plan_id,
383
+ "credits_per_analyze": plan.credits_per_analyze if plan else 0,
384
+ "credits_per_edit": plan.credits_per_edit if plan else 0,
385
+ "last_reset_at": credits.last_reset_at.isoformat() if credits.last_reset_at else None,
386
+ "updated_at": credits.updated_at.isoformat()
387
+ }
388
+
389
+
390
+ # Singleton instance
391
+ credit_service = CreditService()
392
+
app/services/export_service.py ADDED
@@ -0,0 +1,480 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Export service for form responses (CSV, Excel, PDF)
3
+ """
4
+ from sqlalchemy.orm import Session
5
+ from sqlalchemy import desc
6
+ from typing import Optional, List, Dict, Any
7
+ from datetime import datetime
8
+ import io
9
+ import csv
10
+ import json
11
+ import logging
12
+
13
+ # Excel support
14
+ from openpyxl import Workbook
15
+ from openpyxl.styles import Font, Alignment, PatternFill
16
+ from openpyxl.chart import BarChart, Reference
17
+
18
+ # PDF support
19
+ from reportlab.lib.pagesizes import letter, A4
20
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
21
+ from reportlab.lib.units import inch
22
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
23
+ from reportlab.lib import colors
24
+
25
+ from ..models import Form, FormQuestion, FormResponse, ResponseAnswer
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class ExportService:
31
+ """Service for exporting form responses in various formats"""
32
+
33
+ async def export_csv(
34
+ self,
35
+ db: Session,
36
+ form_id: int,
37
+ include_incomplete: bool = False,
38
+ start_date: Optional[datetime] = None,
39
+ end_date: Optional[datetime] = None,
40
+ anonymize_pii: bool = False
41
+ ) -> io.StringIO:
42
+ """
43
+ Enhanced CSV export with filtering.
44
+
45
+ Args:
46
+ db: Database session
47
+ form_id: ID of the form
48
+ include_incomplete: Include incomplete submissions
49
+ start_date: Optional start date filter
50
+ end_date: Optional end date filter
51
+ anonymize_pii: Anonymize personally identifiable information
52
+
53
+ Returns:
54
+ StringIO object with CSV data
55
+ """
56
+ # Get form
57
+ form = db.query(Form).filter(Form.id == form_id).first()
58
+ if not form:
59
+ raise ValueError(f"Form {form_id} not found")
60
+
61
+ # Get questions
62
+ questions = sorted(form.questions, key=lambda q: q.question_order)
63
+
64
+ # Build query for responses
65
+ query = db.query(FormResponse).filter(FormResponse.form_id == form_id)
66
+
67
+ # Apply filters
68
+ if not include_incomplete:
69
+ query = query.filter(FormResponse.status == "complete")
70
+
71
+ if start_date:
72
+ query = query.filter(FormResponse.submitted_at >= start_date)
73
+
74
+ if end_date:
75
+ query = query.filter(FormResponse.submitted_at <= end_date)
76
+
77
+ responses = query.order_by(desc(FormResponse.submitted_at)).all()
78
+
79
+ # Create CSV in memory
80
+ output = io.StringIO()
81
+ writer = csv.writer(output)
82
+
83
+ # Build headers
84
+ headers = [
85
+ "Response ID",
86
+ "Status",
87
+ "Submitted At",
88
+ "Started At",
89
+ "Completion Time (minutes)",
90
+ "IP Address" if not anonymize_pii else "IP Hash",
91
+ "Country",
92
+ "City",
93
+ "UTM Source",
94
+ "UTM Medium",
95
+ "UTM Campaign"
96
+ ]
97
+ headers.extend([q.question_text for q in questions])
98
+ writer.writerow(headers)
99
+
100
+ # Write data rows
101
+ for response in responses:
102
+ # Calculate completion time
103
+ completion_minutes = None
104
+ if response.started_at and response.submitted_at:
105
+ delta = response.submitted_at - response.started_at
106
+ completion_minutes = round(delta.total_seconds() / 60, 2)
107
+
108
+ row = [
109
+ response.id,
110
+ response.status,
111
+ response.submitted_at.isoformat() if response.submitted_at else "",
112
+ response.started_at.isoformat() if response.started_at else "",
113
+ completion_minutes if completion_minutes else "",
114
+ response.ip_address_hash if anonymize_pii else (response.ip_address or ""),
115
+ response.country or "",
116
+ response.city or "",
117
+ response.utm_source or "",
118
+ response.utm_medium or "",
119
+ response.utm_campaign or ""
120
+ ]
121
+
122
+ # Create answer map
123
+ answer_map = {
124
+ answer.form_question_id: answer.answer_value
125
+ for answer in response.answers
126
+ }
127
+
128
+ # Add answers in question order
129
+ for question in questions:
130
+ answer_value = answer_map.get(question.id, {})
131
+ formatted_answer = self._format_answer_for_export(answer_value)
132
+ row.append(formatted_answer)
133
+
134
+ writer.writerow(row)
135
+
136
+ output.seek(0)
137
+ return output
138
+
139
+ async def export_excel(
140
+ self,
141
+ db: Session,
142
+ form_id: int,
143
+ include_incomplete: bool = False
144
+ ) -> io.BytesIO:
145
+ """
146
+ Export as Excel with multiple sheets.
147
+
148
+ Sheets:
149
+ 1. Responses - All response data
150
+ 2. Summary - Statistics and charts
151
+ 3. Metadata - Form structure info
152
+
153
+ Args:
154
+ db: Database session
155
+ form_id: ID of the form
156
+ include_incomplete: Include incomplete submissions
157
+
158
+ Returns:
159
+ BytesIO object with Excel workbook
160
+ """
161
+ # Get form
162
+ form = db.query(Form).filter(Form.id == form_id).first()
163
+ if not form:
164
+ raise ValueError(f"Form {form_id} not found")
165
+
166
+ # Create workbook
167
+ wb = Workbook()
168
+
169
+ # Sheet 1: Responses
170
+ ws_responses = wb.active
171
+ ws_responses.title = "Responses"
172
+
173
+ # Get questions
174
+ questions = sorted(form.questions, key=lambda q: q.question_order)
175
+
176
+ # Headers
177
+ headers = ["Response ID", "Status", "Submitted At", "Country", "City"]
178
+ headers.extend([q.question_text for q in questions])
179
+ ws_responses.append(headers)
180
+
181
+ # Style headers
182
+ header_fill = PatternFill(start_color="9333EA", end_color="9333EA", fill_type="solid")
183
+ header_font = Font(color="FFFFFF", bold=True)
184
+
185
+ for cell in ws_responses[1]:
186
+ cell.fill = header_fill
187
+ cell.font = header_font
188
+ cell.alignment = Alignment(horizontal="center", vertical="center")
189
+
190
+ # Get responses
191
+ query = db.query(FormResponse).filter(FormResponse.form_id == form_id)
192
+ if not include_incomplete:
193
+ query = query.filter(FormResponse.status == "complete")
194
+
195
+ responses = query.order_by(desc(FormResponse.submitted_at)).all()
196
+
197
+ # Write data
198
+ for response in responses:
199
+ answer_map = {
200
+ answer.form_question_id: answer.answer_value
201
+ for answer in response.answers
202
+ }
203
+
204
+ row = [
205
+ response.id,
206
+ response.status,
207
+ response.submitted_at.isoformat() if response.submitted_at else "",
208
+ response.country or "",
209
+ response.city or ""
210
+ ]
211
+
212
+ for question in questions:
213
+ answer_value = answer_map.get(question.id, {})
214
+ formatted_answer = self._format_answer_for_export(answer_value)
215
+ row.append(formatted_answer)
216
+
217
+ ws_responses.append(row)
218
+
219
+ # Auto-size columns
220
+ for column in ws_responses.columns:
221
+ max_length = 0
222
+ column_letter = column[0].column_letter
223
+ for cell in column:
224
+ try:
225
+ if len(str(cell.value)) > max_length:
226
+ max_length = len(str(cell.value))
227
+ except:
228
+ pass
229
+ adjusted_width = min(max_length + 2, 50)
230
+ ws_responses.column_dimensions[column_letter].width = adjusted_width
231
+
232
+ # Sheet 2: Summary
233
+ ws_summary = wb.create_sheet("Summary")
234
+ ws_summary['A1'] = "Form Analytics Summary"
235
+ ws_summary['A1'].font = Font(size=16, bold=True)
236
+
237
+ ws_summary['A3'] = "Total Responses:"
238
+ ws_summary['B3'] = len(responses)
239
+
240
+ complete_count = sum(1 for r in responses if r.status == "complete")
241
+ ws_summary['A4'] = "Complete Responses:"
242
+ ws_summary['B4'] = complete_count
243
+
244
+ ws_summary['A5'] = "Completion Rate:"
245
+ ws_summary['B5'] = f"{(complete_count / len(responses) * 100):.1f}%" if responses else "0%"
246
+
247
+ # Sheet 3: Metadata
248
+ ws_meta = wb.create_sheet("Metadata")
249
+ ws_meta['A1'] = "Form Metadata"
250
+ ws_meta['A1'].font = Font(size=16, bold=True)
251
+
252
+ ws_meta['A3'] = "Form Title:"
253
+ ws_meta['B3'] = form.title
254
+
255
+ ws_meta['A4'] = "Form ID:"
256
+ ws_meta['B4'] = form.id
257
+
258
+ ws_meta['A5'] = "Export Date:"
259
+ ws_meta['B5'] = datetime.utcnow().isoformat()
260
+
261
+ ws_meta['A6'] = "Total Questions:"
262
+ ws_meta['B6'] = len(questions)
263
+
264
+ ws_meta['A8'] = "Questions:"
265
+ for idx, question in enumerate(questions, start=9):
266
+ ws_meta[f'A{idx}'] = f"Q{question.question_order}:"
267
+ ws_meta[f'B{idx}'] = question.question_text
268
+ ws_meta[f'C{idx}'] = question.question_type.value
269
+
270
+ # Save to BytesIO
271
+ output = io.BytesIO()
272
+ wb.save(output)
273
+ output.seek(0)
274
+
275
+ return output
276
+
277
+ async def export_pdf(
278
+ self,
279
+ db: Session,
280
+ form_id: int,
281
+ response_id: int
282
+ ) -> io.BytesIO:
283
+ """
284
+ Export single response as formatted PDF.
285
+
286
+ Args:
287
+ db: Database session
288
+ form_id: ID of the form
289
+ response_id: ID of the response
290
+
291
+ Returns:
292
+ BytesIO object with PDF data
293
+ """
294
+ # Get form and response
295
+ form = db.query(Form).filter(Form.id == form_id).first()
296
+ if not form:
297
+ raise ValueError(f"Form {form_id} not found")
298
+
299
+ response = db.query(FormResponse).filter(
300
+ FormResponse.id == response_id,
301
+ FormResponse.form_id == form_id
302
+ ).first()
303
+
304
+ if not response:
305
+ raise ValueError(f"Response {response_id} not found")
306
+
307
+ # Create PDF in memory
308
+ buffer = io.BytesIO()
309
+ doc = SimpleDocTemplate(buffer, pagesize=letter)
310
+
311
+ # Build content
312
+ story = []
313
+ styles = getSampleStyleSheet()
314
+
315
+ # Title style
316
+ title_style = ParagraphStyle(
317
+ 'CustomTitle',
318
+ parent=styles['Heading1'],
319
+ fontSize=24,
320
+ textColor=colors.HexColor('#9333EA'),
321
+ spaceAfter=30
322
+ )
323
+
324
+ # Add title
325
+ story.append(Paragraph(form.title, title_style))
326
+ story.append(Spacer(1, 0.2 * inch))
327
+
328
+ # Add metadata
329
+ metadata_data = [
330
+ ["Response ID:", str(response.id)],
331
+ ["Submitted:", response.submitted_at.strftime("%Y-%m-%d %H:%M:%S") if response.submitted_at else "N/A"],
332
+ ["Status:", response.status.title()],
333
+ ]
334
+
335
+ if response.country:
336
+ metadata_data.append(["Location:", f"{response.city}, {response.country}" if response.city else response.country])
337
+
338
+ metadata_table = Table(metadata_data, colWidths=[2*inch, 4*inch])
339
+ metadata_table.setStyle(TableStyle([
340
+ ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
341
+ ('FONTSIZE', (0, 0), (-1, -1), 10),
342
+ ('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#6B7280')),
343
+ ('ALIGN', (0, 0), (0, -1), 'RIGHT'),
344
+ ('VALIGN', (0, 0), (-1, -1), 'TOP'),
345
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 6),
346
+ ]))
347
+
348
+ story.append(metadata_table)
349
+ story.append(Spacer(1, 0.4 * inch))
350
+
351
+ # Add divider
352
+ story.append(Paragraph("<hr width='100%'/>", styles['Normal']))
353
+ story.append(Spacer(1, 0.3 * inch))
354
+
355
+ # Create answer map
356
+ answer_map = {
357
+ answer.form_question_id: answer.answer_value
358
+ for answer in response.answers
359
+ }
360
+
361
+ # Add questions and answers
362
+ question_style = ParagraphStyle(
363
+ 'Question',
364
+ parent=styles['Heading3'],
365
+ fontSize=12,
366
+ textColor=colors.HexColor('#1F2937'),
367
+ spaceAfter=6
368
+ )
369
+
370
+ answer_style = ParagraphStyle(
371
+ 'Answer',
372
+ parent=styles['Normal'],
373
+ fontSize=11,
374
+ textColor=colors.HexColor('#4B5563'),
375
+ leftIndent=20,
376
+ spaceAfter=20
377
+ )
378
+
379
+ for idx, question in enumerate(sorted(form.questions, key=lambda q: q.question_order), 1):
380
+ # Question text
381
+ question_text = f"<b>{idx}. {question.question_text}</b>"
382
+ if question.required:
383
+ question_text += " <font color='red'>*</font>"
384
+
385
+ story.append(Paragraph(question_text, question_style))
386
+
387
+ # Answer
388
+ answer_value = answer_map.get(question.id, {})
389
+ formatted_answer = self._format_answer_for_pdf(answer_value)
390
+ story.append(Paragraph(formatted_answer, answer_style))
391
+
392
+ # Add footer
393
+ story.append(Spacer(1, 0.5 * inch))
394
+ footer_style = ParagraphStyle(
395
+ 'Footer',
396
+ parent=styles['Normal'],
397
+ fontSize=8,
398
+ textColor=colors.HexColor('#9CA3AF'),
399
+ alignment=1 # Center
400
+ )
401
+ story.append(Paragraph(
402
+ f"Generated by AutoForm on {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')} UTC",
403
+ footer_style
404
+ ))
405
+
406
+ # Build PDF
407
+ doc.build(story)
408
+ buffer.seek(0)
409
+
410
+ return buffer
411
+
412
+ def _format_answer_for_export(self, answer_value: Dict[str, Any]) -> str:
413
+ """Format answer value for CSV/Excel export"""
414
+ if not answer_value:
415
+ return ""
416
+
417
+ # Text answer
418
+ if "text" in answer_value and answer_value["text"]:
419
+ return str(answer_value["text"])
420
+
421
+ # Number answer
422
+ if "number" in answer_value and answer_value["number"] is not None:
423
+ return str(answer_value["number"])
424
+
425
+ # Choices (multiple choice, checkboxes)
426
+ if "choices" in answer_value and answer_value["choices"]:
427
+ return ", ".join(answer_value["choices"])
428
+
429
+ # Date
430
+ if "date" in answer_value and answer_value["date"]:
431
+ return str(answer_value["date"])
432
+
433
+ # Rating
434
+ if "rating" in answer_value and answer_value["rating"] is not None:
435
+ return str(answer_value["rating"])
436
+
437
+ # Matrix answers
438
+ if "matrix_answers" in answer_value and answer_value["matrix_answers"]:
439
+ matrix_str = "; ".join([
440
+ f"{row}: {col}" for row, col in answer_value["matrix_answers"].items()
441
+ ])
442
+ return matrix_str
443
+
444
+ # Ranked items
445
+ if "ranked_items" in answer_value and answer_value["ranked_items"]:
446
+ return ", ".join(answer_value["ranked_items"])
447
+
448
+ # File upload
449
+ if "file_url" in answer_value and answer_value["file_url"]:
450
+ return answer_value["file_url"]
451
+
452
+ # Wallet address
453
+ if "wallet_address" in answer_value and answer_value["wallet_address"]:
454
+ return answer_value["wallet_address"]
455
+
456
+ # Signature
457
+ if "signature" in answer_value and answer_value["signature"]:
458
+ return "[Signature provided]"
459
+
460
+ # Fallback: JSON dump
461
+ return json.dumps(answer_value)
462
+
463
+ def _format_answer_for_pdf(self, answer_value: Dict[str, Any]) -> str:
464
+ """Format answer value for PDF display"""
465
+ if not answer_value:
466
+ return "<i>No answer provided</i>"
467
+
468
+ formatted = self._format_answer_for_export(answer_value)
469
+
470
+ if not formatted:
471
+ return "<i>No answer provided</i>"
472
+
473
+ # Escape HTML special characters for ReportLab
474
+ formatted = formatted.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
475
+
476
+ return formatted
477
+
478
+
479
+ # Singleton instance
480
+ export_service = ExportService()
app/services/form_creator.py ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Form Creator Module
3
+ ===================
4
+
5
+ This module integrates with DSPy to generate form structures from natural language queries.
6
+ Uses the new FormGenerationModule architecture.
7
+ """
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ from typing import Dict, Any, List, Tuple
13
+ from .agents import FormGenerationModule, FormChatFunction
14
+ import dspy
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ # Validation metrics for signature outputs
20
+ def validate_form_plan(form_result: Dict[str, Any]) -> Tuple[bool, List[str]]:
21
+ """
22
+ Validate that the form plan from FormPlannerSignature is complete.
23
+
24
+ Returns:
25
+ tuple: (is_valid, error_messages)
26
+ """
27
+ errors = []
28
+
29
+ # Check required fields
30
+ if not form_result.get("title"):
31
+ errors.append("Missing required field: title")
32
+
33
+ if not form_result.get("components"):
34
+ errors.append("Missing required field: components (must have at least one)")
35
+ elif not isinstance(form_result.get("components"), list):
36
+ errors.append("Field 'components' must be a list")
37
+ elif len(form_result.get("components")) == 0:
38
+ errors.append("Components list is empty (must have at least one)")
39
+
40
+ # Validate each component has required fields
41
+ components = form_result.get("components", [])
42
+ for idx, comp in enumerate(components):
43
+ if not comp.get("component_id"):
44
+ errors.append(f"Component {idx}: missing component_id")
45
+ if not comp.get("question_type"):
46
+ errors.append(f"Component {idx}: missing question_type")
47
+ if not comp.get("question_text"):
48
+ errors.append(f"Component {idx}: missing question_text")
49
+
50
+ # Validate question type
51
+ valid_types = [
52
+ "short_answer", "long_answer", "multiple_choice", "checkboxes",
53
+ "dropdown", "multi_select", "number", "email", "phone", "link",
54
+ "file_upload", "date", "time", "linear_scale", "matrix", "rating",
55
+ "payment", "signature", "ranking", "wallet_connect"
56
+ ]
57
+ if comp.get("question_type") not in valid_types:
58
+ errors.append(f"Component {idx}: invalid question_type '{comp.get('question_type')}'")
59
+
60
+ # Validate conditional logic references valid components
61
+ component_ids = {comp.get("component_id") for comp in components}
62
+ conditional_logic = form_result.get("conditional_logic", [])
63
+
64
+ for idx, rule in enumerate(conditional_logic):
65
+ trigger_id = rule.get("trigger_component_id")
66
+ target_id = rule.get("target_component_id")
67
+
68
+ if trigger_id not in component_ids:
69
+ errors.append(f"Conditional rule {idx}: invalid trigger_component_id '{trigger_id}'")
70
+ if target_id not in component_ids:
71
+ errors.append(f"Conditional rule {idx}: invalid target_component_id '{target_id}'")
72
+
73
+ valid_conditions = ["equals", "not_equals", "contains", "not_contains", "greater_than", "less_than", "is_empty", "is_not_empty"]
74
+ if rule.get("condition_type") not in valid_conditions:
75
+ errors.append(f"Conditional rule {idx}: invalid condition_type '{rule.get('condition_type')}'")
76
+
77
+ if rule.get("action") not in ["show", "hide"]:
78
+ errors.append(f"Conditional rule {idx}: invalid action '{rule.get('action')}'")
79
+
80
+ is_valid = len(errors) == 0
81
+ return is_valid, errors
82
+
83
+
84
+ def validate_component_settings(question_type: str, settings: Dict[str, Any]) -> Tuple[bool, List[str]]:
85
+ """
86
+ Validate that component settings match the question type requirements.
87
+
88
+ Returns:
89
+ tuple: (is_valid, error_messages)
90
+ """
91
+ errors = []
92
+
93
+ # Check type-specific required settings
94
+ if question_type in ["multiple_choice", "checkboxes", "dropdown", "multi_select"]:
95
+ if not settings.get("choices"):
96
+ errors.append(f"Question type '{question_type}' requires 'choices' in settings")
97
+ elif not isinstance(settings.get("choices"), list) or len(settings.get("choices")) < 2:
98
+ errors.append(f"Question type '{question_type}' requires at least 2 choices")
99
+
100
+ if question_type == "linear_scale":
101
+ if "min_value" not in settings or "max_value" not in settings:
102
+ errors.append(f"Question type 'linear_scale' requires 'min_value' and 'max_value' in settings")
103
+
104
+ if question_type == "matrix":
105
+ if not settings.get("rows") or not settings.get("columns"):
106
+ errors.append(f"Question type 'matrix' requires 'rows' and 'columns' in settings")
107
+
108
+ if question_type == "ranking":
109
+ if not settings.get("ranking_items") or len(settings.get("ranking_items", [])) < 2:
110
+ errors.append(f"Question type 'ranking' requires at least 2 'ranking_items' in settings")
111
+
112
+ if question_type == "payment":
113
+ if "payment_amount" not in settings:
114
+ errors.append(f"Question type 'payment' requires 'payment_amount' in settings")
115
+
116
+ is_valid = len(errors) == 0
117
+ return is_valid, errors
118
+
119
+
120
+ async def generate_form_spec(
121
+ user_query: str,
122
+ user_id: int = None
123
+ ) -> Tuple[Dict[str, Any], List[Dict[str, Any]], List[Dict[str, Any]]]:
124
+ """
125
+ Generate form specification based on the user's query using FormGenerationModule.
126
+
127
+ Args:
128
+ user_query: Natural language description of the form needed
129
+ user_id: User ID for context
130
+
131
+ Returns:
132
+ tuple: (form_data, questions_list, conditional_rules_list)
133
+ - form_data: Dict with title, description, settings
134
+ - questions_list: List of question dicts with type, text, options, etc.
135
+ - conditional_rules_list: List of conditional logic rules
136
+ """
137
+ try:
138
+ # Configure DSPy with OpenAI
139
+ api_key = os.getenv('OPENAI_API_KEY')
140
+ if not api_key:
141
+ raise ValueError("OPENAI_API_KEY environment variable not set")
142
+
143
+ # Initialize FormGenerationModule
144
+ generator = FormGenerationModule()
145
+
146
+ # Generate form structure
147
+ logger.info(f"Generating form for query: {user_query}")
148
+ form_result = await generator.aforward(user_query=user_query)
149
+
150
+ # Check for error in result
151
+ if isinstance(form_result, dict) and 'error' in form_result:
152
+ logger.error(f"Form generation error: {form_result['error']}")
153
+ raise Exception(form_result['error'])
154
+
155
+ # VALIDATION METRIC: Validate form plan structure
156
+ is_valid, validation_errors = validate_form_plan(form_result)
157
+ if not is_valid:
158
+ logger.error(f"Form plan validation failed: {validation_errors}")
159
+ raise Exception(f"Invalid form plan: {'; '.join(validation_errors)}")
160
+
161
+ # Extract form metadata
162
+ form_data = {
163
+ "title": form_result.get("title", "New Form"),
164
+ "description": form_result.get("description", ""),
165
+ "settings": form_result.get("settings", {
166
+ "background_color": "#ffffff",
167
+ "text_color": "#000000",
168
+ "accent_color": "#9333ea",
169
+ "submit_button_text": form_result.get("submit_button_text", "Submit"),
170
+ "show_progress_bar": True
171
+ })
172
+ }
173
+
174
+ # Transform components to questions_list
175
+ # Components use component_id, but database uses question_order (0-indexed)
176
+ components = form_result.get("components", [])
177
+ questions_list = []
178
+ component_id_to_order = {} # Map component_id to question_order
179
+
180
+ for idx, component in enumerate(components):
181
+ component_id = component.get("component_id", f"comp_{idx + 1}")
182
+ component_id_to_order[component_id] = idx
183
+
184
+ # Map component structure to question structure
185
+ question_type = component.get("question_type", "short_answer")
186
+ settings = component.get("settings", {})
187
+
188
+ # FIX: Handle AI generating numbers instead of arrays for matrix/ranking
189
+ # THIS MUST HAPPEN BEFORE VALIDATION
190
+ if question_type == "matrix":
191
+ # Ensure settings dict exists
192
+ if not isinstance(settings, dict):
193
+ settings = {}
194
+
195
+ # Convert rows: handle int, float, None, or non-list
196
+ rows_value = settings.get("rows")
197
+ if isinstance(rows_value, (int, float)):
198
+ num_rows = max(1, int(rows_value))
199
+ settings["rows"] = [f"Row {i+1}" for i in range(num_rows)]
200
+ logger.info(f"Converted matrix rows from {num_rows} to array of {num_rows} strings")
201
+ elif not isinstance(rows_value, list):
202
+ settings["rows"] = ["Row 1", "Row 2", "Row 3"]
203
+ logger.info(f"Set default matrix rows (was: {type(rows_value).__name__})")
204
+
205
+ # Convert columns: handle int, float, None, or non-list
206
+ columns_value = settings.get("columns")
207
+ if isinstance(columns_value, (int, float)):
208
+ num_cols = max(1, int(columns_value))
209
+ settings["columns"] = [f"Column {i+1}" for i in range(num_cols)]
210
+ logger.info(f"Converted matrix columns from {num_cols} to array of {num_cols} strings")
211
+ elif not isinstance(columns_value, list):
212
+ settings["columns"] = ["Column 1", "Column 2", "Column 3"]
213
+ logger.info(f"Set default matrix columns (was: {type(columns_value).__name__})")
214
+
215
+ if question_type == "ranking":
216
+ # Ensure settings dict exists
217
+ if not isinstance(settings, dict):
218
+ settings = {}
219
+
220
+ # Convert ranking_items: handle int, float, None, or non-list
221
+ ranking_value = settings.get("ranking_items")
222
+ if isinstance(ranking_value, (int, float)):
223
+ num_items = max(2, int(ranking_value))
224
+ settings["ranking_items"] = [f"Item {i+1}" for i in range(num_items)]
225
+ logger.info(f"Converted ranking_items from {num_items} to array of {num_items} strings")
226
+ elif not isinstance(ranking_value, list):
227
+ settings["ranking_items"] = ["Item 1", "Item 2", "Item 3"]
228
+ logger.info(f"Set default ranking_items (was: {type(ranking_value).__name__})")
229
+
230
+ # VALIDATION METRIC: Validate component settings
231
+ settings_valid, settings_errors = validate_component_settings(question_type, settings)
232
+ if not settings_valid:
233
+ logger.warning(f"Component {idx} settings validation failed: {settings_errors}")
234
+ # Add defaults for failed settings instead of rejecting
235
+ if question_type in ["multiple_choice", "checkboxes", "dropdown", "multi_select"] and not settings.get("choices"):
236
+ settings["choices"] = ["Option 1", "Option 2", "Option 3"]
237
+ if question_type == "linear_scale" and ("min_value" not in settings or "max_value" not in settings):
238
+ settings["min_value"] = 1
239
+ settings["max_value"] = 10
240
+
241
+ question_data = {
242
+ "question_order": component.get("order", idx),
243
+ "question_type": question_type,
244
+ "question_text": component.get("question_text", f"Question {idx + 1}"),
245
+ "description": component.get("description"),
246
+ "required": component.get("required", False),
247
+ "settings": settings
248
+ }
249
+
250
+ # Add validation rules if present
251
+ if "validation_rules" in component:
252
+ question_data["settings"]["validation_rules"] = component["validation_rules"]
253
+
254
+ questions_list.append(question_data)
255
+
256
+ # Transform conditional logic: map component_ids to question indices
257
+ conditional_rules = []
258
+ raw_rules = form_result.get("conditional_logic", [])
259
+
260
+ for rule in raw_rules:
261
+ trigger_id = rule.get("trigger_component_id")
262
+ target_id = rule.get("target_component_id")
263
+
264
+ # Map component IDs to question indices
265
+ if trigger_id in component_id_to_order and target_id in component_id_to_order:
266
+ conditional_rule = {
267
+ "trigger_question_index": component_id_to_order[trigger_id],
268
+ "target_question_index": component_id_to_order[target_id],
269
+ "condition_type": rule.get("condition_type", "equals"),
270
+ "condition_value": rule.get("condition_value"),
271
+ "action": rule.get("action", "show")
272
+ }
273
+ conditional_rules.append(conditional_rule)
274
+ else:
275
+ logger.warning(f"Skipping rule with invalid component IDs: {trigger_id} -> {target_id}")
276
+
277
+ # VALIDATION METRIC: Ensure output is complete and serializable
278
+ try:
279
+ # Test JSON serialization to ensure frontend can render
280
+ json.dumps({
281
+ "form_data": form_data,
282
+ "questions": questions_list,
283
+ "rules": conditional_rules
284
+ })
285
+ logger.info(f"✓ Form generation complete: {len(questions_list)} questions, {len(conditional_rules)} rules")
286
+ logger.info(f"✓ Validation passed: Complete closed JSON ready for frontend")
287
+ except Exception as e:
288
+ logger.error(f"✗ JSON serialization failed: {e}")
289
+ raise Exception(f"Form generation produced non-serializable output: {e}")
290
+
291
+ return form_data, questions_list, conditional_rules
292
+
293
+ except Exception as e:
294
+ logger.error(f"Form generation failed: {e}", exc_info=True)
295
+ # Return minimal valid form structure
296
+ return {
297
+ "title": "New Form",
298
+ "description": user_query,
299
+ "settings": {
300
+ "background_color": "#ffffff",
301
+ "text_color": "#000000",
302
+ "accent_color": "#9333ea",
303
+ "submit_button_text": "Submit",
304
+ "show_progress_bar": True
305
+ }
306
+ }, [], []
307
+
308
+
309
+ async def edit_form_spec(
310
+ current_form: Dict[str, Any],
311
+ edit_request: str,
312
+ user_id: int = None
313
+ ) -> Dict[str, Any]:
314
+ """
315
+ Edit an existing form based on user request using FormChatFunction.
316
+
317
+ Args:
318
+ current_form: Current form structure with questions
319
+ edit_request: Natural language edit request
320
+ user_id: User ID for context
321
+
322
+ Returns:
323
+ dict: Updated form structure or changes to apply
324
+ """
325
+ try:
326
+ # Initialize FormChatFunction
327
+ chat_function = FormChatFunction()
328
+
329
+ # Prepare form context
330
+ form_context = json.dumps({
331
+ "title": current_form.get("title"),
332
+ "description": current_form.get("description"),
333
+ "components": current_form.get("questions", []), # Pass as components for consistency
334
+ "settings": current_form.get("settings", {})
335
+ })
336
+
337
+ # Process chat request
338
+ result = await chat_function.aforward(
339
+ user_query=edit_request,
340
+ form_context=form_context,
341
+ current_form=current_form
342
+ )
343
+
344
+ # Extract response
345
+ route = result.get('route')
346
+ response = result.get('response')
347
+
348
+ logger.info(f"Form edit route: {route.query_type if hasattr(route, 'query_type') else 'unknown'}")
349
+
350
+ return {
351
+ "route": route.query_type if hasattr(route, 'query_type') else 'unknown',
352
+ "response": response,
353
+ "changes_made": getattr(response, 'changes_made', None) if hasattr(response, 'changes_made') else None
354
+ }
355
+
356
+ except Exception as e:
357
+ logger.error(f"Form edit failed: {e}", exc_info=True)
358
+ return {
359
+ "route": "error",
360
+ "response": str(e),
361
+ "changes_made": None
362
+ }
363
+
364
+
365
+ def validate_question_type(question_type: str) -> bool:
366
+ """Validate that question type is supported"""
367
+ valid_types = [
368
+ "short_answer", "long_answer", "multiple_choice", "checkboxes",
369
+ "dropdown", "multi_select", "number", "email", "phone", "link",
370
+ "file_upload", "date", "time", "linear_scale", "matrix", "rating",
371
+ "payment", "signature", "ranking", "wallet_connect", "button"
372
+ ]
373
+ return question_type in valid_types
374
+
375
+
376
+ def validate_condition_type(condition_type: str) -> bool:
377
+ """Validate that condition type is supported"""
378
+ valid_conditions = [
379
+ "equals", "not_equals", "contains", "not_contains",
380
+ "greater_than", "less_than", "is_empty", "is_not_empty"
381
+ ]
382
+ return condition_type in valid_conditions
app/services/plan_service.py ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Subscription plan service for managing pricing tiers
3
+ """
4
+ from sqlalchemy.orm import Session
5
+ from typing import Optional, List, Dict, Any
6
+ from decimal import Decimal
7
+ import logging
8
+ import os
9
+
10
+ from ..models import SubscriptionPlan
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class PlanService:
16
+ """Service for managing subscription plans"""
17
+
18
+ def get_all_active_plans(self, db: Session) -> List[SubscriptionPlan]:
19
+ """
20
+ Get all active subscription plans
21
+
22
+ Args:
23
+ db: Database session
24
+
25
+ Returns:
26
+ List of active SubscriptionPlan objects, sorted by sort_order
27
+ """
28
+ return (
29
+ db.query(SubscriptionPlan)
30
+ .filter(SubscriptionPlan.is_active == True)
31
+ .order_by(SubscriptionPlan.sort_order)
32
+ .all()
33
+ )
34
+
35
+ def get_plan_by_id(self, db: Session, plan_id: int) -> Optional[SubscriptionPlan]:
36
+ """
37
+ Get a specific plan by ID
38
+
39
+ Args:
40
+ db: Database session
41
+ plan_id: Plan ID
42
+
43
+ Returns:
44
+ SubscriptionPlan object or None if not found
45
+ """
46
+ return db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first()
47
+
48
+ def get_plan_by_name(self, db: Session, name: str) -> Optional[SubscriptionPlan]:
49
+ """
50
+ Get a plan by name
51
+
52
+ Args:
53
+ db: Database session
54
+ name: Plan name
55
+
56
+ Returns:
57
+ SubscriptionPlan object or None if not found
58
+ """
59
+ return db.query(SubscriptionPlan).filter(SubscriptionPlan.name == name).first()
60
+
61
+ def get_plan_by_stripe_price_id(self, db: Session, stripe_price_id: str) -> Optional[SubscriptionPlan]:
62
+ """
63
+ Get a plan by Stripe price ID (checks both monthly and yearly)
64
+
65
+ Args:
66
+ db: Database session
67
+ stripe_price_id: Stripe price ID
68
+
69
+ Returns:
70
+ SubscriptionPlan object or None if not found
71
+ """
72
+ return db.query(SubscriptionPlan).filter(
73
+ (SubscriptionPlan.stripe_price_id == stripe_price_id) |
74
+ (SubscriptionPlan.stripe_price_id_monthly == stripe_price_id) |
75
+ (SubscriptionPlan.stripe_price_id_yearly == stripe_price_id)
76
+ ).first()
77
+
78
+ def create_plan(
79
+ self,
80
+ db: Session,
81
+ name: str,
82
+ price_monthly: Decimal,
83
+ credits_per_month: int,
84
+ credits_per_analyze: int = 5,
85
+ credits_per_edit: int = 2,
86
+ stripe_price_id: Optional[str] = None,
87
+ stripe_price_id_monthly: Optional[str] = None,
88
+ stripe_price_id_yearly: Optional[str] = None,
89
+ price_yearly: Optional[Decimal] = None,
90
+ stripe_product_id: Optional[str] = None,
91
+ features: Optional[Dict[str, Any]] = None,
92
+ sort_order: int = 0
93
+ ) -> SubscriptionPlan:
94
+ """
95
+ Create a new subscription plan
96
+
97
+ Args:
98
+ db: Database session
99
+ name: Plan name
100
+ price_monthly: Monthly price
101
+ credits_per_month: Monthly credit allocation
102
+ credits_per_analyze: Credits per dashboard creation
103
+ credits_per_edit: Credits per edit operation
104
+ stripe_price_id: Legacy Stripe price ID (for backward compatibility)
105
+ stripe_price_id_monthly: Stripe monthly price ID
106
+ stripe_price_id_yearly: Stripe yearly price ID
107
+ price_yearly: Yearly price
108
+ stripe_product_id: Stripe product ID
109
+ features: Optional feature dictionary
110
+ sort_order: Display order
111
+
112
+ Returns:
113
+ Created SubscriptionPlan object
114
+ """
115
+ # Use legacy stripe_price_id as monthly if monthly not provided
116
+ if stripe_price_id_monthly is None and stripe_price_id is not None:
117
+ stripe_price_id_monthly = stripe_price_id
118
+
119
+ plan = SubscriptionPlan(
120
+ name=name,
121
+ price_monthly=price_monthly,
122
+ price_yearly=price_yearly,
123
+ credits_per_month=credits_per_month,
124
+ credits_per_analyze=credits_per_analyze,
125
+ credits_per_edit=credits_per_edit,
126
+ stripe_price_id=stripe_price_id, # Keep for backward compatibility
127
+ stripe_price_id_monthly=stripe_price_id_monthly,
128
+ stripe_price_id_yearly=stripe_price_id_yearly,
129
+ stripe_product_id=stripe_product_id,
130
+ features=features or {},
131
+ sort_order=sort_order,
132
+ is_active=True
133
+ )
134
+ db.add(plan)
135
+ db.commit()
136
+ db.refresh(plan)
137
+ logger.info(f"Created plan: {name} (${price_monthly}/month, {credits_per_month} credits)")
138
+ return plan
139
+
140
+ def update_plan(
141
+ self,
142
+ db: Session,
143
+ plan_id: int,
144
+ **kwargs
145
+ ) -> Optional[SubscriptionPlan]:
146
+ """
147
+ Update a subscription plan
148
+
149
+ Args:
150
+ db: Database session
151
+ plan_id: Plan ID
152
+ **kwargs: Fields to update
153
+
154
+ Returns:
155
+ Updated SubscriptionPlan object or None if not found
156
+ """
157
+ plan = self.get_plan_by_id(db, plan_id)
158
+ if not plan:
159
+ return None
160
+
161
+ for key, value in kwargs.items():
162
+ if hasattr(plan, key):
163
+ setattr(plan, key, value)
164
+
165
+ db.commit()
166
+ db.refresh(plan)
167
+ logger.info(f"Updated plan {plan_id}: {plan.name}")
168
+ return plan
169
+
170
+ def deactivate_plan(self, db: Session, plan_id: int) -> bool:
171
+ """
172
+ Deactivate a plan (soft delete)
173
+
174
+ Args:
175
+ db: Database session
176
+ plan_id: Plan ID
177
+
178
+ Returns:
179
+ True if successful, False if plan not found
180
+ """
181
+ plan = self.get_plan_by_id(db, plan_id)
182
+ if not plan:
183
+ return False
184
+
185
+ plan.is_active = False
186
+ db.commit()
187
+ logger.info(f"Deactivated plan {plan_id}: {plan.name}")
188
+ return True
189
+
190
+ def initialize_default_plans(self, db: Session, force: bool = False) -> List[SubscriptionPlan]:
191
+ """
192
+ Initialize default subscription plans (Free, Pro, Ultra)
193
+ Idempotent - won't create duplicates unless force=True
194
+
195
+ Args:
196
+ db: Database session
197
+ force: If True, recreate plans even if they exist
198
+
199
+ Returns:
200
+ List of created/existing plans
201
+ """
202
+ plans = []
203
+
204
+ # Default plan configurations
205
+ default_plans = [
206
+ {
207
+ "name": "Free",
208
+ "price_monthly": Decimal("0.00"),
209
+ "price_yearly": Decimal("0.00"),
210
+ "credits_per_month": 25,
211
+ "credits_per_analyze": 5,
212
+ "credits_per_edit": 2,
213
+ "stripe_price_id": os.getenv("STRIPE_FREE_PRICE_MONTHLY_ID"), # Legacy support
214
+ "stripe_price_id_monthly": os.getenv("STRIPE_FREE_PRICE_MONTHLY_ID"),
215
+ "stripe_price_id_yearly": os.getenv("STRIPE_FREE_PRICE_YEARLY_ID"),
216
+ "stripe_product_id": os.getenv("STRIPE_FREE_PRODUCT_ID"),
217
+ "features": {
218
+ "max_datasets": 3,
219
+ "max_file_size_mb": 10,
220
+ "export_formats": ["png", "csv"]
221
+ },
222
+ "sort_order": 0
223
+ },
224
+ {
225
+ "name": "Pro",
226
+ "price_monthly": Decimal("20.00"),
227
+ "price_yearly": Decimal("192.00"), # $20 * 12 * 0.8 (20% discount)
228
+ "credits_per_month": 500,
229
+ "credits_per_analyze": 5,
230
+ "credits_per_edit": 2,
231
+ "stripe_price_id": os.getenv("STRIPE_PRO_PRICE_MONTHLY_ID"), # Legacy support
232
+ "stripe_price_id_monthly": os.getenv("STRIPE_PRO_PRICE_MONTHLY_ID"),
233
+ "stripe_price_id_yearly": os.getenv("STRIPE_PRO_PRICE_YEARLY_ID"),
234
+ "stripe_product_id": os.getenv("STRIPE_PRO_PRODUCT_ID"),
235
+ "features": {
236
+ "max_datasets": 50,
237
+ "max_file_size_mb": 100,
238
+ "export_formats": ["png", "csv", "pdf", "xlsx"],
239
+ "priority_support": True
240
+ },
241
+ "sort_order": 1
242
+ },
243
+ {
244
+ "name": "Ultra",
245
+ "price_monthly": Decimal("30"),
246
+ "price_yearly": Decimal("288"), # $29.99 * 12 * 0.8 (20% discount)
247
+ "credits_per_month": 1000,
248
+ "credits_per_analyze": 5,
249
+ "credits_per_edit": 2,
250
+ "stripe_price_id": os.getenv("STRIPE_ULTRA_PRICE_MONTHLY_ID"), # Legacy support
251
+ "stripe_price_id_monthly": os.getenv("STRIPE_ULTRA_PRICE_MONTHLY_ID"),
252
+ "stripe_price_id_yearly": os.getenv("STRIPE_ULTRA_PRICE_YEARLY_ID"),
253
+ "stripe_product_id": os.getenv("STRIPE_ULTRA_PRODUCT_ID"),
254
+ "features": {
255
+ "max_datasets": -1, # Unlimited
256
+ "max_file_size_mb": 500,
257
+ "export_formats": ["png", "csv", "pdf", "xlsx", "json"],
258
+ "priority_support": True,
259
+ "custom_branding": True,
260
+ "api_access": True
261
+ },
262
+ "sort_order": 2
263
+ }
264
+ ]
265
+
266
+ for plan_config in default_plans:
267
+ existing = self.get_plan_by_name(db, plan_config["name"])
268
+
269
+ if existing and not force:
270
+ logger.info(f"Plan '{plan_config['name']}' already exists, skipping")
271
+ plans.append(existing)
272
+ elif existing and force:
273
+ # Update existing plan
274
+ for key, value in plan_config.items():
275
+ if key != "name": # Don't update name
276
+ setattr(existing, key, value)
277
+ db.commit()
278
+ db.refresh(existing)
279
+ logger.info(f"Updated existing plan: {plan_config['name']}")
280
+ plans.append(existing)
281
+ else:
282
+ # Create new plan
283
+ plan = self.create_plan(db, **plan_config)
284
+ plans.append(plan)
285
+
286
+ return plans
287
+
288
+ def get_plan_info(self, db: Session, plan_id: int) -> Optional[Dict[str, Any]]:
289
+ """
290
+ Get comprehensive plan information
291
+
292
+ Args:
293
+ db: Database session
294
+ plan_id: Plan ID
295
+
296
+ Returns:
297
+ Dictionary with plan details or None if not found
298
+ """
299
+ plan = self.get_plan_by_id(db, plan_id)
300
+ if not plan:
301
+ return None
302
+
303
+ return {
304
+ "id": plan.id,
305
+ "name": plan.name,
306
+ "price_monthly": float(plan.price_monthly),
307
+ "price_yearly": float(plan.price_yearly) if plan.price_yearly else None,
308
+ "credits_per_month": plan.credits_per_month,
309
+ "credits_per_analyze": plan.credits_per_analyze,
310
+ "credits_per_edit": plan.credits_per_edit,
311
+ "features": plan.features or {},
312
+ "stripe_price_id": plan.stripe_price_id, # Legacy
313
+ "stripe_price_id_monthly": plan.stripe_price_id_monthly,
314
+ "stripe_price_id_yearly": plan.stripe_price_id_yearly,
315
+ "is_active": plan.is_active
316
+ }
317
+
318
+
319
+ # Singleton instance
320
+ plan_service = PlanService()
321
+