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