Author SHA1 Message Date
abiba-bot 1f15ca5457 docs: production hardening & review checklist for demo->prod transition
Grounded in hands-on review of the live scottdenya deployment and main.
P0 security blockers, P1 operational hardening, WhatsApp wiring runbook,
no-mistakes review items, and production Definition-of-Done.

Prepared by Mumuni (Syslog Falcon) 2026-08-03.
2026-08-03 08:05:26 +00:00
abiba-bot 30c0c01c59 Merge pull request 'Wahab demo prep — workflow hardening, Cancelled status, ticket cleanup support' (#5) from fm/denya-wahab-cleanup-hardening into main 2026-08-02 15:01:24 +00:00
root bf361d76bc no-mistakes(document): Docs synced for Cancelled status; lint cleaned 2026-08-02 14:37:37 +00:00
root 6b92c9098c no-mistakes(review): Guard photo cleanup; add transitions helper and assign auto-advance 2026-08-02 14:26:48 +00:00
root 237284b012 no-mistakes(review): Make Cancelled reachable from all active states; wire phone PATCH; exclude cancelled from CEO resolution avg 2026-08-02 14:12:46 +00:00
root 1dd88a141e feat: Wahab demo prep — Cancelled status, phone persistence, admin delete, users picker, detail-page fixes
- Add Cancelled as terminal status reachable from all active states; exclude
  from SLA breach reporting and dashboard active counts; add to status pickers
- Persist customer phone on ticket create/update (was silently dropped);
  add tickets.phone migration + legacy self-heal guard
- Add admin-only DELETE /api/tickets/{id} (removes timeline/photos/escalations)
- Add GET /api/auth/users for the assign-technician dropdown (was hardcoded)
- TicketOut now returns nested unit/category so the detail page stops showing
  '—' for Unit/Property/Category
- Ticket numbering uses max+1 so deletions never re-issue a number
- New-issue form: require Category and (standard mode) Priority client-side
- Tests: 11 new cases covering cancellation, SLA exemption, phone, delete,
  users endpoint, numbering
2026-08-02 14:06:30 +00:00
abiba-bot ffed595dbd Merge pull request 'Sprint A Wahab review batch — categories, property hierarchy, priority grouping' (#4) from fm/denya-wahab-sprint-a into main 2026-08-02 13:36:31 +00:00
22 changed files with 763 additions and 75 deletions
+10 -4
View File
@@ -52,6 +52,7 @@ Users, units, and categories are auto-seeded on first startup via lifespan hook:
| POST | `/api/auth/refresh` | Token | Refresh tokens | | POST | `/api/auth/refresh` | Token | Refresh tokens |
| GET | `/api/auth/me` | Bearer | Current user | | GET | `/api/auth/me` | Bearer | Current user |
| GET | `/api/auth/admin-only` | Admin/Jerome, Admin/Wahab | RBAC demo endpoint | | GET | `/api/auth/admin-only` | Admin/Jerome, Admin/Wahab | RBAC demo endpoint |
| GET | `/api/auth/users` | Bearer | List users (id, name, role) for the assign-technician picker |
| POST | `/api/whatsapp/mock` | No | Mock WhatsApp | | POST | `/api/whatsapp/mock` | No | Mock WhatsApp |
| GET | `/api/whatsapp/mock-log` | No | Recent mock submissions | | GET | `/api/whatsapp/mock-log` | No | Recent mock submissions |
@@ -73,8 +74,10 @@ Frontend: Alpine.js (CDN) + Tailwind CSS (CDN). Auth state in localStorage. Role
|--------|------|------|-------------| |--------|------|------|-------------|
| POST | `/api/tickets` | Bearer | Create ticket (auto-number PAV-YYYY-NNNNN) | | POST | `/api/tickets` | Bearer | Create ticket (auto-number PAV-YYYY-NNNNN) |
| GET | `/api/tickets` | No | List tickets (filter: status, priority, property, building, unit_id, category_id, assigned_to, date_from, date_to) | | GET | `/api/tickets` | No | List tickets (filter: status, priority, property, building, unit_id, category_id, assigned_to, date_from, date_to) |
| GET | `/api/tickets/{id}` | No | Get ticket detail with timeline, photos, SLA status | | GET | `/api/tickets/{id}` | No | Get ticket detail with timeline, photos, SLA status, nested unit/category, phone |
| PATCH | `/api/tickets/{id}` | Bearer | Update ticket (validates status transitions) | | 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 | | POST | `/api/tickets/{id}/status` | Bearer | Change status with note |
| GET | `/api/tickets/{id}/sla` | No | Check SLA breach status | | GET | `/api/tickets/{id}/sla` | No | Check SLA breach status |
| POST | `/api/tickets/{id}/photos` | Bearer | Upload photos (multipart, is_before param) | | POST | `/api/tickets/{id}/photos` | Bearer | Upload photos (multipart, is_before param) |
@@ -91,8 +94,11 @@ Frontend: Alpine.js (CDN) + Tailwind CSS (CDN). Auth state in localStorage. Role
## Ticket System (Sprint 2) ## Ticket System (Sprint 2)
### Status Lifecycle (15 statuses) ### 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 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. Valid transitions defined in `app/services/ticket.py::VALID_TRANSITIONS`. Invalid transitions return 400.
+214
View File
@@ -0,0 +1,214 @@
# Denya OneCare — Production Hardening & Review Checklist
> **Audience:** Abiba (and any agent working the Denya OneCare repo)
> **Status:** Demo → Production hardening
> **Context:** WhatsApp integration lands within a week (once Denya provides credentials).
> We are moving past "demo" toward the final product. This doc is the concrete,
> ordered punch-list to get there. Each item is grounded in the current codebase
> (verified against `main` and the live deployment on `scottdenya`).
> **How to use:** work top-down. P0 items are hard blockers for any real data.
> When a P0/P1 item is done, mark it `[x]` and PR it with a `no-mistakes(review)` pass.
---
## 0. Current state (verified 2026-08-03)
- **Working & verified:** auth (JWT 30m/7d, bcrypt, RBAC via `require_roles`), ticket
CRUD with 16-status `VALID_TRANSITIONS` state machine, SLA engine, photo uploads,
category/unit hierarchy, 3 role dashboards (CS/FM/CEO), Alembic migrations with
legacy-schema self-heal. **32 pytest tests pass.**
- **Live:** container `denya-onecare` on LXC `scottdenya` (192.168.68.75:8000),
image built 2026-08-02, `restart: unless-stopped`.
- **Known demo-only posture (must change):** `SECRET_KEY=change-me-in-production`,
`CORS_ORIGINS=*`, open `/api/auth/register`, 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
- **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
- **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
- **File:** `app/routers/auth.py` (`POST /api/auth/register`)
- Today anyone on the network can self-register. Decide the model:
- **Recommended:** require an admin-issued invitation token, or restrict
registration to a seed/allowed list, or remove the open route and create
users only via seed/admin.
- If a public self-service resident/tenant signup is genuinely required
(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.
- **Acceptance:** a raw, unauthenticated register call can no longer mint an
`Admin/*` or `Director` account.
### 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 (finish wiring, then lock it)
- **File:** `app/routers/whatsapp.py`
- The handler exists but no credentials are set. When wiring this week:
- Verify the `hub.verify_token` check is constant-time (compare with
`secrets.compare_digest`). **The GET verification path currently returns
`{"error": ...}` with HTTP 200** — flip to `403` on token mismatch.
- Validate **inbound messages only from Meta** — the webhook MUST authenticate
Meta's request signature (X-Hub-Signature-256 HMAC over the raw body with your
app secret) before processing, otherwise anyone who discovers the endpoint can
forge tickets. This is the single most important WhatsApp hardening item.
- Add per-sender rate limiting / dedupe on `wa_message_id` (webhook retries can
double-create tickets). Create an idempotency guard keyed on `wa_message_id`.
- Never log the raw access token; redact in `send_whatsapp_reply` error paths.
- **Acceptance:** a forged POST without the Meta signature is rejected; duplicate
`wa_message_id` does not create a second ticket; verify-token mismatch returns 403.
---
## 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
- Add rate limiting on `POST /api/auth/login` (brute-force) — per-IP/IP+account.
- Consider rate limits on ticket creation (spam / mass-creation).
- Normalize/validate `page_size` (already capped `le=200`) and pagination
tie-breaker (`id DESC` present — good).
---
## 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 currently echoes
`hub.challenge` when the verify token matches — confirm this works, then apply
P0.5 (403 on mismatch, HMAC signature validation).
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)
- [ ] No default `SECRET_KEY`; app fails closed without a real key
- [ ] CORS is an explicit origin allow-list
- [ ] Self-registration cannot mint privileged roles (or is admin-gated/removed)
- [ ] Postgres backend; Alembic applies cleanly on fresh DB; nightly backups
- [ ] TLS-terminated reverse proxy with real domain; no raw :8000 on WAN
- [ ] WhatsApp webhook: Meta signature validated, verify-token mismatch → 403,
idempotent on `wa_message_id`, real credentials injected at runtime
- [ ] Login/ticket rate limiting in place
- [ ] Photo uploads size-limited and content-sniffed
- [ ] RBAC audited per-route; phone data access controlled
- [ ] Expanded test suite (auth, WhatsApp, SLA boundary, uploads) — all green
- [ ] Secrets out of repo; demo password rotated
- [ ] Structured logs + DB-aware health check
---
*Prepared by Mumuni (Syslog Falcon) — 2026-08-03, from a hands-on review of the
denya-onecare repo and the live scottdenya deployment.*
+4 -2
View File
@@ -120,7 +120,7 @@ Close Ticket
Archive → Executive Reporting Archive → Executive Reporting
``` ```
### Detailed Ticket Lifecycle (15 Statuses) ### Detailed Ticket Lifecycle (16 Statuses)
| # | Status | Description | Who Sets | | # | Status | Description | Who Sets |
|---|--------|-------------|----------| |---|--------|-------------|----------|
@@ -139,6 +139,7 @@ Archive → Executive Reporting
| 13 | **Wahab Review** | Final QA oversight and closure approval | Wahab | | 13 | **Wahab Review** | Final QA oversight and closure approval | Wahab |
| 14 | **Closed** | Ticket officially closed | Wahab / Nicholas | | 14 | **Closed** | Ticket officially closed | Wahab / Nicholas |
| 15 | **Reopened** | Issue resurfaces within 7 days | Agent / System | | 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 ### 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 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 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. - **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. - **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,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')
-1
View File
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from functools import wraps
from typing import Annotated, Any, Callable from typing import Annotated, Any, Callable
import bcrypt import bcrypt
+10
View File
@@ -28,6 +28,16 @@ async def ensure_legacy_schema(conn) -> None:
text("ALTER TABLE categories ADD COLUMN show_in_form BOOLEAN NOT NULL DEFAULT 1") 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)") 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)")
result = await conn.execute( result = await conn.execute(
text( text(
"UPDATE categories SET name = 'Missing Item' " "UPDATE categories SET name = 'Missing Item' "
+2 -1
View File
@@ -29,7 +29,7 @@ class Ticket(Base):
comment=( comment=(
"New, Logged, Triage, Assigned, Accepted, Travelling, On Site, " "New, Logged, Triage, Assigned, Accepted, Travelling, On Site, "
"In Progress, Waiting Parts, Escalated, Completed, " "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) 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", comment="urgent, high, medium, low",
) )
reporter: Mapped[str | None] = mapped_column(String(255), nullable=True) 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( reported_via: Mapped[str | None] = mapped_column(
String(20), String(20),
nullable=True, nullable=True,
+16
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
@@ -22,6 +23,21 @@ from app.services import auth as auth_service
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
@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 assignment pickers.
Previously the frontend hard-coded technician ids/names in detail.html;
this endpoint makes the assign dropdown data-driven so a seed change never
silently breaks technician assignment.
"""
result = await db.execute(select(User).order_by(User.full_name))
return list(result.scalars().all())
@router.post("/register", response_model=UserOut, status_code=201) @router.post("/register", response_model=UserOut, status_code=201)
async def register( async def register(
body: RegisterRequest, body: RegisterRequest,
+43 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import logging
import uuid import uuid
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -12,7 +13,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.core.security import get_current_user from app.core.security import get_current_user, require_roles
from app.models.category import Category from app.models.category import Category
from app.models.ticket import Ticket, TicketPhoto from app.models.ticket import Ticket, TicketPhoto
from app.models.unit import Unit from app.models.unit import Unit
@@ -32,6 +33,8 @@ from app.schemas.ticket import (
from app.services import ticket as ticket_service from app.services import ticket as ticket_service
from app.services.sla import get_sla_status from app.services.sla import get_sla_status
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/tickets", tags=["tickets"]) router = APIRouter(prefix="/api/tickets", tags=["tickets"])
# Ensure uploads directory exists # Ensure uploads directory exists
@@ -224,6 +227,19 @@ async def list_tickets(
return TicketListResponse(items=items, total=total, page=page, page_size=page_size) return TicketListResponse(items=items, total=total, page=page, page_size=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) @router.get("/{ticket_id}", response_model=TicketOut)
async def get_ticket( async def get_ticket(
ticket_id: int, ticket_id: int,
@@ -250,6 +266,32 @@ async def update_ticket(
return 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_roles("Admin/Jerome", "Admin/Wahab"))],
) -> 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) ─────────────────────── # ── Status Transitions (convenience endpoints) ───────────────────────
@router.post("/{ticket_id}/status", response_model=TicketOut) @router.post("/{ticket_id}/status", response_model=TicketOut)
async def change_ticket_status( async def change_ticket_status(
+1 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from pydantic import BaseModel, EmailStr from pydantic import BaseModel
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
+4
View File
@@ -54,6 +54,7 @@ class TicketUpdate(BaseModel):
category_id: int | None = None category_id: int | None = None
priority: str | None = None priority: str | None = None
reporter: str | None = None reporter: str | None = None
phone: str | None = None
reported_via: str | None = None reported_via: str | None = None
description: str | None = None description: str | None = None
assigned_to: int | None = None assigned_to: int | None = None
@@ -95,6 +96,7 @@ class TicketBrief(BaseModel):
assigned_to: int | None = None assigned_to: int | None = None
assigned_technician_name: str | None = None assigned_technician_name: str | None = None
reporter: str | None = None reporter: str | None = None
phone: str | None = None
description: str | None = None description: str | None = None
sla_deadline: datetime | None = None sla_deadline: datetime | None = None
reopen_count: int = 0 reopen_count: int = 0
@@ -113,6 +115,8 @@ class TicketOut(TicketBrief):
customer_rating: int | None = None customer_rating: int | None = None
timeline: list[TicketTimelineOut] = [] timeline: list[TicketTimelineOut] = []
photos: list[TicketPhotoOut] = [] photos: list[TicketPhotoOut] = []
unit: UnitOut | None = None
category: CategoryOut | None = None
sla_status: dict | None = None sla_status: dict | None = None
-2
View File
@@ -3,11 +3,9 @@
from __future__ import annotations from __future__ import annotations
from fastapi import HTTPException, status from fastapi import HTTPException, status
from jose import JWTError, jwt
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.security import ( from app.core.security import (
create_access_token, create_access_token,
create_refresh_token, create_refresh_token,
+5 -2
View File
@@ -55,9 +55,12 @@ def should_escalate_on_response(ticket: Ticket) -> bool:
return datetime.now(timezone.utc) > deadline return datetime.now(timezone.utc) > deadline
TERMINAL_STATUSES = {"Closed", "Completed", "Cancelled"}
def is_sla_breached(ticket: Ticket) -> bool: def is_sla_breached(ticket: Ticket) -> bool:
"""Return True if the ticket's resolution SLA deadline has passed.""" """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 return False
deadline = ticket.sla_deadline deadline = ticket.sla_deadline
if deadline.tzinfo is None: if deadline.tzinfo is None:
@@ -88,7 +91,7 @@ async def get_sla_status(ticket: Ticket) -> dict:
rd = resolution_deadline rd = resolution_deadline
if rd.tzinfo is None: if rd.tzinfo is None:
rd = rd.replace(tzinfo=timezone.utc) 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 { return {
"priority": ticket.priority, "priority": ticket.priority,
+69 -23
View File
@@ -6,11 +6,11 @@ from datetime import datetime, timezone
from typing import Any from typing import Any
from fastapi import HTTPException, status 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.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload 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.unit import Unit
from app.models.user import User from app.models.user import User
from app.services.sla import compute_sla_deadline from app.services.sla import compute_sla_deadline
@@ -18,21 +18,23 @@ from app.services.sla import compute_sla_deadline
# ── Status Transition Map ──────────────────────────────────────────── # ── Status Transition Map ────────────────────────────────────────────
# Keys: current status → list of valid next statuses # Keys: current status → list of valid next statuses
VALID_TRANSITIONS: dict[str, list[str]] = { VALID_TRANSITIONS: dict[str, list[str]] = {
"New": ["Logged"], "New": ["Logged", "Cancelled"],
"Logged": ["Triage", "Closed"], "Logged": ["Triage", "Closed", "Cancelled"],
"Triage": ["Assigned", "Escalated"], "Triage": ["Assigned", "Escalated", "Cancelled"],
"Assigned": ["Accepted", "Triage"], "Assigned": ["Accepted", "Triage", "Cancelled"],
"Accepted": ["Travelling", "Triage"], "Accepted": ["Travelling", "Triage", "Cancelled"],
"Travelling": ["On Site", "Triage"], "Travelling": ["On Site", "Triage", "Cancelled"],
"On Site": ["In Progress", "Triage"], "On Site": ["In Progress", "Triage", "Cancelled"],
"In Progress": ["Waiting Parts", "Escalated", "Completed"], "In Progress": ["Waiting Parts", "Escalated", "Completed", "Cancelled"],
"Waiting Parts": ["In Progress", "Escalated"], "Waiting Parts": ["In Progress", "Escalated", "Cancelled"],
"Escalated": ["Triage", "In Progress", "Completed", "Closed"], "Escalated": ["Triage", "In Progress", "Completed", "Closed", "Cancelled"],
"Completed": ["On-Field Verification", "In Progress"], "Completed": ["On-Field Verification", "In Progress", "Cancelled"],
"On-Field Verification": ["Wahab Review", "Completed", "Closed"], "On-Field Verification": ["Wahab Review", "Completed", "Closed", "Cancelled"],
"Wahab Review": ["Closed", "On-Field Verification"], "Wahab Review": ["Closed", "On-Field Verification", "Cancelled"],
"Closed": ["Reopened"], "Closed": ["Reopened"],
"Reopened": ["Triage", "Logged"], "Reopened": ["Triage", "Logged", "Cancelled"],
# Terminal: cancelled tickets cannot resume work.
"Cancelled": [],
} }
REOPEN_WINDOW_DAYS = 7 REOPEN_WINDOW_DAYS = 7
@@ -41,15 +43,25 @@ SLA_ACK_USER = "Ama"
# ── Helpers ────────────────────────────────────────────────────────── # ── Helpers ──────────────────────────────────────────────────────────
async def _generate_ticket_number(db: AsyncSession) -> str: 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 year = datetime.now(timezone.utc).year
prefix = f"PAV-{year}-" prefix = f"PAV-{year}-"
# Count existing tickets this year
result = await db.execute( 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 max_number = result.scalar()
return f"{prefix}{count + 1:05d}" 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( async def _log_status_change(
@@ -110,6 +122,7 @@ async def create_ticket(
category_id=data.get("category_id"), category_id=data.get("category_id"),
priority=priority, priority=priority,
reporter=data.get("reporter") or data.get("customer_name"), reporter=data.get("reporter") or data.get("customer_name"),
phone=data.get("phone"),
reported_via=data.get("reported_via"), reported_via=data.get("reported_via"),
description=data.get("description"), description=data.get("description"),
assigned_to=data.get("assigned_to"), assigned_to=data.get("assigned_to"),
@@ -223,6 +236,7 @@ async def update_ticket(
# Handle status transitions separately # Handle status transitions separately
new_status = data.get("status") new_status = data.get("status")
old_status = ticket.status old_status = ticket.status
status_changed = new_status is not None and old_status != new_status
if new_status is not None: if new_status is not None:
if old_status != new_status: if old_status != new_status:
valid_targets = VALID_TRANSITIONS.get(old_status, []) valid_targets = VALID_TRANSITIONS.get(old_status, [])
@@ -283,9 +297,25 @@ async def update_ticket(
ticket.status = new_status 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) # Handle standalone note (no status change)
note_only = data.get("note") 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( await _log_status_change(
db, db,
ticket.id, ticket.id,
@@ -296,7 +326,7 @@ async def update_ticket(
) )
# Update other fields # 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"): "description", "assigned_to", "eta", "cost", "parts_used"):
if field in data: if field in data:
setattr(ticket, field, data[field]) setattr(ticket, field, data[field])
@@ -310,3 +340,19 @@ async def update_ticket(
return 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
+3 -1
View File
@@ -51,6 +51,7 @@
.status-wahab-review { @apply bg-violet-100 text-violet-800; } .status-wahab-review { @apply bg-violet-100 text-violet-800; }
.status-closed { @apply bg-gray-200 text-gray-600; } .status-closed { @apply bg-gray-200 text-gray-600; }
.status-reopened { @apply bg-pink-100 text-pink-800; } .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-urgent { @apply bg-red-100 text-red-800 border-red-300; }
.priority-high { @apply bg-orange-100 text-orange-800 border-orange-300; } .priority-high { @apply bg-orange-100 text-orange-800 border-orange-300; }
.priority-medium { @apply bg-yellow-100 text-yellow-800 border-yellow-300; } .priority-medium { @apply bg-yellow-100 text-yellow-800 border-yellow-300; }
@@ -318,7 +319,8 @@
'on-field verification': 'status-on-field-verification', 'on-field verification': 'status-on-field-verification',
'wahab review': 'status-wahab-review', 'wahab review': 'status-wahab-review',
'closed': 'status-closed', 'closed': 'status-closed',
'reopened': 'status-reopened' 'reopened': 'status-reopened',
'cancelled': 'status-cancelled'
}; };
return map[status.toLowerCase()] || 'bg-gray-100 text-gray-800'; return map[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
}, },
+5 -5
View File
@@ -189,11 +189,11 @@
if (!all.length) return; if (!all.length) return;
// Basic KPIs // Basic KPIs
const open = 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'].includes(t.status)); const closed = all.filter(t => ['Closed', 'Completed', 'Cancelled'].includes(t.status));
const urgent = all.filter(t => t.priority === 'urgent'); const urgent = all.filter(t => t.priority === 'urgent');
const withSLA = all.filter(t => t.sla_deadline); 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 // Monthly restored
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
@@ -270,7 +270,7 @@
// Risk indicators // Risk indicators
this.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, reopenedThisWeek,
overdueJobs: open.filter(t => t.sla_deadline && new Date(t.sla_deadline) < new Date()).length, 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, pendingVerification: all.filter(t => ['Completed', 'On-Field Verification'].includes(t.status)).length,
@@ -280,7 +280,7 @@
}, },
calcAvgResponse(all) { 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 '—'; 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; 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`; if (avgHrs < 1) return `${Math.round(avgHrs * 60)}m`;
+4 -4
View File
@@ -188,20 +188,20 @@
const newToday = all.filter(t => new Date(t.created_at) >= today && t.status === 'New').length; const newToday = all.filter(t => new Date(t.created_at) >= today && t.status === 'New').length;
// Open tickets (not closed/completed) // 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 // By priority
const byPriority = { urgent: 0, high: 0, medium: 0, low: 0 }; const byPriority = { urgent: 0, high: 0, medium: 0, low: 0 };
open.forEach(t => { if (t.priority) byPriority[t.priority] = (byPriority[t.priority] || 0) + 1; }); open.forEach(t => { if (t.priority) byPriority[t.priority] = (byPriority[t.priority] || 0) + 1; });
// Oldest unassigned // 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)); .sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
this.oldestUnassigned = unassigned[0] || null; this.oldestUnassigned = unassigned[0] || null;
// SLA compliance (rough: tickets with sla_deadline not breached) // SLA compliance (rough: tickets with sla_deadline not breached)
const withSLA = all.filter(t => t.sla_deadline); 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 = { this.kpi = {
newToday, newToday,
@@ -230,7 +230,7 @@
calcAvgResponse(all) { calcAvgResponse(all) {
// Rough approximation — in real system this would come from timeline analysis // 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 '—'; if (!open.length) return '—';
const avgHrs = open.reduce((sum, t) => { const avgHrs = open.reduce((sum, t) => {
const diff = (new Date() - new Date(t.created_at)) / (1000 * 60 * 60); const diff = (new Date() - new Date(t.created_at)) / (1000 * 60 * 60);
+1 -1
View File
@@ -222,7 +222,7 @@
const all = data.items; const all = data.items;
// Active: not closed/completed // 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; this.activeTickets = active;
// KPIs // KPIs
+29 -25
View File
@@ -121,6 +121,10 @@
<dt class="text-sm text-gray-500">Reporter</dt> <dt class="text-sm text-gray-500">Reporter</dt>
<dd class="text-sm font-medium text-gray-900" x-text="ticket.reporter || '—'"></dd> <dd class="text-sm font-medium text-gray-900" x-text="ticket.reporter || '—'"></dd>
</div> </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"> <div class="flex justify-between">
<dt class="text-sm text-gray-500">Reported Via</dt> <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> <dd class="text-sm font-medium text-gray-900 capitalize" x-text="ticket.reported_via || '—'"></dd>
@@ -200,20 +204,9 @@
<label class="block text-sm font-medium text-gray-700 mb-1">Current: <span class="font-bold" x-text="ticket.status"></span></label> <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"> <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="">Select new status</option>
<option value="Logged">Logged</option> <template x-for="target in availableTransitions" :key="target">
<option value="Triage">Triage</option> <option :value="target" x-text="target"></option>
<option value="Assigned">Assigned</option> </template>
<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>
</select> </select>
</div> </div>
<div> <div>
@@ -298,6 +291,7 @@
statusForm: { newStatus: '', note: '' }, statusForm: { newStatus: '', note: '' },
statusSubmitting: false, statusSubmitting: false,
statusError: '', statusError: '',
availableTransitions: [],
// Assign form // Assign form
assignForm: { technicianId: '' }, assignForm: { technicianId: '' },
@@ -311,6 +305,7 @@
async init() { async init() {
await this.loadTicket(); await this.loadTicket();
await this.loadTransitions();
if (app().isFM || app().isAdmin) { if (app().isFM || app().isAdmin) {
await this.loadTechnicians(); await this.loadTechnicians();
} }
@@ -327,18 +322,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() { async loadTechnicians() {
// Users list not exposed via API directly, so use a known list try {
// In a real system we'd have GET /api/users const users = await app().apiGet('/api/auth/users');
this.technicians = [ // Assign dropdown should only offer Tech-role staff
{ id: 9, full_name: 'Prosper' }, this.technicians = (users || []).filter(u => u.role === 'Tech');
{ id: 10, full_name: 'Sam' }, } catch (e) {
{ id: 11, full_name: 'Steven' }, console.error('Technicians load error', e);
{ id: 12, full_name: 'Junior (Samuel)' }, this.technicians = [];
{ id: 13, full_name: 'Francis' }, }
{ id: 14, full_name: 'Desmond Afful' },
{ id: 15, full_name: 'Afful' },
];
}, },
async submitStatusUpdate() { async submitStatusUpdate() {
@@ -354,6 +356,7 @@
const updated = await app().apiPatch(`/api/tickets/${this.ticketId}`, payload); const updated = await app().apiPatch(`/api/tickets/${this.ticketId}`, payload);
this.ticket = updated; this.ticket = updated;
await this.loadTransitions();
this.showStatusModal = false; this.showStatusModal = false;
this.statusForm = { newStatus: '', note: '' }; this.statusForm = { newStatus: '', note: '' };
app().showToast('Status updated', 'success'); app().showToast('Status updated', 'success');
@@ -376,6 +379,7 @@
assigned_to: parseInt(this.assignForm.technicianId) assigned_to: parseInt(this.assignForm.technicianId)
}); });
this.ticket = updated; this.ticket = updated;
await this.loadTransitions();
this.showAssignModal = false; this.showAssignModal = false;
app().showToast('Technician assigned', 'success'); app().showToast('Technician assigned', 'success');
} catch (e) { } catch (e) {
+3 -2
View File
@@ -38,6 +38,7 @@
<option value="Wahab Review">Wahab Review</option> <option value="Wahab Review">Wahab Review</option>
<option value="Closed">Closed</option> <option value="Closed">Closed</option>
<option value="Reopened">Reopened</option> <option value="Reopened">Reopened</option>
<option value="Cancelled">Cancelled</option>
</select> </select>
</div> </div>
<div> <div>
@@ -142,7 +143,7 @@
<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="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="formatDate(ticket.created_at)"></td>
<td class="px-5 py-3"> <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 x-text="formatDateShort(ticket.sla_deadline)"></span>
</span> </span>
<span x-show="!ticket.sla_deadline" class="text-xs text-gray-300"></span> <span x-show="!ticket.sla_deadline" class="text-xs text-gray-300"></span>
@@ -190,7 +191,7 @@
<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="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="formatDate(ticket.created_at)"></td>
<td class="px-5 py-3"> <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 x-text="formatDateShort(ticket.sla_deadline)"></span>
</span> </span>
<span x-show="!ticket.sla_deadline" class="text-xs text-gray-300"></span> <span x-show="!ticket.sla_deadline" class="text-xs text-gray-300"></span>
+10
View File
@@ -363,6 +363,16 @@
this.submitting = false; this.submitting = false;
return; return;
} }
if (!this.form.category_main && !this.form.category_id) {
this.error = 'Please select a Category — every issue needs one for correct routing and SLA.';
this.submitting = false;
return;
}
if (this.mode === 'standard' && !this.form.priority) {
this.error = 'Please select a Priority — without one the ticket gets no SLA deadline.';
this.submitting = false;
return;
}
// Create the ticket // Create the ticket
const payload = { const payload = {
+290
View File
@@ -0,0 +1,290 @@
"""Tests for the Wahab demo-prep hardening batch.
Anchors:
* ``Cancelled`` is a terminal status reachable from every active state, is
excluded from SLA breach reporting, and shows up in the status pickers.
* Customer ``phone`` collected on the new-issue form is persisted (it used to
be silently dropped).
* ``GET /api/tickets/{id}`` returns nested ``unit``/``category`` objects so the
detail page stops rendering "" for Unit/Property/Category.
* ``DELETE /api/tickets/{id}`` is admin-only and removes ticket children.
* ``GET /api/auth/users`` powers the assign-technician dropdown.
* Ticket numbering uses max+1, so deleting tickets never re-issues a number.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
async def _login(client, email="wahab@denya.com", password="denya123") -> str:
resp = await client.post(
"/api/auth/login",
json={"email": email, "password": password},
)
assert resp.status_code == 200, resp.text
return resp.json()["access_token"]
async def _create_ticket(client, token: str, **overrides) -> dict:
payload = {
"unit_id": 2,
"category_id": 3,
"priority": "medium",
"reporter": "Demo Prep Test",
"reported_via": "walk-in",
"description": "hardening batch test ticket",
**overrides,
}
resp = await client.post(
"/api/tickets",
json=payload,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 201, resp.text
return resp.json()
# ── Cancelled status ──────────────────────────────────────────────────
async def test_cancelled_is_terminal(client):
"""A cancelled ticket has no valid next status."""
from app.services.ticket import VALID_TRANSITIONS
assert "Cancelled" in VALID_TRANSITIONS
assert VALID_TRANSITIONS["Cancelled"] == []
async def test_cancelled_reachable_from_active_states(client):
"""Logged/Triage/In Progress → Cancelled are all valid transitions."""
from app.services.ticket import VALID_TRANSITIONS
for state in (
"New", "Logged", "Triage", "Assigned", "Accepted", "Travelling", "On Site",
"In Progress", "Waiting Parts", "Escalated", "On-Field Verification",
"Wahab Review", "Reopened",
):
assert "Cancelled" in VALID_TRANSITIONS[state], f"{state} should allow Cancelled"
async def test_cancel_ticket_via_api(client):
"""PATCH status=Cancelled works end-to-end and lands in the timeline."""
token = await _login(client)
ticket = await _create_ticket(client, token, description="cancel me")
resp = await client.patch(
f"/api/tickets/{ticket['id']}",
json={"status": "Cancelled", "note": "demo sample"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["status"] == "Cancelled"
assert data["timeline"][-1]["to_status"] == "Cancelled"
async def test_cancelled_ticket_not_sla_breached(client):
"""A cancelled ticket must not report SLA breach (like Closed/Completed)."""
token = await _login(client)
ticket = await _create_ticket(client, token, priority="urgent", description="cancel sla test")
# Force the SLA deadline into the past by patching priority (recomputes from now),
# then cancel; the breach flags must be False either way.
resp = await client.patch(
f"/api/tickets/{ticket['id']}",
json={"status": "Cancelled"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
sla = resp.json()["sla_status"]
assert sla["resolution_breached"] is False
assert sla["response_breached"] is False
# ── Phone persistence ─────────────────────────────────────────────────
async def test_phone_persisted_on_create(client):
"""Customer phone sent at creation is stored and returned."""
token = await _login(client)
ticket = await _create_ticket(client, token, phone="+233123456789", description="phone test")
assert ticket["phone"] == "+233123456789"
detail = await client.get(f"/api/tickets/{ticket['id']}")
assert detail.json()["phone"] == "+233123456789"
# ── Nested unit/category in detail ────────────────────────────────────
async def test_ticket_detail_returns_unit_and_category(client):
"""TicketOut includes nested unit/category objects (detail page fix)."""
token = await _login(client)
ticket = await _create_ticket(client, token, unit_id=2, category_id=3)
resp = await client.get(f"/api/tickets/{ticket['id']}")
assert resp.status_code == 200
data = resp.json()
assert data["unit"] is not None
assert data["unit"]["apartment_code"]
assert data["category"] is not None
assert data["category"]["name"]
# ── Admin-only DELETE ─────────────────────────────────────────────────
async def test_delete_ticket_requires_admin(client):
"""A non-admin (CS Rep) gets 403 on DELETE."""
token = await _login(client, email="bella@denya.com") # CS Rep
ticket = await _create_ticket(client, token, description="delete perm test")
resp = await client.delete(
f"/api/tickets/{ticket['id']}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 403
async def test_delete_ticket_removes_children(client):
"""Admin DELETE removes the ticket, timeline, and photos."""
token = await _login(client) # Admin/Wahab
ticket = await _create_ticket(client, token, description="delete me")
tid = ticket["id"]
resp = await client.delete(
f"/api/tickets/{tid}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 204
gone = await client.get(f"/api/tickets/{tid}")
assert gone.status_code == 404
from sqlalchemy import func, select
from app.core.database import async_session_factory
from app.models.ticket import TicketTimeline
async with async_session_factory() as session:
count = (await session.execute(
select(func.count(TicketTimeline.id)).where(TicketTimeline.ticket_id == tid)
)).scalar()
assert count == 0
# ── Users endpoint for the assign picker ──────────────────────────────
async def test_users_endpoint_requires_auth(client):
resp = await client.get("/api/auth/users")
assert resp.status_code == 401
async def test_users_endpoint_lists_technicians(client):
token = await _login(client)
resp = await client.get("/api/auth/users", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
users = resp.json()
assert any(u["role"] == "Tech" for u in users)
# Fields the assign dropdown needs
assert {"id", "full_name", "role"} <= set(users[0].keys())
# ── Transitions helper (status dropdown) ─────────────────────────────
async def test_transitions_helper_returns_valid_targets(client):
"""GET /api/tickets/{id}/transitions returns the valid next statuses."""
from app.services.ticket import VALID_TRANSITIONS
token = await _login(client)
ticket = await _create_ticket(client, token, description="transitions helper") # status: Logged
resp = await client.get(f"/api/tickets/{ticket['id']}/transitions")
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["current_status"] == "Logged"
assert set(data["transitions"]) == set(VALID_TRANSITIONS["Logged"])
async def test_transitions_helper_terminal_is_empty(client):
"""A cancelled ticket exposes no selectable next statuses."""
token = await _login(client)
ticket = await _create_ticket(client, token, description="terminal transitions")
resp = await client.patch(
f"/api/tickets/{ticket['id']}",
json={"status": "Cancelled"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = (await client.get(f"/api/tickets/{ticket['id']}/transitions")).json()
assert data["current_status"] == "Cancelled"
assert data["transitions"] == []
# ── Assign auto-advance ───────────────────────────────────────────────
async def _first_tech_id(client, token: str) -> int:
users = (await client.get("/api/auth/users", headers={"Authorization": f"Bearer {token}"})).json()
return next(u["id"] for u in users if u["role"] == "Tech")
async def test_assign_auto_advances_to_assigned(client):
"""Assigning a pre-Assigned ticket advances it to Assigned with a timeline entry."""
token = await _login(client)
ticket = await _create_ticket(client, token, description="assign advance") # status: Logged
tech_id = await _first_tech_id(client, token)
resp = await client.patch(
f"/api/tickets/{ticket['id']}",
json={"assigned_to": tech_id},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["status"] == "Assigned"
assert data["assigned_to"] == tech_id
assert data["timeline"][-1]["to_status"] == "Assigned"
async def test_assign_does_not_regress_past_assigned(client):
"""Re-assigning a ticket already past Assigned leaves its status untouched."""
token = await _login(client)
ticket = await _create_ticket(client, token, description="no regression")
for step in ("Triage", "Assigned", "Accepted"):
resp = await client.patch(
f"/api/tickets/{ticket['id']}",
json={"status": step},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text
users = (await client.get("/api/auth/users", headers={"Authorization": f"Bearer {token}"})).json()
techs = [u["id"] for u in users if u["role"] == "Tech"]
other_tech = techs[1] if len(techs) > 1 else techs[0]
resp = await client.patch(
f"/api/tickets/{ticket['id']}",
json={"assigned_to": other_tech},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == "Accepted"
# ── Ticket numbering survives deletions ───────────────────────────────
async def test_ticket_number_uses_max_plus_one(client, seed_tickets):
"""After deleting a middle ticket, numbering must skip over surviving numbers."""
from sqlalchemy import delete as sa_delete
from app.core.database import async_session_factory
from app.models.ticket import Ticket
token = await _login(client)
first = await _create_ticket(client, token, description="numbering a") # …001
second = await _create_ticket(client, token, description="numbering b") # …002
third = await _create_ticket(client, token, description="numbering c") # …003
async with async_session_factory() as session:
# Delete the middle ticket; count+1 numbering would now re-issue …003
# (colliding with the surviving third ticket).
await session.execute(sa_delete(Ticket).where(Ticket.id == second["id"]))
await session.commit()
fourth = await _create_ticket(client, token, description="numbering d")
def suffix(tn: str) -> int:
return int(tn.rsplit("-", 1)[1])
survivors = {suffix(first["ticket_number"]), suffix(third["ticket_number"])}
assert suffix(fourth["ticket_number"]) not in survivors
assert suffix(fourth["ticket_number"]) > max(survivors)