diff --git a/backend/app/main.py b/backend/app/main.py index ff21652..dc8c2d2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,11 +5,12 @@ from fastapi.middleware.cors import CORSMiddleware from app.database import engine, Base # Import all models so Alembic/SQLAlchemy picks them up -from app.models import user, school, license, sms, billing, ticket, audit, announcement, email_log, report # noqa +from app.models import user, school, license, sms, billing, ticket, audit, announcement, email_log, report, demo_request # noqa from app.routers import auth, schools, licenses, sms as sms_router, billing as billing_router from app.routers import tickets, users, dashboard, school_portal, announcements, sync, email as email_router from app.routers import search, audit +from app.routers import demo_requests as demo_requests_router @asynccontextmanager async def lifespan(app: FastAPI): @@ -48,6 +49,7 @@ app.include_router(sync.router) app.include_router(email_router.router) app.include_router(search.router) app.include_router(audit.router) +app.include_router(demo_requests_router.router) @app.get("/api/health") async def health(): diff --git a/backend/app/models/demo_request.py b/backend/app/models/demo_request.py new file mode 100644 index 0000000..93204cd --- /dev/null +++ b/backend/app/models/demo_request.py @@ -0,0 +1,25 @@ +import uuid +from datetime import datetime, timezone +from sqlalchemy import String, DateTime, Text +from sqlalchemy.orm import Mapped, mapped_column +from app.database import Base + + +class DemoRequest(Base): + __tablename__ = "demo_requests" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + school_name: Mapped[str] = mapped_column(String(255), nullable=False) + contact_person: Mapped[str] = mapped_column(String(255), nullable=False) + phone_or_email: Mapped[str] = mapped_column(String(255), nullable=False) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(50), default="new", nullable=False) + # "new" | "contacted" | "converted" | "dismissed" + submitted_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/app/routers/demo_requests.py b/backend/app/routers/demo_requests.py new file mode 100644 index 0000000..e98973c --- /dev/null +++ b/backend/app/routers/demo_requests.py @@ -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() diff --git a/backend/migrations/env.py b/backend/migrations/env.py index e352c23..e512fae 100644 --- a/backend/migrations/env.py +++ b/backend/migrations/env.py @@ -10,7 +10,7 @@ if config.config_file_name is not None: fileConfig(config.config_file_name) from app.database import Base -from app.models import user, school, license, sms, billing, ticket, audit, announcement, email_log, report # noqa +from app.models import user, school, license, sms, billing, ticket, audit, announcement, email_log, report, demo_request # noqa target_metadata = Base.metadata DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub") diff --git a/backend/seed.py b/backend/seed.py index 9e36f29..380eae8 100644 --- a/backend/seed.py +++ b/backend/seed.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker from sqlalchemy import select from app.database import Base # Import all models so Base.metadata has the complete schema -from app.models import user, school, license, sms, billing, ticket, audit, announcement, email_log, report # noqa +from app.models import user, school, license, sms, billing, ticket, audit, announcement, email_log, report, demo_request # noqa from app.models.user import HubUser, UserRole from app.auth.password import hash_password diff --git a/frontend/src/components/sidebar/AppSidebar.vue b/frontend/src/components/sidebar/AppSidebar.vue index cf58a39..689c8b5 100644 --- a/frontend/src/components/sidebar/AppSidebar.vue +++ b/frontend/src/components/sidebar/AppSidebar.vue @@ -21,7 +21,7 @@ diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0862934..c6ac54a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -112,6 +112,12 @@ export const getAuditLogs = (params?: object) => api.get('/audit-logs', { para export const getEmailLogs = (params?: object) => api.get('/email/logs', { params }).then(r => r.data) export const sendTestEmail = (to: string) => api.post('/email/test', { to }).then(r => r.data) +// ── Demo Requests ───────────────────────────────────────────────────────────── +export const getDemoRequests = (params?: object) => api.get('/demo-requests', { params }).then(r => r.data) +export const updateDemoRequest = (id: string, data: { status?: string; notes?: string }) => + api.put(`/demo-requests/${id}`, data).then(r => r.data) +export const deleteDemoRequest = (id: string) => api.delete(`/demo-requests/${id}`) + // ── School Portal ───────────────────────────────────────────────────────────── export const getPortalOverview = () => api.get('/portal/overview').then(r => r.data) export const getPortalSmsStats = (params?: object) => api.get('/portal/sms-stats', { params }).then(r => r.data) diff --git a/frontend/src/pages/DemoRequestsPage.vue b/frontend/src/pages/DemoRequestsPage.vue new file mode 100644 index 0000000..d22bdb3 --- /dev/null +++ b/frontend/src/pages/DemoRequestsPage.vue @@ -0,0 +1,226 @@ + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 8ee9e24..29ed5c1 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -83,6 +83,12 @@ const router = createRouter({ component: () => import('@/pages/AuditLogsPage.vue'), meta: { requiresAuth: true, layout: 'app', role: 'super_admin' }, }, + { + path: '/demo-requests', + name: 'demo-requests', + component: () => import('@/pages/DemoRequestsPage.vue'), + meta: { requiresAuth: true, layout: 'app', role: 'super_admin' }, + }, // School Portal routes { path: '/portal',