Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6eb799efa | ||
|
|
f1428335ef | ||
|
|
40f1c0ecf6 | ||
|
|
7ca2924191 | ||
|
|
0d79a582f6 | ||
|
|
4e8b96ed0a | ||
|
|
7b0365b135 | ||
|
|
49b26926cf | ||
|
|
05d768343c | ||
|
|
371826c15e | ||
|
|
e627f50f66 | ||
|
|
f1b68dd1b7 | ||
|
|
3ae1062d65 | ||
|
|
901f95e0f6 | ||
|
|
3ec09470ff | ||
|
|
76d9d12b78 | ||
|
|
3ad81e2c39 | ||
|
|
aef9d90097 | ||
|
|
c9b722041c | ||
|
|
cf1d9413bb | ||
|
|
f256f1a6d2 | ||
|
|
78068c23d9 | ||
|
|
51e00a40cd | ||
|
|
fb0c1784d0 | ||
|
|
e0a7479c3e | ||
|
|
1f15ca5457 | ||
|
|
30c0c01c59 | ||
|
|
bf361d76bc | ||
|
|
6b92c9098c | ||
|
|
237284b012 | ||
|
|
1dd88a141e | ||
|
|
ffed595dbd | ||
|
|
5e9ec29439 | ||
|
|
663354eeaf | ||
|
|
db6826cb49 | ||
|
|
ae17005932 | ||
|
|
2407f294f4 |
@@ -0,0 +1,33 @@
|
||||
# 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=
|
||||
|
||||
# ── WhatsApp demo path (optional) ───────────────────────
|
||||
# Expected sender/recipient number (E.164) for the WhatsApp demo round trip
|
||||
# (webhook -> ticket -> mock-log). Set the real number ONLY on the deploy
|
||||
# host's .env — keep this template an empty placeholder, never a live number.
|
||||
# Empty (default): the demo payload builder fails closed (no fabricated sender).
|
||||
WHATSAPP_DEMO_TO=
|
||||
|
||||
# ── 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
|
||||
@@ -39,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
|
||||
|
||||
@@ -47,13 +47,40 @@ 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) |
|
||||
|
||||
`whatsapp_log` predates the real Meta webhook (the model gained
|
||||
`message_text`/`wa_message_id`/`ticket_number` and dropped `command` without a
|
||||
migration), so legacy tables keep the old `(command, …)` shape and every ORM
|
||||
read/write 500s. `ensure_legacy_schema` (app/main.py) adds the missing columns
|
||||
and backfills+drops the obsolete NOT NULL `command` column idempotently at
|
||||
startup — do not hand-edit legacy DBs, ship a self-heal there instead.
|
||||
|
||||
Demo WhatsApp round trip: `WHATSAPP_DEMO_TO` (E.164, .env-only — never commit
|
||||
a real number; `.env.example` keeps an empty placeholder) is the expected
|
||||
sender/recipient for the demo path.
|
||||
`app/routers/whatsapp.py::build_demo_webhook_payload` builds a Meta webhook
|
||||
payload from it (fails closed when unset), so posting it to
|
||||
`POST /api/whatsapp/webhook` with the secret logs `from_number` = demo number
|
||||
(visible via `GET /api/whatsapp/mock-log`) and the auto-reply targets the same
|
||||
number. Covered by `tests/test_whatsapp_demo_number.py`.
|
||||
|
||||
### Pages (Sprint 3) — Jinja2 templates at `app/templates/`
|
||||
| Method | Path | Auth | Description |
|
||||
@@ -66,33 +93,93 @@ Users, units, and categories are auto-seeded on first startup via lifespan hook:
|
||||
| GET | `/tickets/new` | Client | Create Issue form |
|
||||
| GET | `/tickets/{id}` | Client | Issue detail with timeline |
|
||||
|
||||
Frontend: Alpine.js (CDN) + Tailwind CSS (CDN). Auth state in localStorage. Role-based nav routing in `base.html`.
|
||||
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 allows no external host
|
||||
(`script-src 'self' 'unsafe-inline' 'unsafe-eval'`, `style-src 'self'
|
||||
'unsafe-inline'`, `connect-src 'self'`); no CDN host is allowed in CSP
|
||||
(`app/main.py::SecurityHeadersMiddleware`). `'unsafe-eval'` is required by
|
||||
the Alpine 3.17.2 CDN build: its evaluator compiles every `x-*` expression
|
||||
with `new Function()`, and without it CSP blocks Alpine entirely (stuck
|
||||
loading overlay on every page) — covered by
|
||||
`test_csp_script_src_allows_unsafe_eval_for_alpine` in
|
||||
`tests/test_frontend_vendoring.py`.
|
||||
|
||||
### Branding (logo)
|
||||
Official Denya Developers logo derivatives are committed under
|
||||
`app/static/branding/` (Gitea release `logo-assets-v1`, sha256-verified
|
||||
monochrome forest-green lockup; sourced from the Gitea release, never from
|
||||
kagentz). Placement: login header uses `denya-logo-h96.png` (full lockup); the
|
||||
logged-in topbar (base.html nav) uses `denya-logo-h48.png` on a light chip
|
||||
(logo ink is only ~2:1 against the dark `#0d2b18` nav — keep a light chip
|
||||
there); favicons 32x32+16x16 declared in `base.html <head>`.
|
||||
`img-src 'self' data: blob:` already covers `/static/branding/*` — no CSP
|
||||
change. Regression coverage: `tests/test_branding_assets.py`.
|
||||
|
||||
### Tickets (Sprint 2)
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/tickets` | Bearer | Create ticket (auto-number PAV-YYYY-NNNNN) |
|
||||
| GET | `/api/tickets` | No | List tickets (filter: status, priority, property, 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 (space and underscore
|
||||
forms alike — `cs rep`/`cs_rep` → `CS Rep`, `fm dispatcher`/`fm_dispatcher`,
|
||||
`cs manager`/`cs_manager`), and
|
||||
`normalize_legacy_user_emails` lowercases stored emails — login and the admin
|
||||
create-user duplicate check both compare on the lowercased form, so pre-P0
|
||||
mixed-case emails are never silently locked out.
|
||||
- Use `require_roles(*ADMIN_ROLES)` for admin gates; `sub` claim holds string user ID
|
||||
- Security headers middleware in `app/main.py`: X-Frame-Options DENY +
|
||||
nosniff on everything, CSP on HTML pages, HSTS when `X-Forwarded-Proto: https`
|
||||
(CSP allows no external host — frontend libs are vendored, see "Frontend
|
||||
assets"; `script-src` carries `'unsafe-eval'` for the Alpine runtime)
|
||||
|
||||
## Ticket System (Sprint 2)
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -116,6 +203,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.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
<!-- Points Claude at AGENTS.md via import; edit AGENTS.md, not this file. -->
|
||||
@AGENTS.md
|
||||
@@ -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 .
|
||||
|
||||
@@ -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. **pytest suite passes.**
|
||||
- **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.*
|
||||
@@ -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')
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -23,9 +23,9 @@ 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 ─────────────────────────────────────────────────────────
|
||||
@@ -36,9 +36,42 @@ class Settings(BaseSettings):
|
||||
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 = ""
|
||||
# Expected sender/recipient number (E.164) for the WhatsApp demo round
|
||||
# trip (webhook -> ticket -> mock-log). Set per deployment in .env only —
|
||||
# never commit a real number. When set, the demo payload builder
|
||||
# (``app/routers/whatsapp.py::build_demo_webhook_payload``) originates
|
||||
# messages from it, the auto-reply targets it, and ``/api/whatsapp/mock-log``
|
||||
# surfaces it. Empty (default) means the demo payload cannot be built:
|
||||
# the helper fails closed rather than fabricating a sender.
|
||||
WHATSAPP_DEMO_TO: 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."
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""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`` …) and underscore variants of the space-separated
|
||||
ones (``cs_rep``, ``cs_manager``, ``fm_dispatcher``). ``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_rep": "CS Rep",
|
||||
"cs representative": "CS Rep",
|
||||
"cs manager": "CS Manager",
|
||||
"cs_manager": "CS Manager",
|
||||
"fm": "FM Dispatcher",
|
||||
"fm dispatcher": "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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -9,23 +9,115 @@ 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, pages, tickets, whatsapp
|
||||
from app.services.seed import seed_categories, seed_units, seed_users
|
||||
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)")
|
||||
|
||||
# whatsapp_log predates the real Meta webhook (the model gained
|
||||
# message_text/wa_message_id/ticket_number and dropped `command` in commit
|
||||
# 4afdc36 with no migration), so legacy DBs still carry the old shape and
|
||||
# every read/write through the ORM 500s (no such column: message_text).
|
||||
result = await conn.execute(
|
||||
text("SELECT name FROM sqlite_master WHERE type='table' AND name='whatsapp_log'")
|
||||
)
|
||||
if result.scalar():
|
||||
result = await conn.execute(text("PRAGMA table_info(whatsapp_log)"))
|
||||
log_columns = {row[1] for row in result}
|
||||
if "message_text" not in log_columns:
|
||||
await conn.execute(
|
||||
text("ALTER TABLE whatsapp_log ADD COLUMN message_text TEXT NOT NULL DEFAULT ''")
|
||||
)
|
||||
logger.info("Added missing whatsapp_log.message_text column (legacy database)")
|
||||
if "wa_message_id" not in log_columns:
|
||||
await conn.execute(
|
||||
text("ALTER TABLE whatsapp_log ADD COLUMN wa_message_id VARCHAR(100)")
|
||||
)
|
||||
logger.info("Added missing whatsapp_log.wa_message_id column (legacy database)")
|
||||
if "ticket_number" not in log_columns:
|
||||
await conn.execute(
|
||||
text("ALTER TABLE whatsapp_log ADD COLUMN ticket_number VARCHAR(30)")
|
||||
)
|
||||
logger.info("Added missing whatsapp_log.ticket_number column (legacy database)")
|
||||
if "command" in log_columns:
|
||||
# Legacy rows stored the message body in `command`, which the model
|
||||
# no longer defines (NOT NULL, no default): any ORM insert omitting
|
||||
# it would violate NOT NULL. Preserve the old bodies in
|
||||
# message_text, then drop the obsolete column to match the model.
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE whatsapp_log SET message_text = command "
|
||||
"WHERE (message_text IS NULL OR message_text = '') "
|
||||
"AND command IS NOT NULL AND command != ''"
|
||||
)
|
||||
)
|
||||
await conn.execute(text("ALTER TABLE whatsapp_log DROP COLUMN command"))
|
||||
logger.info("Dropped obsolete whatsapp_log.command 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,20 +134,123 @@ 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. ``script-src`` keeps ``'unsafe-inline'`` for the inline
|
||||
``tailwind.config``/``app()`` blocks and ``'unsafe-eval'`` because
|
||||
Alpine 3.17.2's expression evaluator compiles ``x-data``/``x-show``/
|
||||
``x-text`` etc. with ``new Function()`` — without ``'unsafe-eval'`` CSP
|
||||
blocks every Alpine expression and the loading overlay never clears;
|
||||
style-src keeps ``'unsafe-inline'`` for the 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' 'unsafe-eval'; "
|
||||
"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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -13,7 +13,8 @@ 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
|
||||
@@ -33,8 +34,17 @@ from app.schemas.ticket import (
|
||||
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)
|
||||
@@ -45,33 +55,61 @@ UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
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)."""
|
||||
"""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
|
||||
@@ -81,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,
|
||||
@@ -105,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 ──────────────────────────────────────────────────────
|
||||
@@ -145,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)
|
||||
@@ -197,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(
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
"""WhatsApp webhook handler — Meta Graph API integration."""
|
||||
"""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
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
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 (
|
||||
MetaWebhookRequest,
|
||||
@@ -33,6 +46,47 @@ REPLY_TEMPLATE = (
|
||||
)
|
||||
|
||||
|
||||
# ── WhatsApp demo round trip (WHATSAPP_DEMO_TO) ─────────────────────
|
||||
def build_demo_webhook_payload(
|
||||
text: str = "Demo message — Denya OneCare WhatsApp round trip",
|
||||
wa_message_id: str = "wamid.demo.000001",
|
||||
) -> dict:
|
||||
"""Build a Meta webhook payload for the WhatsApp demo round trip.
|
||||
|
||||
The message ``from`` is ``settings.WHATSAPP_DEMO_TO`` (E.164), so the demo
|
||||
surfaces the expected number end-to-end: the webhook logs it in
|
||||
``whatsapp_log`` (visible via ``GET /api/whatsapp/mock-log``) and the
|
||||
auto-reply is sent back to the same number. The demo number is configured
|
||||
per deployment in .env (never committed); when it is unset this raises
|
||||
rather than fabricating a sender (same fail-closed posture as the webhook
|
||||
secret).
|
||||
"""
|
||||
demo_to = (settings.WHATSAPP_DEMO_TO or "").strip()
|
||||
if not demo_to:
|
||||
raise RuntimeError(
|
||||
"WHATSAPP_DEMO_TO is not configured — set the demo sender number "
|
||||
"in .env to run the WhatsApp demo round trip."
|
||||
)
|
||||
return {
|
||||
"object": "whatsapp_business_account",
|
||||
"entry": [
|
||||
{
|
||||
"id": "1",
|
||||
"changes": [
|
||||
{
|
||||
"id": wa_message_id,
|
||||
"message": {
|
||||
"from": demo_to,
|
||||
"id": wa_message_id,
|
||||
"text": {"text": text},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── Meta Graph API helpers ──────────────────────────────────────────
|
||||
async def send_whatsapp_reply(
|
||||
to_phone: str,
|
||||
@@ -62,24 +116,55 @@ async def send_whatsapp_reply(
|
||||
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.post("/webhook")
|
||||
async def whatsapp_webhook(
|
||||
@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: Annotated[AsyncSession, Depends(get_db)],
|
||||
hub_verify_token: str | None = Query(None, alias="hub.verify_token"),
|
||||
mode: str | None = Query(None),
|
||||
hub_challenge: str | None = Query(None),
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""Handle incoming WhatsApp webhook from Meta."""
|
||||
|
||||
# ── Verification GET request (Meta sends this on webhook setup) ──
|
||||
if mode and hub_challenge:
|
||||
if hub_verify_token != settings.WHATSAPP_VERIFY_TOKEN:
|
||||
return {"error": "Verify token mismatch"}
|
||||
return WebhookVerificationResponse(challenge=hub_challenge).model_dump()
|
||||
|
||||
# ── Process inbound messages ────────────────────────────────────
|
||||
"""Create tickets + logs for inbound messages. Shared by the POST handler."""
|
||||
if not body.entry:
|
||||
return {"status": "no entry"}
|
||||
|
||||
@@ -153,13 +238,24 @@ async def whatsapp_webhook(
|
||||
return {"status": "no messages"}
|
||||
|
||||
|
||||
# ── Legacy debug endpoint ──────────────────────────────────────────
|
||||
@router.post("/webhook")
|
||||
async def whatsapp_webhook(
|
||||
body: MetaWebhookRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_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 WhatsApp webhook submissions for debugging."""
|
||||
"""Return recent WhatsApp webhook submissions (authenticated only)."""
|
||||
result = await db.execute(
|
||||
select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# ── Unit ─────────────────────────────────────────────────────────────
|
||||
@@ -26,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}
|
||||
|
||||
@@ -45,6 +46,7 @@ class TicketCreate(BaseModel):
|
||||
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):
|
||||
@@ -53,6 +55,7 @@ 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
|
||||
@@ -94,9 +97,11 @@ class TicketBrief(BaseModel):
|
||||
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
|
||||
|
||||
@@ -112,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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -110,9 +126,11 @@ async def create_ticket(
|
||||
category_id=data.get("category_id"),
|
||||
priority=priority,
|
||||
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)
|
||||
@@ -215,6 +241,7 @@ 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:
|
||||
if old_status != new_status:
|
||||
valid_targets = VALID_TRANSITIONS.get(old_status, [])
|
||||
@@ -275,9 +302,25 @@ 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 (new_status is not None and old_status != new_status):
|
||||
if note_only and not status_changed and not advanced_to_assigned:
|
||||
await _log_status_change(
|
||||
db,
|
||||
ticket.id,
|
||||
@@ -288,7 +331,7 @@ async def update_ticket(
|
||||
)
|
||||
|
||||
# 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])
|
||||
@@ -302,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
|
||||
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 793 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 118 KiB |
@@ -4,8 +4,12 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Denya OneCare</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- Favicons (same-origin app/static/branding) -->
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/branding/denya-logo-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/branding/denya-logo-16x16.png">
|
||||
<!-- 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: {
|
||||
@@ -51,6 +55,7 @@
|
||||
.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; }
|
||||
@@ -66,15 +71,11 @@
|
||||
<!-- 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>
|
||||
<!-- Denya Developers logo (same-origin app/static/branding) -->
|
||||
<img src="/static/branding/denya-logo-h48.png" alt="Denya Developers"
|
||||
class="h-9 w-auto bg-white rounded-md px-1.5 py-1 shadow-sm">
|
||||
<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>
|
||||
<span class="text-gold text-sm leading-tight font-bold">OneCare</span>
|
||||
</div>
|
||||
</a>
|
||||
<!-- Nav Links -->
|
||||
@@ -318,7 +319,8 @@
|
||||
'on-field verification': 'status-on-field-verification',
|
||||
'wahab review': 'status-wahab-review',
|
||||
'closed': 'status-closed',
|
||||
'reopened': 'status-reopened'
|
||||
'reopened': 'status-reopened',
|
||||
'cancelled': 'status-cancelled'
|
||||
};
|
||||
return map[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
|
||||
},
|
||||
@@ -355,6 +357,32 @@
|
||||
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' },
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,11 +189,11 @@
|
||||
if (!all.length) return;
|
||||
|
||||
// Basic KPIs
|
||||
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
|
||||
const closed = all.filter(t => ['Closed', 'Completed'].includes(t.status));
|
||||
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'].includes(t.status));
|
||||
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);
|
||||
@@ -249,8 +249,9 @@
|
||||
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');
|
||||
const cats = await app().apiGet('/api/tickets/categories/flat?include_hidden=true');
|
||||
const catCounts = {};
|
||||
all.forEach(t => {
|
||||
if (t.category_id) {
|
||||
@@ -269,7 +270,7 @@
|
||||
|
||||
// Risk indicators
|
||||
this.indicators = {
|
||||
openEmergencies: urgent.filter(t => !['Closed', 'Completed'].includes(t.status)).length,
|
||||
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,
|
||||
@@ -279,7 +280,7 @@
|
||||
},
|
||||
|
||||
calcAvgResponse(all) {
|
||||
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
|
||||
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`;
|
||||
|
||||
@@ -70,23 +70,24 @@
|
||||
<!-- 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-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
</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>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
</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>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
</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>
|
||||
</div>
|
||||
</a>
|
||||
<p class="text-[11px] text-gray-400 pt-1">Click a row to open the priority-grouped queue.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -187,20 +188,20 @@
|
||||
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'].includes(t.status));
|
||||
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'].includes(t.status))
|
||||
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'].includes(t.status));
|
||||
const slaMet = withSLA.filter(t => new Date(t.sla_deadline) > new Date() || ['Closed', 'Completed', 'Cancelled'].includes(t.status));
|
||||
|
||||
this.kpi = {
|
||||
newToday,
|
||||
@@ -229,7 +230,7 @@
|
||||
|
||||
calcAvgResponse(all) {
|
||||
// Rough approximation — in real system this would come from timeline analysis
|
||||
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
|
||||
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);
|
||||
|
||||
@@ -127,11 +127,18 @@
|
||||
|
||||
<!-- 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 items-center justify-between">
|
||||
<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>
|
||||
<a href="/tickets" class="text-sm text-denya-600 hover:text-denya-800">View All →</a>
|
||||
<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">
|
||||
<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>
|
||||
@@ -160,6 +167,32 @@
|
||||
</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>
|
||||
|
||||
@@ -171,6 +204,12 @@
|
||||
aging: {},
|
||||
activeTickets: [],
|
||||
emergencyCount: 0,
|
||||
groupByPriority: false,
|
||||
ageDir: 'desc',
|
||||
|
||||
get groupedActiveTickets() {
|
||||
return app().groupByPriority(this.activeTickets, this.ageDir);
|
||||
},
|
||||
|
||||
async init() {
|
||||
await this.loadData();
|
||||
@@ -183,7 +222,7 @@
|
||||
const all = data.items;
|
||||
|
||||
// Active: not closed/completed
|
||||
const active = all.filter(t => !['Closed', 'Completed'].includes(t.status));
|
||||
const active = all.filter(t => !['Closed', 'Completed', 'Cancelled'].includes(t.status));
|
||||
this.activeTickets = active;
|
||||
|
||||
// KPIs
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
<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>
|
||||
<!-- Denya Developers full lockup (same-origin app/static/branding) -->
|
||||
<img src="/static/branding/denya-logo-h96.png" alt="Denya Developers"
|
||||
class="mx-auto mb-4 h-24 w-auto">
|
||||
<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>
|
||||
|
||||
@@ -23,7 +23,11 @@
|
||||
<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></p>
|
||||
<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>
|
||||
@@ -121,10 +125,18 @@
|
||||
<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>
|
||||
@@ -200,20 +212,9 @@
|
||||
<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>
|
||||
<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>
|
||||
<template x-for="target in availableTransitions" :key="target">
|
||||
<option :value="target" x-text="target"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -298,6 +299,7 @@
|
||||
statusForm: { newStatus: '', note: '' },
|
||||
statusSubmitting: false,
|
||||
statusError: '',
|
||||
availableTransitions: [],
|
||||
|
||||
// Assign form
|
||||
assignForm: { technicianId: '' },
|
||||
@@ -311,6 +313,7 @@
|
||||
|
||||
async init() {
|
||||
await this.loadTicket();
|
||||
await this.loadTransitions();
|
||||
if (app().isFM || app().isAdmin) {
|
||||
await this.loadTechnicians();
|
||||
}
|
||||
@@ -327,18 +330,25 @@
|
||||
}
|
||||
},
|
||||
|
||||
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() {
|
||||
// Users list not exposed via API directly, so use a known list
|
||||
// In a real system we'd have GET /api/users
|
||||
this.technicians = [
|
||||
{ id: 9, full_name: 'Prosper' },
|
||||
{ id: 10, full_name: 'Sam' },
|
||||
{ id: 11, full_name: 'Steven' },
|
||||
{ id: 12, full_name: 'Junior (Samuel)' },
|
||||
{ id: 13, full_name: 'Francis' },
|
||||
{ id: 14, full_name: 'Desmond Afful' },
|
||||
{ id: 15, full_name: 'Afful' },
|
||||
];
|
||||
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() {
|
||||
@@ -354,6 +364,7 @@
|
||||
|
||||
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');
|
||||
@@ -376,6 +387,7 @@
|
||||
assigned_to: parseInt(this.assignForm.technicianId)
|
||||
});
|
||||
this.ticket = updated;
|
||||
await this.loadTransitions();
|
||||
this.showAssignModal = false;
|
||||
app().showToast('Technician assigned', 'success');
|
||||
} catch (e) {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<!-- 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 lg:grid-cols-6 gap-3">
|
||||
<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">
|
||||
@@ -38,6 +38,7 @@
|
||||
<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>
|
||||
@@ -52,12 +53,39 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 font-medium mb-1">Property</label>
|
||||
<select x-model="filters.property" @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">
|
||||
<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">
|
||||
@@ -67,20 +95,27 @@
|
||||
<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 items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<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="filters = {search:'',status:'',priority:'',property:'',dateFrom:'',dateTo:''}; page=1; loadTickets()" class="px-3 py-1 text-sm text-gray-500 border rounded hover:bg-gray-50">Clear Filters</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 -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<!-- 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">
|
||||
@@ -95,6 +130,7 @@
|
||||
<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>
|
||||
@@ -107,8 +143,9 @@
|
||||
<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'].includes(ticket.status) ? 'text-red-600 font-medium' : 'text-gray-400'">
|
||||
<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>
|
||||
@@ -116,7 +153,7 @@
|
||||
</tr>
|
||||
</template>
|
||||
<tr x-show="!tickets.length && !loading">
|
||||
<td colspan="7" class="px-5 py-16 text-center text-gray-400">
|
||||
<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>
|
||||
@@ -125,6 +162,55 @@
|
||||
</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>
|
||||
@@ -137,25 +223,130 @@
|
||||
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)}`;
|
||||
|
||||
@@ -204,6 +395,16 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,25 @@
|
||||
<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 -->
|
||||
@@ -20,36 +39,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row: Property + Apartment -->
|
||||
<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">Property <span class="text-red-500">*</span></label>
|
||||
<select x-model="form.property" @change="loadUnits()" 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 Property</option>
|
||||
<option value="East">Pavilion East</option>
|
||||
<option value="West">Pavilion West</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Apartment <span class="text-red-500">*</span></label>
|
||||
<select x-model="form.apartment_code" 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="">Select Apartment</option>
|
||||
<template x-for="unit in units" :key="unit.id">
|
||||
<option :value="unit.apartment_code" x-text="unit.apartment_code"></option>
|
||||
</template>
|
||||
</select>
|
||||
<!-- 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="loadSubCategories()" 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">
|
||||
<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>
|
||||
@@ -62,23 +101,29 @@
|
||||
</div>
|
||||
|
||||
<!-- Priority -->
|
||||
<div>
|
||||
<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" class="sr-only">
|
||||
<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" class="sr-only">
|
||||
<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" class="sr-only">
|
||||
<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" class="sr-only">
|
||||
<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>
|
||||
@@ -109,6 +154,15 @@
|
||||
</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>
|
||||
@@ -136,7 +190,7 @@
|
||||
<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...' : 'Create Issue'"></span>
|
||||
<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>
|
||||
@@ -147,21 +201,31 @@
|
||||
<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_via: '',
|
||||
reported_date: ''
|
||||
},
|
||||
todayStr: '',
|
||||
priorityAuto: false,
|
||||
categories: [],
|
||||
subCategories: [],
|
||||
units: [],
|
||||
unitGroups: {},
|
||||
buildings: [],
|
||||
searchApartment: '',
|
||||
apartmentOpen: false,
|
||||
selectedCategoryHint: '',
|
||||
photoFiles: [],
|
||||
photoPreviews: [],
|
||||
submitting: false,
|
||||
@@ -169,38 +233,122 @@
|
||||
|
||||
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 data = await app().apiGet('/api/tickets/categories');
|
||||
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 loadSubCategories() {
|
||||
this.form.category_id = null;
|
||||
this.subCategories = [];
|
||||
if (!this.form.category_main) return;
|
||||
async loadUnits() {
|
||||
try {
|
||||
const data = await app().apiGet('/api/tickets/categories');
|
||||
const parent = data?.find(c => c.id == this.form.category_main);
|
||||
if (parent?.children) {
|
||||
this.subCategories = parent.children;
|
||||
}
|
||||
} catch (e) { console.error('Subcategories load error', e); }
|
||||
const data = await app().apiGet('/api/tickets/units/grouped');
|
||||
this.unitGroups = data || {};
|
||||
} catch (e) { console.error('Units load error', e); }
|
||||
},
|
||||
|
||||
async loadUnits() {
|
||||
// ── Location cascade ─────────────────────────────────────
|
||||
onPropertyChange() {
|
||||
this.form.building = '';
|
||||
this.form.apartment_code = '';
|
||||
this.units = [];
|
||||
if (!this.form.property) return;
|
||||
try {
|
||||
const data = await app().apiGet(`/api/tickets/units?property=${encodeURIComponent(this.form.property)}`);
|
||||
this.units = data || [];
|
||||
} catch (e) {
|
||||
console.error('Units load error', e);
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -226,32 +374,40 @@
|
||||
async submitTicket() {
|
||||
this.error = '';
|
||||
this.submitting = true;
|
||||
|
||||
|
||||
try {
|
||||
// Validate required fields
|
||||
if (!this.form.property || !this.form.apartment_code || !this.form.description) {
|
||||
this.error = 'Please fill in Property, Apartment, and Description.';
|
||||
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;
|
||||
}
|
||||
|
||||
// Resolve unit_id from selected apartment_code
|
||||
const selectedUnit = this.units.find(u => u.apartment_code === this.form.apartment_code);
|
||||
|
||||
// Create the ticket
|
||||
const payload = {
|
||||
description: this.form.description,
|
||||
priority: this.form.priority || null,
|
||||
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: selectedUnit ? selectedUnit.id : 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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,29 +9,43 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
_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_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
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
|
||||
from app.main import app
|
||||
from app.models.ticket import Ticket
|
||||
from app.services.seed import seed_categories, seed_units, seed_users
|
||||
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():
|
||||
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 (apartment_mapping.json is not committed)
|
||||
# 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)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Denya logo branding — repo-local assets under app/static/branding/.
|
||||
|
||||
The official Denya Developers logo derivatives (Gitea release
|
||||
``logo-assets-v1``, sha256-verified) are committed same-origin under
|
||||
``app/static/branding/`` like the vendored frontend libraries — no CDN, no CSP
|
||||
change needed (``img-src 'self' data: blob:`` already covers them).
|
||||
|
||||
Placement contract:
|
||||
* Login page header uses the h96 full lockup (``denya-logo-h96.png``).
|
||||
* Dashboard topbar (base.html nav) uses the h48 full lockup (compact spot:
|
||||
the DEVELOPERS subtext is unreadable below ~48px, so the mark reads as
|
||||
symbol+DENYA — the intended compact treatment).
|
||||
* Favicon: 32x32 declared first, 16x16 declared, both in <head>.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
BRANDING_ASSETS = (
|
||||
"denya-logo.png",
|
||||
"denya-logo-trimmed.png",
|
||||
"denya-logo-h48.png",
|
||||
"denya-logo-h96.png",
|
||||
"denya-logo-64x64.png",
|
||||
"denya-logo-32x32.png",
|
||||
"denya-logo-16x16.png",
|
||||
)
|
||||
|
||||
|
||||
def _directive_sources(csp: str, directive: str) -> list[str]:
|
||||
"""Return the source list of one CSP directive (e.g. ``img-src``)."""
|
||||
for part in csp.split(";"):
|
||||
tokens = part.split()
|
||||
if tokens and tokens[0].strip() == directive:
|
||||
return [t.strip() for t in tokens[1:]]
|
||||
return []
|
||||
|
||||
|
||||
async def test_login_page_header_uses_h96_logo(client: AsyncClient):
|
||||
"""/login must carry the h96 full-lockup logo (same-origin URL)."""
|
||||
resp = await client.get("/login")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert "/static/branding/denya-logo-h96.png" in resp.text
|
||||
|
||||
|
||||
async def test_topbar_and_favicon_on_dashboard_pages(client: AsyncClient):
|
||||
"""Dashboard chrome (base.html) carries h48 topbar logo + both favicons."""
|
||||
resp = await client.get("/dashboard/fm")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert "/static/branding/denya-logo-h48.png" in resp.text
|
||||
# Favicon 32x32 with 16x16 declared in <head> (link rel="icon").
|
||||
assert 'rel="icon" type="image/png" sizes="32x32" href="/static/branding/denya-logo-32x32.png"' in resp.text
|
||||
assert 'rel="icon" type="image/png" sizes="16x16" href="/static/branding/denya-logo-16x16.png"' in resp.text
|
||||
|
||||
|
||||
async def test_branding_assets_served_same_origin(client: AsyncClient):
|
||||
"""Every committed branding asset must resolve locally as a PNG."""
|
||||
for name in BRANDING_ASSETS:
|
||||
url = f"/static/branding/{name}"
|
||||
resp = await client.get(url)
|
||||
assert resp.status_code == 200, f"{url} -> {resp.status_code}"
|
||||
assert resp.headers.get("content-type", "").startswith("image/png"), f"{url}: {resp.headers.get('content-type')!r}"
|
||||
assert len(resp.content) > 100, f"{url} looks empty"
|
||||
|
||||
|
||||
async def test_csp_serves_branding_without_changes(client: AsyncClient):
|
||||
"""img-src already allows same-origin PNGs — no external host needed."""
|
||||
resp = await client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
csp = resp.headers["content-security-policy"]
|
||||
img_sources = _directive_sources(csp, "img-src")
|
||||
assert img_sources, f"no img-src directive in CSP: {csp}"
|
||||
assert "'self'" in img_sources and "data:" in img_sources and "blob:" in img_sources
|
||||
assert "cdn." not in csp # fully self-contained, like the vendored scripts
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,104 @@
|
||||
"""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
|
||||
|
||||
|
||||
def _directive_sources(csp: str, directive: str) -> list[str]:
|
||||
"""Return the source list of one CSP directive (e.g. ``script-src``)."""
|
||||
for part in csp.split(";"):
|
||||
tokens = part.split()
|
||||
if tokens and tokens[0].strip() == directive:
|
||||
return [t.strip() for t in tokens[1:]]
|
||||
return []
|
||||
|
||||
|
||||
async def test_csp_script_src_allows_unsafe_eval_for_alpine(client: AsyncClient):
|
||||
"""/login CSP must permit 'unsafe-eval' in script-src (Alpine 3.17.2 runtime).
|
||||
|
||||
Alpine's expression evaluator compiles every ``x-data``/``x-show``/``x-text``
|
||||
expression with ``new Function()``. A strict CSP without ``'unsafe-eval'``
|
||||
blocks each evaluation ("Refused to evaluate a string as JavaScript ..."),
|
||||
Alpine never initializes, and the loading overlay (``x-show="loading"`` in
|
||||
base.html) stays visible forever — regression shipped with the P0 CSP.
|
||||
"""
|
||||
resp = await client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
csp = resp.headers["content-security-policy"]
|
||||
script_sources = _directive_sources(csp, "script-src")
|
||||
assert script_sources, f"no script-src directive in CSP: {csp}"
|
||||
assert "'unsafe-eval'" in script_sources, f"script-src missing 'unsafe-eval': {csp}"
|
||||
# Everything else stays as hardened: still 'self'-only apart from the two
|
||||
# Alpine-required relaxations, and connect-src remains 'self'.
|
||||
assert "'self'" in script_sources
|
||||
assert "connect-src 'self'" in csp
|
||||
@@ -0,0 +1,647 @@
|
||||
"""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"
|
||||
|
||||
|
||||
async def test_underscore_legacy_aliases_normalize_to_canonical():
|
||||
"""Open-register rows can carry underscore role forms ('cs_rep',
|
||||
'cs_manager', 'fm_dispatcher') — the exact gap Mumuni relay #747 found on
|
||||
user 18 (test@denya.com). Each must map onto its canonical role so startup
|
||||
normalization can converge the row instead of stranding it on /tickets."""
|
||||
from app.core.roles import normalize_role
|
||||
|
||||
assert normalize_role("cs_rep") == "CS Rep"
|
||||
assert normalize_role("cs_manager") == "CS Manager"
|
||||
assert normalize_role("fm_dispatcher") == "FM Dispatcher"
|
||||
# Matching is case/whitespace tolerant, like the space-form aliases.
|
||||
assert normalize_role(" CS_REP ") == "CS Rep"
|
||||
assert normalize_role("cs_rep") is not None # known, not fail-closed
|
||||
|
||||
|
||||
async def test_legacy_cs_rep_row_converges_and_authenticates(client: AsyncClient):
|
||||
"""A 'cs_rep' row (user 18 test@denya.com shape) is converged to canonical
|
||||
'CS Rep' by the startup self-heal and can log in again (no fail-closed
|
||||
denial, and the frontend CS nav sees the canonical role)."""
|
||||
await _insert_user("cs-rep-legacy@example.com", "cs_rep")
|
||||
await _normalize_roles() # 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 == "cs-rep-legacy@example.com"))
|
||||
).scalar_one()
|
||||
assert user.role == "CS Rep"
|
||||
|
||||
login = await client.post(
|
||||
"/api/auth/login", json={"email": "cs-rep-legacy@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"] == "CS Rep"
|
||||
|
||||
|
||||
# ── 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
|
||||
@@ -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
|
||||
@@ -0,0 +1,164 @@
|
||||
"""WhatsApp demo-number wiring (WHATSAPP_DEMO_TO).
|
||||
|
||||
The demo round trip (webhook POST -> ticket -> auto-reply -> mock-log) surfaces
|
||||
the expected sender/recipient number. That number is **never committed**: it
|
||||
lives only in the deploy host's .env and reaches the app through the
|
||||
``WHATSAPP_DEMO_TO`` setting (same fail-closed env pattern as the webhook
|
||||
secret). ``build_demo_webhook_payload()`` builds the demo payload from the
|
||||
setting so mock-log and the auto-reply show the configured number; with the
|
||||
setting unset it raises instead of fabricating a sender.
|
||||
|
||||
Anchors:
|
||||
* ``settings.WHATSAPP_DEMO_TO`` defaults to ``""`` and no tracked file assigns
|
||||
it a value (real numbers stay out of git history).
|
||||
* ``build_demo_webhook_payload`` uses the configured number as the message
|
||||
``from`` and fails closed when unset.
|
||||
* A full round trip with the secret + demo number logs the number and surfaces
|
||||
it in ``/api/whatsapp/mock-log``; the auto-reply targets the same number.
|
||||
* Webhook stays 403 without the secret and mock-log stays 401 without auth.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
from app.core.config import settings # noqa: E402
|
||||
|
||||
# Clearly-fake test number — never use a real contact number in source.
|
||||
_FAKE_DEMO_TO = "+233559999999"
|
||||
|
||||
|
||||
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:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
async def _capture_reply(monkeypatch, calls: list):
|
||||
"""Swap the Meta client for a recorder; returns the fake."""
|
||||
from app.routers import whatsapp as whatsapp_router
|
||||
from app.schemas.whatsapp import WhatsAppReplyResponse
|
||||
|
||||
async def _fake_reply(to_phone: str, text: str):
|
||||
calls.append((to_phone, text))
|
||||
return WhatsAppReplyResponse(success=True, message="sent")
|
||||
|
||||
monkeypatch.setattr(whatsapp_router, "send_whatsapp_reply", _fake_reply)
|
||||
return whatsapp_router
|
||||
|
||||
|
||||
# ── Config plumbing ───────────────────────────────────────────────────
|
||||
def test_demo_number_defaults_empty_and_never_committed():
|
||||
"""WHATSAPP_DEMO_TO must default empty; no tracked file may set a value.
|
||||
|
||||
Real WhatsApp numbers are deploy-host .env secrets — a committed value
|
||||
(even in tests or .env.example) would leak into git history.
|
||||
"""
|
||||
assert settings.WHATSAPP_DEMO_TO == ""
|
||||
|
||||
root = Path.cwd()
|
||||
listed = subprocess.run(
|
||||
["git", "ls-files", "-z"], cwd=root, capture_output=True, text=True
|
||||
)
|
||||
assert listed.returncode == 0, "git ls-files failed inside the test repo"
|
||||
tracked = [p for p in listed.stdout.split("\0") if p]
|
||||
|
||||
assignment = re.compile(r"^WHATSAPP_DEMO_TO[ \t]*=[ \t]*(\S*)$")
|
||||
offenders = []
|
||||
for rel in tracked:
|
||||
path = root / rel
|
||||
if path.suffix.lower() in {".png", ".jpg", ".jpeg", ".db", ".pyc", ".ico", ".woff", ".woff2", ".gz"}:
|
||||
continue # binaries cannot carry a text assignment
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError:
|
||||
continue
|
||||
for lineno, line in enumerate(text.splitlines(), start=1):
|
||||
match = assignment.match(line)
|
||||
if match and match.group(1):
|
||||
offenders.append(f"{rel}:{lineno}: WHATSAPP_DEMO_TO={match.group(1)!r}")
|
||||
assert not offenders, (
|
||||
"WHATSAPP_DEMO_TO must stay unset in tracked files (set it in the "
|
||||
f"deploy host .env only); found: {offenders}"
|
||||
)
|
||||
|
||||
|
||||
# ── Demo payload builder ─────────────────────────────────────────────
|
||||
def test_demo_payload_builder_fails_closed_when_unset(monkeypatch):
|
||||
"""Without WHATSAPP_DEMO_TO the builder raises rather than fabricating."""
|
||||
from app.routers.whatsapp import build_demo_webhook_payload
|
||||
|
||||
monkeypatch.setattr(settings, "WHATSAPP_DEMO_TO", "")
|
||||
with pytest.raises(RuntimeError, match="WHATSAPP_DEMO_TO"):
|
||||
build_demo_webhook_payload()
|
||||
|
||||
|
||||
def test_demo_payload_builder_uses_configured_number(monkeypatch):
|
||||
"""The demo payload's message ``from`` is the configured demo number."""
|
||||
from app.routers.whatsapp import build_demo_webhook_payload
|
||||
|
||||
monkeypatch.setattr(settings, "WHATSAPP_DEMO_TO", _FAKE_DEMO_TO)
|
||||
payload = build_demo_webhook_payload(text="Leaking tap", wa_message_id="wamid.demo.42")
|
||||
entry = payload["entry"][0]["changes"][0]
|
||||
assert entry["message"]["from"] == _FAKE_DEMO_TO
|
||||
assert entry["message"]["id"] == "wamid.demo.42"
|
||||
assert entry["message"]["text"]["text"] == "Leaking tap"
|
||||
|
||||
|
||||
# ── Demo round trip ──────────────────────────────────────────────────
|
||||
async def test_demo_round_trip_surfaces_number_in_mock_log(client, monkeypatch):
|
||||
"""Webhook demo payload -> ticket + log; mock-log shows the demo number."""
|
||||
from app.routers.whatsapp import build_demo_webhook_payload
|
||||
|
||||
replies: list[tuple[str, str]] = []
|
||||
await _capture_reply(monkeypatch, replies)
|
||||
|
||||
monkeypatch.setattr(settings, "WHATSAPP_WEBHOOK_SECRET", "test-webhook-secret")
|
||||
monkeypatch.setattr(settings, "WHATSAPP_DEMO_TO", _FAKE_DEMO_TO)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/whatsapp/webhook",
|
||||
json=build_demo_webhook_payload(text="Demo leak"),
|
||||
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-")
|
||||
|
||||
# Auto-reply went back to the demo number.
|
||||
assert replies and replies[0][0] == _FAKE_DEMO_TO, replies
|
||||
|
||||
token = await _login(client)
|
||||
log = await client.get("/api/whatsapp/mock-log", headers=_auth(token))
|
||||
assert log.status_code == 200, log.text
|
||||
entries = log.json()
|
||||
assert any(e["from_number"] == _FAKE_DEMO_TO for e in entries)
|
||||
assert any(e["ticket_number"] == data["ticket_number"] for e in entries)
|
||||
|
||||
|
||||
async def test_webhook_still_403_without_secret_even_with_demo_number(client, monkeypatch):
|
||||
"""The webhook secret gate is independent of the demo number."""
|
||||
monkeypatch.setattr(settings, "WHATSAPP_WEBHOOK_SECRET", "test-webhook-secret")
|
||||
monkeypatch.setattr(settings, "WHATSAPP_DEMO_TO", _FAKE_DEMO_TO)
|
||||
resp = await client.post("/api/whatsapp/webhook", json={"object": "whatsapp_business_account"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_mock_log_still_401_gated(client):
|
||||
"""mock-log stays authenticated-only (no token -> 401)."""
|
||||
resp = await client.get("/api/whatsapp/mock-log")
|
||||
assert resp.status_code == 401, resp.text
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Regression: legacy ``whatsapp_log`` tables self-heal at startup.
|
||||
|
||||
Producer/consumer for the CT115 defect (Mumuni relay #747): the live demo DB's
|
||||
``whatsapp_log`` table still has the pre-webhook shape — ``command`` instead of
|
||||
``message_text`` and no ``wa_message_id``/``ticket_number`` — so
|
||||
``GET /api/whatsapp/mock-log`` 500'd with ``OperationalError: no such column:
|
||||
whatsapp_log.message_text`` even for a valid admin token.
|
||||
|
||||
Producer: build the legacy-shaped table (as the live DB holds it) and seed it
|
||||
with ``command`` rows.
|
||||
Consumer: boot the app's startup self-heal (``ensure_legacy_schema``), then read
|
||||
back through the authenticated mock-log endpoint, insert through the ORM write
|
||||
path, and re-run the self-heal to prove idempotency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.database import async_session_factory, engine
|
||||
from app.main import ensure_legacy_schema
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
# Pre-webhook shape (commit 4afdc36 changed the model but no migration shipped):
|
||||
# id, command (NOT NULL), from_number, ticket_id, received_at.
|
||||
LEGACY_WHATSAPP_LOG_DDL = (
|
||||
"CREATE TABLE whatsapp_log ("
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "
|
||||
"command TEXT NOT NULL, "
|
||||
"from_number VARCHAR(50), "
|
||||
"ticket_id INTEGER, "
|
||||
"received_at DATETIME NOT NULL)"
|
||||
)
|
||||
|
||||
EXPECTED_MODEL_COLUMNS = {
|
||||
"id",
|
||||
"from_number",
|
||||
"message_text",
|
||||
"wa_message_id",
|
||||
"ticket_id",
|
||||
"ticket_number",
|
||||
"received_at",
|
||||
}
|
||||
|
||||
|
||||
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 _replace_with_legacy_whatsapp_log() -> None:
|
||||
"""Drop the model-shaped table and recreate the legacy shape with rows."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text("DROP TABLE IF EXISTS whatsapp_log"))
|
||||
await conn.execute(text(LEGACY_WHATSAPP_LOG_DDL))
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO whatsapp_log (command, from_number, ticket_id, received_at) VALUES "
|
||||
"('legacy older message', '+233200000001', 1, '2026-09-08 08:00:00'), "
|
||||
"('legacy newest message', '+233200000002', 2, '2026-09-09 09:30:00')"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _columns() -> set[str]:
|
||||
async with engine.begin() as conn:
|
||||
result = await conn.execute(text("PRAGMA table_info(whatsapp_log)"))
|
||||
return {row[1] for row in result}
|
||||
|
||||
|
||||
async def test_legacy_whatsapp_log_self_heals_and_mock_log_200(client):
|
||||
"""Producer: legacy whatsapp_log (command shape). Consumer: startup self-heal
|
||||
then authenticated mock-log — the exact 500 from the live instance."""
|
||||
token = await _login(client)
|
||||
await _replace_with_legacy_whatsapp_log()
|
||||
|
||||
# Pre-fix reproduction: on the legacy table this endpoint fails exactly as
|
||||
# reported (production: HTTP 500; under ASGITransport the app never returns
|
||||
# a response so the sqlalchemy OperationalError propagates).
|
||||
import sqlalchemy.exc
|
||||
|
||||
with pytest.raises(sqlalchemy.exc.OperationalError):
|
||||
await client.get("/api/whatsapp/mock-log", headers=_auth(token))
|
||||
|
||||
# ── Boot the startup self-heal (what lifespan does each boot) ──
|
||||
async with engine.begin() as conn:
|
||||
await ensure_legacy_schema(conn)
|
||||
|
||||
columns = await _columns()
|
||||
assert EXPECTED_MODEL_COLUMNS <= columns
|
||||
assert "command" not in columns # obsolete model-dropped column is gone
|
||||
|
||||
# Authenticated mock-log returns 200 and the backfilled rows are readable.
|
||||
resp = await client.get("/api/whatsapp/mock-log", headers=_auth(token))
|
||||
assert resp.status_code == 200, resp.text
|
||||
entries = resp.json()
|
||||
assert [e["message_text"] for e in entries] == [
|
||||
"legacy newest message",
|
||||
"legacy older message",
|
||||
] # order: received_at desc; bodies preserved from legacy `command`
|
||||
|
||||
# ORM write path works on the healed table (the current app inserts
|
||||
# message_text/wa_message_id and never writes `command`).
|
||||
from app.models.whatsapp_log import WhatsAppLog
|
||||
|
||||
async with async_session_factory() as session:
|
||||
session.add(
|
||||
WhatsAppLog(
|
||||
from_number="+233200000003",
|
||||
message_text="inbound after self-heal",
|
||||
wa_message_id="wamid.healed.1",
|
||||
ticket_id=None,
|
||||
ticket_number=None,
|
||||
received_at=datetime(2026, 9, 10, 10, 0, 0),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
resp = await client.get("/api/whatsapp/mock-log", headers=_auth(token))
|
||||
assert resp.status_code == 200, resp.text
|
||||
entries = resp.json()
|
||||
assert entries[0]["message_text"] == "inbound after self-heal"
|
||||
assert entries[0]["from_number"] == "+233200000003"
|
||||
assert len(entries) == 3
|
||||
|
||||
# ── Idempotent on the next boot ──
|
||||
async with engine.begin() as conn:
|
||||
await ensure_legacy_schema(conn)
|
||||
columns = await _columns()
|
||||
assert EXPECTED_MODEL_COLUMNS <= columns
|
||||
assert "command" not in columns
|
||||
|
||||
resp = await client.get("/api/whatsapp/mock-log", headers=_auth(token))
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert len(resp.json()) == 3
|
||||
@@ -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)
|
||||