commit d7185786e724cc3e45eac1c403a69a2e11a72fd1 Author: dvirlabs Date: Tue Jan 13 05:17:57 2026 +0200 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ef183e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/FIXING_ERRORS.md b/FIXING_ERRORS.md new file mode 100644 index 0000000..b71a9c8 --- /dev/null +++ b/FIXING_ERRORS.md @@ -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: + ``` + +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! โœ… diff --git a/README.md b/README.md new file mode 100644 index 0000000..f0552c5 --- /dev/null +++ b/README.md @@ -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** diff --git a/TELEGRAM_TESTING.md b/TELEGRAM_TESTING.md new file mode 100644 index 0000000..239a9f2 --- /dev/null +++ b/TELEGRAM_TESTING.md @@ -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 ``): + ``` + https://api.telegram.org/bot/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! ๐Ÿš€** diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..af181e5 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..58100ec --- /dev/null +++ b/backend/Dockerfile @@ -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 diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..299d321 --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..cc5d24d --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..55df286 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/001_initial_migration.py b/backend/alembic/versions/001_initial_migration.py new file mode 100644 index 0000000..7905410 --- /dev/null +++ b/backend/alembic/versions/001_initial_migration.py @@ -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') diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..79f265f --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# Empty file to make app a package diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..932b798 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +# Empty file diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..3eefac4 --- /dev/null +++ b/backend/app/api/auth.py @@ -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 diff --git a/backend/app/api/campaigns.py b/backend/app/api/campaigns.py new file mode 100644 index 0000000..d4440b0 --- /dev/null +++ b/backend/app/api/campaigns.py @@ -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 diff --git a/backend/app/api/contacts.py b/backend/app/api/contacts.py new file mode 100644 index 0000000..85732a2 --- /dev/null +++ b/backend/app/api/contacts.py @@ -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 diff --git a/backend/app/api/google.py b/backend/app/api/google.py new file mode 100644 index 0000000..c2f0ed1 --- /dev/null +++ b/backend/app/api/google.py @@ -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)}") diff --git a/backend/app/api/imports.py b/backend/app/api/imports.py new file mode 100644 index 0000000..1771b98 --- /dev/null +++ b/backend/app/api/imports.py @@ -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)}") diff --git a/backend/app/api/lists.py b/backend/app/api/lists.py new file mode 100644 index 0000000..b7d53b6 --- /dev/null +++ b/backend/app/api/lists.py @@ -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 + ] diff --git a/backend/app/api/stats.py b/backend/app/api/stats.py new file mode 100644 index 0000000..a4a83b2 --- /dev/null +++ b/backend/app/api/stats.py @@ -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 + } diff --git a/backend/app/api/templates.py b/backend/app/api/templates.py new file mode 100644 index 0000000..23a64e9 --- /dev/null +++ b/backend/app/api/templates.py @@ -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 diff --git a/backend/app/api/webhooks.py b/backend/app/api/webhooks.py new file mode 100644 index 0000000..d67509c --- /dev/null +++ b/backend/app/api/webhooks.py @@ -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)} diff --git a/backend/app/api/workers.py b/backend/app/api/workers.py new file mode 100644 index 0000000..303f645 --- /dev/null +++ b/backend/app/api/workers.py @@ -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 + } diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..932b798 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1 @@ +# Empty file diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..e240a94 --- /dev/null +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py new file mode 100644 index 0000000..01ca1e9 --- /dev/null +++ b/backend/app/core/deps.py @@ -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 diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..886c049 --- /dev/null +++ b/backend/app/core/security.py @@ -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 diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..b91c411 --- /dev/null +++ b/backend/app/db/base.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..dbcba7b --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..224407f --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/campaign.py b/backend/app/models/campaign.py new file mode 100644 index 0000000..a4756b4 --- /dev/null +++ b/backend/app/models/campaign.py @@ -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()) diff --git a/backend/app/models/contact.py b/backend/app/models/contact.py new file mode 100644 index 0000000..ecf55ac --- /dev/null +++ b/backend/app/models/contact.py @@ -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()) diff --git a/backend/app/models/google_token.py b/backend/app/models/google_token.py new file mode 100644 index 0000000..9495961 --- /dev/null +++ b/backend/app/models/google_token.py @@ -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()) diff --git a/backend/app/models/job.py b/backend/app/models/job.py new file mode 100644 index 0000000..2faaa6a --- /dev/null +++ b/backend/app/models/job.py @@ -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()) diff --git a/backend/app/models/list.py b/backend/app/models/list.py new file mode 100644 index 0000000..5d35eb4 --- /dev/null +++ b/backend/app/models/list.py @@ -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) diff --git a/backend/app/models/send_log.py b/backend/app/models/send_log.py new file mode 100644 index 0000000..4a474bd --- /dev/null +++ b/backend/app/models/send_log.py @@ -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) diff --git a/backend/app/models/template.py b/backend/app/models/template.py new file mode 100644 index 0000000..ace1bb8 --- /dev/null +++ b/backend/app/models/template.py @@ -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()) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..c26f0bb --- /dev/null +++ b/backend/app/models/user.py @@ -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()) diff --git a/backend/app/providers/__init__.py b/backend/app/providers/__init__.py new file mode 100644 index 0000000..8da136f --- /dev/null +++ b/backend/app/providers/__init__.py @@ -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}") diff --git a/backend/app/providers/base.py b/backend/app/providers/base.py new file mode 100644 index 0000000..bc8bc35 --- /dev/null +++ b/backend/app/providers/base.py @@ -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 diff --git a/backend/app/providers/mock.py b/backend/app/providers/mock.py new file mode 100644 index 0000000..f0ded7c --- /dev/null +++ b/backend/app/providers/mock.py @@ -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" diff --git a/backend/app/providers/telegram.py b/backend/app/providers/telegram.py new file mode 100644 index 0000000..6ae3de3 --- /dev/null +++ b/backend/app/providers/telegram.py @@ -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/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" diff --git a/backend/app/providers/whatsapp_cloud.py b/backend/app/providers/whatsapp_cloud.py new file mode 100644 index 0000000..8db4b38 --- /dev/null +++ b/backend/app/providers/whatsapp_cloud.py @@ -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" diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..932b798 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ +# Empty file diff --git a/backend/app/schemas/campaign.py b/backend/app/schemas/campaign.py new file mode 100644 index 0000000..b1195e3 --- /dev/null +++ b/backend/app/schemas/campaign.py @@ -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] diff --git a/backend/app/schemas/contact.py b/backend/app/schemas/contact.py new file mode 100644 index 0000000..1e4d747 --- /dev/null +++ b/backend/app/schemas/contact.py @@ -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 diff --git a/backend/app/schemas/imports.py b/backend/app/schemas/imports.py new file mode 100644 index 0000000..e7529f5 --- /dev/null +++ b/backend/app/schemas/imports.py @@ -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 diff --git a/backend/app/schemas/list.py b/backend/app/schemas/list.py new file mode 100644 index 0000000..8683926 --- /dev/null +++ b/backend/app/schemas/list.py @@ -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] diff --git a/backend/app/schemas/template.py b/backend/app/schemas/template.py new file mode 100644 index 0000000..36be7ce --- /dev/null +++ b/backend/app/schemas/template.py @@ -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 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..d495b61 --- /dev/null +++ b/backend/app/schemas/user.py @@ -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 diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..932b798 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +# Empty file diff --git a/backend/app/services/messaging.py b/backend/app/services/messaging.py new file mode 100644 index 0000000..48e10b9 --- /dev/null +++ b/backend/app/services/messaging.py @@ -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() + } diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..932b798 --- /dev/null +++ b/backend/app/utils/__init__.py @@ -0,0 +1 @@ +# Empty file diff --git a/backend/app/utils/encryption.py b/backend/app/utils/encryption.py new file mode 100644 index 0000000..f72fe07 --- /dev/null +++ b/backend/app/utils/encryption.py @@ -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() diff --git a/backend/app/utils/phone.py b/backend/app/utils/phone.py new file mode 100644 index 0000000..11aa7fb --- /dev/null +++ b/backend/app/utils/phone.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..5c4ed71 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..932b798 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +# Empty file diff --git a/backend/tests/test_phone.py b/backend/tests/test_phone.py new file mode 100644 index 0000000..64af7d0 --- /dev/null +++ b/backend/tests/test_phone.py @@ -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" diff --git a/backend/tests/test_providers.py b/backend/tests/test_providers.py new file mode 100644 index 0000000..3939656 --- /dev/null +++ b/backend/tests/test_providers.py @@ -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" diff --git a/contacts_demo.xlsx b/contacts_demo.xlsx new file mode 100644 index 0000000..3bad78a Binary files /dev/null and b/contacts_demo.xlsx differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3e9472a --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..5934e2e --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +VITE_API_URL=http://localhost:8000 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..4b1f1ce --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:18-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..20ae00f --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + WhatsApp Campaign Manager + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..4e26a3f --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..ef79d38 --- /dev/null +++ b/frontend/src/App.jsx @@ -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 ; + if (!user) return ; + + return children; +}; + +function AppRoutes() { + const { ToastContainer } = useToast(); + + return ( + <> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); +} + +function App() { + useEffect(() => { + // Load theme from localStorage + const savedTheme = localStorage.getItem('theme') || 'light'; + document.documentElement.setAttribute('data-theme', savedTheme); + }, []); + + return ( + + + + + + ); +} + +export default App; diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..a3a0cf9 --- /dev/null +++ b/frontend/src/api/client.js @@ -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') +}; diff --git a/frontend/src/components/DataTable.jsx b/frontend/src/components/DataTable.jsx new file mode 100644 index 0000000..a2b1b53 --- /dev/null +++ b/frontend/src/components/DataTable.jsx @@ -0,0 +1,37 @@ +export const DataTable = ({ columns, data, onRowClick }) => { + if (!data || data.length === 0) { + return ( +
+
๐Ÿ“‹
+
No data available
+
+ ); + } + + return ( + + + + {columns.map((col, idx) => ( + + ))} + + + + {data.map((row, rowIdx) => ( + onRowClick && onRowClick(row)} + style={{ cursor: onRowClick ? 'pointer' : 'default' }} + > + {columns.map((col, colIdx) => ( + + ))} + + ))} + +
{col.header}
+ {col.render ? col.render(row) : row[col.accessor]} +
+ ); +}; diff --git a/frontend/src/components/Loading.jsx b/frontend/src/components/Loading.jsx new file mode 100644 index 0000000..607ae0a --- /dev/null +++ b/frontend/src/components/Loading.jsx @@ -0,0 +1,8 @@ +export const Loading = () => { + return ( +
+
+

Loading...

+
+ ); +}; diff --git a/frontend/src/components/Modal.jsx b/frontend/src/components/Modal.jsx new file mode 100644 index 0000000..0c2937b --- /dev/null +++ b/frontend/src/components/Modal.jsx @@ -0,0 +1,17 @@ +export const Modal = ({ isOpen, onClose, title, children }) => { + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> +
+

{title}

+ +
+
{children}
+
+
+ ); +}; diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx new file mode 100644 index 0000000..e421299 --- /dev/null +++ b/frontend/src/components/Navbar.jsx @@ -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 ( + + ); +}; diff --git a/frontend/src/components/Toast.jsx b/frontend/src/components/Toast.jsx new file mode 100644 index 0000000..f91c610 --- /dev/null +++ b/frontend/src/components/Toast.jsx @@ -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 ( +
+ {message} +
+ ); +}; + +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) => ( + removeToast(toast.id)} + /> + ))} + + ); + + return { showToast, ToastContainer }; +}; diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx new file mode 100644 index 0000000..477af25 --- /dev/null +++ b/frontend/src/contexts/AuthContext.jsx @@ -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 ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within AuthProvider'); + } + return context; +}; diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..51a8c58 --- /dev/null +++ b/frontend/src/main.jsx @@ -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( + + + , +) diff --git a/frontend/src/pages/CampaignDetailPage.jsx b/frontend/src/pages/CampaignDetailPage.jsx new file mode 100644 index 0000000..ee54b5a --- /dev/null +++ b/frontend/src/pages/CampaignDetailPage.jsx @@ -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) => {row.status} }, + { header: 'Error', render: (row) => row.last_error ? ( +
+
{row.last_error}
+ {row.last_error.includes('not opted in') && ( +
๐Ÿ’ก Fix: Edit contact and check "Opted In"
+ )} + {row.last_error.includes('conversation window') && ( +
๐Ÿ’ก Fix: Check "Approved WhatsApp Template" on template
+ )} +
+ ) : '-' }, + { header: 'Message ID', render: (row) => row.provider_message_id ? {row.provider_message_id.substring(0, 20)}... : '-' }, + { header: 'Updated', render: (row) => new Date(row.updated_at).toLocaleString() } + ]; + + if (loading) return ; + if (!campaign) return
Campaign not found
; + + const stats = recipients.reduce((acc, r) => { + acc[r.status] = (acc[r.status] || 0) + 1; + return acc; + }, {}); + + return ( + <> + +
+
+
+
+ +

{campaign.name}

+
+
+ {(campaign.status === 'done' || campaign.status === 'failed') && ( + + )} + + {campaign.status} + +
+
+ + {stats.failed > 0 && ( +
+
+ โš ๏ธ {stats.failed} message(s) failed +
+
+ Common fixes: +
    +
  • Go to Contacts โ†’ Edit contact โ†’ Check "Opted In"
  • +
  • Go to Templates โ†’ Edit template โ†’ Check "Approved WhatsApp Template"
  • +
  • For testing: Use Telegram provider (see TELEGRAM_TESTING.md)
  • +
+
+
+ )} + +
+
+
{stats.sent || 0}
+
Sent
+
+
+
{stats.delivered || 0}
+
Delivered
+
+
+
{stats.read || 0}
+
Read
+
+
+
{stats.pending || 0}
+
Pending
+
+
+
{stats.failed || 0}
+
Failed
+
+
+ +

Recipients

+ +
+
+ + ); +}; diff --git a/frontend/src/pages/CampaignsPage.jsx b/frontend/src/pages/CampaignsPage.jsx new file mode 100644 index 0000000..a75909e --- /dev/null +++ b/frontend/src/pages/CampaignsPage.jsx @@ -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) => {row.status} }, + { header: 'Created', render: (row) => new Date(row.created_at).toLocaleDateString() }, + { header: 'Actions', render: (row) => ( + <> + {row.status === 'draft' && } + + + + )} + ]; + + if (loading) return ; + + return ( + <> + +
+
+
+

Campaigns

+ +
+ +
+
+ + setShowModal(false)} title="Create Campaign"> +
+
+ + setFormData({ ...formData, name: e.target.value })} required /> +
+
+ + +
+
+ + +
+ +
+
+ + ); +}; diff --git a/frontend/src/pages/ContactsPage.jsx b/frontend/src/pages/ContactsPage.jsx new file mode 100644 index 0000000..7b077ac --- /dev/null +++ b/frontend/src/pages/ContactsPage.jsx @@ -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: { + if (e.target.checked) { + setSelectedContacts(contacts.map(c => c.id)); + } else { + setSelectedContacts([]); + } + }} />, + render: (row) => ( + 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) => {row.opted_in ? 'Yes' : 'No'} }, + { header: 'Source', accessor: 'source' }, + { header: 'Actions', render: (row) => } + ]; + + if (loading) return ; + + return ( + <> + +
+
+
+

Contacts

+
+ {selectedContacts.length > 0 && ( + + )} + +
+
+ +
+
+ + setShowModal(false)} title="Add Contact"> +
+
+ + setFormData({ ...formData, phone_e164: e.target.value })} required placeholder="+1234567890" /> +
+
+ + setFormData({ ...formData, first_name: e.target.value })} /> +
+
+ + setFormData({ ...formData, last_name: e.target.value })} /> +
+
+ + setFormData({ ...formData, email: e.target.value })} /> +
+
+ +
+ +
+
+ + setShowAddToListModal(false)} title="Add Contacts to List"> +
+
+ + +
+

+ Adding {selectedContacts.length} contact(s) to the selected list +

+ +
+
+ + ); +}; diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx new file mode 100644 index 0000000..94cc105 --- /dev/null +++ b/frontend/src/pages/DashboardPage.jsx @@ -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 ; + + return ( + <> + +
+
+
+

Dashboard

+ +
+ +
+ ๐Ÿ’ก Tip: After clicking "Send" on a campaign, click "Process Campaign Jobs" button above to actually send the messages. + Campaign status will change from sending to done. +
+ +
+
+
{stats?.total_contacts || 0}
+
Total Contacts
+
+
+
{stats?.opted_in_contacts || 0}
+
Opted In
+
+
+
{stats?.total_lists || 0}
+
Lists
+
+
+
{stats?.total_campaigns || 0}
+
Campaigns
+
+
+
{stats?.total_sent || 0}
+
Messages Sent
+
+
+ +
+

Recent Campaigns

+ {stats?.recent_campaigns?.length > 0 ? ( + + + + + + + + + + + + {stats.recent_campaigns.map((campaign) => ( + + + + + + + + ))} + +
NameStatusRecipientsSentCreated
+ {campaign.name} + + + {campaign.status} + + {campaign.total_recipients}{campaign.sent_count}{new Date(campaign.created_at).toLocaleDateString()}
+ ) : ( +
+
No campaigns yet
+ + Create Campaign + +
+ )} +
+
+
+ + ); +}; diff --git a/frontend/src/pages/ImportsPage.jsx b/frontend/src/pages/ImportsPage.jsx new file mode 100644 index 0000000..3840efb --- /dev/null +++ b/frontend/src/pages/ImportsPage.jsx @@ -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 ( + <> + +
+
+

Import Contacts

+ +
+

Excel Import

+

Upload an Excel file (.xlsx) with columns: phone, first_name, last_name, email, opted_in

+ handleFileUpload(e.target.files[0], 'excel')} + disabled={uploadingExcel} + /> + {uploadingExcel &&

Uploading...

} +
+ +
+

CSV Import

+

Upload a CSV file with columns: phone, first_name, last_name, email, opted_in

+ handleFileUpload(e.target.files[0], 'csv')} + disabled={uploadingCSV} + /> + {uploadingCSV &&

Uploading...

} +
+ +
+

Google Contacts

+

Import contacts from your Google account

+ + +
+ + {summary && ( +
+

Import Summary

+ + + + + + + + +
Total Rows:{summary.total}
Created:{summary.created}
Updated:{summary.updated}
Skipped:{summary.skipped}
Invalid:{summary.invalid}
+ {summary.errors.length > 0 && ( + <> +

Errors:

+
    {summary.errors.map((err, idx) =>
  • {err}
  • )}
+ + )} +
+ )} +
+
+ + ); +}; diff --git a/frontend/src/pages/ListsPage.jsx b/frontend/src/pages/ListsPage.jsx new file mode 100644 index 0000000..204a67a --- /dev/null +++ b/frontend/src/pages/ListsPage.jsx @@ -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) => } + ]; + + if (loading) return ; + + return ( + <> + +
+
+
+

Contact Lists

+ +
+ +
+
+ + setShowModal(false)} title="Create List"> +
+
+ + setName(e.target.value)} required /> +
+ +
+
+ + ); +}; diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx new file mode 100644 index 0000000..87872e9 --- /dev/null +++ b/frontend/src/pages/LoginPage.jsx @@ -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 ( +
+
+

+ ๐Ÿ“ฑ WhatsApp Campaign Manager +

+
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ +
+

+ Don't have an account? Register +

+
+
+ ); +}; diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx new file mode 100644 index 0000000..bbe44ac --- /dev/null +++ b/frontend/src/pages/RegisterPage.jsx @@ -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 ( +
+
+

+ Create Account +

+
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + minLength={6} + /> +
+
+ + setConfirmPassword(e.target.value)} + required + /> +
+ +
+

+ Already have an account? Login +

+
+
+ ); +}; diff --git a/frontend/src/pages/TemplatesPage.jsx b/frontend/src/pages/TemplatesPage.jsx new file mode 100644 index 0000000..bfb71f5 --- /dev/null +++ b/frontend/src/pages/TemplatesPage.jsx @@ -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) => {row.is_whatsapp_template ? 'Yes' : 'No'} }, + { header: 'Body Preview', render: (row) => row.body_text.substring(0, 50) + '...' }, + { header: 'Actions', render: (row) => } + ]; + + if (loading) return ; + + return ( + <> + +
+
+
+

Templates

+ +
+ +
+
+ + setShowModal(false)} title="Create Template"> +
+
+ + setFormData({ ...formData, name: e.target.value })} required /> +
+
+ + +
+
+ +