diff --git a/docs/learning_ai_common_plat_INVENTORY.md b/docs/learning_ai_common_plat_INVENTORY.md new file mode 100644 index 00000000..e69de29b diff --git a/packages/feedback-client/src/index.ts b/packages/feedback-client/src/index.ts index e2c7eb4a..531a0d2a 100644 --- a/packages/feedback-client/src/index.ts +++ b/packages/feedback-client/src/index.ts @@ -8,7 +8,7 @@ import { createApiClient, type ApiClient } from '@bytelyst/api-client'; export interface FeedbackClientConfig { baseUrl: string; - getAuthToken: () => string | Promise; + getAuthToken: () => string | null; } export interface DeviceContext { @@ -120,18 +120,22 @@ export class FeedbackClient { } // Step 3: Submit feedback - const response = await this.api.post('/api/feedback', { - type: params.type, - title: params.title, - body: params.body, - screen: params.screen, - rating: params.rating, - appVersion: params.appVersion, - platform: params.platform, - screenshotBlobPath: screenshotMeta?.blobPath, - screenshotContentType: screenshotMeta?.contentType as 'image/png' | 'image/jpeg' | 'image/webp', - screenshotSizeBytes: screenshotMeta?.sizeBytes, - deviceContext: params.deviceContext, + const response = await this.api.fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: params.type, + title: params.title, + body: params.body, + screen: params.screen, + rating: params.rating, + appVersion: params.appVersion, + platform: params.platform, + screenshotBlobPath: screenshotMeta?.blobPath, + screenshotContentType: screenshotMeta?.contentType as 'image/png' | 'image/jpeg' | 'image/webp', + screenshotSizeBytes: screenshotMeta?.sizeBytes, + deviceContext: params.deviceContext, + }), }); return response; @@ -143,8 +147,10 @@ export class FeedbackClient { private async generateSasUrl( contentType: 'image/png' | 'image/jpeg' | 'image/webp' ): Promise { - const response = await this.api.post('/api/feedback/sas', { - contentType, + const response = await this.api.fetch('/api/feedback/sas', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ contentType }), }); return response; } diff --git a/services/platform-service/src/modules/diagnostics/session-replay-repository.ts b/services/platform-service/src/modules/diagnostics/session-replay-repository.ts new file mode 100644 index 00000000..a007c1fa --- /dev/null +++ b/services/platform-service/src/modules/diagnostics/session-replay-repository.ts @@ -0,0 +1,162 @@ +/** + * Session Replay Repository — Remote Diagnostics + * Ingest and query session replay events. + */ + +import { getCollection } from '../../lib/datastore.js'; +import type { + SessionReplayDoc, + ReplayEvent, + QueryReplayInput, +} from './session-replay-types.js'; + +const REPLAY_CONTAINER = 'session_replays'; + +function replaysCollection() { + return getCollection(REPLAY_CONTAINER, '/pk'); +} + +/** + * Ingest replay events for a session. + */ +export async function ingestReplayEvents( + productId: string, + sessionId: string, + events: ReplayEvent[], + privacyConfig: SessionReplayDoc['privacyConfig'] +): Promise<{ accepted: number }> { + const collection = replaysCollection(); + + // Build or update session replay document + const pk = `${productId}:${sessionId}`; + const existing = await collection.findMany({ + filter: { pk, sessionId }, + limit: 1, + }); + + const now = new Date().toISOString(); + + if (existing.length > 0) { + // Append events to existing document + const doc = existing[0]; + const updatedEvents = [...doc.events, ...events]; + + await collection.upsert({ + ...doc, + events: updatedEvents, + eventCount: updatedEvents.length, + durationMs: events[events.length - 1]?.timestamp || doc.durationMs, + updatedAt: now, + }); + } else { + // Create new replay document + const firstEvent = events[0]; + const lastEvent = events[events.length - 1]; + + const doc: SessionReplayDoc = { + id: `replay_${sessionId}`, + pk, + sessionId, + productId, + events, + eventCount: events.length, + startTimestamp: now, + endTimestamp: now, + durationMs: lastEvent?.timestamp || 0, + privacyConfig, + createdAt: now, + ttl: 7 * 86400, // 7 days + }; + + await collection.upsert(doc); + } + + return { accepted: events.length }; +} + +/** + * Get replay events for a session. + */ +export async function getSessionReplay( + productId: string, + sessionId: string +): Promise { + const collection = replaysCollection(); + const pk = `${productId}:${sessionId}`; + + const results = await collection.findMany({ + filter: { pk, sessionId }, + limit: 1, + }); + + return results[0] ?? null; +} + +/** + * Query replay events with pagination. + */ +export async function queryReplayEvents( + productId: string, + sessionId: string, + query: QueryReplayInput +): Promise<{ + events: ReplayEvent[]; + totalEvents: number; + continuationToken?: string; +}> { + const replay = await getSessionReplay(productId, sessionId); + + if (!replay) { + return { events: [], totalEvents: 0 }; + } + + // Simple pagination based on event index + const allEvents = replay.events; + const totalEvents = allEvents.length; + + let startIndex = 0; + if (query.continuationToken) { + startIndex = parseInt(query.continuationToken, 10); + if (isNaN(startIndex)) startIndex = 0; + } + + const endIndex = Math.min(startIndex + query.limit, totalEvents); + const events = allEvents.slice(startIndex, endIndex); + + const nextContinuationToken = endIndex < totalEvents ? String(endIndex) : undefined; + + return { + events, + totalEvents, + continuationToken: nextContinuationToken, + }; +} + +/** + * Delete session replay data. + */ +export async function deleteSessionReplay( + productId: string, + sessionId: string +): Promise { + const collection = replaysCollection(); + const pk = `${productId}:${sessionId}`; + + const results = await collection.findMany({ + filter: { pk, sessionId }, + limit: 1, + }); + + if (results.length === 0) return false; + + // Soft delete - update with empty events + await collection.upsert({ + ...results[0], + events: [], + eventCount: 0, + durationMs: 0, + updatedAt: new Date().toISOString(), + }); + + return true; +} diff --git a/services/platform-service/src/modules/predictive-analytics/routes.ts b/services/platform-service/src/modules/predictive-analytics/routes.ts index ef913108..20a8dcf3 100644 --- a/services/platform-service/src/modules/predictive-analytics/routes.ts +++ b/services/platform-service/src/modules/predictive-analytics/routes.ts @@ -7,7 +7,7 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { z } from 'zod'; -import { requireAuth, requireAdmin } from '@bytelyst/auth'; +import { UnauthorizedError, ForbiddenError } from '../../lib/errors.js'; import { extractFeaturesFromTelemetry } from './feature-extractor.js'; import { featureStore } from './feature-store.js'; import { churnModel } from './churn-model.js'; @@ -21,8 +21,25 @@ import { CreateCampaignSchema, CampaignTriggerSchema, RiskSegmentEnum, + type UserChurnPredictionDoc, } from './types.js'; +// Auth Helpers +interface JwtPayload { + sub: string; + role?: string; +} + +function requireAuth(req: { jwtPayload?: JwtPayload }): string { + if (!req.jwtPayload?.sub) throw new UnauthorizedError('Authentication required'); + return req.jwtPayload.sub; +} + +function requireAdmin(req: { jwtPayload?: JwtPayload }): void { + requireAuth(req); + if (req.jwtPayload?.role !== 'admin') throw new ForbiddenError('Admin access required'); +} + // Get telemetry repository for fetching user events async function getUserTelemetryEvents( userId: string, @@ -84,7 +101,8 @@ export async function predictiveAnalyticsRoutes(fastify: FastifyInstance): Promi } // Fetch telemetry events - const events = await getUserTelemetryEvents(userId, productId); + const rawEvents = await getUserTelemetryEvents(userId, productId); + const events = rawEvents as unknown as Parameters[2]; // Extract features const features = extractFeaturesFromTelemetry( @@ -100,15 +118,16 @@ export async function predictiveAnalyticsRoutes(fastify: FastifyInstance): Promi // Run prediction const prediction = churnModel.predict(features, parseInt(horizon, 10)); - // Save prediction - const doc = { + // Save prediction (construct doc carefully to avoid duplicate property issues) + const { userId: _uid, productId: _pid, ...predictionData } = prediction; + const doc: UserChurnPredictionDoc = { id: `cp_${crypto.randomUUID()}`, pk: `${userId}:${productId}`, userId, productId, - ...prediction, - actualChurned: undefined, - validationDate: undefined, + ...predictionData, + explanation: prediction.explanation, + interventionHistory: [], createdAt: new Date().toISOString(), ttl: (parseInt(horizon, 10) + 90) * 24 * 60 * 60, }; @@ -138,11 +157,12 @@ export async function predictiveAnalyticsRoutes(fastify: FastifyInstance): Promi const results = await Promise.all( userIds.map(async (userId) => { - const events = await getUserTelemetryEvents(userId, productId); + const rawEvents = await getUserTelemetryEvents(userId, productId); + const events = rawEvents as unknown as Parameters[2]; const features = extractFeaturesFromTelemetry( userId, productId, - events as Parameters[2], + events, new Date() ); const prediction = churnModel.predict(features, parseInt(horizon, 10));