Compare commits
No commits in common. "f0d39af6dc0b34535254e9f683630e10a75d9de1" and "f94121b848b283c83931cc2d2aad58be90478d7c" have entirely different histories.
f0d39af6dc
...
f94121b848
1250
AUDIT_REPORT.md
1250
AUDIT_REPORT.md
File diff suppressed because it is too large
Load Diff
150
PRD.md
150
PRD.md
@ -1,150 +0,0 @@
|
||||
# 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,12 +73,7 @@ export default function BeneficiaryDetailScreen() {
|
||||
|
||||
// Edit modal state
|
||||
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: '',
|
||||
address: '',
|
||||
avatar: undefined as string | undefined,
|
||||
customName: '' // For non-custodian users
|
||||
});
|
||||
const [editForm, setEditForm] = useState({ name: '', address: '', avatar: undefined as string | undefined });
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
||||
|
||||
@ -220,15 +215,11 @@ 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') {
|
||||
@ -249,8 +240,8 @@ export default function BeneficiaryDetailScreen() {
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!id) {
|
||||
toast.error('Error', 'Invalid beneficiary');
|
||||
if (!editForm.name.trim() || !id) {
|
||||
toast.error('Error', 'Name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -258,14 +249,7 @@ export default function BeneficiaryDetailScreen() {
|
||||
setIsSavingEdit(true);
|
||||
|
||||
try {
|
||||
if (isCustodian) {
|
||||
// Custodian: update name, address in beneficiaries table
|
||||
if (!editForm.name.trim()) {
|
||||
toast.error('Error', 'Name is required');
|
||||
setIsSavingEdit(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update basic info
|
||||
const response = await api.updateWellNuoBeneficiary(beneficiaryId, {
|
||||
name: editForm.name.trim(),
|
||||
address: editForm.address.trim() || undefined,
|
||||
@ -284,25 +268,13 @@ export default function BeneficiaryDetailScreen() {
|
||||
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');
|
||||
}
|
||||
}
|
||||
} 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', isCustodian ? 'Profile updated successfully' : 'Nickname saved');
|
||||
toast.success('Saved', 'Profile updated successfully');
|
||||
loadBeneficiary(false);
|
||||
} catch (err) {
|
||||
toast.error('Error', 'Failed to save changes.');
|
||||
@ -403,7 +375,7 @@ export default function BeneficiaryDetailScreen() {
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>{beneficiary.displayName}</Text>
|
||||
<Text style={styles.headerTitle}>{beneficiary.name}</Text>
|
||||
</View>
|
||||
|
||||
<BeneficiaryMenu
|
||||
@ -499,7 +471,7 @@ export default function BeneficiaryDetailScreen() {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MockDashboard beneficiaryName={beneficiary.displayName} />
|
||||
<MockDashboard beneficiaryName={beneficiary.name} />
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
@ -513,18 +485,14 @@ export default function BeneficiaryDetailScreen() {
|
||||
>
|
||||
<View style={styles.modalContainer}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>
|
||||
{isCustodian ? 'Edit Profile' : 'Edit Nickname'}
|
||||
</Text>
|
||||
<Text style={styles.modalTitle}>Edit Profile</Text>
|
||||
<TouchableOpacity onPress={() => setIsEditModalVisible(false)}>
|
||||
<Ionicons name="close" size={24} color={AppColors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.modalContent}>
|
||||
{isCustodian ? (
|
||||
<>
|
||||
{/* Custodian: Avatar, Name, Address */}
|
||||
{/* Avatar */}
|
||||
<TouchableOpacity
|
||||
style={styles.avatarPicker}
|
||||
onPress={handlePickAvatar}
|
||||
@ -550,6 +518,7 @@ export default function BeneficiaryDetailScreen() {
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Name */}
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.inputLabel}>Name</Text>
|
||||
<TextInput
|
||||
@ -561,6 +530,7 @@ export default function BeneficiaryDetailScreen() {
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Address */}
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.inputLabel}>Address</Text>
|
||||
<TextInput
|
||||
@ -573,34 +543,6 @@ export default function BeneficiaryDetailScreen() {
|
||||
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}>
|
||||
@ -877,34 +819,4 @@ 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,
|
||||
},
|
||||
});
|
||||
|
||||
@ -89,9 +89,6 @@ 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() !== '' &&
|
||||
@ -119,7 +116,7 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
|
||||
) : (
|
||||
<View style={[styles.avatar, hasNoSubscription && styles.avatarNoSubscription]}>
|
||||
<Text style={[styles.avatarText, hasNoSubscription && styles.avatarTextNoSubscription]}>
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
{beneficiary.name.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@ -127,7 +124,7 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
|
||||
|
||||
{/* Name and Status */}
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.name} numberOfLines={1}>{displayName}</Text>
|
||||
<Text style={styles.name} numberOfLines={1}>{beneficiary.name}</Text>
|
||||
{/* User's role for this beneficiary */}
|
||||
{beneficiary.role && (
|
||||
<Text style={styles.roleText}>
|
||||
@ -226,6 +223,7 @@ 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([]);
|
||||
}
|
||||
@ -240,6 +238,7 @@ export default function HomeScreen() {
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load beneficiaries:', err);
|
||||
setError('Failed to load beneficiaries');
|
||||
setBeneficiaries([]);
|
||||
} finally {
|
||||
@ -263,7 +262,7 @@ export default function HomeScreen() {
|
||||
setCurrentBeneficiary(beneficiary);
|
||||
router.push({
|
||||
pathname: '/(auth)/activate',
|
||||
params: { lovedOneName: beneficiary.displayName, beneficiaryId: beneficiary.id.toString() },
|
||||
params: { lovedOneName: beneficiary.name, beneficiaryId: beneficiary.id.toString() },
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
-- 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)
|
||||
const { data: accessRecords, error: accessError } = await supabase
|
||||
.from('user_access')
|
||||
.select('id, beneficiary_id, role, granted_at, custom_name')
|
||||
.select('id, beneficiary_id, role, granted_at')
|
||||
.eq('accessor_id', userId);
|
||||
|
||||
if (accessError) {
|
||||
@ -244,7 +244,6 @@ 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,
|
||||
@ -280,7 +279,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, custom_name')
|
||||
.select('role, beneficiary_id')
|
||||
.eq('accessor_id', userId)
|
||||
.eq('beneficiary_id', beneficiaryId)
|
||||
.single();
|
||||
@ -322,7 +321,6 @@ 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,
|
||||
@ -508,9 +506,8 @@ router.post('/', async (req, res) => {
|
||||
|
||||
/**
|
||||
* PATCH /api/me/beneficiaries/:id
|
||||
* Updates beneficiary info
|
||||
* - Custodian: can update name, phone, address in beneficiaries table
|
||||
* - Guardian/Caretaker: can only update customName in user_access table
|
||||
* Updates beneficiary info (requires custodian or guardian role)
|
||||
* Now uses the proper beneficiaries table (not users)
|
||||
*/
|
||||
router.patch('/:id', async (req, res) => {
|
||||
try {
|
||||
@ -519,23 +516,20 @@ router.patch('/:id', async (req, res) => {
|
||||
|
||||
console.log('[BENEFICIARY PATCH] Request:', { userId, beneficiaryId, body: req.body });
|
||||
|
||||
// Check user has access - using beneficiary_id
|
||||
// Check user has custodian or guardian access - using beneficiary_id
|
||||
const { data: access, error: accessError } = await supabase
|
||||
.from('user_access')
|
||||
.select('id, role')
|
||||
.select('role')
|
||||
.eq('accessor_id', userId)
|
||||
.eq('beneficiary_id', beneficiaryId)
|
||||
.single();
|
||||
|
||||
if (accessError || !access) {
|
||||
return res.status(403).json({ error: 'Access denied to this beneficiary' });
|
||||
if (accessError || !access || !['custodian', 'guardian'].includes(access.role)) {
|
||||
return res.status(403).json({ error: 'Only custodian or guardian can update beneficiary info' });
|
||||
}
|
||||
|
||||
const { name, phone, address, customName } = req.body;
|
||||
const isCustodian = access.role === 'custodian';
|
||||
const { name, phone, address } = req.body;
|
||||
|
||||
// Custodian can update beneficiary data (name, phone, address)
|
||||
if (isCustodian) {
|
||||
const updateData = {
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
@ -557,72 +551,18 @@ router.patch('/:id', async (req, res) => {
|
||||
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 });
|
||||
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,
|
||||
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);
|
||||
@ -1232,67 +1172,4 @@ 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;
|
||||
|
||||
@ -65,7 +65,6 @@ 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',
|
||||
|
||||
@ -536,7 +536,6 @@ class ApiService {
|
||||
{
|
||||
id: 1,
|
||||
name: 'Julia Smith',
|
||||
displayName: 'Julia Smith',
|
||||
status: 'online',
|
||||
relationship: 'Mother',
|
||||
last_activity: '2 min ago',
|
||||
@ -551,7 +550,6 @@ class ApiService {
|
||||
{
|
||||
id: 2,
|
||||
name: 'Robert Johnson',
|
||||
displayName: 'Robert Johnson',
|
||||
status: 'offline',
|
||||
relationship: 'Father',
|
||||
last_activity: '1 hour ago',
|
||||
@ -585,7 +583,6 @@ 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,
|
||||
@ -675,10 +672,7 @@ class ApiService {
|
||||
// Map API response to Beneficiary type
|
||||
const beneficiaries: Beneficiary[] = (data.beneficiaries || []).map((item: any) => ({
|
||||
id: item.id,
|
||||
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
|
||||
name: item.name || item.email,
|
||||
avatar: item.avatarUrl || undefined, // Use uploaded avatar from server
|
||||
status: 'offline' as const,
|
||||
email: item.email,
|
||||
@ -730,10 +724,7 @@ class ApiService {
|
||||
|
||||
const beneficiary: Beneficiary = {
|
||||
id: data.id,
|
||||
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
|
||||
name: data.name || data.email,
|
||||
avatar: data.avatarUrl || undefined,
|
||||
status: 'offline' as const,
|
||||
email: data.email,
|
||||
@ -830,7 +821,6 @@ class ApiService {
|
||||
const beneficiary: Beneficiary = {
|
||||
id: result.beneficiary.id,
|
||||
name: result.beneficiary.name || '',
|
||||
displayName: result.beneficiary.name || '', // For UI display
|
||||
status: 'offline' as const,
|
||||
};
|
||||
|
||||
@ -953,39 +943,6 @@ 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) {
|
||||
|
||||
@ -83,9 +83,6 @@ 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';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user