Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ JWT_SECRET=change-this-in-production-min-32-bytes
JWT_ALGORITHM=HS256
ACCESS_TOKEN_MINUTES=720

# ── Environment ────────────────────────────────────────────────
# Set to "production" to disable dev-only routes (e.g. /dev/email-preview).
ENVIRONMENT=development

# ── Database ───────────────────────────────────────────────────
DATABASE_URL=sqlite:///./assistant.db

Expand Down
7 changes: 7 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,12 @@ class Settings:
"DIGEST_BASE_URL", "https://qyverixai.onrender.com"
)

# ── Environment ─────────────────────────────────────────────
environment: str = os.getenv("ENVIRONMENT", "development").strip().lower()

@property
def is_production(self) -> bool:
return self.environment == "production"


settings = Settings()
6 changes: 6 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@
import logging
from contextlib import asynccontextmanager

from .config import settings
from .routers import (
analyze,
auth,
chat,
debugging,
dev,
explanation,
history,
share,
Expand Down Expand Up @@ -161,6 +163,10 @@ async def add_cache_header(request: Request, call_next):
app.include_router(user_data.router)
app.include_router(upload_file.router, prefix="/upload", tags=['Upload File'] )

if not settings.is_production:
app.include_router(dev.router)
app.include_router(dev.preview_router)


# Operational endpoints: /healthz/live, /healthz/ready, /metrics
app.include_router(health_router.router)
Expand Down
21 changes: 19 additions & 2 deletions backend/app/routers/auth.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, status
import logging

from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session

Expand All @@ -11,12 +13,26 @@
hash_password,
verify_password,
)
from ..services import email_service

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/auth", tags=["Auth"])


def _send_welcome_email_task(email: str) -> None:
try:
email_service.send_welcome_email(email)
except Exception:
logger.exception("Failed to send welcome email to %s", email)


@router.post("/signup", response_model=AuthResponse)
def signup(payload: SignupRequest, db: Session = Depends(get_db)):
def signup(
payload: SignupRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
):
existing = db.execute(
select(User).where(User.email == payload.email.lower().strip())
).scalar_one_or_none()
Expand All @@ -34,6 +50,7 @@ def signup(payload: SignupRequest, db: Session = Depends(get_db)):
db.refresh(user)

token = create_access_token(user.id)
background_tasks.add_task(_send_welcome_email_task, user.email)
return AuthResponse(access_token=token, user_id=user.id, email=user.email)


Expand Down
106 changes: 106 additions & 0 deletions backend/app/routers/dev.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Development-only routes — disabled when ENVIRONMENT=production."""

from fastapi import APIRouter, HTTPException, status
from fastapi.responses import HTMLResponse, RedirectResponse

from ..config import settings
from ..services import email_service

router = APIRouter(prefix="/dev", tags=["Development"])
preview_router = APIRouter(tags=["Development"])


def _ensure_dev_enabled() -> None:
if settings.is_production:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Not found",
)


def _preview_index_html(*, base_path: str) -> str:
"""HTML index listing all email preview links."""
items = "\n".join(
f' <li><a href="{base_path}/{name}">{name}</a></li>'
for name in sorted(email_service.EMAIL_TEMPLATES)
)
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>QyverixAI Email Previews</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 480px;
margin: 48px auto;
padding: 0 16px;
color: #1a1b2e;
}}
h1 {{ font-size: 1.25rem; color: #7c3aed; }}
ul {{ line-height: 2; padding-left: 1.25rem; }}
a {{ color: #7c3aed; }}
</style>
</head>
<body>
<h1>QyverixAI Email Previews</h1>
<p>Select a template to preview in the browser:</p>
<ul>
{items}
</ul>
</body>
</html>"""


def _render_email_preview(template: str, *, base_path: str) -> HTMLResponse:
_ensure_dev_enabled()

resolved = email_service.resolve_template_name(template)
if resolved is None:
available = ", ".join(sorted(email_service.EMAIL_TEMPLATES))
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=(
f"Unknown template '{template}'. "
f"Available: {available}. "
f"Open {base_path} for the full list."
),
)

if resolved != template.strip().lower():
return RedirectResponse(
url=f"{base_path}/{resolved}",
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)

html = email_service.render_template(
resolved,
email_service.preview_context(resolved),
inline_styles=False,
)
return HTMLResponse(content=html)


@router.get("/email-preview", response_class=HTMLResponse)
def email_preview_dev_index() -> HTMLResponse:
_ensure_dev_enabled()
return HTMLResponse(_preview_index_html(base_path="/dev/email-preview"))


@router.get("/email-preview/{template}", response_class=HTMLResponse)
def email_preview_dev(template: str) -> HTMLResponse:
"""Render a transactional email template in the browser for local iteration."""
return _render_email_preview(template, base_path="/dev/email-preview")


@preview_router.get("/email-preview", response_class=HTMLResponse)
def email_preview_index() -> HTMLResponse:
_ensure_dev_enabled()
return HTMLResponse(_preview_index_html(base_path="/email-preview"))


@preview_router.get("/email-preview/{template}", response_class=HTMLResponse)
def email_preview(template: str) -> HTMLResponse:
"""Alias preview route: /email-preview/{template}."""
return _render_email_preview(template, base_path="/email-preview")
Loading