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:
saravanakumardb1 2026-03-02 23:51:23 -08:00
parent 6a23a02cd4
commit 1b11db3f6f
6 changed files with 1011 additions and 27 deletions

View 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

View File

@ -76,6 +76,14 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
debug_traces: { partitionKeyPath: '/pk', defaultTtl: 7 * 86400 }, debug_traces: { partitionKeyPath: '/pk', defaultTtl: 7 * 86400 },
debug_logs: { partitionKeyPath: '/pk', defaultTtl: 3 * 86400 }, debug_logs: { partitionKeyPath: '/pk', defaultTtl: 3 * 86400 },
debug_screenshots: { partitionKeyPath: '/sessionId', defaultTtl: 7 * 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> { export async function initCosmosIfNeeded(): Promise<void> {

View 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' });
}

View 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[],
};
}

View File

@ -18,6 +18,7 @@
*/ */
import type { FastifyInstance, FastifyRequest } from 'fastify'; import type { FastifyInstance, FastifyRequest } from 'fastify';
import { randomUUID } from 'node:crypto';
import { generateId, buildPk } from './types.js'; import { generateId, buildPk } from './types.js';
import { getRequestProductId } from '../../lib/request-context.js'; import { getRequestProductId } from '../../lib/request-context.js';
import { requireRole } from '../../lib/auth.js'; import { requireRole } from '../../lib/auth.js';
@ -59,11 +60,59 @@ export { generateId, buildPk } from './types.js';
// ─── Helpers ─────────────────────────────────────────────────────────────── // ─── Helpers ───────────────────────────────────────────────────────────────
// TODO-2: PII Redaction - need to implement PII scanning for log messages // PII Redaction patterns — same as telemetry module
// This should be shared with telemetry module const PII_PATTERNS = [
function redactPii(message: string): { redacted: string; patterns: string[] } { /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z]{2,}\b/i, // email
// Placeholder - implement actual PII redaction /\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/, // US phone
return { redacted: message, patterns: [] }; /\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})`); throw new BadRequestError(`Session is not active (status: ${session.status})`);
} }
// TODO-6: PII Redaction - implement actual redaction // PII Redaction — apply to each log message and context
// Prepare logs with IDs and redaction const processedLogs = input.logs.map((l) => {
const logs: DebugLogEntryDoc[] = 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, ...l,
id: generateId('log'), id: generateId('log'),
pk: buildPk(productId, id), pk: buildPk(productId, id),
@ -436,15 +498,15 @@ export async function diagnosticsRoutes(app: FastifyInstance) {
await repo.updateSessionStats(id, { logCount: logs.length }); await repo.updateSessionStats(id, { logCount: logs.length });
// Check for fatal logs to trigger alerts // Check for fatal logs to trigger alerts
const hasFatal = logs.some((l) => l.level === 'fatal'); const fatalLog = logs.find((l) => l.level === 'fatal');
if (hasFatal) { if (fatalLog) {
// TODO-7: Emit fatal log event for alerting // Emit fatal log event for alerting
// await emitEvent('diagnostics.ingest.fatal', { bus.emit('diagnostics.ingest.fatal', {
// sessionId: id, sessionId: id,
// productId, productId,
// logEntry: logs.find((l) => l.level === 'fatal')!, logEntry: fatalLog as unknown as Record<string, unknown>,
// timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
// }); });
} }
return { accepted: logs.length }; return { accepted: logs.length };
@ -477,15 +539,24 @@ export async function diagnosticsRoutes(app: FastifyInstance) {
const screenshotId = generateId('scr'); const screenshotId = generateId('scr');
const blobPath = `screenshots/${productId}/${id}/${screenshotId}.png`; 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 = { const metadata: DebugScreenshotDoc = {
...input, ...input,
id: screenshotId, id: screenshotId,
sessionId: id, sessionId: id,
productId, productId,
blobUrl: '', // TODO: Generate SAS URL via blob module blobUrl: uploadUrl ?? '',
blobPath, blobPath,
containerName: 'diagnostics-screenshots', containerName,
}; };
await repo.createScreenshotMetadata(metadata); await repo.createScreenshotMetadata(metadata);
@ -493,18 +564,18 @@ export async function diagnosticsRoutes(app: FastifyInstance) {
// Update stats // Update stats
await repo.updateSessionStats(id, { screenshotCount: 1 }); await repo.updateSessionStats(id, { screenshotCount: 1 });
// TODO-9: Emit screenshot captured event // Emit screenshot captured event
// await emitEvent('diagnostics.screenshot.captured', { bus.emit('diagnostics.screenshot.captured', {
// sessionId: id, sessionId: id,
// productId, productId,
// screenshotId, screenshotId,
// trigger: input.trigger, trigger: input.trigger,
// }); });
reply.status(201); reply.status(201);
return { return {
screenshotId, screenshotId,
uploadUrl: '', // TODO: Return actual SAS URL uploadUrl: uploadUrl ?? '',
blobPath, blobPath,
expiresIn: 300, // 5 minutes expiresIn: 300, // 5 minutes
}; };

View File

@ -48,6 +48,8 @@ import { themeRoutes } from './modules/themes/routes.js';
import { waitlistRoutes } from './modules/waitlist/routes.js'; import { waitlistRoutes } from './modules/waitlist/routes.js';
import { telemetryRoutes } from './modules/telemetry/routes.js'; import { telemetryRoutes } from './modules/telemetry/routes.js';
import { diagnosticsRoutes } from './modules/diagnostics/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 { jobRoutes } from './modules/jobs/routes.js';
import { statusRoutes } from './modules/status/routes.js'; import { statusRoutes } from './modules/status/routes.js';
import { deliveryRoutes } from './modules/delivery/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 { config } from './lib/config.js';
import { seedDefaultFlags } from './modules/flags/seed.js'; import { seedDefaultFlags } from './modules/flags/seed.js';
import { runPendingMigrations } from './migrations/runner.js'; import { runPendingMigrations } from './migrations/runner.js';
import { registerDiagnosticsSubscribers } from './modules/diagnostics/subscribers.js';
await initCosmosIfNeeded(); await initCosmosIfNeeded();
await loadProductCache(); await loadProductCache();
@ -171,5 +174,11 @@ await app.register(changelogRoutes, { prefix: '/api' });
await app.register(webhookRoutes, { prefix: '/api' }); await app.register(webhookRoutes, { prefix: '/api' });
// Generic Marketplace module // Generic Marketplace module
await app.register(marketplaceRoutes, { prefix: '/api' }); 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 }); await startService(app, { port: config.PORT, host: config.HOST });