WellNuo/services/api.ts
Sergei dd5bc7f95a Add performance optimizations for app startup and BLE operations
- Add 2-second timeout to profile fetch in getStoredUser() to ensure
  app startup < 3 seconds even with slow network. Falls back to cached
  user data on timeout.

- Implement early scan termination in BLEManager when devices found.
  Scan now exits after 3 seconds once minimum devices are detected,
  instead of always waiting full 10 seconds.

- Add PerformanceService for tracking app startup time, API response
  times, and BLE operation durations with threshold checking.

- Integrate performance tracking in app/_layout.tsx to measure and
  log startup duration in dev mode.

- Add comprehensive test suite for performance service and BLE
  scan optimizations.

Performance targets:
- App startup: < 3 seconds
- BLE operations: < 10 seconds

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-01 11:45:10 -08:00

2370 lines
74 KiB
TypeScript

import type { ApiError, ApiResponse, AuthResponse, Beneficiary, BeneficiaryDashboardData, ChatResponse, DashboardSingleResponse, NotificationHistoryResponse, NotificationSettings, WPSensor } from '@/types';
import { File } from 'expo-file-system';
import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as wifiPasswordStore from './wifiPasswordStore';
import 'react-native-get-random-values'; // Polyfill for crypto.getRandomValues
import { bustImageCache } from '@/utils/imageUtils';
// Callback for handling unauthorized responses (401)
let onUnauthorizedCallback: (() => void) | null = null;
export function setOnUnauthorizedCallback(callback: () => void) {
onUnauthorizedCallback = callback;
}
// Callback for BLE cleanup on logout
let onLogoutBLECleanupCallback: (() => Promise<void>) | null = null;
export function setOnLogoutBLECleanupCallback(callback: (() => Promise<void>) | null) {
onLogoutBLECleanupCallback = callback;
}
const API_BASE_URL = 'https://eluxnetworks.net/function/well-api/api';
const CLIENT_ID = 'MA_001';
// Threshold for considering a beneficiary "online" (30 minutes in milliseconds)
const ONLINE_THRESHOLD_MS = 30 * 60 * 1000;
// WellNuo Backend API (our own API for auth, OTP, etc.)
// TODO: Update to production URL when deployed
const WELLNUO_API_URL = 'https://wellnuo.smartlaunchhub.com/api';
// Avatar images for elderly beneficiaries - grandmothers (бабушки)
const ELDERLY_AVATARS = [
'https://images.unsplash.com/photo-1566616213894-2d4e1baee5d8?w=200&h=200&fit=crop&crop=face', // grandmother with gray hair
'https://images.unsplash.com/photo-1544027993-37dbfe43562a?w=200&h=200&fit=crop&crop=face', // elderly woman smiling
'https://images.unsplash.com/photo-1491308056676-205b7c9a7dc1?w=200&h=200&fit=crop&crop=face', // senior woman portrait
'https://images.unsplash.com/photo-1580489944761-15a19d654956?w=200&h=200&fit=crop&crop=face', // older woman glasses
'https://images.unsplash.com/photo-1548142813-c348350df52b?w=200&h=200&fit=crop&crop=face', // grandmother portrait
];
// Room locations for sensor placement
// Used in device settings to select where sensor is installed
export const ROOM_LOCATIONS = [
{ id: 'bedroom', label: 'Bedroom', icon: '🛏️', legacyCode: 102 },
{ id: 'living_room', label: 'Living Room', icon: '🛋️', legacyCode: 103 },
{ id: 'kitchen', label: 'Kitchen', icon: '🍳', legacyCode: 104 },
{ id: 'bathroom', label: 'Bathroom', icon: '🚿', legacyCode: 105 },
{ id: 'hallway', label: 'Hallway', icon: '🚪', legacyCode: 106 },
{ id: 'entrance', label: 'Entrance', icon: '🏠', legacyCode: 111 },
{ id: 'garage', label: 'Garage', icon: '🚗', legacyCode: 108 },
{ id: 'basement', label: 'Basement', icon: '🪜', legacyCode: 110 },
{ id: 'office', label: 'Office', icon: '💼', legacyCode: 107 },
{ id: 'other', label: 'Other', icon: '📍', legacyCode: 200 },
] as const;
export type RoomLocationId = typeof ROOM_LOCATIONS[number]['id'];
// Helper to convert location ID to Legacy API code
function getLocationLegacyCode(locationId: string): number | undefined {
const location = ROOM_LOCATIONS.find(loc => loc.id === locationId);
return location?.legacyCode;
}
// Helper to convert Legacy API code to location ID
export function getLocationIdFromCode(code: number): RoomLocationId | undefined {
const location = ROOM_LOCATIONS.find(loc => loc.legacyCode === code);
return location?.id;
}
// Helper to convert location label (e.g., "Kitchen") to location ID (e.g., "kitchen")
// Legacy API sometimes returns label instead of code
export function getLocationIdFromLabel(label: string): RoomLocationId | undefined {
// Case-insensitive comparison
const labelLower = label.toLowerCase().trim();
const location = ROOM_LOCATIONS.find(loc => loc.label.toLowerCase() === labelLower);
return location?.id;
}
// Get consistent avatar based on deployment_id
function getAvatarForBeneficiary(deploymentId: number): string {
const index = deploymentId % ELDERLY_AVATARS.length;
return ELDERLY_AVATARS[index];
}
// Helper function to format time ago
function formatTimeAgo(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins} min ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
}
class ApiService {
// API URLs as instance properties for consistency
private readonly baseUrl = WELLNUO_API_URL;
private readonly legacyApiUrl = API_BASE_URL;
// Public method to get the access token (used by AuthContext)
async getToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync('accessToken');
} catch {
return null;
}
}
// Get legacy API token (for eluxnetworks.net API - dashboard, voice_ask)
private async getLegacyToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync('legacyAccessToken');
} catch {
return null;
}
}
private generateNonce(): string {
// Use Web Crypto API (polyfilled by react-native-get-random-values)
const randomBytes = new Uint8Array(16);
crypto.getRandomValues(randomBytes);
return Array.from(randomBytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
private async makeRequest<T>(params: Record<string, string>): Promise<ApiResponse<T>> {
try {
const formData = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
formData.append(key, value);
});
const response = await fetch(API_BASE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString(),
});
const data = await response.json();
// Handle 401 Unauthorized - trigger logout
if (response.status === 401 || data.status === '401' || data.error === 'Unauthorized') {
if (onUnauthorizedCallback) {
onUnauthorizedCallback();
}
return {
ok: false,
error: {
message: 'Session expired. Please login again.',
code: 'UNAUTHORIZED',
status: 401,
},
};
}
if (data.status === '200 OK' || data.ok === true) {
return { data: data as T, ok: true };
}
return {
ok: false,
error: {
message: data.message || data.error || 'Request failed',
status: response.status,
},
};
} catch (error) {
const apiError: ApiError = {
message: error instanceof Error ? error.message : 'Network error',
code: 'NETWORK_ERROR',
};
return { ok: false, error: apiError };
}
}
// Authentication (Legacy API - eluxnetworks.net)
// Used for dev mode and dashboard access
async login(username: string, password: string): Promise<ApiResponse<AuthResponse>> {
const response = await this.makeRequest<AuthResponse>({
function: 'credentials',
email: username,
ps: password,
clientId: CLIENT_ID,
nonce: this.generateNonce(),
});
if (response.ok && response.data) {
// Save LEGACY credentials separately (not to accessToken!)
// accessToken is reserved for WellNuo API JWT tokens
await SecureStore.setItemAsync('legacyAccessToken', response.data.access_token);
// Keep these for backward compatibility
await SecureStore.setItemAsync('userId', response.data.user_id.toString());
await SecureStore.setItemAsync('privileges', response.data.privileges);
await SecureStore.setItemAsync('maxRole', response.data.max_role.toString());
}
return response;
}
async logout(): Promise<void> {
// Call BLE cleanup callback if set
if (onLogoutBLECleanupCallback) {
try {
await onLogoutBLECleanupCallback();
} catch (error) {
// Continue with logout even if BLE cleanup fails
}
}
// Clear WiFi passwords and encryption key from SecureStore
try {
await wifiPasswordStore.clearAllWiFiPasswords();
} catch (error) {
// Continue with logout even if cleanup fails
}
// Clear encryption key
try {
const { clearEncryptionKey } = await import('./encryption');
await clearEncryptionKey();
} catch (error) {
// Continue with logout even if cleanup fails
}
// Clear WellNuo API auth data
await SecureStore.deleteItemAsync('accessToken');
await SecureStore.deleteItemAsync('userId');
await SecureStore.deleteItemAsync('userEmail');
await SecureStore.deleteItemAsync('onboardingCompleted');
// Clear legacy API auth data
await SecureStore.deleteItemAsync('legacyAccessToken');
await SecureStore.deleteItemAsync('privileges');
await SecureStore.deleteItemAsync('maxRole');
// Clear user profile data (avatar, etc.)
await SecureStore.deleteItemAsync('userAvatar');
// Clear local cached data (beneficiaries, etc.)
await AsyncStorage.removeItem('wellnuo_local_beneficiaries');
}
// Save user email (for OTP auth flow)
async saveEmail(email: string): Promise<void> {
await SecureStore.setItemAsync('userEmail', email);
}
// Get stored email
async getStoredEmail(): Promise<string | null> {
try {
return await SecureStore.getItemAsync('userEmail');
} catch {
return null;
}
}
// Onboarding completion flag - persists across app restarts
async setOnboardingCompleted(completed: boolean): Promise<void> {
await SecureStore.setItemAsync('onboardingCompleted', completed ? '1' : '0');
}
async isOnboardingCompleted(): Promise<boolean> {
try {
const value = await SecureStore.getItemAsync('onboardingCompleted');
return value === '1';
} catch {
return false;
}
}
// Save mock user (for dev mode OTP flow)
async saveMockUser(user: { user_id: string; email: string; max_role: string; privileges: string[] }): Promise<void> {
await SecureStore.setItemAsync('accessToken', `mock-token-${user.user_id}`);
await SecureStore.setItemAsync('userId', user.user_id);
await SecureStore.setItemAsync('privileges', user.privileges.join(','));
await SecureStore.setItemAsync('maxRole', user.max_role);
await SecureStore.setItemAsync('userEmail', user.email);
}
// ==================== OTP Authentication (WellNuo Backend) ====================
// Check if email exists in database
async checkEmail(email: string): Promise<ApiResponse<{ exists: boolean; name?: string | null }>> {
try {
const response = await fetch(`${WELLNUO_API_URL}/auth/check-email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
const data = await response.json();
if (response.ok) {
return { data, ok: true };
}
return {
ok: false,
error: { message: data.error || 'Failed to check email' },
};
} catch (error) {
return {
ok: false,
error: { message: 'Network error. Please check your connection.' },
};
}
}
// Request OTP code - sends email via Brevo
async requestOTP(email: string): Promise<ApiResponse<{ message: string }>> {
try {
const response = await fetch(`${WELLNUO_API_URL}/auth/request-otp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
const data = await response.json();
if (response.ok) {
return { data, ok: true };
}
return {
ok: false,
error: { message: data.error || 'Failed to send OTP' },
};
} catch (error) {
return {
ok: false,
error: { message: 'Network error. Please check your connection.' },
};
}
}
// Verify OTP code and get JWT token
async verifyOTP(email: string, code: string): Promise<ApiResponse<{ token: string; user: { id: string; email: string; first_name?: string; last_name?: string } }>> {
try {
const payload = { email: email.trim().toLowerCase(), code };
const response = await fetch(`${WELLNUO_API_URL}/auth/verify-otp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const data = await response.json();
if (response.ok && data.token) {
// Save ONLY technical auth data (token, userId, email)
// User profile data is fetched from API, NOT stored locally
await SecureStore.setItemAsync('accessToken', data.token);
await SecureStore.setItemAsync('userId', String(data.user.id));
await SecureStore.setItemAsync('userEmail', email);
return { data, ok: true };
}
return {
ok: false,
error: { message: data.error || data.message || 'Invalid or expired code' },
};
} catch (error) {
return {
ok: false,
error: { message: 'Network error. Please check your connection.' },
};
}
}
async isAuthenticated(): Promise<boolean> {
const token = await this.getToken();
return !!token;
}
// Get current user profile from API (not local storage!)
// PERFORMANCE: Uses timeout to ensure app startup < 3 seconds
async getStoredUser() {
try {
const token = await this.getToken();
const userId = await SecureStore.getItemAsync('userId');
if (!token || !userId) {
return null;
}
// Fetch profile from server with timeout for fast app startup
// If API is slow, fall back to cached data to ensure < 3s load time
const PROFILE_TIMEOUT_MS = 2000; // 2 seconds max for profile fetch
const profilePromise = this.getProfile();
const timeoutPromise = new Promise<null>((resolve) =>
setTimeout(() => resolve(null), PROFILE_TIMEOUT_MS)
);
const profile = await Promise.race([profilePromise, timeoutPromise]);
// Handle timeout (profile is null) or API error
if (!profile || !profile.ok || !profile.data) {
// If token is invalid (401), clear all tokens and return null
// This will trigger re-authentication
if (profile && profile.error?.code === 'UNAUTHORIZED') {
await this.logout();
return null;
}
// For network errors, timeouts, OR other API errors, fall back to minimal info
// We don't want to log out the user just because the server is temporarily unavailable
// PERFORMANCE: This ensures fast app startup even with slow/offline network
const email = await SecureStore.getItemAsync('userEmail');
return {
user_id: parseInt(userId, 10),
email: email || undefined,
privileges: '',
max_role: 0,
};
}
// /auth/me returns { user: {...}, beneficiaries: [...] }
// Extract user data from nested 'user' object
const userData = profile.data.user || profile.data;
return {
user_id: userData.id,
email: userData.email,
firstName: userData.firstName,
lastName: userData.lastName,
phone: userData.phone,
privileges: '',
max_role: 0,
};
} catch (error) {
// On any unexpected error, fall back to local data instead of logging out
const userId = await SecureStore.getItemAsync('userId');
const email = await SecureStore.getItemAsync('userEmail');
if (userId) {
return {
user_id: parseInt(userId, 10),
email: email || undefined,
privileges: '',
max_role: 0,
};
}
return null;
}
}
// Get user profile from WellNuo API
// Note: /auth/me can return { user: {...}, beneficiaries: [...] } or just { id, email, ... }
async getProfile(): Promise<ApiResponse<{
id: number;
email: string;
firstName: string | null;
lastName: string | null;
phone: string | null;
address: {
street: string | null;
city: string | null;
zip: string | null;
state: string | null;
country: string | null;
};
createdAt: string;
user?: {
id: number;
email: string;
firstName: string | null;
lastName: string | null;
phone: string | null;
};
}>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/auth/me`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
if (!response.ok) {
return {
ok: false,
error: {
message: data.error || 'Failed to get profile',
code: response.status === 401 ? 'UNAUTHORIZED' : 'API_ERROR',
}
};
}
return { data, ok: true };
} catch (error) {
return { ok: false, error: { message: 'Network error', code: 'NETWORK_ERROR' } };
}
}
// Update user profile on WellNuo API
async updateProfile(updates: {
firstName?: string;
lastName?: string;
phone?: string;
address?: {
street?: string;
city?: string;
zip?: string;
state?: string;
country?: string;
};
}): Promise<ApiResponse<{ id: number; email: string; firstName: string | null; lastName: string | null; phone: string | null }>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/auth/profile`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(updates),
});
const data = await response.json();
if (!response.ok) {
return { ok: false, error: { message: data.error || 'Failed to update profile' } };
}
return { data, ok: true };
} catch (error) {
return { ok: false, error: { message: 'Network error', code: 'NETWORK_ERROR' } };
}
}
// Update user profile avatar on WellNuo API
async updateProfileAvatar(imageUri: string | null): Promise<ApiResponse<{ id: string; avatarUrl: string | null }>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
let avatarData: string | null = null;
if (imageUri) {
// Read image as base64 using new expo-file-system v19+ File API
const file = new File(imageUri);
const base64Data = await file.base64();
// Determine MIME type from URI
const extension = imageUri.split('.').pop()?.toLowerCase() || 'jpeg';
const mimeType = extension === 'png' ? 'image/png' : 'image/jpeg';
avatarData = `data:${mimeType};base64,${base64Data}`;
}
const response = await fetch(`${WELLNUO_API_URL}/auth/avatar`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ avatar: avatarData }),
});
const data = await response.json();
if (!response.ok) {
return { ok: false, error: { message: data.error || 'Failed to update avatar' } };
}
return { data: data.user, ok: true };
} catch (error) {
return { ok: false, error: { message: 'Network error', code: 'NETWORK_ERROR' } };
}
}
// Beneficiaries (elderly people being monitored)
async getBeneficiaries(): Promise<ApiResponse<{ beneficiaries: Beneficiary[] }>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
// Note: Using mock data since API structure is not fully documented
// Replace with actual API call when available
const mockBeneficiaries: Beneficiary[] = [
{
id: 1,
name: 'Julia Smith',
displayName: 'Julia Smith',
status: 'online',
relationship: 'Mother',
last_activity: '2 min ago',
sensor_data: {
motion_detected: true,
last_motion: '2 min ago',
door_status: 'closed',
temperature: 22,
humidity: 45,
},
},
{
id: 2,
name: 'Robert Johnson',
displayName: 'Robert Johnson',
status: 'offline',
relationship: 'Father',
last_activity: '1 hour ago',
sensor_data: {
motion_detected: false,
last_motion: '1 hour ago',
door_status: 'closed',
temperature: 21,
humidity: 50,
},
},
];
return { data: { beneficiaries: mockBeneficiaries }, ok: true };
}
async getBeneficiary(id: number): Promise<ApiResponse<Beneficiary>> {
// Use real API data via getBeneficiaryDashboard
const response = await this.getBeneficiaryDashboard(id.toString());
if (!response.ok || !response.data) {
return { ok: false, error: response.error || { message: 'Beneficiary not found', code: 'NOT_FOUND' } };
}
const data = response.data;
// Determine if beneficiary is "online" based on last_detected_time
const lastDetected = data.last_detected_time ? new Date(data.last_detected_time) : null;
const isRecent = lastDetected && (Date.now() - lastDetected.getTime()) < ONLINE_THRESHOLD_MS;
const deploymentId = parseInt(data.deployment_id, 10);
const beneficiary: Beneficiary = {
id: deploymentId,
name: data.name,
displayName: data.name, // For UI display
avatar: getAvatarForBeneficiary(deploymentId),
status: isRecent ? 'online' : 'offline',
address: data.address,
timezone: data.time_zone,
wellness_score: data.wellness_score_percent,
wellness_descriptor: data.wellness_descriptor,
last_location: data.last_location,
temperature: data.temperature,
units: data.units,
sleep_hours: data.sleep_hours,
bedroom_temperature: data.bedroom_temperature,
before_last_location: data.before_last_location,
last_detected_time: data.last_detected_time,
last_activity: data.last_detected_time
? formatTimeAgo(new Date(data.last_detected_time))
: undefined,
};
return { data: beneficiary, ok: true };
}
// Get beneficiary dashboard data by deployment_id (LEGACY API - eluxnetworks.net)
async getBeneficiaryDashboard(deploymentId: string): Promise<ApiResponse<BeneficiaryDashboardData>> {
// Use legacy API credentials for dashboard
const token = await this.getLegacyToken();
if (!token) {
// Fallback to regular credentials if legacy not available
const fallbackToken = await this.getToken();
if (!fallbackToken) {
return { ok: false, error: { message: 'Not authenticated for dashboard access', code: 'UNAUTHORIZED' } };
}
// Note: This will likely fail if using WellNuo token, but we try anyway
}
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
const response = await this.makeRequest<DashboardSingleResponse>({
function: 'dashboard_single',
token: token || await this.getToken() || '',
deployment_id: deploymentId,
date: today,
nonce: this.generateNonce(),
});
if (response.ok && response.data?.result_list?.[0]) {
return { data: response.data.result_list[0], ok: true };
}
return {
ok: false,
error: response.error || { message: 'Failed to get beneficiary data' },
};
}
// Get all beneficiaries from WellNuo API
async getAllBeneficiaries(): Promise<ApiResponse<Beneficiary[]>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/me/beneficiaries`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
if (!response.ok) {
if (response.status === 401) {
if (onUnauthorizedCallback) onUnauthorizedCallback();
return { ok: false, error: { message: 'Session expired', code: 'UNAUTHORIZED', status: 401 } };
}
return { ok: false, error: { message: data.error || 'Failed to get beneficiaries' } };
}
// Map API response to Beneficiary type
const beneficiaries: Beneficiary[] = (data.beneficiaries || []).map((item: any) => ({
id: item.id,
name: item.originalName || item.name || item.email, // Original name from server
customName: item.customName || null, // User's custom name for this beneficiary
displayName: item.displayName || item.customName || item.name || item.email || 'Unknown User', // Server-provided displayName
originalName: item.originalName || item.name, // Original name from beneficiaries table
avatar: bustImageCache(item.avatarUrl) || undefined, // Use uploaded avatar from server with cache-busting
status: 'offline' as const,
email: item.email,
address: typeof item.address === 'string' ? item.address : (item.address?.street ? `${item.address.street}, ${item.address.city}` : undefined),
subscription: item.subscription,
// Equipment status from orders
equipmentStatus: item.equipmentStatus,
hasDevices: item.hasDevices || false,
trackingNumber: item.trackingNumber,
role: item.role, // User's role for this beneficiary (custodian, guardian, caretaker)
}));
return { data: beneficiaries, ok: true };
} catch (error) {
return { ok: false, error: { message: 'Network error', code: 'NETWORK_ERROR' } };
}
}
// Get single beneficiary details from WellNuo API
async getWellNuoBeneficiary(id: number): Promise<ApiResponse<Beneficiary>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/me/beneficiaries/${id}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
if (!response.ok) {
return { ok: false, error: { message: data.error || 'Failed to get beneficiary' } };
}
const beneficiary: Beneficiary = {
id: data.id,
name: data.originalName || data.name || data.email, // Original name from server
customName: data.customName || null, // User's custom name for this beneficiary
displayName: data.displayName || data.customName || data.name || data.email || 'Unknown User', // Server-provided displayName
originalName: data.originalName || data.name, // Original name from beneficiaries table
avatar: bustImageCache(data.avatarUrl) || undefined, // Cache-bust avatar URL
status: 'offline' as const,
email: data.email,
address: typeof data.address === 'string' ? data.address : (data.address?.street ? `${data.address.street}, ${data.address.city}` : undefined),
subscription: data.subscription ? {
status: data.subscription.status,
planType: data.subscription.planType || data.subscription.plan,
endDate: data.subscription.endDate,
cancelAtPeriodEnd: data.subscription.cancelAtPeriodEnd,
} : undefined,
// Equipment status from orders
equipmentStatus: data.equipmentStatus,
hasDevices: data.hasDevices || false,
trackingNumber: data.trackingNumber,
// User's role for this beneficiary (custodian, guardian, caretaker)
role: data.role,
};
return { data: beneficiary, ok: true };
} catch (error) {
return { ok: false, error: { message: 'Network error', code: 'NETWORK_ERROR' } };
}
}
// Update beneficiary in WellNuo API
async updateWellNuoBeneficiary(id: number, updates: {
name?: string;
phone?: string;
address?: string;
}): Promise<ApiResponse<Beneficiary>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/me/beneficiaries/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(updates),
});
const data = await response.json();
if (!response.ok) {
return { ok: false, error: { message: data.error || 'Failed to update beneficiary' } };
}
const beneficiary: Beneficiary = {
id: data.beneficiary.id,
name: data.beneficiary.name || data.beneficiary.email,
displayName: data.beneficiary.displayName || data.beneficiary.name || data.beneficiary.email || 'Unknown User',
email: data.beneficiary.email,
status: 'offline' as const,
};
return { data: beneficiary, ok: true };
} catch (error) {
return { ok: false, error: { message: 'Network error', code: 'NETWORK_ERROR' } };
}
}
// Create new beneficiary (grants owner access automatically)
async createBeneficiary(data: {
name: string;
phone?: string;
address?: string;
}): Promise<ApiResponse<Beneficiary>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/me/beneficiaries`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(data),
});
const result = await response.json();
if (!response.ok) {
return { ok: false, error: { message: result.error || 'Failed to create beneficiary' } };
}
const beneficiary: Beneficiary = {
id: result.beneficiary.id,
name: result.beneficiary.name || '',
displayName: result.beneficiary.name || 'Unknown User', // For UI display
status: 'offline' as const,
};
return { data: beneficiary, ok: true };
} catch (error) {
return { ok: false, error: { message: 'Network error', code: 'NETWORK_ERROR' } };
}
}
// Upload/update beneficiary avatar
async updateBeneficiaryAvatar(id: number, imageUri: string | null): Promise<ApiResponse<{ avatarUrl: string | null }>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
let base64Image: string | null = null;
if (imageUri) {
// Read file as base64 using new expo-file-system v19+ File API
const file = new File(imageUri);
const base64Data = await file.base64();
// Determine mime type from URI extension
const extension = imageUri.split('.').pop()?.toLowerCase() || 'jpeg';
const mimeType = extension === 'png' ? 'image/png' : 'image/jpeg';
// Create data URI
base64Image = `data:${mimeType};base64,${base64Data}`;
}
const apiResponse = await fetch(`${WELLNUO_API_URL}/me/beneficiaries/${id}/avatar`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ avatar: base64Image }),
});
const data = await apiResponse.json();
if (!apiResponse.ok) {
return { ok: false, error: { message: data.error || 'Failed to update avatar' } };
}
return { data: { avatarUrl: data.beneficiary?.avatarUrl || null }, ok: true };
} catch (error) {
return { ok: false, error: { message: 'Network error', code: 'NETWORK_ERROR' } };
}
}
// Delete beneficiary (removes access record)
async deleteBeneficiary(id: number): Promise<ApiResponse<{ success: boolean }>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/me/beneficiaries/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
if (!response.ok) {
return { ok: false, error: { message: data.error || 'Failed to delete beneficiary' } };
}
return { data: { success: true }, ok: true };
} catch (error) {
return { ok: false, error: { message: 'Network error', code: 'NETWORK_ERROR' } };
}
}
// Update beneficiary equipment status
async updateBeneficiaryEquipmentStatus(
id: number,
status: 'none' | 'ordered' | 'shipped' | 'delivered'
): Promise<ApiResponse<{ success: boolean }>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/me/beneficiaries/${id}/equipment-status`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ status }),
});
const data = await response.json();
if (!response.ok) {
return { ok: false, error: { message: data.error || 'Failed to update equipment status' } };
}
return { data: { success: true }, ok: true };
} catch (error) {
return { ok: false, error: { message: 'Network error', code: 'NETWORK_ERROR' } };
}
}
// Update beneficiary custom name (per-user, stored in user_access)
async updateBeneficiaryCustomName(
id: number,
customName: string | null
): Promise<ApiResponse<{ customName: string | null }>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/me/beneficiaries/${id}/custom-name`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ customName }),
});
const data = await response.json();
if (!response.ok) {
return { ok: false, error: { message: data.error || 'Failed to update custom name' } };
}
return { data: { customName: data.customName }, ok: true };
} catch (error) {
return { ok: false, error: { message: 'Network error', code: 'NETWORK_ERROR' } };
}
}
// AI Chat - deploymentId is required, no default value for security (LEGACY API)
async sendMessage(question: string, deploymentId: string): Promise<ApiResponse<ChatResponse>> {
if (!deploymentId) {
return { ok: false, error: { message: 'Please select a beneficiary first', code: 'NO_BENEFICIARY_SELECTED' } };
}
// Use legacy API credentials for voice_ask
const token = await this.getLegacyToken() || await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
return this.makeRequest<ChatResponse>({
function: 'voice_ask',
clientId: CLIENT_ID,
token: token,
question: question,
deployment_id: deploymentId,
});
}
// ==================== Invitations API ====================
// Send invitation to share access to a beneficiary
async sendInvitation(params: {
beneficiaryId: string;
email: string;
role: 'caretaker' | 'guardian';
label?: string;
}): Promise<ApiResponse<{ invitation: { id: string; status: string } }>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/invitations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
beneficiaryId: params.beneficiaryId,
email: params.email,
role: params.role, // Backend expects 'caretaker' or 'guardian'
label: params.label,
}),
});
const data = await response.json();
if (response.ok) {
return { data, ok: true };
}
return {
ok: false,
error: { message: data.error || 'Failed to send invitation' },
};
} catch (error) {
return {
ok: false,
error: { message: 'Network error. Please check your connection.' },
};
}
}
// Get invitations for a beneficiary
async getInvitations(beneficiaryId: string): Promise<ApiResponse<{
invitations: {
id: string;
email: string;
role: string;
label?: string;
status: string;
created_at: string;
}[]
}>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/invitations/beneficiary/${beneficiaryId}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
if (response.ok) {
return { data, ok: true };
}
return {
ok: false,
error: { message: data.error || 'Failed to get invitations' },
};
} catch (error) {
return {
ok: false,
error: { message: 'Network error. Please check your connection.' },
};
}
}
// Delete invitation
async deleteInvitation(invitationId: string): Promise<ApiResponse<{ success: boolean }>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/invitations/${invitationId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
if (response.ok) {
return { data, ok: true };
}
return {
ok: false,
error: { message: data.error || 'Failed to delete invitation' },
};
} catch (error) {
return {
ok: false,
error: { message: 'Network error. Please check your connection.' },
};
}
}
// Accept invitation code
async acceptInvitation(code: string): Promise<ApiResponse<{
success: boolean;
message: string;
beneficiary?: {
id: number;
firstName: string | null;
lastName: string | null;
email: string | null;
};
role?: string;
}>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/invitations/accept`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ code }),
});
const data = await response.json();
if (response.ok) {
return { data, ok: true };
}
return {
ok: false,
error: { message: data.error || 'Failed to accept invitation' },
};
} catch (error) {
return {
ok: false,
error: { message: 'Network error. Please check your connection.' },
};
}
}
// Activate equipment for beneficiary (saves to server)
async activateBeneficiary(beneficiaryId: number, serialNumber: string): Promise<ApiResponse<{
success: boolean;
beneficiary: {
id: number;
firstName: string | null;
lastName: string | null;
hasDevices: boolean;
equipmentStatus: string;
device_id: string;
};
}>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/me/beneficiaries/${beneficiaryId}/activate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ serialNumber }),
});
const data = await response.json();
if (response.ok) {
return { data, ok: true };
}
return {
ok: false,
error: { message: data.error || 'Failed to activate equipment' },
};
} catch (error) {
return {
ok: false,
error: { message: 'Network error. Please check your connection.' },
};
}
}
// ==================== SUBSCRIPTION MANAGEMENT ====================
// Cancel subscription at period end
async cancelSubscription(beneficiaryId: number): Promise<ApiResponse<{ success: boolean; cancelAt: string }>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/stripe/cancel-subscription`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ beneficiaryId }),
});
const data = await response.json();
if (response.ok) {
return { data, ok: true };
}
return {
ok: false,
error: { message: data.error || 'Failed to cancel subscription' },
};
} catch (error) {
return {
ok: false,
error: { message: 'Network error. Please check your connection.' },
};
}
}
// Get transaction history from Stripe
async getTransactionHistory(beneficiaryId: number, limit = 10): Promise<ApiResponse<{
transactions: {
id: string;
type: 'subscription' | 'one_time';
amount: number;
currency: string;
status: string;
date: string;
description: string;
invoicePdf?: string;
hostedUrl?: string;
receiptUrl?: string;
}[];
hasMore: boolean;
}>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/stripe/transaction-history/${beneficiaryId}?limit=${limit}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
if (response.ok) {
return { data, ok: true };
}
return {
ok: false,
error: { message: data.error || 'Failed to get transaction history' },
};
} catch (error) {
return {
ok: false,
error: { message: 'Network error. Please check your connection.' },
};
}
}
// Sync subscription status from Stripe to local database
// Call this after payment to ensure DB is up-to-date without waiting for webhook
async syncSubscriptionStatus(beneficiaryId: number): Promise<ApiResponse<{
status: string;
synced: boolean;
previousStatus: string;
subscription: {
id: string;
stripeStatus: string;
currentPeriodEnd: string;
cancelAtPeriodEnd: boolean;
} | null;
}>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/stripe/sync-subscription-status`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ beneficiaryId }),
});
const data = await response.json();
if (response.ok) {
return { data, ok: true };
}
return {
ok: false,
error: { message: data.error || 'Failed to sync subscription status' },
};
} catch (error) {
return {
ok: false,
error: { message: 'Network error. Please check your connection.' },
};
}
}
// Reactivate subscription that was set to cancel
async reactivateSubscription(beneficiaryId: number): Promise<ApiResponse<{ success: boolean; status: string }>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/stripe/reactivate-subscription`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ beneficiaryId }),
});
const data = await response.json();
if (response.ok) {
return { data, ok: true };
}
return {
ok: false,
error: { message: data.error || 'Failed to reactivate subscription' },
};
} catch (error) {
return {
ok: false,
error: { message: 'Network error. Please check your connection.' },
};
}
}
// Update invitation role
async updateInvitation(invitationId: string, role: 'caretaker' | 'guardian'): Promise<ApiResponse<{ success: boolean; invitation: { id: string; role: string; email: string } }>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/invitations/${invitationId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ role }),
});
const data = await response.json();
if (response.ok) {
return { data, ok: true };
}
return {
ok: false,
error: { message: data.error || 'Failed to update invitation' },
};
} catch (error) {
return {
ok: false,
error: { message: 'Network error. Please check your connection.' },
};
}
}
// ==================== NOTIFICATION SETTINGS ====================
// Get notification settings for current user
async getNotificationSettings(): Promise<ApiResponse<NotificationSettings>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/notification-settings`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
if (response.ok) {
return { data: data.settings, ok: true };
}
return {
ok: false,
error: { message: data.error || 'Failed to get notification settings' },
};
} catch (error) {
return {
ok: false,
error: { message: 'Network error. Please check your connection.' },
};
}
}
// Get notification history for current user
async getNotificationHistory(options?: {
limit?: number;
offset?: number;
type?: string;
status?: string;
}): Promise<ApiResponse<NotificationHistoryResponse>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
// Build query params
const params = new URLSearchParams();
if (options?.limit) params.append('limit', String(options.limit));
if (options?.offset) params.append('offset', String(options.offset));
if (options?.type) params.append('type', options.type);
if (options?.status) params.append('status', options.status);
const queryString = params.toString();
const url = `${WELLNUO_API_URL}/notification-settings/history${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
if (response.ok) {
return { data, ok: true };
}
return {
ok: false,
error: { message: data.error || 'Failed to get notification history' },
};
} catch (error) {
return {
ok: false,
error: { message: 'Network error. Please check your connection.' },
};
}
}
// Update notification settings for current user
async updateNotificationSettings(settings: Partial<NotificationSettings>): Promise<ApiResponse<NotificationSettings>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/notification-settings`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(settings),
});
const data = await response.json();
if (response.ok) {
return { data: data.settings, ok: true };
}
return {
ok: false,
error: { message: data.error || 'Failed to update notification settings' },
};
} catch (error) {
return {
ok: false,
error: { message: 'Network error. Please check your connection.' },
};
}
}
// ==========================================
// Legacy Dashboard Methods (Developer Mode)
// For eluxnetworks.net dashboard WebView
// ==========================================
// Demo credentials for legacy dashboard
private readonly DEMO_LEGACY_USER = 'robster';
private readonly DEMO_LEGACY_PASSWORD = 'rob2';
private readonly DEMO_DEPLOYMENT_ID = 21; // Ferdinand's deployment
// Login to legacy dashboard API
async loginToLegacyDashboard(): Promise<ApiResponse<AuthResponse>> {
try {
const formData = new URLSearchParams();
formData.append('function', 'credentials');
formData.append('user_name', this.DEMO_LEGACY_USER);
formData.append('ps', this.DEMO_LEGACY_PASSWORD);
formData.append('clientId', CLIENT_ID);
formData.append('nonce', this.generateNonce());
const response = await fetch(API_BASE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString(),
});
const data = await response.json();
// Check that access_token is a valid JWT string (not 0 or empty)
if (data.status === '200 OK' && data.access_token && typeof data.access_token === 'string' && data.access_token.includes('.')) {
// Save legacy credentials
await SecureStore.setItemAsync('legacyAccessToken', data.access_token);
await SecureStore.setItemAsync('legacyUserId', String(data.user_id));
await SecureStore.setItemAsync('legacyUserName', this.DEMO_LEGACY_USER);
return { data: data as AuthResponse, ok: true };
}
return {
ok: false,
error: { message: data.message || 'Legacy login failed - invalid credentials' },
};
} catch (error) {
return {
ok: false,
error: { message: 'Failed to connect to dashboard API' },
};
}
}
// Refresh legacy token
async refreshLegacyToken(): Promise<ApiResponse<AuthResponse>> {
return this.loginToLegacyDashboard();
}
// Check if legacy token is expiring soon (within 1 hour)
async isLegacyTokenExpiringSoon(): Promise<boolean> {
try {
const token = await this.getLegacyToken();
if (!token) return true;
// Decode JWT to get expiration
const parts = token.split('.');
if (parts.length !== 3) return true;
const payload = JSON.parse(atob(parts[1]));
const exp = payload.exp;
if (!exp) return true;
const now = Math.floor(Date.now() / 1000);
const oneHour = 60 * 60;
const isExpiring = (exp - now) < oneHour;
return isExpiring;
} catch (e) {
return true;
}
}
// Get legacy credentials for WebView injection
async getLegacyWebViewCredentials(): Promise<{
token: string;
userName: string;
userId: string;
} | null> {
try {
const token = await SecureStore.getItemAsync('legacyAccessToken');
const userName = await SecureStore.getItemAsync('legacyUserName');
const userId = await SecureStore.getItemAsync('legacyUserId');
// Check if credentials exist AND token is valid JWT (contains dots)
const isValidToken = token && typeof token === 'string' && token.includes('.');
// Additional check: verify token is not expired and userName matches expected
let needsRefresh = false;
if (isValidToken && userName && userId) {
// Check if userName matches expected demo user
if (userName !== this.DEMO_LEGACY_USER) {
needsRefresh = true;
}
// Check if token is expired
if (!needsRefresh) {
try {
const parts = token.split('.');
if (parts.length === 3) {
const payload = JSON.parse(atob(parts[1]));
const exp = payload.exp;
if (exp) {
const now = Math.floor(Date.now() / 1000);
if (now >= exp) {
needsRefresh = true;
}
}
}
} catch (e) {
needsRefresh = true;
}
}
}
if (!isValidToken || !userName || !userId || needsRefresh) {
// Clear any invalid/stale cached credentials
await SecureStore.deleteItemAsync('legacyAccessToken');
await SecureStore.deleteItemAsync('legacyUserName');
await SecureStore.deleteItemAsync('legacyUserId');
const loginResult = await this.loginToLegacyDashboard();
if (!loginResult.ok) return null;
// Get freshly saved credentials
const newToken = await SecureStore.getItemAsync('legacyAccessToken');
const newUserName = await SecureStore.getItemAsync('legacyUserName');
const newUserId = await SecureStore.getItemAsync('legacyUserId');
if (!newToken || !newUserName || !newUserId) return null;
return { token: newToken, userName: newUserName, userId: newUserId };
}
return { token, userName, userId };
} catch (e) {
return null;
}
}
// Get demo deployment ID
getDemoDeploymentId(): number {
return this.DEMO_DEPLOYMENT_ID;
}
/**
* Get Legacy API credentials for device operations
* Uses the same credentials as getLegacyWebViewCredentials but returns only what's needed
*/
async getLegacyCredentials(): Promise<{ userName: string; token: string } | null> {
const creds = await this.getLegacyWebViewCredentials();
if (!creds) return null;
return { userName: creds.userName, token: creds.token };
}
// ============================================================================
// WP SENSORS / DEVICES MANAGEMENT
// ============================================================================
/**
* Get all devices for a beneficiary
* Returns WP sensors with online/offline status
*/
async getDevicesForBeneficiary(beneficiaryId: string) {
try {
// Get auth token for WellNuo API
const token = await this.getToken();
if (!token) {
return { ok: false, error: 'Not authenticated' };
}
// Get beneficiary's deployment_id from PostgreSQL
const response = await fetch(`${this.baseUrl}/me/beneficiaries/${beneficiaryId}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error('Failed to get beneficiary');
}
const beneficiary = await response.json();
const deploymentId = beneficiary.deploymentId;
if (!deploymentId) {
return { ok: true, data: [] }; // No deployment = no devices
}
// Get Legacy API credentials
const creds = await this.getLegacyCredentials();
if (!creds) {
return { ok: false, error: 'Not authenticated with Legacy API' };
}
// Get devices from Legacy API
const formData = new URLSearchParams({
function: 'device_list_by_deployment',
user_name: creds.userName,
token: creds.token,
deployment_id: deploymentId.toString(),
first: '0',
last: '100',
});
const devicesResponse = await fetch(this.legacyApiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString(),
});
if (!devicesResponse.ok) {
throw new Error('Failed to fetch devices from Legacy API');
}
const devicesData = await devicesResponse.json();
if (!devicesData.result_list || devicesData.result_list.length === 0) {
return { ok: true, data: [] };
}
// Get online status
const onlineDevices = await this.getOnlineDevices(deploymentId);
// Transform to WPSensor format with status calculation
const sensors: WPSensor[] = devicesData.result_list.map((device: any) => {
const [deviceId, wellId, mac, lastSeenTimestamp, location, description] = device;
const lastSeen = new Date(lastSeenTimestamp * 1000);
// Calculate status based on lastSeen time
const now = new Date();
const diffMinutes = (now.getTime() - lastSeen.getTime()) / (1000 * 60);
let status: 'online' | 'warning' | 'offline';
if (diffMinutes < 5) {
status = 'online'; // 🟢 Fresh data
} else if (diffMinutes < 60) {
status = 'warning'; // 🟡 Might be issue
} else {
status = 'offline'; // 🔴 Definitely problem
}
// Convert location from Legacy API format to our ID
// Legacy API may return:
// - Numeric code: 104 or "104" -> convert to "kitchen"
// - Label string: "Kitchen" -> convert to "kitchen"
// - Our ID: "kitchen" -> keep as is
let locationId = '';
if (location) {
const locationStr = String(location).trim();
const numericLocation = parseInt(locationStr, 10);
if (!isNaN(numericLocation) && String(numericLocation) === locationStr) {
// It's a numeric code (e.g., 104 or "104")
locationId = getLocationIdFromCode(numericLocation) || '';
} else {
// It's a string - try to match by label first, then by ID
const byLabel = getLocationIdFromLabel(locationStr);
if (byLabel) {
locationId = byLabel;
} else {
// Check if it's already a valid ID
const byId = ROOM_LOCATIONS.find(loc => loc.id === locationStr.toLowerCase());
locationId = byId ? byId.id : locationStr;
}
}
}
return {
deviceId: deviceId.toString(),
wellId: parseInt(wellId, 10),
mac: mac,
name: `WP_${wellId}_${mac.slice(-6).toLowerCase()}`,
status: status,
lastSeen: lastSeen,
location: locationId,
description: description || '',
beneficiaryId: beneficiaryId,
deploymentId: deploymentId,
source: 'api', // From API = attached to beneficiary
};
});
return { ok: true, data: sensors };
} catch (error: any) {
return { ok: false, error: error.message };
}
}
/**
* Get online devices for a deployment (using fresh=true)
* Returns Set of device_ids that are online
*/
private async getOnlineDevices(deploymentId: number): Promise<Set<number>> {
try {
const creds = await this.getLegacyCredentials();
if (!creds) return new Set();
const formData = new URLSearchParams({
function: 'request_devices',
user_name: creds.userName,
token: creds.token,
deployment_id: deploymentId.toString(),
group_id: 'All',
location: 'All',
fresh: 'true', // Only online devices
});
const response = await fetch(this.legacyApiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString(),
});
if (!response.ok) return new Set();
const data = await response.json();
if (!data.result_list) return new Set();
// Extract device_ids from result
const deviceIds = data.result_list.map((device: any) => device[0]);
return new Set(deviceIds);
} catch (error) {
return new Set();
}
}
/**
* Get deployment_id for a beneficiary
* @param beneficiaryId - The ID of the beneficiary
* @returns ApiResponse with deployment_id or error
*/
async getDeploymentForBeneficiary(beneficiaryId: string): Promise<ApiResponse<number>> {
try {
// Get auth token for WellNuo API
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
// Get beneficiary details from WellNuo API
const response = await fetch(`${this.baseUrl}/me/beneficiaries/${beneficiaryId}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
return {
ok: false,
error: {
message: `Failed to get beneficiary: ${response.status}`,
code: 'FETCH_ERROR'
}
};
}
const beneficiary = await response.json();
const deploymentId = beneficiary.deploymentId;
if (!deploymentId) {
return {
ok: false,
error: {
message: 'No deployment configured for this beneficiary. Please remove and re-add the beneficiary to fix this.',
code: 'NO_DEPLOYMENT'
}
};
}
return { ok: true, data: deploymentId };
} catch (error: any) {
return {
ok: false,
error: {
message: error.message || 'Failed to get deployment',
code: 'EXCEPTION'
}
};
}
}
/**
* Attach device to beneficiary's deployment
*/
async attachDeviceToBeneficiary(
beneficiaryId: string,
wellId: number,
deviceMac: string
): Promise<ApiResponse<{ success: true }>> {
try {
// Get deployment ID for beneficiary
const deploymentResponse = await this.getDeploymentForBeneficiary(beneficiaryId);
if (!deploymentResponse.ok) {
return {
ok: false,
error: {
message: 'Could not find beneficiary deployment',
code: 'DEPLOYMENT_NOT_FOUND',
status: 404,
}
};
}
const deploymentId = deploymentResponse.data;
if (!deploymentId) {
return {
ok: false,
error: {
message: 'Beneficiary has no deployment configured. Please contact support.',
code: 'NO_DEPLOYMENT',
status: 404,
}
};
}
const creds = await this.getLegacyCredentials();
if (!creds) {
return {
ok: false,
error: {
message: 'Not authenticated. Please log in again.',
code: 'UNAUTHORIZED',
status: 401,
}
};
}
// Use device_form to attach device to deployment
// Per Robert's documentation: requires well_id + device_mac + deployment_id
const formData = new URLSearchParams({
function: 'device_form',
user_name: creds.userName,
token: creds.token,
well_id: wellId.toString(),
device_mac: deviceMac.toUpperCase(),
deployment_id: deploymentId.toString(),
});
const attachResponse = await fetch(this.legacyApiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString(),
});
if (!attachResponse.ok) {
// Provide more specific error messages based on HTTP status
let errorMessage = 'Could not register sensor. Please try again.';
let errorCode = 'API_ERROR';
if (attachResponse.status === 401 || attachResponse.status === 403) {
errorMessage = 'Authentication expired. Please log in again.';
errorCode = 'UNAUTHORIZED';
} else if (attachResponse.status === 404) {
errorMessage = 'Sensor or deployment not found.';
errorCode = 'NOT_FOUND';
} else if (attachResponse.status === 429) {
errorMessage = 'Too many requests. Please wait a moment and try again.';
errorCode = 'RATE_LIMITED';
} else if (attachResponse.status === 500) {
errorMessage = 'Server error. Please try again later.';
errorCode = 'SERVER_ERROR';
} else if (attachResponse.status >= 500) {
errorMessage = 'Service unavailable. Please check your internet connection.';
errorCode = 'SERVICE_UNAVAILABLE';
}
return {
ok: false,
error: {
message: errorMessage,
code: errorCode,
status: attachResponse.status,
}
};
}
const data = await attachResponse.json();
if (data.status !== '200 OK') {
// Parse Legacy API error response
const errorMessage = data.message || 'Failed to register sensor';
return {
ok: false,
error: {
message: errorMessage,
code: 'LEGACY_API_ERROR',
status: attachResponse.status,
}
};
}
return { ok: true, data: { success: true } };
} catch (error: any) {
// Handle network errors and unexpected exceptions
const isNetworkError = error.message?.includes('network') ||
error.message?.includes('fetch') ||
(typeof navigator !== 'undefined' && !navigator.onLine);
return {
ok: false,
error: {
message: isNetworkError
? 'No internet connection. Please check your network.'
: 'An unexpected error occurred. Please try again.',
code: isNetworkError ? 'NETWORK_ERROR' : 'EXCEPTION',
}
};
}
}
/**
* Update device metadata (location, description) in Legacy API
* Uses device_form endpoint
*/
async updateDeviceMetadata(
deviceId: string,
updates: {
location?: string; // Location ID (e.g., 'bedroom', 'kitchen') - will be converted to Legacy API code
description?: string;
}
): Promise<ApiResponse<{ success: boolean }>> {
try {
const creds = await this.getLegacyWebViewCredentials();
if (!creds) {
return { ok: false, error: { message: 'Not authenticated with Legacy API', code: 'UNAUTHORIZED' } };
}
const formData = new URLSearchParams({
function: 'device_form',
user_name: creds.userName,
token: creds.token,
device_id: deviceId,
});
// Add optional fields if provided
// Location must be converted from ID to Legacy API numeric code
if (updates.location !== undefined) {
const legacyCode = getLocationLegacyCode(updates.location);
if (legacyCode !== undefined) {
formData.append('location', legacyCode.toString());
} else {
// If location ID not found, log warning but don't fail
}
}
if (updates.description !== undefined) {
formData.append('description', updates.description);
}
const response = await fetch(API_BASE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString(),
});
if (!response.ok) {
return { ok: false, error: { message: 'Failed to update device' } };
}
const data = await response.json();
if (data.status !== '200 OK') {
return { ok: false, error: { message: data.message || 'Failed to update device' } };
}
return { ok: true, data: { success: true } };
} catch (error: any) {
return { ok: false, error: { message: error.message || 'Network error', code: 'NETWORK_ERROR' } };
}
}
/**
* Attach device to deployment via Legacy API
* Uses set_deployment endpoint to link a WP sensor to a beneficiary's deployment
*
* @param deploymentId - The deployment ID to attach the device to
* @param wellId - The device's well_id (from BLE scan, e.g., 497)
* @param ssid - WiFi network SSID
* @param password - WiFi network password
*/
async attachDeviceToDeployment(
deploymentId: number,
wellId: number,
ssid: string,
password: string
): Promise<ApiResponse<{ success: boolean }>> {
try {
const creds = await this.getLegacyWebViewCredentials();
if (!creds) {
return { ok: false, error: { message: 'Not authenticated with Legacy API', code: 'UNAUTHORIZED' } };
}
// Call set_deployment to attach device
const formData = new URLSearchParams({
function: 'set_deployment',
user_name: creds.userName,
token: creds.token,
deployment: deploymentId.toString(),
devices: JSON.stringify([wellId]),
wifis: JSON.stringify([`${ssid}|${password}`]),
reuse_existing_devices: '1',
});
const response = await fetch(API_BASE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString(),
});
if (!response.ok) {
return { ok: false, error: { message: 'Failed to attach device to deployment' } };
}
const data = await response.json();
if (data.status !== '200 OK') {
return { ok: false, error: { message: data.message || 'Failed to attach device' } };
}
return { ok: true, data: { success: true } };
} catch (error: any) {
return { ok: false, error: { message: error.message || 'Network error', code: 'NETWORK_ERROR' } };
}
}
/**
* Detach device from beneficiary
*/
async detachDeviceFromBeneficiary(beneficiaryId: string, deviceId: string) {
try {
const creds = await this.getLegacyCredentials();
if (!creds) throw new Error('Not authenticated with Legacy API');
// Set device's deployment to 0 (unassigned)
const formData = new URLSearchParams({
function: 'device_form',
user_name: creds.userName,
token: creds.token,
device_id: deviceId,
deployment_id: '0', // Unassign
});
const response = await fetch(this.legacyApiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString(),
});
if (!response.ok) throw new Error('Failed to detach device');
return { ok: true };
} catch (error: any) {
return { ok: false, error: error.message };
}
}
/**
* Get sensor health history from WellNuo API
* Returns aggregated health metrics over time
*/
async getSensorHealthHistory(
deviceId: string,
timeRange: '24h' | '7d' | '30d' = '24h'
): Promise<ApiResponse<{
deviceId: string;
timeRange: string;
dataPoints: Array<{
timestamp: number;
connectionStatus: 'online' | 'warning' | 'offline';
wifiRssi: number | null;
communicationSuccessRate: number;
}>;
summary: {
uptimePercentage: number;
averageWifiRssi: number | null;
totalCommands: number;
successRate: number;
};
}>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${this.baseUrl}/sensors/${deviceId}/health?range=${timeRange}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
if (!response.ok) {
return {
ok: false,
error: {
message: data.error || 'Failed to get sensor health history',
code: response.status === 401 ? 'UNAUTHORIZED' : 'API_ERROR',
}
};
}
return { data, ok: true };
} catch (error) {
return { ok: false, error: { message: 'Network error', code: 'NETWORK_ERROR' } };
}
}
/**
* Report sensor health metrics to WellNuo API
* Called periodically by the app to log sensor health data
*/
async reportSensorHealth(metrics: {
wellId: number;
mac: string;
connectionStatus: 'online' | 'warning' | 'offline';
wifiRssi: number | null;
wifiSsid: string | null;
bleRssi: number | null;
communicationStats: {
successfulCommands: number;
failedCommands: number;
averageResponseTime: number;
};
}): Promise<ApiResponse<{ success: boolean }>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${this.baseUrl}/sensors/health/report`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(metrics),
});
const data = await response.json();
if (!response.ok) {
return {
ok: false,
error: {
message: data.error || 'Failed to report sensor health',
}
};
}
return { data: { success: true }, ok: true };
} catch (error) {
return { ok: false, error: { message: 'Network error', code: 'NETWORK_ERROR' } };
}
}
}
export const api = new ApiService();