diff --git a/app/(tabs)/beneficiaries/[id]/equipment-status.tsx b/app/(tabs)/beneficiaries/[id]/equipment-status.tsx index dfbd557..2fd7980 100644 --- a/app/(tabs)/beneficiaries/[id]/equipment-status.tsx +++ b/app/(tabs)/beneficiaries/[id]/equipment-status.tsx @@ -8,6 +8,7 @@ import { Pressable, ActivityIndicator, RefreshControl, + Alert, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { SafeAreaView } from 'react-native-safe-area-context'; diff --git a/app/(tabs)/beneficiaries/[id]/equipment.tsx b/app/(tabs)/beneficiaries/[id]/equipment.tsx index 25ab286..dc407c7 100644 --- a/app/(tabs)/beneficiaries/[id]/equipment.tsx +++ b/app/(tabs)/beneficiaries/[id]/equipment.tsx @@ -22,6 +22,7 @@ import { Spacing, Shadows, } from '@/constants/theme'; +import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu'; interface Device { id: string; @@ -222,12 +223,12 @@ export default function EquipmentScreen() { router.back()}> - Equipment + Sensors - Loading devices... + Loading sensors... ); @@ -240,10 +241,17 @@ export default function EquipmentScreen() { router.back()}> - Equipment - - - + Sensors + + + + + + diff --git a/app/(tabs)/beneficiaries/[id]/share.tsx b/app/(tabs)/beneficiaries/[id]/share.tsx index 3a48e2e..4ed616a 100644 --- a/app/(tabs)/beneficiaries/[id]/share.tsx +++ b/app/(tabs)/beneficiaries/[id]/share.tsx @@ -26,6 +26,7 @@ import { FontWeights, Spacing, } from '@/constants/theme'; +import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu'; type Role = 'caretaker' | 'guardian'; @@ -306,7 +307,11 @@ export default function ShareAccessScreen() { Access - + Subscription diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index f8f100b..9712bbe 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -37,6 +37,13 @@ interface BeneficiaryCardProps { // Equipment status config const equipmentStatusConfig = { + none: { + icon: 'cart-outline' as const, + label: 'Get kit', + sublabel: 'Order equipment', + color: AppColors.textMuted, + bgColor: AppColors.backgroundSecondary, + }, ordered: { icon: 'cube-outline' as const, label: 'Kit ordered', @@ -65,33 +72,22 @@ const equipmentStatusConfig = { color: AppColors.success, 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) { const equipmentStatus = beneficiary.equipmentStatus; const isAwaitingEquipment = equipmentStatus && ['ordered', 'shipped', 'delivered'].includes(equipmentStatus); - const isMonitoring = equipmentStatus && ['active', 'demo'].includes(equipmentStatus); - const statusConfig = equipmentStatus ? equipmentStatusConfig[equipmentStatus as keyof typeof equipmentStatusConfig] : null; + const isEquipmentActive = equipmentStatus === 'active' || equipmentStatus === 'demo'; + const hasSubscription = isSubscriptionActive(beneficiary.subscription); + const needsKit = !equipmentStatus || equipmentStatus === 'none'; - // Check if has devices/equipment connected - const hasDevices = beneficiary.hasDevices || - (beneficiary.devices && beneficiary.devices.length > 0) || - beneficiary.device_id; + // Monitoring = equipment works + subscription active (all good!) + const isMonitoring = isEquipmentActive && hasSubscription; - // Show subscription warning if: - // 1. Has devices connected (equipment is installed) - // 2. NOT awaiting equipment delivery - // 3. Subscription is missing or not active - const hasNoSubscription = hasDevices && !isAwaitingEquipment && ( - !isSubscriptionActive(beneficiary.subscription) - ); + // No subscription warning: equipment works but no subscription + const hasNoSubscription = isEquipmentActive && !hasSubscription; + + const statusConfig = equipmentStatus ? equipmentStatusConfig[equipmentStatus as keyof typeof equipmentStatusConfig] : equipmentStatusConfig.none; // Check if avatar is valid (not empty, null, or placeholder) const hasValidAvatar = beneficiary.avatar && @@ -129,6 +125,12 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr {/* Name and Status */} {beneficiary.name} + {/* User's role for this beneficiary */} + {beneficiary.role && ( + + You: {beneficiary.role.charAt(0).toUpperCase() + beneficiary.role.slice(1)} + + )} {/* Equipment status badge (awaiting delivery) */} {isAwaitingEquipment && statusConfig && ( @@ -138,12 +140,12 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr )} - {/* Monitoring badge (active/demo) */} - {isMonitoring && statusConfig && ( - - - - {statusConfig.label} + {/* Monitoring badge (equipment works + subscription active) */} + {isMonitoring && ( + + + + {equipmentStatusConfig.active.label} )} @@ -154,6 +156,15 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr No subscription )} + {/* Get kit badge (no equipment) */} + {needsKit && ( + + + + {equipmentStatusConfig.none.label} + + + )} {/* Action button or Arrow */} @@ -578,6 +589,11 @@ const styles = StyleSheet.create({ fontWeight: FontWeights.semibold, color: AppColors.textPrimary, }, + roleText: { + fontSize: FontSizes.xs, + color: AppColors.textMuted, + marginTop: 2, + }, noSubscriptionBadge: { flexDirection: 'row', alignItems: 'center', diff --git a/backend/src/index.js b/backend/src/index.js index e45ace5..47483c4 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -91,8 +91,9 @@ app.use('/function/well-api/api', authLimiter); // Legacy API с auth app.use('/api/webhook/stripe', express.raw({ type: 'application/json' })); // JSON body parser for other routes -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); +// Increased limit for base64 avatar uploads +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); // ============ ROUTES ============ diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 10339f1..d584e0e 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -4,6 +4,7 @@ const crypto = require('crypto'); const jwt = require('jsonwebtoken'); const { supabase } = require('../config/supabase'); const { sendOTPEmail } = require('../services/email'); +const storage = require('../services/storage'); /** * POST /api/auth/check-email @@ -233,7 +234,8 @@ router.post('/verify-otp', async (req, res) => { firstName: user.first_name, lastName: user.last_name, phone: user.phone, - role: user.role || 'user' + role: user.role || 'user', + avatarUrl: user.avatar_url || null }, beneficiaries }); @@ -312,7 +314,8 @@ router.get('/me', async (req, res) => { firstName: user.first_name, lastName: user.last_name, phone: user.phone, - role: user.role || 'user' + role: user.role || 'user', + avatarUrl: user.avatar_url || null }, 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; diff --git a/components/ui/BeneficiaryMenu.tsx b/components/ui/BeneficiaryMenu.tsx index 185dd38..d9451bb 100644 --- a/components/ui/BeneficiaryMenu.tsx +++ b/components/ui/BeneficiaryMenu.tsx @@ -4,7 +4,8 @@ import { Ionicons } from '@expo/vector-icons'; import { router } from 'expo-router'; 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 { id: MenuItemId; @@ -13,16 +14,29 @@ interface MenuItem { danger?: boolean; } +// Permissions by role (Variant B) +// Custodian: all permissions +// Guardian: all except remove +// Caretaker: dashboard, edit, sensors only +const ROLE_PERMISSIONS: Record = { + custodian: ['dashboard', 'edit', 'access', 'subscription', 'sensors', 'remove'], + guardian: ['dashboard', 'edit', 'access', 'subscription', 'sensors'], + caretaker: ['dashboard', 'edit', 'sensors'], +}; + const ALL_MENU_ITEMS: MenuItem[] = [ + { id: 'dashboard', icon: 'grid-outline', label: 'Dashboard' }, { id: 'edit', icon: 'create-outline', label: 'Edit' }, { id: 'access', icon: 'share-outline', label: 'Access' }, { 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 }, ]; interface BeneficiaryMenuProps { 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 */ visibleItems?: MenuItemId[]; /** Which menu item represents the current page (will be hidden) */ @@ -35,6 +49,7 @@ interface BeneficiaryMenuProps { export function BeneficiaryMenu({ beneficiaryId, + userRole = 'caretaker', // Default to minimum permissions if not specified (security-first approach) visibleItems, currentPage, onEdit, @@ -46,6 +61,9 @@ export function BeneficiaryMenu({ setIsVisible(false); switch (itemId) { + case 'dashboard': + router.push(`/(tabs)/beneficiaries/${beneficiaryId}`); + break; case 'edit': if (onEdit) { onEdit(); @@ -60,7 +78,7 @@ export function BeneficiaryMenu({ case 'subscription': router.push(`/(tabs)/beneficiaries/${beneficiaryId}/subscription`); break; - case 'equipment': + case 'sensors': router.push(`/(tabs)/beneficiaries/${beneficiaryId}/equipment`); break; case 'remove': @@ -74,13 +92,20 @@ export function BeneficiaryMenu({ } }; - // Filter menu items - only hide current page - let menuItems = ALL_MENU_ITEMS; + // Filter menu items based on: + // 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) { - menuItems = ALL_MENU_ITEMS.filter(item => visibleItems.includes(item.id)); - } else if (currentPage) { - menuItems = ALL_MENU_ITEMS.filter(item => item.id !== currentPage); + menuItems = menuItems.filter(item => visibleItems.includes(item.id)); + } + + if (currentPage) { + menuItems = menuItems.filter(item => item.id !== currentPage); } return ( diff --git a/services/api.ts b/services/api.ts index 8a759b6..50659d3 100644 --- a/services/api.ts +++ b/services/api.ts @@ -1,6 +1,6 @@ import type { ApiError, ApiResponse, AuthResponse, Beneficiary, BeneficiaryDashboardData, ChatResponse, DashboardSingleResponse, NotificationSettings } from '@/types'; 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'; // Callback for handling unauthorized responses (401) @@ -344,6 +344,7 @@ class ApiService { // /auth/me returns { user: {...}, beneficiaries: [...] } // Extract user data from nested 'user' object const userData = profile.data.user || profile.data; + console.log('[API] getStoredUser: userData =', JSON.stringify(userData)); return { user_id: userData.id, @@ -460,6 +461,51 @@ class ApiService { } } + // Update user profile avatar on WellNuo API + async updateProfileAvatar(imageUri: string | null): Promise> { + 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) async getBeneficiaries(): Promise> { const token = await this.getToken(); @@ -619,6 +665,7 @@ class ApiService { 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 }; @@ -675,6 +722,8 @@ class ApiService { 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 }; @@ -776,10 +825,9 @@ class ApiService { let base64Image: string | null = null; if (imageUri) { - // Read file as base64 using stable FileSystem API - const base64Data = await FileSystem.readAsStringAsync(imageUri, { - encoding: FileSystem.EncodingType.Base64, - }); + // 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';