WellNuo/services/api.ts
Sergei 2aff43af34 fix(api): Convert location IDs to Legacy API numeric codes
- Add legacyCode to ROOM_LOCATIONS constants (102-200)
- Add getLocationLegacyCode() to convert ID -> code when saving
- Add getLocationIdFromCode() to convert code -> ID when loading
- updateDeviceMetadata now sends numeric codes to Legacy API
- getDevicesForBeneficiary now converts codes back to string IDs

Legacy API expects numeric location codes (e.g., 102 for Bedroom),
but frontend uses string IDs (e.g., 'bedroom'). This fix ensures
proper bidirectional conversion.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-24 14:17:46 -08:00

1948 lines
61 KiB
TypeScript

import type { ApiError, ApiResponse, AuthResponse, Beneficiary, BeneficiaryDashboardData, ChatResponse, DashboardSingleResponse, 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 'react-native-get-random-values'; // Polyfill for crypto.getRandomValues
// Callback for handling unauthorized responses (401)
let onUnauthorizedCallback: (() => void) | null = null;
export function setOnUnauthorizedCallback(callback: () => void) {
onUnauthorizedCallback = callback;
}
const API_BASE_URL = 'https://eluxnetworks.net/function/well-api/api';
const CLIENT_ID = 'MA_001';
// 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;
}
// 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> {
// 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!)
async getStoredUser() {
try {
const token = await this.getToken();
const userId = await SecureStore.getItemAsync('userId');
if (!token || !userId) {
return null;
}
// Fetch profile from server
const profile = await this.getProfile();
if (!profile.ok || !profile.data) {
// If token is invalid (401), clear all tokens and return null
// This will trigger re-authentication
if (profile.error?.code === 'UNAUTHORIZED') {
await this.logout();
return null;
}
// For network errors 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
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()) < 30 * 60 * 1000; // 30 min
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, // Server-provided displayName
originalName: item.originalName || item.name, // Original name from beneficiaries table
avatar: item.avatarUrl || undefined, // Use uploaded avatar from server
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, // Server-provided displayName
originalName: data.originalName || data.name, // Original name from beneficiaries table
avatar: data.avatarUrl || undefined,
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,
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 || '', // 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: Array<{
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: Array<{
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.' },
};
}
}
// 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.' },
};
}
}
// 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 = 'anandk';
private readonly DEMO_LEGACY_PASSWORD = 'anandk_8';
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('.');
if (!isValidToken || !userName || !userId) {
// Clear any invalid cached credentials
if (token && !isValidToken) {
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 numeric location code to string ID if needed
let locationId = '';
if (location) {
const numericLocation = parseInt(location, 10);
if (!isNaN(numericLocation)) {
// It's a numeric code from Legacy API - convert to our ID
locationId = getLocationIdFromCode(numericLocation) || '';
} else {
// It's already a string (legacy data or custom location)
locationId = location;
}
}
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();
}
}
/**
* Attach device to beneficiary's deployment
*/
async attachDeviceToBeneficiary(
beneficiaryId: string,
wellId: number,
ssid: string,
password: string
) {
try {
// Get auth token for WellNuo API
const token = await this.getToken();
if (!token) throw new Error('Not authenticated');
// Get beneficiary details
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) {
throw new Error('Beneficiary has no deployment');
}
const creds = await this.getLegacyCredentials();
if (!creds) throw new Error('Not authenticated with Legacy API');
// 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 attachResponse = await fetch(this.legacyApiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString(),
});
if (!attachResponse.ok) {
throw new Error('Failed to attach device');
}
const data = await attachResponse.json();
if (data.status !== '200 OK') {
throw new Error(data.message || 'Failed to attach device');
}
return { ok: true };
} catch (error: any) {
return { ok: false, error: error.message };
}
}
/**
* 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
console.warn(`Unknown location ID: ${updates.location}, skipping location update`);
}
}
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 };
}
}
}
export const api = new ApiService();