Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f15ca5457 |
@@ -94,11 +94,6 @@ Frontend: Alpine.js (CDN) + Tailwind CSS (CDN). Auth state in localStorage. Role
|
|||||||
|
|
||||||
## Ticket System (Sprint 2)
|
## Ticket System (Sprint 2)
|
||||||
|
|
||||||
- `reported_at` (nullable DateTime, alembic `d5e0f2a1c3b4`) records a ticket's original reported date;
|
|
||||||
`TicketCreate.reported_at` lets Admin/Wahab enter backdated tickets that stay active. It defaults to
|
|
||||||
now when omitted (migration backfilled existing rows from `created_at`). SLA deadlines run from
|
|
||||||
`created_at`, not `reported_at` — backfilling history never instantly breaches a ticket.
|
|
||||||
|
|
||||||
### Status Lifecycle (16 statuses)
|
### 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
|
New → Logged → Triage → Assigned → Accepted → Travelling → On Site → In Progress → Waiting Parts → Escalated → Completed → On-Field Verification → Wahab Review → Closed → Reopened → Cancelled
|
||||||
|
|
||||||
|
|||||||
+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.*
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
"""tickets.reported_at column — original reported date for backdated tickets
|
|
||||||
|
|
||||||
Revision ID: d5e0f2a1c3b4
|
|
||||||
Revises: c4e8f1a2d3b4
|
|
||||||
Create Date: 2026-08-03 00:00:00.000000
|
|
||||||
|
|
||||||
Backdated-ticket support (Wahab demo): one schema change.
|
|
||||||
|
|
||||||
* Add ``tickets.reported_at`` (nullable DateTime) so historical/backfilled
|
|
||||||
tickets keep their true report date instead of inheriting today's
|
|
||||||
``created_at``.
|
|
||||||
* Backfill existing rows with their ``created_at`` value so no ticket shows
|
|
||||||
an empty reported date after the upgrade. The service layer also defaults
|
|
||||||
new tickets without a ``reported_at`` to now, so the column is effectively
|
|
||||||
always populated from here on.
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = 'd5e0f2a1c3b4'
|
|
||||||
down_revision: Union[str, Sequence[str], None] = 'c4e8f1a2d3b4'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Add tickets.reported_at (nullable) and backfill from created_at."""
|
|
||||||
op.add_column(
|
|
||||||
'tickets',
|
|
||||||
sa.Column(
|
|
||||||
'reported_at',
|
|
||||||
sa.DateTime(),
|
|
||||||
nullable=True,
|
|
||||||
comment='Original reported date; backdated/backfilled tickets keep their true report date',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
# Backfill: every existing ticket was reported when it was created.
|
|
||||||
op.execute('UPDATE tickets SET reported_at = created_at WHERE reported_at IS NULL')
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Drop tickets.reported_at."""
|
|
||||||
op.drop_column('tickets', 'reported_at')
|
|
||||||
@@ -38,14 +38,6 @@ async def ensure_legacy_schema(conn) -> None:
|
|||||||
text("ALTER TABLE tickets ADD COLUMN phone VARCHAR(50)")
|
text("ALTER TABLE tickets ADD COLUMN phone VARCHAR(50)")
|
||||||
)
|
)
|
||||||
logger.info("Added missing tickets.phone column (legacy database)")
|
logger.info("Added missing tickets.phone column (legacy database)")
|
||||||
if "reported_at" not in ticket_columns:
|
|
||||||
await conn.execute(
|
|
||||||
text("ALTER TABLE tickets ADD COLUMN reported_at DATETIME")
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
text("UPDATE tickets SET reported_at = created_at WHERE reported_at IS NULL")
|
|
||||||
)
|
|
||||||
logger.info("Added missing tickets.reported_at column (legacy database)")
|
|
||||||
result = await conn.execute(
|
result = await conn.execute(
|
||||||
text(
|
text(
|
||||||
"UPDATE categories SET name = 'Missing Item' "
|
"UPDATE categories SET name = 'Missing Item' "
|
||||||
|
|||||||
@@ -55,11 +55,6 @@ class Ticket(Base):
|
|||||||
customer_rating: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
customer_rating: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
reopen_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
reopen_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
reported_at: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
nullable=True,
|
|
||||||
comment="Original reported date. Backdated/backfilled tickets keep their true report date; NULL falls back to created_at.",
|
|
||||||
)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ class TicketCreate(BaseModel):
|
|||||||
assigned_to: int | None = None
|
assigned_to: int | None = None
|
||||||
customer_name: str | None = None
|
customer_name: str | None = None
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
reported_at: datetime | None = None # original report date for backdated/backfilled tickets; defaults to now when omitted
|
|
||||||
|
|
||||||
|
|
||||||
class TicketUpdate(BaseModel):
|
class TicketUpdate(BaseModel):
|
||||||
@@ -101,7 +100,6 @@ class TicketBrief(BaseModel):
|
|||||||
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
|
||||||
reported_at: datetime | None = None
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -114,10 +114,6 @@ async def create_ticket(
|
|||||||
ticket_number = await _generate_ticket_number(db)
|
ticket_number = await _generate_ticket_number(db)
|
||||||
priority = data.get("priority")
|
priority = data.get("priority")
|
||||||
sla_deadline = compute_sla_deadline(priority) if priority else None
|
sla_deadline = compute_sla_deadline(priority) if priority else None
|
||||||
# Original report date: backdated/backfilled tickets keep their true date;
|
|
||||||
# when omitted the ticket is considered reported right now. The SLA clock
|
|
||||||
# is unchanged — deadlines run from creation time, not the reported date.
|
|
||||||
reported_at = data.get("reported_at") or datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
ticket = Ticket(
|
ticket = Ticket(
|
||||||
ticket_number=ticket_number,
|
ticket_number=ticket_number,
|
||||||
@@ -130,7 +126,6 @@ async def create_ticket(
|
|||||||
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"),
|
||||||
reported_at=reported_at,
|
|
||||||
sla_deadline=sla_deadline,
|
sla_deadline=sla_deadline,
|
||||||
)
|
)
|
||||||
db.add(ticket)
|
db.add(ticket)
|
||||||
|
|||||||
@@ -23,11 +23,7 @@
|
|||||||
<span class="px-3 py-1 rounded-full text-sm font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span>
|
<span class="px-3 py-1 rounded-full text-sm font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span>
|
||||||
<span class="px-3 py-1 rounded text-sm font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span>
|
<span class="px-3 py-1 rounded text-sm font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-500 mt-1">Created <span x-text="formatDate(ticket.created_at)"></span>
|
<p class="text-gray-500 mt-1">Created <span x-text="formatDate(ticket.created_at)"></span></p>
|
||||||
<template x-if="ticket.reported_at && formatDateShort(ticket.reported_at) !== formatDateShort(ticket.created_at)">
|
|
||||||
<span> · Reported <span class="font-medium text-gray-600" x-text="formatDateShort(ticket.reported_at)"></span></span>
|
|
||||||
</template>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<button @click="showStatusModal = true" class="px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition text-sm font-medium">Update Status</button>
|
<button @click="showStatusModal = true" class="px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition text-sm font-medium">Update Status</button>
|
||||||
@@ -133,10 +129,6 @@
|
|||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
|
||||||
<dt class="text-sm text-gray-500">Reported</dt>
|
|
||||||
<dd class="text-sm font-medium text-gray-900" x-text="ticket.reported_at ? formatDateShort(ticket.reported_at) : formatDateShort(ticket.created_at)"></dd>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<dt class="text-sm text-gray-500">Priority</dt>
|
<dt class="text-sm text-gray-500">Priority</dt>
|
||||||
<dd><span class="px-2 py-0.5 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></dd>
|
<dd><span class="px-2 py-0.5 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></dd>
|
||||||
|
|||||||
@@ -130,7 +130,6 @@
|
|||||||
<th class="px-5 py-3 text-left cursor-pointer hover:text-gray-900" @click="sortBy('created_at')">
|
<th class="px-5 py-3 text-left cursor-pointer hover:text-gray-900" @click="sortBy('created_at')">
|
||||||
Created <span x-show="sortField === 'created_at'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
Created <span x-show="sortField === 'created_at'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
|
||||||
</th>
|
</th>
|
||||||
<th class="px-5 py-3 text-left">Reported</th>
|
|
||||||
<th class="px-5 py-3 text-left">SLA</th>
|
<th class="px-5 py-3 text-left">SLA</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -143,7 +142,6 @@
|
|||||||
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
|
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
|
||||||
<td class="px-5 py-3 text-gray-500 text-xs" x-text="ticket.assigned_technician_name || '—'"></td>
|
<td class="px-5 py-3 text-gray-500 text-xs" x-text="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 text-gray-500 text-xs" x-text="formatDateShort(ticket.reported_at || 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','Cancelled'].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>
|
||||||
@@ -153,7 +151,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr x-show="!tickets.length && !loading">
|
<tr x-show="!tickets.length && !loading">
|
||||||
<td colspan="8" class="px-5 py-16 text-center text-gray-400">
|
<td colspan="7" class="px-5 py-16 text-center text-gray-400">
|
||||||
<svg class="w-12 h-12 mx-auto text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
|
<svg class="w-12 h-12 mx-auto text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
|
||||||
No tickets match your filters
|
No tickets match your filters
|
||||||
</td>
|
</td>
|
||||||
@@ -180,7 +178,6 @@
|
|||||||
<th class="px-5 py-3 text-left">Description</th>
|
<th class="px-5 py-3 text-left">Description</th>
|
||||||
<th class="px-5 py-3 text-left">Assigned To</th>
|
<th class="px-5 py-3 text-left">Assigned To</th>
|
||||||
<th class="px-5 py-3 text-left">Created</th>
|
<th class="px-5 py-3 text-left">Created</th>
|
||||||
<th class="px-5 py-3 text-left">Reported</th>
|
|
||||||
<th class="px-5 py-3 text-left">SLA</th>
|
<th class="px-5 py-3 text-left">SLA</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -193,7 +190,6 @@
|
|||||||
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
|
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
|
||||||
<td class="px-5 py-3 text-gray-500 text-xs" x-text="ticket.assigned_technician_name || '—'"></td>
|
<td class="px-5 py-3 text-gray-500 text-xs" x-text="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 text-gray-500 text-xs" x-text="formatDateShort(ticket.reported_at || 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','Cancelled'].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>
|
||||||
|
|||||||
@@ -154,15 +154,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Reported date (backdating support) -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Reported Date</label>
|
|
||||||
<input type="date" x-model="form.reported_date" :max="todayStr" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none">
|
|
||||||
<p class="mt-1 text-xs text-gray-400">Defaults to today. Use a past date when entering an old/backlogged issue — it stays active in the normal workflow.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Photo Upload -->
|
<!-- Photo Upload -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Photos (Before)</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Photos (Before)</label>
|
||||||
@@ -214,10 +205,8 @@
|
|||||||
priority: '',
|
priority: '',
|
||||||
description: '',
|
description: '',
|
||||||
reporter: '',
|
reporter: '',
|
||||||
reported_via: '',
|
reported_via: ''
|
||||||
reported_date: ''
|
|
||||||
},
|
},
|
||||||
todayStr: '',
|
|
||||||
priorityAuto: false,
|
priorityAuto: false,
|
||||||
categories: [],
|
categories: [],
|
||||||
subCategories: [],
|
subCategories: [],
|
||||||
@@ -234,14 +223,6 @@
|
|||||||
async init() {
|
async init() {
|
||||||
await this.loadCategories();
|
await this.loadCategories();
|
||||||
await this.loadUnits();
|
await this.loadUnits();
|
||||||
// Default reported date to today (local), allow backdating via the date picker
|
|
||||||
this.todayStr = this.localDateStr(new Date());
|
|
||||||
if (!this.form.reported_date) this.form.reported_date = this.todayStr;
|
|
||||||
},
|
|
||||||
|
|
||||||
localDateStr(d) {
|
|
||||||
const offset = d.getTimezoneOffset();
|
|
||||||
return new Date(d.getTime() - offset * 60000).toISOString().slice(0, 10);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Report mode ──────────────────────────────────────────
|
// ── Report mode ──────────────────────────────────────────
|
||||||
@@ -403,7 +384,6 @@
|
|||||||
unit_id: this.form.unit.id,
|
unit_id: this.form.unit.id,
|
||||||
customer_name: this.form.customer_name || null,
|
customer_name: this.form.customer_name || null,
|
||||||
phone: this.form.phone || null,
|
phone: this.form.phone || null,
|
||||||
reported_at: this.form.reported_date || null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ticket = await app().apiPost('/api/tickets', payload);
|
const ticket = await app().apiPost('/api/tickets', payload);
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
"""Tests for backdated reported-date support (Wahab demo).
|
|
||||||
|
|
||||||
Anchors:
|
|
||||||
* A ticket created with a past ``reported_at`` persists that date and it is
|
|
||||||
exposed on list + detail responses — this is how Wahab enters old tickets
|
|
||||||
that stay active in the normal workflow.
|
|
||||||
* A ticket created without ``reported_at`` defaults to "now", so existing
|
|
||||||
create behavior is unchanged.
|
|
||||||
* The reported date is metadata only: SLA deadlines still run from creation
|
|
||||||
time and no age/backdate restriction kicks in.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.asyncio
|
|
||||||
|
|
||||||
|
|
||||||
def _naive(iso: str) -> datetime:
|
|
||||||
"""Parse an ISO datetime and strip any tz offset for safe comparison."""
|
|
||||||
dt = datetime.fromisoformat(iso)
|
|
||||||
return dt.replace(tzinfo=None) if dt.tzinfo is not None else dt
|
|
||||||
|
|
||||||
|
|
||||||
async def _login(client, email="wahab@denya.com", password="denya123") -> str:
|
|
||||||
resp = await client.post(
|
|
||||||
"/api/auth/login",
|
|
||||||
json={"email": email, "password": password},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200, resp.text
|
|
||||||
return resp.json()["access_token"]
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_ticket(client, token: str, **overrides) -> dict:
|
|
||||||
payload = {
|
|
||||||
"unit_id": 2,
|
|
||||||
"category_id": 3,
|
|
||||||
"priority": "medium",
|
|
||||||
"reporter": "Backdate Test",
|
|
||||||
"reported_via": "walk-in",
|
|
||||||
"description": "backdate test ticket",
|
|
||||||
**overrides,
|
|
||||||
}
|
|
||||||
resp = await client.post(
|
|
||||||
"/api/tickets",
|
|
||||||
json=payload,
|
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 201, resp.text
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_with_backdated_reported_at_persists(client):
|
|
||||||
"""Wahab (Admin/Wahab) can enter an old ticket and its date sticks."""
|
|
||||||
token = await _login(client)
|
|
||||||
ticket = await _create_ticket(
|
|
||||||
client,
|
|
||||||
token,
|
|
||||||
reported_at="2026-07-20",
|
|
||||||
description="Old plumbing issue reported weeks ago",
|
|
||||||
)
|
|
||||||
assert ticket["reported_at"] is not None
|
|
||||||
assert ticket["reported_at"].startswith("2026-07-20")
|
|
||||||
|
|
||||||
# Still an active ticket in the normal workflow — no age restriction.
|
|
||||||
assert ticket["status"] in {"New", "Logged"}
|
|
||||||
|
|
||||||
# Detail endpoint exposes the reported date.
|
|
||||||
detail = await client.get(f"/api/tickets/{ticket['id']}")
|
|
||||||
assert detail.status_code == 200
|
|
||||||
assert detail.json()["reported_at"].startswith("2026-07-20")
|
|
||||||
|
|
||||||
# List endpoint exposes it too.
|
|
||||||
listing = await client.get("/api/tickets")
|
|
||||||
assert listing.status_code == 200
|
|
||||||
listed = next(t for t in listing.json()["items"] if t["id"] == ticket["id"])
|
|
||||||
assert listed["reported_at"].startswith("2026-07-20")
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_without_reported_at_defaults_to_now(client):
|
|
||||||
"""Omitting reported_at behaves exactly as before: reported == created."""
|
|
||||||
token = await _login(client)
|
|
||||||
ticket = await _create_ticket(client, token, description="normal today ticket")
|
|
||||||
assert ticket["reported_at"] is not None
|
|
||||||
reported = _naive(ticket["reported_at"])
|
|
||||||
created = _naive(ticket["created_at"])
|
|
||||||
assert abs((reported - created).total_seconds()) < 60
|
|
||||||
|
|
||||||
|
|
||||||
async def test_reported_at_does_not_shift_sla_deadline(client):
|
|
||||||
"""SLA computation is unchanged: deadlines run from creation time."""
|
|
||||||
token = await _login(client)
|
|
||||||
ticket = await _create_ticket(
|
|
||||||
client,
|
|
||||||
token,
|
|
||||||
priority="urgent",
|
|
||||||
reported_at="2026-01-01",
|
|
||||||
description="old urgent ticket",
|
|
||||||
)
|
|
||||||
assert ticket["sla_deadline"] is not None
|
|
||||||
created = _naive(ticket["created_at"])
|
|
||||||
deadline = _naive(ticket["sla_deadline"])
|
|
||||||
hours = (deadline - created).total_seconds() / 3600
|
|
||||||
assert 3.5 <= hours <= 4.5 # urgent → 4 h resolution window from creation
|
|
||||||
|
|
||||||
|
|
||||||
async def test_reported_at_round_trips_full_datetime(client):
|
|
||||||
"""A precise datetime (not just a date) survives the round trip."""
|
|
||||||
token = await _login(client)
|
|
||||||
reported = "2026-07-20T14:30:00"
|
|
||||||
ticket = await _create_ticket(client, token, reported_at=reported, description="datetime round trip")
|
|
||||||
assert ticket["reported_at"] is not None
|
|
||||||
parsed = _naive(ticket["reported_at"])
|
|
||||||
assert parsed.date().isoformat() == "2026-07-20"
|
|
||||||
assert parsed.hour == 14 and parsed.minute == 30
|
|
||||||
Reference in New Issue
Block a user