From 9f03e5df32080c7b633ec5693348c2d815ac4150 Mon Sep 17 00:00:00 2001 From: debu-2 Date: Thu, 15 Oct 2026 03:28:51 +0530 Subject: [PATCH] template --- backend/app/main.py | 1 + backend/app/models.py | 1 + backend/app/routes_whatsapp.py | 17 +++- backend/app/whatsapp_broadcast_jobs.py | 98 ++++++++++++++++--- .../src/features/admin/AdminWhatsAppPage.tsx | 11 ++- .../whatsapp/CustomerWhatsAppInbox.tsx | 10 +- .../whatsapp/WhatsAppBroadcastPage.tsx | 34 ++++++- frontend/src/index.css | 75 ++++++++++---- frontend/src/lib/api.ts | 15 +++ 9 files changed, 225 insertions(+), 37 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index bc5202c..0be4185 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -78,6 +78,7 @@ async def lifespan(_: FastAPI): conn.execute(text("ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS contact_name VARCHAR(200)")) conn.execute(text("ALTER TABLE whatsapp_meta_connections ADD COLUMN IF NOT EXISTS meta_app_id VARCHAR(64)")) conn.execute(text("ALTER TABLE whatsapp_meta_connections ADD COLUMN IF NOT EXISTS meta_app_secret TEXT")) + conn.execute(text("ALTER TABLE whatsapp_broadcast_jobs ADD COLUMN IF NOT EXISTS failures JSON")) conn.execute(text("CREATE INDEX IF NOT EXISTS ix_whatsapp_messages_user_id ON whatsapp_messages (user_id)")) conn.execute(text("CREATE INDEX IF NOT EXISTS ix_whatsapp_messages_phone_number_id ON whatsapp_messages (phone_number_id)")) yield diff --git a/backend/app/models.py b/backend/app/models.py index 44afe6f..92f479a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -667,6 +667,7 @@ class WhatsAppBroadcastJob(Base): sent_count: Mapped[int] = mapped_column(default=0) failed_count: Mapped[int] = mapped_column(default=0) next_index: Mapped[int] = mapped_column(default=0) + failures: Mapped[list | None] = mapped_column(JSON, nullable=True) last_error: Mapped[str | None] = mapped_column(String(500), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/backend/app/routes_whatsapp.py b/backend/app/routes_whatsapp.py index 2999206..87da5a6 100644 --- a/backend/app/routes_whatsapp.py +++ b/backend/app/routes_whatsapp.py @@ -16,7 +16,7 @@ from .models import ( WhatsAppPhoneAsset, WhatsAppWebhookEvent, ) -from .whatsapp_broadcast_jobs import create_csv_broadcast, list_broadcast_jobs +from .whatsapp_broadcast_jobs import broadcast_failure_csv, create_csv_broadcast, list_broadcast_jobs from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError from .whatsapp_config import resolved_whatsapp_app_secret from .whatsapp_onboarding import ( @@ -369,6 +369,21 @@ def whatsapp_broadcast_jobs( return list_broadcast_jobs(db, user, account_id) +@router.get("/accounts/{account_id}/broadcasts/jobs/{job_id}/failures.csv") +def whatsapp_broadcast_failures( + account_id: str, + job_id: str, + db: Session = Depends(get_db), + user: UserAccount = Depends(current_user), +) -> Response: + content, filename = broadcast_failure_csv(db, user, account_id, job_id) + return Response( + content=content, + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + @router.post("/accounts/{account_id}/broadcasts/csv") async def whatsapp_broadcast_csv( account_id: str, diff --git a/backend/app/whatsapp_broadcast_jobs.py b/backend/app/whatsapp_broadcast_jobs.py index d80969e..1292d22 100644 --- a/backend/app/whatsapp_broadcast_jobs.py +++ b/backend/app/whatsapp_broadcast_jobs.py @@ -7,7 +7,7 @@ import csv import io import logging import re -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any from uuid import UUID @@ -17,7 +17,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from .database import SessionLocal -from .models import UserAccount, WhatsAppBroadcastJob +from .models import UserAccount, WhatsAppBroadcastJob, WhatsAppMessage from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError from .whatsapp_messages import extract_wa_message_id, record_outbound_message from .whatsapp_onboarding import _messaging_phone, _owned_account @@ -66,7 +66,7 @@ def prepare_phone_number(raw: str, country_code: str) -> tuple[str | None, str | return digits, None -def _parse_csv(raw: bytes, country_code: str) -> tuple[list[str], int]: +def _parse_csv(raw: bytes, country_code: str) -> tuple[list[str], list[dict[str, str]]]: try: text = raw.decode("utf-8-sig") except UnicodeDecodeError as exc: @@ -89,18 +89,22 @@ def _parse_csv(raw: bytes, country_code: str) -> tuple[list[str], int]: ) ready: list[str] = [] seen: set[str] = set() - failed = 0 + failures: list[dict[str, str]] = [] for row in filled: - number, reason = prepare_phone_number(str(row.get("phone_number") or ""), country_code) + original = str(row.get("phone_number") or "").strip() + number, reason = prepare_phone_number(original, country_code) if reason or not number: - failed += 1 + failures.append({ + "phone_number": original, + "reason": reason or "This number does not match the selected country code.", + }) continue if number in seen: - failed += 1 + failures.append({"phone_number": original, "reason": "This number is already in the file."}) continue seen.add(number) ready.append(number) - return ready, failed + return ready, failures def _serialize_job(job: WhatsAppBroadcastJob) -> dict[str, Any]: @@ -121,6 +125,66 @@ def _serialize_job(job: WhatsAppBroadcastJob) -> dict[str, Any]: } +def _owned_job(db: Session, user: UserAccount, account_id: Any, job_id: Any) -> WhatsAppBroadcastJob: + account = _owned_account(db, user, account_id) + try: + parsed = job_id if isinstance(job_id, UUID) else UUID(str(job_id)) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="This broadcast was not found.") from exc + job = db.get(WhatsAppBroadcastJob, parsed) + if job is None or job.user_id != user.id or job.account_id != account.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="This broadcast was not found.") + return job + + +def _legacy_failures(db: Session, job: WhatsAppBroadcastJob) -> list[dict[str, str]]: + """Older broadcasts kept only a count. Recover numbers Meta actually rejected.""" + recipients = [str(item) for item in (job.recipients or [])] + if not recipients or job.created_at is None: + return [] + end = (job.completed_at or datetime.now(timezone.utc)) + timedelta(minutes=2) + sent = set( + db.scalars( + select(WhatsAppMessage.to_number).where( + WhatsAppMessage.user_id == job.user_id, + WhatsAppMessage.body == f"Broadcast: {job.template_name}", + WhatsAppMessage.created_at >= job.created_at, + WhatsAppMessage.created_at <= end, + ) + ).all() + ) + missing = [number for number in recipients if number not in sent] + parse_failed = max(job.total - len(recipients), 0) + send_failed = max(job.failed_count - parse_failed, 0) + unsent = max(len(recipients) - job.next_index, 0) if job.status == "failed" else 0 + if not missing or len(missing) > send_failed + unsent: + return [] + reason = (job.last_error or "Meta did not accept this number.")[:500] + return [{"phone_number": number, "reason": reason} for number in missing] + + +def broadcast_failure_csv(db: Session, user: UserAccount, account_id: Any, job_id: Any) -> tuple[str, str]: + job = _owned_job(db, user, account_id, job_id) + rows = job.failures if isinstance(job.failures, list) else _legacy_failures(db, job) + cleaned = [ + {"phone_number": str(item.get("phone_number") or ""), "reason": str(item.get("reason") or "")} + for item in rows + if isinstance(item, dict) and str(item.get("phone_number") or "").strip() + ] + if not cleaned: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Failed numbers were not saved for this broadcast. New broadcasts keep each one.", + ) + buffer = io.StringIO() + writer = csv.writer(buffer) + writer.writerow(["phone_number", "reason"]) + for item in cleaned: + writer.writerow([item["phone_number"], item["reason"]]) + stem = re.sub(r"[^A-Za-z0-9._-]+", "-", Path(job.filename).stem)[:80] or "broadcast" + return buffer.getvalue(), f"{stem}-failed.csv" + + def list_broadcast_jobs(db: Session, user: UserAccount, account_id: Any) -> list[dict[str, Any]]: account = _owned_account(db, user, account_id) rows = db.scalars( @@ -173,7 +237,7 @@ async def create_csv_broadcast( status_code=status.HTTP_409_CONFLICT, detail="Add the sample file on the template page before this broadcast can attach it.", ) - ready, failed = _parse_csv(raw, code) + ready, failures = _parse_csv(raw, code) safe_name = Path(filename or "broadcast.csv").name[:200] or "broadcast.csv" job = WhatsAppBroadcastJob( user_id=user.id, @@ -187,10 +251,11 @@ async def create_csv_broadcast( body_parameters=[str(item).strip() for item in body_parameters if str(item).strip()], recipients=ready, status="processing", - total=len(ready) + failed, + total=len(ready) + len(failures), sent_count=0, - failed_count=failed, + failed_count=len(failures), next_index=0, + failures=failures, ) db.add(job) db.commit() @@ -345,6 +410,8 @@ async def run_broadcast_job(job_id: UUID) -> None: else: job.failed_count += 1 job.last_error = (error or "Meta did not accept this number.")[:500] + if isinstance(job.failures, list): + job.failures = [*job.failures, {"phone_number": number, "reason": job.last_error}] db.commit() if len(batch) == BATCH_SIZE: await asyncio.sleep(COOLDOWN_SECONDS) @@ -364,5 +431,14 @@ def _mark_failed(job_id: UUID, message: str) -> None: return job.status = "failed" job.last_error = message[:500] + if isinstance(job.failures, list): + recipients = [str(item) for item in (job.recipients or [])] + pending = [ + {"phone_number": number, "reason": "The broadcast stopped before this number was sent."} + for number in recipients[job.next_index:] + ] + if pending: + job.failures = [*job.failures, *pending] + job.failed_count += len(pending) job.completed_at = datetime.now(timezone.utc) db.commit() diff --git a/frontend/src/features/admin/AdminWhatsAppPage.tsx b/frontend/src/features/admin/AdminWhatsAppPage.tsx index 1e487ce..5c4c256 100644 --- a/frontend/src/features/admin/AdminWhatsAppPage.tsx +++ b/frontend/src/features/admin/AdminWhatsAppPage.tsx @@ -1,6 +1,7 @@ import { Braces, Check, + ChevronLeft, CheckCheck, Image as ImageIcon, MessageSquareText, @@ -114,6 +115,7 @@ export function AdminWhatsAppPage() { const [templates, setTemplates] = useState([]) const [loading, setLoading] = useState(true) const [selectedPeer, setSelectedPeer] = useState(null) + const [profileOpen, setProfileOpen] = useState(false) const [newPeer, setNewPeer] = useState('') const [startingNew, setStartingNew] = useState(false) const [sendMode, setSendMode] = useState('text') @@ -392,7 +394,7 @@ export function AdminWhatsAppPage() { )} -
+