From 1c9e3a91be1cf546426ea687df068cd4eda8caab Mon Sep 17 00:00:00 2001 From: Stephen Nwankwo Date: Sun, 10 May 2026 18:09:54 +0100 Subject: [PATCH 1/4] run lint --- backend/alembic/env.py | 8 + ...3e4f5a6b7_add_whatsapp_broadcast_tables.py | 189 +++++++++ ...d_whatsapp_send_rate_to_widget_settings.py | 31 ++ backend/app/api/escalation.py | 41 +- backend/app/api/whatsapp.py | 94 ++++- backend/app/api/whatsapp_campaigns.py | 206 +++++++++ backend/app/api/whatsapp_contacts.py | 322 ++++++++++++++ backend/app/api/whatsapp_templates.py | 223 ++++++++++ backend/app/api/widget.py | 105 ++++- backend/app/core/config.py | 2 + backend/app/main.py | 9 + backend/app/models/whatsapp_broadcast.py | 221 ++++++++++ backend/app/models/widget.py | 1 + backend/app/schemas/whatsapp.py | 185 ++++++++ backend/app/schemas/widget.py | 1 + backend/app/services/whatsapp/__init__.py | 9 + backend/app/services/whatsapp/campaigns.py | 324 ++++++++++++++ backend/app/services/whatsapp/client.py | 121 ++++++ backend/app/services/whatsapp/contacts.py | 271 ++++++++++++ backend/app/services/whatsapp/templates.py | 233 +++++++++++ backend/app/workers/__init__.py | 0 .../app/workers/whatsapp_campaign_worker.py | 185 ++++++++ backend/tests/api/test_escalation.py | 92 +++- backend/tests/api/test_widget_settings.py | 136 +++++- backend/tests/conftest.py | 8 + backend/tests/unit/test_whatsapp_broadcast.py | 238 +++++++++++ content_templates.md | 163 ++++++++ docker-compose.yml | 34 ++ frontend/public/logo.png | Bin 42309 -> 8827 bytes frontend/public/logo_old.png | Bin 0 -> 42309 bytes frontend/src/app/dashboard/analytics/page.tsx | 4 +- .../dashboard/handoff/[escalationId]/page.tsx | 2 +- frontend/src/app/dashboard/handoff/page.tsx | 79 +++- frontend/src/app/dashboard/layout.tsx | 18 +- frontend/src/app/dashboard/page.tsx | 16 +- .../sessions/[guestId]/[sessionId]/page.tsx | 2 +- .../app/dashboard/sessions/[guestId]/page.tsx | 64 ++- frontend/src/app/dashboard/sessions/page.tsx | 55 ++- .../app/dashboard/settings/general/page.tsx | 4 +- .../dashboard/settings/subscription/page.tsx | 2 +- .../app/dashboard/settings/whatsapp/page.tsx | 183 +++++--- .../whatsapp/campaigns/[id]/page.tsx | 186 +++++++++ .../app/dashboard/whatsapp/campaigns/page.tsx | 343 +++++++++++++++ .../app/dashboard/whatsapp/contacts/page.tsx | 395 ++++++++++++++++++ .../app/dashboard/whatsapp/templates/page.tsx | 342 +++++++++++++++ .../app/dashboard/widget-settings/page.tsx | 12 +- frontend/src/app/globals.css | 15 +- .../components/dashboard/FollowUpModal.tsx | 2 +- .../subscription/PlanPricingGrid.tsx | 8 +- .../subscription/SubscriptionOverview.tsx | 4 +- .../dashboard/subscription/UsageRates.tsx | 4 +- frontend/src/components/ui/Pagination.tsx | 118 ++++++ frontend/src/lib/api.ts | 314 +++++++++++++- taimako_customer_onboarding.md | 357 ++++++++++++++++ taimako_golive_checklist.md | 274 ++++++++++++ whatsapp_polls.md | 220 ++++++++++ whatsapp_roadmap.md | 391 +++++++++++++++++ 57 files changed, 6684 insertions(+), 182 deletions(-) create mode 100644 backend/alembic/versions/c2d3e4f5a6b7_add_whatsapp_broadcast_tables.py create mode 100644 backend/alembic/versions/d3e4f5a6b7c8_add_whatsapp_send_rate_to_widget_settings.py create mode 100644 backend/app/api/whatsapp_campaigns.py create mode 100644 backend/app/api/whatsapp_contacts.py create mode 100644 backend/app/api/whatsapp_templates.py create mode 100644 backend/app/models/whatsapp_broadcast.py create mode 100644 backend/app/schemas/whatsapp.py create mode 100644 backend/app/services/whatsapp/__init__.py create mode 100644 backend/app/services/whatsapp/campaigns.py create mode 100644 backend/app/services/whatsapp/client.py create mode 100644 backend/app/services/whatsapp/contacts.py create mode 100644 backend/app/services/whatsapp/templates.py create mode 100644 backend/app/workers/__init__.py create mode 100644 backend/app/workers/whatsapp_campaign_worker.py create mode 100644 backend/tests/unit/test_whatsapp_broadcast.py create mode 100644 content_templates.md create mode 100644 frontend/public/logo_old.png create mode 100644 frontend/src/app/dashboard/whatsapp/campaigns/[id]/page.tsx create mode 100644 frontend/src/app/dashboard/whatsapp/campaigns/page.tsx create mode 100644 frontend/src/app/dashboard/whatsapp/contacts/page.tsx create mode 100644 frontend/src/app/dashboard/whatsapp/templates/page.tsx create mode 100644 frontend/src/components/ui/Pagination.tsx create mode 100644 taimako_customer_onboarding.md create mode 100644 taimako_golive_checklist.md create mode 100644 whatsapp_polls.md create mode 100644 whatsapp_roadmap.md diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 4f80045..399d45d 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -19,6 +19,14 @@ from app.models.escalation import Escalation # noqa: F401 from app.models.document import Document # noqa: F401 from app.models.analytics import AnalyticsDailySummary # noqa: F401 +from app.models.whatsapp_broadcast import ( # noqa: F401 + WhatsAppContact, + WhatsAppContactList, + WhatsAppContactListMember, + WhatsAppTemplate, + WhatsAppCampaign, + WhatsAppCampaignMessage, +) # Alembic Config object diff --git a/backend/alembic/versions/c2d3e4f5a6b7_add_whatsapp_broadcast_tables.py b/backend/alembic/versions/c2d3e4f5a6b7_add_whatsapp_broadcast_tables.py new file mode 100644 index 0000000..0e1b595 --- /dev/null +++ b/backend/alembic/versions/c2d3e4f5a6b7_add_whatsapp_broadcast_tables.py @@ -0,0 +1,189 @@ +"""add whatsapp broadcast tables + +Revision ID: c2d3e4f5a6b7 +Revises: b1c2d3e4f5a6 +Create Date: 2026-04-14 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c2d3e4f5a6b7' +down_revision: Union[str, None] = '266444da8a8b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + 'whatsapp_contacts', + sa.Column('id', sa.String(), nullable=False), + sa.Column('business_id', sa.String(), nullable=False), + sa.Column('phone_e164', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=True), + sa.Column('tags', sa.JSON(), nullable=True), + sa.Column('source', sa.String(), nullable=False, server_default='manual'), + sa.Column('opted_in', sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column('last_contacted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['business_id'], ['businesses.id']), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('business_id', 'phone_e164', name='uq_whatsapp_contacts_business_phone'), + ) + op.create_index( + 'ix_whatsapp_contacts_business_id', 'whatsapp_contacts', ['business_id'] + ) + + op.create_table( + 'whatsapp_contact_lists', + sa.Column('id', sa.String(), nullable=False), + sa.Column('business_id', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['business_id'], ['businesses.id']), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index( + 'ix_whatsapp_contact_lists_business_id', 'whatsapp_contact_lists', ['business_id'] + ) + + op.create_table( + 'whatsapp_contact_list_members', + sa.Column('contact_list_id', sa.String(), nullable=False), + sa.Column('contact_id', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['contact_id'], ['whatsapp_contacts.id']), + sa.ForeignKeyConstraint(['contact_list_id'], ['whatsapp_contact_lists.id']), + sa.PrimaryKeyConstraint('contact_list_id', 'contact_id'), + ) + + op.create_table( + 'whatsapp_templates', + sa.Column('id', sa.String(), nullable=False), + sa.Column('business_id', sa.String(), nullable=False), + sa.Column('meta_template_id', sa.String(), nullable=True), + sa.Column('name', sa.String(), nullable=False), + sa.Column('category', sa.String(), nullable=False), + sa.Column('language', sa.String(), nullable=False, server_default='en_US'), + sa.Column('header', sa.JSON(), nullable=True), + sa.Column('body_text', sa.Text(), nullable=False), + sa.Column('footer', sa.String(), nullable=True), + sa.Column('buttons', sa.JSON(), nullable=True), + sa.Column('variables', sa.JSON(), nullable=True), + sa.Column('status', sa.String(), nullable=False, server_default='DRAFT'), + sa.Column('rejection_reason', sa.Text(), nullable=True), + sa.Column('source', sa.String(), nullable=False, server_default='CREATED'), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['business_id'], ['businesses.id']), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint( + 'business_id', 'name', 'language', name='uq_whatsapp_templates_business_name_lang' + ), + ) + op.create_index( + 'ix_whatsapp_templates_business_id', 'whatsapp_templates', ['business_id'] + ) + op.create_index( + 'ix_whatsapp_templates_meta_template_id', 'whatsapp_templates', ['meta_template_id'] + ) + + op.create_table( + 'whatsapp_campaigns', + sa.Column('id', sa.String(), nullable=False), + sa.Column('business_id', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('template_id', sa.String(), nullable=False), + sa.Column('audience_type', sa.String(), nullable=False), + sa.Column('audience_ref', sa.JSON(), nullable=True), + sa.Column('variable_mapping', sa.JSON(), nullable=True), + sa.Column('status', sa.String(), nullable=False, server_default='DRAFT'), + sa.Column('scheduled_at', sa.DateTime(), nullable=True), + sa.Column('started_at', sa.DateTime(), nullable=True), + sa.Column('completed_at', sa.DateTime(), nullable=True), + sa.Column('total_recipients', sa.Integer(), nullable=False, server_default='0'), + sa.Column('sent_count', sa.Integer(), nullable=False, server_default='0'), + sa.Column('delivered_count', sa.Integer(), nullable=False, server_default='0'), + sa.Column('read_count', sa.Integer(), nullable=False, server_default='0'), + sa.Column('failed_count', sa.Integer(), nullable=False, server_default='0'), + sa.Column('created_by_user_id', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['business_id'], ['businesses.id']), + sa.ForeignKeyConstraint(['template_id'], ['whatsapp_templates.id']), + sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id']), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index( + 'ix_whatsapp_campaigns_business_id', 'whatsapp_campaigns', ['business_id'] + ) + op.create_index( + 'ix_whatsapp_campaigns_status', 'whatsapp_campaigns', ['status'] + ) + op.create_index( + 'ix_whatsapp_campaigns_scheduled_at', 'whatsapp_campaigns', ['scheduled_at'] + ) + + op.create_table( + 'whatsapp_campaign_messages', + sa.Column('id', sa.String(), nullable=False), + sa.Column('campaign_id', sa.String(), nullable=False), + sa.Column('contact_phone', sa.String(), nullable=False), + sa.Column('variables_snapshot', sa.JSON(), nullable=True), + sa.Column('meta_message_id', sa.String(), nullable=True), + sa.Column('status', sa.String(), nullable=False, server_default='QUEUED'), + sa.Column('error_code', sa.String(), nullable=True), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('sent_at', sa.DateTime(), nullable=True), + sa.Column('delivered_at', sa.DateTime(), nullable=True), + sa.Column('read_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['campaign_id'], ['whatsapp_campaigns.id']), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint( + 'campaign_id', 'contact_phone', name='uq_whatsapp_campaign_messages_campaign_phone' + ), + ) + op.create_index( + 'ix_whatsapp_campaign_messages_campaign_id', + 'whatsapp_campaign_messages', + ['campaign_id'], + ) + op.create_index( + 'ix_whatsapp_campaign_messages_meta_id', + 'whatsapp_campaign_messages', + ['meta_message_id'], + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index('ix_whatsapp_campaign_messages_meta_id', table_name='whatsapp_campaign_messages') + op.drop_index('ix_whatsapp_campaign_messages_campaign_id', table_name='whatsapp_campaign_messages') + op.drop_table('whatsapp_campaign_messages') + + op.drop_index('ix_whatsapp_campaigns_scheduled_at', table_name='whatsapp_campaigns') + op.drop_index('ix_whatsapp_campaigns_status', table_name='whatsapp_campaigns') + op.drop_index('ix_whatsapp_campaigns_business_id', table_name='whatsapp_campaigns') + op.drop_table('whatsapp_campaigns') + + op.drop_index('ix_whatsapp_templates_meta_template_id', table_name='whatsapp_templates') + op.drop_index('ix_whatsapp_templates_business_id', table_name='whatsapp_templates') + op.drop_table('whatsapp_templates') + + op.drop_table('whatsapp_contact_list_members') + + op.drop_index('ix_whatsapp_contact_lists_business_id', table_name='whatsapp_contact_lists') + op.drop_table('whatsapp_contact_lists') + + op.drop_index('ix_whatsapp_contacts_business_id', table_name='whatsapp_contacts') + op.drop_table('whatsapp_contacts') diff --git a/backend/alembic/versions/d3e4f5a6b7c8_add_whatsapp_send_rate_to_widget_settings.py b/backend/alembic/versions/d3e4f5a6b7c8_add_whatsapp_send_rate_to_widget_settings.py new file mode 100644 index 0000000..994d89e --- /dev/null +++ b/backend/alembic/versions/d3e4f5a6b7c8_add_whatsapp_send_rate_to_widget_settings.py @@ -0,0 +1,31 @@ +"""add whatsapp_send_rate_per_second to widget_settings + +Revision ID: d3e4f5a6b7c8 +Revises: c2d3e4f5a6b7 +Create Date: 2026-04-19 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd3e4f5a6b7c8' +down_revision: Union[str, None] = 'c2d3e4f5a6b7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column( + 'widget_settings', + sa.Column('whatsapp_send_rate_per_second', sa.Integer(), nullable=True), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column('widget_settings', 'whatsapp_send_rate_per_second') diff --git a/backend/app/api/escalation.py b/backend/app/api/escalation.py index 7e4a7b6..47a275c 100644 --- a/backend/app/api/escalation.py +++ b/backend/app/api/escalation.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from app.db.session import get_db from app.core.response_wrapper import success_response @@ -8,23 +8,42 @@ router = APIRouter() @router.get("/", response_model=None) -async def get_escalations(business_id: str, db: Session = Depends(get_db)): - """List escalations for a business.""" +async def get_escalations( + business_id: str, + status: str | None = Query(default=None), + limit: int = Query(default=20, ge=1, le=200), + offset: int = Query(default=0, ge=0), + db: Session = Depends(get_db), +): + """List escalations for a business with pagination.""" # Security note: In production, verify current_user owns business_id - escalations = db.query(Escalation).filter(Escalation.business_id == business_id).order_by(Escalation.created_at.desc()).all() - - results = [] - for esc in escalations: - results.append({ + query = db.query(Escalation).filter(Escalation.business_id == business_id) + if status: + query = query.filter(Escalation.status == status) + + total = query.count() + escalations = ( + query.order_by(Escalation.created_at.desc()) + .offset(offset) + .limit(limit) + .all() + ) + + items = [ + { "id": esc.id, "business_id": esc.business_id, "session_id": esc.session_id, "status": esc.status, "summary": esc.summary, "sentiment": esc.sentiment, - "created_at": esc.created_at.isoformat() if esc.created_at else "" - }) - return success_response(data=results) + "created_at": esc.created_at.isoformat() if esc.created_at else "", + } + for esc in escalations + ] + return success_response( + data={"items": items, "total": total, "limit": limit, "offset": offset} + ) @router.get("/{escalation_id}", response_model=None) async def get_escalation_details(escalation_id: str, db: Session = Depends(get_db)): diff --git a/backend/app/api/whatsapp.py b/backend/app/api/whatsapp.py index c8b23b7..8712140 100644 --- a/backend/app/api/whatsapp.py +++ b/backend/app/api/whatsapp.py @@ -7,6 +7,11 @@ from app.models.widget import WidgetSettings, GuestUser from app.models.chat_session import ChatSession, SessionChannel from app.models.user import User +from app.models.whatsapp_broadcast import ( + CampaignMessageStatus, + WhatsAppCampaign, + WhatsAppCampaignMessage, +) from app.core.config import settings from app.services.whatsapp_service import send_whatsapp_message, verify_webhook_signature @@ -54,9 +59,22 @@ async def whatsapp_incoming(request: Request): return Response(status_code=200) value = changes[0].get("value", {}) + + # Delivery / read status callbacks from outbound campaigns. + statuses = value.get("statuses") + if statuses: + db = next(get_db()) + try: + _process_status_updates(db, statuses) + except Exception as e: + print(f"WhatsApp status update error: {e}") + traceback.print_exc() + finally: + db.close() + return Response(status_code=200) + messages = value.get("messages") if not messages: - # Status update or other non-message webhook — ignore return Response(status_code=200) message = messages[0] @@ -196,3 +214,77 @@ async def _process_whatsapp_message( from_phone, ai_text, ) + + +_STATUS_TO_ENUM = { + "sent": CampaignMessageStatus.SENT, + "delivered": CampaignMessageStatus.DELIVERED, + "read": CampaignMessageStatus.READ, + "failed": CampaignMessageStatus.FAILED, +} + +# Rank used to prevent regressions when Meta delivers events out of order. +_STATUS_RANK = { + CampaignMessageStatus.QUEUED.value: 0, + CampaignMessageStatus.SENT.value: 1, + CampaignMessageStatus.DELIVERED.value: 2, + CampaignMessageStatus.READ.value: 3, + CampaignMessageStatus.FAILED.value: 4, +} + + +def _process_status_updates(db: Session, statuses: list[dict]) -> None: + """Update campaign messages from Meta `statuses[]` webhook entries.""" + for entry in statuses: + meta_id = entry.get("id") + raw_status = (entry.get("status") or "").lower() + if not meta_id or raw_status not in _STATUS_TO_ENUM: + continue + + new_status = _STATUS_TO_ENUM[raw_status] + msg = ( + db.query(WhatsAppCampaignMessage) + .filter(WhatsAppCampaignMessage.meta_message_id == meta_id) + .first() + ) + if not msg: + continue + + # Idempotency / out-of-order protection: only move forward. + current_rank = _STATUS_RANK.get(msg.status, 0) + new_rank = _STATUS_RANK[new_status.value] + if new_rank <= current_rank and new_status != CampaignMessageStatus.FAILED: + continue + + previous_status = msg.status + msg.status = new_status.value + now = datetime.now(timezone.utc) + + if new_status == CampaignMessageStatus.DELIVERED and not msg.delivered_at: + msg.delivered_at = now + elif new_status == CampaignMessageStatus.READ and not msg.read_at: + msg.read_at = now + elif new_status == CampaignMessageStatus.FAILED: + errors = entry.get("errors") or [] + if errors: + err = errors[0] + msg.error_code = str(err.get("code", ""))[:100] + msg.error_message = str(err.get("title") or err.get("message") or "")[:500] + + # Aggregate into campaign counts (only on first transition into each state). + campaign = ( + db.query(WhatsAppCampaign) + .filter(WhatsAppCampaign.id == msg.campaign_id) + .first() + ) + if campaign and previous_status != new_status.value: + if new_status == CampaignMessageStatus.DELIVERED: + campaign.delivered_count += 1 + elif new_status == CampaignMessageStatus.READ: + campaign.read_count += 1 + elif new_status == CampaignMessageStatus.FAILED: + campaign.failed_count += 1 + if previous_status == CampaignMessageStatus.SENT.value: + campaign.sent_count = max(0, campaign.sent_count - 1) + + db.commit() diff --git a/backend/app/api/whatsapp_campaigns.py b/backend/app/api/whatsapp_campaigns.py new file mode 100644 index 0000000..cfd097a --- /dev/null +++ b/backend/app/api/whatsapp_campaigns.py @@ -0,0 +1,206 @@ +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.auth.router import get_current_user +from app.core.response_wrapper import success_response +from app.db.session import get_db +from app.models.business import Business +from app.models.user import User +from app.models.whatsapp_broadcast import ( + WhatsAppCampaign, + WhatsAppCampaignMessage, +) +from app.schemas.whatsapp import ( + CampaignCreateRequest, + CampaignMessageResponse, + CampaignResponse, + CampaignSendRequest, +) +from app.services.whatsapp import campaigns as campaign_service + +router = APIRouter() + + +def _get_business_or_404(db: Session, user: User) -> Business: + business = db.query(Business).filter(Business.user_id == user.id).first() + if not business: + raise HTTPException(status_code=404, detail="Business profile not found") + return business + + +def _serialize_campaign(campaign: WhatsAppCampaign) -> dict: + return CampaignResponse.model_validate(campaign).model_dump(mode="json") + + +@router.get("/campaigns", response_model=None) +async def list_campaigns( + status: str | None = Query(default=None), + limit: int = Query(default=50, le=500), + offset: int = Query(default=0, ge=0), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + query = db.query(WhatsAppCampaign).filter(WhatsAppCampaign.business_id == business.id) + if status: + query = query.filter(WhatsAppCampaign.status == status) + total = query.count() + rows = ( + query.order_by(WhatsAppCampaign.created_at.desc()) + .offset(offset) + .limit(limit) + .all() + ) + data = { + "items": [_serialize_campaign(c) for c in rows], + "total": total, + "limit": limit, + "offset": offset, + } + return success_response(data=data) + + +@router.post("/campaigns", response_model=None) +async def create_campaign( + payload: CampaignCreateRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + try: + campaign = campaign_service.create_campaign( + db, + business.id, + name=payload.name, + template_id=payload.template_id, + audience_type=payload.audience_type, + audience_ref=payload.audience_ref, + variable_mapping=payload.variable_mapping, + created_by_user_id=current_user.id, + scheduled_at=payload.scheduled_at, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + if payload.send_now or payload.scheduled_at: + campaign = campaign_service.schedule_campaign(db, campaign, payload.scheduled_at) + + return success_response( + message="Campaign created", data=_serialize_campaign(campaign) + ) + + +@router.get("/campaigns/{campaign_id}", response_model=None) +async def get_campaign( + campaign_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + campaign = ( + db.query(WhatsAppCampaign) + .filter( + WhatsAppCampaign.id == campaign_id, + WhatsAppCampaign.business_id == business.id, + ) + .first() + ) + if not campaign: + raise HTTPException(status_code=404, detail="Campaign not found") + return success_response(data=_serialize_campaign(campaign)) + + +@router.get("/campaigns/{campaign_id}/messages", response_model=None) +async def list_campaign_messages( + campaign_id: str, + status: str | None = Query(default=None), + limit: int = Query(default=100, le=1000), + offset: int = Query(default=0, ge=0), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + campaign = ( + db.query(WhatsAppCampaign) + .filter( + WhatsAppCampaign.id == campaign_id, + WhatsAppCampaign.business_id == business.id, + ) + .first() + ) + if not campaign: + raise HTTPException(status_code=404, detail="Campaign not found") + query = db.query(WhatsAppCampaignMessage).filter( + WhatsAppCampaignMessage.campaign_id == campaign.id + ) + if status: + query = query.filter(WhatsAppCampaignMessage.status == status) + total = query.count() + rows = ( + query.order_by(WhatsAppCampaignMessage.created_at.asc()) + .offset(offset) + .limit(limit) + .all() + ) + data = { + "items": [CampaignMessageResponse.model_validate(r).model_dump(mode="json") for r in rows], + "total": total, + "limit": limit, + "offset": offset, + } + return success_response(data=data) + + +@router.post("/campaigns/{campaign_id}/send", response_model=None) +async def send_campaign( + campaign_id: str, + payload: CampaignSendRequest | None = None, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + campaign = ( + db.query(WhatsAppCampaign) + .filter( + WhatsAppCampaign.id == campaign_id, + WhatsAppCampaign.business_id == business.id, + ) + .first() + ) + if not campaign: + raise HTTPException(status_code=404, detail="Campaign not found") + + scheduled_at = payload.scheduled_at if payload else None + if scheduled_at is None: + scheduled_at = datetime.now(timezone.utc) + try: + campaign = campaign_service.schedule_campaign(db, campaign, scheduled_at) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + return success_response(message="Campaign scheduled", data=_serialize_campaign(campaign)) + + +@router.post("/campaigns/{campaign_id}/cancel", response_model=None) +async def cancel_campaign( + campaign_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + campaign = ( + db.query(WhatsAppCampaign) + .filter( + WhatsAppCampaign.id == campaign_id, + WhatsAppCampaign.business_id == business.id, + ) + .first() + ) + if not campaign: + raise HTTPException(status_code=404, detail="Campaign not found") + try: + campaign = campaign_service.cancel_campaign(db, campaign) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + return success_response(message="Campaign cancelled", data=_serialize_campaign(campaign)) diff --git a/backend/app/api/whatsapp_contacts.py b/backend/app/api/whatsapp_contacts.py new file mode 100644 index 0000000..e8e4522 --- /dev/null +++ b/backend/app/api/whatsapp_contacts.py @@ -0,0 +1,322 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query +from sqlalchemy.orm import Session + +from app.auth.router import get_current_user +from app.core.response_wrapper import success_response +from app.db.session import get_db +from app.models.business import Business +from app.models.user import User +from app.models.whatsapp_broadcast import ( + WhatsAppContact, + WhatsAppContactList, + WhatsAppContactListMember, +) +from app.schemas.whatsapp import ( + ContactCreateRequest, + ContactCsvImportResponse, + ContactListCreateRequest, + ContactListMembersRequest, + ContactListResponse, + ContactListUpdateRequest, + ContactResponse, + ContactUpdateRequest, + GuestImportRequest, + GuestImportResponse, +) +from app.services.whatsapp import contacts as contact_service + +router = APIRouter() + + +def _get_business_or_404(db: Session, user: User) -> Business: + business = db.query(Business).filter(Business.user_id == user.id).first() + if not business: + raise HTTPException(status_code=404, detail="Business profile not found") + return business + + +# ---------- Contacts ---------- + + +@router.get("/contacts", response_model=None) +async def list_contacts( + q: str | None = Query(default=None), + limit: int = Query(default=50, le=500), + offset: int = Query(default=0, ge=0), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + query = db.query(WhatsAppContact).filter(WhatsAppContact.business_id == business.id) + if q: + like = f"%{q}%" + query = query.filter( + (WhatsAppContact.phone_e164.ilike(like)) + | (WhatsAppContact.name.ilike(like)) + ) + total = query.count() + rows = ( + query.order_by(WhatsAppContact.created_at.desc()) + .offset(offset) + .limit(limit) + .all() + ) + data = { + "items": [ContactResponse.model_validate(r).model_dump(mode="json") for r in rows], + "total": total, + "limit": limit, + "offset": offset, + } + return success_response(data=data) + + +@router.post("/contacts", response_model=None) +async def create_contact( + payload: ContactCreateRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + try: + contact = contact_service.create_contact( + db, + business.id, + phone=payload.phone, + name=payload.name, + tags=payload.tags, + opted_in=payload.opted_in, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + return success_response( + message="Contact saved", + data=ContactResponse.model_validate(contact).model_dump(mode="json"), + ) + + +@router.patch("/contacts/{contact_id}", response_model=None) +async def update_contact( + contact_id: str, + payload: ContactUpdateRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + contact = ( + db.query(WhatsAppContact) + .filter( + WhatsAppContact.id == contact_id, + WhatsAppContact.business_id == business.id, + ) + .first() + ) + if not contact: + raise HTTPException(status_code=404, detail="Contact not found") + if payload.name is not None: + contact.name = payload.name + if payload.tags is not None: + contact.tags = payload.tags + if payload.opted_in is not None: + contact.opted_in = payload.opted_in + db.commit() + db.refresh(contact) + return success_response( + data=ContactResponse.model_validate(contact).model_dump(mode="json") + ) + + +@router.delete("/contacts/{contact_id}", response_model=None) +async def delete_contact( + contact_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + contact = ( + db.query(WhatsAppContact) + .filter( + WhatsAppContact.id == contact_id, + WhatsAppContact.business_id == business.id, + ) + .first() + ) + if not contact: + raise HTTPException(status_code=404, detail="Contact not found") + db.delete(contact) + db.commit() + return success_response(message="Contact deleted") + + +@router.post("/contacts/csv", response_model=None) +async def upload_csv( + file: UploadFile = File(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + content = await file.read() + result = contact_service.import_csv(db, business.id, content) + return success_response( + message=f"Imported {result.imported}, skipped {result.skipped}", + data=ContactCsvImportResponse( + imported=result.imported, skipped=result.skipped, errors=result.errors + ).model_dump(mode="json"), + ) + + +@router.post("/contacts/import-guests", response_model=None) +async def import_guests( + payload: GuestImportRequest | None = None, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + payload = payload or GuestImportRequest() + count = contact_service.import_from_guests( + db, + business.id, + min_sessions=payload.min_sessions, + last_seen_after=payload.last_seen_after, + ) + return success_response( + message=f"Imported {count} contacts from WhatsApp chats", + data=GuestImportResponse(imported=count).model_dump(mode="json"), + ) + + +# ---------- Contact Lists ---------- + + +def _serialize_list(db: Session, lst: WhatsAppContactList) -> dict: + member_count = ( + db.query(WhatsAppContactListMember) + .filter(WhatsAppContactListMember.contact_list_id == lst.id) + .count() + ) + base = ContactListResponse.model_validate(lst).model_dump(mode="json") + base["member_count"] = member_count + return base + + +@router.get("/contact-lists", response_model=None) +async def list_contact_lists( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + rows = ( + db.query(WhatsAppContactList) + .filter(WhatsAppContactList.business_id == business.id) + .order_by(WhatsAppContactList.created_at.desc()) + .all() + ) + data = [_serialize_list(db, r) for r in rows] + return success_response(data=data) + + +@router.post("/contact-lists", response_model=None) +async def create_contact_list( + payload: ContactListCreateRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + lst = contact_service.create_list( + db, business.id, name=payload.name, description=payload.description + ) + return success_response( + message="Contact list created", data=_serialize_list(db, lst) + ) + + +@router.patch("/contact-lists/{list_id}", response_model=None) +async def update_contact_list( + list_id: str, + payload: ContactListUpdateRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + lst = ( + db.query(WhatsAppContactList) + .filter( + WhatsAppContactList.id == list_id, + WhatsAppContactList.business_id == business.id, + ) + .first() + ) + if not lst: + raise HTTPException(status_code=404, detail="Contact list not found") + if payload.name is not None: + lst.name = payload.name + if payload.description is not None: + lst.description = payload.description + db.commit() + db.refresh(lst) + return success_response(data=_serialize_list(db, lst)) + + +@router.delete("/contact-lists/{list_id}", response_model=None) +async def delete_contact_list( + list_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + lst = ( + db.query(WhatsAppContactList) + .filter( + WhatsAppContactList.id == list_id, + WhatsAppContactList.business_id == business.id, + ) + .first() + ) + if not lst: + raise HTTPException(status_code=404, detail="Contact list not found") + db.delete(lst) + db.commit() + return success_response(message="Contact list deleted") + + +@router.post("/contact-lists/{list_id}/members", response_model=None) +async def add_list_members( + list_id: str, + payload: ContactListMembersRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + lst = ( + db.query(WhatsAppContactList) + .filter( + WhatsAppContactList.id == list_id, + WhatsAppContactList.business_id == business.id, + ) + .first() + ) + if not lst: + raise HTTPException(status_code=404, detail="Contact list not found") + added = contact_service.add_members(db, lst, payload.contact_ids) + return success_response(message=f"Added {added} member(s)", data=_serialize_list(db, lst)) + + +@router.delete("/contact-lists/{list_id}/members", response_model=None) +async def remove_list_members( + list_id: str, + payload: ContactListMembersRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + lst = ( + db.query(WhatsAppContactList) + .filter( + WhatsAppContactList.id == list_id, + WhatsAppContactList.business_id == business.id, + ) + .first() + ) + if not lst: + raise HTTPException(status_code=404, detail="Contact list not found") + removed = contact_service.remove_members(db, lst, payload.contact_ids) + return success_response(message=f"Removed {removed} member(s)", data=_serialize_list(db, lst)) diff --git a/backend/app/api/whatsapp_templates.py b/backend/app/api/whatsapp_templates.py new file mode 100644 index 0000000..f4c345a --- /dev/null +++ b/backend/app/api/whatsapp_templates.py @@ -0,0 +1,223 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.auth.router import get_current_user +from app.core.response_wrapper import success_response +from app.db.session import get_db +from app.models.business import Business +from app.models.user import User +from app.models.whatsapp_broadcast import TemplateStatus, WhatsAppTemplate +from app.schemas.whatsapp import ( + TemplateCreateRequest, + TemplateResponse, + TemplateUpdateRequest, +) +from app.services.whatsapp import templates as template_service +from app.services.whatsapp.client import WhatsAppAPIError + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def _meta_user_message(err: WhatsAppAPIError) -> str: + """Extract Meta's human-readable error, falling back to generic text.""" + body = err.body if isinstance(err.body, dict) else {} + meta_error = body.get("error") if isinstance(body.get("error"), dict) else {} + return ( + meta_error.get("error_user_msg") + or meta_error.get("error_user_title") + or meta_error.get("message") + or f"Meta rejected the request (status {err.status_code})" + ) + + +def _get_business_or_404(db: Session, user: User) -> Business: + business = db.query(Business).filter(Business.user_id == user.id).first() + if not business: + raise HTTPException(status_code=404, detail="Business profile not found") + return business + + +@router.get("/templates", response_model=None) +async def list_templates( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + rows = ( + db.query(WhatsAppTemplate) + .filter(WhatsAppTemplate.business_id == business.id) + .order_by(WhatsAppTemplate.created_at.desc()) + .all() + ) + data = [TemplateResponse.model_validate(r).model_dump(mode="json") for r in rows] + return success_response(data=data) + + +@router.post("/templates", response_model=None) +async def create_template( + payload: TemplateCreateRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + try: + template = template_service.create_draft( + db, + business.id, + name=payload.name, + category=payload.category, + language=payload.language, + body_text=payload.body_text, + header=payload.header, + footer=payload.footer, + buttons=payload.buttons, + ) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + return success_response( + message="Template draft created", + data=TemplateResponse.model_validate(template).model_dump(mode="json"), + ) + + +@router.get("/templates/{template_id}", response_model=None) +async def get_template( + template_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + template = ( + db.query(WhatsAppTemplate) + .filter( + WhatsAppTemplate.id == template_id, + WhatsAppTemplate.business_id == business.id, + ) + .first() + ) + if not template: + raise HTTPException(status_code=404, detail="Template not found") + return success_response( + data=TemplateResponse.model_validate(template).model_dump(mode="json") + ) + + +@router.patch("/templates/{template_id}", response_model=None) +async def update_template( + template_id: str, + payload: TemplateUpdateRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + template = ( + db.query(WhatsAppTemplate) + .filter( + WhatsAppTemplate.id == template_id, + WhatsAppTemplate.business_id == business.id, + ) + .first() + ) + if not template: + raise HTTPException(status_code=404, detail="Template not found") + try: + template = template_service.update_draft( + db, + template, + name=payload.name, + category=payload.category, + language=payload.language, + body_text=payload.body_text, + header=payload.header, + footer=payload.footer, + buttons=payload.buttons, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + return success_response( + message="Template updated", + data=TemplateResponse.model_validate(template).model_dump(mode="json"), + ) + + +@router.post("/templates/{template_id}/submit", response_model=None) +async def submit_template( + template_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + template = ( + db.query(WhatsAppTemplate) + .filter( + WhatsAppTemplate.id == template_id, + WhatsAppTemplate.business_id == business.id, + ) + .first() + ) + if not template: + raise HTTPException(status_code=404, detail="Template not found") + if template.status != TemplateStatus.DRAFT.value: + raise HTTPException( + status_code=400, + detail=f"Template is already in status {template.status}", + ) + try: + template = await template_service.submit_to_meta(db, template) + except WhatsAppAPIError as e: + logger.exception("Meta rejected template submission (status=%s body=%s)", e.status_code, e.body) + raise HTTPException(status_code=502, detail=_meta_user_message(e)) + except Exception as e: + logger.exception("Unexpected error submitting template to Meta") + raise HTTPException(status_code=502, detail=f"Meta API error: {e}") + return success_response( + message="Template submitted to Meta", + data=TemplateResponse.model_validate(template).model_dump(mode="json"), + ) + + +@router.post("/templates/import", response_model=None) +async def import_templates( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + try: + imported = await template_service.import_from_meta(db, business.id) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except WhatsAppAPIError as e: + logger.exception("Meta rejected template import (status=%s body=%s)", e.status_code, e.body) + raise HTTPException(status_code=502, detail=_meta_user_message(e)) + except Exception as e: + logger.exception("Unexpected error importing templates from Meta") + raise HTTPException(status_code=502, detail=f"Meta API error: {e}") + data = [TemplateResponse.model_validate(t).model_dump(mode="json") for t in imported] + return success_response( + message=f"Imported {len(imported)} approved template(s)", data=data + ) + + +@router.delete("/templates/{template_id}", response_model=None) +async def delete_template_endpoint( + template_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business_or_404(db, current_user) + template = ( + db.query(WhatsAppTemplate) + .filter( + WhatsAppTemplate.id == template_id, + WhatsAppTemplate.business_id == business.id, + ) + .first() + ) + if not template: + raise HTTPException(status_code=404, detail="Template not found") + await template_service.delete_template(db, template) + return success_response(message="Template deleted") diff --git a/backend/app/api/widget.py b/backend/app/api/widget.py index 6865bb0..84f5438 100644 --- a/backend/app/api/widget.py +++ b/backend/app/api/widget.py @@ -1,4 +1,5 @@ -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, Query +from sqlalchemy import or_ from sqlalchemy.orm import Session from typing import List, Optional import uuid @@ -42,6 +43,7 @@ class WidgetUpdate(BaseModel): whatsapp_phone_number_id: Optional[str] = None whatsapp_business_account_id: Optional[str] = None whatsapp_access_token: Optional[str] = None + whatsapp_send_rate_per_second: Optional[int] = None max_messages_per_session: Optional[int] = None max_sessions_per_day: Optional[int] = None max_sessions_per_day: Optional[int] = None @@ -177,6 +179,18 @@ def update_my_widget_settings( if settings.whatsapp_access_token is not None: widget.whatsapp_access_token = settings.whatsapp_access_token or None + if settings.whatsapp_send_rate_per_second is not None: + rate = settings.whatsapp_send_rate_per_second + if rate == 0: + widget.whatsapp_send_rate_per_second = None + elif rate < 1 or rate > 80: + raise HTTPException( + status_code=400, + detail="Send rate must be between 1 and 80 messages/second.", + ) + else: + widget.whatsapp_send_rate_per_second = rate + if settings.max_messages_per_session is not None: widget.max_messages_per_session = settings.max_messages_per_session @@ -199,13 +213,67 @@ def update_my_widget_settings( return success_response(data=WidgetConfigResponse.model_validate(widget)) @router.get("/guests", response_model=None) -def get_my_widget_guests(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): +def get_my_widget_guests( + q: str | None = Query(default=None), + limit: int = Query(default=20, ge=1, le=200), + offset: int = Query(default=0, ge=0), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): widget = db.query(WidgetSettings).filter(WidgetSettings.user_id == current_user.id).first() if not widget: - return success_response(data=[]) - - guests = db.query(GuestUser).filter(GuestUser.widget_id == widget.id).order_by(GuestUser.created_at.desc()).all() - return success_response(data=[GuestUserResponse.model_validate(g) for g in guests]) + return success_response( + data={"items": [], "total": 0, "limit": limit, "offset": offset} + ) + + query = db.query(GuestUser).filter(GuestUser.widget_id == widget.id) + if q: + like = f"%{q}%" + query = query.filter( + or_( + GuestUser.name.ilike(like), + GuestUser.email.ilike(like), + GuestUser.phone.ilike(like), + ) + ) + + total = query.count() + guests = ( + query.order_by(GuestUser.created_at.desc()) + .offset(offset) + .limit(limit) + .all() + ) + + return success_response( + data={ + "items": [GuestUserResponse.model_validate(g) for g in guests], + "total": total, + "limit": limit, + "offset": offset, + } + ) + + +@router.get("/guests/{guest_id}", response_model=None) +def get_my_widget_guest( + guest_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + widget = db.query(WidgetSettings).filter(WidgetSettings.user_id == current_user.id).first() + if not widget: + raise HTTPException(status_code=404, detail="Widget not found") + + guest = ( + db.query(GuestUser) + .filter(GuestUser.id == guest_id, GuestUser.widget_id == widget.id) + .first() + ) + if not guest: + raise HTTPException(status_code=404, detail="Guest not found") + + return success_response(data=GuestUserResponse.model_validate(guest)) class LeadStatusUpdate(BaseModel): is_lead: bool @@ -723,9 +791,28 @@ async def process_chat_message(db: Session, widget: WidgetSettings, guest: Guest ) @router.get("/sessions/{guest_id}/history", response_model=None) -def get_guest_session_history(guest_id: str, db: Session = Depends(get_db)): - sessions = db.query(ChatSession).filter(ChatSession.guest_id == guest_id).order_by(ChatSession.created_at.desc()).all() - return success_response(data=[SessionHistoryResponse.model_validate(s) for s in sessions]) +def get_guest_session_history( + guest_id: str, + limit: int = Query(default=20, ge=1, le=200), + offset: int = Query(default=0, ge=0), + db: Session = Depends(get_db), +): + query = db.query(ChatSession).filter(ChatSession.guest_id == guest_id) + total = query.count() + sessions = ( + query.order_by(ChatSession.created_at.desc()) + .offset(offset) + .limit(limit) + .all() + ) + return success_response( + data={ + "items": [SessionHistoryResponse.model_validate(s) for s in sessions], + "total": total, + "limit": limit, + "offset": offset, + } + ) @router.get("/session/{session_id}/messages", response_model=List[GuestMessageSchema]) def get_session_messages(session_id: str, db: Session = Depends(get_db)): diff --git a/backend/app/core/config.py b/backend/app/core/config.py index a08cfdd..42ece93 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -40,6 +40,8 @@ class BaseConfig(BaseSettings): # WhatsApp Cloud API WHATSAPP_VERIFY_TOKEN: str = os.getenv("WHATSAPP_VERIFY_TOKEN", "") WHATSAPP_APP_SECRET: str = os.getenv("WHATSAPP_APP_SECRET", "") + WHATSAPP_CAMPAIGN_POLL_INTERVAL_SECONDS: int = int(os.getenv("WHATSAPP_CAMPAIGN_POLL_INTERVAL_SECONDS", "10")) + WHATSAPP_CAMPAIGN_SEND_RATE_PER_SECOND: int = int(os.getenv("WHATSAPP_CAMPAIGN_SEND_RATE_PER_SECOND", "20")) # JWT diff --git a/backend/app/main.py b/backend/app/main.py index afcadd0..adaf1c2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -86,6 +86,15 @@ from app.api.whatsapp import router as whatsapp_router app.include_router(whatsapp_router, prefix="/whatsapp", tags=["whatsapp"]) +from app.api.whatsapp_templates import router as whatsapp_templates_router +app.include_router(whatsapp_templates_router, prefix="/whatsapp", tags=["whatsapp"]) + +from app.api.whatsapp_contacts import router as whatsapp_contacts_router +app.include_router(whatsapp_contacts_router, prefix="/whatsapp", tags=["whatsapp"]) + +from app.api.whatsapp_campaigns import router as whatsapp_campaigns_router +app.include_router(whatsapp_campaigns_router, prefix="/whatsapp", tags=["whatsapp"]) + from app.core.response_wrapper import success_response @app.get("/") diff --git a/backend/app/models/whatsapp_broadcast.py b/backend/app/models/whatsapp_broadcast.py new file mode 100644 index 0000000..76c9a91 --- /dev/null +++ b/backend/app/models/whatsapp_broadcast.py @@ -0,0 +1,221 @@ +import uuid +import enum +from datetime import datetime, timezone + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + ForeignKey, + Integer, + JSON, + String, + Text, + UniqueConstraint, + Index, +) +from sqlalchemy.orm import relationship + +from app.db.base import Base +from app.models.mixins import SerializerMixin + + +def generate_uuid(): + return str(uuid.uuid4()) + + +def utcnow(): + return datetime.now(timezone.utc) + + +class ContactSource(str, enum.Enum): + MANUAL = "manual" + CSV = "csv" + GUEST_IMPORT = "guest_import" + + +class TemplateCategory(str, enum.Enum): + MARKETING = "MARKETING" + UTILITY = "UTILITY" + AUTHENTICATION = "AUTHENTICATION" + + +class TemplateStatus(str, enum.Enum): + DRAFT = "DRAFT" + PENDING = "PENDING" + APPROVED = "APPROVED" + REJECTED = "REJECTED" + PAUSED = "PAUSED" + DISABLED = "DISABLED" + + +class TemplateSource(str, enum.Enum): + CREATED = "CREATED" + IMPORTED = "IMPORTED" + + +class CampaignAudienceType(str, enum.Enum): + LIST = "LIST" + GUESTS_FILTER = "GUESTS_FILTER" + ADHOC = "ADHOC" + + +class CampaignStatus(str, enum.Enum): + DRAFT = "DRAFT" + SCHEDULED = "SCHEDULED" + SENDING = "SENDING" + COMPLETED = "COMPLETED" + CANCELLED = "CANCELLED" + FAILED = "FAILED" + + +class CampaignMessageStatus(str, enum.Enum): + QUEUED = "QUEUED" + SENT = "SENT" + DELIVERED = "DELIVERED" + READ = "READ" + FAILED = "FAILED" + + +class WhatsAppContact(Base, SerializerMixin): + __tablename__ = "whatsapp_contacts" + __table_args__ = ( + UniqueConstraint("business_id", "phone_e164", name="uq_whatsapp_contacts_business_phone"), + ) + + id = Column(String, primary_key=True, default=generate_uuid) + business_id = Column(String, ForeignKey("businesses.id"), nullable=False, index=True) + phone_e164 = Column(String, nullable=False) + name = Column(String, nullable=True) + tags = Column(JSON, nullable=True) + source = Column(String, default=ContactSource.MANUAL.value, nullable=False) + opted_in = Column(Boolean, default=True, nullable=False) + last_contacted_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=utcnow, nullable=False) + updated_at = Column(DateTime, default=utcnow, onupdate=utcnow, nullable=False) + + business = relationship("Business", backref="whatsapp_contacts") + list_memberships = relationship( + "WhatsAppContactListMember", back_populates="contact", cascade="all, delete-orphan" + ) + + +class WhatsAppContactList(Base, SerializerMixin): + __tablename__ = "whatsapp_contact_lists" + + id = Column(String, primary_key=True, default=generate_uuid) + business_id = Column(String, ForeignKey("businesses.id"), nullable=False, index=True) + name = Column(String, nullable=False) + description = Column(Text, nullable=True) + created_at = Column(DateTime, default=utcnow, nullable=False) + updated_at = Column(DateTime, default=utcnow, onupdate=utcnow, nullable=False) + + business = relationship("Business", backref="whatsapp_contact_lists") + members = relationship( + "WhatsAppContactListMember", back_populates="contact_list", cascade="all, delete-orphan" + ) + + +class WhatsAppContactListMember(Base, SerializerMixin): + __tablename__ = "whatsapp_contact_list_members" + + contact_list_id = Column( + String, ForeignKey("whatsapp_contact_lists.id"), primary_key=True + ) + contact_id = Column( + String, ForeignKey("whatsapp_contacts.id"), primary_key=True + ) + created_at = Column(DateTime, default=utcnow, nullable=False) + + contact_list = relationship("WhatsAppContactList", back_populates="members") + contact = relationship("WhatsAppContact", back_populates="list_memberships") + + +class WhatsAppTemplate(Base, SerializerMixin): + __tablename__ = "whatsapp_templates" + __table_args__ = ( + UniqueConstraint( + "business_id", "name", "language", name="uq_whatsapp_templates_business_name_lang" + ), + ) + + id = Column(String, primary_key=True, default=generate_uuid) + business_id = Column(String, ForeignKey("businesses.id"), nullable=False, index=True) + meta_template_id = Column(String, nullable=True, index=True) + name = Column(String, nullable=False) + category = Column(String, nullable=False) + language = Column(String, nullable=False, default="en_US") + header = Column(JSON, nullable=True) + body_text = Column(Text, nullable=False) + footer = Column(String, nullable=True) + buttons = Column(JSON, nullable=True) + variables = Column(JSON, nullable=True) + status = Column(String, default=TemplateStatus.DRAFT.value, nullable=False) + rejection_reason = Column(Text, nullable=True) + source = Column(String, default=TemplateSource.CREATED.value, nullable=False) + created_at = Column(DateTime, default=utcnow, nullable=False) + updated_at = Column(DateTime, default=utcnow, onupdate=utcnow, nullable=False) + + business = relationship("Business", backref="whatsapp_templates") + + +class WhatsAppCampaign(Base, SerializerMixin): + __tablename__ = "whatsapp_campaigns" + + id = Column(String, primary_key=True, default=generate_uuid) + business_id = Column(String, ForeignKey("businesses.id"), nullable=False, index=True) + name = Column(String, nullable=False) + template_id = Column(String, ForeignKey("whatsapp_templates.id"), nullable=False) + + audience_type = Column(String, nullable=False) + audience_ref = Column(JSON, nullable=True) + variable_mapping = Column(JSON, nullable=True) + + status = Column(String, default=CampaignStatus.DRAFT.value, nullable=False, index=True) + scheduled_at = Column(DateTime, nullable=True, index=True) + started_at = Column(DateTime, nullable=True) + completed_at = Column(DateTime, nullable=True) + + total_recipients = Column(Integer, default=0, nullable=False) + sent_count = Column(Integer, default=0, nullable=False) + delivered_count = Column(Integer, default=0, nullable=False) + read_count = Column(Integer, default=0, nullable=False) + failed_count = Column(Integer, default=0, nullable=False) + + created_by_user_id = Column(String, ForeignKey("users.id"), nullable=True) + created_at = Column(DateTime, default=utcnow, nullable=False) + updated_at = Column(DateTime, default=utcnow, onupdate=utcnow, nullable=False) + + business = relationship("Business", backref="whatsapp_campaigns") + template = relationship("WhatsAppTemplate") + messages = relationship( + "WhatsAppCampaignMessage", back_populates="campaign", cascade="all, delete-orphan" + ) + + +class WhatsAppCampaignMessage(Base, SerializerMixin): + __tablename__ = "whatsapp_campaign_messages" + __table_args__ = ( + UniqueConstraint( + "campaign_id", "contact_phone", name="uq_whatsapp_campaign_messages_campaign_phone" + ), + Index("ix_whatsapp_campaign_messages_meta_id", "meta_message_id"), + ) + + id = Column(String, primary_key=True, default=generate_uuid) + campaign_id = Column( + String, ForeignKey("whatsapp_campaigns.id"), nullable=False, index=True + ) + contact_phone = Column(String, nullable=False) + variables_snapshot = Column(JSON, nullable=True) + meta_message_id = Column(String, nullable=True) + status = Column(String, default=CampaignMessageStatus.QUEUED.value, nullable=False) + error_code = Column(String, nullable=True) + error_message = Column(Text, nullable=True) + sent_at = Column(DateTime, nullable=True) + delivered_at = Column(DateTime, nullable=True) + read_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=utcnow, nullable=False) + updated_at = Column(DateTime, default=utcnow, onupdate=utcnow, nullable=False) + + campaign = relationship("WhatsAppCampaign", back_populates="messages") diff --git a/backend/app/models/widget.py b/backend/app/models/widget.py index ff77cd2..75532d3 100644 --- a/backend/app/models/widget.py +++ b/backend/app/models/widget.py @@ -27,6 +27,7 @@ class WidgetSettings(Base, SerializerMixin): whatsapp_phone_number_id = Column(String, nullable=True) whatsapp_business_account_id = Column(String, nullable=True) whatsapp_access_token = Column(String, nullable=True) + whatsapp_send_rate_per_second = Column(Integer, nullable=True) # Feature Flags is_active = Column(Boolean, default=True) # Master toggle to enable/disable widget diff --git a/backend/app/schemas/whatsapp.py b/backend/app/schemas/whatsapp.py new file mode 100644 index 0000000..a95138f --- /dev/null +++ b/backend/app/schemas/whatsapp.py @@ -0,0 +1,185 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +# ---------- Templates ---------- + + +class TemplateCreateRequest(BaseModel): + name: str + category: str # MARKETING / UTILITY / AUTHENTICATION + language: str = "en_US" + body_text: str + header: Optional[dict] = None + footer: Optional[str] = None + buttons: Optional[list[dict]] = None + + +class TemplateUpdateRequest(BaseModel): + name: Optional[str] = None + category: Optional[str] = None + language: Optional[str] = None + body_text: Optional[str] = None + header: Optional[dict] = None + footer: Optional[str] = None + buttons: Optional[list[dict]] = None + + +class TemplateResponse(BaseModel): + id: str + business_id: str + meta_template_id: Optional[str] = None + name: str + category: str + language: str + header: Optional[dict] = None + body_text: str + footer: Optional[str] = None + buttons: Optional[list] = None + variables: Optional[list] = None + status: str + rejection_reason: Optional[str] = None + source: str + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +# ---------- Contacts ---------- + + +class ContactCreateRequest(BaseModel): + phone: str + name: Optional[str] = None + tags: Optional[list[str]] = None + opted_in: bool = True + + +class ContactUpdateRequest(BaseModel): + name: Optional[str] = None + tags: Optional[list[str]] = None + opted_in: Optional[bool] = None + + +class ContactResponse(BaseModel): + id: str + business_id: str + phone_e164: str + name: Optional[str] = None + tags: Optional[list] = None + source: str + opted_in: bool + last_contacted_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class ContactCsvImportResponse(BaseModel): + imported: int + skipped: int + errors: list[str] + + +class GuestImportRequest(BaseModel): + min_sessions: int = 1 + last_seen_after: Optional[datetime] = None + + +class GuestImportResponse(BaseModel): + imported: int + + +# ---------- Contact Lists ---------- + + +class ContactListCreateRequest(BaseModel): + name: str + description: Optional[str] = None + + +class ContactListUpdateRequest(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + + +class ContactListResponse(BaseModel): + id: str + business_id: str + name: str + description: Optional[str] = None + member_count: int = 0 + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class ContactListMembersRequest(BaseModel): + contact_ids: list[str] + + +# ---------- Campaigns ---------- + + +class CampaignCreateRequest(BaseModel): + name: str + template_id: str + audience_type: str # LIST / GUESTS_FILTER / ADHOC + audience_ref: dict = Field(default_factory=dict) + variable_mapping: dict = Field(default_factory=dict) + scheduled_at: Optional[datetime] = None + send_now: bool = False + + +class CampaignSendRequest(BaseModel): + scheduled_at: Optional[datetime] = None + + +class CampaignResponse(BaseModel): + id: str + business_id: str + name: str + template_id: str + audience_type: str + audience_ref: Optional[dict] = None + variable_mapping: Optional[dict] = None + status: str + scheduled_at: Optional[datetime] = None + started_at: Optional[datetime] = None + completed_at: Optional[datetime] = None + total_recipients: int + sent_count: int + delivered_count: int + read_count: int + failed_count: int + created_by_user_id: Optional[str] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class CampaignMessageResponse(BaseModel): + id: str + campaign_id: str + contact_phone: str + variables_snapshot: Optional[dict] = None + meta_message_id: Optional[str] = None + status: str + error_code: Optional[str] = None + error_message: Optional[str] = None + sent_at: Optional[datetime] = None + delivered_at: Optional[datetime] = None + read_at: Optional[datetime] = None + + class Config: + from_attributes = True diff --git a/backend/app/schemas/widget.py b/backend/app/schemas/widget.py index 5eb53f9..3032caa 100644 --- a/backend/app/schemas/widget.py +++ b/backend/app/schemas/widget.py @@ -66,6 +66,7 @@ class WidgetConfigResponse(BaseModel): whatsapp_phone_number_id: Optional[str] = None whatsapp_business_account_id: Optional[str] = None whatsapp_api_configured: Optional[bool] = False + whatsapp_send_rate_per_second: Optional[int] = None max_messages_per_session: Optional[int] = 50 max_sessions_per_day: Optional[int] = 5 whitelisted_domains: Optional[List[str]] = None diff --git a/backend/app/services/whatsapp/__init__.py b/backend/app/services/whatsapp/__init__.py new file mode 100644 index 0000000..e89f181 --- /dev/null +++ b/backend/app/services/whatsapp/__init__.py @@ -0,0 +1,9 @@ +from app.services.whatsapp_service import ( + send_whatsapp_message, + verify_webhook_signature, +) + +__all__ = [ + "send_whatsapp_message", + "verify_webhook_signature", +] diff --git a/backend/app/services/whatsapp/campaigns.py b/backend/app/services/whatsapp/campaigns.py new file mode 100644 index 0000000..484a37e --- /dev/null +++ b/backend/app/services/whatsapp/campaigns.py @@ -0,0 +1,324 @@ +"""Campaign creation, recipient expansion, and execution.""" +import asyncio +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy.orm import Session + +from app.models.business import Business +from app.models.chat_session import ChatSession, SessionChannel +from app.models.widget import GuestUser, WidgetSettings +from app.models.whatsapp_broadcast import ( + CampaignAudienceType, + CampaignMessageStatus, + CampaignStatus, + TemplateStatus, + WhatsAppCampaign, + WhatsAppCampaignMessage, + WhatsAppContact, + WhatsAppContactListMember, + WhatsAppTemplate, +) +from app.services.whatsapp import client as wa_client +from app.services.whatsapp.contacts import normalize_phone + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _get_widget(db: Session, business_id: str) -> WidgetSettings | None: + business = db.query(Business).filter(Business.id == business_id).first() + if not business: + return None + return ( + db.query(WidgetSettings) + .filter(WidgetSettings.user_id == business.user_id) + .first() + ) + + +class RecipientExpansion: + """Per-recipient payload returned by expand_recipients.""" + + def __init__(self, phone: str, variables: dict[str, str]): + self.phone = phone + self.variables = variables + + +def expand_recipients( + db: Session, campaign: WhatsAppCampaign +) -> list[RecipientExpansion]: + """Resolve audience_ref into a concrete list of (phone, variables).""" + audience_type = campaign.audience_type + audience_ref = campaign.audience_ref or {} + variable_mapping = campaign.variable_mapping or {} + + contacts: list[WhatsAppContact] = [] + ad_hoc_phones: list[str] = [] + + if audience_type == CampaignAudienceType.LIST.value: + list_id = audience_ref.get("list_id") + if not list_id: + return [] + contacts = ( + db.query(WhatsAppContact) + .join( + WhatsAppContactListMember, + WhatsAppContactListMember.contact_id == WhatsAppContact.id, + ) + .filter( + WhatsAppContactListMember.contact_list_id == list_id, + WhatsAppContact.business_id == campaign.business_id, + WhatsAppContact.opted_in.is_(True), + ) + .all() + ) + + elif audience_type == CampaignAudienceType.GUESTS_FILTER.value: + widget = _get_widget(db, campaign.business_id) + if not widget: + return [] + query = ( + db.query(GuestUser) + .join(ChatSession, ChatSession.guest_id == GuestUser.id) + .filter( + GuestUser.widget_id == widget.id, + GuestUser.phone.isnot(None), + ChatSession.channel == SessionChannel.WHATSAPP.value, + ) + ) + min_sessions = audience_ref.get("min_sessions") + if min_sessions: + query = query.filter(GuestUser.total_sessions >= int(min_sessions)) + last_seen_after = audience_ref.get("last_seen_after") + if last_seen_after: + query = query.filter(GuestUser.last_seen_at >= datetime.fromisoformat(last_seen_after)) + guests = query.distinct().all() + for g in guests: + normalized = normalize_phone(g.phone) + if normalized: + ad_hoc_phones.append(normalized) + + elif audience_type == CampaignAudienceType.ADHOC.value: + phones = audience_ref.get("phones", []) or [] + for p in phones: + normalized = normalize_phone(p) + if normalized: + ad_hoc_phones.append(normalized) + + recipients: list[RecipientExpansion] = [] + seen_phones: set[str] = set() + + for contact in contacts: + if contact.phone_e164 in seen_phones: + continue + seen_phones.add(contact.phone_e164) + variables = _resolve_variables(variable_mapping, contact=contact) + recipients.append(RecipientExpansion(contact.phone_e164, variables)) + + for phone in ad_hoc_phones: + if phone in seen_phones: + continue + seen_phones.add(phone) + variables = _resolve_variables(variable_mapping, contact=None) + recipients.append(RecipientExpansion(phone, variables)) + + return recipients + + +def _resolve_variables( + variable_mapping: dict[str, Any], *, contact: WhatsAppContact | None +) -> dict[str, str]: + """Resolve the variable_mapping config into concrete per-recipient values. + + variable_mapping shape: {"1": {"type": "literal", "value": "Hello"}} + or {"1": {"type": "field", "field": "name"}}. + """ + resolved: dict[str, str] = {} + for key, spec in variable_mapping.items(): + if not isinstance(spec, dict): + resolved[key] = str(spec or "") + continue + kind = spec.get("type", "literal") + if kind == "literal": + resolved[key] = str(spec.get("value") or "") + elif kind == "field" and contact is not None: + field = spec.get("field") or "" + resolved[key] = str(getattr(contact, field, "") or "") + else: + resolved[key] = "" + return resolved + + +def create_campaign( + db: Session, + business_id: str, + *, + name: str, + template_id: str, + audience_type: str, + audience_ref: dict, + variable_mapping: dict, + created_by_user_id: str | None, + scheduled_at: datetime | None = None, +) -> WhatsAppCampaign: + template = ( + db.query(WhatsAppTemplate) + .filter( + WhatsAppTemplate.id == template_id, + WhatsAppTemplate.business_id == business_id, + ) + .first() + ) + if not template: + raise ValueError("Template not found") + if template.status != TemplateStatus.APPROVED.value: + raise ValueError("Template must be APPROVED before it can be used in a campaign") + + campaign = WhatsAppCampaign( + business_id=business_id, + name=name, + template_id=template_id, + audience_type=audience_type, + audience_ref=audience_ref, + variable_mapping=variable_mapping, + status=CampaignStatus.DRAFT.value, + scheduled_at=scheduled_at, + created_by_user_id=created_by_user_id, + ) + db.add(campaign) + db.flush() + + recipients = expand_recipients(db, campaign) + campaign.total_recipients = len(recipients) + for r in recipients: + db.add( + WhatsAppCampaignMessage( + campaign_id=campaign.id, + contact_phone=r.phone, + variables_snapshot=r.variables, + status=CampaignMessageStatus.QUEUED.value, + ) + ) + db.commit() + db.refresh(campaign) + return campaign + + +def schedule_campaign( + db: Session, campaign: WhatsAppCampaign, scheduled_at: datetime | None +) -> WhatsAppCampaign: + if campaign.status != CampaignStatus.DRAFT.value: + raise ValueError(f"Cannot schedule campaign in status {campaign.status}") + campaign.scheduled_at = scheduled_at or utcnow() + campaign.status = CampaignStatus.SCHEDULED.value + db.commit() + db.refresh(campaign) + return campaign + + +def cancel_campaign(db: Session, campaign: WhatsAppCampaign) -> WhatsAppCampaign: + if campaign.status not in ( + CampaignStatus.DRAFT.value, + CampaignStatus.SCHEDULED.value, + CampaignStatus.SENDING.value, + ): + raise ValueError(f"Cannot cancel campaign in status {campaign.status}") + campaign.status = CampaignStatus.CANCELLED.value + campaign.completed_at = utcnow() + db.commit() + db.refresh(campaign) + return campaign + + +def _build_components_for_send( + template: WhatsAppTemplate, variables: dict[str, str] +) -> list[dict]: + """Assemble the per-message components payload from the template + variable values.""" + ordered_keys = template.variables or [] + components: list[dict] = [] + if ordered_keys: + parameters = [ + {"type": "text", "text": variables.get(key, "")} for key in ordered_keys + ] + components.append({"type": "body", "parameters": parameters}) + return components + + +async def execute_campaign( + db: Session, + campaign: WhatsAppCampaign, + *, + rate_per_second: int = 20, +) -> None: + """Send all QUEUED messages for a campaign. Caller must hold the row lock.""" + widget = _get_widget(db, campaign.business_id) + if not widget or not widget.whatsapp_phone_number_id or not widget.whatsapp_access_token: + campaign.status = CampaignStatus.FAILED.value + campaign.completed_at = utcnow() + db.commit() + return + + template = ( + db.query(WhatsAppTemplate).filter(WhatsAppTemplate.id == campaign.template_id).first() + ) + if not template: + campaign.status = CampaignStatus.FAILED.value + campaign.completed_at = utcnow() + db.commit() + return + + campaign.started_at = utcnow() + campaign.status = CampaignStatus.SENDING.value + db.commit() + + delay = 1.0 / rate_per_second if rate_per_second > 0 else 0.0 + + queued_messages = ( + db.query(WhatsAppCampaignMessage) + .filter( + WhatsAppCampaignMessage.campaign_id == campaign.id, + WhatsAppCampaignMessage.status == CampaignMessageStatus.QUEUED.value, + ) + .all() + ) + + for msg in queued_messages: + # Re-check cancellation between sends. + db.refresh(campaign) + if campaign.status == CampaignStatus.CANCELLED.value: + return + + components = _build_components_for_send(template, msg.variables_snapshot or {}) + try: + resp = await wa_client.send_template_message( + phone_number_id=widget.whatsapp_phone_number_id, + access_token=widget.whatsapp_access_token, + to_phone=msg.contact_phone, + template_name=template.name, + language=template.language, + components=components, + ) + meta_id = None + try: + meta_id = (resp.get("messages") or [{}])[0].get("id") + except Exception: + meta_id = None + msg.meta_message_id = meta_id + msg.status = CampaignMessageStatus.SENT.value + msg.sent_at = utcnow() + campaign.sent_count += 1 + except Exception as e: + msg.status = CampaignMessageStatus.FAILED.value + msg.error_message = str(e)[:500] + campaign.failed_count += 1 + + db.commit() + + if delay > 0: + await asyncio.sleep(delay) + + campaign.status = CampaignStatus.COMPLETED.value + campaign.completed_at = utcnow() + db.commit() diff --git a/backend/app/services/whatsapp/client.py b/backend/app/services/whatsapp/client.py new file mode 100644 index 0000000..f213079 --- /dev/null +++ b/backend/app/services/whatsapp/client.py @@ -0,0 +1,121 @@ +"""Meta WhatsApp Cloud API client. + +All functions are stateless and accept the tenant's `access_token` and +relevant IDs explicitly. No module-level state. +""" +from typing import Any + +import httpx + +GRAPH_BASE = "https://graph.facebook.com/v21.0" + + +class WhatsAppAPIError(Exception): + def __init__(self, status_code: int, body: Any): + self.status_code = status_code + self.body = body + super().__init__(f"WhatsApp API {status_code}: {body}") + + +def _auth_headers(access_token: str) -> dict: + return { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + + +async def send_template_message( + phone_number_id: str, + access_token: str, + to_phone: str, + template_name: str, + language: str, + components: list | None = None, +) -> dict: + """Send a template message. Returns the Meta response dict. + + `components` follows Meta's template component schema: + https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-message-templates + """ + url = f"{GRAPH_BASE}/{phone_number_id}/messages" + payload: dict = { + "messaging_product": "whatsapp", + "to": to_phone, + "type": "template", + "template": { + "name": template_name, + "language": {"code": language}, + }, + } + if components: + payload["template"]["components"] = components + + async with httpx.AsyncClient() as client: + resp = await client.post(url, json=payload, headers=_auth_headers(access_token), timeout=15.0) + data = resp.json() + if resp.status_code != 200: + raise WhatsAppAPIError(resp.status_code, data) + return data + + +async def create_template( + waba_id: str, + access_token: str, + name: str, + category: str, + language: str, + components: list, +) -> dict: + """Submit a new template to Meta for approval. + + `components` is Meta's template component schema. Returns the response + dict containing at minimum `id` (meta_template_id) and `status`. + """ + url = f"{GRAPH_BASE}/{waba_id}/message_templates" + payload = { + "name": name, + "category": category, + "language": language, + "components": components, + } + async with httpx.AsyncClient() as client: + resp = await client.post(url, json=payload, headers=_auth_headers(access_token), timeout=15.0) + data = resp.json() + if resp.status_code not in (200, 201): + raise WhatsAppAPIError(resp.status_code, data) + return data + + +async def list_templates(waba_id: str, access_token: str, limit: int = 100) -> list[dict]: + """List all templates on the WABA. Returns the `data` array from Meta.""" + url = f"{GRAPH_BASE}/{waba_id}/message_templates" + params = {"limit": limit} + async with httpx.AsyncClient() as client: + resp = await client.get( + url, params=params, headers=_auth_headers(access_token), timeout=15.0 + ) + data = resp.json() + if resp.status_code != 200: + raise WhatsAppAPIError(resp.status_code, data) + return data.get("data", []) + + +async def delete_template(waba_id: str, access_token: str, name: str) -> bool: + url = f"{GRAPH_BASE}/{waba_id}/message_templates" + async with httpx.AsyncClient() as client: + resp = await client.delete( + url, params={"name": name}, headers=_auth_headers(access_token), timeout=15.0 + ) + if resp.status_code != 200: + raise WhatsAppAPIError(resp.status_code, resp.json()) + return True + + +async def get_template_by_id(meta_template_id: str, access_token: str) -> dict: + url = f"{GRAPH_BASE}/{meta_template_id}" + async with httpx.AsyncClient() as client: + resp = await client.get(url, headers=_auth_headers(access_token), timeout=15.0) + data = resp.json() + if resp.status_code != 200: + raise WhatsAppAPIError(resp.status_code, data) + return data diff --git a/backend/app/services/whatsapp/contacts.py b/backend/app/services/whatsapp/contacts.py new file mode 100644 index 0000000..bca1b31 --- /dev/null +++ b/backend/app/services/whatsapp/contacts.py @@ -0,0 +1,271 @@ +"""Contact CRUD, CSV import, and GuestUser import.""" +import csv +import io +import re +from dataclasses import dataclass, field +from datetime import datetime + +from sqlalchemy.orm import Session + +from app.models.business import Business +from app.models.chat_session import ChatSession, SessionChannel +from app.models.widget import GuestUser, WidgetSettings +from app.models.whatsapp_broadcast import ( + ContactSource, + WhatsAppContact, + WhatsAppContactList, + WhatsAppContactListMember, +) + +# Minimal E.164 validator: leading +, 8–15 digits total. +E164_PATTERN = re.compile(r"^\+?[1-9]\d{7,14}$") + + +def normalize_phone(raw: str) -> str | None: + if not raw: + return None + cleaned = re.sub(r"[\s\-()]", "", raw.strip()) + if not cleaned: + return None + if not cleaned.startswith("+"): + cleaned = "+" + cleaned + return cleaned if E164_PATTERN.match(cleaned) else None + + +@dataclass +class CsvImportResult: + imported: int = 0 + skipped: int = 0 + errors: list[str] = field(default_factory=list) + + +def create_contact( + db: Session, + business_id: str, + *, + phone: str, + name: str | None = None, + tags: list[str] | None = None, + source: str = ContactSource.MANUAL.value, + opted_in: bool = True, +) -> WhatsAppContact: + normalized = normalize_phone(phone) + if not normalized: + raise ValueError(f"Invalid phone number: {phone}") + + existing = ( + db.query(WhatsAppContact) + .filter( + WhatsAppContact.business_id == business_id, + WhatsAppContact.phone_e164 == normalized, + ) + .first() + ) + if existing: + if name is not None: + existing.name = name + if tags is not None: + existing.tags = tags + existing.opted_in = opted_in + db.commit() + db.refresh(existing) + return existing + + contact = WhatsAppContact( + business_id=business_id, + phone_e164=normalized, + name=name, + tags=tags, + source=source, + opted_in=opted_in, + ) + db.add(contact) + db.commit() + db.refresh(contact) + return contact + + +def import_csv(db: Session, business_id: str, file_bytes: bytes) -> CsvImportResult: + """Import contacts from a CSV with headers: phone, name (optional), tags (optional, comma-sep).""" + result = CsvImportResult() + try: + text = file_bytes.decode("utf-8-sig") + except UnicodeDecodeError: + result.errors.append("File is not valid UTF-8") + return result + + reader = csv.DictReader(io.StringIO(text)) + if not reader.fieldnames or "phone" not in [h.lower() for h in reader.fieldnames]: + result.errors.append("CSV must have a 'phone' header column") + return result + + # Normalize headers to lowercase for lookup + for row_num, row in enumerate(reader, start=2): + row_lower = {(k or "").lower(): v for k, v in row.items()} + raw_phone = (row_lower.get("phone") or "").strip() + if not raw_phone: + result.skipped += 1 + continue + normalized = normalize_phone(raw_phone) + if not normalized: + result.errors.append(f"Row {row_num}: invalid phone '{raw_phone}'") + result.skipped += 1 + continue + + name = (row_lower.get("name") or "").strip() or None + tags_raw = (row_lower.get("tags") or "").strip() + tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else None + + existing = ( + db.query(WhatsAppContact) + .filter( + WhatsAppContact.business_id == business_id, + WhatsAppContact.phone_e164 == normalized, + ) + .first() + ) + if existing: + if name and not existing.name: + existing.name = name + if tags: + existing.tags = tags + result.skipped += 1 + continue + + contact = WhatsAppContact( + business_id=business_id, + phone_e164=normalized, + name=name, + tags=tags, + source=ContactSource.CSV.value, + opted_in=True, + ) + db.add(contact) + result.imported += 1 + + db.commit() + return result + + +def import_from_guests( + db: Session, + business_id: str, + *, + min_sessions: int = 1, + last_seen_after: datetime | None = None, +) -> int: + """Import phone-bearing GuestUsers from WhatsApp channel sessions into contacts.""" + business = db.query(Business).filter(Business.id == business_id).first() + if not business: + return 0 + + widget_ids = [ + w.id + for w in db.query(WidgetSettings) + .filter(WidgetSettings.user_id == business.user_id) + .all() + ] + if not widget_ids: + return 0 + + query = ( + db.query(GuestUser) + .join(ChatSession, ChatSession.guest_id == GuestUser.id) + .filter( + GuestUser.widget_id.in_(widget_ids), + GuestUser.phone.isnot(None), + ChatSession.channel == SessionChannel.WHATSAPP.value, + ) + ) + if min_sessions > 1: + query = query.filter(GuestUser.total_sessions >= min_sessions) + if last_seen_after: + query = query.filter(GuestUser.last_seen_at >= last_seen_after) + + guests = query.distinct().all() + + imported = 0 + for guest in guests: + normalized = normalize_phone(guest.phone) + if not normalized: + continue + existing = ( + db.query(WhatsAppContact) + .filter( + WhatsAppContact.business_id == business_id, + WhatsAppContact.phone_e164 == normalized, + ) + .first() + ) + if existing: + continue + contact = WhatsAppContact( + business_id=business_id, + phone_e164=normalized, + name=guest.name, + source=ContactSource.GUEST_IMPORT.value, + opted_in=True, + ) + db.add(contact) + imported += 1 + + db.commit() + return imported + + +def create_list( + db: Session, business_id: str, *, name: str, description: str | None = None +) -> WhatsAppContactList: + lst = WhatsAppContactList( + business_id=business_id, name=name, description=description + ) + db.add(lst) + db.commit() + db.refresh(lst) + return lst + + +def add_members( + db: Session, contact_list: WhatsAppContactList, contact_ids: list[str] +) -> int: + existing_ids = { + m.contact_id + for m in db.query(WhatsAppContactListMember) + .filter(WhatsAppContactListMember.contact_list_id == contact_list.id) + .all() + } + added = 0 + valid_contacts = ( + db.query(WhatsAppContact.id) + .filter( + WhatsAppContact.id.in_(contact_ids), + WhatsAppContact.business_id == contact_list.business_id, + ) + .all() + ) + for (cid,) in valid_contacts: + if cid in existing_ids: + continue + db.add( + WhatsAppContactListMember( + contact_list_id=contact_list.id, contact_id=cid + ) + ) + added += 1 + db.commit() + return added + + +def remove_members( + db: Session, contact_list: WhatsAppContactList, contact_ids: list[str] +) -> int: + deleted = ( + db.query(WhatsAppContactListMember) + .filter( + WhatsAppContactListMember.contact_list_id == contact_list.id, + WhatsAppContactListMember.contact_id.in_(contact_ids), + ) + .delete(synchronize_session=False) + ) + db.commit() + return deleted diff --git a/backend/app/services/whatsapp/templates.py b/backend/app/services/whatsapp/templates.py new file mode 100644 index 0000000..9d9babd --- /dev/null +++ b/backend/app/services/whatsapp/templates.py @@ -0,0 +1,233 @@ +"""Template business logic: draft → submit → import lifecycle.""" +import re +from typing import Any + +from sqlalchemy.orm import Session + +from app.models.business import Business +from app.models.widget import WidgetSettings +from app.models.whatsapp_broadcast import ( + TemplateSource, + TemplateStatus, + WhatsAppTemplate, +) +from app.services.whatsapp import client as wa_client + +VARIABLE_PATTERN = re.compile(r"\{\{\s*(\d+)\s*\}\}") + + +def extract_variables(body_text: str) -> list[str]: + """Extract ordered, unique `{{n}}` variable placeholders.""" + seen: list[str] = [] + for match in VARIABLE_PATTERN.finditer(body_text or ""): + key = match.group(1) + if key not in seen: + seen.append(key) + return seen + + +def _get_waba_credentials(db: Session, business_id: str) -> tuple[str, str] | None: + """Return (waba_id, access_token) for the business, or None if unconfigured.""" + business = db.query(Business).filter(Business.id == business_id).first() + if not business: + return None + widget = ( + db.query(WidgetSettings) + .filter(WidgetSettings.user_id == business.user_id) + .first() + ) + if not widget or not widget.whatsapp_business_account_id or not widget.whatsapp_access_token: + return None + return widget.whatsapp_business_account_id, widget.whatsapp_access_token + + +def build_components(template: WhatsAppTemplate) -> list[dict]: + """Build Meta's component schema from a WhatsAppTemplate row.""" + components: list[dict] = [] + if template.header: + components.append(template.header) + components.append({"type": "BODY", "text": template.body_text}) + if template.footer: + components.append({"type": "FOOTER", "text": template.footer}) + if template.buttons: + components.append({"type": "BUTTONS", "buttons": template.buttons}) + return components + + +def create_draft( + db: Session, + business_id: str, + *, + name: str, + category: str, + language: str, + body_text: str, + header: dict | None = None, + footer: str | None = None, + buttons: list | None = None, +) -> WhatsAppTemplate: + template = WhatsAppTemplate( + business_id=business_id, + name=name, + category=category, + language=language, + body_text=body_text, + header=header, + footer=footer, + buttons=buttons, + variables=extract_variables(body_text), + status=TemplateStatus.DRAFT.value, + source=TemplateSource.CREATED.value, + ) + db.add(template) + db.commit() + db.refresh(template) + return template + + +def update_draft( + db: Session, + template: WhatsAppTemplate, + *, + name: str | None = None, + category: str | None = None, + language: str | None = None, + body_text: str | None = None, + header: dict | None = None, + footer: str | None = None, + buttons: list | None = None, +) -> WhatsAppTemplate: + if template.status != TemplateStatus.DRAFT.value: + raise ValueError( + f"Cannot edit template in status {template.status}; only DRAFT templates are editable" + ) + if name is not None: + template.name = name + if category is not None: + template.category = category + if language is not None: + template.language = language + if body_text is not None: + template.body_text = body_text + template.variables = extract_variables(body_text) + if header is not None: + template.header = header or None + if footer is not None: + template.footer = footer or None + if buttons is not None: + template.buttons = buttons or None + db.commit() + db.refresh(template) + return template + + +async def submit_to_meta(db: Session, template: WhatsAppTemplate) -> WhatsAppTemplate: + creds = _get_waba_credentials(db, template.business_id) + if not creds: + raise ValueError("WhatsApp Business Account not configured for this business") + + waba_id, access_token = creds + components = build_components(template) + data = await wa_client.create_template( + waba_id=waba_id, + access_token=access_token, + name=template.name, + category=template.category, + language=template.language, + components=components, + ) + template.meta_template_id = data.get("id") + status = (data.get("status") or "PENDING").upper() + template.status = status + db.commit() + db.refresh(template) + return template + + +async def import_from_meta(db: Session, business_id: str) -> list[WhatsAppTemplate]: + """Fetch templates from Meta and upsert APPROVED ones as IMPORTED rows.""" + creds = _get_waba_credentials(db, business_id) + if not creds: + raise ValueError("WhatsApp Business Account not configured for this business") + + waba_id, access_token = creds + remote = await wa_client.list_templates(waba_id, access_token) + + imported: list[WhatsAppTemplate] = [] + for remote_tpl in remote: + if (remote_tpl.get("status") or "").upper() != "APPROVED": + continue + name = remote_tpl.get("name") + language = remote_tpl.get("language") + if not name or not language: + continue + + existing = ( + db.query(WhatsAppTemplate) + .filter( + WhatsAppTemplate.business_id == business_id, + WhatsAppTemplate.name == name, + WhatsAppTemplate.language == language, + ) + .first() + ) + + components = remote_tpl.get("components", []) or [] + body_text = "" + header = None + footer = None + buttons = None + for comp in components: + ctype = (comp.get("type") or "").upper() + if ctype == "BODY": + body_text = comp.get("text", "") + elif ctype == "HEADER": + header = comp + elif ctype == "FOOTER": + footer = comp.get("text") + elif ctype == "BUTTONS": + buttons = comp.get("buttons") + + fields: dict[str, Any] = { + "meta_template_id": remote_tpl.get("id"), + "category": remote_tpl.get("category", "MARKETING"), + "header": header, + "body_text": body_text, + "footer": footer, + "buttons": buttons, + "variables": extract_variables(body_text), + "status": TemplateStatus.APPROVED.value, + "source": TemplateSource.IMPORTED.value, + } + + if existing: + for k, v in fields.items(): + setattr(existing, k, v) + imported.append(existing) + else: + tpl = WhatsAppTemplate( + business_id=business_id, + name=name, + language=language, + **fields, + ) + db.add(tpl) + imported.append(tpl) + + db.commit() + for tpl in imported: + db.refresh(tpl) + return imported + + +async def delete_template(db: Session, template: WhatsAppTemplate) -> None: + if template.meta_template_id: + creds = _get_waba_credentials(db, template.business_id) + if creds: + waba_id, access_token = creds + try: + await wa_client.delete_template(waba_id, access_token, template.name) + except Exception: + pass + db.delete(template) + db.commit() diff --git a/backend/app/workers/__init__.py b/backend/app/workers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/workers/whatsapp_campaign_worker.py b/backend/app/workers/whatsapp_campaign_worker.py new file mode 100644 index 0000000..35d925a --- /dev/null +++ b/backend/app/workers/whatsapp_campaign_worker.py @@ -0,0 +1,185 @@ +"""Standalone worker that executes scheduled WhatsApp broadcast campaigns. + +Run as: `uv run python -m app.workers.whatsapp_campaign_worker` + +The worker polls `whatsapp_campaigns` for SCHEDULED rows whose +`scheduled_at` has elapsed, claims them via `FOR UPDATE SKIP LOCKED` +so multiple replicas don't double-send, then invokes +`app.services.whatsapp.campaigns.execute_campaign` to send messages. +""" +import asyncio +import signal +from datetime import datetime, timezone + +from sqlalchemy import text + +from app.core.config import settings +from app.db.session import SessionLocal + +# Import all models so SQLAlchemy can resolve string-based relationships +# (this worker runs standalone, not through FastAPI/main.py). +from app.models.user import User # noqa: F401 +from app.models.business import Business # noqa: F401 +from app.models.plan import Plan # noqa: F401 +from app.models.payment import PaymentTransaction # noqa: F401 +from app.models.widget import WidgetSettings, GuestUser, GuestMessage # noqa: F401 +from app.models.chat_session import ChatSession # noqa: F401 +from app.models.escalation import Escalation # noqa: F401 +from app.models.document import Document # noqa: F401 +from app.models.analytics import AnalyticsDailySummary # noqa: F401 +from app.models.whatsapp_broadcast import CampaignStatus, WhatsAppCampaign +from app.services.whatsapp import campaigns as campaign_service + +POLL_INTERVAL = settings.WHATSAPP_CAMPAIGN_POLL_INTERVAL_SECONDS +DEFAULT_RATE = settings.WHATSAPP_CAMPAIGN_SEND_RATE_PER_SECOND +CLAIM_LIMIT = 5 + + +def _resolve_rate_for_campaign(db, campaign: WhatsAppCampaign) -> int: + """Per-business override on widget_settings, else env default.""" + business = db.query(Business).filter(Business.id == campaign.business_id).first() + if not business: + return DEFAULT_RATE + widget = ( + db.query(WidgetSettings) + .filter(WidgetSettings.user_id == business.user_id) + .first() + ) + if widget and widget.whatsapp_send_rate_per_second: + return widget.whatsapp_send_rate_per_second + return DEFAULT_RATE + + +class WorkerShutdown(Exception): + pass + + +_shutdown = False + + +def _handle_signal(signum, frame): + global _shutdown + print(f"whatsapp-worker: signal {signum} received; shutting down after current batch") + _shutdown = True + + +def _recover_orphaned_campaigns() -> None: + """On startup, revert any SENDING rows back to SCHEDULED so they resume.""" + db = SessionLocal() + try: + stale = ( + db.query(WhatsAppCampaign) + .filter(WhatsAppCampaign.status == CampaignStatus.SENDING.value) + .all() + ) + for c in stale: + c.status = CampaignStatus.SCHEDULED.value + c.started_at = None + if stale: + db.commit() + print(f"whatsapp-worker: recovered {len(stale)} orphaned campaign(s)") + finally: + db.close() + + +def _claim_due_campaigns() -> list[str]: + """Atomically claim up to CLAIM_LIMIT due campaigns via row locking. + + Returns campaign IDs. The lock is released when the transaction + commits (we flip status to SENDING in the same tx so other workers + skip these rows). + """ + db = SessionLocal() + try: + now = datetime.now(timezone.utc) + # Use raw SQL for FOR UPDATE SKIP LOCKED (portable-ish; PostgreSQL required in prod). + result = db.execute( + text( + """ + SELECT id FROM whatsapp_campaigns + WHERE status = :scheduled + AND scheduled_at <= :now + ORDER BY scheduled_at ASC + LIMIT :limit + FOR UPDATE SKIP LOCKED + """ + ), + { + "scheduled": CampaignStatus.SCHEDULED.value, + "now": now, + "limit": CLAIM_LIMIT, + }, + ) + ids = [row[0] for row in result] + if not ids: + db.commit() + return [] + + # Mark as SENDING inside the same transaction while the lock is held. + db.execute( + text( + """ + UPDATE whatsapp_campaigns + SET status = :sending, started_at = :now, updated_at = :now + WHERE id = ANY(:ids) + """ + ), + { + "sending": CampaignStatus.SENDING.value, + "now": now, + "ids": ids, + }, + ) + db.commit() + return ids + except Exception as e: + db.rollback() + print(f"whatsapp-worker: claim error: {e}") + return [] + finally: + db.close() + + +async def _run_campaign(campaign_id: str) -> None: + db = SessionLocal() + try: + campaign = ( + db.query(WhatsAppCampaign).filter(WhatsAppCampaign.id == campaign_id).first() + ) + if not campaign: + return + try: + rate = _resolve_rate_for_campaign(db, campaign) + await campaign_service.execute_campaign(db, campaign, rate_per_second=rate) + except Exception as e: + print(f"whatsapp-worker: campaign {campaign_id} failed: {e}") + campaign.status = CampaignStatus.FAILED.value + campaign.completed_at = datetime.now(timezone.utc) + db.commit() + finally: + db.close() + + +async def main() -> None: + signal.signal(signal.SIGTERM, _handle_signal) + signal.signal(signal.SIGINT, _handle_signal) + + _recover_orphaned_campaigns() + print( + f"whatsapp-worker: started " + f"(poll={POLL_INTERVAL}s, default_rate={DEFAULT_RATE}/s, claim_limit={CLAIM_LIMIT})" + ) + + while not _shutdown: + ids = _claim_due_campaigns() + if ids: + print(f"whatsapp-worker: claimed {len(ids)} campaign(s): {ids}") + await asyncio.gather(*[_run_campaign(cid) for cid in ids]) + else: + await asyncio.sleep(POLL_INTERVAL) + + print("whatsapp-worker: stopped") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/tests/api/test_escalation.py b/backend/tests/api/test_escalation.py index 1466cd2..a009abe 100644 --- a/backend/tests/api/test_escalation.py +++ b/backend/tests/api/test_escalation.py @@ -4,7 +4,7 @@ import pytest import uuid -from tests.factories import ( +from tests.factories import ( # noqa: F401 -- imported for use across tests UserFactory, BusinessFactory, WidgetSettingsFactory, @@ -37,10 +37,10 @@ def _make_escalation_chain(db_session): class TestListEscalations: - """Tests for GET /escalations/?business_id=X.""" + """Tests for GET /escalations/?business_id=X with pagination.""" def test_list_escalations(self, client, db_session): - """Returns escalations for the given business.""" + """Returns escalations for the given business in paginated shape.""" user, business, widget, guest, session, esc = _make_escalation_chain(db_session) resp = client.get(f"/escalations/?business_id={business.id}") @@ -49,10 +49,14 @@ def test_list_escalations(self, client, db_session): body = resp.json() assert body["status"] == "success" data = body["data"] - assert len(data) == 1 - assert data[0]["id"] == esc.id - assert data[0]["business_id"] == business.id - assert data[0]["status"] == EscalationStatus.PENDING.value + assert data["total"] == 1 + assert data["limit"] == 20 + assert data["offset"] == 0 + items = data["items"] + assert len(items) == 1 + assert items[0]["id"] == esc.id + assert items[0]["business_id"] == business.id + assert items[0]["status"] == EscalationStatus.PENDING.value def test_list_empty_for_other_business(self, client, db_session): """Returns empty list when no escalations match the business.""" @@ -60,7 +64,79 @@ def test_list_empty_for_other_business(self, client, db_session): resp = client.get(f"/escalations/?business_id={str(uuid.uuid4())}") assert resp.status_code == 200 - assert resp.json()["data"] == [] + data = resp.json()["data"] + assert data["items"] == [] + assert data["total"] == 0 + + def test_pagination_limit_offset(self, client, db_session): + """Pagination limit/offset slice the result set; total reflects full set.""" + user = UserFactory() + business = BusinessFactory(user=user, user_id=user.id) + widget = WidgetSettingsFactory(user=user, user_id=user.id) + guest = GuestUserFactory(widget=widget, widget_id=widget.id) + for _ in range(25): + session = ChatSessionFactory(guest=guest, guest_id=guest.id) + EscalationFactory( + business=business, business_id=business.id, + session=session, session_id=session.id, + ) + db_session.commit() + + resp = client.get(f"/escalations/?business_id={business.id}&limit=10&offset=0") + assert resp.status_code == 200 + first = resp.json()["data"] + assert first["total"] == 25 + assert first["limit"] == 10 + assert first["offset"] == 0 + assert len(first["items"]) == 10 + + resp2 = client.get(f"/escalations/?business_id={business.id}&limit=10&offset=20") + third = resp2.json()["data"] + assert third["total"] == 25 + assert third["offset"] == 20 + assert len(third["items"]) == 5 + + # Pages don't overlap. + first_ids = {e["id"] for e in first["items"]} + third_ids = {e["id"] for e in third["items"]} + assert first_ids.isdisjoint(third_ids) + + def test_status_filter(self, client, db_session): + """status query param narrows the result set and total.""" + user = UserFactory() + business = BusinessFactory(user=user, user_id=user.id) + widget = WidgetSettingsFactory(user=user, user_id=user.id) + guest = GuestUserFactory(widget=widget, widget_id=widget.id) + + # 3 pending, 2 resolved + for _ in range(3): + s = ChatSessionFactory(guest=guest, guest_id=guest.id) + EscalationFactory( + business=business, business_id=business.id, + session=s, session_id=s.id, + status=EscalationStatus.PENDING.value, + ) + for _ in range(2): + s = ChatSessionFactory(guest=guest, guest_id=guest.id) + EscalationFactory( + business=business, business_id=business.id, + session=s, session_id=s.id, + status=EscalationStatus.RESOLVED.value, + ) + db_session.commit() + + resp = client.get( + f"/escalations/?business_id={business.id}&status={EscalationStatus.PENDING.value}" + ) + data = resp.json()["data"] + assert data["total"] == 3 + assert all(i["status"] == EscalationStatus.PENDING.value for i in data["items"]) + + def test_limit_cap_rejects_oversize(self, client, db_session): + """limit > 200 should be rejected by FastAPI validation.""" + user, business, *_ = _make_escalation_chain(db_session) + resp = client.get(f"/escalations/?business_id={business.id}&limit=500") + assert resp.status_code == 422 class TestGetEscalationDetails: diff --git a/backend/tests/api/test_widget_settings.py b/backend/tests/api/test_widget_settings.py index 2de890c..93088f8 100644 --- a/backend/tests/api/test_widget_settings.py +++ b/backend/tests/api/test_widget_settings.py @@ -7,6 +7,7 @@ UserFactory, WidgetSettingsFactory, GuestUserFactory, + ChatSessionFactory, ) @@ -120,7 +121,7 @@ def test_404_for_unknown_widget(self, client): class TestGetGuests: - """Tests for GET /widgets/guests.""" + """Tests for GET /widgets/guests with pagination.""" def test_list_guests(self, auth_client_with_widget, db_session): client, user, business, widget = auth_client_with_widget @@ -133,17 +134,103 @@ def test_list_guests(self, auth_client_with_widget, db_session): assert resp.status_code == 200 data = resp.json()["data"] - assert len(data) == 2 - names = {g["name"] for g in data} - assert "Alice" in names - assert "Bob" in names + assert data["total"] == 2 + assert data["limit"] == 20 + assert data["offset"] == 0 + names = {g["name"] for g in data["items"]} + assert names == {"Alice", "Bob"} def test_empty_when_no_widget(self, authenticated_client): - """Returns empty list when user has no widget.""" + """Returns empty paginated payload when user has no widget.""" client, user = authenticated_client resp = client.get("/widgets/guests") assert resp.status_code == 200 - assert resp.json()["data"] == [] + data = resp.json()["data"] + assert data["items"] == [] + assert data["total"] == 0 + + def test_pagination_limit_offset(self, auth_client_with_widget, db_session): + client, user, business, widget = auth_client_with_widget + for i in range(25): + GuestUserFactory(widget=widget, widget_id=widget.id, name=f"Guest {i:02d}") + db_session.commit() + + first = client.get("/widgets/guests?limit=10&offset=0").json()["data"] + assert first["total"] == 25 + assert len(first["items"]) == 10 + + third = client.get("/widgets/guests?limit=10&offset=20").json()["data"] + assert third["total"] == 25 + assert len(third["items"]) == 5 + + first_ids = {g["id"] for g in first["items"]} + third_ids = {g["id"] for g in third["items"]} + assert first_ids.isdisjoint(third_ids) + + def test_search_query_matches_name_email_phone( + self, auth_client_with_widget, db_session + ): + client, user, business, widget = auth_client_with_widget + GuestUserFactory( + widget=widget, widget_id=widget.id, + name="Alice", email="alice@example.com", phone="+15550001", + ) + GuestUserFactory( + widget=widget, widget_id=widget.id, + name="Bob", email="bob@example.com", phone="+15550002", + ) + db_session.commit() + + # Match on name + data = client.get("/widgets/guests?q=alic").json()["data"] + assert data["total"] == 1 + assert data["items"][0]["name"] == "Alice" + + # Match on email + data = client.get("/widgets/guests?q=bob@").json()["data"] + assert data["total"] == 1 + assert data["items"][0]["email"] == "bob@example.com" + + # Match on phone + data = client.get("/widgets/guests?q=15550002").json()["data"] + assert data["total"] == 1 + assert data["items"][0]["name"] == "Bob" + + +class TestGetGuestById: + """Tests for GET /widgets/guests/{guest_id}.""" + + def test_get_guest(self, auth_client_with_widget, db_session): + client, user, business, widget = auth_client_with_widget + guest = GuestUserFactory(widget=widget, widget_id=widget.id, name="Alice") + db_session.commit() + + resp = client.get(f"/widgets/guests/{guest.id}") + assert resp.status_code == 200 + assert resp.json()["data"]["id"] == guest.id + assert resp.json()["data"]["name"] == "Alice" + + def test_404_for_unknown_guest(self, auth_client_with_widget): + client, user, business, widget = auth_client_with_widget + resp = client.get("/widgets/guests/does-not-exist") + assert resp.status_code == 404 + + def test_404_when_guest_belongs_to_other_widget( + self, auth_client_with_widget, db_session + ): + """A user cannot read guests from another user's widget.""" + client, user, business, widget = auth_client_with_widget + + from tests.factories import UserFactory, WidgetSettingsFactory + other_user = UserFactory() + other_widget = WidgetSettingsFactory(user=other_user, user_id=other_user.id) + other_guest = GuestUserFactory( + widget=other_widget, widget_id=other_widget.id, name="Outsider" + ) + db_session.commit() + + resp = client.get(f"/widgets/guests/{other_guest.id}") + assert resp.status_code == 404 class TestToggleLeadStatus: @@ -183,3 +270,38 @@ def test_guest_not_found(self, auth_client_with_widget): json={"is_lead": True}, ) assert resp.status_code == 404 + + +class TestGuestSessionHistory: + """Tests for GET /widgets/sessions/{guest_id}/history with pagination.""" + + def test_pagination_limit_offset(self, auth_client_with_widget, db_session): + client, user, business, widget = auth_client_with_widget + guest = GuestUserFactory(widget=widget, widget_id=widget.id) + for _ in range(25): + ChatSessionFactory(guest=guest, guest_id=guest.id) + db_session.commit() + + first = client.get( + f"/widgets/sessions/{guest.id}/history?limit=10&offset=0" + ).json()["data"] + assert first["total"] == 25 + assert first["limit"] == 10 + assert first["offset"] == 0 + assert len(first["items"]) == 10 + + third = client.get( + f"/widgets/sessions/{guest.id}/history?limit=10&offset=20" + ).json()["data"] + assert third["total"] == 25 + assert len(third["items"]) == 5 + + def test_empty_returns_paginated_shape(self, auth_client_with_widget, db_session): + client, user, business, widget = auth_client_with_widget + guest = GuestUserFactory(widget=widget, widget_id=widget.id) + db_session.commit() + + data = client.get(f"/widgets/sessions/{guest.id}/history").json()["data"] + assert data["items"] == [] + assert data["total"] == 0 + assert data["limit"] == 20 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index da71467..62843da 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -39,6 +39,14 @@ from app.models.plan import Plan # noqa: F401, E402 from app.models.payment import PaymentTransaction # noqa: F401, E402 from app.models.analytics import AnalyticsDailySummary # noqa: F401, E402 +from app.models.whatsapp_broadcast import ( # noqa: F401, E402 + WhatsAppContact, + WhatsAppContactList, + WhatsAppContactListMember, + WhatsAppTemplate, + WhatsAppCampaign, + WhatsAppCampaignMessage, +) # ===== Core Fixtures ===== diff --git a/backend/tests/unit/test_whatsapp_broadcast.py b/backend/tests/unit/test_whatsapp_broadcast.py new file mode 100644 index 0000000..874691c --- /dev/null +++ b/backend/tests/unit/test_whatsapp_broadcast.py @@ -0,0 +1,238 @@ +"""Unit tests for the WhatsApp broadcast services.""" +from datetime import datetime, timezone + +import pytest + +from app.models.whatsapp_broadcast import ( + CampaignAudienceType, + CampaignStatus, + TemplateStatus, + WhatsAppCampaignMessage, + WhatsAppContact, + WhatsAppTemplate, +) +from app.services.whatsapp import campaigns as campaign_service +from app.services.whatsapp import contacts as contact_service +from app.services.whatsapp import templates as template_service + + +# ---------- templates.extract_variables ---------- + + +@pytest.mark.unit +class TestExtractVariables: + def test_extracts_ordered_unique(self): + body = "Hi {{1}}, your order {{2}} is ready. Thanks {{1}}!" + assert template_service.extract_variables(body) == ["1", "2"] + + def test_empty_body(self): + assert template_service.extract_variables("") == [] + + def test_no_variables(self): + assert template_service.extract_variables("Hello world") == [] + + def test_with_whitespace(self): + assert template_service.extract_variables("Hi {{ 1 }} and {{ 2 }}") == ["1", "2"] + + +# ---------- contacts.normalize_phone ---------- + + +@pytest.mark.unit +class TestNormalizePhone: + def test_valid_e164_passes(self): + assert contact_service.normalize_phone("+2348012345678") == "+2348012345678" + + def test_adds_plus_prefix(self): + assert contact_service.normalize_phone("2348012345678") == "+2348012345678" + + def test_strips_whitespace_and_punct(self): + assert contact_service.normalize_phone("+234 (801) 234-5678") == "+2348012345678" + + def test_rejects_short_numbers(self): + assert contact_service.normalize_phone("+123") is None + + def test_rejects_empty(self): + assert contact_service.normalize_phone("") is None + assert contact_service.normalize_phone(None) is None + + +# ---------- contacts.import_csv ---------- + + +@pytest.mark.unit +class TestImportCsv: + def test_happy_path(self, db_session, auth_client_with_business): + _, _, business = auth_client_with_business + csv = b"phone,name,tags\n+2348012345678,Alice,vip\n+2347098765432,Bob,\n" + result = contact_service.import_csv(db_session, business.id, csv) + assert result.imported == 2 + assert result.skipped == 0 + contacts = db_session.query(WhatsAppContact).filter_by(business_id=business.id).all() + assert {c.phone_e164 for c in contacts} == {"+2348012345678", "+2347098765432"} + + def test_rejects_missing_header(self, db_session, auth_client_with_business): + _, _, business = auth_client_with_business + result = contact_service.import_csv(db_session, business.id, b"wrong,headers\nfoo,bar") + assert result.imported == 0 + assert any("phone" in e for e in result.errors) + + def test_skips_invalid_phones(self, db_session, auth_client_with_business): + _, _, business = auth_client_with_business + csv = b"phone,name\n+2348012345678,Good\nbad,Bad\n" + result = contact_service.import_csv(db_session, business.id, csv) + assert result.imported == 1 + assert result.skipped == 1 + + def test_deduplicates_within_business(self, db_session, auth_client_with_business): + _, _, business = auth_client_with_business + csv1 = b"phone,name\n+2348012345678,Alice\n" + contact_service.import_csv(db_session, business.id, csv1) + csv2 = b"phone,name\n+2348012345678,Alice2\n" + result = contact_service.import_csv(db_session, business.id, csv2) + assert result.imported == 0 + assert result.skipped == 1 + + +# ---------- campaigns.create_campaign ---------- + + +def _approved_template(db_session, business_id: str) -> WhatsAppTemplate: + template = WhatsAppTemplate( + business_id=business_id, + name="promo", + category="MARKETING", + language="en_US", + body_text="Hi {{1}}, check this out!", + variables=["1"], + status=TemplateStatus.APPROVED.value, + ) + db_session.add(template) + db_session.commit() + db_session.refresh(template) + return template + + +@pytest.mark.unit +class TestCreateCampaign: + def test_rejects_non_approved_template(self, db_session, auth_client_with_business): + _, user, business = auth_client_with_business + template = WhatsAppTemplate( + business_id=business.id, + name="draft", + category="MARKETING", + language="en_US", + body_text="hi", + status=TemplateStatus.DRAFT.value, + ) + db_session.add(template) + db_session.commit() + with pytest.raises(ValueError, match="APPROVED"): + campaign_service.create_campaign( + db_session, + business.id, + name="c1", + template_id=template.id, + audience_type=CampaignAudienceType.ADHOC.value, + audience_ref={"phones": ["+2348012345678"]}, + variable_mapping={"1": {"type": "literal", "value": "Alice"}}, + created_by_user_id=user.id, + ) + + def test_adhoc_audience_expansion(self, db_session, auth_client_with_business): + _, user, business = auth_client_with_business + template = _approved_template(db_session, business.id) + campaign = campaign_service.create_campaign( + db_session, + business.id, + name="c1", + template_id=template.id, + audience_type=CampaignAudienceType.ADHOC.value, + audience_ref={"phones": ["+2348012345678", "+2347098765432", "bad"]}, + variable_mapping={"1": {"type": "literal", "value": "Friend"}}, + created_by_user_id=user.id, + ) + assert campaign.total_recipients == 2 + msgs = ( + db_session.query(WhatsAppCampaignMessage) + .filter_by(campaign_id=campaign.id) + .all() + ) + assert {m.contact_phone for m in msgs} == {"+2348012345678", "+2347098765432"} + assert all(m.variables_snapshot == {"1": "Friend"} for m in msgs) + + def test_list_audience_expansion(self, db_session, auth_client_with_business): + _, user, business = auth_client_with_business + template = _approved_template(db_session, business.id) + + c1 = contact_service.create_contact( + db_session, business.id, phone="+2348012345678", name="Alice" + ) + c2 = contact_service.create_contact( + db_session, business.id, phone="+2347098765432", name="Bob" + ) + lst = contact_service.create_list(db_session, business.id, name="VIPs") + contact_service.add_members(db_session, lst, [c1.id, c2.id]) + + campaign = campaign_service.create_campaign( + db_session, + business.id, + name="c2", + template_id=template.id, + audience_type=CampaignAudienceType.LIST.value, + audience_ref={"list_id": lst.id}, + variable_mapping={"1": {"type": "field", "field": "name"}}, + created_by_user_id=user.id, + ) + assert campaign.total_recipients == 2 + msgs = { + m.contact_phone: m.variables_snapshot + for m in db_session.query(WhatsAppCampaignMessage) + .filter_by(campaign_id=campaign.id) + .all() + } + assert msgs["+2348012345678"] == {"1": "Alice"} + assert msgs["+2347098765432"] == {"1": "Bob"} + + +# ---------- campaigns.schedule / cancel ---------- + + +@pytest.mark.unit +class TestScheduleCancel: + def test_cannot_cancel_completed(self, db_session, auth_client_with_business): + _, user, business = auth_client_with_business + template = _approved_template(db_session, business.id) + campaign = campaign_service.create_campaign( + db_session, + business.id, + name="c", + template_id=template.id, + audience_type=CampaignAudienceType.ADHOC.value, + audience_ref={"phones": ["+2348012345678"]}, + variable_mapping={"1": {"type": "literal", "value": "x"}}, + created_by_user_id=user.id, + ) + campaign.status = CampaignStatus.COMPLETED.value + db_session.commit() + with pytest.raises(ValueError): + campaign_service.cancel_campaign(db_session, campaign) + + def test_schedule_flips_status_and_sets_time(self, db_session, auth_client_with_business): + _, user, business = auth_client_with_business + template = _approved_template(db_session, business.id) + campaign = campaign_service.create_campaign( + db_session, + business.id, + name="c", + template_id=template.id, + audience_type=CampaignAudienceType.ADHOC.value, + audience_ref={"phones": ["+2348012345678"]}, + variable_mapping={"1": {"type": "literal", "value": "x"}}, + created_by_user_id=user.id, + ) + scheduled_at = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc) + campaign = campaign_service.schedule_campaign(db_session, campaign, scheduled_at) + assert campaign.status == CampaignStatus.SCHEDULED.value + # SQLite strips tz; compare naive values + assert campaign.scheduled_at.replace(tzinfo=None) == scheduled_at.replace(tzinfo=None) diff --git a/content_templates.md b/content_templates.md new file mode 100644 index 0000000..34017fe --- /dev/null +++ b/content_templates.md @@ -0,0 +1,163 @@ +# TaimakoAI Content Marketing Templates & Strategy + +This document serves as the repository for Taimako's content marketing templates. It is broken down by the type of content, the templates you can use, and the specific data or conversion metric you should aim to get from each. + +## ⏱️ Quick FAQ: How long should a LinkedIn poll last? +**The sweet spot is usually 1 Week (7 Days).** +- **1 Week (Recommended):** Gives the algorithm enough time to circulate your poll across different timezones and usage patterns. A week-long poll maximizes reach. +- **3 Days:** Use this if you are running a fast-paced campaign, trying to gather quick feedback for an upcoming feature, or creating a sense of urgency. + +--- + +## 📊 1. Poll Templates + +**Description:** Low-friction interactions designed to validate assumptions, understand pain points, and cast a wide net for lead generation. +**Data to Gather:** Primary pain points, current tool stack of your market, overall market awareness, leads (everyone who votes is a potential lead to DM). + +### Poll A: The "Current State" Poll +**Goal:** Find out how many businesses are using your targeted channel (WhatsApp). +- **Post Text:** "I'm doing some research for a project I'm building. For those of you who run or work at a B2C business, where does the majority of your customer communication happen?" +- **Options:** + 1. WhatsApp (Manual) + 2. WhatsApp (Automated/API) + 3. Email / Website Chat + 4. IG / Facebook DMs +- **Data Target:** Measure the percentage of the market using WhatsApp manually vs automated. DMs can be sent to those who select "Manual". + +### Poll B: The "Pain Point" Poll +**Goal:** Identify the most urgent problem they are facing right now. +- **Post Text:** "Customer support leaders: what is the biggest bottleneck preventing a perfect customer experience on your messaging channels?" +- **Options:** + 1. Replying instantly 24/7 + 2. Repeating the same FAQs + 3. Organizing chats & tickets + 4. Cost of hiring agents +- **Data Target:** Use the winning option to define the hook for your next landing page or written post. + +--- + +## 🎠 2. Carousel / Document Templates + +**Description:** Highly engaging, educational slider posts. Best for breaking down complex topics or sharing data. +**Data to Gather:** Saves, reposts, and "book a demo" link clicks. High save rates mean your content is valuable. + +### Carousel A: The "Data Reveal" Carousel +**Goal:** Use the data from your polls to create a compelling narrative that proves the need for Taimako. +- **Slide 1 (Hook):** "I surveyed [Number] business owners about their WhatsApp strategy. The results were shocking..." +- **Slide 2:** "Observation 1: [Statistic from poll, e.g., '65% are still answering WhatsApp manually']." +- **Slide 3:** "Why this is a problem: [Explain the cost of manual replies: slow response times, lost sales, frustrated customers]." +- **Slide 4:** "Observation 2: [Another static/insight]." +- **Slide 5 (The Solution):** "This is why we are building Taimako. [1 sentence value prop: AI agents that answer WhatsApp instantly]." +- **Slide 6 (Call to Action):** "Want to see it in action? Link in the comments to join the beta." +- **Data Target:** Track click-through rates (CTR) on the link in your comments. + +### Carousel B: "Before & After" (The Framework) +**Goal:** Show the transformation Taimako provides without being overly salesy. +- **Slide 1:** "How to scale your WhatsApp sales channel (Without hiring a 24/7 team in 2026)." +- **Slide 2:** "The Old Way: The 'Always On' Trap. [Describe the stress of managing customer texts at 10 PM]." +- **Slide 3:** "The bottleneck: Humans need sleep. Customers want instant answers." +- **Slide 4:** "The New Way: Automated AI Context." +- **Slide 5:** "How it works: 1. Customer texts -> 2. Taimako reads product catalog -> 3. Personalized answer in 1 second." +- **Slide 6 (Call to Action):** "Try a live demo here [Link]." + +--- + +## ✍️ 3. Written Post Templates + +**Description:** Text-only or text + image posts. Best for storytelling, building founder authority, and sharing "build in public" moments. +**Data to Gather:** Profile views, comments, direct qualitative feedback. + +### Written Post A: The "Build in Public" Milestone +**Goal:** Show momentum and attract early adopters or partners. +- **Post Text:** + "Today we hit a major milestone with TaimakoAI 🚀. + + When we started, our AI was taking 5-6 seconds to reply to a WhatsApp message. In the age of instant messaging, that felt too slow. + + After spending the weekend rewriting our Vector DB logic and optimizing the backend, we got response times down to under 1.5 seconds. ⚡ + + Now it feels completely human. + + If you run an e-commerce brand or local business and want to test a 24/7 AI sales rep for free, drop a comment below and I'll build you a prototype this week!" +- **Data Target:** Number of "hand raisers" in the comments. + +### Written Post B: The "Spiky Point of View" +**Goal:** Take a stance on a common industry belief to spark debate and engagement. +- **Post Text:** + "Unpopular opinion: If you are making your customers install an app or log into a portal just to ask a simple support question, you are losing money. + + Meet your customers where they already are. + + In 2026, that place is WhatsApp. + + Don't force friction. Automate the channels they already trust. + + Who agrees/disagrees? Let me know below." +- **Data Target:** Engagement rate and qualitative sentiment in the comments. Are people agreeing? + +--- + +## 🎥 4. Video / Screen Recording Templates + +**Description:** Short (15-60 second) visual proof. People are skeptical of AI claims; seeing is believing. +**Data to Gather:** View duration, inbound DMs asking "how do I get this?" + +### Video A: The "Speed Test" Reality Check +**Goal:** Visually demonstrate Taimako's capability. +- **Content:** Put two phones side by side (or do a split screen). + - *Left side:* A human trying to type out a complex answer to a customer asking "Do you have the red shoes in size 10, and can you deliver to Lagos by Tuesday?" + - *Right side:* Taimako receiving the same message and INSTANTLY replying with the correct stock info and shipping policy. +- **Caption:** "Human vs. AI on WhatsApp support. Who wins?" +- **Data Target:** Watch time and shares. + +### Video B: The "Behind the Scenes" Setup +**Goal:** Show how easy it is to onboard and use Taimako. +- **Content:** A quick loom video/screen recording showing the dashboard. "Here is how easy it is to give Taimako a brain. I just uploaded my PDF product menu, and now the WhatsApp bot knows everything." +- **Data Target:** Conversion rate of viewers realizing "oh, I don't need to be technical to use this." + +--- + +## 🌐 5. Website/Landing Page Chatbot Templates + +**Description:** Content focused on the web widget features of Taimako. Aimed at website owners, marketers, and lead-gen agencies who struggle with high bounce rates, generic boring FAQs, or failing to capture leads during off-hours. +**Data to Gather:** Conversion rates, user objections on websites, volume of unresolved queries. + +### Poll C: The "Dead FAQ" Poll +**Goal:** Expose the inefficiency of static FAQ pages and agitate the pain of lost website conversions. +- **Post Text:** "When you visit a software or e-commerce website and have a specific question, what usually happens?" +- **Options:** + 1. I find the answer in the FAQ instantly. + 2. I scan the FAQ, give up, and leave. + 3. I use the live chat (if someone is there). + 4. I email support and wait. +- **Data Target:** Highlighting how many people *give up and leave* if they don't find answers instantly. This serves as lead gen for you to pitch Taimako's interactive web chat. + +### Carousel C: "RIP The FAQ Page" +**Goal:** Position the Taimako web widget as the modern replacement for static FAQ pages. +- **Slide 1:** "It's 2026. The static FAQ page is officially dead. Here's what's replacing it..." +- **Slide 2:** "The Problem: Visitors don't want to CTRL+F through a wall of text to see if you ship to their country." +- **Slide 3:** "The Result: They get frustrated, they bounce, you lose a sale." +- **Slide 4:** "The Solution: An AI specifically trained on your documentation that lives in the corner of your site." +- **Slide 5:** "With Taimako's web widget, visitors type their exact question and get the exact answer, cited from your docs. *(Include a screenshot)*" +- **Slide 6 (Call to Action):** "Want to turn your boring FAQ into an interactive sales rep? Try it out for free [Link]." +- **Data Target:** High click-through rate (CTR) to your landing page from marketers looking to fix bounce rates. + +### Written Post C: The "Silent Lead Gen" Angle +**Goal:** Highlight Taimako's ability to capture leads even when sales reps are asleep. +- **Post Text:** + "Most B2B websites are leaky buckets. + + A prospect lands on your site at 11 PM, has a buying question, sees your live chat is 'offline,' and closes the tab. That's a lost lead. + + We built Taimako's web widget to fix this. It doesn't just answer questions—it collects the prospect's email and context, then hands it off to your sales team in the morning. + + It's effectively a 24/7 SDR that never takes a coffee break. + + Do you know how many leads you are losing outside of business hours?" +- **Data Target:** Comments from businesses sharing their current off-hours setup (or lack thereof), giving you an opening to DM them. + +### Video C: The "Insight Engine" Dashboard Reveal +**Goal:** Show that Taimako isn't just a chatbot, but an analytics tool that reveals what customers *actually* care about. +- **Content:** A quick screen recording comparing a typical Google Analytics page (showing generic 'Time on Page') vs. the Taimako Sessions Dashboard. +- **Voiceover/Caption:** "Analytics tell you *where* visitors drop off. Taimako tells you *why*. Here's our interaction dashboard showing exactly what questions users are asking that our landing page fails to answer. We literally use this data to rewrite our website copy." +- **Data Target:** Attract product managers, marketers, and copywriters—broadening your target audience beyond just customer support. diff --git a/docker-compose.yml b/docker-compose.yml index cb71698..6334486 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,21 @@ services: timeout: 5s retries: 5 + migrate: + build: ./backend + container_name: taimako_migrate + command: uv run alembic upgrade head + volumes: + - ./backend:/app + - /app/.venv + environment: + - PYTHONUNBUFFERED=1 + - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:${POSTGRES_PORT}/${POSTGRES_DB} + depends_on: + postgres: + condition: service_healthy + restart: "no" + backend: build: ./backend container_name: taimako_backend @@ -31,6 +46,25 @@ services: depends_on: postgres: condition: service_healthy + migrate: + condition: service_completed_successfully + + whatsapp-worker: + build: ./backend + container_name: taimako_whatsapp_worker + command: uv run python -m app.workers.whatsapp_campaign_worker + volumes: + - ./backend:/app + - /app/.venv + environment: + - PYTHONUNBUFFERED=1 + - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:${POSTGRES_PORT}/${POSTGRES_DB} + depends_on: + postgres: + condition: service_healthy + migrate: + condition: service_completed_successfully + restart: unless-stopped frontend: build: ./frontend diff --git a/frontend/public/logo.png b/frontend/public/logo.png index bb061a0e7213654be6bc0c1b8f7b16c7999b2607..6cd3b53430d18801d408f45b0b683c26a12c27f5 100644 GIT binary patch literal 8827 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 KLBY8jk^cj!{n%3g literal 42309 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

bHgKZjpD)sHsO2 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

WtQ(kUji-*G_{Ld~r?y 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

bHgKZjpD)sHsO2 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

WtQ(kUji-*G_{Ld~r?y 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

-

Analytics

+

Analytics

Deep insights into your customer interactions.

@@ -182,7 +182,7 @@ export default function AnalyticsPage() { content: (
-

Traffic Sources

+

Traffic Sources

{sources.map(s => (
diff --git a/frontend/src/app/dashboard/handoff/[escalationId]/page.tsx b/frontend/src/app/dashboard/handoff/[escalationId]/page.tsx index 48d1036..c74e5f6 100644 --- a/frontend/src/app/dashboard/handoff/[escalationId]/page.tsx +++ b/frontend/src/app/dashboard/handoff/[escalationId]/page.tsx @@ -143,7 +143,7 @@ export default function EscalationDetailPage() {
-

+

Escalation Details ([]); const [loadingEscalations, setLoadingEscalations] = useState(false); const [filterStatus, setFilterStatus] = useState('all'); + const [businessId, setBusinessId] = useState(null); + const [offset, setOffset] = useState(0); + const [total, setTotal] = useState(0); // Configuration State @@ -35,25 +41,49 @@ export default function HandoffPage() { const [saving, setSaving] = useState(false); useEffect(() => { - fetchBusinessAndEscalations(); + let cancelled = false; + (async () => { + try { + const profileRes = await getBusinessProfile(); + if (cancelled || !profileRes.data?.id) return; + setBusinessId(profileRes.data.id); + setIsEscalationEnabled(profileRes.data.is_escalation_enabled || false); + setEscalationEmails(profileRes.data.escalation_emails || []); + } catch (error) { + console.error("Failed to load business profile", error); + } + })(); + return () => { + cancelled = true; + }; }, []); - const fetchBusinessAndEscalations = async () => { + const fetchEscalations = useCallback(async () => { + if (!businessId) return; try { setLoadingEscalations(true); - const profileRes = await getBusinessProfile(); - if (profileRes.data?.id) { - - setIsEscalationEnabled(profileRes.data.is_escalation_enabled || false); - setEscalationEmails(profileRes.data.escalation_emails || []); - const escRes = await getEscalations(profileRes.data.id); - setEscalations(escRes || []); - } + const params: { status?: string; limit: number; offset: number } = { + limit: PAGE_SIZE, + offset, + }; + if (filterStatus !== 'all') params.status = filterStatus; + const page = await getEscalations(businessId, params); + setEscalations(page.items); + setTotal(page.total); } catch (error) { - console.error("Failed to load data", error); + console.error("Failed to load escalations", error); } finally { setLoadingEscalations(false); } + }, [businessId, filterStatus, offset]); + + useEffect(() => { + fetchEscalations(); + }, [fetchEscalations]); + + const handleStatusChange = (next: string) => { + setFilterStatus(next); + setOffset(0); }; const handleSaveConfig = async () => { @@ -91,12 +121,6 @@ export default function HandoffPage() { // Rule management - // Filtered escalations - const filteredEscalations = escalations.filter(e => { - if (filterStatus === 'all') return true; - return e.status === filterStatus; - }); - const getStatusColor = (status: string) => { switch (status) { case 'resolved': return 'text-green-600 bg-green-50 border-green-200'; @@ -124,14 +148,14 @@ export default function HandoffPage() { -

@@ -142,7 +166,7 @@ export default function HandoffPage() {
- ) : filteredEscalations.length === 0 ? ( + ) : escalations.length === 0 ? (
@@ -152,7 +176,7 @@ export default function HandoffPage() {
) : (
- {filteredEscalations.map((esc) => ( + {escalations.map((esc) => ( )} + + {!loadingEscalations && ( + + )}
); @@ -188,7 +221,7 @@ export default function HandoffPage() {
{/* Escalation Settings (moved from Business Settings) */} -

+

Escalation Settings

diff --git a/frontend/src/app/dashboard/layout.tsx b/frontend/src/app/dashboard/layout.tsx index 6c00ec5..f073425 100644 --- a/frontend/src/app/dashboard/layout.tsx +++ b/frontend/src/app/dashboard/layout.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useState, useEffect } from 'react'; -import { LayoutDashboard, Building2, FileText, MessageSquare, LogOut, X, Settings, Users, Bot, AlignLeft, ChevronLeft, ChevronRight } from 'lucide-react'; +import { LayoutDashboard, Building2, FileText, MessageSquare, LogOut, X, Settings, Users, Bot, AlignLeft, ChevronLeft, ChevronRight, Send } from 'lucide-react'; import Sidebar, { SidebarSection } from '@/components/ui/Sidebar'; import { useAuth } from '@/contexts/AuthContext'; import { useBusiness, BusinessProvider } from '@/contexts/BusinessContext'; @@ -36,6 +36,16 @@ function DashboardLayoutInner({ { label: 'Widget', href: '/dashboard/widget-settings', icon: Settings }, { label: 'Escalations', href: '/dashboard/handoff', icon: Users }, { label: 'Playground', href: '/dashboard/chat', icon: Bot }, + { + label: 'WhatsApp', + icon: Send, + href: '/dashboard/whatsapp', + subItems: [ + { label: 'Templates', href: '/dashboard/whatsapp/templates' }, + { label: 'Contacts', href: '/dashboard/whatsapp/contacts' }, + { label: 'Campaigns', href: '/dashboard/whatsapp/campaigns' }, + ], + }, { label: 'Settings', icon: Settings, @@ -58,7 +68,7 @@ function DashboardLayoutInner({ {/* Mobile Header */}
-

Taimako

+

Taimako

-

{value}

+

{value}

{label}

@@ -121,7 +121,7 @@ export default function DashboardOverview() {
-

Overview

+

Overview

Welcome back. Here's what's happening today.

@@ -167,7 +167,7 @@ export default function DashboardOverview() {
-

Intent Distribution

+

Intent Distribution

@@ -175,12 +175,12 @@ export default function DashboardOverview() {
-

Returning Guests

+

Returning Guests

Guests engaging more than once

-
{metrics?.returning_guests_percentage || 0}%
+
{metrics?.returning_guests_percentage || 0}%
Retention Rate @@ -194,7 +194,7 @@ export default function DashboardOverview() {
-

Top Locations

+

Top Locations

{locations.map((loc: LocationStat) => (
@@ -220,12 +220,12 @@ export default function DashboardOverview() {
-

Traffic Sources

+

Traffic Sources

{sources.map((item) => (
-
{item.count}
+
{item.count}
{item.source || 'Direct'}
))} diff --git a/frontend/src/app/dashboard/sessions/[guestId]/[sessionId]/page.tsx b/frontend/src/app/dashboard/sessions/[guestId]/[sessionId]/page.tsx index 779ce96..992fd03 100644 --- a/frontend/src/app/dashboard/sessions/[guestId]/[sessionId]/page.tsx +++ b/frontend/src/app/dashboard/sessions/[guestId]/[sessionId]/page.tsx @@ -77,7 +77,7 @@ export default function SessionDetailPage() {
-

+

Session Detail {session.sentiment_score !== null && ( ([]); const [guest, setGuest] = useState(null); const [loading, setLoading] = useState(true); + const [offset, setOffset] = useState(0); + const [total, setTotal] = useState(0); const [togglingLead, setTogglingLead] = useState(false); useEffect(() => { - async function loadData() { + let cancelled = false; + async function loadGuest() { if (!guestId) return; try { - const [sessionsData, guestsData] = await Promise.all([ - getGuestSessions(guestId), - getGuests() - ]); - setSessions(sessionsData); - // Find the current guest from the list - const currentGuest = guestsData.find((g: Guest) => g.id === guestId); - if (currentGuest) { - setGuest(currentGuest); - } + const guestData = await getGuest(guestId); + if (!cancelled) setGuest(guestData); } catch (e) { console.error(e); - } finally { - setLoading(false); } } - loadData(); + loadGuest(); + return () => { + cancelled = true; + }; }, [guestId]); + useEffect(() => { + let cancelled = false; + async function loadSessions() { + if (!guestId) return; + try { + setLoading(true); + const page = await getGuestSessions(guestId, { + limit: PAGE_SIZE, + offset, + }); + if (cancelled) return; + setSessions(page.items); + setTotal(page.total); + } catch (e) { + console.error(e); + } finally { + if (!cancelled) setLoading(false); + } + } + loadSessions(); + return () => { + cancelled = true; + }; + }, [guestId, offset]); + const handleToggleLead = async () => { if (!guest) return; setTogglingLead(true); @@ -66,7 +90,7 @@ export default function GuestSessionsList() {
-

+

{guest?.name || 'Guest History'}

{guest?.is_lead && ( @@ -166,6 +190,14 @@ export default function GuestSessionsList() {
)) )} + {!loading && ( + + )}

); diff --git a/frontend/src/app/dashboard/sessions/page.tsx b/frontend/src/app/dashboard/sessions/page.tsx index 0aa0205..dcc4347 100644 --- a/frontend/src/app/dashboard/sessions/page.tsx +++ b/frontend/src/app/dashboard/sessions/page.tsx @@ -8,27 +8,53 @@ import { import { getGuests, toggleLeadStatus } from '@/lib/api'; import { Guest } from '@/lib/types'; import { cn } from '@/lib/utils'; +import Pagination from '@/components/ui/Pagination'; + +const PAGE_SIZE = 20; export default function SessionsGuestList() { const router = useRouter(); const [guests, setGuests] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [offset, setOffset] = useState(0); + const [total, setTotal] = useState(0); const [togglingId, setTogglingId] = useState(null); useEffect(() => { + const handle = setTimeout(() => { + setDebouncedSearch(searchTerm.trim()); + setOffset(0); + }, 300); + return () => clearTimeout(handle); + }, [searchTerm]); + + useEffect(() => { + let cancelled = false; async function loadGuests() { try { - const data = await getGuests(); - setGuests(data); + setLoading(true); + const params: { q?: string; limit: number; offset: number } = { + limit: PAGE_SIZE, + offset, + }; + if (debouncedSearch) params.q = debouncedSearch; + const page = await getGuests(params); + if (cancelled) return; + setGuests(page.items); + setTotal(page.total); } catch (e) { console.error(e); } finally { - setLoading(false); + if (!cancelled) setLoading(false); } } loadGuests(); - }, []); + return () => { + cancelled = true; + }; + }, [debouncedSearch, offset]); const handleToggleLead = async (e: React.MouseEvent, guest: Guest) => { e.stopPropagation(); // Prevent row click navigation @@ -43,16 +69,11 @@ export default function SessionsGuestList() { } }; - const filteredGuests = guests.filter(g => - (g.name?.toLowerCase() || '').includes(searchTerm.toLowerCase()) || - (g.email?.toLowerCase() || '').includes(searchTerm.toLowerCase()) - ); - return (
-

Customer Sessions

+

Customer Sessions

Browse interactions by customer.

@@ -81,13 +102,13 @@ export default function SessionsGuestList() {
{loading ? (
Loading customers...
- ) : filteredGuests.length === 0 ? ( + ) : guests.length === 0 ? (

No customers found matching your search.

) : ( - filteredGuests.map(guest => ( + guests.map(guest => (
router.push(`/dashboard/sessions/${guest.id}`)} @@ -179,6 +200,16 @@ export default function SessionsGuestList() { )) )}
+ {!loading && ( +
+ +
+ )}
); diff --git a/frontend/src/app/dashboard/settings/general/page.tsx b/frontend/src/app/dashboard/settings/general/page.tsx index 4faf37f..754df00 100644 --- a/frontend/src/app/dashboard/settings/general/page.tsx +++ b/frontend/src/app/dashboard/settings/general/page.tsx @@ -203,7 +203,7 @@ export default function BusinessProfilePage() {
{/* Business Details Section */}
-

Business Details

+

Business Details

-

+

Agent Configuration {editing && (
diff --git a/frontend/src/app/dashboard/settings/subscription/page.tsx b/frontend/src/app/dashboard/settings/subscription/page.tsx index 218031e..98d3c74 100644 --- a/frontend/src/app/dashboard/settings/subscription/page.tsx +++ b/frontend/src/app/dashboard/settings/subscription/page.tsx @@ -184,7 +184,7 @@ function SubscriptionPageContent() { onClick={() => setIsPricingOpen(!isPricingOpen)} className="w-full flex items-center justify-between py-4 text-left group" > -

+

Ready to scale your AI agent?

diff --git a/frontend/src/app/dashboard/settings/whatsapp/page.tsx b/frontend/src/app/dashboard/settings/whatsapp/page.tsx index 15ee8ff..40295a5 100644 --- a/frontend/src/app/dashboard/settings/whatsapp/page.tsx +++ b/frontend/src/app/dashboard/settings/whatsapp/page.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { motion } from 'framer-motion'; -import { MessageSquare, Save, AlertCircle, CheckCircle, Copy, Check } from 'lucide-react'; +import { MessageSquare, Save, AlertCircle, CheckCircle } from 'lucide-react'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; @@ -17,6 +17,7 @@ interface WhatsAppSettings { whatsapp_business_account_id: string; whatsapp_access_token: string; whatsapp_api_configured: boolean; + whatsapp_send_rate_per_second: string; } export default function WhatsAppSettingsPage() { @@ -24,7 +25,6 @@ export default function WhatsAppSettingsPage() { const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); - const [copied, setCopied] = useState(false); const [formData, setFormData] = useState({ whatsapp_enabled: false, @@ -33,10 +33,9 @@ export default function WhatsAppSettingsPage() { whatsapp_business_account_id: '', whatsapp_access_token: '', whatsapp_api_configured: false, + whatsapp_send_rate_per_second: '', }); - const webhookUrl = `${BACKEND_URL}/whatsapp/webhook`; - useEffect(() => { fetchSettings(); }, []); @@ -57,6 +56,10 @@ export default function WhatsAppSettingsPage() { whatsapp_business_account_id: data.whatsapp_business_account_id || '', whatsapp_access_token: '', whatsapp_api_configured: data.whatsapp_api_configured || false, + whatsapp_send_rate_per_second: + data.whatsapp_send_rate_per_second != null + ? String(data.whatsapp_send_rate_per_second) + : '', }); } } catch (err) { @@ -85,6 +88,21 @@ export default function WhatsAppSettingsPage() { body.whatsapp_access_token = formData.whatsapp_access_token; } + // Send rate: blank → 0 (server clears column, falls back to default). + // Otherwise clamp [1, 80] before hitting the API. + const rawRate = formData.whatsapp_send_rate_per_second.trim(); + if (rawRate === '') { + body.whatsapp_send_rate_per_second = 0; + } else { + const parsed = Number(rawRate); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 80) { + setError('Send rate must be a whole number between 1 and 80.'); + setSaving(false); + return; + } + body.whatsapp_send_rate_per_second = parsed; + } + const res = await fetch(`${BACKEND_URL}/widgets/my-settings`, { method: 'PUT', headers: { @@ -101,6 +119,10 @@ export default function WhatsAppSettingsPage() { ...prev, whatsapp_api_configured: data.whatsapp_api_configured || false, whatsapp_access_token: '', + whatsapp_send_rate_per_second: + data.whatsapp_send_rate_per_second != null + ? String(data.whatsapp_send_rate_per_second) + : '', })); setSuccess('WhatsApp settings saved successfully!'); setTimeout(() => setSuccess(''), 3000); @@ -115,12 +137,6 @@ export default function WhatsAppSettingsPage() { } }; - const copyWebhookUrl = () => { - navigator.clipboard.writeText(webhookUrl); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - if (loading) { return (
@@ -183,7 +199,7 @@ export default function WhatsAppSettingsPage() {
-

+

WhatsApp Channel

@@ -216,32 +232,24 @@ export default function WhatsAppSettingsPage() { - {/* Webhook URL */} + {/* Coming-soon banner for Embedded Signup */} -

-

- Webhook Configuration +
+

+ One-click connect — coming soon

- Use this URL in your{' '} - - Meta Developer Dashboard - {' '} - as the webhook callback URL. + We're rolling out one-click WhatsApp connection via Meta's + Embedded Signup. Once it's live, you'll link your WhatsApp + Business Account to Taimako with a single popup — no tokens, + no IDs, no copy-pasting. Until then, connect manually using the + steps below.

-
- - {webhookUrl} - - -
@@ -254,7 +262,7 @@ export default function WhatsAppSettingsPage() { >
-

+

WhatsApp API Credentials

@@ -304,6 +312,53 @@ export default function WhatsAppSettingsPage() { + {/* Broadcast Send Rate */} + + +
+

+ Broadcast Send Rate +

+

+ How many template messages/second the broadcast worker will send for + this business. Meta assigns different throughput tiers per WABA + (1K / 10K / 100K / unlimited per 24h); set this to match your tier. + Leave blank to use the Taimako default. +

+ + setFormData({ + ...formData, + whatsapp_send_rate_per_second: e.target.value, + }) + } + /> +

+ Allowed range: 1–80. Values above your Meta tier will still be + rate-limited by Meta.{' '} + + Meta messaging limits → + +

+
+
+
+ {/* Setup Instructions */}
-

- Setup Instructions +

+ How to connect manually

+

+ These steps happen inside{' '} + your own{' '} + Meta Business Manager. Taimako doesn't need any backend access + from you. +

  1. - Go to the{' '} - - Meta Developer Dashboard - {' '} - and create or select your app + Sign in to{' '} + + business.facebook.com + {' '} + and create (or open) your{' '} + Business Portfolio. +
  2. +
  3. + Add a WhatsApp Business Account (WABA) under + your portfolio. Register the phone number you want to use and + complete display-name approval.
  4. -
  5. Add the WhatsApp product to your app
  6. - Under WhatsApp > Configuration, set the webhook callback URL to - the URL shown above + In Business Settings → Users → System Users, + create a system user and assign your WABA to it with{' '} + Full control.
  7. - Set the verify token to the value configured in your backend environment ( + Generate a permanent access token for that + system user with the{' '} - WHATSAPP_VERIFY_TOKEN - - ) + whatsapp_business_messaging + {' '} + and{' '} + + whatsapp_business_management + {' '} + scopes. +
  8. +
  9. + From the WABA overview, copy your Phone Number ID,{' '} + Business Account ID, and the token you just + generated — paste them into the fields above.
  10. -
  11. Subscribe to the messages webhook field
  12. - Copy your Phone Number ID, Business Account ID, and permanent access token into the - fields above + Turn on the WhatsApp Channel toggle at the top + of this page and click Save. Your messages + will start flowing through Taimako within seconds.
  13. -
  14. Enable the WhatsApp channel toggle and save
+

+ Your messaging limits (e.g. 250/day → 1K → 10K → 100K → unlimited) + are set by Meta on your Business Portfolio, not on + Taimako. Verifying your business and keeping quality rating + high moves you up the tiers. Phone-number caps (2 by default, + up to 20 for verified portfolios) are also per-portfolio — raise + them via a Meta support ticket once you're verified. +

diff --git a/frontend/src/app/dashboard/whatsapp/campaigns/[id]/page.tsx b/frontend/src/app/dashboard/whatsapp/campaigns/[id]/page.tsx new file mode 100644 index 0000000..5ec5ea3 --- /dev/null +++ b/frontend/src/app/dashboard/whatsapp/campaigns/[id]/page.tsx @@ -0,0 +1,186 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { useParams } from 'next/navigation'; +import Link from 'next/link'; +import { motion } from 'framer-motion'; +import { ArrowLeft, XCircle, Send as SendIcon } from 'lucide-react'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import SkeletonLoader from '@/components/ui/SkeletonLoader'; +import { + WhatsAppCampaign, + WhatsAppCampaignMessage, + getWhatsAppCampaign, + listWhatsAppCampaignMessages, + cancelWhatsAppCampaign, + sendWhatsAppCampaign, +} from '@/lib/api'; + +const STATUS_COLORS: Record = { + DRAFT: 'bg-gray-200 text-gray-700', + SCHEDULED: 'bg-blue-100 text-blue-800', + SENDING: 'bg-yellow-100 text-yellow-800', + COMPLETED: 'bg-green-100 text-green-800', + CANCELLED: 'bg-gray-100 text-gray-500', + FAILED: 'bg-red-100 text-red-800', + QUEUED: 'bg-gray-100 text-gray-700', + SENT: 'bg-blue-100 text-blue-800', + DELIVERED: 'bg-green-100 text-green-800', + READ: 'bg-emerald-100 text-emerald-800', +}; + +export default function CampaignDetailPage() { + const params = useParams<{ id: string }>(); + const campaignId = params?.id as string; + + const [campaign, setCampaign] = useState(null); + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!campaignId) return; + fetchCampaign(); + const interval = setInterval(() => { + // poll while sending + if (campaign?.status === 'SENDING' || campaign?.status === 'SCHEDULED') { + fetchCampaign(); + } + }, 5000); + return () => clearInterval(interval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [campaignId, campaign?.status]); + + const fetchCampaign = async () => { + try { + const [c, m] = await Promise.all([ + getWhatsAppCampaign(campaignId), + listWhatsAppCampaignMessages(campaignId, { limit: 500 }), + ]); + setCampaign(c); + setMessages(m.items); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + const handleCancel = async () => { + if (!confirm('Cancel this campaign?')) return; + await cancelWhatsAppCampaign(campaignId); + await fetchCampaign(); + }; + + const handleSendNow = async () => { + await sendWhatsAppCampaign(campaignId); + await fetchCampaign(); + }; + + if (loading || !campaign) { + return ; + } + + return ( + +
+ + Back to campaigns + +
+ +
+
+

{campaign.name}

+
+ + {campaign.status} + + + Created {new Date(campaign.created_at).toLocaleString()} + +
+
+
+ {campaign.status === 'DRAFT' && ( + + )} + {(campaign.status === 'SCHEDULED' || campaign.status === 'SENDING') && ( + + )} +
+
+ +
+ +
Recipients
+
{campaign.total_recipients}
+
+ +
Sent
+
{campaign.sent_count}
+
+ +
Delivered
+
{campaign.delivered_count}
+
+ +
Read
+
{campaign.read_count}
+
+ +
Failed
+
{campaign.failed_count}
+
+
+ + +
+ + + + + + + + + + + + + {messages.map((m) => ( + + + + + + + + + ))} + +
PhoneStatusSentDeliveredReadError
{m.contact_phone} + + {m.status} + + {m.sent_at ? new Date(m.sent_at).toLocaleTimeString() : '-'}{m.delivered_at ? new Date(m.delivered_at).toLocaleTimeString() : '-'}{m.read_at ? new Date(m.read_at).toLocaleTimeString() : '-'}{m.error_message || '-'}
+
+
+
+ ); +} diff --git a/frontend/src/app/dashboard/whatsapp/campaigns/page.tsx b/frontend/src/app/dashboard/whatsapp/campaigns/page.tsx new file mode 100644 index 0000000..c76628f --- /dev/null +++ b/frontend/src/app/dashboard/whatsapp/campaigns/page.tsx @@ -0,0 +1,343 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { motion } from 'framer-motion'; +import { Plus } from 'lucide-react'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import Modal from '@/components/ui/Modal'; +import Input from '@/components/ui/Input'; +import Select from '@/components/ui/Select'; +import SkeletonLoader from '@/components/ui/SkeletonLoader'; +import { + WhatsAppCampaign, + WhatsAppTemplate, + WhatsAppContactList, + listWhatsAppCampaigns, + createWhatsAppCampaign, + listWhatsAppTemplates, + listWhatsAppContactLists, +} from '@/lib/api'; + +const STATUS_COLORS: Record = { + DRAFT: 'bg-gray-200 text-gray-700', + SCHEDULED: 'bg-blue-100 text-blue-800', + SENDING: 'bg-yellow-100 text-yellow-800', + COMPLETED: 'bg-green-100 text-green-800', + CANCELLED: 'bg-gray-100 text-gray-500', + FAILED: 'bg-red-100 text-red-800', +}; + +export default function WhatsAppCampaignsPage() { + const [campaigns, setCampaigns] = useState([]); + const [templates, setTemplates] = useState([]); + const [lists, setLists] = useState([]); + const [loading, setLoading] = useState(true); + const [createOpen, setCreateOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + const [form, setForm] = useState({ + name: '', + template_id: '', + audience_type: 'LIST', + list_id: '', + ad_hoc_phones: '', + min_sessions: '1', + schedule: 'now', + scheduled_at: '', + }); + const [variableInputs, setVariableInputs] = useState>({}); + + useEffect(() => { + fetchAll(); + }, []); + + const fetchAll = async () => { + setLoading(true); + try { + const [c, t, l] = await Promise.all([ + listWhatsAppCampaigns({ limit: 100 }), + listWhatsAppTemplates(), + listWhatsAppContactLists(), + ]); + setCampaigns(c.items); + setTemplates(t.filter((x) => x.status === 'APPROVED')); + setLists(l); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + const selectedTemplate = templates.find((t) => t.id === form.template_id); + + const handleCreate = async () => { + if (!form.name || !form.template_id) return; + setSaving(true); + try { + let audience_ref: Record = {}; + if (form.audience_type === 'LIST') { + audience_ref = { list_id: form.list_id }; + } else if (form.audience_type === 'ADHOC') { + audience_ref = { + phones: form.ad_hoc_phones.split(/[\s,\n]+/).filter(Boolean), + }; + } else if (form.audience_type === 'GUESTS_FILTER') { + audience_ref = { min_sessions: parseInt(form.min_sessions || '1', 10) }; + } + + const variable_mapping: Record = {}; + for (const [key, value] of Object.entries(variableInputs)) { + if (value.startsWith('@')) { + variable_mapping[key] = { type: 'field', field: value.slice(1) }; + } else { + variable_mapping[key] = { type: 'literal', value }; + } + } + + const scheduledAt = + form.schedule === 'later' && form.scheduled_at + ? new Date(form.scheduled_at).toISOString() + : null; + + await createWhatsAppCampaign({ + name: form.name, + template_id: form.template_id, + audience_type: form.audience_type, + audience_ref, + variable_mapping, + scheduled_at: scheduledAt, + send_now: form.schedule === 'now', + }); + setMessage({ type: 'success', text: 'Campaign created' }); + setCreateOpen(false); + setForm({ + name: '', + template_id: '', + audience_type: 'LIST', + list_id: '', + ad_hoc_phones: '', + min_sessions: '1', + schedule: 'now', + scheduled_at: '', + }); + setVariableInputs({}); + await fetchAll(); + } catch (e) { + setMessage({ type: 'error', text: 'Failed to create campaign' }); + console.error(e); + } finally { + setSaving(false); + } + }; + + return ( + +
+
+

WhatsApp Campaigns

+

Broadcast templated messages to contact lists.

+
+ +
+ + {message && ( +
+ {message.text} +
+ )} + + + {loading ? ( + + ) : campaigns.length === 0 ? ( +
+ No campaigns yet. Create one to broadcast a template to your contacts. +
+ ) : ( +
+ + + + + + + + + + + + + + + {campaigns.map((c) => ( + + + + + + + + + + + ))} + +
NameStatusRecipientsSentDeliveredReadFailedScheduled
+ + {c.name} + + + + {c.status} + + {c.total_recipients}{c.sent_count}{c.delivered_count}{c.read_count}{c.failed_count} + {c.scheduled_at ? new Date(c.scheduled_at).toLocaleString() : '-'} +
+
+ )} +
+ + setCreateOpen(false)} title="New Campaign"> +
+ setForm({ ...form, name: e.target.value })} + /> + + setVariableInputs({ ...variableInputs, [v]: e.target.value })} + /> + ))} +
+ ) : null} + + ({ label: `${l.name} (${l.member_count})`, value: l.id })), + ]} + value={form.list_id} + onChange={(e) => setForm({ ...form, list_id: e.target.value })} + /> + )} + + {form.audience_type === 'ADHOC' && ( +
+ +