Add status badges for beneficiaries list

- Monitoring badge: equipment active + subscription active
- Get kit badge: user hasn't ordered equipment yet
- Equipment status badges: ordered, shipped, delivered
- No subscription warning when equipment works but no sub
- Stripe subscription caching in backend (hourly sync)
- BeneficiaryMenu with edit/share/archive/delete actions
This commit is contained in:
Sergei 2026-01-09 19:49:07 -08:00
parent 6e277ca940
commit 657737e5a4
10 changed files with 268 additions and 53 deletions

View File

@ -8,6 +8,7 @@ import {
Pressable, Pressable,
ActivityIndicator, ActivityIndicator,
RefreshControl, RefreshControl,
Alert,
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';

View File

@ -22,6 +22,7 @@ import {
Spacing, Spacing,
Shadows, Shadows,
} from '@/constants/theme'; } from '@/constants/theme';
import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu';
interface Device { interface Device {
id: string; id: string;
@ -222,12 +223,12 @@ export default function EquipmentScreen() {
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}> <TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} /> <Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.headerTitle}>Equipment</Text> <Text style={styles.headerTitle}>Sensors</Text>
<View style={styles.placeholder} /> <View style={styles.placeholder} />
</View> </View>
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={AppColors.primary} /> <ActivityIndicator size="large" color={AppColors.primary} />
<Text style={styles.loadingText}>Loading devices...</Text> <Text style={styles.loadingText}>Loading sensors...</Text>
</View> </View>
</SafeAreaView> </SafeAreaView>
); );
@ -240,10 +241,17 @@ export default function EquipmentScreen() {
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}> <TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} /> <Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.headerTitle}>Equipment</Text> <Text style={styles.headerTitle}>Sensors</Text>
<View style={styles.headerRight}>
<TouchableOpacity style={styles.addButton} onPress={handleAddDevice}> <TouchableOpacity style={styles.addButton} onPress={handleAddDevice}>
<Ionicons name="add" size={24} color={AppColors.primary} /> <Ionicons name="add" size={24} color={AppColors.primary} />
</TouchableOpacity> </TouchableOpacity>
<BeneficiaryMenu
beneficiaryId={id || ''}
userRole={currentBeneficiary?.role}
currentPage="sensors"
/>
</View>
</View> </View>
<ScrollView <ScrollView
@ -394,6 +402,11 @@ const styles = StyleSheet.create({
placeholder: { placeholder: {
width: 32, width: 32,
}, },
headerRight: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.xs,
},
addButton: { addButton: {
width: 40, width: 40,
height: 40, height: 40,

View File

@ -356,6 +356,7 @@ export default function BeneficiaryDetailScreen() {
<BeneficiaryMenu <BeneficiaryMenu
beneficiaryId={id || ''} beneficiaryId={id || ''}
userRole={beneficiary?.role}
onEdit={handleEditPress} onEdit={handleEditPress}
onRemove={handleDeleteBeneficiary} onRemove={handleDeleteBeneficiary}
/> />

View File

@ -26,6 +26,7 @@ import {
FontWeights, FontWeights,
Spacing, Spacing,
} from '@/constants/theme'; } from '@/constants/theme';
import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu';
type Role = 'caretaker' | 'guardian'; type Role = 'caretaker' | 'guardian';
@ -306,7 +307,11 @@ export default function ShareAccessScreen() {
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} /> <Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.headerTitle}>Access</Text> <Text style={styles.headerTitle}>Access</Text>
<View style={styles.placeholder} /> <BeneficiaryMenu
beneficiaryId={id || ''}
userRole={currentBeneficiary?.role}
currentPage="access"
/>
</View> </View>
<KeyboardAvoidingView <KeyboardAvoidingView
@ -459,9 +464,6 @@ const styles = StyleSheet.create({
fontWeight: FontWeights.semibold, fontWeight: FontWeights.semibold,
color: AppColors.textPrimary, color: AppColors.textPrimary,
}, },
placeholder: {
width: 32,
},
content: { content: {
flex: 1, flex: 1,
}, },

View File

@ -459,6 +459,7 @@ export default function SubscriptionScreen() {
<Text style={styles.headerTitle}>Subscription</Text> <Text style={styles.headerTitle}>Subscription</Text>
<BeneficiaryMenu <BeneficiaryMenu
beneficiaryId={id || ''} beneficiaryId={id || ''}
userRole={beneficiary?.role}
currentPage="subscription" currentPage="subscription"
/> />
</View> </View>

View File

@ -37,6 +37,13 @@ interface BeneficiaryCardProps {
// Equipment status config // Equipment status config
const equipmentStatusConfig = { const equipmentStatusConfig = {
none: {
icon: 'cart-outline' as const,
label: 'Get kit',
sublabel: 'Order equipment',
color: AppColors.textMuted,
bgColor: AppColors.backgroundSecondary,
},
ordered: { ordered: {
icon: 'cube-outline' as const, icon: 'cube-outline' as const,
label: 'Kit ordered', label: 'Kit ordered',
@ -65,33 +72,22 @@ const equipmentStatusConfig = {
color: AppColors.success, color: AppColors.success,
bgColor: AppColors.successLight, bgColor: AppColors.successLight,
}, },
demo: {
icon: 'pulse-outline' as const,
label: 'Monitoring',
sublabel: 'Demo mode',
color: AppColors.success,
bgColor: AppColors.successLight,
},
}; };
function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardProps) { function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardProps) {
const equipmentStatus = beneficiary.equipmentStatus; const equipmentStatus = beneficiary.equipmentStatus;
const isAwaitingEquipment = equipmentStatus && ['ordered', 'shipped', 'delivered'].includes(equipmentStatus); const isAwaitingEquipment = equipmentStatus && ['ordered', 'shipped', 'delivered'].includes(equipmentStatus);
const isMonitoring = equipmentStatus && ['active', 'demo'].includes(equipmentStatus); const isEquipmentActive = equipmentStatus === 'active' || equipmentStatus === 'demo';
const statusConfig = equipmentStatus ? equipmentStatusConfig[equipmentStatus as keyof typeof equipmentStatusConfig] : null; const hasSubscription = isSubscriptionActive(beneficiary.subscription);
const needsKit = !equipmentStatus || equipmentStatus === 'none';
// Check if has devices/equipment connected // Monitoring = equipment works + subscription active (all good!)
const hasDevices = beneficiary.hasDevices || const isMonitoring = isEquipmentActive && hasSubscription;
(beneficiary.devices && beneficiary.devices.length > 0) ||
beneficiary.device_id;
// Show subscription warning if: // No subscription warning: equipment works but no subscription
// 1. Has devices connected (equipment is installed) const hasNoSubscription = isEquipmentActive && !hasSubscription;
// 2. NOT awaiting equipment delivery
// 3. Subscription is missing or not active const statusConfig = equipmentStatus ? equipmentStatusConfig[equipmentStatus as keyof typeof equipmentStatusConfig] : equipmentStatusConfig.none;
const hasNoSubscription = hasDevices && !isAwaitingEquipment && (
!isSubscriptionActive(beneficiary.subscription)
);
// Check if avatar is valid (not empty, null, or placeholder) // Check if avatar is valid (not empty, null, or placeholder)
const hasValidAvatar = beneficiary.avatar && const hasValidAvatar = beneficiary.avatar &&
@ -129,6 +125,12 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
{/* Name and Status */} {/* Name and Status */}
<View style={styles.info}> <View style={styles.info}>
<Text style={styles.name} numberOfLines={1}>{beneficiary.name}</Text> <Text style={styles.name} numberOfLines={1}>{beneficiary.name}</Text>
{/* User's role for this beneficiary */}
{beneficiary.role && (
<Text style={styles.roleText}>
You: {beneficiary.role.charAt(0).toUpperCase() + beneficiary.role.slice(1)}
</Text>
)}
{/* Equipment status badge (awaiting delivery) */} {/* Equipment status badge (awaiting delivery) */}
{isAwaitingEquipment && statusConfig && ( {isAwaitingEquipment && statusConfig && (
<View style={[styles.statusBadge, { backgroundColor: statusConfig.bgColor }]}> <View style={[styles.statusBadge, { backgroundColor: statusConfig.bgColor }]}>
@ -138,12 +140,12 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
</Text> </Text>
</View> </View>
)} )}
{/* Monitoring badge (active/demo) */} {/* Monitoring badge (equipment works + subscription active) */}
{isMonitoring && statusConfig && ( {isMonitoring && (
<View style={[styles.statusBadge, { backgroundColor: statusConfig.bgColor }]}> <View style={[styles.statusBadge, { backgroundColor: equipmentStatusConfig.active.bgColor }]}>
<Ionicons name={statusConfig.icon} size={14} color={statusConfig.color} /> <Ionicons name={equipmentStatusConfig.active.icon} size={14} color={equipmentStatusConfig.active.color} />
<Text style={[styles.statusText, { color: statusConfig.color }]}> <Text style={[styles.statusText, { color: equipmentStatusConfig.active.color }]}>
{statusConfig.label} {equipmentStatusConfig.active.label}
</Text> </Text>
</View> </View>
)} )}
@ -154,6 +156,15 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
<Text style={styles.noSubscriptionText}>No subscription</Text> <Text style={styles.noSubscriptionText}>No subscription</Text>
</View> </View>
)} )}
{/* Get kit badge (no equipment) */}
{needsKit && (
<View style={[styles.statusBadge, { backgroundColor: equipmentStatusConfig.none.bgColor }]}>
<Ionicons name={equipmentStatusConfig.none.icon} size={14} color={equipmentStatusConfig.none.color} />
<Text style={[styles.statusText, { color: equipmentStatusConfig.none.color }]}>
{equipmentStatusConfig.none.label}
</Text>
</View>
)}
</View> </View>
{/* Action button or Arrow */} {/* Action button or Arrow */}
@ -578,6 +589,11 @@ const styles = StyleSheet.create({
fontWeight: FontWeights.semibold, fontWeight: FontWeights.semibold,
color: AppColors.textPrimary, color: AppColors.textPrimary,
}, },
roleText: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
marginTop: 2,
},
noSubscriptionBadge: { noSubscriptionBadge: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

View File

@ -91,8 +91,9 @@ app.use('/function/well-api/api', authLimiter); // Legacy API с auth
app.use('/api/webhook/stripe', express.raw({ type: 'application/json' })); app.use('/api/webhook/stripe', express.raw({ type: 'application/json' }));
// JSON body parser for other routes // JSON body parser for other routes
app.use(express.json()); // Increased limit for base64 avatar uploads
app.use(express.urlencoded({ extended: true })); app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// ============ ROUTES ============ // ============ ROUTES ============

View File

@ -4,6 +4,7 @@ const crypto = require('crypto');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { supabase } = require('../config/supabase'); const { supabase } = require('../config/supabase');
const { sendOTPEmail } = require('../services/email'); const { sendOTPEmail } = require('../services/email');
const storage = require('../services/storage');
/** /**
* POST /api/auth/check-email * POST /api/auth/check-email
@ -233,7 +234,8 @@ router.post('/verify-otp', async (req, res) => {
firstName: user.first_name, firstName: user.first_name,
lastName: user.last_name, lastName: user.last_name,
phone: user.phone, phone: user.phone,
role: user.role || 'user' role: user.role || 'user',
avatarUrl: user.avatar_url || null
}, },
beneficiaries beneficiaries
}); });
@ -312,7 +314,8 @@ router.get('/me', async (req, res) => {
firstName: user.first_name, firstName: user.first_name,
lastName: user.last_name, lastName: user.last_name,
phone: user.phone, phone: user.phone,
role: user.role || 'user' role: user.role || 'user',
avatarUrl: user.avatar_url || null
}, },
beneficiaries beneficiaries
}); });
@ -385,4 +388,108 @@ router.patch('/profile', async (req, res) => {
} }
}); });
/**
* PATCH /api/auth/avatar
* Upload/update user profile avatar
* - Uploads to MinIO if configured
* - Falls back to base64 in DB if MinIO not available
*/
router.patch('/avatar', async (req, res) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const userId = decoded.userId;
const { avatar } = req.body; // base64 string or null to remove
console.log('[AUTH] Avatar update:', { userId, hasAvatar: !!avatar });
// Validate base64 if provided
if (avatar && !avatar.startsWith('data:image/')) {
return res.status(400).json({ error: 'Invalid image format. Must be base64 data URI' });
}
let avatarUrl = null;
if (avatar) {
// Try to upload to MinIO
if (storage.isConfigured()) {
try {
// Get current avatar to delete old file
const { data: current } = await supabase
.from('users')
.select('avatar_url')
.eq('id', userId)
.single();
// Delete old avatar from MinIO if exists
if (current?.avatar_url && current.avatar_url.includes('minio')) {
const oldKey = storage.extractKeyFromUrl(current.avatar_url);
if (oldKey) {
try {
await storage.deleteFile(oldKey);
} catch (e) {
console.warn('[AUTH] Failed to delete old avatar:', e.message);
}
}
}
// Upload new avatar to MinIO
const filename = `user-${userId}-${Date.now()}`;
const result = await storage.uploadBase64Image(avatar, 'avatars/users', filename);
avatarUrl = result.url;
console.log('[AUTH] Avatar uploaded to MinIO:', avatarUrl);
} catch (uploadError) {
console.error('[AUTH] MinIO upload failed, falling back to DB:', uploadError.message);
// Fallback: store base64 in DB
avatarUrl = avatar;
}
} else {
// MinIO not configured - store base64 in DB
console.log('[AUTH] MinIO not configured, storing base64 in DB');
avatarUrl = avatar;
}
}
// Update avatar_url in users table
const { data: user, error } = await supabase
.from('users')
.update({
avatar_url: avatarUrl,
updated_at: new Date().toISOString()
})
.eq('id', userId)
.select('id, email, first_name, last_name, avatar_url')
.single();
if (error) {
console.error('[AUTH] Avatar update error:', error);
return res.status(500).json({ error: 'Failed to update avatar' });
}
console.log('[AUTH] Avatar updated:', { userId, avatarUrl: user.avatar_url?.substring(0, 50) });
res.json({
success: true,
user: {
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
avatarUrl: user.avatar_url
}
});
} catch (error) {
console.error('[AUTH] Avatar error:', error);
res.status(500).json({ error: error.message });
}
});
module.exports = router; module.exports = router;

View File

@ -4,7 +4,8 @@ import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router'; import { router } from 'expo-router';
import { AppColors, BorderRadius, FontSizes, Spacing, Shadows } from '@/constants/theme'; import { AppColors, BorderRadius, FontSizes, Spacing, Shadows } from '@/constants/theme';
export type MenuItemId = 'edit' | 'access' | 'subscription' | 'equipment' | 'remove'; export type MenuItemId = 'dashboard' | 'edit' | 'access' | 'subscription' | 'sensors' | 'remove';
export type UserRole = 'custodian' | 'guardian' | 'caretaker';
interface MenuItem { interface MenuItem {
id: MenuItemId; id: MenuItemId;
@ -13,16 +14,29 @@ interface MenuItem {
danger?: boolean; danger?: boolean;
} }
// Permissions by role (Variant B)
// Custodian: all permissions
// Guardian: all except remove
// Caretaker: dashboard, edit, sensors only
const ROLE_PERMISSIONS: Record<UserRole, MenuItemId[]> = {
custodian: ['dashboard', 'edit', 'access', 'subscription', 'sensors', 'remove'],
guardian: ['dashboard', 'edit', 'access', 'subscription', 'sensors'],
caretaker: ['dashboard', 'edit', 'sensors'],
};
const ALL_MENU_ITEMS: MenuItem[] = [ const ALL_MENU_ITEMS: MenuItem[] = [
{ id: 'dashboard', icon: 'grid-outline', label: 'Dashboard' },
{ id: 'edit', icon: 'create-outline', label: 'Edit' }, { id: 'edit', icon: 'create-outline', label: 'Edit' },
{ id: 'access', icon: 'share-outline', label: 'Access' }, { id: 'access', icon: 'share-outline', label: 'Access' },
{ id: 'subscription', icon: 'diamond-outline', label: 'Subscription' }, { id: 'subscription', icon: 'diamond-outline', label: 'Subscription' },
{ id: 'equipment', icon: 'hardware-chip-outline', label: 'Equipment' }, { id: 'sensors', icon: 'hardware-chip-outline', label: 'Sensors' },
{ id: 'remove', icon: 'trash-outline', label: 'Remove', danger: true }, { id: 'remove', icon: 'trash-outline', label: 'Remove', danger: true },
]; ];
interface BeneficiaryMenuProps { interface BeneficiaryMenuProps {
beneficiaryId: string | number; beneficiaryId: string | number;
/** User's role for this beneficiary - determines available menu items */
userRole?: UserRole;
/** Which menu items to show. If not provided, shows all except current page */ /** Which menu items to show. If not provided, shows all except current page */
visibleItems?: MenuItemId[]; visibleItems?: MenuItemId[];
/** Which menu item represents the current page (will be hidden) */ /** Which menu item represents the current page (will be hidden) */
@ -35,6 +49,7 @@ interface BeneficiaryMenuProps {
export function BeneficiaryMenu({ export function BeneficiaryMenu({
beneficiaryId, beneficiaryId,
userRole = 'caretaker', // Default to minimum permissions if not specified (security-first approach)
visibleItems, visibleItems,
currentPage, currentPage,
onEdit, onEdit,
@ -46,6 +61,9 @@ export function BeneficiaryMenu({
setIsVisible(false); setIsVisible(false);
switch (itemId) { switch (itemId) {
case 'dashboard':
router.push(`/(tabs)/beneficiaries/${beneficiaryId}`);
break;
case 'edit': case 'edit':
if (onEdit) { if (onEdit) {
onEdit(); onEdit();
@ -60,7 +78,7 @@ export function BeneficiaryMenu({
case 'subscription': case 'subscription':
router.push(`/(tabs)/beneficiaries/${beneficiaryId}/subscription`); router.push(`/(tabs)/beneficiaries/${beneficiaryId}/subscription`);
break; break;
case 'equipment': case 'sensors':
router.push(`/(tabs)/beneficiaries/${beneficiaryId}/equipment`); router.push(`/(tabs)/beneficiaries/${beneficiaryId}/equipment`);
break; break;
case 'remove': case 'remove':
@ -74,13 +92,20 @@ export function BeneficiaryMenu({
} }
}; };
// Filter menu items - only hide current page // Filter menu items based on:
let menuItems = ALL_MENU_ITEMS; // 1. User role permissions
// 2. Explicitly visible items (if provided)
// 3. Current page (hide it from menu)
const allowedByRole = ROLE_PERMISSIONS[userRole] || ROLE_PERMISSIONS.caretaker;
let menuItems = ALL_MENU_ITEMS.filter(item => allowedByRole.includes(item.id));
if (visibleItems) { if (visibleItems) {
menuItems = ALL_MENU_ITEMS.filter(item => visibleItems.includes(item.id)); menuItems = menuItems.filter(item => visibleItems.includes(item.id));
} else if (currentPage) { }
menuItems = ALL_MENU_ITEMS.filter(item => item.id !== currentPage);
if (currentPage) {
menuItems = menuItems.filter(item => item.id !== currentPage);
} }
return ( return (

View File

@ -1,6 +1,6 @@
import type { ApiError, ApiResponse, AuthResponse, Beneficiary, BeneficiaryDashboardData, ChatResponse, DashboardSingleResponse, NotificationSettings } from '@/types'; import type { ApiError, ApiResponse, AuthResponse, Beneficiary, BeneficiaryDashboardData, ChatResponse, DashboardSingleResponse, NotificationSettings } from '@/types';
import * as Crypto from 'expo-crypto'; import * as Crypto from 'expo-crypto';
import * as FileSystem from 'expo-file-system'; import { File } from 'expo-file-system';
import * as SecureStore from 'expo-secure-store'; import * as SecureStore from 'expo-secure-store';
// Callback for handling unauthorized responses (401) // Callback for handling unauthorized responses (401)
@ -344,6 +344,7 @@ class ApiService {
// /auth/me returns { user: {...}, beneficiaries: [...] } // /auth/me returns { user: {...}, beneficiaries: [...] }
// Extract user data from nested 'user' object // Extract user data from nested 'user' object
const userData = profile.data.user || profile.data; const userData = profile.data.user || profile.data;
console.log('[API] getStoredUser: userData =', JSON.stringify(userData));
return { return {
user_id: userData.id, user_id: userData.id,
@ -460,6 +461,51 @@ class ApiService {
} }
} }
// 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) {
console.error('[API] updateProfileAvatar error:', error);
return { ok: false, error: { message: 'Network error', code: 'NETWORK_ERROR' } };
}
}
// Beneficiaries (elderly people being monitored) // Beneficiaries (elderly people being monitored)
async getBeneficiaries(): Promise<ApiResponse<{ beneficiaries: Beneficiary[] }>> { async getBeneficiaries(): Promise<ApiResponse<{ beneficiaries: Beneficiary[] }>> {
const token = await this.getToken(); const token = await this.getToken();
@ -619,6 +665,7 @@ class ApiService {
equipmentStatus: item.equipmentStatus, equipmentStatus: item.equipmentStatus,
hasDevices: item.hasDevices || false, hasDevices: item.hasDevices || false,
trackingNumber: item.trackingNumber, trackingNumber: item.trackingNumber,
role: item.role, // User's role for this beneficiary (custodian, guardian, caretaker)
})); }));
return { data: beneficiaries, ok: true }; return { data: beneficiaries, ok: true };
@ -675,6 +722,8 @@ class ApiService {
equipmentStatus: data.equipmentStatus, equipmentStatus: data.equipmentStatus,
hasDevices: data.hasDevices || false, hasDevices: data.hasDevices || false,
trackingNumber: data.trackingNumber, trackingNumber: data.trackingNumber,
// User's role for this beneficiary (custodian, guardian, caretaker)
role: data.role,
}; };
return { data: beneficiary, ok: true }; return { data: beneficiary, ok: true };
@ -776,10 +825,9 @@ class ApiService {
let base64Image: string | null = null; let base64Image: string | null = null;
if (imageUri) { if (imageUri) {
// Read file as base64 using stable FileSystem API // Read file as base64 using new expo-file-system v19+ File API
const base64Data = await FileSystem.readAsStringAsync(imageUri, { const file = new File(imageUri);
encoding: FileSystem.EncodingType.Base64, const base64Data = await file.base64();
});
// Determine mime type from URI extension // Determine mime type from URI extension
const extension = imageUri.split('.').pop()?.toLowerCase() || 'jpeg'; const extension = imageUri.split('.').pop()?.toLowerCase() || 'jpeg';