First commit
This commit is contained in:
commit
d7185786e7
55
.gitignore
vendored
Normal file
55
.gitignore
vendored
Normal file
@ -0,0 +1,55 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
.venv
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
dist/
|
||||
.cache/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Coverage
|
||||
htmlcov/
|
||||
.coverage
|
||||
.coverage.*
|
||||
coverage.xml
|
||||
*.cover
|
||||
68
FIXING_ERRORS.md
Normal file
68
FIXING_ERRORS.md
Normal file
@ -0,0 +1,68 @@
|
||||
# Fixing Campaign Errors - Quick Guide
|
||||
|
||||
## Why Are Messages Failing?
|
||||
|
||||
Your campaign shows failed messages because of **compliance checks** that protect you from sending spam. This is a GOOD thing! 🛡️
|
||||
|
||||
## Common Errors & Fixes
|
||||
|
||||
### 1. "Contact not opted in; must use approved template"
|
||||
|
||||
**Problem**: Contact hasn't given permission to receive messages.
|
||||
|
||||
**Fix Option A** (Recommended for testing):
|
||||
1. Go to **Contacts** page
|
||||
2. Find the contact(s) that failed
|
||||
3. Click **Edit** (you'll need to add this feature OR recreate contact)
|
||||
4. Check the **"Opted In"** checkbox ✅
|
||||
5. Save
|
||||
|
||||
**Fix Option B**: Use approved WhatsApp template (see #2 below)
|
||||
|
||||
### 2. "No conversation window; must use approved template"
|
||||
|
||||
**Problem**: WhatsApp requires approved templates for cold outreach.
|
||||
|
||||
**Fix**: Mark your template as approved:
|
||||
1. Go to **Templates** page
|
||||
2. Edit your template
|
||||
3. Check **"Approved WhatsApp Template"** checkbox ✅
|
||||
4. Save
|
||||
|
||||
**Note**: In production, you'd actually submit templates to Meta for approval. For testing, just check this box.
|
||||
|
||||
### 3. Using Telegram for Easy Testing
|
||||
|
||||
Instead of dealing with WhatsApp compliance, use Telegram (100% free!):
|
||||
|
||||
1. Set in `docker-compose.yml`:
|
||||
```yaml
|
||||
WHATSAPP_PROVIDER: telegram
|
||||
TELEGRAM_BOT_TOKEN: <your-bot-token>
|
||||
```
|
||||
|
||||
2. Follow **TELEGRAM_TESTING.md** guide
|
||||
|
||||
3. Add contacts with Telegram user IDs instead of phone numbers
|
||||
|
||||
4. Messages will actually be delivered to Telegram! 📱
|
||||
|
||||
## Quick Test Checklist
|
||||
|
||||
Before sending campaigns:
|
||||
|
||||
- [ ] All contacts have **"Opted In"** checked
|
||||
- [ ] Template has **"Approved WhatsApp Template"** checked (if not using Telegram)
|
||||
- [ ] Contacts are in a list
|
||||
- [ ] Campaign created with that list and template
|
||||
- [ ] Worker triggered after clicking "Send"
|
||||
|
||||
## Production Note
|
||||
|
||||
In production with real WhatsApp:
|
||||
- Only send to contacts who explicitly opted in (GDPR compliance)
|
||||
- Submit templates to Meta for approval (takes 24-48 hours)
|
||||
- Use approved templates for first contact
|
||||
- Free-form messages only for existing conversations
|
||||
|
||||
For now, just check the boxes for testing! ✅
|
||||
441
README.md
Normal file
441
README.md
Normal file
@ -0,0 +1,441 @@
|
||||
# WhatsApp Campaign Manager
|
||||
|
||||
A production-quality monorepo application for managing WhatsApp marketing campaigns with full compliance guardrails, contact management, and multi-provider messaging support.
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
### Contact Management
|
||||
- **Import from Multiple Sources**: Excel (.xlsx), CSV, and Google Contacts
|
||||
- **Phone Number Normalization**: Automatic E.164 format conversion
|
||||
- **Tag & Segment**: Organize contacts with tags and lists
|
||||
- **Opt-in Management**: Track opt-in status for compliance
|
||||
- **DND List**: Global do-not-disturb blacklist
|
||||
|
||||
### Campaign Management
|
||||
- **Template System**: Create reusable message templates with variables ({{first_name}}, etc.)
|
||||
- **WhatsApp Templates**: Support for approved WhatsApp Business templates
|
||||
- **Preview Recipients**: See eligible contacts before sending
|
||||
- **Real-time Tracking**: Monitor delivery statuses (sent, delivered, read, failed)
|
||||
- **Batch Processing**: Automatic batching with rate limiting
|
||||
|
||||
### Compliance & Safety
|
||||
- ✅ **Opt-in Enforcement**: Only send to opted-in contacts
|
||||
- ✅ **DND List**: Global blacklist that blocks all sends
|
||||
- ✅ **Rate Limiting**: Per-minute and daily limits
|
||||
- ✅ **Template Enforcement**: Approved templates required for cold outreach
|
||||
- ✅ **Conversation Window**: Free-form messages only when window is open
|
||||
- ✅ **Audit Logging**: Every send attempt is logged
|
||||
- ✅ **No Unofficial Automation**: Uses official WhatsApp Cloud API only
|
||||
|
||||
### Technical Features
|
||||
- **Provider Abstraction**: Easy switching between WhatsApp providers
|
||||
- **Mock Provider**: Local testing without external API calls
|
||||
- **WhatsApp Cloud API**: Production-ready Meta integration
|
||||
- **Webhook Handler**: Automatic status updates from provider
|
||||
- **Background Jobs**: Async processing with retry logic
|
||||
- **JWT Authentication**: Secure user sessions
|
||||
- **Role-Based Access**: Admin and user roles
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
```
|
||||
/whats-sender/
|
||||
├── backend/ # FastAPI Python backend
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # REST API endpoints
|
||||
│ │ ├── core/ # Config, security, dependencies
|
||||
│ │ ├── db/ # Database connection
|
||||
│ │ ├── models/ # SQLAlchemy models
|
||||
│ │ ├── schemas/ # Pydantic schemas
|
||||
│ │ ├── services/ # Business logic
|
||||
│ │ ├── providers/ # WhatsApp provider abstraction
|
||||
│ │ ├── utils/ # Helper functions
|
||||
│ │ └── main.py # FastAPI app
|
||||
│ ├── alembic/ # Database migrations
|
||||
│ ├── tests/ # Unit tests
|
||||
│ └── requirements.txt
|
||||
├── frontend/ # React + Vite frontend
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API client
|
||||
│ │ ├── components/ # Reusable UI components
|
||||
│ │ ├── contexts/ # React contexts
|
||||
│ │ ├── pages/ # Page components
|
||||
│ │ ├── styles/ # CSS
|
||||
│ │ └── App.jsx
|
||||
│ └── package.json
|
||||
├── docker-compose.yml # Docker orchestration
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 📋 Prerequisites
|
||||
|
||||
### For WhatsApp Cloud API (Production)
|
||||
1. **Facebook Business Account**: Create at [business.facebook.com](https://business.facebook.com)
|
||||
2. **WhatsApp Business API Access**: Apply through Meta
|
||||
3. **Phone Number**: Verified business phone number
|
||||
4. **Access Token**: Long-lived access token from Facebook
|
||||
5. **Template Approval**: Pre-approve message templates in Business Manager
|
||||
|
||||
### For Google Contacts Import
|
||||
1. **Google Cloud Project**: Create at [console.cloud.google.com](https://console.cloud.google.com)
|
||||
2. **Enable People API**: In APIs & Services
|
||||
3. **OAuth 2.0 Credentials**: Create Web Application credentials
|
||||
4. **Authorized Redirect URI**: Add `http://localhost:8000/api/imports/google/callback`
|
||||
|
||||
### For Local Development
|
||||
- Docker & Docker Compose
|
||||
- OR: Python 3.11+, Node.js 18+, PostgreSQL 15+
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Clone and Configure
|
||||
|
||||
```bash
|
||||
cd whats-sender
|
||||
|
||||
# Backend environment
|
||||
cp backend/.env.example backend/.env
|
||||
# Edit backend/.env with your credentials
|
||||
|
||||
# Frontend environment
|
||||
cp frontend/.env.example frontend/.env
|
||||
```
|
||||
|
||||
### 2. Start with Docker Compose
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Services will be available at:
|
||||
- **Frontend**: http://localhost:5173
|
||||
- **Backend API**: http://localhost:8000
|
||||
- **API Docs**: http://localhost:8000/docs
|
||||
- **PostgreSQL**: localhost:5432
|
||||
|
||||
### 3. Initialize Database
|
||||
|
||||
```bash
|
||||
# Run migrations
|
||||
docker-compose exec backend alembic upgrade head
|
||||
```
|
||||
|
||||
### 4. Create First User
|
||||
|
||||
Visit http://localhost:5173/register and create an account.
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### Backend Environment Variables
|
||||
|
||||
```bash
|
||||
# Database
|
||||
DATABASE_URL=postgresql://whatssender:whatssender123@postgres:5432/whatssender
|
||||
|
||||
# JWT Authentication
|
||||
JWT_SECRET=your-secret-key-min-32-chars
|
||||
JWT_ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=10080 # 7 days
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
|
||||
|
||||
# Google OAuth (Optional)
|
||||
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=your-client-secret
|
||||
GOOGLE_REDIRECT_URI=http://localhost:8000/api/imports/google/callback
|
||||
# Generate: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
GOOGLE_TOKEN_ENCRYPTION_KEY=your-fernet-key-base64
|
||||
|
||||
# WhatsApp Provider
|
||||
WHATSAPP_PROVIDER=mock # or "cloud" for production
|
||||
WHATSAPP_CLOUD_ACCESS_TOKEN=your-meta-access-token
|
||||
WHATSAPP_CLOUD_PHONE_NUMBER_ID=your-phone-number-id
|
||||
WHATSAPP_WEBHOOK_VERIFY_TOKEN=your-random-token
|
||||
|
||||
# Rate Limiting
|
||||
MAX_MESSAGES_PER_MINUTE=20
|
||||
BATCH_SIZE=10
|
||||
DAILY_LIMIT_PER_CAMPAIGN=1000
|
||||
```
|
||||
|
||||
### Frontend Environment Variables
|
||||
|
||||
```bash
|
||||
VITE_API_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
## 📱 WhatsApp Cloud API Setup
|
||||
|
||||
### 1. Get Access Token
|
||||
|
||||
1. Go to [developers.facebook.com](https://developers.facebook.com)
|
||||
2. Create/Select your App
|
||||
3. Add WhatsApp product
|
||||
4. Go to API Setup → Get a token
|
||||
5. Copy the **Permanent Access Token** (generate from temporary token)
|
||||
|
||||
### 2. Get Phone Number ID
|
||||
|
||||
In the same API Setup page, copy your **Phone Number ID**.
|
||||
|
||||
### 3. Register Webhook
|
||||
|
||||
1. Go to Configuration → Webhooks
|
||||
2. Click "Edit"
|
||||
3. Callback URL: `https://your-domain.com/api/webhooks/whatsapp`
|
||||
4. Verify Token: Same as `WHATSAPP_WEBHOOK_VERIFY_TOKEN`
|
||||
5. Subscribe to: `messages` field
|
||||
|
||||
### 4. Create Message Templates
|
||||
|
||||
1. Go to WhatsApp Manager → Message Templates
|
||||
2. Create templates following Meta guidelines
|
||||
3. Wait for approval (usually 24-48 hours)
|
||||
4. Use template name in app's `provider_template_name` field
|
||||
|
||||
## 🔧 Development
|
||||
|
||||
### Backend Development
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Create .env file
|
||||
cp .env.example .env
|
||||
|
||||
# Run migrations
|
||||
alembic upgrade head
|
||||
|
||||
# Start server
|
||||
uvicorn app.main:app --reload
|
||||
|
||||
# Run tests
|
||||
pytest
|
||||
```
|
||||
|
||||
### Frontend Development
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start dev server
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Running Workers
|
||||
|
||||
The worker processes background jobs (campaign sending). For development:
|
||||
|
||||
```bash
|
||||
# Manual trigger (call this endpoint periodically)
|
||||
curl -X POST http://localhost:8000/api/workers/tick
|
||||
```
|
||||
|
||||
For production, set up a cron job:
|
||||
|
||||
```bash
|
||||
*/1 * * * * curl -X POST http://localhost:8000/api/workers/tick
|
||||
```
|
||||
|
||||
Or use Celery (advanced):
|
||||
```bash
|
||||
# Install celery and redis
|
||||
pip install celery redis
|
||||
|
||||
# Start worker
|
||||
celery -A app.workers worker --loglevel=info
|
||||
```
|
||||
|
||||
## 📊 Usage Guide
|
||||
|
||||
### 1. Import Contacts
|
||||
|
||||
**Excel/CSV Import**:
|
||||
- Prepare file with columns: `phone`, `first_name`, `last_name`, `email`, `opted_in`
|
||||
- Go to Imports page
|
||||
- Upload file
|
||||
- Review summary
|
||||
|
||||
**Google Contacts**:
|
||||
- Click "Connect Google Account"
|
||||
- Authorize access
|
||||
- Click "Sync Contacts"
|
||||
|
||||
### 2. Create Lists
|
||||
|
||||
- Go to Lists page
|
||||
- Click "Create List"
|
||||
- Add contacts to list from Contacts page
|
||||
|
||||
### 3. Create Templates
|
||||
|
||||
- Go to Templates page
|
||||
- Click "Create Template"
|
||||
- Use `{{first_name}}`, `{{last_name}}`, etc. for personalization
|
||||
- For WhatsApp templates: Check "Approved WhatsApp Template" and enter template name from Business Manager
|
||||
|
||||
### 4. Create Campaign
|
||||
|
||||
- Go to Campaigns page
|
||||
- Click "Create Campaign"
|
||||
- Select template and list
|
||||
- Preview recipients
|
||||
- Click "Send"
|
||||
|
||||
### 5. Monitor Campaign
|
||||
|
||||
- Click on campaign name
|
||||
- See recipient statuses in real-time
|
||||
- Webhook updates statuses automatically
|
||||
|
||||
## 🔒 Compliance Best Practices
|
||||
|
||||
### Before Sending
|
||||
1. ✅ Verify all contacts have opted in OR use approved templates
|
||||
2. ✅ Check DND list is up to date
|
||||
3. ✅ Review daily/rate limits
|
||||
4. ✅ Test with MockProvider first
|
||||
|
||||
### Message Guidelines
|
||||
- Use approved templates for first contact
|
||||
- Free-form messages only for existing conversations
|
||||
- Personalize with variables
|
||||
- Keep messages professional
|
||||
- Include opt-out instructions
|
||||
|
||||
### Data Privacy
|
||||
- Store only necessary contact info
|
||||
- Respect DND requests immediately
|
||||
- Encrypt sensitive data (tokens, etc.)
|
||||
- Log all activities for audit
|
||||
- Comply with GDPR/local regulations
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pytest tests/
|
||||
```
|
||||
|
||||
### Testing with MockProvider
|
||||
|
||||
Set `WHATSAPP_PROVIDER=mock` in `.env`. This simulates sending without external calls.
|
||||
|
||||
### Integration Testing
|
||||
|
||||
1. Create test contacts with your own phone
|
||||
2. Mark them as opted-in
|
||||
3. Create small test campaign
|
||||
4. Verify messages arrive
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### "Invalid phone number" errors
|
||||
- Ensure phones are in E.164 format (+1234567890)
|
||||
- Check country code is valid
|
||||
- Use phone normalization in imports
|
||||
|
||||
### "Template not found" errors
|
||||
- Verify template is approved in Business Manager
|
||||
- Check `provider_template_name` matches exactly
|
||||
- Wait 24-48h after template submission
|
||||
|
||||
### "Rate limit exceeded"
|
||||
- Check `MAX_MESSAGES_PER_MINUTE` setting
|
||||
- Review daily limit in `DAILY_LIMIT_PER_CAMPAIGN`
|
||||
- Stagger campaigns over time
|
||||
|
||||
### Webhook not receiving updates
|
||||
- Verify callback URL is publicly accessible (use ngrok for local testing)
|
||||
- Check verify token matches
|
||||
- Review webhook logs in Meta Developer Portal
|
||||
|
||||
## 📈 Production Deployment
|
||||
|
||||
### Recommended Stack
|
||||
- **Backend**: Deploy on Railway, Render, or AWS ECS
|
||||
- **Frontend**: Vercel, Netlify, or Cloudflare Pages
|
||||
- **Database**: Managed PostgreSQL (AWS RDS, Render, etc.)
|
||||
- **Workers**: Background tasks on same platform or Celery with Redis
|
||||
- **Domain**: Custom domain with SSL
|
||||
|
||||
### Security Checklist
|
||||
- [ ] Change all default passwords
|
||||
- [ ] Use strong JWT_SECRET (32+ chars)
|
||||
- [ ] Enable HTTPS only
|
||||
- [ ] Set proper CORS origins
|
||||
- [ ] Rotate access tokens regularly
|
||||
- [ ] Monitor rate limits
|
||||
- [ ] Set up error tracking (Sentry)
|
||||
- [ ] Enable database backups
|
||||
- [ ] Review logs regularly
|
||||
|
||||
### Scaling Considerations
|
||||
- Use connection pooling for database
|
||||
- Add Redis for caching and Celery
|
||||
- Horizontal scaling for API servers
|
||||
- CDN for frontend assets
|
||||
- Monitor provider rate limits
|
||||
- Consider multiple WhatsApp numbers for higher volume
|
||||
|
||||
## 💰 Pricing Notes
|
||||
|
||||
### WhatsApp Cloud API Pricing (Meta)
|
||||
- **Conversations**: Charged per 24-hour conversation window
|
||||
- **Business-Initiated**: Higher cost (~$0.01-0.10 per conversation, varies by country)
|
||||
- **User-Initiated**: Free or lower cost
|
||||
- **Template Messages**: Count as business-initiated
|
||||
- **Free Tier**: 1,000 conversations/month
|
||||
|
||||
Always check current pricing at [developers.facebook.com/docs/whatsapp/pricing](https://developers.facebook.com/docs/whatsapp/pricing)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
This is a production template. Customize for your needs:
|
||||
- Add custom fields to Contact model
|
||||
- Implement additional providers (Twilio, MessageBird, etc.)
|
||||
- Add more analytics and reporting
|
||||
- Enhance UI/UX
|
||||
- Add A/B testing for templates
|
||||
- Implement scheduled campaigns
|
||||
- Add webhook retry logic
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - Feel free to use for commercial or personal projects.
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
This software is provided as-is. Users are responsible for:
|
||||
- Complying with WhatsApp Business Terms of Service
|
||||
- Following local marketing/spam regulations (CAN-SPAM, GDPR, etc.)
|
||||
- Obtaining proper consent from contacts
|
||||
- Maintaining data security and privacy
|
||||
- Using official WhatsApp APIs only
|
||||
|
||||
The developers are not liable for misuse or violations of terms of service.
|
||||
|
||||
## 🆘 Support
|
||||
|
||||
For issues:
|
||||
1. Check this README
|
||||
2. Review API documentation at `/docs`
|
||||
3. Check error logs
|
||||
4. Review Meta WhatsApp documentation
|
||||
5. Test with MockProvider first
|
||||
|
||||
---
|
||||
|
||||
**Built with ❤️ for compliant marketing automation**
|
||||
139
TELEGRAM_TESTING.md
Normal file
139
TELEGRAM_TESTING.md
Normal file
@ -0,0 +1,139 @@
|
||||
# Telegram Bot Testing Guide
|
||||
|
||||
## Why Telegram?
|
||||
- ✅ **Completely FREE** - No costs at all
|
||||
- ✅ **Instant setup** - 2 minutes to get started
|
||||
- ✅ **Real API** - Test actual HTTP requests, not mocks
|
||||
- ✅ **Immediate testing** - Use your own phone
|
||||
- ✅ **No verification** - No business account needed
|
||||
|
||||
## Setup Steps (5 minutes)
|
||||
|
||||
### 1. Create a Telegram Bot
|
||||
|
||||
1. Open Telegram on your phone
|
||||
2. Search for `@BotFather`
|
||||
3. Send `/newbot` command
|
||||
4. Choose a name for your bot (e.g., "My Campaign Tester")
|
||||
5. Choose a username (e.g., "mycampaign_test_bot")
|
||||
6. **Copy the bot token** (looks like: `1234567890:ABCdefGHIjklMNOpqrsTUVwxyz`)
|
||||
|
||||
### 2. Get Your Telegram User ID
|
||||
|
||||
**Option A: Quick way**
|
||||
1. Search for `@userinfobot` on Telegram
|
||||
2. Start a chat with it
|
||||
3. It will reply with your user ID (e.g., `123456789`)
|
||||
|
||||
**Option B: Manual way**
|
||||
1. Start a chat with your new bot (search for the username you created)
|
||||
2. Send any message to it (e.g., "hello")
|
||||
3. Open this URL in browser (replace `<YOUR_BOT_TOKEN>`):
|
||||
```
|
||||
https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates
|
||||
```
|
||||
4. Find your user ID in the JSON response under `"from": {"id": 123456789}`
|
||||
|
||||
### 3. Configure Your App
|
||||
|
||||
Edit `backend/.env` or `docker-compose.yml`:
|
||||
|
||||
```env
|
||||
WHATSAPP_PROVIDER=telegram
|
||||
TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||
```
|
||||
|
||||
### 4. Add Yourself as a Contact
|
||||
|
||||
In your app's Contacts page:
|
||||
- **Phone**: Your Telegram user ID (e.g., `123456789`)
|
||||
- **First Name**: Your name
|
||||
- **Opted In**: ✅ Check this box
|
||||
- Click "Create"
|
||||
|
||||
### 5. Create Campaign and Send
|
||||
|
||||
1. Create a template (e.g., "Hello {{first_name}}!")
|
||||
2. Create a list and add your contact
|
||||
3. Create a campaign with that template and list
|
||||
4. Click "Send"
|
||||
5. Trigger the worker:
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/workers/tick
|
||||
```
|
||||
|
||||
6. **Check your Telegram** - you'll receive the message! 🎉
|
||||
|
||||
## What You'll See
|
||||
|
||||
### In Your App
|
||||
- Campaign status: `sending` → `done`
|
||||
- Recipient status: `pending` → `sent`
|
||||
- Real Telegram message IDs
|
||||
- Actual error messages if something fails
|
||||
|
||||
### On Your Phone
|
||||
- Real message from your bot
|
||||
- Variables replaced ({{first_name}} → your actual name)
|
||||
- Instant delivery
|
||||
|
||||
## Testing Different Scenarios
|
||||
|
||||
### Test Opt-in Enforcement
|
||||
1. Create contact with `opted_in = false`
|
||||
2. Try sending with regular template (not WhatsApp approved)
|
||||
3. View campaign → See error: "Contact not opted in"
|
||||
|
||||
### Test Multiple Contacts
|
||||
1. Get Telegram IDs from friends/family
|
||||
2. Add them as contacts (with permission!)
|
||||
3. Send campaign to the list
|
||||
4. Everyone receives the message
|
||||
|
||||
### Test Rate Limiting
|
||||
- Set `MAX_MESSAGES_PER_MINUTE=2` in config
|
||||
- Send to 10 contacts
|
||||
- Watch messages sent slowly (respects rate limit)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Failed to send Telegram message: 400 Bad Request"
|
||||
- **Cause**: Invalid chat ID (user ID)
|
||||
- **Fix**: Make sure user has started a chat with your bot first
|
||||
|
||||
### "Failed to send Telegram message: 401 Unauthorized"
|
||||
- **Cause**: Invalid bot token
|
||||
- **Fix**: Double-check the token from @BotFather
|
||||
|
||||
### "Contact not opted in"
|
||||
- **Fix**: Check the "Opted In" checkbox when creating contact
|
||||
|
||||
## Switch Between Providers
|
||||
|
||||
```env
|
||||
# For mock testing (no external calls)
|
||||
WHATSAPP_PROVIDER=mock
|
||||
|
||||
# For Telegram testing (free, real API)
|
||||
WHATSAPP_PROVIDER=telegram
|
||||
TELEGRAM_BOT_TOKEN=your-token
|
||||
|
||||
# For production WhatsApp
|
||||
WHATSAPP_PROVIDER=cloud
|
||||
WHATSAPP_CLOUD_ACCESS_TOKEN=your-token
|
||||
WHATSAPP_CLOUD_PHONE_NUMBER_ID=your-id
|
||||
```
|
||||
|
||||
## Production Checklist
|
||||
|
||||
Once Telegram testing looks good:
|
||||
1. ✅ All campaigns working
|
||||
2. ✅ Error handling tested
|
||||
3. ✅ Rate limiting working
|
||||
4. ✅ Opt-in enforcement verified
|
||||
5. ✅ DND list tested
|
||||
6. → **Ready to switch to WhatsApp Cloud API!**
|
||||
|
||||
---
|
||||
|
||||
**Happy Testing! 🚀**
|
||||
24
backend/.env.example
Normal file
24
backend/.env.example
Normal file
@ -0,0 +1,24 @@
|
||||
DATABASE_URL=postgresql://whatssender:whatssender123@localhost:5432/whatssender
|
||||
JWT_SECRET=your-secret-key-change-in-production-min-32-chars
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
|
||||
|
||||
# Google OAuth
|
||||
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||
GOOGLE_REDIRECT_URI=http://localhost:8000/api/imports/google/callback
|
||||
# Generate Fernet key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
GOOGLE_TOKEN_ENCRYPTION_KEY=your-fernet-key-base64-44-chars
|
||||
|
||||
# WhatsApp Provider
|
||||
WHATSAPP_PROVIDER=mock # mock, cloud, or telegram
|
||||
WHATSAPP_CLOUD_ACCESS_TOKEN=your-whatsapp-cloud-api-token
|
||||
WHATSAPP_CLOUD_PHONE_NUMBER_ID=your-phone-number-id
|
||||
WHATSAPP_WEBHOOK_VERIFY_TOKEN=your-webhook-verify-token-random-string
|
||||
|
||||
# Telegram Provider (for testing)
|
||||
TELEGRAM_BOT_TOKEN=your-telegram-bot-token-from-botfather
|
||||
|
||||
# Rate Limiting
|
||||
MAX_MESSAGES_PER_MINUTE=20
|
||||
BATCH_SIZE=10
|
||||
DAILY_LIMIT_PER_CAMPAIGN=1000
|
||||
19
backend/Dockerfile
Normal file
19
backend/Dockerfile
Normal file
@ -0,0 +1,19 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Run migrations and start server
|
||||
CMD alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
102
backend/alembic.ini
Normal file
102
backend/alembic.ini
Normal file
@ -0,0 +1,102 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration files
|
||||
# file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python-dateutil library that can be
|
||||
# installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to dateutil.tz.gettz()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url =
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
86
backend/alembic/env.py
Normal file
86
backend/alembic/env.py
Normal file
@ -0,0 +1,86 @@
|
||||
from logging.config import fileConfig
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
from alembic import context
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.base import Base
|
||||
from app.models import * # noqa
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Set the database URL from settings
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
24
backend/alembic/script.py.mako
Normal file
24
backend/alembic/script.py.mako
Normal file
@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
238
backend/alembic/versions/001_initial_migration.py
Normal file
238
backend/alembic/versions/001_initial_migration.py
Normal file
@ -0,0 +1,238 @@
|
||||
"""initial migration
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2026-01-13 00:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '001'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create users table
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('email', sa.String(), nullable=False),
|
||||
sa.Column('password_hash', sa.String(), nullable=False),
|
||||
sa.Column('role', sa.Enum('ADMIN', 'USER', name='userrole'), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
|
||||
|
||||
# Create contacts table
|
||||
op.create_table('contacts',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('phone_e164', sa.String(), nullable=False),
|
||||
sa.Column('first_name', sa.String(), nullable=True),
|
||||
sa.Column('last_name', sa.String(), nullable=True),
|
||||
sa.Column('email', sa.String(), nullable=True),
|
||||
sa.Column('opted_in', sa.Boolean(), nullable=False),
|
||||
sa.Column('conversation_window_open', sa.Boolean(), nullable=False),
|
||||
sa.Column('source', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'phone_e164', name='uq_user_phone')
|
||||
)
|
||||
op.create_index('ix_contacts_user_phone', 'contacts', ['user_id', 'phone_e164'], unique=False)
|
||||
op.create_index(op.f('ix_contacts_id'), 'contacts', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_contacts_user_id'), 'contacts', ['user_id'], unique=False)
|
||||
|
||||
# Create contact_tags table
|
||||
op.create_table('contact_tags',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_contact_tags_id'), 'contact_tags', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_contact_tags_user_id'), 'contact_tags', ['user_id'], unique=False)
|
||||
|
||||
# Create contact_tag_map table
|
||||
op.create_table('contact_tag_map',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('contact_id', sa.Integer(), nullable=False),
|
||||
sa.Column('tag_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['contact_id'], ['contacts.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['tag_id'], ['contact_tags.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('contact_id', 'tag_id', name='uq_contact_tag')
|
||||
)
|
||||
op.create_index(op.f('ix_contact_tag_map_id'), 'contact_tag_map', ['id'], unique=False)
|
||||
|
||||
# Create dnd_list table
|
||||
op.create_table('dnd_list',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('phone_e164', sa.String(), nullable=False),
|
||||
sa.Column('reason', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'phone_e164', name='uq_dnd_user_phone')
|
||||
)
|
||||
op.create_index(op.f('ix_dnd_list_id'), 'dnd_list', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_dnd_list_phone_e164'), 'dnd_list', ['phone_e164'], unique=False)
|
||||
op.create_index(op.f('ix_dnd_list_user_id'), 'dnd_list', ['user_id'], unique=False)
|
||||
|
||||
# Create lists table
|
||||
op.create_table('lists',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_lists_id'), 'lists', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_lists_user_id'), 'lists', ['user_id'], unique=False)
|
||||
|
||||
# Create list_members table
|
||||
op.create_table('list_members',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('list_id', sa.Integer(), nullable=False),
|
||||
sa.Column('contact_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['contact_id'], ['contacts.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['list_id'], ['lists.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('list_id', 'contact_id', name='uq_list_contact')
|
||||
)
|
||||
op.create_index(op.f('ix_list_members_contact_id'), 'list_members', ['contact_id'], unique=False)
|
||||
op.create_index(op.f('ix_list_members_id'), 'list_members', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_list_members_list_id'), 'list_members', ['list_id'], unique=False)
|
||||
|
||||
# Create templates table
|
||||
op.create_table('templates',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('language', sa.String(), nullable=False),
|
||||
sa.Column('body_text', sa.Text(), nullable=False),
|
||||
sa.Column('is_whatsapp_template', sa.Boolean(), nullable=False),
|
||||
sa.Column('provider_template_name', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_templates_id'), 'templates', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_templates_user_id'), 'templates', ['user_id'], unique=False)
|
||||
|
||||
# Create campaigns table
|
||||
op.create_table('campaigns',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('template_id', sa.Integer(), nullable=True),
|
||||
sa.Column('list_id', sa.Integer(), nullable=True),
|
||||
sa.Column('status', sa.Enum('DRAFT', 'SCHEDULED', 'SENDING', 'DONE', 'FAILED', name='campaignstatus'), nullable=False),
|
||||
sa.Column('scheduled_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['list_id'], ['lists.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['template_id'], ['templates.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_campaigns_id'), 'campaigns', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_campaigns_status'), 'campaigns', ['status'], unique=False)
|
||||
op.create_index(op.f('ix_campaigns_user_id'), 'campaigns', ['user_id'], unique=False)
|
||||
|
||||
# Create campaign_recipients table
|
||||
op.create_table('campaign_recipients',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('campaign_id', sa.Integer(), nullable=False),
|
||||
sa.Column('contact_id', sa.Integer(), nullable=False),
|
||||
sa.Column('status', sa.Enum('PENDING', 'SENT', 'DELIVERED', 'READ', 'FAILED', name='recipientstatus'), nullable=False),
|
||||
sa.Column('provider_message_id', sa.String(), nullable=True),
|
||||
sa.Column('last_error', sa.Text(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['contact_id'], ['contacts.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_campaign_recipients_campaign_status', 'campaign_recipients', ['campaign_id', 'status'], unique=False)
|
||||
op.create_index(op.f('ix_campaign_recipients_campaign_id'), 'campaign_recipients', ['campaign_id'], unique=False)
|
||||
op.create_index(op.f('ix_campaign_recipients_contact_id'), 'campaign_recipients', ['contact_id'], unique=False)
|
||||
op.create_index(op.f('ix_campaign_recipients_id'), 'campaign_recipients', ['id'], unique=False)
|
||||
|
||||
# Create send_logs table
|
||||
op.create_table('send_logs',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('campaign_id', sa.Integer(), nullable=True),
|
||||
sa.Column('contact_id', sa.Integer(), nullable=True),
|
||||
sa.Column('provider', sa.String(), nullable=False),
|
||||
sa.Column('request_payload_json', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('response_payload_json', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('status', sa.String(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['contact_id'], ['contacts.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_send_logs_campaign_id'), 'send_logs', ['campaign_id'], unique=False)
|
||||
op.create_index(op.f('ix_send_logs_created_at'), 'send_logs', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_send_logs_id'), 'send_logs', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_send_logs_user_id'), 'send_logs', ['user_id'], unique=False)
|
||||
|
||||
# Create jobs table
|
||||
op.create_table('jobs',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('type', sa.String(), nullable=False),
|
||||
sa.Column('payload_json', postgresql.JSON(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('status', sa.String(), nullable=False),
|
||||
sa.Column('attempts', sa.Integer(), nullable=False),
|
||||
sa.Column('run_after', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('last_error', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_jobs_status_run_after', 'jobs', ['status', 'run_after'], unique=False)
|
||||
op.create_index(op.f('ix_jobs_id'), 'jobs', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_jobs_user_id'), 'jobs', ['user_id'], unique=False)
|
||||
|
||||
# Create google_tokens table
|
||||
op.create_table('google_tokens',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('encrypted_token', sa.Text(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_google_tokens_id'), 'google_tokens', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_google_tokens_user_id'), 'google_tokens', ['user_id'], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('google_tokens')
|
||||
op.drop_table('jobs')
|
||||
op.drop_table('send_logs')
|
||||
op.drop_table('campaign_recipients')
|
||||
op.drop_table('campaigns')
|
||||
op.drop_table('templates')
|
||||
op.drop_table('list_members')
|
||||
op.drop_table('lists')
|
||||
op.drop_table('dnd_list')
|
||||
op.drop_table('contact_tag_map')
|
||||
op.drop_table('contact_tags')
|
||||
op.drop_table('contacts')
|
||||
op.drop_table('users')
|
||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Empty file to make app a package
|
||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Empty file
|
||||
49
backend/app/api/auth.py
Normal file
49
backend/app/api/auth.py
Normal file
@ -0,0 +1,49 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.base import get_db
|
||||
from app.schemas.user import UserCreate, UserLogin, UserResponse, Token
|
||||
from app.models.user import User
|
||||
from app.core.security import verify_password, get_password_hash, create_access_token
|
||||
from app.core.deps import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/register", response_model=UserResponse)
|
||||
def register(user: UserCreate, db: Session = Depends(get_db)):
|
||||
# Check if user exists
|
||||
existing_user = db.query(User).filter(User.email == user.email).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
# Create new user
|
||||
db_user = User(
|
||||
email=user.email,
|
||||
password_hash=get_password_hash(user.password)
|
||||
)
|
||||
db.add(db_user)
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
|
||||
return db_user
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(user: UserLogin, db: Session = Depends(get_db)):
|
||||
# Find user
|
||||
db_user = db.query(User).filter(User.email == user.email).first()
|
||||
if not db_user or not verify_password(user.password, db_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password"
|
||||
)
|
||||
|
||||
# Create access token
|
||||
access_token = create_access_token(data={"sub": str(db_user.id)})
|
||||
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_me(current_user: User = Depends(get_current_user)):
|
||||
return current_user
|
||||
346
backend/app/api/campaigns.py
Normal file
346
backend/app/api/campaigns.py
Normal file
@ -0,0 +1,346 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
from app.db.base import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.campaign import Campaign, CampaignRecipient, CampaignStatus, RecipientStatus
|
||||
from app.models.template import Template
|
||||
from app.models.list import List as ContactList, ListMember
|
||||
from app.models.contact import Contact, DNDList
|
||||
from app.models.job import Job
|
||||
from app.schemas.campaign import (
|
||||
CampaignCreate, CampaignUpdate, CampaignResponse,
|
||||
RecipientResponse, CampaignPreview
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("", response_model=CampaignResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_campaign(
|
||||
campaign: CampaignCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Verify template exists
|
||||
template = db.query(Template).filter(
|
||||
Template.id == campaign.template_id,
|
||||
Template.user_id == current_user.id
|
||||
).first()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
# Verify list exists
|
||||
contact_list = db.query(ContactList).filter(
|
||||
ContactList.id == campaign.list_id,
|
||||
ContactList.user_id == current_user.id
|
||||
).first()
|
||||
if not contact_list:
|
||||
raise HTTPException(status_code=404, detail="List not found")
|
||||
|
||||
# Create campaign
|
||||
db_campaign = Campaign(
|
||||
user_id=current_user.id,
|
||||
name=campaign.name,
|
||||
template_id=campaign.template_id,
|
||||
list_id=campaign.list_id,
|
||||
status=CampaignStatus.DRAFT,
|
||||
scheduled_at=campaign.scheduled_at
|
||||
)
|
||||
db.add(db_campaign)
|
||||
db.commit()
|
||||
db.refresh(db_campaign)
|
||||
|
||||
# Create recipients
|
||||
members = db.query(ListMember).filter(ListMember.list_id == campaign.list_id).all()
|
||||
|
||||
for member in members:
|
||||
recipient = CampaignRecipient(
|
||||
campaign_id=db_campaign.id,
|
||||
contact_id=member.contact_id,
|
||||
status=RecipientStatus.PENDING
|
||||
)
|
||||
db.add(recipient)
|
||||
|
||||
db.commit()
|
||||
|
||||
return db_campaign
|
||||
|
||||
@router.get("", response_model=List[CampaignResponse])
|
||||
def list_campaigns(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return db.query(Campaign).filter(
|
||||
Campaign.user_id == current_user.id
|
||||
).order_by(Campaign.created_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
@router.get("/{campaign_id}", response_model=CampaignResponse)
|
||||
def get_campaign(
|
||||
campaign_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
campaign = db.query(Campaign).filter(
|
||||
Campaign.id == campaign_id,
|
||||
Campaign.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
return campaign
|
||||
|
||||
@router.put("/{campaign_id}", response_model=CampaignResponse)
|
||||
def update_campaign(
|
||||
campaign_id: int,
|
||||
campaign_update: CampaignUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
campaign = db.query(Campaign).filter(
|
||||
Campaign.id == campaign_id,
|
||||
Campaign.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
update_data = campaign_update.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(campaign, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(campaign)
|
||||
|
||||
return campaign
|
||||
|
||||
@router.delete("/{campaign_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_campaign(
|
||||
campaign_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
campaign = db.query(Campaign).filter(
|
||||
Campaign.id == campaign_id,
|
||||
Campaign.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
db.delete(campaign)
|
||||
db.commit()
|
||||
|
||||
return None
|
||||
|
||||
@router.get("/{campaign_id}/preview", response_model=CampaignPreview)
|
||||
def preview_campaign(
|
||||
campaign_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Preview campaign recipients and compliance stats"""
|
||||
campaign = db.query(Campaign).filter(
|
||||
Campaign.id == campaign_id,
|
||||
Campaign.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
# Get all recipients
|
||||
recipients = db.query(CampaignRecipient).filter(
|
||||
CampaignRecipient.campaign_id == campaign_id
|
||||
).all()
|
||||
|
||||
total_count = len(recipients)
|
||||
opted_in_count = 0
|
||||
dnd_count = 0
|
||||
|
||||
sample_contacts = []
|
||||
|
||||
for recipient in recipients[:5]: # Sample first 5
|
||||
contact = db.query(Contact).filter(Contact.id == recipient.contact_id).first()
|
||||
if not contact:
|
||||
continue
|
||||
|
||||
# Check if opted in
|
||||
if contact.opted_in:
|
||||
opted_in_count += 1
|
||||
|
||||
# Check DND
|
||||
dnd_entry = db.query(DNDList).filter(
|
||||
DNDList.user_id == current_user.id,
|
||||
DNDList.phone_e164 == contact.phone_e164
|
||||
).first()
|
||||
if dnd_entry:
|
||||
dnd_count += 1
|
||||
|
||||
sample_contacts.append({
|
||||
"phone": contact.phone_e164,
|
||||
"name": f"{contact.first_name or ''} {contact.last_name or ''}".strip(),
|
||||
"opted_in": contact.opted_in,
|
||||
"in_dnd": dnd_entry is not None
|
||||
})
|
||||
|
||||
# Count all opted in
|
||||
all_contact_ids = [r.contact_id for r in recipients]
|
||||
opted_in_count = db.query(Contact).filter(
|
||||
Contact.id.in_(all_contact_ids),
|
||||
Contact.opted_in == True
|
||||
).count()
|
||||
|
||||
# Count all DND
|
||||
all_phones = [
|
||||
c.phone_e164 for c in db.query(Contact.phone_e164).filter(
|
||||
Contact.id.in_(all_contact_ids)
|
||||
).all()
|
||||
]
|
||||
dnd_count = db.query(DNDList).filter(
|
||||
DNDList.user_id == current_user.id,
|
||||
DNDList.phone_e164.in_(all_phones)
|
||||
).count()
|
||||
|
||||
eligible_count = total_count - dnd_count
|
||||
|
||||
return CampaignPreview(
|
||||
total_recipients=total_count,
|
||||
opted_in_count=opted_in_count,
|
||||
dnd_count=dnd_count,
|
||||
eligible_count=eligible_count,
|
||||
sample_contacts=sample_contacts
|
||||
)
|
||||
|
||||
@router.post("/{campaign_id}/send")
|
||||
def send_campaign(
|
||||
campaign_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Start sending campaign (creates a job)"""
|
||||
campaign = db.query(Campaign).filter(
|
||||
Campaign.id == campaign_id,
|
||||
Campaign.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
if campaign.status not in [CampaignStatus.DRAFT, CampaignStatus.SCHEDULED]:
|
||||
raise HTTPException(status_code=400, detail="Campaign already sent or in progress")
|
||||
|
||||
# Update status
|
||||
campaign.status = CampaignStatus.SENDING
|
||||
db.commit()
|
||||
|
||||
# Create job for background processing
|
||||
job = Job(
|
||||
user_id=current_user.id,
|
||||
type="send_campaign",
|
||||
payload_json={"campaign_id": campaign_id},
|
||||
status="pending",
|
||||
attempts=0
|
||||
)
|
||||
db.add(job)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "started",
|
||||
"campaign_id": campaign_id,
|
||||
"job_id": job.id
|
||||
}
|
||||
|
||||
@router.post("/{campaign_id}/reset")
|
||||
def reset_campaign(
|
||||
campaign_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Reset campaign to draft and all recipients to pending"""
|
||||
campaign = db.query(Campaign).filter(
|
||||
Campaign.id == campaign_id,
|
||||
Campaign.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
# Reset campaign status
|
||||
campaign.status = CampaignStatus.DRAFT
|
||||
|
||||
# Reset all recipients to pending
|
||||
db.query(CampaignRecipient).filter(
|
||||
CampaignRecipient.campaign_id == campaign_id
|
||||
).update({
|
||||
"status": RecipientStatus.PENDING,
|
||||
"provider_message_id": None,
|
||||
"last_error": None
|
||||
})
|
||||
|
||||
# Delete any pending jobs for this campaign
|
||||
from sqlalchemy import cast, String
|
||||
db.query(Job).filter(
|
||||
Job.type == "send_campaign",
|
||||
cast(Job.payload_json["campaign_id"], String) == str(campaign_id),
|
||||
Job.status == "pending"
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "reset",
|
||||
"campaign_id": campaign_id,
|
||||
"message": "Campaign reset to draft. All recipients set to pending."
|
||||
}
|
||||
|
||||
@router.get("/{campaign_id}/recipients", response_model=List[RecipientResponse])
|
||||
def get_campaign_recipients(
|
||||
campaign_id: int,
|
||||
status: str = Query(None),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get campaign recipients with optional status filter"""
|
||||
campaign = db.query(Campaign).filter(
|
||||
Campaign.id == campaign_id,
|
||||
Campaign.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
query = db.query(CampaignRecipient).filter(
|
||||
CampaignRecipient.campaign_id == campaign_id
|
||||
)
|
||||
|
||||
if status:
|
||||
query = query.filter(CampaignRecipient.status == status)
|
||||
|
||||
recipients = query.offset(skip).limit(limit).all()
|
||||
|
||||
# Enrich with contact data
|
||||
result = []
|
||||
for recipient in recipients:
|
||||
contact = db.query(Contact).filter(Contact.id == recipient.contact_id).first()
|
||||
recipient_dict = {
|
||||
"id": recipient.id,
|
||||
"campaign_id": recipient.campaign_id,
|
||||
"contact_id": recipient.contact_id,
|
||||
"status": recipient.status,
|
||||
"provider_message_id": recipient.provider_message_id,
|
||||
"last_error": recipient.last_error,
|
||||
"updated_at": recipient.updated_at,
|
||||
"contact_phone": contact.phone_e164 if contact else None,
|
||||
"contact_name": f"{contact.first_name or ''} {contact.last_name or ''}".strip() if contact else None
|
||||
}
|
||||
result.append(recipient_dict)
|
||||
|
||||
return result
|
||||
309
backend/app/api/contacts.py
Normal file
309
backend/app/api/contacts.py
Normal file
@ -0,0 +1,309 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_, func
|
||||
from typing import List, Optional
|
||||
from app.db.base import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.contact import Contact, ContactTag, ContactTagMap, DNDList
|
||||
from app.schemas.contact import (
|
||||
ContactCreate, ContactUpdate, ContactResponse,
|
||||
ContactTagCreate, ContactTagResponse,
|
||||
DNDCreate, DNDResponse
|
||||
)
|
||||
from app.utils.phone import normalize_phone
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("", response_model=ContactResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_contact(
|
||||
contact: ContactCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Normalize phone
|
||||
normalized_phone = normalize_phone(contact.phone_e164)
|
||||
if not normalized_phone:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid phone number"
|
||||
)
|
||||
|
||||
# Check DND list
|
||||
dnd_entry = db.query(DNDList).filter(
|
||||
DNDList.user_id == current_user.id,
|
||||
DNDList.phone_e164 == normalized_phone
|
||||
).first()
|
||||
if dnd_entry:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Phone number is in DND list"
|
||||
)
|
||||
|
||||
# Check if contact exists
|
||||
existing = db.query(Contact).filter(
|
||||
Contact.user_id == current_user.id,
|
||||
Contact.phone_e164 == normalized_phone
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Contact already exists"
|
||||
)
|
||||
|
||||
db_contact = Contact(
|
||||
user_id=current_user.id,
|
||||
phone_e164=normalized_phone,
|
||||
first_name=contact.first_name,
|
||||
last_name=contact.last_name,
|
||||
email=contact.email,
|
||||
opted_in=contact.opted_in,
|
||||
conversation_window_open=contact.conversation_window_open,
|
||||
source="manual"
|
||||
)
|
||||
db.add(db_contact)
|
||||
db.commit()
|
||||
db.refresh(db_contact)
|
||||
|
||||
return db_contact
|
||||
|
||||
@router.get("", response_model=List[ContactResponse])
|
||||
def list_contacts(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
search: Optional[str] = None,
|
||||
tag_id: Optional[int] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
query = db.query(Contact).filter(Contact.user_id == current_user.id)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Contact.phone_e164.ilike(f"%{search}%"),
|
||||
Contact.first_name.ilike(f"%{search}%"),
|
||||
Contact.last_name.ilike(f"%{search}%"),
|
||||
Contact.email.ilike(f"%{search}%")
|
||||
)
|
||||
)
|
||||
|
||||
if tag_id:
|
||||
query = query.join(ContactTagMap).filter(ContactTagMap.tag_id == tag_id)
|
||||
|
||||
contacts = query.offset(skip).limit(limit).all()
|
||||
|
||||
# Add tags to response
|
||||
for contact in contacts:
|
||||
tag_mappings = db.query(ContactTagMap).filter(
|
||||
ContactTagMap.contact_id == contact.id
|
||||
).all()
|
||||
tag_ids = [tm.tag_id for tm in tag_mappings]
|
||||
tags = db.query(ContactTag).filter(ContactTag.id.in_(tag_ids)).all() if tag_ids else []
|
||||
contact.tags = [tag.name for tag in tags]
|
||||
|
||||
return contacts
|
||||
|
||||
@router.get("/{contact_id}", response_model=ContactResponse)
|
||||
def get_contact(
|
||||
contact_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
contact = db.query(Contact).filter(
|
||||
Contact.id == contact_id,
|
||||
Contact.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
|
||||
# Add tags
|
||||
tag_mappings = db.query(ContactTagMap).filter(ContactTagMap.contact_id == contact.id).all()
|
||||
tag_ids = [tm.tag_id for tm in tag_mappings]
|
||||
tags = db.query(ContactTag).filter(ContactTag.id.in_(tag_ids)).all() if tag_ids else []
|
||||
contact.tags = [tag.name for tag in tags]
|
||||
|
||||
return contact
|
||||
|
||||
@router.put("/{contact_id}", response_model=ContactResponse)
|
||||
def update_contact(
|
||||
contact_id: int,
|
||||
contact_update: ContactUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
contact = db.query(Contact).filter(
|
||||
Contact.id == contact_id,
|
||||
Contact.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
|
||||
update_data = contact_update.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(contact, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(contact)
|
||||
|
||||
return contact
|
||||
|
||||
@router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_contact(
|
||||
contact_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
contact = db.query(Contact).filter(
|
||||
Contact.id == contact_id,
|
||||
Contact.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
|
||||
db.delete(contact)
|
||||
db.commit()
|
||||
|
||||
return None
|
||||
|
||||
# Tags
|
||||
@router.post("/tags", response_model=ContactTagResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_tag(
|
||||
tag: ContactTagCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
db_tag = ContactTag(user_id=current_user.id, name=tag.name)
|
||||
db.add(db_tag)
|
||||
db.commit()
|
||||
db.refresh(db_tag)
|
||||
return db_tag
|
||||
|
||||
@router.get("/tags", response_model=List[ContactTagResponse])
|
||||
def list_tags(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return db.query(ContactTag).filter(ContactTag.user_id == current_user.id).all()
|
||||
|
||||
@router.post("/{contact_id}/tags/{tag_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def add_tag_to_contact(
|
||||
contact_id: int,
|
||||
tag_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Verify ownership
|
||||
contact = db.query(Contact).filter(
|
||||
Contact.id == contact_id,
|
||||
Contact.user_id == current_user.id
|
||||
).first()
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
|
||||
tag = db.query(ContactTag).filter(
|
||||
ContactTag.id == tag_id,
|
||||
ContactTag.user_id == current_user.id
|
||||
).first()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
# Check if already exists
|
||||
existing = db.query(ContactTagMap).filter(
|
||||
ContactTagMap.contact_id == contact_id,
|
||||
ContactTagMap.tag_id == tag_id
|
||||
).first()
|
||||
if existing:
|
||||
return None
|
||||
|
||||
mapping = ContactTagMap(contact_id=contact_id, tag_id=tag_id)
|
||||
db.add(mapping)
|
||||
db.commit()
|
||||
|
||||
return None
|
||||
|
||||
@router.delete("/{contact_id}/tags/{tag_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def remove_tag_from_contact(
|
||||
contact_id: int,
|
||||
tag_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Verify ownership
|
||||
contact = db.query(Contact).filter(
|
||||
Contact.id == contact_id,
|
||||
Contact.user_id == current_user.id
|
||||
).first()
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
|
||||
mapping = db.query(ContactTagMap).filter(
|
||||
ContactTagMap.contact_id == contact_id,
|
||||
ContactTagMap.tag_id == tag_id
|
||||
).first()
|
||||
|
||||
if mapping:
|
||||
db.delete(mapping)
|
||||
db.commit()
|
||||
|
||||
return None
|
||||
|
||||
# DND List
|
||||
@router.post("/dnd", response_model=DNDResponse, status_code=status.HTTP_201_CREATED)
|
||||
def add_to_dnd(
|
||||
dnd: DNDCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
normalized_phone = normalize_phone(dnd.phone_e164)
|
||||
if not normalized_phone:
|
||||
raise HTTPException(status_code=400, detail="Invalid phone number")
|
||||
|
||||
existing = db.query(DNDList).filter(
|
||||
DNDList.user_id == current_user.id,
|
||||
DNDList.phone_e164 == normalized_phone
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Phone already in DND list")
|
||||
|
||||
db_dnd = DNDList(
|
||||
user_id=current_user.id,
|
||||
phone_e164=normalized_phone,
|
||||
reason=dnd.reason
|
||||
)
|
||||
db.add(db_dnd)
|
||||
db.commit()
|
||||
db.refresh(db_dnd)
|
||||
|
||||
return db_dnd
|
||||
|
||||
@router.get("/dnd", response_model=List[DNDResponse])
|
||||
def list_dnd(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return db.query(DNDList).filter(DNDList.user_id == current_user.id).all()
|
||||
|
||||
@router.delete("/dnd/{dnd_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def remove_from_dnd(
|
||||
dnd_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
dnd = db.query(DNDList).filter(
|
||||
DNDList.id == dnd_id,
|
||||
DNDList.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not dnd:
|
||||
raise HTTPException(status_code=404, detail="DND entry not found")
|
||||
|
||||
db.delete(dnd)
|
||||
db.commit()
|
||||
|
||||
return None
|
||||
202
backend/app/api/google.py
Normal file
202
backend/app/api/google.py
Normal file
@ -0,0 +1,202 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from google_auth_oauthlib.flow import Flow
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import build
|
||||
from typing import Optional
|
||||
from app.db.base import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
from app.models.contact import Contact, DNDList
|
||||
from app.models.google_token import GoogleToken
|
||||
from app.schemas.imports import GoogleAuthURL, GoogleSyncResponse
|
||||
from app.utils.encryption import encrypt_token, decrypt_token
|
||||
from app.utils.phone import normalize_phone
|
||||
import json
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
SCOPES = ['https://www.googleapis.com/auth/contacts.readonly']
|
||||
|
||||
def get_google_flow():
|
||||
"""Create Google OAuth flow"""
|
||||
return Flow.from_client_config(
|
||||
{
|
||||
"web": {
|
||||
"client_id": settings.GOOGLE_CLIENT_ID,
|
||||
"client_secret": settings.GOOGLE_CLIENT_SECRET,
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"redirect_uris": [settings.GOOGLE_REDIRECT_URI]
|
||||
}
|
||||
},
|
||||
scopes=SCOPES,
|
||||
redirect_uri=settings.GOOGLE_REDIRECT_URI
|
||||
)
|
||||
|
||||
@router.get("/google/start", response_model=GoogleAuthURL)
|
||||
def google_auth_start(current_user: User = Depends(get_current_user)):
|
||||
"""Start Google OAuth flow"""
|
||||
if not settings.GOOGLE_CLIENT_ID or not settings.GOOGLE_CLIENT_SECRET:
|
||||
raise HTTPException(status_code=500, detail="Google OAuth not configured")
|
||||
|
||||
flow = get_google_flow()
|
||||
authorization_url, state = flow.authorization_url(
|
||||
access_type='offline',
|
||||
include_granted_scopes='true',
|
||||
prompt='consent'
|
||||
)
|
||||
|
||||
return GoogleAuthURL(auth_url=authorization_url)
|
||||
|
||||
@router.get("/google/callback")
|
||||
async def google_auth_callback(
|
||||
code: str = Query(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Handle Google OAuth callback"""
|
||||
try:
|
||||
flow = get_google_flow()
|
||||
flow.fetch_token(code=code)
|
||||
|
||||
credentials = flow.credentials
|
||||
|
||||
# Store refresh token
|
||||
existing_token = db.query(GoogleToken).filter(
|
||||
GoogleToken.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
token_data = {
|
||||
'token': credentials.token,
|
||||
'refresh_token': credentials.refresh_token,
|
||||
'token_uri': credentials.token_uri,
|
||||
'client_id': credentials.client_id,
|
||||
'client_secret': credentials.client_secret,
|
||||
'scopes': credentials.scopes
|
||||
}
|
||||
|
||||
encrypted = encrypt_token(json.dumps(token_data))
|
||||
|
||||
if existing_token:
|
||||
existing_token.encrypted_token = encrypted
|
||||
else:
|
||||
token_obj = GoogleToken(
|
||||
user_id=current_user.id,
|
||||
encrypted_token=encrypted
|
||||
)
|
||||
db.add(token_obj)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"status": "success", "message": "Google account connected"}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"OAuth error: {str(e)}")
|
||||
|
||||
@router.post("/google/sync", response_model=GoogleSyncResponse)
|
||||
def google_sync_contacts(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Sync contacts from Google"""
|
||||
# Get stored token
|
||||
token_obj = db.query(GoogleToken).filter(
|
||||
GoogleToken.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not token_obj:
|
||||
raise HTTPException(status_code=400, detail="Google account not connected")
|
||||
|
||||
try:
|
||||
# Decrypt token
|
||||
token_data = json.loads(decrypt_token(token_obj.encrypted_token))
|
||||
|
||||
# Create credentials
|
||||
credentials = Credentials(
|
||||
token=token_data.get('token'),
|
||||
refresh_token=token_data.get('refresh_token'),
|
||||
token_uri=token_data.get('token_uri'),
|
||||
client_id=token_data.get('client_id'),
|
||||
client_secret=token_data.get('client_secret'),
|
||||
scopes=token_data.get('scopes')
|
||||
)
|
||||
|
||||
# Build People API service
|
||||
service = build('people', 'v1', credentials=credentials)
|
||||
|
||||
# Fetch contacts
|
||||
results = service.people().connections().list(
|
||||
resourceName='people/me',
|
||||
pageSize=1000,
|
||||
personFields='names,phoneNumbers,emailAddresses'
|
||||
).execute()
|
||||
|
||||
connections = results.get('connections', [])
|
||||
imported_count = 0
|
||||
|
||||
for person in connections:
|
||||
names = person.get('names', [])
|
||||
phones = person.get('phoneNumbers', [])
|
||||
emails = person.get('emailAddresses', [])
|
||||
|
||||
if not phones:
|
||||
continue
|
||||
|
||||
first_name = names[0].get('givenName') if names else None
|
||||
last_name = names[0].get('familyName') if names else None
|
||||
email = emails[0].get('value') if emails else None
|
||||
|
||||
for phone_obj in phones:
|
||||
phone_value = phone_obj.get('value', '').strip()
|
||||
if not phone_value:
|
||||
continue
|
||||
|
||||
normalized_phone = normalize_phone(phone_value)
|
||||
if not normalized_phone:
|
||||
continue
|
||||
|
||||
# Check DND
|
||||
dnd_entry = db.query(DNDList).filter(
|
||||
DNDList.user_id == current_user.id,
|
||||
DNDList.phone_e164 == normalized_phone
|
||||
).first()
|
||||
if dnd_entry:
|
||||
continue
|
||||
|
||||
# Check existing
|
||||
existing = db.query(Contact).filter(
|
||||
Contact.user_id == current_user.id,
|
||||
Contact.phone_e164 == normalized_phone
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
if first_name:
|
||||
existing.first_name = first_name
|
||||
if last_name:
|
||||
existing.last_name = last_name
|
||||
if email:
|
||||
existing.email = email
|
||||
else:
|
||||
contact = Contact(
|
||||
user_id=current_user.id,
|
||||
phone_e164=normalized_phone,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
email=email,
|
||||
opted_in=False,
|
||||
source="google"
|
||||
)
|
||||
db.add(contact)
|
||||
imported_count += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
return GoogleSyncResponse(
|
||||
status="success",
|
||||
contacts_imported=imported_count
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Sync error: {str(e)}")
|
||||
138
backend/app/api/imports.py
Normal file
138
backend/app/api/imports.py
Normal file
@ -0,0 +1,138 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
import pandas as pd
|
||||
import io
|
||||
from app.db.base import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.contact import Contact, DNDList
|
||||
from app.schemas.imports import ImportSummary
|
||||
from app.utils.phone import normalize_phone
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def process_import_file(
|
||||
df: pd.DataFrame,
|
||||
user_id: int,
|
||||
source: str,
|
||||
db: Session
|
||||
) -> ImportSummary:
|
||||
"""Process imported contacts dataframe"""
|
||||
summary = ImportSummary(
|
||||
total=len(df),
|
||||
created=0,
|
||||
updated=0,
|
||||
skipped=0,
|
||||
invalid=0,
|
||||
errors=[]
|
||||
)
|
||||
|
||||
# Normalize column names
|
||||
df.columns = [col.lower().strip() for col in df.columns]
|
||||
|
||||
# Check required columns
|
||||
if 'phone' not in df.columns:
|
||||
summary.errors.append("Missing required column: phone")
|
||||
summary.invalid = len(df)
|
||||
return summary
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
try:
|
||||
phone = str(row.get('phone', '')).strip()
|
||||
if not phone:
|
||||
summary.skipped += 1
|
||||
continue
|
||||
|
||||
# Normalize phone
|
||||
normalized_phone = normalize_phone(phone)
|
||||
if not normalized_phone:
|
||||
summary.invalid += 1
|
||||
summary.errors.append(f"Row {idx+1}: Invalid phone number {phone}")
|
||||
continue
|
||||
|
||||
# Check DND list
|
||||
dnd_entry = db.query(DNDList).filter(
|
||||
DNDList.user_id == user_id,
|
||||
DNDList.phone_e164 == normalized_phone
|
||||
).first()
|
||||
if dnd_entry:
|
||||
summary.skipped += 1
|
||||
continue
|
||||
|
||||
# Check if exists
|
||||
existing = db.query(Contact).filter(
|
||||
Contact.user_id == user_id,
|
||||
Contact.phone_e164 == normalized_phone
|
||||
).first()
|
||||
|
||||
first_name = str(row.get('first_name', '')).strip() or None
|
||||
last_name = str(row.get('last_name', '')).strip() or None
|
||||
email = str(row.get('email', '')).strip() or None
|
||||
opted_in_raw = str(row.get('opted_in', 'false')).lower()
|
||||
opted_in = opted_in_raw in ['true', '1', 'yes', 'y']
|
||||
|
||||
if existing:
|
||||
# Update existing
|
||||
if first_name:
|
||||
existing.first_name = first_name
|
||||
if last_name:
|
||||
existing.last_name = last_name
|
||||
if email:
|
||||
existing.email = email
|
||||
existing.opted_in = opted_in
|
||||
summary.updated += 1
|
||||
else:
|
||||
# Create new
|
||||
contact = Contact(
|
||||
user_id=user_id,
|
||||
phone_e164=normalized_phone,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
email=email,
|
||||
opted_in=opted_in,
|
||||
source=source
|
||||
)
|
||||
db.add(contact)
|
||||
summary.created += 1
|
||||
|
||||
except Exception as e:
|
||||
summary.invalid += 1
|
||||
summary.errors.append(f"Row {idx+1}: {str(e)}")
|
||||
|
||||
db.commit()
|
||||
return summary
|
||||
|
||||
@router.post("/excel", response_model=ImportSummary)
|
||||
async def import_excel(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
if not file.filename.endswith(('.xlsx', '.xls')):
|
||||
raise HTTPException(status_code=400, detail="File must be Excel format (.xlsx or .xls)")
|
||||
|
||||
try:
|
||||
contents = await file.read()
|
||||
# Read phone column as string to preserve '+' sign
|
||||
df = pd.read_excel(io.BytesIO(contents), dtype={'phone': str})
|
||||
return process_import_file(df, current_user.id, "excel", db)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Error processing file: {str(e)}")
|
||||
|
||||
@router.post("/csv", response_model=ImportSummary)
|
||||
async def import_csv(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
if not file.filename.endswith('.csv'):
|
||||
raise HTTPException(status_code=400, detail="File must be CSV format")
|
||||
|
||||
try:
|
||||
contents = await file.read()
|
||||
# Read phone column as string to preserve '+' sign
|
||||
df = pd.read_csv(io.StringIO(contents.decode('utf-8')), dtype={'phone': str})
|
||||
return process_import_file(df, current_user.id, "csv", db)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Error processing file: {str(e)}")
|
||||
205
backend/app/api/lists.py
Normal file
205
backend/app/api/lists.py
Normal file
@ -0,0 +1,205 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from typing import List
|
||||
from app.db.base import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.list import List as ContactList, ListMember
|
||||
from app.models.contact import Contact
|
||||
from app.schemas.list import (
|
||||
ListCreate, ListUpdate, ListResponse,
|
||||
ListMemberAdd, ListMemberRemove
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("", response_model=ListResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_list(
|
||||
list_data: ListCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
db_list = ContactList(
|
||||
user_id=current_user.id,
|
||||
name=list_data.name
|
||||
)
|
||||
db.add(db_list)
|
||||
db.commit()
|
||||
db.refresh(db_list)
|
||||
|
||||
db_list.member_count = 0
|
||||
return db_list
|
||||
|
||||
@router.get("", response_model=List[ListResponse])
|
||||
def list_lists(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
lists = db.query(ContactList).filter(ContactList.user_id == current_user.id).all()
|
||||
|
||||
# Add member counts
|
||||
for lst in lists:
|
||||
count = db.query(func.count(ListMember.id)).filter(
|
||||
ListMember.list_id == lst.id
|
||||
).scalar()
|
||||
lst.member_count = count or 0
|
||||
|
||||
return lists
|
||||
|
||||
@router.get("/{list_id}", response_model=ListResponse)
|
||||
def get_list(
|
||||
list_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
lst = db.query(ContactList).filter(
|
||||
ContactList.id == list_id,
|
||||
ContactList.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not lst:
|
||||
raise HTTPException(status_code=404, detail="List not found")
|
||||
|
||||
count = db.query(func.count(ListMember.id)).filter(
|
||||
ListMember.list_id == lst.id
|
||||
).scalar()
|
||||
lst.member_count = count or 0
|
||||
|
||||
return lst
|
||||
|
||||
@router.put("/{list_id}", response_model=ListResponse)
|
||||
def update_list(
|
||||
list_id: int,
|
||||
list_update: ListUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
lst = db.query(ContactList).filter(
|
||||
ContactList.id == list_id,
|
||||
ContactList.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not lst:
|
||||
raise HTTPException(status_code=404, detail="List not found")
|
||||
|
||||
lst.name = list_update.name
|
||||
db.commit()
|
||||
db.refresh(lst)
|
||||
|
||||
count = db.query(func.count(ListMember.id)).filter(
|
||||
ListMember.list_id == lst.id
|
||||
).scalar()
|
||||
lst.member_count = count or 0
|
||||
|
||||
return lst
|
||||
|
||||
@router.delete("/{list_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_list(
|
||||
list_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
lst = db.query(ContactList).filter(
|
||||
ContactList.id == list_id,
|
||||
ContactList.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not lst:
|
||||
raise HTTPException(status_code=404, detail="List not found")
|
||||
|
||||
db.delete(lst)
|
||||
db.commit()
|
||||
|
||||
return None
|
||||
|
||||
@router.post("/{list_id}/members", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def add_members(
|
||||
list_id: int,
|
||||
members: ListMemberAdd,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
lst = db.query(ContactList).filter(
|
||||
ContactList.id == list_id,
|
||||
ContactList.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not lst:
|
||||
raise HTTPException(status_code=404, detail="List not found")
|
||||
|
||||
# Verify all contacts belong to user
|
||||
contacts = db.query(Contact).filter(
|
||||
Contact.id.in_(members.contact_ids),
|
||||
Contact.user_id == current_user.id
|
||||
).all()
|
||||
|
||||
if len(contacts) != len(members.contact_ids):
|
||||
raise HTTPException(status_code=400, detail="Some contacts not found")
|
||||
|
||||
# Add members (skip duplicates)
|
||||
for contact_id in members.contact_ids:
|
||||
existing = db.query(ListMember).filter(
|
||||
ListMember.list_id == list_id,
|
||||
ListMember.contact_id == contact_id
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
member = ListMember(list_id=list_id, contact_id=contact_id)
|
||||
db.add(member)
|
||||
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
@router.delete("/{list_id}/members", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def remove_members(
|
||||
list_id: int,
|
||||
members: ListMemberRemove,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
lst = db.query(ContactList).filter(
|
||||
ContactList.id == list_id,
|
||||
ContactList.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not lst:
|
||||
raise HTTPException(status_code=404, detail="List not found")
|
||||
|
||||
db.query(ListMember).filter(
|
||||
ListMember.list_id == list_id,
|
||||
ListMember.contact_id.in_(members.contact_ids)
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
@router.get("/{list_id}/contacts", response_model=List[dict])
|
||||
def get_list_contacts(
|
||||
list_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
lst = db.query(ContactList).filter(
|
||||
ContactList.id == list_id,
|
||||
ContactList.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not lst:
|
||||
raise HTTPException(status_code=404, detail="List not found")
|
||||
|
||||
members = db.query(Contact).join(ListMember).filter(
|
||||
ListMember.list_id == list_id
|
||||
).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": c.id,
|
||||
"phone_e164": c.phone_e164,
|
||||
"first_name": c.first_name,
|
||||
"last_name": c.last_name,
|
||||
"email": c.email,
|
||||
"opted_in": c.opted_in
|
||||
}
|
||||
for c in members
|
||||
]
|
||||
79
backend/app/api/stats.py
Normal file
79
backend/app/api/stats.py
Normal file
@ -0,0 +1,79 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from app.db.base import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.contact import Contact
|
||||
from app.models.campaign import Campaign, CampaignRecipient
|
||||
from app.models.send_log import SendLog
|
||||
from app.models.list import List as ContactList
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("")
|
||||
def get_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get dashboard statistics"""
|
||||
|
||||
# Count contacts
|
||||
total_contacts = db.query(func.count(Contact.id)).filter(
|
||||
Contact.user_id == current_user.id
|
||||
).scalar()
|
||||
|
||||
opted_in_contacts = db.query(func.count(Contact.id)).filter(
|
||||
Contact.user_id == current_user.id,
|
||||
Contact.opted_in == True
|
||||
).scalar()
|
||||
|
||||
# Count lists
|
||||
total_lists = db.query(func.count(ContactList.id)).filter(
|
||||
ContactList.user_id == current_user.id
|
||||
).scalar()
|
||||
|
||||
# Count campaigns
|
||||
total_campaigns = db.query(func.count(Campaign.id)).filter(
|
||||
Campaign.user_id == current_user.id
|
||||
).scalar()
|
||||
|
||||
# Count messages sent
|
||||
total_sent = db.query(func.count(SendLog.id)).filter(
|
||||
SendLog.user_id == current_user.id,
|
||||
SendLog.status == "sent"
|
||||
).scalar()
|
||||
|
||||
# Recent campaigns
|
||||
recent_campaigns = db.query(Campaign).filter(
|
||||
Campaign.user_id == current_user.id
|
||||
).order_by(Campaign.created_at.desc()).limit(5).all()
|
||||
|
||||
recent_campaigns_data = []
|
||||
for campaign in recent_campaigns:
|
||||
total_recipients = db.query(func.count(CampaignRecipient.id)).filter(
|
||||
CampaignRecipient.campaign_id == campaign.id
|
||||
).scalar()
|
||||
|
||||
sent_count = db.query(func.count(CampaignRecipient.id)).filter(
|
||||
CampaignRecipient.campaign_id == campaign.id,
|
||||
CampaignRecipient.status.in_(["sent", "delivered", "read"])
|
||||
).scalar()
|
||||
|
||||
recent_campaigns_data.append({
|
||||
"id": campaign.id,
|
||||
"name": campaign.name,
|
||||
"status": campaign.status,
|
||||
"total_recipients": total_recipients,
|
||||
"sent_count": sent_count,
|
||||
"created_at": campaign.created_at
|
||||
})
|
||||
|
||||
return {
|
||||
"total_contacts": total_contacts,
|
||||
"opted_in_contacts": opted_in_contacts,
|
||||
"total_lists": total_lists,
|
||||
"total_campaigns": total_campaigns,
|
||||
"total_sent": total_sent,
|
||||
"recent_campaigns": recent_campaigns_data
|
||||
}
|
||||
96
backend/app/api/templates.py
Normal file
96
backend/app/api/templates.py
Normal file
@ -0,0 +1,96 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from app.db.base import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.template import Template
|
||||
from app.schemas.template import TemplateCreate, TemplateUpdate, TemplateResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_template(
|
||||
template: TemplateCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
db_template = Template(
|
||||
user_id=current_user.id,
|
||||
name=template.name,
|
||||
language=template.language,
|
||||
body_text=template.body_text,
|
||||
is_whatsapp_template=template.is_whatsapp_template,
|
||||
provider_template_name=template.provider_template_name
|
||||
)
|
||||
db.add(db_template)
|
||||
db.commit()
|
||||
db.refresh(db_template)
|
||||
|
||||
return db_template
|
||||
|
||||
@router.get("", response_model=List[TemplateResponse])
|
||||
def list_templates(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return db.query(Template).filter(Template.user_id == current_user.id).all()
|
||||
|
||||
@router.get("/{template_id}", response_model=TemplateResponse)
|
||||
def get_template(
|
||||
template_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
template = db.query(Template).filter(
|
||||
Template.id == template_id,
|
||||
Template.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
return template
|
||||
|
||||
@router.put("/{template_id}", response_model=TemplateResponse)
|
||||
def update_template(
|
||||
template_id: int,
|
||||
template_update: TemplateUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
template = db.query(Template).filter(
|
||||
Template.id == template_id,
|
||||
Template.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
update_data = template_update.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(template, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
|
||||
return template
|
||||
|
||||
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_template(
|
||||
template_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
template = db.query(Template).filter(
|
||||
Template.id == template_id,
|
||||
Template.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
db.delete(template)
|
||||
db.commit()
|
||||
|
||||
return None
|
||||
91
backend/app/api/webhooks.py
Normal file
91
backend/app/api/webhooks.py
Normal file
@ -0,0 +1,91 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.base import get_db
|
||||
from app.core.config import settings
|
||||
from app.models.campaign import CampaignRecipient, RecipientStatus
|
||||
import logging
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@router.get("/whatsapp")
|
||||
async def whatsapp_webhook_verify(
|
||||
request: Request,
|
||||
hub_mode: str = Query(None, alias="hub.mode"),
|
||||
hub_challenge: str = Query(None, alias="hub.challenge"),
|
||||
hub_verify_token: str = Query(None, alias="hub.verify_token")
|
||||
):
|
||||
"""
|
||||
Verify webhook for WhatsApp Cloud API.
|
||||
Meta will send a GET request with these parameters.
|
||||
"""
|
||||
if hub_mode == "subscribe" and hub_verify_token == settings.WHATSAPP_WEBHOOK_VERIFY_TOKEN:
|
||||
logger.info("Webhook verified successfully")
|
||||
return int(hub_challenge)
|
||||
else:
|
||||
raise HTTPException(status_code=403, detail="Verification failed")
|
||||
|
||||
@router.post("/whatsapp")
|
||||
async def whatsapp_webhook_handler(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Handle webhook updates from WhatsApp Cloud API.
|
||||
Updates message statuses (sent, delivered, read, failed).
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
logger.info(f"Webhook received: {body}")
|
||||
|
||||
# Parse WhatsApp webhook payload
|
||||
# Structure: https://developers.facebook.com/docs/whatsapp/cloud-api/webhooks/payload-examples
|
||||
|
||||
entry = body.get("entry", [])
|
||||
if not entry:
|
||||
return {"status": "no_entry"}
|
||||
|
||||
for item in entry:
|
||||
changes = item.get("changes", [])
|
||||
for change in changes:
|
||||
value = change.get("value", {})
|
||||
statuses = value.get("statuses", [])
|
||||
|
||||
for status_update in statuses:
|
||||
message_id = status_update.get("id")
|
||||
status = status_update.get("status") # sent, delivered, read, failed
|
||||
|
||||
if not message_id or not status:
|
||||
continue
|
||||
|
||||
# Find recipient by provider message ID
|
||||
recipient = db.query(CampaignRecipient).filter(
|
||||
CampaignRecipient.provider_message_id == message_id
|
||||
).first()
|
||||
|
||||
if not recipient:
|
||||
logger.warning(f"Recipient not found for message_id: {message_id}")
|
||||
continue
|
||||
|
||||
# Update status
|
||||
if status == "sent":
|
||||
recipient.status = RecipientStatus.SENT
|
||||
elif status == "delivered":
|
||||
recipient.status = RecipientStatus.DELIVERED
|
||||
elif status == "read":
|
||||
recipient.status = RecipientStatus.READ
|
||||
elif status == "failed":
|
||||
recipient.status = RecipientStatus.FAILED
|
||||
errors = status_update.get("errors", [])
|
||||
if errors:
|
||||
recipient.last_error = errors[0].get("message", "Unknown error")
|
||||
|
||||
logger.info(f"Updated message {message_id} to status {status}")
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Webhook error: {str(e)}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
85
backend/app/api/workers.py
Normal file
85
backend/app/api/workers.py
Normal file
@ -0,0 +1,85 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
from app.db.base import get_db
|
||||
from app.models.job import Job
|
||||
from app.models.campaign import Campaign, CampaignStatus
|
||||
from app.services.messaging import MessagingService
|
||||
import logging
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@router.post("/tick")
|
||||
def worker_tick(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Worker endpoint to process pending jobs.
|
||||
In production, this would be called by a cron job or replaced with Celery.
|
||||
For development, you can call this manually or set up a simple scheduler.
|
||||
"""
|
||||
# Get pending jobs
|
||||
now = datetime.utcnow()
|
||||
jobs = db.query(Job).filter(
|
||||
Job.status == "pending",
|
||||
Job.run_after <= now,
|
||||
Job.attempts < 3
|
||||
).limit(10).all()
|
||||
|
||||
processed = 0
|
||||
|
||||
for job in jobs:
|
||||
logger.info(f"Processing job {job.id} of type {job.type}")
|
||||
|
||||
job.status = "running"
|
||||
job.attempts += 1
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
if job.type == "send_campaign":
|
||||
campaign_id = job.payload_json.get("campaign_id")
|
||||
user_id = job.user_id
|
||||
|
||||
messaging_service = MessagingService(db)
|
||||
result = messaging_service.send_campaign_batch(campaign_id, user_id)
|
||||
|
||||
if result["status"] == "done" or result.get("remaining", 0) == 0:
|
||||
# Campaign done
|
||||
job.status = "completed"
|
||||
|
||||
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
||||
if campaign:
|
||||
campaign.status = CampaignStatus.DONE
|
||||
else:
|
||||
# More batches to process
|
||||
job.status = "pending"
|
||||
job.run_after = datetime.utcnow() + timedelta(minutes=1)
|
||||
|
||||
processed += 1
|
||||
|
||||
else:
|
||||
job.status = "failed"
|
||||
job.last_error = f"Unknown job type: {job.type}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Job {job.id} failed: {str(e)}")
|
||||
job.last_error = str(e)
|
||||
|
||||
if job.attempts >= 3:
|
||||
job.status = "failed"
|
||||
|
||||
# Mark campaign as failed
|
||||
if job.type == "send_campaign":
|
||||
campaign_id = job.payload_json.get("campaign_id")
|
||||
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
||||
if campaign:
|
||||
campaign.status = CampaignStatus.FAILED
|
||||
else:
|
||||
job.status = "pending"
|
||||
job.run_after = datetime.utcnow() + timedelta(minutes=5)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"processed": processed
|
||||
}
|
||||
1
backend/app/core/__init__.py
Normal file
1
backend/app/core/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Empty file
|
||||
44
backend/app/core/config.py
Normal file
44
backend/app/core/config.py
Normal file
@ -0,0 +1,44 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import List
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Database
|
||||
DATABASE_URL: str = "postgresql://whatssender:whatssender123@localhost:5432/whatssender"
|
||||
|
||||
# Security
|
||||
JWT_SECRET: str = "your-secret-key-change-in-production"
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:5173"
|
||||
|
||||
# Google OAuth
|
||||
GOOGLE_CLIENT_ID: str = ""
|
||||
GOOGLE_CLIENT_SECRET: str = ""
|
||||
GOOGLE_REDIRECT_URI: str = "http://localhost:8000/api/imports/google/callback"
|
||||
GOOGLE_TOKEN_ENCRYPTION_KEY: str = "" # Fernet key
|
||||
|
||||
# WhatsApp Provider
|
||||
WHATSAPP_PROVIDER: str = "mock" # mock, cloud, or telegram
|
||||
WHATSAPP_CLOUD_ACCESS_TOKEN: str = ""
|
||||
WHATSAPP_CLOUD_PHONE_NUMBER_ID: str = ""
|
||||
WHATSAPP_WEBHOOK_VERIFY_TOKEN: str = ""
|
||||
|
||||
# Telegram Provider (for testing)
|
||||
TELEGRAM_BOT_TOKEN: str = ""
|
||||
|
||||
# Rate Limiting
|
||||
MAX_MESSAGES_PER_MINUTE: int = 20
|
||||
BATCH_SIZE: int = 10
|
||||
DAILY_LIMIT_PER_CAMPAIGN: int = 1000
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> List[str]:
|
||||
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
settings = Settings()
|
||||
45
backend/app/core/deps.py
Normal file
45
backend/app/core/deps.py
Normal file
@ -0,0 +1,45 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.security import decode_access_token
|
||||
from app.db.base import get_db
|
||||
from app.models.user import User
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
token = credentials.credentials
|
||||
payload = decode_access_token(token)
|
||||
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials"
|
||||
)
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials"
|
||||
)
|
||||
|
||||
user = db.query(User).filter(User.id == int(user_id)).first()
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
async def get_current_admin_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
return current_user
|
||||
30
backend/app/core/security.py
Normal file
30
backend/app/core/security.py
Normal file
@ -0,0 +1,30 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from app.core.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def decode_access_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
16
backend/app/db/base.py
Normal file
16
backend/app/db/base.py
Normal file
@ -0,0 +1,16 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
50
backend/app/main.py
Normal file
50
backend/app/main.py
Normal file
@ -0,0 +1,50 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.config import settings
|
||||
from app.api import auth, contacts, lists, templates, campaigns, imports, google, webhooks, stats, workers
|
||||
import logging
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
app = FastAPI(
|
||||
title="WhatsApp Campaign Manager API",
|
||||
description="Production-quality API for managing WhatsApp campaigns with compliance",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
|
||||
app.include_router(contacts.router, prefix="/api/contacts", tags=["Contacts"])
|
||||
app.include_router(lists.router, prefix="/api/lists", tags=["Lists"])
|
||||
app.include_router(templates.router, prefix="/api/templates", tags=["Templates"])
|
||||
app.include_router(campaigns.router, prefix="/api/campaigns", tags=["Campaigns"])
|
||||
app.include_router(imports.router, prefix="/api/imports", tags=["Imports"])
|
||||
app.include_router(google.router, prefix="/api/imports", tags=["Google OAuth"])
|
||||
app.include_router(webhooks.router, prefix="/api/webhooks", tags=["Webhooks"])
|
||||
app.include_router(stats.router, prefix="/api/stats", tags=["Statistics"])
|
||||
app.include_router(workers.router, prefix="/api/workers", tags=["Workers"])
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {
|
||||
"name": "WhatsApp Campaign Manager API",
|
||||
"version": "1.0.0",
|
||||
"status": "running"
|
||||
}
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "healthy"}
|
||||
24
backend/app/models/__init__.py
Normal file
24
backend/app/models/__init__.py
Normal file
@ -0,0 +1,24 @@
|
||||
from app.models.user import User
|
||||
from app.models.contact import Contact, ContactTag, ContactTagMap, DNDList
|
||||
from app.models.list import List, ListMember
|
||||
from app.models.template import Template
|
||||
from app.models.campaign import Campaign, CampaignRecipient
|
||||
from app.models.send_log import SendLog
|
||||
from app.models.job import Job
|
||||
from app.models.google_token import GoogleToken
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Contact",
|
||||
"ContactTag",
|
||||
"ContactTagMap",
|
||||
"DNDList",
|
||||
"List",
|
||||
"ListMember",
|
||||
"Template",
|
||||
"Campaign",
|
||||
"CampaignRecipient",
|
||||
"SendLog",
|
||||
"Job",
|
||||
"GoogleToken",
|
||||
]
|
||||
44
backend/app/models/campaign.py
Normal file
44
backend/app/models/campaign.py
Normal file
@ -0,0 +1,44 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum, Index, Text
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.base import Base
|
||||
import enum
|
||||
|
||||
class CampaignStatus(str, enum.Enum):
|
||||
DRAFT = "draft"
|
||||
SCHEDULED = "scheduled"
|
||||
SENDING = "sending"
|
||||
DONE = "done"
|
||||
FAILED = "failed"
|
||||
|
||||
class RecipientStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
SENT = "sent"
|
||||
DELIVERED = "delivered"
|
||||
READ = "read"
|
||||
FAILED = "failed"
|
||||
|
||||
class Campaign(Base):
|
||||
__tablename__ = "campaigns"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
template_id = Column(Integer, ForeignKey("templates.id", ondelete="SET NULL"), nullable=True)
|
||||
list_id = Column(Integer, ForeignKey("lists.id", ondelete="SET NULL"), nullable=True)
|
||||
status = Column(Enum(CampaignStatus), default=CampaignStatus.DRAFT, nullable=False, index=True)
|
||||
scheduled_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
class CampaignRecipient(Base):
|
||||
__tablename__ = "campaign_recipients"
|
||||
__table_args__ = (
|
||||
Index('ix_campaign_recipients_campaign_status', 'campaign_id', 'status'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
campaign_id = Column(Integer, ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
contact_id = Column(Integer, ForeignKey("contacts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
status = Column(Enum(RecipientStatus), default=RecipientStatus.PENDING, nullable=False)
|
||||
provider_message_id = Column(String, nullable=True)
|
||||
last_error = Column(Text, nullable=True)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
52
backend/app/models/contact.py
Normal file
52
backend/app/models/contact.py
Normal file
@ -0,0 +1,52 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint, Index, Text
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.base import Base
|
||||
|
||||
class Contact(Base):
|
||||
__tablename__ = "contacts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'phone_e164', name='uq_user_phone'),
|
||||
Index('ix_contacts_user_phone', 'user_id', 'phone_e164'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
phone_e164 = Column(String, nullable=False)
|
||||
first_name = Column(String, nullable=True)
|
||||
last_name = Column(String, nullable=True)
|
||||
email = Column(String, nullable=True)
|
||||
opted_in = Column(Boolean, default=False, nullable=False)
|
||||
conversation_window_open = Column(Boolean, default=False, nullable=False)
|
||||
source = Column(String, nullable=True) # excel, csv, google, manual
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
class ContactTag(Base):
|
||||
__tablename__ = "contact_tags"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
class ContactTagMap(Base):
|
||||
__tablename__ = "contact_tag_map"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('contact_id', 'tag_id', name='uq_contact_tag'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
contact_id = Column(Integer, ForeignKey("contacts.id", ondelete="CASCADE"), nullable=False)
|
||||
tag_id = Column(Integer, ForeignKey("contact_tags.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
class DNDList(Base):
|
||||
__tablename__ = "dnd_list"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'phone_e164', name='uq_dnd_user_phone'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
phone_e164 = Column(String, nullable=False, index=True)
|
||||
reason = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
12
backend/app/models/google_token.py
Normal file
12
backend/app/models/google_token.py
Normal file
@ -0,0 +1,12 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.base import Base
|
||||
|
||||
class GoogleToken(Base):
|
||||
__tablename__ = "google_tokens"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, unique=True)
|
||||
encrypted_token = Column(Text, nullable=False) # Encrypted refresh token
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
20
backend/app/models/job.py
Normal file
20
backend/app/models/job.py
Normal file
@ -0,0 +1,20 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, JSON, Text, Index
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.base import Base
|
||||
|
||||
class Job(Base):
|
||||
__tablename__ = "jobs"
|
||||
__table_args__ = (
|
||||
Index('ix_jobs_status_run_after', 'status', 'run_after'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
type = Column(String, nullable=False) # send_campaign, import_google, etc.
|
||||
payload_json = Column(JSON, nullable=False)
|
||||
status = Column(String, default="pending", nullable=False) # pending, running, completed, failed
|
||||
attempts = Column(Integer, default=0, nullable=False)
|
||||
run_after = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
last_error = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
21
backend/app/models/list.py
Normal file
21
backend/app/models/list.py
Normal file
@ -0,0 +1,21 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.base import Base
|
||||
|
||||
class List(Base):
|
||||
__tablename__ = "lists"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
class ListMember(Base):
|
||||
__tablename__ = "list_members"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('list_id', 'contact_id', name='uq_list_contact'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
list_id = Column(Integer, ForeignKey("lists.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
contact_id = Column(Integer, ForeignKey("contacts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
16
backend/app/models/send_log.py
Normal file
16
backend/app/models/send_log.py
Normal file
@ -0,0 +1,16 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, JSON
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.base import Base
|
||||
|
||||
class SendLog(Base):
|
||||
__tablename__ = "send_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
campaign_id = Column(Integer, ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
contact_id = Column(Integer, ForeignKey("contacts.id", ondelete="CASCADE"), nullable=True)
|
||||
provider = Column(String, nullable=False)
|
||||
request_payload_json = Column(JSON, nullable=True)
|
||||
response_payload_json = Column(JSON, nullable=True)
|
||||
status = Column(String, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
15
backend/app/models/template.py
Normal file
15
backend/app/models/template.py
Normal file
@ -0,0 +1,15 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.base import Base
|
||||
|
||||
class Template(Base):
|
||||
__tablename__ = "templates"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
language = Column(String, default="en", nullable=False)
|
||||
body_text = Column(Text, nullable=False)
|
||||
is_whatsapp_template = Column(Boolean, default=False, nullable=False)
|
||||
provider_template_name = Column(String, nullable=True) # Name registered in WhatsApp Business Manager
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
17
backend/app/models/user.py
Normal file
17
backend/app/models/user.py
Normal file
@ -0,0 +1,17 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Enum
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.base import Base
|
||||
import enum
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
ADMIN = "admin"
|
||||
USER = "user"
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String, unique=True, nullable=False, index=True)
|
||||
password_hash = Column(String, nullable=False)
|
||||
role = Column(Enum(UserRole), default=UserRole.USER, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
18
backend/app/providers/__init__.py
Normal file
18
backend/app/providers/__init__.py
Normal file
@ -0,0 +1,18 @@
|
||||
from app.providers.base import BaseProvider
|
||||
from app.providers.mock import MockProvider
|
||||
from app.providers.whatsapp_cloud import WhatsAppCloudProvider
|
||||
from app.providers.telegram import TelegramProvider
|
||||
from app.core.config import settings
|
||||
|
||||
def get_provider() -> BaseProvider:
|
||||
"""Get the configured provider instance"""
|
||||
provider_name = settings.WHATSAPP_PROVIDER.lower()
|
||||
|
||||
if provider_name == "mock":
|
||||
return MockProvider()
|
||||
elif provider_name == "cloud":
|
||||
return WhatsAppCloudProvider()
|
||||
elif provider_name == "telegram":
|
||||
return TelegramProvider()
|
||||
else:
|
||||
raise ValueError(f"Unknown provider: {provider_name}")
|
||||
34
backend/app/providers/base.py
Normal file
34
backend/app/providers/base.py
Normal file
@ -0,0 +1,34 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
class BaseProvider(ABC):
|
||||
"""Base class for WhatsApp messaging providers"""
|
||||
|
||||
@abstractmethod
|
||||
def send_message(
|
||||
self,
|
||||
to: str,
|
||||
template_name: Optional[str],
|
||||
template_body: str,
|
||||
variables: Dict[str, str],
|
||||
language: str = "en"
|
||||
) -> str:
|
||||
"""
|
||||
Send a WhatsApp message.
|
||||
|
||||
Args:
|
||||
to: Phone number in E.164 format
|
||||
template_name: WhatsApp template name (if using approved template)
|
||||
template_body: Message body text
|
||||
variables: Variables to substitute in template
|
||||
language: Language code
|
||||
|
||||
Returns:
|
||||
Provider message ID
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_provider_name(self) -> str:
|
||||
"""Get provider name"""
|
||||
pass
|
||||
30
backend/app/providers/mock.py
Normal file
30
backend/app/providers/mock.py
Normal file
@ -0,0 +1,30 @@
|
||||
import uuid
|
||||
import random
|
||||
from typing import Dict, Any, Optional
|
||||
from app.providers.base import BaseProvider
|
||||
|
||||
class MockProvider(BaseProvider):
|
||||
"""Mock provider for testing without external API calls"""
|
||||
|
||||
def send_message(
|
||||
self,
|
||||
to: str,
|
||||
template_name: Optional[str],
|
||||
template_body: str,
|
||||
variables: Dict[str, str],
|
||||
language: str = "en"
|
||||
) -> str:
|
||||
"""
|
||||
Simulate sending a message.
|
||||
Returns a fake message ID.
|
||||
"""
|
||||
# Simulate message ID
|
||||
message_id = f"mock_{uuid.uuid4().hex[:16]}"
|
||||
|
||||
# Simulate random delivery statuses
|
||||
# In a real implementation, you'd track these and update via webhooks
|
||||
|
||||
return message_id
|
||||
|
||||
def get_provider_name(self) -> str:
|
||||
return "mock"
|
||||
89
backend/app/providers/telegram.py
Normal file
89
backend/app/providers/telegram.py
Normal file
@ -0,0 +1,89 @@
|
||||
import requests
|
||||
from typing import Dict, Any, Optional
|
||||
from app.providers.base import BaseProvider
|
||||
from app.core.config import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class TelegramProvider(BaseProvider):
|
||||
"""
|
||||
Telegram Bot API provider for testing.
|
||||
|
||||
Setup:
|
||||
1. Talk to @BotFather on Telegram
|
||||
2. Create new bot with /newbot
|
||||
3. Copy the bot token
|
||||
4. Set TELEGRAM_BOT_TOKEN in .env
|
||||
5. Start a chat with your bot
|
||||
6. Use your Telegram user ID as the phone number in contacts
|
||||
|
||||
To get your Telegram user ID:
|
||||
- Message @userinfobot on Telegram
|
||||
- OR use https://api.telegram.org/bot<TOKEN>/getUpdates after messaging your bot
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.bot_token = settings.TELEGRAM_BOT_TOKEN
|
||||
self.base_url = f"https://api.telegram.org/bot{self.bot_token}"
|
||||
|
||||
def send_message(
|
||||
self,
|
||||
to: str,
|
||||
template_name: Optional[str],
|
||||
template_body: str,
|
||||
variables: Dict[str, str],
|
||||
language: str = "en"
|
||||
) -> str:
|
||||
"""
|
||||
Send a message via Telegram Bot API.
|
||||
|
||||
Args:
|
||||
to: Telegram user ID or chat ID (stored in phone_e164 field)
|
||||
template_name: Ignored for Telegram (no template system)
|
||||
template_body: Message text with variables like {{first_name}}
|
||||
variables: Dict of variables to replace in template_body
|
||||
language: Ignored for Telegram
|
||||
|
||||
Returns:
|
||||
message_id: Telegram message ID as string
|
||||
"""
|
||||
# Replace variables in template
|
||||
message_text = template_body
|
||||
for key, value in variables.items():
|
||||
message_text = message_text.replace(f"{{{{{key}}}}}", value)
|
||||
|
||||
# Prepare request
|
||||
url = f"{self.base_url}/sendMessage"
|
||||
payload = {
|
||||
"chat_id": to,
|
||||
"text": message_text,
|
||||
"parse_mode": "HTML" # Support basic formatting
|
||||
}
|
||||
|
||||
logger.info(f"Sending Telegram message to chat_id: {to}")
|
||||
|
||||
try:
|
||||
response = requests.post(url, json=payload, timeout=10)
|
||||
|
||||
# Get response body for debugging
|
||||
result = response.json()
|
||||
|
||||
if not response.ok or not result.get("ok"):
|
||||
error_desc = result.get("description", "Unknown error")
|
||||
error_code = result.get("error_code", 0)
|
||||
logger.error(f"Telegram API error {error_code}: {error_desc}")
|
||||
logger.error(f"Request payload: {payload}")
|
||||
raise Exception(f"Telegram API error {error_code}: {error_desc}")
|
||||
|
||||
message_id = str(result["result"]["message_id"])
|
||||
logger.info(f"Telegram message sent successfully. Message ID: {message_id}")
|
||||
|
||||
return message_id
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Telegram API request failed: {str(e)}")
|
||||
raise Exception(f"Failed to send Telegram message: {str(e)}")
|
||||
|
||||
def get_provider_name(self) -> str:
|
||||
return "telegram"
|
||||
95
backend/app/providers/whatsapp_cloud.py
Normal file
95
backend/app/providers/whatsapp_cloud.py
Normal file
@ -0,0 +1,95 @@
|
||||
import httpx
|
||||
from typing import Dict, Any, Optional
|
||||
from app.providers.base import BaseProvider
|
||||
from app.core.config import settings
|
||||
|
||||
class WhatsAppCloudProvider(BaseProvider):
|
||||
"""WhatsApp Cloud API provider (Meta)"""
|
||||
|
||||
def __init__(self):
|
||||
self.access_token = settings.WHATSAPP_CLOUD_ACCESS_TOKEN
|
||||
self.phone_number_id = settings.WHATSAPP_CLOUD_PHONE_NUMBER_ID
|
||||
self.base_url = "https://graph.facebook.com/v18.0"
|
||||
|
||||
def send_message(
|
||||
self,
|
||||
to: str,
|
||||
template_name: Optional[str],
|
||||
template_body: str,
|
||||
variables: Dict[str, str],
|
||||
language: str = "en"
|
||||
) -> str:
|
||||
"""
|
||||
Send message via WhatsApp Cloud API.
|
||||
|
||||
If template_name is provided, sends a template message.
|
||||
Otherwise, sends a text message.
|
||||
"""
|
||||
url = f"{self.base_url}/{self.phone_number_id}/messages"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.access_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
if template_name:
|
||||
# Send template message
|
||||
# Build template parameters
|
||||
components = []
|
||||
if variables:
|
||||
parameters = [
|
||||
{"type": "text", "text": value}
|
||||
for value in variables.values()
|
||||
]
|
||||
components.append({
|
||||
"type": "body",
|
||||
"parameters": parameters
|
||||
})
|
||||
|
||||
payload = {
|
||||
"messaging_product": "whatsapp",
|
||||
"to": to.replace("+", ""),
|
||||
"type": "template",
|
||||
"template": {
|
||||
"name": template_name,
|
||||
"language": {
|
||||
"code": language
|
||||
},
|
||||
"components": components
|
||||
}
|
||||
}
|
||||
else:
|
||||
# Send text message (only for contacts with conversation window)
|
||||
# Substitute variables in body
|
||||
message_text = template_body
|
||||
for key, value in variables.items():
|
||||
message_text = message_text.replace(f"{{{{{key}}}}}", value)
|
||||
|
||||
payload = {
|
||||
"messaging_product": "whatsapp",
|
||||
"to": to.replace("+", ""),
|
||||
"type": "text",
|
||||
"text": {
|
||||
"body": message_text
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
response = client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
message_id = data.get("messages", [{}])[0].get("id")
|
||||
|
||||
if not message_id:
|
||||
raise Exception("No message ID in response")
|
||||
|
||||
return message_id
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise Exception(f"WhatsApp API error: {e.response.status_code} - {e.response.text}")
|
||||
except Exception as e:
|
||||
raise Exception(f"Send error: {str(e)}")
|
||||
|
||||
def get_provider_name(self) -> str:
|
||||
return "whatsapp_cloud"
|
||||
1
backend/app/schemas/__init__.py
Normal file
1
backend/app/schemas/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Empty file
|
||||
46
backend/app/schemas/campaign.py
Normal file
46
backend/app/schemas/campaign.py
Normal file
@ -0,0 +1,46 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
class CampaignCreate(BaseModel):
|
||||
name: str
|
||||
template_id: int
|
||||
list_id: int
|
||||
scheduled_at: Optional[datetime] = None
|
||||
|
||||
class CampaignUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
class CampaignResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
template_id: Optional[int]
|
||||
list_id: Optional[int]
|
||||
status: str
|
||||
scheduled_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class RecipientResponse(BaseModel):
|
||||
id: int
|
||||
campaign_id: int
|
||||
contact_id: int
|
||||
status: str
|
||||
provider_message_id: Optional[str]
|
||||
last_error: Optional[str]
|
||||
updated_at: datetime
|
||||
contact_phone: Optional[str] = None
|
||||
contact_name: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class CampaignPreview(BaseModel):
|
||||
total_recipients: int
|
||||
opted_in_count: int
|
||||
dnd_count: int
|
||||
eligible_count: int
|
||||
sample_contacts: List[dict]
|
||||
56
backend/app/schemas/contact.py
Normal file
56
backend/app/schemas/contact.py
Normal file
@ -0,0 +1,56 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
class ContactBase(BaseModel):
|
||||
phone_e164: str
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
opted_in: bool = False
|
||||
conversation_window_open: bool = False
|
||||
|
||||
class ContactCreate(ContactBase):
|
||||
pass
|
||||
|
||||
class ContactUpdate(BaseModel):
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
opted_in: Optional[bool] = None
|
||||
conversation_window_open: Optional[bool] = None
|
||||
|
||||
class ContactResponse(ContactBase):
|
||||
id: int
|
||||
user_id: int
|
||||
source: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
tags: List[str] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class ContactTagCreate(BaseModel):
|
||||
name: str
|
||||
|
||||
class ContactTagResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class DNDCreate(BaseModel):
|
||||
phone_e164: str
|
||||
reason: Optional[str] = None
|
||||
|
||||
class DNDResponse(BaseModel):
|
||||
id: int
|
||||
phone_e164: str
|
||||
reason: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
17
backend/app/schemas/imports.py
Normal file
17
backend/app/schemas/imports.py
Normal file
@ -0,0 +1,17 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
class ImportSummary(BaseModel):
|
||||
total: int
|
||||
created: int
|
||||
updated: int
|
||||
skipped: int
|
||||
invalid: int
|
||||
errors: List[str] = []
|
||||
|
||||
class GoogleAuthURL(BaseModel):
|
||||
auth_url: str
|
||||
|
||||
class GoogleSyncResponse(BaseModel):
|
||||
status: str
|
||||
contacts_imported: int
|
||||
24
backend/app/schemas/list.py
Normal file
24
backend/app/schemas/list.py
Normal file
@ -0,0 +1,24 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
class ListCreate(BaseModel):
|
||||
name: str
|
||||
|
||||
class ListUpdate(BaseModel):
|
||||
name: str
|
||||
|
||||
class ListResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
created_at: datetime
|
||||
member_count: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class ListMemberAdd(BaseModel):
|
||||
contact_ids: List[int]
|
||||
|
||||
class ListMemberRemove(BaseModel):
|
||||
contact_ids: List[int]
|
||||
29
backend/app/schemas/template.py
Normal file
29
backend/app/schemas/template.py
Normal file
@ -0,0 +1,29 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
class TemplateCreate(BaseModel):
|
||||
name: str
|
||||
language: str = "en"
|
||||
body_text: str
|
||||
is_whatsapp_template: bool = False
|
||||
provider_template_name: Optional[str] = None
|
||||
|
||||
class TemplateUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
body_text: Optional[str] = None
|
||||
is_whatsapp_template: Optional[bool] = None
|
||||
provider_template_name: Optional[str] = None
|
||||
|
||||
class TemplateResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
language: str
|
||||
body_text: str
|
||||
is_whatsapp_template: bool
|
||||
provider_template_name: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
27
backend/app/schemas/user.py
Normal file
27
backend/app/schemas/user.py
Normal file
@ -0,0 +1,27 @@
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
role: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
class TokenData(BaseModel):
|
||||
user_id: Optional[int] = None
|
||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Empty file
|
||||
251
backend/app/services/messaging.py
Normal file
251
backend/app/services/messaging.py
Normal file
@ -0,0 +1,251 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Optional
|
||||
import re
|
||||
from app.models.contact import Contact, DNDList
|
||||
from app.models.template import Template
|
||||
from app.models.campaign import Campaign, CampaignRecipient, RecipientStatus
|
||||
from app.models.send_log import SendLog
|
||||
from app.providers import get_provider
|
||||
from app.core.config import settings
|
||||
import time
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MessagingService:
|
||||
"""Service for sending WhatsApp messages with rate limiting and compliance"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.provider = get_provider()
|
||||
|
||||
def extract_variables(self, template_text: str) -> list:
|
||||
"""Extract variable names from template text like {{first_name}}"""
|
||||
pattern = r'\{\{(\w+)\}\}'
|
||||
return re.findall(pattern, template_text)
|
||||
|
||||
def build_variables(self, template_text: str, contact: Contact) -> Dict[str, str]:
|
||||
"""Build variable dict from contact data"""
|
||||
variables = {}
|
||||
var_names = self.extract_variables(template_text)
|
||||
|
||||
for var_name in var_names:
|
||||
if var_name == 'first_name':
|
||||
variables[var_name] = contact.first_name or ''
|
||||
elif var_name == 'last_name':
|
||||
variables[var_name] = contact.last_name or ''
|
||||
elif var_name == 'email':
|
||||
variables[var_name] = contact.email or ''
|
||||
elif var_name == 'phone':
|
||||
variables[var_name] = contact.phone_e164 or ''
|
||||
else:
|
||||
variables[var_name] = ''
|
||||
|
||||
return variables
|
||||
|
||||
def check_daily_limit(self, campaign_id: int, user_id: int) -> bool:
|
||||
"""Check if daily limit has been reached for campaign"""
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
count = self.db.query(SendLog).filter(
|
||||
SendLog.campaign_id == campaign_id,
|
||||
SendLog.user_id == user_id,
|
||||
SendLog.created_at >= today
|
||||
).count()
|
||||
|
||||
return count < settings.DAILY_LIMIT_PER_CAMPAIGN
|
||||
|
||||
def can_send_to_contact(
|
||||
self,
|
||||
contact: Contact,
|
||||
template: Template,
|
||||
user_id: int
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Check if we can send to a contact based on compliance rules.
|
||||
|
||||
Returns: (can_send, reason_if_not)
|
||||
"""
|
||||
# Check if opted in
|
||||
if not contact.opted_in:
|
||||
# If not opted in, MUST use approved WhatsApp template
|
||||
if not template.is_whatsapp_template:
|
||||
return False, "Contact not opted in; must use approved template"
|
||||
|
||||
# Check DND list
|
||||
dnd_entry = self.db.query(DNDList).filter(
|
||||
DNDList.user_id == user_id,
|
||||
DNDList.phone_e164 == contact.phone_e164
|
||||
).first()
|
||||
if dnd_entry:
|
||||
return False, "Contact in DND list"
|
||||
|
||||
# If using free-form message (not WhatsApp template), check conversation window
|
||||
if not template.is_whatsapp_template:
|
||||
if not contact.conversation_window_open:
|
||||
return False, "No conversation window; must use approved template"
|
||||
|
||||
return True, None
|
||||
|
||||
def send_single_message(
|
||||
self,
|
||||
recipient: CampaignRecipient,
|
||||
campaign: Campaign,
|
||||
template: Template,
|
||||
contact: Contact,
|
||||
user_id: int
|
||||
) -> bool:
|
||||
"""
|
||||
Send a single message with full compliance checks.
|
||||
|
||||
Returns: True if sent successfully, False otherwise
|
||||
"""
|
||||
# Check compliance
|
||||
can_send, reason = self.can_send_to_contact(contact, template, user_id)
|
||||
if not can_send:
|
||||
recipient.status = RecipientStatus.FAILED
|
||||
recipient.last_error = reason
|
||||
self.db.commit()
|
||||
return False
|
||||
|
||||
# Build variables
|
||||
variables = self.build_variables(template.body_text, contact)
|
||||
|
||||
# Prepare request
|
||||
request_payload = {
|
||||
"to": contact.phone_e164,
|
||||
"template_name": template.provider_template_name if template.is_whatsapp_template else None,
|
||||
"template_body": template.body_text,
|
||||
"variables": variables,
|
||||
"language": template.language
|
||||
}
|
||||
|
||||
try:
|
||||
# Send via provider
|
||||
message_id = self.provider.send_message(
|
||||
to=contact.phone_e164,
|
||||
template_name=template.provider_template_name if template.is_whatsapp_template else None,
|
||||
template_body=template.body_text,
|
||||
variables=variables,
|
||||
language=template.language
|
||||
)
|
||||
|
||||
# Update recipient status
|
||||
recipient.status = RecipientStatus.SENT
|
||||
recipient.provider_message_id = message_id
|
||||
recipient.last_error = None
|
||||
|
||||
# Log send
|
||||
send_log = SendLog(
|
||||
user_id=user_id,
|
||||
campaign_id=campaign.id,
|
||||
contact_id=contact.id,
|
||||
provider=self.provider.get_provider_name(),
|
||||
request_payload_json=request_payload,
|
||||
response_payload_json={"message_id": message_id},
|
||||
status="sent"
|
||||
)
|
||||
self.db.add(send_log)
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Sent message to {contact.phone_e164}, message_id: {message_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
logger.error(f"Error sending to {contact.phone_e164}: {error_msg}")
|
||||
|
||||
recipient.status = RecipientStatus.FAILED
|
||||
recipient.last_error = error_msg
|
||||
|
||||
# Log failure
|
||||
send_log = SendLog(
|
||||
user_id=user_id,
|
||||
campaign_id=campaign.id,
|
||||
contact_id=contact.id,
|
||||
provider=self.provider.get_provider_name(),
|
||||
request_payload_json=request_payload,
|
||||
response_payload_json={"error": error_msg},
|
||||
status="failed"
|
||||
)
|
||||
self.db.add(send_log)
|
||||
self.db.commit()
|
||||
|
||||
return False
|
||||
|
||||
def send_campaign_batch(
|
||||
self,
|
||||
campaign_id: int,
|
||||
user_id: int,
|
||||
batch_size: Optional[int] = None
|
||||
) -> dict:
|
||||
"""
|
||||
Send a batch of messages for a campaign with rate limiting.
|
||||
|
||||
Returns: stats dict
|
||||
"""
|
||||
batch_size = batch_size or settings.BATCH_SIZE
|
||||
|
||||
# Get campaign
|
||||
campaign = self.db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
||||
if not campaign:
|
||||
raise ValueError("Campaign not found")
|
||||
|
||||
# Get template
|
||||
template = self.db.query(Template).filter(Template.id == campaign.template_id).first()
|
||||
if not template:
|
||||
raise ValueError("Template not found")
|
||||
|
||||
# Check daily limit
|
||||
if not self.check_daily_limit(campaign_id, user_id):
|
||||
return {
|
||||
"status": "limit_reached",
|
||||
"message": "Daily limit reached for this campaign"
|
||||
}
|
||||
|
||||
# Get pending recipients
|
||||
recipients = self.db.query(CampaignRecipient).filter(
|
||||
CampaignRecipient.campaign_id == campaign_id,
|
||||
CampaignRecipient.status == RecipientStatus.PENDING
|
||||
).limit(batch_size).all()
|
||||
|
||||
if not recipients:
|
||||
return {
|
||||
"status": "done",
|
||||
"message": "No pending recipients"
|
||||
}
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for recipient in recipients:
|
||||
# Get contact
|
||||
contact = self.db.query(Contact).filter(Contact.id == recipient.contact_id).first()
|
||||
if not contact:
|
||||
recipient.status = RecipientStatus.FAILED
|
||||
recipient.last_error = "Contact not found"
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
# Send message
|
||||
success = self.send_single_message(recipient, campaign, template, contact, user_id)
|
||||
|
||||
if success:
|
||||
sent_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
|
||||
# Rate limiting: sleep between messages
|
||||
if sent_count < len(recipients):
|
||||
time.sleep(60.0 / settings.MAX_MESSAGES_PER_MINUTE)
|
||||
|
||||
return {
|
||||
"status": "batch_sent",
|
||||
"sent": sent_count,
|
||||
"failed": failed_count,
|
||||
"remaining": self.db.query(CampaignRecipient).filter(
|
||||
CampaignRecipient.campaign_id == campaign_id,
|
||||
CampaignRecipient.status == RecipientStatus.PENDING
|
||||
).count()
|
||||
}
|
||||
1
backend/app/utils/__init__.py
Normal file
1
backend/app/utils/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Empty file
|
||||
18
backend/app/utils/encryption.py
Normal file
18
backend/app/utils/encryption.py
Normal file
@ -0,0 +1,18 @@
|
||||
from cryptography.fernet import Fernet
|
||||
from app.core.config import settings
|
||||
|
||||
def get_cipher():
|
||||
"""Get Fernet cipher for encryption/decryption"""
|
||||
if not settings.GOOGLE_TOKEN_ENCRYPTION_KEY:
|
||||
raise ValueError("GOOGLE_TOKEN_ENCRYPTION_KEY not set")
|
||||
return Fernet(settings.GOOGLE_TOKEN_ENCRYPTION_KEY.encode())
|
||||
|
||||
def encrypt_token(token: str) -> str:
|
||||
"""Encrypt a token string"""
|
||||
cipher = get_cipher()
|
||||
return cipher.encrypt(token.encode()).decode()
|
||||
|
||||
def decrypt_token(encrypted_token: str) -> str:
|
||||
"""Decrypt an encrypted token"""
|
||||
cipher = get_cipher()
|
||||
return cipher.decrypt(encrypted_token.encode()).decode()
|
||||
30
backend/app/utils/phone.py
Normal file
30
backend/app/utils/phone.py
Normal file
@ -0,0 +1,30 @@
|
||||
import phonenumbers
|
||||
from phonenumbers import NumberParseException
|
||||
from typing import Optional
|
||||
|
||||
def normalize_phone(phone: str, default_region: str = "US") -> Optional[str]:
|
||||
"""
|
||||
Normalize a phone number to E.164 format.
|
||||
For Telegram provider, accepts plain user IDs (just digits).
|
||||
Returns None if the phone number is invalid.
|
||||
"""
|
||||
try:
|
||||
# Clean the phone number
|
||||
phone = str(phone).strip()
|
||||
|
||||
# If it's a short number (< 11 digits), treat as Telegram ID
|
||||
# Telegram user IDs are typically 9-10 digits
|
||||
if phone.isdigit() and len(phone) <= 10:
|
||||
return phone # Return as-is for Telegram
|
||||
|
||||
# For longer numbers, try phone number validation
|
||||
# If phone doesn't start with '+', try adding it
|
||||
if phone and not phone.startswith('+'):
|
||||
phone = '+' + phone
|
||||
|
||||
parsed = phonenumbers.parse(phone, default_region)
|
||||
if phonenumbers.is_valid_number(parsed):
|
||||
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||
return None
|
||||
except NumberParseException:
|
||||
return None
|
||||
47
backend/requirements.txt
Normal file
47
backend/requirements.txt
Normal file
@ -0,0 +1,47 @@
|
||||
# FastAPI and server
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
python-multipart==0.0.6
|
||||
|
||||
# Database
|
||||
sqlalchemy==2.0.25
|
||||
alembic==1.13.1
|
||||
psycopg2-binary==2.9.9
|
||||
|
||||
# Auth
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.1.2
|
||||
|
||||
# Validation
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
email-validator==2.1.0
|
||||
|
||||
# Phone number handling
|
||||
phonenumbers==8.13.27
|
||||
|
||||
# File handling
|
||||
openpyxl==3.1.2
|
||||
pandas==2.1.4
|
||||
|
||||
# HTTP requests
|
||||
httpx==0.26.0
|
||||
requests==2.31.0
|
||||
|
||||
# Google APIs
|
||||
google-auth==2.26.2
|
||||
google-auth-oauthlib==1.2.0
|
||||
google-auth-httplib2==0.2.0
|
||||
google-api-python-client==2.115.0
|
||||
|
||||
# Encryption
|
||||
cryptography==42.0.0
|
||||
|
||||
# Utilities
|
||||
python-dateutil==2.8.2
|
||||
|
||||
# Testing
|
||||
pytest==7.4.4
|
||||
pytest-asyncio==0.23.3
|
||||
httpx==0.26.0
|
||||
1
backend/tests/__init__.py
Normal file
1
backend/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Empty file
|
||||
16
backend/tests/test_phone.py
Normal file
16
backend/tests/test_phone.py
Normal file
@ -0,0 +1,16 @@
|
||||
import pytest
|
||||
from app.utils.phone import normalize_phone
|
||||
|
||||
def test_normalize_phone_us():
|
||||
assert normalize_phone("+1234567890") == "+1234567890"
|
||||
assert normalize_phone("1234567890") == "+1234567890"
|
||||
assert normalize_phone("(123) 456-7890") == "+1234567890"
|
||||
|
||||
def test_normalize_phone_invalid():
|
||||
assert normalize_phone("123") is None
|
||||
assert normalize_phone("abc") is None
|
||||
assert normalize_phone("") is None
|
||||
|
||||
def test_normalize_phone_international():
|
||||
assert normalize_phone("+447700900123") == "+447700900123"
|
||||
assert normalize_phone("+919876543210") == "+919876543210"
|
||||
21
backend/tests/test_providers.py
Normal file
21
backend/tests/test_providers.py
Normal file
@ -0,0 +1,21 @@
|
||||
import pytest
|
||||
from app.providers.mock import MockProvider
|
||||
|
||||
def test_mock_provider_send():
|
||||
provider = MockProvider()
|
||||
|
||||
message_id = provider.send_message(
|
||||
to="+1234567890",
|
||||
template_name=None,
|
||||
template_body="Hello {{first_name}}",
|
||||
variables={"first_name": "John"},
|
||||
language="en"
|
||||
)
|
||||
|
||||
assert message_id is not None
|
||||
assert message_id.startswith("mock_")
|
||||
assert len(message_id) > 10
|
||||
|
||||
def test_mock_provider_name():
|
||||
provider = MockProvider()
|
||||
assert provider.get_provider_name() == "mock"
|
||||
BIN
contacts_demo.xlsx
Normal file
BIN
contacts_demo.xlsx
Normal file
Binary file not shown.
69
docker-compose.yml
Normal file
69
docker-compose.yml
Normal file
@ -0,0 +1,69 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_DB: whatssender
|
||||
POSTGRES_USER: whatssender
|
||||
POSTGRES_PASSWORD: whatssender123
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U whatssender"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://whatssender:whatssender123@postgres:5432/whatssender
|
||||
JWT_SECRET: your-secret-key-change-in-production
|
||||
CORS_ORIGINS: http://localhost:3000,http://localhost:5173
|
||||
GOOGLE_CLIENT_ID: your-google-client-id
|
||||
GOOGLE_CLIENT_SECRET: your-google-client-secret
|
||||
GOOGLE_REDIRECT_URI: http://localhost:8000/api/imports/google/callback
|
||||
GOOGLE_TOKEN_ENCRYPTION_KEY: your-fernet-key-32-bytes-base64
|
||||
WHATSAPP_PROVIDER: telegram
|
||||
WHATSAPP_CLOUD_ACCESS_TOKEN: your-whatsapp-token
|
||||
WHATSAPP_CLOUD_PHONE_NUMBER_ID: your-phone-number-id
|
||||
WHATSAPP_WEBHOOK_VERIFY_TOKEN: your-webhook-verify-token
|
||||
TELEGRAM_BOT_TOKEN: 8428015346:AAH2MOb9D1HUlINOxcDFMa6q98qGo4rPSYo
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "5173:5173"
|
||||
environment:
|
||||
VITE_API_URL: http://localhost:8000
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
command: npm run dev -- --host
|
||||
|
||||
# Optional Redis for Celery (uncomment when ready to use)
|
||||
# redis:
|
||||
# image: redis:7-alpine
|
||||
# ports:
|
||||
# - "6379:6379"
|
||||
# volumes:
|
||||
# - redis_data:/data
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
# redis_data:
|
||||
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
@ -0,0 +1 @@
|
||||
VITE_API_URL=http://localhost:8000
|
||||
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@ -0,0 +1,12 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WhatsApp Campaign Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "whats-sender-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"axios": "^1.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.47",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"vite": "^5.0.11"
|
||||
}
|
||||
}
|
||||
65
frontend/src/App.jsx
Normal file
65
frontend/src/App.jsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { useToast } from './components/Toast';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { RegisterPage } from './pages/RegisterPage';
|
||||
import { DashboardPage } from './pages/DashboardPage';
|
||||
import { ContactsPage } from './pages/ContactsPage';
|
||||
import { ListsPage } from './pages/ListsPage';
|
||||
import { TemplatesPage } from './pages/TemplatesPage';
|
||||
import { CampaignsPage } from './pages/CampaignsPage';
|
||||
import { CampaignDetailPage } from './pages/CampaignDetailPage';
|
||||
import { ImportsPage } from './pages/ImportsPage';
|
||||
import { Loading } from './components/Loading';
|
||||
import './styles/main.css';
|
||||
import './styles/darkmode.css';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) return <Loading />;
|
||||
if (!user) return <Navigate to="/login" />;
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
function AppRoutes() {
|
||||
const { ToastContainer } = useToast();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" />} />
|
||||
<Route path="/dashboard" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
|
||||
<Route path="/contacts" element={<ProtectedRoute><ContactsPage /></ProtectedRoute>} />
|
||||
<Route path="/lists" element={<ProtectedRoute><ListsPage /></ProtectedRoute>} />
|
||||
<Route path="/templates" element={<ProtectedRoute><TemplatesPage /></ProtectedRoute>} />
|
||||
<Route path="/campaigns" element={<ProtectedRoute><CampaignsPage /></ProtectedRoute>} />
|
||||
<Route path="/campaigns/:id" element={<ProtectedRoute><CampaignDetailPage /></ProtectedRoute>} />
|
||||
<Route path="/imports" element={<ProtectedRoute><ImportsPage /></ProtectedRoute>} />
|
||||
</Routes>
|
||||
<ToastContainer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
// Load theme from localStorage
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
124
frontend/src/api/client.js
Normal file
124
frontend/src/api/client.js
Normal file
@ -0,0 +1,124 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: `${API_URL}/api`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// Add token to requests
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Handle 401 errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
|
||||
// Auth API
|
||||
export const authAPI = {
|
||||
register: (email, password) => api.post('/auth/register', { email, password }),
|
||||
login: (email, password) => api.post('/auth/login', { email, password }),
|
||||
getMe: () => api.get('/auth/me')
|
||||
};
|
||||
|
||||
// Contacts API
|
||||
export const contactsAPI = {
|
||||
list: (params) => api.get('/contacts', { params }),
|
||||
get: (id) => api.get(`/contacts/${id}`),
|
||||
create: (data) => api.post('/contacts', data),
|
||||
update: (id, data) => api.put(`/contacts/${id}`, data),
|
||||
delete: (id) => api.delete(`/contacts/${id}`),
|
||||
|
||||
// Tags
|
||||
listTags: () => api.get('/contacts/tags'),
|
||||
createTag: (name) => api.post('/contacts/tags', { name }),
|
||||
addTag: (contactId, tagId) => api.post(`/contacts/${contactId}/tags/${tagId}`),
|
||||
removeTag: (contactId, tagId) => api.delete(`/contacts/${contactId}/tags/${tagId}`),
|
||||
|
||||
// DND
|
||||
listDND: () => api.get('/contacts/dnd'),
|
||||
addDND: (data) => api.post('/contacts/dnd', data),
|
||||
removeDND: (id) => api.delete(`/contacts/dnd/${id}`)
|
||||
};
|
||||
|
||||
// Lists API
|
||||
export const listsAPI = {
|
||||
list: () => api.get('/lists'),
|
||||
get: (id) => api.get(`/lists/${id}`),
|
||||
create: (data) => api.post('/lists', data),
|
||||
update: (id, data) => api.put(`/lists/${id}`, data),
|
||||
delete: (id) => api.delete(`/lists/${id}`),
|
||||
getContacts: (id) => api.get(`/lists/${id}/contacts`),
|
||||
addMembers: (id, contactIds) => api.post(`/lists/${id}/members`, { contact_ids: contactIds }),
|
||||
removeMembers: (id, contactIds) => api.delete(`/lists/${id}/members`, { data: { contact_ids: contactIds } })
|
||||
};
|
||||
|
||||
// Templates API
|
||||
export const templatesAPI = {
|
||||
list: () => api.get('/templates'),
|
||||
get: (id) => api.get(`/templates/${id}`),
|
||||
create: (data) => api.post('/templates', data),
|
||||
update: (id, data) => api.put(`/templates/${id}`, data),
|
||||
delete: (id) => api.delete(`/templates/${id}`)
|
||||
};
|
||||
|
||||
// Campaigns API
|
||||
export const campaignsAPI = {
|
||||
list: (params) => api.get('/campaigns', { params }),
|
||||
get: (id) => api.get(`/campaigns/${id}`),
|
||||
create: (data) => api.post('/campaigns', data),
|
||||
update: (id, data) => api.put(`/campaigns/${id}`, data),
|
||||
delete: (id) => api.delete(`/campaigns/${id}`),
|
||||
preview: (id) => api.get(`/campaigns/${id}/preview`),
|
||||
send: (id) => api.post(`/campaigns/${id}/send`),
|
||||
reset: (id) => api.post(`/campaigns/${id}/reset`),
|
||||
getRecipients: (id, params) => api.get(`/campaigns/${id}/recipients`, { params })
|
||||
};
|
||||
|
||||
// Imports API
|
||||
export const importsAPI = {
|
||||
uploadExcel: (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return api.post('/imports/excel', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
},
|
||||
uploadCSV: (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return api.post('/imports/csv', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
},
|
||||
googleAuthStart: () => api.get('/imports/google/start'),
|
||||
googleSync: () => api.post('/imports/google/sync')
|
||||
};
|
||||
|
||||
// Stats API
|
||||
export const statsAPI = {
|
||||
get: () => api.get('/stats')
|
||||
};
|
||||
|
||||
// Workers API
|
||||
export const workersAPI = {
|
||||
tick: () => api.post('/workers/tick')
|
||||
};
|
||||
37
frontend/src/components/DataTable.jsx
Normal file
37
frontend/src/components/DataTable.jsx
Normal file
@ -0,0 +1,37 @@
|
||||
export const DataTable = ({ columns, data, onRowClick }) => {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">📋</div>
|
||||
<div className="empty-state-text">No data available</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col, idx) => (
|
||||
<th key={idx}>{col.header}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, rowIdx) => (
|
||||
<tr
|
||||
key={rowIdx}
|
||||
onClick={() => onRowClick && onRowClick(row)}
|
||||
style={{ cursor: onRowClick ? 'pointer' : 'default' }}
|
||||
>
|
||||
{columns.map((col, colIdx) => (
|
||||
<td key={colIdx}>
|
||||
{col.render ? col.render(row) : row[col.accessor]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
8
frontend/src/components/Loading.jsx
Normal file
8
frontend/src/components/Loading.jsx
Normal file
@ -0,0 +1,8 @@
|
||||
export const Loading = () => {
|
||||
return (
|
||||
<div className="loading">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
17
frontend/src/components/Modal.jsx
Normal file
17
frontend/src/components/Modal.jsx
Normal file
@ -0,0 +1,17 @@
|
||||
export const Modal = ({ isOpen, onClose, title, children }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">{title}</h2>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
48
frontend/src/components/Navbar.jsx
Normal file
48
frontend/src/components/Navbar.jsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export const Navbar = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const [theme, setTheme] = useState('light');
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
setTheme(savedTheme);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const newTheme = theme === 'light' ? 'dark' : 'light';
|
||||
setTheme(newTheme);
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="navbar">
|
||||
<div className="navbar-content">
|
||||
<Link to="/" className="navbar-brand">
|
||||
📱 WhatsApp Campaign Manager
|
||||
</Link>
|
||||
<ul className="navbar-nav">
|
||||
<li><Link to="/dashboard" className="navbar-link">Dashboard</Link></li>
|
||||
<li><Link to="/contacts" className="navbar-link">Contacts</Link></li>
|
||||
<li><Link to="/lists" className="navbar-link">Lists</Link></li>
|
||||
<li><Link to="/templates" className="navbar-link">Templates</Link></li>
|
||||
<li><Link to="/campaigns" className="navbar-link">Campaigns</Link></li>
|
||||
<li><Link to="/imports" className="navbar-link">Import</Link></li>
|
||||
<li>
|
||||
<button onClick={toggleTheme} className="dark-mode-toggle" title="Toggle dark mode">
|
||||
{theme === 'light' ? '🌙' : '☀️'}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button onClick={logout} className="btn btn-secondary" style={{ padding: '6px 16px' }}>
|
||||
Logout ({user?.email})
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
48
frontend/src/components/Toast.jsx
Normal file
48
frontend/src/components/Toast.jsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
let toastId = 0;
|
||||
|
||||
export const Toast = ({ message, type, duration = 3000, onClose }) => {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onClose();
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [duration, onClose]);
|
||||
|
||||
return (
|
||||
<div className={`toast toast-${type}`}>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const useToast = () => {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
|
||||
const showToast = (message, type = 'info', duration = 3000) => {
|
||||
const id = toastId++;
|
||||
setToasts((prev) => [...prev, { id, message, type, duration }]);
|
||||
};
|
||||
|
||||
const removeToast = (id) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
};
|
||||
|
||||
const ToastContainer = () => (
|
||||
<>
|
||||
{toasts.map((toast) => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
duration={toast.duration}
|
||||
onClose={() => removeToast(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
return { showToast, ToastContainer };
|
||||
};
|
||||
60
frontend/src/contexts/AuthContext.jsx
Normal file
60
frontend/src/contexts/AuthContext.jsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { authAPI } from '../api/client';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
loadUser();
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUser = async () => {
|
||||
try {
|
||||
const response = await authAPI.getMe();
|
||||
setUser(response.data);
|
||||
} catch (error) {
|
||||
localStorage.removeItem('token');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (email, password) => {
|
||||
const response = await authAPI.login(email, password);
|
||||
localStorage.setItem('token', response.data.access_token);
|
||||
await loadUser();
|
||||
};
|
||||
|
||||
const register = async (email, password) => {
|
||||
await authAPI.register(email, password);
|
||||
await login(email, password);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setUser(null);
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
9
frontend/src/main.jsx
Normal file
9
frontend/src/main.jsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
157
frontend/src/pages/CampaignDetailPage.jsx
Normal file
157
frontend/src/pages/CampaignDetailPage.jsx
Normal file
@ -0,0 +1,157 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Navbar } from '../components/Navbar';
|
||||
import { DataTable } from '../components/DataTable';
|
||||
import { Loading } from '../components/Loading';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { campaignsAPI } from '../api/client';
|
||||
|
||||
export const CampaignDetailPage = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [campaign, setCampaign] = useState(null);
|
||||
const [recipients, setRecipients] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { showToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
loadCampaignData();
|
||||
const interval = setInterval(loadCampaignData, 5000); // Refresh every 5 seconds
|
||||
return () => clearInterval(interval);
|
||||
}, [id]);
|
||||
|
||||
const loadCampaignData = async () => {
|
||||
try {
|
||||
const [campaignRes, recipientsRes] = await Promise.all([
|
||||
campaignsAPI.get(id),
|
||||
campaignsAPI.getRecipients(id)
|
||||
]);
|
||||
setCampaign(campaignRes.data);
|
||||
setRecipients(recipientsRes.data);
|
||||
} catch (error) {
|
||||
showToast('Failed to load campaign details', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
if (!confirm('Reset this campaign? All recipients will be set back to pending and you can send again.')) return;
|
||||
try {
|
||||
await campaignsAPI.reset(id);
|
||||
showToast('Campaign reset! Click "Send" to try again.', 'success');
|
||||
loadCampaignData();
|
||||
} catch (error) {
|
||||
showToast('Failed to reset campaign', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeClass = (status) => {
|
||||
switch(status) {
|
||||
case 'sent': return 'info';
|
||||
case 'delivered': return 'success';
|
||||
case 'read': return 'success';
|
||||
case 'failed': return 'danger';
|
||||
default: return 'warning';
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ header: 'Phone', accessor: 'contact_phone' },
|
||||
{ header: 'Name', render: (row) => `${row.contact_first_name || ''} ${row.contact_last_name || ''}`.trim() || '-' },
|
||||
{ header: 'Status', render: (row) => <span className={`badge badge-${getStatusBadgeClass(row.status)}`}>{row.status}</span> },
|
||||
{ header: 'Error', render: (row) => row.last_error ? (
|
||||
<div style={{ fontSize: '13px' }}>
|
||||
<div style={{ color: '#dc3545', fontWeight: '500' }}>{row.last_error}</div>
|
||||
{row.last_error.includes('not opted in') && (
|
||||
<div style={{ color: '#666', fontSize: '11px', marginTop: '4px' }}>💡 Fix: Edit contact and check "Opted In"</div>
|
||||
)}
|
||||
{row.last_error.includes('conversation window') && (
|
||||
<div style={{ color: '#666', fontSize: '11px', marginTop: '4px' }}>💡 Fix: Check "Approved WhatsApp Template" on template</div>
|
||||
)}
|
||||
</div>
|
||||
) : '-' },
|
||||
{ header: 'Message ID', render: (row) => row.provider_message_id ? <span style={{ fontSize: '12px', fontFamily: 'monospace' }}>{row.provider_message_id.substring(0, 20)}...</span> : '-' },
|
||||
{ header: 'Updated', render: (row) => new Date(row.updated_at).toLocaleString() }
|
||||
];
|
||||
|
||||
if (loading) return <Loading />;
|
||||
if (!campaign) return <div>Campaign not found</div>;
|
||||
|
||||
const stats = recipients.reduce((acc, r) => {
|
||||
acc[r.status] = (acc[r.status] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="container">
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<button onClick={() => navigate('/campaigns')} className="btn btn-secondary" style={{ marginRight: '15px' }}>← Back</button>
|
||||
<h1 className="page-title" style={{ display: 'inline' }}>{campaign.name}</h1>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||
{(campaign.status === 'done' || campaign.status === 'failed') && (
|
||||
<button onClick={handleReset} className="btn btn-warning">🔄 Reset & Resend</button>
|
||||
)}
|
||||
<span className={`badge badge-${campaign.status === 'done' ? 'success' : campaign.status === 'failed' ? 'danger' : 'info'}`} style={{ fontSize: '16px', padding: '8px 16px' }}>
|
||||
{campaign.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats.failed > 0 && (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#fef2f2',
|
||||
border: '1px solid #fca5a5',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<div style={{ fontWeight: 'bold', color: '#dc2626', marginBottom: '8px' }}>
|
||||
⚠️ {stats.failed} message(s) failed
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
Common fixes:
|
||||
<ul style={{ marginTop: '8px', marginLeft: '20px' }}>
|
||||
<li>Go to <a href="/contacts" style={{ color: '#2563eb' }}>Contacts</a> → Edit contact → Check "Opted In"</li>
|
||||
<li>Go to <a href="/templates" style={{ color: '#2563eb' }}>Templates</a> → Edit template → Check "Approved WhatsApp Template"</li>
|
||||
<li>For testing: Use Telegram provider (see TELEGRAM_TESTING.md)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '20px', marginBottom: '30px' }}>
|
||||
<div className="card" style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: 'bold', color: '#10b981' }}>{stats.sent || 0}</div>
|
||||
<div style={{ color: '#666', marginTop: '5px' }}>Sent</div>
|
||||
</div>
|
||||
<div className="card" style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: 'bold', color: '#3b82f6' }}>{stats.delivered || 0}</div>
|
||||
<div style={{ color: '#666', marginTop: '5px' }}>Delivered</div>
|
||||
</div>
|
||||
<div className="card" style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: 'bold', color: '#8b5cf6' }}>{stats.read || 0}</div>
|
||||
<div style={{ color: '#666', marginTop: '5px' }}>Read</div>
|
||||
</div>
|
||||
<div className="card" style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: 'bold', color: '#f59e0b' }}>{stats.pending || 0}</div>
|
||||
<div style={{ color: '#666', marginTop: '5px' }}>Pending</div>
|
||||
</div>
|
||||
<div className="card" style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: 'bold', color: '#ef4444' }}>{stats.failed || 0}</div>
|
||||
<div style={{ color: '#666', marginTop: '5px' }}>Failed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style={{ marginBottom: '20px', fontSize: '20px' }}>Recipients</h2>
|
||||
<DataTable columns={columns} data={recipients} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
131
frontend/src/pages/CampaignsPage.jsx
Normal file
131
frontend/src/pages/CampaignsPage.jsx
Normal file
@ -0,0 +1,131 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Navbar } from '../components/Navbar';
|
||||
import { DataTable } from '../components/DataTable';
|
||||
import { Modal } from '../components/Modal';
|
||||
import { Loading } from '../components/Loading';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { campaignsAPI, listsAPI, templatesAPI } from '../api/client';
|
||||
|
||||
export const CampaignsPage = () => {
|
||||
const [campaigns, setCampaigns] = useState([]);
|
||||
const [lists, setLists] = useState([]);
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [formData, setFormData] = useState({ name: '', template_id: '', list_id: '' });
|
||||
const { showToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [campaignsRes, listsRes, templatesRes] = await Promise.all([
|
||||
campaignsAPI.list(),
|
||||
listsAPI.list(),
|
||||
templatesAPI.list()
|
||||
]);
|
||||
setCampaigns(campaignsRes.data);
|
||||
setLists(listsRes.data);
|
||||
setTemplates(templatesRes.data);
|
||||
} catch (error) {
|
||||
showToast('Failed to load data', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await campaignsAPI.create({
|
||||
name: formData.name,
|
||||
template_id: parseInt(formData.template_id),
|
||||
list_id: parseInt(formData.list_id)
|
||||
});
|
||||
showToast('Campaign created successfully', 'success');
|
||||
setShowModal(false);
|
||||
setFormData({ name: '', template_id: '', list_id: '' });
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast('Failed to create campaign', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async (id) => {
|
||||
if (!confirm('Start sending this campaign? This cannot be undone.')) return;
|
||||
try {
|
||||
await campaignsAPI.send(id);
|
||||
showToast('Campaign started! Run worker to process: curl -X POST http://localhost:8000/api/workers/tick', 'success');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast('Failed to start campaign', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Delete this campaign? This will remove all recipients and logs.')) return;
|
||||
try {
|
||||
await campaignsAPI.delete(id);
|
||||
showToast('Campaign deleted', 'success');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast('Failed to delete campaign', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ header: 'Name', accessor: 'name' },
|
||||
{ header: 'Status', render: (row) => <span className={`badge badge-${row.status === 'done' ? 'success' : row.status === 'failed' ? 'danger' : 'info'}`}>{row.status}</span> },
|
||||
{ header: 'Created', render: (row) => new Date(row.created_at).toLocaleDateString() },
|
||||
{ header: 'Actions', render: (row) => (
|
||||
<>
|
||||
{row.status === 'draft' && <button onClick={() => handleSend(row.id)} className="btn btn-primary" style={{ padding: '4px 12px', marginRight: '8px' }}>Send</button>}
|
||||
<button onClick={() => window.location.href = `/campaigns/${row.id}`} className="btn btn-secondary" style={{ padding: '4px 12px', marginRight: '8px' }}>View</button>
|
||||
<button onClick={() => handleDelete(row.id)} className="btn btn-danger" style={{ padding: '4px 12px' }}>Delete</button>
|
||||
</>
|
||||
)}
|
||||
];
|
||||
|
||||
if (loading) return <Loading />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="container">
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Campaigns</h1>
|
||||
<button onClick={() => setShowModal(true)} className="btn btn-primary">Create Campaign</button>
|
||||
</div>
|
||||
<DataTable columns={columns} data={campaigns} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="Create Campaign">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Campaign Name</label>
|
||||
<input type="text" className="form-input" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Template</label>
|
||||
<select className="form-select" value={formData.template_id} onChange={(e) => setFormData({ ...formData, template_id: e.target.value })} required>
|
||||
<option value="">Select template...</option>
|
||||
{templates.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Contact List</label>
|
||||
<select className="form-select" value={formData.list_id} onChange={(e) => setFormData({ ...formData, list_id: e.target.value })} required>
|
||||
<option value="">Select list...</option>
|
||||
{lists.map(l => <option key={l.id} value={l.id}>{l.name} ({l.member_count} contacts)</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary">Create</button>
|
||||
</form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
196
frontend/src/pages/ContactsPage.jsx
Normal file
196
frontend/src/pages/ContactsPage.jsx
Normal file
@ -0,0 +1,196 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Navbar } from '../components/Navbar';
|
||||
import { DataTable } from '../components/DataTable';
|
||||
import { Modal } from '../components/Modal';
|
||||
import { Loading } from '../components/Loading';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { contactsAPI, listsAPI } from '../api/client';
|
||||
|
||||
export const ContactsPage = () => {
|
||||
const [contacts, setContacts] = useState([]);
|
||||
const [lists, setLists] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showAddToListModal, setShowAddToListModal] = useState(false);
|
||||
const [selectedContacts, setSelectedContacts] = useState([]);
|
||||
const [selectedList, setSelectedList] = useState('');
|
||||
const [formData, setFormData] = useState({
|
||||
phone_e164: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
opted_in: false
|
||||
});
|
||||
const { showToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
loadContacts();
|
||||
loadLists();
|
||||
}, []);
|
||||
|
||||
const loadContacts = async () => {
|
||||
try {
|
||||
const response = await contactsAPI.list();
|
||||
setContacts(response.data);
|
||||
} catch (error) {
|
||||
showToast('Failed to load contacts', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadLists = async () => {
|
||||
try {
|
||||
const response = await listsAPI.list();
|
||||
setLists(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load lists', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await contactsAPI.create(formData);
|
||||
showToast('Contact created successfully', 'success');
|
||||
setShowModal(false);
|
||||
setFormData({ phone_e164: '', first_name: '', last_name: '', email: '', opted_in: false });
|
||||
loadContacts();
|
||||
} catch (error) {
|
||||
showToast(error.response?.data?.detail || 'Failed to create contact', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Delete this contact?')) return;
|
||||
try {
|
||||
await contactsAPI.delete(id);
|
||||
showToast('Contact deleted', 'success');
|
||||
loadContacts();
|
||||
} catch (error) {
|
||||
showToast('Failed to delete contact', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (contactId) => {
|
||||
setSelectedContacts(prev =>
|
||||
prev.includes(contactId)
|
||||
? prev.filter(id => id !== contactId)
|
||||
: [...prev, contactId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddToList = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!selectedList || selectedContacts.length === 0) {
|
||||
showToast('Please select a list and contacts', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await listsAPI.addMembers(parseInt(selectedList), selectedContacts);
|
||||
showToast(`${selectedContacts.length} contact(s) added to list`, 'success');
|
||||
setShowAddToListModal(false);
|
||||
setSelectedContacts([]);
|
||||
setSelectedList('');
|
||||
} catch (error) {
|
||||
showToast(error.response?.data?.detail || 'Failed to add contacts to list', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: <input type="checkbox" onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedContacts(contacts.map(c => c.id));
|
||||
} else {
|
||||
setSelectedContacts([]);
|
||||
}
|
||||
}} />,
|
||||
render: (row) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedContacts.includes(row.id)}
|
||||
onChange={() => handleCheckboxChange(row.id)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{ header: 'Phone', accessor: 'phone_e164' },
|
||||
{ header: 'Name', render: (row) => `${row.first_name || ''} ${row.last_name || ''}`.trim() || '-' },
|
||||
{ header: 'Email', accessor: 'email', render: (row) => row.email || '-' },
|
||||
{ header: 'Opted In', render: (row) => <span className={`badge badge-${row.opted_in ? 'success' : 'warning'}`}>{row.opted_in ? 'Yes' : 'No'}</span> },
|
||||
{ header: 'Source', accessor: 'source' },
|
||||
{ header: 'Actions', render: (row) => <button onClick={() => handleDelete(row.id)} className="btn btn-danger" style={{ padding: '4px 12px' }}>Delete</button> }
|
||||
];
|
||||
|
||||
if (loading) return <Loading />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="container">
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Contacts</h1>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
{selectedContacts.length > 0 && (
|
||||
<button onClick={() => setShowAddToListModal(true)} className="btn btn-secondary">
|
||||
Add to List ({selectedContacts.length})
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setShowModal(true)} className="btn btn-primary">Add Contact</button>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable columns={columns} data={contacts} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="Add Contact">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Phone (E.164)</label>
|
||||
<input type="text" className="form-input" value={formData.phone_e164} onChange={(e) => setFormData({ ...formData, phone_e164: e.target.value })} required placeholder="+1234567890" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">First Name</label>
|
||||
<input type="text" className="form-input" value={formData.first_name} onChange={(e) => setFormData({ ...formData, first_name: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Last Name</label>
|
||||
<input type="text" className="form-input" value={formData.last_name} onChange={(e) => setFormData({ ...formData, last_name: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Email</label>
|
||||
<input type="email" className="form-input" value={formData.email} onChange={(e) => setFormData({ ...formData, email: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label><input type="checkbox" className="form-checkbox" checked={formData.opted_in} onChange={(e) => setFormData({ ...formData, opted_in: e.target.checked })} /> Opted In</label>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary">Create</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal isOpen={showAddToListModal} onClose={() => setShowAddToListModal(false)} title="Add Contacts to List">
|
||||
<form onSubmit={handleAddToList}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Select List</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={selectedList}
|
||||
onChange={(e) => setSelectedList(e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">-- Select a List --</option>
|
||||
{lists.map(list => (
|
||||
<option key={list.id} value={list.id}>{list.name} ({list.member_count} members)</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<p style={{ marginBottom: '20px', color: '#666' }}>
|
||||
Adding {selectedContacts.length} contact(s) to the selected list
|
||||
</p>
|
||||
<button type="submit" className="btn btn-primary">Add to List</button>
|
||||
</form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
145
frontend/src/pages/DashboardPage.jsx
Normal file
145
frontend/src/pages/DashboardPage.jsx
Normal file
@ -0,0 +1,145 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Navbar } from '../components/Navbar';
|
||||
import { Loading } from '../components/Loading';
|
||||
import { statsAPI, workersAPI } from '../api/client';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export const DashboardPage = () => {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [processingWorker, setProcessingWorker] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await statsAPI.get();
|
||||
setStats(response.data);
|
||||
} catch (error) {
|
||||
showToast('Failed to load stats', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProcessWorker = async () => {
|
||||
setProcessingWorker(true);
|
||||
try {
|
||||
const response = await workersAPI.tick();
|
||||
const processed = response.data.processed || 0;
|
||||
if (processed > 0) {
|
||||
showToast(`Processed ${processed} job(s). Check campaigns for updates!`, 'success');
|
||||
setTimeout(loadStats, 1000); // Reload stats after 1 second
|
||||
} else {
|
||||
showToast('No pending jobs to process', 'info');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to process worker', 'error');
|
||||
} finally {
|
||||
setProcessingWorker(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Loading />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="container">
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Dashboard</h1>
|
||||
<button
|
||||
onClick={handleProcessWorker}
|
||||
disabled={processingWorker}
|
||||
className="btn btn-success"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
|
||||
>
|
||||
{processingWorker ? '⏳ Processing...' : '▶️ Process Campaign Jobs'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
backgroundColor: '#fef3c7',
|
||||
border: '1px solid #fbbf24',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '24px',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
💡 <strong>Tip:</strong> After clicking "Send" on a campaign, click "Process Campaign Jobs" button above to actually send the messages.
|
||||
Campaign status will change from <span className="badge badge-info" style={{ fontSize: '12px' }}>sending</span> to <span className="badge badge-success" style={{ fontSize: '12px' }}>done</span>.
|
||||
</div>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats?.total_contacts || 0}</div>
|
||||
<div className="stat-label">Total Contacts</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats?.opted_in_contacts || 0}</div>
|
||||
<div className="stat-label">Opted In</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats?.total_lists || 0}</div>
|
||||
<div className="stat-label">Lists</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats?.total_campaigns || 0}</div>
|
||||
<div className="stat-label">Campaigns</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats?.total_sent || 0}</div>
|
||||
<div className="stat-label">Messages Sent</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="card-title">Recent Campaigns</h2>
|
||||
{stats?.recent_campaigns?.length > 0 ? (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Recipients</th>
|
||||
<th>Sent</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.recent_campaigns.map((campaign) => (
|
||||
<tr key={campaign.id}>
|
||||
<td>
|
||||
<Link to={`/campaigns/${campaign.id}`}>{campaign.name}</Link>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge badge-${campaign.status === 'done' ? 'success' : campaign.status === 'failed' ? 'danger' : 'info'}`}>
|
||||
{campaign.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{campaign.total_recipients}</td>
|
||||
<td>{campaign.sent_count}</td>
|
||||
<td>{new Date(campaign.created_at).toLocaleDateString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-text">No campaigns yet</div>
|
||||
<Link to="/campaigns" className="btn btn-primary" style={{ marginTop: '16px' }}>
|
||||
Create Campaign
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
113
frontend/src/pages/ImportsPage.jsx
Normal file
113
frontend/src/pages/ImportsPage.jsx
Normal file
@ -0,0 +1,113 @@
|
||||
import { useState } from 'react';
|
||||
import { Navbar } from '../components/Navbar';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { importsAPI } from '../api/client';
|
||||
|
||||
export const ImportsPage = () => {
|
||||
const [uploadingExcel, setUploadingExcel] = useState(false);
|
||||
const [uploadingCSV, setUploadingCSV] = useState(false);
|
||||
const [summary, setSummary] = useState(null);
|
||||
const { showToast } = useToast();
|
||||
|
||||
const handleFileUpload = async (file, type) => {
|
||||
const setLoading = type === 'excel' ? setUploadingExcel : setUploadingCSV;
|
||||
setLoading(true);
|
||||
setSummary(null);
|
||||
|
||||
try {
|
||||
const uploadFn = type === 'excel' ? importsAPI.uploadExcel : importsAPI.uploadCSV;
|
||||
const response = await uploadFn(file);
|
||||
setSummary(response.data);
|
||||
showToast(`Imported ${response.data.created} contacts`, 'success');
|
||||
} catch (error) {
|
||||
showToast('Import failed', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleAuth = async () => {
|
||||
try {
|
||||
const response = await importsAPI.googleAuthStart();
|
||||
window.location.href = response.data.auth_url;
|
||||
} catch (error) {
|
||||
showToast('Failed to start Google auth', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleSync = async () => {
|
||||
try {
|
||||
const response = await importsAPI.googleSync();
|
||||
showToast(`Imported ${response.data.contacts_imported} contacts from Google`, 'success');
|
||||
} catch (error) {
|
||||
showToast('Google sync failed', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="container">
|
||||
<div className="page">
|
||||
<h1 className="page-title">Import Contacts</h1>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="card-title">Excel Import</h2>
|
||||
<p>Upload an Excel file (.xlsx) with columns: phone, first_name, last_name, email, opted_in</p>
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={(e) => handleFileUpload(e.target.files[0], 'excel')}
|
||||
disabled={uploadingExcel}
|
||||
/>
|
||||
{uploadingExcel && <p>Uploading...</p>}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="card-title">CSV Import</h2>
|
||||
<p>Upload a CSV file with columns: phone, first_name, last_name, email, opted_in</p>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={(e) => handleFileUpload(e.target.files[0], 'csv')}
|
||||
disabled={uploadingCSV}
|
||||
/>
|
||||
{uploadingCSV && <p>Uploading...</p>}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="card-title">Google Contacts</h2>
|
||||
<p>Import contacts from your Google account</p>
|
||||
<button onClick={handleGoogleAuth} className="btn btn-primary" style={{ marginRight: '12px' }}>
|
||||
Connect Google Account
|
||||
</button>
|
||||
<button onClick={handleGoogleSync} className="btn btn-secondary">
|
||||
Sync Contacts
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<div className="card">
|
||||
<h2 className="card-title">Import Summary</h2>
|
||||
<table className="table">
|
||||
<tbody>
|
||||
<tr><td>Total Rows:</td><td>{summary.total}</td></tr>
|
||||
<tr><td>Created:</td><td>{summary.created}</td></tr>
|
||||
<tr><td>Updated:</td><td>{summary.updated}</td></tr>
|
||||
<tr><td>Skipped:</td><td>{summary.skipped}</td></tr>
|
||||
<tr><td>Invalid:</td><td>{summary.invalid}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{summary.errors.length > 0 && (
|
||||
<>
|
||||
<h3>Errors:</h3>
|
||||
<ul>{summary.errors.map((err, idx) => <li key={idx}>{err}</li>)}</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
88
frontend/src/pages/ListsPage.jsx
Normal file
88
frontend/src/pages/ListsPage.jsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Navbar } from '../components/Navbar';
|
||||
import { DataTable } from '../components/DataTable';
|
||||
import { Modal } from '../components/Modal';
|
||||
import { Loading } from '../components/Loading';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { listsAPI } from '../api/client';
|
||||
|
||||
export const ListsPage = () => {
|
||||
const [lists, setLists] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const { showToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
loadLists();
|
||||
}, []);
|
||||
|
||||
const loadLists = async () => {
|
||||
try {
|
||||
const response = await listsAPI.list();
|
||||
setLists(response.data);
|
||||
} catch (error) {
|
||||
showToast('Failed to load lists', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await listsAPI.create({ name });
|
||||
showToast('List created successfully', 'success');
|
||||
setShowModal(false);
|
||||
setName('');
|
||||
loadLists();
|
||||
} catch (error) {
|
||||
showToast('Failed to create list', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Delete this list?')) return;
|
||||
try {
|
||||
await listsAPI.delete(id);
|
||||
showToast('List deleted', 'success');
|
||||
loadLists();
|
||||
} catch (error) {
|
||||
showToast('Failed to delete list', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ header: 'Name', accessor: 'name' },
|
||||
{ header: 'Members', accessor: 'member_count' },
|
||||
{ header: 'Created', render: (row) => new Date(row.created_at).toLocaleDateString() },
|
||||
{ header: 'Actions', render: (row) => <button onClick={() => handleDelete(row.id)} className="btn btn-danger" style={{ padding: '4px 12px' }}>Delete</button> }
|
||||
];
|
||||
|
||||
if (loading) return <Loading />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="container">
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Contact Lists</h1>
|
||||
<button onClick={() => setShowModal(true)} className="btn btn-primary">Create List</button>
|
||||
</div>
|
||||
<DataTable columns={columns} data={lists} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="Create List">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">List Name</label>
|
||||
<input type="text" className="form-input" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary">Create</button>
|
||||
</form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
66
frontend/src/pages/LoginPage.jsx
Normal file
66
frontend/src/pages/LoginPage.jsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useToast } from '../components/Toast';
|
||||
|
||||
export const LoginPage = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
showToast('Login successful!', 'success');
|
||||
navigate('/dashboard');
|
||||
} catch (error) {
|
||||
showToast(error.response?.data?.detail || 'Login failed', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div className="card" style={{ maxWidth: '400px', width: '90%' }}>
|
||||
<h1 className="card-title" style={{ textAlign: 'center', marginBottom: '24px' }}>
|
||||
📱 WhatsApp Campaign Manager
|
||||
</h1>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-input"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
<p style={{ textAlign: 'center', marginTop: '16px' }}>
|
||||
Don't have an account? <Link to="/register">Register</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
84
frontend/src/pages/RegisterPage.jsx
Normal file
84
frontend/src/pages/RegisterPage.jsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useToast } from '../components/Toast';
|
||||
|
||||
export const RegisterPage = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { register } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
showToast('Passwords do not match', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await register(email, password);
|
||||
showToast('Registration successful!', 'success');
|
||||
navigate('/dashboard');
|
||||
} catch (error) {
|
||||
showToast(error.response?.data?.detail || 'Registration failed', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div className="card" style={{ maxWidth: '400px', width: '90%' }}>
|
||||
<h1 className="card-title" style={{ textAlign: 'center', marginBottom: '24px' }}>
|
||||
Create Account
|
||||
</h1>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-input"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-input"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||
{loading ? 'Creating account...' : 'Register'}
|
||||
</button>
|
||||
</form>
|
||||
<p style={{ textAlign: 'center', marginTop: '16px' }}>
|
||||
Already have an account? <Link to="/login">Login</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
117
frontend/src/pages/TemplatesPage.jsx
Normal file
117
frontend/src/pages/TemplatesPage.jsx
Normal file
@ -0,0 +1,117 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Navbar } from '../components/Navbar';
|
||||
import { DataTable } from '../components/DataTable';
|
||||
import { Modal } from '../components/Modal';
|
||||
import { Loading } from '../components/Loading';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { templatesAPI } from '../api/client';
|
||||
|
||||
export const TemplatesPage = () => {
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
language: 'en',
|
||||
body_text: '',
|
||||
is_whatsapp_template: false,
|
||||
provider_template_name: ''
|
||||
});
|
||||
const { showToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
}, []);
|
||||
|
||||
const loadTemplates = async () => {
|
||||
try {
|
||||
const response = await templatesAPI.list();
|
||||
setTemplates(response.data);
|
||||
} catch (error) {
|
||||
showToast('Failed to load templates', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await templatesAPI.create(formData);
|
||||
showToast('Template created successfully', 'success');
|
||||
setShowModal(false);
|
||||
setFormData({ name: '', language: 'en', body_text: '', is_whatsapp_template: false, provider_template_name: '' });
|
||||
loadTemplates();
|
||||
} catch (error) {
|
||||
showToast('Failed to create template', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Delete this template?')) return;
|
||||
try {
|
||||
await templatesAPI.delete(id);
|
||||
showToast('Template deleted', 'success');
|
||||
loadTemplates();
|
||||
} catch (error) {
|
||||
showToast('Failed to delete template', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ header: 'Name', accessor: 'name' },
|
||||
{ header: 'Language', accessor: 'language' },
|
||||
{ header: 'WhatsApp Template', render: (row) => <span className={`badge badge-${row.is_whatsapp_template ? 'success' : 'info'}`}>{row.is_whatsapp_template ? 'Yes' : 'No'}</span> },
|
||||
{ header: 'Body Preview', render: (row) => row.body_text.substring(0, 50) + '...' },
|
||||
{ header: 'Actions', render: (row) => <button onClick={() => handleDelete(row.id)} className="btn btn-danger" style={{ padding: '4px 12px' }}>Delete</button> }
|
||||
];
|
||||
|
||||
if (loading) return <Loading />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="container">
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Templates</h1>
|
||||
<button onClick={() => setShowModal(true)} className="btn btn-primary">Create Template</button>
|
||||
</div>
|
||||
<DataTable columns={columns} data={templates} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="Create Template">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Name</label>
|
||||
<input type="text" className="form-input" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Language</label>
|
||||
<select className="form-select" value={formData.language} onChange={(e) => setFormData({ ...formData, language: e.target.value })}>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Spanish</option>
|
||||
<option value="fr">French</option>
|
||||
<option value="ar">Arabic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Body Text (use {'{{first_name}}'} for variables)</label>
|
||||
<textarea className="form-textarea" value={formData.body_text} onChange={(e) => setFormData({ ...formData, body_text: e.target.value })} required placeholder="Hello {{first_name}}, welcome!" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label><input type="checkbox" className="form-checkbox" checked={formData.is_whatsapp_template} onChange={(e) => setFormData({ ...formData, is_whatsapp_template: e.target.checked })} /> Approved WhatsApp Template</label>
|
||||
</div>
|
||||
{formData.is_whatsapp_template && (
|
||||
<div className="form-group">
|
||||
<label className="form-label">Provider Template Name</label>
|
||||
<input type="text" className="form-input" value={formData.provider_template_name} onChange={(e) => setFormData({ ...formData, provider_template_name: e.target.value })} />
|
||||
</div>
|
||||
)}
|
||||
<button type="submit" className="btn btn-primary">Create</button>
|
||||
</form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
111
frontend/src/styles/darkmode.css
Normal file
111
frontend/src/styles/darkmode.css
Normal file
@ -0,0 +1,111 @@
|
||||
/* Dark Mode CSS */
|
||||
:root {
|
||||
--primary: #10b981;
|
||||
--primary-dark: #059669;
|
||||
--danger: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--info: #3b82f6;
|
||||
--success: #10b981;
|
||||
|
||||
/* Light mode colors */
|
||||
--bg-body: #f5f5f5;
|
||||
--bg-page: #ffffff;
|
||||
--bg-card: #ffffff;
|
||||
--bg-input: #ffffff;
|
||||
--text-primary: #333333;
|
||||
--text-secondary: #666666;
|
||||
--border-color: #e0e0e0;
|
||||
--shadow: rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg-body: #121212;
|
||||
--bg-page: #1e1e1e;
|
||||
--bg-card: #252525;
|
||||
--bg-input: #2a2a2a;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #a0a0a0;
|
||||
--border-color: #404040;
|
||||
--shadow: rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-body) !important;
|
||||
color: var(--text-primary) !important;
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
.page {
|
||||
background: var(--bg-page) !important;
|
||||
box-shadow: 0 2px 4px var(--shadow) !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.form-input, .form-select, textarea {
|
||||
background-color: var(--bg-input) !important;
|
||||
color: var(--text-primary) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.table {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background-color: var(--bg-card) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.table td {
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
background: rgba(0, 0, 0, 0.7) !important;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-page) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
/* Dark mode toggle button */
|
||||
.dark-mode-toggle {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
transition: all 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dark-mode-toggle:hover {
|
||||
background: var(--bg-input);
|
||||
}
|
||||
405
frontend/src/styles/main.css
Normal file
405
frontend/src/styles/main.css
Normal file
@ -0,0 +1,405 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #25D366;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #20BA5A;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background-color: transparent;
|
||||
border: 1px solid #25D366;
|
||||
color: #25D366;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background-color: #25D366;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-select:focus,
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #25D366;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: #dc3545;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.table tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.navbar {
|
||||
background-color: #25D366;
|
||||
padding: 16px 0;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.navbar-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.navbar-link {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.navbar-link:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 16px 20px;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
z-index: 2000;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background-color: #28a745;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background-color: #dc3545;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background-color: #17a2b8;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #25D366;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Badge */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #25D366;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state-text {
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-state-subtext {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
16
frontend/vite.config.js
Normal file
16
frontend/vite.config.js
Normal file
@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_API_URL || 'http://localhost:8000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
10
trigger-worker.sh
Normal file
10
trigger-worker.sh
Normal file
@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
# Worker trigger script
|
||||
# This script calls the worker endpoint to process pending campaign jobs
|
||||
|
||||
while true; do
|
||||
echo "[$(date)] Triggering worker..."
|
||||
curl -X POST http://localhost:8000/api/workers/tick
|
||||
echo ""
|
||||
sleep 60 # Wait 60 seconds before next run
|
||||
done
|
||||
Loading…
x
Reference in New Issue
Block a user