WhatsApp demo path (relay #748): - WHATSAPP_DEMO_TO config under the WhatsApp section (env-based, .env-only; .env.example keeps an empty placeholder; real numbers never enter source). - build_demo_webhook_payload() in app/routers/whatsapp.py builds the Meta demo payload from it (fails closed when unset), so the webhook round trip logs from_number = demo number (surfaces in GET /api/whatsapp/mock-log) and the auto-reply targets the same number. - tests/test_whatsapp_demo_number.py: default empty + never committed in tracked files, payload builder from/to, 200/403/401 gates unchanged. Branding (logo-assets-v1, sha256-verified, same-origin app/static/branding): - Login header uses h96 full lockup; logged-in topbar (base.html) uses h48 on a light chip (logo ink is ~2:1 vs the dark nav); favicons 32x32 + 16x16 in <head>. img-src 'self' data: blob: already allows /static/branding/*. - tests/test_branding_assets.py: page placement + same-origin serving + CSP. - AGENTS.md synced.
13 KiB
Denya OneCare — Agent memory
Denya OneCare is a centralized maintenance/issue tracking system for Pavilion Accra (FastAPI + SQLite + Alpine.js + Twilio).
Quick start
# 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 migrationsalembic revision --autogenerate -m "msg"— new migrationpytest— 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.jsonif 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).
| 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 | /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 inapp/main.py, versioned filenames → immutable cachepublic, max-age=31536000, immutable). Templates must never reference a CDN; updateapp/templates/base.htmlwhen upgrading: downloadalpinejs@<ver>/dist/cdn.min.js(jsDelivr) and the tailwind play script (cdn.tailwindcss.com/<ver>), save them underapp/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>+ theVENDORED_SCRIPTStuple in the regression filetests/test_frontend_vendoring.py. - HTML pages ship
Cache-Control: no-cacheand 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 everyx-*expression withnew Function(), and without it CSP blocks Alpine entirely (stuck loading overlay on every page) — covered bytest_csp_script_src_allows_unsafe_eval_for_alpineintests/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_ALIASESfor legacy nickname roles liketechnician/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/superadminfrom the old open register) fail closed at login/JWT and can never be recreated via the API (422). Startup self-heals (lifespan inapp/main.py, helpers inapp/services/seed.py) converge legacy rows:normalize_legacy_user_rolesmaps 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), andnormalize_legacy_user_emailslowercases 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;subclaim holds string user ID - Security headers middleware in
app/main.py: X-Frame-Options DENY + nosniff on everything, CSP on HTML pages, HSTS whenX-Forwarded-Proto: https(CSP allows no external host — frontend libs are vendored, see "Frontend assets";script-srccarries'unsafe-eval'for the Alpine runtime)
Ticket System (Sprint 2)
reported_at(nullable DateTime, alembicd5e0f2a1c3b4) records a ticket's original reported date;TicketCreate.reported_atlets Admin/Wahab enter backdated tickets that stay active. It defaults to now when omitted (migration backfilled existing rows fromcreated_at). SLA deadlines run fromcreated_at, notreported_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 Leakis hidden from the issue picker but keepssla_urgency="urgent"for SLA/alert/reporting. Pickers (/api/tickets/categories[/flat]) exclude them unlessinclude_hidden=true(used by the emergency quick path ontickets/new.html). Seed taxonomy lives inapp/services/seed.py(SEED_CATEGORIES_DATA); the Lost Property → Missing Item rename is a data migration (alembicb2f4a6c8e0d2), 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 syncshow_in_formon existing rows.- Location hierarchy is Property → Building → Apartment (uses
Unit.building).GET /api/tickets/units/groupedreturns{property: {building: [units]}};/api/ticketsaccepts additivebuilding/unit_idfilters. Unit data is deterministic from the committedapartment_mapping.json(built-in fallback inseed.py). - Priority grouping ("Group by priority") is client-side via
app().groupByPriority()inapp/templates/base.html; used bytickets/list.htmlanddashboard/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.