diff --git a/docs/devops/USER_ISSUE_REPORTING_ROADMAP.md b/docs/devops/USER_ISSUE_REPORTING_ROADMAP.md new file mode 100644 index 00000000..d83e32e5 --- /dev/null +++ b/docs/devops/USER_ISSUE_REPORTING_ROADMAP.md @@ -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_ + 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 diff --git a/services/platform-service/src/lib/cosmos-init.ts b/services/platform-service/src/lib/cosmos-init.ts index 0f8e118b..bd63318f 100644 --- a/services/platform-service/src/lib/cosmos-init.ts +++ b/services/platform-service/src/lib/cosmos-init.ts @@ -76,6 +76,14 @@ const CONTAINER_DEFS: Record = { 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 { diff --git a/services/platform-service/src/modules/broadcasts/routes.ts b/services/platform-service/src/modules/broadcasts/routes.ts new file mode 100644 index 00000000..a897807c --- /dev/null +++ b/services/platform-service/src/modules/broadcasts/routes.ts @@ -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 { + // 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 { + // 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 { + // Admin routes (prefix: /admin/broadcasts) + await app.register(adminRoutes, { prefix: '/admin/broadcasts' }); + + // Public routes (prefix: /broadcasts) + await app.register(publicRoutes, { prefix: '/broadcasts' }); +} diff --git a/services/platform-service/src/modules/broadcasts/targeting.ts b/services/platform-service/src/modules/broadcasts/targeting.ts new file mode 100644 index 00000000..c724463d --- /dev/null +++ b/services/platform-service/src/modules/broadcasts/targeting.ts @@ -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, + 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[], + }; +} diff --git a/services/platform-service/src/modules/diagnostics/routes.ts b/services/platform-service/src/modules/diagnostics/routes.ts index 15a4fce5..a63d7725 100644 --- a/services/platform-service/src/modules/diagnostics/routes.ts +++ b/services/platform-service/src/modules/diagnostics/routes.ts @@ -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): 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 = {}; + 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, + 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 }; diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index 6117caf9..f10bd761 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -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 });