Show user role under beneficiary name
- Added role field to Beneficiary type - Display role (Custodian/Guardian/Caretaker) in small gray text under name - Role comes from user_access table via API 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
28323507f8
commit
e74d1a4b26
@ -61,6 +61,19 @@ export default function TabLayout() {
|
||||
href: null,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="bug"
|
||||
options={{
|
||||
title: 'Test',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Ionicons
|
||||
name={focused ? 'bug' : 'bug-outline'}
|
||||
size={24}
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="chat"
|
||||
options={{
|
||||
|
||||
@ -333,7 +333,7 @@ export default function BeneficiaryDetailScreen() {
|
||||
<Ionicons name="arrow-back" size={22} color={AppColors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Avatar + Name */}
|
||||
{/* Avatar + Name + Role */}
|
||||
<View style={styles.headerCenter}>
|
||||
{beneficiary.avatar && beneficiary.avatar.trim() !== '' && !beneficiary.avatar.includes('placeholder') ? (
|
||||
<Image source={{ uri: beneficiary.avatar }} style={styles.headerAvatarImage} />
|
||||
@ -344,7 +344,14 @@ export default function BeneficiaryDetailScreen() {
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.headerTitle}>{beneficiary.name}</Text>
|
||||
<View style={styles.headerTitleContainer}>
|
||||
<Text style={styles.headerTitle}>{beneficiary.name}</Text>
|
||||
{beneficiary.role && (
|
||||
<Text style={styles.headerRole}>
|
||||
{beneficiary.role.charAt(0).toUpperCase() + beneficiary.role.slice(1)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<BeneficiaryMenu
|
||||
@ -566,11 +573,19 @@ const styles = StyleSheet.create({
|
||||
height: AvatarSizes.sm,
|
||||
borderRadius: AvatarSizes.sm / 2,
|
||||
},
|
||||
headerTitleContainer: {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: FontSizes.lg,
|
||||
fontWeight: FontWeights.semibold,
|
||||
color: AppColors.textPrimary,
|
||||
},
|
||||
headerRole: {
|
||||
fontSize: FontSizes.xs,
|
||||
color: AppColors.textMuted,
|
||||
marginTop: 1,
|
||||
},
|
||||
// Debug Panel
|
||||
debugPanel: {
|
||||
backgroundColor: '#FFF9C4',
|
||||
|
||||
323
app/(tabs)/bug.tsx
Normal file
323
app/(tabs)/bug.tsx
Normal file
@ -0,0 +1,323 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { View, StyleSheet, SafeAreaView } from 'react-native';
|
||||
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { AppColors } from '@/constants/theme';
|
||||
|
||||
// Test HTML page with buttons that send messages to React Native
|
||||
const TEST_HTML = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
color: white;
|
||||
}
|
||||
.container {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.card {
|
||||
background: rgba(255,255,255,0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-bottom: 12px;
|
||||
transition: transform 0.1s, opacity 0.1s;
|
||||
}
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.btn-primary {
|
||||
background: white;
|
||||
color: #667eea;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: white;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
}
|
||||
.btn-danger {
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
}
|
||||
.log {
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.log-entry {
|
||||
margin-bottom: 4px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>WebView Bridge Test</h1>
|
||||
<p class="subtitle">Test communication between Web and React Native</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">Navigation Commands</div>
|
||||
<button class="btn btn-primary" onclick="navigateTo('beneficiaries')">
|
||||
Open Beneficiaries
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="navigateTo('chat')">
|
||||
Open Chat
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="navigateTo('profile')">
|
||||
Open Profile
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">Native Features</div>
|
||||
<button class="btn btn-primary" onclick="requestNativeAction('bluetooth')">
|
||||
Scan Bluetooth Devices
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="requestNativeAction('camera')">
|
||||
Open Camera
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">Send Custom Data</div>
|
||||
<button class="btn btn-danger" onclick="sendCustomMessage()">
|
||||
Send Test Message
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="log" id="log">
|
||||
<div class="log-entry">Ready to communicate...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function log(message) {
|
||||
const logEl = document.getElementById('log');
|
||||
const time = new Date().toLocaleTimeString();
|
||||
logEl.innerHTML += '<div class="log-entry">[' + time + '] ' + message + '</div>';
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
function sendToRN(data) {
|
||||
if (window.ReactNativeWebView) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify(data));
|
||||
log('Sent: ' + JSON.stringify(data));
|
||||
} else {
|
||||
log('ERROR: Not in WebView');
|
||||
alert('This page must be opened in the mobile app');
|
||||
}
|
||||
}
|
||||
|
||||
function navigateTo(screen) {
|
||||
sendToRN({
|
||||
action: 'NAVIGATE',
|
||||
screen: screen
|
||||
});
|
||||
}
|
||||
|
||||
function requestNativeAction(feature) {
|
||||
sendToRN({
|
||||
action: 'NATIVE_FEATURE',
|
||||
feature: feature
|
||||
});
|
||||
}
|
||||
|
||||
function sendCustomMessage() {
|
||||
sendToRN({
|
||||
action: 'CUSTOM',
|
||||
payload: {
|
||||
timestamp: Date.now(),
|
||||
message: 'Hello from WebView!',
|
||||
data: { foo: 'bar', count: 42 }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for messages FROM React Native
|
||||
window.addEventListener('message', function(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
log('Received from RN: ' + JSON.stringify(data));
|
||||
} catch (e) {
|
||||
log('Received: ' + event.data);
|
||||
}
|
||||
});
|
||||
|
||||
// Also handle React Native's onMessage format
|
||||
document.addEventListener('message', function(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
log('Received from RN: ' + JSON.stringify(data));
|
||||
} catch (e) {
|
||||
log('Received: ' + event.data);
|
||||
}
|
||||
});
|
||||
|
||||
log('WebView Bridge initialized');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
export default function BugScreen() {
|
||||
const router = useRouter();
|
||||
const webViewRef = useRef<WebView>(null);
|
||||
|
||||
// Handle messages from WebView
|
||||
const handleMessage = (event: WebViewMessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.nativeEvent.data);
|
||||
console.log('[Bug WebView] Received message:', data);
|
||||
|
||||
switch (data.action) {
|
||||
case 'NAVIGATE':
|
||||
handleNavigation(data.screen);
|
||||
break;
|
||||
case 'NATIVE_FEATURE':
|
||||
handleNativeFeature(data.feature);
|
||||
break;
|
||||
case 'CUSTOM':
|
||||
handleCustomMessage(data.payload);
|
||||
break;
|
||||
default:
|
||||
console.log('[Bug WebView] Unknown action:', data.action);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Bug WebView] Error parsing message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Navigate to different screens
|
||||
const handleNavigation = (screen: string) => {
|
||||
console.log('[Bug WebView] Navigating to:', screen);
|
||||
|
||||
switch (screen) {
|
||||
case 'beneficiaries':
|
||||
router.push('/(tabs)/');
|
||||
break;
|
||||
case 'chat':
|
||||
router.push('/(tabs)/chat');
|
||||
break;
|
||||
case 'profile':
|
||||
router.push('/(tabs)/profile');
|
||||
break;
|
||||
default:
|
||||
console.log('[Bug WebView] Unknown screen:', screen);
|
||||
// Send error back to WebView
|
||||
sendToWebView({ error: `Unknown screen: ${screen}` });
|
||||
}
|
||||
};
|
||||
|
||||
// Handle native feature requests
|
||||
const handleNativeFeature = (feature: string) => {
|
||||
console.log('[Bug WebView] Native feature requested:', feature);
|
||||
|
||||
switch (feature) {
|
||||
case 'bluetooth':
|
||||
// TODO: Implement Bluetooth scanning screen
|
||||
sendToWebView({
|
||||
status: 'not_implemented',
|
||||
message: 'Bluetooth scanning will be implemented here'
|
||||
});
|
||||
break;
|
||||
case 'camera':
|
||||
// TODO: Implement camera
|
||||
sendToWebView({
|
||||
status: 'not_implemented',
|
||||
message: 'Camera will be implemented here'
|
||||
});
|
||||
break;
|
||||
default:
|
||||
sendToWebView({ error: `Unknown feature: ${feature}` });
|
||||
}
|
||||
};
|
||||
|
||||
// Handle custom messages
|
||||
const handleCustomMessage = (payload: any) => {
|
||||
console.log('[Bug WebView] Custom message:', payload);
|
||||
|
||||
// Echo back with confirmation
|
||||
sendToWebView({
|
||||
status: 'received',
|
||||
echo: payload,
|
||||
processedAt: Date.now()
|
||||
});
|
||||
};
|
||||
|
||||
// Send message TO WebView
|
||||
const sendToWebView = (data: object) => {
|
||||
const script = `
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
data: '${JSON.stringify(data)}'
|
||||
}));
|
||||
true;
|
||||
`;
|
||||
webViewRef.current?.injectJavaScript(script);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={{ html: TEST_HTML }}
|
||||
style={styles.webview}
|
||||
onMessage={handleMessage}
|
||||
javaScriptEnabled={true}
|
||||
domStorageEnabled={true}
|
||||
startInLoadingState={true}
|
||||
scalesPageToFit={true}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: AppColors.background,
|
||||
},
|
||||
webview: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@ -58,11 +58,26 @@ const equipmentStatusConfig = {
|
||||
color: AppColors.success,
|
||||
bgColor: AppColors.successLight,
|
||||
},
|
||||
active: {
|
||||
icon: 'pulse-outline' as const,
|
||||
label: 'Monitoring',
|
||||
sublabel: 'System active',
|
||||
color: AppColors.success,
|
||||
bgColor: AppColors.successLight,
|
||||
},
|
||||
demo: {
|
||||
icon: 'pulse-outline' as const,
|
||||
label: 'Monitoring',
|
||||
sublabel: 'Demo mode',
|
||||
color: AppColors.success,
|
||||
bgColor: AppColors.successLight,
|
||||
},
|
||||
};
|
||||
|
||||
function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardProps) {
|
||||
const equipmentStatus = beneficiary.equipmentStatus;
|
||||
const isAwaitingEquipment = equipmentStatus && ['ordered', 'shipped', 'delivered'].includes(equipmentStatus);
|
||||
const isMonitoring = equipmentStatus && ['active', 'demo'].includes(equipmentStatus);
|
||||
const statusConfig = equipmentStatus ? equipmentStatusConfig[equipmentStatus as keyof typeof equipmentStatusConfig] : null;
|
||||
|
||||
// Check if has devices/equipment connected
|
||||
@ -114,7 +129,7 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
|
||||
{/* Name and Status */}
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.name} numberOfLines={1}>{beneficiary.name}</Text>
|
||||
{/* Equipment status badge */}
|
||||
{/* Equipment status badge (awaiting delivery) */}
|
||||
{isAwaitingEquipment && statusConfig && (
|
||||
<View style={[styles.statusBadge, { backgroundColor: statusConfig.bgColor }]}>
|
||||
<Ionicons name={statusConfig.icon} size={14} color={statusConfig.color} />
|
||||
@ -123,6 +138,15 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Monitoring badge (active/demo) */}
|
||||
{isMonitoring && statusConfig && (
|
||||
<View style={[styles.statusBadge, { backgroundColor: statusConfig.bgColor }]}>
|
||||
<Ionicons name={statusConfig.icon} size={14} color={statusConfig.color} />
|
||||
<Text style={[styles.statusText, { color: statusConfig.color }]}>
|
||||
{statusConfig.label}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* No subscription warning */}
|
||||
{hasNoSubscription && (
|
||||
<View style={styles.noSubscriptionBadge}>
|
||||
|
||||
1677
backend/package-lock.json
generated
1677
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,7 @@
|
||||
"dev": "nodemon src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.966.0",
|
||||
"@supabase/supabase-js": "^2.39.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
@ -17,6 +18,7 @@
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"pg": "^8.16.3",
|
||||
"stripe": "^20.1.0"
|
||||
},
|
||||
|
||||
@ -4,6 +4,7 @@ const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const path = require('path');
|
||||
const cron = require('node-cron');
|
||||
const apiRouter = require('./routes/api');
|
||||
const authRouter = require('./routes/auth');
|
||||
const beneficiariesRouter = require('./routes/beneficiaries');
|
||||
@ -14,6 +15,7 @@ const ordersRouter = require('./routes/orders');
|
||||
const stripeRouter = require('./routes/stripe');
|
||||
const webhookRouter = require('./routes/webhook');
|
||||
const adminRouter = require('./routes/admin');
|
||||
const { syncAllSubscriptions } = require('./services/subscription-sync');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
@ -143,6 +145,29 @@ app.get('/api', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ============ CRON JOBS ============
|
||||
|
||||
// Sync subscriptions from Stripe every hour
|
||||
cron.schedule('0 * * * *', async () => {
|
||||
console.log('[CRON] Running subscription sync...');
|
||||
const result = await syncAllSubscriptions();
|
||||
console.log('[CRON] Subscription sync result:', result);
|
||||
});
|
||||
|
||||
// Run sync on startup (after 10 seconds to let everything initialize)
|
||||
setTimeout(async () => {
|
||||
console.log('[STARTUP] Running initial subscription sync...');
|
||||
const result = await syncAllSubscriptions();
|
||||
console.log('[STARTUP] Initial sync result:', result);
|
||||
}, 10000);
|
||||
|
||||
// Manual sync endpoint (for admin)
|
||||
app.post('/api/admin/sync-subscriptions', async (req, res) => {
|
||||
console.log('[ADMIN] Manual subscription sync requested');
|
||||
const result = await syncAllSubscriptions();
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`WellNuo API running on port ${PORT}`);
|
||||
console.log(`Stripe: ${process.env.STRIPE_SECRET_KEY ? '✓ configured' : '✗ missing'}`);
|
||||
|
||||
@ -3,6 +3,7 @@ const router = express.Router();
|
||||
const jwt = require('jsonwebtoken');
|
||||
const Stripe = require('stripe');
|
||||
const { supabase } = require('../config/supabase');
|
||||
const storage = require('../services/storage');
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
@ -178,7 +179,7 @@ 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: Uses batch Stripe API call instead of N individual calls
|
||||
* OPTIMIZED: Reads subscription_status from DB (synced from Stripe hourly)
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
@ -209,10 +210,10 @@ router.get('/', async (req, res) => {
|
||||
return res.json({ beneficiaries: [] });
|
||||
}
|
||||
|
||||
// Batch query: Get all beneficiaries in one DB call
|
||||
// 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, stripe_customer_id')
|
||||
.select('id, name, phone, address, avatar_url, created_at, equipment_status, subscription_status, subscription_updated_at')
|
||||
.in('id', beneficiaryIds);
|
||||
|
||||
if (beneficiariesError) {
|
||||
@ -222,25 +223,19 @@ router.get('/', async (req, res) => {
|
||||
|
||||
const dbTime = Date.now() - startTime;
|
||||
|
||||
// Collect all Stripe customer IDs for batch request
|
||||
const stripeCustomerIds = beneficiariesData
|
||||
.map(b => b.stripe_customer_id)
|
||||
.filter(Boolean);
|
||||
|
||||
// Batch Stripe API call: 1 call instead of N
|
||||
const stripeStartTime = Date.now();
|
||||
const subscriptionMap = await getBatchStripeSubscriptions(stripeCustomerIds);
|
||||
const stripeTime = Date.now() - stripeStartTime;
|
||||
|
||||
// Build response
|
||||
// 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;
|
||||
|
||||
const subscription = beneficiary.stripe_customer_id
|
||||
? (subscriptionMap[beneficiary.stripe_customer_id] || { plan: 'free', status: 'none', hasSubscription: false })
|
||||
: { plan: 'free', status: 'none', hasSubscription: false };
|
||||
// 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,
|
||||
@ -260,7 +255,7 @@ router.get('/', async (req, res) => {
|
||||
}
|
||||
|
||||
const totalTime = Date.now() - startTime;
|
||||
console.log(`[GET BENEFICIARIES] ${beneficiaries.length} items in ${totalTime}ms (DB: ${dbTime}ms, Stripe: ${stripeTime}ms)`);
|
||||
console.log(`[GET BENEFICIARIES] ${beneficiaries.length} items in ${totalTime}ms (DB only)`);
|
||||
|
||||
res.json({ beneficiaries });
|
||||
|
||||
@ -906,7 +901,9 @@ router.post('/:id/transfer', async (req, res) => {
|
||||
|
||||
/**
|
||||
* PATCH /api/me/beneficiaries/:id/avatar
|
||||
* Upload/update beneficiary avatar (base64 image)
|
||||
* 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 {
|
||||
@ -933,11 +930,54 @@ router.patch('/:id/avatar', async (req, res) => {
|
||||
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: avatar || null,
|
||||
avatar_url: avatarUrl,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', beneficiaryId)
|
||||
@ -949,7 +989,7 @@ router.patch('/:id/avatar', async (req, res) => {
|
||||
return res.status(500).json({ error: 'Failed to update avatar' });
|
||||
}
|
||||
|
||||
console.log('[BENEFICIARY] Avatar updated:', { beneficiaryId, hasAvatar: !!beneficiary.avatar_url });
|
||||
console.log('[BENEFICIARY] Avatar updated:', { beneficiaryId, avatarUrl: beneficiary.avatar_url?.substring(0, 50) });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
129
backend/src/services/storage.js
Normal file
129
backend/src/services/storage.js
Normal file
@ -0,0 +1,129 @@
|
||||
/**
|
||||
* MinIO Storage Service
|
||||
* S3-compatible object storage for media files
|
||||
*
|
||||
* MinIO Console: https://minio-console.eluxnetworks.net
|
||||
* S3 Endpoint: https://minio.eluxnetworks.net
|
||||
*/
|
||||
|
||||
const { S3Client, PutObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// MinIO Configuration
|
||||
const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || 'https://minio.eluxnetworks.net';
|
||||
const MINIO_ACCESS_KEY = process.env.MINIO_ACCESS_KEY;
|
||||
const MINIO_SECRET_KEY = process.env.MINIO_SECRET_KEY;
|
||||
const MINIO_BUCKET = process.env.MINIO_BUCKET || 'wellnuo';
|
||||
|
||||
// Public URL for accessing files
|
||||
const PUBLIC_URL = process.env.MINIO_PUBLIC_URL || MINIO_ENDPOINT;
|
||||
|
||||
// Create S3 client for MinIO
|
||||
const s3Client = new S3Client({
|
||||
endpoint: MINIO_ENDPOINT,
|
||||
region: 'us-east-1', // MinIO ignores this but requires it
|
||||
credentials: {
|
||||
accessKeyId: MINIO_ACCESS_KEY,
|
||||
secretAccessKey: MINIO_SECRET_KEY,
|
||||
},
|
||||
forcePathStyle: true, // Required for MinIO
|
||||
});
|
||||
|
||||
/**
|
||||
* Upload a base64 image to MinIO
|
||||
* @param {string} base64Data - Base64 data URI (data:image/png;base64,...)
|
||||
* @param {string} folder - Folder path (e.g., 'avatars/beneficiaries')
|
||||
* @param {string} [filename] - Optional filename, generated if not provided
|
||||
* @returns {Promise<{url: string, key: string}>}
|
||||
*/
|
||||
async function uploadBase64Image(base64Data, folder, filename = null) {
|
||||
if (!MINIO_ACCESS_KEY || !MINIO_SECRET_KEY) {
|
||||
throw new Error('MinIO credentials not configured');
|
||||
}
|
||||
|
||||
// Parse base64 data URI
|
||||
const matches = base64Data.match(/^data:image\/(\w+);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
throw new Error('Invalid base64 image format');
|
||||
}
|
||||
|
||||
const [, extension, base64Content] = matches;
|
||||
const buffer = Buffer.from(base64Content, 'base64');
|
||||
|
||||
// Generate filename if not provided
|
||||
const finalFilename = filename || `${crypto.randomUUID()}.${extension}`;
|
||||
const key = `${folder}/${finalFilename}`;
|
||||
|
||||
// Determine content type
|
||||
const contentType = `image/${extension === 'jpg' ? 'jpeg' : extension}`;
|
||||
|
||||
console.log(`[STORAGE] Uploading ${key} (${buffer.length} bytes)`);
|
||||
|
||||
// Upload to MinIO
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: MINIO_BUCKET,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
ACL: 'public-read', // Make file publicly accessible
|
||||
});
|
||||
|
||||
await s3Client.send(command);
|
||||
|
||||
// Return public URL
|
||||
const url = `${PUBLIC_URL}/${MINIO_BUCKET}/${key}`;
|
||||
|
||||
console.log(`[STORAGE] Uploaded successfully: ${url}`);
|
||||
|
||||
return { url, key };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from MinIO
|
||||
* @param {string} key - Object key (path in bucket)
|
||||
*/
|
||||
async function deleteFile(key) {
|
||||
if (!MINIO_ACCESS_KEY || !MINIO_SECRET_KEY) {
|
||||
throw new Error('MinIO credentials not configured');
|
||||
}
|
||||
|
||||
console.log(`[STORAGE] Deleting ${key}`);
|
||||
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: MINIO_BUCKET,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
await s3Client.send(command);
|
||||
|
||||
console.log(`[STORAGE] Deleted successfully: ${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract key from MinIO URL
|
||||
* @param {string} url - Full MinIO URL
|
||||
* @returns {string|null} - Object key or null
|
||||
*/
|
||||
function extractKeyFromUrl(url) {
|
||||
if (!url) return null;
|
||||
|
||||
// URL format: https://minio.eluxnetworks.net/wellnuo/avatars/xxx.png
|
||||
const match = url.match(/\/wellnuo\/(.+)$/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if storage is configured
|
||||
*/
|
||||
function isConfigured() {
|
||||
return !!(MINIO_ACCESS_KEY && MINIO_SECRET_KEY);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
uploadBase64Image,
|
||||
deleteFile,
|
||||
extractKeyFromUrl,
|
||||
isConfigured,
|
||||
MINIO_BUCKET,
|
||||
PUBLIC_URL,
|
||||
};
|
||||
134
backend/src/services/subscription-sync.js
Normal file
134
backend/src/services/subscription-sync.js
Normal file
@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Subscription Sync Service
|
||||
* Синхронизирует статусы подписок из Stripe в БД
|
||||
* Запускается раз в час через cron
|
||||
*/
|
||||
|
||||
const Stripe = require('stripe');
|
||||
const { supabase } = require('../config/supabase');
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
/**
|
||||
* Нормализует статус Stripe в наш формат
|
||||
*/
|
||||
function normalizeStripeStatus(stripeStatus) {
|
||||
switch (stripeStatus) {
|
||||
case 'active': return 'active';
|
||||
case 'trialing': return 'trialing';
|
||||
case 'past_due': return 'past_due';
|
||||
case 'canceled': return 'canceled';
|
||||
case 'incomplete': return 'none';
|
||||
case 'incomplete_expired': return 'expired';
|
||||
case 'unpaid': return 'past_due';
|
||||
default: return 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронизирует все подписки из Stripe в БД
|
||||
*/
|
||||
async function syncAllSubscriptions() {
|
||||
const startTime = Date.now();
|
||||
console.log('[SUBSCRIPTION SYNC] Starting...');
|
||||
|
||||
try {
|
||||
// 1. Получаем всех beneficiaries с stripe_customer_id (где он не NULL)
|
||||
const { data: allBeneficiaries, error: dbError } = await supabase
|
||||
.from('beneficiaries')
|
||||
.select('id, stripe_customer_id');
|
||||
|
||||
// Фильтруем только тех у кого есть stripe_customer_id
|
||||
const beneficiaries = (allBeneficiaries || []).filter(b => b.stripe_customer_id);
|
||||
|
||||
if (dbError) {
|
||||
console.error('[SUBSCRIPTION SYNC] DB Error:', dbError.message);
|
||||
return { success: false, error: dbError.message };
|
||||
}
|
||||
|
||||
if (!beneficiaries || beneficiaries.length === 0) {
|
||||
console.log('[SUBSCRIPTION SYNC] No beneficiaries with stripe_customer_id');
|
||||
return { success: true, updated: 0 };
|
||||
}
|
||||
|
||||
// Создаём map: stripe_customer_id -> beneficiary_id
|
||||
const customerToBeneficiary = {};
|
||||
for (const b of beneficiaries) {
|
||||
customerToBeneficiary[b.stripe_customer_id] = b.id;
|
||||
}
|
||||
|
||||
const customerIds = Object.keys(customerToBeneficiary);
|
||||
console.log(`[SUBSCRIPTION SYNC] Found ${customerIds.length} beneficiaries with Stripe customers`);
|
||||
|
||||
// 2. Получаем ВСЕ активные подписки из Stripe (batch)
|
||||
const stripeStartTime = Date.now();
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
limit: 100,
|
||||
expand: ['data.customer']
|
||||
});
|
||||
console.log(`[SUBSCRIPTION SYNC] Fetched ${subscriptions.data.length} subscriptions from Stripe in ${Date.now() - stripeStartTime}ms`);
|
||||
|
||||
// 3. Строим map: stripe_customer_id -> subscription status
|
||||
const subscriptionMap = {};
|
||||
|
||||
// Сначала все ставим в 'none'
|
||||
for (const customerId of customerIds) {
|
||||
subscriptionMap[customerId] = 'none';
|
||||
}
|
||||
|
||||
// Затем обновляем по данным из Stripe
|
||||
const statusPriority = { active: 4, trialing: 3, past_due: 2, canceled: 1, expired: 0, none: 0 };
|
||||
|
||||
for (const sub of subscriptions.data) {
|
||||
const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id;
|
||||
if (!customerId || !customerIds.includes(customerId)) continue;
|
||||
|
||||
const normalizedStatus = normalizeStripeStatus(sub.status);
|
||||
const currentStatus = subscriptionMap[customerId] || 'none';
|
||||
|
||||
// Берём статус с наивысшим приоритетом
|
||||
if (statusPriority[normalizedStatus] > statusPriority[currentStatus]) {
|
||||
subscriptionMap[customerId] = normalizedStatus;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Обновляем БД batch'ом
|
||||
const updateStartTime = Date.now();
|
||||
let updated = 0;
|
||||
|
||||
for (const [customerId, status] of Object.entries(subscriptionMap)) {
|
||||
const beneficiaryId = customerToBeneficiary[customerId];
|
||||
if (!beneficiaryId) continue;
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('beneficiaries')
|
||||
.update({
|
||||
subscription_status: status,
|
||||
subscription_updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', beneficiaryId);
|
||||
|
||||
if (updateError) {
|
||||
console.error(`[SUBSCRIPTION SYNC] Error updating beneficiary ${beneficiaryId}:`, updateError.message);
|
||||
} else {
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
const totalTime = Date.now() - startTime;
|
||||
console.log(`[SUBSCRIPTION SYNC] Completed: ${updated}/${customerIds.length} updated in ${totalTime}ms`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
updated,
|
||||
total: customerIds.length,
|
||||
timeMs: totalTime
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('[SUBSCRIPTION SYNC] Error:', error.message);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { syncAllSubscriptions, normalizeStripeStatus };
|
||||
@ -1,6 +1,6 @@
|
||||
import type { ApiError, ApiResponse, AuthResponse, Beneficiary, BeneficiaryDashboardData, ChatResponse, DashboardSingleResponse, NotificationSettings } from '@/types';
|
||||
import * as Crypto from 'expo-crypto';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
// Callback for handling unauthorized responses (401)
|
||||
@ -776,9 +776,9 @@ class ApiService {
|
||||
let base64Image: string | null = null;
|
||||
|
||||
if (imageUri) {
|
||||
// Read file as base64 string using expo-file-system
|
||||
// Read file as base64 using stable FileSystem API
|
||||
const base64Data = await FileSystem.readAsStringAsync(imageUri, {
|
||||
encoding: 'base64',
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
|
||||
// Determine mime type from URI extension
|
||||
|
||||
@ -82,6 +82,8 @@ export interface Beneficiary {
|
||||
equipmentStatus?: EquipmentStatus;
|
||||
trackingNumber?: string; // Shipping tracking number
|
||||
isDemo?: boolean; // Demo mode flag
|
||||
// User's role for this beneficiary
|
||||
role?: 'custodian' | 'guardian' | 'caretaker';
|
||||
// Invitations for sharing access
|
||||
invitations?: {
|
||||
id: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user