Compare commits

...
Author SHA1 Message Date
root 49b26926cf no-mistakes(document): Align vendor asset upgrade naming with committed files 2026-09-09 01:44:56 +00:00
fm crewmate 05d768343c fix(frontend): vendor Alpine.js + Tailwind same-origin (LAN-safe demo)
The P0 templates loaded Alpine.js from cdn.jsdelivr.net and Tailwind from
cdn.tailwindcss.com, so LAN-only demo clients got a login page whose JS never
engaged (stuck form). Vendor both libraries under app/static/vendor/
(alpine-3.17.2.min.js, tailwind-3.4.17.js) served same-origin at /static,
point base.html at local paths, and drop both CDN hosts from the CSP
(script-src/style-src stay 'self' 'unsafe-inline'; connect-src 'self').

HTML pages now ship Cache-Control: no-cache; vendored assets are cached
public, max-age=31536000, immutable (versioned filenames). HSTS stays
TLS-gated. Adds tests/test_frontend_vendoring.py (no external script src on
/login, both vendor paths 200, no-cache + immutable header checks, CSP
without CDN hosts).
2026-09-09 01:01:47 +00:00
abiba-bot 371826c15e Merge pull request #11: Denya OneCare P0 security lockdown (captain-approved) 2026-09-08 17:38:21 +00:00
root e627f50f66 no-mistakes(document): Document P0 auth batch; reconcile HARDENING statuses; lint fixes 2026-09-08 12:48:47 +00:00
root f1b68dd1b7 no-mistakes(review): Normalize legacy emails to prevent case-based login lockout 2026-09-08 12:33:01 +00:00
root 3ae1062d65 P0 security batch: admin-only user mgmt, unified role model, login rate limiting, webhook secret, security headers, pagination caps
- Remove POST /api/auth/register (404); no sign-up UI; users are admin-managed
- Add admin-only POST/PATCH/DELETE /api/auth/users (forced canonical roles,
  self-lockout + reference guards)
- Unify role model in app/core/roles.py; reject unknown roles at creation and
  at login/JWT validation; startup normalizes unambiguous legacy aliases
- Login rate limiting ~5 fails/15 min per IP+email -> 429 (in-process, tunable)
- WhatsApp webhook requires X-Webhook-Secret; fail-closed when env unset;
  GET handshake uses constant-time verify token (403 on mismatch)
- GET /api/whatsapp/mock-log now requires auth
- Security headers middleware: X-Frame-Options DENY, nosniff, CSP on HTML,
  HSTS behind TLS
- Pagination: limit alias for page_size, hard cap enforced, both -> 422
2026-09-08 10:36:16 +00:00
mumuni-bot 901f95e0f6 Merge pull request 'fix: P0 hardening — fail-closed SECRET_KEY, locked CORS, role-safe registration (HARDENING.md P0.1/P0.2/P0.3)' (#10) from fix/p0-hardening-prA into main 2026-09-03 00:10:55 +00:00
Mumuni (Syslog Code Agent) 3ec09470ff fix: P0 hardening — fail-closed SECRET_KEY, locked CORS, role-safe registration (HARDENING.md P0.1/P0.2/P0.3)
P0.1 — fail-closed secrets:
- config.py: no default SECRET_KEY; refuses to boot when unset, a known
  placeholder, or <32 chars. Generate with: openssl rand -hex 32.
- docker-compose.yml: literal secrets removed; runtime env now comes from
  a git-ignored .env via env_file. .env.example added as template.
- .gitignore already covers .env (verified).

P0.2 — locked CORS:
- main.py: CORS_ORIGINS must be an explicit comma-separated allow-list.
  '*' or an empty value refuses to boot (was: silently ['*'] with
  allow_credentials=True).

P0.3 — role-safe registration:
- services/auth.py: client-supplied 'role' is IGNORED on POST
  /api/auth/register; self-registered users always get the
  least-privilege 'CS Rep' role. Unauthenticated callers can no longer
  mint Admin/Jerome, Admin/Wahab, or Director accounts.

Tests:
- conftest.py sets test SECRET_KEY/CORS_ORIGINS before app import.
- New tests/test_p0_hardening.py (8 tests): role-escalation blocked for
  Admin/Jerome and Admin/Wahab, duplicate-email 409, and subprocess
  boot-validation for placeholder/short/missing secret + wildcard CORS.
- Full suite: 44 passed.

Redeploy note (per research): seed_units/seed_categories are insert-only,
so the Aug-26 redeploy does NOT orphan historical tickets referencing
units 103E/103W/105E/105W or the legacy 34-category tree. Pending
Wahab: are 103E/103W/105E/105W real apartments dropped from the Excel
regeneration? Optional follow-up: floor-number backfill for already-
seeded units (mapping corrected floors; existing rows keep old values).

Checks per HARDENING.md acceptance:
- [x] starting without a real key fails loudly (subprocess-verified)
- [x] compose carries no literal secret; secrets come from .env
- [x] CORS_ORIGINS explicit allow-list, '*' rejected
- [x] unauthenticated register cannot mint Admin/* or Director
2026-09-02 23:59:21 +00:00
mumuni-bot 76d9d12b78 Merge pull request 'feat: Phase 1 session timeout — access token 30 -> 60 min' (#9) from feat/phase1-session-timeout into main 2026-08-26 23:52:41 +00:00
Mumuni (Hermes) 3ad81e2c39 feat: increase access token lifetime 30 -> 60 min (Phase 1 session timeout) 2026-08-26 23:52:13 +00:00
mumuni-bot aef9d90097 Merge pull request 'feat: Phase 1 — apartment mapping (134 units) + 12 categories' (#8) from feat/phase1-apartment-mapping-categories-timeout into main 2026-08-26 23:51:40 +00:00
Mumuni (Hermes) c9b722041c fix: include Penthouse rows (PH1E-/PH1W-/PH2E-/PH2W-) in apartment mapping — parser had skipped them 2026-08-26 23:44:30 +00:00
Mumuni (Hermes) cf1d9413bb fix: COPY apartment_mapping.json into image (was missing -> built-in fallback used) 2026-08-26 21:29:55 +00:00
Mumuni (Hermes) f256f1a6d2 feat: Phase 1 — regenerate 134-unit mapping from Excel + add 12 categories 2026-08-26 15:27:59 +00:00
abiba-bot 78068c23d9 Merge pull request 'docs: Production hardening & review checklist (demo -> prod)' (#6) from docs/prod-hardening-checklist into main 2026-08-15 13:36:09 +00:00
abiba-bot 51e00a40cd Merge pull request 'feat: backdated reported date for old active tickets' (#7) from fm/denya-backdate-report-date into main 2026-08-03 09:59:06 +00:00
root fb0c1784d0 no-mistakes(review): Extend ensure_legacy_schema to add tickets.reported_at with backfill 2026-08-03 09:45:41 +00:00
root e0a7479c3e feat: backdated reported date for old active tickets
Wahab can now enter historical tickets that keep their true reported date:

- Add nullable tickets.reported_at (DateTime) via alembic d5e0f2a1c3b4;
  backfill existing rows from created_at so nothing shows empty.
- TicketCreate.reported_at (optional) is persisted by create_ticket and
  defaults to now when omitted, so existing create behavior is unchanged.
- New-issue form gains a 'Reported Date' date picker (defaults to today,
  past dates allowed, future blocked) and sends reported_at in the payload.
- List and detail pages show 'Reported' next to 'Created' (date-only,
  labeled) so backdated tickets are obvious; SLA deadline is unchanged and
  still runs from creation time so backfilling never instantly breaches.
- Tests: backdated create persists + is exposed on list/detail; omitted
  reported_at defaults to now; SLA window unchanged; full datetime round trip.
2026-08-03 09:40:23 +00:00
abiba-bot 1f15ca5457 docs: production hardening & review checklist for demo->prod transition
Grounded in hands-on review of the live scottdenya deployment and main.
P0 security blockers, P1 operational hardening, WhatsApp wiring runbook,
no-mistakes review items, and production Definition-of-Done.

Prepared by Mumuni (Syslog Falcon) 2026-08-03.
2026-08-03 08:05:26 +00:00
abiba-bot 30c0c01c59 Merge pull request 'Wahab demo prep — workflow hardening, Cancelled status, ticket cleanup support' (#5) from fm/denya-wahab-cleanup-hardening into main 2026-08-02 15:01:24 +00:00
root bf361d76bc no-mistakes(document): Docs synced for Cancelled status; lint cleaned 2026-08-02 14:37:37 +00:00
root 6b92c9098c no-mistakes(review): Guard photo cleanup; add transitions helper and assign auto-advance 2026-08-02 14:26:48 +00:00
root 237284b012 no-mistakes(review): Make Cancelled reachable from all active states; wire phone PATCH; exclude cancelled from CEO resolution avg 2026-08-02 14:12:46 +00:00
root 1dd88a141e feat: Wahab demo prep — Cancelled status, phone persistence, admin delete, users picker, detail-page fixes
- Add Cancelled as terminal status reachable from all active states; exclude
  from SLA breach reporting and dashboard active counts; add to status pickers
- Persist customer phone on ticket create/update (was silently dropped);
  add tickets.phone migration + legacy self-heal guard
- Add admin-only DELETE /api/tickets/{id} (removes timeline/photos/escalations)
- Add GET /api/auth/users for the assign-technician dropdown (was hardcoded)
- TicketOut now returns nested unit/category so the detail page stops showing
  '—' for Unit/Property/Category
- Ticket numbering uses max+1 so deletions never re-issue a number
- New-issue form: require Category and (standard mode) Priority client-side
- Tests: 11 new cases covering cancellation, SLA exemption, phone, delete,
  users endpoint, numbering
2026-08-02 14:06:30 +00:00
abiba-bot ffed595dbd Merge pull request 'Sprint A Wahab review batch — categories, property hierarchy, priority grouping' (#4) from fm/denya-wahab-sprint-a into main 2026-08-02 13:36:31 +00:00
root 5e9ec29439 no-mistakes(document): Sync AGENTS.md taxonomy, drop unused imports 2026-08-02 13:34:49 +00:00
root 663354eeaf no-mistakes(review): Extend legacy self-heal rename; clear stale Mould & Damp priority default 2026-08-02 13:22:32 +00:00
root db6826cb49 no-mistakes(review): Add startup self-heal guard for show_in_form column 2026-08-02 12:36:10 +00:00
root ae17005932 no-mistakes(review): Fix stale urgent priority when leaving emergency mode 2026-08-02 12:26:19 +00:00
root 2407f294f4 feat: Sprint A Wahab review batch — categories, property hierarchy, priority grouping
Items 1-5, 9, 11 from denya-wahab-feedback-s2:
- Category.show_in_form (alert-only flag): Gas Leak hidden from issue picker
  but kept urgent for SLA/alert and reporting; emergency quick path on the
  new-issue form creates urgent tickets via include_hidden categories.
- Seed: Aluminum/Glass, Carpentry, Mould & Damp (Medium default) maintenance
  categories; Lost Property renamed Missing Item (+ sub).
- One alembic migration: add show_in_form (backfill True, Gas Leak False) +
  data rename Lost Property -> Missing Item.
- Property -> Building -> Apartment cascade with searchable apartment combobox
  on the new-issue form and ticket list filters; /api/tickets/units gains
  building filter + /units/grouped variant; /api/tickets gains additive
  building/unit_id filters. apartment_mapping.json committed (deterministic).
- Group-by-priority toggle on /tickets (four sections + unknown bucket,
  age-sortable, composes with filters, URL deep links), FM dashboard active
  tickets, and CS dashboard priority card click-through.
- Tests: 13 new (category visibility, seed idempotency/sync, unit grouping,
  ticket building/unit filters).
2026-08-02 09:30:29 +00:00
root 2e995ee758 no-mistakes(review): Add pytest pythonpath config so tests resolve app imports 2026-07-31 10:18:52 +00:00
root d4ef96f17c no-mistakes(document): docs: add pytest suite to AGENTS.md structure and commands 2026-07-31 10:09:54 +00:00
root d074f347dc no-mistakes(review): Add pytest-asyncio dev dep and deterministic pagination tiebreaker 2026-07-31 09:57:56 +00:00
root 47be148240 fix(dashboard): paginate CEO dashboard ticket fetch to respect API cap
The CEO dashboard requested /api/tickets?page_size=500, but the API caps
page_size at 200 (le=200 in app/routers/tickets.py), so the request
returned 422 and every KPI/chart rendered zeros.

- ceo.html: fetch all tickets by looping pages of page_size=200 until
  total items are collected (with a safety bound), keeping KPIs accurate
  as volume grows past 200.
- tests: add test suite anchoring the pagination contract — page_size=500
  returns 422, page_size=200 returns items/total/page/page_size, and a
  page loop collects every ticket without duplicates.
- pyproject: enable pytest-asyncio auto mode and tests/ discovery.
- .gitignore: un-ignore committed tests/test_*.py.
2026-07-31 09:53:35 +00:00
root f84021bc14 Merge remote-tracking branch 'origin/fm/denya-onecare-s3' 2026-07-25 00:19:59 +00:00
root cf55576d10 feat: Denya Developers brand theming
- Brand colors: primary #0d2b18 (deep forest green), accent #c8a96e (gold), bg #faf8f5 (cream), text #1a1a1a
- Nav bg #0d2b18 with gold logo mark and white text
- Denya Developers + OneCare branding in nav header
- Gold accent borders, hover states, and active tab highlights
- Toast/success use brand green shades
- Body background cream (#faf8f5)
2026-07-25 00:19:59 +00:00
jerome 4afdc36765 feat: Replace mock WhatsApp with real Meta Graph API webhook
- POST /api/whatsapp/webhook handles Meta verification (hub.challenge)
- Inbound text messages create tickets via create_ticket()
- Auto-reply confirmation sent back via Meta Graph API
- WhatsApp messages logged with ticket linkage in whatsapp_log
- Added WHATSAPP_PHONE_NUMBER_ID, WHATSAPP_ACCESS_TOKEN, WHATSAPP_VERIFY_TOKEN config
- Kept /mock-log debug endpoint for backward compatibility
- Graceful degradation: logs message even if ticket/reply fails
2026-07-24 20:07:30 -04:00
root c9f5ac4380 feat: Denya Developers brand theming
- Brand colors: primary #0d2b18 (deep forest green), accent #c8a96e (gold), bg #faf8f5 (cream), text #1a1a1a
- Nav bg #0d2b18 with gold logo mark and white text
- Denya Developers + OneCare branding in nav header
- Gold accent borders, hover states, and active tab highlights
- Toast/success use brand green shades
- Body background cream (#faf8f5)
2026-07-23 19:46:08 +00:00
abiba-bot 3920cf14a1 Sprint 3: Alpine.js Dashboards 2026-07-23 19:36:35 +00:00
root 01e28bbcc7 fix: map customer_name to reporter in create_ticket service
Per captain decision: customer_name maps to reporter field (fallback),
phone field skipped for MVP (no DB column).
2026-07-23 19:26:04 +00:00
root ef2297ea87 no-mistakes(review): Fixed aging bucket boundaries and removed wasteful API call 2026-07-23 19:21:31 +00:00
root a9c17d0703 fix: address remaining review findings
- Fix detail.html to use assigned_technician_name (not .assigned_technician?.full_name)
- Remove double error toast in submitNote() catch block
- Replace wasteful API calls in CEO dashboard with units+data approach
- Remove QR Code option from reported_via dropdown (excluded per scope)
2026-07-23 19:18:17 +00:00
root 5560496653 no-mistakes(review): Fix F01/F02/F05: technician name, duplicate timeline, FM dashboard property counts 2026-07-23 19:13:10 +00:00
root 7fab1ede51 fix: address ask-user findings from review
- Move unit loading to use real /api/tickets/units endpoint
- Match unit code format to backend seed (0101E style)
- Send unit_id in create ticket payload
- Fix tech IDs to match seed order (9-15 for Prosper-Afful)
- Add customer_name and phone fields to TicketCreate schema
- Map form customer_name/phone into API payload
2026-07-23 19:08:49 +00:00
root 3dc383e62d fix: auto-fix findings from no-mistakes review
- Remove duplicate  import
- Fix  →  in create ticket
- Fix  →  in ticket detail
- Add note field to TicketUpdate schema and handle note-only updates in backend
- Update frontend submitNote() to use PATCH endpoint
2026-07-23 19:06:33 +00:00
root 9aa7971a28 feat: Sprint 3 frontend dashboards with Alpine.js + Jinja2
- Login page with JWT auth and role-based redirect
- CS Dashboard: KPI cards, priority breakdown, recent tickets
- FM Dashboard: tech workload, aging analysis, emergency alerts
- CEO Dashboard: executive KPIs, charts, risk indicators
- All Issues: filterable/sortable ticket table with pagination
- Create Issue: form with category tree, property/unit selector, photo uploads
- Issue Detail: full timeline, photo gallery, SLA status, action modals
- Role-based nav bar: CS/FM/Executive links adapt to user role
- Base template: shared Alpine.js app state, toast notifications, loading overlay
2026-07-23 19:01:26 +00:00
abiba-bot bc23338bc1 Sprint 2: Ticket Engine, SLA Engine, Photo Uploads, Category System 2026-07-23 18:55:00 +00:00
50 changed files with 7071 additions and 177 deletions
+26
View File
@@ -0,0 +1,26 @@
# Denya OneCare — runtime environment template (HARDENING.md P0.1/P1.1)
# Copy to .env and fill in real values. NEVER commit .env.
# Generate the secret with: openssl rand -hex 32
# ── Required ─────────────────────────────────────────────
SECRET_KEY=
DATABASE_URL=sqlite+aiosqlite:///./data/denya_onecare.db
# Explicit origin allow-list — "*" is rejected at startup (P0.2)
CORS_ORIGINS=http://localhost:8000
# ── Optional (WhatsApp; needed before wiring Meta) ───────
WHATSAPP_PHONE_NUMBER_ID=
WHATSAPP_ACCESS_TOKEN=
WHATSAPP_VERIFY_TOKEN=
META_GRAPH_BASE=https://graph.facebook.com/v18.0
# ── Webhook auth (P0) ──────────────────────────────────
# Shared secret for inbound WhatsApp webhook POSTs (X-Webhook-Secret header).
# FAIL-CLOSED: when unset/empty, every webhook message is rejected (403).
# Generate with: openssl rand -hex 32
WHATSAPP_WEBHOOK_SECRET=
# ── Login rate limiting (P0) ────────────────────────────
# ~5 failed login attempts per 15 minutes per IP+email → HTTP 429
LOGIN_RATE_LIMIT_MAX_ATTEMPTS=5
LOGIN_RATE_LIMIT_WINDOW_SECONDS=900
+1
View File
@@ -6,6 +6,7 @@ __pycache__/
.venv/
uploads/
test_*.py
!tests/test_*.py
venv/
*.egg-info/
dist/
+98 -15
View File
@@ -22,6 +22,7 @@ app/
├── 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)
```
@@ -29,6 +30,7 @@ uploads/ # Photo uploads (created at runtime)
- `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
@@ -37,7 +39,7 @@ Users, units, and categories are auto-seeded on first startup via lifespan hook:
- 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: 20 top-level (10 Maintenance, 6 CS, 4 Emergency) with sub-categories, seeded from `app/services/seed.py::SEED_CATEGORIES_DATA`
- 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
@@ -45,39 +47,103 @@ Users, units, and categories are auto-seeded on first startup via lifespan hook:
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/health` | No | Health check |
| POST | `/api/auth/register` | No | Create user |
| POST | `/api/auth/login` | No | Get JWT tokens |
| 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 |
| POST | `/api/whatsapp/mock` | No | Mock WhatsApp |
| GET | `/api/whatsapp/mock-log` | No | Recent mock submissions |
| 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, category_id, assigned_to, date_from, date_to) |
| GET | `/api/tickets/{id}` | No | Get ticket detail with timeline, photos, SLA status |
| PATCH | `/api/tickets/{id}` | Bearer | Update ticket (validates status transitions) |
| 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 (optional `?type=` filter) |
| GET | `/api/tickets/categories/flat` | No | Flat category list |
| 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
- Roles: CS Rep, CS Manager, FM Dispatcher, Admin/Jerome, Admin/Wahab, Tech, CEO, Director
- Use `require_roles("Admin/Jerome", "Admin/Wahab")` dependency for RBAC
- `sub` claim holds string user ID
- **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)
### Status Lifecycle (15 statuses)
New → Logged → Triage → Assigned → Accepted → Travelling → On Site → In Progress → Waiting Parts → Escalated → Completed → On-Field Verification → Wahab Review → Closed → Reopened
- `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.
@@ -101,6 +167,23 @@ Stored under `uploads/` with UUID filenames. Static-files mounted at `/uploads/`
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.
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+2
View File
@@ -0,0 +1,2 @@
<!-- Points Claude at AGENTS.md via import; edit AGENTS.md, not this file. -->
@AGENTS.md
+1
View File
@@ -12,6 +12,7 @@ COPY pyproject.toml .
COPY alembic.ini .
COPY alembic/ alembic/
COPY app/ app/
COPY apartment_mapping.json .
# Install Python dependencies
RUN pip install --no-cache-dir .
+227
View File
@@ -0,0 +1,227 @@
# Denya OneCare — Production Hardening & Review Checklist
> **Audience:** Abiba (and any agent working the Denya OneCare repo)
> **Status:** Demo → Production hardening
> **Context:** WhatsApp integration lands within a week (once Denya provides credentials).
> We are moving past "demo" toward the final product. This doc is the concrete,
> ordered punch-list to get there. Each item is grounded in the current codebase
> (verified against `main` and the live deployment on `scottdenya`).
> **How to use:** work top-down. P0 items are hard blockers for any real data.
> When a P0/P1 item is done, mark it `[x]` and PR it with a `no-mistakes(review)` pass.
---
## 0. Current state (verified 2026-08-03)
- **Working & verified:** auth (JWT 30m/7d, bcrypt, RBAC via `require_roles`), ticket
CRUD with 16-status `VALID_TRANSITIONS` state machine, SLA engine, photo uploads,
category/unit hierarchy, 3 role dashboards (CS/FM/CEO), Alembic migrations with
legacy-schema self-heal. **32 pytest tests pass.**
- **Live:** container `denya-onecare` on LXC `scottdenya` (192.168.68.75:8000),
image built 2026-08-02, `restart: unless-stopped`.
- **Demo-only posture resolved (PR #10 + P0 batch):** `SECRET_KEY` now fails
closed without a real key, CORS is an explicit origin allow-list, and open
self-registration was removed — `POST /api/auth/register` → 404, users are
admin-managed only (see AGENTS.md).
- **Known demo-only posture (must change):** SQLite backend; WhatsApp webhook
code is present but **no real credentials wired**.
---
## 1. P0 — Security blockers (do these FIRST, before any real data)
### P0.1 Hardcode-safe secrets; never ship the default key — **DONE (PR #10)**
- **Files:** `docker-compose.yml`, `app/core/config.py`
- Replace the hardcoded `SECRET_KEY=change-me-in-production` default with a
fail-closed default: if `SECRET_KEY` is unset/empty or still the well-known
placeholder string, refuse to boot (raise in `Settings` validation or lifespan).
- `docker-compose.yml` must NOT carry a literal secret. Reference an `.env`
(git-ignored) or a runtime secret source. Add `SECRET_KEY` + `WHATSAPP_*` to `.gitignore`.
- **Acceptance:** starting the app without a real key fails loudly; container env
contains a strong random key (≥32 bytes, e.g. `openssl rand -hex 32`).
### P0.2 Lock down CORS — **DONE (PR #10)**
- **Files:** `app/main.py`, `docker-compose.yml`
- `CORS_ORIGINS=*` + `allow_credentials=True` is an invalid/unsafe combo
(browsers reject `*` with credentials anyway). Replace with an explicit
origin allow-list of the real web origins (e.g. `https://denya.sysloggh.net`,
your NetBird/nomad domain + localhost for dev).
- If credentials are used, origins MUST be explicit — never `*`.
- **Acceptance:** `settings.CORS_ORIGINS` is a comma-separated explicit list; the
middleware builds an allow-list, not `["*"]`.
### P0.3 Gate user registration — **DONE (P0 batch, 2026-09)**
- Open `POST /api/auth/register` was removed entirely (404) — there is no sign-up UI.
- Users are admin-managed: `POST /api/auth/users` (Admin/Jerome + Admin/Wahab only)
creates users with a **forced canonical role** (unknown roles → 422);
`PATCH /api/auth/users/{id}` changes role / deactivates (self-modification → 400);
`DELETE /api/auth/users/{id}` is guarded (users referenced by
tickets/timeline/escalations → 409); duplicate email → 409.
- Emails are normalized (strip + lowercase) on every write path; a startup
self-heal lowercases legacy mixed-case rows so pre-P0 accounts can't be locked
out of login. Unified role model lives in `app/core/roles.py`.
- **Regression tests:** `tests/test_p0_auth_admin_batch.py`.
- If a public self-service resident/tenant signup is genuinely required later
(Phase 2 QR/self-service), it must be a SEPARATE endpoint with a **role
default of the least-privilege role** and rate-limiting — never able to mint
admin/FM roles.
### P0.4 Reconsider SQLite for the final product
- **Files:** `docker-compose.yml`, `app/core/database.py`, `app/core/config.py`, PRD §16
- PRD Phase 1 calls for PostgreSQL. SQLite is fine for POC but is a write-lock
bottleneck and a data-integrity risk under concurrent FM/CS/WhatsApp writes.
- **Recommended:** switch `DATABASE_URL` to Postgres via async driver
(`postgresql+asyncpg://`). SQLAlchemy 2.0 + SQLAlchemy models are portable —
the migration is mostly: new driver dependency, `DATABASE_URL`, and re-running
Alembic against Postgres. Keep SQLite as the default for local dev/tests only.
- **Acceptance:** `pytest` green against Postgres (tests param via conftest),
Alembic applies cleanly on a fresh Postgres DB.
### P0.5 WhatsApp webhook auth + hardening — **PARTIAL (P0 batch, 2026-09)**
- **File:** `app/routers/whatsapp.py`
- **Done:** the `GET` handshake validates `hub.verify_token` with a constant-time
compare and returns **403 on mismatch**; inbound `POST`s are gated by the
`X-Webhook-Secret` header matching `WHATSAPP_WEBHOOK_SECRET` (fail-closed 403
when the env var is unset — see `.env.example`); the debug
`GET /api/whatsapp/mock-log` now requires Bearer auth.
- **Still open:** Meta request-signature validation (`X-Hub-Signature-256` HMAC
over the raw body with the app secret — the shared-secret header above is the
interim gate); per-sender rate limiting / dedupe idempotency keyed on
`wa_message_id` (retries can still double-create tickets); redacting the raw
access token in `send_whatsapp_reply` error paths.
- **Acceptance (open items):** a forged POST without the Meta signature is
rejected; duplicate `wa_message_id` does not create a second ticket;
verify-token mismatch returns 403 (done).
---
## 2. P1 — Operational hardening (before/just after go-live)
### P1.1 Secrets handling & git hygiene
- Ensure `SECRET_KEY`, `WHATSAPP_*`, and any DB credentials are **not** in the repo
or in the committed `docker-compose.yml`. `.env` is git-ignored.
- On this fleet: align with Syslog's key-off-disk doctrine — inject secrets at
runtime (Infisical) rather than baking into image or compose if feasible.
- Rotate the seed demo users' `denya123` password before production. `seed_users`
is idempotent but the default password is in `app/services/seed.py` — forced-rotate
on first prod login or at seed time.
### P1.2 Reverse proxy + TLS
- Do not expose the raw uvicorn :8000 behind `CORS_ORIGINS=*` on the WAN.
Terminate TLS at a reverse proxy (Caddy/Traefik/nginx) with a proper domain
(e.g. `denya.sysloggh.net`).
- Configure gunicorn/workers + `--proxy-headers` (or keep uvicorn but behind TLS).
- **Acceptance:** `https://denya.sysloggh.net` serves the app with a valid cert;
`:8000` is not directly reachable from the internet.
### P1.3 DB backups & persistence
- Postgres change (P0.4) enables sane backups. Wire nightly `pg_dump` (or PBS /
Syslog backup cron) of the persistent volume. The compose already mounts
`app-data` volume — make sure it's on backed-up storage.
- Add an Alembic upgrade step to the deploy runbook (never rely only on
`Base.metadata.create_all` + self-heal for schema changes in prod).
### P1.4 Logging & observability
- Add structured request logging; route to a location you can actually check
(stdout + a file/volume). Correlate with `ticket_number`.
- Add a minimal `/health` readiness that checks DB connectivity (currently it
returns OK without touching the DB).
### P1.5 Photo upload hardening
- **File:** `app/routers/tickets.py`
- Uploads already validate MIME + extension and use UUID filenames — good.
- Add: max file-size limit (e.g. 10 MB) and content sniffing (validate magic
bytes, not just `content_type` which is client-supplied).
- Ensure uploaded files are never executable and are served with
`X-Content-Type-Options: nosniff`.
### P1.6 API hardening & rate limiting
- **Done (P0 batch):** `POST /api/auth/login` is rate-limited in-process —
~5 failures / 15 min per IP+email → 429 (env-tunable `LOGIN_RATE_LIMIT_*`,
a successful login resets the window). **Open:** ticket-creation rate limiting
(spam / mass-creation).
- **Done (P0 batch):** ticket-list pagination — `page`/`page_size` (default 50,
cap 200), `limit` alias for `page_size` also capped; passing both with
different values → 422. Tie-breaker `id DESC` present.
---
## 3. WhatsApp integration (this week) — concrete wiring runbook
Assumes Denya provides: **phone number ID, access token, verify token, app secret.**
1. **Add env vars** (`WHATSAPP_PHONE_NUMBER_ID`, `WHATSAPP_ACCESS_TOKEN`,
`WHATSAPP_VERIFY_TOKEN`, `WHATSAPP_APP_SECRET`, `META_GRAPH_BASE`) to `.env`
(git-ignored) and inject at runtime. Never commit.
2. **Webhook handshake:** in Meta dashboard point the webhook URL at
`<domain>/api/whatsapp/webhook`. The GET verify path echoes `hub.challenge`
when the verify token matches (constant-time compare; mismatch → 403). The
still-open P0.5 work is the Meta signature validation in step 3.
3. **Verify incoming signature** (P0.5) — use `X-Hub-Signature-256` = HMAC-SHA256
of the raw body with your app secret, compared with `compare_digest`.
4. **Reply flow:** confirm `send_whatsapp_reply` posts correctly to
`graph.facebook.com/v18.0/<PHONE_NUMBER_ID>/messages`. The reply template
currently builds a JS string manually — prefer sending the nested object as a
proper JSON body rather than a hand-built string (`{\"body\":\"...\"}`) to avoid
escaping bugs. Test with the Meta "send a test message" tool.
5. **Idempotency:** guard ticket creation on `wa_message_id` (P0.5) to prevent
double-creation on retries.
6. **Standalone test:** use the `mock-log` endpoint to confirm webhook → ticket →
auto-reply path end-to-end in the demo env before pointing Meta's production
webhook at it.
---
## 4. Review recommendations (for the `no-mistakes(review)` pass and final QA)
- **RBAC coverage:** audit every route for the correct dependency. Currently:
- `POST /api/tickets`, `PATCH`, `POST /{id}/status`, `POST /{id}/photos` → any
authenticated user. Confirm role intent (should a CS Rep push a ticket to
"On-Field Verification"? or only FM/Tech?).
- `GET /api/tickets`, `GET /{id}`, `/transitions`, `/sla`, `/photos` are
**unauthenticated**. For a facilities tool this may be intentional (resident
view), but confirm you're comfortable with public reads of ticket details
(which include reporter/phone). If not, add auth.
- **Phone/tenant data exposure:** ticket detail returns `phone`. Decide who can
see phone numbers and enforce at the API, not just the UI.
- **Test coverage gaps to add:**
- Auth: expired token, malformed token, RBAC denial per role
- WhatsApp: signature validation (valid/invalid/forged), verify-token mismatch,
duplicate `wa_message_id` idempotency
- Pagination boundary: page > last page returns empty items, tie-breaker stable
- Photo upload: bad MIME spoofing, oversize file, `is_before` flag
- SLA: breach boundary exactly at deadline (not just past it)
- **Schema/migration hygiene:** the `ensure_legacy_schema` self-heal in
`app/main.py` exists because of create_all DBs. Once you move to Alembic-only
(P1.3), this becomes dead weight — plan a deprecation.
- **Concurrency:** ticket-number generation reads `max()` then `+1` — fine at
current scale, but under concurrent Postgres writes this can race. If tickets
ever originate from WhatsApp + web + dashboard simultaneously at volume, move to
a sequenced/unique constraint approach.
---
## 5. Definition of Done (production-ready)
- [x] No default `SECRET_KEY`; app fails closed without a real key (PR #10)
- [x] CORS is an explicit origin allow-list (PR #10)
- [x] Self-registration removed (register → 404); users are admin-managed only
with forced canonical roles (P0 batch)
- [ ] Postgres backend; Alembic applies cleanly on fresh DB; nightly backups
- [ ] TLS-terminated reverse proxy with real domain; no raw :8000 on WAN
- [x] Webhook POST gated by `X-Webhook-Secret` (fail-closed); verify-token
mismatch → 403; `mock-log` requires auth
- [ ] WhatsApp webhook: Meta `X-Hub-Signature-256` HMAC validated; idempotent on
`wa_message_id`; real credentials injected at runtime
- [x] Login rate limiting in place (~5 fails / 15 min per IP+email → 429)
- [ ] Ticket-creation rate limiting in place
- [ ] Photo uploads size-limited and content-sniffed
- [ ] RBAC audited per-route; phone data access controlled
- [ ] Expanded test suite (auth, WhatsApp, SLA boundary, uploads) — all green
- [ ] Secrets out of repo; demo password rotated
- [ ] Structured logs + DB-aware health check
---
*Prepared by Mumuni (Syslog Falcon) — 2026-08-03, from a hands-on review of the
denya-onecare repo and the live scottdenya deployment.*
+4 -2
View File
@@ -120,7 +120,7 @@ Close Ticket
Archive → Executive Reporting
```
### Detailed Ticket Lifecycle (15 Statuses)
### Detailed Ticket Lifecycle (16 Statuses)
| # | Status | Description | Who Sets |
|---|--------|-------------|----------|
@@ -139,6 +139,7 @@ Archive → Executive Reporting
| 13 | **Wahab Review** | Final QA oversight and closure approval | Wahab |
| 14 | **Closed** | Ticket officially closed | Wahab / Nicholas |
| 15 | **Reopened** | Issue resurfaces within 7 days | Agent / System |
| 16 | **Cancelled** | Ticket withdrawn (test/scratch cleanup, guest no-show); terminal — reachable from any active state, excluded from SLA breach reporting and dashboard active counts | Any staff (via dashboard) |
### Step-by-Step Detail
@@ -154,9 +155,10 @@ Archive → Executive Reporting
9. **Wahab Review** — Wahab does final QA oversight in dashboard → status → Wahab Review → approves or sends back
10. **Auto Follow-up** (48 hours later — internal only) — System flags ticket for Wahab/Nicholas: `Issue PAV-001 (505E WC) — resolved 48h ago. Any follow-up needed?` → If issue resurfaces → auto-reopens, escalates to Ama
### Reopening & Reassignment
### Reopening, Reassignment & Cancellation
- **Reopening:** If a verified-closed issue resurfaces within 7 days, the gatekeeper can Reopen it. A reopened issue auto-escalates to Ama.
- **Cancellation:** Any ticket can be cancelled from an active state (terminal; cannot resume work). Cancelled tickets are excluded from SLA breach reporting and dashboard active counts.
- **Reassignment:** A technician who cannot complete a job can request: `#reassign PAV-001 [technician name]` → Nicholas receives notification and approves/reassigns in dashboard.
---
@@ -0,0 +1,60 @@
"""category show_in_form column + Lost Property → Missing Item rename
Revision ID: b2f4a6c8e0d2
Revises: 795c6b95f637
Create Date: 2026-08-02 00:00:00.000000
Sprint A (Wahab feedback batch): one schema change + one data rename.
* Add ``categories.show_in_form`` (bool, default True) and backfill existing
rows to True; flip Gas Leak to False (alert-only: still urgent for SLA/alert
and reporting, but hidden from the new-issue category picker).
* Rename the Customer Service category ``Lost Property`` → ``Missing Item``.
The seed is insert-if-missing, so without this data migration an existing
database would keep the old row and the seed would create a duplicate.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b2f4a6c8e0d2'
down_revision: Union[str, Sequence[str], None] = '795c6b95f637'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add show_in_form, backfill True, hide Gas Leak, rename Lost Property."""
op.add_column(
'categories',
sa.Column(
'show_in_form',
sa.Boolean(),
nullable=False,
server_default=sa.true(),
comment='Visible in the new-issue category picker (False = alert-only, e.g. Gas Leak)',
),
)
# Backfill: Gas Leak stays in the SLA/alert table (urgent) but is hidden
# from the new-issue picker.
op.execute(
"UPDATE categories SET show_in_form = 0 "
"WHERE type = 'emergency' AND name = 'Gas Leak' AND parent_id IS NULL"
)
# Rename Lost Property → Missing Item (data migration; seed is insert-if-missing).
op.execute(
"UPDATE categories SET name = 'Missing Item' "
"WHERE type = 'cs' AND name = 'Lost Property'"
)
def downgrade() -> None:
"""Reverse the rename and drop the column."""
op.execute(
"UPDATE categories SET name = 'Lost Property' "
"WHERE type = 'cs' AND name = 'Missing Item'"
)
op.drop_column('categories', 'show_in_form')
@@ -0,0 +1,40 @@
"""tickets.phone column + Cancelled status support
Revision ID: c4e8f1a2d3b4
Revises: b2f4a6c8e0d2
Create Date: 2026-08-02 00:00:00.000000
Demo-prep batch (Wahab review): one schema change.
* Add ``tickets.phone`` (nullable) so the customer phone collected on the
new-issue form is actually persisted instead of silently dropped.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c4e8f1a2d3b4'
down_revision: Union[str, Sequence[str], None] = 'b2f4a6c8e0d2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add tickets.phone (nullable)."""
op.add_column(
'tickets',
sa.Column(
'phone',
sa.String(length=50),
nullable=True,
comment='Customer contact phone (captured on the new-issue form)',
),
)
def downgrade() -> None:
"""Drop tickets.phone."""
op.drop_column('tickets', 'phone')
@@ -0,0 +1,47 @@
"""tickets.reported_at column — original reported date for backdated tickets
Revision ID: d5e0f2a1c3b4
Revises: c4e8f1a2d3b4
Create Date: 2026-08-03 00:00:00.000000
Backdated-ticket support (Wahab demo): one schema change.
* Add ``tickets.reported_at`` (nullable DateTime) so historical/backfilled
tickets keep their true report date instead of inheriting today's
``created_at``.
* Backfill existing rows with their ``created_at`` value so no ticket shows
an empty reported date after the upgrade. The service layer also defaults
new tickets without a ``reported_at`` to now, so the column is effectively
always populated from here on.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd5e0f2a1c3b4'
down_revision: Union[str, Sequence[str], None] = 'c4e8f1a2d3b4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add tickets.reported_at (nullable) and backfill from created_at."""
op.add_column(
'tickets',
sa.Column(
'reported_at',
sa.DateTime(),
nullable=True,
comment='Original reported date; backdated/backfilled tickets keep their true report date',
),
)
# Backfill: every existing ticket was reported when it was created.
op.execute('UPDATE tickets SET reported_at = created_at WHERE reported_at IS NULL')
def downgrade() -> None:
"""Drop tickets.reported_at."""
op.drop_column('tickets', 'reported_at')
+834
View File
@@ -0,0 +1,834 @@
{
"version": 1,
"property": "Pavilion Accra",
"units": [
{
"apartment_code": "101E",
"property": "East",
"building": "Pavilion East",
"floor": 1
},
{
"apartment_code": "101E(B)",
"property": "East",
"building": "Pavilion East",
"floor": 1
},
{
"apartment_code": "101W",
"property": "West",
"building": "Pavilion West",
"floor": 1
},
{
"apartment_code": "101W(B)",
"property": "West",
"building": "Pavilion West",
"floor": 1
},
{
"apartment_code": "102E",
"property": "East",
"building": "Pavilion East",
"floor": 1
},
{
"apartment_code": "102W",
"property": "West",
"building": "Pavilion West",
"floor": 1
},
{
"apartment_code": "104E",
"property": "East",
"building": "Pavilion East",
"floor": 1
},
{
"apartment_code": "104W",
"property": "West",
"building": "Pavilion West",
"floor": 1
},
{
"apartment_code": "106E",
"property": "East",
"building": "Pavilion East",
"floor": 1
},
{
"apartment_code": "106W",
"property": "West",
"building": "Pavilion West",
"floor": 1
},
{
"apartment_code": "107E",
"property": "East",
"building": "Pavilion East",
"floor": 1
},
{
"apartment_code": "107W",
"property": "West",
"building": "Pavilion West",
"floor": 1
},
{
"apartment_code": "109E",
"property": "East",
"building": "Pavilion East",
"floor": 1
},
{
"apartment_code": "109W",
"property": "West",
"building": "Pavilion West",
"floor": 1
},
{
"apartment_code": "110E",
"property": "East",
"building": "Pavilion East",
"floor": 1
},
{
"apartment_code": "110W",
"property": "West",
"building": "Pavilion West",
"floor": 1
},
{
"apartment_code": "201E",
"property": "East",
"building": "Pavilion East",
"floor": 2
},
{
"apartment_code": "201E(B)",
"property": "East",
"building": "Pavilion East",
"floor": 2
},
{
"apartment_code": "201W",
"property": "West",
"building": "Pavilion West",
"floor": 2
},
{
"apartment_code": "201W(B)",
"property": "West",
"building": "Pavilion West",
"floor": 2
},
{
"apartment_code": "202E",
"property": "East",
"building": "Pavilion East",
"floor": 2
},
{
"apartment_code": "202W",
"property": "West",
"building": "Pavilion West",
"floor": 2
},
{
"apartment_code": "203E",
"property": "East",
"building": "Pavilion East",
"floor": 2
},
{
"apartment_code": "203W",
"property": "West",
"building": "Pavilion West",
"floor": 2
},
{
"apartment_code": "204E",
"property": "East",
"building": "Pavilion East",
"floor": 2
},
{
"apartment_code": "204W",
"property": "West",
"building": "Pavilion West",
"floor": 2
},
{
"apartment_code": "206E",
"property": "East",
"building": "Pavilion East",
"floor": 2
},
{
"apartment_code": "206W",
"property": "West",
"building": "Pavilion West",
"floor": 2
},
{
"apartment_code": "207E",
"property": "East",
"building": "Pavilion East",
"floor": 2
},
{
"apartment_code": "207W",
"property": "West",
"building": "Pavilion West",
"floor": 2
},
{
"apartment_code": "208E",
"property": "East",
"building": "Pavilion East",
"floor": 2
},
{
"apartment_code": "208W",
"property": "West",
"building": "Pavilion West",
"floor": 2
},
{
"apartment_code": "209E",
"property": "East",
"building": "Pavilion East",
"floor": 2
},
{
"apartment_code": "209W",
"property": "West",
"building": "Pavilion West",
"floor": 2
},
{
"apartment_code": "210E",
"property": "East",
"building": "Pavilion East",
"floor": 2
},
{
"apartment_code": "210W",
"property": "West",
"building": "Pavilion West",
"floor": 2
},
{
"apartment_code": "301E",
"property": "East",
"building": "Pavilion East",
"floor": 3
},
{
"apartment_code": "301W",
"property": "West",
"building": "Pavilion West",
"floor": 3
},
{
"apartment_code": "301W(B)",
"property": "West",
"building": "Pavilion West",
"floor": 3
},
{
"apartment_code": "302E",
"property": "East",
"building": "Pavilion East",
"floor": 3
},
{
"apartment_code": "302W",
"property": "West",
"building": "Pavilion West",
"floor": 3
},
{
"apartment_code": "303E",
"property": "East",
"building": "Pavilion East",
"floor": 3
},
{
"apartment_code": "303W",
"property": "West",
"building": "Pavilion West",
"floor": 3
},
{
"apartment_code": "304E",
"property": "East",
"building": "Pavilion East",
"floor": 3
},
{
"apartment_code": "304W",
"property": "West",
"building": "Pavilion West",
"floor": 3
},
{
"apartment_code": "306E",
"property": "East",
"building": "Pavilion East",
"floor": 3
},
{
"apartment_code": "306W",
"property": "West",
"building": "Pavilion West",
"floor": 3
},
{
"apartment_code": "307E",
"property": "East",
"building": "Pavilion East",
"floor": 3
},
{
"apartment_code": "307W",
"property": "West",
"building": "Pavilion West",
"floor": 3
},
{
"apartment_code": "308E",
"property": "East",
"building": "Pavilion East",
"floor": 3
},
{
"apartment_code": "308W",
"property": "West",
"building": "Pavilion West",
"floor": 3
},
{
"apartment_code": "309E",
"property": "East",
"building": "Pavilion East",
"floor": 3
},
{
"apartment_code": "309W",
"property": "West",
"building": "Pavilion West",
"floor": 3
},
{
"apartment_code": "310W",
"property": "West",
"building": "Pavilion West",
"floor": 3
},
{
"apartment_code": "401E",
"property": "East",
"building": "Pavilion East",
"floor": 4
},
{
"apartment_code": "401E(B)",
"property": "East",
"building": "Pavilion East",
"floor": 4
},
{
"apartment_code": "401W",
"property": "West",
"building": "Pavilion West",
"floor": 4
},
{
"apartment_code": "401W(B)",
"property": "West",
"building": "Pavilion West",
"floor": 4
},
{
"apartment_code": "402E",
"property": "East",
"building": "Pavilion East",
"floor": 4
},
{
"apartment_code": "402W",
"property": "West",
"building": "Pavilion West",
"floor": 4
},
{
"apartment_code": "403E",
"property": "East",
"building": "Pavilion East",
"floor": 4
},
{
"apartment_code": "403W",
"property": "West",
"building": "Pavilion West",
"floor": 4
},
{
"apartment_code": "404E",
"property": "East",
"building": "Pavilion East",
"floor": 4
},
{
"apartment_code": "404W",
"property": "West",
"building": "Pavilion West",
"floor": 4
},
{
"apartment_code": "406E",
"property": "East",
"building": "Pavilion East",
"floor": 4
},
{
"apartment_code": "406W",
"property": "West",
"building": "Pavilion West",
"floor": 4
},
{
"apartment_code": "407E",
"property": "East",
"building": "Pavilion East",
"floor": 4
},
{
"apartment_code": "407W",
"property": "West",
"building": "Pavilion West",
"floor": 4
},
{
"apartment_code": "408E",
"property": "East",
"building": "Pavilion East",
"floor": 4
},
{
"apartment_code": "408W",
"property": "West",
"building": "Pavilion West",
"floor": 4
},
{
"apartment_code": "409E",
"property": "East",
"building": "Pavilion East",
"floor": 4
},
{
"apartment_code": "409W",
"property": "West",
"building": "Pavilion West",
"floor": 4
},
{
"apartment_code": "410E",
"property": "East",
"building": "Pavilion East",
"floor": 4
},
{
"apartment_code": "410W",
"property": "West",
"building": "Pavilion West",
"floor": 4
},
{
"apartment_code": "501E",
"property": "East",
"building": "Pavilion East",
"floor": 5
},
{
"apartment_code": "501E(B)",
"property": "East",
"building": "Pavilion East",
"floor": 5
},
{
"apartment_code": "501W",
"property": "West",
"building": "Pavilion West",
"floor": 5
},
{
"apartment_code": "501W(B)",
"property": "West",
"building": "Pavilion West",
"floor": 5
},
{
"apartment_code": "502E",
"property": "East",
"building": "Pavilion East",
"floor": 5
},
{
"apartment_code": "502W",
"property": "West",
"building": "Pavilion West",
"floor": 5
},
{
"apartment_code": "503E",
"property": "East",
"building": "Pavilion East",
"floor": 5
},
{
"apartment_code": "503W",
"property": "West",
"building": "Pavilion West",
"floor": 5
},
{
"apartment_code": "504E",
"property": "East",
"building": "Pavilion East",
"floor": 5
},
{
"apartment_code": "504W",
"property": "West",
"building": "Pavilion West",
"floor": 5
},
{
"apartment_code": "506E",
"property": "East",
"building": "Pavilion East",
"floor": 5
},
{
"apartment_code": "506W",
"property": "West",
"building": "Pavilion West",
"floor": 5
},
{
"apartment_code": "507E",
"property": "East",
"building": "Pavilion East",
"floor": 5
},
{
"apartment_code": "507W",
"property": "West",
"building": "Pavilion West",
"floor": 5
},
{
"apartment_code": "508E",
"property": "East",
"building": "Pavilion East",
"floor": 5
},
{
"apartment_code": "508W",
"property": "West",
"building": "Pavilion West",
"floor": 5
},
{
"apartment_code": "509E",
"property": "East",
"building": "Pavilion East",
"floor": 5
},
{
"apartment_code": "509W",
"property": "West",
"building": "Pavilion West",
"floor": 5
},
{
"apartment_code": "510E",
"property": "East",
"building": "Pavilion East",
"floor": 5
},
{
"apartment_code": "510W",
"property": "West",
"building": "Pavilion West",
"floor": 5
},
{
"apartment_code": "601E",
"property": "East",
"building": "Pavilion East",
"floor": 6
},
{
"apartment_code": "601E(B)",
"property": "East",
"building": "Pavilion East",
"floor": 6
},
{
"apartment_code": "601W",
"property": "West",
"building": "Pavilion West",
"floor": 6
},
{
"apartment_code": "601W(B)",
"property": "West",
"building": "Pavilion West",
"floor": 6
},
{
"apartment_code": "602E",
"property": "East",
"building": "Pavilion East",
"floor": 6
},
{
"apartment_code": "602W",
"property": "West",
"building": "Pavilion West",
"floor": 6
},
{
"apartment_code": "603E",
"property": "East",
"building": "Pavilion East",
"floor": 6
},
{
"apartment_code": "603W",
"property": "West",
"building": "Pavilion West",
"floor": 6
},
{
"apartment_code": "604E",
"property": "East",
"building": "Pavilion East",
"floor": 6
},
{
"apartment_code": "604W",
"property": "West",
"building": "Pavilion West",
"floor": 6
},
{
"apartment_code": "606E",
"property": "East",
"building": "Pavilion East",
"floor": 6
},
{
"apartment_code": "606W",
"property": "West",
"building": "Pavilion West",
"floor": 6
},
{
"apartment_code": "607E",
"property": "East",
"building": "Pavilion East",
"floor": 6
},
{
"apartment_code": "607W",
"property": "West",
"building": "Pavilion West",
"floor": 6
},
{
"apartment_code": "608E",
"property": "East",
"building": "Pavilion East",
"floor": 6
},
{
"apartment_code": "608W",
"property": "West",
"building": "Pavilion West",
"floor": 6
},
{
"apartment_code": "609E",
"property": "East",
"building": "Pavilion East",
"floor": 6
},
{
"apartment_code": "609W",
"property": "West",
"building": "Pavilion West",
"floor": 6
},
{
"apartment_code": "610E",
"property": "East",
"building": "Pavilion East",
"floor": 6
},
{
"apartment_code": "610W",
"property": "West",
"building": "Pavilion West",
"floor": 6
},
{
"apartment_code": "701E",
"property": "East",
"building": "Pavilion East",
"floor": 7
},
{
"apartment_code": "701E(B)",
"property": "East",
"building": "Pavilion East",
"floor": 7
},
{
"apartment_code": "701W",
"property": "West",
"building": "Pavilion West",
"floor": 7
},
{
"apartment_code": "701W(B)",
"property": "West",
"building": "Pavilion West",
"floor": 7
},
{
"apartment_code": "702E",
"property": "East",
"building": "Pavilion East",
"floor": 7
},
{
"apartment_code": "702W",
"property": "West",
"building": "Pavilion West",
"floor": 7
},
{
"apartment_code": "703E",
"property": "East",
"building": "Pavilion East",
"floor": 7
},
{
"apartment_code": "703W",
"property": "West",
"building": "Pavilion West",
"floor": 7
},
{
"apartment_code": "704E",
"property": "East",
"building": "Pavilion East",
"floor": 7
},
{
"apartment_code": "704W",
"property": "West",
"building": "Pavilion West",
"floor": 7
},
{
"apartment_code": "706E",
"property": "East",
"building": "Pavilion East",
"floor": 7
},
{
"apartment_code": "706W",
"property": "West",
"building": "Pavilion West",
"floor": 7
},
{
"apartment_code": "707E",
"property": "East",
"building": "Pavilion East",
"floor": 7
},
{
"apartment_code": "707W",
"property": "West",
"building": "Pavilion West",
"floor": 7
},
{
"apartment_code": "708E",
"property": "East",
"building": "Pavilion East",
"floor": 7
},
{
"apartment_code": "708W",
"property": "West",
"building": "Pavilion West",
"floor": 7
},
{
"apartment_code": "709E",
"property": "East",
"building": "Pavilion East",
"floor": 7
},
{
"apartment_code": "709W",
"property": "West",
"building": "Pavilion West",
"floor": 7
},
{
"apartment_code": "710E",
"property": "East",
"building": "Pavilion East",
"floor": 7
},
{
"apartment_code": "710W",
"property": "West",
"building": "Pavilion West",
"floor": 7
},
{
"apartment_code": "PH1E-",
"property": "East",
"building": "Pavilion East",
"floor": null
},
{
"apartment_code": "PH1W-",
"property": "West",
"building": "Pavilion West",
"floor": null
},
{
"apartment_code": "PH2E-",
"property": "East",
"building": "Pavilion East",
"floor": null
},
{
"apartment_code": "PH2W-",
"property": "West",
"building": "Pavilion West",
"floor": null
}
]
}
+33 -2
View File
@@ -23,16 +23,47 @@ class Settings(BaseSettings):
DATABASE_URL: str = "sqlite+aiosqlite:///./denya_onecare.db"
# ── Auth ─────────────────────────────────────────────────────────
SECRET_KEY: str = "change-me-in-production-use-a-real-secret"
SECRET_KEY: str = ""
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 # Phase 1: raised 30 -> 60 for fewer re-logins
REFRESH_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
# ── CORS ─────────────────────────────────────────────────────────
CORS_ORIGINS: str = "*"
# ── WhatsApp ─────────────────────────────────────────────────────
WHATSAPP_PHONE_NUMBER_ID: str = ""
WHATSAPP_ACCESS_TOKEN: str = ""
WHATSAPP_VERIFY_TOKEN: str = ""
META_GRAPH_BASE: str = "https://graph.facebook.com/v18.0"
# Shared secret for inbound webhook POSTs (header ``X-Webhook-Secret``).
# Fail-closed: when unset/empty the webhook rejects every message.
WHATSAPP_WEBHOOK_SECRET: str = ""
# ── Login rate limiting ──────────────────────────────────────────
LOGIN_RATE_LIMIT_MAX_ATTEMPTS: int = 5
LOGIN_RATE_LIMIT_WINDOW_SECONDS: int = 15 * 60
# ── Paths ────────────────────────────────────────────────────────
BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
settings = Settings()
# ── Fail-closed secret validation (HARDENING.md P0.1) ─────────────────
# Refuse to boot without a real SECRET_KEY. Devs must create a local .env
# (see .env.example); production injects it via docker-compose env_file.
_KNOWN_PLACEHOLDER_SECRETS = {
"",
"change-me-in-production",
"change-me-in-production-use-a-real-secret",
"changeme",
"secret",
}
if settings.SECRET_KEY in _KNOWN_PLACEHOLDER_SECRETS or len(settings.SECRET_KEY) < 32:
raise RuntimeError(
"SECRET_KEY is missing, a known placeholder, or shorter than 32 chars. "
"Generate one with: openssl rand -hex 32 — and set it in .env "
"(dev) or the runtime environment (prod). Refusing to start."
)
+82
View File
@@ -0,0 +1,82 @@
"""Dependency-light login rate limiter.
Brute-force protection for ``POST /api/auth/login``: a sliding window of
failed attempts keyed by ``ip|email``. Defaults to ~5 failures / 15 minutes
(env-tunable via ``LOGIN_RATE_LIMIT_MAX_ATTEMPTS`` /
``LOGIN_RATE_LIMIT_WINDOW_SECONDS``).
In-process storage is intentional: the app currently runs a single uvicorn
worker, and keeping the limiter dependency-light avoids pulling slowapi in
for one endpoint. ``_now`` is a module-level hook so tests can fast-forward
the clock.
"""
from __future__ import annotations
import time
from collections import defaultdict, deque
from fastapi import HTTPException, status
from app.core.config import settings
def _now() -> float:
"""Wall-clock epoch seconds; overridable in tests via monkeypatch."""
return time.time()
class LoginRateLimiter:
"""Sliding-window failure limiter keyed by ``ip|email``."""
def __init__(self, max_attempts: int = 5, window_seconds: int = 15 * 60) -> None:
self.max_attempts = max(max_attempts, 1)
self.window_seconds = max(window_seconds, 1)
self._failures: defaultdict[str, deque[float]] = defaultdict(deque)
def key(self, ip: str, email: str) -> str:
return f"{ip}|{email.strip().lower()}"
def _prune(self, key: str, now: float | None = None) -> None:
now = now if now is not None else _now()
window_start = now - self.window_seconds
bucket = self._failures.get(key)
if bucket is None:
return
while bucket and bucket[0] <= window_start:
bucket.popleft()
if not bucket:
self._failures.pop(key, None)
def failure_count(self, key: str) -> int:
self._prune(key)
return len(self._failures.get(key, ()))
def is_blocked(self, key: str) -> bool:
return self.failure_count(key) >= self.max_attempts
def record_failure(self, key: str) -> None:
self._failures[key].append(_now())
self._prune(key)
def clear(self, key: str) -> None:
self._failures.pop(key, None)
def reset(self) -> None:
self._failures.clear()
def check_or_raise(self, key: str) -> None:
"""Raise HTTP 429 when the key has exhausted its attempts."""
if self.is_blocked(key):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many failed login attempts. Try again later.",
)
# Shared instance — module import is safe because the app fails closed at
# boot (app/core/config.py) before any request can reach the login route.
login_rate_limiter = LoginRateLimiter(
max_attempts=settings.LOGIN_RATE_LIMIT_MAX_ATTEMPTS,
window_seconds=settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS,
)
+99
View File
@@ -0,0 +1,99 @@
"""Unified role model — single source of truth for user roles.
HARDENING (P0 batch): every role string used by seeds, RBAC checks, admin
user management, login, and JWT validation derives from this module so the
system can never silently drift between role vocabularies.
Canonical roles are the human-readable taxonomy the whole product already
uses (PRD §4, ``app/services/seed.py``, the frontend nav in base.html):
Admin/Jerome, Admin/Wahab, CS Rep, CS Manager, FM Dispatcher,
Tech, CEO, Director
Legacy databases created under the pre-P0 open-registration builds can carry
lowercase/nickname role strings (``technician``, ``cs``, ``fm``, ``ceo``,
``admin``, ``superadmin`` …). ``ROLE_ALIASES`` maps the *unambiguous*
nicknames onto a canonical role so startup normalization (see
``app/services/seed.py::normalize_legacy_user_roles``) can converge the data.
``admin`` / ``superadmin`` are deliberately NOT aliased: they are
identity-ambiguous (they cannot be attributed to Jerome or Wahab) and were
mintable by anyone during the open-registration window, so they are treated
as unknown and fail closed — the operator must remediate those rows manually
(role cleanup on the live DB is owned by the deployer).
"""
from __future__ import annotations
# ── Canonical taxonomy ────────────────────────────────────────────────
# Order is cosmetic; membership is what matters.
CANONICAL_ROLES: tuple[str, ...] = (
"Admin/Jerome",
"Admin/Wahab",
"CS Rep",
"CS Manager",
"FM Dispatcher",
"Tech",
"CEO",
"Director",
)
# Roles that pass admin gates (ticket DELETE, user management, …).
ADMIN_ROLES: tuple[str, ...] = ("Admin/Jerome", "Admin/Wahab")
# Roles shown to the frontend nav/assignment helpers as "technician" pool.
TECHNICIAN_ROLE = "Tech"
# ── Legacy alias → canonical mapping (case-insensitive) ───────────────
# Keys are lowercased. Unambiguous nicknames from legacy/early seeds and the
# brief's role model ("technician/cs/fm/ceo") converge onto canonical roles.
ROLE_ALIASES: dict[str, str] = {
"technician": TECHNICIAN_ROLE,
"tech": TECHNICIAN_ROLE,
"cs": "CS Rep",
"cs rep": "CS Rep",
"cs representative": "CS Rep",
"cs manager": "CS Manager",
"fm": "FM Dispatcher",
"fm dispatcher": "FM Dispatcher",
"ceo": "CEO",
"director": "Director",
}
# Aliases that are explicitly NOT auto-mapped (identity-ambiguous and/or
# mintable by the old open register). They stay unknown → denied at login
# and JWT validation until an operator remediates the row.
_BLOCKED_LEGACY_ROLES = frozenset({"admin", "superadmin", "administrator"})
def normalize_role(role: str | None) -> str | None:
"""Return the canonical role for *role*, or ``None`` when unrecognised.
``Admin/Jerome`` → ``Admin/Jerome``; ``technician`` → ``Tech``;
``superadmin`` → ``None`` (unknown; caller must fail closed).
"""
if not role:
return None
stripped = role.strip()
if stripped in CANONICAL_ROLES:
return stripped
return ROLE_ALIASES.get(stripped.lower())
def is_known_role(role: str | None) -> bool:
"""True when *role* is canonical or maps to a canonical role."""
return normalize_role(role) is not None
def is_admin_role(role: str | None) -> bool:
"""True when *role* is one of the canonical administrator roles."""
return normalize_role(role) in ADMIN_ROLES
def is_blocked_legacy_role(role: str | None) -> bool:
"""True for legacy ``admin``/``superadmin`` rows that need remediation.
Such rows are not canonical, are not auto-mapped, and must not pass any
authorization gate; an operator should reassign or remove them.
"""
return bool(role) and role.strip().lower() in _BLOCKED_LEGACY_ROLES
+9 -1
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from functools import wraps
from typing import Annotated, Any, Callable
import bcrypt
@@ -16,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.roles import is_known_role
from app.models.user import User
bearer_scheme = HTTPBearer(auto_error=False)
@@ -91,6 +91,14 @@ async def get_current_user(
user = result.scalar_one_or_none()
if user is None or not user.active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
# Unified role model: reject rows whose stored role is not canonical or a
# known legacy alias. Legacy junk roles (e.g. lowercase ``admin``) must
# fail closed here so they can never ride an access token into the app.
if not is_known_role(user.role):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Account role is not recognised; contact an administrator",
)
return user
+156 -5
View File
@@ -9,23 +9,74 @@ from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from app.core.config import settings
from app.core.database import Base, async_session_factory, engine
from app.routers import auth, health, tickets, whatsapp
from app.services.seed import seed_categories, seed_units, seed_users
from app.routers import auth, health, pages, tickets, whatsapp
from app.services.seed import (
normalize_legacy_user_emails,
normalize_legacy_user_roles,
seed_categories,
seed_units,
seed_users,
)
logger = logging.getLogger(__name__)
async def ensure_legacy_schema(conn) -> None:
"""Add columns/data changes from alembic migrations that legacy create_all databases lack."""
result = await conn.execute(text("PRAGMA table_info(categories)"))
columns = {row[1] for row in result}
if "show_in_form" not in columns:
await conn.execute(
text("ALTER TABLE categories ADD COLUMN show_in_form BOOLEAN NOT NULL DEFAULT 1")
)
logger.info("Added missing categories.show_in_form column (legacy database)")
result = await conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='tickets'"))
if result.scalar():
result = await conn.execute(text("PRAGMA table_info(tickets)"))
ticket_columns = {row[1] for row in result}
if "phone" not in ticket_columns:
await conn.execute(
text("ALTER TABLE tickets ADD COLUMN phone VARCHAR(50)")
)
logger.info("Added missing tickets.phone column (legacy database)")
if "reported_at" not in ticket_columns:
await conn.execute(
text("ALTER TABLE tickets ADD COLUMN reported_at DATETIME")
)
await conn.execute(
text("UPDATE tickets SET reported_at = created_at WHERE reported_at IS NULL")
)
logger.info("Added missing tickets.reported_at column (legacy database)")
result = await conn.execute(
text(
"UPDATE categories SET name = 'Missing Item' "
"WHERE type = 'cs' AND name = 'Lost Property' AND parent_id IS NULL "
"AND NOT EXISTS (SELECT 1 FROM categories c2 "
"WHERE c2.type = 'cs' AND c2.name = 'Missing Item' AND c2.parent_id IS NULL)"
)
)
if result.rowcount:
logger.info("Renamed legacy 'Lost Property' category to 'Missing Item'")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Initialise database and seed data on startup."""
logger.info("Starting Denya OneCare …")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await ensure_legacy_schema(conn)
async with async_session_factory() as session:
await seed_users(session)
# P0 role-model unification: converge legacy nickname roles (e.g.
# ``technician``/``cs``/``fm``) onto the canonical taxonomy at startup.
await normalize_legacy_user_roles(session)
await normalize_legacy_user_emails(session)
await session.commit()
await seed_units(session, json_path=str(settings.BASE_DIR / "apartment_mapping.json"))
await session.commit()
@@ -42,22 +93,122 @@ app = FastAPI(
lifespan=lifespan,
)
# ── CORS ─────────────────────────────────────────────────────────────
# ── Security headers (P0 batch) ──────────────────────────────────────
class SecurityHeadersMiddleware:
"""Set hardening headers on every HTTP response.
* ``X-Frame-Options: DENY`` and ``X-Content-Type-Options: nosniff`` on
all responses;
* CSP on HTML pages (login + app pages). Alpine.js and Tailwind are
vendored same-origin (``/static/vendor/``), so no external hosts are
allowed and the page is fully self-contained — safe on LAN-only demo
clients. Inline scripts/styles stay enabled for the Alpine/tailwind
runtime;
* ``Cache-Control: no-cache`` on HTML pages so templates always
revalidate (the vendored assets themselves are cached immutably via
versioned filenames);
* ``Strict-Transport-Security`` only when TLS terminates (https scheme
or ``X-Forwarded-Proto: https`` from the reverse proxy).
"""
HSTS = "max-age=31536000; includeSubDomains"
CSP = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob:; "
"font-src 'self' data:; "
"connect-src 'self'; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self'; "
"object-src 'none'"
)
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
is_tls = scope.get("scheme") == "https"
for name, value in scope.get("headers") or []:
if name.lower() == b"x-forwarded-proto":
first = value.decode("latin-1").split(",", 1)[0].strip().lower()
if first == "https":
is_tls = True
async def send_wrapper(message):
if message["type"] == "http.response.start":
headers = list(message.get("headers") or [])
content_type = next(
(v for k, v in headers if k.lower() == b"content-type"), b""
)
if content_type.startswith(b"text/html"):
headers.append((b"content-security-policy", self.CSP.encode()))
# Templates must always revalidate: never serve a stale
# page that still points at old vendored filenames.
headers.append((b"cache-control", b"no-cache"))
headers.append((b"x-frame-options", b"DENY"))
headers.append((b"x-content-type-options", b"nosniff"))
if is_tls:
headers.append((b"strict-transport-security", self.HSTS.encode()))
message["headers"] = headers
await send(message)
await self.app(scope, receive, send_wrapper)
app.add_middleware(SecurityHeadersMiddleware)
# ── CORS (HARDENING.md P0.2 — explicit origin allow-list, never "*") ──
_origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()]
if "*" in _origins or not _origins:
raise RuntimeError(
"CORS_ORIGINS must be an explicit comma-separated origin allow-list "
"(e.g. 'https://denya.sysloggh.net,http://localhost:8000'). "
"'*' with allow_credentials=True is invalid and unsafe. Refusing to start."
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS.split(",") if settings.CORS_ORIGINS != "*" else ["*"],
allow_origins=_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── Static files (uploads) ───────────────────────────────────────────
# ── Static files (uploads + vendored frontend assets) ────────────────
class ImmutableStaticFiles(StaticFiles):
"""StaticFiles that serves long-lived immutable cache headers.
Used for the vendored frontend libraries under ``app/static/vendor/``
(Alpine.js + Tailwind Play). Their URLs embed the version, so upgrading
later just bumps the filename and clients fetch the new artifact instead
of a stale immutable copy.
"""
def file_response(self, full_path, stat_result, scope, status_code=200):
response = super().file_response(full_path, stat_result, scope, status_code)
response.headers["cache-control"] = "public, max-age=31536000, immutable"
return response
uploads_dir = Path(settings.BASE_DIR / "uploads")
uploads_dir.mkdir(parents=True, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads")
# Alpine.js/Tailwind are vendored same-origin so LAN-only demo clients render
# the login/dashboards with no external network (see app/templates/base.html).
static_dir = Path(__file__).resolve().parent / "static"
static_dir.mkdir(parents=True, exist_ok=True)
app.mount("/static", ImmutableStaticFiles(directory=str(static_dir)), name="static")
# ── Routers ──────────────────────────────────────────────────────────
app.include_router(health.router)
app.include_router(auth.router)
app.include_router(whatsapp.router)
app.include_router(tickets.router)
app.include_router(pages.router)
+8 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy import Boolean, ForeignKey, Integer, String, true
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
@@ -28,6 +28,13 @@ class Category(Base):
nullable=True,
comment="urgent, high, medium, low",
)
show_in_form: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
server_default=true(),
comment="Visible in the new-issue category picker (False = alert-only, e.g. Gas Leak)",
)
# self-referencing relationship
children: Mapped[list[Category]] = relationship("Category", back_populates="parent", cascade="all, delete-orphan")
+11 -1
View File
@@ -29,7 +29,7 @@ class Ticket(Base):
comment=(
"New, Logged, Triage, Assigned, Accepted, Travelling, On Site, "
"In Progress, Waiting Parts, Escalated, Completed, "
"On-Field Verification, Wahab Review, Closed, Reopened"
"On-Field Verification, Wahab Review, Closed, Reopened, Cancelled"
),
)
unit_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("units.id"), nullable=True)
@@ -40,6 +40,7 @@ class Ticket(Base):
comment="urgent, high, medium, low",
)
reporter: Mapped[str | None] = mapped_column(String(255), nullable=True)
phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
reported_via: Mapped[str | None] = mapped_column(
String(20),
nullable=True,
@@ -54,6 +55,11 @@ class Ticket(Base):
customer_rating: Mapped[int | None] = mapped_column(Integer, nullable=True)
reopen_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
reported_at: Mapped[datetime | None] = mapped_column(
DateTime,
nullable=True,
comment="Original reported date. Backdated/backfilled tickets keep their true report date; NULL falls back to created_at.",
)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
@@ -70,6 +76,10 @@ class Ticket(Base):
photos = relationship("TicketPhoto", back_populates="ticket")
escalations = relationship("Escalation", back_populates="ticket")
@property
def assigned_technician_name(self) -> str | None:
return self.assigned_technician.full_name if self.assigned_technician else None
def __repr__(self) -> str:
return f"<Ticket {self.ticket_number} ({self.status})>"
+6 -4
View File
@@ -1,4 +1,4 @@
"""WhatsApp log model for mock endpoint."""
"""WhatsApp log model for inbound webhook messages."""
from __future__ import annotations
@@ -14,10 +14,12 @@ class WhatsAppLog(Base):
__tablename__ = "whatsapp_log"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
command: Mapped[str] = mapped_column(Text, nullable=False)
from_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
from_number: Mapped[str] = mapped_column(String(50), nullable=False)
message_text: Mapped[str] = mapped_column(Text, nullable=False)
wa_message_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
ticket_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
ticket_number: Mapped[str | None] = mapped_column(String(30), nullable=True)
received_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
def __repr__(self) -> str:
return f"<WhatsAppLog {self.id}: {self.command[:50]}>"
return f"<WhatsAppLog {self.id}: from={self.from_number} ticket={self.ticket_number}>"
+76 -13
View File
@@ -1,19 +1,27 @@
"""Authentication router — register, login, refresh, me."""
"""Authentication router — login, refresh, me, and admin user management.
Self-registration was removed (P0 hardening): users are created/managed by
admins only via ``POST/PATCH/DELETE /api/auth/users``.
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.ratelimit import login_rate_limiter
from app.core.roles import ADMIN_ROLES
from app.core.security import get_current_user, require_roles
from app.models.user import User
from app.schemas.auth import (
AdminCreateUserRequest,
AdminUpdateUserRequest,
LoginRequest,
RefreshRequest,
RegisterRequest,
TokenResponse,
UserOut,
)
@@ -21,21 +29,29 @@ from app.services import auth as auth_service
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/register", response_model=UserOut, status_code=201)
async def register(
body: RegisterRequest,
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
return await auth_service.register(db, body)
_require_admin = require_roles(*ADMIN_ROLES)
# ── Public authN ─────────────────────────────────────────────────────
@router.post("/login", response_model=TokenResponse)
async def login(
request: Request,
body: LoginRequest,
db: Annotated[AsyncSession, Depends(get_db)],
) -> TokenResponse:
access, refresh, _user = await auth_service.login(db, body.email, body.password)
"""Log in. Brute-force limited to ~5 failures / 15 min per IP+email (429)."""
client_ip = request.client.host if request.client else "unknown"
key = login_rate_limiter.key(client_ip, body.email)
login_rate_limiter.check_or_raise(key)
try:
access, refresh, _user = await auth_service.login(db, body.email, body.password)
except HTTPException as exc:
# Count only real auth failures toward the limit; success resets it.
if exc.status_code == status.HTTP_401_UNAUTHORIZED:
login_rate_limiter.record_failure(key)
raise
login_rate_limiter.clear(key) # successful login resets the failure window
return TokenResponse(access_token=access, refresh_token=refresh)
@@ -55,7 +71,54 @@ async def me(current_user: Annotated[User, Depends(get_current_user)]) -> User:
@router.get("/admin-only", response_model=UserOut)
async def admin_only(
current_user: Annotated[User, Depends(require_roles("Admin/Jerome", "Admin/Wahab"))],
current_user: Annotated[User, Depends(_require_admin)],
) -> User:
"""Example RBAC-protected endpoint — only Admins can access."""
"""Example RBAC-protected endpoint — only canonical Admins can access."""
return current_user
# ── User directory ───────────────────────────────────────────────────
@router.get("/users", response_model=list[UserOut])
async def list_users(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> list[User]:
"""List users (id, name, role) for authenticated assignment pickers."""
result = await db.execute(select(User).order_by(User.full_name))
return list(result.scalars().all())
# ── Admin user management ────────────────────────────────────────────
@router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED)
async def create_user(
body: AdminCreateUserRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(_require_admin)],
) -> User:
"""Admin-only: create a user with a forced canonical role.
The client cannot self-register or pick an arbitrary role — unknown roles
(e.g. ``admin``, ``superadmin``) are rejected with 422.
"""
return await auth_service.create_user(db, body)
@router.patch("/users/{user_id}", response_model=UserOut)
async def update_user(
user_id: int,
body: AdminUpdateUserRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(_require_admin)],
) -> User:
"""Admin-only: change a user's role and/or deactivate the account."""
return await auth_service.update_user(db, current_user, user_id, body)
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(_require_admin)],
) -> None:
"""Admin-only: delete a user account (guarded; see auth_service)."""
await auth_service.delete_user(db, current_user, user_id)
+48
View File
@@ -0,0 +1,48 @@
"""Frontend page routes — serve Jinja2 HTML templates for the SPA-like dashboard."""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
router = APIRouter(tags=["pages"])
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
return templates.TemplateResponse(request, "login.html")
@router.get("/dashboard/cs", response_class=HTMLResponse)
async def cs_dashboard(request: Request):
return templates.TemplateResponse(request, "dashboard/cs.html")
@router.get("/dashboard/fm", response_class=HTMLResponse)
async def fm_dashboard(request: Request):
return templates.TemplateResponse(request, "dashboard/fm.html")
@router.get("/dashboard/ceo", response_class=HTMLResponse)
async def ceo_dashboard(request: Request):
return templates.TemplateResponse(request, "dashboard/ceo.html")
@router.get("/tickets", response_class=HTMLResponse)
async def ticket_list(request: Request):
return templates.TemplateResponse(request, "tickets/list.html")
@router.get("/tickets/new", response_class=HTMLResponse)
async def create_ticket_page(request: Request):
return templates.TemplateResponse(request, "tickets/new.html")
@router.get("/tickets/{ticket_id}", response_class=HTMLResponse)
async def ticket_detail_page(request: Request, ticket_id: int):
return templates.TemplateResponse(request, "tickets/detail.html", context={"ticket_id": ticket_id})
+159 -27
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
import os
import logging
import uuid
from datetime import datetime
from pathlib import Path
@@ -13,9 +13,11 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import get_current_user
from app.core.roles import ADMIN_ROLES
from app.core.security import get_current_user, require_roles
from app.models.category import Category
from app.models.ticket import Ticket, TicketPhoto
from app.models.unit import Unit
from app.models.user import User
from app.schemas.ticket import (
CategoryOut,
@@ -27,35 +29,87 @@ from app.schemas.ticket import (
TicketOut,
TicketPhotoOut,
TicketUpdate,
UnitOut,
)
from app.services import ticket as ticket_service
from app.services.sla import get_sla_status
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/tickets", tags=["tickets"])
_require_admin = require_roles(*ADMIN_ROLES)
# Pagination contract: sane defaults and a hard page-size cap so list
# responses never balloon into truncation territory (P0 batch).
DEFAULT_PAGE_SIZE = 50
MAX_PAGE_SIZE = 200
# Ensure uploads directory exists
UPLOADS_DIR = settings.BASE_DIR / "uploads"
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
# ── Units ──────────────────────────────────────────────────────────
@router.get("/units", response_model=list[UnitOut])
async def list_units(
db: Annotated[AsyncSession, Depends(get_db)],
property_filter: str | None = Query(None, alias="property"),
building: str | None = Query(None),
) -> list[Unit]:
"""List all units, optionally filtered by property (East/West) and building."""
query = select(Unit).order_by(Unit.apartment_code)
if property_filter:
query = query.where(Unit.property == property_filter)
if building:
query = query.where(Unit.building == building)
result = await db.execute(query)
return list(result.scalars().all())
@router.get("/units/grouped")
async def list_units_grouped(
db: Annotated[AsyncSession, Depends(get_db)],
property_filter: str | None = Query(None, alias="property"),
) -> dict[str, dict[str, list[dict]]]:
"""Return units grouped as ``{property: {building: [units]}}``.
Additive convenience variant of ``GET /api/tickets/units`` for the
Property → Building → Apartment cascade. Units without a building are
grouped under an empty-string key.
"""
query = select(Unit).order_by(Unit.property, Unit.building, Unit.apartment_code)
if property_filter:
query = query.where(Unit.property == property_filter)
result = await db.execute(query)
grouped: dict[str, dict[str, list[dict]]] = {}
for unit in result.scalars().all():
prop = unit.property or ""
building = unit.building or ""
grouped.setdefault(prop, {}).setdefault(building, []).append(
UnitOut.model_validate(unit).model_dump()
)
return grouped
# ── Categories ──────────────────────────────────────────────────────
async def _build_category_tree(db: AsyncSession, parent_id: int | None = None) -> list[CategoryTreeOut]:
async def _build_category_tree(db: AsyncSession, parent_id: int | None = None, include_hidden: bool = False) -> list[CategoryTreeOut]:
"""Build a nested category tree."""
result = await db.execute(
select(Category)
.where(Category.parent_id == parent_id)
.order_by(Category.name)
)
query = select(Category).where(Category.parent_id == parent_id).order_by(Category.name)
if not include_hidden:
query = query.where(Category.show_in_form.is_(True))
result = await db.execute(query)
categories = result.scalars().all()
tree = []
for cat in categories:
children = await _build_category_tree(db, cat.id)
children = await _build_category_tree(db, cat.id, include_hidden=include_hidden)
tree.append(CategoryTreeOut(
id=cat.id,
type=cat.type,
name=cat.name,
parent_id=cat.parent_id,
sla_urgency=cat.sla_urgency,
show_in_form=cat.show_in_form,
children=children,
))
return tree
@@ -65,23 +119,26 @@ async def _build_category_tree(db: AsyncSession, parent_id: int | None = None) -
async def list_categories(
db: Annotated[AsyncSession, Depends(get_db)],
type_filter: str | None = Query(None, alias="type"),
include_hidden: bool = Query(False, description="Include alert-only categories (e.g. Gas Leak)"),
) -> list[CategoryTreeOut]:
"""Return the full category tree, optionally filtered by type (maintenance, cs, emergency)."""
"""Return the full category tree, optionally filtered by type (maintenance, cs, emergency).
Alert-only categories (``show_in_form=False``, e.g. Gas Leak) are excluded
unless ``include_hidden=true`` is passed (used by the emergency quick path).
"""
if type_filter:
# Return flat list of top-level categories of the given type
result = await db.execute(
select(Category)
.where(Category.parent_id.is_(None), Category.type == type_filter)
.order_by(Category.name)
)
query = select(Category).where(Category.parent_id.is_(None), Category.type == type_filter).order_by(Category.name)
if not include_hidden:
query = query.where(Category.show_in_form.is_(True))
result = await db.execute(query)
parents = result.scalars().all()
tree = []
for parent in parents:
children_result = await db.execute(
select(Category)
.where(Category.parent_id == parent.id)
.order_by(Category.name)
)
children_query = select(Category).where(Category.parent_id == parent.id).order_by(Category.name)
if not include_hidden:
children_query = children_query.where(Category.show_in_form.is_(True))
children_result = await db.execute(children_query)
children = children_result.scalars().all()
tree.append(CategoryTreeOut(
id=parent.id,
@@ -89,24 +146,43 @@ async def list_categories(
name=parent.name,
parent_id=parent.parent_id,
sla_urgency=parent.sla_urgency,
show_in_form=parent.show_in_form,
children=[CategoryOut.model_validate(c) for c in children],
))
return tree
return await _build_category_tree(db)
return await _build_category_tree(db, include_hidden=include_hidden)
@router.get("/categories/flat", response_model=list[CategoryOut])
async def list_categories_flat(
db: Annotated[AsyncSession, Depends(get_db)],
type_filter: str | None = Query(None, alias="type"),
include_hidden: bool = Query(False, description="Include alert-only categories (e.g. Gas Leak)"),
) -> list[CategoryOut]:
"""Return a flat list of all categories (no nesting), optionally filtered by type."""
"""Return a flat list of all categories (no nesting), optionally filtered by type.
Alert-only categories are excluded by default; children of alert-only parents
are excluded too so no orphaned picker entries leak through.
"""
query = select(Category).order_by(Category.type, Category.name)
if type_filter:
query = query.where(Category.type == type_filter)
result = await db.execute(query)
categories = result.scalars().all()
return [CategoryOut.model_validate(c) for c in categories]
if include_hidden:
return [CategoryOut.model_validate(c) for c in categories]
# Exclude alert-only categories and any children whose parent is alert-only.
hidden_ids = {
c.id
for c in categories
if not c.show_in_form and c.parent_id is None
}
visible = [
c
for c in categories
if c.show_in_form and (c.parent_id is None or c.parent_id not in hidden_ids)
]
return [CategoryOut.model_validate(c) for c in visible]
# ── Ticket CRUD ──────────────────────────────────────────────────────
@@ -129,30 +205,60 @@ async def create_ticket(
async def list_tickets(
db: Annotated[AsyncSession, Depends(get_db)],
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
page_size: int | None = Query(None, ge=1, le=MAX_PAGE_SIZE),
limit: int | None = Query(
None, ge=1, le=MAX_PAGE_SIZE, description="Alias for page_size (also capped)"
),
status: str | None = Query(None),
priority: str | None = Query(None),
property: str | None = Query(None),
building: str | None = Query(None),
unit_id: int | None = Query(None),
category_id: int | None = Query(None),
assigned_to: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
) -> TicketListResponse:
"""List tickets with optional filtering and pagination."""
"""List tickets with optional filtering and pagination.
Both ``page_size`` and its alias ``limit`` are capped at MAX_PAGE_SIZE;
supplying both is an error. Neither defaults to DEFAULT_PAGE_SIZE.
"""
if page_size is not None and limit is not None and page_size != limit:
raise HTTPException(
status_code=422, # ``status`` query param shadows fastapi.status in this scope
detail="Provide either 'page_size' or 'limit', not both",
)
effective_page_size = page_size if page_size is not None else (limit or DEFAULT_PAGE_SIZE)
tickets, total = await ticket_service.list_tickets(
db,
status_filter=status,
priority_filter=priority,
property_filter=property,
building_filter=building,
unit_id=unit_id,
category_id=category_id,
assigned_to=assigned_to,
date_from=date_from,
date_to=date_to,
page=page,
page_size=page_size,
page_size=effective_page_size,
)
items = [TicketBrief.model_validate(t) for t in tickets]
return TicketListResponse(items=items, total=total, page=page, page_size=page_size)
return TicketListResponse(items=items, total=total, page=page, page_size=effective_page_size)
@router.get("/{ticket_id}/transitions")
async def get_ticket_transitions(
ticket_id: int,
db: Annotated[AsyncSession, Depends(get_db)],
) -> dict:
"""Return the valid next statuses for a ticket's current status."""
ticket = await ticket_service.get_ticket(db, ticket_id)
return {
"current_status": ticket.status,
"transitions": ticket_service.VALID_TRANSITIONS.get(ticket.status, []),
}
@router.get("/{ticket_id}", response_model=TicketOut)
@@ -181,6 +287,32 @@ async def update_ticket(
return ticket
@router.delete("/{ticket_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_ticket(
ticket_id: int,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(_require_admin)],
) -> None:
"""Delete a ticket and its children (timeline, photos, escalations).
Admin-only: intended for removing test/scratch tickets from the demo
database, never for routine workflow use.
"""
ticket = await ticket_service.delete_ticket(db, ticket_id)
# Remove orphaned photo files from disk after the DB rows are gone.
for photo in ticket.photos:
if photo.photo_url:
name = photo.photo_url.rsplit("/", 1)[-1]
try:
(UPLOADS_DIR / name).unlink(missing_ok=True)
except OSError:
logger.warning(
"Could not remove orphaned photo file %s for ticket %s",
name,
ticket_id,
)
# ── Status Transitions (convenience endpoints) ───────────────────────
@router.post("/{ticket_id}/status", response_model=TicketOut)
async def change_ticket_status(
+195 -19
View File
@@ -1,44 +1,220 @@
"""Mock WhatsApp endpoint for testing command parsing."""
"""WhatsApp webhook handler — Meta Graph API integration.
P0 hardening:
* ``POST /api/whatsapp/webhook`` requires the ``X-Webhook-Secret`` header to
match ``WHATSAPP_WEBHOOK_SECRET``. Fail-closed: when the env var is unset
every message is rejected (same posture as the SECRET_KEY guard).
* ``GET /api/whatsapp/webhook`` (Meta handshake) validates ``hub.verify_token``
with a constant-time compare and returns 403 on mismatch.
* ``GET /api/whatsapp/mock-log`` now requires authentication (was a public
debug endpoint that could 500 and leak stack traces without auth).
"""
from __future__ import annotations
import logging
import secrets
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends
import httpx
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User
from app.models.whatsapp_log import WhatsAppLog
from app.schemas.whatsapp import MockWhatsAppLogEntry, MockWhatsAppRequest, MockWhatsAppResponse
from app.schemas.whatsapp import (
MetaWebhookRequest,
MockWhatsAppLogEntry,
WebhookVerificationResponse,
WhatsAppReplyResponse,
)
from app.services.ticket import create_ticket
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/whatsapp", tags=["whatsapp"])
REPLY_TEMPLATE = (
"Thank you for contacting Denya OneCare. "
"Your ticket number is {ticket_number}. "
"We will get back to you soon."
)
@router.post("/mock", response_model=MockWhatsAppResponse)
async def mock_whatsapp(
body: MockWhatsAppRequest,
# ── Meta Graph API helpers ──────────────────────────────────────────
async def send_whatsapp_reply(
to_phone: str,
text: str,
) -> WhatsAppReplyResponse:
"""Send a text message via Meta Graph API."""
url = f"{settings.META_GRAPH_BASE}/{settings.WHATSAPP_PHONE_NUMBER_ID}/messages"
headers = {
"Authorization": f"Bearer {settings.WHATSAPP_ACCESS_TOKEN}",
"Content-Type": "application/x-www-form-urlencoded",
}
data = {
"messaging_product": "whatsapp",
"to": to_phone,
"type": "text",
"text": {"body": text},
}
# Encode nested dict as JSON string for form data (Meta requirement)
data["text"] = '{"body":' + f'"{text}"' + "}"
try:
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, data=data, timeout=15.0)
response.raise_for_status()
return WhatsAppReplyResponse(success=True, message="Reply sent")
except httpx.HTTPError as exc:
logger.error("Failed to send WhatsApp reply: %s", exc)
return WhatsAppReplyResponse(success=False, message=str(exc))
# ── Webhook auth (fail-closed) ──────────────────────────────────────
async def require_webhook_secret(
x_webhook_secret: Annotated[str | None, Header(alias="X-Webhook-Secret")] = None,
) -> None:
"""Reject webhook messages unless X-Webhook-Secret matches the env secret.
Reads ``settings.WHATSAPP_WEBHOOK_SECRET`` at request time so the value
can be injected per-deployment. Empty/unset env ⇒ reject everything.
"""
expected = settings.WHATSAPP_WEBHOOK_SECRET
if not expected:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Webhook disabled: WHATSAPP_WEBHOOK_SECRET is not configured",
)
if not x_webhook_secret or not secrets.compare_digest(x_webhook_secret, expected):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid webhook secret",
)
# ── Webhook endpoint ────────────────────────────────────────────────
@router.get("/webhook")
async def whatsapp_webhook_verify(
mode: str | None = Query(None, alias="hub.mode"),
verify_token: str | None = Query(None, alias="hub.verify_token"),
challenge: str | None = Query(None, alias="hub.challenge"),
) -> WebhookVerificationResponse | dict:
"""Meta webhook handshake (GET): echo the challenge when the token matches."""
if mode != "subscribe" or not challenge:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing hub.mode / hub.challenge",
)
expected = settings.WHATSAPP_VERIFY_TOKEN
if not expected or not secrets.compare_digest(verify_token or "", expected):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Verify token mismatch",
)
return WebhookVerificationResponse(challenge=challenge)
async def _process_entries(
body: MetaWebhookRequest,
db: AsyncSession,
) -> dict:
"""Create tickets + logs for inbound messages. Shared by the POST handler."""
if not body.entry:
return {"status": "no entry"}
for entry in body.entry:
if not entry.changes:
continue
for change in entry.changes:
if not change.message or not change.message.text:
logger.info("Non-text message received, skipping")
continue
message_text = change.message.text.text
sender = change.message.from_field
wa_msg_id = change.message.id or change.id
# ── Create ticket ──────────────────────────────────────
try:
ticket = await create_ticket(
db,
data={
"description": message_text,
"reporter": sender,
"reported_via": "WhatsApp",
},
)
except Exception as exc:
logger.error("Failed to create ticket: %s", exc)
# Still log the message even if ticket creation fails
log = WhatsAppLog(
from_number=sender,
message_text=message_text,
wa_message_id=wa_msg_id,
ticket_id=None,
ticket_number=None,
received_at=datetime.now(timezone.utc),
)
db.add(log)
await db.flush()
return {"status": "ticket creation failed, message logged"}
# ── Store WhatsApp log ─────────────────────────────────
log = WhatsAppLog(
from_number=sender,
message_text=message_text,
wa_message_id=wa_msg_id,
ticket_id=ticket.id,
ticket_number=ticket.ticket_number,
received_at=datetime.now(timezone.utc),
)
db.add(log)
await db.flush()
# ── Send auto-reply ────────────────────────────────────
reply_text = REPLY_TEMPLATE.format(ticket_number=ticket.ticket_number)
reply_result = await send_whatsapp_reply(sender, reply_text)
if not reply_result.success:
logger.warning(
"Auto-reply failed for ticket %s: %s",
ticket.ticket_number,
reply_result.message,
)
return {
"status": "processed",
"ticket_id": ticket.id,
"ticket_number": ticket.ticket_number,
"reply_sent": reply_result.success,
}
return {"status": "no messages"}
@router.post("/webhook")
async def whatsapp_webhook(
body: MetaWebhookRequest,
db: Annotated[AsyncSession, Depends(get_db)],
) -> MockWhatsAppResponse:
"""Accept a mock WhatsApp command and log it."""
log = WhatsAppLog(
command=body.command,
from_number=body.from_number,
ticket_id=body.ticket_id,
received_at=datetime.now(timezone.utc),
)
db.add(log)
await db.flush()
return MockWhatsAppResponse()
_auth: None = Depends(require_webhook_secret),
) -> dict:
"""Process an inbound WhatsApp message (Meta POST). Requires webhook secret."""
return await _process_entries(body, db)
# ── Debug endpoint (auth required) ──────────────────────────────────
@router.get("/mock-log", response_model=list[MockWhatsAppLogEntry])
async def mock_whatsapp_log(
db: Annotated[AsyncSession, Depends(get_db)],
limit: int = 50,
current_user: Annotated[User, Depends(get_current_user)],
limit: int = Query(50, ge=1, le=200),
) -> list[MockWhatsAppLogEntry]:
"""Return recent mock WhatsApp submissions."""
"""Return recent WhatsApp webhook submissions (authenticated only)."""
result = await db.execute(
select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit)
)
+66 -7
View File
@@ -2,15 +2,27 @@
from __future__ import annotations
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, Field, field_validator, model_validator
from app.core.roles import CANONICAL_ROLES
class RegisterRequest(BaseModel):
email: str
password: str
full_name: str
phone: str | None = None
role: str = "CS Rep"
def _validate_canonical_role(value: str | None) -> str | None:
"""Reject any role that is not part of the unified canonical taxonomy.
The role vocabulary is closed: admin user-management must only ever mint
canonical roles (see app/core/roles.py). Legacy/unknown strings
(``admin``, ``superadmin``, ``technician``, …) are rejected here so junk
roles can never be (re)created through the API.
"""
if value is None:
return None
role = value.strip()
if role not in CANONICAL_ROLES:
raise ValueError(
f"Unknown role '{value}'. Allowed roles: {', '.join(CANONICAL_ROLES)}"
)
return role
class LoginRequest(BaseModel):
@@ -37,3 +49,50 @@ class UserOut(BaseModel):
active: bool
model_config = {"from_attributes": True}
# ── Admin user management (self-registration is removed) ──────────────
class AdminCreateUserRequest(BaseModel):
"""Admin-created user. The role is mandatory and must be canonical.
``role`` is deliberately NOT optional and has no default — an admin must
state the intended role explicitly; the server never infers one.
"""
email: str = Field(min_length=1)
password: str = Field(min_length=8, description="Minimum 8 characters")
full_name: str = Field(min_length=1)
phone: str | None = None
role: str
@field_validator("role")
@classmethod
def _role_canonical(cls, value: str) -> str:
return _validate_canonical_role(value) # type: ignore[return-value]
@field_validator("email")
@classmethod
def _lower_email(cls, value: str) -> str:
return value.strip().lower()
class AdminUpdateUserRequest(BaseModel):
"""Admin edits to an existing user: role change and/or deactivation.
At least one field must be present. ``active=False`` deactivates the
account (login and token refresh then fail closed).
"""
role: str | None = None
active: bool | None = None
@field_validator("role")
@classmethod
def _role_canonical(cls, value: str | None) -> str | None:
return _validate_canonical_role(value)
@model_validator(mode="after")
def _at_least_one_field(self) -> AdminUpdateUserRequest:
if self.role is None and self.active is None:
raise ValueError("Provide at least one of 'role' or 'active'")
return self
+23 -1
View File
@@ -5,7 +5,18 @@ from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, Field
from pydantic import BaseModel
# ── Unit ─────────────────────────────────────────────────────────────
class UnitOut(BaseModel):
id: int
property: str
apartment_code: str
building: str | None = None
floor: int | None = None
model_config = {"from_attributes": True}
# ── Category ─────────────────────────────────────────────────────────
@@ -15,6 +26,7 @@ class CategoryOut(BaseModel):
name: str
parent_id: int | None = None
sla_urgency: str | None = None
show_in_form: bool = True
model_config = {"from_attributes": True}
@@ -32,6 +44,9 @@ class TicketCreate(BaseModel):
reported_via: str | None = None # whatsapp, phone, walk-in, qr, agent
description: str | None = None
assigned_to: int | None = None
customer_name: str | None = None
phone: str | None = None
reported_at: datetime | None = None # original report date for backdated/backfilled tickets; defaults to now when omitted
class TicketUpdate(BaseModel):
@@ -40,12 +55,14 @@ class TicketUpdate(BaseModel):
category_id: int | None = None
priority: str | None = None
reporter: str | None = None
phone: str | None = None
reported_via: str | None = None
description: str | None = None
assigned_to: int | None = None
eta: datetime | None = None
cost: Decimal | None = None
parts_used: str | None = None
note: str | None = None
class TicketTimelineOut(BaseModel):
@@ -78,10 +95,13 @@ class TicketBrief(BaseModel):
unit_id: int | None = None
category_id: int | None = None
assigned_to: int | None = None
assigned_technician_name: str | None = None
reporter: str | None = None
phone: str | None = None
description: str | None = None
sla_deadline: datetime | None = None
reopen_count: int = 0
reported_at: datetime | None = None
created_at: datetime
updated_at: datetime
@@ -97,6 +117,8 @@ class TicketOut(TicketBrief):
customer_rating: int | None = None
timeline: list[TicketTimelineOut] = []
photos: list[TicketPhotoOut] = []
unit: UnitOut | None = None
category: CategoryOut | None = None
sla_status: dict | None = None
+61 -12
View File
@@ -1,10 +1,69 @@
"""Pydantic schemas for mock WhatsApp endpoint."""
"""Pydantic schemas for Meta WhatsApp webhook."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
from pydantic import BaseModel, Field
# ── Meta Webhook Verification ────────────────────────────────────────
class WebhookVerificationResponse(BaseModel):
challenge: str
# ── Inbound Message Parsing ─────────────────────────────────────────
class MessageText(BaseModel):
text: str
class Message(BaseModel):
from_field: str = Field(alias="from")
id: str | None = None
text: MessageText | None = None
type: str | None = None
class Contact(BaseModel):
wa_id: str | None = None
profile: dict | None = None
class EntryMessagesItem(BaseModel):
id: str | None = None
message: Message | None = None
contacts: list[Contact] | None = None
timestamp: str | None = None
class Entry(BaseModel):
id: str | None = None
changes: list[EntryMessagesItem] | None = None
metadata: dict | None = None
class MetaWebhookRequest(BaseModel):
object: str | None = None
entry: list[Entry] | None = None
# ── Auto-reply ──────────────────────────────────────────────────────
class WhatsAppReplyResponse(BaseModel):
success: bool
message: str
# ── Legacy mock schemas (kept for /mock-log endpoint) ───────────────
class MockWhatsAppLogEntry(BaseModel):
id: int
from_number: str
message_text: str
wa_message_id: str | None
ticket_id: int | None
ticket_number: str | None
received_at: datetime
model_config = {"from_attributes": True}
class MockWhatsAppRequest(BaseModel):
@@ -16,13 +75,3 @@ class MockWhatsAppRequest(BaseModel):
class MockWhatsAppResponse(BaseModel):
status: str = "received"
message: str = "Command logged successfully"
class MockWhatsAppLogEntry(BaseModel):
id: int
command: str
from_number: str | None
ticket_id: int | None
received_at: datetime
model_config = {"from_attributes": True}
+146 -33
View File
@@ -1,13 +1,16 @@
"""Authentication service — register, login, refresh."""
"""Authentication service — login, refresh, and admin user management.
Self-registration was removed (HARDENING/P0 batch): users are created and
managed exclusively by admins through the admin user-management endpoints.
"""
from __future__ import annotations
from fastapi import HTTPException, status
from jose import JWTError, jwt
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.roles import is_known_role, normalize_role
from app.core.security import (
create_access_token,
create_refresh_token,
@@ -15,38 +18,35 @@ from app.core.security import (
hash_password,
verify_password,
)
from app.models.ticket import Escalation, Ticket, TicketTimeline
from app.models.user import User
from app.schemas.auth import RegisterRequest
from app.schemas.auth import AdminCreateUserRequest, AdminUpdateUserRequest
async def register(db: AsyncSession, body: RegisterRequest) -> User:
"""Create a new user. Raises 409 if email already exists."""
result = await db.execute(select(User).where(User.email == body.email))
if result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
user = User(
email=body.email,
password_hash=hash_password(body.password),
full_name=body.full_name,
phone=body.phone,
role=body.role,
)
db.add(user)
await db.flush()
await db.refresh(user)
return user
_UNAUTHORIZED = status.HTTP_401_UNAUTHORIZED
# ── AuthN ────────────────────────────────────────────────────────────
async def login(db: AsyncSession, email: str, password: str) -> tuple[str, str, User]:
"""Authenticate and return (access_token, refresh_token, user)."""
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if user is None or not verify_password(password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
if not user.active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account is inactive")
"""Authenticate and return (access_token, refresh_token, user).
Fails closed (401) for bad credentials, inactive accounts, and any user
whose stored role is not part of the unified role model.
"""
result = await db.execute(
select(User)
.where(func.lower(User.email) == email.strip().lower())
.order_by(User.id)
)
user = result.scalars().first()
if user is None or not verify_password(password, user.password_hash):
raise HTTPException(status_code=_UNAUTHORIZED, detail="Invalid email or password")
if not user.active:
raise HTTPException(status_code=_UNAUTHORIZED, detail="Account is inactive")
if not is_known_role(user.role):
raise HTTPException(
status_code=_UNAUTHORIZED,
detail="Account role is not recognised; contact an administrator",
)
access_token = create_access_token({"sub": str(user.id)})
refresh_token = create_refresh_token({"sub": str(user.id)})
return access_token, refresh_token, user
@@ -57,18 +57,131 @@ async def refresh_access_token(db: AsyncSession, token: str) -> tuple[str, str]:
try:
payload = decode_token(token)
if payload.get("type") != "refresh":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
raise HTTPException(status_code=_UNAUTHORIZED, detail="Invalid token type")
except HTTPException:
raise
except Exception:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
raise HTTPException(status_code=_UNAUTHORIZED, detail="Invalid refresh token")
user_id: int = int(payload["sub"])
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None or not user.active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
raise HTTPException(status_code=_UNAUTHORIZED, detail="User not found or inactive")
if not is_known_role(user.role):
raise HTTPException(
status_code=_UNAUTHORIZED,
detail="Account role is not recognised; contact an administrator",
)
new_access = create_access_token({"sub": str(user.id)})
new_refresh = create_refresh_token({"sub": str(user.id)})
return new_access, new_refresh
# ── Admin user management ────────────────────────────────────────────
async def _get_user_or_404(db: AsyncSession, user_id: int) -> User:
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user
async def create_user(db: AsyncSession, body: AdminCreateUserRequest) -> User:
"""Admin-created user with an explicit, canonical role. 409 on duplicate email."""
email = body.email.strip().lower()
result = await db.execute(select(User).where(func.lower(User.email) == email))
if result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
# Defense in depth: schema already guarantees a canonical role.
role = normalize_role(body.role)
if role is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown role")
user = User(
email=email,
password_hash=hash_password(body.password),
full_name=body.full_name.strip(),
phone=body.phone,
role=role,
)
db.add(user)
await db.flush()
await db.refresh(user)
return user
async def update_user(
db: AsyncSession,
actor: User,
user_id: int,
body: AdminUpdateUserRequest,
) -> User:
"""Admin role-change / deactivation for an existing user.
Guards:
* an admin cannot modify their own account through the API (self-lockout);
* role changes are limited to the canonical taxonomy.
The ≥1-active-admin invariant holds structurally: only admins can demote
admins, and no admin can demote/deactivate themselves, so at least one
canonical admin always remains.
"""
user = await _get_user_or_404(db, user_id)
if actor.id == user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Admins cannot change their own role or active state through the API",
)
new_role = normalize_role(body.role) if body.role is not None else None
new_active = body.active
if new_role is not None:
user.role = new_role
if new_active is not None:
user.active = new_active
await db.flush()
await db.refresh(user)
return user
async def delete_user(db: AsyncSession, actor: User, user_id: int) -> None:
"""Admin deletes a user account (hard delete).
Guards:
* an admin cannot delete their own account (self-guard also keeps the
≥1-active-admin invariant: admins can never remove themselves);
* users referenced by tickets / timeline / escalations are kept (409) so
historical data never dangles — reassign or deactivate instead.
"""
user = await _get_user_or_404(db, user_id)
if actor.id == user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Admins cannot delete their own account through the API",
)
referenced = False
for clause in (
select(func.count(Ticket.id)).where(Ticket.assigned_to == user_id),
select(func.count(TicketTimeline.id)).where(TicketTimeline.user_id == user_id),
select(func.count(Escalation.id)).where(Escalation.escalated_to == user_id),
):
count = (await db.execute(clause)).scalar() or 0
if count:
referenced = True
break
if referenced:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="User has related tickets, timeline entries, or escalations; "
"reassign or deactivate instead of deleting",
)
await db.delete(user)
await db.flush()
+122 -3
View File
@@ -13,6 +13,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_password
from app.core.roles import is_blocked_legacy_role, normalize_role
from app.models.unit import Unit
from app.models.user import User
from app.models.category import Category
@@ -41,6 +42,88 @@ SEED_USERS_DATA = [
]
async def normalize_legacy_user_roles(db: AsyncSession) -> int:
"""Converge legacy role strings onto the unified canonical taxonomy.
Databases built before the P0 role-model batch can hold nickname roles
(``technician``, ``cs``, ``fm``, ``ceo`` …) minted by the old open
self-registration. Unambiguous aliases are rewritten to their canonical
role so RBAC keeps working. Ambiguous/unknown roles (e.g. lowercase
``admin``/``superadmin``) are NOT auto-mapped — they fail closed at login
and JWT validation until an operator remediates the row.
Returns the number of rows rewritten. Idempotent.
"""
result = await db.execute(select(User))
changed = 0
for user in result.scalars().all():
canonical = normalize_role(user.role)
if canonical and canonical != user.role:
logger.info("Normalizing legacy role %r → %r for %s", user.role, canonical, user.email)
user.role = canonical
changed += 1
elif canonical is None and not is_blocked_legacy_role(user.role):
logger.warning(
"User %s has unrecognized role %r; login will be denied until fixed",
user.email,
user.role,
)
if changed:
await db.flush()
return changed
async def normalize_legacy_user_emails(db: AsyncSession) -> int:
"""Lowercase stored user emails to match the normalized login lookup.
Databases built before the P0 batch can hold mixed-case emails (the old
open self-registration stored them verbatim) while login now compares on
the lowercase form, so such rows would otherwise be silently locked out.
Rewrites each stored email to its stripped/lowercase form. When two rows
share an email that differs only in case, only the lowest-id row becomes
canonical (any others keep their stored value and are logged as a warning)
so the unique constraint is never violated. Idempotent; returns the
number of rows rewritten.
"""
result = await db.execute(select(User).order_by(User.id))
users = list(result.scalars().all())
groups: dict[str, list[User]] = {}
for user in users:
groups.setdefault(user.email.strip().lower(), []).append(user)
changed = 0
for normalized, members in groups.items():
if len(members) == 1:
user = members[0]
if user.email != normalized:
logger.info(
"Normalizing legacy email %r → %r for user %d", user.email, normalized, user.id
)
user.email = normalized
changed += 1
continue
if any(user.email == normalized for user in members):
losers = [u for u in members if u.email != normalized]
else:
winner = members[0]
logger.info(
"Normalizing legacy email %r → %r for user %d", winner.email, normalized, winner.id
)
winner.email = normalized
changed += 1
losers = members[1:]
for loser in losers:
logger.warning(
"Cannot normalize email %r for user %d: another account already holds "
"that normalized email; keeping the stored value",
normalized,
loser.id,
)
if changed:
await db.flush()
return changed
async def seed_users(db: AsyncSession, default_password: str = "denya123") -> list[User]:
"""Insert seed users if they don't already exist."""
hashed = hash_password(default_password)
@@ -135,23 +218,53 @@ SEED_CATEGORIES_DATA: list[dict] = [
{"type": "maintenance", "name": "Security", "subs": ["Lock broken", "Door not closing", "Window latch", "CCTV issue"]},
{"type": "maintenance", "name": "Internet", "subs": ["WiFi down", "Slow speed", "Router reset"]},
{"type": "maintenance", "name": "Structural", "subs": ["Wall crack", "Ceiling leak", "Floor tile", "Paint touch-up"]},
{"type": "maintenance", "name": "Aluminum/Glass",
"subs": ["Window repair", "Window replacement", "Door repair (sliding)", "Door repair (fixed)",
"Glass replacement", "Aluminum frame repair", "Shower screen", "Mirror replacement"]},
{"type": "maintenance", "name": "Carpentry",
"subs": ["Door repair", "Door replacement", "Door frame", "Cabinet repair", "Wardrobe repair",
"Shelving", "Timber repair", "Loose hinge/lock plate"]},
{"type": "maintenance", "name": "Mould & Damp",
"subs": ["Wall mould", "Ceiling damp patch", "Bathroom mould", "Mould after leak",
"Damp proofing / remediation"],
"sla_urgency": "medium"},
# ── Customer Service ─────────────────────────────────────────
{"type": "cs", "name": "Check-in", "subs": ["Early check-in", "Key handover", "Welcome instructions"]},
{"type": "cs", "name": "Check-out", "subs": ["Late checkout", "Key return", "Inspection"]},
{"type": "cs", "name": "Housekeeping", "subs": ["Mid-stay cleaning", "Linen change", "Restocking"]},
{"type": "cs", "name": "Lost Property", "subs": ["Guest left items behind"]},
{"type": "cs", "name": "Missing Item", "subs": ["Guest left items behind", "Item search request"]},
{"type": "cs", "name": "Billing", "subs": ["Invoice question", "Payment issue", "Deposit query"]},
{"type": "cs", "name": "Staff Behaviour", "subs": ["Staff conduct feedback"]},
# ── Phase 1 additions (maintenance) ──────────────────────────
{"type": "maintenance", "name": "Kitchen Sink", "subs": ["Clogging"]},
{"type": "maintenance", "name": "Painting", "subs": []},
{"type": "maintenance", "name": "Gym", "subs": []},
{"type": "maintenance", "name": "Swimming Pool", "subs": []},
{"type": "maintenance", "name": "Shower Cord", "subs": []},
{"type": "maintenance", "name": "Sliding Doors", "subs": []},
{"type": "maintenance", "name": "Sliding Windows", "subs": []},
{"type": "maintenance", "name": "Low Water Pressure", "subs": []},
{"type": "maintenance", "name": "Damages", "subs": []},
# ── Phase 1 additions (customer service) ─────────────────────
{"type": "cs", "name": "Parking Issues", "subs": []},
{"type": "cs", "name": "Noise Complaints", "subs": []},
{"type": "cs", "name": "Waste Management", "subs": []},
# ── Emergency ────────────────────────────────────────────────
{"type": "emergency", "name": "Fire", "subs": ["Smoke detected", "Fire alarm", "Sprinkler issue"], "sla_urgency": "urgent"},
{"type": "emergency", "name": "Flood", "subs": ["Major water leak", "Burst pipe", "Overflowing"], "sla_urgency": "urgent"},
{"type": "emergency", "name": "Gas Leak", "subs": ["Gas smell", "Suspected leak"], "sla_urgency": "urgent"},
{"type": "emergency", "name": "Gas Leak", "subs": ["Gas smell", "Suspected leak"], "sla_urgency": "urgent", "show_in_form": False},
{"type": "emergency", "name": "Electrical Hazard", "subs": ["Sparking", "Exposed wires", "Power outage multiple units"], "sla_urgency": "urgent"},
]
async def seed_categories(db: AsyncSession) -> list[Category]:
"""Seed the categories table with the full PRD §7 taxonomy."""
"""Seed the categories table with the full PRD §7 taxonomy.
Idempotent: only inserts missing rows. ``show_in_form`` on existing
parents is synced when the seed data explicitly sets it (e.g. Gas Leak
is alert-only, so already-seeded databases get flipped to False on the
next startup).
"""
created: list[Category] = []
for group in SEED_CATEGORIES_DATA:
@@ -170,10 +283,16 @@ async def seed_categories(db: AsyncSession) -> list[Category]:
name=group["name"],
parent_id=None,
sla_urgency=group.get("sla_urgency"),
show_in_form=group.get("show_in_form", True),
)
db.add(parent)
await db.flush()
created.append(parent)
elif "show_in_form" in group and parent.show_in_form != group["show_in_form"]:
# Sync visibility for existing rows (e.g. Gas Leak → alert-only)
parent.show_in_form = group["show_in_form"]
await db.flush()
created.append(parent)
# Seed sub-categories
for sub_name in group["subs"]:
+5 -2
View File
@@ -55,9 +55,12 @@ def should_escalate_on_response(ticket: Ticket) -> bool:
return datetime.now(timezone.utc) > deadline
TERMINAL_STATUSES = {"Closed", "Completed", "Cancelled"}
def is_sla_breached(ticket: Ticket) -> bool:
"""Return True if the ticket's resolution SLA deadline has passed."""
if ticket.sla_deadline is None or ticket.status in ("Closed", "Completed"):
if ticket.sla_deadline is None or ticket.status in TERMINAL_STATUSES:
return False
deadline = ticket.sla_deadline
if deadline.tzinfo is None:
@@ -88,7 +91,7 @@ async def get_sla_status(ticket: Ticket) -> dict:
rd = resolution_deadline
if rd.tzinfo is None:
rd = rd.replace(tzinfo=timezone.utc)
resolution_breached = now > rd if ticket.status not in ("Closed", "Completed") else False
resolution_breached = now > rd if ticket.status not in TERMINAL_STATUSES else False
return {
"priority": ticket.priority,
+96 -25
View File
@@ -6,11 +6,11 @@ from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy import delete as sa_delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.ticket import Escalation, Ticket, TicketTimeline
from app.models.ticket import Escalation, Ticket, TicketPhoto, TicketTimeline
from app.models.unit import Unit
from app.models.user import User
from app.services.sla import compute_sla_deadline
@@ -18,21 +18,23 @@ from app.services.sla import compute_sla_deadline
# ── Status Transition Map ────────────────────────────────────────────
# Keys: current status → list of valid next statuses
VALID_TRANSITIONS: dict[str, list[str]] = {
"New": ["Logged"],
"Logged": ["Triage", "Closed"],
"Triage": ["Assigned", "Escalated"],
"Assigned": ["Accepted", "Triage"],
"Accepted": ["Travelling", "Triage"],
"Travelling": ["On Site", "Triage"],
"On Site": ["In Progress", "Triage"],
"In Progress": ["Waiting Parts", "Escalated", "Completed"],
"Waiting Parts": ["In Progress", "Escalated"],
"Escalated": ["Triage", "In Progress", "Completed", "Closed"],
"Completed": ["On-Field Verification", "In Progress"],
"On-Field Verification": ["Wahab Review", "Completed", "Closed"],
"Wahab Review": ["Closed", "On-Field Verification"],
"New": ["Logged", "Cancelled"],
"Logged": ["Triage", "Closed", "Cancelled"],
"Triage": ["Assigned", "Escalated", "Cancelled"],
"Assigned": ["Accepted", "Triage", "Cancelled"],
"Accepted": ["Travelling", "Triage", "Cancelled"],
"Travelling": ["On Site", "Triage", "Cancelled"],
"On Site": ["In Progress", "Triage", "Cancelled"],
"In Progress": ["Waiting Parts", "Escalated", "Completed", "Cancelled"],
"Waiting Parts": ["In Progress", "Escalated", "Cancelled"],
"Escalated": ["Triage", "In Progress", "Completed", "Closed", "Cancelled"],
"Completed": ["On-Field Verification", "In Progress", "Cancelled"],
"On-Field Verification": ["Wahab Review", "Completed", "Closed", "Cancelled"],
"Wahab Review": ["Closed", "On-Field Verification", "Cancelled"],
"Closed": ["Reopened"],
"Reopened": ["Triage", "Logged"],
"Reopened": ["Triage", "Logged", "Cancelled"],
# Terminal: cancelled tickets cannot resume work.
"Cancelled": [],
}
REOPEN_WINDOW_DAYS = 7
@@ -41,15 +43,25 @@ SLA_ACK_USER = "Ama"
# ── Helpers ──────────────────────────────────────────────────────────
async def _generate_ticket_number(db: AsyncSession) -> str:
"""Generate the next ticket number in PAV-YYYY-NNNNN format."""
"""Generate the next ticket number in PAV-YYYY-NNNNN format.
Uses the highest existing suffix + 1 (not a row count) so that deleting
tickets never re-issues an already-used number.
"""
year = datetime.now(timezone.utc).year
prefix = f"PAV-{year}-"
# Count existing tickets this year
result = await db.execute(
select(func.count(Ticket.id)).where(Ticket.ticket_number.like(f"{prefix}%"))
select(func.max(Ticket.ticket_number)).where(Ticket.ticket_number.like(f"{prefix}%"))
)
count = result.scalar() or 0
return f"{prefix}{count + 1:05d}"
max_number = result.scalar()
if max_number:
try:
next_seq = int(max_number.rsplit("-", 1)[1]) + 1
except (ValueError, IndexError):
next_seq = 1
else:
next_seq = 1
return f"{prefix}{next_seq:05d}"
async def _log_status_change(
@@ -102,6 +114,10 @@ async def create_ticket(
ticket_number = await _generate_ticket_number(db)
priority = data.get("priority")
sla_deadline = compute_sla_deadline(priority) if priority else None
# Original report date: backdated/backfilled tickets keep their true date;
# when omitted the ticket is considered reported right now. The SLA clock
# is unchanged — deadlines run from creation time, not the reported date.
reported_at = data.get("reported_at") or datetime.now(timezone.utc)
ticket = Ticket(
ticket_number=ticket_number,
@@ -109,10 +125,12 @@ async def create_ticket(
unit_id=data.get("unit_id"),
category_id=data.get("category_id"),
priority=priority,
reporter=data.get("reporter"),
reporter=data.get("reporter") or data.get("customer_name"),
phone=data.get("phone"),
reported_via=data.get("reported_via"),
description=data.get("description"),
assigned_to=data.get("assigned_to"),
reported_at=reported_at,
sla_deadline=sla_deadline,
)
db.add(ticket)
@@ -156,6 +174,8 @@ async def list_tickets(
status_filter: str | None = None,
priority_filter: str | None = None,
property_filter: str | None = None,
building_filter: str | None = None,
unit_id: int | None = None,
category_id: int | None = None,
assigned_to: int | None = None,
date_from: datetime | None = None,
@@ -177,6 +197,12 @@ async def list_tickets(
# Join unit to filter by property
query = query.join(Ticket.unit).where(Unit.property == property_filter)
count_query = count_query.join(Ticket.unit).where(Unit.property == property_filter)
if building_filter:
query = query.join(Ticket.unit).where(Unit.building == building_filter)
count_query = count_query.join(Ticket.unit).where(Unit.building == building_filter)
if unit_id:
query = query.where(Ticket.unit_id == unit_id)
count_query = count_query.where(Ticket.unit_id == unit_id)
if category_id:
query = query.where(Ticket.category_id == category_id)
count_query = count_query.where(Ticket.category_id == category_id)
@@ -196,7 +222,7 @@ async def list_tickets(
# Paginate
offset = (page - 1) * page_size
query = query.order_by(Ticket.created_at.desc()).offset(offset).limit(page_size)
query = query.order_by(Ticket.created_at.desc(), Ticket.id.desc()).offset(offset).limit(page_size).options(selectinload(Ticket.assigned_technician))
result = await db.execute(query)
tickets = list(result.scalars().all())
@@ -214,8 +240,9 @@ async def update_ticket(
# Handle status transitions separately
new_status = data.get("status")
old_status = ticket.status
status_changed = new_status is not None and old_status != new_status
if new_status is not None:
old_status = ticket.status
if old_status != new_status:
valid_targets = VALID_TRANSITIONS.get(old_status, [])
if new_status not in valid_targets:
@@ -275,8 +302,36 @@ async def update_ticket(
ticket.status = new_status
# Assigning a technician advances pre-Assigned tickets to Assigned with a
# timeline entry; tickets already past Assigned keep their current status.
advanced_to_assigned = False
if "assigned_to" in data and data["assigned_to"] != ticket.assigned_to:
if ticket.status in {"New", "Logged", "Triage"}:
await _log_status_change(
db,
ticket.id,
from_status=ticket.status,
to_status="Assigned",
note=data.get("note") if not status_changed else None,
user_id=user.id if user else None,
)
ticket.status = "Assigned"
advanced_to_assigned = True
# Handle standalone note (no status change)
note_only = data.get("note")
if note_only and not status_changed and not advanced_to_assigned:
await _log_status_change(
db,
ticket.id,
from_status=ticket.status,
to_status=ticket.status,
note=note_only,
user_id=user.id if user else None,
)
# Update other fields
for field in ("unit_id", "category_id", "priority", "reporter", "reported_via",
for field in ("unit_id", "category_id", "priority", "reporter", "phone", "reported_via",
"description", "assigned_to", "eta", "cost", "parts_used"):
if field in data:
setattr(ticket, field, data[field])
@@ -290,3 +345,19 @@ async def update_ticket(
return ticket
async def delete_ticket(db: AsyncSession, ticket_id: int) -> Ticket:
"""Delete a ticket and all dependent rows (timeline, photos, escalations).
Returns the deleted ticket so the caller can remove orphaned photo files.
"""
ticket = await _get_ticket_or_404(db, ticket_id)
# Delete escalations, timeline, and photo rows first (FK children).
await db.execute(sa_delete(Escalation).where(Escalation.ticket_id == ticket_id))
await db.execute(sa_delete(TicketTimeline).where(TicketTimeline.ticket_id == ticket_id))
await db.execute(sa_delete(TicketPhoto).where(TicketPhoto.ticket_id == ticket_id))
await db.delete(ticket)
await db.flush()
return ticket
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+392
View File
@@ -0,0 +1,392 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Denya OneCare</title>
<!-- Vendored same-origin (no CDN): app/static/vendor/ — LAN-safe demo -->
<script src="/static/vendor/alpine-3.17.2.min.js" defer></script>
<script src="/static/vendor/tailwind-3.4.17.js"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
denya: {
50: '#e8f0ea',
100: '#c5d9cb',
200: '#9ebfaa',
300: '#74a589',
400: '#4d8c69',
500: '#2d734d',
600: '#1d5a3a',
700: '#0d2b18',
800: '#0a2012',
900: '#07150c',
},
gold: {
DEFAULT: '#c8a96e',
light: '#e8d5a8',
dark: '#a88a4e',
},
cream: '#faf8f5',
}
}
}
}
</script>
<style>
[x-cloak] { display: none !important; }
.status-new { @apply bg-gray-100 text-gray-800; }
.status-logged { @apply bg-blue-100 text-blue-800; }
.status-triage { @apply bg-yellow-100 text-yellow-800; }
.status-assigned { @apply bg-indigo-100 text-indigo-800; }
.status-accepted { @apply bg-purple-100 text-purple-800; }
.status-travelling { @apply bg-cyan-100 text-cyan-800; }
.status-onsite { @apply bg-teal-100 text-teal-800; }
.status-inprogress { @apply bg-sky-100 text-sky-800; }
.status-waitingparts { @apply bg-orange-100 text-orange-800; }
.status-escalated { @apply bg-red-100 text-red-800; }
.status-completed { @apply bg-green-100 text-green-800; }
.status-on-field-verification { @apply bg-emerald-100 text-emerald-800; }
.status-wahab-review { @apply bg-violet-100 text-violet-800; }
.status-closed { @apply bg-gray-200 text-gray-600; }
.status-reopened { @apply bg-pink-100 text-pink-800; }
.status-cancelled { @apply bg-gray-300 text-gray-700 line-through; }
.priority-urgent { @apply bg-red-100 text-red-800 border-red-300; }
.priority-high { @apply bg-orange-100 text-orange-800 border-orange-300; }
.priority-medium { @apply bg-yellow-100 text-yellow-800 border-yellow-300; }
.priority-low { @apply bg-green-100 text-green-800 border-green-300; }
.brand-gradient { background: linear-gradient(135deg, #0d2b18 0%, #1a3d24 100%); }
</style>
</head>
<body class="bg-cream min-h-screen text-[#1a1a1a]" x-data="app()" x-init="init()">
<!-- Nav Bar -->
<nav class="bg-[#0d2b18] border-b border-denya-800 shadow-lg sticky top-0 z-50" x-show="isLoggedIn" x-cloak>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Left side -->
<div class="flex items-center space-x-4">
<a href="/dashboard/cs" class="flex items-center space-x-3">
<!-- Denya Developers Logo Mark -->
<div class="w-9 h-9 bg-gold rounded-lg flex items-center justify-center shadow-sm">
<svg class="w-5 h-5 text-[#0d2b18]" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
</div>
<div class="flex flex-col">
<span class="text-white font-bold text-base leading-tight">Denya Developers</span>
<span class="text-gold text-xs leading-tight font-medium">OneCare</span>
</div>
</a>
<!-- Nav Links -->
<template x-if="isCS">
<div class="hidden md:flex space-x-1 ml-6">
<a href="/dashboard/cs" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath === '/dashboard/cs' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Dashboard</a>
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">All Issues</a>
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-denya-800/50 transition-colors">Create Issue</a>
</div>
</template>
<template x-if="isFM">
<div class="hidden md:flex space-x-1 ml-6">
<a href="/dashboard/fm" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath === '/dashboard/fm' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Dashboard</a>
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">All Issues</a>
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-denya-800/50 transition-colors">Create Issue</a>
</div>
</template>
<template x-if="isExecutive">
<div class="hidden md:flex space-x-1 ml-6">
<a href="/dashboard/ceo" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Dashboard</a>
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath.startsWith('/tickets') ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Issues</a>
</div>
</template>
</div>
<!-- Right side -->
<div class="flex items-center space-x-4">
<span class="text-sm text-gray-300 hidden md:block" x-text="`${user.full_name} (${user.role})`"></span>
<button @click="logout()" class="px-3 py-1.5 text-sm text-gold hover:text-gold-light hover:bg-denya-800/50 rounded-md transition-colors border border-denya-600">
Logout
</button>
</div>
</div>
</div>
</nav>
<!-- Mobile Nav -->
<div class="md:hidden border-b bg-[#0d2b18] border-denya-800" x-show="isLoggedIn" x-cloak>
<template x-if="isCS || isFM">
<div class="flex overflow-x-auto px-4 py-2 space-x-2">
<a href="/dashboard/cs" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/cs' ? 'bg-denya-800 text-gold' : 'text-gray-300'">Dashboard</a>
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-800 text-gold' : 'text-gray-300'">Issues</a>
<a href="/tickets/new" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-300">New Issue</a>
</div>
</template>
<template x-if="isExecutive">
<div class="flex overflow-x-auto px-4 py-2 space-x-2">
<a href="/dashboard/ceo" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-800 text-gold' : 'text-gray-300'">CEO Dashboard</a>
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-300">Issues</a>
</div>
</template>
</div>
<!-- Main Content -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{% block content %}{% endblock %}
</main>
<!-- Loading Overlay -->
<div x-show="loading" class="fixed inset-0 bg-[#0d2b18]/40 z-50 flex items-center justify-center" x-cloak>
<div class="bg-white rounded-xl p-6 flex items-center space-x-3 shadow-2xl border border-denya-200">
<svg class="animate-spin h-6 w-6 text-denya-700" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span class="text-denya-800 font-medium" x-text="loadingMessage || 'Loading...'"></span>
</div>
</div>
<!-- Toast Notifications -->
<div class="fixed bottom-4 right-4 z-50 space-y-2">
<template x-for="toast in toasts" :key="toast.id">
<div class="px-4 py-3 rounded-lg shadow-lg text-white text-sm font-medium transition-all duration-300"
:class="{'bg-denya-600': toast.type === 'success', 'bg-red-600': toast.type === 'error', 'bg-denya-500': toast.type === 'info', 'bg-gold': toast.type === 'warning'}"
x-init="setTimeout(() => { toasts = toasts.filter(t => t.id !== toast.id) }, toast.duration || 4000)">
<span x-text="toast.message"></span>
</div>
</template>
</div>
<script>
function app() {
return {
// Auth state
user: JSON.parse(localStorage.getItem('user') || '{}'),
token: localStorage.getItem('access_token') || null,
isLoggedIn: !!localStorage.getItem('access_token'),
currentPath: window.location.pathname,
// UI state
loading: false,
loadingMessage: '',
toasts: [],
// Role helpers
get isCS() { return ['CS Rep', 'CS Manager'].includes(this.user.role) },
get isFM() { return ['FM Dispatcher', 'Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) },
get isExecutive() { return ['CEO', 'Director'].includes(this.user.role) },
get isAdmin() { return ['Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) },
init() {
if (!this.isLoggedIn && this.currentPath !== '/login') {
window.location.href = '/login';
return;
}
if (this.isLoggedIn) {
this.fetchMe();
}
},
async fetchMe() {
try {
const res = await fetch('/api/auth/me', {
headers: this.authHeaders()
});
if (res.ok) {
const userData = await res.json();
this.user = userData;
localStorage.setItem('user', JSON.stringify(userData));
} else if (res.status === 401) {
this.logout();
}
} catch (e) {
console.error('Auth check failed', e);
}
},
authHeaders() {
const headers = { 'Content-Type': 'application/json' };
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
return headers;
},
async api(method, url, body = null) {
this.loading = true;
try {
const opts = {
method,
headers: this.authHeaders()
};
if (body && !(body instanceof FormData)) {
opts.body = JSON.stringify(body);
} else if (body instanceof FormData) {
opts.body = body;
delete opts.headers['Content-Type'];
opts.headers = { 'Authorization': `Bearer ${this.token}` };
}
const res = await fetch(url, opts);
if (res.status === 401 && this.currentPath !== '/login') {
this.logout();
return null;
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return await res.json();
} catch (e) {
this.showToast(e.message, 'error');
throw e;
} finally {
this.loading = false;
}
},
apiGet(url) { return this.api('GET', url); },
apiPost(url, body) { return this.api('POST', url, body); },
apiPatch(url, body) { return this.api('PATCH', url, body); },
apiDelete(url) { return this.api('DELETE', url); },
async login(email, password) {
try {
this.loading = true;
this.loadingMessage = 'Signing in...';
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Login failed' }));
throw new Error(err.detail);
}
const data = await res.json();
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
this.token = data.access_token;
const me = await this.apiGet('/api/auth/me');
this.user = me;
localStorage.setItem('user', JSON.stringify(me));
this.isLoggedIn = true;
const role = me.role;
if (['CS Rep', 'CS Manager'].includes(role)) {
window.location.href = '/dashboard/cs';
} else if (['FM Dispatcher', 'Admin/Jerome', 'Admin/Wahab'].includes(role)) {
window.location.href = '/dashboard/fm';
} else if (['CEO', 'Director'].includes(role)) {
window.location.href = '/dashboard/ceo';
} else {
window.location.href = '/tickets';
}
} catch (e) {
this.showToast(e.message, 'error');
throw e;
} finally {
this.loading = false;
this.loadingMessage = '';
}
},
logout() {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
this.user = {};
this.token = null;
this.isLoggedIn = false;
window.location.href = '/login';
},
showToast(message, type = 'info', duration = 4000) {
const id = Date.now() + Math.random();
this.toasts.push({ id, message, type, duration });
},
statusClass(status) {
if (!status) return 'bg-gray-100 text-gray-800';
const map = {
'new': 'status-new',
'logged': 'status-logged',
'triage': 'status-triage',
'assigned': 'status-assigned',
'accepted': 'status-accepted',
'travelling': 'status-travelling',
'on site': 'status-onsite',
'in progress': 'status-inprogress',
'waiting parts': 'status-waitingparts',
'escalated': 'status-escalated',
'completed': 'status-completed',
'on-field verification': 'status-on-field-verification',
'wahab review': 'status-wahab-review',
'closed': 'status-closed',
'reopened': 'status-reopened',
'cancelled': 'status-cancelled'
};
return map[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
},
priorityBadge(priority) {
const labels = { urgent: '🔴 Urgent', high: '🟠 High', medium: '🟡 Medium', low: '🟢 Low' };
return labels[priority] || priority;
},
priorityClass(priority) {
const map = { urgent: 'priority-urgent', high: 'priority-high', medium: 'priority-medium', low: 'priority-low' };
return map[priority] || 'bg-gray-100 text-gray-800';
},
formatDate(dateStr) {
if (!dateStr) return '-';
const d = new Date(dateStr);
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
},
formatDateShort(dateStr) {
if (!dateStr) return '-';
const d = new Date(dateStr);
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
},
timeSince(dateStr) {
if (!dateStr) return '';
const now = new Date();
const d = new Date(dateStr);
const diffMs = now - d;
const diffHrs = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffHrs < 1) return '< 1h';
if (diffHrs < 24) return `${diffHrs}h`;
return `${diffDays}d`;
},
groupByPriority(tickets, ageDir = 'desc') {
// Group tickets into Urgent/High/Medium/Low + an other bucket,
// each sorted by age (desc = newest first, asc = oldest first).
const order = ['urgent', 'high', 'medium', 'low'];
const groups = { urgent: [], high: [], medium: [], low: [], other: [] };
const age = (t) => new Date(t.created_at).getTime() || 0;
(tickets || []).forEach(t => {
const p = String(t.priority || '').toLowerCase();
groups[order.includes(p) ? p : 'other'].push(t);
});
Object.keys(groups).forEach(k => {
groups[k].sort((a, b) => (ageDir === 'asc' ? age(a) - age(b) : age(b) - age(a)));
});
return groups;
},
priorityGroupMeta() {
return [
{ key: 'urgent', label: '🔴 Urgent', cls: 'border-red-200 bg-red-50' },
{ key: 'high', label: '🟠 High', cls: 'border-orange-200 bg-orange-50' },
{ key: 'medium', label: '🟡 Medium', cls: 'border-yellow-200 bg-yellow-50' },
{ key: 'low', label: '🟢 Low', cls: 'border-green-200 bg-green-50' },
{ key: 'other', label: '⚪ No priority / Unknown', cls: 'border-gray-200 bg-gray-50' },
];
}
}
}
</script>
</body>
</html>
+303
View File
@@ -0,0 +1,303 @@
{% extends "base.html" %}
{% block content %}
<div x-data="ceoDashboard()" x-init="init()">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Executive Dashboard</h1>
<p class="text-gray-500 mt-1">Read-only strategic overview</p>
</div>
<!-- Executive KPI Cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Open Tickets</p>
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.openTickets || 0"></p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Critical (Urgent)</p>
<p class="text-3xl font-bold text-red-600 mt-1" x-text="kpi.criticalCount || 0"></p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">SLA Compliance</p>
<p class="text-3xl font-bold mt-1" :class="kpi.slaCompliance >= 90 ? 'text-green-600' : kpi.slaCompliance >= 70 ? 'text-yellow-600' : 'text-red-600'" x-text="(kpi.slaCompliance || 0) + '%'"></p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Avg Resolution</p>
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.avgResolutionTime || '—'"></p>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Avg Response</p>
<p class="text-2xl font-bold text-gray-900 mt-1" x-text="kpi.avgResponseTime || '—'"></p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Reopened This Week</p>
<p class="text-2xl font-bold text-orange-600 mt-1" x-text="kpi.reopenedThisWeek || 0"></p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Total Tickets</p>
<p class="text-2xl font-bold text-gray-900 mt-1" x-text="kpi.totalTickets || 0"></p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Completion Rate</p>
<p class="text-2xl font-bold mt-1" :class="kpi.completionRate >= 70 ? 'text-green-600' : 'text-yellow-600'" x-text="(kpi.completionRate || 0) + '%'"></p>
</div>
</div>
<!-- Charts Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Complaints by Property (Simple Bar) -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Complaints by Property</h3>
<div class="flex items-end space-x-6 h-40 px-2">
<div class="flex-1 flex flex-col items-center">
<span class="text-lg font-bold text-denya-600" x-text="charts.byProperty?.east || 0"></span>
<div class="w-full bg-denya-200 rounded-t-lg mt-1 transition-all" :style="'height: ' + Math.max((charts.byProperty?.east || 0) / Math.max(charts.byProperty?.max || 1, 1) * 120, 8) + 'px'" style="height: 8px;"></div>
<span class="text-xs text-gray-500 mt-2">East</span>
</div>
<div class="flex-1 flex flex-col items-center">
<span class="text-lg font-bold text-denya-600" x-text="charts.byProperty?.west || 0"></span>
<div class="w-full bg-denya-400 rounded-t-lg mt-1 transition-all" :style="'height: ' + Math.max((charts.byProperty?.west || 0) / Math.max(charts.byProperty?.max || 1, 1) * 120, 8) + 'px'" style="height: 8px;"></div>
<span class="text-xs text-gray-500 mt-2">West</span>
</div>
</div>
</div>
<!-- Complaints by Category -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Complaints by Category</h3>
<div class="space-y-2 max-h-64 overflow-y-auto">
<template x-for="item in charts.byCategory" :key="item.name">
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-600 w-20 truncate" x-text="item.name"></span>
<div class="flex-1 bg-gray-100 rounded-full h-4 overflow-hidden">
<div class="h-full rounded-full transition-all" :style="'width: ' + (item.count / Math.max(charts.byCategoryMax, 1) * 100) + '%'" :class="item.color || 'bg-denya-500'"></div>
</div>
<span class="text-xs font-medium text-gray-600 w-8 text-right" x-text="item.count"></span>
</div>
</template>
<div x-show="!charts.byCategory?.length" class="text-gray-400 text-sm py-6 text-center">No data yet</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Priority Distribution -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Tickets by Priority</h3>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-red-600">🔴 Urgent</span>
<span class="text-2xl font-bold" x-text="charts.byPriority?.urgent || 0"></span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-orange-600">🟠 High</span>
<span class="text-2xl font-bold" x-text="charts.byPriority?.high || 0"></span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-yellow-600">🟡 Medium</span>
<span class="text-2xl font-bold" x-text="charts.byPriority?.medium || 0"></span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-green-600">🟢 Low</span>
<span class="text-2xl font-bold" x-text="charts.byPriority?.low || 0"></span>
</div>
</div>
</div>
<!-- Monthly Trends (Simple) -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Monthly Trends</h3>
<div class="flex items-end space-x-1 h-40 overflow-x-auto pb-2">
<template x-for="(item, idx) in charts.monthlyTrend" :key="idx">
<div class="flex-1 flex flex-col items-center min-w-[30px]">
<span class="text-xs font-medium text-gray-600 mb-1" x-text="item.count"></span>
<div class="w-full bg-denya-300 rounded-t transition-all" :style="'height: ' + Math.max(item.count / Math.max(charts.monthlyMax || 1, 1) * 100, 4) + 'px'"></div>
<span class="text-xs text-gray-400 mt-1" x-text="item.month"></span>
</div>
</template>
<div x-show="!charts.monthlyTrend?.length" class="text-gray-400 text-sm py-6 text-center w-full">No data yet</div>
</div>
</div>
</div>
<!-- Risk Indicators -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-8">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Risk Indicators</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="p-3 rounded-lg" :class="indicators.openEmergencies > 0 ? 'bg-red-50 border border-red-200' : 'bg-green-50 border border-green-200'">
<div class="flex items-center space-x-2">
<div class="w-3 h-3 rounded-full" :class="indicators.openEmergencies > 0 ? 'bg-red-500 animate-pulse' : 'bg-green-500'"></div>
<span class="text-xs font-medium">Open Emergencies</span>
</div>
<p class="text-lg font-bold mt-1" x-text="indicators.openEmergencies || 0"></p>
</div>
<div class="p-3 rounded-lg" :class="indicators.reopenedThisWeek > 0 ? 'bg-orange-50 border border-orange-200' : 'bg-green-50 border border-green-200'">
<div class="flex items-center space-x-2">
<div class="w-3 h-3 rounded-full" :class="indicators.reopenedThisWeek > 0 ? 'bg-orange-500' : 'bg-green-500'"></div>
<span class="text-xs font-medium">Reopened This Week</span>
</div>
<p class="text-lg font-bold mt-1" x-text="indicators.reopenedThisWeek || 0"></p>
</div>
<div class="p-3 rounded-lg" :class="indicators.overdueJobs > 0 ? 'bg-red-50 border border-red-200' : 'bg-green-50 border border-green-200'">
<div class="flex items-center space-x-2">
<div class="w-3 h-3 rounded-full" :class="indicators.overdueJobs > 0 ? 'bg-red-500' : 'bg-green-500'"></div>
<span class="text-xs font-medium">Overdue (Past SLA)</span>
</div>
<p class="text-lg font-bold mt-1" x-text="indicators.overdueJobs || 0"></p>
</div>
<div class="p-3 rounded-lg" :class="indicators.pendingVerification > 0 ? 'bg-yellow-50 border border-yellow-200' : 'bg-green-50 border border-green-200'">
<div class="flex items-center space-x-2">
<div class="w-3 h-3 rounded-full" :class="indicators.pendingVerification > 0 ? 'bg-yellow-500' : 'bg-green-500'"></div>
<span class="text-xs font-medium">Pending Verification</span>
</div>
<p class="text-lg font-bold mt-1" x-text="indicators.pendingVerification || 0"></p>
</div>
</div>
</div>
</div>
<script>
function ceoDashboard() {
return {
kpi: {},
charts: { byProperty: {}, byCategory: [], byPriority: {}, monthlyTrend: [], byCategoryMax: 1, monthlyMax: 1 },
indicators: {},
async init() {
await this.loadData();
},
async loadData() {
try {
// Fetch ALL tickets via pagination. The API caps page_size at 200
// (app/routers/tickets.py), so a single page_size=500 request returns 422
// and the dashboard renders empty KPIs. Loop pages until we have `total`
// tickets so KPIs stay accurate as volume grows past 200.
const all = [];
const pageSize = 200;
let page = 1;
let total = Infinity;
while (all.length < total && page <= 1000) { // 1000-page safety bound
const allData = await app().apiGet(`/api/tickets?page=${page}&page_size=${pageSize}`);
if (!allData?.items || !allData.items.length) break;
all.push(...allData.items);
total = allData.total ?? all.length;
page += 1;
}
if (!all.length) return;
// Basic KPIs
const open = all.filter(t => !['Closed', 'Completed', 'Cancelled'].includes(t.status));
const closed = all.filter(t => ['Closed', 'Completed', 'Cancelled'].includes(t.status));
const urgent = all.filter(t => t.priority === 'urgent');
const withSLA = all.filter(t => t.sla_deadline);
const slaMet = withSLA.filter(t => new Date(t.sla_deadline) > new Date() || ['Closed', 'Completed', 'Cancelled'].includes(t.status));
// Monthly restored
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const reopenedThisWeek = all.filter(t => t.status === 'Reopened' && new Date(t.updated_at) >= oneWeekAgo).length;
this.kpi = {
openTickets: open.length,
criticalCount: urgent.length,
slaCompliance: withSLA.length ? Math.round((slaMet.length / withSLA.length) * 100) : 100,
avgResolutionTime: this.calcAvgResolution(all),
avgResponseTime: this.calcAvgResponse(all),
reopenedThisWeek,
totalTickets: total,
completionRate: total ? Math.round((closed.length / total) * 100) : 0,
};
// Chart data
const byPriority = { urgent: 0, high: 0, medium: 0, low: 0 };
open.forEach(t => { if (t.priority) byPriority[t.priority]++; });
this.charts.byPriority = byPriority;
// Monthly trends (last 6 months)
const months = {};
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
all.forEach(t => {
const d = new Date(t.created_at);
const key = `${d.getFullYear()}-${d.getMonth()}`;
months[key] = (months[key] || 0) + 1;
});
const sortedMonths = Object.keys(months).sort().slice(-6);
this.charts.monthlyTrend = sortedMonths.map(k => {
const [y, m] = k.split('-').map(Number);
return { month: monthNames[m], count: months[k] };
});
this.charts.monthlyMax = Math.max(...this.charts.monthlyTrend.map(m => m.count), 1);
// By property — load units once and compute from ticket data
const propData = { east: 0, west: 0 };
try {
const units = await app().apiGet('/api/tickets/units');
if (units && units.length > 0) {
const unitPropertyMap = {};
units.forEach(u => { unitPropertyMap[u.id] = u.property; });
all.forEach(t => {
if (t.unit_id && unitPropertyMap[t.unit_id]) {
const p = unitPropertyMap[t.unit_id].toLowerCase();
if (p === 'east') propData.east++;
else if (p === 'west') propData.west++;
}
});
}
} catch (e) { console.error('Property stats error', e); }
this.charts.byProperty = { east: propData.east, west: propData.west, max: Math.max(propData.east, propData.west, 1) };
// By category — use categories endpoint
// include_hidden=true so alert-only categories (Gas Leak) stay labeled in reporting
try {
const cats = await app().apiGet('/api/tickets/categories/flat?include_hidden=true');
const catCounts = {};
all.forEach(t => {
if (t.category_id) {
const cat = cats?.find(c => c.id === t.category_id);
const name = cat ? cat.name : `Cat #${t.category_id}`;
catCounts[name] = (catCounts[name] || 0) + 1;
}
});
const colors = ['bg-blue-500', 'bg-green-500', 'bg-yellow-500', 'bg-red-500', 'bg-purple-500', 'bg-pink-500', 'bg-indigo-500', 'bg-teal-500'];
this.charts.byCategory = Object.entries(catCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([name, count], i) => ({ name, count, color: colors[i % colors.length] }));
this.charts.byCategoryMax = Math.max(...this.charts.byCategory.map(c => c.count), 1);
} catch (e) { console.error('Category stats error', e); }
// Risk indicators
this.indicators = {
openEmergencies: urgent.filter(t => !['Closed', 'Completed', 'Cancelled'].includes(t.status)).length,
reopenedThisWeek,
overdueJobs: open.filter(t => t.sla_deadline && new Date(t.sla_deadline) < new Date()).length,
pendingVerification: all.filter(t => ['Completed', 'On-Field Verification'].includes(t.status)).length,
};
} catch (e) { console.error('CEO data load error', e); }
},
calcAvgResponse(all) {
const open = all.filter(t => !['Closed', 'Completed', 'Cancelled'].includes(t.status));
if (!open.length) return '—';
const avgHrs = open.reduce((sum, t) => sum + Math.min((new Date() - new Date(t.created_at)) / (1000 * 60 * 60), 168), 0) / open.length;
if (avgHrs < 1) return `${Math.round(avgHrs * 60)}m`;
return `${Math.round(avgHrs)}h`;
},
calcAvgResolution(all) {
const closed = all.filter(t => ['Closed', 'Completed'].includes(t.status) && t.created_at && t.updated_at);
if (!closed.length) return '—';
const avgHrs = closed.reduce((sum, t) => {
const diff = (new Date(t.updated_at) - new Date(t.created_at)) / (1000 * 60 * 60);
return sum + diff;
}, 0) / closed.length;
if (avgHrs < 1) return `${Math.round(avgHrs * 60)}m`;
return `${Math.round(avgHrs)}h`;
}
}
}
</script>
{% endblock %}
+245
View File
@@ -0,0 +1,245 @@
{% extends "base.html" %}
{% block content %}
<div x-data="csDashboard()" x-init="init()">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Customer Service Dashboard</h1>
<p class="text-gray-500 mt-1">Welcome back, <span x-text="user.full_name"></span></p>
</div>
<!-- KPI Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 font-medium">New Today</p>
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.newToday || 0"></p>
</div>
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 font-medium">Open Tickets</p>
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.openTickets || 0"></p>
</div>
<div class="w-12 h-12 bg-yellow-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 font-medium">Avg Response Time</p>
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.avgResponseTime || '—'"></p>
</div>
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 font-medium">SLA Compliance</p>
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="(kpi.slaCompliance || 0) + '%'"></p>
</div>
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
</div>
<!-- Priority Breakdown + Escalated -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<!-- Open by Priority -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Open by Priority</h3>
<div class="space-y-2">
<a href="/tickets?group=priority&priority=urgent" class="flex items-center justify-between rounded-lg px-2 py-1.5 hover:bg-red-50 transition">
<span class="text-red-600 font-medium">🔴 Urgent</span>
<span class="text-2xl font-bold" x-text="kpi.byPriority?.urgent || 0"></span>
</a>
<a href="/tickets?group=priority&priority=high" class="flex items-center justify-between rounded-lg px-2 py-1.5 hover:bg-orange-50 transition">
<span class="text-orange-600 font-medium">🟠 High</span>
<span class="text-2xl font-bold" x-text="kpi.byPriority?.high || 0"></span>
</a>
<a href="/tickets?group=priority&priority=medium" class="flex items-center justify-between rounded-lg px-2 py-1.5 hover:bg-yellow-50 transition">
<span class="text-yellow-600 font-medium">🟡 Medium</span>
<span class="text-2xl font-bold" x-text="kpi.byPriority?.medium || 0"></span>
</a>
<a href="/tickets?group=priority&priority=low" class="flex items-center justify-between rounded-lg px-2 py-1.5 hover:bg-green-50 transition">
<span class="text-green-600 font-medium">🟢 Low</span>
<span class="text-2xl font-bold" x-text="kpi.byPriority?.low || 0"></span>
</a>
<p class="text-[11px] text-gray-400 pt-1">Click a row to open the priority-grouped queue.</p>
</div>
</div>
<!-- Oldest Unassigned -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Oldest Unassigned</h3>
<div x-show="!oldestUnassigned" class="text-gray-400 text-sm py-8 text-center">No unassigned tickets</div>
<template x-if="oldestUnassigned">
<div>
<a :href="'/tickets/' + oldestUnassigned.id" class="block p-3 bg-orange-50 rounded-lg border border-orange-200 hover:bg-orange-100 transition">
<div class="font-medium text-sm" x-text="oldestUnassigned.ticket_number"></div>
<div class="text-xs text-gray-600 mt-1" x-text="oldestUnassigned.description?.substring(0, 80)"></div>
<div class="text-xs text-gray-400 mt-1">Waiting <span x-text="timeSince(oldestUnassigned.created_at)"></span></div>
</a>
</div>
</template>
</div>
<!-- Escalated -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Escalated Tickets</h3>
<div class="text-3xl font-bold text-red-600 mb-3" x-text="kpi.escalatedCount || 0"></div>
<div class="space-y-2 max-h-48 overflow-y-auto">
<template x-for="ticket in escalatedTickets" :key="ticket.id">
<a :href="'/tickets/' + ticket.id" class="block p-2 bg-red-50 rounded border border-red-200 hover:bg-red-100 transition">
<div class="flex justify-between items-center">
<span class="text-sm font-medium" x-text="ticket.ticket_number"></span>
<span class="text-xs text-red-600 font-medium" x-text="ticket.priority"></span>
</div>
<div class="text-xs text-gray-600 mt-0.5" x-text="ticket.description?.substring(0, 60)"></div>
</a>
</template>
<div x-show="!escalatedTickets.length" class="text-gray-400 text-sm py-4 text-center">No escalated tickets</div>
</div>
</div>
</div>
<!-- Recent Tickets Table -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
<div class="px-5 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Recent Tickets</h3>
<a href="/tickets" class="text-sm text-denya-600 hover:text-denya-800">View All →</a>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
<tr>
<th class="px-5 py-3 text-left">Ticket</th>
<th class="px-5 py-3 text-left">Status</th>
<th class="px-5 py-3 text-left">Priority</th>
<th class="px-5 py-3 text-left">Description</th>
<th class="px-5 py-3 text-left">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<template x-for="ticket in recentTickets" :key="ticket.id">
<tr class="hover:bg-gray-50 transition cursor-pointer" @click="window.location.href='/tickets/'+ticket.id">
<td class="px-5 py-3 font-medium text-denya-600" x-text="ticket.ticket_number"></td>
<td class="px-5 py-3"><span class="px-2 py-1 rounded-full text-xs font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span></td>
<td class="px-5 py-3"><span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></td>
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
<td class="px-5 py-3 text-gray-500 text-xs" x-text="formatDate(ticket.created_at)"></td>
</tr>
</template>
<tr x-show="!recentTickets.length">
<td colspan="5" class="px-5 py-12 text-center text-gray-400">No tickets found</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<script>
function csDashboard() {
return {
kpi: { byPriority: {} },
recentTickets: [],
escalatedTickets: [],
oldestUnassigned: null,
async init() {
await this.loadKPIs();
await this.loadRecentTickets();
await this.loadEscalated();
},
async loadKPIs() {
try {
const tickets = await app().apiGet('/api/tickets?page_size=200');
if (!tickets?.items) return;
const all = tickets.items;
const today = new Date();
today.setHours(0, 0, 0, 0);
// New today
const newToday = all.filter(t => new Date(t.created_at) >= today && t.status === 'New').length;
// Open tickets (not closed/completed)
const open = all.filter(t => !['Closed', 'Completed', 'Cancelled'].includes(t.status));
// By priority
const byPriority = { urgent: 0, high: 0, medium: 0, low: 0 };
open.forEach(t => { if (t.priority) byPriority[t.priority] = (byPriority[t.priority] || 0) + 1; });
// Oldest unassigned
const unassigned = all.filter(t => !t.assigned_to && !['Closed', 'Completed', 'Cancelled'].includes(t.status))
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
this.oldestUnassigned = unassigned[0] || null;
// SLA compliance (rough: tickets with sla_deadline not breached)
const withSLA = all.filter(t => t.sla_deadline);
const slaMet = withSLA.filter(t => new Date(t.sla_deadline) > new Date() || ['Closed', 'Completed', 'Cancelled'].includes(t.status));
this.kpi = {
newToday,
openTickets: open.length,
byPriority,
escalatedCount: all.filter(t => t.status === 'Escalated').length,
avgResponseTime: this.calcAvgResponse(all),
slaCompliance: withSLA.length ? Math.round((slaMet.length / withSLA.length) * 100) : 100
};
} catch (e) { console.error('KPI load error', e); }
},
async loadRecentTickets() {
try {
const data = await app().apiGet('/api/tickets?page_size=20');
this.recentTickets = data?.items || [];
} catch (e) { console.error('Recent tickets load error', e); }
},
async loadEscalated() {
try {
const data = await app().apiGet('/api/tickets?status=Escalated&page_size=10');
this.escalatedTickets = data?.items || [];
} catch (e) { console.error('Escalated load error', e); }
},
calcAvgResponse(all) {
// Rough approximation — in real system this would come from timeline analysis
const open = all.filter(t => !['Closed', 'Completed', 'Cancelled'].includes(t.status));
if (!open.length) return '—';
const avgHrs = open.reduce((sum, t) => {
const diff = (new Date() - new Date(t.created_at)) / (1000 * 60 * 60);
return sum + Math.min(diff, 168); // cap at 1 week
}, 0) / open.length;
if (avgHrs < 1) return `${Math.round(avgHrs * 60)}m`;
return `${Math.round(avgHrs)}h`;
}
}
}
</script>
{% endblock %}
+284
View File
@@ -0,0 +1,284 @@
{% extends "base.html" %}
{% block content %}
<div x-data="fmDashboard()" x-init="init()">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Facilities Management Dashboard</h1>
<p class="text-gray-500 mt-1">Welcome back, <span x-text="user.full_name"></span></p>
</div>
<!-- Emergency Alert -->
<div x-show="emergencyCount > 0" class="mb-6 p-4 bg-red-50 border-2 border-red-300 rounded-xl flex items-center space-x-3">
<svg class="w-8 h-8 text-red-600 flex-shrink-0 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
<div>
<p class="font-bold text-red-800"><span x-text="emergencyCount"></span> Emergency <span x-text="emergencyCount === 1 ? 'Job' : 'Jobs'"></span> Require Immediate Attention!</p>
<p class="text-sm text-red-600">View in the table below</p>
</div>
</div>
<!-- KPI Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 font-medium">Active Jobs</p>
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.activeJobs || 0"></p>
</div>
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 font-medium">Due Today</p>
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.dueToday || 0"></p>
</div>
<div class="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 font-medium">Waiting Parts</p>
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="kpi.waitingParts || 0"></p>
</div>
<div class="w-12 h-12 bg-yellow-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 font-medium">Jobs East / West</p>
<p class="text-3xl font-bold text-gray-900 mt-1"><span x-text="kpi.eastJobs || 0"></span> / <span x-text="kpi.westJobs || 0"></span></p>
</div>
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
</div>
</div>
</div>
</div>
<!-- Technician Workload + Aging Analysis -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Tech Workload -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Technician Workload</h3>
<div class="space-y-3">
<template x-for="tech in techWorkload" :key="tech.id">
<div class="flex items-center justify-between p-2 hover:bg-gray-50 rounded">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-denya-100 rounded-full flex items-center justify-center text-sm font-medium text-denya-700" x-text="tech.name.charAt(0)"></div>
<div>
<p class="text-sm font-medium text-gray-700" x-text="tech.name"></p>
<p class="text-xs text-gray-400" x-text="tech.specialty || 'Technician'"></p>
</div>
</div>
<div class="flex items-center space-x-2">
<span class="text-lg font-bold" :class="tech.activeJobs > 3 ? 'text-red-600' : tech.activeJobs > 1 ? 'text-yellow-600' : 'text-green-600'" x-text="tech.activeJobs"></span>
<span class="text-xs text-gray-400">active</span>
</div>
</div>
</template>
<div x-show="!techWorkload.length" class="text-gray-400 text-sm py-6 text-center">No active assignments</div>
</div>
</div>
<!-- Aging Analysis -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Aging Analysis</h3>
<div class="space-y-4">
<div class="flex items-center justify-between p-3 bg-green-50 rounded-lg">
<span class="text-sm font-medium text-green-700">&lt; 24h</span>
<span class="text-2xl font-bold text-green-700" x-text="aging.under24h || 0"></span>
</div>
<div class="flex items-center justify-between p-3 bg-yellow-50 rounded-lg">
<span class="text-sm font-medium text-yellow-700">1-2 days</span>
<span class="text-2xl font-bold text-yellow-700" x-text="aging.oneToTwoDays || 0"></span>
</div>
<div class="flex items-center justify-between p-3 bg-orange-50 rounded-lg">
<span class="text-sm font-medium text-orange-700">3-5 days</span>
<span class="text-2xl font-bold text-orange-700" x-text="aging.threeToFiveDays || 0"></span>
</div>
<div class="flex items-center justify-between p-3 bg-red-50 rounded-lg">
<span class="text-sm font-medium text-red-700">Overdue &gt; 5d</span>
<span class="text-2xl font-bold text-red-700" x-text="aging.overFiveDays || 0"></span>
</div>
</div>
</div>
</div>
<!-- Active Tickets Table -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
<div class="px-5 py-4 border-b border-gray-200 flex flex-wrap items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Active Tickets</h3>
<div class="flex items-center space-x-3">
<label class="flex items-center space-x-2 text-sm font-medium text-gray-700 cursor-pointer select-none">
<input type="checkbox" x-model="groupByPriority" class="rounded border-gray-300 text-denya-600 focus:ring-denya-500">
<span>Group by priority</span>
</label>
<button x-show="groupByPriority" @click="ageDir = ageDir === 'asc' ? 'desc' : 'asc'" class="px-2 py-1 text-xs border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-600" x-text="ageDir === 'desc' ? 'Newest first ↓' : 'Oldest first ↑'"></button>
<a href="/tickets" class="text-sm text-denya-600 hover:text-denya-800">View All →</a>
</div>
</div>
<div class="overflow-x-auto" x-show="!groupByPriority">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
<tr>
<th class="px-5 py-3 text-left">Ticket</th>
<th class="px-5 py-3 text-left">Status</th>
<th class="px-5 py-3 text-left">Priority</th>
<th class="px-5 py-3 text-left">Technician</th>
<th class="px-5 py-3 text-left">Description</th>
<th class="px-5 py-3 text-left">Age</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<template x-for="ticket in activeTickets" :key="ticket.id">
<tr class="hover:bg-gray-50 transition cursor-pointer" :class="ticket.priority === 'urgent' ? 'bg-red-50' : ''" @click="window.location.href='/tickets/'+ticket.id">
<td class="px-5 py-3 font-medium text-denya-600" x-text="ticket.ticket_number"></td>
<td class="px-5 py-3"><span class="px-2 py-1 rounded-full text-xs font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span></td>
<td class="px-5 py-3"><span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></td>
<td class="px-5 py-3 text-gray-600" x-text="ticket.assigned_technician_name || 'Unassigned'"></td>
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
<td class="px-5 py-3 text-xs font-medium" :class="timeSince(ticket.created_at).includes('d') && parseInt(timeSince(ticket.created_at)) > 3 ? 'text-red-600' : 'text-gray-500'" x-text="timeSince(ticket.created_at)"></td>
</tr>
</template>
<tr x-show="!activeTickets.length">
<td colspan="6" class="px-5 py-12 text-center text-gray-400">No active tickets</td>
</tr>
</tbody>
</table>
</div>
<div class="space-y-3 p-3" x-show="groupByPriority">
<template x-for="meta in priorityGroupMeta()" :key="meta.key">
<div x-show="(groupedActiveTickets[meta.key] || []).length" class="rounded-xl border border-gray-200 overflow-hidden">
<div class="px-4 py-2.5 flex items-center justify-between border-b border-gray-100" :class="meta.cls">
<span class="text-sm font-semibold text-gray-800" x-text="`${meta.label} (${groupedActiveTickets[meta.key].length})`"></span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<tbody class="divide-y divide-gray-100">
<template x-for="ticket in groupedActiveTickets[meta.key]" :key="ticket.id">
<tr class="hover:bg-gray-50 transition cursor-pointer" @click="window.location.href='/tickets/'+ticket.id">
<td class="px-5 py-3 font-medium text-denya-600" x-text="ticket.ticket_number"></td>
<td class="px-5 py-3"><span class="px-2 py-1 rounded-full text-xs font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span></td>
<td class="px-5 py-3"><span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></td>
<td class="px-5 py-3 text-gray-600" x-text="ticket.assigned_technician_name || 'Unassigned'"></td>
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
<td class="px-5 py-3 text-xs font-medium" :class="timeSince(ticket.created_at).includes('d') && parseInt(timeSince(ticket.created_at)) > 3 ? 'text-red-600' : 'text-gray-500'" x-text="timeSince(ticket.created_at)"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<div x-show="!activeTickets.length" class="px-5 py-12 text-center text-gray-400">No active tickets</div>
</div>
</div>
</div>
<script>
function fmDashboard() {
return {
kpi: {},
techWorkload: [],
aging: {},
activeTickets: [],
emergencyCount: 0,
groupByPriority: false,
ageDir: 'desc',
get groupedActiveTickets() {
return app().groupByPriority(this.activeTickets, this.ageDir);
},
async init() {
await this.loadData();
},
async loadData() {
try {
const data = await app().apiGet('/api/tickets?page_size=200');
if (!data?.items) return;
const all = data.items;
// Active: not closed/completed
const active = all.filter(t => !['Closed', 'Completed', 'Cancelled'].includes(t.status));
this.activeTickets = active;
// KPIs
this.kpi.activeJobs = active.length;
this.kpi.waitingParts = active.filter(t => t.status === 'Waiting Parts').length;
// Due today
const today = new Date();
today.setHours(23, 59, 59, 0);
const dueToday = active.filter(t => {
if (!t.sla_deadline) return false;
const deadline = new Date(t.sla_deadline);
return deadline <= today;
});
this.kpi.dueToday = dueToday.length;
// Emergency
this.emergencyCount = active.filter(t => t.priority === 'urgent').length;
// East vs West — compute from loaded tickets using unit map
try {
const units = await app().apiGet('/api/tickets/units');
const unitMap = {};
if (units) units.forEach(u => { unitMap[u.id] = u.property; });
this.kpi.eastJobs = active.filter(t => t.unit_id && unitMap[t.unit_id] === 'East').length;
this.kpi.westJobs = active.filter(t => t.unit_id && unitMap[t.unit_id] === 'West').length;
} catch (e) { console.error('Property stats error', e); }
// Tech workload (simulated from assigned_to counts)
const techMap = {};
active.forEach(t => {
if (t.assigned_to) {
if (!techMap[t.assigned_to]) techMap[t.assigned_to] = { id: t.assigned_to, name: `Tech #${t.assigned_to}`, activeJobs: 0 };
techMap[t.assigned_to].activeJobs++;
}
});
this.techWorkload = Object.values(techMap).sort((a, b) => b.activeJobs - a.activeJobs);
// Aging
const now = new Date();
this.aging = {
under24h: active.filter(t => (now - new Date(t.created_at)) < 24 * 60 * 60 * 1000).length,
oneToTwoDays: active.filter(t => {
const diff = (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24);
return diff >= 1 && diff < 3;
}).length,
threeToFiveDays: active.filter(t => {
const diff = (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24);
return diff >= 3 && diff < 5;
}).length,
overFiveDays: active.filter(t => (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24) >= 5).length,
};
} catch (e) { console.error('FM data load error', e); }
}
}
}
</script>
{% endblock %}
+74
View File
@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block content %}
<div class="min-h-[80vh] flex items-center justify-center">
<div class="w-full max-w-md" x-data="loginForm()">
<div class="bg-white rounded-2xl shadow-lg p-8">
<div class="text-center mb-8">
<div class="mx-auto w-16 h-16 bg-denya-100 rounded-full flex items-center justify-center mb-4">
<svg class="w-8 h-8 text-denya-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
</div>
<h1 class="text-2xl font-bold text-gray-900">Denya OneCare</h1>
<p class="text-gray-500 mt-1">Sign in to your dashboard</p>
</div>
<form @submit.prevent="submitLogin" class="space-y-5">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" x-model="email" required
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none transition"
placeholder="you@example.com">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input type="password" x-model="password" required
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none transition"
placeholder="••••••••">
</div>
<div x-show="error" class="text-red-600 text-sm bg-red-50 p-3 rounded-lg" x-text="error"></div>
<button type="submit" :disabled="submitting"
class="w-full py-2.5 px-4 bg-denya-600 hover:bg-denya-700 text-white font-medium rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2">
<svg x-show="submitting" class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span x-text="submitting ? 'Signing in...' : 'Sign In'"></span>
</button>
</form>
<div class="mt-6 pt-6 border-t border-gray-200">
<p class="text-xs text-gray-400 text-center">
<strong>Demo Accounts:</strong><br>
bella@denya.com / denya123 — CS Rep<br>
nicholas@denya.com / denya123 — FM Dispatcher<br>
scott@denya.com / denya123 — CEO<br>
jerome@denya.com / denya123 — Admin
</p>
</div>
</div>
</div>
</div>
<script>
function loginForm() {
return {
email: '',
password: '',
error: '',
submitting: false,
async submitLogin() {
this.error = '';
this.submitting = true;
try {
await app().login(this.email, this.password);
} catch (e) {
this.error = e.message || 'Invalid credentials. Please try again.';
} finally {
this.submitting = false;
}
}
}
}
</script>
{% endblock %}
+464
View File
@@ -0,0 +1,464 @@
{% extends "base.html" %}
{% block content %}
<div x-data="ticketDetail()" x-init="init()">
<!-- Loading -->
<div x-show="loading && !ticket" class="flex items-center justify-center py-20">
<svg class="animate-spin h-8 w-8 text-denya-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
<template x-if="ticket">
<div>
<!-- Header -->
<div class="mb-6">
<div class="flex items-start justify-between">
<div>
<div class="flex items-center space-x-3">
<a href="/tickets" class="text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
</a>
<h1 class="text-2xl font-bold text-gray-900" x-text="ticket.ticket_number"></h1>
<span class="px-3 py-1 rounded-full text-sm font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span>
<span class="px-3 py-1 rounded text-sm font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span>
</div>
<p class="text-gray-500 mt-1">Created <span x-text="formatDate(ticket.created_at)"></span>
<template x-if="ticket.reported_at && formatDateShort(ticket.reported_at) !== formatDateShort(ticket.created_at)">
<span> · Reported <span class="font-medium text-gray-600" x-text="formatDateShort(ticket.reported_at)"></span></span>
</template>
</p>
</div>
<div class="flex items-center space-x-2">
<button @click="showStatusModal = true" class="px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition text-sm font-medium">Update Status</button>
<button @click="showAssignModal = true" x-show="isFM || isAdmin" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition text-sm font-medium">Assign</button>
</div>
</div>
</div>
<!-- Main grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Left column: Details -->
<div class="lg:col-span-2 space-y-6">
<!-- Description -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Description</h3>
<p class="text-gray-700 whitespace-pre-wrap" x-text="ticket.description || 'No description provided'"></p>
</div>
<!-- Timeline -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Timeline</h3>
<div class="space-y-0">
<template x-for="(entry, idx) in ticket.timeline" :key="entry.id">
<div class="relative pl-8 pb-6" :class="idx === ticket.timeline.length - 1 ? '' : ''">
<!-- Timeline connector -->
<div class="absolute left-3 top-1 bottom-0 w-0.5 bg-gray-200" x-show="idx < ticket.timeline.length - 1"></div>
<!-- Dot -->
<div class="absolute left-1.5 top-1 w-3 h-3 rounded-full border-2" :class="getTimelineColor(entry)"></div>
<div class="bg-gray-50 rounded-lg p-3">
<div class="flex items-center space-x-2 text-sm">
<template x-if="entry.from_status">
<span class="px-2 py-0.5 rounded text-xs font-medium" :class="statusClass(entry.from_status)" x-text="entry.from_status"></span>
</template>
<template x-if="entry.from_status">
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</template>
<span class="px-2 py-0.5 rounded text-xs font-medium" :class="statusClass(entry.to_status)" x-text="entry.to_status"></span>
<span class="text-xs text-gray-400" x-text="formatDate(entry.created_at)"></span>
</div>
<p x-show="entry.note" class="text-sm text-gray-600 mt-1" x-text="entry.note"></p>
</div>
</div>
</template>
<div x-show="!ticket.timeline?.length" class="text-gray-400 text-sm py-6 text-center">No timeline entries</div>
</div>
</div>
<!-- Photos -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Photos</h3>
<div x-show="!ticket.photos?.length" class="text-gray-400 text-sm py-6 text-center">No photos uploaded</div>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
<template x-for="photo in ticket.photos" :key="photo.id">
<div class="rounded-lg overflow-hidden border border-gray-200">
<img :src="photo.photo_url" :alt="photo.is_before ? 'Before' : 'After'" class="w-full h-40 object-cover cursor-pointer hover:opacity-90 transition" @click="showPhoto(photo.photo_url)">
<div class="px-2 py-1 text-xs font-medium" :class="photo.is_before ? 'text-blue-600 bg-blue-50' : 'text-green-600 bg-green-50'" x-text="photo.is_before ? 'Before' : 'After'"></div>
</div>
</template>
</div>
<!-- Upload more photos -->
<div class="mt-4" x-show="isFM || isCS || isAdmin">
<label class="inline-flex items-center space-x-2 text-sm text-denya-600 hover:text-denya-800 cursor-pointer">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
<span>Add Photos</span>
<input type="file" multiple accept="image/*" @change="uploadPhotos($event)" class="hidden">
</label>
</div>
</div>
</div>
<!-- Right column: Metadata -->
<div class="space-y-6">
<!-- Ticket Info -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Ticket Info</h3>
<dl class="space-y-3">
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Unit</dt>
<dd class="text-sm font-medium text-gray-900" x-text="ticket.unit?.apartment_code || '—'"></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Property</dt>
<dd class="text-sm font-medium text-gray-900" x-text="ticket.unit?.property || '—'"></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Category</dt>
<dd class="text-sm font-medium text-gray-900" x-text="ticket.category?.name || '—'"></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Assigned To</dt>
<dd class="text-sm font-medium text-gray-900" x-text="ticket.assigned_technician_name || 'Unassigned'"></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Reporter</dt>
<dd class="text-sm font-medium text-gray-900" x-text="ticket.reporter || '—'"></dd>
</div>
<div class="flex justify-between" x-show="ticket.phone">
<dt class="text-sm text-gray-500">Phone</dt>
<dd class="text-sm font-medium text-gray-900" x-text="ticket.phone"></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Reported Via</dt>
<dd class="text-sm font-medium text-gray-900 capitalize" x-text="ticket.reported_via || '—'"></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Reported</dt>
<dd class="text-sm font-medium text-gray-900" x-text="ticket.reported_at ? formatDateShort(ticket.reported_at) : formatDateShort(ticket.created_at)"></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Priority</dt>
<dd><span class="px-2 py-0.5 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">SLA Deadline</dt>
<dd class="text-sm font-medium" :class="ticket.sla_status?.resolution_breached ? 'text-red-600' : 'text-gray-900'" x-text="ticket.sla_deadline ? formatDate(ticket.sla_deadline) : '—'"></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Reopen Count</dt>
<dd class="text-sm font-medium text-gray-900" x-text="ticket.reopen_count || 0"></dd>
</div>
<div class="flex justify-between" x-show="ticket.cost">
<dt class="text-sm text-gray-500">Cost</dt>
<dd class="text-sm font-medium text-gray-900" x-text="'$' + ticket.cost"></dd>
</div>
</dl>
</div>
<!-- SLA Status -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5" x-show="ticket.sla_status">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">SLA Status</h3>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500">Response</span>
<span class="flex items-center space-x-1">
<span class="w-2 h-2 rounded-full" :class="ticket.sla_status?.response_breached ? 'bg-red-500' : 'bg-green-500'"></span>
<span class="text-sm font-medium" :class="ticket.sla_status?.response_breached ? 'text-red-600' : 'text-green-600'" x-text="ticket.sla_status?.response_breached ? 'Breached' : 'OK'"></span>
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500">Resolution</span>
<span class="flex items-center space-x-1">
<span class="w-2 h-2 rounded-full" :class="ticket.sla_status?.resolution_breached ? 'bg-red-500' : 'bg-green-500'"></span>
<span class="text-sm font-medium" :class="ticket.sla_status?.resolution_breached ? 'text-red-600' : 'text-green-600'" x-text="ticket.sla_status?.resolution_breached ? 'Breached' : 'OK'"></span>
</span>
</div>
<div x-show="ticket.sla_status?.response_deadline">
<p class="text-xs text-gray-400">Response by: <span x-text="formatDate(ticket.sla_status.response_deadline)"></span></p>
</div>
</div>
</div>
<!-- Actions -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Quick Actions</h3>
<div class="space-y-2">
<button @click="showStatusModal = true" class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded transition flex items-center space-x-2">
<svg class="w-4 h-4 text-denya-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
<span>Change Status</span>
</button>
<button @click="showAssignModal = true" x-show="isFM || isAdmin" class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded transition flex items-center space-x-2">
<svg class="w-4 h-4 text-denya-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
<span>Assign Technician</span>
</button>
<button @click="showNoteModal = true" class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded transition flex items-center space-x-2">
<svg class="w-4 h-4 text-denya-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
<span>Add Note</span>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- Status Update Modal -->
<div x-show="showStatusModal" class="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center" x-cloak @click.away="showStatusModal = false">
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-md mx-4" @click.stop>
<h3 class="text-lg font-bold text-gray-900 mb-4">Update Status</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Current: <span class="font-bold" x-text="ticket.status"></span></label>
<select x-model="statusForm.newStatus" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 outline-none">
<option value="">Select new status</option>
<template x-for="target in availableTransitions" :key="target">
<option :value="target" x-text="target"></option>
</template>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Note (optional)</label>
<textarea x-model="statusForm.note" rows="3" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 outline-none" placeholder="Add a note about this status change..."></textarea>
</div>
<div x-show="statusError" class="text-sm text-red-600 bg-red-50 p-3 rounded-lg" x-text="statusError"></div>
<div class="flex space-x-3">
<button @click="submitStatusUpdate" :disabled="statusSubmitting" class="flex-1 px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition font-medium disabled:opacity-50">
<span x-text="statusSubmitting ? 'Updating...' : 'Update Status'"></span>
</button>
<button @click="showStatusModal = false" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cancel</button>
</div>
</div>
</div>
</div>
<!-- Assign Modal -->
<div x-show="showAssignModal" class="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center" x-cloak @click.away="showAssignModal = false">
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-md mx-4" @click.stop>
<h3 class="text-lg font-bold text-gray-900 mb-4">Assign Technician</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Technician</label>
<select x-model="assignForm.technicianId" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 outline-none">
<option value="">Select technician...</option>
<template x-for="tech in technicians" :key="tech.id">
<option :value="tech.id" x-text="tech.full_name"></option>
</template>
</select>
</div>
<div x-show="assignError" class="text-sm text-red-600 bg-red-50 p-3 rounded-lg" x-text="assignError"></div>
<div class="flex space-x-3">
<button @click="submitAssign" :disabled="assignSubmitting" class="flex-1 px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition font-medium disabled:opacity-50">
<span x-text="assignSubmitting ? 'Assigning...' : 'Assign'"></span>
</button>
<button @click="showAssignModal = false" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cancel</button>
</div>
</div>
</div>
</div>
<!-- Note Modal -->
<div x-show="showNoteModal" class="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center" x-cloak @click.away="showNoteModal = false">
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-md mx-4" @click.stop>
<h3 class="text-lg font-bold text-gray-900 mb-4">Add Note</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Note</label>
<textarea x-model="noteForm.note" rows="4" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 outline-none" placeholder="Enter your note..."></textarea>
</div>
<div class="flex space-x-3">
<button @click="submitNote" :disabled="noteSubmitting" class="flex-1 px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition font-medium disabled:opacity-50">
<span x-text="noteSubmitting ? 'Saving...' : 'Save Note'"></span>
</button>
<button @click="showNoteModal = false" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cancel</button>
</div>
</div>
</div>
</div>
<!-- Photo Lightbox -->
<div x-show="lightboxUrl" class="fixed inset-0 bg-black bg-opacity-80 z-50 flex items-center justify-center" x-cloak @click.away="lightboxUrl = ''" @click="lightboxUrl = ''">
<img :src="lightboxUrl" class="max-w-full max-h-full p-4">
</div>
</div>
<script>
function ticketDetail() {
return {
ticket: null,
ticketId: {{ ticket_id }},
loading: true,
// Modals
showStatusModal: false,
showAssignModal: false,
showNoteModal: false,
lightboxUrl: '',
// Status form
statusForm: { newStatus: '', note: '' },
statusSubmitting: false,
statusError: '',
availableTransitions: [],
// Assign form
assignForm: { technicianId: '' },
assignSubmitting: false,
assignError: '',
technicians: [],
// Note form
noteForm: { note: '' },
noteSubmitting: false,
async init() {
await this.loadTicket();
await this.loadTransitions();
if (app().isFM || app().isAdmin) {
await this.loadTechnicians();
}
},
async loadTicket() {
try {
this.ticket = await app().apiGet(`/api/tickets/${this.ticketId}`);
} catch (e) {
console.error('Ticket load error', e);
this.loading = false;
} finally {
this.loading = false;
}
},
async loadTransitions() {
try {
const data = await app().apiGet(`/api/tickets/${this.ticketId}/transitions`);
this.availableTransitions = (data && data.transitions) || [];
} catch (e) {
console.error('Transitions load error', e);
this.availableTransitions = [];
}
},
async loadTechnicians() {
try {
const users = await app().apiGet('/api/auth/users');
// Assign dropdown should only offer Tech-role staff
this.technicians = (users || []).filter(u => u.role === 'Tech');
} catch (e) {
console.error('Technicians load error', e);
this.technicians = [];
}
},
async submitStatusUpdate() {
this.statusError = '';
if (!this.statusForm.newStatus) {
this.statusError = 'Please select a status';
return;
}
this.statusSubmitting = true;
try {
const payload = { status: this.statusForm.newStatus };
if (this.statusForm.note) payload.note = this.statusForm.note;
const updated = await app().apiPatch(`/api/tickets/${this.ticketId}`, payload);
this.ticket = updated;
await this.loadTransitions();
this.showStatusModal = false;
this.statusForm = { newStatus: '', note: '' };
app().showToast('Status updated', 'success');
} catch (e) {
this.statusError = e.message;
} finally {
this.statusSubmitting = false;
}
},
async submitAssign() {
this.assignError = '';
if (!this.assignForm.technicianId) {
this.assignError = 'Please select a technician';
return;
}
this.assignSubmitting = true;
try {
const updated = await app().apiPatch(`/api/tickets/${this.ticketId}`, {
assigned_to: parseInt(this.assignForm.technicianId)
});
this.ticket = updated;
await this.loadTransitions();
this.showAssignModal = false;
app().showToast('Technician assigned', 'success');
} catch (e) {
this.assignError = e.message;
} finally {
this.assignSubmitting = false;
}
},
async submitNote() {
if (!this.noteForm.note.trim()) return;
this.noteSubmitting = true;
try {
// Use PATCH endpoint which now supports note-only updates
const updated = await app().apiPatch(`/api/tickets/${this.ticketId}`, {
note: this.noteForm.note
});
this.ticket = updated;
this.showNoteModal = false;
this.noteForm.note = '';
app().showToast('Note added', 'success');
} catch (e) {
// No need to show error here — api() base method already shows it
} finally {
this.noteSubmitting = false;
}
},
async uploadPhotos(e) {
const files = Array.from(e.target.files || []);
if (!files.length) return;
app().loading = true;
app().loadingMessage = 'Uploading photos...';
try {
const formData = new FormData();
files.forEach(f => formData.append('files', f));
const res = await fetch(`/api/tickets/${this.ticketId}/photos?is_before=true`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${app().token}` },
body: formData
});
if (res.ok) {
await this.loadTicket();
app().showToast('Photos uploaded', 'success');
} else {
const err = await res.json().catch(() => ({ detail: 'Upload failed' }));
app().showToast(err.detail, 'error');
}
} catch (e) {
app().showToast('Photo upload failed', 'error');
} finally {
app().loading = false;
app().loadingMessage = '';
}
},
showPhoto(url) {
this.lightboxUrl = url;
},
getTimelineColor(entry) {
const colors = {
'new': 'border-blue-500 bg-blue-500',
'completed': 'border-green-500 bg-green-500',
'closed': 'border-gray-500 bg-gray-500',
'escalated': 'border-red-500 bg-red-500',
};
const toStatus = entry.to_status?.toLowerCase() || '';
return colors[toStatus] || 'border-denya-500 bg-denya-500';
}
}
}
</script>
{% endblock %}
+412
View File
@@ -0,0 +1,412 @@
{% extends "base.html" %}
{% block content %}
<div x-data="ticketList()" x-init="init()">
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">All Issues</h1>
<p class="text-gray-500 mt-1" x-text="`${total} total tickets`"></p>
</div>
<a href="/tickets/new" class="px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition text-sm font-medium flex items-center space-x-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
<span>New Issue</span>
</a>
</div>
<!-- Filters -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
<div class="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-8 gap-3">
<div>
<label class="block text-xs text-gray-500 font-medium mb-1">Search</label>
<input type="text" x-model="filters.search" @input.debounce="loadTickets()" placeholder="Ticket # or description..." class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none">
</div>
<div>
<label class="block text-xs text-gray-500 font-medium mb-1">Status</label>
<select x-model="filters.status" @change="loadTickets()" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none">
<option value="">All Statuses</option>
<option value="New">New</option>
<option value="Logged">Logged</option>
<option value="Triage">Triage</option>
<option value="Assigned">Assigned</option>
<option value="Accepted">Accepted</option>
<option value="Travelling">Travelling</option>
<option value="On Site">On Site</option>
<option value="In Progress">In Progress</option>
<option value="Waiting Parts">Waiting Parts</option>
<option value="Escalated">Escalated</option>
<option value="Completed">Completed</option>
<option value="On-Field Verification">On-Field Verification</option>
<option value="Wahab Review">Wahab Review</option>
<option value="Closed">Closed</option>
<option value="Reopened">Reopened</option>
<option value="Cancelled">Cancelled</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 font-medium mb-1">Priority</label>
<select x-model="filters.priority" @change="loadTickets()" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none">
<option value="">All Priorities</option>
<option value="urgent">🔴 Urgent</option>
<option value="high">🟠 High</option>
<option value="medium">🟡 Medium</option>
<option value="low">🟢 Low</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 font-medium mb-1">Property</label>
<select x-model="filters.property" @change="onPropertyChange()" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none">
<option value="">All Properties</option>
<option value="East">Pavilion East</option>
<option value="West">Pavilion West</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 font-medium mb-1">Building</label>
<select x-model="filters.building" @change="onBuildingChange()" :disabled="!filters.property" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none disabled:bg-gray-50 disabled:text-gray-400">
<option value="">All Buildings</option>
<template x-for="b in buildings" :key="b">
<option :value="b" x-text="b"></option>
</template>
</select>
</div>
<div class="relative">
<label class="block text-xs text-gray-500 font-medium mb-1">Apartment</label>
<input type="text" x-model="searchApartment" @focus="apartmentOpen = true" @input="apartmentOpen = true" @keydown.escape="apartmentOpen = false"
:disabled="!filters.building" @blur="closeApartment()"
:placeholder="filterUnit ? filterUnit.apartment_code : (filters.building ? 'Search apartment...' : 'Select building')"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none disabled:bg-gray-50 disabled:text-gray-400">
<div x-show="apartmentOpen && filteredApartments.length" class="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-40 overflow-y-auto">
<template x-for="unit in filteredApartments" :key="unit.id">
<button type="button" @mousedown.prevent="selectFilterUnit(unit)" class="block w-full text-left px-3 py-1.5 hover:bg-denya-50 text-xs">
<span class="font-medium text-gray-800" x-text="unit.apartment_code"></span>
<span class="text-gray-400" x-text="` · Floor ${unit.floor || '—'}`"></span>
</button>
</template>
</div>
<div x-show="apartmentOpen && !filteredApartments.length && filters.building" class="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg px-3 py-2 text-xs text-gray-400">
No apartments match
</div>
</div>
<div>
<label class="block text-xs text-gray-500 font-medium mb-1">From</label>
<input type="date" x-model="filters.dateFrom" @change="loadTickets()" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none">
</div>
<div>
<label class="block text-xs text-gray-500 font-medium mb-1">To</label>
<input type="date" x-model="filters.dateTo" @change="loadTickets()" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-denya-500 outline-none">
</div>
</div>
<div class="mt-3 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center space-x-3">
<label class="flex items-center space-x-2 text-sm font-medium text-gray-700 cursor-pointer select-none">
<input type="checkbox" x-model="groupByPriority" @change="syncUrl()" class="rounded border-gray-300 text-denya-600 focus:ring-denya-500">
<span>Group by priority</span>
</label>
<div x-show="groupByPriority" class="flex items-center space-x-2 text-xs">
<button @click="ageDir = ageDir === 'asc' ? 'desc' : 'asc'" class="px-2 py-1 border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-600" x-text="ageDir === 'desc' ? 'Newest first ↓' : 'Oldest first ↑'"></button>
</div>
<span class="text-xs text-gray-500">Page <span x-text="page"></span> of <span x-text="totalPages"></span></span>
</div>
<div class="flex items-center space-x-2">
<button @click="page > 1 && (page--, loadTickets())" :disabled="page <= 1" class="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">Prev</button>
<button @click="page < totalPages && (page++, loadTickets())" :disabled="page >= totalPages" class="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">Next</button>
<button @click="clearFilters()" class="px-3 py-1 text-sm text-gray-500 border rounded hover:bg-gray-50">Clear Filters</button>
</div>
</div>
</div>
<!-- Tickets Table (flat) -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200" x-show="!groupByPriority">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
<tr>
<th class="px-5 py-3 text-left cursor-pointer hover:text-gray-900" @click="sortBy('ticket_number')">
Ticket <span x-show="sortField === 'ticket_number'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
</th>
<th class="px-5 py-3 text-left">Status</th>
<th class="px-5 py-3 text-left">Priority</th>
<th class="px-5 py-3 text-left">Description</th>
<th class="px-5 py-3 text-left">Assigned To</th>
<th class="px-5 py-3 text-left cursor-pointer hover:text-gray-900" @click="sortBy('created_at')">
Created <span x-show="sortField === 'created_at'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
</th>
<th class="px-5 py-3 text-left">Reported</th>
<th class="px-5 py-3 text-left">SLA</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<template x-for="ticket in tickets" :key="ticket.id">
<tr class="hover:bg-gray-50 transition cursor-pointer" :class="ticket.priority === 'urgent' ? 'bg-red-50/50' : ''" @click="window.location.href='/tickets/'+ticket.id">
<td class="px-5 py-3 font-medium text-denya-600" x-text="ticket.ticket_number"></td>
<td class="px-5 py-3"><span class="px-2 py-1 rounded-full text-xs font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span></td>
<td class="px-5 py-3"><span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></td>
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
<td class="px-5 py-3 text-gray-500 text-xs" x-text="ticket.assigned_technician_name || '—'"></td>
<td class="px-5 py-3 text-gray-500 text-xs" x-text="formatDate(ticket.created_at)"></td>
<td class="px-5 py-3 text-gray-500 text-xs" x-text="formatDateShort(ticket.reported_at || ticket.created_at)"></td>
<td class="px-5 py-3">
<span x-show="ticket.sla_deadline" class="text-xs" :class="new Date(ticket.sla_deadline) < new Date() && !['Closed','Completed','Cancelled'].includes(ticket.status) ? 'text-red-600 font-medium' : 'text-gray-400'">
<span x-text="formatDateShort(ticket.sla_deadline)"></span>
</span>
<span x-show="!ticket.sla_deadline" class="text-xs text-gray-300">—</span>
</td>
</tr>
</template>
<tr x-show="!tickets.length && !loading">
<td colspan="8" class="px-5 py-16 text-center text-gray-400">
<svg class="w-12 h-12 mx-auto text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
No tickets match your filters
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Tickets grouped by priority -->
<div class="space-y-4" x-show="groupByPriority">
<template x-for="meta in priorityGroupMeta()" :key="meta.key">
<div x-show="(groupedTickets[meta.key] || []).length" class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="px-5 py-3 flex items-center justify-between border-b border-gray-100" :class="meta.cls">
<span class="text-sm font-semibold text-gray-800" x-text="`${meta.label} (${groupedTickets[meta.key].length})`"></span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
<tr>
<th class="px-5 py-3 text-left">Ticket</th>
<th class="px-5 py-3 text-left">Status</th>
<th class="px-5 py-3 text-left">Priority</th>
<th class="px-5 py-3 text-left">Description</th>
<th class="px-5 py-3 text-left">Assigned To</th>
<th class="px-5 py-3 text-left">Created</th>
<th class="px-5 py-3 text-left">Reported</th>
<th class="px-5 py-3 text-left">SLA</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<template x-for="ticket in groupedTickets[meta.key]" :key="ticket.id">
<tr class="hover:bg-gray-50 transition cursor-pointer" @click="window.location.href='/tickets/'+ticket.id">
<td class="px-5 py-3 font-medium text-denya-600" x-text="ticket.ticket_number"></td>
<td class="px-5 py-3"><span class="px-2 py-1 rounded-full text-xs font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span></td>
<td class="px-5 py-3"><span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></td>
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
<td class="px-5 py-3 text-gray-500 text-xs" x-text="ticket.assigned_technician_name || '—'"></td>
<td class="px-5 py-3 text-gray-500 text-xs" x-text="formatDate(ticket.created_at)"></td>
<td class="px-5 py-3 text-gray-500 text-xs" x-text="formatDateShort(ticket.reported_at || ticket.created_at)"></td>
<td class="px-5 py-3">
<span x-show="ticket.sla_deadline" class="text-xs" :class="new Date(ticket.sla_deadline) < new Date() && !['Closed','Completed','Cancelled'].includes(ticket.status) ? 'text-red-600 font-medium' : 'text-gray-400'">
<span x-text="formatDateShort(ticket.sla_deadline)"></span>
</span>
<span x-show="!ticket.sla_deadline" class="text-xs text-gray-300">—</span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<div x-show="!groupedTotal && !loading" class="bg-white rounded-xl shadow-sm border border-gray-200 px-5 py-16 text-center text-gray-400">
No tickets match your filters
</div>
</div>
</div>
<script>
function ticketList() {
return {
tickets: [],
total: 0,
page: 1,
pageSize: 50,
totalPages: 1,
sortField: 'created_at',
sortDir: 'desc',
groupByPriority: false,
ageDir: 'desc',
unitGroups: {},
buildings: [],
searchApartment: '',
apartmentOpen: false,
filterUnit: null,
filters: {
search: '',
status: '',
priority: '',
property: '',
building: '',
unitId: '',
dateFrom: '',
dateTo: ''
},
async init() {
this.applyUrlParams();
await this.loadUnits();
await this.loadTickets();
},
applyUrlParams() {
const params = new URLSearchParams(window.location.search);
if (params.get('group') === 'priority') this.groupByPriority = true;
if (params.get('priority')) this.filters.priority = params.get('priority');
if (params.get('property')) this.filters.property = params.get('property');
if (params.get('building')) this.filters.building = params.get('building');
if (params.get('unit_id')) this.filters.unitId = params.get('unit_id');
},
syncUrl() {
const params = new URLSearchParams();
if (this.groupByPriority) params.set('group', 'priority');
if (this.filters.priority) params.set('priority', this.filters.priority);
if (this.filters.property) params.set('property', this.filters.property);
if (this.filters.building) params.set('building', this.filters.building);
if (this.filters.unitId) params.set('unit_id', this.filters.unitId);
const qs = params.toString();
const url = qs ? `/tickets?${qs}` : '/tickets';
window.history.replaceState({}, '', url);
},
async loadUnits() {
try {
const data = await app().apiGet('/api/tickets/units/grouped');
this.unitGroups = data || {};
if (this.filters.property) this.buildings = Object.keys(this.unitGroups[this.filters.property] || {});
if (this.filters.unitId) {
// Restore displayed apartment for a unit_id deep link
Object.values(this.unitGroups).forEach(prop => Object.values(prop).forEach(units => {
const u = (units || []).find(x => x.id == this.filters.unitId);
if (u) this.filterUnit = u;
}));
}
} catch (e) { console.error('Units load error', e); }
},
onPropertyChange() {
this.filters.building = '';
this.filters.unitId = '';
this.filterUnit = null;
this.searchApartment = '';
this.buildings = this.filters.property ? Object.keys(this.unitGroups[this.filters.property] || {}) : [];
this.loadTickets();
this.syncUrl();
},
onBuildingChange() {
this.filters.unitId = '';
this.filterUnit = null;
this.searchApartment = '';
this.loadTickets();
this.syncUrl();
},
get buildingUnits() {
if (!this.filters.property || !this.filters.building) return [];
return this.unitGroups[this.filters.property]?.[this.filters.building] || [];
},
get filteredApartments() {
const q = (this.searchApartment || '').toLowerCase().trim();
let units = this.buildingUnits;
if (q) {
units = units.filter(u =>
(u.apartment_code || '').toLowerCase().includes(q) ||
String(u.floor || '').includes(q)
);
}
return units;
},
closeApartment() {
setTimeout(() => { this.apartmentOpen = false; }, 150);
},
selectFilterUnit(unit) {
this.filterUnit = unit;
this.filters.unitId = unit.id;
this.searchApartment = unit.apartment_code;
this.apartmentOpen = false;
this.loadTickets();
this.syncUrl();
},
get groupedTickets() {
return app().groupByPriority(this.tickets, this.ageDir);
},
get groupedTotal() {
return Object.values(this.groupedTickets).reduce((n, g) => n + g.length, 0);
},
async loadTickets() {
try {
let url = `/api/tickets?page=${this.page}&page_size=${this.pageSize}`;
if (this.filters.status) url += `&status=${encodeURIComponent(this.filters.status)}`;
if (this.filters.priority) url += `&priority=${encodeURIComponent(this.filters.priority)}`;
if (this.filters.property) url += `&property=${encodeURIComponent(this.filters.property)}`;
if (this.filters.building) url += `&building=${encodeURIComponent(this.filters.building)}`;
if (this.filters.unitId) url += `&unit_id=${encodeURIComponent(this.filters.unitId)}`;
if (this.filters.dateFrom) url += `&date_from=${encodeURIComponent(this.filters.dateFrom)}`;
if (this.filters.dateTo) url += `&date_to=${encodeURIComponent(this.filters.dateTo)}`;
const data = await app().apiGet(url);
if (data) {
this.tickets = data.items || [];
this.total = data.total || 0;
this.totalPages = Math.ceil(this.total / this.pageSize) || 1;
// Client-side search filter for ticket number or description
if (this.filters.search) {
const q = this.filters.search.toLowerCase();
this.tickets = this.tickets.filter(t =>
(t.ticket_number && t.ticket_number.toLowerCase().includes(q)) ||
(t.description && t.description.toLowerCase().includes(q))
);
}
// Sort
this.applySort();
}
} catch (e) { console.error('Tickets load error', e); }
},
sortBy(field) {
if (this.sortField === field) {
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
} else {
this.sortField = field;
this.sortDir = 'desc';
}
this.applySort();
},
applySort() {
this.tickets.sort((a, b) => {
let valA = a[this.sortField] || '';
let valB = b[this.sortField] || '';
if (this.sortField === 'created_at') {
valA = new Date(valA).getTime();
valB = new Date(valB).getTime();
}
if (typeof valA === 'string') valA = valA.toLowerCase();
if (typeof valB === 'string') valB = valB.toLowerCase();
if (valA < valB) return this.sortDir === 'asc' ? -1 : 1;
if (valA > valB) return this.sortDir === 'asc' ? 1 : -1;
return 0;
});
},
clearFilters() {
this.filters = { search: '', status: '', priority: '', property: '', building: '', unitId: '', dateFrom: '', dateTo: '' };
this.filterUnit = null;
this.searchApartment = '';
this.buildings = [];
this.page = 1;
this.loadTickets();
this.syncUrl();
}
}
}
</script>
{% endblock %}
+436
View File
@@ -0,0 +1,436 @@
{% extends "base.html" %}
{% block content %}
<div x-data="createTicket()" x-init="init()">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Create New Issue</h1>
<p class="text-gray-500 mt-1">Report a maintenance or customer service issue</p>
</div>
<!-- Report Mode: Standard / Emergency quick path -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-3xl mb-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<button type="button" @click="setMode('standard')" class="flex items-center justify-center space-x-2 px-4 py-3 rounded-xl border-2 text-sm font-medium transition"
:class="mode === 'standard' ? 'border-denya-500 bg-denya-50 text-denya-700' : 'border-gray-200 text-gray-600 hover:bg-gray-50'">
<span>Standard Issue</span>
</button>
<button type="button" @click="setMode('emergency')" class="flex items-center justify-center space-x-2 px-4 py-3 rounded-xl border-2 text-sm font-medium transition"
:class="mode === 'emergency' ? 'border-red-500 bg-red-50 text-red-700' : 'border-gray-200 text-gray-600 hover:bg-gray-50'">
<span>🚨 Emergency</span>
</button>
</div>
<p x-show="mode === 'emergency'" class="mt-3 text-xs text-red-600 leading-relaxed">
Emergency reports (gas leak, fire, flood, electrical hazard) are created with 🔴 Urgent priority and urgent SLA targets apply.
The Gas Leak category is available here only — it stays hidden from the standard issue list.
</p>
<p x-show="mode === 'standard'" class="mt-3 text-xs text-gray-400">Standard maintenance or customer service issues.</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-3xl">
<form @submit.prevent="submitTicket" class="space-y-5">
<!-- Row: Customer Name + Phone -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Customer Name</label>
<input type="text" x-model="form.customer_name" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none" placeholder="e.g. John Doe">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Phone</label>
<input type="text" x-model="form.phone" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none" placeholder="e.g. +233 XX XXX XXXX">
</div>
</div>
<!-- Row: Property → Building → Apartment (3-level cascade + searchable combobox) -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Location <span class="text-red-500">*</span></label>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<select x-model="form.property" @change="onPropertyChange()" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none">
<option value="">Property</option>
<option value="East">Pavilion East</option>
<option value="West">Pavilion West</option>
</select>
</div>
<div>
<select x-model="form.building" @change="onBuildingChange()" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none" :disabled="!form.property">
<option value="">Building</option>
<template x-for="b in buildings" :key="b">
<option :value="b" x-text="b"></option>
</template>
</select>
</div>
<div class="relative">
<input type="text" x-model="searchApartment" @focus="apartmentOpen = true" @input="apartmentOpen = true" @keydown.escape="apartmentOpen = false"
:disabled="!form.building" @blur="closeApartment()"
:placeholder="form.apartment_code ? form.apartment_code : (form.building ? 'Search apartment (e.g. 011E, 505, 10W)...' : 'Select building first')"
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none disabled:bg-gray-50 disabled:text-gray-400">
<div x-show="apartmentOpen && filteredApartments.length" class="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto">
<template x-for="unit in filteredApartments" :key="unit.id">
<button type="button" @mousedown.prevent="selectApartment(unit)" class="block w-full text-left px-3 py-2 hover:bg-denya-50 text-sm">
<span class="font-medium text-gray-800" x-text="unit.apartment_code"></span>
<span class="text-xs text-gray-400" x-text="` · Floor ${unit.floor || '—'} · ${unit.building || ''}`"></span>
</button>
</template>
</div>
<div x-show="apartmentOpen && !filteredApartments.length && form.building" class="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg px-3 py-2 text-xs text-gray-400">
No apartments match “<span x-text="searchApartment"></span>”
</div>
</div>
</div>
<p class="mt-1 text-xs text-gray-400">Property → Building → Apartment. Search matches apartment code, floor, or building.</p>
</div>
<!-- Category -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Category <span class="text-red-500">*</span></label>
<select x-model="form.category_main" @change="onCategoryChange()" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none">
<option value="">Select Category</option>
<template x-for="cat in categories" :key="cat.id">
<option :value="cat.id" x-text="cat.name"></option>
</template>
</select>
<div x-show="selectedCategoryHint" class="mt-2 text-xs text-gray-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2" x-text="selectedCategoryHint"></div>
</div>
<div x-show="subCategories.length">
<label class="block text-sm font-medium text-gray-700 mb-1">Sub-Category</label>
<select x-model="form.category_id" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none">
<option value="">Select Sub-Category</option>
<template x-for="cat in subCategories" :key="cat.id">
<option :value="cat.id" x-text="cat.name"></option>
</template>
</select>
</div>
<!-- Priority -->
<div x-show="mode === 'emergency'">
<label class="block text-sm font-medium text-gray-700 mb-1">Priority</label>
<div class="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700 font-medium">
🔴 Urgent — fixed for emergency reports (15 min response / 4 h resolution SLA)
</div>
</div>
<div x-show="mode === 'standard'">
<label class="block text-sm font-medium text-gray-700 mb-1">Priority</label>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:bg-gray-50" :class="form.priority === 'urgent' ? 'border-red-500 bg-red-50' : 'border-gray-200'">
<input type="radio" name="priority" value="urgent" x-model="form.priority" @change="priorityAuto = false" class="sr-only">
<span class="text-sm">🔴 Urgent</span>
</label>
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:bg-gray-50" :class="form.priority === 'high' ? 'border-orange-500 bg-orange-50' : 'border-gray-200'">
<input type="radio" name="priority" value="high" x-model="form.priority" @change="priorityAuto = false" class="sr-only">
<span class="text-sm">🟠 High</span>
</label>
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:bg-gray-50" :class="form.priority === 'medium' ? 'border-yellow-500 bg-yellow-50' : 'border-gray-200'">
<input type="radio" name="priority" value="medium" x-model="form.priority" @change="priorityAuto = false" class="sr-only">
<span class="text-sm">🟡 Medium</span>
</label>
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:bg-gray-50" :class="form.priority === 'low' ? 'border-green-500 bg-green-50' : 'border-gray-200'">
<input type="radio" name="priority" value="low" x-model="form.priority" @change="priorityAuto = false" class="sr-only">
<span class="text-sm">🟢 Low</span>
</label>
</div>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description <span class="text-red-500">*</span></label>
<textarea x-model="form.description" rows="4" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none" placeholder="Describe the issue in detail..."></textarea>
</div>
<!-- Row: Reporter + Reported Via -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Reporter</label>
<input type="text" x-model="form.reporter" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none" placeholder="Who reported this?">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Reported Via</label>
<select x-model="form.reported_via" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none">
<option value="">Select method</option>
<option value="phone">Phone</option>
<option value="walk-in">Walk-in</option>
<option value="whatsapp">WhatsApp</option>
<option value="agent">Agent</option>
<!-- QR Code excluded per Sprint 3 scope -->
</select>
</div>
</div>
<!-- Reported date (backdating support) -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Reported Date</label>
<input type="date" x-model="form.reported_date" :max="todayStr" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none">
<p class="mt-1 text-xs text-gray-400">Defaults to today. Use a past date when entering an old/backlogged issue — it stays active in the normal workflow.</p>
</div>
</div>
<!-- Photo Upload -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Photos (Before)</label>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-denya-400 transition cursor-pointer" @click="document.getElementById('photoInput').click()">
<svg class="w-8 h-8 mx-auto text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<p class="text-sm text-gray-500">Click to upload photos</p>
<input type="file" id="photoInput" multiple accept="image/*" @change="handlePhotos" class="hidden">
</div>
<div class="flex flex-wrap gap-2 mt-2" x-show="photoFiles.length">
<template x-for="(photo, idx) in photoPreviews" :key="idx">
<div class="relative w-20 h-20 rounded-lg overflow-hidden border">
<img :src="photo" class="w-full h-full object-cover">
<button type="button" @click="removePhoto(idx)" class="absolute top-0.5 right-0.5 bg-red-500 text-white w-5 h-5 rounded-full flex items-center justify-center text-xs hover:bg-red-600">&times;</button>
</div>
</template>
</div>
</div>
<!-- Error Display -->
<div x-show="error" class="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700" x-text="error"></div>
<!-- Submit -->
<div class="flex items-center space-x-3 pt-2">
<button type="submit" :disabled="submitting" class="px-6 py-2.5 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2">
<svg x-show="submitting" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
<span x-text="submitting ? 'Creating...' : (mode === 'emergency' ? 'Create Emergency Issue' : 'Create Issue')"></span>
</button>
<a href="/tickets" class="px-6 py-2.5 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition font-medium">Cancel</a>
</div>
</form>
</div>
</div>
<script>
function createTicket() {
return {
mode: 'standard',
form: {
customer_name: '',
phone: '',
property: '',
building: '',
apartment_code: '',
unit: null,
category_main: '',
category_id: null,
priority: '',
description: '',
reporter: '',
reported_via: '',
reported_date: ''
},
todayStr: '',
priorityAuto: false,
categories: [],
subCategories: [],
unitGroups: {},
buildings: [],
searchApartment: '',
apartmentOpen: false,
selectedCategoryHint: '',
photoFiles: [],
photoPreviews: [],
submitting: false,
error: '',
async init() {
await this.loadCategories();
await this.loadUnits();
// Default reported date to today (local), allow backdating via the date picker
this.todayStr = this.localDateStr(new Date());
if (!this.form.reported_date) this.form.reported_date = this.todayStr;
},
localDateStr(d) {
const offset = d.getTimezoneOffset();
return new Date(d.getTime() - offset * 60000).toISOString().slice(0, 10);
},
// ── Report mode ──────────────────────────────────────────
setMode(mode) {
this.mode = mode;
// Reset category selection; priority follows mode
this.form.category_main = '';
this.form.category_id = null;
this.subCategories = [];
this.selectedCategoryHint = '';
if (mode === 'emergency') {
this.form.priority = 'urgent';
} else {
this.form.priority = '';
}
this.priorityAuto = false;
this.loadCategories();
},
async loadCategories() {
try {
const url = this.mode === 'emergency'
? '/api/tickets/categories?type=emergency&include_hidden=true'
: '/api/tickets/categories';
const data = await app().apiGet(url);
// Top-level categories only
this.categories = data?.filter(c => !c.parent_id) || [];
} catch (e) { console.error('Categories load error', e); }
},
async loadUnits() {
try {
const data = await app().apiGet('/api/tickets/units/grouped');
this.unitGroups = data || {};
} catch (e) { console.error('Units load error', e); }
},
// ── Location cascade ─────────────────────────────────────
onPropertyChange() {
this.form.building = '';
this.form.apartment_code = '';
this.form.unit = null;
this.searchApartment = '';
const prop = this.form.property;
this.buildings = prop ? Object.keys(this.unitGroups[prop] || {}) : [];
},
onBuildingChange() {
this.form.apartment_code = '';
this.form.unit = null;
this.searchApartment = '';
},
get buildingUnits() {
if (!this.form.property || !this.form.building) return [];
return this.unitGroups[this.form.property]?.[this.form.building] || [];
},
get filteredApartments() {
const q = (this.searchApartment || '').toLowerCase().trim();
let units = this.buildingUnits;
if (q) {
units = units.filter(u =>
(u.apartment_code || '').toLowerCase().includes(q) ||
(u.building || '').toLowerCase().includes(q) ||
String(u.floor || '').includes(q)
);
}
return units;
},
closeApartment() {
setTimeout(() => { this.apartmentOpen = false; }, 150);
},
selectApartment(unit) {
this.form.unit = unit;
this.form.apartment_code = unit.apartment_code;
this.searchApartment = unit.apartment_code;
this.apartmentOpen = false;
},
// ── Category helpers ─────────────────────────────────────
onCategoryChange() {
this.form.category_id = null;
this.subCategories = [];
this.selectedCategoryHint = '';
if (!this.form.category_main) return;
const parent = this.categories.find(c => c.id == this.form.category_main);
if (parent?.children) {
this.subCategories = parent.children;
}
const hints = {
'Carpentry': 'Triage tip: use Carpentry for structural timber, door and frame work; Furniture for movable pieces (beds, chairs, tables, wardrobes); Security for lock, latch and door-closing functionality.',
'Mould & Damp': 'Damp / mould remediation. Priority defaults to 🟡 Medium unless you change it.',
'Aluminum/Glass': 'Windows, doors, sliding/fixed panels, glass and mirror replacement.'
};
if (parent?.name && hints[parent.name]) this.selectedCategoryHint = hints[parent.name];
// Mould & Damp defaults to Medium priority; clear the auto-default
// when switching away so a non-Mould ticket doesn't keep it silently
if (parent?.name !== 'Mould & Damp' && this.priorityAuto) {
this.form.priority = '';
this.priorityAuto = false;
}
if (parent?.name === 'Mould & Damp' && !this.form.priority) {
this.form.priority = 'medium';
this.priorityAuto = true;
}
},
handlePhotos(e) {
const files = Array.from(e.target.files || []);
files.forEach(file => {
if (file.size > 5 * 1024 * 1024) {
app().showToast('Photo too large (max 5MB)', 'error');
return;
}
this.photoFiles.push(file);
const reader = new FileReader();
reader.onload = ev => this.photoPreviews.push(ev.target.result);
reader.readAsDataURL(file);
});
},
removePhoto(idx) {
this.photoFiles.splice(idx, 1);
this.photoPreviews.splice(idx, 1);
},
async submitTicket() {
this.error = '';
this.submitting = true;
try {
// Validate required fields
if (!this.form.property || !this.form.building || !this.form.unit || !this.form.description) {
this.error = 'Please fill in Property, Building, Apartment, and Description.';
this.submitting = false;
return;
}
if (!this.form.category_main && !this.form.category_id) {
this.error = 'Please select a Category — every issue needs one for correct routing and SLA.';
this.submitting = false;
return;
}
if (this.mode === 'standard' && !this.form.priority) {
this.error = 'Please select a Priority — without one the ticket gets no SLA deadline.';
this.submitting = false;
return;
}
// Create the ticket
const payload = {
description: this.form.description,
priority: this.mode === 'emergency' ? 'urgent' : (this.form.priority || null),
reporter: this.form.reporter || this.form.customer_name || app().user.full_name,
reported_via: this.form.reported_via || 'walk-in',
category_id: this.form.category_id ? parseInt(this.form.category_id) : (this.form.category_main ? parseInt(this.form.category_main) : null),
unit_id: this.form.unit.id,
customer_name: this.form.customer_name || null,
phone: this.form.phone || null,
reported_at: this.form.reported_date || null,
};
const ticket = await app().apiPost('/api/tickets', payload);
// Upload photos if any
if (this.photoFiles.length > 0 && ticket?.id) {
const formData = new FormData();
this.photoFiles.forEach(f => formData.append('files', f));
try {
await fetch(`/api/tickets/${ticket.id}/photos?is_before=true`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${app().token}` },
body: formData
});
} catch (e) { console.error('Photo upload error', e); }
}
app().showToast(`Ticket ${ticket.ticket_number} created successfully!`, 'success');
// Redirect to ticket detail
setTimeout(() => { window.location.href = `/tickets/${ticket.id}`; }, 1000);
} catch (e) {
this.error = e.message || 'Failed to create ticket';
} finally {
this.submitting = false;
}
}
}
}
</script>
{% endblock %}
+2 -3
View File
@@ -4,10 +4,9 @@ services:
container_name: denya-onecare
ports:
- "8000:8000"
env_file:
- .env # git-ignored; see .env.example for required keys
environment:
- DATABASE_URL=sqlite+aiosqlite:///./data/denya_onecare.db
- SECRET_KEY=change-me-in-production
- CORS_ORIGINS=*
- DEBUG=false
volumes:
- app-data:/app/data
+6
View File
@@ -22,8 +22,14 @@ build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["app*"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
pythonpath = ["."]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.24",
"httpx>=0.27.0",
]
+80
View File
@@ -0,0 +1,80 @@
"""Shared fixtures for the Denya OneCare test suite.
Sets DATABASE_URL to an isolated temp SQLite file BEFORE importing any app
module (the engine is created at import time), then provisions tables and
seed data per test.
"""
from __future__ import annotations
import os
import tempfile
_TMP_DIR = tempfile.mkdtemp(prefix="denya-test-")
os.environ["DATABASE_URL"] = f"sqlite+aiosqlite:///{_TMP_DIR}/test.db"
# HARDENING.md P0.1/P0.2: the app now fails closed without a real SECRET_KEY
# and an explicit CORS allow-list — tests must satisfy both.
os.environ.setdefault("SECRET_KEY", "test-secret-key-not-for-production-0123456789abcdef")
os.environ.setdefault("CORS_ORIGINS", "http://test")
import pytest # noqa: E402
import pytest_asyncio # noqa: E402 (DATABASE_URL must be set before app imports)
from httpx import ASGITransport, AsyncClient # noqa: E402
from app.core.database import Base, async_session_factory, engine # noqa: E402
from app.core.ratelimit import login_rate_limiter # noqa: E402
from app.main import app # noqa: E402
from app.models.ticket import Ticket # noqa: E402
from app.services.seed import seed_categories, seed_units, seed_users # noqa: E402
@pytest.fixture
def _reset_login_rate_limiter():
"""Isolate login rate-limit state between tests (shared in-process store)."""
login_rate_limiter.reset()
yield
login_rate_limiter.reset()
@pytest_asyncio.fixture
async def client(_reset_login_rate_limiter):
"""Async test client with a fresh, seeded database per test."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with async_session_factory() as session:
await seed_users(session)
await session.commit()
# json_path=None → built-in fallback units (same data as the committed
# apartment_mapping.json, kept deterministic for tests)
await seed_units(session, json_path=None)
await session.commit()
await seed_categories(session)
await session.commit()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest_asyncio.fixture
async def seed_tickets():
"""Insert `n` tickets directly into the DB; returns the count inserted."""
async def _seed(n: int) -> int:
async with async_session_factory() as session:
for i in range(n):
session.add(
Ticket(
ticket_number=f"PAV-TEST-{i:05d}",
status="Logged",
priority="medium",
description=f"Test ticket {i}",
)
)
await session.commit()
return n
return _seed
+118
View File
@@ -0,0 +1,118 @@
"""Tests for backdated reported-date support (Wahab demo).
Anchors:
* A ticket created with a past ``reported_at`` persists that date and it is
exposed on list + detail responses — this is how Wahab enters old tickets
that stay active in the normal workflow.
* A ticket created without ``reported_at`` defaults to "now", so existing
create behavior is unchanged.
* The reported date is metadata only: SLA deadlines still run from creation
time and no age/backdate restriction kicks in.
"""
from __future__ import annotations
from datetime import datetime
import pytest
pytestmark = pytest.mark.asyncio
def _naive(iso: str) -> datetime:
"""Parse an ISO datetime and strip any tz offset for safe comparison."""
dt = datetime.fromisoformat(iso)
return dt.replace(tzinfo=None) if dt.tzinfo is not None else dt
async def _login(client, email="wahab@denya.com", password="denya123") -> str:
resp = await client.post(
"/api/auth/login",
json={"email": email, "password": password},
)
assert resp.status_code == 200, resp.text
return resp.json()["access_token"]
async def _create_ticket(client, token: str, **overrides) -> dict:
payload = {
"unit_id": 2,
"category_id": 3,
"priority": "medium",
"reporter": "Backdate Test",
"reported_via": "walk-in",
"description": "backdate test ticket",
**overrides,
}
resp = await client.post(
"/api/tickets",
json=payload,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 201, resp.text
return resp.json()
async def test_create_with_backdated_reported_at_persists(client):
"""Wahab (Admin/Wahab) can enter an old ticket and its date sticks."""
token = await _login(client)
ticket = await _create_ticket(
client,
token,
reported_at="2026-07-20",
description="Old plumbing issue reported weeks ago",
)
assert ticket["reported_at"] is not None
assert ticket["reported_at"].startswith("2026-07-20")
# Still an active ticket in the normal workflow — no age restriction.
assert ticket["status"] in {"New", "Logged"}
# Detail endpoint exposes the reported date.
detail = await client.get(f"/api/tickets/{ticket['id']}")
assert detail.status_code == 200
assert detail.json()["reported_at"].startswith("2026-07-20")
# List endpoint exposes it too.
listing = await client.get("/api/tickets")
assert listing.status_code == 200
listed = next(t for t in listing.json()["items"] if t["id"] == ticket["id"])
assert listed["reported_at"].startswith("2026-07-20")
async def test_create_without_reported_at_defaults_to_now(client):
"""Omitting reported_at behaves exactly as before: reported == created."""
token = await _login(client)
ticket = await _create_ticket(client, token, description="normal today ticket")
assert ticket["reported_at"] is not None
reported = _naive(ticket["reported_at"])
created = _naive(ticket["created_at"])
assert abs((reported - created).total_seconds()) < 60
async def test_reported_at_does_not_shift_sla_deadline(client):
"""SLA computation is unchanged: deadlines run from creation time."""
token = await _login(client)
ticket = await _create_ticket(
client,
token,
priority="urgent",
reported_at="2026-01-01",
description="old urgent ticket",
)
assert ticket["sla_deadline"] is not None
created = _naive(ticket["created_at"])
deadline = _naive(ticket["sla_deadline"])
hours = (deadline - created).total_seconds() / 3600
assert 3.5 <= hours <= 4.5 # urgent → 4 h resolution window from creation
async def test_reported_at_round_trips_full_datetime(client):
"""A precise datetime (not just a date) survives the round trip."""
token = await _login(client)
reported = "2026-07-20T14:30:00"
ticket = await _create_ticket(client, token, reported_at=reported, description="datetime round trip")
assert ticket["reported_at"] is not None
parsed = _naive(ticket["reported_at"])
assert parsed.date().isoformat() == "2026-07-20"
assert parsed.hour == 14 and parsed.minute == 30
+248
View File
@@ -0,0 +1,248 @@
"""Tests for the Sprint A category + property-hierarchy changes.
Anchors:
* Alert-only categories (Gas Leak) are hidden from the picker endpoints by
default but still present in the DB with urgent SLA urgency, and retrievable
via ``include_hidden=true`` (emergency quick path).
* New seed categories (Aluminum/Glass, Carpentry, Mould & Damp) and the
Lost Property → Missing Item rename are idempotent.
* ``GET /api/tickets/units`` gains a ``building`` filter and the grouped
variant ``GET /api/tickets/units/grouped`` returns ``{property: {building: [units]}}``.
* ``GET /api/tickets`` supports additive ``building``/``unit_id`` filters.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
def _find(categories: list[dict], name: str) -> dict | None:
return next((c for c in categories if c["name"] == name), None)
# ── Category picker visibility ────────────────────────────────────────
async def test_gas_leak_hidden_from_category_tree_by_default(client):
"""Gas Leak must not appear in the default category picker."""
resp = await client.get("/api/tickets/categories")
assert resp.status_code == 200
data = resp.json()
# Any nesting level: walk top-level only; Gas Leak is top-level emergency
assert _find(data, "Gas Leak") is None
names = [c["name"] for c in data]
assert "Fire" in names and "Flood" in names # other emergencies still visible
async def test_gas_leak_hidden_from_emergency_type_filter(client):
"""The `?type=emergency` picker also excludes Gas Leak by default."""
resp = await client.get("/api/tickets/categories", params={"type": "emergency"})
assert resp.status_code == 200
data = resp.json()
assert _find(data, "Gas Leak") is None
assert _find(data, "Fire") is not None
async def test_include_hidden_returns_gas_leak(client):
"""include_hidden=true exposes alert-only categories (emergency quick path)."""
resp = await client.get("/api/tickets/categories", params={"include_hidden": "true"})
assert resp.status_code == 200
gas = _find(resp.json(), "Gas Leak")
assert gas is not None
assert gas["show_in_form"] is False
assert gas["sla_urgency"] == "urgent"
async def test_flat_list_hides_gas_leak_and_its_children(client):
"""Flat picker excludes Gas Leak and orphaned children of hidden parents."""
resp = await client.get("/api/tickets/categories/flat")
assert resp.status_code == 200
names = [c["name"] for c in resp.json()]
assert "Gas Leak" not in names
assert "Gas smell" not in names # child of alert-only parent
assert "Suspected leak" not in names
assert "Fire" in names
async def test_flat_list_include_hidden_keeps_gas_leak(client):
resp = await client.get("/api/tickets/categories/flat", params={"include_hidden": "true"})
assert resp.status_code == 200
names = [c["name"] for c in resp.json()]
assert "Gas Leak" in names
assert "Gas smell" in names
# ── Seed taxonomy ─────────────────────────────────────────────────────
async def test_seed_adds_new_categories(client):
"""Aluminum/Glass, Carpentry, Mould & Damp land as maintenance categories."""
resp = await client.get("/api/tickets/categories", params={"type": "maintenance"})
assert resp.status_code == 200
data = resp.json()
for name in ("Aluminum/Glass", "Carpentry", "Mould & Damp"):
cat = _find(data, name)
assert cat is not None, f"expected {name} in maintenance categories"
assert cat["children"], f"{name} should have sub-categories"
mould = _find(data, "Mould & Damp")
assert mould["sla_urgency"] == "medium"
async def test_seed_renames_lost_property_to_missing_item(client):
"""Seed must create 'Missing Item' (not 'Lost Property')."""
resp = await client.get("/api/tickets/categories", params={"type": "cs"})
assert resp.status_code == 200
data = resp.json()
assert _find(data, "Missing Item") is not None
assert _find(data, "Lost Property") is None
missing = _find(data, "Missing Item")
sub_names = [s["name"] for s in missing["children"]]
assert "Guest left items behind" in sub_names
assert "Item search request" in sub_names
async def test_seed_is_idempotent(client):
"""Running the seed twice does not duplicate categories."""
from app.core.database import async_session_factory
from app.services.seed import seed_categories
async with async_session_factory() as session:
created = await seed_categories(session)
await session.commit()
assert created == [] # nothing new to insert/sync on second pass
async def test_seed_syncs_show_in_form_on_existing_rows(client):
"""Existing databases pick up Gas Leak's alert-only flag on next startup.
Simulates a pre-existing DB (Gas Leak seeded with show_in_form=True before
the flag existed) — re-running the seed must flip it back to False.
"""
from sqlalchemy import select
from app.core.database import async_session_factory
from app.models.category import Category
from app.services.seed import seed_categories
async with async_session_factory() as session:
result = await session.execute(
select(Category).where(Category.type == "emergency", Category.name == "Gas Leak", Category.parent_id.is_(None))
)
gas = result.scalar_one()
gas.show_in_form = True # simulate legacy DB before the flag existed
await session.commit()
created = await seed_categories(session)
await session.commit()
await session.refresh(gas)
assert gas.show_in_form is False
assert gas.sla_urgency == "urgent" # SLA/alert mapping unchanged
# Sync counts as a change, but no duplicates are created
assert len(created) >= 1
async def test_legacy_db_self_heals_show_in_form_column():
"""A pre-Sprint-A categories table (no show_in_form) gets the column on startup."""
from sqlalchemy import text
from app.core.database import engine
from app.main import ensure_legacy_schema
async with engine.begin() as conn:
await conn.execute(text("DROP TABLE IF EXISTS tickets"))
await conn.execute(text("DROP TABLE IF EXISTS categories"))
await conn.execute(
text(
"CREATE TABLE categories ("
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "
"type VARCHAR(20) NOT NULL, "
"name VARCHAR(100) NOT NULL, "
"parent_id INTEGER, "
"sla_urgency VARCHAR(10))"
)
)
await ensure_legacy_schema(conn)
result = await conn.execute(text("PRAGMA table_info(categories)"))
assert "show_in_form" in {row[1] for row in result}
# Idempotent on the next startup
async with engine.begin() as conn:
await ensure_legacy_schema(conn)
result = await conn.execute(text("PRAGMA table_info(categories)"))
assert "show_in_form" in {row[1] for row in result}
async with engine.begin() as conn:
await conn.execute(text("DROP TABLE IF EXISTS categories"))
await conn.execute(text("DROP TABLE IF EXISTS tickets"))
# ── Unit hierarchy ────────────────────────────────────────────────────
async def test_units_building_filter(client):
"""GET /api/tickets/units?building= filters to one building."""
resp = await client.get("/api/tickets/units", params={"building": "Pavilion East"})
assert resp.status_code == 200
units = resp.json()
assert len(units) > 0
assert all(u["building"] == "Pavilion East" for u in units)
assert all(u["property"] == "East" for u in units)
async def test_units_grouped_shape(client):
"""Grouped variant returns {property: {building: [units]}}."""
resp = await client.get("/api/tickets/units/grouped")
assert resp.status_code == 200
grouped = resp.json()
assert "East" in grouped and "West" in grouped
east = grouped["East"]
assert "Pavilion East" in east
assert len(east["Pavilion East"]) == 60
unit = east["Pavilion East"][0]
assert {"id", "property", "apartment_code", "building", "floor"} <= set(unit.keys())
# Distinct apartment codes
codes = [u["apartment_code"] for u in east["Pavilion East"]]
assert len(set(codes)) == len(codes)
async def test_units_grouped_property_filter(client):
resp = await client.get("/api/tickets/units/grouped", params={"property": "West"})
assert resp.status_code == 200
grouped = resp.json()
assert set(grouped.keys()) == {"West"}
# ── Ticket list filters ───────────────────────────────────────────────
async def test_tickets_filter_by_building_and_unit(client):
"""Additive building/unit_id filters compose with the list endpoint."""
# Grab two units from different buildings/properties
resp = await client.get("/api/tickets/units", params={"building": "Pavilion East"})
east_unit = resp.json()[0]
resp = await client.get("/api/tickets/units", params={"building": "Pavilion West"})
west_unit = resp.json()[0]
# Two tickets, one per building
payloads = [
{"description": "east ticket", "unit_id": east_unit["id"], "priority": "medium"},
{"description": "west ticket", "unit_id": west_unit["id"], "priority": "high"},
]
token = await _login(client)
for p in payloads:
r = await client.post("/api/tickets", json=p, headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 201, r.text
# building filter
r = await client.get("/api/tickets", params={"building": "Pavilion East"})
data = r.json()
assert data["total"] == 1
assert data["items"][0]["description"] == "east ticket"
# unit_id filter
r = await client.get("/api/tickets", params={"unit_id": west_unit["id"]})
data = r.json()
assert data["total"] == 1
assert data["items"][0]["description"] == "west ticket"
async def _login(client) -> str:
resp = await client.post(
"/api/auth/login",
json={"email": "wahab@denya.com", "password": "denya123"},
)
assert resp.status_code == 200, resp.text
return resp.json()["access_token"]
+74
View File
@@ -0,0 +1,74 @@
"""Frontend must be fully self-contained — no CDN (LAN page-freeze regression).
The P0 batch's templates loaded Alpine.js from ``cdn.jsdelivr.net`` and Tailwind
from ``cdn.tailwindcss.com``, so any demo client that cannot reach those CDNs
(LAN-only devices, filtered networks) got a login page whose JS never engaged
(a stuck form). Both libraries are now vendored under ``app/static/vendor/``
and served same-origin with no external ``script src`` in the HTML. HTML pages
always revalidate (``Cache-Control: no-cache``); the vendored assets carry
long-lived immutable caching (their URLs embed the version).
"""
from __future__ import annotations
import re
import pytest
from httpx import AsyncClient
pytestmark = pytest.mark.asyncio
# Any <script … src="//host/…"> or src="https?://host/…"> — i.e. NOT same-origin.
_EXTERNAL_SRC = re.compile(r"""<script\b[^>]*\bsrc\s*=\s*["'](?:https?:)?//[^"']+["']""")
VENDORED_SCRIPTS = (
"/static/vendor/alpine-3.17.2.min.js",
"/static/vendor/tailwind-3.4.17.js",
)
async def test_login_page_has_no_external_script_srcs(client: AsyncClient):
"""/login must reference only same-origin scripts — regex over the body."""
resp = await client.get("/login")
assert resp.status_code == 200, resp.text
body = resp.text
external = _EXTERNAL_SRC.findall(body)
assert not external, f"external script srcs found: {external}"
for src in VENDORED_SCRIPTS:
assert src in body, f"missing vendored script {src} in /login HTML"
async def test_vendor_assets_served_same_origin(client: AsyncClient):
"""Both vendored libraries must resolve locally with real JS content."""
for src in VENDORED_SCRIPTS:
resp = await client.get(src)
assert resp.status_code == 200, f"{src} -> {resp.status_code}"
assert len(resp.content) > 1000, f"{src} looks empty ({len(resp.content)} bytes)"
async def test_html_pages_are_not_cached(client: AsyncClient):
"""HTML page responses must always revalidate (Cache-Control: no-cache)."""
resp = await client.get("/login")
assert resp.status_code == 200
assert resp.headers["cache-control"] == "no-cache"
async def test_vendor_assets_cached_immutable(client: AsyncClient):
"""Versioned vendor assets must carry long-lived immutable caching."""
for src in VENDORED_SCRIPTS:
resp = await client.get(src)
assert resp.status_code == 200
cc = resp.headers.get("cache-control", "")
assert "max-age=31536000" in cc and "immutable" in cc, f"{src}: {cc!r}"
async def test_csp_no_longer_allows_cdn_hosts(client: AsyncClient):
"""CSP must be 'self'-only for scripts/styles; connect-src stays 'self'."""
resp = await client.get("/login")
assert resp.status_code == 200
csp = resp.headers["content-security-policy"]
for host in ("cdn.jsdelivr.net", "cdn.tailwindcss.com"):
assert host not in csp, f"CSP still allows {host}"
assert "script-src 'self' 'unsafe-inline'" in csp
assert "style-src 'self' 'unsafe-inline'" in csp
assert "connect-src 'self'" in csp
+610
View File
@@ -0,0 +1,610 @@
"""P0 security batch — admin user management, unified role model, login rate
limiting, WhatsApp webhook secret, mock-log auth, security headers, pagination.
Each item in the P0 hardening batch has an executable behavioral test here
(plus the register-removal tests living in test_p0_hardening.py).
"""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from app.core.config import settings
from app.core.database import async_session_factory
from app.core.security import hash_password
from app.models.user import User
from app.services.seed import normalize_legacy_user_emails, normalize_legacy_user_roles
pytestmark = pytest.mark.asyncio
# ── helpers ───────────────────────────────────────────────────────────
async def _login(client, email="wahab@denya.com", password="denya123") -> str:
resp = await client.post("/api/auth/login", json={"email": email, "password": password})
assert resp.status_code == 200, resp.text
return resp.json()["access_token"]
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
async def _insert_user(email: str, role: str, *, active: bool = True) -> int:
"""Insert a user row directly (bypasses API role validation) — used to
simulate legacy bootstrap/registration rows in the DB."""
async with async_session_factory() as session:
user = User(
email=email,
password_hash=hash_password("denya123"),
full_name=f"Legacy {email}",
role=role,
active=active,
)
session.add(user)
await session.commit()
return user.id
async def _normalize_roles() -> None:
async with async_session_factory() as session:
await normalize_legacy_user_roles(session)
await session.commit()
async def _normalize_emails() -> None:
async with async_session_factory() as session:
await normalize_legacy_user_emails(session)
await session.commit()
async def _create_ticket(client, token: str, **overrides) -> dict:
payload = {
"unit_id": 2,
"category_id": 3,
"priority": "medium",
"reporter": "P0 Batch Test",
"reported_via": "walk-in",
"description": "p0 batch ticket",
**overrides,
}
resp = await client.post("/api/tickets", json=payload, headers=_auth(token))
assert resp.status_code == 201, resp.text
return resp.json()
# ── Item 2/3: admin user management ───────────────────────────────────
async def test_create_user_requires_admin(client: AsyncClient):
token = await _login(client, email="bella@denya.com") # CS Rep
resp = await client.post(
"/api/auth/users",
json={"email": "x@example.com", "password": "password1", "full_name": "X", "role": "CS Rep"},
headers=_auth(token),
)
assert resp.status_code == 403
async def test_create_user_requires_auth(client: AsyncClient):
resp = await client.post(
"/api/auth/users",
json={"email": "x@example.com", "password": "password1", "full_name": "X", "role": "CS Rep"},
)
assert resp.status_code == 401
async def test_admin_creates_user_with_forced_role(client: AsyncClient):
token = await _login(client) # Admin/Wahab
resp = await client.post(
"/api/auth/users",
json={"email": "New.Tech@Example.com", "password": "password1", "full_name": "New Tech", "role": "Tech"},
headers=_auth(token),
)
assert resp.status_code == 201, resp.text
created = resp.json()
assert created["role"] == "Tech"
assert created["active"] is True
assert created["email"] == "new.tech@example.com" # normalized lower-case
# The new user can actually log in with their forced role.
login = await client.post(
"/api/auth/login", json={"email": "new.tech@example.com", "password": "password1"}
)
assert login.status_code == 200
me = await client.get("/api/auth/me", headers=_auth(login.json()["access_token"]))
assert me.json()["role"] == "Tech"
async def test_admin_create_user_rejects_unknown_roles(client: AsyncClient):
"""Unified role model: junk/legacy roles cannot be minted at creation."""
token = await _login(client)
for role in ("admin", "superadmin", "technician", "root"):
resp = await client.post(
"/api/auth/users",
json={"email": f"{role.strip().lower()}@example.com", "password": "password1", "full_name": "X", "role": role},
headers=_auth(token),
)
assert resp.status_code == 422, (role, resp.text)
async def test_admin_create_user_duplicate_email_conflict(client: AsyncClient):
token = await _login(client)
payload = {"email": "dupe2@example.com", "password": "password1", "full_name": "D", "role": "CS Rep"}
assert (await client.post("/api/auth/users", json=payload, headers=_auth(token))).status_code == 201
resp = await client.post("/api/auth/users", json=payload, headers=_auth(token))
assert resp.status_code == 409
async def test_patch_user_role_change(client: AsyncClient):
token = await _login(client)
users = (await client.get("/api/auth/users", headers=_auth(token))).json()
tech = next(u for u in users if u["role"] == "Tech")
resp = await client.patch(
f"/api/auth/users/{tech['id']}",
json={"role": "CS Rep"},
headers=_auth(token),
)
assert resp.status_code == 200, resp.text
assert resp.json()["role"] == "CS Rep"
assert resp.json()["active"] is True
async def test_patch_user_rejects_unknown_role(client: AsyncClient):
token = await _login(client)
users = (await client.get("/api/auth/users", headers=_auth(token))).json()
tech = next(u for u in users if u["role"] == "Tech")
resp = await client.patch(
f"/api/auth/users/{tech['id']}",
json={"role": "superadmin"},
headers=_auth(token),
)
assert resp.status_code == 422, resp.text
async def test_patch_deactivate_blocks_login(client: AsyncClient):
token = await _login(client)
users = (await client.get("/api/auth/users", headers=_auth(token))).json()
tech = next(u for u in users if u["role"] == "Tech")
resp = await client.patch(
f"/api/auth/users/{tech['id']}",
json={"active": False},
headers=_auth(token),
)
assert resp.status_code == 200
assert resp.json()["active"] is False
login = await client.post(
"/api/auth/login", json={"email": tech["email"], "password": "denya123"}
)
assert login.status_code == 401
async def test_admin_cannot_modify_own_account(client: AsyncClient):
token = await _login(client) # wahab
me = (await client.get("/api/auth/me", headers=_auth(token))).json()
resp = await client.patch(
f"/api/auth/users/{me['id']}", json={"active": False}, headers=_auth(token)
)
assert resp.status_code == 400
async def test_admin_can_demote_other_admin_but_not_self(client: AsyncClient):
"""An admin can manage the other admin seat, but self-removal stays blocked,
so at least one canonical admin always remains (structural invariant)."""
token = await _login(client) # wahab
users = (await client.get("/api/auth/users", headers=_auth(token))).json()
jerome = next(u for u in users if u["role"] == "Admin/Jerome")
wahab = next(u for u in users if u["role"] == "Admin/Wahab")
# Demote the OTHER admin → allowed, wahab is still the acting admin.
resp = await client.patch(
f"/api/auth/users/{jerome['id']}", json={"role": "CEO"}, headers=_auth(token)
)
assert resp.status_code == 200, resp.text
# Demoting/deactivating yourself is always rejected.
resp = await client.patch(
f"/api/auth/users/{wahab['id']}", json={"active": False}, headers=_auth(token)
)
assert resp.status_code == 400
async def test_delete_user_admin_only_and_works(client: AsyncClient):
admin_token = await _login(client)
users = (await client.get("/api/auth/users", headers=_auth(admin_token))).json()
tech = next(u for u in users if u["role"] == "Tech")
# Non-admin cannot delete.
cs_token = await _login(client, email="bella@denya.com")
resp = await client.delete(f"/api/auth/users/{tech['id']}", headers=_auth(cs_token))
assert resp.status_code == 403
# Admin deletes → 204, user gone, login fails.
resp = await client.delete(f"/api/auth/users/{tech['id']}", headers=_auth(admin_token))
assert resp.status_code == 204
login = await client.post(
"/api/auth/login", json={"email": tech["email"], "password": "denya123"}
)
assert login.status_code == 401
async def test_delete_user_referenced_by_ticket_is_409(client: AsyncClient):
token = await _login(client)
users = (await client.get("/api/auth/users", headers=_auth(token))).json()
tech = next(u for u in users if u["role"] == "Tech")
ticket = await _create_ticket(client, token)
resp = await client.patch(
f"/api/tickets/{ticket['id']}",
json={"assigned_to": tech["id"]},
headers=_auth(token),
)
assert resp.status_code == 200, resp.text
resp = await client.delete(f"/api/auth/users/{tech['id']}", headers=_auth(token))
assert resp.status_code == 409
assert "related" in resp.json()["detail"].lower()
async def test_admin_cannot_delete_self(client: AsyncClient):
token = await _login(client)
me = (await client.get("/api/auth/me", headers=_auth(token))).json()
resp = await client.delete(f"/api/auth/users/{me['id']}", headers=_auth(token))
assert resp.status_code == 400
# ── Item 3: unified role model — legacy rows & JWT validation ─────────
async def test_legacy_alias_role_normalized_at_startup(client: AsyncClient):
"""A legacy 'technician' row is mapped onto canonical 'Tech' at startup."""
await _insert_user("legacy-tech@example.com", "technician")
await _normalize_roles() # what lifespan does each boot
login = await client.post(
"/api/auth/login", json={"email": "legacy-tech@example.com", "password": "denya123"}
)
assert login.status_code == 200, login.text
me = await client.get("/api/auth/me", headers=_auth(login.json()["access_token"]))
assert me.json()["role"] == "Tech"
async def test_login_rejects_unknown_legacy_role_fail_closed(client: AsyncClient):
"""Lowercase 'superadmin' rows (mintable by the old open register) cannot
log in — fail closed, never granted admin powers."""
await _insert_user("legacy-admin@example.com", "superadmin")
# NOTE: no normalize call — the row is exactly what the live DB holds today.
login = await client.post(
"/api/auth/login", json={"email": "legacy-admin@example.com", "password": "denya123"}
)
assert login.status_code == 401
assert "role" in login.json()["detail"].lower()
async def test_jwt_validation_rejects_unknown_role(client: AsyncClient):
"""A token for a user whose row later becomes junk-role must fail closed."""
token = await _login(client) # wahab is a canonical admin at token time
me = (await client.get("/api/auth/me", headers=_auth(token))).json()
# Simulate a legacy DB row flip to a non-canonical role.
async with async_session_factory() as session:
from sqlalchemy import select
user = (await session.execute(select(User).where(User.id == me["id"]))).scalar_one()
user.role = "admin"
await session.commit()
resp = await client.get("/api/auth/me", headers=_auth(token))
assert resp.status_code == 401
async def test_normalize_does_not_map_ambiguous_admin_alias(client: AsyncClient):
"""normalize_legacy_user_roles leaves identity-ambiguous 'admin' rows for
operator remediation instead of guessing a canonical admin."""
await _insert_user("legacy-admin2@example.com", "admin")
await _normalize_roles()
async with async_session_factory() as session:
from sqlalchemy import select
user = (
await session.execute(select(User).where(User.email == "legacy-admin2@example.com"))
).scalar_one()
assert user.role == "admin"
# ── P0 email normalization (legacy mixed-case rows) ───────────────────
async def test_legacy_mixed_case_email_migrated_and_authenticates(client: AsyncClient):
"""A legacy row whose email was stored verbatim in mixed case (the old open
register) is lowercased by the startup self-heal and still authenticates."""
await _insert_user("DemoUser@Example.com", "Tech")
await _normalize_emails() # what lifespan does each boot
async with async_session_factory() as session:
from sqlalchemy import select
user = (
await session.execute(select(User).where(User.email == "demouser@example.com"))
).scalar_one()
assert user.email == "demouser@example.com"
for variant in ("demouser@example.com", "DemoUser@Example.com"):
resp = await client.post(
"/api/auth/login", json={"email": variant, "password": "denya123"}
)
assert resp.status_code == 200, resp.text
async def test_login_matches_legacy_mixed_case_email_before_migration(client: AsyncClient):
"""Login compares on the normalized form, so an un-migrated mixed-case row
is still matched by its lowercase login (no hard dependency on the
self-heal having run)."""
await _insert_user("DemoUser@Example.com", "Tech")
resp = await client.post(
"/api/auth/login", json={"email": "demouser@example.com", "password": "denya123"}
)
assert resp.status_code == 200, resp.text
async def test_legacy_email_normalization_is_idempotent(client: AsyncClient):
"""The startup self-heal rewrites once and no-ops on subsequent boots."""
await _insert_user("DemoUser@Example.com", "Tech")
async with async_session_factory() as session:
first = await normalize_legacy_user_emails(session)
await session.commit()
async with async_session_factory() as session:
second = await normalize_legacy_user_emails(session)
await session.commit()
assert first == 1
assert second == 0
async def test_create_user_rejects_case_variant_of_legacy_email(client: AsyncClient):
"""The admin create-user duplicate check compares on the normalized form:
creating a case-variant of a legacy mixed-case row returns 409, not 201."""
await _insert_user("DemoUser@Example.com", "Tech")
token = await _login(client)
resp = await client.post(
"/api/auth/users",
json={
"email": "demouser@example.com",
"password": "password1",
"full_name": "X",
"role": "Tech",
},
headers=_auth(token),
)
assert resp.status_code == 409, resp.text
async def test_admin_only_rbac_gate(client: AsyncClient):
"""Canonical admins pass /api/auth/admin-only; everyone else 403."""
wahab = await _login(client)
assert (await client.get("/api/auth/admin-only", headers=_auth(wahab))).status_code == 200
bella = await _login(client, email="bella@denya.com")
assert (await client.get("/api/auth/admin-only", headers=_auth(bella))).status_code == 403
# ── Item 4: login rate limiting ───────────────────────────────────────
async def test_login_rate_limited_after_five_failures(client: AsyncClient):
email, password = "rate-limited@example.com", "denya123"
# Make sure the account exists with a valid password.
token = await _login(client)
await client.post(
"/api/auth/users",
json={"email": email, "password": password, "full_name": "Rate", "role": "CS Rep"},
headers=_auth(token),
)
for _ in range(5):
resp = await client.post(
"/api/auth/login", json={"email": email, "password": "wrong-password"}
)
assert resp.status_code == 401
# 6th attempt — even with the CORRECT password — is throttled.
resp = await client.post(
"/api/auth/login", json={"email": email, "password": password}
)
assert resp.status_code == 429, resp.text
async def test_rate_limit_is_per_email(client: AsyncClient):
"""Failures for one account never lock out another account."""
token = await _login(client)
for email in ("victim@example.com", "other@example.com"):
await client.post(
"/api/auth/users",
json={"email": email, "password": "password1", "full_name": "U", "role": "CS Rep"},
headers=_auth(token),
)
for _ in range(6):
resp = await client.post(
"/api/auth/login", json={"email": "victim@example.com", "password": "bad"}
)
assert resp.status_code in (401, 429)
# Unaffected account still logs in fine.
resp = await client.post(
"/api/auth/login", json={"email": "other@example.com", "password": "password1"}
)
assert resp.status_code == 200, resp.text
async def test_rate_limit_window_expires(client: AsyncClient, monkeypatch):
"""After the 15-minute window passes, the account can log in again."""
import time as _time
import app.core.ratelimit as ratelimit_mod
email, password = "window@example.com", "password1"
token = await _login(client)
await client.post(
"/api/auth/users",
json={"email": email, "password": password, "full_name": "W", "role": "CS Rep"},
headers=_auth(token),
)
# Pin the limiter clock so the window can be fast-forwarded deterministically.
clock = {"now": _time.time()}
monkeypatch.setattr(ratelimit_mod, "_now", lambda: clock["now"])
for _ in range(5):
await client.post("/api/auth/login", json={"email": email, "password": "bad"})
blocked = await client.post("/api/auth/login", json={"email": email, "password": password})
assert blocked.status_code == 429, blocked.text
clock["now"] += settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS + 1
resp = await client.post("/api/auth/login", json={"email": email, "password": password})
assert resp.status_code == 200, resp.text
# ── Item 5: WhatsApp webhook secret (fail closed) ─────────────────────
WEBHOOK_BODY = {
"object": "whatsapp_business_account",
"entry": [
{
"id": "1",
"changes": [
{
"id": "wamid.1",
"message": {"from": "+233000000000", "id": "wamid.1", "text": {"text": "AC leaking"}},
}
],
}
],
}
async def test_webhook_fail_closed_when_env_unset(client: AsyncClient):
"""WHATSAPP_WEBHOOK_SECRET unset ⇒ every message rejected (403)."""
assert settings.WHATSAPP_WEBHOOK_SECRET == "" # test env default is unset
resp = await client.post("/api/whatsapp/webhook", json=WEBHOOK_BODY)
assert resp.status_code == 403
assert "not configured" in resp.json()["detail"].lower()
async def test_webhook_rejects_missing_or_wrong_secret(client: AsyncClient, monkeypatch):
monkeypatch.setattr(settings, "WHATSAPP_WEBHOOK_SECRET", "test-webhook-secret")
resp = await client.post("/api/whatsapp/webhook", json=WEBHOOK_BODY)
assert resp.status_code == 403
resp = await client.post(
"/api/whatsapp/webhook", json=WEBHOOK_BODY, headers={"X-Webhook-Secret": "wrong"}
)
assert resp.status_code == 403
async def test_webhook_accepts_valid_secret_and_creates_ticket(client: AsyncClient, monkeypatch):
from app.routers import whatsapp as whatsapp_router
async def _fake_reply(to_phone: str, text: str):
from app.schemas.whatsapp import WhatsAppReplyResponse
return WhatsAppReplyResponse(success=True, message="sent")
monkeypatch.setattr(settings, "WHATSAPP_WEBHOOK_SECRET", "test-webhook-secret")
monkeypatch.setattr(whatsapp_router, "send_whatsapp_reply", _fake_reply)
resp = await client.post(
"/api/whatsapp/webhook",
json=WEBHOOK_BODY,
headers={"X-Webhook-Secret": "test-webhook-secret"},
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["status"] == "processed"
assert data["ticket_number"].startswith("PAV-")
# The message is logged and visible to an authenticated mock-log caller.
token = await _login(client)
log = await client.get("/api/whatsapp/mock-log", headers=_auth(token))
assert log.status_code == 200
assert len(log.json()) == 1
assert log.json()[0]["ticket_number"] == data["ticket_number"]
async def test_webhook_verify_token_mismatch_403(client: AsyncClient, monkeypatch):
monkeypatch.setattr(settings, "WHATSAPP_VERIFY_TOKEN", "verify-me")
resp = await client.get(
"/api/whatsapp/webhook",
params={"hub.mode": "subscribe", "hub.verify_token": "nope", "hub.challenge": "1234"},
)
assert resp.status_code == 403
async def test_webhook_verify_token_match_returns_challenge(client: AsyncClient, monkeypatch):
monkeypatch.setattr(settings, "WHATSAPP_VERIFY_TOKEN", "verify-me")
resp = await client.get(
"/api/whatsapp/webhook",
params={"hub.mode": "subscribe", "hub.verify_token": "verify-me", "hub.challenge": "1234"},
)
assert resp.status_code == 200
assert resp.json() == {"challenge": "1234"}
# ── Item 6: mock-log requires auth ────────────────────────────────────
async def test_mock_log_requires_auth(client: AsyncClient):
resp = await client.get("/api/whatsapp/mock-log")
assert resp.status_code == 401, resp.text
token = await _login(client)
resp = await client.get("/api/whatsapp/mock-log", headers=_auth(token))
assert resp.status_code == 200
assert resp.json() == []
# ── Item 7: security headers ──────────────────────────────────────────
async def test_security_headers_on_html_page(client: AsyncClient):
resp = await client.get("/login")
assert resp.status_code == 200
assert resp.headers["x-frame-options"] == "DENY"
assert resp.headers["x-content-type-options"] == "nosniff"
assert "content-security-policy" in resp.headers
assert "default-src 'self'" in resp.headers["content-security-policy"]
async def test_csp_only_on_html_not_json_api(client: AsyncClient):
resp = await client.get("/api/tickets")
assert resp.status_code == 200
assert "content-type" in resp.headers and resp.headers["content-type"].startswith("application/json")
assert "content-security-policy" not in resp.headers
# Frame/type hardening headers apply everywhere.
assert resp.headers["x-frame-options"] == "DENY"
assert resp.headers["x-content-type-options"] == "nosniff"
async def test_hsts_only_when_tls_terminates(client: AsyncClient):
plain = await client.get("/login")
assert "strict-transport-security" not in plain.headers
tls = await client.get("/login", headers={"X-Forwarded-Proto": "https"})
assert tls.headers["strict-transport-security"] == "max-age=31536000; includeSubDomains"
# ── Item 8: pagination (page/limit) and sane max page size ────────────
async def test_limit_alias_over_cap_rejected(client: AsyncClient, seed_tickets):
await seed_tickets(10)
resp = await client.get("/api/tickets", params={"limit": 500})
assert resp.status_code == 422
async def test_limit_alias_paginates(client: AsyncClient, seed_tickets):
await seed_tickets(14)
resp = await client.get("/api/tickets", params={"page": 2, "limit": 5})
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 14
assert len(data["items"]) == 5
assert data["page_size"] == 5
assert data["page"] == 2
async def test_page_beyond_last_returns_empty_with_total(client: AsyncClient, seed_tickets):
await seed_tickets(7)
resp = await client.get("/api/tickets", params={"page": 999, "page_size": 10})
assert resp.status_code == 200
data = resp.json()
assert data["items"] == []
assert data["total"] == 7
async def test_page_size_and_limit_conflict_is_422(client: AsyncClient, seed_tickets):
await seed_tickets(3)
resp = await client.get("/api/tickets", params={"page_size": 10, "limit": 20})
assert resp.status_code == 422
+132
View File
@@ -0,0 +1,132 @@
"""P0 hardening regression tests (HARDENING.md P0.1 / P0.2 / P0.3).
Covers:
- P0.3: self-registration is REMOVED — POST /api/auth/register returns 404 and
no public path can mint a user at all (users are admin-created only).
- P0.1: app fails to import/boot with placeholder or missing SECRET_KEY
- P0.2: app fails to boot with CORS_ORIGINS="*"
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
import pytest
from httpx import AsyncClient
REPO_ROOT = Path(__file__).resolve().parent.parent
pytestmark = pytest.mark.asyncio
# ── P0.3: self-registration is removed entirely ───────────────────────
async def test_register_endpoint_is_removed(client: AsyncClient):
"""A raw unauthenticated register call must 404 — no public signup path."""
resp = await client.post(
"/api/auth/register",
json={
"email": "attacker@example.com",
"password": "Sup3rSecret!",
"full_name": "Attacker",
"role": "Admin/Jerome",
},
)
assert resp.status_code == 404, resp.text
async def test_register_endpoint_removed_regardless_of_role(client: AsyncClient):
"""Attempts to mint privileged (or any) roles via register all 404."""
for role in ("Admin/Jerome", "Admin/Wahab", "admin", "superadmin", "CS Rep"):
resp = await client.post(
"/api/auth/register",
json={
"email": f"attacker-{role.lower().replace('/', '-')}@example.com",
"password": "Sup3rSecret!",
"full_name": "Attacker",
"role": role,
},
)
assert resp.status_code == 404, (role, resp.text)
async def test_register_does_not_create_user(client: AsyncClient):
"""No user row is ever created through the removed register endpoint."""
await client.post(
"/api/auth/register",
json={
"email": "ghost@example.com",
"password": "Sup3rSecret!",
"full_name": "Ghost",
},
)
# The ghost account must not be able to log in.
resp = await client.post(
"/api/auth/login",
json={"email": "ghost@example.com", "password": "Sup3rSecret!"},
)
assert resp.status_code == 401, resp.text
def _boot_with_env(env_overrides: dict[str, str]) -> subprocess.CompletedProcess:
"""Try importing app.main in a subprocess with the given env; the import
must fail (non-zero) when fail-closed validation trips."""
env = os.environ.copy()
env["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
env.pop("SECRET_KEY", None)
env.pop("CORS_ORIGINS", None)
env.update(env_overrides)
script = (
"import sys; sys.path.insert(0, ''); "
"import app.main" # noqa
)
return subprocess.run(
[sys.executable, "-c", script],
cwd=str(REPO_ROOT),
env=env,
capture_output=True,
text=True,
timeout=60,
)
def test_boot_fails_with_placeholder_secret():
result = _boot_with_env({"SECRET_KEY": "change-me-in-production"})
assert result.returncode != 0, "app booted with placeholder SECRET_KEY!"
assert "SECRET_KEY" in result.stderr
def test_boot_fails_with_short_secret():
result = _boot_with_env({"SECRET_KEY": "tooshort"})
assert result.returncode != 0, "app booted with a <32-char SECRET_KEY!"
assert "SECRET_KEY" in result.stderr
def test_boot_fails_without_secret():
result = _boot_with_env({"SECRET_KEY": ""})
assert result.returncode != 0, "app booted without a SECRET_KEY!"
assert "SECRET_KEY" in result.stderr
def test_boot_fails_with_wildcard_cors():
result = _boot_with_env(
{
"SECRET_KEY": "test-secret-key-not-for-production-0123456789abcdef",
"CORS_ORIGINS": "*",
}
)
assert result.returncode != 0, "app booted with CORS_ORIGINS=* !"
assert "CORS_ORIGINS" in result.stderr
def test_boot_succeeds_with_valid_env():
result = _boot_with_env(
{
"SECRET_KEY": "test-secret-key-not-for-production-0123456789abcdef",
"CORS_ORIGINS": "http://test",
}
)
assert result.returncode == 0, result.stderr
+56
View File
@@ -0,0 +1,56 @@
"""Tests anchoring ticket list pagination behavior.
The CEO dashboard (app/templates/dashboard/ceo.html) previously requested
`page_size=500`; the API caps page_size at 200 (`le=200` in
app/routers/tickets.py), so that request returned 422 and the dashboard
rendered empty KPIs. These tests pin the API contract the frontend now
relies on: page_size=200 + page loops that collect every ticket.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
async def test_page_size_over_cap_returns_422(client):
"""Requests above the page_size cap must be rejected (the original bug)."""
resp = await client.get("/api/tickets", params={"page_size": 500})
assert resp.status_code == 422
async def test_page_size_at_cap_returns_items_and_total(client, seed_tickets):
"""page_size=200 is the max legal value and returns the full response shape."""
await seed_tickets(14)
resp = await client.get("/api/tickets", params={"page": 1, "page_size": 200})
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 14
assert len(data["items"]) == 14
assert data["page"] == 1
assert data["page_size"] == 200
async def test_paginated_loop_collects_all_tickets(client, seed_tickets):
"""The frontend's page loop (page_size=200 until total reached) collects every ticket."""
total_seeded = await seed_tickets(450) # 3 pages of 200
collected: list[dict] = []
total = float("inf")
page = 1
page_size = 200
while len(collected) < total and page <= 1000:
resp = await client.get("/api/tickets", params={"page": page, "page_size": page_size})
assert resp.status_code == 200
data = resp.json()
assert data["items"], "expected a non-empty page"
collected.extend(data["items"])
total = data["total"] or len(collected)
page += 1
assert total == total_seeded
assert len(collected) == total_seeded
# No duplicate tickets across pages
ids = [t["id"] for t in collected]
assert len(set(ids)) == len(ids)
+290
View File
@@ -0,0 +1,290 @@
"""Tests for the Wahab demo-prep hardening batch.
Anchors:
* ``Cancelled`` is a terminal status reachable from every active state, is
excluded from SLA breach reporting, and shows up in the status pickers.
* Customer ``phone`` collected on the new-issue form is persisted (it used to
be silently dropped).
* ``GET /api/tickets/{id}`` returns nested ``unit``/``category`` objects so the
detail page stops rendering "—" for Unit/Property/Category.
* ``DELETE /api/tickets/{id}`` is admin-only and removes ticket children.
* ``GET /api/auth/users`` powers the assign-technician dropdown.
* Ticket numbering uses max+1, so deleting tickets never re-issues a number.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
async def _login(client, email="wahab@denya.com", password="denya123") -> str:
resp = await client.post(
"/api/auth/login",
json={"email": email, "password": password},
)
assert resp.status_code == 200, resp.text
return resp.json()["access_token"]
async def _create_ticket(client, token: str, **overrides) -> dict:
payload = {
"unit_id": 2,
"category_id": 3,
"priority": "medium",
"reporter": "Demo Prep Test",
"reported_via": "walk-in",
"description": "hardening batch test ticket",
**overrides,
}
resp = await client.post(
"/api/tickets",
json=payload,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 201, resp.text
return resp.json()
# ── Cancelled status ──────────────────────────────────────────────────
async def test_cancelled_is_terminal(client):
"""A cancelled ticket has no valid next status."""
from app.services.ticket import VALID_TRANSITIONS
assert "Cancelled" in VALID_TRANSITIONS
assert VALID_TRANSITIONS["Cancelled"] == []
async def test_cancelled_reachable_from_active_states(client):
"""Logged/Triage/In Progress → Cancelled are all valid transitions."""
from app.services.ticket import VALID_TRANSITIONS
for state in (
"New", "Logged", "Triage", "Assigned", "Accepted", "Travelling", "On Site",
"In Progress", "Waiting Parts", "Escalated", "On-Field Verification",
"Wahab Review", "Reopened",
):
assert "Cancelled" in VALID_TRANSITIONS[state], f"{state} should allow Cancelled"
async def test_cancel_ticket_via_api(client):
"""PATCH status=Cancelled works end-to-end and lands in the timeline."""
token = await _login(client)
ticket = await _create_ticket(client, token, description="cancel me")
resp = await client.patch(
f"/api/tickets/{ticket['id']}",
json={"status": "Cancelled", "note": "demo sample"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["status"] == "Cancelled"
assert data["timeline"][-1]["to_status"] == "Cancelled"
async def test_cancelled_ticket_not_sla_breached(client):
"""A cancelled ticket must not report SLA breach (like Closed/Completed)."""
token = await _login(client)
ticket = await _create_ticket(client, token, priority="urgent", description="cancel sla test")
# Force the SLA deadline into the past by patching priority (recomputes from now),
# then cancel; the breach flags must be False either way.
resp = await client.patch(
f"/api/tickets/{ticket['id']}",
json={"status": "Cancelled"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
sla = resp.json()["sla_status"]
assert sla["resolution_breached"] is False
assert sla["response_breached"] is False
# ── Phone persistence ─────────────────────────────────────────────────
async def test_phone_persisted_on_create(client):
"""Customer phone sent at creation is stored and returned."""
token = await _login(client)
ticket = await _create_ticket(client, token, phone="+233123456789", description="phone test")
assert ticket["phone"] == "+233123456789"
detail = await client.get(f"/api/tickets/{ticket['id']}")
assert detail.json()["phone"] == "+233123456789"
# ── Nested unit/category in detail ────────────────────────────────────
async def test_ticket_detail_returns_unit_and_category(client):
"""TicketOut includes nested unit/category objects (detail page fix)."""
token = await _login(client)
ticket = await _create_ticket(client, token, unit_id=2, category_id=3)
resp = await client.get(f"/api/tickets/{ticket['id']}")
assert resp.status_code == 200
data = resp.json()
assert data["unit"] is not None
assert data["unit"]["apartment_code"]
assert data["category"] is not None
assert data["category"]["name"]
# ── Admin-only DELETE ─────────────────────────────────────────────────
async def test_delete_ticket_requires_admin(client):
"""A non-admin (CS Rep) gets 403 on DELETE."""
token = await _login(client, email="bella@denya.com") # CS Rep
ticket = await _create_ticket(client, token, description="delete perm test")
resp = await client.delete(
f"/api/tickets/{ticket['id']}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 403
async def test_delete_ticket_removes_children(client):
"""Admin DELETE removes the ticket, timeline, and photos."""
token = await _login(client) # Admin/Wahab
ticket = await _create_ticket(client, token, description="delete me")
tid = ticket["id"]
resp = await client.delete(
f"/api/tickets/{tid}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 204
gone = await client.get(f"/api/tickets/{tid}")
assert gone.status_code == 404
from sqlalchemy import func, select
from app.core.database import async_session_factory
from app.models.ticket import TicketTimeline
async with async_session_factory() as session:
count = (await session.execute(
select(func.count(TicketTimeline.id)).where(TicketTimeline.ticket_id == tid)
)).scalar()
assert count == 0
# ── Users endpoint for the assign picker ──────────────────────────────
async def test_users_endpoint_requires_auth(client):
resp = await client.get("/api/auth/users")
assert resp.status_code == 401
async def test_users_endpoint_lists_technicians(client):
token = await _login(client)
resp = await client.get("/api/auth/users", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
users = resp.json()
assert any(u["role"] == "Tech" for u in users)
# Fields the assign dropdown needs
assert {"id", "full_name", "role"} <= set(users[0].keys())
# ── Transitions helper (status dropdown) ─────────────────────────────
async def test_transitions_helper_returns_valid_targets(client):
"""GET /api/tickets/{id}/transitions returns the valid next statuses."""
from app.services.ticket import VALID_TRANSITIONS
token = await _login(client)
ticket = await _create_ticket(client, token, description="transitions helper") # status: Logged
resp = await client.get(f"/api/tickets/{ticket['id']}/transitions")
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["current_status"] == "Logged"
assert set(data["transitions"]) == set(VALID_TRANSITIONS["Logged"])
async def test_transitions_helper_terminal_is_empty(client):
"""A cancelled ticket exposes no selectable next statuses."""
token = await _login(client)
ticket = await _create_ticket(client, token, description="terminal transitions")
resp = await client.patch(
f"/api/tickets/{ticket['id']}",
json={"status": "Cancelled"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = (await client.get(f"/api/tickets/{ticket['id']}/transitions")).json()
assert data["current_status"] == "Cancelled"
assert data["transitions"] == []
# ── Assign auto-advance ───────────────────────────────────────────────
async def _first_tech_id(client, token: str) -> int:
users = (await client.get("/api/auth/users", headers={"Authorization": f"Bearer {token}"})).json()
return next(u["id"] for u in users if u["role"] == "Tech")
async def test_assign_auto_advances_to_assigned(client):
"""Assigning a pre-Assigned ticket advances it to Assigned with a timeline entry."""
token = await _login(client)
ticket = await _create_ticket(client, token, description="assign advance") # status: Logged
tech_id = await _first_tech_id(client, token)
resp = await client.patch(
f"/api/tickets/{ticket['id']}",
json={"assigned_to": tech_id},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["status"] == "Assigned"
assert data["assigned_to"] == tech_id
assert data["timeline"][-1]["to_status"] == "Assigned"
async def test_assign_does_not_regress_past_assigned(client):
"""Re-assigning a ticket already past Assigned leaves its status untouched."""
token = await _login(client)
ticket = await _create_ticket(client, token, description="no regression")
for step in ("Triage", "Assigned", "Accepted"):
resp = await client.patch(
f"/api/tickets/{ticket['id']}",
json={"status": step},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text
users = (await client.get("/api/auth/users", headers={"Authorization": f"Bearer {token}"})).json()
techs = [u["id"] for u in users if u["role"] == "Tech"]
other_tech = techs[1] if len(techs) > 1 else techs[0]
resp = await client.patch(
f"/api/tickets/{ticket['id']}",
json={"assigned_to": other_tech},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == "Accepted"
# ── Ticket numbering survives deletions ───────────────────────────────
async def test_ticket_number_uses_max_plus_one(client, seed_tickets):
"""After deleting a middle ticket, numbering must skip over surviving numbers."""
from sqlalchemy import delete as sa_delete
from app.core.database import async_session_factory
from app.models.ticket import Ticket
token = await _login(client)
first = await _create_ticket(client, token, description="numbering a") # …001
second = await _create_ticket(client, token, description="numbering b") # …002
third = await _create_ticket(client, token, description="numbering c") # …003
async with async_session_factory() as session:
# Delete the middle ticket; count+1 numbering would now re-issue …003
# (colliding with the surviving third ticket).
await session.execute(sa_delete(Ticket).where(Ticket.id == second["id"]))
await session.commit()
fourth = await _create_ticket(client, token, description="numbering d")
def suffix(tn: str) -> int:
return int(tn.rsplit("-", 1)[1])
survivors = {suffix(first["ticket_number"]), suffix(third["ticket_number"])}
assert suffix(fourth["ticket_number"]) not in survivors
assert suffix(fourth["ticket_number"]) > max(survivors)