diff --git a/AGENTS.md b/AGENTS.md
index a3cdd37..59691e5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/alembic/versions/d5e0f2a1c3b4_tickets_reported_at.py b/alembic/versions/d5e0f2a1c3b4_tickets_reported_at.py
new file mode 100644
index 0000000..fbaa617
--- /dev/null
+++ b/alembic/versions/d5e0f2a1c3b4_tickets_reported_at.py
@@ -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')
diff --git a/app/models/ticket.py b/app/models/ticket.py
index 919bdc5..17882aa 100644
--- a/app/models/ticket.py
+++ b/app/models/ticket.py
@@ -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,
diff --git a/app/schemas/ticket.py b/app/schemas/ticket.py
index e3d9ae0..86172ba 100644
--- a/app/schemas/ticket.py
+++ b/app/schemas/ticket.py
@@ -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
diff --git a/app/services/ticket.py b/app/services/ticket.py
index a8cf8da..00fd6d1 100644
--- a/app/services/ticket.py
+++ b/app/services/ticket.py
@@ -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)
diff --git a/app/templates/tickets/detail.html b/app/templates/tickets/detail.html
index ffdce9d..872a844 100644
--- a/app/templates/tickets/detail.html
+++ b/app/templates/tickets/detail.html
@@ -23,7 +23,11 @@
-
Created
+ Created
+
+ · Reported
+
+
@@ -129,6 +133,10 @@
Reported Via
+
+
Reported
+
+
Priority
diff --git a/app/templates/tickets/list.html b/app/templates/tickets/list.html
index d906478..c8bde85 100644
--- a/app/templates/tickets/list.html
+++ b/app/templates/tickets/list.html
@@ -130,6 +130,7 @@
Created
|
+
Reported |
SLA |
@@ -142,6 +143,7 @@
|
|
|
+
|
@@ -151,7 +153,7 @@
- |
+ |
No tickets match your filters
|
@@ -178,6 +180,7 @@
Description |
Assigned To |
Created |
+ Reported |
SLA |
@@ -190,6 +193,7 @@
| |
|
|
+
|
diff --git a/app/templates/tickets/new.html b/app/templates/tickets/new.html
index d29b945..764b758 100644
--- a/app/templates/tickets/new.html
+++ b/app/templates/tickets/new.html
@@ -154,6 +154,15 @@
+
+
+
+
+
+ Defaults to today. Use a past date when entering an old/backlogged issue — it stays active in the normal workflow.
+
+
+
@@ -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);
diff --git a/tests/test_backdated_reported_date.py b/tests/test_backdated_reported_date.py
new file mode 100644
index 0000000..5e04661
--- /dev/null
+++ b/tests/test_backdated_reported_date.py
@@ -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
|