fix(rn-platform-sdk): align providers with platform-service APIs

- Feature flags: GET /flags/poll legacy { flags } + optional userId
- Kill switch: GET /settings/kill-switch, map message to reason
- Broadcasts: GET /broadcasts, POST dismiss; map server message shape
- Surveys: GET /surveys/active; submit via start/response/complete
- Auth: register(); login/register bodies include productId
- Telemetry: map queued events to TelemetryEventSchema; RN Platform import
- prepare script runs tsc on install

Made-with: Cursor
This commit is contained in:
Saravana Achu Mac 2026-03-30 01:12:18 -07:00
parent e13d0cba6b
commit e174335a9e
7 changed files with 136 additions and 30 deletions

View File

@ -37,6 +37,7 @@
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"prepare": "tsc",
"test": "vitest run --pool forks", "test": "vitest run --pool forks",
"lint": "eslint src/**/*.ts", "lint": "eslint src/**/*.ts",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"

View File

@ -15,6 +15,7 @@ export interface AuthState {
export interface AuthContextType extends AuthState { export interface AuthContextType extends AuthState {
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, displayName: string) => Promise<void>;
loginWithGoogle: (idToken: string) => Promise<void>; loginWithGoogle: (idToken: string) => Promise<void>;
loginWithApple: (idToken: string) => Promise<void>; loginWithApple: (idToken: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
@ -85,7 +86,7 @@ export function AuthProvider({
try { try {
const res = await sdk.fetch('/auth/login', { const res = await sdk.fetch('/auth/login', {
method: 'POST', method: 'POST',
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password, productId: sdk.config.productId }),
}); });
await handleTokenResponse(res); await handleTokenResponse(res);
} catch (e: unknown) { } catch (e: unknown) {
@ -96,6 +97,28 @@ export function AuthProvider({
[sdk, handleTokenResponse] [sdk, handleTokenResponse]
); );
const register = useCallback(
async (email: string, password: string, displayName: string) => {
setState(s => ({ ...s, isLoading: true, error: null }));
try {
const res = await sdk.fetch('/auth/register', {
method: 'POST',
body: JSON.stringify({
email,
password,
displayName,
productId: sdk.config.productId,
}),
});
await handleTokenResponse(res);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Registration failed';
setState(s => ({ ...s, isLoading: false, error: msg }));
}
},
[sdk, handleTokenResponse]
);
const loginWithGoogle = useCallback( const loginWithGoogle = useCallback(
async (idToken: string) => { async (idToken: string) => {
setState(s => ({ ...s, isLoading: true, error: null })); setState(s => ({ ...s, isLoading: true, error: null }));
@ -174,6 +197,7 @@ export function AuthProvider({
const value: AuthContextType = { const value: AuthContextType = {
...state, ...state,
login, login,
register,
loginWithGoogle, loginWithGoogle,
loginWithApple, loginWithApple,
logout, logout,

View File

@ -45,11 +45,33 @@ export function BroadcastProvider({
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
const res = await sdk.fetch('/api/broadcasts/active'); const res = await sdk.fetch('/broadcasts');
if (res.ok) { if (!res.ok) return;
const data = (await res.json()) as InAppMessage[]; const data = (await res.json()) as {
setMessages(data); messages?: Array<{
} id: string;
title: string;
body: string;
ctaText?: string;
ctaUrl?: string;
priority?: 'low' | 'normal' | 'high' | 'urgent';
dismissible?: boolean;
expiresAt?: string;
}>;
};
const raw = data.messages ?? [];
const mapped: InAppMessage[] = raw.map(m => ({
id: m.id,
title: m.title,
body: m.body,
type:
m.priority === 'urgent' ? 'critical' : m.priority === 'high' ? 'warning' : 'info',
action:
m.ctaText && m.ctaUrl ? { label: m.ctaText, url: m.ctaUrl } : undefined,
dismissible: m.dismissible !== false,
expiresAt: m.expiresAt,
}));
setMessages(mapped);
} catch { } catch {
/* silent */ /* silent */
} }
@ -58,8 +80,7 @@ export function BroadcastProvider({
const dismiss = useCallback( const dismiss = useCallback(
(id: string) => { (id: string) => {
setMessages(prev => prev.filter(m => m.id !== id)); setMessages(prev => prev.filter(m => m.id !== id));
// Fire-and-forget dismiss on server sdk.fetch(`/broadcasts/${id}/dismiss`, { method: 'POST' }).catch(() => {});
sdk.fetch(`/api/broadcasts/${id}/dismiss`, { method: 'POST' }).catch(() => {});
}, },
[sdk] [sdk]
); );

View File

@ -30,31 +30,37 @@ interface FeatureFlagProviderProps {
sdk: PlatformSDK; sdk: PlatformSDK;
/** Poll interval in ms (default: 60000) */ /** Poll interval in ms (default: 60000) */
pollInterval?: number; pollInterval?: number;
/** Optional user id for targeted flag evaluation (GET /flags/poll). */
userId?: string | null;
children: React.ReactNode; children: React.ReactNode;
} }
export function FeatureFlagProvider({ export function FeatureFlagProvider({
sdk, sdk,
pollInterval = 60_000, pollInterval = 60_000,
userId,
children, children,
}: FeatureFlagProviderProps): React.JSX.Element { }: FeatureFlagProviderProps): React.JSX.Element {
const [flags, setFlags] = useState<Map<string, FeatureFlag>>(new Map()); const [flags, setFlags] = useState<Map<string, FeatureFlag>>(new Map());
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
const res = await sdk.fetch('/api/flags/poll'); const qs = new URLSearchParams({ platform: 'mobile' });
if (userId) qs.set('userId', userId);
const res = await sdk.fetch(`/flags/poll?${qs.toString()}`);
if (res.ok) { if (res.ok) {
const data = (await res.json()) as FeatureFlag[]; const data = (await res.json()) as { flags?: Record<string, boolean> };
const map = new Map<string, FeatureFlag>(); const map = new Map<string, FeatureFlag>();
for (const flag of data) { const raw = data.flags ?? {};
map.set(flag.key, flag); for (const [key, enabled] of Object.entries(raw)) {
map.set(key, { key, enabled, value: enabled });
} }
setFlags(map); setFlags(map);
} }
} catch { } catch {
/* fail-open: keep existing flags */ /* fail-open: keep existing flags */
} }
}, [sdk]); }, [sdk, userId]);
const isEnabled = useCallback( const isEnabled = useCallback(
(key: string): boolean => { (key: string): boolean => {

View File

@ -43,12 +43,16 @@ export function KillSwitchProvider({
const check = useCallback(async () => { const check = useCallback(async () => {
try { try {
const res = await sdk.fetch('/api/flags/kill-switch'); const res = await sdk.fetch('/settings/kill-switch');
if (res.ok) { if (res.ok) {
const data = (await res.json()) as { disabled?: boolean; reason?: string }; const data = (await res.json()) as {
disabled?: boolean;
reason?: string;
message?: string;
};
setState({ setState({
disabled: data.disabled ?? false, disabled: data.disabled ?? false,
reason: data.reason, reason: data.reason ?? data.message,
isLoading: false, isLoading: false,
}); });
} else { } else {

View File

@ -52,11 +52,10 @@ export function SurveyProvider({
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
const res = await sdk.fetch('/api/surveys/active'); const res = await sdk.fetch('/surveys/active');
if (res.ok) { if (!res.ok) return;
const data = (await res.json()) as ActiveSurvey | null; const data = (await res.json()) as { survey?: ActiveSurvey | null };
setActiveSurvey(data ?? null); setActiveSurvey(data.survey ?? null);
}
} catch { } catch {
/* silent */ /* silent */
} }
@ -64,10 +63,14 @@ export function SurveyProvider({
const submit = useCallback( const submit = useCallback(
async (surveyId: string, answers: Record<string, unknown>) => { async (surveyId: string, answers: Record<string, unknown>) => {
await sdk.fetch(`/api/surveys/${surveyId}/respond`, { await sdk.fetch(`/surveys/${surveyId}/start`, { method: 'POST' });
method: 'POST', for (const [questionId, answer] of Object.entries(answers)) {
body: JSON.stringify({ answers }), await sdk.fetch(`/surveys/${surveyId}/response`, {
}); method: 'POST',
body: JSON.stringify({ questionId, answer }),
});
}
await sdk.fetch(`/surveys/${surveyId}/complete`, { method: 'POST', body: '{}' });
setActiveSurvey(null); setActiveSurvey(null);
}, },
[sdk] [sdk]
@ -76,7 +79,7 @@ export function SurveyProvider({
const dismiss = useCallback( const dismiss = useCallback(
(surveyId: string) => { (surveyId: string) => {
setActiveSurvey(null); setActiveSurvey(null);
sdk.fetch(`/api/surveys/${surveyId}/dismiss`, { method: 'POST' }).catch(() => {}); sdk.fetch(`/surveys/${surveyId}/dismiss`, { method: 'POST' }).catch(() => {});
}, },
[sdk] [sdk]
); );

View File

@ -1,8 +1,10 @@
/** /**
* Telemetry module React context + hook for event tracking in React Native apps. * Telemetry module React context + hook for event tracking in React Native apps.
* Maps queued events to platform-service TelemetryEventSchema for POST /telemetry/events.
*/ */
import React, { createContext, useContext, useCallback, useRef, useEffect } from 'react'; import React, { createContext, useContext, useCallback, useRef, useEffect } from 'react';
import { Platform } from 'react-native';
import type { PlatformSDK } from '../core.js'; import type { PlatformSDK } from '../core.js';
export interface TelemetryEvent { export interface TelemetryEvent {
@ -16,6 +18,22 @@ export interface TelemetryConfig {
flushInterval?: number; flushInterval?: number;
/** Max batch size before auto-flush (default: 20) */ /** Max batch size before auto-flush (default: 20) */
maxBatchSize?: number; maxBatchSize?: number;
appVersion?: string;
buildNumber?: string;
/** Default: beta */
releaseChannel?: 'dev' | 'beta' | 'prod';
/** Stable anonymous id (e.g. from MMKV). If omitted, generated per app session. */
getInstallId?: () => string;
}
function randomUuid(): string {
if (typeof globalThis.crypto?.randomUUID === 'function') {
return globalThis.crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0;
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
});
} }
interface TelemetryContextType { interface TelemetryContextType {
@ -43,22 +61,51 @@ export function TelemetryProvider({
children, children,
}: TelemetryProviderProps): React.JSX.Element { }: TelemetryProviderProps): React.JSX.Element {
const queue = useRef<TelemetryEvent[]>([]); const queue = useRef<TelemetryEvent[]>([]);
const sessionIdRef = useRef(randomUuid());
const installIdRef = useRef<string | null>(null);
const flushInterval = config?.flushInterval ?? 30_000; const flushInterval = config?.flushInterval ?? 30_000;
const maxBatchSize = config?.maxBatchSize ?? 20; const maxBatchSize = config?.maxBatchSize ?? 20;
const resolveInstallId = useCallback((): string => {
if (!installIdRef.current) {
installIdRef.current = config?.getInstallId?.() ?? randomUuid();
}
return installIdRef.current;
}, [config?.getInstallId]);
const flush = useCallback(async () => { const flush = useCallback(async () => {
if (queue.current.length === 0) return; if (queue.current.length === 0) return;
const batch = queue.current.splice(0); const batch = queue.current.splice(0);
const os = Platform.OS;
const platform = os === 'ios' ? 'ios' : os === 'android' ? 'android' : 'web';
const osFamily = platform === 'ios' ? 'ios' : platform === 'android' ? 'android' : 'other';
const events = batch.map(e => ({
id: randomUuid(),
productId: sdk.config.productId,
anonymousInstallId: resolveInstallId(),
sessionId: sessionIdRef.current,
platform,
channel: 'mobile_app' as const,
osFamily,
osVersion: String(Platform.Version ?? ''),
appVersion: config?.appVersion ?? '0.0.0',
buildNumber: config?.buildNumber ?? '0',
releaseChannel: config?.releaseChannel ?? ('beta' as const),
eventType: 'info' as const,
module: 'app',
eventName: e.name,
occurredAt: e.timestamp ?? new Date().toISOString(),
context: e.properties,
}));
try { try {
await sdk.fetch('/api/telemetry/batch', { await sdk.fetch('/telemetry/events', {
method: 'POST', method: 'POST',
body: JSON.stringify({ events: batch }), body: JSON.stringify({ productId: sdk.config.productId, events }),
}); });
} catch { } catch {
// Re-queue on failure (best-effort)
queue.current.unshift(...batch); queue.current.unshift(...batch);
} }
}, [sdk]); }, [sdk, config?.appVersion, config?.buildNumber, config?.releaseChannel, resolveInstallId]);
const track = useCallback( const track = useCallback(
(name: string, properties?: Record<string, unknown>) => { (name: string, properties?: Record<string, unknown>) => {