From 9c0932a48ce9f0138e21260c2a8427299ce948da Mon Sep 17 00:00:00 2001 From: Sam Holmes Date: Tue, 12 Aug 2025 21:32:12 -0700 Subject: [PATCH 1/2] Add documentation and AGENTS.md file --- AGENTS.md | 49 +++++++++ README.md | 30 +++++- docs/guides/amqp-configuration.md | 161 ++++++++++++++++++++++++++++++ src/serverConfig.ts | 2 +- 4 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/guides/amqp-configuration.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c891817 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,49 @@ +# Edge Push Server Documentation Index + +This file serves as the central index for all documentation in the Edge Push Server project. + +## Documentation Structure + +### Project Setup and Overview + +#### `README.md` + +- **When to read**: First time setting up the project, understanding basic server setup and deployment +- **Summary**: Project overview, setup instructions including AMQP/RabbitMQ 3.12 Docker configuration, PM2 management, and deployment procedures + +### Configuration Guides + +#### `docs/guides/amqp-configuration.md` + +- **When to read**: Before running `yarn start` for the first time, when setting up development environment, or troubleshooting message queue issues +- **Summary**: Complete guide for configuring the AMQP client connection required by the push server, including RabbitMQ 3.12 Docker setup, connection string format, security considerations, and troubleshooting tips + +### Additional Resources + +#### `docs/demo.ts` + +- **When to read**: When learning how to integrate with the v2 API, testing push notifications +- **Summary**: Example TypeScript code demonstrating how to use the Edge Push Server v2 API + +#### `docs/logrotate` + +- **When to read**: When setting up production server, configuring log management +- **Summary**: Log rotation configuration for managing server logs in production + +## Quick Start + +1. Read `README.md` for project setup +2. Configure AMQP by following `docs/guides/amqp-configuration.md` +3. Set up your `pushServerConfig.json` with database and AMQP credentials +4. Run `yarn install` and `yarn prepare` +5. Start the server with `yarn start` + +## Architecture Overview + +The Edge Push Server consists of: + +- HTTP API server for device registration and notification triggers +- AMQP message queue for reliable message delivery +- Background daemons for processing notifications, price changes, and confirmations +- CouchDB for storing device registrations and settings +- Firebase Admin SDK for sending push notifications diff --git a/README.md b/README.md index 9649323..e5e16b6 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,35 @@ The docs folder has can find [an example of how to use the v2 API](./docs/demo.t ## Setup -This server requires a working copies of Node.js, Yarn, PM2, and CouchDB. We also recommend using Caddy to terminate SSL connections. +This server requires a working copies of Node.js, Yarn, PM2, CouchDB, and RabbitMQ 3.12 (via Docker). We also recommend using Caddy to terminate SSL connections. + +### Configure AMQP Message Queue + +The push server uses AMQP (RabbitMQ) for reliable message delivery between the HTTP server and push notification daemons. Before running `yarn start`, you must: + +1. **Start RabbitMQ 3.12 using Docker**: + +```bash +docker run -d --name rabbitmq \ + -p 5672:5672 \ + -p 15672:15672 \ + -e RABBITMQ_DEFAULT_USER=guest \ + -e RABBITMQ_DEFAULT_PASS=guest \ + rabbitmq:3.12-management +``` + +2. **Create a configuration file** `pushServerConfig.json` in the project root: + +```json +{ + "listenHost": "127.0.0.1", + "listenPort": 8008, + "amqpUri": "amqp://guest:guest@localhost:5672", + "couchUri": "http://username:password@localhost:5984" +} +``` + +For detailed AMQP configuration instructions, troubleshooting, and security considerations, see [docs/guides/amqp-configuration.md](./docs/guides/amqp-configuration.md). ### Set up logging diff --git a/docs/guides/amqp-configuration.md b/docs/guides/amqp-configuration.md new file mode 100644 index 0000000..9da7a21 --- /dev/null +++ b/docs/guides/amqp-configuration.md @@ -0,0 +1,161 @@ +# AMQP Client Configuration Guide + +## Overview + +The Edge Push Server uses AMQP (Advanced Message Queuing Protocol) for reliable message delivery between the HTTP server and the push notification daemon. This guide explains how to configure the AMQP client before running the server. + +## What is AMQP Used For? + +The AMQP client serves as the messaging backbone for the push notification system: + +1. **Message Queue**: The HTTP server enqueues push notification requests into a RabbitMQ/AMQP queue named "messages" +2. **Decoupling**: Separates the HTTP API from the actual push notification delivery process +3. **Reliability**: Ensures messages aren't lost if the push daemon is temporarily down +4. **Load Management**: The queue prefetch is set to 50 messages to prevent overwhelming the push daemon + +## Architecture Flow + +``` +HTTP Server → AMQP Queue ("messages") → Publish Daemon → Firebase/Push Services +``` + +## Configuration Requirements + +### 1. Create Configuration File + +Before running `yarn start`, create a `pushServerConfig.json` file in the project root: + +```json +{ + "listenHost": "127.0.0.1", + "listenPort": 8008, + "amqpUri": "amqp://username:password@localhost:5672", + "couchUri": "http://username:password@localhost:5984", + "currentCluster": "production" +} +``` + +### 2. AMQP URI Format + +The `amqpUri` follows the standard AMQP connection string format: + +``` +amqp://[username[:password]@]hostname[:port][/vhost] +``` + +Examples: + +- Local development (Docker): `amqp://guest:guest@localhost:5672` +- Production with vhost: `amqp://edgeuser:securepass@rabbitmq.example.com:5672/edge` +- CloudAMQP: `amqp://user:pass@hostname.cloudamqp.com/instance` + +### 3. Required AMQP Server Setup + +Before starting the Edge Push Server, ensure you have: + +1. **RabbitMQ Server version 3.12** (or compatible AMQP broker) running via Docker +2. **User credentials** with permissions to: + - Create queues + - Publish messages + - Consume messages + - Set prefetch count +3. **Network access** to the AMQP server on port 5672 + +### 4. Queue Configuration + +The server automatically creates a queue named "messages" with: + +- **Prefetch limit**: 50 messages (prevents memory overflow) +- **Manual acknowledgment**: Messages are only removed after successful processing +- **Durable**: Queue persists through server restarts (implementation dependent) + +## RabbitMQ Setup (Docker) + +**Important**: The Edge Push Server requires RabbitMQ version 3.12 with management interface. + +Run the following Docker command to set up RabbitMQ: + +```bash +docker run -d --name rabbitmq \ + -p 5672:5672 \ + -p 15672:15672 \ + -e RABBITMQ_DEFAULT_USER=guest \ + -e RABBITMQ_DEFAULT_PASS=guest \ + rabbitmq:3.12-management +``` + +This command: + +- Uses RabbitMQ version 3.12 with management interface +- Exposes port 5672 for AMQP connections +- Exposes port 15672 for the web management UI +- Sets default credentials (guest/guest) + +### Cloud Services Alternative + +- **CloudAMQP**: Managed RabbitMQ hosting +- **Amazon MQ**: AWS managed message broker +- **Azure Service Bus**: Alternative AMQP-compatible service + +## Troubleshooting + +### Connection Errors + +If you see connection errors when starting the server: + +1. **Check AMQP server is running**: + + ```bash + # For Docker RabbitMQ + docker ps | grep rabbitmq + docker logs rabbitmq + ``` + +2. **Verify credentials**: + + ```bash + # Test connection + curl -i -u username:password http://localhost:15672/api/overview + ``` + +3. **Check firewall/network**: + ```bash + telnet localhost 5672 + ``` + +### Common Issues + +- **"Connection refused"**: AMQP server not running or wrong port +- **"Authentication failed"**: Incorrect username/password +- **"Access refused"**: User lacks necessary permissions +- **"Channel closed"**: Often indicates permission issues or resource limits + +## Security Considerations + +1. **Never commit** `pushServerConfig.json` with real credentials +2. **Use strong passwords** for production AMQP instances +3. **Enable TLS** for production: `amqps://` instead of `amqp://` +4. **Limit network access** to AMQP ports using firewall rules +5. **Use separate vhosts** for different environments (dev/staging/prod) + +## Monitoring + +Monitor your AMQP queue health: + +- **Queue depth**: Messages waiting to be processed +- **Consumer count**: Should match number of publish daemons +- **Message rates**: Publishing vs consuming rates +- **Connection status**: Watch for disconnections + +RabbitMQ Management UI (if enabled): `http://localhost:15672` + +## Next Steps + +After configuring AMQP: + +1. Start the server: `yarn start` +2. Start the publish daemon: `yarn publish-daemon` +3. Monitor logs: + - `/var/log/pushServer.log` + - `/var/log/publishDaemon.log` +4. Test the connection using the demo script: `yarn demo` diff --git a/src/serverConfig.ts b/src/serverConfig.ts index 6235f7a..8e6c0b4 100644 --- a/src/serverConfig.ts +++ b/src/serverConfig.ts @@ -15,7 +15,7 @@ const asServerConfig = asObject({ listenPort: asOptional(asNumber, 8008), // Databases: - amqpUri: asOptional(asString, 'amqp://username:password@localhost:5672'), + amqpUri: asOptional(asString, 'amqp://guest:guest@localhost:5672'), couchUri: asOptional(asString, 'http://username:password@localhost:5984'), currentCluster: asOptional(asString) }) From 78e174b9a99d50b27ef76afa13ca20c26546e6ff Mon Sep 17 00:00:00 2001 From: Sam Holmes Date: Thu, 14 Aug 2025 11:52:16 -0700 Subject: [PATCH 2/2] Add marketing API --- AGENTS.md | 21 ++ README.md | 14 +- docs/guides/marketing-api-migration.md | 202 ++++++++++++ docs/guides/marketing-api.md | 340 ++++++++++++++++++++ docs/guides/marketing-database-schema.md | 190 +++++++++++ package.json | 1 + src/daemons/marketingDaemon.ts | 162 ++++++++++ src/db/couchApiKeys.ts | 3 +- src/db/couchMarketingTasks.ts | 265 +++++++++++++++ src/db/couchSetup.ts | 2 + src/server/middleware/withMarketerApiKey.ts | 45 +++ src/server/routes/marketingRoutes.ts | 207 ++++++++++++ src/server/urls.ts | 21 ++ src/types/pushTypes.ts | 42 +++ 14 files changed, 1513 insertions(+), 2 deletions(-) create mode 100644 docs/guides/marketing-api-migration.md create mode 100644 docs/guides/marketing-api.md create mode 100644 docs/guides/marketing-database-schema.md create mode 100644 src/daemons/marketingDaemon.ts create mode 100644 src/db/couchMarketingTasks.ts create mode 100644 src/server/middleware/withMarketerApiKey.ts create mode 100644 src/server/routes/marketingRoutes.ts diff --git a/AGENTS.md b/AGENTS.md index c891817..03ea63d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,27 @@ This file serves as the central index for all documentation in the Edge Push Ser - **When to read**: Before running `yarn start` for the first time, when setting up development environment, or troubleshooting message queue issues - **Summary**: Complete guide for configuring the AMQP client connection required by the push server, including RabbitMQ 3.12 Docker setup, connection string format, security considerations, and troubleshooting tips +### API Documentation + +#### `docs/guides/marketing-api.md` + +- **When to read**: When implementing marketing campaigns, sending location-based push notifications, or integrating with the marketing task queue system +- **Summary**: Complete documentation for the Marketing API endpoints including authentication, location targeting, task queue management, progress tracking, and integration examples. Covers device filtering, error handling, and best practices for large-scale push notification campaigns. + +### Technical References + +#### `docs/guides/marketing-database-schema.md` + +- **When to read**: When working on marketing system internals, database maintenance, or understanding the task queue architecture +- **Summary**: Technical documentation of database schema changes for the Marketing API, including ApiKey updates, MarketingTask structure, CouchDB views, migration considerations, and monitoring queries. Essential for developers working on the marketing system backend. + +### Migration Guides + +#### `docs/guides/marketing-api-migration.md` + +- **When to read**: When upgrading an existing Edge Push Server installation to include Marketing API support +- **Summary**: Step-by-step migration guide covering code updates, database migration, API key configuration, daemon setup, and verification procedures. Includes troubleshooting tips, rollback procedures, and post-migration tasks for existing installations. + ### Additional Resources #### `docs/demo.ts` diff --git a/README.md b/README.md index e5e16b6..f163ba9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,19 @@ This server sends push notifications to Edge client apps. It contains an HTTP server that clients can use to register for notifications, and a background process that checks for price changes and actually sends the messages. -The docs folder has can find [an example of how to use the v2 API](./docs/demo.ts). +## Features + +- **Device Registration API**: Register devices for push notifications +- **Event-based Notifications**: Price changes, transaction confirmations, balance alerts +- **Marketing API**: Location-based marketing campaigns with async task processing +- **Background Processing**: Reliable message delivery via AMQP queues + +## Documentation + +- **[Marketing API Guide](./docs/guides/marketing-api.md)** - Complete guide for location-based push campaigns +- **[API Integration Example](./docs/demo.ts)** - TypeScript example for v2 API integration +- **[AMQP Configuration](./docs/guides/amqp-configuration.md)** - Detailed message queue setup +- **[Migration Guides](./docs/guides/)** - For upgrading existing installations ## Setup diff --git a/docs/guides/marketing-api-migration.md b/docs/guides/marketing-api-migration.md new file mode 100644 index 0000000..ca0e997 --- /dev/null +++ b/docs/guides/marketing-api-migration.md @@ -0,0 +1,202 @@ +# Marketing API Migration Guide + +This guide helps you migrate an existing Edge Push Server installation to support the new Marketing API. + +## Overview + +The Marketing API adds location-based push notification campaigns to your existing Edge Push Server. For complete API documentation and usage examples, see the [Marketing API Guide](./marketing-api.md). + +## Migration Steps + +### 1. Update Server Code + +```bash +git pull +yarn install +yarn prepare +``` + +### 2. Database Migration + +The Marketing API requires no manual database migration. The new `db_marketing_tasks` database is created automatically when the server starts. + +**Existing data is not affected** - all current functionality remains unchanged. + +### 3. API Key Configuration + +Existing API keys will have `marketer: false` by default. To enable marketing access: + +#### Option A: Update via CouchDB Fauxton Interface + +1. Open CouchDB Fauxton: `http://localhost:5984/_utils` +2. Navigate to `db_api_keys` database +3. Edit the API key document +4. Add or update: `"marketer": true` +5. Save the document + +#### Option B: Update via cURL + +```bash +# Get current API key document +curl -X GET "http://username:password@localhost:5984/db_api_keys/your-api-key-here" + +# Update with marketer permission (replace _rev with actual revision) +curl -X PUT "http://username:password@localhost:5984/db_api_keys/your-api-key-here" \ + -H "Content-Type: application/json" \ + -d '{ + "_rev": "1-abc123...", + "appId": "your-app-id", + "admin": false, + "marketer": true + }' +``` + +### 4. Start Marketing Daemon + +Add the marketing daemon to your PM2 configuration or run it manually: + +#### Manual Start + +```bash +npm run marketing-daemon +``` + +#### PM2 Configuration (pm2.json) + +Add to your existing PM2 configuration: + +```json +{ + "apps": [ + // ... existing apps ... + { + "name": "marketingDaemon", + "script": "src/daemons/marketingDaemon.ts", + "node_args": "-r sucrase/register", + "log_file": "/var/log/marketingDaemon.log", + "error_file": "/var/log/marketingDaemon.log", + "out_file": "/var/log/marketingDaemon.log", + "merge_logs": true + } + ] +} +``` + +Then restart PM2: + +```bash +pm2 reload pm2.json +pm2 save +``` + +### 5. Restart Server + +```bash +pm2 restart pushServer +# or for full restart +pm2 restart pm2.json +``` + +## Verification + +### 1. Check Database Creation + +Verify the new database exists: + +```bash +curl "http://username:password@localhost:5984/_all_dbs" | grep marketing +``` + +Should return: `db_marketing_tasks` + +### 2. Test API Access + +Test device counting with your newly configured marketer API key. For endpoint details and examples, see the [Marketing API Guide](./marketing-api.md#endpoints). + +### 3. Check Logs + +Monitor logs for any errors: + +```bash +tail -f /var/log/pushServer.log +tail -f /var/log/marketingDaemon.log # if using PM2 +``` + +## Troubleshooting + +### Common Issues + +**403 Forbidden on marketing endpoints** + +- Verify the API key has `marketer: true` or `admin: true` +- Check the API key exists in `db_api_keys` + +**Marketing daemon not processing tasks** + +- Verify the daemon is running: `pm2 status` +- Check daemon logs for errors +- Ensure CouchDB is accessible + +**Database connection errors** + +- Verify CouchDB credentials in `pushServerConfig.json` +- Test CouchDB connectivity: `curl http://username:password@localhost:5984` + +### Rollback Plan + +If you need to rollback: + +1. **Stop marketing daemon**: `pm2 stop marketingDaemon` +2. **Revert code**: `git checkout previous-version` +3. **Restart server**: `pm2 restart pushServer` + +The marketing database can remain - it won't affect existing functionality. + +## Post-Migration Tasks + +### 1. Configure API Keys + +Determine which API keys should have marketing access: + +- Marketing team keys: Add `marketer: true` +- Admin keys: Already have access via `admin: true` +- Regular app keys: Leave as `marketer: false` + +### 2. Monitor Performance + +Initially monitor: + +- Marketing daemon memory usage +- CouchDB disk space (tasks accumulate over time) +- Task processing times + +### 3. Set Up Task Cleanup (Optional) + +Consider implementing periodic cleanup of old completed tasks: + +```bash +# Example: Delete completed tasks older than 30 days +# This is manual - automated cleanup could be added later +curl -X GET "http://username:password@localhost:5984/db_marketing_tasks/_design/status/_view/by-status?startkey=[%22completed%22]&endkey=[%22completed%22,{}]&include_docs=true" \ + | jq -r '.rows[] | select(.doc.completed < "2024-01-01") | .doc._id' +``` + +## Next Steps + +After successful migration: + +1. **Review the [Marketing API Guide](./marketing-api.md)** for complete endpoint documentation +2. **Understand the [database schema](./marketing-database-schema.md)** for technical details +3. **Test your integration** using the examples in the API guide +4. **Monitor performance** through daemon logs and task completion rates + +## Support + +For issues: + +1. Check server logs first +2. Verify API key permissions +3. Test with simple device count queries +4. Check CouchDB connectivity and view creation + +The Marketing API is designed to be non-intrusive - existing functionality remains unchanged even if marketing features are not used. diff --git a/docs/guides/marketing-api.md b/docs/guides/marketing-api.md new file mode 100644 index 0000000..97bfe2a --- /dev/null +++ b/docs/guides/marketing-api.md @@ -0,0 +1,340 @@ +# Marketing API Guide + +The Marketing API provides endpoints for sending targeted push notifications to devices based on geographical location. This API uses an asynchronous task queue system to handle large-scale message delivery efficiently. + +> **Note**: If you're upgrading an existing installation, see the [Marketing API Migration Guide](./marketing-api-migration.md) for setup instructions. + +## Authentication + +All marketing endpoints require authentication via the `x-api-key` header. The API key must have either: + +- `marketer: true` permission for marketing operations +- `admin: true` permission for full access + +```bash +curl -H "x-api-key: your-api-key" \ + "https://your-server.com/marketing/count?country=United States" +``` + +## Endpoints + +### GET /marketing/count + +Query the number of devices available for targeting by location. + +**Query Parameters:** + +- `country` (optional): Target country name +- `region` (optional): Target region/state (requires country) +- `city` (optional): Target city (requires country) + +**Example Request:** + +```bash +curl -H "x-api-key: your-api-key" \ + "https://your-server.com/marketing/count?country=United%20States®ion=CA&city=San%20Diego" +``` + +**Example Response:** + +```json +{ + "counts": [ + { + "location": { + "country": "United States", + "city": "San Diego", + "region": "CA" + }, + "count": 150 + } + ], + "total": 150 +} +``` + +### POST /marketing/send + +Create a marketing campaign task. Returns immediately with a task ID for tracking progress. + +**Request Body:** + +- `title` (required): Push notification title +- `body` (required): Push notification message body +- `country` (optional): Target country name +- `region` (optional): Target region/state (requires country) +- `city` (optional): Target city (requires country) + +**Example Request:** + +```bash +curl -X POST \ + -H "x-api-key: your-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Special Offer!", + "body": "Check out our new features and get 20% off!", + "country": "United States", + "region": "CA" + }' \ + "https://your-server.com/marketing/send" +``` + +**Example Response:** + +```json +{ + "taskId": "lzt9x2k-abc123", + "status": "pending" +} +``` + +### GET /marketing/send/:taskId + +Get the status and progress of a specific marketing task. + +**Example Request:** + +```bash +curl -H "x-api-key: your-api-key" \ + "https://your-server.com/marketing/send/lzt9x2k-abc123" +``` + +**Example Response:** + +```json +{ + "taskId": "lzt9x2k-abc123", + "createdTime": "2024-01-20T10:30:00Z", + "status": "completed", + "started": "2024-01-20T10:31:00Z", + "completed": "2024-01-20T10:35:00Z", + "location": { + "country": "United States", + "region": "CA" + }, + "message": { + "title": "Special Offer!", + "body": "Check out our new features and get 20% off!" + }, + "progress": { + "total": 1500, + "queried": 1500, + "sent": 1400, + "failed": 50, + "filtered": 50 + } +} +``` + +### GET /marketing/sends + +List all marketing tasks. + +**Query Parameters:** + +- `status` (optional): Filter by task status (`pending`, `processing`, `completed`, `failed`) +- `limit` (optional): Number of tasks to return (default: 100) +- `skip` (optional): Number of tasks to skip (default: 0) + +**Example Request:** + +```bash +curl -H "x-api-key: your-api-key" \ + "https://your-server.com/marketing/sends?status=completed&limit=10" +``` + +**Example Response:** + +```json +{ + "tasks": [ + { + "taskId": "lzt9x2k-abc123", + "createdTime": "2024-01-20T10:30:00Z", + "status": "completed", + "location": { + "country": "United States", + "region": "CA" + }, + "message": { + "title": "Special Offer!", + "body": "Check out our new features and get 20% off!" + }, + "progress": { + "total": 1500, + "queried": 1500, + "sent": 1400, + "failed": 50, + "filtered": 50 + } + } + ] +} +``` + +## Task States + +Marketing tasks progress through the following states: + +- **pending**: Task created and waiting to be processed +- **processing**: Task is currently being executed +- **completed**: Task finished successfully +- **failed**: Task encountered an error and stopped + +## Progress Tracking + +The progress object provides detailed statistics: + +- **total**: Total number of devices found for the location +- **queried**: Number of devices processed so far +- **sent**: Number of successful push notifications sent +- **failed**: Number of devices that failed to receive the notification +- **filtered**: Number of devices filtered out (opted out of marketing, invalid tokens, etc.) + +## Device Filtering + +The system automatically filters out devices that: + +- Have `ignoreMarketing: true` set +- Don't have a valid device token +- Have invalid token formats +- Don't have an associated API key + +## Location Targeting + +Location targeting follows a hierarchical structure: + +1. **Country only**: Targets all devices in the specified country +2. **Country + Region**: Targets devices in the specified country and region/state +3. **Country + City**: Targets devices in the specified country and city +4. **Country + Region + City**: Most specific targeting + +**Important**: When specifying `region` or `city`, you must also specify `country`. + +## Error Responses + +The API returns appropriate HTTP status codes and error messages: + +**400 Bad Request:** + +```json +{ + "error": "Missing country parameter when city or region is specified" +} +``` + +**401 Unauthorized:** + +```json +{ + "error": "Missing API key" +} +``` + +**403 Forbidden:** + +```json +{ + "error": "Not authorized for marketing operations" +} +``` + +**404 Not Found:** + +```json +{ + "error": "Task not found" +} +``` + +**500 Internal Server Error:** + +```json +{ + "error": "Failed to create marketing task" +} +``` + +## Best Practices + +1. **Test with counts first**: Use `/marketing/count` to verify your targeting before creating a campaign +2. **Monitor task progress**: Poll `/marketing/send/:taskId` to track campaign progress +3. **Handle failures gracefully**: Check the `failed` count in progress and investigate if unusually high +4. **Use appropriate targeting**: More specific targeting (country + region + city) typically has better engagement +5. **Respect user preferences**: The system automatically honors marketing opt-outs, but consider additional consent mechanisms +6. **Rate limiting**: Don't create too many concurrent campaigns as they share processing resources + +## Integration Example + +Here's a complete example of creating and monitoring a marketing campaign: + +```typescript +// 1. Check device count +const countResponse = await fetch( + '/marketing/count?country=United States®ion=CA', + { + headers: { 'x-api-key': 'your-api-key' } + } +) +const { total } = await countResponse.json() +console.log(`Found ${total} devices to target`) + +// 2. Create campaign +const sendResponse = await fetch('/marketing/send', { + method: 'POST', + headers: { + 'x-api-key': 'your-api-key', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + title: 'New Feature Alert!', + body: 'Try our latest update now available', + country: 'United States', + region: 'CA' + }) +}) +const { taskId } = await sendResponse.json() +console.log(`Campaign created: ${taskId}`) + +// 3. Monitor progress +const pollProgress = async () => { + const response = await fetch(`/marketing/send/${taskId}`, { + headers: { 'x-api-key': 'your-api-key' } + }) + const task = await response.json() + + console.log(`Status: ${task.status}`) + if (task.progress) { + const { sent, failed, total } = task.progress + console.log( + `Progress: ${sent + failed}/${total} (${sent} sent, ${failed} failed)` + ) + } + + if (task.status === 'completed' || task.status === 'failed') { + return task + } + + // Poll again in 10 seconds + setTimeout(pollProgress, 10000) +} + +pollProgress() +``` + +## Background Processing + +Marketing tasks are processed by a background daemon that: + +- Runs every 6 seconds checking for pending tasks +- Processes up to 5 tasks concurrently +- Updates task progress every 100 devices processed +- Handles device filtering and validation +- Integrates with the existing push notification infrastructure + +The daemon can be monitored through server logs and will automatically retry failed operations where appropriate. + +## Technical Details + +For database schema, implementation details, and monitoring queries, see the [Marketing Database Schema](./marketing-database-schema.md) documentation. diff --git a/docs/guides/marketing-database-schema.md b/docs/guides/marketing-database-schema.md new file mode 100644 index 0000000..2e39a6f --- /dev/null +++ b/docs/guides/marketing-database-schema.md @@ -0,0 +1,190 @@ +# Marketing Database Schema + +This document describes the database schema changes for the Marketing API implementation. + +## API Keys Schema Changes + +### Updated Fields + +The `ApiKey` interface in `src/types/pushTypes.ts` has been extended with a new field: + +```typescript +export interface ApiKey { + apiKey: string + appId: string + + admin: boolean + marketer: boolean // New field - grants marketing permissions + adminsdk?: FirebaseAdminKey +} +``` + +### Database Storage + +In CouchDB's `db_api_keys` database: + +- `marketer` field defaults to `false` if not specified +- Existing API keys will have `marketer: false` until explicitly updated +- Admin keys (`admin: true`) automatically have marketing permissions + +### Cleaner Updates + +The `asCouchApiKey` cleaner in `src/db/couchApiKeys.ts` includes: + +```typescript +export const asCouchApiKey = asCouchDoc( + asObject({ + appId: asString, + admin: asBoolean, + marketer: asOptional(asBoolean, false), // Defaults to false + adminsdk: asOptional(asFirebaseAdminKey) + }) +) +``` + +## Marketing Tasks Schema + +### New Database: `db_marketing_tasks` + +A new CouchDB database stores marketing task information with the following structure: + +```typescript +export interface MarketingTask { + readonly taskId: string // Unique task identifier + readonly createdTime: Date // Task creation timestamp + + // Task parameters + readonly location: { + country?: string + city?: string + region?: string + } + readonly message: { + title: string + body: string + } + + // Task status + status: MarketingTaskStatus // 'pending' | 'processing' | 'completed' | 'failed' + started?: Date + completed?: Date + error?: string + + // Progress tracking + progress: { + total: number // Total devices found + queried: number // Devices processed so far + sent: number // Successfully sent notifications + failed: number // Failed to send + filtered: number // Filtered out (opted out, invalid tokens) + } +} +``` + +### Document ID Format + +Marketing task documents use a compound ID format for efficient querying: + +``` +{ISO_DATE}_{TASK_ID} +``` + +Example: `2024-01-20T10:30:00.000Z_lzt9x2k-abc123` + +This allows: + +- Chronological sorting by creation time +- Easy extraction of both timestamp and task ID +- Efficient range queries + +### CouchDB Views + +One view is created for efficient querying: + +#### Status View (`_design/status`) + +```javascript +function (doc) { + emit([doc.status, doc.createdTime], null); +} +``` + +Enables queries like: + +- All pending tasks +- Tasks by status with date ordering +- Date range queries within status + +The view uses the `createdTime` field directly instead of parsing the document ID, making it more efficient and cleaner. + +## Database Setup + +### Initialization + +The marketing tasks database is automatically created during server startup via `src/db/couchSetup.ts`: + +```typescript +await Promise.all([ + setupDatabase(connections.couch, couchApiKeysSetup, options), + setupDatabase(connections.couch, couchDevicesSetup, options), + setupDatabase(connections.couch, couchEventsSetup, options), + setupDatabase(connections.couch, couchMarketingTasksSetup, options), // New + setupDatabase(connections.couch, devicesSetup, options), + setupDatabase(connections.couch, usersSetup, options) +]) +``` + +### Migration Considerations + +For existing installations: + +1. **API Keys**: Existing API keys will have `marketer: false` by default +2. **Database Creation**: The `db_marketing_tasks` database is created automatically +3. **No Data Migration**: This is a new feature with no existing data to migrate +4. **Backward Compatibility**: All existing endpoints and functionality remain unchanged + +### Performance Considerations + +1. **Indexing**: Views provide efficient access patterns for common queries +2. **Task Cleanup**: Consider implementing task cleanup for old completed/failed tasks +3. **Concurrent Processing**: The daemon processes up to 5 tasks concurrently +4. **Progress Updates**: Progress is updated every 100 processed devices to balance accuracy vs. performance + +### Monitoring Queries + +Useful CouchDB queries for monitoring: + +```bash +# Get all pending tasks +GET /db_marketing_tasks/_design/status/_view/by-status?startkey=["pending"]&endkey=["pending",{}] + +# Get failed tasks for investigation +GET /db_marketing_tasks/_design/status/_view/by-status?startkey=["failed"]&endkey=["failed",{}]&include_docs=true + +# Get completed tasks +GET /db_marketing_tasks/_design/status/_view/by-status?startkey=["completed"]&endkey=["completed",{}] +``` + +## Database Operations + +### Common Operations + +The `src/db/couchMarketingTasks.ts` module provides: + +- `createMarketingTask()` - Create new task +- `getMarketingTask()` - Get task by ID +- `updateMarketingTask()` - Update task status/progress +- `listMarketingTasks()` - List tasks with filters +- `getPendingMarketingTasks()` - Get tasks ready for processing + +### Error Handling + +- **Conflict Resolution**: Automatic retry on CouchDB conflicts +- **Missing Tasks**: Proper 404 handling for non-existent tasks +- **Invalid IDs**: Validation of task ID format + +### Security + +- **Access Control**: All users with marketing permissions can view all tasks +- **API Key Validation**: Marketing permission checked on all operations +- **Input Validation**: All inputs validated with cleaners diff --git a/package.json b/package.json index 3a842b2..f60e14b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "cli": "node -r sucrase/register src/cli/cli.ts", "confirmation-daemon": "node -r sucrase/register src/daemons/confirmationDaemon.ts", "demo": "node -r sucrase/register docs/demo.ts", + "marketing-daemon": "node -r sucrase/register src/daemons/marketingDaemon.ts", "fix": "yarn-deduplicate && eslint . --fix", "lint": "eslint .", "precommit": "lint-staged && npm-run-all types test prepare", diff --git a/src/daemons/marketingDaemon.ts b/src/daemons/marketingDaemon.ts new file mode 100644 index 0000000..4c1c541 --- /dev/null +++ b/src/daemons/marketingDaemon.ts @@ -0,0 +1,162 @@ +import { + countDevicesByLocation, + streamDevicesByLocation +} from '../db/couchDevices' +import { + getPendingMarketingTasks, + updateMarketingTask +} from '../db/couchMarketingTasks' +import { DbConnections } from '../db/dbConnections' +import { MarketingTask } from '../types/pushTypes' +import { logger } from '../util/logger' +import { PushSender, SendableMessage } from '../util/pushSender' +import { runDaemon } from './runDaemon' + +/** + * Marketing daemon - processes pending marketing tasks from the queue + */ +runDaemon(async tools => { + const { connections, heartbeat, sender } = tools + + try { + // Get pending tasks + const pendingTasks = await getPendingMarketingTasks(connections, 5) + + if (pendingTasks.length === 0) { + heartbeat('No pending marketing tasks') + return + } + + logger.info(`Processing ${pendingTasks.length} marketing tasks`) + heartbeat(`Processing ${pendingTasks.length} tasks`) + + // Process tasks sequentially to avoid overwhelming the system + for (const task of pendingTasks) { + await processMarketingTask(connections, sender, task, heartbeat).catch( + error => { + logger.error(`Error processing marketing task ${task.taskId}:`, error) + } + ) + } + } catch (error) { + logger.error('Marketing daemon error:', error) + throw error + } +}) + +/** + * Process a single marketing task + */ +async function processMarketingTask( + connections: DbConnections, + sender: PushSender, + task: MarketingTask, + heartbeat: (item?: string) => void +): Promise { + logger.info(`Processing marketing task ${task.taskId}`) + heartbeat(`Task ${task.taskId}`) + + // Update status to processing + await updateMarketingTask(connections, task.taskId, { + status: 'processing', + started: new Date() + }) + + try { + // Count devices first + const countResults = await countDevicesByLocation( + connections, + task.location + ) + const total = countResults.reduce((sum, row) => sum + row.count, 0) + + logger.info( + `Task ${task.taskId}: Found ${total} devices for location`, + task.location + ) + heartbeat(`Task ${task.taskId}: ${total} devices`) + + // Initialize progress tracking + let sent = 0 + let failed = 0 + let filtered = 0 + let queried = 0 + + // Create message + const message: SendableMessage = { + ...task.message, + isMarketing: true, + isPriceChange: false + } + + // Stream and send to devices + for await (const deviceRow of streamDevicesByLocation( + connections, + task.location + )) { + const { device } = deviceRow + const { apiKey, deviceId, deviceToken, ignoreMarketing } = device + queried++ + + // Skip document conditions + if ( + ignoreMarketing || + apiKey == null || + deviceToken == null || + deviceToken.trim() === '' + ) { + filtered++ + continue + } + + // Validate token format + if (!/^[a-zA-z0-9_\-:]+$/.test(deviceToken)) { + logger.warn(`Invalid token '${deviceToken}' for device '${deviceId}'`) + filtered++ + continue + } + + // Send message + try { + await sender.sendToDevice(device, message) + sent++ + } catch (error) { + logger.warn(`Failed to send to device ${deviceId}:`, error) + failed++ + } + + // Update progress periodically (every 100 devices) + if (queried % 100 === 0) { + await updateMarketingTask(connections, task.taskId, { + progress: { total, queried, sent, failed, filtered } + }) + heartbeat(`Task ${task.taskId}: ${queried}/${total}`) + logger.info( + `Task ${task.taskId} progress: ${queried}/${total} queried, ${sent} sent` + ) + } + } + + // Final progress update + await updateMarketingTask(connections, task.taskId, { + status: 'completed', + completed: new Date(), + progress: { total, queried, sent, failed, filtered } + }) + + logger.info( + `Task ${task.taskId} completed: ${sent} sent, ${failed} failed, ${filtered} filtered out of ${total} total` + ) + heartbeat(`Task ${task.taskId}: completed`) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error(`Task ${task.taskId} failed:`, error) + + await updateMarketingTask(connections, task.taskId, { + status: 'failed', + error: errorMessage, + completed: new Date() + }) + heartbeat(`Task ${task.taskId}: failed`) + } +} diff --git a/src/db/couchApiKeys.ts b/src/db/couchApiKeys.ts index 4283021..1e9d7e9 100644 --- a/src/db/couchApiKeys.ts +++ b/src/db/couchApiKeys.ts @@ -32,6 +32,7 @@ export const asCouchApiKey = asCouchDoc( asObject({ appId: asString, admin: asBoolean, + marketer: asOptional(asBoolean, false), adminsdk: asOptional(asFirebaseAdminKey) }) ) @@ -60,5 +61,5 @@ export async function getApiKeyByKey( } function unpackApiKey(doc: CouchDoc): ApiKey { - return { ...doc.doc, apiKey: doc.id } + return { ...doc.doc, apiKey: doc.id, marketer: doc.doc.marketer ?? false } } diff --git a/src/db/couchMarketingTasks.ts b/src/db/couchMarketingTasks.ts new file mode 100644 index 0000000..61ef1ca --- /dev/null +++ b/src/db/couchMarketingTasks.ts @@ -0,0 +1,265 @@ +import { + asDate, + asNumber, + asObject, + asOptional, + asString, + asValue, + Cleaner +} from 'cleaners' +import { + asCouchDoc, + asMaybeConflictError, + CouchDoc, + DatabaseSetup, + makeJsDesign +} from 'edge-server-tools' + +import { MarketingTask, MarketingTaskStatus } from '../types/pushTypes' +import { DbConnections } from './dbConnections' + +interface CouchMarketingTask extends Omit {} + +export const asMarketingTaskStatus: Cleaner = asValue( + 'pending', + 'processing', + 'completed', + 'failed' +) + +/** + * A marketing task, as stored in Couch. + * The document ID is the task ID. + */ +export const asCouchMarketingTask = asCouchDoc( + asObject({ + createdTime: asDate, + location: asObject({ + country: asOptional(asString), + city: asOptional(asString), + region: asOptional(asString) + }), + message: asObject({ + title: asString, + body: asString + }), + status: asMarketingTaskStatus, + started: asOptional(asDate), + completed: asOptional(asDate), + error: asOptional(asString), + progress: asObject({ + total: asNumber, + queried: asNumber, + sent: asNumber, + failed: asNumber, + filtered: asNumber + }) + }) +) + +/** + * The marketing tasks database setup. + */ +export const couchMarketingTasksSetup: DatabaseSetup = { + name: 'db_marketing_tasks', + documents: { + '_design/status': makeJsDesign('status', ({ emit }) => ({ + map: function (doc: any) { + emit([doc.status, doc.createdTime], null) + } + })) + } +} + +/** + * Generate a unique task ID + */ +function generateTaskId(): string { + const timestamp = Date.now().toString(36) + const random = Math.random().toString(36).substring(2, 9) + return `${timestamp}-${random}` +} + +/** + * Creates a new marketing task. + */ +export async function createMarketingTask( + connections: DbConnections, + task: Omit +): Promise { + const db = connections.couch.db.use(couchMarketingTasksSetup.name) + + const now = new Date() + const taskId = generateTaskId() + const docId = `${now.toISOString()}_${taskId}` + + const doc = { + _id: docId, + ...task, + createdTime: now, + progress: { + total: 0, + queried: 0, + sent: 0, + failed: 0, + filtered: 0 + } + } + + await db.insert(doc) + return taskId +} + +/** + * Gets a marketing task by ID. + */ +export async function getMarketingTask( + connections: DbConnections, + taskId: string +): Promise { + const db = connections.couch.db.use(couchMarketingTasksSetup.name) + + // Query using the status view to find all tasks + const result = await db.view('status', 'status', { + startkey: ['pending', ''], + endkey: ['failed', '\ufff0'], + include_docs: true + }) + + for (const row of result.rows) { + if (row.id !== '' && row.id?.includes(taskId) && row.doc != null) { + const doc = asCouchMarketingTask({ + ...row.doc, + _id: row.id, + _rev: row.doc._rev + }) + return unpackMarketingTask(doc) + } + } + + return undefined +} + +/** + * Updates a marketing task. + */ +export async function updateMarketingTask( + connections: DbConnections, + taskId: string, + updates: Partial< + Omit + > +): Promise { + const db = connections.couch.db.use(couchMarketingTasksSetup.name) + + // Find the document + const result = await db.view('status', 'status', { + startkey: ['pending', ''], + endkey: ['failed', '\ufff0'], + include_docs: true + }) + + for (const row of result.rows) { + if (row.id !== '' && row.id?.includes(taskId) && row.doc != null) { + const existingDoc = row.doc as any + + const updated = { + ...existingDoc, + ...updates, + progress: + updates.progress != null + ? { ...existingDoc.progress, ...updates.progress } + : existingDoc.progress, + _id: row.id, + _rev: existingDoc._rev + } + + await db.insert(updated).catch(async error => { + if (asMaybeConflictError(error) != null) { + // Retry once on conflict + return await updateMarketingTask(connections, taskId, updates) + } + throw error + }) + + return + } + } + + throw new Error(`Marketing task ${taskId} not found`) +} + +/** + * Lists marketing tasks with optional filters. + */ +export async function listMarketingTasks( + connections: DbConnections, + options: { + status?: MarketingTaskStatus + limit?: number + skip?: number + } = {} +): Promise { + const db = connections.couch.db.use(couchMarketingTasksSetup.name) + const { status, limit = 100, skip = 0 } = options + + if (status != null) { + // Query by status + const result = await db.view('status', 'status', { + startkey: [status, '\ufff0'], + endkey: [status, ''], + include_docs: true, + limit, + skip, + descending: true + }) + + return result.rows.map((row: any) => { + if (row.doc == null || row.id == null) throw new Error('Invalid row data') + const doc = asCouchMarketingTask({ + ...row.doc, + _id: row.id, + _rev: row.doc._rev + }) + return unpackMarketingTask(doc) + }) + } else { + // Get all tasks + const result = await db.view('status', 'status', { + startkey: ['\ufff0', '\ufff0'], + endkey: ['', ''], + include_docs: true, + limit, + skip, + descending: true + }) + + return result.rows.map((row: any) => { + if (row.doc == null || row.id == null) throw new Error('Invalid row data') + const doc = asCouchMarketingTask({ + ...row.doc, + _id: row.id, + _rev: row.doc._rev + }) + return unpackMarketingTask(doc) + }) + } +} + +/** + * Gets pending marketing tasks for processing. + */ +export async function getPendingMarketingTasks( + connections: DbConnections, + limit: number = 10 +): Promise { + return await listMarketingTasks(connections, { status: 'pending', limit }) +} + +function unpackMarketingTask(doc: CouchDoc): MarketingTask { + const [, taskId] = doc.id.split('_') + return { + ...doc.doc, + taskId + } +} diff --git a/src/db/couchSetup.ts b/src/db/couchSetup.ts index bcab1dc..338149a 100644 --- a/src/db/couchSetup.ts +++ b/src/db/couchSetup.ts @@ -7,6 +7,7 @@ import { import { serverConfig } from '../serverConfig' import { couchApiKeysSetup } from './couchApiKeys' import { couchDevicesSetup } from './couchDevices' +import { couchMarketingTasksSetup } from './couchMarketingTasks' import { couchEventsSetup } from './couchPushEvents' import { settingsSetup, syncedReplicators } from './couchSettings' import { DbConnections } from './dbConnections' @@ -45,6 +46,7 @@ export async function setupDatabases( setupDatabase(connections.couch, couchApiKeysSetup, options), setupDatabase(connections.couch, couchDevicesSetup, options), setupDatabase(connections.couch, couchEventsSetup, options), + setupDatabase(connections.couch, couchMarketingTasksSetup, options), setupDatabase(connections.couch, devicesSetup, options), setupDatabase(connections.couch, usersSetup, options) ]) diff --git a/src/server/middleware/withMarketerApiKey.ts b/src/server/middleware/withMarketerApiKey.ts new file mode 100644 index 0000000..a34c079 --- /dev/null +++ b/src/server/middleware/withMarketerApiKey.ts @@ -0,0 +1,45 @@ +import { Serverlet } from 'serverlet' + +import { getApiKeyByKey } from '../../db/couchApiKeys' +import { ApiRequest, DbRequest } from '../../types/requestTypes' +import { errorResponse } from '../../types/responseTypes' + +/** + * Checks the API key passed in the request headers, + * then passes the request along if the key is valid and has marketer or admin permissions. + */ +export const withMarketerApiKey = + (server: Serverlet): Serverlet => + async request => { + const { connections, headers, log } = request + + // Parse the key out of the headers: + const header = headers['x-api-key'] + if (header == null) { + return errorResponse('Missing API key', { status: 401 }) + } + + // Look up the key in the database: + const apiKey = await log.debugTime( + 'getApiKeyByKey', + getApiKeyByKey(connections, header) + ) + if (apiKey == null) { + return errorResponse('Incorrect API key', { status: 401 }) + } + + // Check if the API key has marketer or admin permissions + if (!apiKey.marketer && !apiKey.admin) { + return errorResponse('Not authorized for marketing operations', { + status: 403 + }) + } + + // Pass that along: + return await server({ + ...request, + apiKey, + json: request.req.body, + query: request.req.query + }) + } diff --git a/src/server/routes/marketingRoutes.ts b/src/server/routes/marketingRoutes.ts new file mode 100644 index 0000000..844e0ed --- /dev/null +++ b/src/server/routes/marketingRoutes.ts @@ -0,0 +1,207 @@ +import { asObject, asOptional, asString } from 'cleaners' +import { Serverlet } from 'serverlet' + +import { asNumberString } from '../../cli/cliTools' +import { countDevicesByLocation } from '../../db/couchDevices' +import { + asMarketingTaskStatus, + createMarketingTask, + getMarketingTask, + listMarketingTasks +} from '../../db/couchMarketingTasks' +import { ApiRequest } from '../../types/requestTypes' +import { errorResponse, jsonResponse } from '../../types/responseTypes' +import { checkPayload } from '../../util/checkPayload' + +/** + * Query device counts by location + * + * GET /marketing/count + * Query params: country, city, region (all optional) + */ +export const marketingCountRoute: Serverlet = async request => { + const { connections, query, log } = request + + const checkedQuery = checkPayload(asMarketingCountQuery, query) + if (checkedQuery.error != null) return checkedQuery.error + const { country, city, region } = checkedQuery.clean + + // Validate location parameters + if (country == null && (city != null || region != null)) { + return errorResponse( + 'Missing country parameter when city or region is specified', + { status: 400 } + ) + } + + log( + `Counting devices for location: ${JSON.stringify({ + country, + city, + region + })}` + ) + + try { + const countResults = await countDevicesByLocation(connections, { + country, + region, + city + }) + + // Format the response + const counts = countResults.map(row => { + const [country, city, region] = row.key + return { + location: { + country: country !== '' ? country : undefined, + city: city !== '' ? city : undefined, + region: region !== '' ? region : undefined + }, + count: row.count + } + }) + + const total = countResults.reduce((sum, row) => sum + row.count, 0) + + return jsonResponse({ counts, total }) + } catch (error) { + log(`Error counting devices: ${String(error)}`) + return errorResponse('Failed to count devices', { status: 500 }) + } +} + +/** + * Create a marketing send task + * + * POST /marketing/send + * Request body: { country?, city?, region?, title, body } + */ +export const marketingSendRoute: Serverlet = async request => { + const { connections, json, log } = request + + const checkedBody = checkPayload(asMarketingSendBody, json) + if (checkedBody.error != null) return checkedBody.error + const { country, city, region, title, body } = checkedBody.clean + + // Validate location parameters + if (country == null && (city != null || region != null)) { + return errorResponse( + 'Missing country parameter when city or region is specified', + { status: 400 } + ) + } + + log( + `Creating marketing task for location: ${JSON.stringify({ + country, + city, + region + })}` + ) + + try { + const taskId = await createMarketingTask(connections, { + location: { country, city, region }, + message: { title, body }, + status: 'pending' + }) + + log(`Created marketing task ${taskId}`) + return jsonResponse({ taskId, status: 'pending' }) + } catch (error) { + log(`Error creating marketing task: ${String(error)}`) + return errorResponse('Failed to create marketing task', { status: 500 }) + } +} + +/** + * Get a specific marketing task by ID + * + * GET /marketing/send/:id + */ +export const marketingTaskRoute: Serverlet = async request => { + const { connections, path, log } = request + + // Extract task ID from path + const pathParts = path.split('/') + const taskId = + pathParts[pathParts.length - 1] !== '' + ? pathParts[pathParts.length - 1] + : pathParts[pathParts.length - 2] + + if (taskId === '' || taskId === 'send') { + return errorResponse('Missing task ID', { status: 400 }) + } + + log(`Getting marketing task ${taskId}`) + + try { + const task = await getMarketingTask(connections, taskId) + + if (task == null) { + return errorResponse('Task not found', { status: 404 }) + } + + return jsonResponse(task) + } catch (error) { + log(`Error getting marketing task: ${String(error)}`) + return errorResponse('Failed to get marketing task', { status: 500 }) + } +} + +/** + * List marketing tasks with optional filters + * + * GET /marketing/sends + * Query params: status?, limit?, skip? + */ +export const marketingTasksListRoute: Serverlet = async request => { + const { connections, query, log } = request + + const checkedQuery = checkPayload(asMarketingTasksQuery, query) + if (checkedQuery.error != null) return checkedQuery.error + const { status, limit, skip } = checkedQuery.clean + + log( + `Listing marketing tasks with filters: ${JSON.stringify({ + status, + limit, + skip + })}` + ) + + try { + const tasks = await listMarketingTasks(connections, { + status, + limit: limit ?? 100, + skip: skip ?? 0 + }) + + return jsonResponse({ tasks }) + } catch (error) { + log(`Error listing marketing tasks: ${String(error)}`) + return errorResponse('Failed to list marketing tasks', { status: 500 }) + } +} + +// Cleaners for request validation +const asMarketingCountQuery = asObject({ + country: asOptional(asString), + city: asOptional(asString), + region: asOptional(asString) +}) + +const asMarketingSendBody = asObject({ + country: asOptional(asString), + city: asOptional(asString), + region: asOptional(asString), + title: asString, + body: asString +}) + +const asMarketingTasksQuery = asObject({ + status: asOptional(asMarketingTaskStatus), + limit: asOptional(asNumberString), + skip: asOptional(asNumberString) +}) diff --git a/src/server/urls.ts b/src/server/urls.ts index 994733c..3cad110 100644 --- a/src/server/urls.ts +++ b/src/server/urls.ts @@ -3,6 +3,7 @@ import { pickMethod, pickPath, Serverlet } from 'serverlet' import { DbRequest } from '../types/requestTypes' import { errorResponse, jsonResponse } from '../types/responseTypes' import { withLegacyApiKey } from './middleware/withLegacyApiKey' +import { withMarketerApiKey } from './middleware/withMarketerApiKey' import { deviceFetchRoute, deviceUpdateRoute } from './routes/deviceRoutes' import { attachUserV1Route, @@ -14,6 +15,12 @@ import { toggleStateV1Route } from './routes/legacyRoutes' import { loginFetchRoute, loginUpdateRoute } from './routes/loginRoutes' +import { + marketingCountRoute, + marketingSendRoute, + marketingTaskRoute, + marketingTasksListRoute +} from './routes/marketingRoutes' import { sendNotificationV1Route } from './routes/notificationRoute' const missingRoute: Serverlet = request => @@ -61,6 +68,20 @@ const urls: { [path: string]: Serverlet } = { }), '/v2/login/update/?': pickMethod({ POST: loginUpdateRoute + }), + + // Marketing endpoints + '/marketing/count/?': pickMethod({ + GET: withMarketerApiKey(marketingCountRoute) + }), + '/marketing/send/?': pickMethod({ + POST: withMarketerApiKey(marketingSendRoute) + }), + '/marketing/send/[a-zA-Z0-9-]+/?': pickMethod({ + GET: withMarketerApiKey(marketingTaskRoute) + }), + '/marketing/sends/?': pickMethod({ + GET: withMarketerApiKey(marketingTasksListRoute) }) } export const allRoutes: Serverlet = pickPath(urls, missingRoute) diff --git a/src/types/pushTypes.ts b/src/types/pushTypes.ts index 2713fb6..96a7863 100644 --- a/src/types/pushTypes.ts +++ b/src/types/pushTypes.ts @@ -26,6 +26,7 @@ export interface ApiKey { appId: string admin: boolean + marketer: boolean adminsdk?: FirebaseAdminKey } @@ -149,6 +150,47 @@ export type PushEventState = | 'triggered' // The trigger and effects are done | 'hidden' // Removed after being triggered +// +// Marketing task queue types +// + +export type MarketingTaskStatus = + | 'pending' + | 'processing' + | 'completed' + | 'failed' + +export interface MarketingTask { + readonly taskId: string + readonly createdTime: Date + + // Task parameters + readonly location: { + country?: string + city?: string + region?: string + } + readonly message: { + title: string + body: string + } + + // Task status + status: MarketingTaskStatus + started?: Date + completed?: Date + error?: string + + // Progress tracking + progress: { + total: number + queried: number + sent: number + failed: number + filtered: number + } +} + /** * Combines a trigger with an action. * This the in-memory format, independent of the database.