Author SHA1 Message Date
jerome 4afdc36765 feat: Replace mock WhatsApp with real Meta Graph API webhook
- POST /api/whatsapp/webhook handles Meta verification (hub.challenge)
- Inbound text messages create tickets via create_ticket()
- Auto-reply confirmation sent back via Meta Graph API
- WhatsApp messages logged with ticket linkage in whatsapp_log
- Added WHATSAPP_PHONE_NUMBER_ID, WHATSAPP_ACCESS_TOKEN, WHATSAPP_VERIFY_TOKEN config
- Kept /mock-log debug endpoint for backward compatibility
- Graceful degradation: logs message even if ticket/reply fails
2026-07-24 20:07:30 -04:00
abiba-bot 3920cf14a1 Sprint 3: Alpine.js Dashboards 2026-07-23 19:36:35 +00:00
root 01e28bbcc7 fix: map customer_name to reporter in create_ticket service
Per captain decision: customer_name maps to reporter field (fallback),
phone field skipped for MVP (no DB column).
2026-07-23 19:26:04 +00:00
root ef2297ea87 no-mistakes(review): Fixed aging bucket boundaries and removed wasteful API call 2026-07-23 19:21:31 +00:00
root a9c17d0703 fix: address remaining review findings
- Fix detail.html to use assigned_technician_name (not .assigned_technician?.full_name)
- Remove double error toast in submitNote() catch block
- Replace wasteful API calls in CEO dashboard with units+data approach
- Remove QR Code option from reported_via dropdown (excluded per scope)
2026-07-23 19:18:17 +00:00
root 5560496653 no-mistakes(review): Fix F01/F02/F05: technician name, duplicate timeline, FM dashboard property counts 2026-07-23 19:13:10 +00:00
root 7fab1ede51 fix: address ask-user findings from review
- Move unit loading to use real /api/tickets/units endpoint
- Match unit code format to backend seed (0101E style)
- Send unit_id in create ticket payload
- Fix tech IDs to match seed order (9-15 for Prosper-Afful)
- Add customer_name and phone fields to TicketCreate schema
- Map form customer_name/phone into API payload
2026-07-23 19:08:49 +00:00
root 3dc383e62d fix: auto-fix findings from no-mistakes review
- Remove duplicate  import
- Fix  →  in create ticket
- Fix  →  in ticket detail
- Add note field to TicketUpdate schema and handle note-only updates in backend
- Update frontend submitNote() to use PATCH endpoint
2026-07-23 19:06:33 +00:00
13 changed files with 313 additions and 89 deletions
+6
View File
@@ -31,6 +31,12 @@ class Settings(BaseSettings):
# ── CORS ─────────────────────────────────────────────────────────
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 ────────────────────────────────────────────────────────
BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
+4
View File
@@ -70,6 +70,10 @@ class Ticket(Base):
photos = relationship("TicketPhoto", 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:
return f"<Ticket {self.ticket_number} ({self.status})>"
+6 -4
View File
@@ -1,4 +1,4 @@
"""WhatsApp log model for mock endpoint."""
"""WhatsApp log model for inbound webhook messages."""
from __future__ import annotations
@@ -14,10 +14,12 @@ class WhatsAppLog(Base):
__tablename__ = "whatsapp_log"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
command: Mapped[str] = mapped_column(Text, nullable=False)
from_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
from_number: Mapped[str] = mapped_column(String(50), nullable=False)
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_number: Mapped[str | None] = mapped_column(String(30), nullable=True)
received_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
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
View File
@@ -2,8 +2,6 @@
from __future__ import annotations
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Request
+16
View File
@@ -16,6 +16,7 @@ from app.core.database import get_db
from app.core.security import get_current_user
from app.models.category import Category
from app.models.ticket import Ticket, TicketPhoto
from app.models.unit import Unit
from app.models.user import User
from app.schemas.ticket import (
CategoryOut,
@@ -27,6 +28,7 @@ from app.schemas.ticket import (
TicketOut,
TicketPhotoOut,
TicketUpdate,
UnitOut,
)
from app.services import ticket as ticket_service
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)
# ── 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 ──────────────────────────────────────────────────────
async def _build_category_tree(db: AsyncSession, parent_id: int | None = None) -> list[CategoryTreeOut]:
"""Build a nested category tree."""
+139 -18
View File
@@ -1,44 +1,165 @@
"""Mock WhatsApp endpoint for testing command parsing."""
"""WhatsApp webhook handler — Meta Graph API integration."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends
import httpx
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.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"])
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(
body: MockWhatsAppRequest,
# ── Meta Graph API helpers ──────────────────────────────────────────
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)],
) -> MockWhatsAppResponse:
"""Accept a mock WhatsApp command and log it."""
log = WhatsAppLog(
command=body.command,
from_number=body.from_number,
ticket_id=body.ticket_id,
received_at=datetime.now(timezone.utc),
)
db.add(log)
await db.flush()
return MockWhatsAppResponse()
hub_verify_token: str | None = Query(None, alias="hub.verify_token"),
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(
from_number=sender,
message_text=message_text,
wa_message_id=wa_msg_id,
ticket_id=None,
ticket_number=None,
received_at=datetime.now(timezone.utc),
)
db.add(log)
await db.flush()
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])
async def mock_whatsapp_log(
db: Annotated[AsyncSession, Depends(get_db)],
limit: int = 50,
) -> list[MockWhatsAppLogEntry]:
"""Return recent mock WhatsApp submissions."""
"""Return recent WhatsApp webhook submissions for debugging."""
result = await db.execute(
select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit)
)
+15
View File
@@ -8,6 +8,17 @@ from decimal import Decimal
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 ─────────────────────────────────────────────────────────
class CategoryOut(BaseModel):
id: int
@@ -32,6 +43,8 @@ class TicketCreate(BaseModel):
reported_via: str | None = None # whatsapp, phone, walk-in, qr, agent
description: str | None = None
assigned_to: int | None = None
customer_name: str | None = None
phone: str | None = None
class TicketUpdate(BaseModel):
@@ -46,6 +59,7 @@ class TicketUpdate(BaseModel):
eta: datetime | None = None
cost: Decimal | None = None
parts_used: str | None = None
note: str | None = None
class TicketTimelineOut(BaseModel):
@@ -78,6 +92,7 @@ class TicketBrief(BaseModel):
unit_id: int | None = None
category_id: int | None = None
assigned_to: int | None = None
assigned_technician_name: str | None = None
reporter: str | None = None
description: str | None = None
sla_deadline: datetime | None = None
+61 -12
View File
@@ -1,10 +1,69 @@
"""Pydantic schemas for mock WhatsApp endpoint."""
"""Pydantic schemas for Meta WhatsApp webhook."""
from __future__ import annotations
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):
@@ -16,13 +75,3 @@ class MockWhatsAppRequest(BaseModel):
class MockWhatsAppResponse(BaseModel):
status: str = "received"
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
View File
@@ -109,7 +109,7 @@ async def create_ticket(
unit_id=data.get("unit_id"),
category_id=data.get("category_id"),
priority=priority,
reporter=data.get("reporter"),
reporter=data.get("reporter") or data.get("customer_name"),
reported_via=data.get("reported_via"),
description=data.get("description"),
assigned_to=data.get("assigned_to"),
@@ -196,7 +196,7 @@ async def list_tickets(
# Paginate
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)
tickets = list(result.scalars().all())
@@ -214,8 +214,8 @@ async def update_ticket(
# Handle status transitions separately
new_status = data.get("status")
old_status = ticket.status
if new_status is not None:
old_status = ticket.status
if old_status != new_status:
valid_targets = VALID_TRANSITIONS.get(old_status, [])
if new_status not in valid_targets:
@@ -275,6 +275,18 @@ async def update_ticket(
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
for field in ("unit_id", "category_id", "priority", "reporter", "reported_via",
"description", "assigned_to", "eta", "cost", "parts_used"):
+14 -7
View File
@@ -218,15 +218,22 @@
});
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 };
all.forEach(t => { /* would need unit join — estimate from ticket IDs */ });
try {
const east = await app().apiGet('/api/tickets?property=East&page_size=1');
const west = await app().apiGet('/api/tickets?property=West&page_size=1');
propData.east = east?.total || 0;
propData.west = west?.total || 0;
} catch (e) { /* api may not support property filter directly */ }
const units = await app().apiGet('/api/tickets/units');
if (units && units.length > 0) {
const unitPropertyMap = {};
units.forEach(u => { unitPropertyMap[u.id] = u.property; });
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) };
// By category — use categories endpoint
+8 -11
View File
@@ -203,16 +203,13 @@
// Emergency
this.emergencyCount = active.filter(t => t.priority === 'urgent').length;
// East vs West — we need full tickets with unit info
// For now, estimate from overall data or show placeholder
// We'll load again with property filter or just use total
// East vs West — compute from loaded tickets using unit map
try {
const east = await app().apiGet('/api/tickets?property=East&page_size=1');
const west = await app().apiGet('/api/tickets?property=West&page_size=1');
const eastTotal = east?.total || 0;
const westTotal = west?.total || 0;
this.kpi.eastJobs = eastTotal;
this.kpi.westJobs = westTotal;
const units = await app().apiGet('/api/tickets/units');
const unitMap = {};
if (units) units.forEach(u => { unitMap[u.id] = u.property; });
this.kpi.eastJobs = active.filter(t => t.unit_id && unitMap[t.unit_id] === 'East').length;
this.kpi.westJobs = active.filter(t => t.unit_id && unitMap[t.unit_id] === 'West').length;
} catch (e) { console.error('Property stats error', e); }
// 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,
oneToTwoDays: active.filter(t => {
const diff = (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24);
return diff >= 1 && diff < 2;
return diff >= 1 && diff < 3;
}).length,
threeToFiveDays: active.filter(t => {
const diff = (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24);
return diff >= 2 && diff < 5;
return diff >= 3 && diff < 5;
}).length,
overFiveDays: active.filter(t => (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24) >= 5).length,
};
+16 -20
View File
@@ -115,7 +115,7 @@
</div>
<div class="flex justify-between">
<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 class="flex justify-between">
<dt class="text-sm text-gray-500">Reporter</dt>
@@ -311,7 +311,7 @@
async init() {
await this.loadTicket();
if (this.isFM || this.isAdmin) {
if (app().isFM || app().isAdmin) {
await this.loadTechnicians();
}
},
@@ -328,20 +328,17 @@
},
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
// In a real system we'd have GET /api/users
this.technicians = [
{ id: 8, full_name: 'Prosper' },
{ id: 9, full_name: 'Sam' },
{ id: 10, full_name: 'Steven' },
{ id: 11, full_name: 'Junior (Samuel)' },
{ id: 12, full_name: 'Francis' },
{ id: 13, full_name: 'Desmond Afful' },
];
} catch (e) { console.error('Tech load error', e); }
// Users list not exposed via API directly, so use a known list
// In a real system we'd have GET /api/users
this.technicians = [
{ id: 9, full_name: 'Prosper' },
{ id: 10, full_name: 'Sam' },
{ id: 11, full_name: 'Steven' },
{ id: 12, full_name: 'Junior (Samuel)' },
{ id: 13, full_name: 'Francis' },
{ id: 14, full_name: 'Desmond Afful' },
{ id: 15, full_name: 'Afful' },
];
},
async submitStatusUpdate() {
@@ -392,9 +389,8 @@
if (!this.noteForm.note.trim()) return;
this.noteSubmitting = true;
try {
// Use status update endpoint to add a note without changing status
const updated = await app().apiPost(`/api/tickets/${this.ticketId}/status`, {
status: this.ticket.status,
// Use PATCH endpoint which now supports note-only updates
const updated = await app().apiPatch(`/api/tickets/${this.ticketId}`, {
note: this.noteForm.note
});
this.ticket = updated;
@@ -402,7 +398,7 @@
this.noteForm.note = '';
app().showToast('Note added', 'success');
} catch (e) {
app().showToast(e.message, 'error');
// No need to show error here — api() base method already shows it
} finally {
this.noteSubmitting = false;
}
+13 -12
View File
@@ -104,7 +104,7 @@
<option value="walk-in">Walk-in</option>
<option value="whatsapp">WhatsApp</option>
<option value="agent">Agent</option>
<option value="qr">QR Code</option>
<!-- QR Code excluded per Sprint 3 scope -->
</select>
</div>
</div>
@@ -196,17 +196,12 @@
this.form.apartment_code = '';
this.units = [];
if (!this.form.property) return;
// Units aren't directly exposed via API, so we'll construct from known patterns
const prefix = this.form.property === 'East' ? 'E' : 'W';
const units = [];
const buildings = ['A', 'B', 'C', 'D', 'E', 'F'];
for (let floor = 1; floor <= 10; floor++) {
for (const bld of buildings) {
const code = `${floor}0${bld}${prefix}`;
units.push({ id: code, apartment_code: code });
}
try {
const data = await app().apiGet(`/api/tickets/units?property=${encodeURIComponent(this.form.property)}`);
this.units = data || [];
} catch (e) {
console.error('Units load error', e);
}
this.units = units;
},
handlePhotos(e) {
@@ -240,13 +235,19 @@
return;
}
// Resolve unit_id from selected apartment_code
const selectedUnit = this.units.find(u => u.apartment_code === this.form.apartment_code);
// Create the ticket
const payload = {
description: this.form.description,
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',
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);