Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f15ca5457 | ||
|
|
30c0c01c59 | ||
|
|
bf361d76bc | ||
|
|
6b92c9098c | ||
|
|
237284b012 | ||
|
|
1dd88a141e | ||
|
|
ffed595dbd |
@@ -52,6 +52,7 @@ Users, units, and categories are auto-seeded on first startup via lifespan hook:
|
||||
| POST | `/api/auth/refresh` | Token | Refresh tokens |
|
||||
| GET | `/api/auth/me` | Bearer | Current user |
|
||||
| GET | `/api/auth/admin-only` | Admin/Jerome, Admin/Wahab | RBAC demo endpoint |
|
||||
| GET | `/api/auth/users` | Bearer | List users (id, name, role) for the assign-technician picker |
|
||||
| POST | `/api/whatsapp/mock` | No | Mock WhatsApp |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| PATCH | `/api/tickets/{id}` | Bearer | Update ticket (validates status transitions) |
|
||||
| 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) |
|
||||
@@ -91,8 +94,11 @@ Frontend: Alpine.js (CDN) + Tailwind CSS (CDN). Auth state in localStorage. Role
|
||||
|
||||
## 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
|
||||
### 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.
|
||||
|
||||
|
||||
+214
@@ -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.*
|
||||
@@ -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,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')
|
||||
@@ -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
|
||||
|
||||
+10
@@ -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")
|
||||
)
|
||||
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(
|
||||
text(
|
||||
"UPDATE categories SET name = 'Missing Item' "
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.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)
|
||||
async def register(
|
||||
body: RegisterRequest,
|
||||
|
||||
+43
-1
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -12,7 +13,7 @@ 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.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
|
||||
@@ -32,6 +33,8 @@ 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"])
|
||||
|
||||
# Ensure uploads directory exists
|
||||
@@ -224,6 +227,19 @@ async def list_tickets(
|
||||
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)
|
||||
async def get_ticket(
|
||||
ticket_id: int,
|
||||
@@ -250,6 +266,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_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) ───────────────────────
|
||||
@router.post("/{ticket_id}/status", response_model=TicketOut)
|
||||
async def change_ticket_status(
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
|
||||
@@ -54,6 +54,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
|
||||
@@ -95,6 +96,7 @@ 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
|
||||
@@ -113,6 +115,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
|
||||
|
||||
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
|
||||
+5
-2
@@ -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,
|
||||
|
||||
+69
-23
@@ -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(
|
||||
@@ -110,6 +122,7 @@ 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"),
|
||||
@@ -223,6 +236,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, [])
|
||||
@@ -283,9 +297,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,
|
||||
@@ -296,7 +326,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])
|
||||
@@ -310,3 +340,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
|
||||
|
||||
|
||||
|
||||
@@ -51,6 +51,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; }
|
||||
@@ -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';
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
@@ -270,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,
|
||||
@@ -280,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`;
|
||||
|
||||
@@ -188,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,
|
||||
@@ -230,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);
|
||||
|
||||
@@ -222,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
|
||||
|
||||
@@ -121,6 +121,10 @@
|
||||
<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>
|
||||
@@ -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>
|
||||
<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 +291,7 @@
|
||||
statusForm: { newStatus: '', note: '' },
|
||||
statusSubmitting: false,
|
||||
statusError: '',
|
||||
availableTransitions: [],
|
||||
|
||||
// Assign form
|
||||
assignForm: { technicianId: '' },
|
||||
@@ -311,6 +305,7 @@
|
||||
|
||||
async init() {
|
||||
await this.loadTicket();
|
||||
await this.loadTransitions();
|
||||
if (app().isFM || app().isAdmin) {
|
||||
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() {
|
||||
// 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 +356,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 +379,7 @@
|
||||
assigned_to: parseInt(this.assignForm.technicianId)
|
||||
});
|
||||
this.ticket = updated;
|
||||
await this.loadTransitions();
|
||||
this.showAssignModal = false;
|
||||
app().showToast('Technician assigned', 'success');
|
||||
} catch (e) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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="formatDate(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>
|
||||
@@ -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="formatDate(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>
|
||||
|
||||
@@ -363,6 +363,16 @@
|
||||
this.submitting = false;
|
||||
return;
|
||||
}
|
||||
if (!this.form.category_main && !this.form.category_id) {
|
||||
this.error = 'Please select a Category — every issue needs one for correct routing and SLA.';
|
||||
this.submitting = false;
|
||||
return;
|
||||
}
|
||||
if (this.mode === 'standard' && !this.form.priority) {
|
||||
this.error = 'Please select a Priority — without one the ticket gets no SLA deadline.';
|
||||
this.submitting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the ticket
|
||||
const payload = {
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user