Skip to content

refactor: migrate Garmin integration services to TypeScript#1089

Closed
serjsv87 wants to merge 6 commits into
CodeWithCJ:mainfrom
serjsv87:refactor/garmin-typescript
Closed

refactor: migrate Garmin integration services to TypeScript#1089
serjsv87 wants to merge 6 commits into
CodeWithCJ:mainfrom
serjsv87:refactor/garmin-typescript

Conversation

@serjsv87

@serjsv87 serjsv87 commented Apr 6, 2026

Copy link
Copy Markdown

This PR migrates the Garmin integration services and related models to TypeScript to improve type safety, maintainability, and runtime robustness. It also fixes several issues found during code review, including logging safety, database client reuse, and performance problems with N+1 queries. Telegram-related pieces introduced earlier were cleaned up or adjusted according to project expectations.

Key changes:

  • Migrated garminService.js and related Garmin integration logic to TypeScript.
  • Migrated garminRoutes.js and aligned route handlers with the updated service layer.
  • Updated exerciseEntry model and repository to use proper TypeScript types and improved upsert logic.
  • Fixed logging utilities to safely truncate large payloads and handle circular references without crashing the server.
  • Improved database usage by reusing clients inside upsertExerciseEntryData instead of acquiring multiple clients per call.
  • Reduced N+1 query patterns by batching retrieval of activity details where possible.
  • Hardened Garmin date handling using moment().isValid() with a safe fallback and warnings for invalid dates.
  • Addressed Telegram webhook handling by awaiting async processing and adding error logging, then cleaned up or reverted parts as requested by maintainers.
  • Restored explicit .ts extensions in shared exports where required by the current tsx-based runtime configuration.

Linked Issue: #1089

✅ Checklist

  • Alignment: Technical debt reduction by migrating Garmin services and related backend pieces to TypeScript.
  • Tests: Existing Garmin sync and server tests pass locally with the new TypeScript implementation and refactors.
  • Frontend changes: N/A – backend refactor and infrastructure fixes only.
  • Integrity & License: I certify this is my own work and complies with the project’s license.
  • Code Quality: Source files have been migrated to TypeScript with stricter types, logging is safer, and known performance issues (N+1 queries, redundant DB clients, unsafe date parsing) have been addressed.

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

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.

Code Review

This pull request introduces Telegram bot integration, including a webhook handler and initialization logic, and migrates several core modules like the Garmin Connect service and exercise entry repository to TypeScript. It also implements a custom logging utility with level-based filtering and object sanitization. Key feedback includes a critical missing function definition for the new Garmin nutrition sync endpoint, potential unhandled promise rejections in the Telegram webhook, and a risk of stack overflow in the logger due to circular references. Additionally, there are opportunities to improve performance by resolving an N+1 query pattern and reducing redundant database client acquisitions.

.json({ error: 'Valid date (YYYY-MM-DD) is required.' });
}

const result = await garminService.syncDailyNutritionToGarmin(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.

high

The function garminService.syncDailyNutritionToGarmin is called here, but it is not defined or exported in SparkyFitnessServer/services/garminService.js. This will result in a runtime error (TypeError: garminService.syncDailyNutritionToGarmin is not a function) when this endpoint is hit.

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.

medium

The Telegram webhook handler calls telegramBotService.handleUpdate(req.body) without awaiting it or providing a catch block. If handleUpdate is an asynchronous function, any errors occurring during processing will result in unhandled promise rejections. It is safer to wrap this in a try-catch and ensure it is properly awaited.

app.post('/api/telegram/webhook', async (req, res) => {
  try {
    await telegramBotService.handleUpdate(req.body);
  } catch (err) {
    log('error', '[TELEGRAM BOT] Webhook handling failed:', err);
  }
  res.sendStatus(200);
});

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. Passing an object with a circular reference (e.g., a request object or a complex model) will result in a stack overflow (Maximum call stack size exceeded). Since this is used in the global logger, it could crash the entire server when logging certain objects.

const truncateStrings = (obj: any, maxLength: number = 1000, visited = new WeakSet()): any => {
  if (typeof obj === 'string') {
    return obj.length > maxLength ? obj.substring(0, maxLength) + '... [truncated]' : obj;
  }
  if (Array.isArray(obj)) {
    if (visited.has(obj)) return '[Circular]';
    visited.add(obj);
    return obj.map(item => truncateStrings(item, maxLength, visited));
  }
  if (typeof obj === 'object' && obj !== null) {
    if (visited.has(obj)) return '[Circular]';
    visited.add(obj);
    const newObj: any = {};
    for (const key in obj) {
      if (Object.prototype.hasOwnProperty.call(obj, key)) {
        newObj[key] = truncateStrings(obj[key], maxLength, visited);
      }
    }
    return newObj;
  }
  return obj;
};

Comment on lines +53 to +135
export async function upsertExerciseEntryData(
userId: string,
createdByUserId: string,
exerciseId: string,
caloriesBurned: number,
date: string | Date
): Promise<any> {
log('info', 'upsertExerciseEntryData received date parameter:', date);
const client = await getClient(userId);
let existingEntry: any = null;
let exerciseName = 'Unknown Exercise';

try {
const exercise = await exerciseRepository.getExerciseById(exerciseId, userId);
if (exercise) {
exerciseName = exercise.name;
log('info', `Fetched exercise name: ${exerciseName} for exerciseId: ${exerciseId}`);
} else {
log('warn', `Exercise with ID ${exerciseId} not found for user ${userId}. Using default name.`);
}

const result = await client.query(
'SELECT id, calories_burned FROM exercise_entries WHERE user_id = $1 AND exercise_id = $2 AND entry_date = $3',
[userId, exerciseId, date]
);
existingEntry = result.rows[0];
} 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}`);
} finally {
client.release();
}

let finalResult: any;
if (existingEntry) {
log('info', `Existing active calories entry found for ${date}, updating calories from ${existingEntry.calories_burned} to ${caloriesBurned}.`);
const updateClient = await getClient(userId);
try {
const updateResult = await updateClient.query(
'UPDATE exercise_entries SET calories_burned = $1, notes = $2, updated_by_user_id = $3, exercise_name = $4 WHERE id = $5 RETURNING *',
[
caloriesBurned,
'Active calories logged from Apple Health (updated).',
createdByUserId,
exerciseName,
existingEntry.id,
]
);
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}`);
} finally {
updateClient.release();
}
} else {
log('info', `No existing active calories entry found for ${date}, inserting new entry.`);
const insertClient = await getClient(userId);
try {
const insertResult = await insertClient.query(
`INSERT INTO exercise_entries (user_id, exercise_id, entry_date, calories_burned, duration_minutes, notes, created_by_user_id, exercise_name)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
[
userId,
exerciseId,
date,
caloriesBurned,
0,
'Active calories logged from Apple Health.',
createdByUserId,
exerciseName,
]
);
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}`);
} finally {
insertClient.release();
}
}
return finalResult;
}

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 upsertExerciseEntryData function acquires and releases database clients multiple times redundantly. It acquires a client at the start, releases it, and then acquires another one (updateClient or insertClient) for the actual operation. This adds unnecessary overhead. You should reuse the initial client for all queries within the function.

export async function upsertExerciseEntryData(
  userId: string,
  createdByUserId: string,
  exerciseId: string,
  caloriesBurned: number,
  date: string | Date
): Promise<any> {
  log('info', 'upsertExerciseEntryData received date parameter:', date);
  const client = await getClient(userId);
  try {
    let exerciseName = 'Unknown Exercise';
    const exercise = await exerciseRepository.getExerciseById(exerciseId, userId);
    if (exercise) {
      exerciseName = exercise.name;
      log('info', `Fetched exercise name: ${exerciseName} for exerciseId: ${exerciseId}`);
    } else {
      log('warn', `Exercise with ID ${exerciseId} not found for user ${userId}. Using default name.`);
    }

    const result = await client.query(
      'SELECT id, calories_burned FROM exercise_entries WHERE user_id = $1 AND exercise_id = $2 AND entry_date = $3',
      [userId, exerciseId, date]
    );
    const existingEntry = result.rows[0];

    if (existingEntry) {
      log('info', `Existing active calories entry found for ${date}, updating calories from ${existingEntry.calories_burned} to ${caloriesBurned}.`);
      const updateResult = await client.query(
        'UPDATE exercise_entries SET calories_burned = $1, notes = $2, updated_by_user_id = $3, exercise_name = $4 WHERE id = $5 RETURNING *',
        [
          caloriesBurned,
          'Active calories logged from Apple Health (updated).',
          createdByUserId,
          exerciseName,
          existingEntry.id,
        ]
      );
      return updateResult.rows[0];
    } else {
      log('info', `No existing active calories entry found for ${date}, inserting new entry.`);
      const insertResult = await client.query(
        `INSERT INTO exercise_entries (user_id, exercise_id, entry_date, calories_burned, duration_minutes, notes, created_by_user_id, exercise_name)
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
        [
          userId,
          exerciseId,
          date,
          caloriesBurned,
          0,
          'Active calories logged from Apple Health.',
          createdByUserId,
          exerciseName,
        ]
      );
      return insertResult.rows[0];
    }
  } catch (error: any) {
    log('error', 'Error in upsertExerciseEntryData:', error);
    throw new Error(`Failed to upsert active calories exercise entry: ${error.message}`);
  } finally {
    client.release();
  }
}

});

const entriesWithDetails = await Promise.all(allExerciseEntries.map(async (row: any) => {
const activityDetails = await activityDetailsRepository.getActivityDetailsByEntryOrPresetId(userId, row.id, null);

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

This implementation introduces an N+1 query pattern. For every exercise entry found for a date, a separate database call is made to activityDetailsRepository.getActivityDetailsByEntryOrPresetId. This can significantly degrade performance as the number of entries grows. It is recommended to fetch all activity details in a single bulk query using the list of entry IDs.

Comment on lines +850 to +856
try {
calendarDate = moment(calendarDateRaw).format('YYYY-MM-DD');
} catch (e) {
calendarDate = new Date(calendarDateRaw)
.toISOString()
.split('T')[0];
}

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 try-catch block around moment().format() is ineffective because moment does not throw an exception when parsing an invalid date; instead, it returns an "Invalid date" object, and format() returns the string "Invalid date". You should use moment(calendarDateRaw).isValid() to check for parsing success. Additionally, new Date(calendarDateRaw).toISOString() will throw a RangeError if the date is invalid, which could crash the process if not handled.

          const m = moment(calendarDateRaw);
          let calendarDate;
          if (m.isValid()) {
            calendarDate = m.format('YYYY-MM-DD');
          } else {
            try {
              calendarDate = new Date(calendarDateRaw).toISOString().split('T')[0];
            } catch (e) {
              log('warn', `[garminService] Invalid date encountered: ${calendarDateRaw}`);
              continue;
            }
          }

@CodeWithCJ

Copy link
Copy Markdown
Owner

Ci/test failed for all the CRs. have look at them and also the comments in my previous PR.
Appreciate your help on these. attaching screenshot in your previous PRs will help to understand better how the changes will look like easily.

I will merge them one by one in the same order you submitted, but not sure if it will throw any error as some PRs include same files being changed.

Let me know if any questions.

@serjsv87 serjsv87 force-pushed the refactor/garmin-typescript branch 2 times, most recently from b658ecf to 13c35c3 Compare April 6, 2026 20:39
@serjsv87

serjsv87 commented Apr 6, 2026

Copy link
Copy Markdown
Author

I have rebased this branch and fixed the formatting/type issues. It's now ready for review.

@CodeWithCJ

Copy link
Copy Markdown
Owner
image any reason to remove ts at the end in this file?

@serjsv87

serjsv87 commented Apr 8, 2026

Copy link
Copy Markdown
Author

image any reason to remove ts at the end in this file?

This change was implemented as part of a 'Vibe Coding' session assisted by AI, as including .ts extensions in import/export paths is generally considered a legacy pattern that often introduces resolution issues.

Why it’s a positive change:

  1. TS Best Practices: TypeScript recommends extensionless imports to ensure compatibility across different module resolution strategies. This allows the compiler and bundlers (like Vite) to handle the file lookup natively.

  2. Runtime Compatibility: Explicitly using .ts often breaks logic in ESM environments (e.g., modern Node.js) where files are eventually compiled to .js. Removing the extension makes researchers-agnostic and more robust.

  3. Cleanliness: It standardizes the index.ts file, making the exports cleaner and easier to maintain as the project grows.

@apedley

apedley commented Apr 8, 2026

Copy link
Copy Markdown
Contributor
2. **Runtime Compatibility:** Explicitly using .ts often breaks logic in ESM environments (e.g., modern Node.js) where files are eventually compiled to .js. Removing the extension makes researchers-agnostic and more robust.

We're using tsx on the server due to migration from to ts and the shared package isn't compiled to js so the opposite is actually true. Not explicitly declaring the file extension causes crashes in production environments.

@serjsv87

serjsv87 commented Apr 8, 2026

Copy link
Copy Markdown
Author

I restored the .ts file and edited out a few mentions of Telegram

@CodeWithCJ

Copy link
Copy Markdown
Owner

@serjsv87 I still can't merge as there are some changes and clarifications required. Check my previous comments

@serjsv87

serjsv87 commented Apr 9, 2026

Copy link
Copy Markdown
Author

@serjsv87 I still can't merge as there are some changes and clarifications required. Check my previous comments

I don't understand which comment to look at, I switch to this thread, it works for me, what should I do?

@CodeWithCJ

Copy link
Copy Markdown
Owner

Why does this file needs change?
image

This looks like duplicate
image

RLS takes care of filtering only the required providers for that user. why do we need to pass user id in here?
image

The longer we wait for the merge, it will go out of sync like this.

image

@serjsv87 serjsv87 force-pushed the refactor/garmin-typescript branch from 7926793 to 50cc32c Compare April 10, 2026 13:09
@Sim-sat

Sim-sat commented Apr 13, 2026

Copy link
Copy Markdown
Contributor

Because of the full migration to typescript in #1118 this will not really merge anymore. It was done with ts-migrate so alot of types are still missing. Please Look what your changes would improve without changing the whole file. Sorry for the extra work

@Sim-sat

Sim-sat commented Apr 13, 2026

Copy link
Copy Markdown
Contributor

@CodeWithCJ this can be closed. I added the remaining types in #1126

@CodeWithCJ CodeWithCJ closed this Jun 9, 2026
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.

4 participants