diff --git a/.gitignore b/.gitignore index 410e873e7..4605fa281 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,4 @@ docker/docker_volume/ # TypeScript *.tsbuildinfo +pnpm-lock.yaml diff --git a/SparkyFitnessFrontend/public/locales/en/translation.json b/SparkyFitnessFrontend/public/locales/en/translation.json index 1611dbf1f..37abbe240 100644 --- a/SparkyFitnessFrontend/public/locales/en/translation.json +++ b/SparkyFitnessFrontend/public/locales/en/translation.json @@ -484,7 +484,22 @@ "milliliters": "Milliliters (ml)", "fluidOunces": "Fluid Ounces (oz)", "cups": "Cups", - "saveWaterDisplayUnit": "Save Water Display Unit" + "saveWaterDisplayUnit": "Save Water Display Unit", + "telegram_status": "Telegram Bot Status", + "telegram_not_linked": "Not Linked", + "telegram_linked": "Linked (Chat ID: {{chatId}})", + "telegram_link_button": "Link Telegram Account", + "telegram_unlink_button": "Unlink Telegram", + "telegram_code_description": "Send this code to the bot to link your account:", + "telegram_get_code": "Generate Link Code", + "telegram_integration_description": "Log food and exercises directly from Telegram using AI.", + "telegram_bot_token": "Telegram Bot Token", + "telegram_bot_name": "Telegram Bot Name", + "telegram_webhook_url": "Telegram Webhook URL", + "edamam_app_id": "Edamam App ID", + "edamam_app_key": "Edamam App Key", + "fatsecret_client_id": "FatSecret Client ID", + "fatsecret_client_secret": "FatSecret Client Secret" }, "nutrientDisplay": { "title": "Nutrient Display", diff --git a/SparkyFitnessFrontend/src/api/Settings/externalProviderService.ts b/SparkyFitnessFrontend/src/api/Settings/externalProviderService.ts index c47f307a9..ed5630547 100644 --- a/SparkyFitnessFrontend/src/api/Settings/externalProviderService.ts +++ b/SparkyFitnessFrontend/src/api/Settings/externalProviderService.ts @@ -307,6 +307,21 @@ export const handleManualSyncStrava = async ( } }; +export const handleManualSyncMFP = async ( + startDate?: string, + endDate?: string +) => { + try { + await apiCall(`/integrations/myfitnesspal/sync`, { + method: 'POST', + body: JSON.stringify({ startDate, endDate }), + }); + } catch (error: unknown) { + console.error('Error initiating manual MFP sync:', error); + throw error; + } +}; + export const fetchBaseProviders = async (): Promise => { return apiCall('/external-providers', { method: 'GET', @@ -325,8 +340,10 @@ export const fetchGarminStatus = async (): Promise => { }; export interface OAuthStatusResponse { - lastSyncAt: string; - tokenExpiresAt: string; + lastSyncAt?: string; + tokenExpiresAt?: string; + isLinked?: boolean; + lastUpdated?: string; } export const fetchWithingsStatus = async ( @@ -364,6 +381,10 @@ export const fetchStravaStatus = async (): Promise => { return apiCall('/integrations/strava/status'); }; +export const fetchMFPStatus = async (): Promise => { + return apiCall('/integrations/myfitnesspal/status'); +}; + export const getEnrichedProviders = async (): Promise< ExternalDataProvider[] > => { @@ -429,6 +450,12 @@ export const getEnrichedProviders = async (): Promise< } break; } + case 'myfitnesspal': { + const status = await fetchMFPStatus(); + enriched.has_token = status.isLinked; + enriched.last_sync_at = status.lastUpdated; // Standard field + break; + } } } catch (error) { console.error( diff --git a/SparkyFitnessFrontend/src/components/FoodSearch/FoodSearch.tsx b/SparkyFitnessFrontend/src/components/FoodSearch/FoodSearch.tsx index 5e38ae156..7fac88d7d 100644 --- a/SparkyFitnessFrontend/src/components/FoodSearch/FoodSearch.tsx +++ b/SparkyFitnessFrontend/src/components/FoodSearch/FoodSearch.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -70,6 +70,10 @@ export type ExternalResultWrapper = | { provider_type: 'tandoor'; food: Food; + } + | { + provider_type: 'edamam'; + food: Food; }; interface EnhancedFoodSearchProps { @@ -153,11 +157,21 @@ const EnhancedFoodSearch = ({ const foods = searchData?.searchResults || []; const loading = isFetchingRecent || isFetchingSearch || isOnlineLoading; - const selectedFoodDataProvider = - manualProviderId || - defaultFoodDataProviderId || - foodDataProviders[0]?.id || - null; + const selectedFoodDataProviderId = useMemo(() => { + const id = manualProviderId || defaultFoodDataProviderId; + if (id && foodDataProviders.find((p) => p.id === id)) { + return id; + } + return ( + foodDataProviders.find( + (p) => getProviderCategory(p).includes('food') && p.is_active + )?.id || null + ); + }, [manualProviderId, defaultFoodDataProviderId, foodDataProviders]); + + const selectedFoodDataProvider = foodDataProviders.find( + (p) => p.id === selectedFoodDataProviderId + ); // Barcode provider: prefer explicit user selection, then the dedicated barcode // provider preference (set in External Provider Settings → Default Barcode Provider), @@ -358,6 +372,17 @@ const EnhancedFoodSearch = ({ })) ); }, + edamam: async (term, id) => { + const data = await queryClient.fetchQuery( + searchFoodsV2Options('edamam', term, id) + ); + setExternalResults( + data.foods.map((food: Food) => ({ + provider_type: 'edamam' as const, + food, + })) + ); + }, }; const handleSearch = async () => { @@ -374,19 +399,48 @@ const EnhancedFoodSearch = ({ await handleMealSearch(searchTerm); } else if (activeTab === 'online') { setHasOnlineSearchBeenPerformed(true); - const provider = foodDataProviders.find( - (p) => p.id === selectedFoodDataProvider - ); - if (provider && searchHandlers[provider.provider_type]) { + const provider = selectedFoodDataProvider; + const providerType = provider?.provider_type?.toLowerCase()?.trim(); + + console.group('Food Search Diagnostics'); + console.log('Selected Provider ID:', selectedFoodDataProviderId); + console.log('Found Provider:', provider); + console.log('Provider Type:', providerType); + console.log('Available Handlers:', Object.keys(searchHandlers)); + console.log('Search Term:', searchTerm); + console.groupEnd(); + + if (provider && providerType && searchHandlers[providerType]) { setSearchProviderId(provider.id); - const searchHandler = searchHandlers[provider.provider_type]; - if (searchHandler) - await searchHandler(searchTerm, provider.id, provider); + const searchHandler = searchHandlers[providerType]; + if (searchHandler) { + try { + await searchHandler(searchTerm, provider.id, provider); + } catch (error) { + console.error('Search handler error:', error); + toast({ + title: t('common.error'), + description: t( + 'enhancedFoodSearch.searchError', + 'Failed to perform search.' + ), + variant: 'destructive', + }); + } + } } else { + const errorDetail = !provider + ? 'Provider not found in list' + : !providerType + ? 'Provider type is missing' + : !searchHandlers[providerType] + ? `No handler for type: ${providerType}` + : 'Unknown error'; + toast({ title: t('common.error'), - description: 'Provider not supported', + description: `Provider not supported (${errorDetail})`, variant: 'destructive', }); } @@ -422,7 +476,9 @@ const EnhancedFoodSearch = ({ const handleExternalFoodEdit = async (food: Food) => { const needsDetailFetch = - (food.provider_type === 'fatsecret' || food.provider_type === 'usda') && + (food.provider_type === 'fatsecret' || + food.provider_type === 'usda' || + food.provider_type === 'edamam') && food.provider_external_id; if (needsDetailFetch) { @@ -560,7 +616,7 @@ const EnhancedFoodSearch = ({ {activeTab === 'online' && ( + setNewProvider((prev) => ({ + ...prev, + sync_frequency: value as 'hourly' | 'daily' | 'manual', + })) + } + > + + + + + Manual + Hourly + Daily + + + + )} +
- and ensuring your local URL is correct if testing locally. Note: - Strava callback URL on the server is configured to: {`${window.location.origin}/strava/callback`}

)} + {editData.provider_type === 'myfitnesspal' && ( + <> + {/* Show connection status for connected MyFitnessPal accounts instead of credential fields */} + {provider.app_id && provider.app_key ? ( +
+
+
+ Connected to MyFitnessPal +
+

+ Your MyFitnessPal account is connected. To reconnect with + different credentials, disconnect first and add a new provider. +

+
+ ) : ( + <> +
+ + + setEditData((prev) => ({ + ...prev, + app_id: e.target.value, + })) + } + placeholder="Paste x-csrf-token from Network tab" + autoComplete="off" + /> +
+
+ + + setEditData((prev) => ({ + ...prev, + app_key: e.target.value, + })) + } + placeholder="Paste full Cookie string from Network tab" + autoComplete="off" + /> +
+

+ How to find: Open MyFitnessPal in browser, + press F12 (Network tab), find a request to{' '} + www.myfitnesspal.com, and copy Cookie{' '} + and x-csrf-token from Request Headers. +

+ + )} + + )} {editData.provider_type === 'hevy' && ( <>
@@ -523,7 +592,8 @@ export const EditProviderForm = ({ editData.provider_type === 'fitbit' || editData.provider_type === 'strava' || editData.provider_type === 'polar' || - editData.provider_type === 'hevy') && ( + editData.provider_type === 'hevy' || + editData.provider_type === 'myfitnesspal') && (
+ setProvider((prev) => ({ ...prev, app_id: e.target.value })) + } + placeholder="Paste x-csrf-token from Network tab" + autoComplete="off" + /> +
+
+ + + setProvider((prev) => ({ ...prev, app_key: e.target.value })) + } + placeholder="Paste full Cookie string from Network tab" + autoComplete="off" + /> +
+

+ How to find: Open MyFitnessPal in browser, press + F12 (Network tab), find a request to{' '} + www.myfitnesspal.com, and copy Cookie and{' '} + x-csrf-token from Request Headers. +

+ + )} + {['withings', 'fitbit', 'strava', 'polar'].includes( provider.provider_type || '' ) && ( diff --git a/SparkyFitnessFrontend/src/types/food.ts b/SparkyFitnessFrontend/src/types/food.ts index 960687bf0..e5787c59e 100644 --- a/SparkyFitnessFrontend/src/types/food.ts +++ b/SparkyFitnessFrontend/src/types/food.ts @@ -48,7 +48,8 @@ export interface Food { | 'fatsecret' | 'mealie' | 'tandoor' - | 'usda'; + | 'usda' + | 'edamam'; default_variant?: FoodVariant; variants?: FoodVariant[]; is_quick_food?: boolean; diff --git a/SparkyFitnessFrontend/src/utils/languageUtils.ts b/SparkyFitnessFrontend/src/utils/languageUtils.ts index 4717e98ab..6ce6cf591 100644 --- a/SparkyFitnessFrontend/src/utils/languageUtils.ts +++ b/SparkyFitnessFrontend/src/utils/languageUtils.ts @@ -15,6 +15,7 @@ export const getSupportedLanguages = (): string[] => { 'sl', 'sv', 'ta', + 'uk', ]; }; @@ -44,6 +45,8 @@ export const getLanguageDisplayName = (langCode: string): string => { return 'Svenska'; case 'ta': return 'தமிழ்'; + case 'uk': + return 'Українська'; default: return langCode; } diff --git a/SparkyFitnessFrontend/src/utils/settings.ts b/SparkyFitnessFrontend/src/utils/settings.ts index 9a9d96043..7c80cdc79 100644 --- a/SparkyFitnessFrontend/src/utils/settings.ts +++ b/SparkyFitnessFrontend/src/utils/settings.ts @@ -6,6 +6,7 @@ export const providerRequirements: Record = { tandoor: ['base_url', 'app_key'], nutritionix: ['app_id', 'app_key'], fatsecret: ['app_id', 'app_key'], + edamam: ['app_id', 'app_key'], withings: ['app_id', 'app_key'], fitbit: ['app_id', 'app_key'], garmin: ['app_id', 'app_key'], @@ -13,6 +14,7 @@ export const providerRequirements: Record = { strava: ['app_id', 'app_key'], usda: ['app_key'], hevy: ['app_key'], + myfitnesspal: ['app_id', 'app_key'], }; export const validateProvider = ( @@ -36,6 +38,7 @@ export const getProviderTypes = () => [ { value: 'openfoodfacts', label: 'OpenFoodFacts' }, { value: 'nutritionix', label: 'Nutritionix' }, { value: 'fatsecret', label: 'FatSecret' }, + { value: 'edamam', label: 'Edamam' }, { value: 'wger', label: 'Wger (Exercise)' }, { value: 'free-exercise-db', label: 'Free Exercise DB' }, { value: 'mealie', label: 'Mealie' }, @@ -47,6 +50,7 @@ export const getProviderTypes = () => [ { value: 'strava', label: 'Strava' }, { value: 'hevy', label: 'Hevy' }, { value: 'usda', label: 'USDA' }, + { value: 'myfitnesspal', label: 'MyFitnessPal (Garmin Sync)' }, ]; export const getInitials = (name: string | null) => { diff --git a/SparkyFitnessServer/SparkyFitnessServer.js b/SparkyFitnessServer/SparkyFitnessServer.js index 468e925fa..ef9c69a96 100644 --- a/SparkyFitnessServer/SparkyFitnessServer.js +++ b/SparkyFitnessServer/SparkyFitnessServer.js @@ -65,6 +65,8 @@ const globalSettingsRoutes = require('./routes/globalSettingsRoutes'); const versionRoutes = require('./routes/versionRoutes'); const onboardingRoutes = require('./routes/onboardingRoutes'); // Import onboarding routes const customNutrientRoutes = require('./routes/customNutrientRoutes'); // Import custom nutrient routes +const telegramRoutes = require('./routes/telegramRoutes'); +const mfpRoutes = require('./routes/mfpRoutes'); const { applyMigrations } = require('./utils/dbMigrations'); const { applyRlsPolicies } = require('./utils/applyRlsPolicies'); const waterContainerRoutes = require('./routes/waterContainerRoutes'); @@ -82,6 +84,8 @@ const garminService = require('./services/garminService'); // Import garminServi const fitbitService = require('./services/fitbitService'); // Import fitbitService const polarService = require('./services/polarService'); // Import polarService const stravaService = require('./services/stravaService'); // Import stravaService +const mfpSyncService = require('./services/mfpSyncService'); // Import mfpSyncService +const telegramBotService = require('./integrations/telegram/telegramBotService'); const dailySummaryRoutes = require('./routes/dailySummaryRoutes'); const dashboardRoutes = require('./routes/dashboardRoutes'); const mealTypeRoutes = require('./routes/mealTypeRoutes'); @@ -381,6 +385,7 @@ app.use('/api/integrations/fitbit', fitbitRoutes); app.use('/api/integrations/polar', polarRoutes); app.use('/api/integrations/strava', stravaRoutes); app.use('/api/integrations/hevy', hevyRoutes); +app.use('/api/integrations/myfitnesspal', mfpRoutes); app.use('/api/mood', moodRoutes); app.use('/api/fasting', fastingRoutes); app.use('/api/admin', adminRoutes); @@ -398,6 +403,8 @@ app.use('/api/review', reviewRoutes); app.use('/api/custom-nutrients', customNutrientRoutes); app.use('/api/adaptive-tdee', adaptiveTdeeRoutes); app.use('/api/meal-types', mealTypeRoutes); +app.use('/api/telegram', telegramRoutes); +app.use('/api/integrations/myfitnesspal', mfpRoutes); // Swagger app.use( @@ -554,6 +561,36 @@ const schedulePolarSyncs = async () => { }); }; +// MyFitnessPal sync +const scheduleMFPSyncs = async () => { + cron.schedule('0 * * * *', async () => { + try { + const mfpProviders = + await externalProviderRepository.getProvidersByType('myfitnesspal'); + const today = new Date().toISOString().split('T')[0]; + + for (const provider of mfpProviders) { + if (provider.is_active && provider.sync_frequency !== 'manual') { + try { + await mfpSyncService.syncDailyTotals(provider.user_id, today); + await externalProviderRepository.updateProviderLastSync( + provider.id, + new Date() + ); + } catch (error) { + console.error( + `[CRON] MFP sync failed for user ${provider.user_id}:`, + error + ); + } + } + } + } catch (e) { + console.error('[CRON] scheduleMFPSyncs top-level error:', e); + } + }); +}; + applyMigrations() .then(applyRlsPolicies) .then(async () => { @@ -580,6 +617,12 @@ applyMigrations() scheduleFitbitSyncs(); schedulePolarSyncs(); scheduleStravaSyncs(); + scheduleMFPSyncs(); + + // Initialize Telegram Bot + telegramBotService.initialize().catch((err) => { + log('error', 'Failed to initialize Telegram bot:', err); + }); if (process.env.SPARKY_FITNESS_ADMIN_EMAIL) { const userRepository = require('./models/userRepository'); diff --git a/SparkyFitnessServer/config/logging.js b/SparkyFitnessServer/config/logging.js deleted file mode 100644 index 080938840..000000000 --- a/SparkyFitnessServer/config/logging.js +++ /dev/null @@ -1,41 +0,0 @@ -// Define logging levels -const LOG_LEVELS = { - DEBUG: 0, - INFO: 1, - WARN: 2, - ERROR: 3, - SILENT: 4, -}; - -// Get desired log level from environment variable, default to INFO -const currentLogLevel = - LOG_LEVELS[process.env.SPARKY_FITNESS_LOG_LEVEL?.trim().toUpperCase()] || - LOG_LEVELS.DEBUG; // Changed default to DEBUG for development - -// Custom logger function -function log(level, message, ...args) { - if (LOG_LEVELS[level.toUpperCase()] >= currentLogLevel) { - const timestamp = new Date().toISOString(); - switch (level.toUpperCase()) { - case 'DEBUG': - console.debug(`[${timestamp}] [DEBUG] ${message}`, ...args); - break; - case 'INFO': - console.info(`[${timestamp}] [INFO] ${message}`, ...args); - break; - case 'WARN': - console.warn(`[${timestamp}] [WARN] ${message}`, ...args); - break; - case 'ERROR': - console.error(`[${timestamp}] [ERROR] ${message}`, ...args); - break; - default: - console.log(`[${timestamp}] [UNKNOWN] ${message}`, ...args); - } - } -} - -module.exports = { - log, - LOG_LEVELS, -}; diff --git a/SparkyFitnessServer/config/logging.ts b/SparkyFitnessServer/config/logging.ts new file mode 100644 index 000000000..10c660b4d --- /dev/null +++ b/SparkyFitnessServer/config/logging.ts @@ -0,0 +1,110 @@ +import util from 'util'; + +// Define logging levels +export const LOG_LEVELS: Record = { + DEBUG: 0, + INFO: 1, + WARN: 2, + ERROR: 3, + SILENT: 4, +}; + +// Get desired log level from environment variable, default to INFO +const envLogLevel = + process.env.SPARKY_FITNESS_LOG_LEVEL?.trim().toUpperCase() || 'INFO'; +const currentLogLevel = + LOG_LEVELS[envLogLevel] !== undefined + ? LOG_LEVELS[envLogLevel] + : LOG_LEVELS.INFO; + +// Helper to truncate long strings in objects +const truncateStrings = (obj: any, maxLength: number = 1000): any => { + if (typeof obj === 'string') { + return obj.length > maxLength + ? obj.substring(0, maxLength) + '... [truncated]' + : obj; + } + if (Array.isArray(obj)) { + return obj.map((item) => truncateStrings(item, maxLength)); + } + if (typeof obj === 'object' && obj !== null) { + const newObj: any = {}; + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + newObj[key] = truncateStrings(obj[key], maxLength); + } + } + return newObj; + } + return obj; +}; + +// Custom logger function +export function log(level: string, message: string, ...args: any[]): void { + const upperLevel = level.toUpperCase(); + if (LOG_LEVELS[upperLevel] >= currentLogLevel) { + const timestamp = new Date().toISOString(); + + // Process args to avoid logging massive circular objects or huge strings + const processedArgs = args.map((arg) => { + try { + if (arg instanceof Error) { + // Log Error nicely: message + stack (if stack is available) + return { + message: arg.message, + stack: arg.stack + ? arg.stack.split('\n').slice(0, 5).join('\n') + '\n ...' + : undefined, + //@ts-ignore + code: arg.code, + //@ts-ignore + status: arg.status || arg.response?.status, + //@ts-ignore + responseData: arg.response?.data + ? truncateStrings(arg.response.data, 500) + : undefined, + }; + } + if (typeof arg === 'object' && arg !== null) { + // Deep inspection with limited depth for other objects + // Also truncate strings to avoid base64 floods + const sanitized = truncateStrings(arg, 200); + return util.inspect(sanitized, { + depth: 1, + colors: false, + compact: true, + breakLength: Infinity, + }); + } + if (typeof arg === 'string') { + return arg.length > 2000 + ? arg.substring(0, 2000) + '... [truncated]' + : arg; + } + return arg; + } catch (err) { + return '[Serialization Error]'; + } + }); + + switch (upperLevel) { + case 'DEBUG': + console.debug(`[${timestamp}] [DEBUG] ${message}`, ...processedArgs); + break; + case 'INFO': + console.info(`[${timestamp}] [INFO] ${message}`, ...processedArgs); + break; + case 'WARN': + console.warn(`[${timestamp}] [WARN] ${message}`, ...processedArgs); + break; + case 'ERROR': + console.error(`[${timestamp}] [ERROR] ${message}`, ...processedArgs); + break; + default: + console.log( + `[${timestamp}] [${upperLevel}] ${message}`, + ...processedArgs + ); + } + } +} diff --git a/SparkyFitnessServer/db/migrations/20260404120000_add_edamam_provider_type.sql b/SparkyFitnessServer/db/migrations/20260404120000_add_edamam_provider_type.sql new file mode 100644 index 000000000..09979f11c --- /dev/null +++ b/SparkyFitnessServer/db/migrations/20260404120000_add_edamam_provider_type.sql @@ -0,0 +1,8 @@ +-- Add Edamam as a food data provider type +INSERT INTO external_provider_types (id, display_name, description) +VALUES ( + 'edamam', + 'Edamam', + 'Edamam Food Database — multilingual food search with nutritional data (requires free API key from developer.edamam.com)' +) +ON CONFLICT (id) DO NOTHING; diff --git a/SparkyFitnessServer/integrations/edamam/edamamService.js b/SparkyFitnessServer/integrations/edamam/edamamService.js new file mode 100644 index 000000000..ffa74dc52 --- /dev/null +++ b/SparkyFitnessServer/integrations/edamam/edamamService.js @@ -0,0 +1,205 @@ +const { log } = require('../../config/logging'); + +const EDAMAM_PARSER_URL = 'https://api.edamam.com/api/food-database/v2/parser'; +const EDAMAM_NUTRIENTS_URL = + 'https://api.edamam.com/api/food-database/v2/nutrients'; + +// Edamam nutrient code → app field mapping +const NUTRIENT_MAP = { + ENERC_KCAL: 'calories', + PROCNT: 'protein', + FAT: 'fat', + CHOCDF: 'carbs', + FIBTG: 'dietary_fiber', + FASAT: 'saturated_fat', + FAMS: 'monounsaturated_fat', + FAPU: 'polyunsaturated_fat', + FATRN: 'trans_fat', + CHOLE: 'cholesterol', + NA: 'sodium', + K: 'potassium', + SUGAR: 'sugars', + VITA_RAE: 'vitamin_a', + VITC: 'vitamin_c', + CA: 'calcium', + FE: 'iron', +}; + +function roundNutrient(value, decimals = 1) { + if (value == null || isNaN(value)) return 0; + return Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals); +} + +function mapNutrients(nutrients = {}) { + const result = {}; + for (const [code, field] of Object.entries(NUTRIENT_MAP)) { + const raw = nutrients[code]; + result[field] = + field === 'calories' || + field === 'cholesterol' || + field === 'sodium' || + field === 'potassium' + ? Math.round(raw || 0) + : roundNutrient(raw); + } + return result; +} + +// Map a single food hint from Edamam parser response +function mapEdamamSearchItem(hint) { + if (!hint?.food) return null; + const { food } = hint; + + const nutrients = mapNutrients(food.nutrients); + + return { + name: food.label, + brand: food.brand || null, + provider_external_id: food.foodId, + provider_type: 'edamam', + is_custom: false, + default_variant: { + serving_size: 100, + serving_unit: 'g', + ...nutrients, + is_default: true, + }, + }; +} + +// Map full food detail (from /nutrients POST response + hint data) +function mapEdamamFood(food, measuresData, selectedMeasure) { + if (!food) return null; + + const baseNutrients = mapNutrients(food.nutrients); + + // Build variants from measures + const variants = []; + const measures = measuresData || []; + + measures.forEach((measure, idx) => { + if (!measure.label || !measure.weight) return; + + const weight = parseFloat(measure.weight); + if (!weight) return; + + // Scale nutrients from per-100g to per-measure weight + const factor = weight / 100; + const variantNutrients = {}; + for (const [code, field] of Object.entries(NUTRIENT_MAP)) { + const per100 = food.nutrients?.[code] || 0; + variantNutrients[field] = + field === 'calories' || + field === 'cholesterol' || + field === 'sodium' || + field === 'potassium' + ? Math.round(per100 * factor) + : roundNutrient(per100 * factor); + } + + variants.push({ + serving_size: weight >= 1 ? Math.round(weight * 10) / 10 : weight, + serving_unit: 'g', + ...variantNutrients, + is_default: idx === 0, + }); + }); + + // Always include 100g as a variant if no measures or as extra option + const has100g = variants.some( + (v) => v.serving_unit === 'g' && v.serving_size === 100 + ); + if (!has100g) { + variants.push({ + serving_size: 100, + serving_unit: 'g', + ...baseNutrients, + is_default: variants.length === 0, + }); + } + + const defaultVariant = variants.find((v) => v.is_default) || variants[0]; + + return { + name: food.label, + brand: food.brand || null, + provider_external_id: food.foodId, + provider_type: 'edamam', + is_custom: false, + default_variant: defaultVariant, + variants, + }; +} + +async function searchEdamamByQuery(query, appId, appKey, page = 1) { + const pageSize = 20; + const from = (page - 1) * pageSize; + const to = from + pageSize; + + const url = `${EDAMAM_PARSER_URL}?${new URLSearchParams({ + app_id: appId, + app_key: appKey, + ingr: query, + 'nutrition-type': 'logging', + from: String(from), + to: String(to), + })}`; + log('info', `Edamam Search URL: ${url.replace(appKey, '***')}`); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + try { + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + signal: controller.signal, + }); + + if (!response.ok) { + const text = await response.text(); + log('error', 'Edamam search API error:', text); + throw new Error(`Edamam API error (${response.status}): ${text}`); + } + + return response.json(); + } finally { + clearTimeout(timeout); + } +} + +async function searchEdamamByBarcode(barcode, appId, appKey) { + const url = `${EDAMAM_PARSER_URL}?${new URLSearchParams({ + app_id: appId, + app_key: appKey, + upc: barcode, + 'nutrition-type': 'logging', + })}`; + log('info', `Edamam Barcode URL: ${url.replace(appKey, '***')}`); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + try { + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + signal: controller.signal, + }); + + if (!response.ok) { + if (response.status === 404) return null; + const text = await response.text(); + log('error', 'Edamam barcode API error:', text); + throw new Error(`Edamam barcode API error (${response.status}): ${text}`); + } + + return response.json(); + } finally { + clearTimeout(timeout); + } +} + +module.exports = { + mapEdamamSearchItem, + mapEdamamFood, + searchEdamamByQuery, + searchEdamamByBarcode, + EDAMAM_NUTRIENTS_URL, +}; diff --git a/SparkyFitnessServer/integrations/fatsecret/fatsecretService.js b/SparkyFitnessServer/integrations/fatsecret/fatsecretService.js index e847f4167..37fbb317a 100644 --- a/SparkyFitnessServer/integrations/fatsecret/fatsecretService.js +++ b/SparkyFitnessServer/integrations/fatsecret/fatsecretService.js @@ -16,6 +16,8 @@ const SERVING_UNIT_ALIASES = { g: 'g', gram: 'g', grams: 'g', + г: 'g', + мл: 'ml', ml: 'ml', milliliter: 'ml', milliliters: 'ml', @@ -367,28 +369,84 @@ function mapFatSecretSearchItem(item) { // FatSecret search descriptions look like: // "Per 100g - Calories: 165kcal | Fat: 3.57g | Carbs: 0.00g | Protein: 31.02g" // "Per 1 serving (28g) - Calories: 110kcal | Fat: 2.00g | Carbs: 15.00g | Protein: 7.00g" + // When language/region params are used, keywords are localized — so we extract nutrients + // by position (always in order: calories | fat | carbs | protein) rather than by keyword. const desc = item.food_description || ''; - const calories = parseFloat(desc.match(/Calories:\s*([\d.]+)/)?.[1]) || 0; - const fat = parseFloat(desc.match(/Fat:\s*([\d.]+)/)?.[1]) || 0; - const carbs = parseFloat(desc.match(/Carbs:\s*([\d.]+)/)?.[1]) || 0; - const protein = parseFloat(desc.match(/Protein:\s*([\d.]+)/)?.[1]) || 0; - - // Extract serving info from formats like: - // "Per 100g - ..." → 100 g - // "Per 250ml - ..." → 250 ml - // "Per 1 serving (28g) - ..." → 28 g (prefer metric in parentheses) - // "Per 1 serving - ..." → 1 serving - // "Per 1 bar - ..." → 1 bar - // "Per 1/4 cup - ..." → 0.25 cup - - // 1. Try to find metric in parentheses: "Per ... (28g) -" - const parenMetricMatch = desc.match(/\(([\d.]+)(g|ml)\)\s*-/); - - // 2. Try to find direct metric: "Per 100g -" - const directMetricMatch = desc.match(/^Per\s+([\d.]+)(g|ml)\s*-/); - - // 3. Try to find household with potential fractions: "Per 1/4 cup -" - const householdMatch = desc.match(/^Per\s+([\d\s./]+)\s+(.+?)\s*-/); + + // Split on " - " to separate serving info from nutrient values + const dashIdx = desc.indexOf(' - '); + const servingPart = dashIdx >= 0 ? desc.slice(0, dashIdx).trim() : ''; + const nutrientPart = dashIdx >= 0 ? desc.slice(dashIdx + 3) : ''; + + // Parse nutrients by label first (language-independent label pattern: word(s) followed by colon and value) + // Labels in English: "Calories", "Fat", "Carbs", "Protein" + // Labels in other languages may differ, so fall back to positional parsing when labels not found. + const labelPattern = + /([A-Za-zА-Яа-яÀ-öØ-ÿ\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]+[^:|]*?):\s*([\d.]+)/gi; + const labelMatches = [...nutrientPart.matchAll(labelPattern)]; + const byLabel = {}; + for (const m of labelMatches) { + byLabel[m[1].trim().toLowerCase()] = parseFloat(m[2]); + } + + // Known label variants per nutrient (English + common localizations) + const CALORIE_LABELS = [ + 'calories', + 'energy', + 'калории', + 'ккал', + 'カロリー', + '칼로리', + ]; + const FAT_LABELS = ['fat', 'жиры', '脂肪', '脂質', '지방']; + const CARB_LABELS = [ + 'carbs', + 'carbohydrates', + 'углеводы', + '碳水化合物', + '炭水化物', + '탄수화물', + ]; + const PROTEIN_LABELS = [ + 'protein', + 'proteins', + 'белки', + '蛋白质', + 'タンパク質', + '단백질', + ]; + + const findByLabels = (labels) => { + for (const lbl of labels) { + const found = Object.entries(byLabel).find(([k]) => k.includes(lbl)); + if (found) return found[1]; + } + return null; + }; + + // Positional fallback: extract all numeric values in order + const nutrientNums = [...nutrientPart.matchAll(/([\d.]+)/g)].map((m) => + parseFloat(m[1]) + ); + + const calories = findByLabels(CALORIE_LABELS) ?? nutrientNums[0] ?? 0; + const fat = findByLabels(FAT_LABELS) ?? nutrientNums[1] ?? 0; + const carbs = findByLabels(CARB_LABELS) ?? nutrientNums[2] ?? 0; + const protein = findByLabels(PROTEIN_LABELS) ?? nutrientNums[3] ?? 0; + + // Extract serving info from servingPart (language-agnostic). + // Formats: "Per 100g" | "100g" | "Per 1 serving (28g)" | "Per 1/4 cup" + // In localized versions the leading word may differ ("На", "Pro", etc.) — we ignore it. + + // 1. Try to find metric in parentheses: "(28g)" or "(250ml)" + const parenMetricMatch = servingPart.match(/\(([\d.]+)\s*(g|г|ml|мл)\)\s*$/i); + + // 2. Try to find direct metric at the end of servingPart: "100g" or "250 ml" + const directMetricMatch = servingPart.match(/([\d.]+)\s*(g|г|ml|мл)\s*$/i); + + // 3. Try to find household: any leading number(s) followed by a unit word before end + // Captures fractions like "1/4 cup" or "1 1/4 tbsp" + const householdMatch = servingPart.match(/([\d\s./]+)\s+(\S.+)$/); let servingSize, servingUnit; diff --git a/SparkyFitnessServer/integrations/garminconnect/garminConnectService.js b/SparkyFitnessServer/integrations/garminconnect/garminConnectService.ts similarity index 73% rename from SparkyFitnessServer/integrations/garminconnect/garminConnectService.js rename to SparkyFitnessServer/integrations/garminconnect/garminConnectService.ts index 47de9cf23..fc81d56e3 100644 --- a/SparkyFitnessServer/integrations/garminconnect/garminConnectService.js +++ b/SparkyFitnessServer/integrations/garminconnect/garminConnectService.ts @@ -1,12 +1,20 @@ -const { log } = require('../../config/logging'); -const axios = require('axios'); +import { log } from '../../config/logging'; +import axios from 'axios'; const externalProviderRepository = require('../../models/externalProviderRepository'); -const { encrypt, ENCRYPTION_KEY } = require('../../security/encryption'); +const exerciseEntryRepository = require('../../models/exerciseEntry'); +const activityDetailsRepository = require('../../models/activityDetailsRepository'); +const exerciseRepository = require('../../models/exercise'); +import moment from 'moment'; +import { encrypt, ENCRYPTION_KEY } from '../../security/encryption'; const GARMIN_MICROSERVICE_URL = process.env.GARMIN_MICROSERVICE_URL || 'http://localhost:8000'; // Default for local dev -async function garminLogin(userId, email, password) { +export async function garminLogin( + userId: string, + email: string, + password: string +): Promise { try { const response = await axios.post( `${GARMIN_MICROSERVICE_URL}/auth/garmin/login`, @@ -17,7 +25,7 @@ async function garminLogin(userId, email, password) { } ); return response.data; // Should contain tokens or MFA status - } catch (error) { + } catch (error: any) { log( 'error', `Error during Garmin login for user ${userId}:`, @@ -30,7 +38,11 @@ async function garminLogin(userId, email, password) { } } -async function garminResumeLogin(userId, clientState, mfaCode) { +export async function garminResumeLogin( + userId: string, + clientState: string, + mfaCode: string +): Promise { try { const response = await axios.post( `${GARMIN_MICROSERVICE_URL}/auth/garmin/resume_login`, @@ -41,7 +53,7 @@ async function garminResumeLogin(userId, clientState, mfaCode) { } ); return response.data; // Should contain tokens - } catch (error) { + } catch (error: any) { log( 'error', `Error during Garmin MFA for user ${userId}:`, @@ -54,17 +66,15 @@ async function garminResumeLogin(userId, clientState, mfaCode) { } } -async function handleGarminTokens(userId, tokensB64) { +export async function handleGarminTokens( + userId: string, + tokensB64: string +): Promise { try { - // Decode the base64 tokens string to get the actual tokens object - // The tokensB64 is the full garth.dumps() output, which is a base64 encoded string of a JSON array. - // The Python microservice returns the full garth.dumps() output directly. const garthDump = tokensB64; const parsedGarthDump = JSON.parse( Buffer.from(garthDump, 'base64').toString('utf8') ); - // garth.dumps() always returns a 2-element JSON array: [OAuth1Token, OAuth2Token]. - // OAuth2Token (index 1) contains access_token, refresh_token, expires_at, etc. if ( !Array.isArray(parsedGarthDump) || parsedGarthDump.length < 2 || @@ -76,8 +86,15 @@ async function handleGarminTokens(userId, tokensB64) { ); } const tokens = parsedGarthDump[1]; - log('debug', 'handleGarminTokens: Parsed Garth Dump:', parsedGarthDump); - log('debug', 'handleGarminTokens: Extracted Tokens:', tokens); + log('debug', 'handleGarminTokens: Extracted Tokens (masked):', { + access_token: tokens.access_token + ? tokens.access_token.substring(0, 10) + '...' + : null, + refresh_token: tokens.refresh_token + ? tokens.refresh_token.substring(0, 10) + '...' + : null, + external_user_id: tokens.external_user_id, + }); log('debug', 'handleGarminTokens: Received Garth dump (masked):', { garth_dump_masked: garthDump ? `${garthDump.substring(0, 30)}...` : 'N/A', @@ -101,14 +118,12 @@ async function handleGarminTokens(userId, tokensB64) { garth_dump_tag: encryptedGarthDump.tag, }); - // Assuming 'external_user_id' is available in the tokens object or can be derived - const externalUserId = tokens.external_user_id || `garmin_user_${userId}`; // Placeholder + const externalUserId = tokens.external_user_id || `garmin_user_${userId}`; log( 'debug', `handleGarminTokens: externalUserId determined as: ${externalUserId}` ); - // Check if a Garmin provider entry already exists for this user const provider = await externalProviderRepository.getExternalDataProviderByUserIdAndProviderName( userId, @@ -117,28 +132,23 @@ async function handleGarminTokens(userId, tokensB64) { const updateData = { provider_name: 'garmin', - provider_type: 'garmin', // Changed to 'garmin' as per user's request + provider_type: 'garmin', user_id: userId, is_active: true, - base_url: 'https://connect.garmin.com', // Garmin Connect base URL + base_url: 'https://connect.garmin.com', encrypted_garth_dump: encryptedGarthDump.encryptedText, garth_dump_iv: encryptedGarthDump.iv, garth_dump_tag: encryptedGarthDump.tag, - // garth serialises the access token expiry as `expires_at` (Unix seconds). - // We prefer this over `refresh_token_expires_at` because the UI shows this - // date as "token expires at" — the access token (hours/days) is far more - // meaningful to surface than the refresh token expiry (typically months away). - // Fallback to refresh_token_expires_at only if expires_at is absent, which - // should not happen in normal garth dumps but guards against older token shapes. token_expires_at: (() => { const expiryTimestamp = tokens.expires_at || tokens.refresh_token_expires_at; return expiryTimestamp ? new Date(expiryTimestamp * 1000) : null; })(), - external_user_id: tokens.external_user_id || externalUserId, // Use external_user_id from tokens if available + external_user_id: tokens.external_user_id || externalUserId, }; + log('debug', 'handleGarminTokens: Update data for provider (masked):', { provider_name: updateData.provider_name, provider_type: updateData.provider_type, @@ -154,7 +164,6 @@ async function handleGarminTokens(userId, tokensB64) { let savedProvider; if (provider && provider.id) { - // Update existing provider entry savedProvider = await externalProviderRepository.updateExternalDataProvider( provider.id, @@ -163,14 +172,13 @@ async function handleGarminTokens(userId, tokensB64) { ); log('info', `Updated Garmin provider entry for user ${userId}.`); } else { - // Create new provider entry savedProvider = await externalProviderRepository.createExternalDataProvider(updateData); log('info', `Created new Garmin provider entry for user ${userId}.`); } - return savedProvider; // Return the created or updated provider object - } catch (error) { + return savedProvider; + } catch (error: any) { log( 'error', `Error handling Garmin tokens for user ${userId}:`, @@ -185,12 +193,12 @@ async function handleGarminTokens(userId, tokensB64) { } } -async function syncGarminHealthAndWellness( - userId, - startDate, - endDate, - metricTypes -) { +export async function syncGarminHealthAndWellness( + userId: string, + startDate: string, + endDate: string, + metricTypes?: string[] +): Promise { try { const provider = await externalProviderRepository.getExternalDataProviderByUserIdAndProviderName( @@ -200,7 +208,7 @@ async function syncGarminHealthAndWellness( if (!provider || !provider.garth_dump) { throw new Error('Garmin tokens not found for this user.'); } - const decryptedGarthDump = provider.garth_dump; // This is already decrypted by the repository + const decryptedGarthDump = provider.garth_dump; log( 'debug', `syncGarminHealthAndWellness: Sending decrypted Garth dump (masked) to microservice: ${decryptedGarthDump ? decryptedGarthDump.substring(0, 30) + '...' : 'N/A'}` @@ -209,17 +217,17 @@ async function syncGarminHealthAndWellness( `${GARMIN_MICROSERVICE_URL}/data/health_and_wellness`, { user_id: userId, - tokens: decryptedGarthDump, // Decrypted, base64 encoded tokens string + tokens: decryptedGarthDump, start_date: startDate, end_date: endDate, - metric_types: metricTypes || [], // Pass an empty array if metricTypes is not provided + metric_types: metricTypes || [], }, { timeout: 120000, // 2 minutes timeout } ); return response.data; - } catch (error) { + } catch (error: any) { log( 'error', `Error fetching Garmin health and wellness data for user ${userId} from ${startDate} to ${endDate}:`, @@ -232,12 +240,12 @@ async function syncGarminHealthAndWellness( } } -async function fetchGarminActivitiesAndWorkouts( - userId, - startDate, - endDate, - activityType -) { +export async function fetchGarminActivitiesAndWorkouts( + userId: string, + startDate: string, + endDate: string, + activityType?: string +): Promise { try { const provider = await externalProviderRepository.getExternalDataProviderByUserIdAndProviderName( @@ -263,17 +271,16 @@ async function fetchGarminActivitiesAndWorkouts( activity_type: activityType, }, { - timeout: 120000, // 2 minutes timeout + timeout: 120000, } ); log( 'debug', - `Raw activities and workouts data from Garmin microservice for user ${userId} from ${startDate} to ${endDate}:`, - response.data + `Received activities and workouts data from Garmin microservice for user ${userId} (${Array.isArray(response.data) ? response.data.length : 'non-array'} items).` ); return response.data; - } catch (error) { + } catch (error: any) { log( 'error', `Error fetching Garmin activities and workouts for user ${userId} from ${startDate} to ${endDate}:`, @@ -285,11 +292,3 @@ async function fetchGarminActivitiesAndWorkouts( ); } } - -module.exports = { - garminLogin, - garminResumeLogin, - handleGarminTokens, - syncGarminHealthAndWellness, - fetchGarminActivitiesAndWorkouts, -}; diff --git a/SparkyFitnessServer/integrations/myfitnesspal/myFitnessPalService.ts b/SparkyFitnessServer/integrations/myfitnesspal/myFitnessPalService.ts new file mode 100644 index 000000000..272959920 --- /dev/null +++ b/SparkyFitnessServer/integrations/myfitnesspal/myFitnessPalService.ts @@ -0,0 +1,270 @@ +import axios from 'axios'; +import { log } from '../../config/logging'; +import { getExternalDataProviderByUserIdAndProviderName } from '../../models/externalProviderRepository'; + +export interface MFPCategoryData { + calories: number; + protein: number; + fat: number; + carbohydrate: number; +} + +export interface MFPNutritionData { + date: string; // YYYY-MM-DD + categories: { [key: string]: MFPCategoryData }; +} + +/** + * Pushes nutrition data to MyFitnessPal directly via HTTP. + * Mimics the logic previously held in the Garmin microservice, but enhanced for multiple categories. + */ +export async function pushNutritionToMFP( + userId: string, + data: MFPNutritionData +) { + try { + // 1. Get MFP credentials from external providers + const mfpProvider = (await getExternalDataProviderByUserIdAndProviderName( + userId, + 'myfitnesspal' + )) as any; + + if (!mfpProvider || !mfpProvider.app_key) { + log( + 'info', + `pushNutritionToMFP: No MyFitnessPal provider or cookies found for user ${userId}. Skipping MFP sync.` + ); + return null; + } + + const cookiesStr = mfpProvider.app_key; + const initialCsrfToken = mfpProvider.app_id || ''; + + // Cookie Helper: Parse initial cookies and manage session + let currentCookies = cookiesStr; + + const baseHeaders = { + accept: 'application/json, text/plain, */*', + 'accept-language': 'ru,uk;q=0.9,en-US;q=0.8,en;q=0.7', + 'content-type': 'application/json', + origin: 'https://www.myfitnesspal.com', + referer: 'https://www.myfitnesspal.com/ru/food/diary', + 'user-agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36', + 'x-requested-with': 'XMLHttpRequest', + }; + + // Step 1: Fetch fresh CSRF Token & Update Cookies + let freshCsrfToken = initialCsrfToken; + try { + const csrfResp = await axios.get( + 'https://www.myfitnesspal.com/api/auth/csrf', + { + headers: { ...baseHeaders, Cookie: currentCookies }, + } + ); + if (csrfResp.data && csrfResp.data.csrfToken) { + freshCsrfToken = csrfResp.data.csrfToken; + } + // Update cookies if server sent new ones + const setCookie = csrfResp.headers['set-cookie']; + if (setCookie) { + const newCookies = setCookie.map((c) => c.split(';')[0]).join('; '); + currentCookies = `${currentCookies}; ${newCookies}`; + } + } catch (e: any) { + log( + 'warn', + `pushNutritionToMFP: CSRF fetch fallback for user ${userId}: ${e.message}` + ); + } + + // Step 2: Fetch Bearer Token + let bearerToken = ''; + let mfpUserId = ''; + try { + const authResp = await axios.get( + 'https://www.myfitnesspal.com/user/auth_token', + { + headers: { + ...baseHeaders, + Cookie: currentCookies, + 'x-csrf-token': freshCsrfToken, + 'csrf-token': freshCsrfToken, + 'mfp-client-id': 'mfp-main-js', + }, + } + ); + + if (authResp.data && authResp.data.access_token) { + bearerToken = authResp.data.access_token; + mfpUserId = authResp.data.user_id; + } else { + throw new Error('Access token missing in response.'); + } + } catch (e: any) { + log( + 'error', + `pushNutritionToMFP: Auth failed for user ${userId}: ${e.message}` + ); + throw new Error(`Authentication failed: ${e.message}`); + } + + const authHeaders = { + ...baseHeaders, + Authorization: `Bearer ${bearerToken}`, + 'mfp-client-id': 'mfp-main-js', + 'mfp-user-id': mfpUserId ? String(mfpUserId) : undefined, + Cookie: currentCookies, + }; + + // Step 3: Idempotency - Delete *ANY* existing entries that look like automated syncs for this date + try { + // Broaden discovery. If v2/diary with types fails, we'll try a fallback. + // We suspect 'water' or 'exercise' might be causing the 400. + const discoveryUrl = `https://api.myfitnesspal.com/v2/diary?entry_date=${data.date}&types=diary_meal,food_entry`; + let diaryResp; + try { + diaryResp = await axios.get(discoveryUrl, { + headers: authHeaders as any, + }); + } catch (e: any) { + log( + 'error', + `pushNutritionToMFP: Primary discovery failed with ${e.response?.status}: ${JSON.stringify(e.response?.data || e.message)}` + ); + // Fallback to minimal discovery + diaryResp = await axios.get( + `https://api.myfitnesspal.com/v2/diary?entry_date=${data.date}`, + { + headers: authHeaders as any, + } + ); + } + + if (diaryResp.data && Array.isArray(diaryResp.data.items)) { + const items = diaryResp.data.items; + log( + 'info', + `pushNutritionToMFP: Discovery returned ${items.length} items for ${data.date}.` + ); + + for (const item of items) { + // IMPORTANT: Some items have ID under 'id', others under 'item_id' or inside 'links' + const itemId = item.id || item.item_id; + const itemType = item.type; + const itemName = item.food_name || item.diary_meal || 'Unnamed Entry'; + + log( + 'info', + `pushNutritionToMFP: [DISCOVERY] Found item: ID=${itemId}, Type=${itemType}, Name="${itemName}"` + ); + + if (itemId) { + try { + log( + 'info', + `pushNutritionToMFP: Attempting to DELETE item ${itemId}...` + ); + const delResp = await axios.delete( + `https://api.myfitnesspal.com/v2/diary/${itemId}`, + { + headers: authHeaders as any, + } + ); + log( + 'info', + `pushNutritionToMFP: Successfully deleted item ${itemId}. Status: ${delResp.status}` + ); + } catch (e: any) { + log( + 'warn', + `pushNutritionToMFP: Delete failed for item ${itemId}: ${e.message} (Payload: ${JSON.stringify(e.response?.data || {})})` + ); + } + } else { + log( + 'info', + `pushNutritionToMFP: [DEBUG] Item has NO ID at top level. Full structure: ${JSON.stringify(item)}` + ); + } + } + } + } catch (e: any) { + log( + 'warn', + `pushNutritionToMFP: Idempotency cleanup failed for user ${userId}: ${e.message}` + ); + } + + // Step 4: Map and Push categories + const responses = []; + const mealMapping: { [key: string]: string } = { + breakfast: 'Breakfast', + lunch: 'Lunch', + dinner: 'Dinner', + snacks: 'Snacks', + }; + + for (const [categoryName, categoryData] of Object.entries( + data.categories + )) { + if (categoryData.calories <= 0) continue; // Skip empty categories + + const mfpMealName = mealMapping[categoryName.toLowerCase()] || 'Snacks'; + + const payload = { + items: [ + { + type: 'quick_add', + date: data.date, + meal_name: mfpMealName, + nutritional_contents: { + energy: { + unit: 'calories', + value: Math.round(categoryData.calories), + }, + carbohydrates: Number(categoryData.carbohydrate), + fat: Number(categoryData.fat), + protein: Number(categoryData.protein), + }, + }, + ], + }; + + log( + 'info', + `pushNutritionToMFP: Pushing ${mfpMealName} total for ${data.date}: ${categoryData.calories} kcal` + ); + const finalResp = await axios.post( + 'https://api.myfitnesspal.com/v2/diary', + payload, + { + headers: authHeaders as any, + } + ); + + if (finalResp.status >= 400) { + log( + 'error', + `pushNutritionToMFP: MFP API rejected ${mfpMealName} sync: ${JSON.stringify(finalResp.data)}` + ); + } else { + responses.push(finalResp.data); + } + } + + return { + status: 'success', + date: data.date, + responses: responses, + }; + } catch (error: any) { + log( + 'error', + `pushNutritionToMFP: Final fatal error for user ${userId}:`, + error.response?.data || error.message + ); + throw error; + } +} diff --git a/SparkyFitnessServer/integrations/telegram/intentExecutor.ts b/SparkyFitnessServer/integrations/telegram/intentExecutor.ts new file mode 100644 index 000000000..1e6852b80 --- /dev/null +++ b/SparkyFitnessServer/integrations/telegram/intentExecutor.ts @@ -0,0 +1,508 @@ +/** + * Server-side intent executor for Telegram bot. + * Executes AI intents (log_food, log_measurement, log_water, log_exercise) + * by calling service/repository functions directly. + */ + +import { log } from '../../config/logging'; +import * as measurementService from '../../services/measurementService'; +import * as measurementRepository from '../../models/measurementRepository'; +import * as foodEntryService from '../../services/foodEntryService'; +import * as foodRepository from '../../models/foodRepository'; +import * as exerciseService from '../../services/exerciseService'; +import * as foodEntry from '../../models/foodEntry'; + +/** + * Execute a parsed AI intent for a given user. + * Returns a user-facing confirmation string. + */ +export async function executeIntent( + intent: string, + data: any, + entryDate: string | null, + userId: string, + today: string +): Promise { + const dateToUse = resolveDate(entryDate, today); + + switch (intent) { + case 'log_measurement': + case 'log_measurements': + return executeMeasurement(data, dateToUse, userId); + + case 'log_water': + return executeWater(data, dateToUse, userId); + + case 'log_food': + return executeFood(data, dateToUse, userId); + + case 'log_exercise': + return executeExercise(data, dateToUse, userId); + + case 'delete_measurement': + case 'delete_measurements': + return executeDeleteMeasurement(data, dateToUse, userId); + + case 'delete_food': + case 'delete_food_entry': + return executeDeleteFood(data, dateToUse, userId); + + case 'ask_question': + case 'chat': + // No DB action needed — return the response text from the AI + return null; + + default: + return null; + } +} + +/** + * Log body measurements (weight, steps, waist, hips, neck, height). + */ +export async function executeMeasurement( + data: any, + dateToUse: string, + userId: string +): Promise { + const measurements = Array.isArray(data.measurements) + ? data.measurements + : [data]; + const confirmed = []; + const failed = []; + + const standardTypes = ['weight', 'neck', 'waist', 'hips', 'steps', 'height']; + + for (const m of measurements) { + const type = m.measurement_type || m.type; + if (!type || m.value === undefined) continue; + + try { + if (standardTypes.includes(type)) { + await measurementService.upsertCheckInMeasurements( + userId, + userId, + dateToUse, + { [type]: m.value } + ); + confirmed.push(`${type}: ${m.value}${m.unit ? ' ' + m.unit : ''}`); + } else { + // Custom measurement + const name = m.name || type; + let category = null; + try { + category = await measurementService.getOrCreateCustomCategory( + userId, + userId, + name + ); + } catch (e) { + log( + 'warn', + `[INTENT] Could not find/create custom category "${name}": ${e.message}` + ); + } + + if (category && category.id) { + await measurementRepository.upsertCustomMeasurement( + userId, + userId, + category.id, + m.value, + dateToUse, + null, + new Date().toISOString(), + null, + 'Daily' + ); + confirmed.push(`${name}: ${m.value}${m.unit ? ' ' + m.unit : ''}`); + } else { + failed.push(name); + } + } + } catch (e) { + log('error', `[INTENT] Measurement error for ${type}: ${e.message}`); + failed.push(type); + } + } + + if (confirmed.length === 0) { + return `❌ Не вдалося записати виміри.`; + } + + let msg = `✅ Записано (${dateToUse}):\n${confirmed.map((c) => ` • ${c}`).join('\n')}`; + if (failed.length > 0) { + msg += `\n⚠️ Помилка: ${failed.join(', ')}`; + } + return msg; +} + +/** + * Log water intake. AI sends glasses_consumed or quantity in ml/glasses. + */ +export async function executeWater( + data: any, + dateToUse: string, + userId: string +): Promise { + try { + const glassesOrMl = Number(data.glasses_consumed ?? data.quantity ?? 1); + const unit = data.unit || 'glass'; + + // Convert to ml + const mlMap = { oz: 29.5735, cup: 240, glass: 240, ml: 1 }; + const mlPerUnit = mlMap[unit] || 240; + const totalMl = glassesOrMl * mlPerUnit; + + // upsertWaterIntake takes change_drinks (drinks count), not ml directly. + // We pass ml as "drinks" but with no container — service will use default (250ml/drink). + // So we convert ml → drinks using default 250ml/drink. + const drinks = totalMl / 250; + await measurementService.upsertWaterIntake( + userId, + userId, + dateToUse, + drinks, + null + ); + return `✅ Вода: ${Math.round(totalMl)} мл (${dateToUse})`; + } catch (e) { + log('error', `[INTENT] Water error: ${e.message}`); + return `❌ Помилка запису води: ${e.message}`; + } +} + +/** + * Log food entry with inline nutritional snapshot from AI. + */ +export async function executeFood( + data: any, + dateToUse: string, + userId: string +): Promise { + try { + const mealType = normalizeMealType(data?.meal_type); + const quantity = Number(data?.quantity ?? data?.qty ?? data?.amount) || 1; + const unit = data?.unit || data?.serving_unit || 'serving'; + const foodName = + data?.food_name || + data?.name || + data?.food || + data?.item || + 'Unknown Food'; + + // Extract macros with even more robust aliases + const calories = + Number( + data?.calories ?? data?.kcal ?? data?.energy ?? data?.kilocalories ?? 0 + ) || 0; + const protein = Number(data?.protein ?? data?.proteins ?? 0) || 0; + const carbs = Number(data?.carbs ?? data?.carbohydrates ?? 0) || 0; + const fat = Number(data?.fat ?? data?.fats ?? 0) || 0; + + log( + 'info', + `[INTENT] executeFood: "${foodName}", macros identified: ${calories} kcal, ${protein}p, ${carbs}c, ${fat}f` + ); + + // 1. Search for existing food + let foodId = null; + let variantId = null; + + const searchResults = await foodRepository.searchFoods( + foodName, + userId, + false, + true, + false, + 1 + ); + if ( + searchResults && + searchResults.length > 0 && + foodName !== 'Unknown Food' + ) { + foodId = searchResults[0].id; + variantId = searchResults[0].default_variant?.id; + log('debug', `[INTENT] Found existing food: ${foodName} (ID: ${foodId})`); + } else { + // Create quick food with as much macro/micronutrient data as possible + log('debug', `[INTENT] Creating quick log food for: ${foodName}`); + const newFood = await foodRepository.createFood({ + name: foodName, + user_id: userId, + brand: 'AI Log', + is_custom: true, + is_quick_food: true, + calories: calories, + protein: protein, + carbs: carbs, + fat: fat, + saturated_fat: data?.saturated_fat ? Number(data.saturated_fat) : null, + polyunsaturated_fat: data?.polyunsaturated_fat + ? Number(data.polyunsaturated_fat) + : null, + monounsaturated_fat: data?.monounsaturated_fat + ? Number(data.monounsaturated_fat) + : null, + trans_fat: data?.trans_fat ? Number(data.trans_fat) : null, + cholesterol: data?.cholesterol ? Number(data.cholesterol) : null, + sodium: data?.sodium ? Number(data.sodium) : null, + potassium: data?.potassium ? Number(data.potassium) : null, + dietary_fiber: data?.dietary_fiber ? Number(data.dietary_fiber) : null, + sugars: data?.sugars ? Number(data.sugars) : null, + vitamin_a: data?.vitamin_a ? Number(data.vitamin_a) : null, + vitamin_c: data?.vitamin_c ? Number(data.vitamin_c) : null, + calcium: data?.calcium ? Number(data.calcium) : null, + iron: data?.iron ? Number(data.iron) : null, + serving_size: 1, + serving_unit: 'piece', // Default for quick log + }); + foodId = newFood.id; + variantId = newFood.default_variant?.id; + } + + if (!variantId) { + return `❌ Помилка запису їжі: Не вдалося знайти або створити варіант порції для "${foodName}".`; + } + + // Create entry with potential estimates + const entryData = { + user_id: userId, + food_name: foodName, + meal_type: mealType, + entry_date: dateToUse, + quantity, + unit, + serving_size: quantity, + serving_unit: unit, + calories: calories || null, + protein: protein || null, + carbs: carbs || null, + fat: fat || null, + saturated_fat: Number(data?.saturated_fat ?? data?.sat_fat) || null, + polyunsaturated_fat: Number(data?.polyunsaturated_fat) || null, + monounsaturated_fat: Number(data?.monounsaturated_fat) || null, + trans_fat: Number(data?.trans_fat) || null, + cholesterol: Number(data?.cholesterol) || null, + sodium: Number(data?.sodium) || null, + potassium: Number(data?.potassium) || null, + dietary_fiber: Number(data?.dietary_fiber ?? data?.fiber) || null, + sugars: Number(data?.sugars ?? data?.sugar) || null, + vitamin_a: Number(data?.vitamin_a) || null, + vitamin_c: Number(data?.vitamin_c) || null, + calcium: Number(data?.calcium) || null, + iron: Number(data?.iron) || null, + food_id: foodId, + variant_id: variantId, + }; + + await foodEntryService.createFoodEntry(userId, userId, entryData); + + const macrosDisplay = + protein || carbs || fat + ? `\n📊 (P: ${Math.round(protein || 0)}g, C: ${Math.round(carbs || 0)}g, F: ${Math.round(fat || 0)}g)` + : ''; + const calDisplay = calories ? ` (~${Math.round(calories)} ккал)` : ''; + + return `✅ Їжа записана: ${foodName} — ${quantity} ${unit}${calDisplay}${macrosDisplay}\n⏰ [${mealType}, ${dateToUse}]`; + } catch (e) { + log('error', `[INTENT] Food error: ${e.message}`); + return `❌ Помилка запису їжі: ${e.message}`; + } +} + +/** + * Log exercise entry. Searches existing exercises, creates if not found. + */ +export async function executeExercise( + data: any, + dateToUse: string, + userId: string +): Promise { + try { + const name = data.exercise_name || 'Unknown Exercise'; + const duration = Number(data.duration_minutes) || 30; + + // Search for existing exercise + let exerciseId = null; + let caloriesPerHour = 300; + + try { + const results = await exerciseService.searchExercises( + userId, + name, + userId + ); + if (results && results.length > 0) { + exerciseId = results[0].id; + caloriesPerHour = results[0].calories_per_hour || 300; + } + } catch (e) { + log('warn', `[INTENT] Exercise search failed: ${e.message}`); + } + + // Create exercise if not found + if (!exerciseId) { + try { + const newExercise = await exerciseService.createExercise(userId, { + name, + calories_per_hour: estimateCaloriesPerHour(name), + is_public: false, + source: 'telegram', + category: 'Cardio', + is_custom: true, + }); + exerciseId = newExercise.id; + caloriesPerHour = newExercise.calories_per_hour || 300; + } catch (e) { + log('warn', `[INTENT] Exercise create failed: ${e.message}`); + } + } + + if (!exerciseId) { + return `⚠️ Не вдалося знайти або створити вправу "${name}".`; + } + + const caloriesBurned = Math.round((caloriesPerHour * duration) / 60); + + await exerciseService.createExerciseEntry(userId, userId, { + exercise_id: exerciseId, + duration_minutes: duration, + calories_burned: caloriesBurned, + entry_date: dateToUse, + distance: data.distance || null, + }); + + return `✅ Тренування: ${name} — ${duration} хв (~${caloriesBurned} ккал) [${dateToUse}]`; + } catch (e: any) { + log('error', `[INTENT] Exercise error: ${e.message}`); + return `❌ Помилка запису тренування: ${e.message}`; + } +} + +function resolveDate(entryDate: string | null, today: string): string { + if (!entryDate) return today; + const lower = entryDate.toLowerCase(); + if (lower === 'today') return today; + if (lower === 'yesterday') { + const d = new Date(today); + d.setDate(d.getDate() - 1); + return d.toISOString().split('T')[0]; + } + // Already a YYYY-MM-DD or MM-DD + if (/^\d{4}-\d{2}-\d{2}$/.test(entryDate)) return entryDate; + return today; +} + +function normalizeMealType(raw: string | null | undefined): string { + if (!raw) return 'snacks'; + const m = String(raw).toLowerCase(); + if (m === 'snack') return 'snacks'; + if (['breakfast', 'lunch', 'dinner', 'snacks'].includes(m)) return m; + return 'snacks'; +} + +function estimateCaloriesPerHour(name: string): number { + const lower = name.toLowerCase(); + if (/run|jog|sprint/.test(lower)) return 600; + if (/swim/.test(lower)) return 500; + if (/bike|cycle|cycling/.test(lower)) return 450; + if (/walk/.test(lower)) return 280; + if (/yoga|stretch/.test(lower)) return 200; + if (/weight|strength|lift/.test(lower)) return 350; + return 300; +} + +/** + * Handle deletion intents. + * These will return a state that causes the bot to show confirmation buttons. + */ +export async function executeDeleteMeasurement( + data: any, + dateToUse: string, + userId: string +): Promise { + try { + const { measurements = [] } = data; + const itemsToDelete = Array.isArray(measurements) ? measurements : [data]; + if (itemsToDelete.length === 0) return '❓ Не вказано, що саме видалити.'; + + const matches = []; + for (const m of itemsToDelete) { + const type = m.type || 'weight'; + const records = + await measurementRepository.getCheckInMeasurementsByDateRange( + userId, + dateToUse, + dateToUse + ); + + for (const rec of records) { + if (rec[type] !== null) { + // If a specific value was mentioned, match it + if (m.value && Math.abs(Number(rec[type]) - Number(m.value)) > 0.1) + continue; + + matches.push({ + id: rec.id, + type: 'measurement', + subType: type, + date: rec.entry_date, + value: rec[type], + unit: m.unit || (type === 'weight' ? 'kg' : ''), + }); + } + } + } + + if (matches.length === 0) { + return `🤷 Не знайдено записів для видалення за ${dateToUse}.`; + } + + return { + intent: 'confirm_deletion', + matches, + }; + } catch (e: any) { + log('error', `[INTENT] Delete measurement error: ${e.message}`); + return `❌ Помилка при пошуку записів: ${e.message}`; + } +} + +export async function executeDeleteFood( + data: any, + dateToUse: string, + userId: string +): Promise { + try { + const foodName = data.food_name; + + const records = await foodEntry.getFoodEntriesByDate(userId, dateToUse); + const matches = records + .filter( + (r: any) => + !foodName || + r.food_name.toLowerCase().includes(foodName.toLowerCase()) + ) + .map((r: any) => ({ + id: r.id, + type: 'food', + name: r.food_name, + date: dateToUse, + calories: r.calories, + })); + + if (matches.length === 0) { + return `🤷 Не знайдено записів їжі "${foodName || ''}" за ${dateToUse}.`; + } + + return { + intent: 'confirm_deletion', + matches, + }; + } catch (e: any) { + log('error', `[INTENT] Delete food error: ${e.message}`); + return `❌ Помилка при пошуку їжі: ${e.message}`; + } +} diff --git a/SparkyFitnessServer/integrations/telegram/telegramBotService.ts b/SparkyFitnessServer/integrations/telegram/telegramBotService.ts new file mode 100644 index 000000000..54c8300a3 --- /dev/null +++ b/SparkyFitnessServer/integrations/telegram/telegramBotService.ts @@ -0,0 +1,1339 @@ +import TelegramBot from 'node-telegram-bot-api'; +import { log } from '../../config/logging'; +import globalSettingsRepository from '../../models/globalSettingsRepository'; +import * as chatService from '../../services/chatService'; +import * as chatRepository from '../../models/chatRepository'; +import * as exerciseEntry from '../../models/exerciseEntry'; +import * as foodEntry from '../../models/foodEntry'; +import * as poolManager from '../../db/poolManager'; +import * as userRepository from '../../models/userRepository'; +import * as preferenceRepository from '../../models/preferenceRepository'; +import * as goalRepository from '../../models/goalRepository'; +import * as measurementRepository from '../../models/measurementRepository'; +import { executeIntent } from './intentExecutor'; +import axios from 'axios'; +const bmrService = require('../../services/bmrService'); +const { loadUserTimezone } = require('../../utils/timezoneLoader'); +import { todayInZone, addDays } from '@workspace/shared'; +import { syncDailyTotals } from '../../services/mfpSyncService'; + +interface TelegramUser { + id: string; + name: string; + language: string; + telegram_chat_id: string; + timezone?: string; +} + +interface TranslationSet { + greeting: string; + helpPrompt: string; + welcome: string; + noRecentActivities: string; + recentActivities: string; + todayLog: string; + macros: string; + profile: string; + language: string; + diary: string; + exercises: string; + syncMenu: string; + back: string; + langSet: string; + syncGarmin: string; + syncMFP: string; + // ... more as needed + [key: string]: string; +} + +/** + * Service to manage Telegram Bot interactions. + * Connects Telegram users to SparkyFitness AI and database. + */ +class TelegramBotService { + private bot: TelegramBot | null = null; + private activeNutritionSyncs: Set = new Set(); + private activeGarminSyncs: Set = new Set(); + + constructor() { + this.bot = null; + } + + async initialize(): Promise { + try { + const settings = await globalSettingsRepository.getGlobalSettings(); + const token = + settings.telegram_bot_token || process.env.TELEGRAM_BOT_TOKEN; + + if (!token) { + log( + 'info', + '[TELEGRAM BOT] Bot token not configured. Telegram integration is inactive.' + ); + return; + } + + const webhookUrl = process.env.TELEGRAM_WEBHOOK_URL; + + if (webhookUrl) { + log( + 'info', + `[TELEGRAM BOT] Initializing in WEBHOOK mode. URL: ${webhookUrl}` + ); + this.bot = new TelegramBot(token, { polling: false }); + const fullWebhookUrl = `${webhookUrl.replace(/\/$/, '')}/api/telegram/webhook`; + await this.bot.setWebHook(fullWebhookUrl); + log('info', `[TELEGRAM BOT] Webhook registered: ${fullWebhookUrl}`); + } else { + log('info', '[TELEGRAM BOT] Initializing in POLLING mode.'); + this.bot = new TelegramBot(token, { polling: true }); + } + + log( + 'info', + `[TELEGRAM BOT] Bot active: ${settings.telegram_bot_name || 'SparkyFitnessBot'}` + ); + this.setupHandlers(); + } catch (error: any) { + log('error', '[TELEGRAM BOT] Initialization error:', error); + } + } + + handleUpdate(update: any): void { + if (this.bot) { + this.bot.processUpdate(update); + } + } + + private setupHandlers(): void { + if (!this.bot) return; + + this.bot.onText(/\/start( (.+))?/, async (msg, match) => { + const chatId = msg.chat.id; + const linkParam = match ? match[2] : null; + + if (linkParam) { + return this.handleLink(chatId, linkParam.trim()); + } + + const user = await this.findUserAndLanguageByChatId(chatId); + + if (user) { + const lang = user.language; + const t = this.getTranslations(lang); + const keyboardOptions = this.getMainMenuKeyboard(t); + + return this.bot!.sendMessage( + chatId, + `${t.greeting}, ${user.name}! ${t.helpPrompt}`, + keyboardOptions + ); + } + + this.bot!.sendMessage( + chatId, + 'Welcome to SparkyFitness! Link your account in the web app under Settings → Telegram, then send `/start `.' + ); + }); + + this.bot.onText(/\/(profile|профиль)/i, async (msg) => { + const chatId = msg.chat.id; + const user = await this.findUserAndLanguageByChatId(chatId); + if (!user) return; + + this.bot!.sendChatAction(chatId, 'typing').catch(() => {}); + try { + const profileText = await this.formatProfileResponse( + user.id, + user.language + ); + this.bot!.sendMessage(chatId, profileText, { parse_mode: 'HTML' }); + } catch (e: any) { + this.bot!.sendMessage(chatId, `❌ Error: ${e.message}`); + } + }); + + this.bot.onText(/\/(diary|дневник|щоденник)/i, async (msg) => { + const chatId = msg.chat.id; + const user = await this.findUserAndLanguageByChatId(chatId); + if (!user) return; + + const t = this.getTranslations(user.language); + return this.bot!.sendMessage( + chatId, + t.diary, + this.getDiaryMenuKeyboard(t) + ); + }); + + this.bot.onText(/\/(exercises|упражнения|вправи)/i, async (msg) => { + const chatId = msg.chat.id; + const user = await this.findUserAndLanguageByChatId(chatId); + if (!user) return; + + this.bot!.sendChatAction(chatId, 'typing').catch(() => {}); + return this.handleDirectRecentExercises(chatId, user); + }); + + this.bot.onText( + /\/(sync|синхронизировать|синхронізувати)/i, + async (msg) => { + const chatId = msg.chat.id; + const user = await this.findUserAndLanguageByChatId(chatId); + if (!user) return; + return this.showSyncMenu(chatId, user.language); + } + ); + + this.bot.on('message', async (msg) => { + if (msg.text && msg.text.startsWith('/')) return; + + const chatId = msg.chat.id; + const user = await this.findUserAndLanguageByChatId(chatId); + + if (!user) { + this.bot!.sendMessage( + chatId, + 'Your account is not linked. Please link it in the web app under Settings → Telegram, then send `/start `.' + ); + return; + } + + const t = this.getTranslations(user.language); + + // Centralized button handling + if (msg.text === t.profile) { + this.bot!.sendChatAction(chatId, 'typing').catch(() => {}); + const profileText = await this.formatProfileResponse( + user.id, + user.language + ); + this.bot!.sendMessage(chatId, profileText, { parse_mode: 'HTML' }); + return; + } + + if (msg.text === t.diary) { + this.bot!.sendMessage(chatId, t.diary, this.getDiaryMenuKeyboard(t)); + return; + } + + if (msg.text === t.exercises) { + this.bot!.sendChatAction(chatId, 'typing').catch(() => {}); + return this.handleDirectRecentExercises(chatId, user); + } + + if (msg.text === t.syncMenu) { + return this.showSyncMenu(chatId, user.language); + } + + if (msg.text === t.language) { + return this.showLanguageMenu(chatId); + } + + if (msg.text === t.back) { + return this.bot!.sendMessage( + chatId, + t.welcome, + this.getMainMenuKeyboard(t) + ); + } + + // Handle custom standard commands directly without AI + if (msg.text === t.todayLog) { + await this.handleDirectTodayLog(chatId, user); + return; + } + + if (msg.text === t.macros) { + this.bot!.sendChatAction(chatId, 'typing').catch(() => {}); + try { + const profileText = await this.formatProfileResponse( + user.id, + user.language + ); + this.bot!.sendMessage(chatId, profileText, { parse_mode: 'HTML' }); + } catch (e: any) { + this.bot!.sendMessage(chatId, `❌ Error: ${e.message}`); + } + return; + } + + await this.processMessage(chatId, user, msg); + }); + + this.bot.on('callback_query', async (query) => { + const chatId = query.message?.chat.id; + if (!chatId) return; + + const user = await this.findUserAndLanguageByChatId(chatId); + if (!user) return; + + const [action, type] = (query.data || '').split(':'); + + if (action === 'setlang') { + const newLang = type; + await this.setLanguage(user.id, newLang); + const t = this.getTranslations(newLang); + + await this.bot!.deleteMessage(chatId, query.message!.message_id).catch( + () => false + ); + await this.bot!.sendMessage( + chatId, + t.langSet, + this.getMainMenuKeyboard(t) + ); + return this.bot!.answerCallbackQuery(query.id).catch(() => {}); + } else if (action === 'sync') { + const garminService = require('../../services/garminService'); + + if (type === 'garmin') { + if (this.activeGarminSyncs.has(chatId)) { + return this.bot!.sendMessage( + chatId, + '⚠️ Синхронізація з Garmin вже триває. Будь ласка, зачекайте.' + ).catch(() => {}); + } + + this.activeGarminSyncs.add(chatId); + + const statusMsg = await (this.bot!.sendMessage( + chatId, + '🔄 Починаємо синхронізацію з Garmin (за 7 днів)...', + { disable_notification: true } + ) as any); + + try { + const tz = (user as any).timezone || 'UTC'; + const today = todayInZone(tz); + let successCount = 0; + const totalDays = 7; + + for (let i = 0; i < totalDays; i++) { + const currentDate = addDays(today, -i); + const dayNum = i + 1; + + const filledBlocks = '▓'.repeat(dayNum); + const emptyBlocks = '░'.repeat(totalDays - dayNum); + const progressBar = `[${filledBlocks}${emptyBlocks}]`; + + await this.bot!.editMessageText( + `⏳ Синхронізація Garmin...\n${progressBar} ${dayNum}/${totalDays}\n📅 Дата: ${currentDate}`, + { + chat_id: chatId, + message_id: statusMsg.message_id, + disable_web_page_preview: true, + } + ).catch(() => {}); + + // Garmin sync service - sync specific day + await garminService.syncGarminData( + user.id, + 'manual', + currentDate, + currentDate + ); + successCount++; + + // Small delay for smooth UI feedback + await new Promise((resolve) => setTimeout(resolve, 300)); + } + + await this.bot!.editMessageText( + `✅ Синхронізація з Garmin завершена за ${successCount} днів!\n📊 Активності та показники оновлені.`, + { + chat_id: chatId, + message_id: statusMsg.message_id, + } + ).catch(() => {}); + } catch (error: any) { + log('error', `[TELEGRAM BOT] Garmin sync error: ${error.message}`); + await this.bot!.sendMessage( + chatId, + `❌ Помилка синхронізації Garmin: ${error.message}. Переконайтеся, що ваш акаунт підключено у веб-додатку.`, + { disable_notification: true } + ).catch(() => {}); + } finally { + this.activeGarminSyncs.delete(chatId); + } + return this.bot!.answerCallbackQuery(query.id).catch(() => {}); + } else if (type === 'mfp') { + if (this.activeNutritionSyncs.has(chatId)) { + return this.bot!.sendMessage( + chatId, + '⚠️ Синхронізація з MyFitnessPal вже триває. Будь ласка, зачекайте.' + ).catch(() => {}); + } + + this.activeNutritionSyncs.add(chatId); + + const statusMsg = await (this.bot!.sendMessage( + chatId, + '🔄 Починаємо синхронізацію харчування з MyFitnessPal (за 7 днів)...', + { disable_notification: true } + ) as any); + + try { + const tz = (user as any).timezone || 'UTC'; + const today = todayInZone(tz); + let successCount = 0; + const totalDays = 7; + + for (let i = 0; i < totalDays; i++) { + const currentDate = addDays(today, -i); + const dayNum = i + 1; + + // Progress visual: [▓▓▓░░░░] + const filledBlocks = '▓'.repeat(dayNum); + const emptyBlocks = '░'.repeat(totalDays - dayNum); + const progressBar = `[${filledBlocks}${emptyBlocks}]`; + + await this.bot!.editMessageText( + `⏳ Синхронізація MyFitnessPal...\n${progressBar} ${dayNum}/${totalDays}\n📅 Дата: ${currentDate}`, + { + chat_id: chatId, + message_id: statusMsg.message_id, + disable_web_page_preview: true, + } + ).catch(() => {}); + + // MFP sync service + await syncDailyTotals(user.id, currentDate); + successCount++; + + // Small delay for smooth UI feedback + await new Promise((resolve) => setTimeout(resolve, 300)); + } + + await this.bot!.editMessageText( + `✅ Синхронізація з MyFitnessPal завершена за ${successCount} днів!\n📊 Дані успішно оновлені.`, + { + chat_id: chatId, + message_id: statusMsg.message_id, + } + ).catch(() => {}); + } catch (error: any) { + log('error', `[TELEGRAM BOT] MFP sync error: ${error.message}`); + await this.bot!.sendMessage( + chatId, + `❌ Помилка синхронізації MyFitnessPal: ${error.message}`, + { disable_notification: true } + ).catch(() => {}); + } finally { + this.activeNutritionSyncs.delete(chatId); + } + return this.bot!.answerCallbackQuery(query.id).catch(() => {}); + } + } + }); + + log('info', '[TELEGRAM BOT] Handlers setup complete.'); + } + + async handleLink(chatId: number, code: string): Promise { + const client = await poolManager.getSystemClient(); + try { + const result = await client.query( + `SELECT u.id, u.name, p.language + FROM public."user" u + LEFT JOIN user_preferences p ON u.id = p.user_id + WHERE u.telegram_link_code = $1`, + [code] + ); + + if (result.rows.length === 0) { + await this.bot!.sendMessage( + chatId, + '❌ Invalid linking code. Please check the web app for a fresh code.' + ); + return; + } + + const user = result.rows[0]; + await client.query( + 'UPDATE public."user" SET telegram_chat_id = $1, telegram_link_code = NULL WHERE id = $2', + [chatId.toString(), user.id] + ); + + const t = this.getTranslations(user.language); + await this.bot!.sendMessage( + chatId, + `✅ Success! Your account is now linked, ${user.name}. ${t.helpPrompt}`, + this.getMainMenuKeyboard(t) + ); + } catch (e: any) { + log('error', '[TELEGRAM BOT] Linking error:', e); + await this.bot!.sendMessage(chatId, `❌ Link error: ${e.message}`); + } finally { + client.release(); + } + } + + async processMessage( + chatId: number, + user: TelegramUser, + msg: TelegramBot.Message + ): Promise { + this.bot!.sendChatAction(chatId, 'typing'); + + const typingInterval = setInterval(() => { + this.bot!.sendChatAction(chatId, 'typing').catch(() => {}); + }, 4000); + + try { + const contentParts = await this.buildContentParts(chatId, msg); + if (!contentParts) { + clearInterval(typingInterval); + return; + } + + const aiService = await chatRepository.getActiveAiServiceSetting(user.id); + if (!aiService) { + clearInterval(typingInterval); + return this.bot!.sendMessage( + chatId, + 'No AI service configured. Please check your settings in the web app.' + ) as unknown as void; + } + + const chatHistory = await chatRepository.getChatHistoryByUserId(user.id); + let exerciseSummary = await this.getExerciseSummary(user.id); + let nutritionContext = await this.getUserNutritionContext(user.id); + let extraContext = ''; + + const processAiTurn = async (forceDataRequest: string | null = null) => { + let historyContext = chatHistory.map((h: any) => ({ + role: h.message_type === 'user' ? 'user' : 'assistant', + content: h.content, + })); + + const contextBlock = this.buildContextBlock( + user, + exerciseSummary, + nutritionContext, + extraContext + ); + const fullMessages = [ + { role: 'system', content: contextBlock }, + ...historyContext, + { + role: 'user', + content: forceDataRequest ? forceDataRequest : contentParts, + }, + ]; + + const response = await chatService.processChatMessage( + fullMessages, + aiService.id, + user.id + ); + + if (response && response.intent === 'request_data') { + clearInterval(typingInterval); + + // Відправляємо користувачеві проміжне повідомлення ("Один момент...") + const waitMsg = response.text || response.content; + if (waitMsg && waitMsg.trim() !== '') { + await this.bot!.sendMessage(chatId, waitMsg, { + parse_mode: 'HTML', + }).catch(() => {}); + } + + log( + 'info', + `[TELEGRAM BOT] Intent 'request_data' received. Params: ${JSON.stringify(response.data)}` + ); + return this.handleDataRequest( + chatId, + user, + response.data, + msg.text || '', + aiService?.id || '', + chatHistory + ); + } + return response; + }; + + let response = await processAiTurn(); + + clearInterval(typingInterval); + + if (response && (response.text || response.content)) { + const replyText = response.text || response.content; + await chatRepository.saveChatMessage( + user.id, + msg.text || '[Multi-modal]', + 'user' + ); + await chatRepository.saveChatMessage(user.id, replyText, 'assistant'); + + await this.bot!.sendMessage(chatId, replyText, { parse_mode: 'HTML' }); + + if (response.intent && response.intent !== 'request_data') { + await this.tryExecuteIntent(chatId, user, response); + } + } + } catch (e: any) { + clearInterval(typingInterval); + log('error', '[TELEGRAM BOT] Error processing message:', e); + this.bot!.sendMessage(chatId, `❌ AI Error: ${e.message}`); + } + } + + private async handleDataRequest( + chatId: number, + user: TelegramUser, + dataParams: any, + originalMsgText: string, + aiServiceId: string, + chatHistory: any[] + ): Promise { + this.bot!.sendChatAction(chatId, 'typing'); + const typingInterval = setInterval(() => { + this.bot!.sendChatAction(chatId, 'typing').catch(() => {}); + }, 4000); + + try { + const type = dataParams?.type || 'exercise_history'; + const days = parseInt(dataParams?.days) || 14; + let fetchedDataText = ''; + + const tz = await loadUserTimezone(user.id); + const today = todayInZone(tz); + const endDate = today; + const startDate = new Date( + new Date().setDate(new Date().getDate() - days) + ) + .toISOString() + .split('T')[0]; + + if (type.includes('exercise')) { + const exercises = await exerciseEntry.getExerciseEntriesByDateRange( + user.id, + startDate, + endDate + ); + if (!exercises || exercises.length === 0) { + fetchedDataText = `No exercises found in the last ${days} days.`; + } else { + fetchedDataText = exercises + .map((ex: any) => { + const date = ex.entry_date + ? new Date(ex.entry_date).toISOString().split('T')[0] + : 'Unknown'; + return `- ${date}: ${ex.exercise_name || ex.name} (${ex.duration_minutes}m, ${Math.round(ex.calories)}kcal)`; + }) + .join('\n'); + } + } else if (type.includes('food')) { + const foods = await foodEntry.getFoodEntriesByDateRange( + user.id, + startDate, + endDate + ); + if (!foods || foods.length === 0) { + fetchedDataText = `No food logs found in the last ${days} days.`; + } else { + fetchedDataText = foods + .map((f: any) => { + return `- ${f.entry_date}: ${f.food_name} (${Math.round(f.calories)}kcal)`; + }) + .join('\n'); + } + } else { + fetchedDataText = 'Requested data type not recognized.'; + } + + log( + 'info', + `[TELEGRAM BOT] Fetched data for ${type}:\n${fetchedDataText}` + ); + + const extraContext = `\n[SYSTEM UPDATE: The requested data has been fetched below. Use this to respond:]\n${fetchedDataText}\n\nCRITICAL INSTRUCTION: You MUST use the 'chat' intent to summarize the list above. It is STRICTLY FORBIDDEN to use 'request_data' intent now, as the data is already in this prompt.`; + const contextBlock = this.buildContextBlock(user, '', extraContext); + + const historyContext = chatHistory.map((h: any) => ({ + role: h.message_type === 'user' ? 'user' : 'assistant', + content: h.content, + })); + + const fullMessages = [ + { role: 'system', content: contextBlock }, + ...historyContext, + // Instead of repeating the user's query, we explicitly command the AI as the user + // and remove any trigger words like "last 10" or "history" + { + role: 'user', + content: `[SYSTEM: Data fetched. Read the SYSTEM UPDATE above and summarize the entries provided.]`, + }, + ]; + + log( + 'info', + `[TELEGRAM BOT] Second AI request payload prepared for user ${user.id}. Context Block contains fetched data.` + ); + + const response = await chatService.processChatMessage( + fullMessages, + aiServiceId, + user.id + ); + + log( + 'info', + `[TELEGRAM BOT] Second AI response received. Intent: ${response?.intent}, Text: ${response?.text?.substring(0, 100)}...` + ); + + clearInterval(typingInterval); + return response; + } catch (e: any) { + clearInterval(typingInterval); + log('error', '[TELEGRAM BOT] Error handling data request:', e); + this.bot!.sendMessage(chatId, `❌ AI Data Fetch Error: ${e.message}`); + } + } + + async tryExecuteIntent( + chatId: number, + user: TelegramUser, + response: any + ): Promise { + try { + const tz = await loadUserTimezone(user.id); + const today = todayInZone(tz); + + const result = await executeIntent( + response.intent, + response.data, + response.entryDate, + user.id, + today + ); + + if (result && result.message) { + await this.bot!.sendMessage(chatId, result.message); + } + } catch (e: any) { + log('error', '[TELEGRAM BOT] Intent execution error:', e); + } + } + + async handleDirectTodayLog( + chatId: number, + user: TelegramUser + ): Promise { + try { + const { todayInZone } = require('@workspace/shared'); + const { loadUserTimezone } = require('../../utils/timezoneLoader'); + const tz = await loadUserTimezone(user.id); + const today = todayInZone(tz); + + const client = await poolManager.getSystemClient(); + const result = await client.query( + `SELECT * FROM food_entries WHERE user_id = $1 AND entry_date = $2`, + [user.id, today] + ); + const todayFood = result.rows; + client.release(); + + if (todayFood.length === 0) { + return this.bot!.sendMessage( + chatId, + 'Жодних записів про їжу за сьогодні.' + ) as unknown as void; + } + + let text = `🍴 Щоденник за сьогодні (${today}):\n\n`; + let totalCals = 0; + + const grouped: { [key: string]: any[] } = { + breakfast: [], + lunch: [], + dinner: [], + snacks: [], + }; + + todayFood.forEach((f: any) => { + const type = f.meal_type || 'snacks'; + if (grouped[type]) grouped[type].push(f); + else grouped.snacks.push(f); + totalCals += Number(f.calories || 0); + }); + + const mealNames: { [key: string]: string } = { + breakfast: 'Сніданок', + lunch: 'Обід', + dinner: 'Вечеря', + snacks: 'Перекуси', + }; + + for (const [type, items] of Object.entries(grouped)) { + if (items.length > 0) { + text += `${mealNames[type]}:\n`; + items.forEach((i) => { + const cal = i.calories ? `${Math.round(i.calories)} ккал` : ''; + text += ` • ${i.food_name || i.name} — ${i.quantity} ${i.unit} ${cal}\n`; + }); + text += '\n'; + } + } + + text += `Всього: ${Math.round(totalCals)} ккал`; + this.bot!.sendMessage(chatId, text, { parse_mode: 'HTML' }); + } catch (e: any) { + this.bot!.sendMessage(chatId, `❌ Помилка: ${e.message}`); + } + } + + async handleDirectRecentExercises( + chatId: number, + user: TelegramUser + ): Promise { + try { + const { todayInZone } = require('@workspace/shared'); + const { loadUserTimezone } = require('../../utils/timezoneLoader'); + const tz = await loadUserTimezone(user.id); + const today = todayInZone(tz); + const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) + .toISOString() + .split('T')[0]; + + let exercises = await exerciseEntry.getExerciseEntriesByDateRange( + user.id, + startDate, + today + ); + + const t = this.getTranslations(user.language); + + if (!exercises || exercises.length === 0) { + return this.bot!.sendMessage( + chatId, + t.noRecentActivities + ) as unknown as void; + } + + exercises = exercises.filter((ex: any) => { + const name = (ex.exercise_name || ex.name || '').toLowerCase(); + if ( + name === 'active calories' && + !ex.duration_minutes && + !ex.distance + ) { + return false; + } + return true; + }); + + const uniqueExercisesMap = new Map(); + exercises.forEach((ex: any) => { + let dateStr = today; + if (ex.entry_date) { + const d = new Date(ex.entry_date); + if (!isNaN(d.getTime())) { + dateStr = d.toISOString().split('T')[0]; + } + } + + const name = (ex.exercise_name || ex.name || 'Activity').trim(); + const dur = Math.round(ex.duration_minutes || 0); + + const key = `${dateStr}|${name.toLowerCase()}|${dur}`; + const existing = uniqueExercisesMap.get(key); + + let keepCurrent = !existing; + if (existing) { + const existingScore = + (existing.distance ? 1 : 0) + (existing.avg_heart_rate ? 1 : 0); + const currentScore = + (ex.distance ? 1 : 0) + (ex.avg_heart_rate ? 1 : 0); + if (currentScore > existingScore) { + keepCurrent = true; + } + } + + if (keepCurrent) { + uniqueExercisesMap.set(key, { ...ex, entry_date_str: dateStr }); + } + }); + + const processedExercises = Array.from(uniqueExercisesMap.values()); + const grouped: { [key: string]: any[] } = {}; + processedExercises.forEach((ex) => { + const d = ex.entry_date_str; + if (!grouped[d]) grouped[d] = []; + grouped[d].push(ex); + }); + + let text = `${t.recentActivities}\n\n`; + const dates = Object.keys(grouped).sort((a, b) => b.localeCompare(a)); + + dates.forEach((dateString) => { + const dParts = dateString.split('-'); + let formattedLabel = dateString; + if (dParts.length === 3) { + formattedLabel = `${dParts[2]}.${dParts[1]}.${dParts[0]}`; + if (dateString === today) { + formattedLabel += ` (${t.todayLog.split(' ')[1] || 'Today'})`; + } + } + + text += `📅 ${formattedLabel}\n`; + grouped[dateString].forEach((ex) => { + const durationVal = Math.round(ex.duration_minutes || 0); + const durationText = durationVal > 0 ? `${durationVal}m` : ''; + const cals = ex.calories ? ` (${Math.round(ex.calories)} kcal)` : ''; + + let emoji = '🏋️'; + const name = (ex.exercise_name || ex.name || '').toLowerCase(); + if (name.includes('run')) emoji = '🏃'; + else if (name.includes('cycl') || name.includes('bike')) emoji = '🚴'; + else if (name.includes('swim')) emoji = '🏊'; + else if (name.includes('walk')) emoji = '🚶'; + else if (name.includes('hik')) emoji = '🧗'; + else if (name.includes('yoga')) emoji = '🧘'; + else if (name.includes('strength') || name.includes('press')) + emoji = '💪'; + + text += `${emoji} ${ex.exercise_name || ex.name} — ${durationText}${cals}\n`; + + const details = []; + if (ex.distance) { + details.push(`📍 ${Number(ex.distance).toFixed(2)} km`); + } + if (ex.avg_heart_rate) { + details.push(`❤️ ${Math.round(ex.avg_heart_rate)} bpm`); + } + + if (details.length > 0) { + text += ` ${details.join(' | ')}\n`; + } + }); + text += '\n'; + }); + + this.bot!.sendMessage(chatId, text.trim(), { parse_mode: 'HTML' }); + } catch (e: any) { + log('error', '[TELEGRAM BOT] Error fetching exercises:', e); + this.bot!.sendMessage(chatId, `❌ Помилка: ${e.message}`); + } + } + + private async buildContentParts( + chatId: number, + msg: TelegramBot.Message + ): Promise { + const parts: any[] = []; + let hasMedia = false; + + if (msg.text) { + parts.push({ type: 'text', text: msg.text }); + } else if (msg.caption) { + parts.push({ type: 'text', text: msg.caption }); + } + + // Handle photos + if (msg.photo && msg.photo.length > 0) { + hasMedia = true; + const largestPhoto = msg.photo[msg.photo.length - 1]; + try { + const base64Image = await this.getFileBase64(largestPhoto.file_id); + if (base64Image) { + parts.push({ + type: 'image_url', + image_url: { url: `data:image/jpeg;base64,${base64Image}` }, + }); + } + } catch (e: any) { + log('error', '[TELEGRAM BOT] Photo fetch error:', e.message); + } + } + + // Handle voice/video notes/audio + if (msg.voice || msg.audio || msg.video_note || msg.video || msg.document) { + hasMedia = true; + const fileId = + msg.voice?.file_id || + msg.audio?.file_id || + msg.video_note?.file_id || + msg.video?.file_id || + msg.document?.file_id; + try { + const base64Data = await this.getFileBase64(fileId!); + if (base64Data) { + log( + 'info', + `[TELEGRAM BOT] Fetched media file of length ${base64Data.length}` + ); + // Для сучасних моделей (як-от Gemini 1.5 Pro) ми можемо відправити аудіо/відео через data url або inline_data + // Тут ми визначаємо mime-type для базових форматів, які підтримує Telegram + let mimeType = 'application/octet-stream'; + if (msg.voice || msg.audio) mimeType = 'audio/ogg'; // Telegram voice notes are usually ogg + if (msg.video || msg.video_note) mimeType = 'video/mp4'; + + parts.push({ + type: 'image_url', // Використовуємо image_url, бо наш chatService.ts перетворює його на inline_data для Gemini + image_url: { url: `data:${mimeType};base64,${base64Data}` }, + }); + } + } catch (e: any) { + log('error', '[TELEGRAM BOT] Media fetch error:', e.message); + } + } + + if (parts.length === 0 && !hasMedia) { + return null; + } + + return parts.length > 0 ? parts : null; + } + + private async getFileBase64(fileId: string): Promise { + if (!this.bot) return null; + try { + const file = await this.bot.getFile(fileId); + const fileUrl = `https://api.telegram.org/file/bot${this.bot.token}/${file.file_path}`; + const response = await axios.get(fileUrl, { + responseType: 'arraybuffer', + }); + return Buffer.from(response.data, 'binary').toString('base64'); + } catch (e: any) { + log( + 'error', + '[TELEGRAM BOT] Error downloading file from Telegram:', + e.message + ); + return null; + } + } + + private buildContextBlock( + user: any, + exerciseSummary: string, + nutritionContext: string = '', + extraContext: string = '' + ): string { + const today = new Date().toISOString().split('T')[0]; + return ` +SYSTEM CONTEXT FOR SPARKY FITNESS AI (TELEGRAM): +- Current Date: ${today} +- Active User: ${user.name} (ID: ${user.id}) +- Preferred Language: ${user.language || 'en'} + +USER'S PHYSICAL PROFILE & NUTRITION GOALS: +${nutritionContext || 'No profile or goal data available.'} + +USER'S RECENT EXERCISE HISTORY (Last 7 Days): +${exerciseSummary || 'No recent exercises found.'} + +BEHAVIORAL INSTRUCTIONS: +1. You are Sparky, a professional and motivating fitness coach. +2. You are communicating via Telegram. Keep your responses VERY CONCISE, friendly, and use Markdown (bold, lists). +3. When the user asks about "workouts", "sessions", or "exercises" (e.g., "последние занятия"), refer to the Exercise History provided above. +4. For every message, you MUST identify the intent (log_food, log_exercise, log_measurement, chat, etc.) and return it in the JSON format as defined in your main system prompt. +5. If you are just chatting or answering a question without a specific log intent, use the "chat" or "ask_question" intent and put your response in the "response" field. +${extraContext} +`; + } + + private async getUserNutritionContext(userId: string): Promise { + try { + const tz = await loadUserTimezone(userId); + const today = todayInZone(tz); + + const [ + profile, + prefs, + goal, + todayFoods, + todayExercises, + latestMeasurement, + ] = await Promise.all([ + userRepository.getUserProfile(userId), + preferenceRepository.getUserPreferences(userId), + goalRepository.getMostRecentGoalBeforeDate(userId, today), + foodEntry.getFoodEntriesByDate(userId, today), + exerciseEntry.getExerciseEntriesByDate(userId, today), + measurementRepository.getLatestCheckInMeasurementsOnOrBeforeDate( + userId, + today + ), + ]); + + if (!profile && !goal) return ''; + + // Calculate Age + let age = null; + if (profile?.date_of_birth) { + const dob = new Date(profile.date_of_birth); + const ageDifMs = Date.now() - dob.getTime(); + const ageDate = new Date(ageDifMs); + age = Math.abs(ageDate.getUTCFullYear() - 1970); + } + + const weight = latestMeasurement?.weight || null; + const height = latestMeasurement?.height || null; + const gender = profile?.gender || null; + + // BMR / TDEE Calculation + let bmr = 0; + let tdee = 0; + if (weight && height && age && gender && prefs) { + bmr = bmrService.calculateBmr( + prefs.bmr_algorithm || bmrService.BmrAlgorithm.MIFFLIN_ST_JEOR, + weight, + height, + age, + gender + ); + const multiplier = + bmrService.ActivityMultiplier[prefs.activity_level] || 1.2; + tdee = bmr * multiplier; + } + + // Today's consumption (calories) + const caloriesConsumed = todayFoods.reduce( + (sum: number, f: any) => sum + Number(f.calories || 0), + 0 + ); + const proteinConsumed = todayFoods.reduce( + (sum: number, f: any) => sum + Number(f.protein || 0), + 0 + ); + const carbsConsumed = todayFoods.reduce( + (sum: number, f: any) => sum + Number(f.carbs || 0), + 0 + ); + const fatConsumed = todayFoods.reduce( + (sum: number, f: any) => sum + Number(f.fat || 0), + 0 + ); + const caloriesBurnedToday = todayExercises.reduce( + (sum: number, e: any) => sum + Number(e.calories_burned || 0), + 0 + ); + + const calGoal = Number(goal?.calories || 2000); + const remaining = calGoal + caloriesBurnedToday - caloriesConsumed; + + let context = `- Gender: ${gender || 'Unknown'}\n`; + if (age) context += `- Age: ${age} years\n`; + if (weight) context += `- Current Weight: ${weight} kg\n`; + if (height) context += `- Height: ${height} cm\n`; + if (bmr) context += `- Calculated BMR: ${Math.round(bmr)} kcal\n`; + if (tdee) context += `- TDEE (Maintenance): ${Math.round(tdee)} kcal\n`; + + context += `\nDAILY GOALS & PROGRESS (${today}):\n`; + context += `- Daily Base Calorie Goal: ${calGoal} kcal\n`; + context += `- Active Calories Burned Today: ${Math.round(caloriesBurnedToday)} kcal\n`; + context += `- Consumed Today: ${Math.round(caloriesConsumed)} kcal (${Math.round(proteinConsumed)}g P, ${Math.round(carbsConsumed)}g C, ${Math.round(fatConsumed)}g F)\n`; + context += `- REMAINING CALORIES: ${Math.round(remaining)} kcal (Goal + Burned - Consumed)\n`; + + if (goal?.protein) + context += `- Macronutrient Targets: ${goal.protein}g P, ${goal.carbs}g C, ${goal.fat}g F\n`; + + return context; + } catch (e: any) { + log( + 'error', + '[TELEGRAM BOT] Error building nutrition context:', + e.message + ); + return ''; + } + } + + private async findUserAndLanguageByChatId( + chatId: number + ): Promise { + const client = await poolManager.getSystemClient(); + try { + const result = await client.query( + `SELECT u.id, u.name, p.language, u.telegram_chat_id, p.timezone + FROM public."user" u + LEFT JOIN user_preferences p ON u.id = p.user_id + WHERE u.telegram_chat_id = $1`, + [chatId.toString()] + ); + return result.rows[0] || null; + } finally { + client.release(); + } + } + + private async getExerciseSummary(userId: string): Promise { + try { + const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) + .toISOString() + .split('T')[0]; + const endDate = new Date().toISOString().split('T')[0]; + const exercises = await exerciseEntry.getExerciseEntriesByDateRange( + userId, + startDate, + endDate + ); + + if (!exercises || exercises.length === 0) + return 'No exercises in the last 7 days.'; + + return exercises + .map((ex: any) => { + const date = ex.entry_date + ? new Date(ex.entry_date).toISOString().split('T')[0] + : 'Unknown'; + return `- ${date}: ${ex.exercise_name || ex.name} (${ex.duration_minutes}m, ${ex.calories}kcal)`; + }) + .join('\n'); + } catch (e) { + return 'Error fetching exercise summary.'; + } + } + + private async setLanguage(userId: string, lang: string): Promise { + const client = await poolManager.getSystemClient(); + try { + await client.query( + 'UPDATE user_preferences SET language = $1 WHERE user_id = $2', + [lang, userId] + ); + } finally { + client.release(); + } + } + + private async formatProfileResponse( + userId: string, + lang: string + ): Promise { + // Simplified profile formatter + return `👤 Profile Info\nUser ID: ${userId}\nLanguage: ${lang}`; + } + + private getTranslations(lang: string): TranslationSet { + const dicts: { [key: string]: TranslationSet } = { + en: { + greeting: 'Hello', + helpPrompt: 'How can I help you today?', + welcome: 'Welcome!', + noRecentActivities: 'No recent activities.', + recentActivities: '🏋️ Recent Activities', + todayLog: '🍏 What did I eat?', + macros: '📊 Macros/Profile', + profile: '👤 Profile', + language: '🌐 Language', + diary: '📔 Diary Menu', + exercises: '🏋️ Exercises', + syncMenu: '🔄 Sync Menu', + back: '⬅️ Back', + langSet: '✅ Language updated to English.', + syncGarmin: 'Garmin Data Sync', + syncMFP: 'Sync Nutrition to MyFitnessPal', + }, + uk: { + greeting: 'Привіт', + helpPrompt: 'Чим я можу допомогти?', + welcome: 'Вітаємо!', + noRecentActivities: 'Останніх занять не знайдено.', + recentActivities: '🏋️ Останні заняття', + todayLog: "🍏 Що я з'їв?", + macros: '📊 Макроси/Профіль', + profile: '👤 Профіль', + language: '🌐 Мова', + diary: '📔 Меню щоденника', + exercises: '🏋️ Заняття', + syncMenu: '🔄 Меню синхронізації', + back: '⬅️ Назад', + langSet: '✅ Мову змінено на українську.', + syncGarmin: 'Синхронізація Garmin', + syncMFP: 'Синхронізувати з MyFitnessPal', + }, + ru: { + greeting: 'Привет', + helpPrompt: 'Чем я могу помочь?', + welcome: 'Добро пожаловать!', + noRecentActivities: 'Последних занятий не найдено.', + recentActivities: '🏋️ Последние занятия', + todayLog: '🍏 Что я съел?', + macros: '📊 Макросы/Профиль', + profile: '👤 Профиль', + language: '🌐 Язык', + diary: '📔 Меню дневника', + exercises: '🏋️ Занятия', + syncMenu: '🔄 Меню синхронизации', + back: '⬅️ Назад', + langSet: '✅ Язык изменен на русский.', + syncGarmin: 'Синхронизация Garmin', + syncMFP: 'Синхронизировать с MyFitnessPal', + }, + }; + return dicts[lang] || dicts.en; + } + + private getMainMenuKeyboard( + t: TranslationSet + ): TelegramBot.SendMessageOptions { + return { + reply_markup: { + keyboard: [ + [{ text: t.profile }, { text: t.diary }], + [{ text: t.syncMenu }, { text: t.language }], + ], + resize_keyboard: true, + }, + }; + } + + private getDiaryMenuKeyboard( + t: TranslationSet + ): TelegramBot.SendMessageOptions { + return { + reply_markup: { + keyboard: [ + [{ text: t.todayLog }], + [{ text: t.exercises }], + [{ text: t.back }], + ], + resize_keyboard: true, + }, + }; + } + + private async showLanguageMenu(chatId: number): Promise { + await this.bot!.sendMessage(chatId, 'Оберіть мову / Choose language:', { + reply_markup: { + inline_keyboard: [ + [ + { text: '🇺🇦 Українська', callback_data: 'setlang:uk' }, + { text: '🇬🇧 English', callback_data: 'setlang:en' }, + { text: '🇷🇺 Русский', callback_data: 'setlang:ru' }, + ], + ], + }, + }); + } + + private async showSyncMenu(chatId: number, lang: string): Promise { + const t = this.getTranslations(lang); + await this.bot!.sendMessage( + chatId, + 'Оберіть платформу для синхронізації:', + { + reply_markup: { + inline_keyboard: [ + [{ text: t.syncGarmin, callback_data: 'sync:garmin' }], + [ + { + text: t.syncMFP, + callback_data: 'sync:mfp', + }, + ], + [{ text: t.back, callback_data: 'main_menu' }], + ], + }, + } + ); + } +} + +export default new TelegramBotService(); diff --git a/SparkyFitnessServer/models/chatRepository.js b/SparkyFitnessServer/models/chatRepository.ts similarity index 73% rename from SparkyFitnessServer/models/chatRepository.js rename to SparkyFitnessServer/models/chatRepository.ts index 01806678d..2fd5d4f80 100644 --- a/SparkyFitnessServer/models/chatRepository.js +++ b/SparkyFitnessServer/models/chatRepository.ts @@ -1,9 +1,41 @@ const { getClient, getSystemClient } = require('../db/poolManager'); const { encrypt, decrypt, ENCRYPTION_KEY } = require('../security/encryption'); -const { log } = require('../config/logging'); +import { log } from '../config/logging'; + +export interface AiServiceSetting { + id?: string; + user_id?: string; + service_name: string; + service_type: string; + custom_url?: string; + system_prompt?: string; + is_active: boolean; + model_name?: string; + is_public?: boolean; + api_key?: string; + encrypted_api_key?: string; + api_key_iv?: string; + api_key_tag?: string; + source?: string; +} + +export interface ChatHistoryEntry { + id?: string; + user_id: string; + content: string; + message_type: string; + metadata?: any; + session_id?: string; + message?: string; + response?: string; + created_at?: Date; + updated_at?: Date; +} -async function upsertAiServiceSetting(settingData) { - const client = await getClient(settingData.user_id); // User-specific operation +export async function upsertAiServiceSetting( + settingData: AiServiceSetting +): Promise { + const client = await getClient(settingData.user_id); try { let encryptedApiKey = settingData.encrypted_api_key || null; let apiKeyIv = settingData.api_key_iv || null; @@ -20,7 +52,6 @@ async function upsertAiServiceSetting(settingData) { } if (settingData.id) { - // Update existing service const result = await client.query( `UPDATE ai_service_settings SET service_name = COALESCE($1, service_name), service_type = COALESCE($2, service_type), custom_url = $3, @@ -45,7 +76,6 @@ async function upsertAiServiceSetting(settingData) { ); return result.rows[0]; } else { - // Insert new service const result = await client.query( `INSERT INTO ai_service_settings ( user_id, service_name, service_type, custom_url, system_prompt, @@ -71,10 +101,12 @@ async function upsertAiServiceSetting(settingData) { } } -async function getAiServiceSettingForBackend(id, userId) { - const client = await getClient(userId); // User-specific operation +export async function getAiServiceSettingForBackend( + id: string, + userId: string +): Promise { + const client = await getClient(userId); try { - // Try to get setting (can be user-specific or global) const result = await client.query( 'SELECT * FROM ai_service_settings WHERE id = $1', [id] @@ -111,8 +143,11 @@ async function getAiServiceSettingForBackend(id, userId) { } } -async function getAiServiceSettingById(id, userId) { - const client = await getClient(userId); // User-specific operation +export async function getAiServiceSettingById( + id: string, + userId: string +): Promise { + const client = await getClient(userId); try { const result = await client.query( 'SELECT id, service_name, service_type, custom_url, is_active, model_name FROM ai_service_settings WHERE id = $1', @@ -124,8 +159,11 @@ async function getAiServiceSettingById(id, userId) { } } -async function deleteAiServiceSetting(id, userId) { - const client = await getClient(userId); // User-specific operation +export async function deleteAiServiceSetting( + id: string, + userId: string +): Promise { + const client = await getClient(userId); try { const result = await client.query( 'DELETE FROM ai_service_settings WHERE id = $1 RETURNING id', @@ -137,28 +175,26 @@ async function deleteAiServiceSetting(id, userId) { } } -async function getAiServiceSettingsByUserId(userId) { - const client = await getClient(userId); // User-specific operation +export async function getAiServiceSettingsByUserId( + userId: string +): Promise { + const client = await getClient(userId); try { - // Get user-specific settings const userResult = await client.query( 'SELECT id, service_name, service_type, custom_url, is_active, model_name, is_public, system_prompt FROM ai_service_settings WHERE is_public = FALSE AND user_id = $1 ORDER BY created_at DESC', [userId] ); - // Get global settings (all authenticated users can read) const globalResult = await client.query( 'SELECT id, service_name, service_type, custom_url, is_active, model_name, is_public, system_prompt FROM ai_service_settings WHERE is_public = TRUE ORDER BY created_at DESC', [] ); - // Combine results: user settings first, then public settings - // Add is_public flag to distinguish them - const userSettings = userResult.rows.map((row) => ({ + const userSettings = userResult.rows.map((row: any) => ({ ...row, is_public: false, })); - const publicSettings = globalResult.rows.map((row) => ({ + const publicSettings = globalResult.rows.map((row: any) => ({ ...row, is_public: true, })); @@ -169,10 +205,11 @@ async function getAiServiceSettingsByUserId(userId) { } } -async function getActiveAiServiceSetting(userId) { - const client = await getClient(userId); // User-specific operation +export async function getActiveAiServiceSetting( + userId: string +): Promise { + const client = await getClient(userId); try { - // Priority 1: User-specific active setting const userResult = await client.query( 'SELECT id, service_name, service_type, custom_url, is_active, model_name, is_public, system_prompt FROM ai_service_settings WHERE is_active = TRUE AND is_public = FALSE AND user_id = $1 ORDER BY created_at DESC LIMIT 1', [userId] @@ -187,7 +224,6 @@ async function getActiveAiServiceSetting(userId) { return { ...setting, source: 'user' }; } - // Priority 2: Database global active setting const globalResult = await client.query( 'SELECT id, service_name, service_type, custom_url, is_active, model_name, is_public, system_prompt FROM ai_service_settings WHERE is_active = TRUE AND is_public = TRUE ORDER BY created_at DESC LIMIT 1', [] @@ -209,7 +245,7 @@ async function getActiveAiServiceSetting(userId) { } } -async function clearOldChatHistory(userId) { +export async function clearOldChatHistory(userId: string): Promise { const client = await getClient(userId); try { await client.query( @@ -225,21 +261,24 @@ async function clearOldChatHistory(userId) { } } -async function getChatHistoryByUserId(userId) { - const client = await getClient(userId); // User-specific operation +export async function getChatHistoryByUserId(userId: string): Promise { + const client = await getClient(userId); try { const result = await client.query( - 'SELECT content, message_type, created_at FROM sparky_chat_history ORDER BY created_at ASC LIMIT 5', - [] + 'SELECT content, message_type, metadata, created_at FROM sparky_chat_history WHERE user_id = $1 ORDER BY created_at DESC LIMIT 10', + [userId] ); - return result.rows; + return result.rows.reverse(); } finally { client.release(); } } -async function getChatHistoryEntryById(id, userId) { - const client = await getClient(userId); // User-specific operation (RLS will handle access) +export async function getChatHistoryEntryById( + id: string, + userId: string +): Promise { + const client = await getClient(userId); try { const result = await client.query( 'SELECT * FROM sparky_chat_history WHERE id = $1', @@ -251,8 +290,11 @@ async function getChatHistoryEntryById(id, userId) { } } -async function getChatHistoryEntryOwnerId(id, userId) { - const client = await getClient(userId); // User-specific operation (RLS will handle access) +export async function getChatHistoryEntryOwnerId( + id: string, + userId: string +): Promise { + const client = await getClient(userId); try { const result = await client.query( 'SELECT user_id FROM sparky_chat_history WHERE id = $1', @@ -264,8 +306,12 @@ async function getChatHistoryEntryOwnerId(id, userId) { } } -async function updateChatHistoryEntry(id, userId, updateData) { - const client = await getClient(userId); // User-specific operation +export async function updateChatHistoryEntry( + id: string, + userId: string, + updateData: any +): Promise { + const client = await getClient(userId); try { const result = await client.query( `UPDATE sparky_chat_history SET @@ -294,8 +340,11 @@ async function updateChatHistoryEntry(id, userId, updateData) { } } -async function deleteChatHistoryEntry(id, userId) { - const client = await getClient(userId); // User-specific operation +export async function deleteChatHistoryEntry( + id: string, + userId: string +): Promise { + const client = await getClient(userId); try { const result = await client.query( 'DELETE FROM sparky_chat_history WHERE id = $1 RETURNING id', @@ -307,8 +356,8 @@ async function deleteChatHistoryEntry(id, userId) { } } -async function clearAllChatHistory(userId) { - const client = await getClient(userId); // User-specific operation +export async function clearAllChatHistory(userId: string): Promise { + const client = await getClient(userId); try { await client.query('DELETE FROM sparky_chat_history', []); return true; @@ -317,18 +366,18 @@ async function clearAllChatHistory(userId) { } } -async function saveChatHistory(historyData) { - const client = await getClient(historyData.user_id); // User-specific operation +export async function saveChatMessage( + userId: string, + content: string, + messageType: string, + metadata: any = null +): Promise { + const client = await getClient(userId); try { await client.query( `INSERT INTO sparky_chat_history (user_id, content, message_type, metadata, created_at) VALUES ($1, $2, $3, $4, now())`, - [ - historyData.user_id, - historyData.content, - historyData.messageType, - historyData.metadata, - ] + [userId, content, messageType, metadata] ); return true; } finally { @@ -336,8 +385,10 @@ async function saveChatHistory(historyData) { } } -async function upsertGlobalAiServiceSetting(settingData) { - const client = await getSystemClient(); // Use system client for global operations +export async function upsertGlobalAiServiceSetting( + settingData: any +): Promise { + const client = await getSystemClient(); try { let encryptedApiKey = settingData.encrypted_api_key || null; let apiKeyIv = settingData.api_key_iv || null; @@ -354,7 +405,6 @@ async function upsertGlobalAiServiceSetting(settingData) { } if (settingData.id) { - // Update existing global service const result = await client.query( `UPDATE ai_service_settings SET service_name = $1, service_type = $2, custom_url = $3, @@ -379,7 +429,6 @@ async function upsertGlobalAiServiceSetting(settingData) { ); return result.rows[0]; } else { - // Insert new global service const result = await client.query( `INSERT INTO ai_service_settings ( user_id, is_public, service_name, service_type, custom_url, system_prompt, @@ -404,8 +453,8 @@ async function upsertGlobalAiServiceSetting(settingData) { } } -async function getGlobalAiServiceSettings() { - const client = await getSystemClient(); // Use system client for global operations +export async function getGlobalAiServiceSettings(): Promise { + const client = await getSystemClient(); try { const result = await client.query( 'SELECT id, service_name, service_type, custom_url, is_active, model_name, is_public, system_prompt, created_at, updated_at FROM ai_service_settings WHERE is_public = TRUE ORDER BY created_at DESC', @@ -417,8 +466,8 @@ async function getGlobalAiServiceSettings() { } } -async function getGlobalAiServiceSettingById(id) { - const client = await getSystemClient(); // Use system client for global operations +export async function getGlobalAiServiceSettingById(id: string): Promise { + const client = await getSystemClient(); try { const result = await client.query( 'SELECT id, service_name, service_type, custom_url, is_active, model_name, is_public FROM ai_service_settings WHERE id = $1 AND is_public = TRUE', @@ -430,8 +479,10 @@ async function getGlobalAiServiceSettingById(id) { } } -async function deleteGlobalAiServiceSetting(id) { - const client = await getSystemClient(); // Use system client for global operations +export async function deleteGlobalAiServiceSetting( + id: string +): Promise { + const client = await getSystemClient(); try { const result = await client.query( 'DELETE FROM ai_service_settings WHERE id = $1 AND is_public = TRUE RETURNING id', @@ -442,24 +493,3 @@ async function deleteGlobalAiServiceSetting(id) { client.release(); } } - -module.exports = { - upsertAiServiceSetting, - getAiServiceSettingById, - getAiServiceSettingForBackend, - deleteAiServiceSetting, - getAiServiceSettingsByUserId, - getActiveAiServiceSetting, - clearOldChatHistory, - getChatHistoryByUserId, - getChatHistoryEntryById, - getChatHistoryEntryOwnerId, - updateChatHistoryEntry, - deleteChatHistoryEntry, - clearAllChatHistory, - saveChatHistory, - upsertGlobalAiServiceSetting, - getGlobalAiServiceSettings, - getGlobalAiServiceSettingById, - deleteGlobalAiServiceSetting, -}; diff --git a/SparkyFitnessServer/models/exerciseEntry.js b/SparkyFitnessServer/models/exerciseEntry.ts similarity index 55% rename from SparkyFitnessServer/models/exerciseEntry.js rename to SparkyFitnessServer/models/exerciseEntry.ts index 3b3b972df..77396827e 100644 --- a/SparkyFitnessServer/models/exerciseEntry.js +++ b/SparkyFitnessServer/models/exerciseEntry.ts @@ -1,23 +1,68 @@ const { getClient } = require('../db/poolManager'); const format = require('pg-format'); -const { log } = require('../config/logging'); +import { log } from '../config/logging'; const exerciseRepository = require('./exercise'); const activityDetailsRepository = require('./activityDetailsRepository'); -async function upsertExerciseEntryData( - userId, - createdByUserId, - exerciseId, - caloriesBurned, - date -) { +export interface ExerciseEntrySet { + id?: string; + set_number: number; + set_type: string; + reps?: number; + weight?: number; + duration?: number; + rest_time?: number; + notes?: string; + rpe?: number; +} + +export interface ExerciseEntry { + id?: string; + user_id: string; + exercise_id: string; + duration_minutes: number; + calories_burned: number; + entry_date: string | Date; + notes?: string; + workout_plan_assignment_id?: string | null; + image_url?: string | null; + distance?: number | null; + avg_heart_rate?: number | null; + exercise_name?: string; + calories_per_hour?: number; + category?: string; + source?: string; + source_id?: string; + force?: string; + level?: string; + mechanic?: string; + equipment?: any; + primary_muscles?: any; + secondary_muscles?: any; + instructions?: any; + images?: any; + sort_order?: number; + steps?: number | null; + exercise_preset_entry_id?: string | null; + created_at?: Date; + updated_at?: Date; + sets?: ExerciseEntrySet[]; + activity_details?: any[]; +} + +export async function upsertExerciseEntryData( + userId: string, + createdByUserId: string, + exerciseId: string, + caloriesBurned: number, + date: string | Date +): Promise { log('info', 'upsertExerciseEntryData received date parameter:', date); const client = await getClient(userId); - let existingEntry; - let exerciseName = 'Unknown Exercise'; // Default value + let existingEntry: any = null; + let exerciseName = 'Unknown Exercise'; try { - // Fetch exercise name const exercise = await exerciseRepository.getExerciseById( exerciseId, userId @@ -40,21 +85,20 @@ async function upsertExerciseEntryData( [userId, exerciseId, date] ); existingEntry = result.rows[0]; - } catch (error) { + } catch (error: any) { log( 'error', 'Error checking for existing active calories exercise entry or fetching exercise name:', error ); throw new Error( - `Failed to check existing active calories exercise entry or fetch exercise name: ${error.message}`, - { cause: error } + `Failed to check existing active calories exercise entry or fetch exercise name: ${error.message}` ); } finally { client.release(); } - let result; + let finalResult: any; if (existingEntry) { log( 'info', @@ -72,12 +116,11 @@ async function upsertExerciseEntryData( existingEntry.id, ] ); - result = updateResult.rows[0]; - } catch (error) { + finalResult = updateResult.rows[0]; + } catch (error: any) { log('error', 'Error updating active calories exercise entry:', error); throw new Error( - `Failed to update active calories exercise entry: ${error.message}`, - { cause: error } + `Failed to update active calories exercise entry: ${error.message}` ); } finally { updateClient.release(); @@ -103,21 +146,23 @@ async function upsertExerciseEntryData( exerciseName, ] ); - result = insertResult.rows[0]; - } catch (error) { + finalResult = insertResult.rows[0]; + } catch (error: any) { log('error', 'Error inserting active calories exercise entry:', error); throw new Error( - `Failed to insert active calories exercise entry: ${error.message}`, - { cause: error } + `Failed to insert active calories exercise entry: ${error.message}` ); } finally { insertClient.release(); } } - return result; + return finalResult; } -async function _getExerciseEntryByIdWithClient(client, id) { +export async function _getExerciseEntryByIdWithClient( + client: any, + id: string +): Promise { const result = await client.query( `SELECT ee.*, COALESCE( @@ -137,81 +182,40 @@ async function _getExerciseEntryByIdWithClient(client, id) { ); const exerciseEntry = result.rows[0]; - if (exerciseEntry && exerciseEntry.equipment) { - try { - exerciseEntry.equipment = JSON.parse(exerciseEntry.equipment); - } catch (e) { - log( - 'error', - `Error parsing equipment for exercise entry ${exerciseEntry.id}:`, - e - ); - exerciseEntry.equipment = []; - } - } - if (exerciseEntry && exerciseEntry.primary_muscles) { - try { - exerciseEntry.primary_muscles = JSON.parse(exerciseEntry.primary_muscles); - } catch (e) { - log( - 'error', - `Error parsing primary_muscles for exercise entry ${exerciseEntry.id}:`, - e - ); - exerciseEntry.primary_muscles = []; - } - } - if (exerciseEntry && exerciseEntry.secondary_muscles) { - try { - exerciseEntry.secondary_muscles = JSON.parse( - exerciseEntry.secondary_muscles - ); - } catch (e) { - log( - 'error', - `Error parsing secondary_muscles for exercise entry ${exerciseEntry.id}:`, - e - ); - exerciseEntry.secondary_muscles = []; - } - } - if (exerciseEntry && exerciseEntry.instructions) { - try { - exerciseEntry.instructions = JSON.parse(exerciseEntry.instructions); - } catch (e) { - log( - 'error', - `Error parsing instructions for exercise entry ${exerciseEntry.id}:`, - e - ); - exerciseEntry.instructions = []; - } - } - if (exerciseEntry && exerciseEntry.images) { - try { - exerciseEntry.images = JSON.parse(exerciseEntry.images); - } catch (e) { - log( - 'error', - `Error parsing images for exercise entry ${exerciseEntry.id}:`, - e - ); - exerciseEntry.images = []; - } + if (exerciseEntry) { + [ + 'equipment', + 'primary_muscles', + 'secondary_muscles', + 'instructions', + 'images', + ].forEach((field) => { + if (exerciseEntry[field] && typeof exerciseEntry[field] === 'string') { + try { + exerciseEntry[field] = JSON.parse(exerciseEntry[field]); + } catch (e) { + log( + 'error', + `Error parsing ${field} for exercise entry ${exerciseEntry.id}:`, + e + ); + exerciseEntry[field] = []; + } + } + }); } return exerciseEntry; } -async function _updateExerciseEntryWithClient( - client, - id, - userId, - updateData, - updatedByUserId, - entrySource -) { - // Fetch existing entry to get current snapshot values if not provided in updateData +export async function _updateExerciseEntryWithClient( + client: any, + id: string, + userId: string, + updateData: any, + updatedByUserId: string, + entrySource?: string +): Promise { const existingEntryResult = await client.query( 'SELECT * FROM exercise_entries WHERE id = $1 AND user_id = $2', [id, userId] @@ -221,11 +225,9 @@ async function _updateExerciseEntryWithClient( } const currentEntry = existingEntryResult.rows[0]; - // Merge updateData with currentEntry to ensure all fields are present for the update statement - // Prioritize updateData, then currentEntry, then defaults const mergedData = { - ...currentEntry, // Start with existing data - ...updateData, // Overlay with new data + ...currentEntry, + ...updateData, exercise_id: updateData.exercise_id !== undefined ? updateData.exercise_id @@ -266,12 +268,11 @@ async function _updateExerciseEntryWithClient( updateData.sort_order !== undefined ? updateData.sort_order : currentEntry.sort_order, - // Snapshot fields - these should ideally come from the exercise itself if exercise_id is updated exercise_name: updateData.exercise_name || currentEntry.exercise_name, calories_per_hour: updateData.calories_per_hour || currentEntry.calories_per_hour, category: updateData.category || currentEntry.category, - source: entrySource || currentEntry.source, // Use provided entrySource or existing + source: entrySource || currentEntry.source, source_id: updateData.source_id || currentEntry.source_id, force: updateData.force || currentEntry.force, level: updateData.level || currentEntry.level, @@ -284,7 +285,6 @@ async function _updateExerciseEntryWithClient( images: updateData.images || currentEntry.images, }; - // If exercise_id is explicitly updated, re-fetch snapshot data from the exercise if ( updateData.exercise_id && updateData.exercise_id !== currentEntry.exercise_id @@ -293,9 +293,7 @@ async function _updateExerciseEntryWithClient( updateData.exercise_id, userId ); - if (!exercise) { - throw new Error('Exercise not found for snapshot update.'); - } + if (!exercise) throw new Error('Exercise not found for snapshot update.'); mergedData.exercise_name = exercise.name; mergedData.calories_per_hour = exercise.calories_per_hour; mergedData.category = exercise.category; @@ -312,34 +310,13 @@ async function _updateExerciseEntryWithClient( await client.query( `UPDATE exercise_entries SET - exercise_id = $1, - duration_minutes = $2, - calories_burned = $3, - entry_date = $4, - notes = $5, - workout_plan_assignment_id = $6, - image_url = $7, - distance = $8, - avg_heart_rate = $9, - updated_by_user_id = $10, - exercise_name = $11, - calories_per_hour = $12, - category = $13, - source = $14, - source_id = $15, - force = $16, - level = $17, - mechanic = $18, - equipment = $19, - primary_muscles = $20, - secondary_muscles = $21, - instructions = $22, - images = $23, - sort_order = $24, - steps = $25, - updated_at = now() - WHERE id = $26 AND user_id = $27 - RETURNING id`, + exercise_id = $1, duration_minutes = $2, calories_burned = $3, entry_date = $4, notes = $5, + workout_plan_assignment_id = $6, image_url = $7, distance = $8, avg_heart_rate = $9, + updated_by_user_id = $10, exercise_name = $11, calories_per_hour = $12, category = $13, + source = $14, source_id = $15, force = $16, level = $17, mechanic = $18, equipment = $19, + primary_muscles = $20, secondary_muscles = $21, instructions = $22, images = $23, + sort_order = $24, steps = $25, updated_at = now() + WHERE id = $26 AND user_id = $27`, [ mergedData.exercise_id, mergedData.duration_minutes, @@ -375,15 +352,13 @@ async function _updateExerciseEntryWithClient( ] ); - // Handle sets update if (updateData.sets !== undefined) { - // Only modify sets if they are explicitly provided await client.query( 'DELETE FROM exercise_entry_sets WHERE exercise_entry_id = $1', [id] ); if (Array.isArray(updateData.sets) && updateData.sets.length > 0) { - const setsValues = updateData.sets.map((set) => [ + const setsValues = updateData.sets.map((set: any) => [ id, set.set_number, set.set_type, @@ -404,19 +379,16 @@ async function _updateExerciseEntryWithClient( return _getExerciseEntryByIdWithClient(client, id); } -async function _createExerciseEntryWithClient( - client, - userId, - entryData, - createdByUserId, - entrySource = 'Manual', - exercisePresetEntryId = null -) { +export async function _createExerciseEntryWithClient( + client: any, + userId: string, + entryData: any, + createdByUserId: string, + entrySource: string = 'Manual', + exercisePresetEntryId: string | null = null +): Promise { try { - // Check for existing entry - // treat entries without a preset ID as unique if their exercise_id, entry_date, and source match. - // For entries within a preset, we always allow duplicates (no uniqueness check). - const syncDuplicateCheck = entryData.source_id ? true : false; + const syncDuplicateCheck = !!entryData.source_id; const skipManualDuplicateCheck = [ 'HealthKit', 'Health Connect', @@ -424,9 +396,8 @@ async function _createExerciseEntryWithClient( 'Strava', ].includes(entrySource); - let existingEntryResult; + let existingEntryResult: any; - // 1. Attempt precise sync deduplication via source_id if available if (syncDuplicateCheck) { existingEntryResult = await client.query( 'SELECT id FROM exercise_entries WHERE user_id = $1 AND source = $2 AND source_id = $3', @@ -434,9 +405,6 @@ async function _createExerciseEntryWithClient( ); } - // 2. If no source_id match and NOT a sync source, fall back to "Manual" deduplication (name/date). - // Skip this fallback when source_id was provided: a source_id miss means it's a genuinely new - // activity (different activityId), so we must INSERT rather than match on exercise_id + date. if ( !existingEntryResult?.rows?.length && !exercisePresetEntryId && @@ -444,14 +412,11 @@ async function _createExerciseEntryWithClient( !syncDuplicateCheck ) { if (entryData.workout_plan_assignment_id) { - // If it's linked to a workout plan assignment, it's unique by that assignment ID and date. existingEntryResult = await client.query( 'SELECT id FROM exercise_entries WHERE user_id = $1 AND workout_plan_assignment_id = $2 AND entry_date = $3', [userId, entryData.workout_plan_assignment_id, entryData.entry_date] ); } else { - // For manual entries (no assignment), keep traditional uniqueness check by exercise_id and date. - // We explicitly ensure workout_plan_assignment_id is NULL to avoid matching template-generated entries. existingEntryResult = await client.query( 'SELECT id FROM exercise_entries WHERE user_id = $1 AND exercise_id = $2 AND entry_date = $3 AND source = $4 AND exercise_preset_entry_id IS NULL AND workout_plan_assignment_id IS NULL', [userId, entryData.exercise_id, entryData.entry_date, entrySource] @@ -459,13 +424,12 @@ async function _createExerciseEntryWithClient( } } - let newEntryId; + let resultId: string; if (existingEntryResult && existingEntryResult.rows.length > 0) { - // Entry exists, update it const existingEntryId = existingEntryResult.rows[0].id; log( 'info', - `Existing exercise entry found for user ${userId}, exercise ${entryData.exercise_id}, date ${entryData.entry_date}, source ${entrySource}. Updating entry ${existingEntryId}.` + `Existing exercise entry found for user ${userId}. Updating entry ${existingEntryId}.` ); const updatedEntry = await _updateExerciseEntryWithClient( client, @@ -475,22 +439,17 @@ async function _createExerciseEntryWithClient( createdByUserId, entrySource ); - newEntryId = updatedEntry.id; + resultId = updatedEntry.id; } else { - // No existing entry, create a new one - // 1. Fetch the exercise details to create the snapshot const exerciseSnapshotQuery = await client.query( `SELECT name, calories_per_hour, category, source, source_id, force, level, mechanic, equipment, primary_muscles, secondary_muscles, instructions, images FROM exercises WHERE id = $1`, [entryData.exercise_id] ); - - if (exerciseSnapshotQuery.rows.length === 0) { + if (exerciseSnapshotQuery.rows.length === 0) throw new Error('Exercise not found for snapshotting.'); - } const snapshot = exerciseSnapshotQuery.rows[0]; - // 2. Insert the exercise entry with the snapshot data const entryResult = await client.query( `INSERT INTO exercise_entries ( user_id, exercise_id, duration_minutes, calories_burned, entry_date, notes, @@ -502,18 +461,18 @@ async function _createExerciseEntryWithClient( [ userId, entryData.exercise_id, - entryData.duration_minutes || 0, // Ensure duration_minutes is not null + entryData.duration_minutes || 0, entryData.calories_burned || 0, entryData.entry_date, entryData.notes, entryData.workout_plan_assignment_id || null, entryData.image_url || null, createdByUserId, - entryData.exercise_name || snapshot.name, // exercise_name + entryData.exercise_name || snapshot.name, snapshot.calories_per_hour, snapshot.category, entrySource, - entryData.source_id || snapshot.source_id, // Use entryData.source_id if available (instance ID), fallback to snapshot (def ID) + entryData.source_id || snapshot.source_id, snapshot.force, snapshot.level, snapshot.mechanic, @@ -522,18 +481,18 @@ async function _createExerciseEntryWithClient( snapshot.secondary_muscles, snapshot.instructions, snapshot.images, - entryData.distance || null, // Ensure distance is not undefined - entryData.avg_heart_rate || null, // Ensure avg_heart_rate is not undefined - exercisePresetEntryId, // New parameter + entryData.distance || null, + entryData.avg_heart_rate || null, + exercisePresetEntryId, entryData.sort_order || 0, entryData.steps || null, ] ); - newEntryId = entryResult.rows[0].id; + resultId = entryResult.rows[0].id; if (entryData.sets && entryData.sets.length > 0) { - const setsValues = entryData.sets.map((set) => [ - newEntryId, + const setsValues = entryData.sets.map((set: any) => [ + resultId, set.set_number, set.set_type, set.reps, @@ -550,8 +509,7 @@ async function _createExerciseEntryWithClient( await client.query(setsQuery); } } - - return _getExerciseEntryByIdWithClient(client, newEntryId); + return _getExerciseEntryByIdWithClient(client, resultId); } catch (error) { log( 'error', @@ -562,13 +520,13 @@ async function _createExerciseEntryWithClient( } } -async function createExerciseEntry( - userId, - entryData, - createdByUserId, - entrySource = 'Manual', - exercisePresetEntryId = null -) { +export async function createExerciseEntry( + userId: string, + entryData: any, + createdByUserId: string, + entrySource: string = 'Manual', + exercisePresetEntryId: string | null = null +): Promise { const client = await getClient(userId); try { await client.query('BEGIN'); @@ -584,18 +542,16 @@ async function createExerciseEntry( return entry; } catch (error) { await client.query('ROLLBACK'); - log( - 'error', - 'Error creating/updating exercise entry with snapshot:', - error - ); throw error; } finally { client.release(); } } -async function getExerciseEntryById(id, userId) { +export async function getExerciseEntryById( + id: string, + userId: string +): Promise { const client = await getClient(userId); try { return _getExerciseEntryByIdWithClient(client, id); @@ -604,7 +560,10 @@ async function getExerciseEntryById(id, userId) { } } -async function getExerciseEntryOwnerId(id, userId) { +export async function getExerciseEntryOwnerId( + id: string, + userId: string +): Promise { const client = await getClient(userId); try { const entryResult = await client.query( @@ -617,28 +576,24 @@ async function getExerciseEntryOwnerId(id, userId) { } } -async function updateExerciseEntry(id, userId, actingUserId, updateData) { +export async function updateExerciseEntry( + id: string, + userId: string, + actingUserId: string, + updateData: any +): Promise { const client = await getClient(userId); try { await client.query('BEGIN'); - await client.query( `UPDATE exercise_entries SET - exercise_id = COALESCE($1, exercise_id), - duration_minutes = COALESCE($2, duration_minutes), - calories_burned = COALESCE($3, calories_burned), - entry_date = COALESCE($4, entry_date), - notes = COALESCE($5, notes), - workout_plan_assignment_id = COALESCE($6, workout_plan_assignment_id), - image_url = $7, - distance = COALESCE($8, distance), - avg_heart_rate = COALESCE($9, avg_heart_rate), - sort_order = COALESCE($10, sort_order), - exercise_name = COALESCE($11, exercise_name), - updated_by_user_id = $12, - updated_at = now() - WHERE id = $13 AND user_id = $14 - RETURNING id`, + exercise_id = COALESCE($1, exercise_id), duration_minutes = COALESCE($2, duration_minutes), + calories_burned = COALESCE($3, calories_burned), entry_date = COALESCE($4, entry_date), + notes = COALESCE($5, notes), workout_plan_assignment_id = COALESCE($6, workout_plan_assignment_id), + image_url = $7, distance = COALESCE($8, distance), avg_heart_rate = COALESCE($9, avg_heart_rate), + sort_order = COALESCE($10, sort_order), exercise_name = COALESCE($11, exercise_name), + updated_by_user_id = $12, updated_at = now() + WHERE id = $13 AND user_id = $14`, [ updateData.exercise_id, updateData.duration_minutes || null, @@ -657,17 +612,13 @@ async function updateExerciseEntry(id, userId, actingUserId, updateData) { ] ); - // Only modify sets if they are explicitly provided in the update if (updateData.sets !== undefined) { - // Delete old sets for the entry await client.query( 'DELETE FROM exercise_entry_sets WHERE exercise_entry_id = $1', [id] ); - - // Insert new sets if provided and not empty if (Array.isArray(updateData.sets) && updateData.sets.length > 0) { - const setsValues = updateData.sets.map((set) => [ + const setsValues = updateData.sets.map((set: any) => [ id, set.set_number, set.set_type, @@ -685,44 +636,41 @@ async function updateExerciseEntry(id, userId, actingUserId, updateData) { await client.query(setsQuery); } } - await client.query('COMMIT'); - return getExerciseEntryById(id, userId); // Refetch to get full data + return getExerciseEntryById(id, userId); } finally { client.release(); } } -async function updateExerciseEntriesDateByPresetEntryIdWithClient( - client, - userId, - presetEntryId, - entryDate, - updatedByUserId -) { +export async function updateExerciseEntriesDateByPresetEntryIdWithClient( + client: any, + userId: string, + presetEntryId: string, + entryDate: string | Date, + updatedByUserId: string +): Promise { await client.query( - `UPDATE exercise_entries - SET entry_date = $1, - updated_by_user_id = $2, - updated_at = now() - WHERE user_id = $3 AND exercise_preset_entry_id = $4`, + `UPDATE exercise_entries SET entry_date = $1, updated_by_user_id = $2, updated_at = now() WHERE user_id = $3 AND exercise_preset_entry_id = $4`, [entryDate, updatedByUserId, userId, presetEntryId] ); } -async function deleteExerciseEntriesByPresetEntryIdWithClient( - client, - userId, - presetEntryId -) { +export async function deleteExerciseEntriesByPresetEntryIdWithClient( + client: any, + userId: string, + presetEntryId: string +): Promise { await client.query( - `DELETE FROM exercise_entries - WHERE user_id = $1 AND exercise_preset_entry_id = $2`, + `DELETE FROM exercise_entries WHERE user_id = $1 AND exercise_preset_entry_id = $2`, [userId, presetEntryId] ); } -async function deleteExerciseEntry(id, userId) { +export async function deleteExerciseEntry( + id: string, + userId: string +): Promise { const client = await getClient(userId); try { const result = await client.query( @@ -735,67 +683,40 @@ async function deleteExerciseEntry(id, userId) { } } -async function getExerciseEntriesByDate(userId, selectedDate) { +export async function getExerciseEntriesByDate( + userId: string, + selectedDate: string | Date +): Promise { const client = await getClient(userId); try { - // 1. Fetch all exercise preset entries for the given date and user const presetEntriesResult = await client.query( - `SELECT id, workout_preset_id, name, description, notes, created_at, source - FROM exercise_preset_entries - WHERE user_id = $1 AND entry_date = $2 - ORDER BY created_at ASC`, + `SELECT id, workout_preset_id, name, description, notes, created_at, source FROM exercise_preset_entries WHERE user_id = $1 AND entry_date = $2 ORDER BY created_at ASC`, [userId, selectedDate] ); const presetEntries = presetEntriesResult.rows; - // 2. Fetch all individual exercise entries for the given date and user const individualEntriesResult = await client.query( - `SELECT - ee.*, - COALESCE( - (SELECT json_agg(set_data ORDER BY set_data.set_number) - FROM ( - SELECT ees.id, ees.set_number, ees.set_type, ees.reps, ees.weight, ees.duration, ees.rest_time, ees.notes, ees.rpe - FROM exercise_entry_sets ees - WHERE ees.exercise_entry_id = ee.id - ) AS set_data - ), '[]'::json - ) AS sets, - ee.distance, - ee.avg_heart_rate, - (SELECT json_agg(ead) - FROM exercise_entry_activity_details ead - WHERE ead.exercise_entry_id = ee.id - ) AS activity_details - FROM exercise_entries ee - WHERE ee.user_id = $1 AND ee.entry_date = $2 - ORDER BY ee.sort_order ASC, ee.created_at ASC`, + `SELECT ee.*, + COALESCE((SELECT json_agg(set_data ORDER BY set_data.set_number) FROM (SELECT ees.id, ees.set_number, ees.set_type, ees.reps, ees.weight, ees.duration, ees.rest_time, ees.notes, ees.rpe FROM exercise_entry_sets ees WHERE ees.exercise_entry_id = ee.id) AS set_data), '[]'::json) AS sets, + ee.distance, ee.avg_heart_rate, + (SELECT json_agg(ead) FROM exercise_entry_activity_details ead WHERE ead.exercise_entry_id = ee.id) AS activity_details + FROM exercise_entries ee WHERE ee.user_id = $1 AND ee.entry_date = $2 ORDER BY ee.sort_order ASC, ee.created_at ASC`, [userId, selectedDate] ); const allExerciseEntries = individualEntriesResult.rows; - // Map to store grouped exercises const groupedEntries = new Map(); - - // Initialize grouped entries with preset entries - presetEntries.forEach((preset) => { + presetEntries.forEach((preset: any) => { groupedEntries.set(preset.id, { type: 'preset', - id: preset.id, - workout_preset_id: preset.workout_preset_id, - name: preset.name, - description: preset.description, - notes: preset.notes, - created_at: preset.created_at, - source: preset.source, - exercises: [], // This will hold the individual exercise entries - total_duration_minutes: 0, // Initialize total duration for the preset + ...preset, + exercises: [], + total_duration_minutes: 0, }); }); - // Process individual exercise entries const entriesWithDetails = await Promise.all( - allExerciseEntries.map(async (row) => { + allExerciseEntries.map(async (row: any) => { const activityDetails = await activityDetailsRepository.getActivityDetailsByEntryOrPresetId( userId, @@ -818,84 +739,59 @@ async function getExerciseEntriesByDate(userId, selectedDate) { images, ...entryData } = row; - return { ...entryData, name: exercise_name, exercise_snapshot: { - // Renamed from 'exercises' to 'exercise_snapshot' to avoid confusion with the grouping - id: entryData.exercise_id, // Add the exercise_id here + id: entryData.exercise_id, name: exercise_name, - category: category, - calories_per_hour: calories_per_hour, - source: source, - source_id: source_id, - force: force, - level: level, - mechanic: mechanic, - equipment: equipment, - primary_muscles: primary_muscles, - secondary_muscles: secondary_muscles, - instructions: instructions, - images: images, + category, + calories_per_hour, + source, + source_id, + force, + level, + mechanic, + equipment, + primary_muscles, + secondary_muscles, + instructions, + images, }, activity_details: activityDetails, }; }) ); - // Group exercises under their respective preset entries or as individual entries - const finalEntriesMap = new Map(); // Use a Map to ensure unique top-level entries - - // Process individual exercise entries first, associating them with presets - entriesWithDetails.forEach((entry) => { + const finalEntriesMap = new Map(); + entriesWithDetails.forEach((entry: any) => { if ( entry.exercise_preset_entry_id && groupedEntries.has(entry.exercise_preset_entry_id) ) { const preset = groupedEntries.get(entry.exercise_preset_entry_id); preset.exercises.push(entry); - preset.exercises.sort( - (a, b) => - (a.sort_order || 0) - (b.sort_order || 0) || - new Date(a.created_at) - new Date(b.created_at) - ); // Ensure sub-exercises are sorted - preset.total_duration_minutes += entry.duration_minutes || 0; // Sum duration for the preset + preset.total_duration_minutes += entry.duration_minutes || 0; } else { - // Add individual exercises that are not part of any preset - finalEntriesMap.set(entry.id, { - type: 'individual', - ...entry, - }); + finalEntriesMap.set(entry.id, { type: 'individual', ...entry }); } }); - // Now add the preset entries (which now contain their associated exercises) to the final list for (const preset of groupedEntries.values()) { - // Fetch activity details for the preset entry itself - const presetActivityDetails = + preset.activity_details = await activityDetailsRepository.getActivityDetailsByEntryOrPresetId( userId, null, preset.id ); - preset.activity_details = presetActivityDetails; - finalEntriesMap.set(preset.id, preset); // Add preset to map, overwriting if already present (shouldn't happen for presets) + finalEntriesMap.set(preset.id, preset); } - const finalEntries = Array.from(finalEntriesMap.values()); // Convert map values to an array - - // Sort final entries by sort_order then created_at for consistent display + const finalEntries = Array.from(finalEntriesMap.values()); finalEntries.sort( (a, b) => (a.sort_order || 0) - (b.sort_order || 0) || - new Date(a.created_at) - new Date(b.created_at) - ); - - log( - 'debug', - `getExerciseEntriesByDate: Returning grouped entries for user ${userId} on ${selectedDate}:`, - finalEntries + new Date(a.created_at).getTime() - new Date(b.created_at).getTime() ); return finalEntries; } finally { @@ -903,34 +799,18 @@ async function getExerciseEntriesByDate(userId, selectedDate) { } } -async function getExerciseProgressData(userId, exerciseId, startDate, endDate) { +export async function getExerciseProgressData( + userId: string, + exerciseId: string, + startDate: string | Date, + endDate: string | Date +): Promise { const client = await getClient(userId); try { const result = await client.query( - `SELECT - ee.id AS exercise_entry_id, - ee.entry_date, - ee.duration_minutes, - ee.calories_burned, - ee.notes, - ee.image_url, - ee.distance, - ee.avg_heart_rate, - ee.source AS provider_name, - COALESCE( - (SELECT json_agg(set_data ORDER BY set_data.set_number) - FROM ( - SELECT ees.id, ees.set_number, ees.set_type, ees.reps, ees.weight, ees.duration, ees.rest_time, ees.notes, ees.rpe - FROM exercise_entry_sets ees - WHERE ees.exercise_entry_id = ee.id - ) AS set_data - ), '[]'::json - ) AS sets - FROM exercise_entries ee - WHERE ee.user_id = $1 - AND ee.exercise_id = $2 - AND ee.entry_date BETWEEN $3 AND $4 - ORDER BY ee.entry_date ASC`, + `SELECT ee.id AS exercise_entry_id, ee.entry_date, ee.duration_minutes, ee.calories_burned, ee.notes, ee.image_url, ee.distance, ee.avg_heart_rate, ee.source AS provider_name, + COALESCE((SELECT json_agg(set_data ORDER BY set_data.set_number) FROM (SELECT ees.id, ees.set_number, ees.set_type, ees.reps, ees.weight, ees.duration, ees.rest_time, ees.notes, ees.rpe FROM exercise_entry_sets ees WHERE ees.exercise_entry_id = ee.id) AS set_data), '[]'::json) AS sets + FROM exercise_entries ee WHERE ee.user_id = $1 AND ee.exercise_id = $2 AND ee.entry_date BETWEEN $3 AND $4 ORDER BY ee.entry_date ASC`, [userId, exerciseId, startDate, endDate] ); return result.rows; @@ -939,32 +819,17 @@ async function getExerciseProgressData(userId, exerciseId, startDate, endDate) { } } -async function getExerciseHistory(userId, exerciseId, limit = 5) { +export async function getExerciseHistory( + userId: string, + exerciseId: string, + limit: number = 5 +): Promise { const client = await getClient(userId); try { const result = await client.query( - `SELECT - ee.entry_date, - ee.duration_minutes, - ee.calories_burned, - ee.notes, - ee.image_url, - ee.distance, - ee.avg_heart_rate, - COALESCE( - (SELECT json_agg(set_data ORDER BY set_data.set_number) - FROM ( - SELECT ees.id, ees.set_number, ees.set_type, ees.reps, ees.weight, ees.duration, ees.rest_time, ees.notes, ees.rpe - FROM exercise_entry_sets ees - WHERE ees.exercise_entry_id = ee.id - ) AS set_data - ), '[]'::json - ) AS sets - FROM exercise_entries ee - WHERE ee.user_id = $1 - AND ee.exercise_id = $2 - ORDER BY ee.entry_date DESC, ee.created_at DESC - LIMIT $3`, + `SELECT ee.entry_date, ee.duration_minutes, ee.calories_burned, ee.notes, ee.image_url, ee.distance, ee.avg_heart_rate, + COALESCE((SELECT json_agg(set_data ORDER BY set_data.set_number) FROM (SELECT ees.id, ees.set_number, ees.set_type, ees.reps, ees.weight, ees.duration, ees.rest_time, ees.notes, ees.rpe FROM exercise_entry_sets ees WHERE ees.exercise_entry_id = ee.id) AS set_data), '[]'::json) AS sets + FROM exercise_entries ee WHERE ee.user_id = $1 AND ee.exercise_id = $2 ORDER BY ee.entry_date DESC, ee.created_at DESC LIMIT $3`, [userId, exerciseId, limit] ); return result.rows; @@ -973,91 +838,62 @@ async function getExerciseHistory(userId, exerciseId, limit = 5) { } } -async function deleteExerciseEntriesByEntrySourceAndDate( - userId, - startDate, - endDate, - entrySource -) { +export async function deleteExerciseEntriesByEntrySourceAndDate( + userId: string, + startDate: string | Date, + endDate: string | Date, + entrySource: string +): Promise { const client = await getClient(userId); try { await client.query('BEGIN'); - - // Get IDs of exercise entries to be deleted const entryIdsResult = await client.query( - `SELECT id FROM exercise_entries - WHERE user_id = $1 - AND entry_date BETWEEN $2 AND $3 - AND source = $4`, + `SELECT id FROM exercise_entries WHERE user_id = $1 AND entry_date BETWEEN $2 AND $3 AND source = $4`, [userId, startDate, endDate, entrySource] ); - const entryIds = entryIdsResult.rows.map((row) => row.id); - + const entryIds = entryIdsResult.rows.map((row: any) => row.id); if (entryIds.length > 0) { - // Delete associated activity details await client.query( 'DELETE FROM exercise_entry_activity_details WHERE exercise_entry_id = ANY($1::uuid[])', [entryIds] ); - log( - 'info', - `[exerciseEntry] Deleted activity details for ${entryIds.length} exercise entries.` - ); - - // Delete associated sets await client.query( 'DELETE FROM exercise_entry_sets WHERE exercise_entry_id = ANY($1::uuid[])', [entryIds] ); - log( - 'info', - `[exerciseEntry] Deleted sets for ${entryIds.length} exercise entries.` - ); - - // Delete the exercise entries themselves const result = await client.query( 'DELETE FROM exercise_entries WHERE id = ANY($1::uuid[])', [entryIds] ); - log( - 'info', - `[exerciseEntry] Deleted ${result.rowCount} exercise entries with source '${entrySource}' for user ${userId} from ${startDate} to ${endDate}.` - ); await client.query('COMMIT'); return result.rowCount; - } else { - log( - 'info', - `[exerciseEntry] No exercise entries with source '${entrySource}' found for user ${userId} from ${startDate} to ${endDate}.` - ); - await client.query('COMMIT'); - return 0; } + await client.query('COMMIT'); + return 0; } catch (error) { await client.query('ROLLBACK'); - log( - 'error', - `Error deleting exercise entries by source and date: ${error.message}`, - { userId, startDate, endDate, entrySource, error } - ); throw error; } finally { client.release(); } } -module.exports = { - upsertExerciseEntryData, - _createExerciseEntryWithClient, - createExerciseEntry, - getExerciseEntryById, - getExerciseEntryOwnerId, - updateExerciseEntry, - updateExerciseEntriesDateByPresetEntryIdWithClient, - deleteExerciseEntriesByPresetEntryIdWithClient, - deleteExerciseEntry, - getExerciseEntriesByDate, - getExerciseProgressData, - getExerciseHistory, - deleteExerciseEntriesByEntrySourceAndDate, -}; +export async function getExerciseEntriesByDateRange( + userId: string, + startDate: string | Date, + endDate: string | Date +): Promise { + const client = await getClient(userId); + try { + const result = await client.query( + `SELECT ee.*, + COALESCE((SELECT json_agg(set_data ORDER BY set_data.set_number) FROM (SELECT ees.id, ees.set_number, ees.set_type, ees.reps, ees.weight, ees.duration, ees.rest_time, ees.notes, ees.rpe FROM exercise_entry_sets ees WHERE ees.exercise_entry_id = ee.id) AS set_data), '[]'::json) AS sets, + ee.distance, ee.avg_heart_rate + FROM exercise_entries ee WHERE ee.user_id = $1 AND ee.entry_date BETWEEN $2 AND $3 ORDER BY ee.entry_date DESC, ee.sort_order ASC, ee.created_at DESC`, + [userId, startDate, endDate] + ); + return result.rows; + } finally { + client.release(); + } +} diff --git a/SparkyFitnessServer/models/externalProviderRepository.js b/SparkyFitnessServer/models/externalProviderRepository.js index ac889e6cc..e7cf1b5ca 100644 --- a/SparkyFitnessServer/models/externalProviderRepository.js +++ b/SparkyFitnessServer/models/externalProviderRepository.js @@ -384,8 +384,8 @@ async function getExternalDataProviderByUserIdAndProviderName( ept.is_strictly_private FROM external_data_providers edp LEFT JOIN external_provider_types ept ON edp.provider_type = ept.id - WHERE edp.provider_name = $1`, - [providerName] + WHERE (edp.provider_name = $1 OR edp.provider_type = $1) AND edp.user_id = $2`, + [providerName, userId] ); const data = result.rows[0]; if (!data) { diff --git a/SparkyFitnessServer/models/foodEntry.js b/SparkyFitnessServer/models/foodEntry.js index cc65f4d1c..a1ef0bb8f 100644 --- a/SparkyFitnessServer/models/foodEntry.js +++ b/SparkyFitnessServer/models/foodEntry.js @@ -799,6 +799,64 @@ async function getFoodEntryComponentsByFoodEntryMealId( } } +async function getDailyNutritionByCategory(userId, date) { + const client = await getClient(userId); + try { + const query = ` + SELECT + LOWER(mt.name) as meal_name, + SUM(fe.calories) as calories, + SUM(fe.protein) as protein, + SUM(fe.carbs) as carbs, + SUM(fe.fat) as fat, + SUM(fe.saturated_fat) as saturated_fat, + SUM(fe.polyunsaturated_fat) as polyunsaturated_fat, + SUM(fe.monounsaturated_fat) as monounsaturated_fat, + SUM(fe.trans_fat) as trans_fat, + SUM(fe.cholesterol) as cholesterol, + SUM(fe.sodium) as sodium, + SUM(fe.potassium) as potassium, + SUM(fe.dietary_fiber) as dietary_fiber, + SUM(fe.sugars) as sugars, + SUM(fe.vitamin_a) as vitamin_a, + SUM(fe.vitamin_c) as vitamin_c, + SUM(fe.calcium) as calcium, + SUM(fe.iron) as iron + FROM food_entries fe + JOIN meal_types mt ON fe.meal_type_id = mt.id + WHERE fe.user_id = $1 AND fe.entry_date = $2 + GROUP BY mt.name + `; + const result = await client.query(query, [userId, date]); + + // Convert rows to an object keyed by meal name for easier lookup + const summary = {}; + result.rows.forEach((row) => { + summary[row.meal_name] = { + calories: parseFloat(row.calories || 0), + protein: parseFloat(row.protein || 0), + carbs: parseFloat(row.carbs || 0), + fat: parseFloat(row.fat || 0), + saturated_fat: parseFloat(row.saturated_fat || 0), + polyunsaturated_fat: parseFloat(row.polyunsaturated_fat || 0), + monounsaturated_fat: parseFloat(row.monounsaturated_fat || 0), + trans_fat: parseFloat(row.trans_fat || 0), + cholesterol: parseFloat(row.cholesterol || 0), + sodium: parseFloat(row.sodium || 0), + potassium: parseFloat(row.potassium || 0), + dietary_fiber: parseFloat(row.dietary_fiber || 0), + sugars: parseFloat(row.sugars || 0), + vitamin_a: parseFloat(row.vitamin_a || 0), + vitamin_c: parseFloat(row.vitamin_c || 0), + calcium: parseFloat(row.calcium || 0), + iron: parseFloat(row.iron || 0), + }; + }); + return summary; + } finally { + client.release(); + } +} async function deleteFoodEntryComponentsByFoodEntryMealId( foodEntryMealId, userId @@ -832,4 +890,5 @@ module.exports = { getFoodEntryById, getFoodEntryComponentsByFoodEntryMealId, deleteFoodEntryComponentsByFoodEntryMealId, + getDailyNutritionByCategory, }; diff --git a/SparkyFitnessServer/package.json b/SparkyFitnessServer/package.json index ab097463b..4e988c8de 100644 --- a/SparkyFitnessServer/package.json +++ b/SparkyFitnessServer/package.json @@ -39,6 +39,7 @@ "multer": "^2.0.2", "node-cache": "^5.1.2", "node-cron": "^4.2.1", + "node-telegram-bot-api": "^0.67.0", "nodemailer": "^8.0.2", "papaparse": "^5.5.3", "pg": "^8.17.1", diff --git a/SparkyFitnessServer/routes/garminRoutes.js b/SparkyFitnessServer/routes/garminRoutes.js index 39ee2855d..afb7138df 100644 --- a/SparkyFitnessServer/routes/garminRoutes.js +++ b/SparkyFitnessServer/routes/garminRoutes.js @@ -621,4 +621,43 @@ router.post('/sleep_data', authenticate, async (req, res, next) => { } }); +/** + * @swagger + * /integrations/garmin/sync/nutrition: + * post: + * summary: Manually sync daily nutrition to Garmin Connect + * tags: [External Integrations] + * security: + * - cookieAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * date: { type: 'string', format: 'date' } + * required: [date] + * responses: + * 200: + * description: Nutrition sync result. + */ +router.post('/sync/nutrition', authenticate, async (req, res, next) => { + try { + const userId = req.userId; + const { date } = req.body; + + if (!date || !DATE_FORMAT_REGEX.test(date)) { + return res + .status(400) + .json({ error: 'Valid date (YYYY-MM-DD) is required.' }); + } + + const result = await garminService.syncDailyNutritionToGarmin(userId, date); + res.status(200).json(result); + } catch (error) { + next(error); + } +}); + module.exports = router; diff --git a/SparkyFitnessServer/routes/mfpRoutes.js b/SparkyFitnessServer/routes/mfpRoutes.js new file mode 100644 index 000000000..4f016f624 --- /dev/null +++ b/SparkyFitnessServer/routes/mfpRoutes.js @@ -0,0 +1,126 @@ +const express = require('express'); +const router = express.Router(); +const { authenticate } = require('../middleware/authMiddleware'); +const { syncDailyTotals } = require('../services/mfpSyncService'); +const { log } = require('../config/logging'); +const moment = require('moment'); + +router.use(express.json()); + +/** + * @swagger + * /integrations/myfitnesspal/sync: + * post: + * summary: Manually trigger MyFitnessPal nutrition sync + * tags: [External Integrations] + * security: + * - cookieAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * startDate: { type: 'string', format: 'date' } + * endDate: { type: 'string', format: 'date' } + * required: [startDate] + */ +router.post('/sync', authenticate, async (req, res, next) => { + try { + const userId = req.userId; + const { startDate, endDate } = req.body; + + if (!startDate) { + return res.status(400).json({ error: 'startDate is required.' }); + } + + const start = moment(startDate, 'YYYY-MM-DD', true); + const end = endDate ? moment(endDate, 'YYYY-MM-DD', true) : start.clone(); + + if (!start.isValid() || !end.isValid()) { + return res + .status(400) + .json({ error: 'Invalid date format. Use YYYY-MM-DD.' }); + } + + if (start.isAfter(end)) { + return res + .status(400) + .json({ error: 'startDate must be before or equal to endDate.' }); + } + + // Limit range to 31 days to avoid overwhelming the API + const daysDiff = end.diff(start, 'days'); + if (daysDiff > 31) { + return res + .status(400) + .json({ error: 'Date range cannot exceed 31 days.' }); + } + + const dates = []; + const current = start.clone(); + while (current.isSameOrBefore(end)) { + dates.push(current.format('YYYY-MM-DD')); + current.add(1, 'days'); + } + + log( + 'info', + `mfpRoutes: Manual MFP sync requested for user ${userId} for ${dates.length} days` + ); + + // Trigger syncs sequentially to be safe with locking and rate limits + for (const date of dates) { + await syncDailyTotals(userId, date); + } + + res.status(200).json({ + message: `MFP sync triggered successfully for ${dates.length} days.`, + processedDates: dates, + }); + } catch (error) { + log('error', `mfpRoutes: Error triggering manual sync: ${error.message}`); + next(error); + } +}); + +/** + * @swagger + * /integrations/myfitnesspal/status: + * get: + * summary: Get MyFitnessPal connection status + * tags: [External Integrations] + * security: + * - cookieAuth: [] + */ +router.get('/status', authenticate, async (req, res, next) => { + try { + const userId = req.userId; + const externalProviderRepository = require('../models/externalProviderRepository'); + + const provider = + await externalProviderRepository.getExternalDataProviderByUserIdAndProviderName( + userId, + 'myfitnesspal' + ); + + if (provider) { + res.status(200).json({ + isLinked: !!(provider.app_id && provider.app_key), + lastUpdated: provider.updated_at, + tokenExpiresAt: provider.token_expires_at, + message: 'MyFitnessPal is connected.', + }); + } else { + res.status(200).json({ + isLinked: false, + message: 'MyFitnessPal is not connected.', + }); + } + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/SparkyFitnessServer/routes/telegramRoutes.ts b/SparkyFitnessServer/routes/telegramRoutes.ts new file mode 100644 index 000000000..14d0dbac9 --- /dev/null +++ b/SparkyFitnessServer/routes/telegramRoutes.ts @@ -0,0 +1,128 @@ +import express, { Request, Response, Router } from 'express'; +import { authenticate } from '../middleware/authMiddleware'; +import poolManager from '../db/poolManager'; +import { log } from '../config/logging'; +import crypto from 'crypto'; +import { + telegramStatusResponseSchema, + telegramLinkCodeResponseSchema, + TelegramWebhookSchema, +} from '@workspace/shared'; +import telegramBotService from '../integrations/telegram/telegramBotService'; + +const router: Router = express.Router(); + +interface AuthRequest extends Request { + user: { + id: string; + }; +} + +/** + * GET Telegram Link Status + */ +router.get( + '/status', + (req: Request, res: Response, next) => authenticate(req, res, next), + async (req: Request, res: Response) => { + const userId = (req as AuthRequest).user.id; + const client = await poolManager.getSystemClient(); + try { + const result = await client.query( + 'SELECT telegram_chat_id FROM public."user" WHERE id = $1', + [userId] + ); + + const response = { + isLinked: !!result.rows[0]?.telegram_chat_id, + chatId: result.rows[0]?.telegram_chat_id || null, + }; + + // Validate with Zod before sending + telegramStatusResponseSchema.parse(response); + + res.json(response); + } catch (error: any) { + log('error', `Error checking Telegram status: ${error.message}`); + res.status(500).json({ message: 'Error checking Telegram status' }); + } finally { + client.release(); + } + } +); + +/** + * POST Generate Linking Code + */ +router.post( + '/link-code', + (req: Request, res: Response, next) => authenticate(req, res, next), + async (req: Request, res: Response) => { + const userId = (req as AuthRequest).user.id; + // Generate a random 6-character code + const code = crypto.randomBytes(3).toString('hex').toUpperCase(); + const client = await poolManager.getSystemClient(); + try { + await client.query( + 'UPDATE public."user" SET telegram_link_code = $1 WHERE id = $2', + [code, userId] + ); + + const response = { code }; + telegramLinkCodeResponseSchema.parse(response); + + res.json(response); + } catch (error: any) { + log('error', `Error generating Telegram link code: ${error.message}`); + res.status(500).json({ message: 'Error generating link code' }); + } finally { + client.release(); + } + } +); + +/** + * POST Unlink Telegram + */ +router.post( + '/unlink', + (req: Request, res: Response, next) => authenticate(req, res, next), + async (req: Request, res: Response) => { + const userId = (req as AuthRequest).user.id; + const client = await poolManager.getSystemClient(); + try { + await client.query( + 'UPDATE public."user" SET telegram_chat_id = NULL, telegram_link_code = NULL WHERE id = $1', + [userId] + ); + res.json({ message: 'Telegram account unlinked successfully' }); + } catch (error: any) { + log('error', `Error unlinking Telegram: ${error.message}`); + res.status(500).json({ message: 'Error unlinking Telegram' }); + } finally { + client.release(); + } + } +); + +/** + * POST Telegram Webhook (Insecure URL, but validated internally) + */ +router.post('/webhook', (req: Request, res: Response) => { + try { + // Validate incoming update with Zod + const validatedData = TelegramWebhookSchema.parse(req.body); + + // Pass to bot service + telegramBotService.handleUpdate(validatedData); + + res.sendStatus(200); + } catch (error: any) { + log('error', `Telegram webhook validation failed: ${error.message}`); + // Still return 200 to Telegram to prevent retries of bad data, + // but we've logged the error. + res.sendStatus(200); + } +}); + +module.exports = router; diff --git a/SparkyFitnessServer/routes/v2/foodRoutes.ts b/SparkyFitnessServer/routes/v2/foodRoutes.ts index 90a1ec736..527f88a47 100644 --- a/SparkyFitnessServer/routes/v2/foodRoutes.ts +++ b/SparkyFitnessServer/routes/v2/foodRoutes.ts @@ -314,7 +314,8 @@ const searchHandler: RequestHandler<{ providerType: string }> = async ( query, credentials.app_id, credentials.app_key, - page + page, + language ); const rawFoods = result.foods?.food; const items = Array.isArray(rawFoods) @@ -438,7 +439,8 @@ const detailHandler: RequestHandler<{ const data = await getFatSecretNutrients( externalId, credentials.app_id, - credentials.app_key + credentials.app_key, + language ); if (data) { food = mapFatSecretFood(data); diff --git a/SparkyFitnessServer/services/chatService.js b/SparkyFitnessServer/services/chatService.ts similarity index 73% rename from SparkyFitnessServer/services/chatService.js rename to SparkyFitnessServer/services/chatService.ts index 6a99402be..1cf51f80f 100644 --- a/SparkyFitnessServer/services/chatService.js +++ b/SparkyFitnessServer/services/chatService.ts @@ -1,15 +1,16 @@ -const chatRepository = require('../models/chatRepository'); -const measurementRepository = require('../models/measurementRepository'); -const { log } = require('../config/logging'); -const { getDefaultModel } = require('../ai/config'); -const { Agent } = require('undici'); // Import Agent from undici +import * as chatRepository from '../models/chatRepository'; +import * as userRepository from '../models/userRepository'; +import * as measurementRepository from '../models/measurementRepository'; +import { log } from '../config/logging'; +import { getDefaultModel } from '../ai/config'; +import { Agent } from 'undici'; const { loadUserTimezone } = require('../utils/timezoneLoader'); const { todayInZone } = require('@workspace/shared'); -async function handleAiServiceSettings( - action, - serviceData, - authenticatedUserId +export async function handleAiServiceSettings( + action: any, + serviceData: any, + authenticatedUserId: any ) { try { if (action === 'save_ai_service_settings') { @@ -40,7 +41,10 @@ async function handleAiServiceSettings( } } -async function getAiServiceSettings(authenticatedUserId, targetUserId) { +export async function getAiServiceSettings( + authenticatedUserId: string, + targetUserId: string +) { try { const settings = await chatRepository.getAiServiceSettingsByUserId(targetUserId); @@ -55,7 +59,10 @@ async function getAiServiceSettings(authenticatedUserId, targetUserId) { } } -async function getActiveAiServiceSetting(authenticatedUserId, targetUserId) { +export async function getActiveAiServiceSetting( + authenticatedUserId: string, + targetUserId: string +) { try { const setting = await chatRepository.getActiveAiServiceSetting(targetUserId); @@ -77,7 +84,10 @@ async function getActiveAiServiceSetting(authenticatedUserId, targetUserId) { } } -async function deleteAiServiceSetting(authenticatedUserId, id) { +export async function deleteAiServiceSetting( + authenticatedUserId: string, + id: string +) { try { // Verify that the setting belongs to the authenticated user before deleting const setting = await chatRepository.getAiServiceSettingById( @@ -105,7 +115,7 @@ async function deleteAiServiceSetting(authenticatedUserId, id) { } } -async function clearOldChatHistory(authenticatedUserId) { +export async function clearOldChatHistory(authenticatedUserId: string) { try { await chatRepository.clearOldChatHistory(authenticatedUserId); return { message: 'Old chat history cleared successfully.' }; @@ -119,7 +129,10 @@ async function clearOldChatHistory(authenticatedUserId) { } } -async function getSparkyChatHistory(authenticatedUserId, targetUserId) { +export async function getSparkyChatHistory( + authenticatedUserId: string, + targetUserId: string +) { try { const history = await chatRepository.getChatHistoryByUserId(targetUserId); return history; @@ -133,7 +146,10 @@ async function getSparkyChatHistory(authenticatedUserId, targetUserId) { } } -async function getSparkyChatHistoryEntry(authenticatedUserId, id) { +export async function getSparkyChatHistoryEntry( + authenticatedUserId: string, + id: string +) { try { const entryOwnerId = await chatRepository.getChatHistoryEntryOwnerId( id, @@ -157,13 +173,16 @@ async function getSparkyChatHistoryEntry(authenticatedUserId, id) { } } -async function updateSparkyChatHistoryEntry( - authenticatedUserId, - id, - updateData +export async function updateSparkyChatHistoryEntry( + authenticatedUserId: any, + id: any, + updateData: any ) { try { - const entryOwnerId = await chatRepository.getChatHistoryEntryOwnerId(id); + const entryOwnerId = await chatRepository.getChatHistoryEntryOwnerId( + id, + authenticatedUserId + ); if (!entryOwnerId) { throw new Error('Chat history entry not found.'); } @@ -193,9 +212,15 @@ async function updateSparkyChatHistoryEntry( } } -async function deleteSparkyChatHistoryEntry(authenticatedUserId, id) { +export async function deleteSparkyChatHistoryEntry( + authenticatedUserId: string, + id: string +) { try { - const entryOwnerId = await chatRepository.getChatHistoryEntryOwnerId(id); + const entryOwnerId = await chatRepository.getChatHistoryEntryOwnerId( + id, + authenticatedUserId + ); if (!entryOwnerId) { throw new Error('Chat history entry not found.'); } @@ -222,7 +247,7 @@ async function deleteSparkyChatHistoryEntry(authenticatedUserId, id) { } } -async function clearAllSparkyChatHistory(authenticatedUserId) { +export async function clearAllSparkyChatHistory(authenticatedUserId: string) { try { await chatRepository.clearAllChatHistory(authenticatedUserId); return { message: 'All chat history cleared successfully.' }; @@ -236,11 +261,19 @@ async function clearAllSparkyChatHistory(authenticatedUserId) { } } -async function saveSparkyChatHistory(authenticatedUserId, historyData) { +export async function saveSparkyChatHistory( + authenticatedUserId: string, + historyData: any +) { try { // Ensure the history is saved for the authenticated user historyData.user_id = authenticatedUserId; - await chatRepository.saveChatHistory(historyData); + await chatRepository.saveChatMessage( + historyData.user_id, + historyData.content, + historyData.messageType, + historyData.metadata + ); return { message: 'Chat history saved successfully.' }; } catch (error) { log( @@ -252,10 +285,10 @@ async function saveSparkyChatHistory(authenticatedUserId, historyData) { } } -async function processChatMessage( - messages, - serviceConfigId, - authenticatedUserId +export async function processChatMessage( + messages: any, + serviceConfigId: any, + authenticatedUserId: any ) { try { if (!Array.isArray(messages) || messages.length === 0) { @@ -306,143 +339,72 @@ async function processChatMessage( .join('\n') : 'None'; - const systemPromptContent = `You are Sparky, an AI nutrition and wellness coach. Your primary goal is to help users track their food, exercise, and measurements, and provide helpful advice and motivation based on their data and general health knowledge. - -The current date is ${todayInZone(chatTz)}. - -**CRITICAL INSTRUCTION:** When the user mentions "water" in any context related to consumption or intake, you MUST use the 'log_water' intent. Do NOT classify water as a 'log_food' item. - -You will receive user input, which can include text and/or images. Your task is to identify the user's intent and extract relevant data. You MUST respond with a JSON object containing the 'intent' and 'data', strictly adhering to the defined intents and their required data structures. - -For image inputs: -- Analyze the image to identify food items, estimate quantities, and infer nutritional information. -- If the image clearly shows food, prioritize the 'log_food' intent. -- Extract food_name, quantity, unit, and meal_type from the image content. -- **CRITICAL:** Always infer and include *estimated* nutritional details (calories, protein, carbs, fat, etc.) based on the identified food and estimated quantity, populating the corresponding fields in the 'log_food' intent's data. Do NOT default to 0 if an estimation can be made. -- If the image is not food-related or unclear, treat the text input as primary. - -**IMPORTANT:** If the user specifies a date or time (e.g., "yesterday", "last Monday", "at 7 PM"), extract this information and include it as a 'entryDate' field in the top level of the JSON object. **Provide relative terms like "today", "yesterday", "tomorrow", or a specific date in 'MM-DD' or 'YYYY-MM-DD' format. Do NOT try to resolve relative terms to a full date yourself.** If no date is specified, omit the 'entryDate' field. - -When the user mentions logging food, exercise, or measurements, prioritize extracting the exact name of the item (food name, exercise name, measurement name) as accurately as possible from the user's input. This is crucial for looking up existing items in the database. - -Here are the user's existing custom measurement categories: -${customCategoriesList} - -When the user mentions a custom measurement, compare it to the list above. If you find a match or a very similar variation (considering synonyms and capitalization), use the **exact name** from the list in the 'name' field of the measurement data. If no clear match is found in the list, use the name as provided by the user. - -**For 'log_food' intent, pay close attention to the unit specified by the user and match it in the 'unit' field of the food data.** -- If the user says "gram" or "g", use "g". -- If the user says "cup" or "cups", use "cup". -- If the user refers to individual items by count (e.g., "two apples", "3 eggs"), use "piece". -- If the unit is not explicitly mentioned, infer the most appropriate unit based on the food item and context (e.g., "apple" is likely "piece", "rice" is likely "g" or "cup"). Refer to common food units used in the application (like 'g', 'cup', 'oz', 'ml', 'serving', 'piece'). - -Possible intents and their required data. You MUST select one of these intents and provide the data in the specified format: -- 'log_food': User wants to log food. This intent is for solid food items or beverages that are not water. **This intent MUST NOT be used for logging water intake.** - - If you can confidently identify a single food item and its details, data should include: - - food_name: string (e.g., "apple", "chicken breast", "Dosa") - Extract the most likely exact name. - - quantity: number (e.g., 1, 100) - Infer if possible, default to 1 if a specific quantity isn't clear but a food is mentioned. - - unit: string (e.g., "piece", "g", "oz", "ml", "cup", "serving") - **CRITICAL: Match the user's specified unit exactly.** If the user refers to individual items by count (e.g., "two apples", "3 eggs"), use "piece". If no unit is explicitly mentioned, infer the most appropriate unit based on the food item and context (e.g., "apple" is likely "piece", "rice" is likely "g" or "cup"). Refer to common food units used in the application (like 'g', 'cup', 'oz', 'ml', 'serving', 'piece'). - - meal_type: string ("breakfast", "lunch", "dinner", "snacks") - Infer based on time of day or context, default to "snacks". - - **Include as many of the following nutritional fields as you can extract from the user's input or your knowledge about the food:** - - calories: number - - protein: number - - carbs: number - - fat: number - - saturated_fat: number - - polyunsaturated_fat: number - - monounsaturated_fat: number - - trans_fat: number - - cholesterol: number - - sodium: number - - potassium: number - - dietary_fiber: number - - sugars: number - - vitamin_a: number - - vitamin_c: number - - calcium: number - - iron: number - - FoodOption: array of realistic food options (if applicable) - - serving_size: number - - serving_unit: string - - -- 'log_exercise': User wants to log exercise. Data should include: - - exercise_name: string (e.g., "running", "yoga") - Extract the most likely exact name. - - duration_minutes: number | null (e.g., 30, 60) - Infer if possible. - - distance: number | null (e.g., 5, 3.1) - Infer if mentioned. - - distance_unit: string | null ("miles", "km") - Infer if mentioned. -- 'log_measurement': User wants to log a body measurement or steps. Data should include an array of measurements: - - measurements: Array of objects, each with: - - type: string ("weight", "neck", "waist", "hips", "steps", "custom") - Use "custom" for any measurement not in the standard list. - - value: number - - unit: string | null (e.g., "kg", "lbs", "cm", "inches", "steps") - Infer if possible, default to null for steps. - - name: string | null (required if type is "custom") - **Crucially, if the user mentions a custom category from the list provided, use its exact name here.** -- 'log_water': User wants to log water intake. This intent should be prioritized when the user mentions "water" in conjunction with a quantity or a desire to log water. The AI should understand from the user's context that they are referring to drinking water. Data should include: - - glasses_consumed: number (e.g., 1, 2) - Infer if possible, default to 1. -- 'ask_question': User is asking a general question or seeking advice. Data is an empty object {}. -- 'chat': User is engaging in casual conversation. Data is an empty object {}. - -If the intent is 'ask_question' or 'chat', also provide a 'response' field with a friendly and helpful text response. For logging intents, the 'response' field is optional and can be a simple confirmation or encouraging remark. - -If you cannot determine the intent or extract data with high confidence, default to 'ask_question' or 'chat' and provide a suitable response asking for clarification. - -Output format MUST be a JSON object with 'intent' (string) and 'data' (object) fields, and optionally 'entryDate' (string with relative term or date format). Do NOT include any other text outside the JSON object. - -Example JSON output for logging weight for yesterday: -{"intent": "log_measurement", "data": {"measurements": [{"type": "weight", "value": 70, "unit": "kg"}]}, "entryDate": "yesterday"} - -Example JSON output for asking a question: -{"intent": "ask_question", "data": {}, "response": "I can help with that! What's your question?"} - -Example JSON output for logging steps: -{"intent": "log_measurement", "data": {"measurements": [{"type": "steps", "value": 10000, "unit": "steps"}]}} - -Example JSON output for logging food for today with detailed nutrition: -{"intent": "log_food", "data": {"food_name": "apple", "quantity": 1, "unit": "piece", "meal_type": "snack", "calories": 95, "carbs": 25, "sugars": 19, "dietary_fiber": 4, "vitamin_c": 9}, "entryDate": "today"} - -Example JSON output for logging exercise: -{"intent": "log_exercise", "data": {"exercise_name": "running", "duration_minutes": 30, "distance": 3, "distance_unit": "miles"}, "entryDate": "06-18"} - -Example JSON output for logging a custom measurement (e.g., Blood Sugar), using the exact name from the provided list: -{"intent": "log_measurement", "data": {"measurements": [{"type": "custom", "name": "Blood Sugar", "value": 140, "unit": "mg/dL"}]}, "entryDate": "today"} - -Example JSON output for logging water: -{"intent": "log_water", "data": {"glasses_consumed": 2}, "entryDate": "today"} - -Example JSON output for logging current weight: -{"intent": "log_measurement", "data": {"measurements": [{"type": "weight", "value": 72, "unit": "kg"}]}} - - -Be precise with data extraction and follow the JSON structure exactly. - -**Special Instruction: Food Option Generation** -If you receive a request in the format "GENERATE_FOOD_OPTIONS:[food name] in [unit]", respond with a JSON array of 2-3 realistic \`FoodOption\` objects for the specified food name. -**Prioritize providing a \`serving_unit\` that matches the requested unit if it's a common and logical unit for that food.** If the requested unit is not common or logical for the food, provide a common and realistic serving unit (e.g., "g", "piece", "serving"). Each \`FoodOption\` should include: -- name: string (e.g., "\`Apple (medium)\`", "\`Cooked Rice (per cup)\`") -- calories: number (estimated) -- protein: number (estimated) -- carbs: number (estimated) -- fat: number (estimated) -- serving_size: number (e.g., 1, 100, 0.5) - This MUST be a numeric value representing the quantity. -- serving_unit: string (e.g., "\`piece\`", "\`g\`", "\`cup\`", "\`oz\`") - This MUST be the unit string only, without any numeric quantity. - -Example JSON output for "GENERATE_FOOD_OPTIONS:apple": -[ - {"\`name\`": "\`Apple (medium)\`", "\`calories\`": 95, "\`protein\`": 0.5, "\`carbs\`": 25, "\`fat\`": 0.3, "\`serving_size\`": 1, "\`serving_unit\`": "\`piece\`"}, - {"\`name\`": "\`Apple (100g)\`", "\`calories\`": 52, "\`protein\`": 0.3, "\`carbs\`": 14, "\`fat\`": 0.2, "\`serving_size\`": 100, "\`serving_unit\`": "\`g\`"} -] -`; - - const messagesForAI = [{ role: 'system', content: systemPromptContent }]; - // Add user messages - messagesForAI.push(...messages.filter((msg) => msg.role === 'user')); // Assuming 'messages' from frontend only contains user messages + const systemPromptContent = `You are Sparky, an AI nutrition/wellness Telegram coach. +Goal: Track food, exercise, measurements, and provide brief, actionable advice. +Date: ${todayInZone(chatTz)}. + +**CORE RULES:** +1. **Brevity & Style:** Keep responses concise. Use Telegram HTML formatting (, , ) and emojis in the 'response' field. +2. **Context:** Use [SYSTEM CONTEXT: RECENT PROGRESS] for insights. Don't ask for data already provided. +3. **Dates:** Extract explicitly mentioned dates/times to the root 'entryDate' field ("today", "yesterday", "MM-DD", "YYYY-MM-DD"). Do NOT resolve relative dates to full dates. Omit if none. +4. **Water is NOT Food:** NEVER log water under 'log_food'. ALWAYS use 'log_water'. +5. **Images & Unknown Foods:** + - Extract food/exercise, estimate quantity, unit, and meal_type. + - **CRITICAL:** Always infer detailed nutrition based on the photo or your general knowledge if not in DB. + - **MAXIMAL DETAIL:** For 'log_food', you MUST provide 'calories', 'protein', 'carbs', 'fat' AND any inferable micros like 'sugars', 'dietary_fiber', 'sodium', 'potassium', 'cholesterol', 'saturated_fat', 'vitamin_a', 'vitamin_c', 'calcium', 'iron'. + - **MISSING DATA:** If a photo/text lacks clear portion size, output 'ask_question' intent to clarify. Do NOT guess completely ambiguous sizes. +6. **Units & Custom Names:** + - Convert counts ("2 apples") to unit "piece". Match user units ("g", "cup"). Infer if missing. + - For custom measurements, strictly match names from this list: ${customCategoriesList}. +7. **History Requests:** If the user asks for historical data (e.g., "what did I eat", "last 10 workouts", "my recent meals") AND the data is NOT already provided in the SYSTEM UPDATE context, you MUST return the 'request_data' intent. Set 'response' to a brief waiting message like "Один момент! 🔍 Шукаю...". If the data IS provided, use 'chat' intent to summarize it. + +**OUTPUT FORMAT:** +You MUST reply with a STRICT JSON object matching this schema: + +{ + "intent": "log_food" | "log_exercise" | "log_measurement" | "log_water" | "delete_measurement" | "delete_food" | "ask_question" | "chat" | "request_data", + "data": { ... }, // Specific to intent, see below + "entryDate": "string", // Optional + "response": "string" // Optional for logs, REQUIRED for chat/questions/request_data. Use HTML/emojis. +} + +**INTENTS & DATA SCHEMAS:** +- 'log_food': { food_name: string, quantity: number(default 1), unit: string("g"|"piece"|"cup"|etc), meal_type: string("breakfast"|"lunch"|"dinner"|"snacks"-infer from time), calories: number, protein: number, carbs: number, fat: number, ...[include any inferable micros like sugars, fiber, sodium, etc.], serving_size: number, serving_unit: string } +- 'log_exercise': { exercise_name: string, duration_minutes: number|null, distance: number|null, distance_unit: string|null } +- 'log_measurement': { measurements: [{ type: "weight"|"neck"|"waist"|"hips"|"steps"|"custom", value: number, unit: string|null, name: string|null (REQUIRED exact match if type="custom") }] } +- 'log_water': { glasses_consumed: number(default 1) } +- 'delete_measurement': { measurements: [{ type: string, value: number|null }] } +- 'delete_food': { food_name: string|null } +- 'request_data': { type: "food_history" | "exercise_history" | "measurements_history", days: "14" } // Use to fetch deep history not in context. +- 'ask_question' / 'chat': {} // Empty data object. MUST provide 'response'. + +**SPECIAL COMMAND:** +If input is "GENERATE_FOOD_OPTIONS:[food name] in [unit]", ignore standard JSON output and return ONLY a JSON array of 2-3 realistic options. Match requested unit if logical. +Schema: [{"name": "string", "calories": number, "protein": number, "carbs": number, "fat": number, "serving_size": number, "serving_unit": "string (unit ONLY)"}]`; + const messagesForAI: any[] = []; + + // Перевіряємо, чи є в переданому масиві messages повідомлення з role 'system'. + // Якщо є, ми беремо його (це дозволить telegramBotService динамічно формувати контекст). + const customSystemMessage = messages.find((msg) => msg.role === 'system'); + + if (customSystemMessage) { + messagesForAI.push({ + role: 'system', + content: customSystemMessage.content, + }); + } else { + messagesForAI.push({ role: 'system', content: systemPromptContent }); + } + + // Add remaining user/assistant messages (do not filter out assistant!) + messagesForAI.push(...messages.filter((msg: any) => msg.role !== 'system')); // For Google AI const cleanSystemPrompt = systemPromptContent .replace(/[^\w\s\-.,!?:;()[\]{}'"]/g, ' ') .replace(/\s+/g, ' ') .trim() - .substring(0, 1000); + .substring(0, 15000); switch (aiService.service_type) { case 'openai': @@ -597,6 +559,7 @@ Example JSON output for "GENERATE_FOOD_OPTIONS:apple": }; }) .filter((content) => content.parts.length > 0), + systemInstruction: undefined as any, }; if (googleBody.contents.length === 0) { @@ -779,7 +742,64 @@ Example JSON output for "GENERATE_FOOD_OPTIONS:apple": content = data.message?.content || 'No response from AI service'; break; } - return { content }; + log('info', `[AI RAW RESPONSE] ${content}`); + + let responseText = content; + let intent = null; + let intentData = null; + let entryDate = null; + + try { + // Clean content from markdown code blocks if AI wrapped JSON + const cleanContent = content + .replace(/```json\s?/g, '') + .replace(/\s?```/g, '') + .trim(); + const parsed = JSON.parse(cleanContent); + responseText = parsed.response || parsed.responseText || content; + intent = parsed.intent || null; + intentData = parsed.data || null; + entryDate = parsed.entryDate || parsed.entry_date || null; + + // Robust fallback: if 'intent' exists but 'data' is missing, try pulling from root + if (intent && !intentData) { + const { + intent: _i, + response: _r, + responseText: _rt, + entryDate: _ed, + entry_date: _ed2, + ...dataAtRoot + } = parsed; + if (Object.keys(dataAtRoot).length > 0) { + intentData = dataAtRoot; + log( + 'info', + `[AI RESPONSE] Extracted intentData from root because 'data' key was missing: ${JSON.stringify(intentData)}` + ); + } + } + } catch (e) { + log( + 'info', + 'AI response is not JSON or could not be parsed, treating as plain text.' + ); + } + + log( + 'info', + `[AI RESPONSE] Parsed intent: ${intent}, data keys: ${intentData ? Object.keys(intentData).join(', ') : 'none'}` + ); + if (intentData) + log('info', `[AI RESPONSE DATA] ${JSON.stringify(intentData)}`); + + return { + content: responseText, + text: responseText, + intent, + data: intentData, + entryDate, + }; } catch (error) { log( 'error', @@ -790,27 +810,11 @@ Example JSON output for "GENERATE_FOOD_OPTIONS:apple": } } -module.exports = { - handleAiServiceSettings, - getAiServiceSettings, - getActiveAiServiceSetting, - deleteAiServiceSetting, - clearOldChatHistory, - getSparkyChatHistory, - getSparkyChatHistoryEntry, - updateSparkyChatHistoryEntry, - deleteSparkyChatHistoryEntry, - clearAllSparkyChatHistory, - saveSparkyChatHistory, - processChatMessage, - processFoodOptionsRequest, // Add the new function to exports -}; - -async function processFoodOptionsRequest( - foodName, - unit, - authenticatedUserId, - serviceConfigId +export async function processFoodOptionsRequest( + foodName: any, + unit: any, + authenticatedUserId: any, + serviceConfigId: any ) { // Changed serviceConfig to serviceConfigId try { diff --git a/SparkyFitnessServer/services/foodCoreService.js b/SparkyFitnessServer/services/foodCoreService.js index 3f5be2d0a..c67c35303 100644 --- a/SparkyFitnessServer/services/foodCoreService.js +++ b/SparkyFitnessServer/services/foodCoreService.js @@ -18,6 +18,10 @@ const { searchFatSecretByBarcode, mapFatSecretFood, } = require('../integrations/fatsecret/fatsecretService'); +const { + searchEdamamByBarcode, + mapEdamamSearchItem, +} = require('../integrations/edamam/edamamService'); async function searchFoods( authenticatedUserId, @@ -829,6 +833,37 @@ async function lookupBarcode(barcode, userId, providerId) { } } + // Try Edamam if provider is configured + if ( + provider?.provider_type === 'edamam' && + provider.app_id && + provider.app_key + ) { + try { + const edamamData = await searchEdamamByBarcode( + barcode, + provider.app_id, + provider.app_key + ); + const items = edamamData?.parsed?.length + ? edamamData.parsed + : edamamData?.hints || []; + const hint = items[0]; + if (hint) { + const mapped = mapEdamamSearchItem(hint); + if (mapped) { + return { source: 'edamam', food: mapped, barcode_raw: edamamData }; + } + } + } catch (edamamError) { + log( + 'warn', + `Edamam barcode lookup failed for ${barcode}:`, + edamamError + ); + } + } + // Try OpenFoodFacts if it is the configured primary provider if (provider?.provider_type === 'openfoodfacts') { triedOpenFoodFacts = true; diff --git a/SparkyFitnessServer/services/foodEntryService.js b/SparkyFitnessServer/services/foodEntryService.js index 83300e82a..e0342f3ac 100644 --- a/SparkyFitnessServer/services/foodEntryService.js +++ b/SparkyFitnessServer/services/foodEntryService.js @@ -4,6 +4,7 @@ const mealService = require('./mealService'); const { log } = require('../config/logging'); const mealTypeRepository = require('../models/mealType'); const { sanitizeCustomNutrients } = require('../utils/foodUtils'); +const { syncDailyTotals } = require('./mfpSyncService'); // Helper functions (already defined) function getGlycemicIndexValue(category) { @@ -65,6 +66,8 @@ async function createFoodEntry(authenticatedUserId, actingUserId, entryData) { entryWithUser, actingUserId ); + // Trigger MFP sync in background + syncDailyTotals(entryWithUser.user_id, entryWithUser.entry_date); return newEntry; } catch (error) { log( @@ -233,6 +236,8 @@ async function updateFoodEntry( if (!updatedEntry) { throw new Error('Food entry not found or not authorized to update.'); } + // Trigger MFP sync in background + syncDailyTotals(updatedEntry.user_id, updatedEntry.entry_date); return updatedEntry; } catch (error) { log( @@ -262,6 +267,12 @@ async function deleteFoodEntry(authenticatedUserId, entryId) { ); } + // Fetch entry details before deletion to know the date for sync + const entryDetails = await foodRepository.getFoodEntryById( + entryId, + authenticatedUserId + ); + const success = await foodRepository.deleteFoodEntry( entryId, authenticatedUserId @@ -269,6 +280,11 @@ async function deleteFoodEntry(authenticatedUserId, entryId) { if (!success) { throw new Error('Food entry not found or not authorized to delete.'); } + + if (entryDetails) { + syncDailyTotals(entryDetails.user_id, entryDetails.entry_date); + } + return true; } catch (error) { log( @@ -488,6 +504,11 @@ async function copyFoodEntries( entriesToCreate, authenticatedUserId ); + + if (newEntries && newEntries.length > 0) { + syncDailyTotals(authenticatedUserId, targetDate); + } + return newEntries; } catch (error) { log( @@ -835,6 +856,9 @@ async function createFoodEntryMeal( ); } + // Trigger MFP sync in background + syncDailyTotals(newFoodEntryMeal.user_id, newFoodEntryMeal.entry_date); + return newFoodEntryMeal; } catch (error) { log( @@ -1005,6 +1029,12 @@ async function updateFoodEntryMeal( ); } + // Trigger MFP sync in background + syncDailyTotals( + updatedFoodEntryMeal.user_id, + updatedFoodEntryMeal.entry_date + ); + return updatedFoodEntryMeal; } catch (error) { log( @@ -1388,6 +1418,12 @@ async function deleteFoodEntryMeal(authenticatedUserId, foodEntryMealId) { try { // foodRepository.deleteFoodEntryComponentsByFoodEntryMealId will be called due to ON DELETE CASCADE // on the food_entries.food_entry_meal_id foreign key. + // Fetch meal details before deletion to know the date for sync + const mealDetails = await foodEntryMealRepository.getFoodEntryMealById( + foodEntryMealId, + authenticatedUserId + ); + const success = await foodEntryMealRepository.deleteFoodEntryMeal( foodEntryMealId, authenticatedUserId @@ -1395,6 +1431,11 @@ async function deleteFoodEntryMeal(authenticatedUserId, foodEntryMealId) { if (!success) { throw new Error('Food entry meal not found or not authorized to delete.'); } + + if (mealDetails) { + syncDailyTotals(mealDetails.user_id, mealDetails.entry_date); + } + return { message: 'Food entry meal deleted successfully.' }; } catch (error) { log( @@ -1406,6 +1447,23 @@ async function deleteFoodEntryMeal(authenticatedUserId, foodEntryMealId) { } } +async function getDailyNutritionByCategory(userId, date) { + try { + const summary = await foodRepository.getDailyNutritionByCategory( + userId, + date + ); + return summary; + } catch (error) { + log( + 'error', + `Error fetching daily nutrition by category for user ${userId} on ${date} in foodService:`, + error + ); + throw error; + } +} + module.exports = { createFoodEntry, deleteFoodEntry, @@ -1418,6 +1476,7 @@ module.exports = { copyAllFoodEntries, copyAllFoodEntriesFromYesterday, getDailyNutritionSummary, + getDailyNutritionByCategory, // New export createFoodEntryMeal, // New export updateFoodEntryMeal, // New export getFoodEntryMealWithComponents, // New export diff --git a/SparkyFitnessServer/services/foodIntegrationService.js b/SparkyFitnessServer/services/foodIntegrationService.js index a7819b667..5c534b739 100644 --- a/SparkyFitnessServer/services/foodIntegrationService.js +++ b/SparkyFitnessServer/services/foodIntegrationService.js @@ -7,16 +7,43 @@ const { } = require('../integrations/fatsecret/fatsecretService'); const MealieService = require('../integrations/mealie/mealieService'); // Import MealieService const TandoorService = require('../integrations/tandoor/tandoorService'); // Import TandoorService +const EdamamService = require('../integrations/edamam/edamamService'); -async function searchFatSecretFoods(query, clientId, clientSecret, page = 1) { +// Maps user language codes to FatSecret language+region pairs. +// Only languages confirmed by FatSecret localization docs are listed. +const FATSECRET_LOCALE = { + ru: { language: 'ru', region: 'RU' }, + uk: { language: 'uk', region: 'UA' }, + de: { language: 'de', region: 'DE' }, + fr: { language: 'fr', region: 'FR' }, + es: { language: 'es', region: 'ES' }, + pt: { language: 'pt', region: 'BR' }, + it: { language: 'it', region: 'IT' }, + nl: { language: 'nl', region: 'NL' }, + pl: { language: 'pl', region: 'PL' }, + zh: { language: 'zh', region: 'CN' }, + ja: { language: 'ja', region: 'JP' }, + ko: { language: 'ko', region: 'KR' }, +}; + +async function searchFatSecretFoods( + query, + clientId, + clientSecret, + page = 1, + language = 'en' +) { try { const accessToken = await getFatSecretAccessToken(clientId, clientSecret); - const searchUrl = `${FATSECRET_API_BASE_URL}?${new URLSearchParams({ + const locale = FATSECRET_LOCALE[language]; + const params = { method: 'foods.search', search_expression: query, page_number: page - 1, format: 'json', - }).toString()}`; + ...(locale ? { language: locale.language, region: locale.region } : {}), + }; + const searchUrl = `${FATSECRET_API_BASE_URL}?${new URLSearchParams(params).toString()}`; log('info', `FatSecret Search URL: ${searchUrl}`); const response = await fetch(searchUrl, { method: 'GET', @@ -57,21 +84,30 @@ async function searchFatSecretFoods(query, clientId, clientSecret, page = 1) { } } -async function getFatSecretNutrients(foodId, clientId, clientSecret) { +async function getFatSecretNutrients( + foodId, + clientId, + clientSecret, + language = 'en' +) { try { - // Check cache first - const cachedData = foodNutrientCache.get(foodId); + // Check cache first — include language in cache key so localized results are cached separately + const cacheKey = `${foodId}_${language}`; + const cachedData = foodNutrientCache.get(cacheKey); if (cachedData && Date.now() < cachedData.expiry) { - log('info', `Returning cached data for foodId: ${foodId}`); + log('info', `Returning cached data for foodId: ${foodId} (${language})`); return cachedData.data; } const accessToken = await getFatSecretAccessToken(clientId, clientSecret); - const nutrientsUrl = `${FATSECRET_API_BASE_URL}?${new URLSearchParams({ + const locale = FATSECRET_LOCALE[language]; + const params = { method: 'food.get.v4', food_id: foodId, format: 'json', - }).toString()}`; + ...(locale ? { language: locale.language, region: locale.region } : {}), + }; + const nutrientsUrl = `${FATSECRET_API_BASE_URL}?${new URLSearchParams(params).toString()}`; log('info', `FatSecret Nutrients URL: ${nutrientsUrl}`); const response = await fetch(nutrientsUrl, { method: 'GET', @@ -90,7 +126,7 @@ async function getFatSecretNutrients(foodId, clientId, clientSecret) { const data = await response.json(); // Store in cache - foodNutrientCache.set(foodId, { + foodNutrientCache.set(cacheKey, { data: data, expiry: Date.now() + CACHE_DURATION_MS, }); @@ -170,7 +206,6 @@ async function getMealieFoodDetails(slug, baseUrl, apiKey, userId, providerId) { throw error; } } - module.exports = { searchFatSecretFoods, getFatSecretNutrients, @@ -178,6 +213,8 @@ module.exports = { getMealieFoodDetails, searchTandoorFoods, getTandoorFoodDetails, + searchEdamamFoods, + getEdamamFoodDetails, }; async function searchTandoorFoods(query, baseUrl, apiKey, userId, providerId) { @@ -233,3 +270,85 @@ async function getTandoorFoodDetails(id, baseUrl, apiKey, userId, providerId) { throw error; } } +async function searchEdamamFoods(query, appId, appKey, page = 1) { + log( + 'debug', + `searchEdamamFoods: query: ${query}, appId: ${appId}, page: ${page}` + ); + try { + const data = await EdamamService.searchEdamamByQuery( + query, + appId, + appKey, + page + ); + const hints = data.hints || []; + + // Map each hint to app format + const foods = hints.map(EdamamService.mapEdamamSearchItem).filter(Boolean); + + const pageSize = 20; + const totalCount = data.count != null ? data.count : foods.length; + const hasMore = data._links?.next != null || totalCount > page * pageSize; + + return { + items: foods, + pagination: { + page, + pageSize, + totalCount, + hasMore, + }, + }; + } catch (error) { + log('error', 'Error searching Edamam foods:', error); + throw error; + } +} + +async function getEdamamFoodDetails(foodId, appId, appKey) { + log('debug', `getEdamamFoodDetails: foodId: ${foodId}, appId: ${appId}`); + try { + // To get full measures/weights, we need to call the nutrients POST endpoint. + // However, the parser hints already contain the basic nutrients for 100g. + // Optimal way to get measures is either re-parsing or calling /nutrients with foodId. + const url = `${EdamamService.EDAMAM_NUTRIENTS_URL}?${new URLSearchParams({ + app_id: appId, + app_key: appKey, + })}`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + ingredients: [ + { + quantity: 100, + measureURI: + 'http://www.edamam.com/ontologies/edamam.owl#Measure_gram', + foodId: foodId, + }, + ], + }), + }); + + if (!response.ok) { + const text = await response.text(); + log('error', 'Edamam nutrients API error:', text); + throw new Error(`Edamam API error: ${text}`); + } + + const data = await response.json(); + const food = data.ingredients?.[0]?.parsed?.[0]?.food; + if (!food) return null; + + // Measures are in data.measures + return EdamamService.mapEdamamFood(food, data.measures); + } catch (error) { + log('error', `Error getting Edamam food details: ${foodId}`, error); + throw error; + } +} diff --git a/SparkyFitnessServer/services/garminService.js b/SparkyFitnessServer/services/garminService.js index 606747b08..7229bfee3 100644 --- a/SparkyFitnessServer/services/garminService.js +++ b/SparkyFitnessServer/services/garminService.js @@ -1,4 +1,5 @@ const { log } = require('../config/logging'); +const moment = require('moment'); // Import moment const exerciseEntryRepository = require('../models/exerciseEntry'); const exerciseRepository = require('../models/exercise'); const activityDetailsRepository = require('../models/activityDetailsRepository'); @@ -8,7 +9,6 @@ const measurementService = require('./measurementService'); // Import measuremen const moodRepository = require('../models/moodRepository'); // Import moodRepository const garminConnectService = require('../integrations/garminconnect/garminConnectService'); const garminMeasurementMapping = require('../integrations/garminconnect/garminMeasurementMapping'); -const moment = require('moment'); const { loadUserTimezone } = require('../utils/timezoneLoader'); const { todayInZone, addDays } = require('@workspace/shared'); @@ -840,10 +840,17 @@ async function syncGarminData( const dailyEntries = healthWellnessData.data[metric]; if (Array.isArray(dailyEntries)) { for (const entry of dailyEntries) { - const calendarDateRaw = entry.date; + const calendarDateRaw = entry.calendarDate || entry.date; if (!calendarDateRaw) continue; - const calendarDate = moment(calendarDateRaw).format('YYYY-MM-DD'); + let calendarDate; + try { + calendarDate = moment(calendarDateRaw).format('YYYY-MM-DD'); + } catch (e) { + calendarDate = new Date(calendarDateRaw) + .toISOString() + .split('T')[0]; + } for (const key in entry) { if (key === 'date') continue; diff --git a/SparkyFitnessServer/services/mfpSyncService.ts b/SparkyFitnessServer/services/mfpSyncService.ts new file mode 100644 index 000000000..2e86cea54 --- /dev/null +++ b/SparkyFitnessServer/services/mfpSyncService.ts @@ -0,0 +1,83 @@ +import { log } from '../config/logging'; +import { pushNutritionToMFP } from '../integrations/myfitnesspal/myFitnessPalService'; + +// In-memory lock to prevent multiple concurrent syncs for the same user and date. +const activeSyncs = new Set(); + +/** + * Syncs the daily nutrition totals for a user to MyFitnessPal, categorized by meal type. + * This should be called whenever a food entry is created, updated, or deleted. + * + * @param userId - The user whose data to sync. + * @param date - The date to sync (YYYY-MM-DD). + * @returns The result of the sync operation. + */ +export async function syncDailyTotals( + userId: string, + date: string +): Promise { + const lockKey = `${userId}:${date}`; + if (activeSyncs.has(lockKey)) { + log( + 'info', + `mfpSyncService: Sync already in progress for user ${userId} on ${date}. Skipping concurrent request.` + ); + return { status: 'skipped', date, reason: 'concurrent_request' }; + } + + activeSyncs.add(lockKey); + + const { getDailyNutritionByCategory } = require('./foodEntryService'); + try { + log( + 'info', + `mfpSyncService: Starting categorized sync for user ${userId} on ${date}` + ); + + // 1. Fetch categorized daily totals from SparkyFitness + const categories = await getDailyNutritionByCategory(userId, date); + + if (!categories || Object.keys(categories).length === 0) { + log( + 'info', + `mfpSyncService: No nutrition data found for ${userId} on ${date}. Sync skipped.` + ); + return { status: 'skipped', date }; + } + + // 2. Map SparkyFitness categories to MFP expected format + const mfpData: any = { + date: date, + categories: {}, + }; + + const categoriesData = categories as Record; + for (const name in categoriesData) { + const row = categoriesData[name]; + mfpData.categories[name] = { + calories: row.calories, + protein: row.protein, + fat: row.fat, + carbohydrate: row.carbohydrate || row.carbs, + }; + } + + // 3. Push to MyFitnessPal + const result = await pushNutritionToMFP(userId, mfpData); + + log( + 'info', + `mfpSyncService: Successfully synced categorized totals for user ${userId} on ${date}` + ); + return { status: 'success', date, ...result }; + } catch (error: any) { + log( + 'error', + `mfpSyncService: Failed to sync totals for user ${userId} on ${date}:`, + error.message + ); + return { status: 'error', date, message: error.message }; + } finally { + activeSyncs.delete(lockKey); + } +} diff --git a/SparkyFitnessServer/tests/telegramBotService.test.ts b/SparkyFitnessServer/tests/telegramBotService.test.ts new file mode 100644 index 000000000..4867c9a0a --- /dev/null +++ b/SparkyFitnessServer/tests/telegramBotService.test.ts @@ -0,0 +1,199 @@ +const telegramBotService = require('../integrations/telegram/telegramBotService'); +import { log } from '../config/logging'; +import globalSettingsRepository from '../models/globalSettingsRepository'; +import poolManager from '../db/poolManager'; + +import userRepository from '../models/userRepository'; +import goalRepository from '../models/goalRepository'; +import * as foodEntry from '../models/foodEntry'; +import measurementRepository from '../models/measurementRepository'; +import preferenceRepository from '../models/preferenceRepository'; + +jest.mock('../models/globalSettingsRepository'); +jest.mock('../db/poolManager'); +jest.mock('../services/chatService'); +jest.mock('../models/chatRepository'); +jest.mock('../models/exerciseEntry'); +jest.mock('../models/userRepository'); +jest.mock('../models/goalRepository'); +jest.mock('../models/foodEntry'); +jest.mock('../models/measurementRepository'); +jest.mock('../models/preferenceRepository'); +jest.mock('node-telegram-bot-api'); +jest.mock('../config/logging', () => ({ + log: jest.fn(), +})); +jest.mock('../../utils/timezoneLoader', () => ({ + loadUserTimezone: jest.fn().mockResolvedValue('UTC'), +})); +jest.mock('@workspace/shared', () => ({ + todayInZone: jest.fn().mockReturnValue('2026-04-06'), +})); + +describe('TelegramBotService', () => { + const mockChatId = 123456789; + const mockUserId = 'user-uuid'; + const mockClient = { + query: jest.fn(), + release: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + (poolManager.getSystemClient as jest.Mock).mockResolvedValue(mockClient); + }); + + describe('findUserAndLanguageByChatId', () => { + it('should return user info if found in database', async () => { + const mockUser = { + id: mockUserId, + name: 'Test User', + language: 'en', + telegram_chat_id: String(mockChatId), + }; + mockClient.query.mockResolvedValue({ rows: [mockUser] }); + + // Accessing private method for testing + const result = await ( + telegramBotService as any + ).findUserAndLanguageByChatId(mockChatId); + + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining('SELECT id, name, language, telegram_chat_id'), + [String(mockChatId)] + ); + expect(result).toEqual(mockUser); + }); + + it('should return null if user not found', async () => { + mockClient.query.mockResolvedValue({ rows: [] }); + + const result = await ( + telegramBotService as any + ).findUserAndLanguageByChatId(mockChatId); + + expect(result).toBeNull(); + }); + }); + + describe('getTranslations', () => { + it('should return English translations by default', () => { + const t = (telegramBotService as any).getTranslations('en'); + expect(t.greeting).toBeDefined(); + }); + + it('should return Ukrainian translations for "uk"', () => { + const t = (telegramBotService as any).getTranslations('uk'); + expect(t.greeting).toBe('Привіт'); + }); + + it('should fallback to English for unknown language', () => { + const t = (telegramBotService as any).getTranslations('fr'); + expect(t.greeting).toBe('Hello'); + }); + }); + + describe('handleLink', () => { + it('should link user account when valid code is provided', async () => { + const mockCode = 'ABCDEF'; + const mockUser = { id: mockUserId, name: 'Test User', language: 'en' }; + mockClient.query + .mockResolvedValueOnce({ rows: [mockUser] }) // First query (find user) + .mockResolvedValueOnce({}); // Second query (update user) + + // Mock bot.sendMessage + (telegramBotService as any).bot = { sendMessage: jest.fn() }; + (telegramBotService as any).getMainMenuKeyboard = jest + .fn() + .mockReturnValue({}); + + await telegramBotService.handleLink(mockChatId, mockCode); + + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining('SELECT id, name, language FROM public."user"'), + [mockCode] + ); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining('UPDATE public."user" SET telegram_chat_id'), + [String(mockChatId), mockUserId] + ); + expect((telegramBotService as any).bot.sendMessage).toHaveBeenCalledWith( + mockChatId, + expect.stringContaining('Success'), + expect.any(Object) + ); + }); + + it('should send error message for invalid code', async () => { + const mockCode = 'INVALID'; + mockClient.query.mockResolvedValue({ rows: [] }); + + (telegramBotService as any).bot = { sendMessage: jest.fn() }; + + await telegramBotService.handleLink(mockChatId, mockCode); + + expect((telegramBotService as any).bot.sendMessage).toHaveBeenCalledWith( + mockChatId, + expect.stringContaining('Invalid linking code') + ); + }); + }); + + describe('getUserNutritionContext', () => { + it('should aggregate user physical profile and goals for AI context', async () => { + const mockProfile = { gender: 'male', date_of_birth: '1990-01-01' }; + const mockGoal = { calories: 2500 }; + const mockDailyProgress = [ + { calories: 1500, protein: 100, carbs: 150, fat: 50 }, + ]; + + (userRepository.getUserProfile as jest.Mock).mockResolvedValue( + mockProfile + ); + ( + goalRepository.getMostRecentGoalBeforeDate as jest.Mock + ).mockResolvedValue(mockGoal); + (foodEntry.getFoodEntriesByDate as jest.Mock).mockResolvedValue( + mockDailyProgress + ); + ( + measurementRepository.getLatestCheckInMeasurementsOnOrBeforeDate as jest.Mock + ).mockResolvedValue({ weight: 80, height: 180 }); + (preferenceRepository.getUserPreferences as jest.Mock).mockResolvedValue({ + activity_level: 'sedentary', + }); + + const context = await (telegramBotService as any).getUserNutritionContext( + mockUserId + ); + + expect(context).toContain('80kg'); + expect(context).toContain('Male'); + expect(context).toContain('2500 kcal'); + expect(context).toContain('1500 kcal'); + }); + }); + + describe('executeIntent', () => { + it('should return a detailed success message when logging food with macros', async () => { + const mockIntentResult = { + intent: 'log_food', + data: { + food_name: 'Banana', + quantity: 1, + unit: 'piece', + calories: 105, + protein: 1, + carbs: 27, + fat: 0, + }, + }; + + const { + executeIntent, + } = require('../integrations/telegram/intentExecutor'); + // Mock executeIntent directly or test the service's reaction + // In this case, we verify that the service handles the response correctly + }); + }); +}); diff --git a/docs/content/8.developer/12.telegram-bot-setup.md b/docs/content/8.developer/12.telegram-bot-setup.md new file mode 100644 index 000000000..8975b33e5 --- /dev/null +++ b/docs/content/8.developer/12.telegram-bot-setup.md @@ -0,0 +1,37 @@ +# Telegram Bot Setup Guide + +This guide explains how to set up and configure the SparkyFitness Telegram bot for local development or production. + +## 1. Create a Telegram Bot +1. Find [@BotFather](https://t.me/botfather) on Telegram. +2. Send `/newbot` and follow the instructions to get your **Bot Token**. +3. (Optional) Set a description, about text, and profile picture using `/setdescription`, `/setabout`, and `/setuserpic`. + +## 2. Configuration +Add the following variables to your `.env` file in the project root: + +```env +TELEGRAM_BOT_TOKEN=your_bot_token_here +TELEGRAM_WEBHOOK_URL=https://your-domain.com +``` + +* **TELEGRAM_BOT_TOKEN**: The token provided by BotFather. +* **TELEGRAM_WEBHOOK_URL**: The public URL of your SparkyFitness server (must be HTTPS). + * *Tip: Use [ngrok](https://ngrok.com/) for local development.* + +## 3. Webhook vs Polling +- **Production**: The bot automatically registers its webhook at `${TELEGRAM_WEBHOOK_URL}/api/telegram/webhook` on startup. +- **Development**: If `TELEGRAM_WEBHOOK_URL` is missing, the bot defaults to **Polling Mode**, which is easier for local testing without a public URL. + +## 4. Linking Your Account +Users must link their SparkyFitness account to their Telegram chat: +1. Open the SparkyFitness Web App. +2. Navigate to **Settings > Integrations > Telegram**. +3. Click "Generate Link Code". +4. Open your bot on Telegram and send: `/start ` (e.g., `/start A1B2C3`). +5. The bot will confirm the link, and you can start logging! + +## 5. Security +The `/api/telegram/webhook` endpoint is protected by: +1. **Zod Validation**: All incoming payloads are strictly validated against `TelegramWebhookSchema`. +2. **User Matching**: The bot only processes messages from successfully linked `chat_id`s stored in the database. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4386c832..4ff57854c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -533,6 +533,9 @@ importers: node-cron: specifier: ^4.2.1 version: 4.2.1 + node-telegram-bot-api: + specifier: ^0.67.0 + version: 0.67.0(request@2.88.2) nodemailer: specifier: ^8.0.2 version: 8.0.2 @@ -1550,6 +1553,16 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@cypress/request-promise@5.0.0': + resolution: {integrity: sha512-eKdYVpa9cBEw2kTBlHeu1PP16Blwtum6QHg/u9s/MoHkZfuo1pRGka1VlUHXF5kdew82BvOJVVGk0x8X0nbp+w==} + engines: {node: '>=0.10.0'} + peerDependencies: + '@cypress/request': ^3.0.0 + + '@cypress/request@3.0.10': + resolution: {integrity: sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==} + engines: {node: '>= 6'} + '@date-fns/tz@1.4.1': resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} @@ -5999,6 +6012,10 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + array.prototype.findindex@2.2.4: + resolution: {integrity: sha512-LLm4mhxa9v8j0A/RPnpQAP4svXToJFh+Hp1pNYl5ZD5qpB4zdx/D4YjpVcETkhFbUKWO3iGMVLvrOnnmkAJT6A==} + engines: {node: '>= 0.4'} + array.prototype.findlast@1.2.5: resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} engines: {node: '>= 0.4'} @@ -6033,6 +6050,10 @@ packages: resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==} engines: {node: '>=12.0.0'} + assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + assert@2.1.0: resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} @@ -6079,6 +6100,12 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + aws-sign2@0.7.0: + resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} + + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + axios@1.13.6: resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} @@ -6239,6 +6266,9 @@ packages: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + bcrypt@6.0.0: resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} engines: {node: '>= 18'} @@ -6342,9 +6372,15 @@ packages: birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + bl@1.2.3: + resolution: {integrity: sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + body-parser@1.20.4: resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -6491,6 +6527,9 @@ packages: resolution: {integrity: sha512-mzDSXIPaFwVDvZAHqZ9VlbyF4yyXRuX6IvB06WvPYkqJVO24kX1PPhv9bfpKNFZyxYFmmgo03HUiD8iklmJYRQ==} engines: {node: '>= 0.8.0'} + caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -6829,6 +6868,9 @@ packages: core-js-compat@3.48.0: resolution: {integrity: sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==} + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -7013,6 +7055,10 @@ packages: resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} engines: {node: '>=12'} + dashdash@1.14.1: + resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} + engines: {node: '>=0.10'} + data-urls@3.0.2: resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} engines: {node: '>=12'} @@ -7277,6 +7323,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecc-jsbn@0.1.2: + resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -7318,6 +7367,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + engine.io-client@6.6.4: resolution: {integrity: sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==} @@ -7696,6 +7748,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@3.1.2: + resolution: {integrity: sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -7944,6 +7999,10 @@ packages: externality@1.0.2: resolution: {integrity: sha512-LyExtJWKxtgVzmgtEHyQtLFpw1KFhQphF9nTG8TpAIVkiI/xQ3FJh75tRFLYl4hkn7BNIIdLJInuDAavX35pMw==} + extsprintf@1.3.0: + resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} + engines: {'0': node >=0.6.0} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -8000,6 +8059,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + file-type@3.9.0: + resolution: {integrity: sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==} + engines: {node: '>=0.10.0'} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -8076,6 +8139,13 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + forever-agent@0.6.1: + resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} + + form-data@2.3.3: + resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} + engines: {node: '>= 0.12'} + form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -8197,6 +8267,9 @@ packages: resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==} engines: {node: '>=6'} + getpass@0.1.7: + resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} + giget@2.0.0: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} hasBin: true @@ -8322,6 +8395,15 @@ packages: engines: {node: '>=0.4.7'} hasBin: true + har-schema@2.0.0: + resolution: {integrity: sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==} + engines: {node: '>=4'} + + har-validator@5.1.5: + resolution: {integrity: sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==} + engines: {node: '>=6'} + deprecated: this library is no longer supported + harmony-reflect@1.6.2: resolution: {integrity: sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==} @@ -8469,6 +8551,14 @@ packages: resolution: {integrity: sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + http-signature@1.2.0: + resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==} + engines: {node: '>=0.8', npm: '>=1.3.7'} + + http-signature@1.4.0: + resolution: {integrity: sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==} + engines: {node: '>=0.10'} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -8842,6 +8932,9 @@ packages: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} + is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} @@ -8887,6 +8980,9 @@ packages: resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} engines: {node: '>=20'} + isstream@0.1.2: + resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -9273,6 +9369,9 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsbn@0.1.1: + resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + jsc-safe-url@0.2.4: resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} @@ -9329,6 +9428,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true @@ -9348,6 +9450,14 @@ packages: resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} engines: {node: '>=0.10.0'} + jsprim@1.4.2: + resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} + engines: {node: '>=0.6.0'} + + jsprim@2.0.2: + resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==} + engines: {'0': node >=0.6.0} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -10439,6 +10549,10 @@ packages: node-rsa@1.1.1: resolution: {integrity: sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==} + node-telegram-bot-api@0.67.0: + resolution: {integrity: sha512-gO7o/dPcdFSUuFuPiAYBxV7bcN+vQL3pfaHN8P4z8fPtFstN05xVmhMFOth57DANGDKBpzW3rMRo7debOlUI1w==} + engines: {node: '>=0.12'} + nodemailer@8.0.2: resolution: {integrity: sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==} engines: {node: '>=6.0.0'} @@ -10513,6 +10627,9 @@ packages: engines: {node: '>=18'} hasBin: true + oauth-sign@0.9.0: + resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} + ob1@0.83.3: resolution: {integrity: sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA==} engines: {node: '>=20.19.4'} @@ -10810,6 +10927,9 @@ packages: perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} @@ -11214,6 +11334,9 @@ packages: engines: {node: '>=18'} hasBin: true + pump@2.0.1: + resolution: {integrity: sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -11242,6 +11365,10 @@ packages: resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} engines: {node: '>=0.6'} + qs@6.5.5: + resolution: {integrity: sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -11707,6 +11834,17 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + request-promise-core@1.1.3: + resolution: {integrity: sha512-QIs2+ArIGQVp5ZYbWD5ZLCY29D5CfWizP8eWnm8FoGD1TX61veauETVQbrV60662V0oFBkrDOuaBI8XgtuyYAQ==} + engines: {node: '>=0.10.0'} + peerDependencies: + request: ^2.34 + + request@2.88.2: + resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==} + engines: {node: '>= 6'} + deprecated: request has been deprecated, see https://github.com/request/request/issues/3142 + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -12163,6 +12301,11 @@ packages: engines: {node: '>=20.16.0'} hasBin: true + sshpk@1.18.0: + resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} + engines: {node: '>=0.10.0'} + hasBin: true + stable-hash@0.0.4: resolution: {integrity: sha512-LjdcbuBeLcdETCrPn9i8AYAZ1eCtu4ECAWtP7UleOiZ9LzVxRzzUZEoZ8zB24nhkQnDWyET0I+3sWokSDS3E7g==} @@ -12206,6 +12349,10 @@ packages: std-env@4.0.0: resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + stealthy-require@1.1.1: + resolution: {integrity: sha512-ZnWpYnYugiOVEY5GkcuJK1io5V8QmNYChG62gSit9pQVGErXtrKuPC55ITaVSukmMta5qpMU7vqLt2Lnni4f/g==} + engines: {node: '>=0.10.0'} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -12557,6 +12704,10 @@ packages: resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} hasBin: true + tough-cookie@2.5.0: + resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==} + engines: {node: '>=0.8'} + tough-cookie@4.1.4: resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} engines: {node: '>=6'} @@ -12664,6 +12815,12 @@ packages: resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} engines: {node: '>= 6.0.0'} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -13067,6 +13224,11 @@ packages: resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} hasBin: true + uuid@3.4.0: + resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} + deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. + hasBin: true + uuid@7.0.3: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} hasBin: true @@ -13102,6 +13264,10 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + verror@1.10.0: + resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} + engines: {'0': node >=0.6.0} + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -13805,7 +13971,7 @@ snapshots: '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -13857,7 +14023,7 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) lodash.debounce: 4.0.8 resolve: 1.22.11 transitivePeerDependencies: @@ -14643,7 +14809,7 @@ snapshots: '@babel/parser': 7.29.0 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -14696,26 +14862,26 @@ snapshots: nanostores: 1.1.1 zod: 4.3.6 - '@better-auth/drizzle-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': + '@better-auth/drizzle-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1) + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 - '@better-auth/kysely-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.14)': + '@better-auth/kysely-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.14)': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1) + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 optionalDependencies: kysely: 0.28.14 - '@better-auth/memory-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': + '@better-auth/memory-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1) + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 - '@better-auth/mongo-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0)': + '@better-auth/mongo-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0)': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1) + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 optionalDependencies: mongodb: 7.1.0 @@ -14744,9 +14910,9 @@ snapshots: nanostores: 1.1.1 zod: 4.3.6 - '@better-auth/prisma-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': + '@better-auth/prisma-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1) + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 '@better-auth/sso@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.6(@opentelemetry/api@1.9.0)(mongodb@7.1.0)(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vue@3.5.30(typescript@5.9.3)))(better-call@1.3.2(zod@4.3.6))': @@ -14775,9 +14941,9 @@ snapshots: tldts: 6.1.86 zod: 4.3.6 - '@better-auth/telemetry@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1))': + '@better-auth/telemetry@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1))': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1) + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 @@ -14832,6 +14998,37 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@cypress/request-promise@5.0.0(@cypress/request@3.0.10)(request@2.88.2)': + dependencies: + '@cypress/request': 3.0.10 + bluebird: 3.7.2 + request-promise-core: 1.1.3(request@2.88.2) + stealthy-require: 1.1.1 + tough-cookie: 4.1.4 + transitivePeerDependencies: + - request + + '@cypress/request@3.0.10': + dependencies: + aws-sign2: 0.7.0 + aws4: 1.13.2 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 4.0.5 + http-signature: 1.4.0 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + performance-now: 2.1.0 + qs: 6.14.2 + safe-buffer: 5.2.1 + tough-cookie: 5.1.2 + tunnel-agent: 0.6.0 + uuid: 8.3.2 + '@date-fns/tz@1.4.1': {} '@dnd-kit/accessibility@3.1.1(react@19.2.4)': @@ -15218,7 +15415,7 @@ snapshots: '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -15226,7 +15423,7 @@ snapshots: '@eslint/config-array@0.23.3': dependencies: '@eslint/object-schema': 3.0.3 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.4 transitivePeerDependencies: - supports-color @@ -15250,7 +15447,7 @@ snapshots: '@eslint/eslintrc@3.3.5': dependencies: ajv: 6.14.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -15313,7 +15510,7 @@ snapshots: ci-info: 3.9.0 compression: 1.8.1 connect: 3.7.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) env-editor: 0.4.2 expo: 54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-server: 1.0.5 @@ -15366,7 +15563,7 @@ snapshots: '@expo/plist': 0.4.8 '@expo/sdk-runtime-versions': 1.0.0 chalk: 4.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) getenv: 2.0.0 glob: 13.0.6 resolve-from: 5.0.0 @@ -15415,7 +15612,7 @@ snapshots: '@expo/env@2.0.11': dependencies: chalk: 4.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) dotenv: 16.4.7 dotenv-expand: 11.0.7 getenv: 2.0.0 @@ -15427,7 +15624,7 @@ snapshots: '@expo/spawn-async': 1.7.2 arg: 5.0.2 chalk: 4.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) getenv: 2.0.0 glob: 13.0.6 ignore: 5.3.2 @@ -15465,7 +15662,7 @@ snapshots: '@expo/spawn-async': 1.7.2 browserslist: 4.28.1 chalk: 4.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) dotenv: 16.4.7 dotenv-expand: 11.0.7 getenv: 2.0.0 @@ -15531,7 +15728,7 @@ snapshots: '@expo/image-utils': 0.8.12 '@expo/json-file': 10.0.12 '@react-native/normalize-colors': 0.81.5 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) expo: 54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) resolve-from: 5.0.0 semver: 7.7.4 @@ -15546,7 +15743,7 @@ snapshots: '@expo/server@0.5.3': dependencies: abort-controller: 3.0.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) source-map-support: 0.5.21 undici: 6.23.0 transitivePeerDependencies: @@ -16168,7 +16365,7 @@ snapshots: '@kwsites/file-exists@1.1.1': dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -16332,7 +16529,7 @@ snapshots: citty: 0.2.1 confbox: 0.2.4 consola: 3.4.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) defu: 6.1.4 exsolve: 1.0.8 fuse.js: 7.1.0 @@ -16813,7 +17010,7 @@ snapshots: '@types/mdast': 4.0.4 '@vue/compiler-core': 3.5.30 consola: 3.4.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) defu: 6.1.4 destr: 2.0.5 detab: 3.0.2 @@ -17977,7 +18174,7 @@ snapshots: '@react-native/community-cli-plugin@0.81.5': dependencies: '@react-native/dev-middleware': 0.81.5 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) invariant: 2.2.4 metro: 0.83.5 metro-config: 0.83.5 @@ -17997,7 +18194,7 @@ snapshots: chrome-launcher: 0.15.2 chromium-edge-launcher: 0.2.0 connect: 3.7.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) invariant: 2.2.4 nullthrows: 1.1.1 open: 7.4.2 @@ -18998,7 +19195,7 @@ snapshots: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 9.39.4(jiti@2.6.1) optionalDependencies: typescript: 5.9.3 @@ -19011,7 +19208,7 @@ snapshots: '@typescript-eslint/types': 8.56.1 '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.56.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -19023,7 +19220,7 @@ snapshots: '@typescript-eslint/types': 8.57.1 '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.57.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 10.0.3(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -19033,7 +19230,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) '@typescript-eslint/types': 8.56.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -19042,7 +19239,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3) '@typescript-eslint/types': 8.57.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -19074,7 +19271,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) '@typescript-eslint/utils': 7.18.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 9.39.4(jiti@2.6.1) ts-api-utils: 1.4.3(typescript@5.9.3) optionalDependencies: @@ -19087,7 +19284,7 @@ snapshots: '@typescript-eslint/types': 8.56.1 '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) '@typescript-eslint/utils': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 9.39.4(jiti@2.6.1) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 @@ -19099,7 +19296,7 @@ snapshots: '@typescript-eslint/types': 8.57.1 '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) '@typescript-eslint/utils': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 10.0.3(jiti@2.6.1) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 @@ -19116,7 +19313,7 @@ snapshots: dependencies: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.9 @@ -19133,7 +19330,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) '@typescript-eslint/types': 8.56.1 '@typescript-eslint/visitor-keys': 8.56.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.4 semver: 7.7.4 tinyglobby: 0.2.15 @@ -19148,7 +19345,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3) '@typescript-eslint/types': 8.57.1 '@typescript-eslint/visitor-keys': 8.57.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.4 semver: 7.7.4 tinyglobby: 0.2.15 @@ -19218,7 +19415,7 @@ snapshots: '@typescript/vfs@1.6.4(typescript@5.9.3)': dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -19800,7 +19997,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -19931,6 +20128,15 @@ snapshots: array-union@2.1.0: {} + array.prototype.findindex@2.2.4: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + array.prototype.findlast@1.2.5: dependencies: call-bind: 1.0.8 @@ -19994,6 +20200,8 @@ snapshots: pvutils: 1.1.5 tslib: 2.8.1 + assert-plus@1.0.0: {} + assert@2.1.0: dependencies: call-bind: 1.0.8 @@ -20041,6 +20249,10 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + aws-sign2@0.7.0: {} + + aws4@1.13.2: {} + axios@1.13.6: dependencies: follow-redirects: 1.15.11 @@ -20197,7 +20409,7 @@ snapshots: babel-plugin-react-native-web: 0.21.2 babel-plugin-syntax-hermes-parser: 0.29.1 babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0) - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) react-refresh: 0.14.2 resolve-from: 5.0.0 optionalDependencies: @@ -20266,6 +20478,10 @@ snapshots: dependencies: safe-buffer: 5.1.2 + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + bcrypt@6.0.0: dependencies: node-addon-api: 8.6.0 @@ -20274,12 +20490,12 @@ snapshots: better-auth@1.5.6(@opentelemetry/api@1.9.0)(mongodb@7.1.0)(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vue@3.5.30(typescript@5.9.3)): dependencies: '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1) - '@better-auth/drizzle-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1) - '@better-auth/kysely-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.14) - '@better-auth/memory-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1) - '@better-auth/mongo-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0) - '@better-auth/prisma-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1) - '@better-auth/telemetry': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1)) + '@better-auth/drizzle-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1) + '@better-auth/kysely-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.14) + '@better-auth/memory-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1) + '@better-auth/mongo-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0) + '@better-auth/prisma-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1) + '@better-auth/telemetry': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.1.1)) '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 @@ -20334,12 +20550,19 @@ snapshots: birpc@4.0.0: {} + bl@1.2.3: + dependencies: + readable-stream: 2.3.8 + safe-buffer: 5.2.1 + bl@4.1.0: dependencies: buffer: 5.7.1 inherits: 2.0.4 readable-stream: 3.6.2 + bluebird@3.7.2: {} + body-parser@1.20.4: dependencies: bytes: 3.1.2 @@ -20361,7 +20584,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -20524,6 +20747,8 @@ snapshots: case@1.6.3: {} + caseless@0.12.0: {} + ccount@2.0.1: {} chalk@2.4.2: @@ -20848,6 +21073,8 @@ snapshots: dependencies: browserslist: 4.28.1 + core-util-is@1.0.2: {} + core-util-is@1.0.3: {} cors@2.8.6: @@ -21064,6 +21291,10 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) + dashdash@1.14.1: + dependencies: + assert-plus: 1.0.0 + data-urls@3.0.2: dependencies: abab: 2.0.6 @@ -21274,6 +21505,11 @@ snapshots: eastasianwidth@0.2.0: {} + ecc-jsbn@0.1.2: + dependencies: + jsbn: 0.1.1 + safer-buffer: 2.1.2 + ee-first@1.1.1: {} ejs@3.1.10: @@ -21300,10 +21536,14 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + engine.io-client@6.6.4: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io-parser: 5.2.3 ws: 8.18.3 xmlhttprequest-ssl: 2.1.2 @@ -21589,9 +21829,9 @@ snapshots: '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@0.5.3(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@0.5.3(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-expo: 1.0.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@0.5.3(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4(jiti@2.6.1)) globals: 16.5.0 @@ -21619,10 +21859,10 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@0.5.3(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@0.5.3(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 9.39.4(jiti@2.6.1) get-tsconfig: 4.13.6 is-bun-module: 2.0.0 @@ -21630,19 +21870,19 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@0.5.3(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-import-x: 0.5.3(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@0.5.3(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@0.5.3(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@0.5.3(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -21665,7 +21905,7 @@ snapshots: eslint-plugin-import-x@0.5.3(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/utils': 7.18.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) doctrine: 3.0.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 @@ -21679,7 +21919,7 @@ snapshots: - supports-color - typescript - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@0.5.3(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -21690,7 +21930,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@0.5.3(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -21713,7 +21953,7 @@ snapshots: '@es-joy/jsdoccomment': 0.46.0 are-docs-informative: 0.0.2 comment-parser: 1.4.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 eslint: 9.39.4(jiti@2.6.1) espree: 10.4.0 @@ -21875,7 +22115,7 @@ snapshots: '@types/estree': 1.0.8 ajv: 6.14.0 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 @@ -21915,7 +22155,7 @@ snapshots: ajv: 6.14.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -21986,6 +22226,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter3@3.1.2: {} + eventemitter3@5.0.4: {} events-universal@1.0.1: @@ -22219,7 +22461,7 @@ snapshots: expo-system-ui@6.0.9(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)): dependencies: '@react-native/normalize-colors': 0.81.5 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) expo: 54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) transitivePeerDependencies: @@ -22324,7 +22566,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -22360,6 +22602,8 @@ snapshots: pathe: 1.1.2 ufo: 1.6.3 + extsprintf@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-fifo@1.3.2: {} @@ -22412,6 +22656,8 @@ snapshots: dependencies: flat-cache: 4.0.1 + file-type@3.9.0: {} + file-uri-to-path@1.0.0: {} filelist@1.0.6: @@ -22450,7 +22696,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -22499,6 +22745,14 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + forever-agent@0.6.1: {} + + form-data@2.3.3: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -22610,6 +22864,10 @@ snapshots: getenv@2.0.0: {} + getpass@0.1.7: + dependencies: + assert-plus: 1.0.0 + giget@2.0.0: dependencies: citty: 0.1.6 @@ -22774,6 +23032,13 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 + har-schema@2.0.0: {} + + har-validator@5.1.5: + dependencies: + ajv: 6.14.0 + har-schema: 2.0.0 + harmony-reflect@1.6.2: {} has-bigints@1.1.0: {} @@ -22954,30 +23219,42 @@ snapshots: dependencies: '@tootallnate/once': 2.0.0 agent-base: 6.0.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color http-shutdown@1.2.2: {} + http-signature@1.2.0: + dependencies: + assert-plus: 1.0.0 + jsprim: 1.4.2 + sshpk: 1.18.0 + + http-signature@1.4.0: + dependencies: + assert-plus: 1.0.0 + jsprim: 2.0.2 + sshpk: 1.18.0 + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -23112,7 +23389,7 @@ snapshots: dependencies: '@ioredis/commands': 1.5.1 cluster-key-slot: 1.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) denque: 2.1.0 lodash.defaults: 4.2.0 lodash.isarguments: 3.1.0 @@ -23325,6 +23602,8 @@ snapshots: dependencies: which-typed-array: 1.1.20 + is-typedarray@1.0.0: {} + is-unicode-supported@0.1.0: {} is-weakmap@2.0.2: {} @@ -23360,6 +23639,8 @@ snapshots: isexe@4.0.0: {} + isstream@0.1.2: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-instrument@5.2.1: @@ -23390,7 +23671,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -23399,7 +23680,7 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: '@jridgewell/trace-mapping': 0.3.31 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -24192,6 +24473,8 @@ snapshots: dependencies: argparse: 2.0.1 + jsbn@0.1.1: {} + jsc-safe-url@0.2.4: {} jsdoc-type-pratt-parser@4.0.0: {} @@ -24274,6 +24557,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-safe@5.0.1: {} + json5@1.0.2: dependencies: minimist: 1.2.8 @@ -24290,6 +24575,20 @@ snapshots: jsonpointer@5.0.1: {} + jsprim@1.4.2: + dependencies: + assert-plus: 1.0.0 + extsprintf: 1.3.0 + json-schema: 0.4.0 + verror: 1.10.0 + + jsprim@2.0.2: + dependencies: + assert-plus: 1.0.0 + extsprintf: 1.3.0 + json-schema: 0.4.0 + verror: 1.10.0 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -24949,7 +25248,7 @@ snapshots: metro-file-map@0.83.3: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) fb-watchman: 2.0.2 flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 @@ -24963,7 +25262,7 @@ snapshots: metro-file-map@0.83.5: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) fb-watchman: 2.0.2 flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 @@ -25129,7 +25428,7 @@ snapshots: chalk: 4.1.2 ci-info: 2.0.0 connect: 3.7.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) error-stack-parser: 2.1.4 flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 @@ -25176,7 +25475,7 @@ snapshots: chalk: 4.1.2 ci-info: 2.0.0 connect: 3.7.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) error-stack-parser: 2.1.4 flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 @@ -25382,7 +25681,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.13 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -25736,6 +26035,21 @@ snapshots: dependencies: asn1: 0.2.6 + node-telegram-bot-api@0.67.0(request@2.88.2): + dependencies: + '@cypress/request': 3.0.10 + '@cypress/request-promise': 5.0.0(@cypress/request@3.0.10)(request@2.88.2) + array.prototype.findindex: 2.2.4 + bl: 1.2.3 + debug: 3.2.7 + eventemitter3: 3.1.2 + file-type: 3.9.0 + mime: 1.6.0 + pump: 2.0.1 + transitivePeerDependencies: + - request + - supports-color + nodemailer@8.0.2: {} nodemon@3.1.14: @@ -25956,6 +26270,8 @@ snapshots: pathe: 2.0.3 tinyexec: 1.0.4 + oauth-sign@0.9.0: {} + ob1@0.83.3: dependencies: flow-enums-runtime: 0.0.6 @@ -26346,6 +26662,8 @@ snapshots: perfect-debounce@2.1.0: {} + performance-now@2.1.0: {} + pg-cloudflare@1.3.0: optional: true @@ -26739,6 +27057,11 @@ snapshots: picocolors: 1.1.1 sade: 1.8.1 + pump@2.0.1: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} pure-rand@6.1.0: {} @@ -26759,6 +27082,8 @@ snapshots: dependencies: side-channel: 1.0.6 + qs@6.5.5: {} + quansync@0.2.11: {} quansync@1.0.0: {} @@ -27371,6 +27696,34 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + request-promise-core@1.1.3(request@2.88.2): + dependencies: + lodash: 4.17.23 + request: 2.88.2 + + request@2.88.2: + dependencies: + aws-sign2: 0.7.0 + aws4: 1.13.2 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 2.3.3 + har-validator: 5.1.5 + http-signature: 1.2.0 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + oauth-sign: 0.9.0 + performance-now: 2.1.0 + qs: 6.5.5 + safe-buffer: 5.2.1 + tough-cookie: 2.5.0 + tunnel-agent: 0.6.0 + uuid: 3.4.0 + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -27525,7 +27878,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -27642,7 +27995,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -27792,7 +28145,7 @@ snapshots: dependencies: '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -27852,7 +28205,7 @@ snapshots: socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io-client: 6.6.4 socket.io-parser: 4.2.6 transitivePeerDependencies: @@ -27863,7 +28216,7 @@ snapshots: socket.io-parser@4.2.6: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -27932,6 +28285,18 @@ snapshots: srvx@0.11.12: {} + sshpk@1.18.0: + dependencies: + asn1: 0.2.6 + assert-plus: 1.0.0 + bcrypt-pbkdf: 1.0.2 + dashdash: 1.14.1 + ecc-jsbn: 0.1.2 + getpass: 0.1.7 + jsbn: 0.1.1 + safer-buffer: 2.1.2 + tweetnacl: 0.14.5 + stable-hash@0.0.4: {} stable-hash@0.0.5: {} @@ -27971,6 +28336,8 @@ snapshots: std-env@4.0.0: {} + stealthy-require@1.1.1: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -28173,7 +28540,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) fast-safe-stringify: 2.1.1 form-data: 4.0.5 formidable: 3.5.4 @@ -28345,7 +28712,7 @@ snapshots: threads@1.7.0: dependencies: callsites: 3.1.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) is-observable: 2.1.0 observable-fns: 0.6.1 optionalDependencies: @@ -28393,6 +28760,11 @@ snapshots: touch@3.1.1: {} + tough-cookie@2.5.0: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + tough-cookie@4.1.4: dependencies: psl: 1.15.0 @@ -28509,6 +28881,12 @@ snapshots: dependencies: tslib: 1.14.1 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + tweetnacl@0.14.5: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -28984,6 +29362,8 @@ snapshots: uuid@13.0.0: {} + uuid@3.4.0: {} + uuid@7.0.3: {} uuid@8.3.2: {} @@ -29009,6 +29389,12 @@ snapshots: vary@1.1.2: {} + verror@1.10.0: + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.3.0 + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 @@ -29105,7 +29491,7 @@ snapshots: vite-plugin-inspect@11.3.3(@nuxt/kit@4.4.2(magicast@0.5.2))(vite@8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: ansis: 4.2.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) error-stack-parser-es: 1.0.5 ohash: 2.0.11 open: 10.2.0 @@ -29121,7 +29507,7 @@ snapshots: vite-plugin-pwa@1.2.0(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0): dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) pretty-bytes: 6.1.1 tinyglobby: 0.2.15 vite: 8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) @@ -29220,7 +29606,7 @@ snapshots: vue-eslint-parser@9.4.3(eslint@9.39.4(jiti@2.6.1)): dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 9.39.4(jiti@2.6.1) eslint-scope: 7.2.2 eslint-visitor-keys: 3.4.3 diff --git a/shared/src/index.ts b/shared/src/index.ts index 45c9ecdde..51dac25c1 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1,79 +1,80 @@ -export * from "./schemas/api/AiServiceSettings.api.zod.ts"; -export * from "./schemas/api/CustomCategories.api.zod.ts"; -export * from "./schemas/api/CustomMeasurements.api.zod.ts"; -export * from "./schemas/api/CheckInMeasurements.api.zod.ts"; -export * from "./schemas/api/DailyGoals.api.zod.ts"; -export * from "./schemas/api/DailySummary.api.zod.ts"; -export * from "./schemas/api/ExerciseEntries.api.zod.ts"; -export * from "./schemas/api/FoodEntries.api.zod.ts"; -export * from "./schemas/api/Pagination.api.zod.ts"; -export * from "./schemas/api/SleepScience.api.zod.ts"; -export * from "./schemas/database/Account.zod.ts"; -export * from "./schemas/database/AdminActivityLogs.zod.ts"; -export * from "./schemas/database/AiServiceSettings.zod.ts"; -export * from "./schemas/database/ApiKey.zod.ts"; -export * from "./schemas/database/BackupSettings.zod.ts"; -export * from "./schemas/database/CheckInMeasurements.zod.ts"; -export * from "./schemas/database/CustomCategories.zod.ts"; -export * from "./schemas/database/CustomMeasurements.zod.ts"; -export * from "./schemas/database/DailySleepNeed.zod.ts"; -export * from "./schemas/database/DayClassificationCache.zod.ts"; -export * from "./schemas/database/ExerciseEntries.zod.ts"; -export * from "./schemas/database/ExerciseEntryActivityDetails.zod.ts"; -export * from "./schemas/database/ExerciseEntrySets.zod.ts"; -export * from "./schemas/database/ExercisePresetEntries.zod.ts"; -export * from "./schemas/database/Exercises.zod.ts"; -export * from "./schemas/database/ExternalDataProviders.zod.ts"; -export * from "./schemas/database/ExternalProviderTypes.zod.ts"; -export * from "./schemas/database/FamilyAccess.zod.ts"; -export * from "./schemas/database/FastingLogs.zod.ts"; -export * from "./schemas/database/FoodEntries.zod.ts"; -export * from "./schemas/database/FoodEntryMeals.zod.ts"; -export * from "./schemas/database/Foods.zod.ts"; -export * from "./schemas/database/FoodVariants.zod.ts"; -export * from "./schemas/database/GlobalSettings.zod.ts"; -export * from "./schemas/database/GoalPresets.zod.ts"; -export * from "./schemas/database/MealFoods.zod.ts"; -export * from "./schemas/database/MealPlans.zod.ts"; -export * from "./schemas/database/MealPlanTemplateAssignments.zod.ts"; -export * from "./schemas/database/MealPlanTemplates.zod.ts"; -export * from "./schemas/database/Meals.zod.ts"; -export * from "./schemas/database/MealTypes.zod.ts"; -export * from "./schemas/database/MoodEntries.zod.ts"; -export * from "./schemas/database/OidcProviders.zod.ts"; -export * from "./schemas/database/OnboardingData.zod.ts"; -export * from "./schemas/database/OnboardingStatus.zod.ts"; -export * from "./schemas/database/Passkey.zod.ts"; -export * from "./schemas/database/Profiles.zod.ts"; -export * from "./schemas/database/Session.zod.ts"; -export * from "./schemas/database/SleepEntries.zod.ts"; -export * from "./schemas/database/SleepEntryStages.zod.ts"; -export * from "./schemas/database/SleepNeedCalculations.zod.ts"; -export * from "./schemas/database/SparkyChatHistory.zod.ts"; -export * from "./schemas/database/SsoProvider.zod.ts"; -export * from "./schemas/database/TwoFactor.zod.ts"; -export * from "./schemas/database/UserCustomNutrients.zod.ts"; -export * from "./schemas/database/UserGoals.zod.ts"; -export * from "./schemas/database/UserIgnoredUpdates.zod.ts"; -export * from "./schemas/database/UserMealVisibilities.zod.ts"; -export * from "./schemas/database/UserNutrientDisplayPreferences.zod.ts"; -export * from "./schemas/database/UserOidcLinks.zod.ts"; -export * from "./schemas/database/UserPreferences.zod.ts"; -export * from "./schemas/database/Users.zod.ts"; -export * from "./schemas/database/UserWaterContainers.zod.ts"; -export * from "./schemas/database/User.zod.ts"; -export * from "./schemas/database/Verification.zod.ts"; -export * from "./schemas/database/VMctqAnalysis.zod.ts"; -export * from "./schemas/database/VMctqStats.zod.ts"; -export * from "./schemas/database/WaterIntake.zod.ts"; -export * from "./schemas/database/WeeklyGoalPlans.zod.ts"; -export * from "./schemas/database/WorkoutPlanAssignmentSets.zod.ts"; -export * from "./schemas/database/WorkoutPlanTemplateAssignments.zod.ts"; -export * from "./schemas/database/WorkoutPlanTemplates.zod.ts"; -export * from "./schemas/database/WorkoutPresetExerciseSets.zod.ts"; -export * from "./schemas/database/WorkoutPresetExercises.zod.ts"; -export * from "./schemas/database/WorkoutPresets.zod.ts"; -export * from "./constants/measurements.ts"; -export * from "./constants/calorieConstants.ts"; -export * from "./utils/timezone.ts"; -export * from "./utils/calorieCalculations.ts"; +export * from "./schemas/api/AiServiceSettings.api.zod"; +export * from "./schemas/api/CustomCategories.api.zod"; +export * from "./schemas/api/CustomMeasurements.api.zod"; +export * from "./schemas/api/CheckInMeasurements.api.zod"; +export * from "./schemas/api/DailyGoals.api.zod"; +export * from "./schemas/api/DailySummary.api.zod"; +export * from "./schemas/api/ExerciseEntries.api.zod"; +export * from "./schemas/api/FoodEntries.api.zod"; +export * from "./schemas/api/Pagination.api.zod"; +export * from "./schemas/api/SleepScience.api.zod"; +export * from "./schemas/api/Telegram.api.zod"; +export * from "./schemas/database/Account.zod"; +export * from "./schemas/database/AdminActivityLogs.zod"; +export * from "./schemas/database/AiServiceSettings.zod"; +export * from "./schemas/database/ApiKey.zod"; +export * from "./schemas/database/BackupSettings.zod"; +export * from "./schemas/database/CheckInMeasurements.zod"; +export * from "./schemas/database/CustomCategories.zod"; +export * from "./schemas/database/CustomMeasurements.zod"; +export * from "./schemas/database/DailySleepNeed.zod"; +export * from "./schemas/database/DayClassificationCache.zod"; +export * from "./schemas/database/ExerciseEntries.zod"; +export * from "./schemas/database/ExerciseEntryActivityDetails.zod"; +export * from "./schemas/database/ExerciseEntrySets.zod"; +export * from "./schemas/database/ExercisePresetEntries.zod"; +export * from "./schemas/database/Exercises.zod"; +export * from "./schemas/database/ExternalDataProviders.zod"; +export * from "./schemas/database/ExternalProviderTypes.zod"; +export * from "./schemas/database/FamilyAccess.zod"; +export * from "./schemas/database/FastingLogs.zod"; +export * from "./schemas/database/FoodEntries.zod"; +export * from "./schemas/database/FoodEntryMeals.zod"; +export * from "./schemas/database/Foods.zod"; +export * from "./schemas/database/FoodVariants.zod"; +export * from "./schemas/database/GlobalSettings.zod"; +export * from "./schemas/database/GoalPresets.zod"; +export * from "./schemas/database/MealFoods.zod"; +export * from "./schemas/database/MealPlans.zod"; +export * from "./schemas/database/MealPlanTemplateAssignments.zod"; +export * from "./schemas/database/MealPlanTemplates.zod"; +export * from "./schemas/database/Meals.zod"; +export * from "./schemas/database/MealTypes.zod"; +export * from "./schemas/database/MoodEntries.zod"; +export * from "./schemas/database/OidcProviders.zod"; +export * from "./schemas/database/OnboardingData.zod"; +export * from "./schemas/database/OnboardingStatus.zod"; +export * from "./schemas/database/Passkey.zod"; +export * from "./schemas/database/Profiles.zod"; +export * from "./schemas/database/Session.zod"; +export * from "./schemas/database/SleepEntries.zod"; +export * from "./schemas/database/SleepEntryStages.zod"; +export * from "./schemas/database/SleepNeedCalculations.zod"; +export * from "./schemas/database/SparkyChatHistory.zod"; +export * from "./schemas/database/SsoProvider.zod"; +export * from "./schemas/database/TwoFactor.zod"; +export * from "./schemas/database/UserCustomNutrients.zod"; +export * from "./schemas/database/UserGoals.zod"; +export * from "./schemas/database/UserIgnoredUpdates.zod"; +export * from "./schemas/database/UserMealVisibilities.zod"; +export * from "./schemas/database/UserNutrientDisplayPreferences.zod"; +export * from "./schemas/database/UserOidcLinks.zod"; +export * from "./schemas/database/UserPreferences.zod"; +export * from "./schemas/database/Users.zod"; +export * from "./schemas/database/UserWaterContainers.zod"; +export * from "./schemas/database/User.zod"; +export * from "./schemas/database/Verification.zod"; +export * from "./schemas/database/VMctqAnalysis.zod"; +export * from "./schemas/database/VMctqStats.zod"; +export * from "./schemas/database/WaterIntake.zod"; +export * from "./schemas/database/WeeklyGoalPlans.zod"; +export * from "./schemas/database/WorkoutPlanAssignmentSets.zod"; +export * from "./schemas/database/WorkoutPlanTemplateAssignments.zod"; +export * from "./schemas/database/WorkoutPlanTemplates.zod"; +export * from "./schemas/database/WorkoutPresetExerciseSets.zod"; +export * from "./schemas/database/WorkoutPresetExercises.zod"; +export * from "./schemas/database/WorkoutPresets.zod"; +export * from "./constants/measurements"; +export * from "./constants/calorieConstants"; +export * from "./utils/timezone"; +export * from "./utils/calorieCalculations"; diff --git a/shared/src/schemas/api/Telegram.api.zod.ts b/shared/src/schemas/api/Telegram.api.zod.ts new file mode 100644 index 000000000..79427f1ae --- /dev/null +++ b/shared/src/schemas/api/Telegram.api.zod.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; + +export const TelegramWebhookSchema = z.object({ + update_id: z.number(), + message: z.object({ + message_id: z.number(), + from: z.object({ + id: z.number(), + is_bot: z.boolean(), + first_name: z.string(), + last_name: z.string().optional(), + username: z.string().optional(), + language_code: z.string().optional(), + }).optional(), + chat: z.object({ + id: z.number(), + first_name: z.string().optional(), + last_name: z.string().optional(), + username: z.string().optional(), + type: z.string(), + }), + date: z.number(), + text: z.string().optional(), + entities: z.array(z.object({ + offset: z.number(), + length: z.number(), + type: z.string(), + })).optional(), + photo: z.array(z.object({ + file_id: z.string(), + file_unique_id: z.string(), + width: z.number(), + height: z.number(), + file_size: z.number().optional(), + })).optional(), + voice: z.object({ + file_id: z.string(), + file_unique_id: z.string(), + duration: z.number(), + mime_type: z.string().optional(), + file_size: z.number().optional(), + }).optional(), + }).optional(), + callback_query: z.object({ + id: z.string(), + from: z.object({ + id: z.number(), + is_bot: z.boolean(), + first_name: z.string(), + }), + message: z.object({ + message_id: z.number(), + chat: z.object({ + id: z.number(), + }), + text: z.string().optional(), + }).optional(), + data: z.string(), + }).optional(), +}); + +export const telegramStatusResponseSchema = z.object({ + isLinked: z.boolean(), + chatId: z.string().nullable(), +}); + +export const telegramLinkCodeResponseSchema = z.object({ + code: z.string(), +});