First commit

This commit is contained in:
dvirlabs 2026-01-13 05:17:57 +02:00
commit d7185786e7
86 changed files with 6230 additions and 0 deletions

55
.gitignore vendored Normal file
View File

@ -0,0 +1,55 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
.venv
pip-log.txt
pip-delete-this-directory.txt
.pytest_cache/
*.egg-info/
dist/
build/
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
dist/
.cache/
# Environment
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Database
*.db
*.sqlite
# Logs
*.log
logs/
# Coverage
htmlcov/
.coverage
.coverage.*
coverage.xml
*.cover

68
FIXING_ERRORS.md Normal file
View File

@ -0,0 +1,68 @@
# Fixing Campaign Errors - Quick Guide
## Why Are Messages Failing?
Your campaign shows failed messages because of **compliance checks** that protect you from sending spam. This is a GOOD thing! 🛡️
## Common Errors & Fixes
### 1. "Contact not opted in; must use approved template"
**Problem**: Contact hasn't given permission to receive messages.
**Fix Option A** (Recommended for testing):
1. Go to **Contacts** page
2. Find the contact(s) that failed
3. Click **Edit** (you'll need to add this feature OR recreate contact)
4. Check the **"Opted In"** checkbox ✅
5. Save
**Fix Option B**: Use approved WhatsApp template (see #2 below)
### 2. "No conversation window; must use approved template"
**Problem**: WhatsApp requires approved templates for cold outreach.
**Fix**: Mark your template as approved:
1. Go to **Templates** page
2. Edit your template
3. Check **"Approved WhatsApp Template"** checkbox ✅
4. Save
**Note**: In production, you'd actually submit templates to Meta for approval. For testing, just check this box.
### 3. Using Telegram for Easy Testing
Instead of dealing with WhatsApp compliance, use Telegram (100% free!):
1. Set in `docker-compose.yml`:
```yaml
WHATSAPP_PROVIDER: telegram
TELEGRAM_BOT_TOKEN: <your-bot-token>
```
2. Follow **TELEGRAM_TESTING.md** guide
3. Add contacts with Telegram user IDs instead of phone numbers
4. Messages will actually be delivered to Telegram! 📱
## Quick Test Checklist
Before sending campaigns:
- [ ] All contacts have **"Opted In"** checked
- [ ] Template has **"Approved WhatsApp Template"** checked (if not using Telegram)
- [ ] Contacts are in a list
- [ ] Campaign created with that list and template
- [ ] Worker triggered after clicking "Send"
## Production Note
In production with real WhatsApp:
- Only send to contacts who explicitly opted in (GDPR compliance)
- Submit templates to Meta for approval (takes 24-48 hours)
- Use approved templates for first contact
- Free-form messages only for existing conversations
For now, just check the boxes for testing! ✅

441
README.md Normal file
View File

@ -0,0 +1,441 @@
# WhatsApp Campaign Manager
A production-quality monorepo application for managing WhatsApp marketing campaigns with full compliance guardrails, contact management, and multi-provider messaging support.
## 🚀 Features
### Contact Management
- **Import from Multiple Sources**: Excel (.xlsx), CSV, and Google Contacts
- **Phone Number Normalization**: Automatic E.164 format conversion
- **Tag & Segment**: Organize contacts with tags and lists
- **Opt-in Management**: Track opt-in status for compliance
- **DND List**: Global do-not-disturb blacklist
### Campaign Management
- **Template System**: Create reusable message templates with variables ({{first_name}}, etc.)
- **WhatsApp Templates**: Support for approved WhatsApp Business templates
- **Preview Recipients**: See eligible contacts before sending
- **Real-time Tracking**: Monitor delivery statuses (sent, delivered, read, failed)
- **Batch Processing**: Automatic batching with rate limiting
### Compliance & Safety
- ✅ **Opt-in Enforcement**: Only send to opted-in contacts
- ✅ **DND List**: Global blacklist that blocks all sends
- ✅ **Rate Limiting**: Per-minute and daily limits
- ✅ **Template Enforcement**: Approved templates required for cold outreach
- ✅ **Conversation Window**: Free-form messages only when window is open
- ✅ **Audit Logging**: Every send attempt is logged
- ✅ **No Unofficial Automation**: Uses official WhatsApp Cloud API only
### Technical Features
- **Provider Abstraction**: Easy switching between WhatsApp providers
- **Mock Provider**: Local testing without external API calls
- **WhatsApp Cloud API**: Production-ready Meta integration
- **Webhook Handler**: Automatic status updates from provider
- **Background Jobs**: Async processing with retry logic
- **JWT Authentication**: Secure user sessions
- **Role-Based Access**: Admin and user roles
## 🏗️ Architecture
```
/whats-sender/
├── backend/ # FastAPI Python backend
│ ├── app/
│ │ ├── api/ # REST API endpoints
│ │ ├── core/ # Config, security, dependencies
│ │ ├── db/ # Database connection
│ │ ├── models/ # SQLAlchemy models
│ │ ├── schemas/ # Pydantic schemas
│ │ ├── services/ # Business logic
│ │ ├── providers/ # WhatsApp provider abstraction
│ │ ├── utils/ # Helper functions
│ │ └── main.py # FastAPI app
│ ├── alembic/ # Database migrations
│ ├── tests/ # Unit tests
│ └── requirements.txt
├── frontend/ # React + Vite frontend
│ ├── src/
│ │ ├── api/ # API client
│ │ ├── components/ # Reusable UI components
│ │ ├── contexts/ # React contexts
│ │ ├── pages/ # Page components
│ │ ├── styles/ # CSS
│ │ └── App.jsx
│ └── package.json
├── docker-compose.yml # Docker orchestration
└── README.md
```
## 📋 Prerequisites
### For WhatsApp Cloud API (Production)
1. **Facebook Business Account**: Create at [business.facebook.com](https://business.facebook.com)
2. **WhatsApp Business API Access**: Apply through Meta
3. **Phone Number**: Verified business phone number
4. **Access Token**: Long-lived access token from Facebook
5. **Template Approval**: Pre-approve message templates in Business Manager
### For Google Contacts Import
1. **Google Cloud Project**: Create at [console.cloud.google.com](https://console.cloud.google.com)
2. **Enable People API**: In APIs & Services
3. **OAuth 2.0 Credentials**: Create Web Application credentials
4. **Authorized Redirect URI**: Add `http://localhost:8000/api/imports/google/callback`
### For Local Development
- Docker & Docker Compose
- OR: Python 3.11+, Node.js 18+, PostgreSQL 15+
## 🚀 Quick Start
### 1. Clone and Configure
```bash
cd whats-sender
# Backend environment
cp backend/.env.example backend/.env
# Edit backend/.env with your credentials
# Frontend environment
cp frontend/.env.example frontend/.env
```
### 2. Start with Docker Compose
```bash
docker-compose up -d
```
Services will be available at:
- **Frontend**: http://localhost:5173
- **Backend API**: http://localhost:8000
- **API Docs**: http://localhost:8000/docs
- **PostgreSQL**: localhost:5432
### 3. Initialize Database
```bash
# Run migrations
docker-compose exec backend alembic upgrade head
```
### 4. Create First User
Visit http://localhost:5173/register and create an account.
## ⚙️ Configuration
### Backend Environment Variables
```bash
# Database
DATABASE_URL=postgresql://whatssender:whatssender123@postgres:5432/whatssender
# JWT Authentication
JWT_SECRET=your-secret-key-min-32-chars
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=10080 # 7 days
# CORS
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
# Google OAuth (Optional)
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret
GOOGLE_REDIRECT_URI=http://localhost:8000/api/imports/google/callback
# Generate: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
GOOGLE_TOKEN_ENCRYPTION_KEY=your-fernet-key-base64
# WhatsApp Provider
WHATSAPP_PROVIDER=mock # or "cloud" for production
WHATSAPP_CLOUD_ACCESS_TOKEN=your-meta-access-token
WHATSAPP_CLOUD_PHONE_NUMBER_ID=your-phone-number-id
WHATSAPP_WEBHOOK_VERIFY_TOKEN=your-random-token
# Rate Limiting
MAX_MESSAGES_PER_MINUTE=20
BATCH_SIZE=10
DAILY_LIMIT_PER_CAMPAIGN=1000
```
### Frontend Environment Variables
```bash
VITE_API_URL=http://localhost:8000
```
## 📱 WhatsApp Cloud API Setup
### 1. Get Access Token
1. Go to [developers.facebook.com](https://developers.facebook.com)
2. Create/Select your App
3. Add WhatsApp product
4. Go to API Setup → Get a token
5. Copy the **Permanent Access Token** (generate from temporary token)
### 2. Get Phone Number ID
In the same API Setup page, copy your **Phone Number ID**.
### 3. Register Webhook
1. Go to Configuration → Webhooks
2. Click "Edit"
3. Callback URL: `https://your-domain.com/api/webhooks/whatsapp`
4. Verify Token: Same as `WHATSAPP_WEBHOOK_VERIFY_TOKEN`
5. Subscribe to: `messages` field
### 4. Create Message Templates
1. Go to WhatsApp Manager → Message Templates
2. Create templates following Meta guidelines
3. Wait for approval (usually 24-48 hours)
4. Use template name in app's `provider_template_name` field
## 🔧 Development
### Backend Development
```bash
cd backend
# Install dependencies
pip install -r requirements.txt
# Create .env file
cp .env.example .env
# Run migrations
alembic upgrade head
# Start server
uvicorn app.main:app --reload
# Run tests
pytest
```
### Frontend Development
```bash
cd frontend
# Install dependencies
npm install
# Start dev server
npm run dev
# Build for production
npm run build
```
### Running Workers
The worker processes background jobs (campaign sending). For development:
```bash
# Manual trigger (call this endpoint periodically)
curl -X POST http://localhost:8000/api/workers/tick
```
For production, set up a cron job:
```bash
*/1 * * * * curl -X POST http://localhost:8000/api/workers/tick
```
Or use Celery (advanced):
```bash
# Install celery and redis
pip install celery redis
# Start worker
celery -A app.workers worker --loglevel=info
```
## 📊 Usage Guide
### 1. Import Contacts
**Excel/CSV Import**:
- Prepare file with columns: `phone`, `first_name`, `last_name`, `email`, `opted_in`
- Go to Imports page
- Upload file
- Review summary
**Google Contacts**:
- Click "Connect Google Account"
- Authorize access
- Click "Sync Contacts"
### 2. Create Lists
- Go to Lists page
- Click "Create List"
- Add contacts to list from Contacts page
### 3. Create Templates
- Go to Templates page
- Click "Create Template"
- Use `{{first_name}}`, `{{last_name}}`, etc. for personalization
- For WhatsApp templates: Check "Approved WhatsApp Template" and enter template name from Business Manager
### 4. Create Campaign
- Go to Campaigns page
- Click "Create Campaign"
- Select template and list
- Preview recipients
- Click "Send"
### 5. Monitor Campaign
- Click on campaign name
- See recipient statuses in real-time
- Webhook updates statuses automatically
## 🔒 Compliance Best Practices
### Before Sending
1. ✅ Verify all contacts have opted in OR use approved templates
2. ✅ Check DND list is up to date
3. ✅ Review daily/rate limits
4. ✅ Test with MockProvider first
### Message Guidelines
- Use approved templates for first contact
- Free-form messages only for existing conversations
- Personalize with variables
- Keep messages professional
- Include opt-out instructions
### Data Privacy
- Store only necessary contact info
- Respect DND requests immediately
- Encrypt sensitive data (tokens, etc.)
- Log all activities for audit
- Comply with GDPR/local regulations
## 🧪 Testing
### Unit Tests
```bash
cd backend
pytest tests/
```
### Testing with MockProvider
Set `WHATSAPP_PROVIDER=mock` in `.env`. This simulates sending without external calls.
### Integration Testing
1. Create test contacts with your own phone
2. Mark them as opted-in
3. Create small test campaign
4. Verify messages arrive
## 🚨 Troubleshooting
### "Invalid phone number" errors
- Ensure phones are in E.164 format (+1234567890)
- Check country code is valid
- Use phone normalization in imports
### "Template not found" errors
- Verify template is approved in Business Manager
- Check `provider_template_name` matches exactly
- Wait 24-48h after template submission
### "Rate limit exceeded"
- Check `MAX_MESSAGES_PER_MINUTE` setting
- Review daily limit in `DAILY_LIMIT_PER_CAMPAIGN`
- Stagger campaigns over time
### Webhook not receiving updates
- Verify callback URL is publicly accessible (use ngrok for local testing)
- Check verify token matches
- Review webhook logs in Meta Developer Portal
## 📈 Production Deployment
### Recommended Stack
- **Backend**: Deploy on Railway, Render, or AWS ECS
- **Frontend**: Vercel, Netlify, or Cloudflare Pages
- **Database**: Managed PostgreSQL (AWS RDS, Render, etc.)
- **Workers**: Background tasks on same platform or Celery with Redis
- **Domain**: Custom domain with SSL
### Security Checklist
- [ ] Change all default passwords
- [ ] Use strong JWT_SECRET (32+ chars)
- [ ] Enable HTTPS only
- [ ] Set proper CORS origins
- [ ] Rotate access tokens regularly
- [ ] Monitor rate limits
- [ ] Set up error tracking (Sentry)
- [ ] Enable database backups
- [ ] Review logs regularly
### Scaling Considerations
- Use connection pooling for database
- Add Redis for caching and Celery
- Horizontal scaling for API servers
- CDN for frontend assets
- Monitor provider rate limits
- Consider multiple WhatsApp numbers for higher volume
## 💰 Pricing Notes
### WhatsApp Cloud API Pricing (Meta)
- **Conversations**: Charged per 24-hour conversation window
- **Business-Initiated**: Higher cost (~$0.01-0.10 per conversation, varies by country)
- **User-Initiated**: Free or lower cost
- **Template Messages**: Count as business-initiated
- **Free Tier**: 1,000 conversations/month
Always check current pricing at [developers.facebook.com/docs/whatsapp/pricing](https://developers.facebook.com/docs/whatsapp/pricing)
## 🤝 Contributing
This is a production template. Customize for your needs:
- Add custom fields to Contact model
- Implement additional providers (Twilio, MessageBird, etc.)
- Add more analytics and reporting
- Enhance UI/UX
- Add A/B testing for templates
- Implement scheduled campaigns
- Add webhook retry logic
## 📄 License
MIT License - Feel free to use for commercial or personal projects.
## ⚠️ Disclaimer
This software is provided as-is. Users are responsible for:
- Complying with WhatsApp Business Terms of Service
- Following local marketing/spam regulations (CAN-SPAM, GDPR, etc.)
- Obtaining proper consent from contacts
- Maintaining data security and privacy
- Using official WhatsApp APIs only
The developers are not liable for misuse or violations of terms of service.
## 🆘 Support
For issues:
1. Check this README
2. Review API documentation at `/docs`
3. Check error logs
4. Review Meta WhatsApp documentation
5. Test with MockProvider first
---
**Built with ❤️ for compliant marketing automation**

139
TELEGRAM_TESTING.md Normal file
View File

@ -0,0 +1,139 @@
# Telegram Bot Testing Guide
## Why Telegram?
- ✅ **Completely FREE** - No costs at all
- ✅ **Instant setup** - 2 minutes to get started
- ✅ **Real API** - Test actual HTTP requests, not mocks
- ✅ **Immediate testing** - Use your own phone
- ✅ **No verification** - No business account needed
## Setup Steps (5 minutes)
### 1. Create a Telegram Bot
1. Open Telegram on your phone
2. Search for `@BotFather`
3. Send `/newbot` command
4. Choose a name for your bot (e.g., "My Campaign Tester")
5. Choose a username (e.g., "mycampaign_test_bot")
6. **Copy the bot token** (looks like: `1234567890:ABCdefGHIjklMNOpqrsTUVwxyz`)
### 2. Get Your Telegram User ID
**Option A: Quick way**
1. Search for `@userinfobot` on Telegram
2. Start a chat with it
3. It will reply with your user ID (e.g., `123456789`)
**Option B: Manual way**
1. Start a chat with your new bot (search for the username you created)
2. Send any message to it (e.g., "hello")
3. Open this URL in browser (replace `<YOUR_BOT_TOKEN>`):
```
https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates
```
4. Find your user ID in the JSON response under `"from": {"id": 123456789}`
### 3. Configure Your App
Edit `backend/.env` or `docker-compose.yml`:
```env
WHATSAPP_PROVIDER=telegram
TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
```
### 4. Add Yourself as a Contact
In your app's Contacts page:
- **Phone**: Your Telegram user ID (e.g., `123456789`)
- **First Name**: Your name
- **Opted In**: ✅ Check this box
- Click "Create"
### 5. Create Campaign and Send
1. Create a template (e.g., "Hello {{first_name}}!")
2. Create a list and add your contact
3. Create a campaign with that template and list
4. Click "Send"
5. Trigger the worker:
```bash
curl -X POST http://localhost:8000/api/workers/tick
```
6. **Check your Telegram** - you'll receive the message! 🎉
## What You'll See
### In Your App
- Campaign status: `sending``done`
- Recipient status: `pending``sent`
- Real Telegram message IDs
- Actual error messages if something fails
### On Your Phone
- Real message from your bot
- Variables replaced ({{first_name}} → your actual name)
- Instant delivery
## Testing Different Scenarios
### Test Opt-in Enforcement
1. Create contact with `opted_in = false`
2. Try sending with regular template (not WhatsApp approved)
3. View campaign → See error: "Contact not opted in"
### Test Multiple Contacts
1. Get Telegram IDs from friends/family
2. Add them as contacts (with permission!)
3. Send campaign to the list
4. Everyone receives the message
### Test Rate Limiting
- Set `MAX_MESSAGES_PER_MINUTE=2` in config
- Send to 10 contacts
- Watch messages sent slowly (respects rate limit)
## Troubleshooting
### "Failed to send Telegram message: 400 Bad Request"
- **Cause**: Invalid chat ID (user ID)
- **Fix**: Make sure user has started a chat with your bot first
### "Failed to send Telegram message: 401 Unauthorized"
- **Cause**: Invalid bot token
- **Fix**: Double-check the token from @BotFather
### "Contact not opted in"
- **Fix**: Check the "Opted In" checkbox when creating contact
## Switch Between Providers
```env
# For mock testing (no external calls)
WHATSAPP_PROVIDER=mock
# For Telegram testing (free, real API)
WHATSAPP_PROVIDER=telegram
TELEGRAM_BOT_TOKEN=your-token
# For production WhatsApp
WHATSAPP_PROVIDER=cloud
WHATSAPP_CLOUD_ACCESS_TOKEN=your-token
WHATSAPP_CLOUD_PHONE_NUMBER_ID=your-id
```
## Production Checklist
Once Telegram testing looks good:
1. ✅ All campaigns working
2. ✅ Error handling tested
3. ✅ Rate limiting working
4. ✅ Opt-in enforcement verified
5. ✅ DND list tested
6. → **Ready to switch to WhatsApp Cloud API!**
---
**Happy Testing! 🚀**

24
backend/.env.example Normal file
View File

@ -0,0 +1,24 @@
DATABASE_URL=postgresql://whatssender:whatssender123@localhost:5432/whatssender
JWT_SECRET=your-secret-key-change-in-production-min-32-chars
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
# Google OAuth
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-google-client-secret
GOOGLE_REDIRECT_URI=http://localhost:8000/api/imports/google/callback
# Generate Fernet key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
GOOGLE_TOKEN_ENCRYPTION_KEY=your-fernet-key-base64-44-chars
# WhatsApp Provider
WHATSAPP_PROVIDER=mock # mock, cloud, or telegram
WHATSAPP_CLOUD_ACCESS_TOKEN=your-whatsapp-cloud-api-token
WHATSAPP_CLOUD_PHONE_NUMBER_ID=your-phone-number-id
WHATSAPP_WEBHOOK_VERIFY_TOKEN=your-webhook-verify-token-random-string
# Telegram Provider (for testing)
TELEGRAM_BOT_TOKEN=your-telegram-bot-token-from-botfather
# Rate Limiting
MAX_MESSAGES_PER_MINUTE=20
BATCH_SIZE=10
DAILY_LIMIT_PER_CAMPAIGN=1000

19
backend/Dockerfile Normal file
View File

@ -0,0 +1,19 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Run migrations and start server
CMD alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000

102
backend/alembic.ini Normal file
View File

@ -0,0 +1,102 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url =
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

86
backend/alembic/env.py Normal file
View File

@ -0,0 +1,86 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
import sys
import os
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app.core.config import settings
from app.db.base import Base
from app.models import * # noqa
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Set the database URL from settings
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,238 @@
"""initial migration
Revision ID: 001
Revises:
Create Date: 2026-01-13 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create users table
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('password_hash', sa.String(), nullable=False),
sa.Column('role', sa.Enum('ADMIN', 'USER', name='userrole'), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
# Create contacts table
op.create_table('contacts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('phone_e164', sa.String(), nullable=False),
sa.Column('first_name', sa.String(), nullable=True),
sa.Column('last_name', sa.String(), nullable=True),
sa.Column('email', sa.String(), nullable=True),
sa.Column('opted_in', sa.Boolean(), nullable=False),
sa.Column('conversation_window_open', sa.Boolean(), nullable=False),
sa.Column('source', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'phone_e164', name='uq_user_phone')
)
op.create_index('ix_contacts_user_phone', 'contacts', ['user_id', 'phone_e164'], unique=False)
op.create_index(op.f('ix_contacts_id'), 'contacts', ['id'], unique=False)
op.create_index(op.f('ix_contacts_user_id'), 'contacts', ['user_id'], unique=False)
# Create contact_tags table
op.create_table('contact_tags',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_contact_tags_id'), 'contact_tags', ['id'], unique=False)
op.create_index(op.f('ix_contact_tags_user_id'), 'contact_tags', ['user_id'], unique=False)
# Create contact_tag_map table
op.create_table('contact_tag_map',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('contact_id', sa.Integer(), nullable=False),
sa.Column('tag_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['contact_id'], ['contacts.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tag_id'], ['contact_tags.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('contact_id', 'tag_id', name='uq_contact_tag')
)
op.create_index(op.f('ix_contact_tag_map_id'), 'contact_tag_map', ['id'], unique=False)
# Create dnd_list table
op.create_table('dnd_list',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('phone_e164', sa.String(), nullable=False),
sa.Column('reason', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'phone_e164', name='uq_dnd_user_phone')
)
op.create_index(op.f('ix_dnd_list_id'), 'dnd_list', ['id'], unique=False)
op.create_index(op.f('ix_dnd_list_phone_e164'), 'dnd_list', ['phone_e164'], unique=False)
op.create_index(op.f('ix_dnd_list_user_id'), 'dnd_list', ['user_id'], unique=False)
# Create lists table
op.create_table('lists',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_lists_id'), 'lists', ['id'], unique=False)
op.create_index(op.f('ix_lists_user_id'), 'lists', ['user_id'], unique=False)
# Create list_members table
op.create_table('list_members',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('list_id', sa.Integer(), nullable=False),
sa.Column('contact_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['contact_id'], ['contacts.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['list_id'], ['lists.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('list_id', 'contact_id', name='uq_list_contact')
)
op.create_index(op.f('ix_list_members_contact_id'), 'list_members', ['contact_id'], unique=False)
op.create_index(op.f('ix_list_members_id'), 'list_members', ['id'], unique=False)
op.create_index(op.f('ix_list_members_list_id'), 'list_members', ['list_id'], unique=False)
# Create templates table
op.create_table('templates',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('language', sa.String(), nullable=False),
sa.Column('body_text', sa.Text(), nullable=False),
sa.Column('is_whatsapp_template', sa.Boolean(), nullable=False),
sa.Column('provider_template_name', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_templates_id'), 'templates', ['id'], unique=False)
op.create_index(op.f('ix_templates_user_id'), 'templates', ['user_id'], unique=False)
# Create campaigns table
op.create_table('campaigns',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('template_id', sa.Integer(), nullable=True),
sa.Column('list_id', sa.Integer(), nullable=True),
sa.Column('status', sa.Enum('DRAFT', 'SCHEDULED', 'SENDING', 'DONE', 'FAILED', name='campaignstatus'), nullable=False),
sa.Column('scheduled_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['list_id'], ['lists.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['template_id'], ['templates.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_campaigns_id'), 'campaigns', ['id'], unique=False)
op.create_index(op.f('ix_campaigns_status'), 'campaigns', ['status'], unique=False)
op.create_index(op.f('ix_campaigns_user_id'), 'campaigns', ['user_id'], unique=False)
# Create campaign_recipients table
op.create_table('campaign_recipients',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('campaign_id', sa.Integer(), nullable=False),
sa.Column('contact_id', sa.Integer(), nullable=False),
sa.Column('status', sa.Enum('PENDING', 'SENT', 'DELIVERED', 'READ', 'FAILED', name='recipientstatus'), nullable=False),
sa.Column('provider_message_id', sa.String(), nullable=True),
sa.Column('last_error', sa.Text(), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['contact_id'], ['contacts.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_campaign_recipients_campaign_status', 'campaign_recipients', ['campaign_id', 'status'], unique=False)
op.create_index(op.f('ix_campaign_recipients_campaign_id'), 'campaign_recipients', ['campaign_id'], unique=False)
op.create_index(op.f('ix_campaign_recipients_contact_id'), 'campaign_recipients', ['contact_id'], unique=False)
op.create_index(op.f('ix_campaign_recipients_id'), 'campaign_recipients', ['id'], unique=False)
# Create send_logs table
op.create_table('send_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('campaign_id', sa.Integer(), nullable=True),
sa.Column('contact_id', sa.Integer(), nullable=True),
sa.Column('provider', sa.String(), nullable=False),
sa.Column('request_payload_json', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('response_payload_json', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['contact_id'], ['contacts.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_send_logs_campaign_id'), 'send_logs', ['campaign_id'], unique=False)
op.create_index(op.f('ix_send_logs_created_at'), 'send_logs', ['created_at'], unique=False)
op.create_index(op.f('ix_send_logs_id'), 'send_logs', ['id'], unique=False)
op.create_index(op.f('ix_send_logs_user_id'), 'send_logs', ['user_id'], unique=False)
# Create jobs table
op.create_table('jobs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('type', sa.String(), nullable=False),
sa.Column('payload_json', postgresql.JSON(astext_type=sa.Text()), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('attempts', sa.Integer(), nullable=False),
sa.Column('run_after', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('last_error', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_jobs_status_run_after', 'jobs', ['status', 'run_after'], unique=False)
op.create_index(op.f('ix_jobs_id'), 'jobs', ['id'], unique=False)
op.create_index(op.f('ix_jobs_user_id'), 'jobs', ['user_id'], unique=False)
# Create google_tokens table
op.create_table('google_tokens',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('encrypted_token', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_google_tokens_id'), 'google_tokens', ['id'], unique=False)
op.create_index(op.f('ix_google_tokens_user_id'), 'google_tokens', ['user_id'], unique=True)
def downgrade() -> None:
op.drop_table('google_tokens')
op.drop_table('jobs')
op.drop_table('send_logs')
op.drop_table('campaign_recipients')
op.drop_table('campaigns')
op.drop_table('templates')
op.drop_table('list_members')
op.drop_table('lists')
op.drop_table('dnd_list')
op.drop_table('contact_tag_map')
op.drop_table('contact_tags')
op.drop_table('contacts')
op.drop_table('users')

1
backend/app/__init__.py Normal file
View File

@ -0,0 +1 @@
# Empty file to make app a package

View File

@ -0,0 +1 @@
# Empty file

49
backend/app/api/auth.py Normal file
View File

@ -0,0 +1,49 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.base import get_db
from app.schemas.user import UserCreate, UserLogin, UserResponse, Token
from app.models.user import User
from app.core.security import verify_password, get_password_hash, create_access_token
from app.core.deps import get_current_user
router = APIRouter()
@router.post("/register", response_model=UserResponse)
def register(user: UserCreate, db: Session = Depends(get_db)):
# Check if user exists
existing_user = db.query(User).filter(User.email == user.email).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Create new user
db_user = User(
email=user.email,
password_hash=get_password_hash(user.password)
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
@router.post("/login", response_model=Token)
def login(user: UserLogin, db: Session = Depends(get_db)):
# Find user
db_user = db.query(User).filter(User.email == user.email).first()
if not db_user or not verify_password(user.password, db_user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password"
)
# Create access token
access_token = create_access_token(data={"sub": str(db_user.id)})
return {"access_token": access_token, "token_type": "bearer"}
@router.get("/me", response_model=UserResponse)
def get_me(current_user: User = Depends(get_current_user)):
return current_user

View File

@ -0,0 +1,346 @@
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import List
from datetime import datetime
from app.db.base import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.models.campaign import Campaign, CampaignRecipient, CampaignStatus, RecipientStatus
from app.models.template import Template
from app.models.list import List as ContactList, ListMember
from app.models.contact import Contact, DNDList
from app.models.job import Job
from app.schemas.campaign import (
CampaignCreate, CampaignUpdate, CampaignResponse,
RecipientResponse, CampaignPreview
)
router = APIRouter()
@router.post("", response_model=CampaignResponse, status_code=status.HTTP_201_CREATED)
def create_campaign(
campaign: CampaignCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# Verify template exists
template = db.query(Template).filter(
Template.id == campaign.template_id,
Template.user_id == current_user.id
).first()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
# Verify list exists
contact_list = db.query(ContactList).filter(
ContactList.id == campaign.list_id,
ContactList.user_id == current_user.id
).first()
if not contact_list:
raise HTTPException(status_code=404, detail="List not found")
# Create campaign
db_campaign = Campaign(
user_id=current_user.id,
name=campaign.name,
template_id=campaign.template_id,
list_id=campaign.list_id,
status=CampaignStatus.DRAFT,
scheduled_at=campaign.scheduled_at
)
db.add(db_campaign)
db.commit()
db.refresh(db_campaign)
# Create recipients
members = db.query(ListMember).filter(ListMember.list_id == campaign.list_id).all()
for member in members:
recipient = CampaignRecipient(
campaign_id=db_campaign.id,
contact_id=member.contact_id,
status=RecipientStatus.PENDING
)
db.add(recipient)
db.commit()
return db_campaign
@router.get("", response_model=List[CampaignResponse])
def list_campaigns(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
return db.query(Campaign).filter(
Campaign.user_id == current_user.id
).order_by(Campaign.created_at.desc()).offset(skip).limit(limit).all()
@router.get("/{campaign_id}", response_model=CampaignResponse)
def get_campaign(
campaign_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
campaign = db.query(Campaign).filter(
Campaign.id == campaign_id,
Campaign.user_id == current_user.id
).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
return campaign
@router.put("/{campaign_id}", response_model=CampaignResponse)
def update_campaign(
campaign_id: int,
campaign_update: CampaignUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
campaign = db.query(Campaign).filter(
Campaign.id == campaign_id,
Campaign.user_id == current_user.id
).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
update_data = campaign_update.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(campaign, field, value)
db.commit()
db.refresh(campaign)
return campaign
@router.delete("/{campaign_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_campaign(
campaign_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
campaign = db.query(Campaign).filter(
Campaign.id == campaign_id,
Campaign.user_id == current_user.id
).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
db.delete(campaign)
db.commit()
return None
@router.get("/{campaign_id}/preview", response_model=CampaignPreview)
def preview_campaign(
campaign_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Preview campaign recipients and compliance stats"""
campaign = db.query(Campaign).filter(
Campaign.id == campaign_id,
Campaign.user_id == current_user.id
).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
# Get all recipients
recipients = db.query(CampaignRecipient).filter(
CampaignRecipient.campaign_id == campaign_id
).all()
total_count = len(recipients)
opted_in_count = 0
dnd_count = 0
sample_contacts = []
for recipient in recipients[:5]: # Sample first 5
contact = db.query(Contact).filter(Contact.id == recipient.contact_id).first()
if not contact:
continue
# Check if opted in
if contact.opted_in:
opted_in_count += 1
# Check DND
dnd_entry = db.query(DNDList).filter(
DNDList.user_id == current_user.id,
DNDList.phone_e164 == contact.phone_e164
).first()
if dnd_entry:
dnd_count += 1
sample_contacts.append({
"phone": contact.phone_e164,
"name": f"{contact.first_name or ''} {contact.last_name or ''}".strip(),
"opted_in": contact.opted_in,
"in_dnd": dnd_entry is not None
})
# Count all opted in
all_contact_ids = [r.contact_id for r in recipients]
opted_in_count = db.query(Contact).filter(
Contact.id.in_(all_contact_ids),
Contact.opted_in == True
).count()
# Count all DND
all_phones = [
c.phone_e164 for c in db.query(Contact.phone_e164).filter(
Contact.id.in_(all_contact_ids)
).all()
]
dnd_count = db.query(DNDList).filter(
DNDList.user_id == current_user.id,
DNDList.phone_e164.in_(all_phones)
).count()
eligible_count = total_count - dnd_count
return CampaignPreview(
total_recipients=total_count,
opted_in_count=opted_in_count,
dnd_count=dnd_count,
eligible_count=eligible_count,
sample_contacts=sample_contacts
)
@router.post("/{campaign_id}/send")
def send_campaign(
campaign_id: int,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Start sending campaign (creates a job)"""
campaign = db.query(Campaign).filter(
Campaign.id == campaign_id,
Campaign.user_id == current_user.id
).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
if campaign.status not in [CampaignStatus.DRAFT, CampaignStatus.SCHEDULED]:
raise HTTPException(status_code=400, detail="Campaign already sent or in progress")
# Update status
campaign.status = CampaignStatus.SENDING
db.commit()
# Create job for background processing
job = Job(
user_id=current_user.id,
type="send_campaign",
payload_json={"campaign_id": campaign_id},
status="pending",
attempts=0
)
db.add(job)
db.commit()
return {
"status": "started",
"campaign_id": campaign_id,
"job_id": job.id
}
@router.post("/{campaign_id}/reset")
def reset_campaign(
campaign_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Reset campaign to draft and all recipients to pending"""
campaign = db.query(Campaign).filter(
Campaign.id == campaign_id,
Campaign.user_id == current_user.id
).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
# Reset campaign status
campaign.status = CampaignStatus.DRAFT
# Reset all recipients to pending
db.query(CampaignRecipient).filter(
CampaignRecipient.campaign_id == campaign_id
).update({
"status": RecipientStatus.PENDING,
"provider_message_id": None,
"last_error": None
})
# Delete any pending jobs for this campaign
from sqlalchemy import cast, String
db.query(Job).filter(
Job.type == "send_campaign",
cast(Job.payload_json["campaign_id"], String) == str(campaign_id),
Job.status == "pending"
).delete(synchronize_session=False)
db.commit()
return {
"status": "reset",
"campaign_id": campaign_id,
"message": "Campaign reset to draft. All recipients set to pending."
}
@router.get("/{campaign_id}/recipients", response_model=List[RecipientResponse])
def get_campaign_recipients(
campaign_id: int,
status: str = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get campaign recipients with optional status filter"""
campaign = db.query(Campaign).filter(
Campaign.id == campaign_id,
Campaign.user_id == current_user.id
).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
query = db.query(CampaignRecipient).filter(
CampaignRecipient.campaign_id == campaign_id
)
if status:
query = query.filter(CampaignRecipient.status == status)
recipients = query.offset(skip).limit(limit).all()
# Enrich with contact data
result = []
for recipient in recipients:
contact = db.query(Contact).filter(Contact.id == recipient.contact_id).first()
recipient_dict = {
"id": recipient.id,
"campaign_id": recipient.campaign_id,
"contact_id": recipient.contact_id,
"status": recipient.status,
"provider_message_id": recipient.provider_message_id,
"last_error": recipient.last_error,
"updated_at": recipient.updated_at,
"contact_phone": contact.phone_e164 if contact else None,
"contact_name": f"{contact.first_name or ''} {contact.last_name or ''}".strip() if contact else None
}
result.append(recipient_dict)
return result

309
backend/app/api/contacts.py Normal file
View File

@ -0,0 +1,309 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from sqlalchemy import or_, func
from typing import List, Optional
from app.db.base import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.models.contact import Contact, ContactTag, ContactTagMap, DNDList
from app.schemas.contact import (
ContactCreate, ContactUpdate, ContactResponse,
ContactTagCreate, ContactTagResponse,
DNDCreate, DNDResponse
)
from app.utils.phone import normalize_phone
router = APIRouter()
@router.post("", response_model=ContactResponse, status_code=status.HTTP_201_CREATED)
def create_contact(
contact: ContactCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# Normalize phone
normalized_phone = normalize_phone(contact.phone_e164)
if not normalized_phone:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid phone number"
)
# Check DND list
dnd_entry = db.query(DNDList).filter(
DNDList.user_id == current_user.id,
DNDList.phone_e164 == normalized_phone
).first()
if dnd_entry:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Phone number is in DND list"
)
# Check if contact exists
existing = db.query(Contact).filter(
Contact.user_id == current_user.id,
Contact.phone_e164 == normalized_phone
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Contact already exists"
)
db_contact = Contact(
user_id=current_user.id,
phone_e164=normalized_phone,
first_name=contact.first_name,
last_name=contact.last_name,
email=contact.email,
opted_in=contact.opted_in,
conversation_window_open=contact.conversation_window_open,
source="manual"
)
db.add(db_contact)
db.commit()
db.refresh(db_contact)
return db_contact
@router.get("", response_model=List[ContactResponse])
def list_contacts(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
search: Optional[str] = None,
tag_id: Optional[int] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
query = db.query(Contact).filter(Contact.user_id == current_user.id)
if search:
query = query.filter(
or_(
Contact.phone_e164.ilike(f"%{search}%"),
Contact.first_name.ilike(f"%{search}%"),
Contact.last_name.ilike(f"%{search}%"),
Contact.email.ilike(f"%{search}%")
)
)
if tag_id:
query = query.join(ContactTagMap).filter(ContactTagMap.tag_id == tag_id)
contacts = query.offset(skip).limit(limit).all()
# Add tags to response
for contact in contacts:
tag_mappings = db.query(ContactTagMap).filter(
ContactTagMap.contact_id == contact.id
).all()
tag_ids = [tm.tag_id for tm in tag_mappings]
tags = db.query(ContactTag).filter(ContactTag.id.in_(tag_ids)).all() if tag_ids else []
contact.tags = [tag.name for tag in tags]
return contacts
@router.get("/{contact_id}", response_model=ContactResponse)
def get_contact(
contact_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
contact = db.query(Contact).filter(
Contact.id == contact_id,
Contact.user_id == current_user.id
).first()
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
# Add tags
tag_mappings = db.query(ContactTagMap).filter(ContactTagMap.contact_id == contact.id).all()
tag_ids = [tm.tag_id for tm in tag_mappings]
tags = db.query(ContactTag).filter(ContactTag.id.in_(tag_ids)).all() if tag_ids else []
contact.tags = [tag.name for tag in tags]
return contact
@router.put("/{contact_id}", response_model=ContactResponse)
def update_contact(
contact_id: int,
contact_update: ContactUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
contact = db.query(Contact).filter(
Contact.id == contact_id,
Contact.user_id == current_user.id
).first()
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
update_data = contact_update.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(contact, field, value)
db.commit()
db.refresh(contact)
return contact
@router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_contact(
contact_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
contact = db.query(Contact).filter(
Contact.id == contact_id,
Contact.user_id == current_user.id
).first()
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
db.delete(contact)
db.commit()
return None
# Tags
@router.post("/tags", response_model=ContactTagResponse, status_code=status.HTTP_201_CREATED)
def create_tag(
tag: ContactTagCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
db_tag = ContactTag(user_id=current_user.id, name=tag.name)
db.add(db_tag)
db.commit()
db.refresh(db_tag)
return db_tag
@router.get("/tags", response_model=List[ContactTagResponse])
def list_tags(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
return db.query(ContactTag).filter(ContactTag.user_id == current_user.id).all()
@router.post("/{contact_id}/tags/{tag_id}", status_code=status.HTTP_204_NO_CONTENT)
def add_tag_to_contact(
contact_id: int,
tag_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# Verify ownership
contact = db.query(Contact).filter(
Contact.id == contact_id,
Contact.user_id == current_user.id
).first()
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
tag = db.query(ContactTag).filter(
ContactTag.id == tag_id,
ContactTag.user_id == current_user.id
).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
# Check if already exists
existing = db.query(ContactTagMap).filter(
ContactTagMap.contact_id == contact_id,
ContactTagMap.tag_id == tag_id
).first()
if existing:
return None
mapping = ContactTagMap(contact_id=contact_id, tag_id=tag_id)
db.add(mapping)
db.commit()
return None
@router.delete("/{contact_id}/tags/{tag_id}", status_code=status.HTTP_204_NO_CONTENT)
def remove_tag_from_contact(
contact_id: int,
tag_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# Verify ownership
contact = db.query(Contact).filter(
Contact.id == contact_id,
Contact.user_id == current_user.id
).first()
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
mapping = db.query(ContactTagMap).filter(
ContactTagMap.contact_id == contact_id,
ContactTagMap.tag_id == tag_id
).first()
if mapping:
db.delete(mapping)
db.commit()
return None
# DND List
@router.post("/dnd", response_model=DNDResponse, status_code=status.HTTP_201_CREATED)
def add_to_dnd(
dnd: DNDCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
normalized_phone = normalize_phone(dnd.phone_e164)
if not normalized_phone:
raise HTTPException(status_code=400, detail="Invalid phone number")
existing = db.query(DNDList).filter(
DNDList.user_id == current_user.id,
DNDList.phone_e164 == normalized_phone
).first()
if existing:
raise HTTPException(status_code=400, detail="Phone already in DND list")
db_dnd = DNDList(
user_id=current_user.id,
phone_e164=normalized_phone,
reason=dnd.reason
)
db.add(db_dnd)
db.commit()
db.refresh(db_dnd)
return db_dnd
@router.get("/dnd", response_model=List[DNDResponse])
def list_dnd(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
return db.query(DNDList).filter(DNDList.user_id == current_user.id).all()
@router.delete("/dnd/{dnd_id}", status_code=status.HTTP_204_NO_CONTENT)
def remove_from_dnd(
dnd_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
dnd = db.query(DNDList).filter(
DNDList.id == dnd_id,
DNDList.user_id == current_user.id
).first()
if not dnd:
raise HTTPException(status_code=404, detail="DND entry not found")
db.delete(dnd)
db.commit()
return None

202
backend/app/api/google.py Normal file
View File

@ -0,0 +1,202 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from google_auth_oauthlib.flow import Flow
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from typing import Optional
from app.db.base import get_db
from app.core.deps import get_current_user
from app.core.config import settings
from app.models.user import User
from app.models.contact import Contact, DNDList
from app.models.google_token import GoogleToken
from app.schemas.imports import GoogleAuthURL, GoogleSyncResponse
from app.utils.encryption import encrypt_token, decrypt_token
from app.utils.phone import normalize_phone
import json
router = APIRouter()
SCOPES = ['https://www.googleapis.com/auth/contacts.readonly']
def get_google_flow():
"""Create Google OAuth flow"""
return Flow.from_client_config(
{
"web": {
"client_id": settings.GOOGLE_CLIENT_ID,
"client_secret": settings.GOOGLE_CLIENT_SECRET,
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"redirect_uris": [settings.GOOGLE_REDIRECT_URI]
}
},
scopes=SCOPES,
redirect_uri=settings.GOOGLE_REDIRECT_URI
)
@router.get("/google/start", response_model=GoogleAuthURL)
def google_auth_start(current_user: User = Depends(get_current_user)):
"""Start Google OAuth flow"""
if not settings.GOOGLE_CLIENT_ID or not settings.GOOGLE_CLIENT_SECRET:
raise HTTPException(status_code=500, detail="Google OAuth not configured")
flow = get_google_flow()
authorization_url, state = flow.authorization_url(
access_type='offline',
include_granted_scopes='true',
prompt='consent'
)
return GoogleAuthURL(auth_url=authorization_url)
@router.get("/google/callback")
async def google_auth_callback(
code: str = Query(...),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Handle Google OAuth callback"""
try:
flow = get_google_flow()
flow.fetch_token(code=code)
credentials = flow.credentials
# Store refresh token
existing_token = db.query(GoogleToken).filter(
GoogleToken.user_id == current_user.id
).first()
token_data = {
'token': credentials.token,
'refresh_token': credentials.refresh_token,
'token_uri': credentials.token_uri,
'client_id': credentials.client_id,
'client_secret': credentials.client_secret,
'scopes': credentials.scopes
}
encrypted = encrypt_token(json.dumps(token_data))
if existing_token:
existing_token.encrypted_token = encrypted
else:
token_obj = GoogleToken(
user_id=current_user.id,
encrypted_token=encrypted
)
db.add(token_obj)
db.commit()
return {"status": "success", "message": "Google account connected"}
except Exception as e:
raise HTTPException(status_code=400, detail=f"OAuth error: {str(e)}")
@router.post("/google/sync", response_model=GoogleSyncResponse)
def google_sync_contacts(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Sync contacts from Google"""
# Get stored token
token_obj = db.query(GoogleToken).filter(
GoogleToken.user_id == current_user.id
).first()
if not token_obj:
raise HTTPException(status_code=400, detail="Google account not connected")
try:
# Decrypt token
token_data = json.loads(decrypt_token(token_obj.encrypted_token))
# Create credentials
credentials = Credentials(
token=token_data.get('token'),
refresh_token=token_data.get('refresh_token'),
token_uri=token_data.get('token_uri'),
client_id=token_data.get('client_id'),
client_secret=token_data.get('client_secret'),
scopes=token_data.get('scopes')
)
# Build People API service
service = build('people', 'v1', credentials=credentials)
# Fetch contacts
results = service.people().connections().list(
resourceName='people/me',
pageSize=1000,
personFields='names,phoneNumbers,emailAddresses'
).execute()
connections = results.get('connections', [])
imported_count = 0
for person in connections:
names = person.get('names', [])
phones = person.get('phoneNumbers', [])
emails = person.get('emailAddresses', [])
if not phones:
continue
first_name = names[0].get('givenName') if names else None
last_name = names[0].get('familyName') if names else None
email = emails[0].get('value') if emails else None
for phone_obj in phones:
phone_value = phone_obj.get('value', '').strip()
if not phone_value:
continue
normalized_phone = normalize_phone(phone_value)
if not normalized_phone:
continue
# Check DND
dnd_entry = db.query(DNDList).filter(
DNDList.user_id == current_user.id,
DNDList.phone_e164 == normalized_phone
).first()
if dnd_entry:
continue
# Check existing
existing = db.query(Contact).filter(
Contact.user_id == current_user.id,
Contact.phone_e164 == normalized_phone
).first()
if existing:
if first_name:
existing.first_name = first_name
if last_name:
existing.last_name = last_name
if email:
existing.email = email
else:
contact = Contact(
user_id=current_user.id,
phone_e164=normalized_phone,
first_name=first_name,
last_name=last_name,
email=email,
opted_in=False,
source="google"
)
db.add(contact)
imported_count += 1
db.commit()
return GoogleSyncResponse(
status="success",
contacts_imported=imported_count
)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Sync error: {str(e)}")

138
backend/app/api/imports.py Normal file
View File

@ -0,0 +1,138 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.orm import Session
from typing import List
import pandas as pd
import io
from app.db.base import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.models.contact import Contact, DNDList
from app.schemas.imports import ImportSummary
from app.utils.phone import normalize_phone
router = APIRouter()
def process_import_file(
df: pd.DataFrame,
user_id: int,
source: str,
db: Session
) -> ImportSummary:
"""Process imported contacts dataframe"""
summary = ImportSummary(
total=len(df),
created=0,
updated=0,
skipped=0,
invalid=0,
errors=[]
)
# Normalize column names
df.columns = [col.lower().strip() for col in df.columns]
# Check required columns
if 'phone' not in df.columns:
summary.errors.append("Missing required column: phone")
summary.invalid = len(df)
return summary
for idx, row in df.iterrows():
try:
phone = str(row.get('phone', '')).strip()
if not phone:
summary.skipped += 1
continue
# Normalize phone
normalized_phone = normalize_phone(phone)
if not normalized_phone:
summary.invalid += 1
summary.errors.append(f"Row {idx+1}: Invalid phone number {phone}")
continue
# Check DND list
dnd_entry = db.query(DNDList).filter(
DNDList.user_id == user_id,
DNDList.phone_e164 == normalized_phone
).first()
if dnd_entry:
summary.skipped += 1
continue
# Check if exists
existing = db.query(Contact).filter(
Contact.user_id == user_id,
Contact.phone_e164 == normalized_phone
).first()
first_name = str(row.get('first_name', '')).strip() or None
last_name = str(row.get('last_name', '')).strip() or None
email = str(row.get('email', '')).strip() or None
opted_in_raw = str(row.get('opted_in', 'false')).lower()
opted_in = opted_in_raw in ['true', '1', 'yes', 'y']
if existing:
# Update existing
if first_name:
existing.first_name = first_name
if last_name:
existing.last_name = last_name
if email:
existing.email = email
existing.opted_in = opted_in
summary.updated += 1
else:
# Create new
contact = Contact(
user_id=user_id,
phone_e164=normalized_phone,
first_name=first_name,
last_name=last_name,
email=email,
opted_in=opted_in,
source=source
)
db.add(contact)
summary.created += 1
except Exception as e:
summary.invalid += 1
summary.errors.append(f"Row {idx+1}: {str(e)}")
db.commit()
return summary
@router.post("/excel", response_model=ImportSummary)
async def import_excel(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
if not file.filename.endswith(('.xlsx', '.xls')):
raise HTTPException(status_code=400, detail="File must be Excel format (.xlsx or .xls)")
try:
contents = await file.read()
# Read phone column as string to preserve '+' sign
df = pd.read_excel(io.BytesIO(contents), dtype={'phone': str})
return process_import_file(df, current_user.id, "excel", db)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Error processing file: {str(e)}")
@router.post("/csv", response_model=ImportSummary)
async def import_csv(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
if not file.filename.endswith('.csv'):
raise HTTPException(status_code=400, detail="File must be CSV format")
try:
contents = await file.read()
# Read phone column as string to preserve '+' sign
df = pd.read_csv(io.StringIO(contents.decode('utf-8')), dtype={'phone': str})
return process_import_file(df, current_user.id, "csv", db)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Error processing file: {str(e)}")

205
backend/app/api/lists.py Normal file
View File

@ -0,0 +1,205 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import List
from app.db.base import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.models.list import List as ContactList, ListMember
from app.models.contact import Contact
from app.schemas.list import (
ListCreate, ListUpdate, ListResponse,
ListMemberAdd, ListMemberRemove
)
router = APIRouter()
@router.post("", response_model=ListResponse, status_code=status.HTTP_201_CREATED)
def create_list(
list_data: ListCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
db_list = ContactList(
user_id=current_user.id,
name=list_data.name
)
db.add(db_list)
db.commit()
db.refresh(db_list)
db_list.member_count = 0
return db_list
@router.get("", response_model=List[ListResponse])
def list_lists(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
lists = db.query(ContactList).filter(ContactList.user_id == current_user.id).all()
# Add member counts
for lst in lists:
count = db.query(func.count(ListMember.id)).filter(
ListMember.list_id == lst.id
).scalar()
lst.member_count = count or 0
return lists
@router.get("/{list_id}", response_model=ListResponse)
def get_list(
list_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
lst = db.query(ContactList).filter(
ContactList.id == list_id,
ContactList.user_id == current_user.id
).first()
if not lst:
raise HTTPException(status_code=404, detail="List not found")
count = db.query(func.count(ListMember.id)).filter(
ListMember.list_id == lst.id
).scalar()
lst.member_count = count or 0
return lst
@router.put("/{list_id}", response_model=ListResponse)
def update_list(
list_id: int,
list_update: ListUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
lst = db.query(ContactList).filter(
ContactList.id == list_id,
ContactList.user_id == current_user.id
).first()
if not lst:
raise HTTPException(status_code=404, detail="List not found")
lst.name = list_update.name
db.commit()
db.refresh(lst)
count = db.query(func.count(ListMember.id)).filter(
ListMember.list_id == lst.id
).scalar()
lst.member_count = count or 0
return lst
@router.delete("/{list_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_list(
list_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
lst = db.query(ContactList).filter(
ContactList.id == list_id,
ContactList.user_id == current_user.id
).first()
if not lst:
raise HTTPException(status_code=404, detail="List not found")
db.delete(lst)
db.commit()
return None
@router.post("/{list_id}/members", status_code=status.HTTP_204_NO_CONTENT)
def add_members(
list_id: int,
members: ListMemberAdd,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
lst = db.query(ContactList).filter(
ContactList.id == list_id,
ContactList.user_id == current_user.id
).first()
if not lst:
raise HTTPException(status_code=404, detail="List not found")
# Verify all contacts belong to user
contacts = db.query(Contact).filter(
Contact.id.in_(members.contact_ids),
Contact.user_id == current_user.id
).all()
if len(contacts) != len(members.contact_ids):
raise HTTPException(status_code=400, detail="Some contacts not found")
# Add members (skip duplicates)
for contact_id in members.contact_ids:
existing = db.query(ListMember).filter(
ListMember.list_id == list_id,
ListMember.contact_id == contact_id
).first()
if not existing:
member = ListMember(list_id=list_id, contact_id=contact_id)
db.add(member)
db.commit()
return None
@router.delete("/{list_id}/members", status_code=status.HTTP_204_NO_CONTENT)
def remove_members(
list_id: int,
members: ListMemberRemove,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
lst = db.query(ContactList).filter(
ContactList.id == list_id,
ContactList.user_id == current_user.id
).first()
if not lst:
raise HTTPException(status_code=404, detail="List not found")
db.query(ListMember).filter(
ListMember.list_id == list_id,
ListMember.contact_id.in_(members.contact_ids)
).delete(synchronize_session=False)
db.commit()
return None
@router.get("/{list_id}/contacts", response_model=List[dict])
def get_list_contacts(
list_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
lst = db.query(ContactList).filter(
ContactList.id == list_id,
ContactList.user_id == current_user.id
).first()
if not lst:
raise HTTPException(status_code=404, detail="List not found")
members = db.query(Contact).join(ListMember).filter(
ListMember.list_id == list_id
).all()
return [
{
"id": c.id,
"phone_e164": c.phone_e164,
"first_name": c.first_name,
"last_name": c.last_name,
"email": c.email,
"opted_in": c.opted_in
}
for c in members
]

79
backend/app/api/stats.py Normal file
View File

@ -0,0 +1,79 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.db.base import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.models.contact import Contact
from app.models.campaign import Campaign, CampaignRecipient
from app.models.send_log import SendLog
from app.models.list import List as ContactList
router = APIRouter()
@router.get("")
def get_stats(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get dashboard statistics"""
# Count contacts
total_contacts = db.query(func.count(Contact.id)).filter(
Contact.user_id == current_user.id
).scalar()
opted_in_contacts = db.query(func.count(Contact.id)).filter(
Contact.user_id == current_user.id,
Contact.opted_in == True
).scalar()
# Count lists
total_lists = db.query(func.count(ContactList.id)).filter(
ContactList.user_id == current_user.id
).scalar()
# Count campaigns
total_campaigns = db.query(func.count(Campaign.id)).filter(
Campaign.user_id == current_user.id
).scalar()
# Count messages sent
total_sent = db.query(func.count(SendLog.id)).filter(
SendLog.user_id == current_user.id,
SendLog.status == "sent"
).scalar()
# Recent campaigns
recent_campaigns = db.query(Campaign).filter(
Campaign.user_id == current_user.id
).order_by(Campaign.created_at.desc()).limit(5).all()
recent_campaigns_data = []
for campaign in recent_campaigns:
total_recipients = db.query(func.count(CampaignRecipient.id)).filter(
CampaignRecipient.campaign_id == campaign.id
).scalar()
sent_count = db.query(func.count(CampaignRecipient.id)).filter(
CampaignRecipient.campaign_id == campaign.id,
CampaignRecipient.status.in_(["sent", "delivered", "read"])
).scalar()
recent_campaigns_data.append({
"id": campaign.id,
"name": campaign.name,
"status": campaign.status,
"total_recipients": total_recipients,
"sent_count": sent_count,
"created_at": campaign.created_at
})
return {
"total_contacts": total_contacts,
"opted_in_contacts": opted_in_contacts,
"total_lists": total_lists,
"total_campaigns": total_campaigns,
"total_sent": total_sent,
"recent_campaigns": recent_campaigns_data
}

View File

@ -0,0 +1,96 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from app.db.base import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.models.template import Template
from app.schemas.template import TemplateCreate, TemplateUpdate, TemplateResponse
router = APIRouter()
@router.post("", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
def create_template(
template: TemplateCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
db_template = Template(
user_id=current_user.id,
name=template.name,
language=template.language,
body_text=template.body_text,
is_whatsapp_template=template.is_whatsapp_template,
provider_template_name=template.provider_template_name
)
db.add(db_template)
db.commit()
db.refresh(db_template)
return db_template
@router.get("", response_model=List[TemplateResponse])
def list_templates(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
return db.query(Template).filter(Template.user_id == current_user.id).all()
@router.get("/{template_id}", response_model=TemplateResponse)
def get_template(
template_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
template = db.query(Template).filter(
Template.id == template_id,
Template.user_id == current_user.id
).first()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
return template
@router.put("/{template_id}", response_model=TemplateResponse)
def update_template(
template_id: int,
template_update: TemplateUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
template = db.query(Template).filter(
Template.id == template_id,
Template.user_id == current_user.id
).first()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
update_data = template_update.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(template, field, value)
db.commit()
db.refresh(template)
return template
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_template(
template_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
template = db.query(Template).filter(
Template.id == template_id,
Template.user_id == current_user.id
).first()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
db.delete(template)
db.commit()
return None

View File

@ -0,0 +1,91 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Query
from sqlalchemy.orm import Session
from app.db.base import get_db
from app.core.config import settings
from app.models.campaign import CampaignRecipient, RecipientStatus
import logging
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/whatsapp")
async def whatsapp_webhook_verify(
request: Request,
hub_mode: str = Query(None, alias="hub.mode"),
hub_challenge: str = Query(None, alias="hub.challenge"),
hub_verify_token: str = Query(None, alias="hub.verify_token")
):
"""
Verify webhook for WhatsApp Cloud API.
Meta will send a GET request with these parameters.
"""
if hub_mode == "subscribe" and hub_verify_token == settings.WHATSAPP_WEBHOOK_VERIFY_TOKEN:
logger.info("Webhook verified successfully")
return int(hub_challenge)
else:
raise HTTPException(status_code=403, detail="Verification failed")
@router.post("/whatsapp")
async def whatsapp_webhook_handler(
request: Request,
db: Session = Depends(get_db)
):
"""
Handle webhook updates from WhatsApp Cloud API.
Updates message statuses (sent, delivered, read, failed).
"""
try:
body = await request.json()
logger.info(f"Webhook received: {body}")
# Parse WhatsApp webhook payload
# Structure: https://developers.facebook.com/docs/whatsapp/cloud-api/webhooks/payload-examples
entry = body.get("entry", [])
if not entry:
return {"status": "no_entry"}
for item in entry:
changes = item.get("changes", [])
for change in changes:
value = change.get("value", {})
statuses = value.get("statuses", [])
for status_update in statuses:
message_id = status_update.get("id")
status = status_update.get("status") # sent, delivered, read, failed
if not message_id or not status:
continue
# Find recipient by provider message ID
recipient = db.query(CampaignRecipient).filter(
CampaignRecipient.provider_message_id == message_id
).first()
if not recipient:
logger.warning(f"Recipient not found for message_id: {message_id}")
continue
# Update status
if status == "sent":
recipient.status = RecipientStatus.SENT
elif status == "delivered":
recipient.status = RecipientStatus.DELIVERED
elif status == "read":
recipient.status = RecipientStatus.READ
elif status == "failed":
recipient.status = RecipientStatus.FAILED
errors = status_update.get("errors", [])
if errors:
recipient.last_error = errors[0].get("message", "Unknown error")
logger.info(f"Updated message {message_id} to status {status}")
db.commit()
return {"status": "ok"}
except Exception as e:
logger.error(f"Webhook error: {str(e)}")
return {"status": "error", "message": str(e)}

View File

@ -0,0 +1,85 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from app.db.base import get_db
from app.models.job import Job
from app.models.campaign import Campaign, CampaignStatus
from app.services.messaging import MessagingService
import logging
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/tick")
def worker_tick(db: Session = Depends(get_db)):
"""
Worker endpoint to process pending jobs.
In production, this would be called by a cron job or replaced with Celery.
For development, you can call this manually or set up a simple scheduler.
"""
# Get pending jobs
now = datetime.utcnow()
jobs = db.query(Job).filter(
Job.status == "pending",
Job.run_after <= now,
Job.attempts < 3
).limit(10).all()
processed = 0
for job in jobs:
logger.info(f"Processing job {job.id} of type {job.type}")
job.status = "running"
job.attempts += 1
db.commit()
try:
if job.type == "send_campaign":
campaign_id = job.payload_json.get("campaign_id")
user_id = job.user_id
messaging_service = MessagingService(db)
result = messaging_service.send_campaign_batch(campaign_id, user_id)
if result["status"] == "done" or result.get("remaining", 0) == 0:
# Campaign done
job.status = "completed"
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
if campaign:
campaign.status = CampaignStatus.DONE
else:
# More batches to process
job.status = "pending"
job.run_after = datetime.utcnow() + timedelta(minutes=1)
processed += 1
else:
job.status = "failed"
job.last_error = f"Unknown job type: {job.type}"
except Exception as e:
logger.error(f"Job {job.id} failed: {str(e)}")
job.last_error = str(e)
if job.attempts >= 3:
job.status = "failed"
# Mark campaign as failed
if job.type == "send_campaign":
campaign_id = job.payload_json.get("campaign_id")
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
if campaign:
campaign.status = CampaignStatus.FAILED
else:
job.status = "pending"
job.run_after = datetime.utcnow() + timedelta(minutes=5)
db.commit()
return {
"status": "ok",
"processed": processed
}

View File

@ -0,0 +1 @@
# Empty file

View File

@ -0,0 +1,44 @@
from pydantic_settings import BaseSettings
from typing import List
class Settings(BaseSettings):
# Database
DATABASE_URL: str = "postgresql://whatssender:whatssender123@localhost:5432/whatssender"
# Security
JWT_SECRET: str = "your-secret-key-change-in-production"
JWT_ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
# CORS
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:5173"
# Google OAuth
GOOGLE_CLIENT_ID: str = ""
GOOGLE_CLIENT_SECRET: str = ""
GOOGLE_REDIRECT_URI: str = "http://localhost:8000/api/imports/google/callback"
GOOGLE_TOKEN_ENCRYPTION_KEY: str = "" # Fernet key
# WhatsApp Provider
WHATSAPP_PROVIDER: str = "mock" # mock, cloud, or telegram
WHATSAPP_CLOUD_ACCESS_TOKEN: str = ""
WHATSAPP_CLOUD_PHONE_NUMBER_ID: str = ""
WHATSAPP_WEBHOOK_VERIFY_TOKEN: str = ""
# Telegram Provider (for testing)
TELEGRAM_BOT_TOKEN: str = ""
# Rate Limiting
MAX_MESSAGES_PER_MINUTE: int = 20
BATCH_SIZE: int = 10
DAILY_LIMIT_PER_CAMPAIGN: int = 1000
@property
def cors_origins_list(self) -> List[str]:
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()

45
backend/app/core/deps.py Normal file
View File

@ -0,0 +1,45 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.core.security import decode_access_token
from app.db.base import get_db
from app.models.user import User
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> User:
token = credentials.credentials
payload = decode_access_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials"
)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials"
)
user = db.query(User).filter(User.id == int(user_id)).first()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
)
return user
async def get_current_admin_user(current_user: User = Depends(get_current_user)) -> User:
if current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user

View File

@ -0,0 +1,30 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
return encoded_jwt
def decode_access_token(token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
return payload
except JWTError:
return None

16
backend/app/db/base.py Normal file
View File

@ -0,0 +1,16 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

50
backend/app/main.py Normal file
View File

@ -0,0 +1,50 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.api import auth, contacts, lists, templates, campaigns, imports, google, webhooks, stats, workers
import logging
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
app = FastAPI(
title="WhatsApp Campaign Manager API",
description="Production-quality API for managing WhatsApp campaigns with compliance",
version="1.0.0"
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
app.include_router(contacts.router, prefix="/api/contacts", tags=["Contacts"])
app.include_router(lists.router, prefix="/api/lists", tags=["Lists"])
app.include_router(templates.router, prefix="/api/templates", tags=["Templates"])
app.include_router(campaigns.router, prefix="/api/campaigns", tags=["Campaigns"])
app.include_router(imports.router, prefix="/api/imports", tags=["Imports"])
app.include_router(google.router, prefix="/api/imports", tags=["Google OAuth"])
app.include_router(webhooks.router, prefix="/api/webhooks", tags=["Webhooks"])
app.include_router(stats.router, prefix="/api/stats", tags=["Statistics"])
app.include_router(workers.router, prefix="/api/workers", tags=["Workers"])
@app.get("/")
def root():
return {
"name": "WhatsApp Campaign Manager API",
"version": "1.0.0",
"status": "running"
}
@app.get("/health")
def health_check():
return {"status": "healthy"}

View File

@ -0,0 +1,24 @@
from app.models.user import User
from app.models.contact import Contact, ContactTag, ContactTagMap, DNDList
from app.models.list import List, ListMember
from app.models.template import Template
from app.models.campaign import Campaign, CampaignRecipient
from app.models.send_log import SendLog
from app.models.job import Job
from app.models.google_token import GoogleToken
__all__ = [
"User",
"Contact",
"ContactTag",
"ContactTagMap",
"DNDList",
"List",
"ListMember",
"Template",
"Campaign",
"CampaignRecipient",
"SendLog",
"Job",
"GoogleToken",
]

View File

@ -0,0 +1,44 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum, Index, Text
from sqlalchemy.sql import func
from app.db.base import Base
import enum
class CampaignStatus(str, enum.Enum):
DRAFT = "draft"
SCHEDULED = "scheduled"
SENDING = "sending"
DONE = "done"
FAILED = "failed"
class RecipientStatus(str, enum.Enum):
PENDING = "pending"
SENT = "sent"
DELIVERED = "delivered"
READ = "read"
FAILED = "failed"
class Campaign(Base):
__tablename__ = "campaigns"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
name = Column(String, nullable=False)
template_id = Column(Integer, ForeignKey("templates.id", ondelete="SET NULL"), nullable=True)
list_id = Column(Integer, ForeignKey("lists.id", ondelete="SET NULL"), nullable=True)
status = Column(Enum(CampaignStatus), default=CampaignStatus.DRAFT, nullable=False, index=True)
scheduled_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
class CampaignRecipient(Base):
__tablename__ = "campaign_recipients"
__table_args__ = (
Index('ix_campaign_recipients_campaign_status', 'campaign_id', 'status'),
)
id = Column(Integer, primary_key=True, index=True)
campaign_id = Column(Integer, ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
contact_id = Column(Integer, ForeignKey("contacts.id", ondelete="CASCADE"), nullable=False, index=True)
status = Column(Enum(RecipientStatus), default=RecipientStatus.PENDING, nullable=False)
provider_message_id = Column(String, nullable=True)
last_error = Column(Text, nullable=True)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@ -0,0 +1,52 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint, Index, Text
from sqlalchemy.sql import func
from app.db.base import Base
class Contact(Base):
__tablename__ = "contacts"
__table_args__ = (
UniqueConstraint('user_id', 'phone_e164', name='uq_user_phone'),
Index('ix_contacts_user_phone', 'user_id', 'phone_e164'),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
phone_e164 = Column(String, nullable=False)
first_name = Column(String, nullable=True)
last_name = Column(String, nullable=True)
email = Column(String, nullable=True)
opted_in = Column(Boolean, default=False, nullable=False)
conversation_window_open = Column(Boolean, default=False, nullable=False)
source = Column(String, nullable=True) # excel, csv, google, manual
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class ContactTag(Base):
__tablename__ = "contact_tags"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
name = Column(String, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
class ContactTagMap(Base):
__tablename__ = "contact_tag_map"
__table_args__ = (
UniqueConstraint('contact_id', 'tag_id', name='uq_contact_tag'),
)
id = Column(Integer, primary_key=True, index=True)
contact_id = Column(Integer, ForeignKey("contacts.id", ondelete="CASCADE"), nullable=False)
tag_id = Column(Integer, ForeignKey("contact_tags.id", ondelete="CASCADE"), nullable=False)
class DNDList(Base):
__tablename__ = "dnd_list"
__table_args__ = (
UniqueConstraint('user_id', 'phone_e164', name='uq_dnd_user_phone'),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
phone_e164 = Column(String, nullable=False, index=True)
reason = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@ -0,0 +1,12 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
from sqlalchemy.sql import func
from app.db.base import Base
class GoogleToken(Base):
__tablename__ = "google_tokens"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, unique=True)
encrypted_token = Column(Text, nullable=False) # Encrypted refresh token
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

20
backend/app/models/job.py Normal file
View File

@ -0,0 +1,20 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, JSON, Text, Index
from sqlalchemy.sql import func
from app.db.base import Base
class Job(Base):
__tablename__ = "jobs"
__table_args__ = (
Index('ix_jobs_status_run_after', 'status', 'run_after'),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
type = Column(String, nullable=False) # send_campaign, import_google, etc.
payload_json = Column(JSON, nullable=False)
status = Column(String, default="pending", nullable=False) # pending, running, completed, failed
attempts = Column(Integer, default=0, nullable=False)
run_after = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
last_error = 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())

View File

@ -0,0 +1,21 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.sql import func
from app.db.base import Base
class List(Base):
__tablename__ = "lists"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
name = Column(String, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
class ListMember(Base):
__tablename__ = "list_members"
__table_args__ = (
UniqueConstraint('list_id', 'contact_id', name='uq_list_contact'),
)
id = Column(Integer, primary_key=True, index=True)
list_id = Column(Integer, ForeignKey("lists.id", ondelete="CASCADE"), nullable=False, index=True)
contact_id = Column(Integer, ForeignKey("contacts.id", ondelete="CASCADE"), nullable=False, index=True)

View File

@ -0,0 +1,16 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, JSON
from sqlalchemy.sql import func
from app.db.base import Base
class SendLog(Base):
__tablename__ = "send_logs"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
campaign_id = Column(Integer, ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=True, index=True)
contact_id = Column(Integer, ForeignKey("contacts.id", ondelete="CASCADE"), nullable=True)
provider = Column(String, nullable=False)
request_payload_json = Column(JSON, nullable=True)
response_payload_json = Column(JSON, nullable=True)
status = Column(String, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)

View File

@ -0,0 +1,15 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text
from sqlalchemy.sql import func
from app.db.base import Base
class Template(Base):
__tablename__ = "templates"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
name = Column(String, nullable=False)
language = Column(String, default="en", nullable=False)
body_text = Column(Text, nullable=False)
is_whatsapp_template = Column(Boolean, default=False, nullable=False)
provider_template_name = Column(String, nullable=True) # Name registered in WhatsApp Business Manager
created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@ -0,0 +1,17 @@
from sqlalchemy import Column, Integer, String, DateTime, Enum
from sqlalchemy.sql import func
from app.db.base import Base
import enum
class UserRole(str, enum.Enum):
ADMIN = "admin"
USER = "user"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, nullable=False, index=True)
password_hash = Column(String, nullable=False)
role = Column(Enum(UserRole), default=UserRole.USER, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@ -0,0 +1,18 @@
from app.providers.base import BaseProvider
from app.providers.mock import MockProvider
from app.providers.whatsapp_cloud import WhatsAppCloudProvider
from app.providers.telegram import TelegramProvider
from app.core.config import settings
def get_provider() -> BaseProvider:
"""Get the configured provider instance"""
provider_name = settings.WHATSAPP_PROVIDER.lower()
if provider_name == "mock":
return MockProvider()
elif provider_name == "cloud":
return WhatsAppCloudProvider()
elif provider_name == "telegram":
return TelegramProvider()
else:
raise ValueError(f"Unknown provider: {provider_name}")

View File

@ -0,0 +1,34 @@
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional
class BaseProvider(ABC):
"""Base class for WhatsApp messaging providers"""
@abstractmethod
def send_message(
self,
to: str,
template_name: Optional[str],
template_body: str,
variables: Dict[str, str],
language: str = "en"
) -> str:
"""
Send a WhatsApp message.
Args:
to: Phone number in E.164 format
template_name: WhatsApp template name (if using approved template)
template_body: Message body text
variables: Variables to substitute in template
language: Language code
Returns:
Provider message ID
"""
pass
@abstractmethod
def get_provider_name(self) -> str:
"""Get provider name"""
pass

View File

@ -0,0 +1,30 @@
import uuid
import random
from typing import Dict, Any, Optional
from app.providers.base import BaseProvider
class MockProvider(BaseProvider):
"""Mock provider for testing without external API calls"""
def send_message(
self,
to: str,
template_name: Optional[str],
template_body: str,
variables: Dict[str, str],
language: str = "en"
) -> str:
"""
Simulate sending a message.
Returns a fake message ID.
"""
# Simulate message ID
message_id = f"mock_{uuid.uuid4().hex[:16]}"
# Simulate random delivery statuses
# In a real implementation, you'd track these and update via webhooks
return message_id
def get_provider_name(self) -> str:
return "mock"

View File

@ -0,0 +1,89 @@
import requests
from typing import Dict, Any, Optional
from app.providers.base import BaseProvider
from app.core.config import settings
import logging
logger = logging.getLogger(__name__)
class TelegramProvider(BaseProvider):
"""
Telegram Bot API provider for testing.
Setup:
1. Talk to @BotFather on Telegram
2. Create new bot with /newbot
3. Copy the bot token
4. Set TELEGRAM_BOT_TOKEN in .env
5. Start a chat with your bot
6. Use your Telegram user ID as the phone number in contacts
To get your Telegram user ID:
- Message @userinfobot on Telegram
- OR use https://api.telegram.org/bot<TOKEN>/getUpdates after messaging your bot
"""
def __init__(self):
self.bot_token = settings.TELEGRAM_BOT_TOKEN
self.base_url = f"https://api.telegram.org/bot{self.bot_token}"
def send_message(
self,
to: str,
template_name: Optional[str],
template_body: str,
variables: Dict[str, str],
language: str = "en"
) -> str:
"""
Send a message via Telegram Bot API.
Args:
to: Telegram user ID or chat ID (stored in phone_e164 field)
template_name: Ignored for Telegram (no template system)
template_body: Message text with variables like {{first_name}}
variables: Dict of variables to replace in template_body
language: Ignored for Telegram
Returns:
message_id: Telegram message ID as string
"""
# Replace variables in template
message_text = template_body
for key, value in variables.items():
message_text = message_text.replace(f"{{{{{key}}}}}", value)
# Prepare request
url = f"{self.base_url}/sendMessage"
payload = {
"chat_id": to,
"text": message_text,
"parse_mode": "HTML" # Support basic formatting
}
logger.info(f"Sending Telegram message to chat_id: {to}")
try:
response = requests.post(url, json=payload, timeout=10)
# Get response body for debugging
result = response.json()
if not response.ok or not result.get("ok"):
error_desc = result.get("description", "Unknown error")
error_code = result.get("error_code", 0)
logger.error(f"Telegram API error {error_code}: {error_desc}")
logger.error(f"Request payload: {payload}")
raise Exception(f"Telegram API error {error_code}: {error_desc}")
message_id = str(result["result"]["message_id"])
logger.info(f"Telegram message sent successfully. Message ID: {message_id}")
return message_id
except requests.exceptions.RequestException as e:
logger.error(f"Telegram API request failed: {str(e)}")
raise Exception(f"Failed to send Telegram message: {str(e)}")
def get_provider_name(self) -> str:
return "telegram"

View File

@ -0,0 +1,95 @@
import httpx
from typing import Dict, Any, Optional
from app.providers.base import BaseProvider
from app.core.config import settings
class WhatsAppCloudProvider(BaseProvider):
"""WhatsApp Cloud API provider (Meta)"""
def __init__(self):
self.access_token = settings.WHATSAPP_CLOUD_ACCESS_TOKEN
self.phone_number_id = settings.WHATSAPP_CLOUD_PHONE_NUMBER_ID
self.base_url = "https://graph.facebook.com/v18.0"
def send_message(
self,
to: str,
template_name: Optional[str],
template_body: str,
variables: Dict[str, str],
language: str = "en"
) -> str:
"""
Send message via WhatsApp Cloud API.
If template_name is provided, sends a template message.
Otherwise, sends a text message.
"""
url = f"{self.base_url}/{self.phone_number_id}/messages"
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json"
}
if template_name:
# Send template message
# Build template parameters
components = []
if variables:
parameters = [
{"type": "text", "text": value}
for value in variables.values()
]
components.append({
"type": "body",
"parameters": parameters
})
payload = {
"messaging_product": "whatsapp",
"to": to.replace("+", ""),
"type": "template",
"template": {
"name": template_name,
"language": {
"code": language
},
"components": components
}
}
else:
# Send text message (only for contacts with conversation window)
# Substitute variables in body
message_text = template_body
for key, value in variables.items():
message_text = message_text.replace(f"{{{{{key}}}}}", value)
payload = {
"messaging_product": "whatsapp",
"to": to.replace("+", ""),
"type": "text",
"text": {
"body": message_text
}
}
try:
with httpx.Client(timeout=30.0) as client:
response = client.post(url, headers=headers, json=payload)
response.raise_for_status()
data = response.json()
message_id = data.get("messages", [{}])[0].get("id")
if not message_id:
raise Exception("No message ID in response")
return message_id
except httpx.HTTPStatusError as e:
raise Exception(f"WhatsApp API error: {e.response.status_code} - {e.response.text}")
except Exception as e:
raise Exception(f"Send error: {str(e)}")
def get_provider_name(self) -> str:
return "whatsapp_cloud"

View File

@ -0,0 +1 @@
# Empty file

View File

@ -0,0 +1,46 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional, List
class CampaignCreate(BaseModel):
name: str
template_id: int
list_id: int
scheduled_at: Optional[datetime] = None
class CampaignUpdate(BaseModel):
name: Optional[str] = None
status: Optional[str] = None
class CampaignResponse(BaseModel):
id: int
name: str
template_id: Optional[int]
list_id: Optional[int]
status: str
scheduled_at: Optional[datetime]
created_at: datetime
class Config:
from_attributes = True
class RecipientResponse(BaseModel):
id: int
campaign_id: int
contact_id: int
status: str
provider_message_id: Optional[str]
last_error: Optional[str]
updated_at: datetime
contact_phone: Optional[str] = None
contact_name: Optional[str] = None
class Config:
from_attributes = True
class CampaignPreview(BaseModel):
total_recipients: int
opted_in_count: int
dnd_count: int
eligible_count: int
sample_contacts: List[dict]

View File

@ -0,0 +1,56 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional, List
class ContactBase(BaseModel):
phone_e164: str
first_name: Optional[str] = None
last_name: Optional[str] = None
email: Optional[str] = None
opted_in: bool = False
conversation_window_open: bool = False
class ContactCreate(ContactBase):
pass
class ContactUpdate(BaseModel):
first_name: Optional[str] = None
last_name: Optional[str] = None
email: Optional[str] = None
opted_in: Optional[bool] = None
conversation_window_open: Optional[bool] = None
class ContactResponse(ContactBase):
id: int
user_id: int
source: Optional[str] = None
created_at: datetime
updated_at: datetime
tags: List[str] = []
class Config:
from_attributes = True
class ContactTagCreate(BaseModel):
name: str
class ContactTagResponse(BaseModel):
id: int
name: str
created_at: datetime
class Config:
from_attributes = True
class DNDCreate(BaseModel):
phone_e164: str
reason: Optional[str] = None
class DNDResponse(BaseModel):
id: int
phone_e164: str
reason: Optional[str]
created_at: datetime
class Config:
from_attributes = True

View File

@ -0,0 +1,17 @@
from pydantic import BaseModel
from typing import Dict, List, Optional
class ImportSummary(BaseModel):
total: int
created: int
updated: int
skipped: int
invalid: int
errors: List[str] = []
class GoogleAuthURL(BaseModel):
auth_url: str
class GoogleSyncResponse(BaseModel):
status: str
contacts_imported: int

View File

@ -0,0 +1,24 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional, List
class ListCreate(BaseModel):
name: str
class ListUpdate(BaseModel):
name: str
class ListResponse(BaseModel):
id: int
name: str
created_at: datetime
member_count: int = 0
class Config:
from_attributes = True
class ListMemberAdd(BaseModel):
contact_ids: List[int]
class ListMemberRemove(BaseModel):
contact_ids: List[int]

View File

@ -0,0 +1,29 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional
class TemplateCreate(BaseModel):
name: str
language: str = "en"
body_text: str
is_whatsapp_template: bool = False
provider_template_name: Optional[str] = None
class TemplateUpdate(BaseModel):
name: Optional[str] = None
language: Optional[str] = None
body_text: Optional[str] = None
is_whatsapp_template: Optional[bool] = None
provider_template_name: Optional[str] = None
class TemplateResponse(BaseModel):
id: int
name: str
language: str
body_text: str
is_whatsapp_template: bool
provider_template_name: Optional[str]
created_at: datetime
class Config:
from_attributes = True

View File

@ -0,0 +1,27 @@
from pydantic import BaseModel, EmailStr
from datetime import datetime
from typing import Optional
class UserCreate(BaseModel):
email: EmailStr
password: str
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserResponse(BaseModel):
id: int
email: str
role: str
created_at: datetime
class Config:
from_attributes = True
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
user_id: Optional[int] = None

View File

@ -0,0 +1 @@
# Empty file

View File

@ -0,0 +1,251 @@
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from typing import Dict, Optional
import re
from app.models.contact import Contact, DNDList
from app.models.template import Template
from app.models.campaign import Campaign, CampaignRecipient, RecipientStatus
from app.models.send_log import SendLog
from app.providers import get_provider
from app.core.config import settings
import time
import logging
logger = logging.getLogger(__name__)
class MessagingService:
"""Service for sending WhatsApp messages with rate limiting and compliance"""
def __init__(self, db: Session):
self.db = db
self.provider = get_provider()
def extract_variables(self, template_text: str) -> list:
"""Extract variable names from template text like {{first_name}}"""
pattern = r'\{\{(\w+)\}\}'
return re.findall(pattern, template_text)
def build_variables(self, template_text: str, contact: Contact) -> Dict[str, str]:
"""Build variable dict from contact data"""
variables = {}
var_names = self.extract_variables(template_text)
for var_name in var_names:
if var_name == 'first_name':
variables[var_name] = contact.first_name or ''
elif var_name == 'last_name':
variables[var_name] = contact.last_name or ''
elif var_name == 'email':
variables[var_name] = contact.email or ''
elif var_name == 'phone':
variables[var_name] = contact.phone_e164 or ''
else:
variables[var_name] = ''
return variables
def check_daily_limit(self, campaign_id: int, user_id: int) -> bool:
"""Check if daily limit has been reached for campaign"""
today = datetime.utcnow().date()
count = self.db.query(SendLog).filter(
SendLog.campaign_id == campaign_id,
SendLog.user_id == user_id,
SendLog.created_at >= today
).count()
return count < settings.DAILY_LIMIT_PER_CAMPAIGN
def can_send_to_contact(
self,
contact: Contact,
template: Template,
user_id: int
) -> tuple[bool, Optional[str]]:
"""
Check if we can send to a contact based on compliance rules.
Returns: (can_send, reason_if_not)
"""
# Check if opted in
if not contact.opted_in:
# If not opted in, MUST use approved WhatsApp template
if not template.is_whatsapp_template:
return False, "Contact not opted in; must use approved template"
# Check DND list
dnd_entry = self.db.query(DNDList).filter(
DNDList.user_id == user_id,
DNDList.phone_e164 == contact.phone_e164
).first()
if dnd_entry:
return False, "Contact in DND list"
# If using free-form message (not WhatsApp template), check conversation window
if not template.is_whatsapp_template:
if not contact.conversation_window_open:
return False, "No conversation window; must use approved template"
return True, None
def send_single_message(
self,
recipient: CampaignRecipient,
campaign: Campaign,
template: Template,
contact: Contact,
user_id: int
) -> bool:
"""
Send a single message with full compliance checks.
Returns: True if sent successfully, False otherwise
"""
# Check compliance
can_send, reason = self.can_send_to_contact(contact, template, user_id)
if not can_send:
recipient.status = RecipientStatus.FAILED
recipient.last_error = reason
self.db.commit()
return False
# Build variables
variables = self.build_variables(template.body_text, contact)
# Prepare request
request_payload = {
"to": contact.phone_e164,
"template_name": template.provider_template_name if template.is_whatsapp_template else None,
"template_body": template.body_text,
"variables": variables,
"language": template.language
}
try:
# Send via provider
message_id = self.provider.send_message(
to=contact.phone_e164,
template_name=template.provider_template_name if template.is_whatsapp_template else None,
template_body=template.body_text,
variables=variables,
language=template.language
)
# Update recipient status
recipient.status = RecipientStatus.SENT
recipient.provider_message_id = message_id
recipient.last_error = None
# Log send
send_log = SendLog(
user_id=user_id,
campaign_id=campaign.id,
contact_id=contact.id,
provider=self.provider.get_provider_name(),
request_payload_json=request_payload,
response_payload_json={"message_id": message_id},
status="sent"
)
self.db.add(send_log)
self.db.commit()
logger.info(f"Sent message to {contact.phone_e164}, message_id: {message_id}")
return True
except Exception as e:
error_msg = str(e)
logger.error(f"Error sending to {contact.phone_e164}: {error_msg}")
recipient.status = RecipientStatus.FAILED
recipient.last_error = error_msg
# Log failure
send_log = SendLog(
user_id=user_id,
campaign_id=campaign.id,
contact_id=contact.id,
provider=self.provider.get_provider_name(),
request_payload_json=request_payload,
response_payload_json={"error": error_msg},
status="failed"
)
self.db.add(send_log)
self.db.commit()
return False
def send_campaign_batch(
self,
campaign_id: int,
user_id: int,
batch_size: Optional[int] = None
) -> dict:
"""
Send a batch of messages for a campaign with rate limiting.
Returns: stats dict
"""
batch_size = batch_size or settings.BATCH_SIZE
# Get campaign
campaign = self.db.query(Campaign).filter(Campaign.id == campaign_id).first()
if not campaign:
raise ValueError("Campaign not found")
# Get template
template = self.db.query(Template).filter(Template.id == campaign.template_id).first()
if not template:
raise ValueError("Template not found")
# Check daily limit
if not self.check_daily_limit(campaign_id, user_id):
return {
"status": "limit_reached",
"message": "Daily limit reached for this campaign"
}
# Get pending recipients
recipients = self.db.query(CampaignRecipient).filter(
CampaignRecipient.campaign_id == campaign_id,
CampaignRecipient.status == RecipientStatus.PENDING
).limit(batch_size).all()
if not recipients:
return {
"status": "done",
"message": "No pending recipients"
}
sent_count = 0
failed_count = 0
for recipient in recipients:
# Get contact
contact = self.db.query(Contact).filter(Contact.id == recipient.contact_id).first()
if not contact:
recipient.status = RecipientStatus.FAILED
recipient.last_error = "Contact not found"
failed_count += 1
continue
# Send message
success = self.send_single_message(recipient, campaign, template, contact, user_id)
if success:
sent_count += 1
else:
failed_count += 1
# Rate limiting: sleep between messages
if sent_count < len(recipients):
time.sleep(60.0 / settings.MAX_MESSAGES_PER_MINUTE)
return {
"status": "batch_sent",
"sent": sent_count,
"failed": failed_count,
"remaining": self.db.query(CampaignRecipient).filter(
CampaignRecipient.campaign_id == campaign_id,
CampaignRecipient.status == RecipientStatus.PENDING
).count()
}

View File

@ -0,0 +1 @@
# Empty file

View File

@ -0,0 +1,18 @@
from cryptography.fernet import Fernet
from app.core.config import settings
def get_cipher():
"""Get Fernet cipher for encryption/decryption"""
if not settings.GOOGLE_TOKEN_ENCRYPTION_KEY:
raise ValueError("GOOGLE_TOKEN_ENCRYPTION_KEY not set")
return Fernet(settings.GOOGLE_TOKEN_ENCRYPTION_KEY.encode())
def encrypt_token(token: str) -> str:
"""Encrypt a token string"""
cipher = get_cipher()
return cipher.encrypt(token.encode()).decode()
def decrypt_token(encrypted_token: str) -> str:
"""Decrypt an encrypted token"""
cipher = get_cipher()
return cipher.decrypt(encrypted_token.encode()).decode()

View File

@ -0,0 +1,30 @@
import phonenumbers
from phonenumbers import NumberParseException
from typing import Optional
def normalize_phone(phone: str, default_region: str = "US") -> Optional[str]:
"""
Normalize a phone number to E.164 format.
For Telegram provider, accepts plain user IDs (just digits).
Returns None if the phone number is invalid.
"""
try:
# Clean the phone number
phone = str(phone).strip()
# If it's a short number (< 11 digits), treat as Telegram ID
# Telegram user IDs are typically 9-10 digits
if phone.isdigit() and len(phone) <= 10:
return phone # Return as-is for Telegram
# For longer numbers, try phone number validation
# If phone doesn't start with '+', try adding it
if phone and not phone.startswith('+'):
phone = '+' + phone
parsed = phonenumbers.parse(phone, default_region)
if phonenumbers.is_valid_number(parsed):
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
return None
except NumberParseException:
return None

47
backend/requirements.txt Normal file
View File

@ -0,0 +1,47 @@
# FastAPI and server
fastapi==0.109.0
uvicorn[standard]==0.27.0
python-multipart==0.0.6
# Database
sqlalchemy==2.0.25
alembic==1.13.1
psycopg2-binary==2.9.9
# Auth
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.1.2
# Validation
pydantic==2.5.3
pydantic-settings==2.1.0
email-validator==2.1.0
# Phone number handling
phonenumbers==8.13.27
# File handling
openpyxl==3.1.2
pandas==2.1.4
# HTTP requests
httpx==0.26.0
requests==2.31.0
# Google APIs
google-auth==2.26.2
google-auth-oauthlib==1.2.0
google-auth-httplib2==0.2.0
google-api-python-client==2.115.0
# Encryption
cryptography==42.0.0
# Utilities
python-dateutil==2.8.2
# Testing
pytest==7.4.4
pytest-asyncio==0.23.3
httpx==0.26.0

View File

@ -0,0 +1 @@
# Empty file

View File

@ -0,0 +1,16 @@
import pytest
from app.utils.phone import normalize_phone
def test_normalize_phone_us():
assert normalize_phone("+1234567890") == "+1234567890"
assert normalize_phone("1234567890") == "+1234567890"
assert normalize_phone("(123) 456-7890") == "+1234567890"
def test_normalize_phone_invalid():
assert normalize_phone("123") is None
assert normalize_phone("abc") is None
assert normalize_phone("") is None
def test_normalize_phone_international():
assert normalize_phone("+447700900123") == "+447700900123"
assert normalize_phone("+919876543210") == "+919876543210"

View File

@ -0,0 +1,21 @@
import pytest
from app.providers.mock import MockProvider
def test_mock_provider_send():
provider = MockProvider()
message_id = provider.send_message(
to="+1234567890",
template_name=None,
template_body="Hello {{first_name}}",
variables={"first_name": "John"},
language="en"
)
assert message_id is not None
assert message_id.startswith("mock_")
assert len(message_id) > 10
def test_mock_provider_name():
provider = MockProvider()
assert provider.get_provider_name() == "mock"

BIN
contacts_demo.xlsx Normal file

Binary file not shown.

69
docker-compose.yml Normal file
View File

@ -0,0 +1,69 @@
version: '3.8'
services:
postgres:
image: postgres:15
environment:
POSTGRES_DB: whatssender
POSTGRES_USER: whatssender
POSTGRES_PASSWORD: whatssender123
ports:
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U whatssender"]
interval: 5s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql://whatssender:whatssender123@postgres:5432/whatssender
JWT_SECRET: your-secret-key-change-in-production
CORS_ORIGINS: http://localhost:3000,http://localhost:5173
GOOGLE_CLIENT_ID: your-google-client-id
GOOGLE_CLIENT_SECRET: your-google-client-secret
GOOGLE_REDIRECT_URI: http://localhost:8000/api/imports/google/callback
GOOGLE_TOKEN_ENCRYPTION_KEY: your-fernet-key-32-bytes-base64
WHATSAPP_PROVIDER: telegram
WHATSAPP_CLOUD_ACCESS_TOKEN: your-whatsapp-token
WHATSAPP_CLOUD_PHONE_NUMBER_ID: your-phone-number-id
WHATSAPP_WEBHOOK_VERIFY_TOKEN: your-webhook-verify-token
TELEGRAM_BOT_TOKEN: 8428015346:AAH2MOb9D1HUlINOxcDFMa6q98qGo4rPSYo
depends_on:
postgres:
condition: service_healthy
volumes:
- ./backend:/app
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "5173:5173"
environment:
VITE_API_URL: http://localhost:8000
volumes:
- ./frontend:/app
- /app/node_modules
command: npm run dev -- --host
# Optional Redis for Celery (uncomment when ready to use)
# redis:
# image: redis:7-alpine
# ports:
# - "6379:6379"
# volumes:
# - redis_data:/data
volumes:
postgres_data:
# redis_data:

1
frontend/.env.example Normal file
View File

@ -0,0 +1 @@
VITE_API_URL=http://localhost:8000

12
frontend/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WhatsApp Campaign Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

23
frontend/package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "whats-sender-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1",
"axios": "^1.6.5"
},
"devDependencies": {
"@types/react": "^18.2.47",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.0.11"
}
}

65
frontend/src/App.jsx Normal file
View File

@ -0,0 +1,65 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { useToast } from './components/Toast';
import { LoginPage } from './pages/LoginPage';
import { RegisterPage } from './pages/RegisterPage';
import { DashboardPage } from './pages/DashboardPage';
import { ContactsPage } from './pages/ContactsPage';
import { ListsPage } from './pages/ListsPage';
import { TemplatesPage } from './pages/TemplatesPage';
import { CampaignsPage } from './pages/CampaignsPage';
import { CampaignDetailPage } from './pages/CampaignDetailPage';
import { ImportsPage } from './pages/ImportsPage';
import { Loading } from './components/Loading';
import './styles/main.css';
import './styles/darkmode.css';
import { useEffect } from 'react';
const ProtectedRoute = ({ children }) => {
const { user, loading } = useAuth();
if (loading) return <Loading />;
if (!user) return <Navigate to="/login" />;
return children;
};
function AppRoutes() {
const { ToastContainer } = useToast();
return (
<>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/" element={<Navigate to="/dashboard" />} />
<Route path="/dashboard" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
<Route path="/contacts" element={<ProtectedRoute><ContactsPage /></ProtectedRoute>} />
<Route path="/lists" element={<ProtectedRoute><ListsPage /></ProtectedRoute>} />
<Route path="/templates" element={<ProtectedRoute><TemplatesPage /></ProtectedRoute>} />
<Route path="/campaigns" element={<ProtectedRoute><CampaignsPage /></ProtectedRoute>} />
<Route path="/campaigns/:id" element={<ProtectedRoute><CampaignDetailPage /></ProtectedRoute>} />
<Route path="/imports" element={<ProtectedRoute><ImportsPage /></ProtectedRoute>} />
</Routes>
<ToastContainer />
</>
);
}
function App() {
useEffect(() => {
// Load theme from localStorage
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
}, []);
return (
<BrowserRouter>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
);
}
export default App;

124
frontend/src/api/client.js Normal file
View File

@ -0,0 +1,124 @@
import axios from 'axios';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const api = axios.create({
baseURL: `${API_URL}/api`,
headers: {
'Content-Type': 'application/json'
}
});
// Add token to requests
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle 401 errors
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default api;
// Auth API
export const authAPI = {
register: (email, password) => api.post('/auth/register', { email, password }),
login: (email, password) => api.post('/auth/login', { email, password }),
getMe: () => api.get('/auth/me')
};
// Contacts API
export const contactsAPI = {
list: (params) => api.get('/contacts', { params }),
get: (id) => api.get(`/contacts/${id}`),
create: (data) => api.post('/contacts', data),
update: (id, data) => api.put(`/contacts/${id}`, data),
delete: (id) => api.delete(`/contacts/${id}`),
// Tags
listTags: () => api.get('/contacts/tags'),
createTag: (name) => api.post('/contacts/tags', { name }),
addTag: (contactId, tagId) => api.post(`/contacts/${contactId}/tags/${tagId}`),
removeTag: (contactId, tagId) => api.delete(`/contacts/${contactId}/tags/${tagId}`),
// DND
listDND: () => api.get('/contacts/dnd'),
addDND: (data) => api.post('/contacts/dnd', data),
removeDND: (id) => api.delete(`/contacts/dnd/${id}`)
};
// Lists API
export const listsAPI = {
list: () => api.get('/lists'),
get: (id) => api.get(`/lists/${id}`),
create: (data) => api.post('/lists', data),
update: (id, data) => api.put(`/lists/${id}`, data),
delete: (id) => api.delete(`/lists/${id}`),
getContacts: (id) => api.get(`/lists/${id}/contacts`),
addMembers: (id, contactIds) => api.post(`/lists/${id}/members`, { contact_ids: contactIds }),
removeMembers: (id, contactIds) => api.delete(`/lists/${id}/members`, { data: { contact_ids: contactIds } })
};
// Templates API
export const templatesAPI = {
list: () => api.get('/templates'),
get: (id) => api.get(`/templates/${id}`),
create: (data) => api.post('/templates', data),
update: (id, data) => api.put(`/templates/${id}`, data),
delete: (id) => api.delete(`/templates/${id}`)
};
// Campaigns API
export const campaignsAPI = {
list: (params) => api.get('/campaigns', { params }),
get: (id) => api.get(`/campaigns/${id}`),
create: (data) => api.post('/campaigns', data),
update: (id, data) => api.put(`/campaigns/${id}`, data),
delete: (id) => api.delete(`/campaigns/${id}`),
preview: (id) => api.get(`/campaigns/${id}/preview`),
send: (id) => api.post(`/campaigns/${id}/send`),
reset: (id) => api.post(`/campaigns/${id}/reset`),
getRecipients: (id, params) => api.get(`/campaigns/${id}/recipients`, { params })
};
// Imports API
export const importsAPI = {
uploadExcel: (file) => {
const formData = new FormData();
formData.append('file', file);
return api.post('/imports/excel', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
},
uploadCSV: (file) => {
const formData = new FormData();
formData.append('file', file);
return api.post('/imports/csv', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
},
googleAuthStart: () => api.get('/imports/google/start'),
googleSync: () => api.post('/imports/google/sync')
};
// Stats API
export const statsAPI = {
get: () => api.get('/stats')
};
// Workers API
export const workersAPI = {
tick: () => api.post('/workers/tick')
};

View File

@ -0,0 +1,37 @@
export const DataTable = ({ columns, data, onRowClick }) => {
if (!data || data.length === 0) {
return (
<div className="empty-state">
<div className="empty-state-icon">📋</div>
<div className="empty-state-text">No data available</div>
</div>
);
}
return (
<table className="table">
<thead>
<tr>
{columns.map((col, idx) => (
<th key={idx}>{col.header}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, rowIdx) => (
<tr
key={rowIdx}
onClick={() => onRowClick && onRowClick(row)}
style={{ cursor: onRowClick ? 'pointer' : 'default' }}
>
{columns.map((col, colIdx) => (
<td key={colIdx}>
{col.render ? col.render(row) : row[col.accessor]}
</td>
))}
</tr>
))}
</tbody>
</table>
);
};

View File

@ -0,0 +1,8 @@
export const Loading = () => {
return (
<div className="loading">
<div className="spinner"></div>
<p>Loading...</p>
</div>
);
};

View File

@ -0,0 +1,17 @@
export const Modal = ({ isOpen, onClose, title, children }) => {
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{title}</h2>
<button className="modal-close" onClick={onClose}>
×
</button>
</div>
<div className="modal-body">{children}</div>
</div>
</div>
);
};

View File

@ -0,0 +1,48 @@
import { Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useState, useEffect } from 'react';
export const Navbar = () => {
const { user, logout } = useAuth();
const [theme, setTheme] = useState('light');
useEffect(() => {
const savedTheme = localStorage.getItem('theme') || 'light';
setTheme(savedTheme);
}, []);
const toggleTheme = () => {
const newTheme = theme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
};
return (
<nav className="navbar">
<div className="navbar-content">
<Link to="/" className="navbar-brand">
📱 WhatsApp Campaign Manager
</Link>
<ul className="navbar-nav">
<li><Link to="/dashboard" className="navbar-link">Dashboard</Link></li>
<li><Link to="/contacts" className="navbar-link">Contacts</Link></li>
<li><Link to="/lists" className="navbar-link">Lists</Link></li>
<li><Link to="/templates" className="navbar-link">Templates</Link></li>
<li><Link to="/campaigns" className="navbar-link">Campaigns</Link></li>
<li><Link to="/imports" className="navbar-link">Import</Link></li>
<li>
<button onClick={toggleTheme} className="dark-mode-toggle" title="Toggle dark mode">
{theme === 'light' ? '🌙' : '☀️'}
</button>
</li>
<li>
<button onClick={logout} className="btn btn-secondary" style={{ padding: '6px 16px' }}>
Logout ({user?.email})
</button>
</li>
</ul>
</div>
</nav>
);
};

View File

@ -0,0 +1,48 @@
import { useState, useEffect } from 'react';
let toastId = 0;
export const Toast = ({ message, type, duration = 3000, onClose }) => {
useEffect(() => {
const timer = setTimeout(() => {
onClose();
}, duration);
return () => clearTimeout(timer);
}, [duration, onClose]);
return (
<div className={`toast toast-${type}`}>
{message}
</div>
);
};
export const useToast = () => {
const [toasts, setToasts] = useState([]);
const showToast = (message, type = 'info', duration = 3000) => {
const id = toastId++;
setToasts((prev) => [...prev, { id, message, type, duration }]);
};
const removeToast = (id) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
};
const ToastContainer = () => (
<>
{toasts.map((toast) => (
<Toast
key={toast.id}
message={toast.message}
type={toast.type}
duration={toast.duration}
onClose={() => removeToast(toast.id)}
/>
))}
</>
);
return { showToast, ToastContainer };
};

View File

@ -0,0 +1,60 @@
import { createContext, useContext, useState, useEffect } from 'react';
import { authAPI } from '../api/client';
const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
loadUser();
} else {
setLoading(false);
}
}, []);
const loadUser = async () => {
try {
const response = await authAPI.getMe();
setUser(response.data);
} catch (error) {
localStorage.removeItem('token');
} finally {
setLoading(false);
}
};
const login = async (email, password) => {
const response = await authAPI.login(email, password);
localStorage.setItem('token', response.data.access_token);
await loadUser();
};
const register = async (email, password) => {
await authAPI.register(email, password);
await login(email, password);
};
const logout = () => {
localStorage.removeItem('token');
setUser(null);
window.location.href = '/login';
};
return (
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
};

9
frontend/src/main.jsx Normal file
View File

@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@ -0,0 +1,157 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Navbar } from '../components/Navbar';
import { DataTable } from '../components/DataTable';
import { Loading } from '../components/Loading';
import { useToast } from '../components/Toast';
import { campaignsAPI } from '../api/client';
export const CampaignDetailPage = () => {
const { id } = useParams();
const navigate = useNavigate();
const [campaign, setCampaign] = useState(null);
const [recipients, setRecipients] = useState([]);
const [loading, setLoading] = useState(true);
const { showToast } = useToast();
useEffect(() => {
loadCampaignData();
const interval = setInterval(loadCampaignData, 5000); // Refresh every 5 seconds
return () => clearInterval(interval);
}, [id]);
const loadCampaignData = async () => {
try {
const [campaignRes, recipientsRes] = await Promise.all([
campaignsAPI.get(id),
campaignsAPI.getRecipients(id)
]);
setCampaign(campaignRes.data);
setRecipients(recipientsRes.data);
} catch (error) {
showToast('Failed to load campaign details', 'error');
} finally {
setLoading(false);
}
};
const handleReset = async () => {
if (!confirm('Reset this campaign? All recipients will be set back to pending and you can send again.')) return;
try {
await campaignsAPI.reset(id);
showToast('Campaign reset! Click "Send" to try again.', 'success');
loadCampaignData();
} catch (error) {
showToast('Failed to reset campaign', 'error');
}
};
const getStatusBadgeClass = (status) => {
switch(status) {
case 'sent': return 'info';
case 'delivered': return 'success';
case 'read': return 'success';
case 'failed': return 'danger';
default: return 'warning';
}
};
const columns = [
{ header: 'Phone', accessor: 'contact_phone' },
{ header: 'Name', render: (row) => `${row.contact_first_name || ''} ${row.contact_last_name || ''}`.trim() || '-' },
{ header: 'Status', render: (row) => <span className={`badge badge-${getStatusBadgeClass(row.status)}`}>{row.status}</span> },
{ header: 'Error', render: (row) => row.last_error ? (
<div style={{ fontSize: '13px' }}>
<div style={{ color: '#dc3545', fontWeight: '500' }}>{row.last_error}</div>
{row.last_error.includes('not opted in') && (
<div style={{ color: '#666', fontSize: '11px', marginTop: '4px' }}>💡 Fix: Edit contact and check "Opted In"</div>
)}
{row.last_error.includes('conversation window') && (
<div style={{ color: '#666', fontSize: '11px', marginTop: '4px' }}>💡 Fix: Check "Approved WhatsApp Template" on template</div>
)}
</div>
) : '-' },
{ header: 'Message ID', render: (row) => row.provider_message_id ? <span style={{ fontSize: '12px', fontFamily: 'monospace' }}>{row.provider_message_id.substring(0, 20)}...</span> : '-' },
{ header: 'Updated', render: (row) => new Date(row.updated_at).toLocaleString() }
];
if (loading) return <Loading />;
if (!campaign) return <div>Campaign not found</div>;
const stats = recipients.reduce((acc, r) => {
acc[r.status] = (acc[r.status] || 0) + 1;
return acc;
}, {});
return (
<>
<Navbar />
<div className="container">
<div className="page">
<div className="page-header">
<div>
<button onClick={() => navigate('/campaigns')} className="btn btn-secondary" style={{ marginRight: '15px' }}> Back</button>
<h1 className="page-title" style={{ display: 'inline' }}>{campaign.name}</h1>
</div>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
{(campaign.status === 'done' || campaign.status === 'failed') && (
<button onClick={handleReset} className="btn btn-warning">🔄 Reset & Resend</button>
)}
<span className={`badge badge-${campaign.status === 'done' ? 'success' : campaign.status === 'failed' ? 'danger' : 'info'}`} style={{ fontSize: '16px', padding: '8px 16px' }}>
{campaign.status}
</span>
</div>
</div>
{stats.failed > 0 && (
<div style={{
padding: '16px',
backgroundColor: '#fef2f2',
border: '1px solid #fca5a5',
borderRadius: '8px',
marginBottom: '24px'
}}>
<div style={{ fontWeight: 'bold', color: '#dc2626', marginBottom: '8px' }}>
{stats.failed} message(s) failed
</div>
<div style={{ fontSize: '14px', color: '#666' }}>
Common fixes:
<ul style={{ marginTop: '8px', marginLeft: '20px' }}>
<li>Go to <a href="/contacts" style={{ color: '#2563eb' }}>Contacts</a> Edit contact Check "Opted In"</li>
<li>Go to <a href="/templates" style={{ color: '#2563eb' }}>Templates</a> Edit template Check "Approved WhatsApp Template"</li>
<li>For testing: Use Telegram provider (see TELEGRAM_TESTING.md)</li>
</ul>
</div>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '20px', marginBottom: '30px' }}>
<div className="card" style={{ padding: '20px', textAlign: 'center' }}>
<div style={{ fontSize: '32px', fontWeight: 'bold', color: '#10b981' }}>{stats.sent || 0}</div>
<div style={{ color: '#666', marginTop: '5px' }}>Sent</div>
</div>
<div className="card" style={{ padding: '20px', textAlign: 'center' }}>
<div style={{ fontSize: '32px', fontWeight: 'bold', color: '#3b82f6' }}>{stats.delivered || 0}</div>
<div style={{ color: '#666', marginTop: '5px' }}>Delivered</div>
</div>
<div className="card" style={{ padding: '20px', textAlign: 'center' }}>
<div style={{ fontSize: '32px', fontWeight: 'bold', color: '#8b5cf6' }}>{stats.read || 0}</div>
<div style={{ color: '#666', marginTop: '5px' }}>Read</div>
</div>
<div className="card" style={{ padding: '20px', textAlign: 'center' }}>
<div style={{ fontSize: '32px', fontWeight: 'bold', color: '#f59e0b' }}>{stats.pending || 0}</div>
<div style={{ color: '#666', marginTop: '5px' }}>Pending</div>
</div>
<div className="card" style={{ padding: '20px', textAlign: 'center' }}>
<div style={{ fontSize: '32px', fontWeight: 'bold', color: '#ef4444' }}>{stats.failed || 0}</div>
<div style={{ color: '#666', marginTop: '5px' }}>Failed</div>
</div>
</div>
<h2 style={{ marginBottom: '20px', fontSize: '20px' }}>Recipients</h2>
<DataTable columns={columns} data={recipients} />
</div>
</div>
</>
);
};

View File

@ -0,0 +1,131 @@
import { useState, useEffect } from 'react';
import { Navbar } from '../components/Navbar';
import { DataTable } from '../components/DataTable';
import { Modal } from '../components/Modal';
import { Loading } from '../components/Loading';
import { useToast } from '../components/Toast';
import { campaignsAPI, listsAPI, templatesAPI } from '../api/client';
export const CampaignsPage = () => {
const [campaigns, setCampaigns] = useState([]);
const [lists, setLists] = useState([]);
const [templates, setTemplates] = useState([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [formData, setFormData] = useState({ name: '', template_id: '', list_id: '' });
const { showToast } = useToast();
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [campaignsRes, listsRes, templatesRes] = await Promise.all([
campaignsAPI.list(),
listsAPI.list(),
templatesAPI.list()
]);
setCampaigns(campaignsRes.data);
setLists(listsRes.data);
setTemplates(templatesRes.data);
} catch (error) {
showToast('Failed to load data', 'error');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
await campaignsAPI.create({
name: formData.name,
template_id: parseInt(formData.template_id),
list_id: parseInt(formData.list_id)
});
showToast('Campaign created successfully', 'success');
setShowModal(false);
setFormData({ name: '', template_id: '', list_id: '' });
loadData();
} catch (error) {
showToast('Failed to create campaign', 'error');
}
};
const handleSend = async (id) => {
if (!confirm('Start sending this campaign? This cannot be undone.')) return;
try {
await campaignsAPI.send(id);
showToast('Campaign started! Run worker to process: curl -X POST http://localhost:8000/api/workers/tick', 'success');
loadData();
} catch (error) {
showToast('Failed to start campaign', 'error');
}
};
const handleDelete = async (id) => {
if (!confirm('Delete this campaign? This will remove all recipients and logs.')) return;
try {
await campaignsAPI.delete(id);
showToast('Campaign deleted', 'success');
loadData();
} catch (error) {
showToast('Failed to delete campaign', 'error');
}
};
const columns = [
{ header: 'Name', accessor: 'name' },
{ header: 'Status', render: (row) => <span className={`badge badge-${row.status === 'done' ? 'success' : row.status === 'failed' ? 'danger' : 'info'}`}>{row.status}</span> },
{ header: 'Created', render: (row) => new Date(row.created_at).toLocaleDateString() },
{ header: 'Actions', render: (row) => (
<>
{row.status === 'draft' && <button onClick={() => handleSend(row.id)} className="btn btn-primary" style={{ padding: '4px 12px', marginRight: '8px' }}>Send</button>}
<button onClick={() => window.location.href = `/campaigns/${row.id}`} className="btn btn-secondary" style={{ padding: '4px 12px', marginRight: '8px' }}>View</button>
<button onClick={() => handleDelete(row.id)} className="btn btn-danger" style={{ padding: '4px 12px' }}>Delete</button>
</>
)}
];
if (loading) return <Loading />;
return (
<>
<Navbar />
<div className="container">
<div className="page">
<div className="page-header">
<h1 className="page-title">Campaigns</h1>
<button onClick={() => setShowModal(true)} className="btn btn-primary">Create Campaign</button>
</div>
<DataTable columns={columns} data={campaigns} />
</div>
</div>
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="Create Campaign">
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Campaign Name</label>
<input type="text" className="form-input" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} required />
</div>
<div className="form-group">
<label className="form-label">Template</label>
<select className="form-select" value={formData.template_id} onChange={(e) => setFormData({ ...formData, template_id: e.target.value })} required>
<option value="">Select template...</option>
{templates.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Contact List</label>
<select className="form-select" value={formData.list_id} onChange={(e) => setFormData({ ...formData, list_id: e.target.value })} required>
<option value="">Select list...</option>
{lists.map(l => <option key={l.id} value={l.id}>{l.name} ({l.member_count} contacts)</option>)}
</select>
</div>
<button type="submit" className="btn btn-primary">Create</button>
</form>
</Modal>
</>
);
};

View File

@ -0,0 +1,196 @@
import { useState, useEffect } from 'react';
import { Navbar } from '../components/Navbar';
import { DataTable } from '../components/DataTable';
import { Modal } from '../components/Modal';
import { Loading } from '../components/Loading';
import { useToast } from '../components/Toast';
import { contactsAPI, listsAPI } from '../api/client';
export const ContactsPage = () => {
const [contacts, setContacts] = useState([]);
const [lists, setLists] = useState([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [showAddToListModal, setShowAddToListModal] = useState(false);
const [selectedContacts, setSelectedContacts] = useState([]);
const [selectedList, setSelectedList] = useState('');
const [formData, setFormData] = useState({
phone_e164: '',
first_name: '',
last_name: '',
email: '',
opted_in: false
});
const { showToast } = useToast();
useEffect(() => {
loadContacts();
loadLists();
}, []);
const loadContacts = async () => {
try {
const response = await contactsAPI.list();
setContacts(response.data);
} catch (error) {
showToast('Failed to load contacts', 'error');
} finally {
setLoading(false);
}
};
const loadLists = async () => {
try {
const response = await listsAPI.list();
setLists(response.data);
} catch (error) {
console.error('Failed to load lists', error);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
await contactsAPI.create(formData);
showToast('Contact created successfully', 'success');
setShowModal(false);
setFormData({ phone_e164: '', first_name: '', last_name: '', email: '', opted_in: false });
loadContacts();
} catch (error) {
showToast(error.response?.data?.detail || 'Failed to create contact', 'error');
}
};
const handleDelete = async (id) => {
if (!confirm('Delete this contact?')) return;
try {
await contactsAPI.delete(id);
showToast('Contact deleted', 'success');
loadContacts();
} catch (error) {
showToast('Failed to delete contact', 'error');
}
};
const handleCheckboxChange = (contactId) => {
setSelectedContacts(prev =>
prev.includes(contactId)
? prev.filter(id => id !== contactId)
: [...prev, contactId]
);
};
const handleAddToList = async (e) => {
e.preventDefault();
if (!selectedList || selectedContacts.length === 0) {
showToast('Please select a list and contacts', 'error');
return;
}
try {
await listsAPI.addMembers(parseInt(selectedList), selectedContacts);
showToast(`${selectedContacts.length} contact(s) added to list`, 'success');
setShowAddToListModal(false);
setSelectedContacts([]);
setSelectedList('');
} catch (error) {
showToast(error.response?.data?.detail || 'Failed to add contacts to list', 'error');
}
};
const columns = [
{
header: <input type="checkbox" onChange={(e) => {
if (e.target.checked) {
setSelectedContacts(contacts.map(c => c.id));
} else {
setSelectedContacts([]);
}
}} />,
render: (row) => (
<input
type="checkbox"
checked={selectedContacts.includes(row.id)}
onChange={() => handleCheckboxChange(row.id)}
/>
)
},
{ header: 'Phone', accessor: 'phone_e164' },
{ header: 'Name', render: (row) => `${row.first_name || ''} ${row.last_name || ''}`.trim() || '-' },
{ header: 'Email', accessor: 'email', render: (row) => row.email || '-' },
{ header: 'Opted In', render: (row) => <span className={`badge badge-${row.opted_in ? 'success' : 'warning'}`}>{row.opted_in ? 'Yes' : 'No'}</span> },
{ header: 'Source', accessor: 'source' },
{ header: 'Actions', render: (row) => <button onClick={() => handleDelete(row.id)} className="btn btn-danger" style={{ padding: '4px 12px' }}>Delete</button> }
];
if (loading) return <Loading />;
return (
<>
<Navbar />
<div className="container">
<div className="page">
<div className="page-header">
<h1 className="page-title">Contacts</h1>
<div style={{ display: 'flex', gap: '10px' }}>
{selectedContacts.length > 0 && (
<button onClick={() => setShowAddToListModal(true)} className="btn btn-secondary">
Add to List ({selectedContacts.length})
</button>
)}
<button onClick={() => setShowModal(true)} className="btn btn-primary">Add Contact</button>
</div>
</div>
<DataTable columns={columns} data={contacts} />
</div>
</div>
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="Add Contact">
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Phone (E.164)</label>
<input type="text" className="form-input" value={formData.phone_e164} onChange={(e) => setFormData({ ...formData, phone_e164: e.target.value })} required placeholder="+1234567890" />
</div>
<div className="form-group">
<label className="form-label">First Name</label>
<input type="text" className="form-input" value={formData.first_name} onChange={(e) => setFormData({ ...formData, first_name: e.target.value })} />
</div>
<div className="form-group">
<label className="form-label">Last Name</label>
<input type="text" className="form-input" value={formData.last_name} onChange={(e) => setFormData({ ...formData, last_name: e.target.value })} />
</div>
<div className="form-group">
<label className="form-label">Email</label>
<input type="email" className="form-input" value={formData.email} onChange={(e) => setFormData({ ...formData, email: e.target.value })} />
</div>
<div className="form-group">
<label><input type="checkbox" className="form-checkbox" checked={formData.opted_in} onChange={(e) => setFormData({ ...formData, opted_in: e.target.checked })} /> Opted In</label>
</div>
<button type="submit" className="btn btn-primary">Create</button>
</form>
</Modal>
<Modal isOpen={showAddToListModal} onClose={() => setShowAddToListModal(false)} title="Add Contacts to List">
<form onSubmit={handleAddToList}>
<div className="form-group">
<label className="form-label">Select List</label>
<select
className="form-input"
value={selectedList}
onChange={(e) => setSelectedList(e.target.value)}
required
>
<option value="">-- Select a List --</option>
{lists.map(list => (
<option key={list.id} value={list.id}>{list.name} ({list.member_count} members)</option>
))}
</select>
</div>
<p style={{ marginBottom: '20px', color: '#666' }}>
Adding {selectedContacts.length} contact(s) to the selected list
</p>
<button type="submit" className="btn btn-primary">Add to List</button>
</form>
</Modal>
</>
);
};

View File

@ -0,0 +1,145 @@
import { useState, useEffect } from 'react';
import { Navbar } from '../components/Navbar';
import { Loading } from '../components/Loading';
import { statsAPI, workersAPI } from '../api/client';
import { useToast } from '../components/Toast';
import { Link } from 'react-router-dom';
export const DashboardPage = () => {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [processingWorker, setProcessingWorker] = useState(false);
const { showToast } = useToast();
useEffect(() => {
loadStats();
}, []);
const loadStats = async () => {
try {
const response = await statsAPI.get();
setStats(response.data);
} catch (error) {
showToast('Failed to load stats', 'error');
} finally {
setLoading(false);
}
};
const handleProcessWorker = async () => {
setProcessingWorker(true);
try {
const response = await workersAPI.tick();
const processed = response.data.processed || 0;
if (processed > 0) {
showToast(`Processed ${processed} job(s). Check campaigns for updates!`, 'success');
setTimeout(loadStats, 1000); // Reload stats after 1 second
} else {
showToast('No pending jobs to process', 'info');
}
} catch (error) {
showToast('Failed to process worker', 'error');
} finally {
setProcessingWorker(false);
}
};
if (loading) return <Loading />;
return (
<>
<Navbar />
<div className="container">
<div className="page">
<div className="page-header">
<h1 className="page-title">Dashboard</h1>
<button
onClick={handleProcessWorker}
disabled={processingWorker}
className="btn btn-success"
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
>
{processingWorker ? '⏳ Processing...' : '▶️ Process Campaign Jobs'}
</button>
</div>
<div style={{
padding: '12px 16px',
backgroundColor: '#fef3c7',
border: '1px solid #fbbf24',
borderRadius: '8px',
marginBottom: '24px',
fontSize: '14px'
}}>
💡 <strong>Tip:</strong> After clicking "Send" on a campaign, click "Process Campaign Jobs" button above to actually send the messages.
Campaign status will change from <span className="badge badge-info" style={{ fontSize: '12px' }}>sending</span> to <span className="badge badge-success" style={{ fontSize: '12px' }}>done</span>.
</div>
<div className="stats-grid">
<div className="stat-card">
<div className="stat-value">{stats?.total_contacts || 0}</div>
<div className="stat-label">Total Contacts</div>
</div>
<div className="stat-card">
<div className="stat-value">{stats?.opted_in_contacts || 0}</div>
<div className="stat-label">Opted In</div>
</div>
<div className="stat-card">
<div className="stat-value">{stats?.total_lists || 0}</div>
<div className="stat-label">Lists</div>
</div>
<div className="stat-card">
<div className="stat-value">{stats?.total_campaigns || 0}</div>
<div className="stat-label">Campaigns</div>
</div>
<div className="stat-card">
<div className="stat-value">{stats?.total_sent || 0}</div>
<div className="stat-label">Messages Sent</div>
</div>
</div>
<div className="card">
<h2 className="card-title">Recent Campaigns</h2>
{stats?.recent_campaigns?.length > 0 ? (
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Recipients</th>
<th>Sent</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{stats.recent_campaigns.map((campaign) => (
<tr key={campaign.id}>
<td>
<Link to={`/campaigns/${campaign.id}`}>{campaign.name}</Link>
</td>
<td>
<span className={`badge badge-${campaign.status === 'done' ? 'success' : campaign.status === 'failed' ? 'danger' : 'info'}`}>
{campaign.status}
</span>
</td>
<td>{campaign.total_recipients}</td>
<td>{campaign.sent_count}</td>
<td>{new Date(campaign.created_at).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="empty-state">
<div className="empty-state-text">No campaigns yet</div>
<Link to="/campaigns" className="btn btn-primary" style={{ marginTop: '16px' }}>
Create Campaign
</Link>
</div>
)}
</div>
</div>
</div>
</>
);
};

View File

@ -0,0 +1,113 @@
import { useState } from 'react';
import { Navbar } from '../components/Navbar';
import { useToast } from '../components/Toast';
import { importsAPI } from '../api/client';
export const ImportsPage = () => {
const [uploadingExcel, setUploadingExcel] = useState(false);
const [uploadingCSV, setUploadingCSV] = useState(false);
const [summary, setSummary] = useState(null);
const { showToast } = useToast();
const handleFileUpload = async (file, type) => {
const setLoading = type === 'excel' ? setUploadingExcel : setUploadingCSV;
setLoading(true);
setSummary(null);
try {
const uploadFn = type === 'excel' ? importsAPI.uploadExcel : importsAPI.uploadCSV;
const response = await uploadFn(file);
setSummary(response.data);
showToast(`Imported ${response.data.created} contacts`, 'success');
} catch (error) {
showToast('Import failed', 'error');
} finally {
setLoading(false);
}
};
const handleGoogleAuth = async () => {
try {
const response = await importsAPI.googleAuthStart();
window.location.href = response.data.auth_url;
} catch (error) {
showToast('Failed to start Google auth', 'error');
}
};
const handleGoogleSync = async () => {
try {
const response = await importsAPI.googleSync();
showToast(`Imported ${response.data.contacts_imported} contacts from Google`, 'success');
} catch (error) {
showToast('Google sync failed', 'error');
}
};
return (
<>
<Navbar />
<div className="container">
<div className="page">
<h1 className="page-title">Import Contacts</h1>
<div className="card">
<h2 className="card-title">Excel Import</h2>
<p>Upload an Excel file (.xlsx) with columns: phone, first_name, last_name, email, opted_in</p>
<input
type="file"
accept=".xlsx,.xls"
onChange={(e) => handleFileUpload(e.target.files[0], 'excel')}
disabled={uploadingExcel}
/>
{uploadingExcel && <p>Uploading...</p>}
</div>
<div className="card">
<h2 className="card-title">CSV Import</h2>
<p>Upload a CSV file with columns: phone, first_name, last_name, email, opted_in</p>
<input
type="file"
accept=".csv"
onChange={(e) => handleFileUpload(e.target.files[0], 'csv')}
disabled={uploadingCSV}
/>
{uploadingCSV && <p>Uploading...</p>}
</div>
<div className="card">
<h2 className="card-title">Google Contacts</h2>
<p>Import contacts from your Google account</p>
<button onClick={handleGoogleAuth} className="btn btn-primary" style={{ marginRight: '12px' }}>
Connect Google Account
</button>
<button onClick={handleGoogleSync} className="btn btn-secondary">
Sync Contacts
</button>
</div>
{summary && (
<div className="card">
<h2 className="card-title">Import Summary</h2>
<table className="table">
<tbody>
<tr><td>Total Rows:</td><td>{summary.total}</td></tr>
<tr><td>Created:</td><td>{summary.created}</td></tr>
<tr><td>Updated:</td><td>{summary.updated}</td></tr>
<tr><td>Skipped:</td><td>{summary.skipped}</td></tr>
<tr><td>Invalid:</td><td>{summary.invalid}</td></tr>
</tbody>
</table>
{summary.errors.length > 0 && (
<>
<h3>Errors:</h3>
<ul>{summary.errors.map((err, idx) => <li key={idx}>{err}</li>)}</ul>
</>
)}
</div>
)}
</div>
</div>
</>
);
};

View File

@ -0,0 +1,88 @@
import { useState, useEffect } from 'react';
import { Navbar } from '../components/Navbar';
import { DataTable } from '../components/DataTable';
import { Modal } from '../components/Modal';
import { Loading } from '../components/Loading';
import { useToast } from '../components/Toast';
import { listsAPI } from '../api/client';
export const ListsPage = () => {
const [lists, setLists] = useState([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [name, setName] = useState('');
const { showToast } = useToast();
useEffect(() => {
loadLists();
}, []);
const loadLists = async () => {
try {
const response = await listsAPI.list();
setLists(response.data);
} catch (error) {
showToast('Failed to load lists', 'error');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
await listsAPI.create({ name });
showToast('List created successfully', 'success');
setShowModal(false);
setName('');
loadLists();
} catch (error) {
showToast('Failed to create list', 'error');
}
};
const handleDelete = async (id) => {
if (!confirm('Delete this list?')) return;
try {
await listsAPI.delete(id);
showToast('List deleted', 'success');
loadLists();
} catch (error) {
showToast('Failed to delete list', 'error');
}
};
const columns = [
{ header: 'Name', accessor: 'name' },
{ header: 'Members', accessor: 'member_count' },
{ header: 'Created', render: (row) => new Date(row.created_at).toLocaleDateString() },
{ header: 'Actions', render: (row) => <button onClick={() => handleDelete(row.id)} className="btn btn-danger" style={{ padding: '4px 12px' }}>Delete</button> }
];
if (loading) return <Loading />;
return (
<>
<Navbar />
<div className="container">
<div className="page">
<div className="page-header">
<h1 className="page-title">Contact Lists</h1>
<button onClick={() => setShowModal(true)} className="btn btn-primary">Create List</button>
</div>
<DataTable columns={columns} data={lists} />
</div>
</div>
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="Create List">
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">List Name</label>
<input type="text" className="form-input" value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<button type="submit" className="btn btn-primary">Create</button>
</form>
</Modal>
</>
);
};

View File

@ -0,0 +1,66 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useToast } from '../components/Toast';
export const LoginPage = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { login } = useAuth();
const { showToast } = useToast();
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
await login(email, password);
showToast('Login successful!', 'success');
navigate('/dashboard');
} catch (error) {
showToast(error.response?.data?.detail || 'Login failed', 'error');
} finally {
setLoading(false);
}
};
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="card" style={{ maxWidth: '400px', width: '90%' }}>
<h1 className="card-title" style={{ textAlign: 'center', marginBottom: '24px' }}>
📱 WhatsApp Campaign Manager
</h1>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Email</label>
<input
type="email"
className="form-input"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="form-group">
<label className="form-label">Password</label>
<input
type="password"
className="form-input"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
<p style={{ textAlign: 'center', marginTop: '16px' }}>
Don't have an account? <Link to="/register">Register</Link>
</p>
</div>
</div>
);
};

View File

@ -0,0 +1,84 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useToast } from '../components/Toast';
export const RegisterPage = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { register } = useAuth();
const { showToast } = useToast();
const handleSubmit = async (e) => {
e.preventDefault();
if (password !== confirmPassword) {
showToast('Passwords do not match', 'error');
return;
}
setLoading(true);
try {
await register(email, password);
showToast('Registration successful!', 'success');
navigate('/dashboard');
} catch (error) {
showToast(error.response?.data?.detail || 'Registration failed', 'error');
} finally {
setLoading(false);
}
};
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="card" style={{ maxWidth: '400px', width: '90%' }}>
<h1 className="card-title" style={{ textAlign: 'center', marginBottom: '24px' }}>
Create Account
</h1>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Email</label>
<input
type="email"
className="form-input"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="form-group">
<label className="form-label">Password</label>
<input
type="password"
className="form-input"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
</div>
<div className="form-group">
<label className="form-label">Confirm Password</label>
<input
type="password"
className="form-input"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
</div>
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
{loading ? 'Creating account...' : 'Register'}
</button>
</form>
<p style={{ textAlign: 'center', marginTop: '16px' }}>
Already have an account? <Link to="/login">Login</Link>
</p>
</div>
</div>
);
};

View File

@ -0,0 +1,117 @@
import { useState, useEffect } from 'react';
import { Navbar } from '../components/Navbar';
import { DataTable } from '../components/DataTable';
import { Modal } from '../components/Modal';
import { Loading } from '../components/Loading';
import { useToast } from '../components/Toast';
import { templatesAPI } from '../api/client';
export const TemplatesPage = () => {
const [templates, setTemplates] = useState([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [formData, setFormData] = useState({
name: '',
language: 'en',
body_text: '',
is_whatsapp_template: false,
provider_template_name: ''
});
const { showToast } = useToast();
useEffect(() => {
loadTemplates();
}, []);
const loadTemplates = async () => {
try {
const response = await templatesAPI.list();
setTemplates(response.data);
} catch (error) {
showToast('Failed to load templates', 'error');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
await templatesAPI.create(formData);
showToast('Template created successfully', 'success');
setShowModal(false);
setFormData({ name: '', language: 'en', body_text: '', is_whatsapp_template: false, provider_template_name: '' });
loadTemplates();
} catch (error) {
showToast('Failed to create template', 'error');
}
};
const handleDelete = async (id) => {
if (!confirm('Delete this template?')) return;
try {
await templatesAPI.delete(id);
showToast('Template deleted', 'success');
loadTemplates();
} catch (error) {
showToast('Failed to delete template', 'error');
}
};
const columns = [
{ header: 'Name', accessor: 'name' },
{ header: 'Language', accessor: 'language' },
{ header: 'WhatsApp Template', render: (row) => <span className={`badge badge-${row.is_whatsapp_template ? 'success' : 'info'}`}>{row.is_whatsapp_template ? 'Yes' : 'No'}</span> },
{ header: 'Body Preview', render: (row) => row.body_text.substring(0, 50) + '...' },
{ header: 'Actions', render: (row) => <button onClick={() => handleDelete(row.id)} className="btn btn-danger" style={{ padding: '4px 12px' }}>Delete</button> }
];
if (loading) return <Loading />;
return (
<>
<Navbar />
<div className="container">
<div className="page">
<div className="page-header">
<h1 className="page-title">Templates</h1>
<button onClick={() => setShowModal(true)} className="btn btn-primary">Create Template</button>
</div>
<DataTable columns={columns} data={templates} />
</div>
</div>
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="Create Template">
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Name</label>
<input type="text" className="form-input" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} required />
</div>
<div className="form-group">
<label className="form-label">Language</label>
<select className="form-select" value={formData.language} onChange={(e) => setFormData({ ...formData, language: e.target.value })}>
<option value="en">English</option>
<option value="es">Spanish</option>
<option value="fr">French</option>
<option value="ar">Arabic</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Body Text (use {'{{first_name}}'} for variables)</label>
<textarea className="form-textarea" value={formData.body_text} onChange={(e) => setFormData({ ...formData, body_text: e.target.value })} required placeholder="Hello {{first_name}}, welcome!" />
</div>
<div className="form-group">
<label><input type="checkbox" className="form-checkbox" checked={formData.is_whatsapp_template} onChange={(e) => setFormData({ ...formData, is_whatsapp_template: e.target.checked })} /> Approved WhatsApp Template</label>
</div>
{formData.is_whatsapp_template && (
<div className="form-group">
<label className="form-label">Provider Template Name</label>
<input type="text" className="form-input" value={formData.provider_template_name} onChange={(e) => setFormData({ ...formData, provider_template_name: e.target.value })} />
</div>
)}
<button type="submit" className="btn btn-primary">Create</button>
</form>
</Modal>
</>
);
};

View File

@ -0,0 +1,111 @@
/* Dark Mode CSS */
:root {
--primary: #10b981;
--primary-dark: #059669;
--danger: #ef4444;
--warning: #f59e0b;
--info: #3b82f6;
--success: #10b981;
/* Light mode colors */
--bg-body: #f5f5f5;
--bg-page: #ffffff;
--bg-card: #ffffff;
--bg-input: #ffffff;
--text-primary: #333333;
--text-secondary: #666666;
--border-color: #e0e0e0;
--shadow: rgba(0,0,0,0.1);
}
[data-theme="dark"] {
--bg-body: #121212;
--bg-page: #1e1e1e;
--bg-card: #252525;
--bg-input: #2a2a2a;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--border-color: #404040;
--shadow: rgba(0,0,0,0.3);
}
body {
background-color: var(--bg-body) !important;
color: var(--text-primary) !important;
transition: background-color 0.3s, color 0.3s;
}
.page {
background: var(--bg-page) !important;
box-shadow: 0 2px 4px var(--shadow) !important;
}
.card {
background: var(--bg-card) !important;
border: 1px solid var(--border-color) !important;
}
.page-title {
color: var(--text-primary) !important;
}
.form-input, .form-select, textarea {
background-color: var(--bg-input) !important;
color: var(--text-primary) !important;
border-color: var(--border-color) !important;
}
.table {
color: var(--text-primary) !important;
}
.table th {
background-color: var(--bg-card) !important;
color: var(--text-secondary) !important;
border-color: var(--border-color) !important;
}
.table td {
border-color: var(--border-color) !important;
}
.modal-overlay {
background: rgba(0, 0, 0, 0.7) !important;
}
.modal {
background: var(--bg-page) !important;
color: var(--text-primary) !important;
}
.form-label {
color: var(--text-primary) !important;
}
.stat-card {
background: var(--bg-card) !important;
border: 1px solid var(--border-color) !important;
}
.stat-label {
color: var(--text-secondary) !important;
}
/* Dark mode toggle button */
.dark-mode-toggle {
background: var(--bg-card);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 20px;
transition: all 0.3s;
display: flex;
align-items: center;
gap: 8px;
}
.dark-mode-toggle:hover {
background: var(--bg-input);
}

View File

@ -0,0 +1,405 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
}
#root {
min-height: 100vh;
}
/* Layout */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.page {
background: white;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-title {
font-size: 24px;
font-weight: 600;
color: #333;
}
/* Buttons */
.btn {
padding: 10px 20px;
border-radius: 6px;
border: none;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background-color: #25D366;
color: white;
}
.btn-primary:hover {
background-color: #20BA5A;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5a6268;
}
.btn-danger {
background-color: #dc3545;
color: white;
}
.btn-danger:hover {
background-color: #c82333;
}
.btn-outline {
background-color: transparent;
border: 1px solid #25D366;
color: #25D366;
}
.btn-outline:hover {
background-color: #25D366;
color: white;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Forms */
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
margin-bottom: 6px;
font-weight: 500;
color: #333;
}
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
outline: none;
border-color: #25D366;
}
.form-textarea {
min-height: 100px;
resize: vertical;
}
.form-checkbox {
margin-right: 8px;
}
.form-error {
color: #dc3545;
font-size: 12px;
margin-top: 4px;
}
/* Tables */
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
.table th {
background-color: #f8f9fa;
font-weight: 600;
color: #333;
}
.table tr:hover {
background-color: #f8f9fa;
}
/* Cards */
.card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 16px;
}
.card-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
color: #333;
}
/* Navigation */
.navbar {
background-color: #25D366;
padding: 16px 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.navbar-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar-brand {
color: white;
font-size: 20px;
font-weight: 600;
text-decoration: none;
}
.navbar-nav {
display: flex;
gap: 20px;
list-style: none;
}
.navbar-link {
color: white;
text-decoration: none;
font-weight: 500;
transition: opacity 0.2s;
}
.navbar-link:hover {
opacity: 0.8;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 8px;
padding: 24px;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.modal-title {
font-size: 20px;
font-weight: 600;
color: #333;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
}
/* Toast */
.toast {
position: fixed;
top: 20px;
right: 20px;
padding: 16px 20px;
border-radius: 6px;
color: white;
font-weight: 500;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
z-index: 2000;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.toast-success {
background-color: #28a745;
}
.toast-error {
background-color: #dc3545;
}
.toast-info {
background-color: #17a2b8;
}
/* Loading */
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #25D366;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Badge */
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.badge-success {
background-color: #d4edda;
color: #155724;
}
.badge-warning {
background-color: #fff3cd;
color: #856404;
}
.badge-danger {
background-color: #f8d7da;
color: #721c24;
}
.badge-info {
background-color: #d1ecf1;
color: #0c5460;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.stat-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: #25D366;
margin-bottom: 8px;
}
.stat-label {
color: #666;
font-size: 14px;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #666;
}
.empty-state-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state-text {
font-size: 18px;
margin-bottom: 8px;
}
.empty-state-subtext {
font-size: 14px;
color: #999;
}

16
frontend/vite.config.js Normal file
View File

@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
target: process.env.VITE_API_URL || 'http://localhost:8000',
changeOrigin: true
}
}
}
})

10
trigger-worker.sh Normal file
View File

@ -0,0 +1,10 @@
#!/bin/bash
# Worker trigger script
# This script calls the worker endpoint to process pending campaign jobs
while true; do
echo "[$(date)] Triggering worker..."
curl -X POST http://localhost:8000/api/workers/tick
echo ""
sleep 60 # Wait 60 seconds before next run
done