WellNuo/backend/src/routes/beneficiaries.js
Sergei 4a4fc5c077 fix(security): add input validation for POST/PATCH endpoints
- Install express-validator package
- Add validation to beneficiaries.js:
  - POST /: name (string 1-200), phone (optional), address (optional)
  - PATCH /🆔 name (string 1-200), phone, address, customName (max 100)
- Add validation to stripe.js:
  - create-checkout-session: userId, beneficiaryName, beneficiaryAddress, email
  - create-portal-session: customerId (string)
  - create-payment-sheet: email (valid email), amount (positive int)
  - create-subscription: beneficiaryId (int), paymentMethodId (string)
  - cancel-subscription: beneficiaryId (int)
  - reactivate-subscription: beneficiaryId (int)
  - create-subscription-payment-sheet: beneficiaryId (int)
  - confirm-subscription-payment: subscriptionId (string)
- Add validation to invitations.js:
  - POST /: beneficiaryId (int), role (enum: caretaker/guardian), email (valid)
  - POST /accept: code (string)
  - POST /accept-public: code (string)
  - PATCH /🆔 role (enum: caretaker/guardian)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 16:47:35 -08:00

1410 lines
47 KiB
JavaScript

const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
const Stripe = require('stripe');
const { body, validationResult } = require('express-validator');
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'
};
// Compute displayName: customName takes priority, then name
const customName = record.custom_name || null;
const originalName = beneficiary.name;
const displayName = customName || originalName;
beneficiaries.push({
accessId: record.id,
id: beneficiary.id,
role: record.role,
grantedAt: record.granted_at,
name: beneficiary.name,
customName: customName, // User's custom name for this beneficiary
displayName: displayName, // For UI display (customName || name)
originalName: originalName, // Original name from beneficiaries table
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 });
// Compute displayName: customName takes priority, then name
const customName = access.custom_name || null;
const originalName = beneficiary.name;
const displayName = customName || originalName;
res.json({
id: beneficiary.id,
name: beneficiary.name,
customName: customName, // User's custom name for this beneficiary
displayName: displayName, // For UI display (customName || name)
originalName: originalName, // Original name from beneficiaries table
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('/',
[
body('name')
.isString().withMessage('name must be a string')
.trim()
.isLength({ min: 1, max: 200 }).withMessage('name must be between 1 and 200 characters'),
body('phone')
.optional({ nullable: true })
.isString().withMessage('phone must be a string')
.trim(),
body('address')
.optional({ nullable: true })
.isString().withMessage('address must be a string')
.trim()
],
async (req, res) => {
try {
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
const userId = req.user.userId;
const { name, phone, address } = req.body;
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);
// Format name for Legacy API (expects EXACTLY "FirstName LastName" - 2 words!)
// Legacy API does: firstName, lastName = beneficiary_name.split(" ")
// This crashes if there are more or fewer than 2 words
const nameParts = name.trim().split(/\s+/);
let legacyName;
if (nameParts.length === 1) {
legacyName = `${nameParts[0]} User`; // Single name -> add "User" as lastName
} else {
legacyName = `${nameParts[0]} ${nameParts[1]}`; // Take only first two words
}
// Generate beneficiary username for Legacy API
const beneficiaryLegacyUsername = `beneficiary_${beneficiary.id}`;
// Create deployment in Legacy API
// Note: Legacy API requires signature and skip_email to avoid crash in SendWelcomeBeneficiaryEmail
const legacyDeploymentId = await legacyAPI.createLegacyDeployment({
username: legacyUsername,
token: legacyToken,
beneficiaryName: legacyName,
beneficiaryEmail: `beneficiary-${beneficiary.id}@wellnuo.app`, // Auto-generated email
beneficiaryUsername: beneficiaryLegacyUsername,
beneficiaryPassword: Math.random().toString(36).substring(2, 15), // Random password
address: address || 'Unknown',
caretakerUsername: legacyUsername,
caretakerEmail: `caretaker-${beneficiary.id}@wellnuo.app`,
persons: 1,
pets: 0,
gender: 'Male', // Use 'Male' as default, 'Other' may cause issues
race: 0,
born: new Date().getFullYear() - 65,
lat: 40.7128, // Default NYC coordinates
lng: -74.0060,
wifis: [],
devices: []
});
console.log('[BENEFICIARY] Created Legacy deployment:', legacyDeploymentId);
// If deployment was created but ID not returned, try to find it
let finalDeploymentId = legacyDeploymentId;
if (!finalDeploymentId) {
console.log('[BENEFICIARY] No deployment_id returned, attempting to find by username...');
finalDeploymentId = await legacyAPI.findDeploymentByUsername(
legacyUsername,
legacyToken,
beneficiaryLegacyUsername
);
console.log('[BENEFICIARY] Found deployment by username:', finalDeploymentId);
}
// Update our deployment with legacy_deployment_id if we have it
if (finalDeploymentId) {
const { error: updateError } = await supabase
.from('beneficiary_deployments')
.update({
legacy_deployment_id: finalDeploymentId,
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 = finalDeploymentId;
}
} else {
console.warn('[BENEFICIARY] Legacy deployment created but ID could not be retrieved');
}
}
} 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 based on user's role
*
* - Custodian: can update name, phone, address in beneficiaries table
* AND can update customName in user_access table
* - Guardian/Caretaker: can ONLY update customName in user_access table
*
* Request body:
* - name, phone, address: beneficiary data (custodian only)
* - customName: user's personal alias for this beneficiary (any role)
*/
router.patch('/:id',
[
body('name')
.optional()
.isString().withMessage('name must be a string')
.trim()
.isLength({ min: 1, max: 200 }).withMessage('name must be between 1 and 200 characters'),
body('phone')
.optional({ nullable: true })
.isString().withMessage('phone must be a string')
.trim(),
body('address')
.optional({ nullable: true })
.isString().withMessage('address must be a string')
.trim(),
body('customName')
.optional({ nullable: true })
.isString().withMessage('customName must be a string')
.trim()
.isLength({ max: 100 }).withMessage('customName must be 100 characters or less')
],
async (req, res) => {
try {
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
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, custom_name')
.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';
// Validate customName if provided (any role can set it)
if (customName !== undefined) {
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' });
}
}
// Check if non-custodian is trying to update beneficiary data
if (!isCustodian && (name !== undefined || phone !== undefined || address !== undefined)) {
return res.status(403).json({
error: 'Only custodian can update beneficiary name, phone, or address. You can only update customName.'
});
}
// Check if there's anything to update
const hasBeneficiaryUpdates = name !== undefined || phone !== undefined || address !== undefined;
const hasCustomNameUpdate = customName !== undefined;
if (!hasBeneficiaryUpdates && !hasCustomNameUpdate) {
return res.status(400).json({ error: 'No fields to update. Provide name, phone, address, or customName.' });
}
let beneficiary = null;
let updatedCustomName = access.custom_name || null; // Empty string → null
// Step 1: Update beneficiaries table if custodian and has beneficiary updates
if (isCustodian && hasBeneficiaryUpdates) {
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;
const { data: updatedBeneficiary, 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' });
}
beneficiary = updatedBeneficiary;
console.log('[BENEFICIARY PATCH] Custodian updated beneficiary:', {
id: beneficiary.id,
name: beneficiary.name,
phone: beneficiary.phone,
address: beneficiary.address
});
}
// Step 2: Update customName in user_access if provided (any role)
if (hasCustomNameUpdate) {
const { error: customNameError } = await supabase
.from('user_access')
.update({
custom_name: customName || null // Empty string becomes null
})
.eq('id', access.id);
if (customNameError) {
console.error('[BENEFICIARY PATCH] Custom name update error:', customNameError);
return res.status(500).json({ error: 'Failed to update custom name' });
}
updatedCustomName = customName || null;
console.log('[BENEFICIARY PATCH] Updated customName:', { beneficiaryId, customName: updatedCustomName });
}
// Step 3: Get beneficiary data for response if not already fetched
if (!beneficiary) {
const { data: fetchedBeneficiary } = await supabase
.from('beneficiaries')
.select('id, name, phone, address, avatar_url')
.eq('id', beneficiaryId)
.single();
beneficiary = fetchedBeneficiary;
}
const originalName = beneficiary?.name || null;
const displayName = updatedCustomName || originalName;
res.json({
success: true,
beneficiary: {
id: beneficiaryId,
name: originalName,
customName: updatedCustomName,
displayName: displayName,
originalName: originalName,
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;