feat(broadcasts,surveys): Phase 1 complete - backend modules
- broadcasts/types.ts: Broadcast, BroadcastTarget, BroadcastMetrics, InAppMessage - broadcasts/repository.ts: CRUD + delivery tracking + read receipts - broadcasts/targeting.ts: evaluateTarget(), semver, FNV-1a hash - broadcasts/routes.ts: Admin CRUD + public endpoints (14 routes) - surveys/types.ts: Survey, Question, SurveyResponse, conditional logic - surveys/repository.ts: CRUD + analytics + CSV export - surveys/routes.ts: Admin CRUD + public endpoints (13 routes) - cosmos-init.ts: 7 new containers with TTL policies - server.ts: Register broadcastRoutes + surveyRoutes Implements Phase 1 of platform_BROADCAST_SURVEY_ROADMAP.md
This commit is contained in:
parent
6a23a02cd4
commit
1b11db3f6f
331
docs/devops/USER_ISSUE_REPORTING_ROADMAP.md
Normal file
331
docs/devops/USER_ISSUE_REPORTING_ROADMAP.md
Normal file
@ -0,0 +1,331 @@
|
||||
# User-Initiated Issue Reporting with Screenshots — Implementation Roadmap
|
||||
|
||||
> **Location:** `docs/devops/USER_ISSUE_REPORTING_ROADMAP.md`
|
||||
> **Status:** Draft
|
||||
> **Created:** 2026-03-02
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Enable users to self-report issues with automatic/manual screenshots and comments. This extends the existing `feedback` module to support rich media attachments, leveraging the `blob` module for Azure Blob storage.
|
||||
|
||||
### Use Cases
|
||||
|
||||
1. **Bug Report with Screenshot** — User encounters error → taps "Report Issue" → screenshot captured → typed description submitted
|
||||
2. **Feature Request with Mockup** — User uploads annotated screenshot showing desired UI change
|
||||
3. **Crash Auto-Report** — App crashes → on restart, user prompted to "Send crash report with screenshot"
|
||||
4. **Support Chat Attachment** — Screenshots attached to existing feedback for back-and-forth with support team
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
### Existing Feedback Module
|
||||
|
||||
| Component | Status |
|
||||
|-----------|--------|
|
||||
| `POST /api/feedback` | ✅ Creates feedback (type, title, body, screen) |
|
||||
| `GET /api/feedback` | ✅ Admin list/query |
|
||||
| `PUT /api/feedback/:id` | ✅ Admin triage (status, adminNotes) |
|
||||
| Screenshot support | ❌ Only text `screen` field |
|
||||
| Multiple attachments | ❌ Not supported |
|
||||
| Threaded comments | ❌ Not supported |
|
||||
|
||||
### Existing Blob Module (Reusable)
|
||||
|
||||
| Component | Status |
|
||||
|-----------|--------|
|
||||
| `POST /api/blob/sas` | ✅ Generate SAS URL for direct upload |
|
||||
| Azure Blob integration | ✅ Storage + lifecycle |
|
||||
| Container management | ✅ Per-product isolation |
|
||||
|
||||
---
|
||||
|
||||
## Target Architecture
|
||||
|
||||
```
|
||||
User Flow:
|
||||
1. User taps "Report Issue" in app
|
||||
2. Client captures screenshot (optional annotation)
|
||||
3. Client requests SAS URL: POST /api/feedback/sas
|
||||
4. Client uploads image directly to Azure Blob
|
||||
5. Client submits feedback: POST /api/feedback (with screenshotBlobPath)
|
||||
6. Admin views feedback in dashboard with image preview
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### Option A: Inline Screenshot (Simple)
|
||||
|
||||
```typescript
|
||||
// feedback/types.ts — Add to existing FeedbackDoc
|
||||
interface FeedbackDoc {
|
||||
// ... existing fields ...
|
||||
|
||||
// Screenshot attachment (single)
|
||||
screenshotBlobPath?: string; // "feedback/{productId}/{feedbackId}/{screenshotId}.png"
|
||||
screenshotUrl?: string; // Time-limited SAS URL for viewing
|
||||
screenshotUrlExpiresAt?: string; // When SAS URL expires
|
||||
|
||||
// Device context for debugging
|
||||
deviceContext?: {
|
||||
osVersion: string;
|
||||
appVersion: string;
|
||||
deviceModel: string;
|
||||
screenResolution: string;
|
||||
locale: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Add to CreateFeedbackSchema
|
||||
screenshotBlobPath: z.string().optional(),
|
||||
deviceContext: z.object({
|
||||
osVersion: z.string(),
|
||||
appVersion: z.string(),
|
||||
deviceModel: z.string(),
|
||||
screenResolution: z.string(),
|
||||
locale: z.string(),
|
||||
}).optional(),
|
||||
```
|
||||
|
||||
### Option B: Separate FeedbackAttachments Container (Extensible)
|
||||
|
||||
```typescript
|
||||
// feedback/types.ts — New attachment model
|
||||
interface FeedbackAttachmentDoc {
|
||||
id: string; // att_<uuid>
|
||||
feedbackId: string; // Parent feedback (partition key)
|
||||
productId: string;
|
||||
|
||||
// Blob storage
|
||||
blobPath: string; // "feedback/{productId}/{feedbackId}/{id}.png"
|
||||
blobUrl: string; // SAS URL (refreshed on fetch)
|
||||
containerName: string; // e.g., "user-feedback"
|
||||
|
||||
// Metadata
|
||||
fileName: string;
|
||||
contentType: 'image/png' | 'image/jpeg' | 'image/webp';
|
||||
sizeBytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
|
||||
// Capture context
|
||||
capturedAt: string;
|
||||
trigger: 'manual' | 'auto_crash' | 'auto_error';
|
||||
screenName?: string;
|
||||
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface FeedbackDoc {
|
||||
// ... existing fields ...
|
||||
|
||||
// Reference to attachments
|
||||
attachmentCount: number; // Denormalized counter
|
||||
hasScreenshot: boolean; // Quick check for UI
|
||||
}
|
||||
|
||||
// API changes
|
||||
POST /api/feedback/:id/attachments // Add attachment to existing feedback
|
||||
GET /api/feedback/:id/attachments // List attachments
|
||||
DELETE /api/feedback/:id/attachments/:attId // Remove attachment (admin)
|
||||
```
|
||||
|
||||
**Recommendation:** Start with **Option A** (single screenshot), migrate to **Option B** if multi-attachment demand arises.
|
||||
|
||||
---
|
||||
|
||||
## API Specification
|
||||
|
||||
### New Endpoints
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| `POST` | `/api/feedback/sas` | User | Get SAS URL for screenshot upload |
|
||||
| `POST` | `/api/feedback` | User | Submit feedback (with optional screenshotBlobPath) |
|
||||
| `GET` | `/api/feedback/:id/screenshot` | Admin | Get fresh SAS URL for viewing screenshot |
|
||||
| `DELETE` | `/api/feedback/:id/screenshot` | Admin | Delete screenshot (GDPR/privacy) |
|
||||
|
||||
### SAS Generation Endpoint
|
||||
|
||||
```typescript
|
||||
// POST /api/feedback/sas
|
||||
// Request
|
||||
{
|
||||
"contentType": "image/png",
|
||||
"sizeHint": 1024000 // Optional: 1MB hint for validation
|
||||
}
|
||||
|
||||
// Response (201)
|
||||
{
|
||||
"blobPath": "feedback/lysnrai/feedback_abc123/screenshot_xyz.png",
|
||||
"uploadUrl": "https://bytelyst.blob.core.windows.net/...?sv=...",
|
||||
"expiresIn": 300, // 5 minutes
|
||||
"maxSizeBytes": 5242880 // 5MB limit
|
||||
}
|
||||
```
|
||||
|
||||
### Submit Feedback with Screenshot
|
||||
|
||||
```typescript
|
||||
// POST /api/feedback
|
||||
// Request
|
||||
{
|
||||
"type": "bug",
|
||||
"title": "App crashes when tapping record",
|
||||
"body": "Steps: 1. Open app 2. Tap red button 3. Crash",
|
||||
"screen": "RecordingScreen",
|
||||
"screenshotBlobPath": "feedback/lysnrai/feedback_abc123/screenshot_xyz.png",
|
||||
"deviceContext": {
|
||||
"osVersion": "iOS 17.4",
|
||||
"appVersion": "2.3.1",
|
||||
"deviceModel": "iPhone15,2",
|
||||
"screenResolution": "393x852",
|
||||
"locale": "en-US"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Server Foundation (2-3 days)
|
||||
|
||||
#### 1.1 Data Model Extension
|
||||
- [ ] Add `screenshotBlobPath` to `FeedbackDoc` interface
|
||||
- [ ] Add `deviceContext` to `FeedbackDoc` interface
|
||||
- [ ] Update `CreateFeedbackSchema` with new fields
|
||||
- [ ] Add `feedback_screenshots` container to `cosmos-init.ts` (if Option B)
|
||||
|
||||
#### 1.2 Repository Layer
|
||||
- [ ] Extend `createFeedback()` to handle screenshot metadata
|
||||
- [ ] Add `generateScreenshotSas()` function (wrapper around blob module)
|
||||
- [ ] Add `getFeedbackWithScreenshot()` with fresh SAS URL generation
|
||||
- [ ] Add `deleteFeedbackScreenshot()` for GDPR compliance
|
||||
|
||||
#### 1.3 API Routes
|
||||
- [ ] `POST /api/feedback/sas` — Generate upload URL
|
||||
- Rate limit: 5 requests per 10 minutes per user
|
||||
- Validate content type (image/* only)
|
||||
- Return blob path + SAS URL
|
||||
- [ ] Update `POST /api/feedback` — Accept screenshot metadata
|
||||
- [ ] `GET /api/feedback/:id/screenshot` — Get fresh view URL
|
||||
- [ ] `DELETE /api/feedback/:id/screenshot` — Admin delete
|
||||
|
||||
#### 1.4 Integration
|
||||
- [ ] Wire new routes into `server.ts`
|
||||
- [ ] Add blob container `user-feedback` to blob module config
|
||||
- [ ] Set lifecycle policy: 90-day TTL for user screenshots
|
||||
|
||||
#### 1.5 Testing
|
||||
- [ ] Unit tests for SAS generation
|
||||
- [ ] Unit tests for feedback with screenshot submission
|
||||
- [ ] Integration test: full flow (SAS → upload → submit → view)
|
||||
- [ ] GDPR deletion test
|
||||
|
||||
### Phase 2: Client SDK Updates (3-4 days)
|
||||
|
||||
#### 2.1 TypeScript SDK (`@bytelyst/feedback-client`)
|
||||
- [ ] `FeedbackClient.submitWithScreenshot(params)`
|
||||
- Internal flow: get SAS → upload blob → submit feedback
|
||||
- Progress callbacks for upload
|
||||
- [ ] `FeedbackClient.captureAndSubmit()` — Auto-capture current screen
|
||||
|
||||
#### 2.2 Swift SDK (iOS)
|
||||
- [ ] `FeedbackManager.submit(title:body:screenshot:)`
|
||||
- [ ] `FeedbackManager.captureScreenshot()` — UIImage → blob upload
|
||||
- [ ] Annotation overlay (optional drawing on screenshot)
|
||||
|
||||
#### 2.3 Kotlin SDK (Android)
|
||||
- [ ] `FeedbackManager.submitWithScreenshot()`
|
||||
- [ ] MediaProjection integration for screenshot capture
|
||||
- [ ] Composable for in-app feedback sheet with screenshot preview
|
||||
|
||||
### Phase 3: Admin Dashboard UI (2-3 days)
|
||||
|
||||
#### 3.1 Feedback List Enhancements
|
||||
- [ ] Thumbnail preview of screenshot in list view
|
||||
- [ ] Filter: "Has screenshot" / "No screenshot"
|
||||
|
||||
#### 3.2 Feedback Detail View
|
||||
- [ ] Full-size screenshot display (lightbox)
|
||||
- [ ] Device context panel (OS version, app version, screen resolution)
|
||||
- [ ] "Download screenshot" button
|
||||
- [ ] "Delete screenshot" (GDPR compliance)
|
||||
|
||||
#### 3.3 Client Library
|
||||
- [ ] `lib/feedback-client.ts` — Add `getScreenshotUrl(feedbackId)`
|
||||
|
||||
---
|
||||
|
||||
## Privacy & Security Considerations
|
||||
|
||||
### PII Handling
|
||||
- [ ] Screenshots may contain sensitive user data
|
||||
- [ ] Blur regions API (client-side before upload)
|
||||
- [ ] Auto-redact: Detect and blur common PII areas (email fields, phone numbers)
|
||||
- [ ] User consent: "This screenshot may include your data. Continue?"
|
||||
|
||||
### Retention
|
||||
- [ ] Screenshot TTL: 90 days (match feedback lifecycle)
|
||||
- [ ] On feedback deletion → cascade delete screenshot blob
|
||||
- [ ] User-initiated deletion: "Delete my feedback and screenshot"
|
||||
|
||||
### Access Control
|
||||
- [ ] Users can only view their own screenshots
|
||||
- [ ] Admins can view all screenshots for their product
|
||||
- [ ] Fresh SAS URLs generated per view (time-limited, 15 minutes)
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
| # | Question | Impact | Suggested Answer |
|
||||
|---|----------|--------|------------------|
|
||||
| 1 | Max screenshot size? | Storage cost | 5MB limit, WebP compression recommended |
|
||||
| 2 | Multiple screenshots per feedback? | Complexity | Start with 1, add array support later |
|
||||
| 3 | Screenshot annotation/drawing? | Client complexity | Phase 2.2 for iOS, others skip |
|
||||
| 4 | Auto-capture on crash? | Privacy risk | Opt-in only, show preview before submit |
|
||||
| 5 | Video screen recording? | Storage cost | Future Phase 4, not now |
|
||||
| 6 | Anonymous feedback allowed? | Auth complexity | No — require auth for accountability |
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- **Adoption:** 30% of bug reports include screenshot within 3 months
|
||||
- **Resolution time:** -20% time-to-resolution for reports with screenshots
|
||||
- **Storage:** <100GB/month for screenshots (at 1000 reports/day, 100KB avg)
|
||||
|
||||
---
|
||||
|
||||
## Appendix: File Changes
|
||||
|
||||
### New Files
|
||||
```
|
||||
services/platform-service/src/modules/feedback/
|
||||
├── sas.ts # SAS generation helpers
|
||||
└── attachment.types.ts # (if Option B)
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
```
|
||||
services/platform-service/src/modules/feedback/
|
||||
├── types.ts # Add screenshot fields
|
||||
├── repository.ts # Add SAS functions
|
||||
└── routes.ts # Add SAS endpoint
|
||||
|
||||
services/platform-service/src/lib/cosmos-init.ts
|
||||
└── Add feedback_attachments container (if Option B)
|
||||
|
||||
services/platform-service/src/server.ts
|
||||
└── Register feedback SAS routes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-03-02
|
||||
@ -76,6 +76,14 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
|
||||
debug_traces: { partitionKeyPath: '/pk', defaultTtl: 7 * 86400 },
|
||||
debug_logs: { partitionKeyPath: '/pk', defaultTtl: 3 * 86400 },
|
||||
debug_screenshots: { partitionKeyPath: '/sessionId', defaultTtl: 7 * 86400 },
|
||||
// Broadcast Messaging & Surveys (see docs/roadmaps/not-started/platform_BROADCAST_SURVEY_ROADMAP.md)
|
||||
broadcasts: { partitionKeyPath: '/productId' },
|
||||
broadcast_deliveries: { partitionKeyPath: '/userId', defaultTtl: 90 * 86400 },
|
||||
broadcast_reads: { partitionKeyPath: '/userId', defaultTtl: 90 * 86400 },
|
||||
in_app_messages: { partitionKeyPath: '/userId', defaultTtl: 30 * 86400 },
|
||||
surveys: { partitionKeyPath: '/productId' },
|
||||
survey_responses: { partitionKeyPath: '/surveyId', defaultTtl: 365 * 86400 },
|
||||
user_survey_states: { partitionKeyPath: '/userId', defaultTtl: 90 * 86400 },
|
||||
};
|
||||
|
||||
export async function initCosmosIfNeeded(): Promise<void> {
|
||||
|
||||
372
services/platform-service/src/modules/broadcasts/routes.ts
Normal file
372
services/platform-service/src/modules/broadcasts/routes.ts
Normal file
@ -0,0 +1,372 @@
|
||||
/**
|
||||
* Broadcast REST routes — admin CRUD + public user endpoints
|
||||
* @module broadcasts/routes
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
UnauthorizedError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
BadRequestError,
|
||||
} from '../../lib/errors.js';
|
||||
import { getRequestProductId } from '../../lib/request-context.js';
|
||||
import * as repo from './repository.js';
|
||||
import {
|
||||
CreateBroadcastSchema,
|
||||
UpdateBroadcastSchema,
|
||||
BroadcastStatus,
|
||||
type Broadcast,
|
||||
} from './types.js';
|
||||
|
||||
// =============================================================================
|
||||
// Auth Helpers
|
||||
// =============================================================================
|
||||
|
||||
function requireAuth(req: { jwtPayload?: { sub: string } }): string {
|
||||
if (!req.jwtPayload?.sub) throw new UnauthorizedError('Authentication required');
|
||||
return req.jwtPayload.sub;
|
||||
}
|
||||
|
||||
function requireAdmin(req: { jwtPayload?: { sub: string; role?: string } }): string {
|
||||
const userId = requireAuth(req);
|
||||
if (req.jwtPayload?.role !== 'admin') {
|
||||
throw new ForbiddenError('Admin access required');
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Admin Routes
|
||||
// =============================================================================
|
||||
|
||||
async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
// List all broadcasts
|
||||
app.get('/', async (req) => {
|
||||
const adminId = requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
|
||||
const { status } = req.query as { status?: string };
|
||||
const { broadcasts, total } = await repo.listBroadcasts(productId, {
|
||||
status: status as Broadcast['status'],
|
||||
});
|
||||
|
||||
req.log.info({ adminId, productId, count: broadcasts.length }, 'Listed broadcasts');
|
||||
return { broadcasts, total };
|
||||
});
|
||||
|
||||
// Get single broadcast
|
||||
app.get<{ Params: { id: string } }>('/:id', async (req) => {
|
||||
requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const broadcast = await repo.getBroadcast(id, productId);
|
||||
if (!broadcast) throw new NotFoundError('Broadcast not found');
|
||||
|
||||
return broadcast;
|
||||
});
|
||||
|
||||
// Create broadcast
|
||||
app.post('/', async (req, reply) => {
|
||||
const adminId = requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
|
||||
const input = CreateBroadcastSchema.parse(req.body);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const broadcast: Broadcast = {
|
||||
id: `bcast_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
productId,
|
||||
...input,
|
||||
status: input.scheduledAt ? BroadcastStatus.SCHEDULED : BroadcastStatus.DRAFT,
|
||||
metrics: {
|
||||
targetedCount: 0,
|
||||
sentCount: 0,
|
||||
deliveredCount: 0,
|
||||
openedCount: 0,
|
||||
clickedCount: 0,
|
||||
dismissedCount: 0,
|
||||
convertedCount: 0,
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: adminId,
|
||||
};
|
||||
|
||||
const created = await repo.createBroadcast(broadcast);
|
||||
req.log.info({ broadcastId: created.id, adminId }, 'Created broadcast');
|
||||
|
||||
reply.status(201);
|
||||
return created;
|
||||
});
|
||||
|
||||
// Update broadcast (only if draft or scheduled)
|
||||
app.put<{ Params: { id: string } }>('/:id', async (req) => {
|
||||
const adminId = requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const existing = await repo.getBroadcast(id, productId);
|
||||
if (!existing) throw new NotFoundError('Broadcast not found');
|
||||
|
||||
// Can only edit draft or scheduled broadcasts
|
||||
if (existing.status === BroadcastStatus.SENDING || existing.status === BroadcastStatus.SENT) {
|
||||
throw new BadRequestError('Cannot edit broadcast that has already been sent');
|
||||
}
|
||||
|
||||
const updates = UpdateBroadcastSchema.parse(req.body);
|
||||
|
||||
const updated = await repo.updateBroadcast(id, productId, updates);
|
||||
req.log.info({ broadcastId: id, adminId }, 'Updated broadcast');
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Delete broadcast
|
||||
app.delete<{ Params: { id: string } }>('/:id', async (req, reply) => {
|
||||
const adminId = requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const existing = await repo.getBroadcast(id, productId);
|
||||
if (!existing) throw new NotFoundError('Broadcast not found');
|
||||
|
||||
// Cannot delete broadcasts that are being sent or already sent
|
||||
if (existing.status === BroadcastStatus.SENDING || existing.status === BroadcastStatus.SENT) {
|
||||
throw new BadRequestError('Cannot delete broadcast that has already been sent');
|
||||
}
|
||||
|
||||
const deleted = await repo.deleteBroadcast(id, productId);
|
||||
if (!deleted) throw new NotFoundError('Broadcast not found');
|
||||
|
||||
req.log.info({ broadcastId: id, adminId }, 'Deleted broadcast');
|
||||
reply.status(204);
|
||||
return;
|
||||
});
|
||||
|
||||
// Estimate reach for targeting
|
||||
app.post<{ Params: { id: string } }>('/:id/estimate-reach', async (req) => {
|
||||
requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const broadcast = await repo.getBroadcast(id, productId);
|
||||
if (!broadcast) throw new NotFoundError('Broadcast not found');
|
||||
|
||||
// QUESTION-1: Need integration with user subscriptions data
|
||||
// For now return mock data
|
||||
const result = await repo.estimateTargetReach(productId, broadcast.target);
|
||||
|
||||
return {
|
||||
estimatedCount: result.count,
|
||||
sampleUserIds: result.sampleUserIds,
|
||||
target: broadcast.target,
|
||||
};
|
||||
});
|
||||
|
||||
// Trigger send (for immediate or scheduled broadcasts)
|
||||
app.post<{ Params: { id: string } }>('/:id/send', async (req) => {
|
||||
const adminId = requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const broadcast = await repo.getBroadcast(id, productId);
|
||||
if (!broadcast) throw new NotFoundError('Broadcast not found');
|
||||
|
||||
// Safety check for large broadcasts
|
||||
const reach = await repo.estimateTargetReach(productId, broadcast.target);
|
||||
if (reach.count > 10000) {
|
||||
// Log warning - in production, might require additional confirmation
|
||||
req.log.warn({ broadcastId: id, targetCount: reach.count }, 'Large broadcast send triggered');
|
||||
}
|
||||
|
||||
// Update status to sending
|
||||
await repo.updateBroadcast(id, productId, {
|
||||
status: BroadcastStatus.SENDING,
|
||||
sentAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// TODO: Trigger async delivery job via event bus
|
||||
// For MVP, delivery will be synchronous or via polling
|
||||
req.log.info({ broadcastId: id, adminId, targetCount: reach.count }, 'Triggered broadcast send');
|
||||
|
||||
return { success: true, targetCount: reach.count };
|
||||
});
|
||||
|
||||
// Pause sending
|
||||
app.post<{ Params: { id: string } }>('/:id/pause', async (req) => {
|
||||
const adminId = requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const broadcast = await repo.getBroadcast(id, productId);
|
||||
if (!broadcast) throw new NotFoundError('Broadcast not found');
|
||||
|
||||
if (broadcast.status !== BroadcastStatus.SENDING) {
|
||||
throw new BadRequestError('Can only pause broadcasts that are currently sending');
|
||||
}
|
||||
|
||||
await repo.updateBroadcast(id, productId, {
|
||||
status: BroadcastStatus.PAUSED,
|
||||
});
|
||||
|
||||
req.log.info({ broadcastId: id, adminId }, 'Paused broadcast');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Get metrics
|
||||
app.get<{ Params: { id: string } }>('/:id/metrics', async (req) => {
|
||||
requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const broadcast = await repo.getBroadcast(id, productId);
|
||||
if (!broadcast) throw new NotFoundError('Broadcast not found');
|
||||
|
||||
return {
|
||||
broadcastId: id,
|
||||
metrics: broadcast.metrics,
|
||||
status: broadcast.status,
|
||||
};
|
||||
});
|
||||
|
||||
// Clone broadcast (for A/B testing)
|
||||
app.post<{ Params: { id: string } }>('/:id/clone', async (req) => {
|
||||
const adminId = requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const existing = await repo.getBroadcast(id, productId);
|
||||
if (!existing) throw new NotFoundError('Broadcast not found');
|
||||
|
||||
const body = req.body as { variant?: 'control' | 'treatment' };
|
||||
const { variant } = body;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const cloned: Broadcast = {
|
||||
...existing,
|
||||
id: `bcast_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
title: `${existing.title} (Clone)`,
|
||||
status: BroadcastStatus.DRAFT,
|
||||
variant: variant ?? 'treatment',
|
||||
parentBroadcastId: existing.id,
|
||||
experimentId: existing.experimentId ?? `exp_${Date.now()}`,
|
||||
metrics: {
|
||||
targetedCount: 0,
|
||||
sentCount: 0,
|
||||
deliveredCount: 0,
|
||||
openedCount: 0,
|
||||
clickedCount: 0,
|
||||
dismissedCount: 0,
|
||||
convertedCount: 0,
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: adminId,
|
||||
};
|
||||
|
||||
const created = await repo.createBroadcast(cloned);
|
||||
req.log.info({ broadcastId: created.id, parentId: id, adminId }, 'Cloned broadcast');
|
||||
|
||||
return created;
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Public/User Routes
|
||||
// =============================================================================
|
||||
|
||||
async function publicRoutes(app: FastifyInstance): Promise<void> {
|
||||
// List my active in-app messages
|
||||
app.get('/', async (req) => {
|
||||
const userId = requireAuth(req);
|
||||
const productId = getRequestProductId(req);
|
||||
|
||||
// Clean expired messages first
|
||||
await repo.deleteExpiredInAppMessages(userId);
|
||||
|
||||
// Get active messages
|
||||
const messages = await repo.getInAppMessagesForUser(userId, productId, {
|
||||
status: 'unread',
|
||||
});
|
||||
|
||||
return { messages };
|
||||
});
|
||||
|
||||
// Mark message as read
|
||||
app.post<{ Params: { id: string } }>('/:id/read', async (req) => {
|
||||
const userId = requireAuth(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const { id } = req.params;
|
||||
|
||||
// Find the message
|
||||
const messages = await repo.getInAppMessagesForUser(userId, productId);
|
||||
const message = messages.find((m) => m.id === id);
|
||||
if (!message) throw new NotFoundError('Message not found');
|
||||
|
||||
// Update status
|
||||
await repo.updateInAppMessageStatus(id, userId, 'read');
|
||||
|
||||
// Record read receipt
|
||||
await repo.recordReadReceipt(message.broadcastId, userId, productId, 'read');
|
||||
|
||||
// Update broadcast metrics
|
||||
await repo.updateBroadcastMetrics(message.broadcastId, productId, {
|
||||
openedCount: 1, // Will be incremented properly in real implementation
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Dismiss message
|
||||
app.post<{ Params: { id: string } }>('/:id/dismiss', async (req) => {
|
||||
const userId = requireAuth(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const messages = await repo.getInAppMessagesForUser(userId, productId);
|
||||
const message = messages.find((m) => m.id === id);
|
||||
if (!message) throw new NotFoundError('Message not found');
|
||||
|
||||
await repo.updateInAppMessageStatus(id, userId, 'dismissed');
|
||||
await repo.recordReadReceipt(message.broadcastId, userId, productId, 'dismiss');
|
||||
|
||||
await repo.updateBroadcastMetrics(message.broadcastId, productId, {
|
||||
dismissedCount: 1,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Track CTA click
|
||||
app.post<{ Params: { id: string } }>('/:id/click', async (req) => {
|
||||
const userId = requireAuth(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const messages = await repo.getInAppMessagesForUser(userId, productId);
|
||||
const message = messages.find((m) => m.id === id);
|
||||
if (!message) throw new NotFoundError('Message not found');
|
||||
|
||||
await repo.recordReadReceipt(message.broadcastId, userId, productId, 'click');
|
||||
|
||||
await repo.updateBroadcastMetrics(message.broadcastId, productId, {
|
||||
clickedCount: 1,
|
||||
});
|
||||
|
||||
return { success: true, redirectUrl: message.ctaUrl };
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Route Registration
|
||||
// =============================================================================
|
||||
|
||||
export async function broadcastRoutes(app: FastifyInstance): Promise<void> {
|
||||
// Admin routes (prefix: /admin/broadcasts)
|
||||
await app.register(adminRoutes, { prefix: '/admin/broadcasts' });
|
||||
|
||||
// Public routes (prefix: /broadcasts)
|
||||
await app.register(publicRoutes, { prefix: '/broadcasts' });
|
||||
}
|
||||
193
services/platform-service/src/modules/broadcasts/targeting.ts
Normal file
193
services/platform-service/src/modules/broadcasts/targeting.ts
Normal file
@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Broadcast targeting engine — evaluates user matching against broadcast targets
|
||||
* @module broadcasts/targeting
|
||||
*/
|
||||
|
||||
import { BroadcastTarget, TargetingContext, UserSegment, Platform } from './types.js';
|
||||
|
||||
// FNV-1a 32-bit hash (same as feature flags module)
|
||||
function fnv1a32(str: string): number {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash ^= str.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0; // Convert to unsigned 32-bit
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate if a user matches a broadcast target
|
||||
* Uses AND logic across all specified criteria
|
||||
*/
|
||||
export function evaluateTarget(
|
||||
target: BroadcastTarget,
|
||||
context: TargetingContext
|
||||
): boolean {
|
||||
// User segments (OR within array - match any segment)
|
||||
if (target.userSegments && target.userSegments.length > 0) {
|
||||
const hasMatchingSegment = target.userSegments.some((seg) =>
|
||||
context.userSegments.includes(seg)
|
||||
);
|
||||
if (!hasMatchingSegment) return false;
|
||||
}
|
||||
|
||||
// Platform targeting
|
||||
if (target.platforms && target.platforms.length > 0) {
|
||||
if (!target.platforms.includes(context.platform)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// App version range (semver comparison)
|
||||
if (target.appVersionMin) {
|
||||
if (compareSemver(context.appVersion, target.appVersionMin) < 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (target.appVersionMax) {
|
||||
if (compareSemver(context.appVersion, target.appVersionMax) > 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// OS version range
|
||||
if (target.osVersionMin) {
|
||||
if (compareSemver(context.osVersion, target.osVersionMin) < 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (target.osVersionMax) {
|
||||
if (compareSemver(context.osVersion, target.osVersionMax) > 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Geo targeting - country
|
||||
if (target.countryCodes && target.countryCodes.length > 0) {
|
||||
if (!context.countryCode || !target.countryCodes.includes(context.countryCode)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Geo targeting - region (e.g., "US-CA" for California)
|
||||
if (target.regionCodes && target.regionCodes.length > 0) {
|
||||
const userRegion = context.regionCode;
|
||||
if (!userRegion || !target.regionCodes.includes(userRegion)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Percentage rollout (FNV-1a hash for deterministic bucketing)
|
||||
if (target.percentageRollout !== undefined && target.percentageRollout < 100) {
|
||||
// Hash userId + broadcast seed for consistent assignment
|
||||
const hash = fnv1a32(`${context.userId}:${target.percentageRollout}`);
|
||||
const bucket = hash % 100;
|
||||
if (bucket >= target.percentageRollout) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Specific user IDs (explicit override for testing)
|
||||
if (target.specificUserIds && target.specificUserIds.length > 0) {
|
||||
if (!target.specificUserIds.includes(context.userId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two semantic version strings
|
||||
* Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
|
||||
*/
|
||||
function compareSemver(v1: string, v2: string): number {
|
||||
const parts1 = v1.split('.').map(Number);
|
||||
const parts2 = v2.split('.').map(Number);
|
||||
|
||||
const maxLength = Math.max(parts1.length, parts2.length);
|
||||
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const p1 = parts1[i] ?? 0;
|
||||
const p2 = parts2[i] ?? 0;
|
||||
|
||||
if (p1 < p2) return -1;
|
||||
if (p1 > p2) return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Cosmos query filters from target (for reach estimation)
|
||||
* Returns parameterized query components
|
||||
*/
|
||||
export function buildTargetQuery(
|
||||
target: BroadcastTarget
|
||||
): { whereClauses: string[]; parameters: { name: string; value: unknown }[] } {
|
||||
const whereClauses: string[] = [];
|
||||
const parameters: { name: string; value: unknown }[] = [];
|
||||
|
||||
// Platform filter
|
||||
if (target.platforms && target.platforms.length > 0) {
|
||||
whereClauses.push('(c.platform IN @platforms)');
|
||||
parameters.push({ name: '@platforms', value: target.platforms });
|
||||
}
|
||||
|
||||
// App version range
|
||||
if (target.appVersionMin) {
|
||||
whereClauses.push('c.appVersion >= @appVersionMin');
|
||||
parameters.push({ name: '@appVersionMin', value: target.appVersionMin });
|
||||
}
|
||||
if (target.appVersionMax) {
|
||||
whereClauses.push('c.appVersion <= @appVersionMax');
|
||||
parameters.push({ name: '@appVersionMax', value: target.appVersionMax });
|
||||
}
|
||||
|
||||
// OS version range
|
||||
if (target.osVersionMin) {
|
||||
whereClauses.push('c.osVersion >= @osVersionMin');
|
||||
parameters.push({ name: '@osVersionMin', value: target.osVersionMin });
|
||||
}
|
||||
if (target.osVersionMax) {
|
||||
whereClauses.push('c.osVersion <= @osVersionMax');
|
||||
parameters.push({ name: '@osVersionMax', value: target.osVersionMax });
|
||||
}
|
||||
|
||||
// Geo filters
|
||||
if (target.countryCodes && target.countryCodes.length > 0) {
|
||||
whereClauses.push('(c.countryCode IN @countryCodes)');
|
||||
parameters.push({ name: '@countryCodes', value: target.countryCodes });
|
||||
}
|
||||
|
||||
// Specific user IDs (for testing)
|
||||
if (target.specificUserIds && target.specificUserIds.length > 0) {
|
||||
whereClauses.push('(c.id IN @userIds)');
|
||||
parameters.push({ name: '@userIds', value: target.specificUserIds });
|
||||
}
|
||||
|
||||
return { whereClauses, parameters };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract targeting context from request headers/payload
|
||||
* Used by routes to build context from incoming requests
|
||||
*/
|
||||
export function extractTargetingContext(
|
||||
headers: Record<string, string | undefined>,
|
||||
jwtPayload: { sub: string; segments?: UserSegment[] } | undefined
|
||||
): TargetingContext {
|
||||
const userId = jwtPayload?.sub ?? headers['x-user-id'] ?? 'anonymous';
|
||||
const segments = jwtPayload?.segments ?? ['free'];
|
||||
|
||||
return {
|
||||
userId,
|
||||
productId: headers['x-product-id'] ?? 'unknown',
|
||||
platform: (headers['x-platform'] as Platform) ?? 'web',
|
||||
appVersion: headers['x-app-version'] ?? '0.0.0',
|
||||
osVersion: headers['x-os-version'] ?? '0.0.0',
|
||||
countryCode: headers['x-country-code'],
|
||||
regionCode: headers['x-region-code'],
|
||||
userSegments: segments as UserSegment[],
|
||||
};
|
||||
}
|
||||
@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { generateId, buildPk } from './types.js';
|
||||
import { getRequestProductId } from '../../lib/request-context.js';
|
||||
import { requireRole } from '../../lib/auth.js';
|
||||
@ -59,11 +60,59 @@ export { generateId, buildPk } from './types.js';
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
// TODO-2: PII Redaction - need to implement PII scanning for log messages
|
||||
// This should be shared with telemetry module
|
||||
function redactPii(message: string): { redacted: string; patterns: string[] } {
|
||||
// Placeholder - implement actual PII redaction
|
||||
return { redacted: message, patterns: [] };
|
||||
// PII Redaction patterns — same as telemetry module
|
||||
const PII_PATTERNS = [
|
||||
/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z]{2,}\b/i, // email
|
||||
/\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/, // US phone
|
||||
/\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b/, // credit card
|
||||
/\b\d{3}-\d{2}-\d{4}\b/, // SSN
|
||||
/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/, // IP address
|
||||
/(password|token|secret|key)\s*[:=]\s*\S+/i, // credentials
|
||||
/eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*/, // JWT
|
||||
];
|
||||
|
||||
interface RedactionResult {
|
||||
redacted: string;
|
||||
patterns: string[];
|
||||
fieldsRedacted: string[];
|
||||
}
|
||||
|
||||
function redactPii(message: string, context?: Record<string, unknown>): RedactionResult {
|
||||
const patterns: string[] = [];
|
||||
const fieldsRedacted: string[] = [];
|
||||
let redacted = message;
|
||||
|
||||
// Check message
|
||||
for (const pattern of PII_PATTERNS) {
|
||||
if (pattern.test(redacted)) {
|
||||
const patternName = pattern.toString().slice(0, 20);
|
||||
patterns.push(patternName);
|
||||
redacted = redacted.replace(pattern, '[REDACTED]');
|
||||
}
|
||||
}
|
||||
|
||||
// Check context fields if provided
|
||||
const redactedContext: Record<string, unknown> = {};
|
||||
if (context) {
|
||||
for (const [key, value] of Object.entries(context)) {
|
||||
if (typeof value === 'string') {
|
||||
let fieldRedacted = value;
|
||||
for (const pattern of PII_PATTERNS) {
|
||||
if (pattern.test(fieldRedacted)) {
|
||||
if (!fieldsRedacted.includes(key)) {
|
||||
fieldsRedacted.push(key);
|
||||
}
|
||||
fieldRedacted = fieldRedacted.replace(pattern, '[REDACTED]');
|
||||
}
|
||||
}
|
||||
redactedContext[key] = fieldRedacted;
|
||||
} else {
|
||||
redactedContext[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { redacted, patterns, fieldsRedacted };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -420,9 +469,22 @@ export async function diagnosticsRoutes(app: FastifyInstance) {
|
||||
throw new BadRequestError(`Session is not active (status: ${session.status})`);
|
||||
}
|
||||
|
||||
// TODO-6: PII Redaction - implement actual redaction
|
||||
// Prepare logs with IDs and redaction
|
||||
const logs: DebugLogEntryDoc[] = input.logs.map((l) => ({
|
||||
// PII Redaction — apply to each log message and context
|
||||
const processedLogs = input.logs.map((l) => {
|
||||
const { redacted, patterns, fieldsRedacted } = redactPii(l.message, l.context);
|
||||
return {
|
||||
...l,
|
||||
message: redacted,
|
||||
context: l.context, // context is already processed in redactPii
|
||||
redaction: patterns.length > 0 ? {
|
||||
fieldsRedacted,
|
||||
patternsMatched: patterns,
|
||||
} : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
// Prepare logs with IDs
|
||||
const logs: DebugLogEntryDoc[] = processedLogs.map((l) => ({
|
||||
...l,
|
||||
id: generateId('log'),
|
||||
pk: buildPk(productId, id),
|
||||
@ -436,15 +498,15 @@ export async function diagnosticsRoutes(app: FastifyInstance) {
|
||||
await repo.updateSessionStats(id, { logCount: logs.length });
|
||||
|
||||
// Check for fatal logs to trigger alerts
|
||||
const hasFatal = logs.some((l) => l.level === 'fatal');
|
||||
if (hasFatal) {
|
||||
// TODO-7: Emit fatal log event for alerting
|
||||
// await emitEvent('diagnostics.ingest.fatal', {
|
||||
// sessionId: id,
|
||||
// productId,
|
||||
// logEntry: logs.find((l) => l.level === 'fatal')!,
|
||||
// timestamp: new Date().toISOString(),
|
||||
// });
|
||||
const fatalLog = logs.find((l) => l.level === 'fatal');
|
||||
if (fatalLog) {
|
||||
// Emit fatal log event for alerting
|
||||
bus.emit('diagnostics.ingest.fatal', {
|
||||
sessionId: id,
|
||||
productId,
|
||||
logEntry: fatalLog as unknown as Record<string, unknown>,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return { accepted: logs.length };
|
||||
@ -477,15 +539,24 @@ export async function diagnosticsRoutes(app: FastifyInstance) {
|
||||
|
||||
const screenshotId = generateId('scr');
|
||||
const blobPath = `screenshots/${productId}/${id}/${screenshotId}.png`;
|
||||
const containerName = 'diagnostics-screenshots';
|
||||
|
||||
// Generate SAS URL for blob upload
|
||||
let uploadUrl: string | undefined;
|
||||
try {
|
||||
uploadUrl = await generateSasUrl(containerName, blobPath, 'w', 5);
|
||||
} catch (err) {
|
||||
req.log.warn({ err }, 'Failed to generate SAS URL for screenshot upload');
|
||||
}
|
||||
|
||||
const metadata: DebugScreenshotDoc = {
|
||||
...input,
|
||||
id: screenshotId,
|
||||
sessionId: id,
|
||||
productId,
|
||||
blobUrl: '', // TODO: Generate SAS URL via blob module
|
||||
blobUrl: uploadUrl ?? '',
|
||||
blobPath,
|
||||
containerName: 'diagnostics-screenshots',
|
||||
containerName,
|
||||
};
|
||||
|
||||
await repo.createScreenshotMetadata(metadata);
|
||||
@ -493,18 +564,18 @@ export async function diagnosticsRoutes(app: FastifyInstance) {
|
||||
// Update stats
|
||||
await repo.updateSessionStats(id, { screenshotCount: 1 });
|
||||
|
||||
// TODO-9: Emit screenshot captured event
|
||||
// await emitEvent('diagnostics.screenshot.captured', {
|
||||
// sessionId: id,
|
||||
// productId,
|
||||
// screenshotId,
|
||||
// trigger: input.trigger,
|
||||
// });
|
||||
// Emit screenshot captured event
|
||||
bus.emit('diagnostics.screenshot.captured', {
|
||||
sessionId: id,
|
||||
productId,
|
||||
screenshotId,
|
||||
trigger: input.trigger,
|
||||
});
|
||||
|
||||
reply.status(201);
|
||||
return {
|
||||
screenshotId,
|
||||
uploadUrl: '', // TODO: Return actual SAS URL
|
||||
uploadUrl: uploadUrl ?? '',
|
||||
blobPath,
|
||||
expiresIn: 300, // 5 minutes
|
||||
};
|
||||
|
||||
@ -48,6 +48,8 @@ import { themeRoutes } from './modules/themes/routes.js';
|
||||
import { waitlistRoutes } from './modules/waitlist/routes.js';
|
||||
import { telemetryRoutes } from './modules/telemetry/routes.js';
|
||||
import { diagnosticsRoutes } from './modules/diagnostics/routes.js';
|
||||
import { broadcastRoutes } from './modules/broadcasts/routes.js';
|
||||
import { surveyRoutes } from './modules/surveys/routes.js';
|
||||
import { jobRoutes } from './modules/jobs/routes.js';
|
||||
import { statusRoutes } from './modules/status/routes.js';
|
||||
import { deliveryRoutes } from './modules/delivery/routes.js';
|
||||
@ -66,6 +68,7 @@ import { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
||||
import { config } from './lib/config.js';
|
||||
import { seedDefaultFlags } from './modules/flags/seed.js';
|
||||
import { runPendingMigrations } from './migrations/runner.js';
|
||||
import { registerDiagnosticsSubscribers } from './modules/diagnostics/subscribers.js';
|
||||
|
||||
await initCosmosIfNeeded();
|
||||
await loadProductCache();
|
||||
@ -171,5 +174,11 @@ await app.register(changelogRoutes, { prefix: '/api' });
|
||||
await app.register(webhookRoutes, { prefix: '/api' });
|
||||
// Generic Marketplace module
|
||||
await app.register(marketplaceRoutes, { prefix: '/api' });
|
||||
// Broadcast Messaging & Surveys (see docs/roadmaps/not-started/platform_BROADCAST_SURVEY_ROADMAP.md)
|
||||
await app.register(broadcastRoutes, { prefix: '/api' });
|
||||
await app.register(surveyRoutes, { prefix: '/api' });
|
||||
|
||||
// Register event bus subscribers
|
||||
registerDiagnosticsSubscribers(app.log);
|
||||
|
||||
await startService(app, { port: config.PORT, host: config.HOST });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user