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_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> {
|
||||||
|
|||||||
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 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
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user