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,
ActivityIndicator,
RefreshControl,
Alert,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';

View File

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

View File

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

View File

@ -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() {
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Access</Text>
<View style={styles.placeholder} />
<BeneficiaryMenu
beneficiaryId={id || ''}
userRole={currentBeneficiary?.role}
currentPage="access"
/>
</View>
<KeyboardAvoidingView
@ -459,9 +464,6 @@ const styles = StyleSheet.create({
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
placeholder: {
width: 32,
},
content: {
flex: 1,
},

View File

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

View File

@ -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 */}
<View style={styles.info}>
<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) */}
{isAwaitingEquipment && statusConfig && (
<View style={[styles.statusBadge, { backgroundColor: statusConfig.bgColor }]}>
@ -138,12 +140,12 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
</Text>
</View>
)}
{/* Monitoring badge (active/demo) */}
{isMonitoring && statusConfig && (
<View style={[styles.statusBadge, { backgroundColor: statusConfig.bgColor }]}>
<Ionicons name={statusConfig.icon} size={14} color={statusConfig.color} />
<Text style={[styles.statusText, { color: statusConfig.color }]}>
{statusConfig.label}
{/* Monitoring badge (equipment works + subscription active) */}
{isMonitoring && (
<View style={[styles.statusBadge, { backgroundColor: equipmentStatusConfig.active.bgColor }]}>
<Ionicons name={equipmentStatusConfig.active.icon} size={14} color={equipmentStatusConfig.active.color} />
<Text style={[styles.statusText, { color: equipmentStatusConfig.active.color }]}>
{equipmentStatusConfig.active.label}
</Text>
</View>
)}
@ -154,6 +156,15 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
<Text style={styles.noSubscriptionText}>No subscription</Text>
</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>
{/* 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',

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' }));
// 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 ============

View File

@ -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;

View File

@ -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<UserRole, MenuItemId[]> = {
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 (

View File

@ -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<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)
async getBeneficiaries(): Promise<ApiResponse<{ beneficiaries: Beneficiary[] }>> {
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';