Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4afdc36765 | ||
|
|
3920cf14a1 | ||
|
|
01e28bbcc7 | ||
|
|
ef2297ea87 | ||
|
|
a9c17d0703 | ||
|
|
5560496653 | ||
|
|
7fab1ede51 | ||
|
|
3dc383e62d |
@@ -31,6 +31,12 @@ class Settings(BaseSettings):
|
|||||||
# ── CORS ─────────────────────────────────────────────────────────
|
# ── CORS ─────────────────────────────────────────────────────────
|
||||||
CORS_ORIGINS: str = "*"
|
CORS_ORIGINS: str = "*"
|
||||||
|
|
||||||
|
# ── WhatsApp ─────────────────────────────────────────────────────
|
||||||
|
WHATSAPP_PHONE_NUMBER_ID: str = ""
|
||||||
|
WHATSAPP_ACCESS_TOKEN: str = ""
|
||||||
|
WHATSAPP_VERIFY_TOKEN: str = ""
|
||||||
|
META_GRAPH_BASE: str = "https://graph.facebook.com/v18.0"
|
||||||
|
|
||||||
# ── Paths ────────────────────────────────────────────────────────
|
# ── Paths ────────────────────────────────────────────────────────
|
||||||
BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
|
BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ class Ticket(Base):
|
|||||||
photos = relationship("TicketPhoto", back_populates="ticket")
|
photos = relationship("TicketPhoto", back_populates="ticket")
|
||||||
escalations = relationship("Escalation", back_populates="ticket")
|
escalations = relationship("Escalation", back_populates="ticket")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def assigned_technician_name(self) -> str | None:
|
||||||
|
return self.assigned_technician.full_name if self.assigned_technician else None
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<Ticket {self.ticket_number} ({self.status})>"
|
return f"<Ticket {self.ticket_number} ({self.status})>"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""WhatsApp log model for mock endpoint."""
|
"""WhatsApp log model for inbound webhook messages."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -14,10 +14,12 @@ class WhatsAppLog(Base):
|
|||||||
__tablename__ = "whatsapp_log"
|
__tablename__ = "whatsapp_log"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
command: Mapped[str] = mapped_column(Text, nullable=False)
|
from_number: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
from_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
message_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
wa_message_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
ticket_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
ticket_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
ticket_number: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||||
received_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
received_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<WhatsAppLog {self.id}: {self.command[:50]}>"
|
return f"<WhatsAppLog {self.id}: from={self.from_number} ticket={self.ticket_number}>"
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.core.database import get_db
|
|||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user
|
||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
from app.models.ticket import Ticket, TicketPhoto
|
from app.models.ticket import Ticket, TicketPhoto
|
||||||
|
from app.models.unit import Unit
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.ticket import (
|
from app.schemas.ticket import (
|
||||||
CategoryOut,
|
CategoryOut,
|
||||||
@@ -27,6 +28,7 @@ from app.schemas.ticket import (
|
|||||||
TicketOut,
|
TicketOut,
|
||||||
TicketPhotoOut,
|
TicketPhotoOut,
|
||||||
TicketUpdate,
|
TicketUpdate,
|
||||||
|
UnitOut,
|
||||||
)
|
)
|
||||||
from app.services import ticket as ticket_service
|
from app.services import ticket as ticket_service
|
||||||
from app.services.sla import get_sla_status
|
from app.services.sla import get_sla_status
|
||||||
@@ -38,6 +40,20 @@ UPLOADS_DIR = settings.BASE_DIR / "uploads"
|
|||||||
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Units ──────────────────────────────────────────────────────────
|
||||||
|
@router.get("/units", response_model=list[UnitOut])
|
||||||
|
async def list_units(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
property_filter: str | None = Query(None, alias="property"),
|
||||||
|
) -> list[Unit]:
|
||||||
|
"""List all units, optionally filtered by property (East/West)."""
|
||||||
|
query = select(Unit).order_by(Unit.apartment_code)
|
||||||
|
if property_filter:
|
||||||
|
query = query.where(Unit.property == property_filter)
|
||||||
|
result = await db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
# ── Categories ──────────────────────────────────────────────────────
|
# ── Categories ──────────────────────────────────────────────────────
|
||||||
async def _build_category_tree(db: AsyncSession, parent_id: int | None = None) -> list[CategoryTreeOut]:
|
async def _build_category_tree(db: AsyncSession, parent_id: int | None = None) -> list[CategoryTreeOut]:
|
||||||
"""Build a nested category tree."""
|
"""Build a nested category tree."""
|
||||||
|
|||||||
+134
-13
@@ -1,44 +1,165 @@
|
|||||||
"""Mock WhatsApp endpoint for testing command parsing."""
|
"""WhatsApp webhook handler — Meta Graph API integration."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.models.whatsapp_log import WhatsAppLog
|
from app.models.whatsapp_log import WhatsAppLog
|
||||||
from app.schemas.whatsapp import MockWhatsAppLogEntry, MockWhatsAppRequest, MockWhatsAppResponse
|
from app.schemas.whatsapp import (
|
||||||
|
MetaWebhookRequest,
|
||||||
|
MockWhatsAppLogEntry,
|
||||||
|
WebhookVerificationResponse,
|
||||||
|
WhatsAppReplyResponse,
|
||||||
|
)
|
||||||
|
from app.services.ticket import create_ticket
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/whatsapp", tags=["whatsapp"])
|
router = APIRouter(prefix="/api/whatsapp", tags=["whatsapp"])
|
||||||
|
|
||||||
|
REPLY_TEMPLATE = (
|
||||||
|
"Thank you for contacting Denya OneCare. "
|
||||||
|
"Your ticket number is {ticket_number}. "
|
||||||
|
"We will get back to you soon."
|
||||||
|
)
|
||||||
|
|
||||||
@router.post("/mock", response_model=MockWhatsAppResponse)
|
|
||||||
async def mock_whatsapp(
|
# ── Meta Graph API helpers ──────────────────────────────────────────
|
||||||
body: MockWhatsAppRequest,
|
async def send_whatsapp_reply(
|
||||||
|
to_phone: str,
|
||||||
|
text: str,
|
||||||
|
) -> WhatsAppReplyResponse:
|
||||||
|
"""Send a text message via Meta Graph API."""
|
||||||
|
url = f"{settings.META_GRAPH_BASE}/{settings.WHATSAPP_PHONE_NUMBER_ID}/messages"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {settings.WHATSAPP_ACCESS_TOKEN}",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
"messaging_product": "whatsapp",
|
||||||
|
"to": to_phone,
|
||||||
|
"type": "text",
|
||||||
|
"text": {"body": text},
|
||||||
|
}
|
||||||
|
# Encode nested dict as JSON string for form data (Meta requirement)
|
||||||
|
data["text"] = '{"body":' + f'"{text}"' + "}"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(url, headers=headers, data=data, timeout=15.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
return WhatsAppReplyResponse(success=True, message="Reply sent")
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
logger.error("Failed to send WhatsApp reply: %s", exc)
|
||||||
|
return WhatsAppReplyResponse(success=False, message=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Webhook endpoint ────────────────────────────────────────────────
|
||||||
|
@router.post("/webhook")
|
||||||
|
async def whatsapp_webhook(
|
||||||
|
body: MetaWebhookRequest,
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
) -> MockWhatsAppResponse:
|
hub_verify_token: str | None = Query(None, alias="hub.verify_token"),
|
||||||
"""Accept a mock WhatsApp command and log it."""
|
mode: str | None = Query(None),
|
||||||
|
hub_challenge: str | None = Query(None),
|
||||||
|
) -> dict:
|
||||||
|
"""Handle incoming WhatsApp webhook from Meta."""
|
||||||
|
|
||||||
|
# ── Verification GET request (Meta sends this on webhook setup) ──
|
||||||
|
if mode and hub_challenge:
|
||||||
|
if hub_verify_token != settings.WHATSAPP_VERIFY_TOKEN:
|
||||||
|
return {"error": "Verify token mismatch"}
|
||||||
|
return WebhookVerificationResponse(challenge=hub_challenge).model_dump()
|
||||||
|
|
||||||
|
# ── Process inbound messages ────────────────────────────────────
|
||||||
|
if not body.entry:
|
||||||
|
return {"status": "no entry"}
|
||||||
|
|
||||||
|
for entry in body.entry:
|
||||||
|
if not entry.changes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for change in entry.changes:
|
||||||
|
if not change.message or not change.message.text:
|
||||||
|
logger.info("Non-text message received, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
message_text = change.message.text.text
|
||||||
|
sender = change.message.from_field
|
||||||
|
wa_msg_id = change.message.id or change.id
|
||||||
|
|
||||||
|
# ── Create ticket ──────────────────────────────────────
|
||||||
|
try:
|
||||||
|
ticket = await create_ticket(
|
||||||
|
db,
|
||||||
|
data={
|
||||||
|
"description": message_text,
|
||||||
|
"reporter": sender,
|
||||||
|
"reported_via": "WhatsApp",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to create ticket: %s", exc)
|
||||||
|
# Still log the message even if ticket creation fails
|
||||||
log = WhatsAppLog(
|
log = WhatsAppLog(
|
||||||
command=body.command,
|
from_number=sender,
|
||||||
from_number=body.from_number,
|
message_text=message_text,
|
||||||
ticket_id=body.ticket_id,
|
wa_message_id=wa_msg_id,
|
||||||
|
ticket_id=None,
|
||||||
|
ticket_number=None,
|
||||||
received_at=datetime.now(timezone.utc),
|
received_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
db.add(log)
|
db.add(log)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return MockWhatsAppResponse()
|
return {"status": "ticket creation failed, message logged"}
|
||||||
|
|
||||||
|
# ── Store WhatsApp log ─────────────────────────────────
|
||||||
|
log = WhatsAppLog(
|
||||||
|
from_number=sender,
|
||||||
|
message_text=message_text,
|
||||||
|
wa_message_id=wa_msg_id,
|
||||||
|
ticket_id=ticket.id,
|
||||||
|
ticket_number=ticket.ticket_number,
|
||||||
|
received_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# ── Send auto-reply ────────────────────────────────────
|
||||||
|
reply_text = REPLY_TEMPLATE.format(ticket_number=ticket.ticket_number)
|
||||||
|
reply_result = await send_whatsapp_reply(sender, reply_text)
|
||||||
|
if not reply_result.success:
|
||||||
|
logger.warning(
|
||||||
|
"Auto-reply failed for ticket %s: %s",
|
||||||
|
ticket.ticket_number,
|
||||||
|
reply_result.message,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "processed",
|
||||||
|
"ticket_id": ticket.id,
|
||||||
|
"ticket_number": ticket.ticket_number,
|
||||||
|
"reply_sent": reply_result.success,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"status": "no messages"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Legacy debug endpoint ──────────────────────────────────────────
|
||||||
@router.get("/mock-log", response_model=list[MockWhatsAppLogEntry])
|
@router.get("/mock-log", response_model=list[MockWhatsAppLogEntry])
|
||||||
async def mock_whatsapp_log(
|
async def mock_whatsapp_log(
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
) -> list[MockWhatsAppLogEntry]:
|
) -> list[MockWhatsAppLogEntry]:
|
||||||
"""Return recent mock WhatsApp submissions."""
|
"""Return recent WhatsApp webhook submissions for debugging."""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit)
|
select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ from decimal import Decimal
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
# ── Unit ─────────────────────────────────────────────────────────────
|
||||||
|
class UnitOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
property: str
|
||||||
|
apartment_code: str
|
||||||
|
building: str | None = None
|
||||||
|
floor: int | None = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
# ── Category ─────────────────────────────────────────────────────────
|
# ── Category ─────────────────────────────────────────────────────────
|
||||||
class CategoryOut(BaseModel):
|
class CategoryOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
@@ -32,6 +43,8 @@ class TicketCreate(BaseModel):
|
|||||||
reported_via: str | None = None # whatsapp, phone, walk-in, qr, agent
|
reported_via: str | None = None # whatsapp, phone, walk-in, qr, agent
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
assigned_to: int | None = None
|
assigned_to: int | None = None
|
||||||
|
customer_name: str | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class TicketUpdate(BaseModel):
|
class TicketUpdate(BaseModel):
|
||||||
@@ -46,6 +59,7 @@ class TicketUpdate(BaseModel):
|
|||||||
eta: datetime | None = None
|
eta: datetime | None = None
|
||||||
cost: Decimal | None = None
|
cost: Decimal | None = None
|
||||||
parts_used: str | None = None
|
parts_used: str | None = None
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class TicketTimelineOut(BaseModel):
|
class TicketTimelineOut(BaseModel):
|
||||||
@@ -78,6 +92,7 @@ class TicketBrief(BaseModel):
|
|||||||
unit_id: int | None = None
|
unit_id: int | None = None
|
||||||
category_id: int | None = None
|
category_id: int | None = None
|
||||||
assigned_to: int | None = None
|
assigned_to: int | None = None
|
||||||
|
assigned_technician_name: str | None = None
|
||||||
reporter: str | None = None
|
reporter: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
sla_deadline: datetime | None = None
|
sla_deadline: datetime | None = None
|
||||||
|
|||||||
+61
-12
@@ -1,10 +1,69 @@
|
|||||||
"""Pydantic schemas for mock WhatsApp endpoint."""
|
"""Pydantic schemas for Meta WhatsApp webhook."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
# ── Meta Webhook Verification ────────────────────────────────────────
|
||||||
|
class WebhookVerificationResponse(BaseModel):
|
||||||
|
challenge: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── Inbound Message Parsing ─────────────────────────────────────────
|
||||||
|
class MessageText(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class Message(BaseModel):
|
||||||
|
from_field: str = Field(alias="from")
|
||||||
|
id: str | None = None
|
||||||
|
text: MessageText | None = None
|
||||||
|
type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Contact(BaseModel):
|
||||||
|
wa_id: str | None = None
|
||||||
|
profile: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EntryMessagesItem(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
|
message: Message | None = None
|
||||||
|
contacts: list[Contact] | None = None
|
||||||
|
timestamp: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Entry(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
|
changes: list[EntryMessagesItem] | None = None
|
||||||
|
metadata: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MetaWebhookRequest(BaseModel):
|
||||||
|
object: str | None = None
|
||||||
|
entry: list[Entry] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auto-reply ──────────────────────────────────────────────────────
|
||||||
|
class WhatsAppReplyResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── Legacy mock schemas (kept for /mock-log endpoint) ───────────────
|
||||||
|
class MockWhatsAppLogEntry(BaseModel):
|
||||||
|
id: int
|
||||||
|
from_number: str
|
||||||
|
message_text: str
|
||||||
|
wa_message_id: str | None
|
||||||
|
ticket_id: int | None
|
||||||
|
ticket_number: str | None
|
||||||
|
received_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
class MockWhatsAppRequest(BaseModel):
|
class MockWhatsAppRequest(BaseModel):
|
||||||
@@ -16,13 +75,3 @@ class MockWhatsAppRequest(BaseModel):
|
|||||||
class MockWhatsAppResponse(BaseModel):
|
class MockWhatsAppResponse(BaseModel):
|
||||||
status: str = "received"
|
status: str = "received"
|
||||||
message: str = "Command logged successfully"
|
message: str = "Command logged successfully"
|
||||||
|
|
||||||
|
|
||||||
class MockWhatsAppLogEntry(BaseModel):
|
|
||||||
id: int
|
|
||||||
command: str
|
|
||||||
from_number: str | None
|
|
||||||
ticket_id: int | None
|
|
||||||
received_at: datetime
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|||||||
+15
-3
@@ -109,7 +109,7 @@ async def create_ticket(
|
|||||||
unit_id=data.get("unit_id"),
|
unit_id=data.get("unit_id"),
|
||||||
category_id=data.get("category_id"),
|
category_id=data.get("category_id"),
|
||||||
priority=priority,
|
priority=priority,
|
||||||
reporter=data.get("reporter"),
|
reporter=data.get("reporter") or data.get("customer_name"),
|
||||||
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"),
|
||||||
@@ -196,7 +196,7 @@ async def list_tickets(
|
|||||||
|
|
||||||
# Paginate
|
# Paginate
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
query = query.order_by(Ticket.created_at.desc()).offset(offset).limit(page_size)
|
query = query.order_by(Ticket.created_at.desc()).offset(offset).limit(page_size).options(selectinload(Ticket.assigned_technician))
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
tickets = list(result.scalars().all())
|
tickets = list(result.scalars().all())
|
||||||
@@ -214,8 +214,8 @@ async def update_ticket(
|
|||||||
|
|
||||||
# Handle status transitions separately
|
# Handle status transitions separately
|
||||||
new_status = data.get("status")
|
new_status = data.get("status")
|
||||||
if new_status is not None:
|
|
||||||
old_status = ticket.status
|
old_status = ticket.status
|
||||||
|
if new_status is not None:
|
||||||
if old_status != new_status:
|
if old_status != new_status:
|
||||||
valid_targets = VALID_TRANSITIONS.get(old_status, [])
|
valid_targets = VALID_TRANSITIONS.get(old_status, [])
|
||||||
if new_status not in valid_targets:
|
if new_status not in valid_targets:
|
||||||
@@ -275,6 +275,18 @@ async def update_ticket(
|
|||||||
|
|
||||||
ticket.status = new_status
|
ticket.status = new_status
|
||||||
|
|
||||||
|
# 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):
|
||||||
|
await _log_status_change(
|
||||||
|
db,
|
||||||
|
ticket.id,
|
||||||
|
from_status=ticket.status,
|
||||||
|
to_status=ticket.status,
|
||||||
|
note=note_only,
|
||||||
|
user_id=user.id if user else None,
|
||||||
|
)
|
||||||
|
|
||||||
# Update other fields
|
# Update other fields
|
||||||
for field in ("unit_id", "category_id", "priority", "reporter", "reported_via",
|
for field in ("unit_id", "category_id", "priority", "reporter", "reported_via",
|
||||||
"description", "assigned_to", "eta", "cost", "parts_used"):
|
"description", "assigned_to", "eta", "cost", "parts_used"):
|
||||||
|
|||||||
@@ -218,15 +218,22 @@
|
|||||||
});
|
});
|
||||||
this.charts.monthlyMax = Math.max(...this.charts.monthlyTrend.map(m => m.count), 1);
|
this.charts.monthlyMax = Math.max(...this.charts.monthlyTrend.map(m => m.count), 1);
|
||||||
|
|
||||||
// By property
|
// By property — load units once and compute from ticket data
|
||||||
const propData = { east: 0, west: 0 };
|
const propData = { east: 0, west: 0 };
|
||||||
all.forEach(t => { /* would need unit join — estimate from ticket IDs */ });
|
|
||||||
try {
|
try {
|
||||||
const east = await app().apiGet('/api/tickets?property=East&page_size=1');
|
const units = await app().apiGet('/api/tickets/units');
|
||||||
const west = await app().apiGet('/api/tickets?property=West&page_size=1');
|
if (units && units.length > 0) {
|
||||||
propData.east = east?.total || 0;
|
const unitPropertyMap = {};
|
||||||
propData.west = west?.total || 0;
|
units.forEach(u => { unitPropertyMap[u.id] = u.property; });
|
||||||
} catch (e) { /* api may not support property filter directly */ }
|
all.forEach(t => {
|
||||||
|
if (t.unit_id && unitPropertyMap[t.unit_id]) {
|
||||||
|
const p = unitPropertyMap[t.unit_id].toLowerCase();
|
||||||
|
if (p === 'east') propData.east++;
|
||||||
|
else if (p === 'west') propData.west++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('Property stats error', e); }
|
||||||
this.charts.byProperty = { east: propData.east, west: propData.west, max: Math.max(propData.east, propData.west, 1) };
|
this.charts.byProperty = { east: propData.east, west: propData.west, max: Math.max(propData.east, propData.west, 1) };
|
||||||
|
|
||||||
// By category — use categories endpoint
|
// By category — use categories endpoint
|
||||||
|
|||||||
@@ -203,16 +203,13 @@
|
|||||||
// Emergency
|
// Emergency
|
||||||
this.emergencyCount = active.filter(t => t.priority === 'urgent').length;
|
this.emergencyCount = active.filter(t => t.priority === 'urgent').length;
|
||||||
|
|
||||||
// East vs West — we need full tickets with unit info
|
// East vs West — compute from loaded tickets using unit map
|
||||||
// For now, estimate from overall data or show placeholder
|
|
||||||
// We'll load again with property filter or just use total
|
|
||||||
try {
|
try {
|
||||||
const east = await app().apiGet('/api/tickets?property=East&page_size=1');
|
const units = await app().apiGet('/api/tickets/units');
|
||||||
const west = await app().apiGet('/api/tickets?property=West&page_size=1');
|
const unitMap = {};
|
||||||
const eastTotal = east?.total || 0;
|
if (units) units.forEach(u => { unitMap[u.id] = u.property; });
|
||||||
const westTotal = west?.total || 0;
|
this.kpi.eastJobs = active.filter(t => t.unit_id && unitMap[t.unit_id] === 'East').length;
|
||||||
this.kpi.eastJobs = eastTotal;
|
this.kpi.westJobs = active.filter(t => t.unit_id && unitMap[t.unit_id] === 'West').length;
|
||||||
this.kpi.westJobs = westTotal;
|
|
||||||
} catch (e) { console.error('Property stats error', e); }
|
} catch (e) { console.error('Property stats error', e); }
|
||||||
|
|
||||||
// Tech workload (simulated from assigned_to counts)
|
// Tech workload (simulated from assigned_to counts)
|
||||||
@@ -231,11 +228,11 @@
|
|||||||
under24h: active.filter(t => (now - new Date(t.created_at)) < 24 * 60 * 60 * 1000).length,
|
under24h: active.filter(t => (now - new Date(t.created_at)) < 24 * 60 * 60 * 1000).length,
|
||||||
oneToTwoDays: active.filter(t => {
|
oneToTwoDays: active.filter(t => {
|
||||||
const diff = (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24);
|
const diff = (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24);
|
||||||
return diff >= 1 && diff < 2;
|
return diff >= 1 && diff < 3;
|
||||||
}).length,
|
}).length,
|
||||||
threeToFiveDays: active.filter(t => {
|
threeToFiveDays: active.filter(t => {
|
||||||
const diff = (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24);
|
const diff = (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24);
|
||||||
return diff >= 2 && diff < 5;
|
return diff >= 3 && diff < 5;
|
||||||
}).length,
|
}).length,
|
||||||
overFiveDays: active.filter(t => (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24) >= 5).length,
|
overFiveDays: active.filter(t => (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24) >= 5).length,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -115,7 +115,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<dt class="text-sm text-gray-500">Assigned To</dt>
|
<dt class="text-sm text-gray-500">Assigned To</dt>
|
||||||
<dd class="text-sm font-medium text-gray-900" x-text="ticket.assigned_technician?.full_name || 'Unassigned'"></dd>
|
<dd class="text-sm font-medium text-gray-900" x-text="ticket.assigned_technician_name || 'Unassigned'"></dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<dt class="text-sm text-gray-500">Reporter</dt>
|
<dt class="text-sm text-gray-500">Reporter</dt>
|
||||||
@@ -311,7 +311,7 @@
|
|||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
await this.loadTicket();
|
await this.loadTicket();
|
||||||
if (this.isFM || this.isAdmin) {
|
if (app().isFM || app().isAdmin) {
|
||||||
await this.loadTechnicians();
|
await this.loadTechnicians();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -328,20 +328,17 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
async loadTechnicians() {
|
async loadTechnicians() {
|
||||||
// Load all users and filter for Tech role
|
|
||||||
try {
|
|
||||||
const me = await app().apiGet('/api/auth/me');
|
|
||||||
// Users list not exposed via API directly, so use a known list
|
// Users list not exposed via API directly, so use a known list
|
||||||
// In a real system we'd have GET /api/users
|
// In a real system we'd have GET /api/users
|
||||||
this.technicians = [
|
this.technicians = [
|
||||||
{ id: 8, full_name: 'Prosper' },
|
{ id: 9, full_name: 'Prosper' },
|
||||||
{ id: 9, full_name: 'Sam' },
|
{ id: 10, full_name: 'Sam' },
|
||||||
{ id: 10, full_name: 'Steven' },
|
{ id: 11, full_name: 'Steven' },
|
||||||
{ id: 11, full_name: 'Junior (Samuel)' },
|
{ id: 12, full_name: 'Junior (Samuel)' },
|
||||||
{ id: 12, full_name: 'Francis' },
|
{ id: 13, full_name: 'Francis' },
|
||||||
{ id: 13, full_name: 'Desmond Afful' },
|
{ id: 14, full_name: 'Desmond Afful' },
|
||||||
|
{ id: 15, full_name: 'Afful' },
|
||||||
];
|
];
|
||||||
} catch (e) { console.error('Tech load error', e); }
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async submitStatusUpdate() {
|
async submitStatusUpdate() {
|
||||||
@@ -392,9 +389,8 @@
|
|||||||
if (!this.noteForm.note.trim()) return;
|
if (!this.noteForm.note.trim()) return;
|
||||||
this.noteSubmitting = true;
|
this.noteSubmitting = true;
|
||||||
try {
|
try {
|
||||||
// Use status update endpoint to add a note without changing status
|
// Use PATCH endpoint which now supports note-only updates
|
||||||
const updated = await app().apiPost(`/api/tickets/${this.ticketId}/status`, {
|
const updated = await app().apiPatch(`/api/tickets/${this.ticketId}`, {
|
||||||
status: this.ticket.status,
|
|
||||||
note: this.noteForm.note
|
note: this.noteForm.note
|
||||||
});
|
});
|
||||||
this.ticket = updated;
|
this.ticket = updated;
|
||||||
@@ -402,7 +398,7 @@
|
|||||||
this.noteForm.note = '';
|
this.noteForm.note = '';
|
||||||
app().showToast('Note added', 'success');
|
app().showToast('Note added', 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
app().showToast(e.message, 'error');
|
// No need to show error here — api() base method already shows it
|
||||||
} finally {
|
} finally {
|
||||||
this.noteSubmitting = false;
|
this.noteSubmitting = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
<option value="walk-in">Walk-in</option>
|
<option value="walk-in">Walk-in</option>
|
||||||
<option value="whatsapp">WhatsApp</option>
|
<option value="whatsapp">WhatsApp</option>
|
||||||
<option value="agent">Agent</option>
|
<option value="agent">Agent</option>
|
||||||
<option value="qr">QR Code</option>
|
<!-- QR Code excluded per Sprint 3 scope -->
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,17 +196,12 @@
|
|||||||
this.form.apartment_code = '';
|
this.form.apartment_code = '';
|
||||||
this.units = [];
|
this.units = [];
|
||||||
if (!this.form.property) return;
|
if (!this.form.property) return;
|
||||||
// Units aren't directly exposed via API, so we'll construct from known patterns
|
try {
|
||||||
const prefix = this.form.property === 'East' ? 'E' : 'W';
|
const data = await app().apiGet(`/api/tickets/units?property=${encodeURIComponent(this.form.property)}`);
|
||||||
const units = [];
|
this.units = data || [];
|
||||||
const buildings = ['A', 'B', 'C', 'D', 'E', 'F'];
|
} catch (e) {
|
||||||
for (let floor = 1; floor <= 10; floor++) {
|
console.error('Units load error', e);
|
||||||
for (const bld of buildings) {
|
|
||||||
const code = `${floor}0${bld}${prefix}`;
|
|
||||||
units.push({ id: code, apartment_code: code });
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
this.units = units;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
handlePhotos(e) {
|
handlePhotos(e) {
|
||||||
@@ -240,13 +235,19 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve unit_id from selected apartment_code
|
||||||
|
const selectedUnit = this.units.find(u => u.apartment_code === this.form.apartment_code);
|
||||||
|
|
||||||
// Create the ticket
|
// Create the ticket
|
||||||
const payload = {
|
const payload = {
|
||||||
description: this.form.description,
|
description: this.form.description,
|
||||||
priority: this.form.priority || null,
|
priority: this.form.priority || null,
|
||||||
reporter: this.form.reporter || this.user.full_name,
|
reporter: this.form.reporter || this.form.customer_name || app().user.full_name,
|
||||||
reported_via: this.form.reported_via || 'walk-in',
|
reported_via: this.form.reported_via || 'walk-in',
|
||||||
category_id: this.form.category_id ? parseInt(this.form.category_id) : (this.form.category_main ? parseInt(this.form.category_main) : null),
|
category_id: this.form.category_id ? parseInt(this.form.category_id) : (this.form.category_main ? parseInt(this.form.category_main) : null),
|
||||||
|
unit_id: selectedUnit ? selectedUnit.id : null,
|
||||||
|
customer_name: this.form.customer_name || null,
|
||||||
|
phone: this.form.phone || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ticket = await app().apiPost('/api/tickets', payload);
|
const ticket = await app().apiPost('/api/tickets', payload);
|
||||||
|
|||||||
Reference in New Issue
Block a user