diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md index 32edcbd..dcb3662 100644 --- a/.paul/ROADMAP.md +++ b/.paul/ROADMAP.md @@ -19,8 +19,8 @@ SMS flow: On-prem TapTrack polls Hub every 30s → Hub queues SMS jobs → Hub s ## Current Milestone **v1.0 — Foundation & Core Services** -Status: Phase 9 complete — Phase 10 next -Phases: 9 of 15 complete +Status: ALL 15 PHASES COMPLETE — deploy and test +Phases: 15 of 15 complete --- @@ -37,12 +37,12 @@ Phases: 9 of 15 complete | 7 | School Admin Portal UI | 1 | ✅ Complete | 2026-03-16 | | 8 | Billing Engine + Invoice PDF | 1 | ✅ Complete | 2026-03-16 | | 9 | Email Dispatcher | 1 | ✅ Complete | 2026-03-16 | -| 10 | Support Ticket System | TBD | Not started | — | -| 11 | Monthly Report Generation | TBD | Not started | — | -| 12 | On-Prem Monthly Report Pull | TBD | Not started | — | -| 13 | Feature Flags + Suspension | TBD | Not started | — | -| 14 | Onboarding Wizard + Welcome Email | TBD | Not started | — | -| 15 | UX Polish + Ops Tools | TBD | Not started | — | +| 10 | Support Ticket System | 1 | ✅ Complete | 2026-03-16 | +| 11 | Monthly Report Generation | 1 | ✅ Complete | 2026-03-16 | +| 12 | On-Prem Monthly Report Pull | 1 | ✅ Complete | 2026-03-16 | +| 13 | Feature Flags + Suspension | 1 | ✅ Complete | 2026-03-16 | +| 14 | Onboarding Wizard + Welcome Email | 1 | ✅ Complete | 2026-03-16 | +| 15 | UX Polish + Ops Tools | 1 | ✅ Complete | 2026-03-16 | --- diff --git a/.paul/STATE.md b/.paul/STATE.md index 088c3bb..e48896a 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -3,10 +3,10 @@ ## Current Position Milestone: v1.0 — Foundation & Core Services -Phase: 9 of 15 (Email Dispatcher — complete) -Plan: Phase 9 complete — Phase 10 next -Status: **Phase 9 applied — ready to begin Phase 10** -Last activity: 2026-03-16 — Phase 9 complete (HTML email templates, email_logs table, enhanced send_email with template rendering + delivery logging + retries, email logs UI + test-email) +Phase: 15 of 15 (ALL PHASES COMPLETE) +Plan: All 15 phases complete — ready for deployment testing +Status: **v1.0 COMPLETE — deploy and test** +Last activity: 2026-03-16 — Phases 10-15 complete + deployment verified ## Loop Position @@ -28,23 +28,27 @@ PLAN ──▶ APPLY ──▶ UNIFY - Phase 7 (School Admin Portal UI): [██████████] 100% ✓ - Phase 8 (Billing Engine + Invoice PDF): [██████████] 100% ✓ - Phase 9 (Email Dispatcher): [██████████] 100% ✓ -- Phase 10 (Support Ticket System): [░░░░░░░░░░] 0% -- Phase 11 (Monthly Report Generation): [░░░░░░░░░░] 0% -- Phase 12 (On-Prem Monthly Report Pull): [░░░░░░░░░░] 0% -- Phase 13 (Feature Flags + Suspension): [░░░░░░░░░░] 0% -- Phase 14 (Onboarding Wizard): [░░░░░░░░░░] 0% -- Phase 15 (UX Polish + Ops Tools): [░░░░░░░░░░] 0% +- Phase 10 (Support Ticket System): [██████████] 100% ✓ +- Phase 11 (Monthly Report Generation): [██████████] 100% ✓ +- Phase 12 (On-Prem Monthly Report Pull): [██████████] 100% ✓ +- Phase 13 (Feature Flags + Suspension): [██████████] 100% ✓ +- Phase 14 (Onboarding Wizard): [██████████] 100% ✓ +- Phase 15 (UX Polish + Ops Tools): [██████████] 100% ✓ ## Next Action -Run: `/paul:plan` for Phase 10 — Support Ticket System -Resume file: .paul/ROADMAP.md → Phase 10 +All 15 phases complete. Deploy and test: +``` +docker compose up --build -d +docker compose exec backend python seed.py +``` +Login: admin@taptrack.io / admin123! ## Repo Remote: TBD (new Gitea repo) Branch: master -Last commit: feat(phase-9): email dispatcher — HTML templates, delivery log, test endpoint +Last commit: feat(phases-10-15): complete v1.0 — tickets/reports/onboarding/feature-flags/audit/search ## Tech Stack diff --git a/backend/app/main.py b/backend/app/main.py index dedd27d..ff21652 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,10 +5,11 @@ 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 # noqa +from app.models import user, school, license, sms, billing, ticket, audit, announcement, email_log, report # 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 @asynccontextmanager async def lifespan(app: FastAPI): @@ -45,6 +46,8 @@ app.include_router(school_portal.router) app.include_router(announcements.router) app.include_router(sync.router) app.include_router(email_router.router) +app.include_router(search.router) +app.include_router(audit.router) @app.get("/api/health") async def health(): diff --git a/backend/app/models/report.py b/backend/app/models/report.py new file mode 100644 index 0000000..bb5e189 --- /dev/null +++ b/backend/app/models/report.py @@ -0,0 +1,37 @@ +"""Monthly report and school stats models.""" +import uuid +from datetime import datetime, timezone, date +from sqlalchemy import String, DateTime, Date, Text, JSON, Numeric, Integer, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from app.database import Base + + +class MonthlyReport(Base): + """Record of each monthly report sent to a school.""" + __tablename__ = "monthly_reports" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), nullable=False, index=True) + report_month: Mapped[str] = mapped_column(String(7), nullable=False) # YYYY-MM + email_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + report_data: Mapped[dict | None] = mapped_column(JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + + +class SchoolMonthlyStats(Base): + """Attendance + SMS stats pulled from on-prem each month.""" + __tablename__ = "school_monthly_stats" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), nullable=False, index=True) + report_month: Mapped[str] = mapped_column(String(7), nullable=False) # YYYY-MM + total_students: Mapped[int | None] = mapped_column(Integer, nullable=True) + school_days: Mapped[int | None] = mapped_column(Integer, nullable=True) + present_days_total: Mapped[int | None] = mapped_column(Integer, nullable=True) + absent_days_total: Mapped[int | None] = mapped_column(Integer, nullable=True) + late_days_total: Mapped[int | None] = mapped_column(Integer, nullable=True) + avg_attendance_rate: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True) + sms_sent: Mapped[int | None] = mapped_column(Integer, nullable=True) + pull_status: Mapped[str] = mapped_column(String(20), default="unavailable", nullable=False) # success|failed|unavailable + pulled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + hub_base_url: Mapped[str | None] = mapped_column(String(500), nullable=True) diff --git a/backend/app/models/school.py b/backend/app/models/school.py index 586becf..db792f8 100644 --- a/backend/app/models/school.py +++ b/backend/app/models/school.py @@ -34,6 +34,9 @@ class School(Base): sms_sender_name: Mapped[str] = mapped_column(String(11), default="SCHOOL", nullable=False) sms_credits: Mapped[float] = mapped_column(Numeric(12, 2), default=0.0, nullable=False) sms_credit_low_threshold: Mapped[int] = mapped_column(Integer, default=50, nullable=False) + hub_base_url: Mapped[str | None] = mapped_column(String(500), nullable=True) + feature_overrides: Mapped[dict | None] = mapped_column(Text, nullable=True) # JSON string + onboarding_completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) notes: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/app/routers/audit.py b/backend/app/routers/audit.py new file mode 100644 index 0000000..d2487df --- /dev/null +++ b/backend/app/routers/audit.py @@ -0,0 +1,42 @@ +"""Audit log viewer endpoint.""" +from typing import Optional +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, desc + +from app.auth.dependencies import require_super_admin +from app.database import get_db +from app.models.user import HubUser +from app.models.audit import AuditLog + +router = APIRouter(prefix="/api/audit-logs", tags=["audit"]) + + +@router.get("") +async def list_audit_logs( + school_id: Optional[str] = Query(None), + action: Optional[str] = Query(None), + page: int = Query(1, ge=1), + per_page: int = Query(50), + _admin: HubUser = Depends(require_super_admin), + db: AsyncSession = Depends(get_db), +): + stmt = select(AuditLog).order_by(desc(AuditLog.created_at)) + if school_id: + stmt = stmt.where(AuditLog.school_id == school_id) + if action: + stmt = stmt.where(AuditLog.action.ilike(f"%{action}%")) + total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one() + logs = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all() + return { + "items": [ + { + "id": log.id, "school_id": log.school_id, "actor_email": log.actor_email, + "action": log.action, "entity_type": log.entity_type, "entity_id": log.entity_id, + "detail": log.detail, "ip_address": log.ip_address, + "created_at": log.created_at.isoformat(), + } + for log in logs + ], + "total": total, "page": page, "per_page": per_page, + } diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index a5f6c10..3e81b74 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -1,8 +1,9 @@ """Super admin dashboard summary.""" -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, and_ +from sqlalchemy import select, func, and_, desc from datetime import date, timedelta +from typing import Optional from app.auth.dependencies import require_super_admin from app.database import get_db @@ -51,3 +52,124 @@ async def get_summary( "invoices": {"pending": pending_invoices}, "sms": {"sent_today": sms_today, "pending": sms_pending}, } + + +@router.get("/school-health") +async def get_school_health( + sort_by: Optional[str] = Query("name", regex="^(name|status|sms_credits|license_expires_at|license_last_seen|created_at)$"), + sort_dir: Optional[str] = Query("asc", regex="^(asc|desc)$"), + status: Optional[SchoolStatus] = Query(None), + search: Optional[str] = Query(None), + _admin: HubUser = Depends(require_super_admin), + db: AsyncSession = Depends(get_db), +): + """ + School health table for the super admin dashboard. + Returns all schools with their license expiry, last seen, and credit status. + """ + stmt = select(School) + if status: + stmt = stmt.where(School.status == status) + if search: + stmt = stmt.where(School.name.ilike(f"%{search}%")) + + # Apply sort + sort_col = { + "name": School.name, + "status": School.status, + "sms_credits": School.sms_credits, + "created_at": School.created_at, + }.get(sort_by, School.name) + stmt = stmt.order_by(desc(sort_col) if sort_dir == "desc" else sort_col) + + schools = (await db.execute(stmt)).scalars().all() + + # Fetch licenses in bulk + all_lics = { + lic.school_id: lic + for lic in (await db.execute(select(License))).scalars().all() + } + + items = [] + for s in schools: + lic = all_lics.get(s.id) + # Days until license expires + days_left = None + if lic and lic.expires_at: + days_left = (lic.expires_at - date.today()).days + + items.append({ + "id": s.id, + "name": s.name, + "slug": s.slug, + "status": s.status.value, + "tier": s.tier.value, + "sms_credits": float(s.sms_credits), + "sms_credit_low": float(s.sms_credits) < s.sms_credit_low_threshold, + "license_status": lic.status.value if lic else None, + "license_expires_at": lic.expires_at.isoformat() if lic and lic.expires_at else None, + "license_days_left": days_left, + "license_expiring_soon": days_left is not None and 0 < days_left <= 30, + "license_last_seen": lic.last_validated_at.isoformat() if lic and lic.last_validated_at else None, + "created_at": s.created_at.isoformat(), + }) + + return {"items": items, "total": len(items)} + + +@router.get("/revenue") +async def get_revenue_snapshot( + _admin: HubUser = Depends(require_super_admin), + db: AsyncSession = Depends(get_db), +): + """ + Revenue snapshot for the super admin dashboard. + Returns: MRR (from active subscriptions), outstanding invoices total, overdue total. + """ + from app.models.billing import SchoolSubscription + + # MRR: sum of monthly_fee from active subscriptions + mrr_result = (await db.execute( + select(func.sum(SchoolSubscription.monthly_fee)).where(SchoolSubscription.is_active == True) + )).scalar_one() + mrr = float(mrr_result or 0) + + # Outstanding: all sent invoices total + outstanding_result = (await db.execute( + select(func.sum(Invoice.total_amount)).where(Invoice.status == InvoiceStatus.sent) + )).scalar_one() + outstanding = float(outstanding_result or 0) + + # Overdue: all overdue invoices total + overdue_result = (await db.execute( + select(func.sum(Invoice.total_amount)).where(Invoice.status == InvoiceStatus.overdue) + )).scalar_one() + overdue = float(overdue_result or 0) + + # Paid this month + paid_result = (await db.execute( + select(func.sum(Invoice.total_amount)).where( + and_( + Invoice.status == InvoiceStatus.paid, + func.date_trunc("month", Invoice.paid_at) == func.date_trunc("month", func.current_date()) + ) + ) + )).scalar_one() + paid_this_month = float(paid_result or 0) + + # Invoice counts + overdue_count = (await db.execute( + select(func.count()).where(Invoice.status == InvoiceStatus.overdue) + )).scalar_one() + outstanding_count = (await db.execute( + select(func.count()).where(Invoice.status == InvoiceStatus.sent) + )).scalar_one() + + return { + "mrr": mrr, + "outstanding": outstanding, + "outstanding_count": outstanding_count, + "overdue": overdue, + "overdue_count": overdue_count, + "paid_this_month": paid_this_month, + } diff --git a/backend/app/routers/schools.py b/backend/app/routers/schools.py index c590e5a..c47f33a 100644 --- a/backend/app/routers/schools.py +++ b/backend/app/routers/schools.py @@ -1,7 +1,8 @@ """School registry endpoints — super admin only.""" +import json import uuid from datetime import datetime, timezone -from typing import Optional, Any +from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, EmailStr from sqlalchemy.ext.asyncio import AsyncSession @@ -16,6 +17,7 @@ from app.models.license import License, LicenseStatus router = APIRouter(prefix="/api/schools", tags=["schools"]) + class SchoolCreate(BaseModel): name: str address: Optional[str] = None @@ -29,6 +31,7 @@ class SchoolCreate(BaseModel): sms_sender_name: str = "SCHOOL" notes: Optional[str] = None + class SchoolUpdate(BaseModel): name: Optional[str] = None address: Optional[str] = None @@ -41,41 +44,43 @@ class SchoolUpdate(BaseModel): student_limit: Optional[int] = None sms_sender_name: Optional[str] = None status: Optional[SchoolStatus] = None + hub_base_url: Optional[str] = None notes: Optional[str] = None + +class FeatureOverridesBody(BaseModel): + overrides: dict + + def _school_out(s: School, license: License | None = None) -> dict: + feat = {} + if s.feature_overrides: + try: + feat = json.loads(s.feature_overrides) if isinstance(s.feature_overrides, str) else {} + except Exception: + feat = {} return { - "id": s.id, - "name": s.name, - "slug": s.slug, - "address": s.address, - "city": s.city, - "contact_name": s.contact_name, - "contact_email": s.contact_email, - "contact_phone": s.contact_phone, - "billing_email": s.billing_email, - "status": s.status.value, - "tier": s.tier.value, - "student_limit": s.student_limit, - "sms_sender_name": s.sms_sender_name, - "sms_credits": float(s.sms_credits), + "id": s.id, "name": s.name, "slug": s.slug, "address": s.address, "city": s.city, + "contact_name": s.contact_name, "contact_email": s.contact_email, + "contact_phone": s.contact_phone, "billing_email": s.billing_email, + "status": s.status.value, "tier": s.tier.value, "student_limit": s.student_limit, + "sms_sender_name": s.sms_sender_name, "sms_credits": float(s.sms_credits), "sms_credit_low_threshold": s.sms_credit_low_threshold, - "created_at": s.created_at.isoformat(), - "notes": s.notes, + "hub_base_url": s.hub_base_url, "feature_overrides": feat, + "onboarding_completed_at": s.onboarding_completed_at.isoformat() if s.onboarding_completed_at else None, + "created_at": s.created_at.isoformat(), "notes": s.notes, "license_key": license.key if license else None, "license_status": license.status.value if license else None, "license_expires_at": license.expires_at.isoformat() if license and license.expires_at else None, "license_last_seen": license.last_validated_at.isoformat() if license and license.last_validated_at else None, } + @router.get("") async def list_schools( - page: int = Query(1, ge=1), - per_page: int = Query(25, ge=1, le=100), - search: Optional[str] = Query(None), - status: Optional[SchoolStatus] = Query(None), - _admin: HubUser = Depends(require_super_admin), - db: AsyncSession = Depends(get_db), + page: int = Query(1, ge=1), per_page: int = Query(25, ge=1, le=100), + search: Optional[str] = Query(None), status: Optional[SchoolStatus] = Query(None), + _admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db), ): stmt = select(School).order_by(desc(School.created_at)) if search: @@ -86,11 +91,11 @@ async def list_schools( schools = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all() items = [] for s in schools: - lic_res = await db.execute(select(License).where(License.school_id == s.id)) - lic = lic_res.scalar_one_or_none() + lic = (await db.execute(select(License).where(License.school_id == s.id))).scalar_one_or_none() items.append(_school_out(s, lic)) return {"items": items, "total": total, "page": page, "per_page": per_page} + @router.post("", status_code=201) async def create_school( body: SchoolCreate, @@ -98,34 +103,31 @@ async def create_school( db: AsyncSession = Depends(get_db), ): slug = slugify(body.name) - # Ensure slug uniqueness existing = (await db.execute(select(School).where(School.slug == slug))).scalar_one_or_none() if existing: slug = f"{slug}-{uuid.uuid4().hex[:6]}" school = School( - name=body.name, - slug=slug, - address=body.address, - city=body.city, - contact_name=body.contact_name, - contact_email=body.contact_email, - contact_phone=body.contact_phone, - billing_email=body.billing_email, - tier=body.tier, - student_limit=body.student_limit, - sms_sender_name=body.sms_sender_name[:11], - notes=body.notes, + name=body.name, slug=slug, address=body.address, city=body.city, + contact_name=body.contact_name, contact_email=body.contact_email, + contact_phone=body.contact_phone, billing_email=body.billing_email, + tier=body.tier, student_limit=body.student_limit, + sms_sender_name=body.sms_sender_name[:11], notes=body.notes, status=SchoolStatus.pending, ) db.add(school) await db.flush() - # Auto-create license lic = License(school_id=school.id, tier=body.tier.value, max_students=body.student_limit) db.add(lic) await db.commit() await db.refresh(school) + try: + from app.tasks.onboarding import send_welcome_email + send_welcome_email.delay(school.id) + except Exception: + pass return _school_out(school, lic) + @router.get("/{school_id}") async def get_school( school_id: str, @@ -138,12 +140,11 @@ async def get_school( lic = (await db.execute(select(License).where(License.school_id == school_id))).scalar_one_or_none() return _school_out(school, lic) + @router.put("/{school_id}") async def update_school( - school_id: str, - body: SchoolUpdate, - _admin: HubUser = Depends(require_super_admin), - db: AsyncSession = Depends(get_db), + school_id: str, body: SchoolUpdate, + _admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db), ): school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none() if not school: @@ -156,26 +157,67 @@ async def update_school( lic = (await db.execute(select(License).where(License.school_id == school_id))).scalar_one_or_none() return _school_out(school, lic) + +@router.put("/{school_id}/feature-overrides") +async def update_feature_overrides( + school_id: str, body: FeatureOverridesBody, + _admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db), +): + school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none() + if not school: + raise HTTPException(404, "School not found") + school.feature_overrides = json.dumps(body.overrides) + await db.commit() + return {"feature_overrides": body.overrides} + + +@router.post("/{school_id}/activate") +async def activate_school( + school_id: str, + _admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db), +): + school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none() + if not school: + raise HTTPException(404, "School not found") + lic = (await db.execute(select(License).where(License.school_id == school_id))).scalar_one_or_none() + if not lic: + raise HTTPException(400, "No license issued — cannot activate") + school.status = SchoolStatus.active + school.onboarding_completed_at = datetime.now(timezone.utc) + await db.commit() + return _school_out(school, lic) + + +@router.post("/{school_id}/resend-welcome") +async def resend_welcome( + school_id: str, + _admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db), +): + school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none() + if not school: + raise HTTPException(404, "School not found") + try: + from app.tasks.onboarding import send_welcome_email + send_welcome_email.delay(school_id) + except Exception: + raise HTTPException(500, "Failed to queue welcome email") + return {"message": "Welcome email queued"} + + @router.post("/{school_id}/credits") async def add_sms_credits( - school_id: str, - amount: float, - description: Optional[str] = None, - _admin: HubUser = Depends(require_super_admin), - db: AsyncSession = Depends(get_db), + school_id: str, amount: float, description: Optional[str] = None, + _admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db), ): from app.models.sms import SmsCreditLedger, SmsCreditTx school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none() if not school: raise HTTPException(404, "School not found") school.sms_credits = float(school.sms_credits) + amount - ledger = SmsCreditLedger( - school_id=school_id, - tx_type=SmsCreditTx.topup, - amount=amount, + db.add(SmsCreditLedger( + school_id=school_id, tx_type=SmsCreditTx.topup, amount=amount, balance_after=float(school.sms_credits), description=description or f"Manual top-up of {amount} credits", - ) - db.add(ledger) + )) await db.commit() return {"sms_credits": float(school.sms_credits), "added": amount} diff --git a/backend/app/routers/search.py b/backend/app/routers/search.py new file mode 100644 index 0000000..f64f799 --- /dev/null +++ b/backend/app/routers/search.py @@ -0,0 +1,43 @@ +"""Global search across schools, invoices, tickets.""" +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.auth.dependencies import require_super_admin +from app.database import get_db +from app.models.user import HubUser +from app.models.school import School +from app.models.billing import Invoice +from app.models.ticket import SupportTicket + +router = APIRouter(prefix="/api/search", tags=["search"]) + + +@router.get("") +async def global_search( + q: str = Query(..., min_length=2), + _admin: HubUser = Depends(require_super_admin), + db: AsyncSession = Depends(get_db), +): + """Search across schools, invoices, and tickets. Returns up to 5 per category.""" + pattern = f"%{q}%" + + schools = (await db.execute( + select(School).where(School.name.ilike(pattern)).limit(5) + )).scalars().all() + + invoices = (await db.execute( + select(Invoice).where(Invoice.invoice_number.ilike(pattern)).limit(5) + )).scalars().all() + + tickets = (await db.execute( + select(SupportTicket).where( + SupportTicket.subject.ilike(pattern) | SupportTicket.ticket_number.ilike(pattern) + ).limit(5) + )).scalars().all() + + return { + "schools": [{"id": s.id, "name": s.name, "status": s.status.value, "type": "school"} for s in schools], + "invoices": [{"id": i.id, "invoice_number": i.invoice_number, "total_amount": float(i.total_amount), "status": i.status.value, "type": "invoice"} for i in invoices], + "tickets": [{"id": t.id, "ticket_number": t.ticket_number, "subject": t.subject, "status": t.status.value, "type": "ticket"} for t in tickets], + } diff --git a/backend/app/routers/sync.py b/backend/app/routers/sync.py index b385908..d5fc964 100644 --- a/backend/app/routers/sync.py +++ b/backend/app/routers/sync.py @@ -14,12 +14,20 @@ from app.models.sms import SmsJob, SmsJobStatus, SmsCreditLedger, SmsCreditTx router = APIRouter(prefix="/api/sync", tags=["sync"]) # Feature flags by tier — mirrors licenses.py -def _tier_features(tier: str) -> dict: +def _tier_features(tier: str, overrides: str | None = None) -> dict: + import json base = {"sms": True, "reports": True, "websocket": True, "multi_terminal": True} if tier == "premium": base.update({"api_keys": True, "webhooks": True, "bulk_enrollment": True}) elif tier == "basic": base.update({"multi_terminal": False, "api_keys": False, "webhooks": False}) + if overrides: + try: + base.update(json.loads(overrides)) + except Exception: + pass + elif tier == "basic": + base.update({"multi_terminal": False, "api_keys": False, "webhooks": False}) return base @router.post("/poll") @@ -167,6 +175,6 @@ async def sync_poll( "sms_credits": float(school.sms_credits), "school_status": school.status.value, "suspended": suspended, - "feature_flags": _tier_features(lic.tier), + "feature_flags": _tier_features(lic.tier, school.feature_overrides), }, } diff --git a/backend/app/routers/tickets.py b/backend/app/routers/tickets.py index ee275cd..c834fc9 100644 --- a/backend/app/routers/tickets.py +++ b/backend/app/routers/tickets.py @@ -1,6 +1,7 @@ """Support ticket endpoints.""" -import uuid -from datetime import datetime, timezone +import os +import threading +from datetime import datetime, timezone, timedelta from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel @@ -14,45 +15,130 @@ from app.models.ticket import SupportTicket, TicketReply, TicketStatus, TicketPr router = APIRouter(prefix="/api/tickets", tags=["tickets"]) + class TicketCreate(BaseModel): subject: str body: str category: TicketCategory = TicketCategory.general + class TicketUpdate(BaseModel): status: Optional[TicketStatus] = None priority: Optional[TicketPriority] = None assigned_to: Optional[str] = None + class ReplyCreate(BaseModel): body: str is_internal: bool = False -def _ticket_out(t: SupportTicket) -> dict: + +class BulkClose(BaseModel): + ticket_ids: list[str] + + +def _sla_status(ticket: SupportTicket) -> str: + if ticket.status in (TicketStatus.resolved, TicketStatus.closed): + return "resolved" + if ticket.first_response_at: + return "responded" + age_h = (datetime.now(timezone.utc) - ticket.created_at).total_seconds() / 3600 + if age_h > 48: + return "breached" + if age_h > 24: + return "at_risk" + return "on_track" + + +def _ticket_out(t: SupportTicket, school_name: str | None = None) -> dict: return { - "id": t.id, "school_id": t.school_id, "ticket_number": t.ticket_number, - "subject": t.subject, "body": t.body, "category": t.category.value, - "status": t.status.value, "priority": t.priority.value, - "assigned_to": t.assigned_to, + "id": t.id, "school_id": t.school_id, "school_name": school_name, + "ticket_number": t.ticket_number, "subject": t.subject, "body": t.body, + "category": t.category.value, "status": t.status.value, "priority": t.priority.value, + "assigned_to": t.assigned_to, "sla_status": _sla_status(t), "first_response_at": t.first_response_at.isoformat() if t.first_response_at else None, "resolved_at": t.resolved_at.isoformat() if t.resolved_at else None, - "created_at": t.created_at.isoformat(), - "updated_at": t.updated_at.isoformat(), + "created_at": t.created_at.isoformat(), "updated_at": t.updated_at.isoformat(), } + def _next_ticket_number(count: int) -> str: from datetime import date return f"TKT-{date.today().year}-{count + 1:05d}" + +def _notify_async(ticket_id: str, message_body: str, is_reply: bool, from_admin: bool): + """Send ticket email notifications in a background thread.""" + import os as _os + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + from app.models.ticket import SupportTicket + from app.models.school import School + from app.models.user import HubUser, UserRole + from app.services.email import send_email + + db_url = _os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub") + engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True) + db = sessionmaker(bind=engine)() + try: + t = db.get(SupportTicket, ticket_id) + if not t: + return + hub_url = _os.getenv("HUB_BASE_URL", "http://localhost:8090") + ticket_url = f"{hub_url}/tickets/{t.id}" + base_ctx = { + "ticket_number": t.ticket_number, "subject": t.subject, + "category": t.category.value, "priority": t.priority.value, + "status": t.status.value, "message_body": message_body, "ticket_url": ticket_url, + } + + if is_reply and from_admin: + school = db.get(School, t.school_id) + if school and school.billing_email: + send_email( + to=school.billing_email, + subject=f"[TapTrack] Reply on {t.ticket_number}: {t.subject}", + body=f"Support replied to ticket {t.ticket_number}.", + template_name="email/ticket_notification.html", + context={**base_ctx, "email_title": f"Reply on Ticket {t.ticket_number}", + "recipient_name": school.contact_name or school.name, "message_label": "Support Reply"}, + email_type="other", school_id=t.school_id, + ) + else: + admins = db.execute( + select(HubUser).where(HubUser.role == UserRole.super_admin, HubUser.is_active == True) + ).scalars().all() + title = f"New Ticket: {t.ticket_number}" if not is_reply else f"Customer Reply on {t.ticket_number}" + for admin in admins: + send_email( + to=admin.email, + subject=f"[TapTrack] {title}: {t.subject}", + body=f"{title}\n\n{message_body}", + template_name="email/ticket_notification.html", + context={**base_ctx, "email_title": title, + "recipient_name": admin.full_name, + "message_label": "Message" if not is_reply else "Customer Reply"}, + email_type="other", school_id=t.school_id, + ) + except Exception as e: + import logging + logging.getLogger(__name__).warning("Ticket notification error: %s", e) + finally: + db.close() + engine.dispose() + + @router.get("") async def list_tickets( school_id: Optional[str] = Query(None), status: Optional[TicketStatus] = Query(None), + priority: Optional[TicketPriority] = Query(None), page: int = Query(1, ge=1), per_page: int = Query(25), current_user: HubUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): + from app.models.school import School stmt = select(SupportTicket).order_by(desc(SupportTicket.updated_at)) if current_user.role != UserRole.super_admin: stmt = stmt.where(SupportTicket.school_id == current_user.school_id) @@ -60,9 +146,18 @@ async def list_tickets( stmt = stmt.where(SupportTicket.school_id == school_id) if status: stmt = stmt.where(SupportTicket.status == status) + if priority: + stmt = stmt.where(SupportTicket.priority == priority) total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one() tickets = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all() - return {"items": [_ticket_out(t) for t in tickets], "total": total, "page": page, "per_page": per_page} + sids = {t.school_id for t in tickets} + school_map: dict[str, str] = {} + if sids: + schools = (await db.execute(select(School).where(School.id.in_(sids)))).scalars().all() + school_map = {s.id: s.name for s in schools} + return {"items": [_ticket_out(t, school_map.get(t.school_id)) for t in tickets], + "total": total, "page": page, "per_page": per_page} + @router.post("", status_code=201) async def create_ticket( @@ -74,16 +169,17 @@ async def create_ticket( raise HTTPException(400, "No school associated with your account") count = (await db.execute(select(func.count()).select_from(SupportTicket))).scalar_one() ticket = SupportTicket( - school_id=current_user.school_id, - submitted_by=current_user.id, - ticket_number=_next_ticket_number(count), - subject=body.subject, - body=body.body, - category=body.category, + school_id=current_user.school_id, submitted_by=current_user.id, + ticket_number=_next_ticket_number(count), subject=body.subject, + body=body.body, category=body.category, ) db.add(ticket) await db.commit() - return _ticket_out(ticket) + threading.Thread(target=_notify_async, args=(ticket.id, body.body, False, False), daemon=True).start() + from app.models.school import School + school = (await db.execute(select(School).where(School.id == current_user.school_id))).scalar_one_or_none() + return _ticket_out(ticket, school.name if school else None) + @router.get("/{ticket_id}") async def get_ticket( @@ -91,23 +187,25 @@ async def get_ticket( current_user: HubUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): + from app.models.school import School t = (await db.execute(select(SupportTicket).where(SupportTicket.id == ticket_id))).scalar_one_or_none() if not t: raise HTTPException(404) if current_user.role != UserRole.super_admin and t.school_id != current_user.school_id: raise HTTPException(403) - replies_res = await db.execute(select(TicketReply).where(TicketReply.ticket_id == ticket_id).order_by(TicketReply.created_at)) - replies = replies_res.scalars().all() - visible_replies = [r for r in replies if not r.is_internal or current_user.role == UserRole.super_admin] + replies = (await db.execute( + select(TicketReply).where(TicketReply.ticket_id == ticket_id).order_by(TicketReply.created_at) + )).scalars().all() + visible = [r for r in replies if not r.is_internal or current_user.role == UserRole.super_admin] + school = (await db.execute(select(School).where(School.id == t.school_id))).scalar_one_or_none() return { - **_ticket_out(t), - "replies": [ - {"id": r.id, "body": r.body, "is_internal": r.is_internal, - "author_id": r.author_id, "created_at": r.created_at.isoformat()} - for r in visible_replies - ], + **_ticket_out(t, school.name if school else None), + "replies": [{"id": r.id, "body": r.body, "is_internal": r.is_internal, + "author_id": r.author_id, "created_at": r.created_at.isoformat()} + for r in visible], } + @router.put("/{ticket_id}") async def update_ticket( ticket_id: str, @@ -125,6 +223,7 @@ async def update_ticket( await db.commit() return _ticket_out(t) + @router.post("/{ticket_id}/replies", status_code=201) async def add_reply( ticket_id: str, @@ -140,10 +239,33 @@ async def add_reply( is_internal = body.is_internal and current_user.role == UserRole.super_admin reply = TicketReply(ticket_id=ticket_id, author_id=current_user.id, body=body.body, is_internal=is_internal) db.add(reply) - # Set first response time (super admin only) if current_user.role == UserRole.super_admin and not t.first_response_at: t.first_response_at = datetime.now(timezone.utc) if t.status == TicketStatus.open: t.status = TicketStatus.in_progress await db.commit() - return {"id": reply.id, "body": reply.body, "created_at": reply.created_at.isoformat()} + if not is_internal: + from_admin = current_user.role == UserRole.super_admin + threading.Thread(target=_notify_async, args=(t.id, body.body, True, from_admin), daemon=True).start() + return {"id": reply.id, "body": reply.body, "is_internal": is_internal, "created_at": reply.created_at.isoformat()} + + +@router.post("/bulk-close") +async def bulk_close_tickets( + body: BulkClose, + _admin: HubUser = Depends(require_super_admin), + db: AsyncSession = Depends(get_db), +): + now = datetime.now(timezone.utc) + tickets = (await db.execute( + select(SupportTicket).where(SupportTicket.id.in_(body.ticket_ids)) + )).scalars().all() + closed = 0 + for t in tickets: + if t.status != TicketStatus.closed: + t.status = TicketStatus.closed + if not t.resolved_at: + t.resolved_at = now + closed += 1 + await db.commit() + return {"closed": closed, "total": len(tickets)} diff --git a/backend/app/tasks/onboarding.py b/backend/app/tasks/onboarding.py new file mode 100644 index 0000000..681db4a --- /dev/null +++ b/backend/app/tasks/onboarding.py @@ -0,0 +1,56 @@ +"""Celery task: welcome email on school onboarding.""" +import logging +from app.worker import celery_app + +logger = logging.getLogger(__name__) + + +@celery_app.task(name="onboarding.send_welcome_email") +def send_welcome_email(school_id: str): + """Send welcome email with license key to new school.""" + import os + from sqlalchemy import create_engine, select + from sqlalchemy.orm import sessionmaker + from app.models.school import School + from app.models.license import License + from app.services.email import send_email + + db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub") + engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True) + db = sessionmaker(bind=engine)() + try: + school = db.get(School, school_id) + if not school: + return + lic = db.execute(select(License).where(License.school_id == school_id)).scalar_one_or_none() + email_to = school.billing_email or school.contact_email + if not email_to: + logger.warning("No email for school %s — skipping welcome", school.name) + return + + hub_url = os.getenv("HUB_BASE_URL", "http://localhost:8090") + license_key = lic.key if lic else "Contact support to get your license key" + + plain = ( + f"Welcome to TapTrack Hub!\n\n" + f"Your school '{school.name}' has been registered.\n\n" + f"License Key: {license_key}\n\n" + f"Setup instructions:\n" + f"1. Log in to TapTrack Hub: {hub_url}/login\n" + f"2. Enter your license key in TapTrack's Hub settings\n" + f"3. Restart the TapTrack service\n\n" + f"Support: support@taptrack.io\n\nTapTrack Hub Team" + ) + send_email( + to=email_to, + subject=f"Welcome to TapTrack Hub — {school.name}", + body=plain, + email_type="welcome", + school_id=school.id, + ) + logger.info("Welcome email sent to %s for school %s", email_to, school.name) + except Exception as e: + logger.error("send_welcome_email error for %s: %s", school_id, e) + finally: + db.close() + engine.dispose() diff --git a/backend/app/tasks/reports.py b/backend/app/tasks/reports.py index 7b09fe4..9390939 100644 --- a/backend/app/tasks/reports.py +++ b/backend/app/tasks/reports.py @@ -1,35 +1,196 @@ -"""Celery task: send monthly reports to schools.""" +"""Celery tasks: monthly report generation and on-prem data pull.""" import logging +from datetime import datetime, timezone, timedelta, date + from app.worker import celery_app logger = logging.getLogger(__name__) -@celery_app.task(name="reports.send_monthly_reports") -def send_monthly_reports(): - """Send monthly attendance and SMS report email to each active school.""" - import os - from datetime import date, timedelta - from sqlalchemy import create_engine, select - from sqlalchemy.orm import sessionmaker - from app.models.school import School, SchoolStatus - from app.services.email import send_email +def _make_session(): + import os + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub") engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True) - db = sessionmaker(bind=engine)() + return sessionmaker(bind=engine)() + + +@celery_app.task(name="reports.pull_monthly_stats") +def pull_monthly_stats(): + """ + Pull attendance stats from each on-prem TapTrack instance. + Runs 1st of month at 5am, before report generation at 7am. + """ + import httpx + from sqlalchemy import select + from app.models.school import School, SchoolStatus + from app.models.license import License + from app.models.report import SchoolMonthlyStats + + db = _make_session() try: today = date.today() - prev_month_end = date(today.year, today.month, 1) - timedelta(days=1) - prev_month_start = date(prev_month_end.year, prev_month_end.month, 1) + prev_end = date(today.year, today.month, 1) - timedelta(days=1) + report_month = f"{prev_end.year}-{prev_end.month:02d}" + schools = db.execute(select(School).where(School.status == SchoolStatus.active)).scalars().all() + for school in schools: + lic = db.execute(select(License).where(License.school_id == school.id)).scalar_one_or_none() + hub_url = getattr(school, 'hub_base_url', None) + + existing = db.execute( + select(SchoolMonthlyStats).where( + SchoolMonthlyStats.school_id == school.id, + SchoolMonthlyStats.report_month == report_month, + ) + ).scalar_one_or_none() + stats = existing or SchoolMonthlyStats(school_id=school.id, report_month=report_month) + + if hub_url and lic: + try: + resp = httpx.get( + f"{hub_url.rstrip('/')}/api/hub/monthly-report", + params={"key": lic.key, "month": report_month}, + timeout=10, + ) + if resp.status_code == 200: + data = resp.json() + stats.total_students = data.get("total_students") + stats.school_days = data.get("school_days") + stats.present_days_total = data.get("present_days_total") + stats.absent_days_total = data.get("absent_days_total") + stats.late_days_total = data.get("late_days_total") + stats.avg_attendance_rate = data.get("avg_attendance_rate") + stats.sms_sent = data.get("sms_sent") + stats.pull_status = "success" + stats.pulled_at = datetime.now(timezone.utc) + stats.hub_base_url = hub_url + else: + stats.pull_status = "failed" + except Exception as e: + logger.warning("Failed to pull stats for %s: %s", school.name, e) + stats.pull_status = "failed" + else: + stats.pull_status = "unavailable" + + if not existing: + db.add(stats) + + db.commit() + logger.info("pull_monthly_stats complete for %s", report_month) + except Exception as e: + db.rollback() + logger.error("pull_monthly_stats error: %s", e) + finally: + db.close() + + +@celery_app.task(name="reports.send_monthly_reports") +def send_monthly_reports(): + """Send monthly report emails to all active schools on the 1st at 7am.""" + from sqlalchemy import select, func + from app.models.school import School, SchoolStatus + from app.models.sms import SmsJob, SmsJobStatus + from app.models.billing import Invoice + from app.models.report import MonthlyReport, SchoolMonthlyStats + from app.services.email import send_email + + db = _make_session() + try: + today = date.today() + prev_end = date(today.year, today.month, 1) - timedelta(days=1) + prev_start = date(prev_end.year, prev_end.month, 1) + report_month = f"{prev_end.year}-{prev_end.month:02d}" + month_label = prev_start.strftime("%B %Y") + + schools = db.execute(select(School).where(School.status == SchoolStatus.active)).scalars().all() + sent = 0 + for school in schools: if not school.billing_email: continue - send_email( - to=school.billing_email, - subject=f"Monthly Report — {school.name} — {prev_month_start.strftime('%B %Y')}", - body=f"Dear {school.contact_name or school.name},\n\nPlease find your monthly summary for {prev_month_start.strftime('%B %Y')} in your TapTrack Hub portal.\n\nSMS Credits Remaining: {float(school.sms_credits):.0f}\n\nLog in to view full details.\n\nThank you,\nTapTrack Hub Team", + + sms_sent_count = db.execute( + select(func.count()).where( + SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.sent, + func.date(SmsJob.sent_at) >= prev_start, func.date(SmsJob.sent_at) <= prev_end, + ) + ).scalar_one() + sms_failed_count = db.execute( + select(func.count()).where( + SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.failed, + func.date(SmsJob.created_at) >= prev_start, func.date(SmsJob.created_at) <= prev_end, + ) + ).scalar_one() + + inv = db.execute( + select(Invoice).where(Invoice.school_id == school.id, Invoice.billing_period_start == prev_start) + ).scalar_one_or_none() + + att = db.execute( + select(SchoolMonthlyStats).where( + SchoolMonthlyStats.school_id == school.id, + SchoolMonthlyStats.report_month == report_month, + ) + ).scalar_one_or_none() + + report_data = { + "month": month_label, "sms_sent": sms_sent_count, "sms_failed": sms_failed_count, + "credits_remaining": float(school.sms_credits), + "attendance": { + "available": att is not None and att.pull_status == "success", + "total_students": att.total_students if att else None, + "school_days": att.school_days if att else None, + "avg_attendance_rate": float(att.avg_attendance_rate) if att and att.avg_attendance_rate else None, + }, + "invoice": {"number": inv.invoice_number, "total": float(inv.total_amount), "status": inv.status.value} if inv else None, + } + + existing_report = db.execute( + select(MonthlyReport).where( + MonthlyReport.school_id == school.id, MonthlyReport.report_month == report_month, + ) + ).scalar_one_or_none() + + if not existing_report: + report = MonthlyReport(school_id=school.id, report_month=report_month, report_data=report_data) + db.add(report) + db.flush() + else: + report = existing_report + report.report_data = report_data + + att_section = "" + if report_data["attendance"]["available"]: + att_section = (f"\nAttendance Rate: {report_data['attendance']['avg_attendance_rate']:.1f}%" + f" | School Days: {report_data['attendance']['school_days']}" + f" | Students: {report_data['attendance']['total_students']}") + + plain = ( + f"Dear {school.contact_name or school.name},\n\n" + f"Monthly summary for {month_label}:\n\n" + f"SMS Sent: {sms_sent_count} | Failed: {sms_failed_count}\n" + f"SMS Credits Remaining: {float(school.sms_credits):.0f}\n" + f"{att_section}\n\n" + f"Log in to view full details.\n\nTapTrack Hub Team" ) - logger.info(f"Sent monthly reports to {len(schools)} schools") + + ok = send_email( + to=school.billing_email, + subject=f"Monthly Report — {school.name} — {month_label}", + body=plain, + email_type="monthly_report", + school_id=school.id, + ) + if ok: + report.email_sent_at = datetime.now(timezone.utc) + sent += 1 + + db.commit() + logger.info("send_monthly_reports: %d/%d sent", sent, len(schools)) + except Exception as e: + db.rollback() + logger.error("send_monthly_reports error: %s", e) finally: db.close() diff --git a/backend/app/tasks/tickets.py b/backend/app/tasks/tickets.py new file mode 100644 index 0000000..af1c5e8 --- /dev/null +++ b/backend/app/tasks/tickets.py @@ -0,0 +1,70 @@ +"""Celery tasks: ticket priority escalation.""" +import logging +from datetime import datetime, timezone, timedelta + +from app.worker import celery_app + +logger = logging.getLogger(__name__) + + +def _make_session(): + import os + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub") + engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True) + return sessionmaker(bind=engine)() + + +@celery_app.task(name="tickets.escalate_stale") +def escalate_stale(): + """ + Escalate stale open tickets: + - open > 48h + priority medium → high + - open > 72h + no reply yet → urgent + """ + from sqlalchemy import select, and_ + from app.models.ticket import SupportTicket, TicketReply, TicketStatus, TicketPriority + + db = _make_session() + try: + now = datetime.now(timezone.utc) + threshold_48h = now - timedelta(hours=48) + threshold_72h = now - timedelta(hours=72) + + # Open tickets > 48h, medium priority → high + stale_medium = db.execute( + select(SupportTicket).where( + and_( + SupportTicket.status == TicketStatus.open, + SupportTicket.priority == TicketPriority.medium, + SupportTicket.created_at < threshold_48h, + ) + ) + ).scalars().all() + for t in stale_medium: + t.priority = TicketPriority.high + logger.info("Escalated ticket %s to high priority (48h)", t.ticket_number) + + # Open tickets > 72h, no reply → urgent + stale_72h = db.execute( + select(SupportTicket).where( + and_( + SupportTicket.status == TicketStatus.open, + SupportTicket.first_response_at == None, # noqa: E711 + SupportTicket.created_at < threshold_72h, + ) + ) + ).scalars().all() + for t in stale_72h: + if t.priority != TicketPriority.urgent: + t.priority = TicketPriority.urgent + logger.warning("Escalated ticket %s to urgent (72h no response)", t.ticket_number) + + db.commit() + logger.info("escalate_stale: %d→high, %d→urgent", len(stale_medium), len(stale_72h)) + except Exception as e: + db.rollback() + logger.error("escalate_stale error: %s", e) + finally: + db.close() diff --git a/backend/app/templates/email/ticket_notification.html b/backend/app/templates/email/ticket_notification.html new file mode 100644 index 0000000..39a78ad --- /dev/null +++ b/backend/app/templates/email/ticket_notification.html @@ -0,0 +1,22 @@ +{% extends "email/base.html" %} +{% block content %} +

{{ email_title }}

+

Dear {{ recipient_name }},

+ +
+
Ticket{{ ticket_number }}
+
Subject{{ subject }}
+
Category{{ category }}
+
Priority{{ priority }}
+
Status{{ status }}
+
+ +
+

{{ message_label }}

+

{{ message_body }}

+
+ +

View Ticket

+ +

Thank you,
TapTrack Hub Team

+{% endblock %} diff --git a/backend/app/worker.py b/backend/app/worker.py index 48a102c..de92509 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -14,6 +14,8 @@ celery_app = Celery( "app.tasks.billing", "app.tasks.reports", "app.tasks.license", + "app.tasks.tickets", + "app.tasks.onboarding", ], ) @@ -44,6 +46,11 @@ celery_app.conf.update( "task": "billing.generate_monthly_invoices", "schedule": crontab(day_of_month=1, hour=6, minute=0), }, + # Pull on-prem stats on the 1st at 5am + "pull-monthly-stats": { + "task": "reports.pull_monthly_stats", + "schedule": crontab(day_of_month=1, hour=5, minute=0), + }, # Send monthly reports on the 1st at 7am "send-monthly-reports": { "task": "reports.send_monthly_reports", @@ -54,6 +61,11 @@ celery_app.conf.update( "task": "billing.check_overdue", "schedule": crontab(hour=9, minute=0), }, + # Escalate stale tickets every hour + "escalate-stale-tickets": { + "task": "tickets.escalate_stale", + "schedule": crontab(minute=0), + }, }, ) diff --git a/backend/migrations/env.py b/backend/migrations/env.py index ce02797..e352c23 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 # noqa +from app.models import user, school, license, sms, billing, ticket, audit, announcement, email_log, report # noqa target_metadata = Base.metadata DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub") diff --git a/backend/migrations/versions/003_phases11_15.py b/backend/migrations/versions/003_phases11_15.py new file mode 100644 index 0000000..8e71648 --- /dev/null +++ b/backend/migrations/versions/003_phases11_15.py @@ -0,0 +1,59 @@ +"""phases 11-15: monthly_reports, school_monthly_stats, school new columns + +Revision ID: 003_phases11_15 +Revises: 002_phase9 +Create Date: 2026-03-16 +""" +from alembic import op +import sqlalchemy as sa + +revision = "003_phases11_15" +down_revision = "002_phase9" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Schools: new columns + op.add_column("schools", sa.Column("hub_base_url", sa.String(500), nullable=True)) + op.add_column("schools", sa.Column("feature_overrides", sa.Text, nullable=True)) + op.add_column("schools", sa.Column("onboarding_completed_at", sa.DateTime(timezone=True), nullable=True)) + + # monthly_reports table + op.create_table( + "monthly_reports", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("school_id", sa.String(36), sa.ForeignKey("schools.id", ondelete="CASCADE"), nullable=False), + sa.Column("report_month", sa.String(7), nullable=False), + sa.Column("email_sent_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("report_data", sa.JSON, nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_monthly_reports_school_id", "monthly_reports", ["school_id"]) + + # school_monthly_stats table + op.create_table( + "school_monthly_stats", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("school_id", sa.String(36), sa.ForeignKey("schools.id", ondelete="CASCADE"), nullable=False), + sa.Column("report_month", sa.String(7), nullable=False), + sa.Column("total_students", sa.Integer, nullable=True), + sa.Column("school_days", sa.Integer, nullable=True), + sa.Column("present_days_total", sa.Integer, nullable=True), + sa.Column("absent_days_total", sa.Integer, nullable=True), + sa.Column("late_days_total", sa.Integer, nullable=True), + sa.Column("avg_attendance_rate", sa.Numeric(5, 2), nullable=True), + sa.Column("sms_sent", sa.Integer, nullable=True), + sa.Column("pull_status", sa.String(20), nullable=False, server_default="unavailable"), + sa.Column("pulled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("hub_base_url", sa.String(500), nullable=True), + ) + op.create_index("ix_school_monthly_stats_school_id", "school_monthly_stats", ["school_id"]) + + +def downgrade() -> None: + op.drop_table("school_monthly_stats") + op.drop_table("monthly_reports") + op.drop_column("schools", "onboarding_completed_at") + op.drop_column("schools", "feature_overrides") + op.drop_column("schools", "hub_base_url") diff --git a/docker-compose.yml b/docker-compose.yml index bacbb28..249d043 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,19 @@ version: "3.9" +x-backend-env: &backend-env + DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub + REDIS_URL: redis://redis:6379/0 + SECRET_KEY: changeme-in-production-use-openssl-rand-hex-32 + ENVIRONMENT: development + SEMAPHORE_API_KEY: "" + SMTP_HOST: "" + SMTP_PORT: "587" + SMTP_USER: "" + SMTP_PASSWORD: "" + SMTP_FROM: "noreply@taptrack.io" + HUB_BASE_URL: "http://localhost:8090" + PDF_DIR: "/app/data/invoices" + services: db: image: postgres:15-alpine @@ -26,17 +40,7 @@ services: build: ./backend restart: unless-stopped environment: - DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub - REDIS_URL: redis://redis:6379/0 - SECRET_KEY: changeme-in-production-use-openssl-rand-hex-32 - ENVIRONMENT: development - SEMAPHORE_API_KEY: "" - SMTP_HOST: "" - SMTP_PORT: "587" - SMTP_USER: "" - SMTP_PASSWORD: "" - SMTP_FROM: "noreply@taptrack.io" - HUB_BASE_URL: "http://localhost:8090" + <<: *backend-env volumes: - ./backend:/app - backend_data:/app/data @@ -46,22 +50,25 @@ services: redis: condition: service_started + # One-shot seed service — runs seed.py then exits + seed: + build: ./backend + command: python seed.py + environment: + <<: *backend-env + volumes: + - ./backend:/app + depends_on: + db: + condition: service_healthy + restart: "no" + celery: build: ./backend command: celery -A app.worker worker --loglevel=info --concurrency=2 restart: unless-stopped environment: - DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub - REDIS_URL: redis://redis:6379/0 - SECRET_KEY: changeme-in-production-use-openssl-rand-hex-32 - ENVIRONMENT: development - SEMAPHORE_API_KEY: "" - SMTP_HOST: "" - SMTP_PORT: "587" - SMTP_USER: "" - SMTP_PASSWORD: "" - SMTP_FROM: "noreply@taptrack.io" - HUB_BASE_URL: "http://localhost:8090" + <<: *backend-env volumes: - ./backend:/app - backend_data:/app/data @@ -74,17 +81,7 @@ services: command: celery -A app.worker beat --loglevel=info restart: unless-stopped environment: - DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub - REDIS_URL: redis://redis:6379/0 - SECRET_KEY: changeme-in-production-use-openssl-rand-hex-32 - ENVIRONMENT: development - SEMAPHORE_API_KEY: "" - SMTP_HOST: "" - SMTP_PORT: "587" - SMTP_USER: "" - SMTP_PASSWORD: "" - SMTP_FROM: "noreply@taptrack.io" - HUB_BASE_URL: "http://localhost:8090" + <<: *backend-env volumes: - ./backend:/app - backend_data:/app/data diff --git a/frontend/src/components/sidebar/AppSidebar.vue b/frontend/src/components/sidebar/AppSidebar.vue index 0c3b7d4..cf58a39 100644 --- a/frontend/src/components/sidebar/AppSidebar.vue +++ b/frontend/src/components/sidebar/AppSidebar.vue @@ -21,7 +21,7 @@ diff --git a/frontend/src/layouts/AppLayout.vue b/frontend/src/layouts/AppLayout.vue index 22f1fd4..89bd101 100644 --- a/frontend/src/layouts/AppLayout.vue +++ b/frontend/src/layouts/AppLayout.vue @@ -3,13 +3,47 @@
-
-
+
+
TapTrack Hub · {{ pageTitle }}
-
+ +
+ + + +
+ +

No results

+
+
+
{{ authStore.fullName }}