From c058ebe2c60b90210c59e81c3e5d1ae533277e6f Mon Sep 17 00:00:00 2001 From: Sergei Date: Thu, 22 Jan 2026 12:51:46 -0800 Subject: [PATCH] feat(beneficiaries): Display customName in beneficiaries list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add displayName (customName || name) to BeneficiaryCard component - Update header and MockDashboard to show customName when set - Add custom name editing for non-custodian users (guardian/caretaker) - Backend PATCH endpoint now supports customName updates via user_access table 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/(tabs)/beneficiaries/[id]/index.tsx | 238 ++++++++++++++++-------- app/(tabs)/index.tsx | 10 +- backend/src/routes/beneficiaries.js | 130 +++++++++---- 3 files changed, 264 insertions(+), 114 deletions(-) diff --git a/app/(tabs)/beneficiaries/[id]/index.tsx b/app/(tabs)/beneficiaries/[id]/index.tsx index c6a12c5..c19b9d0 100644 --- a/app/(tabs)/beneficiaries/[id]/index.tsx +++ b/app/(tabs)/beneficiaries/[id]/index.tsx @@ -73,7 +73,12 @@ export default function BeneficiaryDetailScreen() { // Edit modal state const [isEditModalVisible, setIsEditModalVisible] = useState(false); - const [editForm, setEditForm] = useState({ name: '', address: '', avatar: undefined as string | undefined }); + const [editForm, setEditForm] = useState({ + name: '', + address: '', + avatar: undefined as string | undefined, + customName: '' // For non-custodian users + }); const [isSavingEdit, setIsSavingEdit] = useState(false); const [isUploadingAvatar, setIsUploadingAvatar] = useState(false); @@ -215,11 +220,15 @@ export default function BeneficiaryDetailScreen() { name: beneficiary.name || '', address: beneficiary.address || '', avatar: beneficiary.avatar, + customName: beneficiary.customName || '', }); setIsEditModalVisible(true); } }; + // Check if user is custodian (can edit all fields) + const isCustodian = beneficiary?.role === 'custodian'; + const handlePickAvatar = async () => { const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (status !== 'granted') { @@ -240,8 +249,8 @@ export default function BeneficiaryDetailScreen() { }; const handleSaveEdit = async () => { - if (!editForm.name.trim() || !id) { - toast.error('Error', 'Name is required'); + if (!id) { + toast.error('Error', 'Invalid beneficiary'); return; } @@ -249,32 +258,51 @@ export default function BeneficiaryDetailScreen() { setIsSavingEdit(true); try { - // Update basic info - const response = await api.updateWellNuoBeneficiary(beneficiaryId, { - name: editForm.name.trim(), - address: editForm.address.trim() || undefined, - }); + if (isCustodian) { + // Custodian: update name, address in beneficiaries table + if (!editForm.name.trim()) { + toast.error('Error', 'Name is required'); + setIsSavingEdit(false); + return; + } - if (!response.ok) { - toast.error('Error', response.error?.message || 'Failed to save changes.'); - setIsSavingEdit(false); - return; - } + const response = await api.updateWellNuoBeneficiary(beneficiaryId, { + name: editForm.name.trim(), + address: editForm.address.trim() || undefined, + }); - // Upload avatar if changed (new local file URI) - if (editForm.avatar && editForm.avatar.startsWith('file://')) { - setIsUploadingAvatar(true); - const avatarResult = await api.updateBeneficiaryAvatar(beneficiaryId, editForm.avatar); - setIsUploadingAvatar(false); - if (!avatarResult.ok) { - console.warn('[BeneficiaryDetail] Failed to upload avatar:', avatarResult.error?.message); - // Show info but don't fail the whole operation - toast.info('Note', 'Profile saved but avatar upload failed'); + if (!response.ok) { + toast.error('Error', response.error?.message || 'Failed to save changes.'); + setIsSavingEdit(false); + return; + } + + // Upload avatar if changed (new local file URI) + if (editForm.avatar && editForm.avatar.startsWith('file://')) { + setIsUploadingAvatar(true); + const avatarResult = await api.updateBeneficiaryAvatar(beneficiaryId, editForm.avatar); + setIsUploadingAvatar(false); + if (!avatarResult.ok) { + console.warn('[BeneficiaryDetail] Failed to upload avatar:', avatarResult.error?.message); + toast.info('Note', 'Profile saved but avatar upload failed'); + } + } + } else { + // Guardian/Caretaker: update only customName in user_access table + const response = await api.updateBeneficiaryCustomName( + beneficiaryId, + editForm.customName.trim() || null + ); + + if (!response.ok) { + toast.error('Error', response.error?.message || 'Failed to save nickname.'); + setIsSavingEdit(false); + return; } } setIsEditModalVisible(false); - toast.success('Saved', 'Profile updated successfully'); + toast.success('Saved', isCustodian ? 'Profile updated successfully' : 'Nickname saved'); loadBeneficiary(false); } catch (err) { toast.error('Error', 'Failed to save changes.'); @@ -375,7 +403,7 @@ export default function BeneficiaryDetailScreen() { )} - {beneficiary.name} + {beneficiary.customName || beneficiary.name} } > - + )} @@ -485,64 +513,94 @@ export default function BeneficiaryDetailScreen() { > - Edit Profile + + {isCustodian ? 'Edit Profile' : 'Edit Nickname'} + setIsEditModalVisible(false)}> - {/* Avatar */} - - {editForm.avatar ? ( - - ) : ( - - - - )} - {isUploadingAvatar && ( - - - Uploading... - - )} - {!isUploadingAvatar && ( - - - - )} - + {isCustodian ? ( + <> + {/* Custodian: Avatar, Name, Address */} + + {editForm.avatar ? ( + + ) : ( + + + + )} + {isUploadingAvatar && ( + + + Uploading... + + )} + {!isUploadingAvatar && ( + + + + )} + - {/* Name */} - - Name - setEditForm(prev => ({ ...prev, name: text }))} - placeholder="Full name" - placeholderTextColor={AppColors.textMuted} - /> - + + Name + setEditForm(prev => ({ ...prev, name: text }))} + placeholder="Full name" + placeholderTextColor={AppColors.textMuted} + /> + - {/* Address */} - - Address - setEditForm(prev => ({ ...prev, address: text }))} - placeholder="Street address" - placeholderTextColor={AppColors.textMuted} - multiline - numberOfLines={3} - /> - + + Address + setEditForm(prev => ({ ...prev, address: text }))} + placeholder="Street address" + placeholderTextColor={AppColors.textMuted} + multiline + numberOfLines={3} + /> + + + ) : ( + <> + {/* Guardian/Caretaker: Only custom nickname */} + + + Set a personal nickname for {beneficiary?.name}. This is only visible to you. + + + + + Nickname + setEditForm(prev => ({ ...prev, customName: text }))} + placeholder={`e.g., "Mom", "Dad", "Grandma"`} + placeholderTextColor={AppColors.textMuted} + maxLength={100} + /> + + + + Original name: + {beneficiary?.name} + + + )} @@ -819,4 +877,34 @@ const styles = StyleSheet.create({ buttonDisabled: { opacity: 0.6, }, + // Non-custodian edit modal styles + nicknameInfo: { + backgroundColor: AppColors.surfaceSecondary, + padding: Spacing.md, + borderRadius: BorderRadius.md, + marginBottom: Spacing.lg, + }, + nicknameInfoText: { + fontSize: FontSizes.sm, + color: AppColors.textSecondary, + lineHeight: 20, + }, + originalNameContainer: { + flexDirection: 'row', + alignItems: 'center', + marginTop: Spacing.sm, + paddingTop: Spacing.md, + borderTopWidth: 1, + borderTopColor: AppColors.border, + }, + originalNameLabel: { + fontSize: FontSizes.sm, + color: AppColors.textMuted, + marginRight: Spacing.xs, + }, + originalNameValue: { + fontSize: FontSizes.sm, + color: AppColors.textSecondary, + fontWeight: FontWeights.medium, + }, }); diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 9712bbe..29a6216 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -89,6 +89,9 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr const statusConfig = equipmentStatus ? equipmentStatusConfig[equipmentStatus as keyof typeof equipmentStatusConfig] : equipmentStatusConfig.none; + // Display name: customName (e.g., "Mom") if set, otherwise full name + const displayName = beneficiary.customName || beneficiary.name; + // Check if avatar is valid (not empty, null, or placeholder) const hasValidAvatar = beneficiary.avatar && beneficiary.avatar.trim() !== '' && @@ -116,7 +119,7 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr ) : ( - {beneficiary.name.charAt(0).toUpperCase()} + {displayName.charAt(0).toUpperCase()} )} @@ -124,7 +127,7 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr {/* Name and Status */} - {beneficiary.name} + {displayName} {/* User's role for this beneficiary */} {beneficiary.role && ( @@ -260,9 +263,10 @@ export default function HomeScreen() { const handleActivate = (beneficiary: Beneficiary) => { setCurrentBeneficiary(beneficiary); + const lovedOneName = beneficiary.customName || beneficiary.name; router.push({ pathname: '/(auth)/activate', - params: { lovedOneName: beneficiary.name, beneficiaryId: beneficiary.id.toString() }, + params: { lovedOneName, beneficiaryId: beneficiary.id.toString() }, }); }; diff --git a/backend/src/routes/beneficiaries.js b/backend/src/routes/beneficiaries.js index 8444ad2..0481324 100644 --- a/backend/src/routes/beneficiaries.js +++ b/backend/src/routes/beneficiaries.js @@ -508,8 +508,9 @@ router.post('/', async (req, res) => { /** * PATCH /api/me/beneficiaries/:id - * Updates beneficiary info (requires custodian or guardian role) - * Now uses the proper beneficiaries table (not users) + * Updates beneficiary info + * - Custodian: can update name, phone, address in beneficiaries table + * - Guardian/Caretaker: can only update customName in user_access table */ router.patch('/:id', async (req, res) => { try { @@ -518,53 +519,110 @@ router.patch('/:id', async (req, res) => { console.log('[BENEFICIARY PATCH] Request:', { userId, beneficiaryId, body: req.body }); - // Check user has custodian or guardian access - using beneficiary_id + // Check user has access - using beneficiary_id const { data: access, error: accessError } = await supabase .from('user_access') - .select('role') + .select('id, role') .eq('accessor_id', userId) .eq('beneficiary_id', beneficiaryId) .single(); - if (accessError || !access || !['custodian', 'guardian'].includes(access.role)) { - return res.status(403).json({ error: 'Only custodian or guardian can update beneficiary info' }); + if (accessError || !access) { + return res.status(403).json({ error: 'Access denied to this beneficiary' }); } - const { name, phone, address } = req.body; + const { name, phone, address, customName } = req.body; + const isCustodian = access.role === 'custodian'; - const updateData = { - updated_at: new Date().toISOString() - }; + // Custodian can update beneficiary data (name, phone, address) + if (isCustodian) { + const updateData = { + updated_at: new Date().toISOString() + }; - if (name !== undefined) updateData.name = name; - if (phone !== undefined) updateData.phone = phone; - if (address !== undefined) updateData.address = address; + if (name !== undefined) updateData.name = name; + if (phone !== undefined) updateData.phone = phone; + if (address !== undefined) updateData.address = address; - // Update in beneficiaries table - const { data: beneficiary, error } = await supabase - .from('beneficiaries') - .update(updateData) - .eq('id', beneficiaryId) - .select() - .single(); + // Update in beneficiaries table + const { data: beneficiary, error } = await supabase + .from('beneficiaries') + .update(updateData) + .eq('id', beneficiaryId) + .select() + .single(); - if (error) { - console.error('[BENEFICIARY PATCH] Supabase error:', error); - return res.status(500).json({ error: 'Failed to update beneficiary' }); - } - - console.log('[BENEFICIARY PATCH] Success:', { id: beneficiary.id, name: beneficiary.name, address: beneficiary.address }); - - res.json({ - success: true, - beneficiary: { - id: beneficiary.id, - name: beneficiary.name, - phone: beneficiary.phone, - address: beneficiary.address || null, - avatarUrl: beneficiary.avatar_url + if (error) { + console.error('[BENEFICIARY PATCH] Supabase error:', error); + return res.status(500).json({ error: 'Failed to update beneficiary' }); } - }); + + console.log('[BENEFICIARY PATCH] Custodian updated:', { id: beneficiary.id, name: beneficiary.name, address: beneficiary.address }); + + res.json({ + success: true, + beneficiary: { + id: beneficiary.id, + name: beneficiary.name, + displayName: beneficiary.name, // For custodian, displayName = name + originalName: beneficiary.name, + phone: beneficiary.phone, + address: beneficiary.address || null, + avatarUrl: beneficiary.avatar_url + } + }); + } else { + // Guardian/Caretaker can only update their custom_name + if (customName === undefined) { + return res.status(400).json({ error: 'customName is required for non-custodian roles' }); + } + + // Validate custom name + if (customName !== null && typeof customName !== 'string') { + return res.status(400).json({ error: 'customName must be a string or null' }); + } + if (customName && customName.length > 100) { + return res.status(400).json({ error: 'customName must be 100 characters or less' }); + } + + // Update custom_name in user_access + const { error: updateError } = await supabase + .from('user_access') + .update({ + custom_name: customName || null // Empty string becomes null + }) + .eq('id', access.id); + + if (updateError) { + console.error('[BENEFICIARY PATCH] Custom name update error:', updateError); + return res.status(500).json({ error: 'Failed to update custom name' }); + } + + // Get beneficiary data for response + const { data: beneficiary } = await supabase + .from('beneficiaries') + .select('id, name, phone, address, avatar_url') + .eq('id', beneficiaryId) + .single(); + + const displayName = customName || beneficiary?.name || null; + + console.log('[BENEFICIARY PATCH] Custom name updated:', { beneficiaryId, customName, displayName }); + + res.json({ + success: true, + beneficiary: { + id: beneficiaryId, + name: beneficiary?.name || null, + displayName: displayName, + originalName: beneficiary?.name || null, + customName: customName || null, + phone: beneficiary?.phone || null, + address: beneficiary?.address || null, + avatarUrl: beneficiary?.avatar_url || null + } + }); + } } catch (error) { console.error('[BENEFICIARY PATCH] Error:', error);