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