feat: add Demo Requests module

- New demo_requests table (SQLAlchemy model + Alembic-ready)
- Public POST /api/demo-requests endpoint for the TapTrack website form
- Super-admin GET/PUT/DELETE endpoints to manage leads
- DemoRequestsPage.vue with stats row, filterable table, status updates, notes modal
- Sidebar nav item and route registered
This commit is contained in:
kevin-asprec
2026-03-17 09:50:14 +08:00
parent 56ecdbead3
commit 7d8cc3ca22
9 changed files with 385 additions and 7 deletions

View File

@@ -0,0 +1,112 @@
"""Demo Requests — public POST from TapTrack Website, super admin manages."""
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.auth.dependencies import require_super_admin
from app.database import get_db
from app.models.user import HubUser
from app.models.demo_request import DemoRequest
router = APIRouter(prefix="/api/demo-requests", tags=["demo-requests"])
class DemoRequestSubmit(BaseModel):
school: str
contact: str
phone: str
class DemoRequestUpdate(BaseModel):
status: Optional[str] = None # "new" | "contacted" | "converted" | "dismissed"
notes: Optional[str] = None
@router.post("", status_code=201)
async def submit_demo_request(
body: DemoRequestSubmit,
db: AsyncSession = Depends(get_db),
):
"""Public endpoint — called by TapTrack Website form."""
req = DemoRequest(
school_name=body.school,
contact_person=body.contact,
phone_or_email=body.phone,
)
db.add(req)
await db.commit()
return {"ok": True, "id": req.id}
@router.get("")
async def list_demo_requests(
status: Optional[str] = None,
limit: int = 50,
offset: int = 0,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
"""Super admin only — list all demo requests."""
stmt = select(DemoRequest).order_by(desc(DemoRequest.submitted_at))
if status:
stmt = stmt.where(DemoRequest.status == status)
stmt = stmt.limit(limit).offset(offset)
items = (await db.execute(stmt)).scalars().all()
return [
{
"id": r.id,
"school_name": r.school_name,
"contact_person": r.contact_person,
"phone_or_email": r.phone_or_email,
"status": r.status,
"notes": r.notes,
"submitted_at": r.submitted_at.isoformat(),
"updated_at": r.updated_at.isoformat(),
}
for r in items
]
@router.put("/{req_id}")
async def update_demo_request(
req_id: str,
body: DemoRequestUpdate,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
"""Super admin only — update status/notes on a demo request."""
req = (await db.execute(select(DemoRequest).where(DemoRequest.id == req_id))).scalar_one_or_none()
if not req:
raise HTTPException(404, detail="Demo request not found")
if body.status is not None:
valid_statuses = {"new", "contacted", "converted", "dismissed"}
if body.status not in valid_statuses:
raise HTTPException(400, detail=f"status must be one of: {', '.join(valid_statuses)}")
req.status = body.status
if body.notes is not None:
req.notes = body.notes
req.updated_at = datetime.now(timezone.utc)
await db.commit()
return {
"id": req.id,
"status": req.status,
"notes": req.notes,
"updated_at": req.updated_at.isoformat(),
}
@router.delete("/{req_id}", status_code=204)
async def delete_demo_request(
req_id: str,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
"""Super admin only — permanently delete a demo request."""
req = (await db.execute(select(DemoRequest).where(DemoRequest.id == req_id))).scalar_one_or_none()
if not req:
raise HTTPException(404, detail="Demo request not found")
await db.delete(req)
await db.commit()