# Denya OneCare — Agent memory Denya OneCare is a centralized maintenance/issue tracking system for Pavilion Accra (FastAPI + SQLite + Alpine.js + Twilio). ## Quick start ```bash # Start the app docker-compose up # Or directly uvicorn app.main:app --reload ``` ## Project structure ``` app/ ├── core/ # config, database, security (JWT + bcrypt + RBAC) ├── models/ # SQLAlchemy ORM models ├── schemas/ # Pydantic request/response schemas ├── services/ # Business logic (auth, seed, ticket, sla) └── routers/ # FastAPI route handlers alembic/ # Database migrations tests/ # pytest suite; conftest.py swaps DATABASE_URL to a temp SQLite uploads/ # Photo uploads (created at runtime) ``` ## Commands - `alembic upgrade head` — apply migrations - `alembic revision --autogenerate -m "msg"` — new migration - `pytest` — run the API test suite (tests/; pagination contract anchored in tests/test_tickets_pagination.py) ## Seed data Users, units, and categories are auto-seeded on first startup via lifespan hook: - 17 users covering all roles (Admin/Jerome, Admin/Wahab, CS Rep, CS Manager, FM Dispatcher, Tech, CEO, Director) - 120 apartment units (East/West, 10 floors × 6 apts per wing) - Default password for all seed users: `denya123` - Units load from `apartment_mapping.json` if present, else built-in fallback - Categories: 23 top-level (13 Maintenance, 6 CS, 4 Emergency) with sub-categories, seeded from `app/services/seed.py::SEED_CATEGORIES_DATA` ## Key API endpoints ### Auth & Health | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/health` | No | Health check | | POST | `/api/auth/login` | No | Get JWT tokens (rate-limited ~5 fails/15min/IP+email → 429) | | POST | `/api/auth/refresh` | Token | Refresh tokens | | GET | `/api/auth/me` | Bearer | Current user | | GET | `/api/auth/admin-only` | Admin/Jerome, Admin/Wahab | RBAC demo endpoint | | GET | `/api/auth/users` | Bearer | List users (id, name, role) for the assign-technician picker | | POST | `/api/auth/users` | Admin/Jerome, Admin/Wahab | Admin creates a user (forced canonical role; unknown roles → 422) | | PATCH | `/api/auth/users/{id}` | Admin/Jerome, Admin/Wahab | Role change / deactivate (self-modification → 400) | | DELETE | `/api/auth/users/{id}` | Admin/Jerome, Admin/Wahab | Delete user (409 if referenced by tickets/timeline/escalations) | **Self-registration is removed** — `POST /api/auth/register` 404s and there is no sign-up UI; users are created/managed by admins only (P0 hardening batch). ### WhatsApp | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/api/whatsapp/webhook` | No | Meta handshake (`hub.verify_token`, constant-time; mismatch → 403) | | POST | `/api/whatsapp/webhook` | `X-Webhook-Secret` header | Inbound message → ticket + log. Fail-closed: 403 when `WHATSAPP_WEBHOOK_SECRET` is unset or the header doesn't match | | GET | `/api/whatsapp/mock-log` | Bearer | Recent webhook submissions (debug; auth required) | `whatsapp_log` predates the real Meta webhook (the model gained `message_text`/`wa_message_id`/`ticket_number` and dropped `command` without a migration), so legacy tables keep the old `(command, …)` shape and every ORM read/write 500s. `ensure_legacy_schema` (app/main.py) adds the missing columns and backfills+drops the obsolete NOT NULL `command` column idempotently at startup — do not hand-edit legacy DBs, ship a self-heal there instead. Demo WhatsApp round trip: `WHATSAPP_DEMO_TO` (E.164, .env-only — never commit a real number; `.env.example` keeps an empty placeholder) is the expected sender/recipient for the demo path. `app/routers/whatsapp.py::build_demo_webhook_payload` builds a Meta webhook payload from it (fails closed when unset), so posting it to `POST /api/whatsapp/webhook` with the secret logs `from_number` = demo number (visible via `GET /api/whatsapp/mock-log`) and the auto-reply targets the same number. Covered by `tests/test_whatsapp_demo_number.py`. ### Pages (Sprint 3) — Jinja2 templates at `app/templates/` | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/login` | No | Login page | | GET | `/dashboard/cs` | Client | CS dashboard | | GET | `/dashboard/fm` | Client | FM dashboard | | GET | `/dashboard/ceo` | Client | CEO dashboard | | GET | `/dashboard/tech-performance` | Client | Technician performance report (FM + CEO nav) | | GET | `/tickets` | Client | All Issues filterable table | | GET | `/tickets/new` | Client | Create Issue form | | GET | `/tickets/{id}` | Client | Issue detail with timeline | Frontend: Alpine.js + Tailwind CSS vendored same-origin (no CDN) — see "Frontend assets" below. Auth state in localStorage. Role-based nav routing in `base.html`. ### Technician performance (Wahab request) `GET /api/tickets/tech-performance` (Bearer) aggregates per-technician workload/outcomes by **real name** using only existing ticket columns (`assigned_to`/`status`/`created_at`/`closed_at` → `users.full_name`) — no schema change. Aggregation lives in `app/services/ticket.py::get_technician_performance`; bucket map `_TECH_STATUS_BUCKETS` defines `completed` (Completed/Closed), `in_progress`, `escalated`, `cancelled`, and `pending` (remainder), so the buckets always sum to `total_assigned`. `completion_rate = completed/total_assigned`; `avg_resolution_hours` averages `created_at → closed_at` only where `closed_at` is set, with `resolved_without_timestamps` counting finished tasks that lack one. Rows key on user id (same-name techs stay separate), sorted completed desc → workload → name. UI: `app/templates/dashboard/tech-performance.html`, linked from FM + CEO nav and the FM "Technician Workload" card — that card reads `Ticket.assigned_technician_name`, never an id placeholder. Regression: `tests/test_tech_performance.py`. ### Frontend assets (vendored, LAN-safe) - Alpine.js 3.17.2 + Tailwind Play 3.4.17 are committed under `app/static/vendor/` and served at `/static/vendor/…` (mounted in `app/main.py`, versioned filenames → immutable cache `public, max-age=31536000, immutable`). Templates must never reference a CDN; update `app/templates/base.html` when upgrading: download `alpinejs@/dist/cdn.min.js` (jsDelivr) and the tailwind play script (`cdn.tailwindcss.com/`), save them under `app/static/vendor/` mirroring the committed names (Alpine keeps `.min.js`, e.g. `alpine-3.17.2.min.js`; the tailwind play file does not, e.g. `tailwind-3.4.17.js`), then bump the `