const express = require('express'); const router = express.Router(); const jwt = require('jsonwebtoken'); const Stripe = require('stripe'); const { supabase } = require('../config/supabase'); const storage = require('../services/storage'); const legacyAPI = require('../services/legacyAPI'); const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); /** * Helper: Normalize Stripe subscription status to app statuses */ function normalizeStripeStatus(status) { switch (status) { case 'active': return 'active'; case 'trialing': return 'trialing'; case 'past_due': case 'unpaid': return 'past_due'; case 'canceled': return 'canceled'; case 'incomplete': case 'incomplete_expired': return 'expired'; default: return 'none'; } } /** * Helper: Get subscription status from Stripe (source of truth) * Used for single beneficiary requests */ async function getStripeSubscriptionStatus(stripeCustomerId) { if (!stripeCustomerId) { return { plan: 'free', status: 'none', hasSubscription: false }; } try { // Get active subscriptions from Stripe const subscriptions = await stripe.subscriptions.list({ customer: stripeCustomerId, status: 'active', limit: 1 }); if (subscriptions.data.length > 0) { const sub = subscriptions.data[0]; // Use cancel_at if subscription is set to cancel, otherwise use current_period_end const periodEndTimestamp = sub.cancel_at || sub.current_period_end; const endDate = periodEndTimestamp ? new Date(periodEndTimestamp * 1000).toISOString() : null; return { plan: 'premium', status: 'active', hasSubscription: true, endDate: endDate, cancelAtPeriodEnd: sub.cancel_at_period_end || false }; } // Check for past_due or other statuses const allSubs = await stripe.subscriptions.list({ customer: stripeCustomerId, limit: 1 }); if (allSubs.data.length > 0) { const sub = allSubs.data[0]; const normalizedStatus = normalizeStripeStatus(sub.status); const periodEndTimestamp = sub.cancel_at || sub.current_period_end; const endDate = periodEndTimestamp ? new Date(periodEndTimestamp * 1000).toISOString() : null; return { plan: normalizedStatus === 'canceled' || normalizedStatus === 'none' || normalizedStatus === 'expired' ? 'free' : 'premium', status: normalizedStatus, hasSubscription: normalizedStatus === 'active' || normalizedStatus === 'trialing', endDate: endDate, cancelAtPeriodEnd: sub.cancel_at_period_end || false }; } return { plan: 'free', status: 'none', hasSubscription: false }; } catch (error) { console.error('Error fetching Stripe subscription:', error); return { plan: 'free', status: 'none', hasSubscription: false }; } } /** * Helper: Batch fetch subscription statuses for multiple customers * Makes 1 Stripe API call instead of N calls */ async function getBatchStripeSubscriptions(stripeCustomerIds) { // Filter out null/undefined const validIds = stripeCustomerIds.filter(Boolean); if (validIds.length === 0) { return {}; } const subscriptionMap = {}; // Initialize all as free/none for (const customerId of validIds) { subscriptionMap[customerId] = { plan: 'free', status: 'none', hasSubscription: false }; } try { const startTime = Date.now(); // Fetch all subscriptions in one call (Stripe allows up to 100) // We fetch all statuses and filter in code const subscriptions = await stripe.subscriptions.list({ limit: 100, expand: ['data.customer'] }); console.log(`[STRIPE BATCH] Fetched ${subscriptions.data.length} subscriptions in ${Date.now() - startTime}ms`); // Build map of customer_id -> subscription for (const sub of subscriptions.data) { const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id; if (!customerId || !validIds.includes(customerId)) { continue; } const periodEndTimestamp = sub.cancel_at || sub.current_period_end; const endDate = periodEndTimestamp ? new Date(periodEndTimestamp * 1000).toISOString() : null; const normalizedStatus = normalizeStripeStatus(sub.status); // Only update if this is a better status (active > trialing > past_due > etc) const currentStatus = subscriptionMap[customerId]?.status || 'none'; const statusPriority = { active: 4, trialing: 3, past_due: 2, canceled: 1, expired: 0, none: 0 }; if (statusPriority[normalizedStatus] > statusPriority[currentStatus]) { subscriptionMap[customerId] = { plan: normalizedStatus === 'active' || normalizedStatus === 'trialing' ? 'premium' : 'free', status: normalizedStatus, hasSubscription: normalizedStatus === 'active' || normalizedStatus === 'trialing', endDate: endDate, cancelAtPeriodEnd: sub.cancel_at_period_end || false }; } } return subscriptionMap; } catch (error) { console.error('[STRIPE BATCH] Error fetching subscriptions:', error.message); return subscriptionMap; // Return map with defaults } } /** * Middleware to verify JWT token */ function authMiddleware(req, res, next) { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ error: 'No token provided' }); } try { const token = authHeader.split(' ')[1]; req.user = jwt.verify(token, process.env.JWT_SECRET); next(); } catch (err) { return res.status(401).json({ error: 'Invalid token' }); } } // Apply auth middleware to all routes router.use(authMiddleware); /** * GET /api/me/beneficiaries * Returns list of beneficiaries the user has access to * Now uses the proper beneficiaries table (not users) * OPTIMIZED: Reads subscription_status from DB (synced from Stripe hourly) */ router.get('/', async (req, res) => { try { const startTime = Date.now(); const userId = req.user.userId; // 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') .eq('accessor_id', userId); if (accessError) { console.error('Get access records error:', accessError); return res.status(500).json({ error: 'Failed to get beneficiaries' }); } if (!accessRecords || accessRecords.length === 0) { return res.json({ beneficiaries: [] }); } // Get all beneficiary IDs const beneficiaryIds = accessRecords .map(r => r.beneficiary_id) .filter(Boolean); if (beneficiaryIds.length === 0) { return res.json({ beneficiaries: [] }); } // Batch query: Get all beneficiaries in one DB call (including subscription_status) const { data: beneficiariesData, error: beneficiariesError } = await supabase .from('beneficiaries') .select('id, name, phone, address, avatar_url, created_at, equipment_status, subscription_status, subscription_updated_at') .in('id', beneficiaryIds); if (beneficiariesError) { console.error('Get beneficiaries error:', beneficiariesError); return res.status(500).json({ error: 'Failed to get beneficiaries' }); } const dbTime = Date.now() - startTime; // Build response - subscription status from DB (no Stripe calls!) const beneficiaries = []; for (const record of accessRecords) { const beneficiary = beneficiariesData.find(b => b.id === record.beneficiary_id); if (!beneficiary) continue; // Build subscription object from cached DB status const status = beneficiary.subscription_status || 'none'; const subscription = { plan: status === 'active' || status === 'trialing' ? 'premium' : 'free', status: status, hasSubscription: status === 'active' || status === 'trialing' }; beneficiaries.push({ accessId: record.id, id: beneficiary.id, 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, createdAt: beneficiary.created_at, subscription: subscription, // Equipment status from beneficiaries table - CRITICAL for navigation! hasDevices: beneficiary.equipment_status === 'active' || beneficiary.equipment_status === 'demo', equipmentStatus: beneficiary.equipment_status || 'none' }); } const totalTime = Date.now() - startTime; console.log(`[GET BENEFICIARIES] ${beneficiaries.length} items in ${totalTime}ms (DB only)`); res.json({ beneficiaries }); } catch (error) { console.error('Get beneficiaries error:', error); res.status(500).json({ error: error.message }); } }); /** * GET /api/me/beneficiaries/:id * Returns details of a specific beneficiary * Now uses the proper beneficiaries table (not users) */ router.get('/:id', async (req, res) => { try { const userId = req.user.userId; const beneficiaryId = parseInt(req.params.id, 10); // 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') .eq('accessor_id', userId) .eq('beneficiary_id', beneficiaryId) .single(); if (accessError || !access) { return res.status(403).json({ error: 'Access denied' }); } // Get beneficiary details from beneficiaries table const { data: beneficiary, error } = await supabase .from('beneficiaries') .select('*') .eq('id', beneficiaryId) .single(); if (error || !beneficiary) { return res.status(404).json({ error: 'Beneficiary not found' }); } // Get subscription status from Stripe (source of truth) const subscription = await getStripeSubscriptionStatus(beneficiary.stripe_customer_id); // Get primary deployment with legacy_deployment_id const { data: deployment } = await supabase .from('beneficiary_deployments') .select('id, legacy_deployment_id') .eq('beneficiary_id', beneficiaryId) .eq('is_primary', true) .single(); // Get orders for this beneficiary const { data: orders } = await supabase .from('orders') .select('id, order_number, status, total_amount, created_at') .eq('beneficiary_id', beneficiaryId) .eq('user_id', userId) .order('created_at', { ascending: false }); 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, role: access.role, subscription: subscription, orders: orders || [], // Equipment status from beneficiaries table - CRITICAL for navigation! hasDevices: beneficiary.equipment_status === 'active' || beneficiary.equipment_status === 'demo', equipmentStatus: beneficiary.equipment_status || 'none', // Legacy deployment ID for fetching sensors from Legacy API deploymentId: deployment?.legacy_deployment_id || null }); } catch (error) { console.error('Get beneficiary error:', error); res.status(500).json({ error: error.message }); } }); /** * POST /api/me/beneficiaries * Creates a new beneficiary and grants custodian access to creator * Now uses the proper beneficiaries table (not users) * AUTO-CREATES FIRST DEPLOYMENT */ router.post('/', async (req, res) => { try { const userId = req.user.userId; const { name, phone, address } = req.body; if (!name) { return res.status(400).json({ error: 'name is required' }); } console.log('[BENEFICIARY] Creating beneficiary:', { userId, name }); // Create beneficiary in the proper beneficiaries table (not users!) const { data: beneficiary, error: createError } = await supabase .from('beneficiaries') .insert({ name: name, phone: phone || null, address: address || null, equipment_status: 'none', created_by: userId, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }) .select() .single(); if (createError) { console.error('[BENEFICIARY] Create error:', createError); return res.status(500).json({ error: 'Failed to create beneficiary' }); } console.log('[BENEFICIARY] Created beneficiary:', beneficiary.id); // Create user_access record with custodian role // beneficiary_id points to the beneficiaries table const { error: accessError } = await supabase .from('user_access') .insert({ accessor_id: userId, beneficiary_id: beneficiary.id, role: 'custodian', granted_by: userId, granted_at: new Date().toISOString() }); if (accessError) { console.error('[BENEFICIARY] Access error:', accessError); // Rollback - delete the beneficiary await supabase.from('beneficiaries').delete().eq('id', beneficiary.id); return res.status(500).json({ error: 'Failed to grant access' }); } console.log('[BENEFICIARY] Custodian access granted'); // AUTO-CREATE FIRST DEPLOYMENT // This is the "Home" deployment - primary deployment for this beneficiary const { data: deployment, error: deploymentError } = await supabase .from('beneficiary_deployments') .insert({ beneficiary_id: beneficiary.id, name: 'Home', address: address || null, is_primary: true, legacy_deployment_id: null, // Will be set after Legacy API call created_at: new Date().toISOString(), updated_at: new Date().toISOString() }) .select() .single(); if (deploymentError) { console.error('[BENEFICIARY] Deployment create error:', deploymentError); // Rollback - delete access and beneficiary await supabase.from('user_access').delete().eq('accessor_id', userId).eq('beneficiary_id', beneficiary.id); await supabase.from('beneficiaries').delete().eq('id', beneficiary.id); return res.status(500).json({ error: 'Failed to create deployment' }); } console.log('[BENEFICIARY] Created primary deployment:', deployment.id); // CREATE DEPLOYMENT IN LEGACY API // This links our beneficiary to the Legacy API system for device management try { const legacyUsername = process.env.LEGACY_API_USERNAME || ''; const legacyPassword = process.env.LEGACY_API_PASSWORD || ''; if (!legacyUsername || !legacyPassword) { console.warn('[BENEFICIARY] Legacy API credentials not configured, skipping Legacy deployment creation'); } else { // Get Legacy API token const legacyToken = await legacyAPI.getLegacyToken(legacyUsername, legacyPassword); // Create deployment in Legacy API const legacyDeploymentId = await legacyAPI.createLegacyDeployment({ username: legacyUsername, token: legacyToken, beneficiaryName: name, beneficiaryEmail: `beneficiary-${beneficiary.id}@wellnuo.app`, // Auto-generated email beneficiaryUsername: `beneficiary_${beneficiary.id}`, beneficiaryPassword: Math.random().toString(36).substring(2, 15), // Random password address: address || '', caretakerUsername: legacyUsername, caretakerEmail: '', // Can be set later persons: 1, pets: 0, gender: 'Other', race: 0, born: new Date().getFullYear() - 65, lat: 0, lng: 0, wifis: [], devices: [] }); console.log('[BENEFICIARY] Created Legacy deployment:', legacyDeploymentId); // Update our deployment with legacy_deployment_id const { error: updateError } = await supabase .from('beneficiary_deployments') .update({ legacy_deployment_id: legacyDeploymentId, updated_at: new Date().toISOString() }) .eq('id', deployment.id); if (updateError) { console.error('[BENEFICIARY] Failed to update legacy_deployment_id:', updateError); // Not critical - deployment still works without this link } else { deployment.legacy_deployment_id = legacyDeploymentId; } } } catch (legacyError) { console.error('[BENEFICIARY] Legacy API deployment failed:', legacyError); // Not critical - continue without Legacy API link // Beneficiary and deployment are still created in our database } res.status(201).json({ success: true, beneficiary: { id: beneficiary.id, name: beneficiary.name, phone: beneficiary.phone, address: beneficiary.address || null, avatarUrl: beneficiary.avatar_url, role: 'custodian', equipmentStatus: 'none', primaryDeploymentId: deployment.id // Return deployment ID for future use } }); } catch (error) { console.error('[BENEFICIARY] Create error:', error); res.status(500).json({ error: error.message }); } }); /** * 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 */ router.patch('/:id', async (req, res) => { try { const userId = req.user.userId; const beneficiaryId = parseInt(req.params.id, 10); console.log('[BENEFICIARY PATCH] Request:', { userId, beneficiaryId, body: req.body }); // Check user has access - using beneficiary_id 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' }); } const { name, phone, address, customName } = req.body; const isCustodian = access.role === 'custodian'; // 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; // 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] 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); res.status(500).json({ error: error.message }); } }); /** * DELETE /api/me/beneficiaries/:id * Deletes a beneficiary and all related data (custodian only) * Now uses the proper beneficiaries table (not users) */ router.delete('/:id', async (req, res) => { try { const userId = req.user.userId; const beneficiaryId = parseInt(req.params.id, 10); console.log('[BENEFICIARY] Delete request:', { userId, beneficiaryId }); // Check user has custodian access (only custodians can delete) - using beneficiary_id const { data: access, error: accessError } = await supabase .from('user_access') .select('role') .eq('accessor_id', userId) .eq('beneficiary_id', beneficiaryId) .single(); if (accessError || !access || access.role !== 'custodian') { console.log('[BENEFICIARY] Delete denied:', { userId, beneficiaryId, role: access?.role || 'none' }); return res.status(403).json({ error: 'Only custodian can delete beneficiary' }); } // Delete all related data in order (to respect foreign keys) // 1. Delete invitations await supabase .from('invitations') .delete() .eq('beneficiary_id', beneficiaryId); // 2. Delete all user_access records (using beneficiary_id) await supabase .from('user_access') .delete() .eq('beneficiary_id', beneficiaryId); // 3. Delete orders await supabase .from('orders') .delete() .eq('beneficiary_id', beneficiaryId); // 4. Delete devices await supabase .from('devices') .delete() .eq('beneficiary_id', beneficiaryId); // 5. Finally delete the beneficiary from beneficiaries table (not users!) const { error: deleteError } = await supabase .from('beneficiaries') .delete() .eq('id', beneficiaryId); if (deleteError) { console.error('[BENEFICIARY] Delete error:', deleteError); return res.status(500).json({ error: 'Failed to delete beneficiary' }); } console.log('[BENEFICIARY] Deleted beneficiary:', beneficiaryId); res.json({ success: true, message: 'Beneficiary deleted' }); } catch (error) { console.error('[BENEFICIARY] Delete error:', error); res.status(500).json({ error: error.message }); } }); /** * GET /api/me/beneficiaries/:id/access * Returns list of users who have access to this beneficiary * Now uses beneficiary_id (points to beneficiaries table) */ router.get('/:id/access', async (req, res) => { try { const userId = req.user.userId; const beneficiaryId = parseInt(req.params.id, 10); // Check user has custodian or guardian access - using beneficiary_id const { data: access } = await supabase .from('user_access') .select('role') .eq('accessor_id', userId) .eq('beneficiary_id', beneficiaryId) .single(); if (!access || !['custodian', 'guardian'].includes(access.role)) { return res.status(403).json({ error: 'Only custodian or guardian can view access list' }); } // Get all access records for this beneficiary - using beneficiary_id const { data: accessRecords, error } = await supabase .from('user_access') .select('id, accessor_id, role, granted_at, granted_by') .eq('beneficiary_id', beneficiaryId); if (error) { return res.status(500).json({ error: 'Failed to get access list' }); } // Get user details for each access record const result = []; for (const record of (accessRecords || [])) { const { data: user } = await supabase .from('users') .select('id, email, first_name, last_name') .eq('id', record.accessor_id) .single(); if (user) { result.push({ accessId: record.id, userId: user.id, email: user.email, firstName: user.first_name, lastName: user.last_name, name: [user.first_name, user.last_name].filter(Boolean).join(' '), role: record.role, grantedAt: record.granted_at, isCurrentUser: record.accessor_id === userId }); } } res.json({ accessList: result }); } catch (error) { console.error('[BENEFICIARY] Get access list error:', error); res.status(500).json({ error: error.message }); } }); /** * PATCH /api/me/beneficiaries/:id/access/:targetUserId * Updates role for a user's access (custodian/guardian only) * Now uses beneficiary_id (points to beneficiaries table) */ router.patch('/:id/access/:targetUserId', async (req, res) => { try { const userId = req.user.userId; const beneficiaryId = parseInt(req.params.id, 10); const targetUserId = parseInt(req.params.targetUserId, 10); const { role } = req.body; console.log('[BENEFICIARY] Update access:', { userId, beneficiaryId, targetUserId, role }); if (!role || !['guardian', 'caretaker'].includes(role)) { return res.status(400).json({ error: 'Valid role required (guardian or caretaker)' }); } // Check user has custodian or guardian access - using beneficiary_id const { data: access } = await supabase .from('user_access') .select('role') .eq('accessor_id', userId) .eq('beneficiary_id', beneficiaryId) .single(); if (!access || !['custodian', 'guardian'].includes(access.role)) { return res.status(403).json({ error: 'Only custodian or guardian can update access' }); } // Can't change custodian's role this way (use transfer instead) - using beneficiary_id const { data: targetAccess } = await supabase .from('user_access') .select('id, role') .eq('accessor_id', targetUserId) .eq('beneficiary_id', beneficiaryId) .single(); if (!targetAccess) { return res.status(404).json({ error: 'User access not found' }); } if (targetAccess.role === 'custodian') { return res.status(400).json({ error: 'Cannot change custodian role. Use transfer instead.' }); } // Update role const { error } = await supabase .from('user_access') .update({ role }) .eq('id', targetAccess.id); if (error) { return res.status(500).json({ error: 'Failed to update role' }); } console.log('[BENEFICIARY] Access updated:', { targetUserId, newRole: role }); res.json({ success: true, newRole: role }); } catch (error) { console.error('[BENEFICIARY] Update access error:', error); res.status(500).json({ error: error.message }); } }); /** * DELETE /api/me/beneficiaries/:id/access/:targetUserId * Revokes access for a user (custodian/guardian only) * Now uses beneficiary_id (points to beneficiaries table) */ router.delete('/:id/access/:targetUserId', async (req, res) => { try { const userId = req.user.userId; const beneficiaryId = parseInt(req.params.id, 10); const targetUserId = parseInt(req.params.targetUserId, 10); console.log('[BENEFICIARY] Revoke access:', { userId, beneficiaryId, targetUserId }); // Check user has custodian or guardian access - using beneficiary_id const { data: access } = await supabase .from('user_access') .select('role') .eq('accessor_id', userId) .eq('beneficiary_id', beneficiaryId) .single(); if (!access || !['custodian', 'guardian'].includes(access.role)) { return res.status(403).json({ error: 'Only custodian or guardian can revoke access' }); } // Can't revoke your own access if (targetUserId === userId) { return res.status(400).json({ error: 'Cannot revoke your own access' }); } // Can't revoke custodian's access - using beneficiary_id const { data: targetAccess } = await supabase .from('user_access') .select('id, role') .eq('accessor_id', targetUserId) .eq('beneficiary_id', beneficiaryId) .single(); if (!targetAccess) { return res.status(404).json({ error: 'User access not found' }); } if (targetAccess.role === 'custodian') { return res.status(400).json({ error: 'Cannot revoke custodian access' }); } // Delete access record const { error } = await supabase .from('user_access') .delete() .eq('id', targetAccess.id); if (error) { return res.status(500).json({ error: 'Failed to revoke access' }); } console.log('[BENEFICIARY] Access revoked:', { targetUserId }); res.json({ success: true }); } catch (error) { console.error('[BENEFICIARY] Revoke access error:', error); res.status(500).json({ error: error.message }); } }); /** * POST /api/me/beneficiaries/:id/activate * Activates equipment for a beneficiary (demo or real serial number) * Now uses the proper beneficiaries table (not users) */ router.post('/:id/activate', async (req, res) => { try { const userId = req.user.userId; const beneficiaryId = parseInt(req.params.id, 10); const { serialNumber } = req.body; console.log('[BENEFICIARY] Activate request:', { userId, beneficiaryId, serialNumber }); if (!serialNumber) { return res.status(400).json({ error: 'serialNumber is required' }); } // Check user has custodian or guardian access - using beneficiary_id const { data: access, error: accessError } = await supabase .from('user_access') .select('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 activate equipment' }); } // Check for demo serial const isDemoMode = serialNumber === 'DEMO-00000' || serialNumber === 'DEMO-1234-5678'; const equipmentStatus = isDemoMode ? 'demo' : 'active'; // Update beneficiary record in beneficiaries table (not users!) const { data: beneficiary, error: updateError } = await supabase .from('beneficiaries') .update({ equipment_status: equipmentStatus, updated_at: new Date().toISOString() }) .eq('id', beneficiaryId) .select('id, name, equipment_status') .single(); if (updateError) { console.error('[BENEFICIARY] Update error:', updateError); return res.status(500).json({ error: 'Failed to activate equipment' }); } console.log('[BENEFICIARY] Activated:', { beneficiaryId, equipmentStatus, isDemoMode }); res.json({ success: true, beneficiary: { id: beneficiary?.id || beneficiaryId, name: beneficiary?.name || null, hasDevices: true, equipmentStatus: equipmentStatus } }); } catch (error) { console.error('[BENEFICIARY] Activate error:', error); res.status(500).json({ error: error.message }); } }); /** * POST /api/me/beneficiaries/:id/transfer * Transfers custodian rights to another user (custodian only) * Now uses beneficiary_id (points to beneficiaries table) */ router.post('/:id/transfer', async (req, res) => { try { const userId = req.user.userId; const beneficiaryId = parseInt(req.params.id, 10); const { newCustodianId } = req.body; console.log('[BENEFICIARY] Transfer request:', { userId, beneficiaryId, newCustodianId }); if (!newCustodianId) { return res.status(400).json({ error: 'newCustodianId is required' }); } // Check user has custodian access - using beneficiary_id 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 || access.role !== 'custodian') { return res.status(403).json({ error: 'Only custodian can transfer rights' }); } // Check new custodian exists and has access to this beneficiary - using beneficiary_id const { data: newUserAccess } = await supabase .from('user_access') .select('id, role') .eq('accessor_id', newCustodianId) .eq('beneficiary_id', beneficiaryId) .single(); if (!newUserAccess) { return res.status(400).json({ error: 'New custodian must have access to this beneficiary first' }); } // Transaction: update both roles // 1. Demote current custodian to guardian const { error: demoteError } = await supabase .from('user_access') .update({ role: 'guardian' }) .eq('id', access.id); if (demoteError) { console.error('[BENEFICIARY] Demote error:', demoteError); return res.status(500).json({ error: 'Failed to transfer rights' }); } // 2. Promote new user to custodian const { error: promoteError } = await supabase .from('user_access') .update({ role: 'custodian' }) .eq('id', newUserAccess.id); if (promoteError) { // Rollback: restore original custodian await supabase .from('user_access') .update({ role: 'custodian' }) .eq('id', access.id); console.error('[BENEFICIARY] Promote error:', promoteError); return res.status(500).json({ error: 'Failed to transfer rights' }); } console.log('[BENEFICIARY] Transferred custodian rights:', { from: userId, to: newCustodianId, beneficiaryId }); res.json({ success: true, message: 'Custodian rights transferred', newRole: 'guardian' // Current user's new role }); } catch (error) { console.error('[BENEFICIARY] Transfer error:', error); res.status(500).json({ error: error.message }); } }); /** * PATCH /api/me/beneficiaries/:id/avatar * Upload/update beneficiary avatar * - Uploads to MinIO if configured * - Falls back to base64 in DB if MinIO not available */ router.patch('/:id/avatar', async (req, res) => { try { const userId = req.user.userId; const beneficiaryId = parseInt(req.params.id, 10); const { avatar } = req.body; // base64 string or null to remove console.log('[BENEFICIARY] Avatar update:', { userId, beneficiaryId, hasAvatar: !!avatar }); // Check user has custodian or guardian access const { data: access, error: accessError } = await supabase .from('user_access') .select('role') .eq('accessor_id', userId) .eq('beneficiary_id', beneficiaryId) .single(); if (accessError || !access || !['custodian', 'guardian', 'caretaker'].includes(access.role)) { return res.status(403).json({ error: 'Only custodian, guardian or caretaker can update avatar' }); } // Validate base64 if provided if (avatar && !avatar.startsWith('data:image/')) { return res.status(400).json({ error: 'Invalid image format. Must be base64 data URI' }); } let avatarUrl = null; if (avatar) { // Try to upload to MinIO if (storage.isConfigured()) { try { // Get current avatar to delete old file const { data: current } = await supabase .from('beneficiaries') .select('avatar_url') .eq('id', beneficiaryId) .single(); // Delete old avatar from MinIO if exists if (current?.avatar_url && current.avatar_url.includes('minio')) { const oldKey = storage.extractKeyFromUrl(current.avatar_url); if (oldKey) { try { await storage.deleteFile(oldKey); } catch (e) { console.warn('[BENEFICIARY] Failed to delete old avatar:', e.message); } } } // Upload new avatar to MinIO const filename = `beneficiary-${beneficiaryId}-${Date.now()}`; const result = await storage.uploadBase64Image(avatar, 'avatars/beneficiaries', filename); avatarUrl = result.url; console.log('[BENEFICIARY] Avatar uploaded to MinIO:', avatarUrl); } catch (uploadError) { console.error('[BENEFICIARY] MinIO upload failed, falling back to DB:', uploadError.message); // Fallback: store base64 in DB avatarUrl = avatar; } } else { // MinIO not configured - store base64 in DB console.log('[BENEFICIARY] MinIO not configured, storing base64 in DB'); avatarUrl = avatar; } } // Update avatar_url in beneficiaries table const { data: beneficiary, error } = await supabase .from('beneficiaries') .update({ avatar_url: avatarUrl, updated_at: new Date().toISOString() }) .eq('id', beneficiaryId) .select('id, name, avatar_url') .single(); if (error) { console.error('[BENEFICIARY] Avatar update error:', error); return res.status(500).json({ error: 'Failed to update avatar' }); } console.log('[BENEFICIARY] Avatar updated:', { beneficiaryId, avatarUrl: beneficiary.avatar_url?.substring(0, 50) }); res.json({ success: true, beneficiary: { id: beneficiary.id, name: beneficiary.name, avatarUrl: beneficiary.avatar_url } }); } catch (error) { console.error('[BENEFICIARY] Avatar error:', error); res.status(500).json({ error: error.message }); } }); /** * Update equipment status for a beneficiary * PATCH /me/beneficiaries/:id/equipment-status * Now uses beneficiary_id and beneficiaries table */ router.patch('/:id/equipment-status', authMiddleware, async (req, res) => { try { const beneficiaryId = parseInt(req.params.id, 10); const userId = req.user.userId; const { status } = req.body; // Validate status const validStatuses = ['none', 'ordered', 'shipped', 'delivered', 'active', 'demo']; if (!validStatuses.includes(status)) { return res.status(400).json({ error: 'Invalid status. Must be one of: ' + validStatuses.join(', ') }); } // Check access - using beneficiary_id const { data: access, error: accessError } = await supabase .from('user_access') .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' }); } // Only custodian can change equipment status if (access.role !== 'custodian') { return res.status(403).json({ error: 'Only custodian can update equipment status' }); } // Update equipment status in beneficiaries table (new architecture!) const { data: updated, error: updateError } = await supabase .from('beneficiaries') .update({ equipment_status: status, updated_at: new Date().toISOString() }) .eq('id', beneficiaryId) .select('id, name, equipment_status') .single(); if (updateError) { console.error('[BENEFICIARY] Failed to update equipment status:', updateError); return res.status(500).json({ error: 'Failed to update equipment status' }); } if (!updated) { console.error('[BENEFICIARY] Beneficiary not found:', beneficiaryId); return res.status(404).json({ error: 'Beneficiary not found' }); } console.log('[BENEFICIARY] Equipment status updated:', { beneficiaryId, status }); res.json({ success: true, id: updated.id, name: updated.name, equipmentStatus: updated.equipment_status }); } catch (error) { console.error('[BENEFICIARY] Equipment status update error:', error); res.status(500).json({ error: 'Server error' }); } }); /** * 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;