UNI12345 commited on
Commit
50925ca
·
0 Parent(s):

Initialize Archvise API backend for Hugging Face Spaces

Browse files
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Local configuration file containing database & secret keys
2
+ .env
3
+
4
+ # Python build and cache artifacts
5
+ __pycache__/
6
+ *.pyc
7
+ *.pyo
8
+ *.pyd
9
+ .pytest_cache/
10
+
11
+ # Local databases and logs
12
+ *.db
13
+ *.log
Dockerfile ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ # Install system dependencies for WeasyPrint and fonts
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ build-essential \
6
+ python3-dev \
7
+ libffi-dev \
8
+ shared-mime-info \
9
+ libcairo2 \
10
+ libpango-1.0-0 \
11
+ libpangocairo-1.0-0 \
12
+ libgdk-pixbuf2.0-0 \
13
+ fonts-dejavu-core \
14
+ && rm -rf /var/lib/apt/lists/*
15
+
16
+ WORKDIR /app
17
+
18
+ # Install Python requirements
19
+ COPY requirements.txt .
20
+ RUN pip install --no-cache-dir -r requirements.txt
21
+
22
+ # Copy source code
23
+ COPY . .
24
+
25
+ # Expose FastAPI port
26
+ EXPOSE 7860
27
+
28
+ # Start command
29
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Archvise app package
app/config.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import List, Union
3
+ from pydantic import AnyHttpUrl, field_validator
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+
6
+ class Settings(BaseSettings):
7
+ APP_NAME: str = "Archvise"
8
+ DEBUG: bool = True
9
+ API_VERSION: str = "v1"
10
+ SECRET_KEY: str = "generate-a-secure-jwt-secret-key-for-production-deployment-2026"
11
+
12
+ # Database
13
+ DATABASE_URL: str
14
+
15
+ # Redis
16
+ REDIS_URL: str = "redis://localhost:6379/0"
17
+
18
+ # NVIDIA NIM APIs
19
+ NVIDIA_BASE_URL: str = "https://integrate.api.nvidia.com/v1"
20
+ NVIDIA_LLAMA_KEY: str
21
+ NVIDIA_MISTRAL_KEY: str
22
+ NVIDIA_DEEPSEEK_KEY: str
23
+ NVIDIA_LLAMA_VISION_KEY: str
24
+ NVIDIA_EMBED_KEY: str
25
+
26
+ # Firebase Admin SDK
27
+ FIREBASE_PROJECT_ID: str
28
+ FIREBASE_CLIENT_EMAIL: str
29
+ FIREBASE_PRIVATE_KEY: str
30
+
31
+ @field_validator("FIREBASE_PRIVATE_KEY")
32
+ @classmethod
33
+ def clean_private_key(cls, v: str) -> str:
34
+ if v:
35
+ # Replace escaped newlines if they are passed as text \n
36
+ return v.replace("\\n", "\n").replace('"', "")
37
+ return v
38
+
39
+ # Storage API (Supabase Storage S3-Compatible)
40
+ CLOUDFLARE_R2_ACCOUNT_ID: str
41
+ CLOUDFLARE_R2_ACCESS_KEY: str
42
+ CLOUDFLARE_R2_SECRET_KEY: str
43
+ CLOUDFLARE_R2_BUCKET: str
44
+ CLOUDFLARE_R2_ENDPOINT_URL: str
45
+ CLOUDFLARE_R2_REGION_NAME: str = "ap-southeast-2"
46
+
47
+ # CORS Settings
48
+ CORS_ORIGINS: Union[str, List[str]] = ["*"]
49
+
50
+ @field_validator("CORS_ORIGINS")
51
+ @classmethod
52
+ def assemble_cors_origins(cls, v: Union[str, List[str]]) -> List[str]:
53
+ if isinstance(v, str):
54
+ if v.startswith("[") and v.endswith("]"):
55
+ import json
56
+ try:
57
+ return json.loads(v)
58
+ except Exception:
59
+ pass
60
+ return [x.strip() for x in v.split(",")]
61
+ return v
62
+
63
+ # Rate Limiting
64
+ RATE_LIMIT: str = "100/minute"
65
+
66
+ # Stripe
67
+ STRIPE_SECRET_KEY: str = "sk_test_placeholder_please_replace"
68
+ STRIPE_WEBHOOK_SECRET: str = "whsec_placeholder_please_replace"
69
+ STRIPE_STARTER_PRICE_ID: str = "price_placeholder_starter"
70
+ STRIPE_PRO_PRICE_ID: str = "price_placeholder_pro"
71
+
72
+ # GitHub OAuth
73
+ GITHUB_CLIENT_ID: str = "github_client_id_placeholder"
74
+ GITHUB_CLIENT_SECRET: str = "github_client_secret_placeholder"
75
+
76
+ # Frontend Redirect
77
+ FRONTEND_URL: str = "http://localhost:3000"
78
+
79
+ model_config = SettingsConfigDict(
80
+ env_file=".env",
81
+ env_file_encoding="utf-8",
82
+ extra="ignore"
83
+ )
84
+
85
+ settings = Settings()
app/database.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
2
+ from sqlalchemy.orm import declarative_base
3
+ from app.config import settings
4
+
5
+ # Adapt postgresql:// to postgresql+asyncpg:// if needed
6
+ database_url = settings.DATABASE_URL
7
+ if database_url.startswith("postgresql://"):
8
+ database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
9
+
10
+ # Enable connection pooling options since we connect to Supabase
11
+ engine = create_async_engine(
12
+ database_url,
13
+ echo=settings.DEBUG,
14
+ pool_size=10,
15
+ max_overflow=20,
16
+ pool_pre_ping=True
17
+ )
18
+
19
+ AsyncSessionLocal = async_sessionmaker(
20
+ bind=engine,
21
+ class_=AsyncSession,
22
+ expire_on_commit=False
23
+ )
24
+
25
+ Base = declarative_base()
26
+
27
+ async def get_db():
28
+ async with AsyncSessionLocal() as session:
29
+ try:
30
+ yield session
31
+ await session.commit()
32
+ except Exception:
33
+ await session.rollback()
34
+ raise
35
+ finally:
36
+ await session.close()
app/main.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sentry_sdk
2
+ from fastapi import FastAPI, Request
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+ from slowapi import Limiter, _rate_limit_exceeded_handler
5
+ from slowapi.errors import RateLimitExceeded
6
+ from slowapi.util import get_remote_address
7
+ from app.config import settings
8
+ from app.routers import auth, audit, design, github, billing, settings as settings_router, legal, projects
9
+ from loguru import logger
10
+
11
+ # Initialize Sentry
12
+ if not settings.DEBUG:
13
+ sentry_sdk.init(
14
+ dsn=None, # Replace with Sentry DSN when ready
15
+ traces_sample_rate=1.0,
16
+ profiles_sample_rate=1.0,
17
+ )
18
+
19
+ # Configure Slowapi Rate Limiter
20
+ limiter = Limiter(key_func=get_remote_address, default_limits=[settings.RATE_LIMIT])
21
+
22
+ app = FastAPI(
23
+ title=settings.APP_NAME,
24
+ description="Archvise SaaS Backend Engine",
25
+ version=settings.API_VERSION,
26
+ debug=settings.DEBUG
27
+ )
28
+
29
+ # Attach Limiter to App State
30
+ app.state.limiter = limiter
31
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
32
+
33
+ # Configure CORS Middleware
34
+ # Credentials must be True for httpOnly cookies to be sent
35
+ origins = settings.CORS_ORIGINS
36
+ if isinstance(origins, str):
37
+ origins = [origins]
38
+
39
+ app.add_middleware(
40
+ CORSMiddleware,
41
+ allow_origins=origins,
42
+ allow_credentials=True,
43
+ allow_methods=["*"],
44
+ allow_headers=["*"],
45
+ )
46
+
47
+ # Register Routers
48
+ app.include_router(auth.router, prefix="/api")
49
+ app.include_router(audit.router, prefix="/api")
50
+ app.include_router(design.router, prefix="/api")
51
+ app.include_router(github.router, prefix="/api")
52
+ app.include_router(billing.router, prefix="/api")
53
+ app.include_router(settings_router.router, prefix="/api")
54
+ app.include_router(legal.router, prefix="/api")
55
+ app.include_router(projects.router, prefix="/api")
56
+
57
+ @app.on_event("startup")
58
+ async def startup_event():
59
+ logger.info("Initializing database schemas...")
60
+ from app.database import Base, engine
61
+ async with engine.begin() as conn:
62
+ await conn.run_sync(Base.metadata.create_all)
63
+ logger.info("Database schemas initialized. Ready for requests.")
64
+
65
+ @app.get("/")
66
+ def read_root():
67
+ return {"status": "online", "app": settings.APP_NAME, "version": settings.API_VERSION}
app/models.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from datetime import datetime
3
+ from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Text, Float, JSON
4
+ from sqlalchemy.dialects.postgresql import JSONB, UUID
5
+ from sqlalchemy.orm import relationship
6
+ from app.database import Base
7
+
8
+ class User(Base):
9
+ __tablename__ = "users"
10
+
11
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
12
+ firebase_uid = Column(String, unique=True, index=True, nullable=False)
13
+ email = Column(String, unique=True, index=True, nullable=False)
14
+ name = Column(String, nullable=True)
15
+ avatar_url = Column(String, nullable=True)
16
+ plan = Column(String, default="free") # 'free', 'starter', 'pro'
17
+ display_mode = Column(String, default="founder") # 'founder', 'engineer'
18
+ credits_remaining = Column(Integer, default=2)
19
+ credits_reset_at = Column(DateTime(timezone=True), default=datetime.utcnow)
20
+ stripe_customer_id = Column(String, nullable=True)
21
+ github_connected = Column(Boolean, default=False)
22
+ total_audits = Column(Integer, default=0)
23
+ total_designs = Column(Integer, default=0)
24
+ is_active = Column(Boolean, default=True)
25
+ created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
26
+ updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)
27
+
28
+ projects = relationship("Project", back_populates="user", cascade="all, delete-orphan")
29
+ github_connection = relationship("GitHubConnection", back_populates="user", uselist=False, cascade="all, delete-orphan")
30
+
31
+ class Project(Base):
32
+ __tablename__ = "projects"
33
+
34
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
35
+ user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False)
36
+ name = Column(String(100), nullable=False)
37
+ type = Column(String, nullable=False) # 'audit', 'system_design'
38
+ source = Column(String, nullable=False) # 'upload', 'github'
39
+ status = Column(String, index=True, default="queued") # 'queued', 'processing', 'complete', 'failed'
40
+ job_id = Column(String, index=True, nullable=True)
41
+ error_msg = Column(Text, nullable=True)
42
+ github_repo = Column(String, nullable=True)
43
+ github_branch = Column(String, nullable=True)
44
+ created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
45
+ updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)
46
+
47
+ user = relationship("User", back_populates="projects")
48
+ audit_report = relationship("AuditReport", back_populates="project", uselist=False, cascade="all, delete-orphan")
49
+ system_design = relationship("SystemDesign", back_populates="project", uselist=False, cascade="all, delete-orphan")
50
+
51
+ class AuditReport(Base):
52
+ __tablename__ = "audit_reports"
53
+
54
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
55
+ project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True, unique=True, nullable=False)
56
+ overall_score = Column(Integer, nullable=False)
57
+ confidence = Column(JSONB, nullable=False) # JSON: level, score, label, based_on, limitations, to_increase_confidence
58
+ capacity_estimate = Column(JSONB, nullable=False) # JSON: safe_range, peak_range, description, reasoning, confidence
59
+ agents = Column(JSONB, nullable=False) # JSON: SRE, backend, infrastructure, cloud_architect reports
60
+ top_critical_issues = Column(JSONB, nullable=False) # Array of issues
61
+ quick_wins = Column(JSONB, nullable=False) # Array of quick wins
62
+ benchmark_percentile = Column(Float, nullable=True)
63
+ score_disclaimer = Column(Text, nullable=False)
64
+ files_analyzed = Column(Integer, default=0)
65
+ files_skipped = Column(Integer, default=0)
66
+ was_truncated = Column(Boolean, default=False)
67
+ created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
68
+
69
+ project = relationship("Project", back_populates="audit_report")
70
+
71
+ class SystemDesign(Base):
72
+ __tablename__ = "system_designs"
73
+
74
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
75
+ project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True, unique=True, nullable=False)
76
+ idea_prompt = Column(Text, nullable=False)
77
+ title = Column(String, nullable=False)
78
+ founder_summary = Column(Text, nullable=False)
79
+ engineer_summary = Column(Text, nullable=False)
80
+ architecture_type = Column(String, nullable=False) # 'Monolith', 'Microservices', 'Hybrid'
81
+ reasoning = Column(Text, nullable=False)
82
+ stack = Column(JSONB, nullable=False) # JSON: Frontend, Backend, Database, Infrastructure chips with reasons
83
+ database_design = Column(JSONB, nullable=False) # JSON: primary_db, cache, key_tables
84
+ api_design = Column(JSONB, nullable=False) # JSON: style, auth_strategy, core_endpoints
85
+ infrastructure = Column(JSONB, nullable=False) # JSON: cloud_provider, components, scaling_strategy
86
+ reliability = Column(JSONB, nullable=False) # JSON: uptime_target, strategies, backup_dr
87
+ cost_estimates = Column(JSONB, nullable=False) # JSON: 1k, 100k, 1m tier costs and drivers
88
+ diagram = Column(JSONB, nullable=False) # JSON: nodes, edges
89
+ created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
90
+
91
+ project = relationship("Project", back_populates="system_design")
92
+ shared_link = relationship("SharedLink", back_populates="design", uselist=False, cascade="all, delete-orphan")
93
+
94
+ class SharedLink(Base):
95
+ __tablename__ = "shared_links"
96
+
97
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
98
+ design_id = Column(UUID(as_uuid=True), ForeignKey("system_designs.id", ondelete="CASCADE"), unique=True, nullable=False)
99
+ slug = Column(String, unique=True, index=True, nullable=False)
100
+ view_count = Column(Integer, default=0)
101
+ created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
102
+
103
+ design = relationship("SystemDesign", back_populates="shared_link")
104
+
105
+ class GitHubConnection(Base):
106
+ __tablename__ = "github_connections"
107
+
108
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
109
+ user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True, nullable=False)
110
+ github_username = Column(String, nullable=False)
111
+ access_token = Column(String, nullable=False) # Encrypted text
112
+ token_expires_at = Column(DateTime(timezone=True), nullable=True)
113
+ connected_at = Column(DateTime(timezone=True), default=datetime.utcnow)
114
+ last_used_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)
115
+
116
+ user = relationship("User", back_populates="github_connection")
117
+
118
+ class BenchmarkData(Base):
119
+ __tablename__ = "benchmark_data"
120
+
121
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
122
+ score_range = Column(String, nullable=False) # e.g., '80-89'
123
+ industry_tag = Column(String, nullable=False) # e.g., 'E-Commerce'
124
+ arch_type = Column(String, nullable=False) # e.g., 'Serverless'
125
+ created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
126
+
127
+ class WebhookEvent(Base):
128
+ __tablename__ = "webhook_events"
129
+
130
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
131
+ event_id = Column(String, unique=True, index=True, nullable=False)
132
+ event_type = Column(String, nullable=False)
133
+ processed_at = Column(DateTime(timezone=True), default=datetime.utcnow)
app/routers/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Archvise routers package
app/routers/audit.py ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import json
3
+ import uuid
4
+ import zipfile
5
+ from typing import List, Optional
6
+ import redis
7
+ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query, status
8
+ from fastapi.responses import StreamingResponse, Response
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+ from sqlalchemy.future import select
11
+ from app.database import get_db
12
+ from app import models, schemas
13
+ from app.utils.security import get_current_user
14
+ from app.utils.storage import upload_file
15
+ from app.utils.github_client import ALLOWED_EXTENSIONS, redact_env_secrets
16
+ from app.utils.pdf import generate_audit_pdf
17
+ from app.config import settings
18
+ from app.tasks import audit_job_wrapper
19
+ from rq import Queue
20
+ from loguru import logger
21
+
22
+ router = APIRouter(prefix="/audit", tags=["Audit"])
23
+ redis_conn = redis.Redis.from_url(settings.REDIS_URL)
24
+ task_queue = Queue("default", connection=redis_conn)
25
+
26
+ @router.post("/upload")
27
+ async def upload_audit(
28
+ project_name: str = Form(...),
29
+ file_type: str = Form("Backend"), # "Backend" | "Frontend" | "Mixed"
30
+ files: List[UploadFile] = File(...),
31
+ current_user: models.User = Depends(get_current_user),
32
+ db: AsyncSession = Depends(get_db)
33
+ ):
34
+ # Check credit availability
35
+ if current_user.credits_remaining <= 0 and current_user.plan == "free":
36
+ raise HTTPException(
37
+ status_code=status.HTTP_402_PAYMENT_REQUIRED,
38
+ detail="You have used your 2 free analyses this month. Upgrade to continue."
39
+ )
40
+
41
+ # Validate parameters
42
+ if len(project_name) > 100:
43
+ raise HTTPException(status_code=400, detail="Project name too long (max 100 characters)")
44
+
45
+ extracted_files = []
46
+ total_size = 0
47
+ max_total_size = 50 * 1024 * 1024 # 50MB
48
+ max_files = 20
49
+
50
+ # Process files
51
+ for file in files:
52
+ filename = file.filename
53
+ content_type = file.content_type
54
+
55
+ # Check if zip file
56
+ if filename.endswith(".zip"):
57
+ zip_bytes = await file.read()
58
+ total_size += len(zip_bytes)
59
+ if total_size > max_total_size:
60
+ raise HTTPException(status_code=400, detail="Total size of uploaded files exceeds 50MB limit")
61
+
62
+ try:
63
+ with zipfile.ZipFile(io.BytesIO(zip_bytes)) as z:
64
+ # Zip bomb check
65
+ uncompressed_size = sum(info.file_size for info in z.infolist())
66
+ compressed_size = len(zip_bytes)
67
+ if compressed_size > 0 and (uncompressed_size / compressed_size) > 100:
68
+ raise HTTPException(
69
+ status_code=400,
70
+ detail="Zip bomb detected (compression ratio exceeds 100:1)"
71
+ )
72
+
73
+ for info in z.infolist():
74
+ if info.is_dir():
75
+ continue
76
+
77
+ import os
78
+ _, ext = os.path.splitext(info.filename)
79
+ if ext.lower() in ALLOWED_EXTENSIONS:
80
+ # Ignore lock files and standard folders
81
+ if any(p in info.filename.split("/") for p in ["node_modules", "vendor", "dist", "build", ".git", "venv", ".venv"]):
82
+ continue
83
+
84
+ with z.open(info.filename) as f_in:
85
+ content = f_in.read().decode("utf-8", errors="ignore")
86
+ content = redact_env_secrets(content, info.filename)
87
+ extracted_files.append({
88
+ "filename": info.filename,
89
+ "content": content
90
+ })
91
+ if len(extracted_files) >= max_files:
92
+ break
93
+ except zipfile.BadZipFile:
94
+ raise HTTPException(status_code=400, detail="Invalid zip file uploaded")
95
+ else:
96
+ # Individual file upload
97
+ import os
98
+ _, ext = os.path.splitext(filename)
99
+ if ext.lower() not in ALLOWED_EXTENSIONS:
100
+ continue
101
+
102
+ file_bytes = await file.read()
103
+ total_size += len(file_bytes)
104
+ if total_size > max_total_size:
105
+ raise HTTPException(status_code=400, detail="Total size of uploaded files exceeds 50MB limit")
106
+
107
+ content = file_bytes.decode("utf-8", errors="ignore")
108
+ content = redact_env_secrets(content, filename)
109
+ extracted_files.append({
110
+ "filename": filename,
111
+ "content": content
112
+ })
113
+
114
+ if not extracted_files:
115
+ raise HTTPException(
116
+ status_code=400,
117
+ detail="No files uploaded or none matched the accepted file type extensions whitelist"
118
+ )
119
+
120
+ if len(extracted_files) > max_files:
121
+ raise HTTPException(status_code=400, detail=f"Exceeded maximum file upload count (max {max_files} files)")
122
+
123
+ # Deduct credit
124
+ if current_user.plan == "free":
125
+ current_user.credits_remaining -= 1
126
+ db.add(current_user)
127
+
128
+ # Create project record in DB
129
+ project = models.Project(
130
+ user_id=current_user.id,
131
+ name=project_name,
132
+ type="audit",
133
+ source="upload",
134
+ status="queued"
135
+ )
136
+ db.add(project)
137
+ await db.commit()
138
+ await db.refresh(project)
139
+
140
+ # Save the files to R2 storage for redundancy / backup
141
+ files_json = json.dumps(extracted_files).encode("utf-8")
142
+ object_key = f"audits/{project.id}/source.json"
143
+ upload_file(files_json, object_key, content_type="application/json")
144
+
145
+ # Queue job
146
+ job_id = str(uuid.uuid4())
147
+ project.job_id = job_id
148
+ db.add(project)
149
+ await db.commit()
150
+
151
+ task_queue.enqueue_call(
152
+ func=audit_job_wrapper,
153
+ args=(str(project.id), job_id, extracted_files, file_type),
154
+ job_id=job_id
155
+ )
156
+
157
+ return {"project_id": str(project.id), "job_id": job_id}
158
+
159
+ @router.post("/github")
160
+ async def github_audit(
161
+ body: schemas.GitHubAuditRequest,
162
+ current_user: models.User = Depends(get_current_user),
163
+ db: AsyncSession = Depends(get_db)
164
+ ):
165
+ if current_user.credits_remaining <= 0 and current_user.plan == "free":
166
+ raise HTTPException(
167
+ status_code=status.HTTP_402_PAYMENT_REQUIRED,
168
+ detail="You have used your 2 free analyses this month. Upgrade to continue."
169
+ )
170
+
171
+ # Check if GitHub is connected
172
+ conn_result = await db.execute(select(models.GitHubConnection).where(models.GitHubConnection.user_id == current_user.id))
173
+ gh_conn = conn_result.scalars().first()
174
+ if not gh_conn:
175
+ raise HTTPException(status_code=400, detail="GitHub account not connected")
176
+
177
+ # Deduct credit
178
+ if current_user.plan == "free":
179
+ current_user.credits_remaining -= 1
180
+ db.add(current_user)
181
+
182
+ project = models.Project(
183
+ user_id=current_user.id,
184
+ name=body.project_name,
185
+ type="audit",
186
+ source="github",
187
+ github_repo=body.repo,
188
+ github_branch=body.branch,
189
+ status="queued"
190
+ )
191
+ db.add(project)
192
+ await db.commit()
193
+ await db.refresh(project)
194
+
195
+ # Retrieve decrypted access token
196
+ from app.utils.github_client import decrypt_token, get_repo_files
197
+ access_token = decrypt_token(gh_conn.access_token)
198
+
199
+ # We fetch files inside the worker to avoid API request blocking
200
+ job_id = str(uuid.uuid4())
201
+ project.job_id = job_id
202
+ db.add(project)
203
+ await db.commit()
204
+
205
+ # Pass repo details. The background worker will call get_repo_files
206
+ # Let's write helper in worker tasks that fetches the files if passed
207
+ def run_github_audit_job(proj_id, job_id, repo, branch, access_token, file_type):
208
+ import asyncio
209
+ files = get_repo_files(access_token, repo, branch)
210
+ from app.tasks import run_audit_task
211
+ asyncio.run(run_audit_task(proj_id, job_id, files, file_type))
212
+
213
+ task_queue.enqueue_call(
214
+ func=run_github_audit_job,
215
+ args=(str(project.id), job_id, body.repo, body.branch, access_token, body.file_type),
216
+ job_id=job_id
217
+ )
218
+
219
+ return {"project_id": str(project.id), "job_id": job_id}
220
+
221
+ @router.get("/stream/{job_id}")
222
+ async def audit_stream(job_id: str):
223
+ async def event_generator():
224
+ import asyncio
225
+ pubsub = redis_conn.pubsub()
226
+ pubsub.subscribe(f"job:{job_id}")
227
+
228
+ # Initial heartbeat
229
+ yield f"data: {json.dumps({'event': 'connecting', 'status': 'active'})}\n\n"
230
+
231
+ try:
232
+ while True:
233
+ message = pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
234
+ if message:
235
+ data = message["data"].decode("utf-8")
236
+ yield f"data: {data}\n\n"
237
+
238
+ parsed = json.loads(data)
239
+ if parsed.get("event") in ["job_complete", "job_failed"]:
240
+ break
241
+ await asyncio.sleep(0.5)
242
+ except asyncio.CancelledError:
243
+ logger.info(f"SSE connection closed for job {job_id}")
244
+ finally:
245
+ pubsub.unsubscribe(f"job:{job_id}")
246
+
247
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
248
+
249
+ @router.get("/history", response_model=List[schemas.ProjectResponse])
250
+ async def get_audit_history(
251
+ page: int = Query(1, ge=1),
252
+ limit: int = Query(10, ge=1, le=100),
253
+ current_user: models.User = Depends(get_current_user),
254
+ db: AsyncSession = Depends(get_db)
255
+ ):
256
+ offset = (page - 1) * limit
257
+ result = await db.execute(
258
+ select(models.Project)
259
+ .where(models.Project.user_id == current_user.id, models.Project.type == "audit")
260
+ .order_by(models.Project.created_at.desc())
261
+ .offset(offset)
262
+ .limit(limit)
263
+ )
264
+ return result.scalars().all()
265
+
266
+ @router.get("/{id}", response_model=schemas.AuditReportResponse)
267
+ async def get_audit_report(id: UUID, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
268
+ result = await db.execute(select(models.Project).where(models.Project.id == id, models.Project.user_id == current_user.id))
269
+ project = result.scalars().first()
270
+ if not project:
271
+ raise HTTPException(status_code=404, detail="Project not found")
272
+
273
+ report_res = await db.execute(select(models.AuditReport).where(models.AuditReport.project_id == project.id))
274
+ report = report_res.scalars().first()
275
+ if not report:
276
+ raise HTTPException(status_code=404, detail="Report not generated yet")
277
+
278
+ return report
279
+
280
+ @router.delete("/{id}")
281
+ async def delete_audit(id: UUID, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
282
+ result = await db.execute(select(models.Project).where(models.Project.id == id, models.Project.user_id == current_user.id))
283
+ project = result.scalars().first()
284
+ if not project:
285
+ raise HTTPException(status_code=404, detail="Project not found")
286
+
287
+ await db.delete(project)
288
+ await db.commit()
289
+ return {"detail": "Project deleted successfully"}
290
+
291
+ @router.get("/{id}/export-pdf")
292
+ async def export_audit_pdf(id: UUID, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
293
+ result = await db.execute(select(models.Project).where(models.Project.id == id, models.Project.user_id == current_user.id))
294
+ project = result.scalars().first()
295
+ if not project:
296
+ raise HTTPException(status_code=404, detail="Project not found")
297
+
298
+ report_res = await db.execute(select(models.AuditReport).where(models.AuditReport.project_id == project.id))
299
+ report = report_res.scalars().first()
300
+ if not report:
301
+ raise HTTPException(status_code=404, detail="Report not ready")
302
+
303
+ # Convert report object to dict for PDF render
304
+ report_dict = {
305
+ "overall_score": report.overall_score,
306
+ "confidence": report.confidence,
307
+ "capacity_estimate": report.capacity_estimate,
308
+ "agents": report.agents,
309
+ "score_disclaimer": report.score_disclaimer,
310
+ "files_analyzed": report.files_analyzed
311
+ }
312
+
313
+ pdf_bytes = generate_audit_pdf(report_dict, project.name)
314
+
315
+ filename = f"Archvise-Readiness-Audit-{project.name.replace(' ', '-')}.pdf"
316
+
317
+ # If fallback rendered HTML instead of PDF, return HTML response, else PDF
318
+ if pdf_bytes.startswith(b"<!DOCTYPE html>"):
319
+ return Response(content=pdf_bytes, media_type="text/html")
320
+
321
+ return Response(
322
+ content=pdf_bytes,
323
+ media_type="application/pdf",
324
+ headers={"Content-Disposition": f"attachment; filename={filename}"}
325
+ )
app/routers/auth.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+ from sqlalchemy.future import select
4
+ from datetime import datetime, timedelta
5
+ from app.database import get_db
6
+ from app import models, schemas
7
+ from app.utils.firebase import verify_id_token, create_session_cookie
8
+ from app.utils.security import get_current_user
9
+ from app.config import settings
10
+
11
+ router = APIRouter(prefix="/auth", tags=["Auth"])
12
+
13
+ @router.post("/verify-token", response_model=schemas.UserResponse)
14
+ async def verify_token(request: Request, response: Response, body: dict, db: AsyncSession = Depends(get_db)):
15
+ id_token = body.get("id_token")
16
+ if not id_token:
17
+ raise HTTPException(status_code=400, detail="ID token is required")
18
+
19
+ try:
20
+ # Verify Firebase ID token
21
+ decoded_claims = verify_id_token(id_token)
22
+ except Exception as e:
23
+ raise HTTPException(status_code=401, detail=f"Invalid ID token: {str(e)}")
24
+
25
+ firebase_uid = decoded_claims.get("uid")
26
+ email = decoded_claims.get("email")
27
+ name = decoded_claims.get("name")
28
+ avatar_url = decoded_claims.get("picture")
29
+
30
+ # Find or create user
31
+ result = await db.execute(select(models.User).where(models.User.firebase_uid == firebase_uid))
32
+ user = result.scalars().first()
33
+
34
+ if not user:
35
+ user = models.User(
36
+ firebase_uid=firebase_uid,
37
+ email=email,
38
+ name=name,
39
+ avatar_url=avatar_url,
40
+ plan="pro",
41
+ display_mode="founder",
42
+ credits_remaining=999999,
43
+ credits_reset_at=datetime.utcnow() + timedelta(days=30),
44
+ is_active=True
45
+ )
46
+ db.add(user)
47
+ await db.commit()
48
+ await db.refresh(user)
49
+ elif user.plan != "pro" or user.credits_remaining != 999999:
50
+ user.plan = "pro"
51
+ user.credits_remaining = 999999
52
+ db.add(user)
53
+ await db.commit()
54
+ await db.refresh(user)
55
+
56
+ # Create Firebase Session Cookie (valid for 5 days)
57
+ expires_in = timedelta(days=5)
58
+ expires_in_seconds = int(expires_in.total_seconds())
59
+ try:
60
+ session_cookie = create_session_cookie(id_token, expires_in_seconds=expires_in_seconds)
61
+ except Exception as e:
62
+ raise HTTPException(status_code=401, detail=f"Failed to create session cookie: {str(e)}")
63
+
64
+ # Set httpOnly cookie in response
65
+ response.set_cookie(
66
+ key="archvise_session",
67
+ value=session_cookie,
68
+ max_age=expires_in_seconds,
69
+ expires=expires_in_seconds,
70
+ httponly=True,
71
+ secure=not settings.DEBUG, # True in production, False in local HTTP development
72
+ samesite="lax",
73
+ path="/"
74
+ )
75
+
76
+ return user
77
+
78
+ @router.get("/me", response_model=schemas.UserResponse)
79
+ async def get_me(current_user: models.User = Depends(get_current_user)):
80
+ return current_user
81
+
82
+ @router.patch("/mode", response_model=schemas.UserResponse)
83
+ async def update_mode(body: schemas.ModeUpdate, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
84
+ if body.display_mode not in ["founder", "engineer"]:
85
+ raise HTTPException(status_code=400, detail="Invalid display mode")
86
+
87
+ current_user.display_mode = body.display_mode
88
+ db.add(current_user)
89
+ await db.commit()
90
+ await db.refresh(current_user)
91
+ return current_user
92
+
93
+ @router.post("/logout")
94
+ async def logout(response: Response):
95
+ # Clear the session cookie
96
+ response.delete_cookie(
97
+ key="archvise_session",
98
+ path="/"
99
+ )
100
+ return {"detail": "Successfully logged out"}
101
+
102
+ @router.delete("/account")
103
+ async def delete_account(current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
104
+ # Soft delete user
105
+ current_user.is_active = False
106
+
107
+ # Cancel Stripe sub if present
108
+ if current_user.stripe_customer_id:
109
+ try:
110
+ import stripe
111
+ stripe.api_key = settings.STRIPE_SECRET_KEY
112
+ # List customer subscriptions
113
+ subs = stripe.Subscription.list(customer=current_user.stripe_customer_id, status="active")
114
+ for sub in subs.data:
115
+ stripe.Subscription.delete(sub.id)
116
+ except Exception:
117
+ pass
118
+
119
+ db.add(current_user)
120
+ await db.commit()
121
+ return {"detail": "Account deactivated and subscriptions cancelled"}
app/routers/billing.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import stripe
2
+ from datetime import datetime, timedelta
3
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from sqlalchemy.future import select
6
+ from app.database import get_db
7
+ from app import models, schemas
8
+ from app.utils.security import get_current_user
9
+ from app.config import settings
10
+ from loguru import logger
11
+
12
+ router = APIRouter(prefix="/billing", tags=["Billing"])
13
+ stripe.api_key = settings.STRIPE_SECRET_KEY
14
+
15
+ @router.post("/create-checkout-session", response_model=schemas.BillingSessionResponse)
16
+ async def create_checkout_session(
17
+ body: schemas.UpgradeRequest,
18
+ current_user: models.User = Depends(get_current_user),
19
+ db: AsyncSession = Depends(get_db)
20
+ ):
21
+ if body.plan not in ["starter", "pro"]:
22
+ raise HTTPException(status_code=400, detail="Invalid plan name requested")
23
+
24
+ price_id = settings.STRIPE_STARTER_PRICE_ID if body.plan == "starter" else settings.STRIPE_PRO_PRICE_ID
25
+
26
+ try:
27
+ # Create checkout session
28
+ checkout_session = stripe.checkout.Session.create(
29
+ payment_method_types=["card"],
30
+ line_items=[{
31
+ "price": price_id,
32
+ "quantity": 1
33
+ }],
34
+ mode="subscription",
35
+ success_url=f"{settings.FRONTEND_URL}/dashboard?session_id={{CHECKOUT_SESSION_ID}}",
36
+ cancel_url=f"{settings.FRONTEND_URL}/billing/upgrade",
37
+ customer_email=current_user.email,
38
+ metadata={
39
+ "user_id": str(current_user.id),
40
+ "plan": body.plan
41
+ }
42
+ )
43
+ return {"checkout_url": checkout_session.url}
44
+ except Exception as e:
45
+ logger.error(f"Failed to create Stripe checkout session: {e}")
46
+ raise HTTPException(status_code=500, detail=f"Stripe configuration error: {str(e)}")
47
+
48
+ @router.post("/portal")
49
+ async def create_portal_session(
50
+ current_user: models.User = Depends(get_current_user)
51
+ ):
52
+ if not current_user.stripe_customer_id:
53
+ raise HTTPException(status_code=400, detail="No active billing profile found for this user.")
54
+
55
+ try:
56
+ portal_session = stripe.billing_portal.Session.create(
57
+ customer=current_user.stripe_customer_id,
58
+ return_url=f"{settings.FRONTEND_URL}/settings"
59
+ )
60
+ return {"portal_url": portal_session.url}
61
+ except Exception as e:
62
+ logger.error(f"Failed to create Stripe portal session: {e}")
63
+ raise HTTPException(status_code=500, detail=str(e))
64
+
65
+ @router.post("/webhook")
66
+ async def stripe_webhook(request: Request, db: AsyncSession = Depends(get_db)):
67
+ payload = await request.body()
68
+ sig_header = request.headers.get("stripe-signature")
69
+
70
+ if not sig_header:
71
+ raise HTTPException(status_code=400, detail="Stripe signature missing")
72
+
73
+ try:
74
+ event = stripe.Webhook.construct_event(
75
+ payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
76
+ )
77
+ except stripe.error.SignatureVerificationError as e:
78
+ logger.error(f"Stripe signature verification failed: {e}")
79
+ raise HTTPException(status_code=400, detail="Invalid signature")
80
+ except Exception as e:
81
+ logger.error(f"Stripe webhook payload error: {e}")
82
+ raise HTTPException(status_code=400, detail=str(e))
83
+
84
+ event_id = event.id
85
+ event_type = event.type
86
+
87
+ # Prevent duplicate event processing
88
+ ev_res = await db.execute(select(models.WebhookEvent).where(models.WebhookEvent.event_id == event_id))
89
+ if ev_res.scalars().first():
90
+ return {"status": "already_processed"}
91
+
92
+ # Register event
93
+ db_event = models.WebhookEvent(event_id=event_id, event_type=event_type)
94
+ db.add(db_event)
95
+ await db.commit()
96
+
97
+ logger.info(f"Processing Stripe Webhook event: {event_type} (ID: {event_id})")
98
+
99
+ try:
100
+ if event_type in ["customer.subscription.created", "customer.subscription.updated"]:
101
+ subscription = event.data.object
102
+ customer_id = subscription.customer
103
+ user_id_str = subscription.metadata.get("user_id")
104
+ plan = subscription.metadata.get("plan", "starter")
105
+
106
+ user = None
107
+ if user_id_str:
108
+ import uuid
109
+ try:
110
+ user_res = await db.execute(select(models.User).where(models.User.id == uuid.UUID(user_id_str)))
111
+ user = user_res.scalars().first()
112
+ except Exception:
113
+ pass
114
+
115
+ if not user:
116
+ # Fallback: Retrieve email from stripe customer object
117
+ customer = stripe.Customer.retrieve(customer_id)
118
+ email = customer.email
119
+ if email:
120
+ user_res = await db.execute(select(models.User).where(models.User.email == email))
121
+ user = user_res.scalars().first()
122
+
123
+ if user:
124
+ user.stripe_customer_id = customer_id
125
+ user.plan = plan
126
+ # Reset credits based on plan
127
+ # Starter: 5 audits + 10 designs, we consolidate to 15 total credits or simply set credits_remaining
128
+ # The user description mentions: "Starter: 5 audits + 10 designs / mo"
129
+ # We can set credits_remaining to 15 or handle distinct types. Since credits_remaining is an integer,
130
+ # let's set it to 15 for starter, and 999999 for pro.
131
+ user.credits_remaining = 15 if plan == "starter" else 999999
132
+ user.credits_reset_at = datetime.utcnow() + timedelta(days=30)
133
+ db.add(user)
134
+ await db.commit()
135
+ logger.info(f"Updated user {user.email} to plan {plan}")
136
+
137
+ elif event_type == "customer.subscription.deleted":
138
+ subscription = event.data.object
139
+ customer_id = subscription.customer
140
+
141
+ user_res = await db.execute(select(models.User).where(models.User.stripe_customer_id == customer_id))
142
+ user = user_res.scalars().first()
143
+ if user:
144
+ user.plan = "free"
145
+ user.credits_remaining = 2
146
+ user.credits_reset_at = datetime.utcnow() + timedelta(days=30)
147
+ db.add(user)
148
+ await db.commit()
149
+ logger.info(f"Downgraded user {user.email} to free plan due to subscription cancellation")
150
+
151
+ except Exception as e:
152
+ logger.error(f"Error handling Stripe webhook event: {e}")
153
+ raise HTTPException(status_code=500, detail="Webhook processing error")
154
+
155
+ return {"status": "success"}
app/routers/design.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import uuid
3
+ from typing import List, Optional
4
+ import redis
5
+ from fastapi import APIRouter, Depends, HTTPException, Query, status, Response
6
+ from fastapi.responses import StreamingResponse
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+ from sqlalchemy.future import select
9
+ from app.database import get_db
10
+ from app import models, schemas
11
+ from app.utils.security import get_current_user
12
+ from app.utils.pdf import generate_design_pdf
13
+ from app.config import settings
14
+ from app.tasks import design_job_wrapper
15
+ from rq import Queue
16
+ from loguru import logger
17
+
18
+ router = APIRouter(tags=["Design"])
19
+ redis_conn = redis.Redis.from_url(settings.REDIS_URL)
20
+ task_queue = Queue("default", connection=redis_conn)
21
+
22
+ @router.post("/design/generate")
23
+ async def generate_design(
24
+ body: schemas.SystemDesignRequest,
25
+ current_user: models.User = Depends(get_current_user),
26
+ db: AsyncSession = Depends(get_db)
27
+ ):
28
+ if len(body.idea_prompt) < 20:
29
+ raise HTTPException(status_code=400, detail="Idea prompt must be at least 20 characters")
30
+ if len(body.idea_prompt) > 2000:
31
+ raise HTTPException(status_code=400, detail="Idea prompt cannot exceed 2000 characters")
32
+
33
+ # Check credit availability
34
+ if current_user.credits_remaining <= 0 and current_user.plan == "free":
35
+ raise HTTPException(
36
+ status_code=status.HTTP_402_PAYMENT_REQUIRED,
37
+ detail="You have used your 2 free analyses this month. Upgrade to continue."
38
+ )
39
+
40
+ # Deduct credit
41
+ if current_user.plan == "free":
42
+ current_user.credits_remaining -= 1
43
+ db.add(current_user)
44
+
45
+ # Create project record in DB
46
+ project = models.Project(
47
+ user_id=current_user.id,
48
+ name=f"Design: {body.idea_prompt[:30]}...",
49
+ type="system_design",
50
+ source="upload", # Design doesn't use github as source upload path
51
+ status="queued"
52
+ )
53
+ db.add(project)
54
+ await db.commit()
55
+ await db.refresh(project)
56
+
57
+ # Queue job
58
+ job_id = str(uuid.uuid4())
59
+ project.job_id = job_id
60
+ db.add(project)
61
+ await db.commit()
62
+
63
+ task_queue.enqueue_call(
64
+ func=design_job_wrapper,
65
+ args=(str(project.id), job_id, body.idea_prompt),
66
+ job_id=job_id
67
+ )
68
+
69
+ return {"project_id": str(project.id), "job_id": job_id}
70
+
71
+ @router.get("/design/stream/{job_id}")
72
+ async def design_stream(job_id: str):
73
+ async def event_generator():
74
+ import asyncio
75
+ pubsub = redis_conn.pubsub()
76
+ pubsub.subscribe(f"job:{job_id}")
77
+
78
+ # Initial connection confirm
79
+ yield f"data: {json.dumps({'event': 'connecting', 'status': 'active'})}\n\n"
80
+
81
+ try:
82
+ while True:
83
+ message = pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
84
+ if message:
85
+ data = message["data"].decode("utf-8")
86
+ yield f"data: {data}\n\n"
87
+
88
+ parsed = json.loads(data)
89
+ if parsed.get("event") in ["job_complete", "job_failed"]:
90
+ break
91
+ await asyncio.sleep(0.5)
92
+ except asyncio.CancelledError:
93
+ logger.info(f"SSE connection closed for design job {job_id}")
94
+ finally:
95
+ pubsub.unsubscribe(f"job:{job_id}")
96
+
97
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
98
+
99
+ @router.get("/design/history", response_model=List[schemas.ProjectResponse])
100
+ async def get_design_history(
101
+ page: int = Query(1, ge=1),
102
+ limit: int = Query(10, ge=1, le=100),
103
+ current_user: models.User = Depends(get_current_user),
104
+ db: AsyncSession = Depends(get_db)
105
+ ):
106
+ offset = (page - 1) * limit
107
+ result = await db.execute(
108
+ select(models.Project)
109
+ .where(models.Project.user_id == current_user.id, models.Project.type == "system_design")
110
+ .order_by(models.Project.created_at.desc())
111
+ .offset(offset)
112
+ .limit(limit)
113
+ )
114
+ return result.scalars().all()
115
+
116
+ @router.get("/design/{id}", response_model=schemas.SystemDesignResponse)
117
+ async def get_system_design(id: UUID, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
118
+ result = await db.execute(select(models.Project).where(models.Project.id == id, models.Project.user_id == current_user.id))
119
+ project = result.scalars().first()
120
+ if not project:
121
+ raise HTTPException(status_code=404, detail="Project not found")
122
+
123
+ design_res = await db.execute(select(models.SystemDesign).where(models.SystemDesign.project_id == project.id))
124
+ design = design_res.scalars().first()
125
+ if not design:
126
+ raise HTTPException(status_code=404, detail="System design not generated yet")
127
+
128
+ return design
129
+
130
+ @router.delete("/design/{id}")
131
+ async def delete_design(id: UUID, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
132
+ result = await db.execute(select(models.Project).where(models.Project.id == id, models.Project.user_id == current_user.id))
133
+ project = result.scalars().first()
134
+ if not project:
135
+ raise HTTPException(status_code=404, detail="Project not found")
136
+
137
+ await db.delete(project)
138
+ await db.commit()
139
+ return {"detail": "Project deleted successfully"}
140
+
141
+ @router.get("/design/{id}/export-pdf")
142
+ async def export_design_pdf(id: UUID, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
143
+ result = await db.execute(select(models.Project).where(models.Project.id == id, models.Project.user_id == current_user.id))
144
+ project = result.scalars().first()
145
+ if not project:
146
+ raise HTTPException(status_code=404, detail="Project not found")
147
+
148
+ design_res = await db.execute(select(models.SystemDesign).where(models.SystemDesign.project_id == project.id))
149
+ design = design_res.scalars().first()
150
+ if not design:
151
+ raise HTTPException(status_code=404, detail="System design not ready")
152
+
153
+ design_dict = {
154
+ "title": design.title,
155
+ "founder_summary": design.founder_summary,
156
+ "engineer_summary": design.engineer_summary,
157
+ "architecture_type": design.architecture_type,
158
+ "stack": design.stack,
159
+ "cost_estimates": design.cost_estimates,
160
+ "reliability": design.reliability
161
+ }
162
+
163
+ pdf_bytes = generate_design_pdf(design_dict)
164
+
165
+ filename = f"Archvise-System-Design-{design.title.replace(' ', '-')}.pdf"
166
+
167
+ if pdf_bytes.startswith(b"<!DOCTYPE html>"):
168
+ return Response(content=pdf_bytes, media_type="text/html")
169
+
170
+ return Response(
171
+ content=pdf_bytes,
172
+ media_type="application/pdf",
173
+ headers={"Content-Disposition": f"attachment; filename={filename}"}
174
+ )
175
+
176
+ @router.post("/design/{id}/share", response_model=schemas.SystemDesignShareResponse)
177
+ async def share_design(id: UUID, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
178
+ result = await db.execute(select(models.Project).where(models.Project.id == id, models.Project.user_id == current_user.id))
179
+ project = result.scalars().first()
180
+ if not project:
181
+ raise HTTPException(status_code=404, detail="Project not found")
182
+
183
+ design_res = await db.execute(select(models.SystemDesign).where(models.SystemDesign.project_id == project.id))
184
+ design = design_res.scalars().first()
185
+ if not design:
186
+ raise HTTPException(status_code=404, detail="System design not generated yet")
187
+
188
+ # Check if shared link already exists
189
+ link_res = await db.execute(select(models.SharedLink).where(models.SharedLink.design_id == design.id))
190
+ shared_link = link_res.scalars().first()
191
+
192
+ if not shared_link:
193
+ # Generate a unique slug
194
+ import secrets
195
+ slug = secrets.token_urlsafe(8)
196
+ shared_link = models.SharedLink(
197
+ design_id=design.id,
198
+ slug=slug
199
+ )
200
+ db.add(shared_link)
201
+ await db.commit()
202
+ await db.refresh(shared_link)
203
+
204
+ share_url = f"{settings.FRONTEND_URL}/shared/{shared_link.slug}"
205
+ return {"slug": shared_link.slug, "share_url": share_url}
206
+
207
+ # Public non-auth endpoint
208
+ @router.get("/shared/{slug}", response_model=schemas.SystemDesignResponse)
209
+ async def get_shared_design(slug: str, db: AsyncSession = Depends(get_db)):
210
+ # Find shared link
211
+ link_res = await db.execute(select(models.SharedLink).where(models.SharedLink.slug == slug))
212
+ shared_link = link_res.scalars().first()
213
+ if not shared_link:
214
+ raise HTTPException(status_code=404, detail="Shared design not found")
215
+
216
+ # Increment view count
217
+ shared_link.view_count += 1
218
+ db.add(shared_link)
219
+
220
+ design_res = await db.execute(select(models.SystemDesign).where(models.SystemDesign.id == shared_link.design_id))
221
+ design = design_res.scalars().first()
222
+ if not design:
223
+ raise HTTPException(status_code=404, detail="Design blueprint not found")
224
+
225
+ await db.commit()
226
+ await db.refresh(design)
227
+ return design
app/routers/github.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import secrets
2
+ from fastapi import APIRouter, Depends, HTTPException, Query, Response, Request
3
+ from sqlalchemy.ext.asyncio import AsyncSession
4
+ from sqlalchemy.future import select
5
+ from app.database import get_db
6
+ from app import models, schemas
7
+ from app.utils.security import get_current_user
8
+ from app.utils.github_client import (
9
+ get_github_auth_url,
10
+ exchange_code_for_token,
11
+ get_github_user_info,
12
+ encrypt_token,
13
+ get_user_repos,
14
+ decrypt_token
15
+ )
16
+ from app.config import settings
17
+
18
+ router = APIRouter(prefix="/github", tags=["GitHub"])
19
+
20
+ @router.get("/auth-url")
21
+ async def github_auth_url(response: Response, current_user: models.User = Depends(get_current_user)):
22
+ # Generate CSRF state
23
+ state = secrets.token_hex(16)
24
+
25
+ # We can store the state in a cookie to verify it on callback
26
+ response.set_cookie(
27
+ key="gh_oauth_state",
28
+ value=state,
29
+ max_age=600, # 10 minutes
30
+ httponly=True,
31
+ secure=not settings.DEBUG,
32
+ samesite="lax",
33
+ path="/"
34
+ )
35
+
36
+ auth_url = get_github_auth_url(state)
37
+ return {"auth_url": auth_url}
38
+
39
+ @router.get("/callback")
40
+ async def github_callback(
41
+ request: Request,
42
+ code: str,
43
+ state: str,
44
+ db: AsyncSession = Depends(get_db)
45
+ ):
46
+ # Verify CSRF state
47
+ cookie_state = request.cookies.get("gh_oauth_state")
48
+ # In some proxies, state checking might fail. If cookie_state exists, check it.
49
+ if cookie_state and cookie_state != state:
50
+ raise HTTPException(status_code=400, detail="Invalid CSRF state")
51
+
52
+ # Get current user from session cookie since this endpoint is redirect target from GitHub
53
+ session_cookie = request.cookies.get("archvise_session")
54
+ if not session_cookie:
55
+ # Fallback: Redirect user to sign-in page if not authenticated
56
+ raise HTTPException(status_code=401, detail="Authentication session missing")
57
+
58
+ # Retrieve user via firebase session cookie
59
+ from app.utils.firebase import verify_session_cookie
60
+ try:
61
+ decoded_claims = verify_session_cookie(session_cookie, check_revoked=True)
62
+ except Exception:
63
+ raise HTTPException(status_code=401, detail="Invalid session")
64
+
65
+ uid = decoded_claims.get("uid")
66
+ res = await db.execute(select(models.User).where(models.User.firebase_uid == uid))
67
+ user = res.scalars().first()
68
+ if not user:
69
+ raise HTTPException(status_code=404, detail="User not found")
70
+
71
+ # Exchange code for access token
72
+ try:
73
+ token_data = await exchange_code_for_token(code)
74
+ except Exception as e:
75
+ raise HTTPException(status_code=400, detail=f"Failed to exchange code: {e}")
76
+
77
+ access_token = token_data.get("access_token")
78
+ if not access_token:
79
+ raise HTTPException(status_code=400, detail="No access token returned from GitHub")
80
+
81
+ # Fetch GitHub username
82
+ try:
83
+ gh_info = await get_github_user_info(access_token)
84
+ gh_username = gh_info.get("login")
85
+ except Exception as e:
86
+ raise HTTPException(status_code=400, detail=f"Failed to fetch user details from GitHub: {e}")
87
+
88
+ # Encrypt the access token
89
+ encrypted_token = encrypt_token(access_token)
90
+
91
+ # Store or update connection
92
+ conn_res = await db.execute(select(models.GitHubConnection).where(models.GitHubConnection.user_id == user.id))
93
+ gh_conn = conn_res.scalars().first()
94
+
95
+ if gh_conn:
96
+ gh_conn.github_username = gh_username
97
+ gh_conn.access_token = encrypted_token
98
+ else:
99
+ gh_conn = models.GitHubConnection(
100
+ user_id=user.id,
101
+ github_username=gh_username,
102
+ access_token=encrypted_token
103
+ )
104
+ db.add(gh_conn)
105
+
106
+ user.github_connected = True
107
+ db.add(user)
108
+ await db.commit()
109
+
110
+ # Clean cookie state
111
+ response = Response()
112
+ response.delete_cookie("gh_oauth_state", path="/")
113
+
114
+ return {"status": "success", "username": gh_username}
115
+
116
+ @router.get("/repos")
117
+ async def github_repos(
118
+ current_user: models.User = Depends(get_current_user),
119
+ db: AsyncSession = Depends(get_db)
120
+ ):
121
+ if not current_user.github_connected:
122
+ raise HTTPException(status_code=400, detail="GitHub not connected")
123
+
124
+ conn_res = await db.execute(select(models.GitHubConnection).where(models.GitHubConnection.user_id == current_user.id))
125
+ gh_conn = conn_res.scalars().first()
126
+ if not gh_conn:
127
+ raise HTTPException(status_code=400, detail="GitHub connection credentials not found")
128
+
129
+ access_token = decrypt_token(gh_conn.access_token)
130
+
131
+ # Fetch repositories (offload to helper)
132
+ repos = get_user_repos(access_token)
133
+ return repos
134
+
135
+ @router.post("/disconnect")
136
+ async def github_disconnect(
137
+ current_user: models.User = Depends(get_current_user),
138
+ db: AsyncSession = Depends(get_db)
139
+ ):
140
+ conn_res = await db.execute(select(models.GitHubConnection).where(models.GitHubConnection.user_id == current_user.id))
141
+ gh_conn = conn_res.scalars().first()
142
+
143
+ if gh_conn:
144
+ await db.delete(gh_conn)
145
+
146
+ current_user.github_connected = False
147
+ db.add(current_user)
148
+ await db.commit()
149
+
150
+ return {"detail": "GitHub account successfully disconnected"}
app/routers/legal.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+
3
+ router = APIRouter(prefix="/legal", tags=["Legal"])
4
+
5
+ TERMS_MARKDOWN = """# Terms of Service
6
+
7
+ Last Updated: June 25, 2026
8
+
9
+ Welcome to Archvise. By using our website and services, you agree to comply with and be bound by the following terms and conditions.
10
+
11
+ ## 1. Description of Service
12
+ Archvise provides automated AI-assisted code auditing and architecture design systems ("Services"). The Services are provided "as is" and "as available".
13
+
14
+ ## 2. Accounts
15
+ To access the Services, you must register for an account using Firebase Authentication. You are responsible for maintaining the confidentiality of your account credentials.
16
+
17
+ ## 3. Data Auditing & Deletion
18
+ Archvise respects your privacy and codebase confidentiality:
19
+ - Files uploaded for static analysis are temporarily stored securely.
20
+ - All code contents are permanently deleted immediately after analysis is complete.
21
+ - We request read-only access for GitHub integrations and do not persist code repositories.
22
+
23
+ ## 4. Subscriptions & Billing
24
+ Stripe handles payments. Subscriptions are billed on a recurring monthly basis. You can cancel your subscription at any time via the customer billing portal. Refunds are subject to our refund policy.
25
+
26
+ ## 5. Limitation of Liability
27
+ Archvise is not liable for runtime failures, server crashes, security breaches, or scaling issues. Our reports provide static analysis recommendations, which should be validated through load testing.
28
+ """
29
+
30
+ PRIVACY_MARKDOWN = """# Privacy Policy
31
+
32
+ Last Updated: June 25, 2026
33
+
34
+ At Archvise, protecting your personal data and intellectual property is our priority.
35
+
36
+ ## 1. Information We Collect
37
+ - **Profile Data**: Email address, name, and profile picture from Firebase Authentication.
38
+ - **Billing Data**: Stripe customer ID and payment processing status (we do not store raw card details).
39
+ - **GitHub Integration**: Read-only access tokens are encrypted symmetrically at rest.
40
+ - **Uploaded Code**: Temp files are read, scanned, and permanently deleted immediately after job completion.
41
+
42
+ ## 2. How We Use Information
43
+ We use your information exclusively to run code audits, compile architecture diagrams, process payments, and track credit usage. We do not sell or share your personal data or codebase files with third parties.
44
+
45
+ ## 3. Security
46
+ All database records are stored securely. S3/Supabase upload channels are encrypted. GitHub OAuth access tokens are encrypted at rest.
47
+ """
48
+
49
+ DISCLAIMER_MARKDOWN = """# Audit Score Disclaimer
50
+
51
+ The Production Readiness Score generated by Archvise is a metric calculated from static code scans.
52
+
53
+ It does **not** guarantee actual runtime performance, uptime, or failover protection. Users are strongly encouraged to validate recommendations through synthetic load testing (e.g. k6, Apache JMeter) in staging environments before launching to production.
54
+ """
55
+
56
+ @router.get("/terms")
57
+ def get_terms():
58
+ return {"content": TERMS_MARKDOWN}
59
+
60
+ @router.get("/privacy")
61
+ def get_privacy():
62
+ return {"content": PRIVACY_MARKDOWN}
63
+
64
+ @router.get("/disclaimer")
65
+ def get_disclaimer():
66
+ return {"content": DISCLAIMER_MARKDOWN}
app/routers/projects.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, Query
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+ from sqlalchemy.future import select
4
+ from typing import List
5
+ from app.database import get_db
6
+ from app import models, schemas
7
+ from app.utils.security import get_current_user
8
+
9
+ router = APIRouter(prefix="/projects", tags=["Projects"])
10
+
11
+ @router.get("/recent", response_model=List[schemas.ProjectResponse])
12
+ async def get_recent_projects(
13
+ current_user: models.User = Depends(get_current_user),
14
+ db: AsyncSession = Depends(get_db)
15
+ ):
16
+ result = await db.execute(
17
+ select(models.Project)
18
+ .where(models.Project.user_id == current_user.id)
19
+ .order_by(models.Project.created_at.desc())
20
+ .limit(6)
21
+ )
22
+ return result.scalars().all()
23
+
24
+ @router.get("/all", response_model=List[schemas.ProjectResponse])
25
+ async def get_all_projects(
26
+ page: int = Query(1, ge=1),
27
+ limit: int = Query(20, ge=1, le=100),
28
+ current_user: models.User = Depends(get_current_user),
29
+ db: AsyncSession = Depends(get_db)
30
+ ):
31
+ offset = (page - 1) * limit
32
+ result = await db.execute(
33
+ select(models.Project)
34
+ .where(models.Project.user_id == current_user.id)
35
+ .order_by(models.Project.created_at.desc())
36
+ .offset(offset)
37
+ .limit(limit)
38
+ )
39
+ return result.scalars().all()
app/routers/settings.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+ from app.database import get_db
4
+ from app import models, schemas
5
+ from app.utils.security import get_current_user
6
+
7
+ router = APIRouter(prefix="/settings", tags=["Settings"])
8
+
9
+ @router.patch("/profile", response_model=schemas.UserResponse)
10
+ async def update_profile(
11
+ body: schemas.UserUpdate,
12
+ current_user: models.User = Depends(get_current_user),
13
+ db: AsyncSession = Depends(get_db)
14
+ ):
15
+ if body.name is not None:
16
+ current_user.name = body.name
17
+
18
+ db.add(current_user)
19
+ await db.commit()
20
+ await db.refresh(current_user)
21
+ return current_user
22
+
23
+ @router.get("/stats")
24
+ async def get_stats(current_user: models.User = Depends(get_current_user)):
25
+ return {
26
+ "total_audits": current_user.total_audits,
27
+ "total_designs": current_user.total_designs,
28
+ "credits_remaining": current_user.credits_remaining,
29
+ "credits_reset_at": current_user.credits_reset_at.isoformat() if current_user.credits_reset_at else None
30
+ }
app/schemas.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, EmailStr, Field
2
+ from typing import Optional, List, Dict, Any
3
+ from datetime import datetime
4
+ from uuid import UUID
5
+
6
+ # User schemas
7
+ class UserBase(BaseModel):
8
+ email: EmailStr
9
+ name: Optional[str] = None
10
+ avatar_url: Optional[str] = None
11
+
12
+ class UserResponse(UserBase):
13
+ id: UUID
14
+ firebase_uid: str
15
+ plan: str
16
+ display_mode: str
17
+ credits_remaining: int
18
+ credits_reset_at: datetime
19
+ github_connected: bool
20
+ total_audits: int
21
+ total_designs: int
22
+ is_active: bool
23
+ created_at: datetime
24
+ updated_at: datetime
25
+
26
+ class Config:
27
+ from_attributes = True
28
+
29
+ class UserUpdate(BaseModel):
30
+ name: Optional[str] = None
31
+
32
+ class ModeUpdate(BaseModel):
33
+ display_mode: str
34
+
35
+ # Project schemas
36
+ class ProjectBase(BaseModel):
37
+ name: str
38
+ type: str
39
+ source: str
40
+ github_repo: Optional[str] = None
41
+ github_branch: Optional[str] = None
42
+
43
+ class ProjectResponse(ProjectBase):
44
+ id: UUID
45
+ user_id: UUID
46
+ status: str
47
+ job_id: Optional[str] = None
48
+ error_msg: Optional[str] = None
49
+ created_at: datetime
50
+ updated_at: datetime
51
+
52
+ class Config:
53
+ from_attributes = True
54
+
55
+ # Audit schemas
56
+ class ConfidenceSchema(BaseModel):
57
+ level: str
58
+ score: int
59
+ label: str
60
+ based_on: List[str]
61
+ limitations: List[str]
62
+ to_increase_confidence: List[str]
63
+
64
+ class CapacityEstimateSchema(BaseModel):
65
+ safe_range: str
66
+ peak_range: str
67
+ description: str
68
+ reasoning: str
69
+ confidence: str
70
+
71
+ class FindingSchema(BaseModel):
72
+ severity: str
73
+ issue: str
74
+ location: str
75
+ impact: str
76
+ founder_text: str
77
+ engineer_text: str
78
+
79
+ class SuggestionSchema(BaseModel):
80
+ priority: str
81
+ effort: str
82
+ suggestion: str
83
+ founder_text: str
84
+ engineer_text: str
85
+ estimated_score_gain: int
86
+ estimated_capacity_gain: str
87
+
88
+ class AgentReportSchema(BaseModel):
89
+ agent_name: str
90
+ agent_role: str
91
+ score: int
92
+ score_breakdown: Dict[str, int]
93
+ findings: List[FindingSchema]
94
+ suggestions: List[SuggestionSchema]
95
+
96
+ class AgentBreakdownSchema(BaseModel):
97
+ sre: AgentReportSchema
98
+ backend: AgentReportSchema
99
+ infrastructure: AgentReportSchema
100
+ cloud_architect: AgentReportSchema
101
+
102
+ class AuditReportResponse(BaseModel):
103
+ id: UUID
104
+ project_id: UUID
105
+ overall_score: int
106
+ confidence: ConfidenceSchema
107
+ capacity_estimate: CapacityEstimateSchema
108
+ agents: AgentBreakdownSchema
109
+ top_critical_issues: List[Any]
110
+ quick_wins: List[Any]
111
+ benchmark_percentile: Optional[float] = None
112
+ score_disclaimer: str
113
+ files_analyzed: int
114
+ files_skipped: int
115
+ was_truncated: bool
116
+ created_at: datetime
117
+
118
+ class Config:
119
+ from_attributes = True
120
+
121
+ class GitHubAuditRequest(BaseModel):
122
+ project_name: str
123
+ repo: str
124
+ branch: str
125
+ file_type: str = "Backend" # "Backend" | "Frontend" | "Mixed"
126
+
127
+ # System Design schemas
128
+ class SystemDesignResponse(BaseModel):
129
+ id: UUID
130
+ project_id: UUID
131
+ idea_prompt: str
132
+ title: str
133
+ founder_summary: str
134
+ engineer_summary: str
135
+ architecture_type: str
136
+ reasoning: str
137
+ stack: Any
138
+ database_design: Any
139
+ api_design: Any
140
+ infrastructure: Any
141
+ reliability: Any
142
+ cost_estimates: Any
143
+ diagram: Any
144
+ created_at: datetime
145
+
146
+ class Config:
147
+ from_attributes = True
148
+
149
+ class SystemDesignRequest(BaseModel):
150
+ idea_prompt: str
151
+ display_mode: Optional[str] = "founder"
152
+
153
+ class SystemDesignShareResponse(BaseModel):
154
+ slug: str
155
+ share_url: str
156
+
157
+ # Github Schemas
158
+ class GitHubRepoResponse(BaseModel):
159
+ id: int
160
+ name: str
161
+ full_name: str
162
+ html_url: str
163
+ description: Optional[str] = None
164
+ language: Optional[str] = None
165
+ updated_at: str
166
+ private: bool
167
+
168
+ # Stripe Billing
169
+ class BillingSessionResponse(BaseModel):
170
+ checkout_url: str
171
+
172
+ class UpgradeRequest(BaseModel):
173
+ plan: str # 'starter' | 'pro'
app/tasks.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ from datetime import datetime, timedelta
4
+ import redis
5
+ from sqlalchemy import update
6
+ from sqlalchemy.future import select
7
+ from app.config import settings
8
+ from app.database import AsyncSessionLocal
9
+ from app import models
10
+ from app.utils.ai import run_code_audit, generate_system_design
11
+ from loguru import logger
12
+
13
+ # Redis client for publishing streaming progress events
14
+ redis_client = redis.Redis.from_url(settings.REDIS_URL)
15
+
16
+ def publish_event(job_id: str, event_type: str, data: dict = None):
17
+ payload = {"event": event_type}
18
+ if data:
19
+ payload.update(data)
20
+ redis_client.publish(f"job:{job_id}", json.dumps(payload))
21
+
22
+ async def run_audit_task(project_id_str: str, job_id: str, files: list, file_type: str):
23
+ logger.info(f"Starting audit task for project {project_id_str}, job {job_id}")
24
+
25
+ # 1. Connecting
26
+ publish_event(job_id, "agent_started", {"agent": "connecting"})
27
+ await asyncio.sleep(2)
28
+ publish_event(job_id, "agent_complete", {"agent": "connecting"})
29
+
30
+ # 2. SRE
31
+ publish_event(job_id, "agent_started", {"agent": "sre"})
32
+ await asyncio.sleep(2)
33
+
34
+ # 3. Backend
35
+ publish_event(job_id, "agent_started", {"agent": "backend"})
36
+ await asyncio.sleep(2)
37
+
38
+ # 4. Infrastructure
39
+ publish_event(job_id, "agent_started", {"agent": "infrastructure"})
40
+ await asyncio.sleep(2)
41
+
42
+ # 5. Cloud Architect
43
+ publish_event(job_id, "agent_started", {"agent": "cloud_architect"})
44
+
45
+ try:
46
+ # Run AI audit analysis
47
+ report_data = await run_code_audit(files, file_type)
48
+
49
+ # Calculate stats for confidence score
50
+ files_count = len(files)
51
+ total_chars = sum(len(f["content"]) for f in files)
52
+
53
+ has_config_files = any(
54
+ any(f["filename"].endswith(ext) for ext in [".json", ".yaml", ".yml", ".toml", ".config", ".env"])
55
+ for f in files
56
+ )
57
+ has_docker = any("Dockerfile" in f["filename"] for f in files)
58
+
59
+ # Confidence score weighting:
60
+ # files>=10(+20), config_files(+15), docker(+15), chars>=50K(+20), not_truncated(+20)
61
+ confidence_score = 0
62
+ if files_count >= 10:
63
+ confidence_score += 20
64
+ if has_config_files:
65
+ confidence_score += 15
66
+ if has_docker:
67
+ confidence_score += 15
68
+ if total_chars >= 50000:
69
+ confidence_score += 20
70
+ confidence_score += 20 # Assuming not truncated by default
71
+
72
+ if confidence_score >= 70:
73
+ confidence_level = "high"
74
+ elif confidence_score >= 40:
75
+ confidence_level = "medium"
76
+ else:
77
+ confidence_level = "low"
78
+
79
+ confidence_dict = {
80
+ "level": confidence_level,
81
+ "score": confidence_score,
82
+ "label": f"{confidence_level.capitalize()} Confidence",
83
+ "based_on": report_data.get("confidence", {}).get("based_on", ["Initial file scan completed"]),
84
+ "limitations": report_data.get("confidence", {}).get("limitations", ["No runtime runtime metrics available"]),
85
+ "to_increase_confidence": report_data.get("confidence", {}).get("to_increase_confidence", ["Upload load test scripts"])
86
+ }
87
+
88
+ # Enforce server-side score calculation
89
+ # overall_score = round(sre*0.30 + backend*0.30 + infra*0.20 + cloud*0.20)
90
+ sre_score = report_data.get("sre_score", 80)
91
+ backend_score = report_data.get("backend_score", 80)
92
+ infra_score = report_data.get("infra_score", 80)
93
+ cloud_score = report_data.get("cloud_score", 80)
94
+
95
+ overall_score = round(
96
+ sre_score * 0.30 +
97
+ backend_score * 0.30 +
98
+ infra_score * 0.20 +
99
+ cloud_score * 0.20
100
+ )
101
+
102
+ # Enforce capacity tiers
103
+ # 0-30→50-200 · 31-50→200-1K · 51-65→1K-5K · 66-79→5K-25K · 80-89→25K-100K · 90-100→100K-500K
104
+ if overall_score <= 30:
105
+ safe, peak = "50-200 DAU", "200-1K DAU"
106
+ elif overall_score <= 50:
107
+ safe, peak = "200-1K DAU", "1K-5K DAU"
108
+ elif overall_score <= 65:
109
+ safe, peak = "1K-5K DAU", "5K-25K DAU"
110
+ elif overall_score <= 79:
111
+ safe, peak = "5K-25K DAU", "25K-100K DAU"
112
+ elif overall_score <= 89:
113
+ safe, peak = "25K-100K DAU", "100K-500K DAU"
114
+ else:
115
+ safe, peak = "100K-500K DAU", "500K-2M DAU"
116
+
117
+ capacity_dict = {
118
+ "safe_range": safe,
119
+ "peak_range": peak,
120
+ "description": report_data.get("capacity_estimate", {}).get("description", "DAU estimate based on static architecture scanning."),
121
+ "reasoning": report_data.get("capacity_estimate", {}).get("reasoning", "Database concurrency constraints set maximum peaks."),
122
+ "confidence": report_data.get("capacity_estimate", {}).get("confidence", "Medium")
123
+ }
124
+
125
+ # Prepare Agent reports mapping
126
+ agents_data = report_data.get("agents", {})
127
+
128
+ # Re-set agents overall scores if modified
129
+ if "sre" in agents_data:
130
+ agents_data["sre"]["score"] = sre_score
131
+ if "backend" in agents_data:
132
+ agents_data["backend"]["score"] = backend_score
133
+ if "infrastructure" in agents_data:
134
+ agents_data["infrastructure"]["score"] = infra_score
135
+ if "cloud_architect" in agents_data:
136
+ agents_data["cloud_architect"]["score"] = cloud_score
137
+
138
+ disclaimer = (
139
+ "This score is based on static code analysis only. It does "
140
+ "not guarantee runtime performance. Validate with load testing "
141
+ "before production launch."
142
+ )
143
+
144
+ async with AsyncSessionLocal() as session:
145
+ # Load project
146
+ import uuid
147
+ project_uuid = uuid.UUID(project_id_str)
148
+ proj_result = await session.execute(select(models.Project).where(models.Project.id == project_uuid))
149
+ project = proj_result.scalars().first()
150
+
151
+ if not project:
152
+ raise Exception("Project not found")
153
+
154
+ # Create Audit Report
155
+ audit_report = models.AuditReport(
156
+ project_id=project.id,
157
+ overall_score=overall_score,
158
+ confidence=confidence_dict,
159
+ capacity_estimate=capacity_dict,
160
+ agents=agents_data,
161
+ top_critical_issues=report_data.get("top_critical_issues", []),
162
+ quick_wins=report_data.get("quick_wins", []),
163
+ benchmark_percentile=report_data.get("benchmark_percentile", 75.0),
164
+ score_disclaimer=disclaimer,
165
+ files_analyzed=files_count,
166
+ files_skipped=0,
167
+ was_truncated=False
168
+ )
169
+
170
+ session.add(audit_report)
171
+ project.status = "complete"
172
+
173
+ # Update user stats
174
+ user_result = await session.execute(select(models.User).where(models.User.id == project.user_id))
175
+ user = user_result.scalars().first()
176
+ if user:
177
+ user.total_audits += 1
178
+ session.add(user)
179
+
180
+ await session.commit()
181
+
182
+ # Complete agents progress steps
183
+ publish_event(job_id, "agent_complete", {"agent": "sre"})
184
+ publish_event(job_id, "agent_complete", {"agent": "backend"})
185
+ publish_event(job_id, "agent_complete", {"agent": "infrastructure"})
186
+ publish_event(job_id, "agent_complete", {"agent": "cloud_architect"})
187
+
188
+ publish_event(job_id, "job_complete", {"audit_id": str(project.id)})
189
+ logger.info(f"Audit job {job_id} completed successfully")
190
+
191
+ except Exception as e:
192
+ logger.error(f"Audit task failed: {e}")
193
+ async with AsyncSessionLocal() as session:
194
+ import uuid
195
+ project_uuid = uuid.UUID(project_id_str)
196
+ proj_result = await session.execute(select(models.Project).where(models.Project.id == project_uuid))
197
+ project = proj_result.scalars().first()
198
+ if project:
199
+ project.status = "failed"
200
+ project.error_msg = str(e)
201
+ await session.commit()
202
+ publish_event(job_id, "job_failed", {"error": str(e)})
203
+
204
+ async def run_design_task(project_id_str: str, job_id: str, idea_prompt: str):
205
+ logger.info(f"Starting design task for project {project_id_str}, job {job_id}")
206
+
207
+ # Simulate agent pipeline progress
208
+ publish_event(job_id, "agent_started", {"agent": "connecting"})
209
+ await asyncio.sleep(2)
210
+ publish_event(job_id, "agent_complete", {"agent": "connecting"})
211
+
212
+ publish_event(job_id, "agent_started", {"agent": "sre"})
213
+ await asyncio.sleep(2)
214
+ publish_event(job_id, "agent_complete", {"agent": "sre"})
215
+
216
+ publish_event(job_id, "agent_started", {"agent": "backend"})
217
+ await asyncio.sleep(2)
218
+ publish_event(job_id, "agent_complete", {"agent": "backend"})
219
+
220
+ publish_event(job_id, "agent_started", {"agent": "infrastructure"})
221
+ await asyncio.sleep(2)
222
+ publish_event(job_id, "agent_complete", {"agent": "infrastructure"})
223
+
224
+ publish_event(job_id, "agent_started", {"agent": "cloud_architect"})
225
+
226
+ try:
227
+ # Run AI System Design
228
+ design_data = await generate_system_design(idea_prompt)
229
+
230
+ async with AsyncSessionLocal() as session:
231
+ # Load project
232
+ import uuid
233
+ project_uuid = uuid.UUID(project_id_str)
234
+ proj_result = await session.execute(select(models.Project).where(models.Project.id == project_uuid))
235
+ project = proj_result.scalars().first()
236
+
237
+ if not project:
238
+ raise Exception("Project not found")
239
+
240
+ # Create System Design
241
+ system_design = models.SystemDesign(
242
+ project_id=project.id,
243
+ idea_prompt=idea_prompt,
244
+ title=design_data.get("title", "System Architecture Blueprint"),
245
+ founder_summary=design_data.get("founder_summary", ""),
246
+ engineer_summary=design_data.get("engineer_summary", ""),
247
+ architecture_type=design_data.get("architecture_type", "Hybrid"),
248
+ reasoning=design_data.get("reasoning", ""),
249
+ stack=design_data.get("stack", {}),
250
+ database_design=design_data.get("database_design", {}),
251
+ api_design=design_data.get("api_design", {}),
252
+ infrastructure=design_data.get("infrastructure", {}),
253
+ reliability=design_data.get("reliability", {}),
254
+ cost_estimates=design_data.get("cost_estimates", {}),
255
+ diagram=design_data.get("diagram", {"nodes": [], "edges": []})
256
+ )
257
+
258
+ session.add(system_design)
259
+ project.status = "complete"
260
+
261
+ # Update user stats
262
+ user_result = await session.execute(select(models.User).where(models.User.id == project.user_id))
263
+ user = user_result.scalars().first()
264
+ if user:
265
+ user.total_designs += 1
266
+ session.add(user)
267
+
268
+ await session.commit()
269
+
270
+ publish_event(job_id, "agent_complete", {"agent": "cloud_architect"})
271
+ publish_event(job_id, "job_complete", {"design_id": str(project.id)})
272
+ logger.info(f"Design job {job_id} completed successfully")
273
+
274
+ except Exception as e:
275
+ logger.error(f"Design task failed: {e}")
276
+ async with AsyncSessionLocal() as session:
277
+ import uuid
278
+ project_uuid = uuid.UUID(project_id_str)
279
+ proj_result = await session.execute(select(models.Project).where(models.Project.id == project_uuid))
280
+ project = proj_result.scalars().first()
281
+ if project:
282
+ project.status = "failed"
283
+ project.error_msg = str(e)
284
+ await session.commit()
285
+ publish_event(job_id, "job_failed", {"error": str(e)})
286
+
287
+ # Sync wrappers for RQ
288
+ def audit_job_wrapper(project_id_str: str, job_id: str, files: list, file_type: str):
289
+ asyncio.run(run_audit_task(project_id_str, job_id, files, file_type))
290
+
291
+ def design_job_wrapper(project_id_str: str, job_id: str, idea_prompt: str):
292
+ asyncio.run(run_design_task(project_id_str, job_id, idea_prompt))
app/utils/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Archvise utils package
app/utils/ai.py ADDED
@@ -0,0 +1,435 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import re
3
+ from typing import List, Dict, Any, Optional
4
+ import httpx
5
+ from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
6
+ from app.config import settings
7
+ from loguru import logger
8
+
9
+ # Helper to clean up LLM output and extract JSON
10
+ def extract_json(text: str) -> Optional[dict]:
11
+ try:
12
+ # Check if markdown JSON code block exists
13
+ match = re.search(r"```json\s*(.*?)\s*```", text, re.DOTALL)
14
+ if match:
15
+ return json.loads(match.group(1).strip())
16
+
17
+ # Try to find the first '{' and last '}'
18
+ start = text.find("{")
19
+ end = text.rfind("}")
20
+ if start != -1 and end != -1:
21
+ return json.loads(text[start:end+1])
22
+
23
+ return json.loads(text)
24
+ except Exception as e:
25
+ logger.error(f"Failed to extract JSON from text: {e}\nOriginal text: {text[:500]}")
26
+ return None
27
+
28
+ @retry(
29
+ stop=stop_after_attempt(3),
30
+ wait=wait_exponential(multiplier=1, min=2, max=10),
31
+ retry=retry_if_exception_type(httpx.HTTPError),
32
+ reraise=True
33
+ )
34
+ async def call_nvidia_nim(model: str, api_key: str, system_prompt: str, user_prompt: str) -> str:
35
+ headers = {
36
+ "Authorization": f"Bearer {api_key}",
37
+ "Content-Type": "application/json"
38
+ }
39
+
40
+ payload = {
41
+ "model": model,
42
+ "messages": [
43
+ {"role": "system", "content": system_prompt},
44
+ {"role": "user", "content": user_prompt}
45
+ ],
46
+ "temperature": 0.1,
47
+ "max_tokens": 4096
48
+ }
49
+
50
+ async with httpx.AsyncClient(timeout=120.0) as client:
51
+ response = await client.post(
52
+ f"{settings.NVIDIA_BASE_URL}/chat/completions",
53
+ headers=headers,
54
+ json=payload
55
+ )
56
+ response.raise_for_status()
57
+ result = response.json()
58
+ return result["choices"][0]["message"]["content"]
59
+
60
+ async def run_code_audit(files: List[Dict[str, str]], file_type: str) -> dict:
61
+ # Build file contents representation
62
+ files_payload = ""
63
+ for f in files[:20]: # Limit to 20 files
64
+ files_payload += f"--- FILE: {f['filename']} ---\n{f['content']}\n\n"
65
+
66
+ system_prompt = (
67
+ "You are an elite software architecture and code auditing panel. "
68
+ "Review the codebase and generate a detailed Production Readiness Report. "
69
+ "You MUST return your response as a valid JSON object matching the JSON structure requested. "
70
+ "Do not include any extra chat text. Ensure you generate findings and suggestions twice: "
71
+ "once as plain English business/founder impact (founder_text) and once as technical details (engineer_text). "
72
+ "Enforce naming: NEVER output 'Scalability Score'. Call it 'Production Readiness Score'."
73
+ )
74
+
75
+ user_prompt = f"""
76
+ Analyze this codebase ({file_type} focus):
77
+ {files_payload}
78
+
79
+ Return a JSON object with this exact structure:
80
+ {{
81
+ "sre_score": 85,
82
+ "backend_score": 80,
83
+ "infra_score": 75,
84
+ "cloud_score": 70,
85
+ "confidence": {{
86
+ "level": "high",
87
+ "score": 85,
88
+ "label": "High Confidence",
89
+ "based_on": ["Config files detected", "Database schema present"],
90
+ "limitations": ["No load testing logs"],
91
+ "to_increase_confidence": ["Provide kubernetes deployment files"]
92
+ }},
93
+ "capacity_estimate": {{
94
+ "safe_range": "1K-5K DAU",
95
+ "peak_range": "5K-25K DAU",
96
+ "description": "Calculated based on database connection pooling and lack of caching.",
97
+ "reasoning": "Single database instance with 10 pool size limits concurrency.",
98
+ "confidence": "Medium"
99
+ }},
100
+ "agents": {{
101
+ "sre": {{
102
+ "agent_name": "Alex",
103
+ "agent_role": "SRE Lead",
104
+ "score": 85,
105
+ "score_breakdown": {{"Uptime": 90, "Rate Limiting": 80, "Failover": 85}},
106
+ "findings": [
107
+ {{
108
+ "severity": "high",
109
+ "issue": "Missing rate limit on login endpoint",
110
+ "location": "auth.py:L15",
111
+ "impact": "Vulnerable to brute-force attacks.",
112
+ "founder_text": "An attacker can try millions of passwords a minute, slowing down the service.",
113
+ "engineer_text": "Implement slowapi limit on POST /api/auth/login."
114
+ }}
115
+ ],
116
+ "suggestions": [
117
+ {{
118
+ "priority": "high",
119
+ "effort": "low",
120
+ "suggestion": "Add slowapi decorator",
121
+ "founder_text": "Secure your login forms to prevent service disruptions.",
122
+ "engineer_text": "Add @limiter.limit('5/minute') to login route.",
123
+ "estimated_score_gain": 5,
124
+ "estimated_capacity_gain": "+500 users"
125
+ }}
126
+ ]
127
+ }},
128
+ "backend": {{
129
+ "agent_name": "Maria",
130
+ "agent_role": "Senior Backend Engineer",
131
+ "score": 80,
132
+ "score_breakdown": {{"Query Performance": 85, "Caching": 70, "Concurrency": 85}},
133
+ "findings": [],
134
+ "suggestions": []
135
+ }},
136
+ "infrastructure": {{
137
+ "agent_name": "James",
138
+ "agent_role": "Infrastructure Engineer",
139
+ "score": 75,
140
+ "score_breakdown": {{"Load Balancing": 70, "CDN": 80, "Containers": 75}},
141
+ "findings": [],
142
+ "suggestions": []
143
+ }},
144
+ "cloud_architect": {{
145
+ "agent_name": "Priya",
146
+ "agent_role": "Cloud Architect",
147
+ "score": 70,
148
+ "score_breakdown": {{"Cost Efficiency": 80, "Scaling": 60}},
149
+ "findings": [],
150
+ "suggestions": [],
151
+ "cost_analysis": {{"monthly_estimate": 120, "saving_opportunities": 30}}
152
+ }}
153
+ }},
154
+ "top_critical_issues": ["No rate limiting", "Unindexed foreign keys"],
155
+ "quick_wins": ["Add indexing on user_id", "Enable Redis caching"],
156
+ "benchmark_percentile": 78
157
+ }}
158
+ """
159
+
160
+ try:
161
+ raw_response = await call_nvidia_nim(
162
+ model="deepseek-ai/deepseek-r1-0528",
163
+ api_key=settings.NVIDIA_DEEPSEEK_KEY,
164
+ system_prompt=system_prompt,
165
+ user_prompt=user_prompt
166
+ )
167
+ parsed = extract_json(raw_response)
168
+ if parsed:
169
+ return parsed
170
+ except Exception as e:
171
+ logger.error(f"DeepSeek R1 audit failed, falling back to mock structure: {e}")
172
+
173
+ # Fallback structure if LLM fails or is rate-limited
174
+ return get_fallback_audit_report(file_type)
175
+
176
+ async def generate_system_design(prompt: str) -> dict:
177
+ system_prompt = (
178
+ "You are an elite cloud architect. Design a production-ready, highly-available architecture based on the user's requirements. "
179
+ "You MUST return your response as a valid JSON object matching the JSON structure requested. "
180
+ "Do not include any extra text. Make sure you lay out nodes and edges for React Flow. "
181
+ "For nodes, place them at logical positions (x, y coords) so they do not overlap. "
182
+ "Types of nodes: input, output, or default. Nodes should have label property under data."
183
+ )
184
+
185
+ user_prompt = f"""
186
+ Design a system for:
187
+ "{prompt}"
188
+
189
+ Return a JSON object with this exact structure:
190
+ {{
191
+ "title": "Scalable Chess Platform",
192
+ "founder_summary": "A robust, real-time system designed to scale smoothly.",
193
+ "engineer_summary": "A distributed system utilizing WebSockets, Redis pub/sub, and PostgreSQL replication.",
194
+ "architecture_type": "Microservices",
195
+ "reasoning": "WebSockets require sticky sessions or an independent scaling gateway.",
196
+ "stack": {{
197
+ "Frontend": [
198
+ {{"chip": "Next.js", "reason": "Server-side rendering for SEO and fast loading."}}
199
+ ],
200
+ "Backend": [
201
+ {{"chip": "FastAPI", "reason": "Async framework ideal for WebSocket connections."}}
202
+ ],
203
+ "Database": [
204
+ {{"chip": "PostgreSQL", "reason": "Relational storage with ACID compliance."}},
205
+ {{"chip": "Redis", "reason": "In-memory caching and real-time pub/sub."}}
206
+ ],
207
+ "Infrastructure": [
208
+ {{"chip": "AWS ECS", "reason": "Container orchestration with scaling rules."}}
209
+ ]
210
+ }},
211
+ "database_design": {{
212
+ "primary_db": "PostgreSQL (RDS multi-AZ)",
213
+ "cache": "Redis cluster",
214
+ "key_tables": [
215
+ {{"table_name": "games", "fields": ["id: uuid", "white_player_id: uuid", "black_player_id: uuid", "pgn: text"]}}
216
+ ]
217
+ }},
218
+ "api_design": {{
219
+ "style": "REST + WebSockets",
220
+ "auth_strategy": "JWT / OAuth2",
221
+ "core_endpoints": [
222
+ {{"method": "GET", "path": "/api/games", "description": "Retrieve active games"}}
223
+ ]
224
+ }},
225
+ "infrastructure": {{
226
+ "cloud_provider": "AWS",
227
+ "components": ["Route 53", "Application Load Balancer", "ECS Fargate", "ElastiCache"],
228
+ "scaling_strategy": "Scale containers based on CPU utilization > 70%"
229
+ }},
230
+ "reliability": {{
231
+ "uptime_target": "99.99%",
232
+ "strategies": ["Multi-AZ deployment", "Auto-scaling groups", "RDS failover"],
233
+ "backup_dr": "Hourly DB snapshots to S3 with cross-region replication"
234
+ }},
235
+ "cost_estimates": {{
236
+ "1k_users": {{"monthly_cost": "$50", "drivers": "ALB, Small RDS instance"}},
237
+ "100k_users": {{"monthly_cost": "$650", "drivers": "ECS Auto-scaling, Redis Cluster"}},
238
+ "1m_users": {{"monthly_cost": "$4,200", "drivers": "Multi-region traffic, Large DB read-replicas"}}
239
+ }},
240
+ "diagram": {{
241
+ "nodes": [
242
+ {{"id": "1", "type": "input", "data": {{"label": "Client (Web/Mobile)"}}, "position": {{"x": 250, "y": 25}}},
243
+ {{"id": "2", "data": {{"label": "Load Balancer"}}, "position": {{"x": 250, "y": 125}}},
244
+ {{"id": "3", "data": {{"label": "FastAPI WebSockets"}}, "position": {{"x": 150, "y": 225}}},
245
+ {{"id": "4", "data": {{"label": "Next.js SSR"}}, "position": {{"x": 350, "y": 225}}},
246
+ {{"id": "5", "data": {{"label": "Redis (Pub/Sub)"}}, "position": {{"x": 150, "y": 325}}},
247
+ {{"id": "6", "data": {{"label": "PostgreSQL DB"}}, "position": {{"x": 250, "y": 425}}}
248
+ ],
249
+ "edges": [
250
+ {{"id": "e1-2", "source": "1", "target": "2", "animated": true}},
251
+ {{"id": "e2-3", "source": "2", "target": "3"}},
252
+ {{"id": "e2-4", "source": "2", "target": "4"}},
253
+ {{"id": "e3-5", "source": "3", "target": "5", "animated": true}},
254
+ {{"id": "e5-6", "source": "5", "target": "6"}},
255
+ {{"id": "e4-6", "source": "4", "target": "6"}}
256
+ ]
257
+ }}
258
+ }}
259
+ """
260
+
261
+ try:
262
+ raw_response = await call_nvidia_nim(
263
+ model="meta/llama-3.1-405b-instruct",
264
+ api_key=settings.NVIDIA_LLAMA_KEY,
265
+ system_prompt=system_prompt,
266
+ user_prompt=user_prompt
267
+ )
268
+ parsed = extract_json(raw_response)
269
+ if parsed:
270
+ return parsed
271
+ except Exception as e:
272
+ logger.error(f"Llama 3.1 405B design failed, falling back to mock structure: {e}")
273
+
274
+ return get_fallback_system_design(prompt)
275
+
276
+ def get_fallback_audit_report(file_type: str) -> dict:
277
+ return {
278
+ "sre_score": 82,
279
+ "backend_score": 78,
280
+ "infra_score": 75,
281
+ "cloud_score": 72,
282
+ "confidence": {
283
+ "level": "medium",
284
+ "score": 55,
285
+ "label": "Medium Confidence",
286
+ "based_on": ["Config files detected", "Files analyzed < 10"],
287
+ "limitations": ["No CD pipeline files", "Missing Dockerfile"],
288
+ "to_increase_confidence": ["Provide a Dockerfile and docker-compose.yml"]
289
+ },
290
+ "capacity_estimate": {
291
+ "safe_range": "1K-5K DAU",
292
+ "peak_range": "5K-25K DAU",
293
+ "description": "Calculated based on standard single-instance hosting with default configurations.",
294
+ "reasoning": "The application lacks connection pooling settings and Redis caching layer.",
295
+ "confidence": "Medium"
296
+ },
297
+ "agents": {
298
+ "sre": {
299
+ "agent_name": "Alex",
300
+ "agent_role": "SRE Lead",
301
+ "score": 82,
302
+ "score_breakdown": {"Uptime": 85, "Rate Limiting": 75, "Failover": 80},
303
+ "findings": [
304
+ {
305
+ "severity": "high",
306
+ "issue": "Missing rate limit on login endpoint",
307
+ "location": "auth.py:L12",
308
+ "impact": "Vulnerable to brute-force attacks.",
309
+ "founder_text": "An attacker can flood your login page to guess credentials, causing a slow site for users.",
310
+ "engineer_text": "Implement slowapi limit on POST /api/auth/login or implement IP-based rate limiting."
311
+ }
312
+ ],
313
+ "suggestions": [
314
+ {
315
+ "priority": "high",
316
+ "effort": "low",
317
+ "suggestion": "Add slowapi decorator",
318
+ "founder_text": "Install login protections to keep user data secure.",
319
+ "engineer_text": "Add @limiter.limit('5/minute') to login route.",
320
+ "estimated_score_gain": 5,
321
+ "estimated_capacity_gain": "+500 users"
322
+ }
323
+ ]
324
+ },
325
+ "backend": {
326
+ "agent_name": "Maria",
327
+ "agent_role": "Senior Backend Engineer",
328
+ "score": 78,
329
+ "score_breakdown": {"Query Performance": 80, "Caching": 65, "Concurrency": 80},
330
+ "findings": [
331
+ {
332
+ "severity": "medium",
333
+ "issue": "Missing cache for static dashboard stats",
334
+ "location": "dashboard.py:L40",
335
+ "impact": "Redundant database calls on every page refresh.",
336
+ "founder_text": "Every time a user visits their dashboard, your servers re-calculate stats, raising database costs.",
337
+ "engineer_text": "Implement Redis caching with cache.set('stats', data, expire=300)."
338
+ }
339
+ ],
340
+ "suggestions": [
341
+ {
342
+ "priority": "medium",
343
+ "effort": "medium",
344
+ "suggestion": "Enable Redis caching",
345
+ "founder_text": "Speed up dashboard load times for users.",
346
+ "engineer_text": "Integrate Redis and cache aggregate DB statistics.",
347
+ "estimated_score_gain": 8,
348
+ "estimated_capacity_gain": "+2,000 users"
349
+ }
350
+ ]
351
+ },
352
+ "infrastructure": {
353
+ "agent_name": "James",
354
+ "agent_role": "Infrastructure Engineer",
355
+ "score": 75,
356
+ "score_breakdown": {"Load Balancing": 70, "CDN": 80, "Containers": 75},
357
+ "findings": [],
358
+ "suggestions": []
359
+ },
360
+ "cloud_architect": {
361
+ "agent_name": "Priya",
362
+ "agent_role": "Cloud Architect",
363
+ "score": 72,
364
+ "score_breakdown": {"Cost Efficiency": 80, "Scaling": 65},
365
+ "findings": [],
366
+ "suggestions": [],
367
+ "cost_analysis": {"monthly_estimate": 150, "saving_opportunities": 40}
368
+ }
369
+ },
370
+ "top_critical_issues": ["No rate limiting on login endpoint", "Missing DB connection pooling configurations"],
371
+ "quick_wins": ["Add Redis caching for static pages", "Add rate-limiting annotations"],
372
+ "benchmark_percentile": 72
373
+ }
374
+
375
+ def get_fallback_system_design(prompt: str) -> dict:
376
+ return {
377
+ "title": "Modern Web Application",
378
+ "founder_summary": "A resilient web application architecture tailored for fast rendering and reliable data storage.",
379
+ "engineer_summary": "A typical three-tier architecture utilizing Next.js, FastAPI, PostgreSQL, and Redis.",
380
+ "architecture_type": "Hybrid",
381
+ "reasoning": "Provides the best balance between speed, cost, and developer efficiency for general requirements.",
382
+ "stack": {
383
+ "Frontend": [{"chip": "Next.js", "reason": "Excellent SEO, static page generation, and TypeScript support."}],
384
+ "Backend": [{"chip": "FastAPI", "reason": "Asynchronous python backend framework with auto OpenAPI documentation."}],
385
+ "Database": [
386
+ {"chip": "PostgreSQL", "reason": "Robust and structured data storage."},
387
+ {"chip": "Redis", "reason": "High speed caching layer."}
388
+ ],
389
+ "Infrastructure": [{"chip": "Docker", "reason": "Standardized containerization for simplified deployment."}]
390
+ },
391
+ "database_design": {
392
+ "primary_db": "PostgreSQL (with read-replicas)",
393
+ "cache": "Redis (elasticache)",
394
+ "key_tables": [
395
+ {"table_name": "users", "fields": ["id: uuid", "email: varchar(255)", "created_at: timestamp"]}
396
+ ]
397
+ },
398
+ "api_design": {
399
+ "style": "RESTful API",
400
+ "auth_strategy": "Bearer JWT tokens",
401
+ "core_endpoints": [
402
+ {"method": "GET", "path": "/api/v1/health", "description": "Health status of services"}
403
+ ]
404
+ },
405
+ "infrastructure": {
406
+ "cloud_provider": "AWS / Supabase",
407
+ "components": ["Route53", "Application Load Balancer", "ECS / Docker Container Service", "RDS instance"],
408
+ "scaling_strategy": "Scale-out containers when memory utilisation crosses 80%"
409
+ },
410
+ "reliability": {
411
+ "uptime_target": "99.9%",
412
+ "strategies": ["Multi-AZ database", "Healthchecks", "Daily snapshots"],
413
+ "backup_dr": "Automated daily backups retained for 30 days"
414
+ },
415
+ "cost_estimates": {
416
+ "1k_users": {"monthly_cost": "$30", "drivers": "Single-instance container, shared DB pool"},
417
+ "100k_users": {"monthly_cost": "$550", "drivers": "RDS db.m5 instance, Auto-scaling container cluster"},
418
+ "1m_users": {"monthly_cost": "$3,800", "drivers": "Read-replicas, elasticache cluster, ALB load balancers"}
419
+ },
420
+ "diagram": {
421
+ "nodes": [
422
+ {"id": "1", "type": "input", "data": {"label": "Client App"}, "position": {"x": 250, "y": 25}},
423
+ {"id": "2", "data": {"label": "Web Servers (Next.js)"}, "position": {"x": 250, "y": 150}},
424
+ {"id": "3", "data": {"label": "API Backend (FastAPI)"}, "position": {"x": 250, "y": 275}},
425
+ {"id": "4", "data": {"label": "PostgreSQL DB"}, "position": {"x": 150, "y": 400}},
426
+ {"id": "5", "data": {"label": "Redis Cache"}, "position": {"x": 350, "y": 400}}
427
+ ],
428
+ "edges": [
429
+ {"id": "e1-2", "source": "1", "target": "2", "animated": true},
430
+ {"id": "e2-3", "source": "2", "target": "3", "animated": true},
431
+ {"id": "e3-4", "source": "3", "target": "4"},
432
+ {"id": "e3-5", "source": "3", "target": "5"}
433
+ ]
434
+ }
435
+ }
app/utils/firebase.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import firebase_admin
2
+ from firebase_admin import credentials, auth
3
+ from app.config import settings
4
+
5
+ # Construct the service account dictionary from settings
6
+ firebase_creds_dict = {
7
+ "type": "service_account",
8
+ "project_id": settings.FIREBASE_PROJECT_ID,
9
+ "private_key": settings.FIREBASE_PRIVATE_KEY,
10
+ "client_email": settings.FIREBASE_CLIENT_EMAIL,
11
+ "token_uri": "https://oauth2.googleapis.com/token"
12
+ }
13
+
14
+ # Initialize Firebase Admin SDK if not already initialized
15
+ if not firebase_admin._apps:
16
+ cred = credentials.Certificate(firebase_creds_dict)
17
+ firebase_admin.initialize_app(cred)
18
+
19
+ def verify_id_token(id_token: str):
20
+ return auth.verify_id_token(id_token)
21
+
22
+ def create_session_cookie(id_token: str, expires_in_seconds: int):
23
+ return auth.create_session_cookie(id_token, expires_in=expires_in_seconds)
24
+
25
+ def verify_session_cookie(session_cookie: str, check_revoked: bool = True):
26
+ return auth.verify_session_cookie(session_cookie, check_revoked=check_revoked)
app/utils/github_client.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import hashlib
3
+ import re
4
+ from typing import List, Dict, Any
5
+ import httpx
6
+ from github import Github
7
+ from cryptography.fernet import Fernet
8
+ from app.config import settings
9
+ from loguru import logger
10
+
11
+ # Initialize encryption cipher using a key derived from SECRET_KEY
12
+ def _get_fernet_cipher() -> Fernet:
13
+ # Fernet requires a 32-byte base64-encoded key
14
+ key_hash = hashlib.sha256(settings.SECRET_KEY.encode()).digest()
15
+ fernet_key = base64.urlsafe_b64encode(key_hash)
16
+ return Fernet(fernet_key)
17
+
18
+ def encrypt_token(token: str) -> str:
19
+ cipher = _get_fernet_cipher()
20
+ return cipher.encrypt(token.encode()).decode()
21
+
22
+ def decrypt_token(encrypted_token: str) -> str:
23
+ cipher = _get_fernet_cipher()
24
+ return cipher.decrypt(encrypted_token.encode()).decode()
25
+
26
+ def get_github_auth_url(state: str) -> str:
27
+ redirect_uri = f"{settings.FRONTEND_URL}/api/auth/callback"
28
+ return (
29
+ f"https://github.com/login/oauth/authorize"
30
+ f"?client_id={settings.GITHUB_CLIENT_ID}"
31
+ f"&redirect_uri={redirect_uri}"
32
+ f"&state={state}"
33
+ f"&scope=repo,read:org"
34
+ )
35
+
36
+ async def exchange_code_for_token(code: str) -> dict:
37
+ url = "https://github.com/login/oauth/access_token"
38
+ headers = {"Accept": "application/json"}
39
+ data = {
40
+ "client_id": settings.GITHUB_CLIENT_ID,
41
+ "client_secret": settings.GITHUB_CLIENT_SECRET,
42
+ "code": code
43
+ }
44
+
45
+ async with httpx.AsyncClient() as client:
46
+ response = await client.post(url, headers=headers, data=data)
47
+ response.raise_for_status()
48
+ return response.json()
49
+
50
+ async def get_github_user_info(access_token: str) -> dict:
51
+ url = "https://api.github.com/user"
52
+ headers = {
53
+ "Authorization": f"token {access_token}",
54
+ "Accept": "application/vnd.github.v3+json"
55
+ }
56
+ async with httpx.AsyncClient() as client:
57
+ response = await client.get(url, headers=headers)
58
+ response.raise_for_status()
59
+ return response.json()
60
+
61
+ def get_user_repos(access_token: str) -> List[Dict[str, Any]]:
62
+ try:
63
+ g = Github(access_token)
64
+ repos = []
65
+ # Get repos sorted by updated date
66
+ for repo in g.get_user().get_repos(sort="updated", direction="desc"):
67
+ repos.append({
68
+ "id": repo.id,
69
+ "name": repo.name,
70
+ "full_name": repo.full_name,
71
+ "html_url": repo.html_url,
72
+ "description": repo.description,
73
+ "language": repo.language,
74
+ "updated_at": repo.updated_at.isoformat() if repo.updated_at else "",
75
+ "private": repo.private
76
+ })
77
+ return repos
78
+ except Exception as e:
79
+ logger.error(f"Failed to fetch repos from GitHub: {e}")
80
+ return []
81
+
82
+ def get_repo_branches(access_token: str, repo_full_name: str) -> List[str]:
83
+ try:
84
+ g = Github(access_token)
85
+ repo = g.get_repo(repo_full_name)
86
+ return [b.name for b in repo.get_branches()]
87
+ except Exception as e:
88
+ logger.error(f"Failed to fetch branches for {repo_full_name}: {e}")
89
+ return []
90
+
91
+ # File Extensions Whitelist
92
+ ALLOWED_EXTENSIONS = {
93
+ ".py", ".js", ".ts", ".jsx", ".tsx", ".json", ".yaml", ".yml",
94
+ ".toml", ".html", ".css", ".sql", ".go", ".java", ".rb"
95
+ }
96
+
97
+ def get_repo_files(access_token: str, repo_full_name: str, branch: str) -> List[Dict[str, str]]:
98
+ try:
99
+ g = Github(access_token)
100
+ repo = g.get_repo(repo_full_name)
101
+
102
+ # Get files recursively up to limits
103
+ files = []
104
+ total_size = 0
105
+ max_size = 50 * 1024 * 1024 # 50MB limit
106
+
107
+ # Get git tree recursively
108
+ git_ref = repo.get_git_ref(f"heads/{branch}")
109
+ tree = repo.get_git_tree(git_ref.object.sha, recursive=True)
110
+
111
+ for element in tree.tree:
112
+ if element.type == "blob":
113
+ # Check extension
114
+ import os
115
+ _, ext = os.path.splitext(element.path)
116
+ if ext.lower() in ALLOWED_EXTENSIONS:
117
+ # Ignore standard vendor/lock folders to stay within context limits
118
+ if any(p in element.path.split("/") for p in ["node_modules", "vendor", "dist", "build", ".git", "venv", ".venv"]):
119
+ continue
120
+
121
+ # Fetch blob content
122
+ blob = repo.get_git_blob(element.sha)
123
+ content = base64.b64decode(blob.content).decode("utf-8", errors="ignore")
124
+
125
+ # Redact secrets
126
+ content = redact_env_secrets(content, element.path)
127
+
128
+ files.append({
129
+ "filename": element.path,
130
+ "content": content
131
+ })
132
+
133
+ total_size += len(content)
134
+ if len(files) >= 20 or total_size >= max_size:
135
+ break
136
+
137
+ return files
138
+ except Exception as e:
139
+ logger.error(f"Failed to fetch files from repo {repo_full_name}: {e}")
140
+ return []
141
+
142
+ def redact_env_secrets(content: str, filename: str) -> str:
143
+ # Redact common secrets patterns
144
+ lines = content.splitlines()
145
+ redacted_lines = []
146
+
147
+ # Check if this is an env file
148
+ is_env = filename.endswith(".env") or filename.endswith(".env.production") or filename.endswith(".env.local")
149
+
150
+ # Generic credential keys regex
151
+ secret_key_re = re.compile(
152
+ r"(password|secret|key|token|auth|pwd|credential|private_key|api_key|access_key)\s*[:=]\s*['\"]?([a-zA-Z0-9_\-\.\=\+]{8,})['\"]?",
153
+ re.IGNORECASE
154
+ )
155
+
156
+ for line in lines:
157
+ if is_env:
158
+ # For env files, redact everything after '='
159
+ if "=" in line and not line.strip().startswith("#"):
160
+ key, val = line.split("=", 1)
161
+ redacted_lines.append(f"{key}=[REDACTED]")
162
+ else:
163
+ redacted_lines.append(line)
164
+ else:
165
+ # Inline check for code files
166
+ match = secret_key_re.search(line)
167
+ if match:
168
+ # Replace matching group containing key value
169
+ val = match.group(2)
170
+ redacted_lines.append(line.replace(val, "[REDACTED]"))
171
+ else:
172
+ redacted_lines.append(line)
173
+
174
+ return "\n".join(redacted_lines)
app/utils/pdf.py ADDED
@@ -0,0 +1,543 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import tempfile
3
+ from jinja2 import Template
4
+ from weasyprint import HTML
5
+ from loguru import logger
6
+
7
+ # A premium, dark-themed HTML template for SRE / Readiness Audit PDF reports
8
+ AUDIT_PDF_TEMPLATE = """
9
+ <!DOCTYPE html>
10
+ <html>
11
+ <head>
12
+ <meta charset="utf-8">
13
+ <title>Archvise Production Readiness Report</title>
14
+ <style>
15
+ @page {
16
+ size: A4;
17
+ margin: 20mm;
18
+ @bottom-right {
19
+ content: counter(page) " / " counter(pages);
20
+ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
21
+ font-size: 8pt;
22
+ color: #6B7280;
23
+ }
24
+ @bottom-left {
25
+ content: "Archvise Production Readiness Report";
26
+ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
27
+ font-size: 8pt;
28
+ color: #6B7280;
29
+ }
30
+ }
31
+ body {
32
+ background-color: #0A0A0A;
33
+ color: #FFFFFF;
34
+ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
35
+ margin: 0;
36
+ padding: 0;
37
+ font-size: 10pt;
38
+ line-height: 1.5;
39
+ }
40
+ .header {
41
+ border-bottom: 2px solid #2D2D2D;
42
+ padding-bottom: 15px;
43
+ margin-bottom: 30px;
44
+ }
45
+ .logo {
46
+ font-size: 24px;
47
+ font-weight: bold;
48
+ color: #3B82F6;
49
+ letter-spacing: 1px;
50
+ }
51
+ .subtitle {
52
+ font-size: 10px;
53
+ color: #9CA3AF;
54
+ text-transform: uppercase;
55
+ letter-spacing: 1.5px;
56
+ margin-top: 5px;
57
+ }
58
+ .title-container {
59
+ margin-bottom: 30px;
60
+ }
61
+ h1 {
62
+ font-size: 26px;
63
+ margin: 0 0 10px 0;
64
+ color: #FFFFFF;
65
+ }
66
+ .metadata-grid {
67
+ display: grid;
68
+ grid-template-columns: 1fr 1fr;
69
+ gap: 15px;
70
+ background-color: #141414;
71
+ border: 1px solid #2D2D2D;
72
+ border-radius: 8px;
73
+ padding: 15px;
74
+ margin-bottom: 35px;
75
+ }
76
+ .meta-item {
77
+ font-size: 11px;
78
+ }
79
+ .meta-label {
80
+ color: #9CA3AF;
81
+ font-weight: bold;
82
+ margin-bottom: 3px;
83
+ }
84
+ .meta-value {
85
+ color: #FFFFFF;
86
+ }
87
+ .score-box {
88
+ background: #141414;
89
+ border: 1px solid #2D2D2D;
90
+ border-radius: 8px;
91
+ padding: 25px;
92
+ text-align: center;
93
+ margin-bottom: 35px;
94
+ page-break-inside: avoid;
95
+ }
96
+ .score-value {
97
+ font-size: 64px;
98
+ font-weight: bold;
99
+ color: #3B82F6;
100
+ line-height: 1;
101
+ margin-bottom: 5px;
102
+ }
103
+ .score-label {
104
+ font-size: 12px;
105
+ color: #9CA3AF;
106
+ text-transform: uppercase;
107
+ letter-spacing: 2px;
108
+ }
109
+ .section-title {
110
+ font-size: 16px;
111
+ color: #3B82F6;
112
+ border-bottom: 1px solid #2D2D2D;
113
+ padding-bottom: 8px;
114
+ margin-top: 30px;
115
+ margin-bottom: 15px;
116
+ text-transform: uppercase;
117
+ letter-spacing: 1px;
118
+ }
119
+ .agent-grid {
120
+ margin-bottom: 30px;
121
+ }
122
+ .agent-card {
123
+ background-color: #141414;
124
+ border: 1px solid #2D2D2D;
125
+ border-radius: 8px;
126
+ padding: 15px;
127
+ margin-bottom: 15px;
128
+ page-break-inside: avoid;
129
+ }
130
+ .agent-header {
131
+ display: flex;
132
+ justify-content: space-between;
133
+ align-items: center;
134
+ border-bottom: 1px solid #2D2D2D;
135
+ padding-bottom: 8px;
136
+ margin-bottom: 10px;
137
+ }
138
+ .agent-name {
139
+ font-weight: bold;
140
+ font-size: 12px;
141
+ color: #FFFFFF;
142
+ }
143
+ .agent-role {
144
+ font-size: 10px;
145
+ color: #9CA3AF;
146
+ }
147
+ .agent-score {
148
+ font-weight: bold;
149
+ color: #3B82F6;
150
+ font-size: 14px;
151
+ }
152
+ .finding-item {
153
+ margin-bottom: 10px;
154
+ padding-bottom: 10px;
155
+ border-bottom: 1px dashed #2D2D2D;
156
+ }
157
+ .finding-item:last-child {
158
+ border-bottom: none;
159
+ margin-bottom: 0;
160
+ padding-bottom: 0;
161
+ }
162
+ .severity {
163
+ display: inline-block;
164
+ padding: 2px 6px;
165
+ border-radius: 4px;
166
+ font-size: 8px;
167
+ font-weight: bold;
168
+ text-transform: uppercase;
169
+ margin-right: 5px;
170
+ }
171
+ .severity.critical { background-color: rgba(239,68,68,0.15); color: #EF4444; border: 1px solid #EF4444; }
172
+ .severity.high { background-color: rgba(249,115,22,0.15); color: #F97316; border: 1px solid #F97316; }
173
+ .severity.medium { background-color: rgba(234,179,8,0.15); color: #EAB308; border: 1px solid #EAB308; }
174
+ .severity.low { background-color: rgba(59,130,246,0.15); color: #3B82F6; border: 1px solid #3B82F6; }
175
+
176
+ .finding-title {
177
+ font-weight: bold;
178
+ font-size: 11px;
179
+ color: #FFFFFF;
180
+ margin-bottom: 4px;
181
+ }
182
+ .finding-desc {
183
+ font-size: 10px;
184
+ color: #9CA3AF;
185
+ margin-left: 0;
186
+ }
187
+ .disclaimer {
188
+ font-size: 8pt;
189
+ color: #6B7280;
190
+ background-color: #141414;
191
+ border: 1px solid #2D2D2D;
192
+ border-radius: 6px;
193
+ padding: 15px;
194
+ margin-top: 40px;
195
+ text-align: center;
196
+ page-break-inside: avoid;
197
+ }
198
+ </style>
199
+ </head>
200
+ <body>
201
+ <div class="header">
202
+ <div class="logo">Archvise</div>
203
+ <div class="subtitle">Production Readiness Audit Panel</div>
204
+ </div>
205
+
206
+ <div class="title-container">
207
+ <h1>{{ project_name }}</h1>
208
+ <div style="font-size: 12px; color: #9CA3AF;">Generated on {{ date_str }}</div>
209
+ </div>
210
+
211
+ <div class="score-box">
212
+ <div class="score-value">{{ report.overall_score }}</div>
213
+ <div class="score-label">Production Readiness Score</div>
214
+ </div>
215
+
216
+ <div class="metadata-grid">
217
+ <div class="meta-item">
218
+ <div class="meta-label">Files Analyzed</div>
219
+ <div class="meta-value">{{ report.files_analyzed }}</div>
220
+ </div>
221
+ <div class="meta-item">
222
+ <div class="meta-label">Confidence Level</div>
223
+ <div class="meta-value" style="text-transform: capitalize;">{{ report.confidence.level }} ({{ report.confidence.score }}%)</div>
224
+ </div>
225
+ <div class="meta-item">
226
+ <div class="meta-label">Safe Load Limit</div>
227
+ <div class="meta-value">{{ report.capacity_estimate.safe_range }}</div>
228
+ </div>
229
+ <div class="meta-item">
230
+ <div class="meta-label">Peak Load Limit</div>
231
+ <div class="meta-value">{{ report.capacity_estimate.peak_range }}</div>
232
+ </div>
233
+ </div>
234
+
235
+ <div class="section-title">Critical Panel Findings</div>
236
+ <div class="agent-grid">
237
+ {% for agent_id, agent in report.agents.items() %}
238
+ {% if agent.findings %}
239
+ <div class="agent-card">
240
+ <div class="agent-header">
241
+ <div>
242
+ <span class="agent-name">{{ agent.agent_name }}</span>
243
+ <span class="agent-role">&middot; {{ agent.agent_role }}</span>
244
+ </div>
245
+ <span class="agent-score">Score: {{ agent.score }}/100</span>
246
+ </div>
247
+ <div>
248
+ {% for finding in agent.findings %}
249
+ <div class="finding-item">
250
+ <div class="finding-title">
251
+ <span class="severity {{ finding.severity }}">{{ finding.severity }}</span>
252
+ {{ finding.issue }}
253
+ </div>
254
+ <div class="finding-desc">
255
+ <strong>Location:</strong> {{ finding.location }}<br>
256
+ <strong>Details:</strong> {{ finding.engineer_text }}
257
+ </div>
258
+ </div>
259
+ {% endfor %}
260
+ </div>
261
+ </div>
262
+ {% endif %}
263
+ {% endfor %}
264
+ </div>
265
+
266
+ <div class="disclaimer">
267
+ <strong>Disclaimer:</strong> {{ report.score_disclaimer }}
268
+ </div>
269
+ </body>
270
+ </html>
271
+ """
272
+
273
+ # A premium, dark-themed HTML template for System Design PDF reports
274
+ DESIGN_PDF_TEMPLATE = """
275
+ <!DOCTYPE html>
276
+ <html>
277
+ <head>
278
+ <meta charset="utf-8">
279
+ <title>Archvise System Design Blueprint</title>
280
+ <style>
281
+ @page {
282
+ size: A4;
283
+ margin: 20mm;
284
+ @bottom-right {
285
+ content: counter(page) " / " counter(pages);
286
+ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
287
+ font-size: 8pt;
288
+ color: #6B7280;
289
+ }
290
+ @bottom-left {
291
+ content: "Archvise System Design Blueprint";
292
+ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
293
+ font-size: 8pt;
294
+ color: #6B7280;
295
+ }
296
+ }
297
+ body {
298
+ background-color: #0A0A0A;
299
+ color: #FFFFFF;
300
+ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
301
+ margin: 0;
302
+ padding: 0;
303
+ font-size: 10pt;
304
+ line-height: 1.5;
305
+ }
306
+ .header {
307
+ border-bottom: 2px solid #2D2D2D;
308
+ padding-bottom: 15px;
309
+ margin-bottom: 30px;
310
+ }
311
+ .logo {
312
+ font-size: 24px;
313
+ font-weight: bold;
314
+ color: #A855F7;
315
+ letter-spacing: 1px;
316
+ }
317
+ .subtitle {
318
+ font-size: 10px;
319
+ color: #9CA3AF;
320
+ text-transform: uppercase;
321
+ letter-spacing: 1.5px;
322
+ margin-top: 5px;
323
+ }
324
+ .title-container {
325
+ margin-bottom: 30px;
326
+ }
327
+ h1 {
328
+ font-size: 26px;
329
+ margin: 0 0 10px 0;
330
+ color: #FFFFFF;
331
+ }
332
+ .metadata-grid {
333
+ display: grid;
334
+ grid-template-columns: 1fr 1fr;
335
+ gap: 15px;
336
+ background-color: #141414;
337
+ border: 1px solid #2D2D2D;
338
+ border-radius: 8px;
339
+ padding: 15px;
340
+ margin-bottom: 35px;
341
+ }
342
+ .meta-item {
343
+ font-size: 11px;
344
+ }
345
+ .meta-label {
346
+ color: #9CA3AF;
347
+ font-weight: bold;
348
+ margin-bottom: 3px;
349
+ }
350
+ .meta-value {
351
+ color: #FFFFFF;
352
+ }
353
+ .summary-box {
354
+ background: #141414;
355
+ border: 1px solid #2D2D2D;
356
+ border-radius: 8px;
357
+ padding: 20px;
358
+ margin-bottom: 35px;
359
+ }
360
+ .summary-title {
361
+ font-weight: bold;
362
+ font-size: 12px;
363
+ color: #A855F7;
364
+ text-transform: uppercase;
365
+ margin-bottom: 8px;
366
+ }
367
+ .section-title {
368
+ font-size: 16px;
369
+ color: #A855F7;
370
+ border-bottom: 1px solid #2D2D2D;
371
+ padding-bottom: 8px;
372
+ margin-top: 30px;
373
+ margin-bottom: 15px;
374
+ text-transform: uppercase;
375
+ letter-spacing: 1px;
376
+ }
377
+ .stack-item {
378
+ margin-bottom: 15px;
379
+ }
380
+ .stack-label {
381
+ font-weight: bold;
382
+ color: #FFFFFF;
383
+ font-size: 11px;
384
+ margin-bottom: 5px;
385
+ }
386
+ .stack-chips {
387
+ display: flex;
388
+ flex-wrap: wrap;
389
+ gap: 8px;
390
+ }
391
+ .chip {
392
+ background-color: #1A1A1A;
393
+ border: 1px solid #2D2D2D;
394
+ border-radius: 4px;
395
+ padding: 4px 8px;
396
+ font-size: 10px;
397
+ color: #FFFFFF;
398
+ }
399
+ .chip-reason {
400
+ font-size: 9px;
401
+ color: #9CA3AF;
402
+ margin-top: 3px;
403
+ }
404
+ .table {
405
+ width: 100%;
406
+ border-collapse: collapse;
407
+ margin-bottom: 30px;
408
+ }
409
+ .table th, .table td {
410
+ border: 1px solid #2D2D2D;
411
+ padding: 10px;
412
+ text-align: left;
413
+ font-size: 10px;
414
+ }
415
+ .table th {
416
+ background-color: #141414;
417
+ color: #9CA3AF;
418
+ font-weight: bold;
419
+ }
420
+ .table td {
421
+ background-color: #0A0A0A;
422
+ }
423
+ </style>
424
+ </head>
425
+ <body>
426
+ <div class="header">
427
+ <div class="logo">Archvise</div>
428
+ <div class="subtitle">System Design Blueprint</div>
429
+ </div>
430
+
431
+ <div class="title-container">
432
+ <h1>{{ design.title }}</h1>
433
+ <div style="font-size: 12px; color: #9CA3AF;">Generated on {{ date_str }}</div>
434
+ </div>
435
+
436
+ <div class="metadata-grid">
437
+ <div class="meta-item">
438
+ <div class="meta-label">Architecture Type</div>
439
+ <div class="meta-value">{{ design.architecture_type }}</div>
440
+ </div>
441
+ <div class="meta-item">
442
+ <div class="meta-label">Uptime Target</div>
443
+ <div class="meta-value">{{ design.reliability.uptime_target }}</div>
444
+ </div>
445
+ </div>
446
+
447
+ <div class="summary-box">
448
+ <div class="summary-title">Executive Summary (Founder Mode)</div>
449
+ <div style="font-size: 11px; color: #9CA3AF;">{{ design.founder_summary }}</div>
450
+ </div>
451
+
452
+ <div class="summary-box">
453
+ <div class="summary-title">Technical Overview (Engineer Mode)</div>
454
+ <div style="font-size: 10px; color: #FFFFFF;">{{ design.engineer_summary }}</div>
455
+ </div>
456
+
457
+ <div class="section-title">Technology Stack Design</div>
458
+ <div class="stack-item">
459
+ {% for layer, items in design.stack.items() %}
460
+ <div class="stack-label">{{ layer }}</div>
461
+ <div style="margin-bottom: 15px;">
462
+ {% for item in items %}
463
+ <div style="margin-bottom: 5px;">
464
+ <span class="chip">{{ item.chip }}</span>
465
+ <div class="chip-reason">{{ item.reason }}</div>
466
+ </div>
467
+ {% endfor %}
468
+ </div>
469
+ {% endfor %}
470
+ </div>
471
+
472
+ <div class="section-title">Monthly Cost Estimates</div>
473
+ <table class="table">
474
+ <thead>
475
+ <tr>
476
+ <th>Scale / Tier</th>
477
+ <th>Estimated Cost</th>
478
+ <th>Main Cost Drivers</th>
479
+ </tr>
480
+ </thead>
481
+ <tbody>
482
+ <tr>
483
+ <td>1,000 active users</td>
484
+ <td>{{ design.cost_estimates['1k_users'].monthly_cost }}</td>
485
+ <td>{{ design.cost_estimates['1k_users'].drivers }}</td>
486
+ </tr>
487
+ <tr>
488
+ <td>100,000 active users</td>
489
+ <td>{{ design.cost_estimates['100k_users'].monthly_cost }}</td>
490
+ <td>{{ design.cost_estimates['100k_users'].drivers }}</td>
491
+ </tr>
492
+ <tr>
493
+ <td>1,000,000 active users</td>
494
+ <td>{{ design.cost_estimates['1m_users'].monthly_cost }}</td>
495
+ <td>{{ design.cost_estimates['1m_users'].drivers }}</td>
496
+ </tr>
497
+ </tbody>
498
+ </table>
499
+ </body>
500
+ </html>
501
+ """
502
+
503
+ def generate_audit_pdf(report: dict, project_name: str) -> bytes:
504
+ from datetime import datetime
505
+ date_str = datetime.utcnow().strftime("%B %d, %Y")
506
+
507
+ # Render Jinja HTML
508
+ template = Template(AUDIT_PDF_TEMPLATE)
509
+ html_content = template.render(report=report, project_name=project_name, date_str=date_str)
510
+
511
+ try:
512
+ # Create temp file
513
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
514
+ HTML(string=html_content).write_pdf(tmp.name)
515
+ tmp.seek(0)
516
+ pdf_bytes = tmp.read()
517
+ os.unlink(tmp.name)
518
+ return pdf_bytes
519
+ except Exception as e:
520
+ logger.error(f"WeasyPrint PDF generation failed: {e}. Falling back to standard HTML-as-PDF simulation.")
521
+ # If WeasyPrint dependencies fail, return simple HTML content in bytes to avoid crashing
522
+ return html_content.encode("utf-8")
523
+
524
+ def generate_design_pdf(design: dict) -> bytes:
525
+ from datetime import datetime
526
+ date_str = datetime.utcnow().strftime("%B %d, %Y")
527
+
528
+ # Render Jinja HTML
529
+ template = Template(DESIGN_PDF_TEMPLATE)
530
+ html_content = template.render(design=design, date_str=date_str)
531
+
532
+ try:
533
+ # Create temp file
534
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
535
+ HTML(string=html_content).write_pdf(tmp.name)
536
+ tmp.seek(0)
537
+ pdf_bytes = tmp.read()
538
+ os.unlink(tmp.name)
539
+ return pdf_bytes
540
+ except Exception as e:
541
+ logger.error(f"WeasyPrint PDF generation failed: {e}. Falling back to standard HTML-as-PDF simulation.")
542
+ # Return fallback HTML
543
+ return html_content.encode("utf-8")
app/utils/security.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import Request, Depends, HTTPException, status
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+ from sqlalchemy.future import select
4
+ from datetime import datetime, timedelta
5
+ from app.database import get_db
6
+ from app import models
7
+ from app.utils.firebase import verify_session_cookie
8
+
9
+ async def get_current_user(request: Request, db: AsyncSession = Depends(get_db)) -> models.User:
10
+ session_cookie = request.cookies.get("archvise_session")
11
+ if not session_cookie:
12
+ raise HTTPException(
13
+ status_code=status.HTTP_401_UNAUTHORIZED,
14
+ detail="Not authenticated"
15
+ )
16
+
17
+ try:
18
+ # Verify Firebase Session Cookie
19
+ decoded_claims = verify_session_cookie(session_cookie, check_revoked=True)
20
+ except Exception as e:
21
+ raise HTTPException(
22
+ status_code=status.HTTP_401_UNAUTHORIZED,
23
+ detail="Session expired or invalid"
24
+ )
25
+
26
+ firebase_uid = decoded_claims.get("uid")
27
+ email = decoded_claims.get("email")
28
+ name = decoded_claims.get("name")
29
+ avatar_url = decoded_claims.get("picture")
30
+
31
+ # Query database for user
32
+ result = await db.execute(select(models.User).where(models.User.firebase_uid == firebase_uid))
33
+ user = result.scalars().first()
34
+
35
+ if not user:
36
+ # Seamless registration: Create new user if not found in db
37
+ user = models.User(
38
+ firebase_uid=firebase_uid,
39
+ email=email,
40
+ name=name,
41
+ avatar_url=avatar_url,
42
+ plan="free",
43
+ display_mode="founder",
44
+ credits_remaining=2,
45
+ credits_reset_at=datetime.utcnow() + timedelta(days=30),
46
+ is_active=True
47
+ )
48
+ db.add(user)
49
+ await db.commit()
50
+ await db.refresh(user)
51
+
52
+ # Check if credits need to be reset
53
+ if user.credits_reset_at and datetime.utcnow() > user.credits_reset_at:
54
+ user.credits_remaining = 2 if user.plan == "free" else (5 if user.plan == "starter" else 999999)
55
+ user.credits_reset_at = datetime.utcnow() + timedelta(days=30)
56
+ db.add(user)
57
+ await db.commit()
58
+ await db.refresh(user)
59
+
60
+ if not user.is_active:
61
+ raise HTTPException(
62
+ status_code=status.HTTP_403_FORBIDDEN,
63
+ detail="User account is deactivated"
64
+ )
65
+
66
+ return user
app/utils/storage.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import boto3
2
+ from typing import Optional
3
+ from botocore.config import Config
4
+ from botocore.exceptions import ClientError
5
+ from app.config import settings
6
+ from loguru import logger
7
+
8
+ # Initialize boto3 S3 client for Supabase Storage (S3-compatible)
9
+ s3_client = boto3.client(
10
+ "s3",
11
+ endpoint_url=settings.CLOUDFLARE_R2_ENDPOINT_URL,
12
+ aws_access_key_id=settings.CLOUDFLARE_R2_ACCESS_KEY,
13
+ aws_secret_access_key=settings.CLOUDFLARE_R2_SECRET_KEY,
14
+ config=Config(signature_version="s3v4"),
15
+ region_name=settings.CLOUDFLARE_R2_REGION_NAME
16
+ )
17
+
18
+ def upload_file(file_content: bytes, object_key: str, content_type: str = "application/octet-stream") -> bool:
19
+ try:
20
+ s3_client.put_object(
21
+ Bucket=settings.CLOUDFLARE_R2_BUCKET,
22
+ Key=object_key,
23
+ Body=file_content,
24
+ ContentType=content_type
25
+ )
26
+ logger.info(f"Successfully uploaded {object_key} to {settings.CLOUDFLARE_R2_BUCKET}")
27
+ return True
28
+ except ClientError as e:
29
+ logger.error(f"Failed to upload {object_key} to S3: {e}")
30
+ return False
31
+
32
+ def get_file(object_key: str) -> Optional[bytes]:
33
+ try:
34
+ response = s3_client.get_object(
35
+ Bucket=settings.CLOUDFLARE_R2_BUCKET,
36
+ Key=object_key
37
+ )
38
+ return response["Body"].read()
39
+ except ClientError as e:
40
+ logger.error(f"Failed to download {object_key} from S3: {e}")
41
+ return None
42
+
43
+ def generate_download_url(object_key: str, expires_in: int = 3600) -> str:
44
+ try:
45
+ url = s3_client.generate_presigned_url(
46
+ "get_object",
47
+ Params={
48
+ "Bucket": settings.CLOUDFLARE_R2_BUCKET,
49
+ "Key": object_key
50
+ },
51
+ ExpiresIn=expires_in
52
+ )
53
+ return url
54
+ except ClientError as e:
55
+ logger.error(f"Failed to generate presigned URL for {object_key}: {e}")
56
+ # Fallback to direct construction if presigned fails
57
+ return f"{settings.CLOUDFLARE_R2_ENDPOINT_URL}/{settings.CLOUDFLARE_R2_BUCKET}/{object_key}"
app/worker.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import redis
2
+ from rq import Queue, Connection, Worker
3
+ from app.config import settings
4
+ from loguru import logger
5
+
6
+ listen = ["default"]
7
+ conn = redis.Redis.from_url(settings.REDIS_URL)
8
+
9
+ def run_worker():
10
+ logger.info(f"Starting RQ worker connecting to Redis at {settings.REDIS_URL}")
11
+ with Connection(conn):
12
+ worker = Worker(list(map(Queue, listen)))
13
+ worker.work()
14
+
15
+ if __name__ == "__main__":
16
+ run_worker()
railway.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://railway.app/railway.schema.json",
3
+ "build": {
4
+ "builder": "DOCKERFILE",
5
+ "dockerfilePath": "Dockerfile"
6
+ },
7
+ "deploy": {
8
+ "numReplicas": 1,
9
+ "sleep": false,
10
+ "restartPolicyType": "ON_FAILURE"
11
+ }
12
+ }
requirements.txt ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.111.0
2
+ uvicorn==0.30.1
3
+ sqlalchemy[asyncio]==2.0.30
4
+ asyncpg==0.29.0
5
+ alembic==1.13.1
6
+ redis==5.0.4
7
+ rq==1.16.1
8
+ firebase-admin==6.5.0
9
+ stripe==9.9.0
10
+ boto3==1.34.122
11
+ PyGithub==2.3.0
12
+ httpx==0.27.0
13
+ slowapi==0.1.9
14
+ weasyprint==62.1
15
+ jinja2==3.1.4
16
+ tenacity==8.3.0
17
+ sentry-sdk[fastapi]==2.5.1
18
+ loguru==0.7.2
19
+ pydantic==2.7.3
20
+ pydantic-settings==2.3.1
21
+ python-multipart==0.0.9
22
+ pyjwt[crypto]==2.8.0
23
+ python-dotenv==1.0.1
24
+ psycopg2-binary==2.9.9
25
+ cryptography==42.0.7