diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..a38a5b4 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,61 @@ +# TaimakoAI - Project Context + +## Project Overview +**TaimakoAI** is an AI-powered customer support platform that leverages a Retrieval-Augmented Generation (RAG) engine. It processes uploaded knowledge base documents to provide accurate, context-aware responses to customer queries via a smart embeddable chat widget or WhatsApp. It includes a business dashboard for managing documents and analytics, along with capabilities for human escalation and subscription billing. + +## Architecture & Tech Stack +- **Frontend:** Next.js 16, React 19, Tailwind CSS 4, TypeScript. +- **Backend:** FastAPI (Python 3.10+), SQLAlchemy ORM, Alembic for migrations. +- **AI / RAG Engine:** Google ADK, Gemini 2.0 Flash, ChromaDB, LiteLLM. +- **Database:** PostgreSQL. +- **Payments:** Paystack integration for tiered billing. +- **Infrastructure:** Docker, Docker Compose, GitHub Actions (CI/CD). + +## Building and Running +The repository relies heavily on a `Makefile` to orchestrate setup, running, testing, and db management. Docker is the recommended approach for local development. + +### Setup +`make setup` # Installs dependencies (uv for backend, npm for frontend) and git pre-commit hooks + +### Running Locally (Docker) +`make build` # Build/rebuild containers +`make start-d` # Start all services (frontend, backend, db) in detached mode +`make migrate` # Apply database migrations inside the backend container + +### Running Locally (Without Docker) +- **Backend:** `cd backend && uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000` +- **Frontend:** `cd frontend && npm run dev` + +### Stopping & Cleaning +`make stop` # Stop all services gracefully +`make clean` # Stop services and remove volumes (CAUTION: deletes DB data) + +## Development Conventions + +### Testing +- **Backend (Pytest):** + - All tests: `make test-be` + - Unit tests only: `make test-be-unit` + - API tests only: `make test-be-api` + - Integration tests only: `make test-be-integration` +- **Frontend (Vitest):** + - Run all tests: `make test-fe` + +### Linting & Formatting +- **Backend:** Code is linted using `ruff`. Run `make lint-be` or `make lint-be-fix` to auto-fix issues. +- **Frontend:** Code is linted using `eslint`. Run `make lint-fe` or `make lint-fe-fix` to auto-fix issues. +- **Pre-commit:** A pre-commit hook automatically runs linters on staged files. + +### Database Management +- Generate a new migration: `make migrate-generate` +- Apply migrations: `make migrate` +- Useful DB Commands: `make db-shell` (psql access), `make db-backup`, `make db-restore`, `make db-reset` + +### Admin Management +- Create or promote a user to admin: `make create-admin EMAIL=user@example.com` + +## Contribution Guidelines +1. Fork and clone the repository. +2. Run `make setup` to prepare your local environment. +3. Ensure all tests (`make test-be` and `make test-fe`) pass and code is linted before submitting a pull request. +4. For detailed guidelines, refer to `CONTRIBUTING.md`, `backend/TESTING.md`, and `frontend/TESTING.md`. diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 4f80045..b78b452 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -12,6 +12,7 @@ # Import all models so they are registered with Base.metadata from app.models.user import User # noqa: F401 from app.models.business import Business # noqa: F401 +from app.models.product import Product # noqa: F401 from app.models.plan import Plan # noqa: F401 from app.models.payment import PaymentTransaction # noqa: F401 from app.models.chat_session import ChatSession # noqa: F401 @@ -19,6 +20,15 @@ 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.order import Order, OrderItem # 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/a2b3c4d5e6f7_add_orders_and_order_items_tables.py b/backend/alembic/versions/a2b3c4d5e6f7_add_orders_and_order_items_tables.py new file mode 100644 index 0000000..0a9a6e0 --- /dev/null +++ b/backend/alembic/versions/a2b3c4d5e6f7_add_orders_and_order_items_tables.py @@ -0,0 +1,68 @@ +"""add orders and order_items tables + +Revision ID: a2b3c4d5e6f7 +Revises: ec1c5c9e9e3b +Create Date: 2026-06-13 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'a2b3c4d5e6f7' +down_revision: Union[str, Sequence[str], None] = 'ec1c5c9e9e3b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'orders', + sa.Column('id', sa.String(), nullable=False), + sa.Column('business_id', sa.String(), nullable=False), + sa.Column('session_id', sa.String(), nullable=True), + sa.Column('customer_name', sa.String(), nullable=False), + sa.Column('customer_email', sa.String(), nullable=True), + sa.Column('customer_phone', sa.String(), nullable=True), + sa.Column('customer_address', sa.Text(), nullable=True), + sa.Column('status', sa.String(), nullable=False, server_default='pending'), + sa.Column('total_amount', sa.Numeric(10, 2), nullable=False), + sa.Column('currency', sa.String(), nullable=False, server_default='USD'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['session_id'], ['chat_sessions.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('ix_orders_business_id', 'orders', ['business_id']) + op.create_index('ix_orders_session_id', 'orders', ['session_id']) + op.create_index('ix_orders_status', 'orders', ['status']) + + op.create_table( + 'order_items', + sa.Column('id', sa.String(), nullable=False), + sa.Column('order_id', sa.String(), nullable=False), + sa.Column('product_id', sa.String(), nullable=True), + sa.Column('product_name', sa.String(), nullable=False), + sa.Column('product_sku', sa.String(), nullable=False), + sa.Column('quantity', sa.Integer(), nullable=False), + sa.Column('unit_price', sa.Numeric(10, 2), nullable=False), + sa.Column('total_price', sa.Numeric(10, 2), nullable=False), + sa.Column('currency', sa.String(), nullable=False, server_default='USD'), + sa.ForeignKeyConstraint(['order_id'], ['orders.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['product_id'], ['products.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('ix_order_items_order_id', 'order_items', ['order_id']) + + +def downgrade() -> None: + op.drop_index('ix_order_items_order_id', table_name='order_items') + op.drop_table('order_items') + op.drop_index('ix_orders_status', table_name='orders') + op.drop_index('ix_orders_session_id', table_name='orders') + op.drop_index('ix_orders_business_id', table_name='orders') + op.drop_table('orders') 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/alembic/versions/ec1c5c9e9e3b_add_products_table.py b/backend/alembic/versions/ec1c5c9e9e3b_add_products_table.py new file mode 100644 index 0000000..bf3f633 --- /dev/null +++ b/backend/alembic/versions/ec1c5c9e9e3b_add_products_table.py @@ -0,0 +1,60 @@ +"""add_products_table + +Revision ID: ec1c5c9e9e3b +Revises: d3e4f5a6b7c8 +Create Date: 2026-05-10 18:13:07.409154 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'ec1c5c9e9e3b' +down_revision: Union[str, Sequence[str], None] = 'd3e4f5a6b7c8' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('products', + 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('price', sa.Numeric(precision=10, scale=2), nullable=False), + sa.Column('currency', sa.String(), nullable=True), + sa.Column('sku', sa.String(), nullable=False), + sa.Column('stock_quantity', sa.Integer(), nullable=True), + sa.Column('category', sa.String(), nullable=True), + sa.Column('image_urls', postgresql.ARRAY(sa.String()), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('products', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_products_business_id'), ['business_id'], unique=False) + batch_op.create_index(batch_op.f('ix_products_category'), ['category'], unique=False) + batch_op.create_index(batch_op.f('ix_products_name'), ['name'], unique=False) + batch_op.create_index(batch_op.f('ix_products_sku'), ['sku'], unique=False) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('products', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_products_sku')) + batch_op.drop_index(batch_op.f('ix_products_name')) + batch_op.drop_index(batch_op.f('ix_products_category')) + batch_op.drop_index(batch_op.f('ix_products_business_id')) + + op.drop_table('products') + # ### end Alembic commands ### diff --git a/backend/app/admin/views.py b/backend/app/admin/views.py index 975050b..3cb0e1f 100644 --- a/backend/app/admin/views.py +++ b/backend/app/admin/views.py @@ -2,6 +2,7 @@ from app.models.user import User from app.models.business import Business +from app.models.product import Product from app.models.plan import Plan from app.models.payment import PaymentTransaction from app.models.chat_session import ChatSession @@ -336,3 +337,28 @@ class AnalyticsDailySummaryAdmin(ModelView, model=AnalyticsDailySummary): } can_export = True + +# ─── Products ──────────────────────────────────────────────────────────────── + +class ProductAdmin(ModelView, model=Product): + name = "Product" + name_plural = "Products" + icon = "fa-solid fa-cart-shopping" + category = "Catalogue" + + column_list = [ + Product.id, Product.business_id, Product.name, + Product.price, Product.currency, Product.sku, + Product.stock_quantity, Product.is_active, + ] + column_searchable_list = [Product.name, Product.sku, Product.category] + column_sortable_list = [Product.name, Product.price, Product.stock_quantity, Product.is_active] + column_default_sort = ("name", False) + + column_labels = { + "business_id": "Business", + "stock_quantity": "Stock", + "is_active": "Active", + } + + can_export = True 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/orders.py b/backend/app/api/orders.py new file mode 100644 index 0000000..8f00d71 --- /dev/null +++ b/backend/app/api/orders.py @@ -0,0 +1,91 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from app.db.session import get_db +from app.auth.router import get_current_user +from app.models.user import User +from app.models.business import Business +from app.models.order import Order +from app.schemas.order import OrderResponse, OrderStatusUpdate, ORDER_STATUSES +from app.core.response_wrapper import success_response + +router = APIRouter(prefix="/orders", tags=["orders"]) + + +def _get_business(db: Session, user_id: str) -> 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("/", response_model=None) +async def list_orders( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + status: str = Query(default=""), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business(db, current_user.id) + + query = db.query(Order).filter(Order.business_id == business.id) + if status: + query = query.filter(Order.status == status) + + total = query.count() + orders = ( + query.order_by(Order.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + + return success_response( + data={ + "items": [OrderResponse.from_orm(o) for o in orders], + "total": total, + "page": page, + "page_size": page_size, + "pages": (total + page_size - 1) // page_size, + } + ) + + +@router.get("/{order_id}", response_model=None) +async def get_order( + order_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + business = _get_business(db, current_user.id) + order = db.query(Order).filter( + Order.id == order_id, + Order.business_id == business.id + ).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found.") + return success_response(data=OrderResponse.from_orm(order)) + + +@router.patch("/{order_id}/status", response_model=None) +async def update_order_status( + order_id: str, + body: OrderStatusUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + if body.status not in ORDER_STATUSES: + raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {ORDER_STATUSES}") + + business = _get_business(db, current_user.id) + order = db.query(Order).filter( + Order.id == order_id, + Order.business_id == business.id + ).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found.") + + order.status = body.status + db.commit() + db.refresh(order) + return success_response(message="Order status updated.", data=OrderResponse.from_orm(order)) diff --git a/backend/app/api/products.py b/backend/app/api/products.py new file mode 100644 index 0000000..da9c326 --- /dev/null +++ b/backend/app/api/products.py @@ -0,0 +1,188 @@ +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File +from sqlalchemy.orm import Session +import csv +import io +from app.db.session import get_db +from app.auth.router import get_current_user +from app.models.user import User +from app.models.business import Business +from app.models.product import Product +from app.schemas.product import ProductCreate, ProductUpdate, ProductResponse +from app.core.response_wrapper import success_response + +router = APIRouter(prefix="/products", tags=["products"]) + +def get_business(db: Session, user_id: str): + business = db.query(Business).filter(Business.user_id == user_id).first() + if not business: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Business profile not found. Please create a business profile first." + ) + return business + +@router.post("/", response_model=None) +async def create_product( + product_in: ProductCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + business = get_business(db, current_user.id) + + product = Product( + **product_in.model_dump(), + business_id=business.id + ) + db.add(product) + db.commit() + db.refresh(product) + return success_response( + message="Product created successfully", + data=ProductResponse.from_orm(product) + ) + +@router.get("/", response_model=None) +async def list_products( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + business = get_business(db, current_user.id) + products = db.query(Product).filter(Product.business_id == business.id).all() + return success_response( + data=[ProductResponse.from_orm(p) for p in products] + ) + +@router.get("/{product_id}", response_model=None) +async def get_product( + product_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + business = get_business(db, current_user.id) + product = db.query(Product).filter( + Product.id == product_id, + Product.business_id == business.id + ).first() + + if not product: + raise HTTPException(status_code=404, detail="Product not found") + + return success_response(data=ProductResponse.from_orm(product)) + +@router.put("/{product_id}", response_model=None) +async def update_product( + product_id: str, + product_in: ProductUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + business = get_business(db, current_user.id) + product = db.query(Product).filter( + Product.id == product_id, + Product.business_id == business.id + ).first() + + if not product: + raise HTTPException(status_code=404, detail="Product not found") + + update_data = product_in.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(product, field, value) + + db.add(product) + db.commit() + db.refresh(product) + return success_response( + message="Product updated successfully", + data=ProductResponse.from_orm(product) + ) + +@router.delete("/{product_id}", response_model=None) +async def delete_product( + product_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + business = get_business(db, current_user.id) + product = db.query(Product).filter( + Product.id == product_id, + Product.business_id == business.id + ).first() + + if not product: + raise HTTPException(status_code=404, detail="Product not found") + + db.delete(product) + db.commit() + return success_response(message="Product deleted successfully") + +@router.post("/bulk", response_model=None) +async def bulk_upload_products( + file: UploadFile = File(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + business = get_business(db, current_user.id) + + try: + content = await file.read() + decoded = content.decode('utf-8') + reader = csv.DictReader(io.StringIO(decoded)) + + imported = 0 + updated = 0 + errors = [] + + for row in reader: + try: + sku = row.get('sku') + if not sku: + errors.append(f"Missing SKU for product: {row.get('name', 'Unknown')}") + continue + + # Check if product exists + product = db.query(Product).filter( + Product.business_id == business.id, + Product.sku == sku + ).first() + + price_str = row.get('price', '0').replace(',', '') + price = float(price_str) if price_str else 0.0 + stock = int(row.get('stock_quantity', 0)) + is_active = row.get('is_active', 'true').lower() == 'true' + + if product: + product.name = row.get('name', product.name) + product.description = row.get('description', product.description) + product.price = price + product.stock_quantity = stock + product.category = row.get('category', product.category) + product.is_active = is_active + updated += 1 + else: + product = Product( + business_id=business.id, + name=row.get('name', 'Unnamed Product'), + description=row.get('description', ''), + price=price, + sku=sku, + stock_quantity=stock, + category=row.get('category', ''), + is_active=is_active + ) + db.add(product) + imported += 1 + except Exception as e: + errors.append(f"Error processing row with SKU {row.get('sku')}: {str(e)}") + + db.commit() + return success_response( + message=f"Bulk upload complete. Imported: {imported}, Updated: {updated}", + data={ + "imported": imported, + "updated": updated, + "errors": errors + } + ) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to process CSV file: {str(e)}") diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index b98272d..62d9646 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -10,8 +10,10 @@ from app.core.config import settings from app.core.response_wrapper import success_response +from app.api.products import router as products_router router = APIRouter() +router.include_router(products_router) @router.post("/documents/upload", response_model=None) 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..47118d9 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -34,12 +34,18 @@ class BaseConfig(BaseSettings): # System AI Key GOOGLE_API_KEY: str = os.getenv("GOOGLE_API_KEY", "") + # Gemini model configuration + GEMINI_MODEL: str = os.getenv("GEMINI_MODEL", "gemini-2.5-flash") + GEMINI_EMBEDDING_MODEL: str = os.getenv("GEMINI_EMBEDDING_MODEL", "models/gemini-embedding-001") + # Paystack PAYSTACK_WEBHOOK_SECRET: str = os.getenv("PAYSTACK_WEBHOOK_SECRET", "") # 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..66f0f78 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,7 +16,7 @@ UserAdmin, BusinessAdmin, PlanAdmin, PaymentTransactionAdmin, ChatSessionAdmin, GuestMessageAdmin, EscalationAdmin, WidgetSettingsAdmin, GuestUserAdmin, DocumentAdmin, - AnalyticsDailySummaryAdmin, + AnalyticsDailySummaryAdmin, ProductAdmin, ) app = FastAPI( @@ -51,6 +51,7 @@ admin.add_view(GuestUserAdmin) admin.add_view(DocumentAdmin) admin.add_view(AnalyticsDailySummaryAdmin) +admin.add_view(ProductAdmin) # Register Middleware (CORS, Security, Rate Limiting) register_middleware(app) @@ -86,6 +87,18 @@ 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.api.orders import router as orders_router +app.include_router(orders_router) + from app.core.response_wrapper import success_response @app.get("/") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..05cb444 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,19 @@ +from app.models.user import User # noqa: F401 +from app.models.business import Business # noqa: F401 +from app.models.product import Product # noqa: F401 +from app.models.plan import Plan # noqa: F401 +from app.models.payment import PaymentTransaction # noqa: F401 +from app.models.chat_session import ChatSession # noqa: F401 +from app.models.widget import WidgetSettings, GuestUser, GuestMessage # 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.order import Order, OrderItem # noqa: F401 +from app.models.whatsapp_broadcast import ( # noqa: F401 + WhatsAppContact, + WhatsAppContactList, + WhatsAppContactListMember, + WhatsAppTemplate, + WhatsAppCampaign, + WhatsAppCampaignMessage, +) diff --git a/backend/app/models/business.py b/backend/app/models/business.py index 732fb45..f124739 100644 --- a/backend/app/models/business.py +++ b/backend/app/models/business.py @@ -63,6 +63,8 @@ class Business(Base, SerializerMixin): # Relationship user = relationship("User", back_populates="business") transactions = relationship("PaymentTransaction", back_populates="business", cascade="all, delete-orphan") + products = relationship("Product", back_populates="business", cascade="all, delete-orphan") + orders = relationship("Order", back_populates="business", cascade="all, delete-orphan") # metadata = Column(JSON, nullable=True) diff --git a/backend/app/models/order.py b/backend/app/models/order.py new file mode 100644 index 0000000..dab5cff --- /dev/null +++ b/backend/app/models/order.py @@ -0,0 +1,51 @@ +import uuid +from sqlalchemy import Column, String, Text, DateTime, ForeignKey, Numeric, Integer +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.db.base import Base + + +def _uuid(): + return str(uuid.uuid4()) + + +class Order(Base): + __tablename__ = "orders" + + id = Column(String, primary_key=True, default=_uuid) + business_id = Column(String, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True) + session_id = Column(String, ForeignKey("chat_sessions.id", ondelete="SET NULL"), nullable=True, index=True) + + customer_name = Column(String, nullable=False) + customer_email = Column(String, nullable=True) + customer_phone = Column(String, nullable=True) + customer_address = Column(Text, nullable=True) + + status = Column(String, nullable=False, default="pending") + total_amount = Column(Numeric(10, 2), nullable=False) + currency = Column(String, nullable=False, default="USD") + notes = Column(Text, nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + business = relationship("Business", back_populates="orders") + items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan") + + +class OrderItem(Base): + __tablename__ = "order_items" + + id = Column(String, primary_key=True, default=_uuid) + order_id = Column(String, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False, index=True) + product_id = Column(String, ForeignKey("products.id", ondelete="SET NULL"), nullable=True) + + product_name = Column(String, nullable=False) + product_sku = Column(String, nullable=False) + quantity = Column(Integer, nullable=False, default=1) + unit_price = Column(Numeric(10, 2), nullable=False) + total_price = Column(Numeric(10, 2), nullable=False) + currency = Column(String, nullable=False, default="USD") + + order = relationship("Order", back_populates="items") + product = relationship("Product") diff --git a/backend/app/models/product.py b/backend/app/models/product.py new file mode 100644 index 0000000..89ae540 --- /dev/null +++ b/backend/app/models/product.py @@ -0,0 +1,32 @@ +import uuid +from sqlalchemy import Column, String, Text, Numeric, Integer, Boolean, ForeignKey, DateTime, JSON +from sqlalchemy.orm import relationship +from sqlalchemy.dialects.postgresql import ARRAY as PG_ARRAY +from datetime import datetime, timezone +from app.db.base import Base +from app.models.mixins import SerializerMixin + +def generate_uuid(): + return str(uuid.uuid4()) + +class Product(Base, SerializerMixin): + __tablename__ = "products" + + id = Column(String, primary_key=True, default=generate_uuid) + business_id = Column(String, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True) + + name = Column(String, nullable=False, index=True) + description = Column(Text, nullable=True) + price = Column(Numeric(10, 2), nullable=False) + currency = Column(String, default="USD") + sku = Column(String, nullable=False, index=True) + stock_quantity = Column(Integer, default=0) + category = Column(String, nullable=True, index=True) + image_urls = Column(PG_ARRAY(String).with_variant(JSON, 'sqlite'), nullable=True) # Array of image URLs + is_active = Column(Boolean, default=True) + + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + # Relationship + business = relationship("Business", back_populates="products") 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/order.py b/backend/app/schemas/order.py new file mode 100644 index 0000000..1cd723d --- /dev/null +++ b/backend/app/schemas/order.py @@ -0,0 +1,45 @@ +from typing import Optional, List +from datetime import datetime +from pydantic import BaseModel + + +ORDER_STATUSES = ["pending", "confirmed", "processing", "shipped", "delivered", "cancelled"] + + +class OrderItemResponse(BaseModel): + id: str + order_id: str + product_id: Optional[str] = None + product_name: str + product_sku: str + quantity: int + unit_price: float + total_price: float + currency: str + + class Config: + from_attributes = True + + +class OrderResponse(BaseModel): + id: str + business_id: str + session_id: Optional[str] = None + customer_name: str + customer_email: Optional[str] = None + customer_phone: Optional[str] = None + customer_address: Optional[str] = None + status: str + total_amount: float + currency: str + notes: Optional[str] = None + created_at: datetime + updated_at: datetime + items: List[OrderItemResponse] = [] + + class Config: + from_attributes = True + + +class OrderStatusUpdate(BaseModel): + status: str diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py new file mode 100644 index 0000000..c60d1fe --- /dev/null +++ b/backend/app/schemas/product.py @@ -0,0 +1,41 @@ +from pydantic import BaseModel +from typing import Optional, List +from decimal import Decimal +from datetime import datetime + +class ProductBase(BaseModel): + name: str + description: Optional[str] = None + price: Decimal + currency: str = "USD" + sku: str + stock_quantity: int = 0 + category: Optional[str] = None + image_urls: Optional[List[str]] = None + is_active: bool = True + +class ProductCreate(ProductBase): + pass + +class ProductUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + price: Optional[Decimal] = None + currency: Optional[str] = None + sku: Optional[str] = None + stock_quantity: Optional[int] = None + category: Optional[str] = None + image_urls: Optional[List[str]] = None + is_active: Optional[bool] = None + +class ProductResponse(ProductBase): + id: str + business_id: str + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +class ProductBulkUpload(BaseModel): + products: List[ProductCreate] 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/agent_service.py b/backend/app/services/agent_service.py index 485cdbd..775f053 100644 --- a/backend/app/services/agent_service.py +++ b/backend/app/services/agent_service.py @@ -37,16 +37,22 @@ async def call_agent_async(query: str, runner: Runner, user_id: str, session_id: print(f"\n>>> User Query: {query} (User: {user_id})") content = types.Content(role='user', parts=[types.Part(text=query)]) - final_response_text = "Agent did not produce a final response." + final_response_text = None async for event in runner.run_async(user_id=user_id, session_id=session_id, new_message=content): - # print(f" [Event] {type(event).__name__}") # Uncomment for debug if event.is_final_response(): if event.content and event.content.parts: - final_response_text = event.content.parts[0].text + text = event.content.parts[0].text + if text: + final_response_text = text + break elif event.actions and event.actions.escalate: final_response_text = f"Agent escalated: {event.error_message or 'No specific message.'}" - break + break + + if not final_response_text: + print("<<< WARNING: No final response text produced by agent") + final_response_text = "I'm sorry, I didn't catch that. Could you try again?" print(f"<<< Agent Response: {final_response_text}") return final_response_text diff --git a/backend/app/services/agent_system/agent_factory.py b/backend/app/services/agent_system/agent_factory.py index ae8ab2e..cc94ae5 100644 --- a/backend/app/services/agent_system/agent_factory.py +++ b/backend/app/services/agent_system/agent_factory.py @@ -1,8 +1,9 @@ from google.adk.agents import Agent from google.adk.models.lite_llm import LiteLlm from app.services.agent_system.tools import ( - get_context, say_hello, say_goodbye, - analyze_sentiment, escalate_to_human + get_context, say_hello, say_goodbye, + analyze_sentiment, escalate_to_human, + search_products, create_order, ) from app.services.agent_system.callbacks import ( block_unsafe_content, @@ -12,6 +13,7 @@ chain_callbacks ) from typing import Optional +from app.core.config import settings # Default detailed instruction used when a business does not provide a custom one. # This follows best practices: be friendly, professional, concise, and reference the business name. @@ -24,9 +26,6 @@ ) -# --- Model Constants --- -MODEL_GEMINI_2_0_FLASH = "gemini-2.0-flash" - class AgentFactory: """Factory for creating dynamically configured agents based on business settings.""" @@ -34,11 +33,11 @@ class AgentFactory: def _get_model(api_key: Optional[str] = None): """Get the appropriate model, with API key if provided.""" if api_key: - model_name = MODEL_GEMINI_2_0_FLASH + model_name = settings.GEMINI_MODEL if not model_name.startswith("gemini/"): model_name = f"gemini/{model_name}" return LiteLlm(model=model_name, api_key=api_key) - return MODEL_GEMINI_2_0_FLASH + return settings.GEMINI_MODEL @staticmethod def create_greeting_agent(business_name: str = "our company", api_key: Optional[str] = None): @@ -93,9 +92,51 @@ def create_escalation_agent(business_name: str = "our company", api_key: Optiona before_model_callback=block_unsafe_content, after_model_callback=chain_callbacks(sanitize_model_response, trigger_session_analysis) ) - + + @staticmethod + def create_sales_agent(business_name: str = "our company", api_key: Optional[str] = None): + """Create the sales sub-agent for product inquiries and assisted selling.""" + instruction = ( + f"You are a sales specialist for {business_name}. Your goal is to help users find products and place orders.\n\n" + f"PRODUCT SEARCH RULES:\n" + f"1. Use 'search_products' for ANY query related to products, prices, stock, or categories.\n" + f"2. SEARCH QUERY RULES:\n" + f" - For specific product/category searches, pass the relevant keyword (e.g. 'solar', 'panel', 'battery').\n" + f" - For general questions like 'what do you sell?', 'show me everything', 'what else do you have?', " + f"'what other products?', or any phrasing asking for the full catalogue, pass an EMPTY STRING '' as the query to retrieve all products.\n" + f" - NEVER pass conversational words like 'other', 'more', 'all', 'everything', 'anything' as the query — use '' instead.\n" + f"3. Provide clear, concise product information. Always include price and availability if known.\n" + f"4. If a product is out of stock, suggest looking for similar items.\n" + f"5. If no products match a specific search, politely inform the user and suggest they try a different term.\n\n" + f"ORDER PLACEMENT RULES:\n" + f"6. When a user confirms they want to buy one or more products, guide them through providing:\n" + f" - Full name (required)\n" + f" - Delivery address (required)\n" + f" - Email address (optional but recommended)\n" + f" - Phone number (optional)\n" + f"7. Once you have at minimum their name and address, AND they have confirmed the quantity and product, " + f"call 'create_order' immediately. Do NOT promise to 'forward to a team' or invent any other process.\n" + f"8. Use only the product names, SKUs, and prices returned by 'search_products' when building the items list for 'create_order'.\n" + f"9. After 'create_order' succeeds, confirm the order ID and tell the user the team will contact them for payment.\n" + f"10. NEVER collect payment details (card numbers, bank info).\n" + f"11. Be persuasive but helpful and professional.\n" + f"12. NEVER make up products, prices, or details. NEVER mention internal tools or systems.\n" + ) + + return Agent( + name="sales_agent", + model=AgentFactory._get_model(api_key), + description=f"Handles product searches and order placement for {business_name}.", + instruction=instruction, + tools=[search_products, create_order], + before_model_callback=block_unsafe_content, + before_tool_callback=validate_tool_args, + after_model_callback=chain_callbacks(sanitize_model_response, trigger_session_analysis) + ) + @staticmethod def create_rag_agent(business_name: str, custom_instruction: Optional[str] = None, intents: Optional[list] = None, api_key: Optional[str] = None): + """ Create a RAG agent with business-specific configuration. This agent is a specialist in retrieving context and answering questions. @@ -185,6 +226,7 @@ def create_chief_agent(business_name: str, custom_instruction: Optional[str] = N farewell_agent = AgentFactory.create_farewell_agent(business_name, api_key) rag_agent = AgentFactory.create_rag_agent(business_name, custom_instruction, intents, api_key) escalation_agent = AgentFactory.create_escalation_agent(business_name, api_key) + sales_agent = AgentFactory.create_sales_agent(business_name, api_key) instruction = ( f"You are the main assistant for {business_name}. Provide helpful, professional support ONLY for {business_name}-related topics.\n\n" @@ -193,12 +235,14 @@ def create_chief_agent(business_name: str, custom_instruction: Optional[str] = N f"- For ANY question about other companies, products, or unrelated topics, politely decline\n" f"- If asked about topics outside {business_name}, respond: 'I can only assist with questions about {business_name}. For other topics, please consult the appropriate support resources.'\n" f"- NEVER provide 'general guidance' or 'one-time exceptions' for topics outside your scope\n\n" - f"Handle user requests appropriately:\n" - f"- For greetings: Provide a warm welcome\n" - f"- For farewells: Provide a polite goodbye\n" - f"- For help/support needed/human requests: Delegate to 'escalation_agent'\n" - f"- For questions about {business_name}: Provide accurate, helpful information\n" - f"- For questions about anything else: Politely decline\n\n" + f"ROUTING RULES — you MUST delegate every request, NEVER respond directly:\n" + f"- Greetings (hello, hi, hey, good morning, etc.): ALWAYS delegate to 'greeting_agent'\n" + f"- Farewells (bye, goodbye, see you, thanks and bye, etc.): ALWAYS delegate to 'farewell_agent'\n" + f"- Products, prices, stock, purchasing, or order questions: ALWAYS delegate to 'sales_agent'\n" + f"- General info, FAQ, how-to, or support questions about {business_name}: ALWAYS delegate to 'rag_agent'\n" + f"- User frustrated, requests a human, or asks to speak to someone: ALWAYS delegate to 'escalation_agent'\n" + f"- Anything outside {business_name} topics: respond with 'I can only assist with questions about {business_name}.'\n\n" + f"IMPORTANT: You have NO tools of your own. You MUST delegate to the appropriate agent for every request. Do NOT compose a reply yourself.\n\n" f"ESCALATION:\n" f"If the user asks to speak to a human, or expresses significant frustration, delegate to 'escalation_agent'.\n\n" f"SECURITY RULES:\n" @@ -223,7 +267,7 @@ def create_chief_agent(business_name: str, custom_instruction: Optional[str] = N description=f"Chief Orchestrator Agent for {business_name}.", instruction=instruction, tools=[], - sub_agents=[greeting_agent, farewell_agent, rag_agent, escalation_agent], + sub_agents=[greeting_agent, farewell_agent, rag_agent, escalation_agent, sales_agent], output_key="last_agent_response", before_model_callback=block_unsafe_content, after_model_callback=chain_callbacks(sanitize_model_response, trigger_session_analysis) diff --git a/backend/app/services/agent_system/agents.py b/backend/app/services/agent_system/agents.py index 57ef57f..dc27089 100644 --- a/backend/app/services/agent_system/agents.py +++ b/backend/app/services/agent_system/agents.py @@ -1,17 +1,15 @@ from google.adk.agents import Agent from app.services.agent_system.tools import ( - get_context, say_hello, say_goodbye, + get_context, say_hello, say_goodbye, analyze_sentiment, escalate_to_human ) from app.services.agent_system.callbacks import block_unsafe_content, validate_tool_args - -# --- Model Constants --- -MODEL_GEMINI_2_0_FLASH = "gemini-2.0-flash" +from app.core.config import settings # Sub-Agent: Greeting greeting_agent = Agent( name="greeting_agent", - model=MODEL_GEMINI_2_0_FLASH, + model=settings.GEMINI_MODEL, description="Handles simple greetings.", instruction="You are a friendly greeting agent. Use 'say_hello' to greet the user.", tools=[say_hello] @@ -20,7 +18,7 @@ # Sub-Agent: Farewell farewell_agent = Agent( name="farewell_agent", - model=MODEL_GEMINI_2_0_FLASH, + model=settings.GEMINI_MODEL, description="Handles simple farewells.", instruction="You are a polite farewell agent. Use 'say_goodbye' to say goodbye.", tools=[say_goodbye] @@ -29,7 +27,7 @@ # Sub-Agent: Escalation escalation_agent = Agent( name="escalation_agent", - model=MODEL_GEMINI_2_0_FLASH, + model=settings.GEMINI_MODEL, description="Handles escalation to human agents and sentiment analysis.", instruction="You are an escalation specialist. " "1. If the user is expressing frustration or anger, first use 'analyze_sentiment' to confirm. " @@ -42,7 +40,7 @@ rag_agent = Agent( name="rag_agent", # Using LiteLlm wrapper for multi-model support (even if using Gemini here) - model=MODEL_GEMINI_2_0_FLASH, + model=settings.GEMINI_MODEL, description="Main agent. Provides context for questions, delegates greetings/farewells.", instruction="You are a helpful customer support assistant. " "For greetings, delegate to 'greeting_agent'. " diff --git a/backend/app/services/agent_system/tool_schemas.py b/backend/app/services/agent_system/tool_schemas.py index 03dc04a..4ecb8e0 100644 --- a/backend/app/services/agent_system/tool_schemas.py +++ b/backend/app/services/agent_system/tool_schemas.py @@ -112,3 +112,50 @@ class EscalateToHumanOutput(BaseModel): escalation_id: str = Field(..., description="ID of the created escalation ticket") status: str = Field(..., description="Status of the escalation (e.g. pending)") message: str = Field(..., description="Message to display to the user confirming handoff") + +class SearchProductsInput(BaseModel): + """Input for product search tool.""" + query: str = Field(..., description="Search term for products (name, category, or description)") + +class ProductToolSchema(BaseModel): + """Internal schema for product data returned to the agent.""" + name: str + price: float + currency: str + sku: str + description: Optional[str] = None + stock_quantity: int + image_urls: Optional[list[str]] = None + +class SearchProductsOutput(BaseModel): + """Output for product search tool.""" + products: list[ProductToolSchema] + count: int + + +class OrderItemInput(BaseModel): + """A single line item when creating an order.""" + product_name: str = Field(..., description="Product name") + product_sku: str = Field(..., description="Product SKU") + quantity: int = Field(..., ge=1, description="Quantity to order") + unit_price: float = Field(..., gt=0, description="Price per unit") + currency: str = Field(default="USD", description="Currency code") + + +class CreateOrderInput(BaseModel): + """Input for the create_order tool.""" + customer_name: str = Field(..., description="Customer's full name") + customer_email: Optional[str] = Field(None, description="Customer's email address") + customer_phone: Optional[str] = Field(None, description="Customer's phone number") + customer_address: Optional[str] = Field(None, description="Customer's shipping address") + items: list[OrderItemInput] = Field(..., description="List of items being ordered") + notes: Optional[str] = Field(None, description="Any additional notes") + + +class CreateOrderOutput(BaseModel): + """Output for the create_order tool.""" + order_id: str = Field(..., description="Unique order ID") + status: str = Field(..., description="Order status (pending)") + total_amount: float = Field(..., description="Total order value") + currency: str = Field(..., description="Currency code") + message: str = Field(..., description="Confirmation message for the user") diff --git a/backend/app/services/agent_system/tools.py b/backend/app/services/agent_system/tools.py index 05a7d08..4246a75 100644 --- a/backend/app/services/agent_system/tools.py +++ b/backend/app/services/agent_system/tools.py @@ -7,15 +7,20 @@ ) from app.db.session import SessionLocal from app.models.business import Business +from app.models.product import Product from app.models.escalation import Escalation, EscalationStatus from app.models.chat_session import ChatSession from app.services.email_service import EmailServiceFactory from app.services.agent_system.tool_schemas import ( AnalyzeSentimentInput, AnalyzeSentimentOutput, - EscalateToHumanInput, EscalateToHumanOutput + EscalateToHumanInput, EscalateToHumanOutput, + SearchProductsInput, SearchProductsOutput, ProductToolSchema, + CreateOrderInput, CreateOrderOutput, ) import json +from decimal import Decimal from app.core.subscription import TIER_LIMITS +from app.core.config import settings try: @@ -148,7 +153,7 @@ def analyze_sentiment(user_text: str, tool_context: ToolContext) -> str: Return JSON only: {{"sentiment": "Positive" | "Neutral" | "Negative", "score": 0.0 to 1.0}} """ response = client.models.generate_content( - model="gemini-2.0-flash", + model=settings.GEMINI_MODEL, contents=prompt ) text = response.text.replace("```json", "").replace("```", "").strip() @@ -320,3 +325,228 @@ def send_in_thread(coro): return f"Error processing escalation: {str(e)}" finally: db.close() + +def search_products(query: str, tool_context: ToolContext) -> str: + """Searches the product catalogue for items matching the query. + + Args: + query: Search term (name, category, description). + tool_context: Context containing business user_id. + + Returns: + JSON string with list of matching products. + """ + print(f"--- Tool: search_products called for: {query} ---") + + try: + validated_input = SearchProductsInput(query=query) + except Exception as e: + return f"Error: Invalid input - {str(e)}" + + user_id = tool_context.state.get("user_id") + if not user_id: + return "Error: User ID not found in session state." + + db = SessionLocal() + try: + # 1. Find business + business = db.query(Business).filter(Business.user_id == user_id).first() + if not business: + return "Error: Business not found for this session." + + # 2. Search products + search_term = f"%{validated_input.query}%" + products = db.query(Product).filter( + Product.business_id == business.id, + Product.is_active, + (Product.name.ilike(search_term)) | + (Product.category.ilike(search_term)) | + (Product.description.ilike(search_term)) | + (Product.sku.ilike(search_term)) + ).all() + + # 3. Format output + product_list = [ + ProductToolSchema( + name=p.name, + price=float(p.price), + currency=p.currency, + sku=p.sku, + description=p.description, + stock_quantity=p.stock_quantity, + image_urls=p.image_urls + ) for p in products + ] + + output = SearchProductsOutput(products=product_list, count=len(product_list)) + return json.dumps(output.model_dump()) + + except Exception as e: + print(f"Search Products Error: {e}") + return f"Error searching products: {str(e)}" + finally: + db.close() + + +def create_order( + customer_name: str, + items: list[dict], + tool_context: ToolContext, + customer_email: Optional[str] = None, + customer_phone: Optional[str] = None, + customer_address: Optional[str] = None, + notes: Optional[str] = None, +) -> str: + """Creates a new order from a customer's purchase intent. + + Args: + customer_name: Customer's full name. + items: List of order items. Each item is a dict with keys: product_name (str), product_sku (str), quantity (int), unit_price (float), currency (str). + tool_context: Context containing business user_id and session_id. + customer_email: Customer's email address. + customer_phone: Customer's phone number. + customer_address: Customer's shipping/delivery address. + notes: Any additional notes. + + Returns: + JSON string with order confirmation details. + """ + print(f"--- Tool: create_order called for customer: {customer_name} ---") + + try: + validated = CreateOrderInput( + customer_name=customer_name, + customer_email=customer_email, + customer_phone=customer_phone, + customer_address=customer_address, + items=items, + notes=notes, + ) + except Exception as e: + return f"Error: Invalid order input - {str(e)}" + + user_id = tool_context.state.get("user_id") + session_id = tool_context.state.get("session_id") + if not user_id: + return "Error: User ID not found in session state." + + from app.models.order import Order, OrderItem + + db = SessionLocal() + try: + business = db.query(Business).filter(Business.user_id == user_id).first() + if not business: + return "Error: Business not found for this session." + + total = Decimal("0.00") + order_items = [] + currency = "USD" + + for item_data in validated.items: + qty = item_data.quantity + unit_price = Decimal(str(item_data.unit_price)) + line_total = unit_price * qty + total += line_total + currency = item_data.currency + + # Try to resolve product_id by SKU + product = db.query(Product).filter( + Product.business_id == business.id, + Product.sku == item_data.product_sku, + ).first() + + order_items.append(OrderItem( + product_id=product.id if product else None, + product_name=item_data.product_name, + product_sku=item_data.product_sku, + quantity=qty, + unit_price=unit_price, + total_price=line_total, + currency=currency, + )) + + order = Order( + business_id=business.id, + session_id=session_id, + customer_name=validated.customer_name, + customer_email=validated.customer_email, + customer_phone=validated.customer_phone, + customer_address=validated.customer_address, + status="pending", + total_amount=total, + currency=currency, + notes=validated.notes, + ) + db.add(order) + db.flush() # get order.id before adding items + + for oi in order_items: + oi.order_id = order.id + db.add(oi) + + db.commit() + db.refresh(order) + + print(f"--- Tool: Order {order.id} created for business {business.id} ---") + + # Notify business owner by email + try: + email_service = EmailServiceFactory.get_service() + emails = business.escalation_emails or [] + if emails: + from app.services.email_service import EmailSchema + import asyncio + import threading + + item_lines = "\n".join( + f" - {oi.product_name} (SKU: {oi.product_sku}) x{oi.quantity} @ {oi.unit_price} {oi.currency}" + for oi in order_items + ) + body = ( + f"New Order Received!\n\n" + f"Order ID: {order.id}\n" + f"Customer: {validated.customer_name}\n" + f"Email: {validated.customer_email or 'N/A'}\n" + f"Phone: {validated.customer_phone or 'N/A'}\n" + f"Address: {validated.customer_address or 'N/A'}\n\n" + f"Items:\n{item_lines}\n\n" + f"Total: {total} {currency}\n" + ) + schema = EmailSchema( + subject=f"New Order from {validated.customer_name} — {business.business_name}", + recipients=emails, + body=body, + ) + + def _send(coro): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(coro) + except Exception as err: + print(f"Order email error: {err}") + finally: + loop.close() + + threading.Thread(target=_send, args=(email_service.send_email(schema),)).start() + except Exception as email_err: + print(f"--- Tool: Order email notification failed: {email_err} ---") + + output = CreateOrderOutput( + order_id=order.id, + status="pending", + total_amount=float(total), + currency=currency, + message=( + f"Your order has been placed successfully! Order ID: {order.id}. " + f"Total: {total} {currency}. " + f"Our team will contact you shortly to arrange payment and delivery." + ), + ) + return json.dumps(output.model_dump()) + + except Exception as e: + print(f"Create Order Error: {e}") + return f"Error creating order: {str(e)}" + finally: + db.close() diff --git a/backend/app/services/analysis_agent.py b/backend/app/services/analysis_agent.py index a7fe4be..cfc7d8f 100644 --- a/backend/app/services/analysis_agent.py +++ b/backend/app/services/analysis_agent.py @@ -7,6 +7,7 @@ # Use specific client for multi-tenant API key support from google import genai +from app.core.config import settings INTENT_ENUM = ["Support", "Sales", "Feedback", "Bug Report", "General"] @@ -24,8 +25,8 @@ async def analyze_session(db: Session, session_id: str, intents: Optional[List[s session = db.query(ChatSession).filter(ChatSession.id == session_id).first() if not session: - print(f"Analysis Agent: Session {session_id} not found in database") - raise ValueError("Session not found") + print(f"Analysis Agent: Session {session_id} not found in database — skipping analysis") + return "No session record", "General" messages = db.query(GuestMessage).filter(GuestMessage.session_id == session_id).order_by(GuestMessage.created_at).all() @@ -74,7 +75,7 @@ async def analyze_session(db: Session, session_id: str, intents: Optional[List[s print("Analysis Agent: Calling Gemini 2.0 Flash for analysis...") client = genai.Client(api_key=api_key) response = client.models.generate_content( - model="gemini-2.0-flash", + model=settings.GEMINI_MODEL, contents=prompt ) @@ -140,7 +141,7 @@ async def generate_business_intents(business_description: str, api_key: str = No try: client = genai.Client(api_key=api_key) response = client.models.generate_content( - model="gemini-2.0-flash", + model=settings.GEMINI_MODEL, contents=prompt ) text = response.text @@ -186,7 +187,7 @@ async def generate_followup_content(messages: List[GuestMessage], follow_up_type try: client = genai.Client(api_key=api_key) response = client.models.generate_content( - model="gemini-2.0-flash", + model=settings.GEMINI_MODEL, contents=prompt ) return response.text diff --git a/backend/app/services/rag_service.py b/backend/app/services/rag_service.py index a329b82..a44721e 100644 --- a/backend/app/services/rag_service.py +++ b/backend/app/services/rag_service.py @@ -25,7 +25,7 @@ def _get_embedding(self, text: str, api_key: str) -> List[float]: try: genai.configure(api_key=api_key) result = genai.embed_content( - model="models/gemini-embedding-001", + model=settings.GEMINI_EMBEDDING_MODEL, content=text, task_type="retrieval_document" ) @@ -42,7 +42,7 @@ def _get_query_embedding(self, text: str, api_key: str) -> List[float]: try: genai.configure(api_key=api_key) result = genai.embed_content( - model="models/gemini-embedding-001", + model=settings.GEMINI_EMBEDDING_MODEL, content=text, task_type="retrieval_query" ) 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..c647770 --- /dev/null +++ b/backend/app/workers/whatsapp_campaign_worker.py @@ -0,0 +1,186 @@ +"""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.order import Order, OrderItem # 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/backend/tests/unit/tools/test_search_products.py b/backend/tests/unit/tools/test_search_products.py new file mode 100644 index 0000000..f0dd83d --- /dev/null +++ b/backend/tests/unit/tools/test_search_products.py @@ -0,0 +1,109 @@ +""" +Tests for the search_products tool. +Validates product catalogue searching functionality for the sales agent. +""" +import json +from unittest.mock import MagicMock +from app.services.agent_system.tools import search_products +from app.models.business import Business +from app.models.product import Product + + +class TestSearchProducts: + """Test suite for search_products tool functionality.""" + + class TestSuccessfulSearch: + """Tests for successful product search scenarios.""" + + def test_search_returns_matching_products(self, mock_tool_context, mock_session_local): + """Test that valid search query returns correct product data.""" + # 1. Setup Mock Business + mock_business = MagicMock(spec=Business) + mock_business.id = "business-uuid-123" + mock_business.user_id = "test-user-123" + + # Setup Mock Product + mock_product = MagicMock(spec=Product) + mock_product.name = "Solar Panel" + mock_product.price = 250.00 + mock_product.currency = "USD" + mock_product.sku = "SOL-001" + mock_product.description = "High efficiency solar panel" + mock_product.stock_quantity = 15 + mock_product.image_urls = ["http://example.com/solar.jpg"] + mock_product.is_active = True + + # 2. Configure Mock Session + # First query is for Business + mock_session_local.query.return_value.filter.return_value.first.return_value = mock_business + # Second query is for Products + mock_session_local.query.return_value.filter.return_value.all.return_value = [mock_product] + + # 3. Execute Tool + result = search_products(query="solar", tool_context=mock_tool_context) + + # 4. Assertions + data = json.loads(result) + assert data["count"] == 1 + assert data["products"][0]["name"] == "Solar Panel" + assert data["products"][0]["price"] == 250.00 + assert data["products"][0]["sku"] == "SOL-001" + + # Verify queries + assert mock_session_local.query.called + assert mock_session_local.commit.called is False # Should be read-only + + def test_search_returns_empty_list_when_no_matches(self, mock_tool_context, mock_session_local): + """Test that search returns empty list when no products match.""" + mock_business = MagicMock(spec=Business) + mock_business.id = "business-123" + + mock_session_local.query.return_value.filter.return_value.first.return_value = mock_business + mock_session_local.query.return_value.filter.return_value.all.return_value = [] + + result = search_products(query="nonexistent", tool_context=mock_tool_context) + + data = json.loads(result) + assert data["count"] == 0 + assert len(data["products"]) == 0 + + class TestErrorHandling: + """Tests for error handling in search_products tool.""" + + def test_missing_user_id_returns_error(self, mock_session_local): + """Test handling of missing user_id in tool context.""" + context = MagicMock() + context.state = {} # Empty state + + result = search_products(query="test", tool_context=context) + + assert "Error" in result + assert "User ID not found" in result + + def test_business_not_found_returns_error(self, mock_tool_context, mock_session_local): + """Test handling when business cannot be found for user_id.""" + mock_session_local.query.return_value.filter.return_value.first.return_value = None + + result = search_products(query="test", tool_context=mock_tool_context) + + assert "Error" in result + assert "Business not found" in result + + def test_database_exception_handled(self, mock_tool_context, mock_session_local): + """Test that database exceptions are caught and reported.""" + mock_session_local.query.side_effect = Exception("DB Connection Failed") + + result = search_products(query="test", tool_context=mock_tool_context) + + assert "Error searching products" in result + assert "DB Connection Failed" in result + + class TestInputValidation: + """Tests for input validation.""" + + def test_invalid_query_type_handled(self, mock_tool_context, mock_session_local): + """Test that non-string queries are handled (though Pydantic should catch it).""" + # Passing something that's not a string (if we weren't using Pydantic, but we are) + # Actually, search_products(query=None, ...) might happen if not careful + result = search_products(query=None, tool_context=mock_tool_context) + assert "Error" in result 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/cspell.json b/cspell.json new file mode 100644 index 0000000..580081d --- /dev/null +++ b/cspell.json @@ -0,0 +1,12 @@ +{ + "version": "0.2", + "words": [ + "Taimako", + "taimako", + "dubem", + "Paystack", + "PAYSTACK", + "supersecretkey", + "coro" + ] +} 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 bb061a0..6cd3b53 100644 Binary files a/frontend/public/logo.png and b/frontend/public/logo.png differ diff --git a/frontend/public/logo_old.png b/frontend/public/logo_old.png new file mode 100644 index 0000000..bb061a0 Binary files /dev/null and b/frontend/public/logo_old.png differ diff --git a/frontend/src/app/dashboard/analytics/page.tsx b/frontend/src/app/dashboard/analytics/page.tsx index 789a86f..a716901 100644 --- a/frontend/src/app/dashboard/analytics/page.tsx +++ b/frontend/src/app/dashboard/analytics/page.tsx @@ -116,7 +116,7 @@ export default function AnalyticsPage() { return (
-

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/catalogue/page.tsx b/frontend/src/app/dashboard/catalogue/page.tsx new file mode 100644 index 0000000..81977e1 --- /dev/null +++ b/frontend/src/app/dashboard/catalogue/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function CatalogueRedirect() { + redirect('/dashboard/marketplace/catalogue'); +} 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..fede842 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, ShoppingBag, ClipboardList, Store } from 'lucide-react'; import Sidebar, { SidebarSection } from '@/components/ui/Sidebar'; import { useAuth } from '@/contexts/AuthContext'; import { useBusiness, BusinessProvider } from '@/contexts/BusinessContext'; @@ -31,11 +31,30 @@ function DashboardLayoutInner({ items: [ { label: 'Overview', href: '/dashboard', icon: LayoutDashboard }, { label: 'Sessions', href: '/dashboard/sessions', icon: MessageSquare }, + { + label: 'Marketplace', + icon: Store, + href: '/dashboard/marketplace', + subItems: [ + { label: 'Catalogue', href: '/dashboard/marketplace/catalogue', icon: ShoppingBag }, + { label: 'Orders', href: '/dashboard/marketplace/orders', icon: ClipboardList }, + ], + }, { label: 'Analytics', href: '/dashboard/analytics', icon: Building2 }, { label: 'Knowledge Base', href: '/dashboard/documents', icon: FileText }, { 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 +77,7 @@ function DashboardLayoutInner({ {/* Mobile Header */}
-

Taimako

+

Taimako

+ +
+
+ + {/* Notifications */} + + {message && ( + + {message.type === 'success' ? ( + + ) : ( + + )} +

{message.text}

+ +
+ )} +
+ + {/* Filters & Search */} + +
+
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> + +
+ + + +
+
+
+ + {/* Products Table */} + +
+ + + + + + + + + + + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + + + )) + ) : pagedProducts.length === 0 ? ( + + + + ) : ( + pagedProducts.map((product) => ( + + + + + + + + + + )) + )} + +
ProductSKUCategoryPriceStockStatus
+ +
+ +

No products found

+ +
+
+
+ {product.image_urls?.[0] ? ( + + ) : ( + + )} +
+
+

{product.name}

+

{product.description}

+
+
+
{product.sku} + + {product.category || 'None'} + + + {product.currency} {Number(product.price).toFixed(2)} + + + {product.stock_quantity} + + + + {product.is_active ? 'Active' : 'Inactive'} + + +
+ + +
+
+
+ + {/* Pagination */} + {!loading && products.length > PAGE_SIZE && ( +
+

+ Showing {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, products.length)} of {products.length} products +

+
+ + + {page} / {totalPages} + + +
+
+ )} +
+ + {/* Add/Edit Modal */} + { setIsAddModalOpen(false); setIsEditModalOpen(false); resetForm(); }} + title={isEditModalOpen ? 'Edit Product' : 'Add New Product'} + > +
+
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g. Premium Solar Panel" + /> +
+
+ + setFormData({ ...formData, sku: e.target.value })} + placeholder="SOL-001" + /> +
+
+ + setFormData({ ...formData, category: e.target.value })} + placeholder="Solar" + /> +
+
+ + setFormData({ ...formData, price: parseFloat(e.target.value) })} + /> +
+
+ + setFormData({ ...formData, stock_quantity: parseInt(e.target.value) })} + /> +
+
+ +