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:
Sergei 2026-01-09 19:08:12 -08:00
parent 28323507f8
commit e74d1a4b26
12 changed files with 2411 additions and 27 deletions

View File

@ -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={{

View File

@ -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
View 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,
},
});

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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"
},

View File

@ -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'}`);

View File

@ -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,

View 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,
};

View 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 };

View File

@ -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

View File

@ -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;