- 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>
1948 lines
61 KiB
TypeScript
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();
|