Skip to content

feat: add MyFitnessPal integration and external provider settings UI#1088

Open
serjsv87 wants to merge 2 commits intoCodeWithCJ:mainfrom
serjsv87:feature/fitnesspal
Open

feat: add MyFitnessPal integration and external provider settings UI#1088
serjsv87 wants to merge 2 commits intoCodeWithCJ:mainfrom
serjsv87:feature/fitnesspal

Conversation

@serjsv87
Copy link
Copy Markdown

@serjsv87 serjsv87 commented Apr 6, 2026

Comprehensive UI for managing external data providers and MyFitnessPal sync.

New Settings page: ExternalProviderSettings.tsx.
Form components for adding/editing providers (MFP, Edamam, FatSecret, Garmin).
Implemented mfpSyncService for data synchronization.
Centralized external provider service and hooks.

✅ Checklist

  • Alignment: Alignment with the goal of improving nutrition data synchronization via MFP.
  • Tests: I have verified the sync and deletion logic in the development environment.
  • Screenshots: [x] Included - strong warning added to the provider setup page.
  • Frontend changes: Quality: pnpm run typecheck and pnpm run validate pass.
  • Translations: UI strings for the MFP warning and provider description updated.
  • Architecture: Follows existing pattern for external integrations and background syncing.
  • Database Security: No new tables; uses existing external_data_providers schema.
  • Integrity & License: I certify this is my own work, free of malicious code, and I agree to the License terms.
  • Code Quality: Backend logic moved to TypeScript; uses Zod for synchronization data validation.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a MyFitnessPal integration, allowing users to sync nutrition data by providing session cookies and CSRF tokens. It includes new frontend components for credential management, backend services for data synchronization with idempotency logic, and a manual sync trigger. Additionally, the PR adds Ukrainian language support, a new custom logging utility, and a Telegram bot integration with a webhook handler. Feedback focuses on critical security and reliability concerns: the Telegram webhook lacks authentication, sensitive credential inputs should be masked as passwords, and the MFP sync logic needs better error resilience, cookie persistence, and distributed locking. There are also concerns regarding potential data loss in the MFP deletion logic and request timeouts during multi-day syncs.

Comment on lines +404 to +407
app.post('/api/telegram/webhook', (req, res) => {
telegramBotService.handleUpdate(req.body);
res.sendStatus(200);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The Telegram webhook endpoint is publicly accessible and lacks any verification. An attacker could send forged updates to this endpoint to manipulate the bot's state or trigger unauthorized actions. You should implement a secret token check (e.g., via the X-Telegram-Bot-Api-Secret-Token header) as recommended by the Telegram Bot API documentation.

Comment on lines +130 to +151
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)}`);
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This idempotency logic is potentially destructive. It deletes all items returned by the discovery API for the target date without verifying if they were originally created by SparkyFitness. This could lead to data loss if the user has manually entered items or used other integrations on the same day. It is safer to filter items by a specific name or metadata before deletion.

<>
<div>
<Label>MFP CSRF Token (x-csrf-token)</Label>
<Input
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The input field for the MyFitnessPal CSRF token is using type="text", which leaves sensitive session-level credentials visible on the screen. It is recommended to use type="password" to prevent shoulder surfing and improve security.

Suggested change
<Input
type="password"

</div>
<div>
<Label>MFP Session Cookies</Label>
<Input
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The input field for MyFitnessPal session cookies is using type="text". Since these cookies grant full access to the user's account, they should be treated as sensitive credentials and masked using type="password".

Suggested change
<Input
type="password"

<Label htmlFor="add-mfp-csrf">MFP CSRF Token (x-csrf-token)</Label>
<Input
id="add-mfp-csrf"
type="text"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

Sensitive credentials like the MFP CSRF token should not be displayed in plain text. Please change the input type to password.

Suggested change
type="text"
type="password"

Comment on lines +17 to +34
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;
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The truncateStrings function is recursive and does not handle circular references. If an object with circular dependencies is passed to the logger, it will result in a stack overflow. Consider using a library like json-stringify-safe or implementing a cycle-detection mechanism.

Comment on lines +66 to +70
const setCookie = csrfResp.headers['set-cookie'];
if (setCookie) {
const newCookies = setCookie.map(c => c.split(';')[0]).join('; ');
currentCookies = `${currentCookies}; ${newCookies}`;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The function captures new cookies from the set-cookie header but does not persist them back to the database. If MyFitnessPal rotates session cookies or updates them, subsequent sync attempts will continue using the old, potentially invalid cookies stored in the app_key field. You should update the provider record in the database whenever new cookies are received.

Comment on lines +166 to +197
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);
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The category push loop is wrapped in a single try-catch block. If one category (e.g., Breakfast) fails to sync, the entire function throws, and all subsequent categories (Lunch, Dinner, etc.) are skipped. Wrapping each individual push in its own try-catch would make the sync process more resilient.

Comment on lines +65 to +67
for (const date of dates) {
await syncDailyTotals(userId, date);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Processing up to 31 days of sync sequentially within a single HTTP request is risky. Each day involves multiple external network calls (auth, discovery, deletions, pushes). This could easily exceed the timeout limits of reverse proxies or gateways. Consider offloading this to a background worker or processing days in smaller batches.

import { pushNutritionToMFP } from '../integrations/myfitnesspal/myFitnessPalService';

// In-memory lock to prevent multiple concurrent syncs for the same user and date.
const activeSyncs = new Set<string>();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using an in-memory Set for locking is not resilient to server restarts and will not work in a distributed environment with multiple server instances. If the application scales, concurrent syncs for the same user/date could still occur. A database-backed lock or a distributed lock manager (like Redis) would be more appropriate for production environments.

@CodeWithCJ
Copy link
Copy Markdown
Owner

Could you explain more on MFP? Is it only write back to MFP or importing into SparkyFitness as well?

We never did any write-back so far not because we couldn't but simply we want to be caution if anything goes wrong.
Could you add a strong warning in the provider setting page for MFP to clearly indicate the risk.

How does the deletion in MFP performed only for the SparkyFitness written data and have you make sure it doesn't delete any other sources in MFP?

How are the custom meal types (say late night snacks, morning snack that are different than breakfast, lunch etc.) are handled?

@serjsv87
Copy link
Copy Markdown
Author

serjsv87 commented Apr 6, 2026

Mfp - MyFitnessPal
From SparkyFitness -> MyFitnessPal (quick add)-> garmin
IMG_4133
IMG_4134

@serjsv87
Copy link
Copy Markdown
Author

serjsv87 commented Apr 6, 2026

Could you explain more on MFP? Is it only write back to MFP or importing into SparkyFitness as well?

We never did any write-back so far not because we couldn't but simply we want to be caution if anything goes wrong. Could you add a strong warning in the provider setting page for MFP to clearly indicate the risk.

How does the deletion in MFP performed only for the SparkyFitness written data and have you make sure it doesn't delete any other sources in MFP?

How are the custom meal types (say late night snacks, morning snack that are different than breakfast, lunch etc.) are handled?

Hi @CodeWithCJ! To answer your questions about the MFP integration:

  1. Write-back vs Import: This is a write-back (push) to MyFitnessPal. It syncs the daily nutrition totals (calories, macros) from SparkyFitness to the user's MFP diary.
  2. Deletion Logic: The sync uses an 'idempotency' step where it discovers and deletes existing entries for that specific day before pushing the new totals. This ensures we don't double-count if the user syncs multiple times. I have added a strong warning in the UI (AddExternalProviderForm) to make users aware that other manual entries for that day in MFP will be removed.
  3. Custom Meal Types: We use a standard mapping (Breakfast, Lunch, Dinner, Snacks). Any custom meal names in SparkyFitness that don't match these categories will default to 'Snacks' in MFP to ensure the data is recorded.

I'll attach a screenshot of the new UI warning shortly!

@serjsv87 serjsv87 force-pushed the feature/fitnesspal branch from 365b16b to fdf39ba Compare April 6, 2026 20:15
@serjsv87 serjsv87 force-pushed the feature/fitnesspal branch from fdf39ba to 9980d65 Compare April 6, 2026 20:39
@serjsv87
Copy link
Copy Markdown
Author

serjsv87 commented Apr 6, 2026

image I've added a strong warning in the UI about the data overwrite risk. To clarify: the sync works as a write-back (push) to MFP. It deletes existing entries for the day to ensure idempotency. Custom meal types are mapped to standard MFP categories (Breakfast, Lunch, Dinner, Snacks)

@CodeWithCJ
Copy link
Copy Markdown
Owner

CI test failure is still there

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants