Wahab customer request (relay #748 v3 next-wave). 3a. Fix 'Tech #N' display: - FM dashboard's "Technician Workload" card was built from `Tech #${t.assigned_to}`; it now reads Ticket.assigned_technician_name (falling back to 'Unassigned', matching /tickets). - Template sweep confirms no other `Tech #` placeholder exists. 3b. Technician performance dashboard: - New bearer-gated GET /api/tickets/tech-performance aggregating existing ticket columns only (no schema change): per-technician total assigned, completed, in progress, escalated, cancelled, pending, open tasks, completion rate, and created_at -> closed_at resolution time with explicit counts for finished tasks lacking a timestamp. Rows key on user id so same-name technicians stay separate; labels are real names. Fleet totals and an unassigned-ticket count are included. - New /dashboard/tech-performance page (FM + CEO nav and links) with sortable table, completion bars and empty states; same-origin vendored assets only. - Bucket map, service, schemas and tests in app/services/ticket.py, app/schemas/ticket.py, tests/test_tech_performance.py. Tests: 111 passed (101 existing + 10 new).
244 lines
14 KiB
Markdown
244 lines
14 KiB
Markdown
# 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@<ver>/dist/cdn.min.js` (jsDelivr) and the tailwind play
|
||
script (`cdn.tailwindcss.com/<ver>`), 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 `<script src>` + the
|
||
`VENDORED_SCRIPTS` tuple in the regression file `tests/test_frontend_vendoring.py`.
|
||
- HTML pages ship `Cache-Control: no-cache` and CSP allows no external host
|
||
(`script-src 'self' 'unsafe-inline' 'unsafe-eval'`, `style-src 'self'
|
||
'unsafe-inline'`, `connect-src 'self'`); no CDN host is allowed in CSP
|
||
(`app/main.py::SecurityHeadersMiddleware`). `'unsafe-eval'` is required by
|
||
the Alpine 3.17.2 CDN build: its evaluator compiles every `x-*` expression
|
||
with `new Function()`, and without it CSP blocks Alpine entirely (stuck
|
||
loading overlay on every page) — covered by
|
||
`test_csp_script_src_allows_unsafe_eval_for_alpine` in
|
||
`tests/test_frontend_vendoring.py`.
|
||
|
||
### Branding (logo)
|
||
Official Denya Developers logo derivatives are committed under
|
||
`app/static/branding/` (Gitea release `logo-assets-v1`, sha256-verified
|
||
monochrome forest-green lockup; sourced from the Gitea release, never from
|
||
kagentz). Placement: login header uses `denya-logo-h96.png` (full lockup); the
|
||
logged-in topbar (base.html nav) uses `denya-logo-h48.png` on a light chip
|
||
(logo ink is only ~2:1 against the dark `#0d2b18` nav — keep a light chip
|
||
there); favicons 32x32+16x16 declared in `base.html <head>`.
|
||
`img-src 'self' data: blob:` already covers `/static/branding/*` — no CSP
|
||
change. Regression coverage: `tests/test_branding_assets.py`.
|
||
|
||
### Tickets (Sprint 2)
|
||
| Method | Path | Auth | Description |
|
||
|--------|------|------|-------------|
|
||
| POST | `/api/tickets` | Bearer | Create ticket (auto-number PAV-YYYY-NNNNN) |
|
||
| GET | `/api/tickets` | No | List tickets (filter: status, priority, property, building, unit_id, category_id, assigned_to, date_from, date_to; paginate with `page`/`page_size` or `limit` alias — hard cap 200, both given → 422) |
|
||
| GET | `/api/tickets/{id}` | No | Get ticket detail with timeline, photos, SLA status, nested unit/category, phone |
|
||
| GET | `/api/tickets/{id}/transitions` | No | Valid next statuses for the ticket's current status (drives the detail-page status picker) |
|
||
| PATCH | `/api/tickets/{id}` | Bearer | Update ticket (validates status transitions; assigning a technician auto-advances New/Logged/Triage to Assigned) |
|
||
| DELETE | `/api/tickets/{id}` | Admin/Jerome, Admin/Wahab | Delete ticket + children (timeline/photos/escalations); test/scratch cleanup only |
|
||
| POST | `/api/tickets/{id}/status` | Bearer | Change status with note |
|
||
| GET | `/api/tickets/{id}/sla` | No | Check SLA breach status |
|
||
| POST | `/api/tickets/{id}/photos` | Bearer | Upload photos (multipart, is_before param) |
|
||
| GET | `/api/tickets/{id}/photos` | No | List photos |
|
||
| GET | `/api/tickets/categories` | No | Category tree (filters: `type`; alert-only hidden unless `include_hidden=true`) |
|
||
| GET | `/api/tickets/categories/flat` | No | Flat category list (same `type`/`include_hidden` filters) |
|
||
|
||
## Auth
|
||
|
||
- JWT access (30min) + refresh (7d) tokens
|
||
- **Unified role model** lives in `app/core/roles.py` (`CANONICAL_ROLES`,
|
||
`ADMIN_ROLES` = Admin/Jerome + Admin/Wahab, `ROLE_ALIASES` for legacy
|
||
nickname roles like `technician`/`cs`/`fm`/`ceo`). Canonical stored roles:
|
||
CS Rep, CS Manager, FM Dispatcher, Admin/Jerome, Admin/Wahab, Tech, CEO, Director.
|
||
- Role checks, admin user creation, login, and JWT validation all derive from the
|
||
role module; unknown/junk roles (e.g. lowercase `admin`/`superadmin` from the
|
||
old open register) fail closed at login/JWT and can never be recreated via the
|
||
API (422). Startup self-heals (lifespan in `app/main.py`, helpers in
|
||
`app/services/seed.py`) converge legacy rows: `normalize_legacy_user_roles`
|
||
maps unambiguous alias nicknames onto canonical roles (space and underscore
|
||
forms alike — `cs rep`/`cs_rep` → `CS Rep`, `fm dispatcher`/`fm_dispatcher`,
|
||
`cs manager`/`cs_manager`), and
|
||
`normalize_legacy_user_emails` lowercases stored emails — login and the admin
|
||
create-user duplicate check both compare on the lowercased form, so pre-P0
|
||
mixed-case emails are never silently locked out.
|
||
- Use `require_roles(*ADMIN_ROLES)` for admin gates; `sub` claim holds string user ID
|
||
- Security headers middleware in `app/main.py`: X-Frame-Options DENY +
|
||
nosniff on everything, CSP on HTML pages, HSTS when `X-Forwarded-Proto: https`
|
||
(CSP allows no external host — frontend libs are vendored, see "Frontend
|
||
assets"; `script-src` carries `'unsafe-eval'` for the Alpine runtime)
|
||
|
||
## Ticket System (Sprint 2)
|
||
|
||
- `reported_at` (nullable DateTime, alembic `d5e0f2a1c3b4`) records a ticket's original reported date;
|
||
`TicketCreate.reported_at` lets Admin/Wahab enter backdated tickets that stay active. It defaults to
|
||
now when omitted (migration backfilled existing rows from `created_at`). SLA deadlines run from
|
||
`created_at`, not `reported_at` — backfilling history never instantly breaches a ticket.
|
||
|
||
### Status Lifecycle (16 statuses)
|
||
New → Logged → Triage → Assigned → Accepted → Travelling → On Site → In Progress → Waiting Parts → Escalated → Completed → On-Field Verification → Wahab Review → Closed → Reopened → Cancelled
|
||
|
||
Cancelled is terminal (no outgoing transitions) and reachable from any active
|
||
state; it is excluded from SLA breach reporting and dashboard "active" counts.
|
||
|
||
Valid transitions defined in `app/services/ticket.py::VALID_TRANSITIONS`. Invalid transitions return 400.
|
||
|
||
### SLA Engine
|
||
Defined in `app/services/sla.py`. Priority-based targets:
|
||
- Urgent: respond 15min, resolve 4h
|
||
- High: respond 30min, resolve 24h
|
||
- Medium: respond 4h, resolve 72h (3d)
|
||
- Low: respond 24h, resolve 168h (7d)
|
||
|
||
`sla_deadline` auto-calculated on ticket creation. SLA status check at `GET /api/tickets/{id}/sla`.
|
||
|
||
### Ticket Number Format
|
||
`PAV-YYYY-NNNNN` — sequential per year (e.g., PAV-2026-00001).
|
||
|
||
### Photo Uploads
|
||
Stored under `uploads/` with UUID filenames. Static-files mounted at `/uploads/`. Multipart POST with `is_before` query param.
|
||
|
||
## Database
|
||
|
||
SQLite via aiosqlite with async SQLAlchemy 2.0. Alembic for migrations.
|
||
Tables: users, units, categories, tickets, ticket_timeline, ticket_photos, escalations, whatsapp_log
|
||
|
||
## Categories & location (Sprint A)
|
||
|
||
- `Category.show_in_form` (default True) marks alert-only categories: `Gas Leak` is hidden
|
||
from the issue picker but keeps `sla_urgency="urgent"` for SLA/alert/reporting. Pickers
|
||
(`/api/tickets/categories[/flat]`) exclude them unless `include_hidden=true` (used by the
|
||
emergency quick path on `tickets/new.html`). Seed taxonomy lives in `app/services/seed.py`
|
||
(`SEED_CATEGORIES_DATA`); the Lost Property → Missing Item rename is a data migration
|
||
(alembic `b2f4a6c8e0d2`), with a startup self-heal (`app/main.py::ensure_legacy_schema`)
|
||
applying the same column/rename fix to legacy create_all databases. Seeds are idempotent
|
||
and sync `show_in_form` on existing rows.
|
||
- Location hierarchy is Property → Building → Apartment (uses `Unit.building`).
|
||
`GET /api/tickets/units/grouped` returns `{property: {building: [units]}}`;
|
||
`/api/tickets` accepts additive `building`/`unit_id` filters. Unit data is deterministic
|
||
from the committed `apartment_mapping.json` (built-in fallback in `seed.py`).
|
||
- Priority grouping ("Group by priority") is client-side via `app().groupByPriority()` in
|
||
`app/templates/base.html`; used by `tickets/list.html` and `dashboard/fm.html`.
|
||
|
||
## Maintaining this file
|
||
|
||
Keep this file for knowledge useful to almost every future agent session in this project.
|
||
Do not repeat what the codebase already shows; point to the authoritative file or command instead.
|
||
Prefer rewriting or pruning existing entries over appending new ones.
|
||
When updating this file, preserve this bar for all agents and keep entries concise.
|