Compare commits
6 Commits
f94121b848
...
f0d39af6dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0d39af6dc | ||
|
|
f8939a6817 | ||
|
|
9e77a8e059 | ||
|
|
9f0baea3fd | ||
|
|
c058ebe2c6 | ||
|
|
4bdfa69dbe |
1250
AUDIT_REPORT.md
Normal file
1250
AUDIT_REPORT.md
Normal file
File diff suppressed because it is too large
Load Diff
150
PRD.md
Normal file
150
PRD.md
Normal 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**
|
||||||
@ -73,7 +73,12 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
|
|
||||||
// Edit modal state
|
// Edit modal state
|
||||||
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
|
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 [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
||||||
|
|
||||||
@ -215,11 +220,15 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
name: beneficiary.name || '',
|
name: beneficiary.name || '',
|
||||||
address: beneficiary.address || '',
|
address: beneficiary.address || '',
|
||||||
avatar: beneficiary.avatar,
|
avatar: beneficiary.avatar,
|
||||||
|
customName: beneficiary.customName || '',
|
||||||
});
|
});
|
||||||
setIsEditModalVisible(true);
|
setIsEditModalVisible(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if user is custodian (can edit all fields)
|
||||||
|
const isCustodian = beneficiary?.role === 'custodian';
|
||||||
|
|
||||||
const handlePickAvatar = async () => {
|
const handlePickAvatar = async () => {
|
||||||
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
if (status !== 'granted') {
|
if (status !== 'granted') {
|
||||||
@ -240,8 +249,8 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveEdit = async () => {
|
const handleSaveEdit = async () => {
|
||||||
if (!editForm.name.trim() || !id) {
|
if (!id) {
|
||||||
toast.error('Error', 'Name is required');
|
toast.error('Error', 'Invalid beneficiary');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -249,7 +258,14 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
setIsSavingEdit(true);
|
setIsSavingEdit(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Update basic info
|
if (isCustodian) {
|
||||||
|
// Custodian: update name, address in beneficiaries table
|
||||||
|
if (!editForm.name.trim()) {
|
||||||
|
toast.error('Error', 'Name is required');
|
||||||
|
setIsSavingEdit(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await api.updateWellNuoBeneficiary(beneficiaryId, {
|
const response = await api.updateWellNuoBeneficiary(beneficiaryId, {
|
||||||
name: editForm.name.trim(),
|
name: editForm.name.trim(),
|
||||||
address: editForm.address.trim() || undefined,
|
address: editForm.address.trim() || undefined,
|
||||||
@ -268,13 +284,25 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
setIsUploadingAvatar(false);
|
setIsUploadingAvatar(false);
|
||||||
if (!avatarResult.ok) {
|
if (!avatarResult.ok) {
|
||||||
console.warn('[BeneficiaryDetail] Failed to upload avatar:', avatarResult.error?.message);
|
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');
|
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);
|
setIsEditModalVisible(false);
|
||||||
toast.success('Saved', 'Profile updated successfully');
|
toast.success('Saved', isCustodian ? 'Profile updated successfully' : 'Nickname saved');
|
||||||
loadBeneficiary(false);
|
loadBeneficiary(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error('Error', 'Failed to save changes.');
|
toast.error('Error', 'Failed to save changes.');
|
||||||
@ -375,7 +403,7 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={styles.headerTitle}>{beneficiary.name}</Text>
|
<Text style={styles.headerTitle}>{beneficiary.displayName}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<BeneficiaryMenu
|
<BeneficiaryMenu
|
||||||
@ -471,7 +499,7 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<MockDashboard beneficiaryName={beneficiary.name} />
|
<MockDashboard beneficiaryName={beneficiary.displayName} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@ -485,14 +513,18 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
>
|
>
|
||||||
<View style={styles.modalContainer}>
|
<View style={styles.modalContainer}>
|
||||||
<View style={styles.modalHeader}>
|
<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)}>
|
<TouchableOpacity onPress={() => setIsEditModalVisible(false)}>
|
||||||
<Ionicons name="close" size={24} color={AppColors.textPrimary} />
|
<Ionicons name="close" size={24} color={AppColors.textPrimary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView style={styles.modalContent}>
|
<ScrollView style={styles.modalContent}>
|
||||||
{/* Avatar */}
|
{isCustodian ? (
|
||||||
|
<>
|
||||||
|
{/* Custodian: Avatar, Name, Address */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.avatarPicker}
|
style={styles.avatarPicker}
|
||||||
onPress={handlePickAvatar}
|
onPress={handlePickAvatar}
|
||||||
@ -518,7 +550,6 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Name */}
|
|
||||||
<View style={styles.inputGroup}>
|
<View style={styles.inputGroup}>
|
||||||
<Text style={styles.inputLabel}>Name</Text>
|
<Text style={styles.inputLabel}>Name</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
@ -530,7 +561,6 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Address */}
|
|
||||||
<View style={styles.inputGroup}>
|
<View style={styles.inputGroup}>
|
||||||
<Text style={styles.inputLabel}>Address</Text>
|
<Text style={styles.inputLabel}>Address</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
@ -543,6 +573,34 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
numberOfLines={3}
|
numberOfLines={3}
|
||||||
/>
|
/>
|
||||||
</View>
|
</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>
|
</ScrollView>
|
||||||
|
|
||||||
<View style={styles.modalFooter}>
|
<View style={styles.modalFooter}>
|
||||||
@ -819,4 +877,34 @@ const styles = StyleSheet.create({
|
|||||||
buttonDisabled: {
|
buttonDisabled: {
|
||||||
opacity: 0.6,
|
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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -89,6 +89,9 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
|
|||||||
|
|
||||||
const statusConfig = equipmentStatus ? equipmentStatusConfig[equipmentStatus as keyof typeof equipmentStatusConfig] : equipmentStatusConfig.none;
|
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)
|
// Check if avatar is valid (not empty, null, or placeholder)
|
||||||
const hasValidAvatar = beneficiary.avatar &&
|
const hasValidAvatar = beneficiary.avatar &&
|
||||||
beneficiary.avatar.trim() !== '' &&
|
beneficiary.avatar.trim() !== '' &&
|
||||||
@ -116,7 +119,7 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
|
|||||||
) : (
|
) : (
|
||||||
<View style={[styles.avatar, hasNoSubscription && styles.avatarNoSubscription]}>
|
<View style={[styles.avatar, hasNoSubscription && styles.avatarNoSubscription]}>
|
||||||
<Text style={[styles.avatarText, hasNoSubscription && styles.avatarTextNoSubscription]}>
|
<Text style={[styles.avatarText, hasNoSubscription && styles.avatarTextNoSubscription]}>
|
||||||
{beneficiary.name.charAt(0).toUpperCase()}
|
{displayName.charAt(0).toUpperCase()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@ -124,7 +127,7 @@ 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}>{displayName}</Text>
|
||||||
{/* User's role for this beneficiary */}
|
{/* User's role for this beneficiary */}
|
||||||
{beneficiary.role && (
|
{beneficiary.role && (
|
||||||
<Text style={styles.roleText}>
|
<Text style={styles.roleText}>
|
||||||
@ -223,7 +226,6 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
// Show error when API fails
|
// Show error when API fails
|
||||||
const errorMessage = response.error?.message || 'Failed to load beneficiaries';
|
const errorMessage = response.error?.message || 'Failed to load beneficiaries';
|
||||||
console.error('[HomeScreen] Failed to load beneficiaries:', errorMessage);
|
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
setBeneficiaries([]);
|
setBeneficiaries([]);
|
||||||
}
|
}
|
||||||
@ -238,7 +240,6 @@ export default function HomeScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load beneficiaries:', err);
|
|
||||||
setError('Failed to load beneficiaries');
|
setError('Failed to load beneficiaries');
|
||||||
setBeneficiaries([]);
|
setBeneficiaries([]);
|
||||||
} finally {
|
} finally {
|
||||||
@ -262,7 +263,7 @@ export default function HomeScreen() {
|
|||||||
setCurrentBeneficiary(beneficiary);
|
setCurrentBeneficiary(beneficiary);
|
||||||
router.push({
|
router.push({
|
||||||
pathname: '/(auth)/activate',
|
pathname: '/(auth)/activate',
|
||||||
params: { lovedOneName: beneficiary.name, beneficiaryId: beneficiary.id.toString() },
|
params: { lovedOneName: beneficiary.displayName, beneficiaryId: beneficiary.id.toString() },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
17
backend/migrations/009_add_custom_name.sql
Normal file
17
backend/migrations/009_add_custom_name.sql
Normal 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';
|
||||||
@ -190,7 +190,7 @@ router.get('/', async (req, res) => {
|
|||||||
// Get access records with beneficiary_id (points to beneficiaries table)
|
// Get access records with beneficiary_id (points to beneficiaries table)
|
||||||
const { data: accessRecords, error: accessError } = await supabase
|
const { data: accessRecords, error: accessError } = await supabase
|
||||||
.from('user_access')
|
.from('user_access')
|
||||||
.select('id, beneficiary_id, role, granted_at')
|
.select('id, beneficiary_id, role, granted_at, custom_name')
|
||||||
.eq('accessor_id', userId);
|
.eq('accessor_id', userId);
|
||||||
|
|
||||||
if (accessError) {
|
if (accessError) {
|
||||||
@ -244,6 +244,7 @@ router.get('/', async (req, res) => {
|
|||||||
role: record.role,
|
role: record.role,
|
||||||
grantedAt: record.granted_at,
|
grantedAt: record.granted_at,
|
||||||
name: beneficiary.name,
|
name: beneficiary.name,
|
||||||
|
customName: record.custom_name || null, // User's custom name for this beneficiary
|
||||||
phone: beneficiary.phone,
|
phone: beneficiary.phone,
|
||||||
address: beneficiary.address || null,
|
address: beneficiary.address || null,
|
||||||
avatarUrl: beneficiary.avatar_url,
|
avatarUrl: beneficiary.avatar_url,
|
||||||
@ -279,7 +280,7 @@ router.get('/:id', async (req, res) => {
|
|||||||
// Check user has access - beneficiaryId is now from beneficiaries table
|
// Check user has access - beneficiaryId is now from beneficiaries table
|
||||||
const { data: access, error: accessError } = await supabase
|
const { data: access, error: accessError } = await supabase
|
||||||
.from('user_access')
|
.from('user_access')
|
||||||
.select('role, beneficiary_id')
|
.select('role, beneficiary_id, custom_name')
|
||||||
.eq('accessor_id', userId)
|
.eq('accessor_id', userId)
|
||||||
.eq('beneficiary_id', beneficiaryId)
|
.eq('beneficiary_id', beneficiaryId)
|
||||||
.single();
|
.single();
|
||||||
@ -321,6 +322,7 @@ router.get('/:id', async (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
id: beneficiary.id,
|
id: beneficiary.id,
|
||||||
name: beneficiary.name,
|
name: beneficiary.name,
|
||||||
|
customName: access.custom_name || null, // User's custom name for this beneficiary
|
||||||
phone: beneficiary.phone,
|
phone: beneficiary.phone,
|
||||||
address: beneficiary.address || null,
|
address: beneficiary.address || null,
|
||||||
avatarUrl: beneficiary.avatar_url,
|
avatarUrl: beneficiary.avatar_url,
|
||||||
@ -506,8 +508,9 @@ router.post('/', async (req, res) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/me/beneficiaries/:id
|
* PATCH /api/me/beneficiaries/:id
|
||||||
* Updates beneficiary info (requires custodian or guardian role)
|
* Updates beneficiary info
|
||||||
* Now uses the proper beneficiaries table (not users)
|
* - 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) => {
|
router.patch('/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@ -516,20 +519,23 @@ router.patch('/:id', async (req, res) => {
|
|||||||
|
|
||||||
console.log('[BENEFICIARY PATCH] Request:', { userId, beneficiaryId, body: req.body });
|
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
|
const { data: access, error: accessError } = await supabase
|
||||||
.from('user_access')
|
.from('user_access')
|
||||||
.select('role')
|
.select('id, role')
|
||||||
.eq('accessor_id', userId)
|
.eq('accessor_id', userId)
|
||||||
.eq('beneficiary_id', beneficiaryId)
|
.eq('beneficiary_id', beneficiaryId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (accessError || !access || !['custodian', 'guardian'].includes(access.role)) {
|
if (accessError || !access) {
|
||||||
return res.status(403).json({ error: 'Only custodian or guardian can update beneficiary info' });
|
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';
|
||||||
|
|
||||||
|
// Custodian can update beneficiary data (name, phone, address)
|
||||||
|
if (isCustodian) {
|
||||||
const updateData = {
|
const updateData = {
|
||||||
updated_at: new Date().toISOString()
|
updated_at: new Date().toISOString()
|
||||||
};
|
};
|
||||||
@ -551,18 +557,72 @@ router.patch('/:id', async (req, res) => {
|
|||||||
return res.status(500).json({ error: 'Failed to update beneficiary' });
|
return res.status(500).json({ error: 'Failed to update beneficiary' });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[BENEFICIARY PATCH] Success:', { id: beneficiary.id, name: beneficiary.name, address: beneficiary.address });
|
console.log('[BENEFICIARY PATCH] Custodian updated:', { id: beneficiary.id, name: beneficiary.name, address: beneficiary.address });
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
beneficiary: {
|
beneficiary: {
|
||||||
id: beneficiary.id,
|
id: beneficiary.id,
|
||||||
name: beneficiary.name,
|
name: beneficiary.name,
|
||||||
|
displayName: beneficiary.name, // For custodian, displayName = name
|
||||||
|
originalName: beneficiary.name,
|
||||||
phone: beneficiary.phone,
|
phone: beneficiary.phone,
|
||||||
address: beneficiary.address || null,
|
address: beneficiary.address || null,
|
||||||
avatarUrl: beneficiary.avatar_url
|
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) {
|
} catch (error) {
|
||||||
console.error('[BENEFICIARY PATCH] Error:', 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;
|
module.exports = router;
|
||||||
|
|||||||
@ -65,6 +65,7 @@ export function BeneficiaryProvider({ children }: { children: React.ReactNode })
|
|||||||
const newBeneficiary: Beneficiary = {
|
const newBeneficiary: Beneficiary = {
|
||||||
id: Date.now(), // Use timestamp as unique ID
|
id: Date.now(), // Use timestamp as unique ID
|
||||||
name: beneficiaryData.name.trim(),
|
name: beneficiaryData.name.trim(),
|
||||||
|
displayName: beneficiaryData.name.trim(), // For UI display
|
||||||
address: beneficiaryData.address?.trim(),
|
address: beneficiaryData.address?.trim(),
|
||||||
avatar: beneficiaryData.avatar,
|
avatar: beneficiaryData.avatar,
|
||||||
status: 'offline',
|
status: 'offline',
|
||||||
|
|||||||
@ -536,6 +536,7 @@ class ApiService {
|
|||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Julia Smith',
|
name: 'Julia Smith',
|
||||||
|
displayName: 'Julia Smith',
|
||||||
status: 'online',
|
status: 'online',
|
||||||
relationship: 'Mother',
|
relationship: 'Mother',
|
||||||
last_activity: '2 min ago',
|
last_activity: '2 min ago',
|
||||||
@ -550,6 +551,7 @@ class ApiService {
|
|||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
name: 'Robert Johnson',
|
name: 'Robert Johnson',
|
||||||
|
displayName: 'Robert Johnson',
|
||||||
status: 'offline',
|
status: 'offline',
|
||||||
relationship: 'Father',
|
relationship: 'Father',
|
||||||
last_activity: '1 hour ago',
|
last_activity: '1 hour ago',
|
||||||
@ -583,6 +585,7 @@ class ApiService {
|
|||||||
const beneficiary: Beneficiary = {
|
const beneficiary: Beneficiary = {
|
||||||
id: deploymentId,
|
id: deploymentId,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
|
displayName: data.name, // For UI display
|
||||||
avatar: getAvatarForBeneficiary(deploymentId),
|
avatar: getAvatarForBeneficiary(deploymentId),
|
||||||
status: isRecent ? 'online' : 'offline',
|
status: isRecent ? 'online' : 'offline',
|
||||||
address: data.address,
|
address: data.address,
|
||||||
@ -672,7 +675,10 @@ class ApiService {
|
|||||||
// Map API response to Beneficiary type
|
// Map API response to Beneficiary type
|
||||||
const beneficiaries: Beneficiary[] = (data.beneficiaries || []).map((item: any) => ({
|
const beneficiaries: Beneficiary[] = (data.beneficiaries || []).map((item: any) => ({
|
||||||
id: item.id,
|
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
|
avatar: item.avatarUrl || undefined, // Use uploaded avatar from server
|
||||||
status: 'offline' as const,
|
status: 'offline' as const,
|
||||||
email: item.email,
|
email: item.email,
|
||||||
@ -724,7 +730,10 @@ class ApiService {
|
|||||||
|
|
||||||
const beneficiary: Beneficiary = {
|
const beneficiary: Beneficiary = {
|
||||||
id: data.id,
|
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,
|
avatar: data.avatarUrl || undefined,
|
||||||
status: 'offline' as const,
|
status: 'offline' as const,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
@ -821,6 +830,7 @@ class ApiService {
|
|||||||
const beneficiary: Beneficiary = {
|
const beneficiary: Beneficiary = {
|
||||||
id: result.beneficiary.id,
|
id: result.beneficiary.id,
|
||||||
name: result.beneficiary.name || '',
|
name: result.beneficiary.name || '',
|
||||||
|
displayName: result.beneficiary.name || '', // For UI display
|
||||||
status: 'offline' as const,
|
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)
|
// AI Chat - deploymentId is required, no default value for security (LEGACY API)
|
||||||
async sendMessage(question: string, deploymentId: string): Promise<ApiResponse<ChatResponse>> {
|
async sendMessage(question: string, deploymentId: string): Promise<ApiResponse<ChatResponse>> {
|
||||||
if (!deploymentId) {
|
if (!deploymentId) {
|
||||||
|
|||||||
@ -83,6 +83,9 @@ export interface Deployment {
|
|||||||
export interface Beneficiary {
|
export interface Beneficiary {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
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;
|
avatar?: string;
|
||||||
device_id?: string;
|
device_id?: string;
|
||||||
status: 'online' | 'offline';
|
status: 'online' | 'offline';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user