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": {
"build": "tsc",
"prepare": "tsc",
"test": "vitest run --pool forks",
"lint": "eslint src/**/*.ts",
"typecheck": "tsc --noEmit"

View File

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

View File

@ -45,11 +45,33 @@ export function BroadcastProvider({
const refresh = useCallback(async () => {
try {
const res = await sdk.fetch('/api/broadcasts/active');
if (res.ok) {
const data = (await res.json()) as InAppMessage[];
setMessages(data);
}
const res = await sdk.fetch('/broadcasts');
if (!res.ok) return;
const data = (await res.json()) as {
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 {
/* silent */
}
@ -58,8 +80,7 @@ export function BroadcastProvider({
const dismiss = useCallback(
(id: string) => {
setMessages(prev => prev.filter(m => m.id !== id));
// Fire-and-forget dismiss on server
sdk.fetch(`/api/broadcasts/${id}/dismiss`, { method: 'POST' }).catch(() => {});
sdk.fetch(`/broadcasts/${id}/dismiss`, { method: 'POST' }).catch(() => {});
},
[sdk]
);

View File

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

View File

@ -43,12 +43,16 @@ export function KillSwitchProvider({
const check = useCallback(async () => {
try {
const res = await sdk.fetch('/api/flags/kill-switch');
const res = await sdk.fetch('/settings/kill-switch');
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({
disabled: data.disabled ?? false,
reason: data.reason,
reason: data.reason ?? data.message,
isLoading: false,
});
} else {

View File

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

View File

@ -1,8 +1,10 @@
/**
* 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 { Platform } from 'react-native';
import type { PlatformSDK } from '../core.js';
export interface TelemetryEvent {
@ -16,6 +18,22 @@ export interface TelemetryConfig {
flushInterval?: number;
/** Max batch size before auto-flush (default: 20) */
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 {
@ -43,22 +61,51 @@ export function TelemetryProvider({
children,
}: TelemetryProviderProps): React.JSX.Element {
const queue = useRef<TelemetryEvent[]>([]);
const sessionIdRef = useRef(randomUuid());
const installIdRef = useRef<string | null>(null);
const flushInterval = config?.flushInterval ?? 30_000;
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 () => {
if (queue.current.length === 0) return;
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 {
await sdk.fetch('/api/telemetry/batch', {
await sdk.fetch('/telemetry/events', {
method: 'POST',
body: JSON.stringify({ events: batch }),
body: JSON.stringify({ productId: sdk.config.productId, events }),
});
} catch {
// Re-queue on failure (best-effort)
queue.current.unshift(...batch);
}
}, [sdk]);
}, [sdk, config?.appVersion, config?.buildNumber, config?.releaseChannel, resolveInstallId]);
const track = useCallback(
(name: string, properties?: Record<string, unknown>) => {