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,
|
href: null,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="bug"
|
||||||
|
options={{
|
||||||
|
title: 'Test',
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<Ionicons
|
||||||
|
name={focused ? 'bug' : 'bug-outline'}
|
||||||
|
size={24}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="chat"
|
name="chat"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
@ -333,7 +333,7 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
<Ionicons name="arrow-back" size={22} color={AppColors.textPrimary} />
|
<Ionicons name="arrow-back" size={22} color={AppColors.textPrimary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Avatar + Name */}
|
{/* Avatar + Name + Role */}
|
||||||
<View style={styles.headerCenter}>
|
<View style={styles.headerCenter}>
|
||||||
{beneficiary.avatar && beneficiary.avatar.trim() !== '' && !beneficiary.avatar.includes('placeholder') ? (
|
{beneficiary.avatar && beneficiary.avatar.trim() !== '' && !beneficiary.avatar.includes('placeholder') ? (
|
||||||
<Image source={{ uri: beneficiary.avatar }} style={styles.headerAvatarImage} />
|
<Image source={{ uri: beneficiary.avatar }} style={styles.headerAvatarImage} />
|
||||||
@ -344,7 +344,14 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
<View style={styles.headerTitleContainer}>
|
||||||
<Text style={styles.headerTitle}>{beneficiary.name}</Text>
|
<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>
|
</View>
|
||||||
|
|
||||||
<BeneficiaryMenu
|
<BeneficiaryMenu
|
||||||
@ -566,11 +573,19 @@ const styles = StyleSheet.create({
|
|||||||
height: AvatarSizes.sm,
|
height: AvatarSizes.sm,
|
||||||
borderRadius: AvatarSizes.sm / 2,
|
borderRadius: AvatarSizes.sm / 2,
|
||||||
},
|
},
|
||||||
|
headerTitleContainer: {
|
||||||
|
flexDirection: 'column',
|
||||||
|
},
|
||||||
headerTitle: {
|
headerTitle: {
|
||||||
fontSize: FontSizes.lg,
|
fontSize: FontSizes.lg,
|
||||||
fontWeight: FontWeights.semibold,
|
fontWeight: FontWeights.semibold,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
},
|
},
|
||||||
|
headerRole: {
|
||||||
|
fontSize: FontSizes.xs,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
marginTop: 1,
|
||||||
|
},
|
||||||
// Debug Panel
|
// Debug Panel
|
||||||
debugPanel: {
|
debugPanel: {
|
||||||
backgroundColor: '#FFF9C4',
|
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,
|
color: AppColors.success,
|
||||||
bgColor: AppColors.successLight,
|
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) {
|
function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardProps) {
|
||||||
const equipmentStatus = beneficiary.equipmentStatus;
|
const equipmentStatus = beneficiary.equipmentStatus;
|
||||||
const isAwaitingEquipment = equipmentStatus && ['ordered', 'shipped', 'delivered'].includes(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;
|
const statusConfig = equipmentStatus ? equipmentStatusConfig[equipmentStatus as keyof typeof equipmentStatusConfig] : null;
|
||||||
|
|
||||||
// Check if has devices/equipment connected
|
// Check if has devices/equipment connected
|
||||||
@ -114,7 +129,7 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
|
|||||||
{/* Name and Status */}
|
{/* Name and Status */}
|
||||||
<View style={styles.info}>
|
<View style={styles.info}>
|
||||||
<Text style={styles.name} numberOfLines={1}>{beneficiary.name}</Text>
|
<Text style={styles.name} numberOfLines={1}>{beneficiary.name}</Text>
|
||||||
{/* Equipment status badge */}
|
{/* Equipment status badge (awaiting delivery) */}
|
||||||
{isAwaitingEquipment && statusConfig && (
|
{isAwaitingEquipment && statusConfig && (
|
||||||
<View style={[styles.statusBadge, { backgroundColor: statusConfig.bgColor }]}>
|
<View style={[styles.statusBadge, { backgroundColor: statusConfig.bgColor }]}>
|
||||||
<Ionicons name={statusConfig.icon} size={14} color={statusConfig.color} />
|
<Ionicons name={statusConfig.icon} size={14} color={statusConfig.color} />
|
||||||
@ -123,6 +138,15 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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 */}
|
{/* No subscription warning */}
|
||||||
{hasNoSubscription && (
|
{hasNoSubscription && (
|
||||||
<View style={styles.noSubscriptionBadge}>
|
<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"
|
"dev": "nodemon src/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.966.0",
|
||||||
"@supabase/supabase-js": "^2.39.0",
|
"@supabase/supabase-js": "^2.39.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@ -17,6 +18,7 @@
|
|||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"stripe": "^20.1.0"
|
"stripe": "^20.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -4,6 +4,7 @@ const cors = require('cors');
|
|||||||
const helmet = require('helmet');
|
const helmet = require('helmet');
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const cron = require('node-cron');
|
||||||
const apiRouter = require('./routes/api');
|
const apiRouter = require('./routes/api');
|
||||||
const authRouter = require('./routes/auth');
|
const authRouter = require('./routes/auth');
|
||||||
const beneficiariesRouter = require('./routes/beneficiaries');
|
const beneficiariesRouter = require('./routes/beneficiaries');
|
||||||
@ -14,6 +15,7 @@ const ordersRouter = require('./routes/orders');
|
|||||||
const stripeRouter = require('./routes/stripe');
|
const stripeRouter = require('./routes/stripe');
|
||||||
const webhookRouter = require('./routes/webhook');
|
const webhookRouter = require('./routes/webhook');
|
||||||
const adminRouter = require('./routes/admin');
|
const adminRouter = require('./routes/admin');
|
||||||
|
const { syncAllSubscriptions } = require('./services/subscription-sync');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
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, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`WellNuo API running on port ${PORT}`);
|
console.log(`WellNuo API running on port ${PORT}`);
|
||||||
console.log(`Stripe: ${process.env.STRIPE_SECRET_KEY ? '✓ configured' : '✗ missing'}`);
|
console.log(`Stripe: ${process.env.STRIPE_SECRET_KEY ? '✓ configured' : '✗ missing'}`);
|
||||||
|
|||||||
@ -3,6 +3,7 @@ const router = express.Router();
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const Stripe = require('stripe');
|
const Stripe = require('stripe');
|
||||||
const { supabase } = require('../config/supabase');
|
const { supabase } = require('../config/supabase');
|
||||||
|
const storage = require('../services/storage');
|
||||||
|
|
||||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||||
|
|
||||||
@ -178,7 +179,7 @@ router.use(authMiddleware);
|
|||||||
* GET /api/me/beneficiaries
|
* GET /api/me/beneficiaries
|
||||||
* Returns list of beneficiaries the user has access to
|
* Returns list of beneficiaries the user has access to
|
||||||
* Now uses the proper beneficiaries table (not users)
|
* 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) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@ -209,10 +210,10 @@ router.get('/', async (req, res) => {
|
|||||||
return res.json({ beneficiaries: [] });
|
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
|
const { data: beneficiariesData, error: beneficiariesError } = await supabase
|
||||||
.from('beneficiaries')
|
.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);
|
.in('id', beneficiaryIds);
|
||||||
|
|
||||||
if (beneficiariesError) {
|
if (beneficiariesError) {
|
||||||
@ -222,25 +223,19 @@ router.get('/', async (req, res) => {
|
|||||||
|
|
||||||
const dbTime = Date.now() - startTime;
|
const dbTime = Date.now() - startTime;
|
||||||
|
|
||||||
// Collect all Stripe customer IDs for batch request
|
// Build response - subscription status from DB (no Stripe calls!)
|
||||||
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
|
|
||||||
const beneficiaries = [];
|
const beneficiaries = [];
|
||||||
for (const record of accessRecords) {
|
for (const record of accessRecords) {
|
||||||
const beneficiary = beneficiariesData.find(b => b.id === record.beneficiary_id);
|
const beneficiary = beneficiariesData.find(b => b.id === record.beneficiary_id);
|
||||||
if (!beneficiary) continue;
|
if (!beneficiary) continue;
|
||||||
|
|
||||||
const subscription = beneficiary.stripe_customer_id
|
// Build subscription object from cached DB status
|
||||||
? (subscriptionMap[beneficiary.stripe_customer_id] || { plan: 'free', status: 'none', hasSubscription: false })
|
const status = beneficiary.subscription_status || 'none';
|
||||||
: { plan: 'free', status: 'none', hasSubscription: false };
|
const subscription = {
|
||||||
|
plan: status === 'active' || status === 'trialing' ? 'premium' : 'free',
|
||||||
|
status: status,
|
||||||
|
hasSubscription: status === 'active' || status === 'trialing'
|
||||||
|
};
|
||||||
|
|
||||||
beneficiaries.push({
|
beneficiaries.push({
|
||||||
accessId: record.id,
|
accessId: record.id,
|
||||||
@ -260,7 +255,7 @@ router.get('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totalTime = Date.now() - startTime;
|
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 });
|
res.json({ beneficiaries });
|
||||||
|
|
||||||
@ -906,7 +901,9 @@ router.post('/:id/transfer', async (req, res) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/me/beneficiaries/:id/avatar
|
* 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) => {
|
router.patch('/:id/avatar', async (req, res) => {
|
||||||
try {
|
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' });
|
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
|
// Update avatar_url in beneficiaries table
|
||||||
const { data: beneficiary, error } = await supabase
|
const { data: beneficiary, error } = await supabase
|
||||||
.from('beneficiaries')
|
.from('beneficiaries')
|
||||||
.update({
|
.update({
|
||||||
avatar_url: avatar || null,
|
avatar_url: avatarUrl,
|
||||||
updated_at: new Date().toISOString()
|
updated_at: new Date().toISOString()
|
||||||
})
|
})
|
||||||
.eq('id', beneficiaryId)
|
.eq('id', beneficiaryId)
|
||||||
@ -949,7 +989,7 @@ router.patch('/:id/avatar', async (req, res) => {
|
|||||||
return res.status(500).json({ error: 'Failed to update avatar' });
|
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({
|
res.json({
|
||||||
success: true,
|
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 type { ApiError, ApiResponse, AuthResponse, Beneficiary, BeneficiaryDashboardData, ChatResponse, DashboardSingleResponse, NotificationSettings } from '@/types';
|
||||||
import * as Crypto from 'expo-crypto';
|
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';
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
|
||||||
// Callback for handling unauthorized responses (401)
|
// Callback for handling unauthorized responses (401)
|
||||||
@ -776,9 +776,9 @@ class ApiService {
|
|||||||
let base64Image: string | null = null;
|
let base64Image: string | null = null;
|
||||||
|
|
||||||
if (imageUri) {
|
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, {
|
const base64Data = await FileSystem.readAsStringAsync(imageUri, {
|
||||||
encoding: 'base64',
|
encoding: FileSystem.EncodingType.Base64,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Determine mime type from URI extension
|
// Determine mime type from URI extension
|
||||||
|
|||||||
@ -82,6 +82,8 @@ export interface Beneficiary {
|
|||||||
equipmentStatus?: EquipmentStatus;
|
equipmentStatus?: EquipmentStatus;
|
||||||
trackingNumber?: string; // Shipping tracking number
|
trackingNumber?: string; // Shipping tracking number
|
||||||
isDemo?: boolean; // Demo mode flag
|
isDemo?: boolean; // Demo mode flag
|
||||||
|
// User's role for this beneficiary
|
||||||
|
role?: 'custodian' | 'guardian' | 'caretaker';
|
||||||
// Invitations for sharing access
|
// Invitations for sharing access
|
||||||
invitations?: {
|
invitations?: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user