193 lines
11 KiB
Markdown
193 lines
11 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) |
|
||
|
||
### 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 | `/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`.
|
||
|
||
### 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 is self-only
|
||
(`script-src`/`style-src 'self' 'unsafe-inline'`, `connect-src 'self'`); no
|
||
CDN host is allowed in CSP (`app/main.py::SecurityHeadersMiddleware`).
|
||
|
||
### 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, 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 is self-only — frontend libs are vendored, see "Frontend assets")
|
||
|
||
## 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.
|