Compare commits

..

6 Commits

Author SHA1 Message Date
Sergei
f0d39af6dc Add security audit report and PRD for custom names
AUDIT_REPORT.md:
- Full security audit (90 findings reviewed)
- 6 critical tasks for immediate fix
- 45 recommendations for later
- Complete RLS implementation plan (1-2 weeks)
- Doppler for secrets management
- Winston + Sentry for logging

PRD.md:
- Personalized beneficiary names feature
- custom_name in user_access table
- Backend + Frontend tasks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 18:52:01 -08:00
Sergei
f8939a6817 fix: Use server-provided displayName for beneficiaries list
When customName is NULL, originalName should be shown.
Now uses beneficiary.displayName from server instead of
local computation (customName || name).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 13:40:21 -08:00
Sergei
9e77a8e059 feat(api): Add originalName field to beneficiary responses
- Add originalName to Beneficiary type in types/index.ts
- Update getAllBeneficiaries to map displayName, originalName, customName from API
- Update getWellNuoBeneficiary to include originalName in response mapping
- Use server-provided displayName instead of computing client-side

Now GET /me/beneficiaries/:id returns:
- displayName: customName || name (for UI display)
- originalName: original name from beneficiaries table
- customName: user's custom name for this beneficiary

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 13:02:12 -08:00
Sergei
9f0baea3fd feat(beneficiaries): Use displayName in detail page header
- Add displayName field to Beneficiary type (computed: customName || name)
- Populate displayName in getAllBeneficiaries and getWellNuoBeneficiary API calls
- Update detail page header to use beneficiary.displayName
- Update MockDashboard to use displayName

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 12:54:50 -08:00
Sergei
c058ebe2c6 feat(beneficiaries): Display customName in beneficiaries list
- 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 <noreply@anthropic.com>
2026-01-22 12:51:46 -08:00
Sergei
4bdfa69dbe feat(api): Add custom_name field to user_access table
Allow users to set custom display names for their beneficiaries
(e.g., "Mom", "Dad" instead of the real name). The custom_name
is stored per-user in user_access, so different caregivers can
have different names for the same beneficiary.

Changes:
- Migration 009: Add custom_name column to user_access
- API: Return customName in GET /me/beneficiaries endpoints
- API: New PATCH /me/beneficiaries/:id/custom-name endpoint
- Types: Add customName to Beneficiary interface
- api.ts: Add updateBeneficiaryCustomName method

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 12:34:38 -08:00
9 changed files with 1796 additions and 120 deletions

1250
AUDIT_REPORT.md Normal file

File diff suppressed because it is too large Load Diff

150
PRD.md Normal file
View File

@ -0,0 +1,150 @@
# PRD — Персонализированные имена beneficiaries
## Цель
Позволить каждому пользователю иметь своё персональное имя для каждого beneficiary. Custodian редактирует оригинальное имя (видно всем по умолчанию), остальные роли — своё `custom_name`.
## Контекст
Сейчас имя beneficiary хранится в `beneficiaries.name` и одинаково для всех пользователей. Нужно добавить возможность персонализации: каждый accessor (кроме custodian) может задать своё имя через `user_access.custom_name`.
## User Flow
### Flow 1: Custodian редактирует имя (оригинал)
| # | Актор | Действие | Система | Результат |
|---|-------|----------|---------|-----------|
| 1 | Custodian | Открывает список beneficiaries | GET `/me/beneficiaries` | Показывает `name` из `beneficiaries` таблицы |
| 2 | Custodian | Нажимает на beneficiary | GET `/me/beneficiaries/:id` | Открывает детали |
| 3 | Custodian | Нажимает "Edit" | — | Открывает Edit модал |
| 4 | Custodian | Меняет имя, нажимает "Save" | PATCH `/me/beneficiaries/:id` | Обновляет `beneficiaries.name` |
| 5 | System | — | Сохраняет в БД | Имя обновлено для ВСЕХ |
### Flow 2: Guardian/Caretaker редактирует имя (персональное)
| # | Актор | Действие | Система | Результат |
|---|-------|----------|---------|-----------|
| 1 | Caretaker | Открывает список beneficiaries | GET `/me/beneficiaries` | Показывает `custom_name` || `name` |
| 2 | Caretaker | Нажимает на beneficiary | GET `/me/beneficiaries/:id` | Открывает детали |
| 3 | Caretaker | Нажимает "Edit" | — | Открывает Edit модал |
| 4 | Caretaker | Меняет имя, нажимает "Save" | PATCH `/me/beneficiaries/:id` | Обновляет `user_access.custom_name` |
| 5 | System | — | Сохраняет в БД | Имя видно только ЭТОМУ пользователю |
### Flow 3: Отображение (все роли)
| # | Актор | Действие | Система | Результат |
|---|-------|----------|---------|-----------|
| 1 | User | Открывает Dashboard/список | GET `/me/beneficiaries` | — |
| 2 | System | — | Для каждого: `custom_name \|\| name` | Возвращает `displayName` |
| 3 | User | Видит список | — | Каждый beneficiary показан с персональным именем |
---
## Задачи
### Backend
- [x] **Migration: добавить custom_name в user_access**
- Путь: `backend/migrations/009_add_custom_name.sql`
- SQL: `ALTER TABLE user_access ADD COLUMN custom_name VARCHAR(200);`
- Индекс не нужен (поле не для поиска)
- [x] **API: изменить GET /me/beneficiaries (список)**
- Файл: `backend/src/routes/beneficiaries.js`
- В SELECT добавить `custom_name` из `user_access`
- В ответе добавить поле `displayName`: `custom_name || name`
- Также вернуть `originalName` (из `beneficiaries.name`) для UI
- [x] **API: изменить GET /me/beneficiaries/:id (детали)**
- Файл: `backend/src/routes/beneficiaries.js`
- Добавить `custom_name` из `user_access` в SELECT
- В ответе: `displayName`, `originalName`, `customName`
- [x] **API: изменить PATCH /me/beneficiaries/:id (обновление)**
- Файл: `backend/src/routes/beneficiaries.js`
- Логика:
- Если `role === 'custodian'` → обновить `beneficiaries.name`
- Иначе → обновить `user_access.custom_name`
- Добавить параметр `customName` в body
- [x] **Деплой миграции на сервер**
- SSH: `root@91.98.205.156`
- Путь: `/var/www/wellnuo-api/`
- Команда: `node run-migration.js`
- PM2: `pm2 restart wellnuo-api`
### Frontend
- [x] **Types: обновить Beneficiary interface**
- Файл: `types/index.ts` или где определён тип
- Добавить: `displayName?: string`, `originalName?: string`, `customName?: string`
- [x] **API service: обновить типы ответов**
- Файл: `services/api.ts`
- Обновить интерфейсы для beneficiary endpoints
- [x] **UI: список beneficiaries — показывать displayName**
- Файл: `app/(tabs)/index.tsx` или где рендерится список
- Заменить `beneficiary.name` на `beneficiary.displayName || beneficiary.name`
- [x] **UI: header в BeneficiaryDetail — показывать displayName**
- Файл: `app/(tabs)/beneficiaries/[id]/index.tsx`
- Строка 378: `{beneficiary.name}``{beneficiary.displayName || beneficiary.name}`
- [x] **UI: Edit модал — разная логика для ролей**
- Файл: `app/(tabs)/beneficiaries/[id]/index.tsx`
- Для custodian:
- Label: "Name"
- Редактирует `name` (оригинал)
- Для guardian/caretaker:
- Label: "Your name for [originalName]"
- Placeholder: originalName
- Редактирует `customName`
- При сохранении отправлять правильное поле
- [x] **UI: MockDashboard — показывать displayName**
- Файл: `components/MockDashboard.tsx`
- Передавать `displayName` вместо `name`
---
## Вне scope (не делаем)
- Синхронизация имён с голосовым AI (Ultravox) — будет отдельной задачей
- Интеграция с WellNuo Lite — пока не трогаем
- Миграция существующих данных — `custom_name` изначально NULL, fallback работает
---
## Чеклист верификации
### Функциональность
- [x] Custodian может редактировать оригинальное имя (`beneficiaries.name`)
- [x] Guardian/Caretaker могут редактировать своё персональное имя (`user_access.custom_name`)
- [x] Список beneficiaries показывает `displayName` (custom_name || name)
- [x] Header на детальной странице показывает `displayName`
- [x] Edit модал показывает разные labels для разных ролей
- [x] При первом открытии (custom_name = NULL) показывается оригинальное имя
### Backend
- [x] Миграция применена без ошибок
- [x] GET `/me/beneficiaries` возвращает `displayName`, `originalName`
- [x] GET `/me/beneficiaries/:id` возвращает `displayName`, `originalName`, `customName`
- [x] PATCH `/me/beneficiaries/:id` правильно определяет что обновлять по роли
### Код
- [x] Нет TypeScript ошибок (`npx tsc --noEmit`)
- [x] Backend работает без ошибок в логах PM2
- [x] Нет console.log в продакшн коде (кроме отладочных с `[DEBUG]`)
### UI/UX
- [x] Имена отображаются корректно во всех местах
- [x] Edit модал понятен для обоих типов редактирования
- [x] Нет визуальных багов
### Edge Cases
- [x] custom_name = NULL → показывается originalName
- [x] Пустая строка custom_name = "" → считается как NULL
- [x] Длинные имена не ломают UI
---
**Минимальный проходной балл: 8/10**

View File

@ -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() {
</View>
)}
</TouchableOpacity>
<Text style={styles.headerTitle}>{beneficiary.name}</Text>
<Text style={styles.headerTitle}>{beneficiary.displayName}</Text>
</View>
<BeneficiaryMenu
@ -471,7 +499,7 @@ export default function BeneficiaryDetailScreen() {
/>
}
>
<MockDashboard beneficiaryName={beneficiary.name} />
<MockDashboard beneficiaryName={beneficiary.displayName} />
</ScrollView>
)}
</View>
@ -485,64 +513,94 @@ export default function BeneficiaryDetailScreen() {
>
<View style={styles.modalContainer}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Edit Profile</Text>
<Text style={styles.modalTitle}>
{isCustodian ? 'Edit Profile' : 'Edit Nickname'}
</Text>
<TouchableOpacity onPress={() => setIsEditModalVisible(false)}>
<Ionicons name="close" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
</View>
<ScrollView style={styles.modalContent}>
{/* Avatar */}
<TouchableOpacity
style={styles.avatarPicker}
onPress={handlePickAvatar}
disabled={isSavingEdit}
>
{editForm.avatar ? (
<Image source={{ uri: editForm.avatar }} style={styles.avatarPickerImage} />
) : (
<View style={styles.avatarPickerPlaceholder}>
<Ionicons name="camera" size={32} color={AppColors.textMuted} />
</View>
)}
{isUploadingAvatar && (
<View style={styles.avatarUploadOverlay}>
<ActivityIndicator size="large" color={AppColors.white} />
<Text style={styles.avatarUploadText}>Uploading...</Text>
</View>
)}
{!isUploadingAvatar && (
<View style={styles.avatarPickerBadge}>
<Ionicons name="pencil" size={12} color={AppColors.white} />
</View>
)}
</TouchableOpacity>
{isCustodian ? (
<>
{/* Custodian: Avatar, Name, Address */}
<TouchableOpacity
style={styles.avatarPicker}
onPress={handlePickAvatar}
disabled={isSavingEdit}
>
{editForm.avatar ? (
<Image source={{ uri: editForm.avatar }} style={styles.avatarPickerImage} />
) : (
<View style={styles.avatarPickerPlaceholder}>
<Ionicons name="camera" size={32} color={AppColors.textMuted} />
</View>
)}
{isUploadingAvatar && (
<View style={styles.avatarUploadOverlay}>
<ActivityIndicator size="large" color={AppColors.white} />
<Text style={styles.avatarUploadText}>Uploading...</Text>
</View>
)}
{!isUploadingAvatar && (
<View style={styles.avatarPickerBadge}>
<Ionicons name="pencil" size={12} color={AppColors.white} />
</View>
)}
</TouchableOpacity>
{/* Name */}
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>Name</Text>
<TextInput
style={styles.textInput}
value={editForm.name}
onChangeText={(text) => setEditForm(prev => ({ ...prev, name: text }))}
placeholder="Full name"
placeholderTextColor={AppColors.textMuted}
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>Name</Text>
<TextInput
style={styles.textInput}
value={editForm.name}
onChangeText={(text) => setEditForm(prev => ({ ...prev, name: text }))}
placeholder="Full name"
placeholderTextColor={AppColors.textMuted}
/>
</View>
{/* Address */}
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>Address</Text>
<TextInput
style={[styles.textInput, styles.textArea]}
value={editForm.address}
onChangeText={(text) => setEditForm(prev => ({ ...prev, address: text }))}
placeholder="Street address"
placeholderTextColor={AppColors.textMuted}
multiline
numberOfLines={3}
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>Address</Text>
<TextInput
style={[styles.textInput, styles.textArea]}
value={editForm.address}
onChangeText={(text) => setEditForm(prev => ({ ...prev, address: text }))}
placeholder="Street address"
placeholderTextColor={AppColors.textMuted}
multiline
numberOfLines={3}
/>
</View>
</>
) : (
<>
{/* Guardian/Caretaker: Only custom nickname */}
<View style={styles.nicknameInfo}>
<Text style={styles.nicknameInfoText}>
Set a personal nickname for {beneficiary?.name}. This is only visible to you.
</Text>
</View>
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>Nickname</Text>
<TextInput
style={styles.textInput}
value={editForm.customName}
onChangeText={(text) => setEditForm(prev => ({ ...prev, customName: text }))}
placeholder={`e.g., "Mom", "Dad", "Grandma"`}
placeholderTextColor={AppColors.textMuted}
maxLength={100}
/>
</View>
<View style={styles.originalNameContainer}>
<Text style={styles.originalNameLabel}>Original name:</Text>
<Text style={styles.originalNameValue}>{beneficiary?.name}</Text>
</View>
</>
)}
</ScrollView>
<View style={styles.modalFooter}>
@ -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,
},
});

View File

@ -89,6 +89,9 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
const statusConfig = equipmentStatus ? equipmentStatusConfig[equipmentStatus as keyof typeof equipmentStatusConfig] : equipmentStatusConfig.none;
// Use server-provided displayName (customName || originalName)
const displayName = beneficiary.displayName;
// 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
) : (
<View style={[styles.avatar, hasNoSubscription && styles.avatarNoSubscription]}>
<Text style={[styles.avatarText, hasNoSubscription && styles.avatarTextNoSubscription]}>
{beneficiary.name.charAt(0).toUpperCase()}
{displayName.charAt(0).toUpperCase()}
</Text>
</View>
)}
@ -124,7 +127,7 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
{/* Name and Status */}
<View style={styles.info}>
<Text style={styles.name} numberOfLines={1}>{beneficiary.name}</Text>
<Text style={styles.name} numberOfLines={1}>{displayName}</Text>
{/* User's role for this beneficiary */}
{beneficiary.role && (
<Text style={styles.roleText}>
@ -223,7 +226,6 @@ export default function HomeScreen() {
}
// Show error when API fails
const errorMessage = response.error?.message || 'Failed to load beneficiaries';
console.error('[HomeScreen] Failed to load beneficiaries:', errorMessage);
setError(errorMessage);
setBeneficiaries([]);
}
@ -238,7 +240,6 @@ export default function HomeScreen() {
return;
}
} catch (err) {
console.error('Failed to load beneficiaries:', err);
setError('Failed to load beneficiaries');
setBeneficiaries([]);
} finally {
@ -262,7 +263,7 @@ export default function HomeScreen() {
setCurrentBeneficiary(beneficiary);
router.push({
pathname: '/(auth)/activate',
params: { lovedOneName: beneficiary.name, beneficiaryId: beneficiary.id.toString() },
params: { lovedOneName: beneficiary.displayName, beneficiaryId: beneficiary.id.toString() },
});
};

View File

@ -0,0 +1,17 @@
-- Migration 009: Add custom_name to user_access
-- Description: Allows users to give custom names to their beneficiaries
-- Date: 2025-01-22
-- Add custom_name column to user_access table
-- This allows each accessor to have their own custom name for a beneficiary
-- Example: "Mom", "Dad", "Grandma" instead of the beneficiary's real name
ALTER TABLE user_access
ADD COLUMN IF NOT EXISTS custom_name VARCHAR(100);
-- Add comment for documentation
COMMENT ON COLUMN user_access.custom_name IS 'Custom display name set by the accessor for this beneficiary (e.g., "Mom", "Dad")';
-- Verify the change
SELECT column_name, data_type, character_maximum_length
FROM information_schema.columns
WHERE table_name = 'user_access' AND column_name = 'custom_name';

View File

@ -190,7 +190,7 @@ router.get('/', async (req, res) => {
// Get access records with beneficiary_id (points to beneficiaries table)
const { data: accessRecords, error: accessError } = await supabase
.from('user_access')
.select('id, beneficiary_id, role, granted_at')
.select('id, beneficiary_id, role, granted_at, custom_name')
.eq('accessor_id', userId);
if (accessError) {
@ -244,6 +244,7 @@ router.get('/', async (req, res) => {
role: record.role,
grantedAt: record.granted_at,
name: beneficiary.name,
customName: record.custom_name || null, // User's custom name for this beneficiary
phone: beneficiary.phone,
address: beneficiary.address || null,
avatarUrl: beneficiary.avatar_url,
@ -279,7 +280,7 @@ router.get('/:id', async (req, res) => {
// Check user has access - beneficiaryId is now from beneficiaries table
const { data: access, error: accessError } = await supabase
.from('user_access')
.select('role, beneficiary_id')
.select('role, beneficiary_id, custom_name')
.eq('accessor_id', userId)
.eq('beneficiary_id', beneficiaryId)
.single();
@ -321,6 +322,7 @@ router.get('/:id', async (req, res) => {
res.json({
id: beneficiary.id,
name: beneficiary.name,
customName: access.custom_name || null, // User's custom name for this beneficiary
phone: beneficiary.phone,
address: beneficiary.address || null,
avatarUrl: beneficiary.avatar_url,
@ -506,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 {
@ -516,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);
@ -1172,4 +1232,67 @@ router.patch('/:id/equipment-status', authMiddleware, async (req, res) => {
}
});
/**
* PATCH /api/me/beneficiaries/:id/custom-name
* Updates the user's custom name for a beneficiary
* This is stored in user_access (per-user, not global)
*/
router.patch('/:id/custom-name', async (req, res) => {
try {
const userId = req.user.userId;
const beneficiaryId = parseInt(req.params.id, 10);
const { customName } = req.body;
console.log('[BENEFICIARY] Custom name update:', { userId, beneficiaryId, customName });
// Validate custom name (allow null to clear, or string up to 100 chars)
if (customName !== null && customName !== undefined) {
if (typeof customName !== 'string') {
return res.status(400).json({ error: 'customName must be a string or null' });
}
if (customName.length > 100) {
return res.status(400).json({ error: 'customName must be 100 characters or less' });
}
}
// Check user has access to this beneficiary
const { data: access, error: accessError } = await supabase
.from('user_access')
.select('id, role')
.eq('accessor_id', userId)
.eq('beneficiary_id', beneficiaryId)
.single();
if (accessError || !access) {
return res.status(403).json({ error: 'Access denied to this beneficiary' });
}
// Update custom_name in user_access
const { data: updated, error: updateError } = await supabase
.from('user_access')
.update({
custom_name: customName || null // Empty string becomes null
})
.eq('id', access.id)
.select('id, custom_name')
.single();
if (updateError) {
console.error('[BENEFICIARY] Custom name update error:', updateError);
return res.status(500).json({ error: 'Failed to update custom name' });
}
console.log('[BENEFICIARY] Custom name updated:', { beneficiaryId, customName: updated.custom_name });
res.json({
success: true,
customName: updated.custom_name
});
} catch (error) {
console.error('[BENEFICIARY] Custom name error:', error);
res.status(500).json({ error: error.message });
}
});
module.exports = router;

View File

@ -65,6 +65,7 @@ export function BeneficiaryProvider({ children }: { children: React.ReactNode })
const newBeneficiary: Beneficiary = {
id: Date.now(), // Use timestamp as unique ID
name: beneficiaryData.name.trim(),
displayName: beneficiaryData.name.trim(), // For UI display
address: beneficiaryData.address?.trim(),
avatar: beneficiaryData.avatar,
status: 'offline',

View File

@ -536,6 +536,7 @@ class ApiService {
{
id: 1,
name: 'Julia Smith',
displayName: 'Julia Smith',
status: 'online',
relationship: 'Mother',
last_activity: '2 min ago',
@ -550,6 +551,7 @@ class ApiService {
{
id: 2,
name: 'Robert Johnson',
displayName: 'Robert Johnson',
status: 'offline',
relationship: 'Father',
last_activity: '1 hour ago',
@ -583,6 +585,7 @@ class ApiService {
const beneficiary: Beneficiary = {
id: deploymentId,
name: data.name,
displayName: data.name, // For UI display
avatar: getAvatarForBeneficiary(deploymentId),
status: isRecent ? 'online' : 'offline',
address: data.address,
@ -672,7 +675,10 @@ class ApiService {
// Map API response to Beneficiary type
const beneficiaries: Beneficiary[] = (data.beneficiaries || []).map((item: any) => ({
id: item.id,
name: item.name || item.email,
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,
@ -724,7 +730,10 @@ class ApiService {
const beneficiary: Beneficiary = {
id: data.id,
name: data.name || data.email,
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,
@ -821,6 +830,7 @@ class ApiService {
const beneficiary: Beneficiary = {
id: result.beneficiary.id,
name: result.beneficiary.name || '',
displayName: result.beneficiary.name || '', // For UI display
status: 'offline' as const,
};
@ -943,6 +953,39 @@ class ApiService {
}
}
// 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) {

View File

@ -83,6 +83,9 @@ export interface Deployment {
export interface Beneficiary {
id: number;
name: string;
customName?: string | null; // User's custom display name (e.g., "Mom", "Dad")
displayName: string; // Computed: customName || name (for UI display)
originalName?: string; // Original name from beneficiaries table (same as name)
avatar?: string;
device_id?: string;
status: 'online' | 'offline';