Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions GEMINI.md
Original file line number Diff line number Diff line change
@@ -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`.
10 changes: 10 additions & 0 deletions backend/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,23 @@
# 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
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,
)


# Alembic Config object
Expand Down
Original file line number Diff line number Diff line change
@@ -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')
189 changes: 189 additions & 0 deletions backend/alembic/versions/c2d3e4f5a6b7_add_whatsapp_broadcast_tables.py
Original file line number Diff line number Diff line change
@@ -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')
Original file line number Diff line number Diff line change
@@ -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')
Loading
Loading