Skip to content

Commit 41f9a19

Browse files
authored
Merge pull request #1171 from apedley/family-exercise-sharing
2 parents feec460 + ec22e4f commit 41f9a19

11 files changed

Lines changed: 182 additions & 31 deletions

SparkyFitnessServer/SparkyFitnessServer.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ import swaggerSpecs from './config/swagger.js';
8282
import { createCorsOriginChecker } from './utils/corsHelper.js';
8383
import authModule from './auth.js';
8484
import { toNodeHandler } from 'better-auth/node';
85-
import exerciseRepository from './models/exerciseRepository.js';
8685
import freeExerciseDBService from './integrations/freeexercisedb/FreeExerciseDBService.js';
8786
import { downloadImage } from './utils/imageDownloader.js';
8887
import authRoutes from './routes/authRoutes.js';
@@ -258,19 +257,18 @@ app.get(
258257
if (fs.existsSync(localImagePath)) {
259258
return res.sendFile(localImagePath);
260259
}
261-
// If not found, attempt to re-download
260+
// If not found, attempt to re-download. Resolve image paths from the
261+
// upstream free-exercise-db record (the canonical source) rather than a
262+
// per-user DB copy — different users may have locally diverged the images
263+
// array, and this route is unauthenticated so we have no user context.
262264
try {
263-
const exercise = await exerciseRepository.getExerciseBySourceAndSourceId(
264-
'free-exercise-db',
265-
exerciseId
266-
);
265+
const exercise = await freeExerciseDBService.getExerciseById(exerciseId);
267266
if (!exercise) {
268267
return res.status(404).send('Exercise not found.');
269268
}
270-
// @ts-expect-error TS7006
271-
const originalRelativeImagePath = exercise.images.find((img) =>
272-
img.endsWith(imageFileName)
273-
);
269+
const originalRelativeImagePath = (
270+
(exercise as { images?: string[] }).images ?? []
271+
).find((img) => path.basename(img) === imageFileName);
274272
if (!originalRelativeImagePath) {
275273
return res.status(404).send('Image not found for this exercise.');
276274
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
-- Scope the exercises (source, source_id) uniqueness to each user so that
2+
-- multiple users can import the same external-source exercise (e.g. the same
3+
-- Free-Exercise-DB entry) into their own per-user copies.
4+
5+
DROP INDEX IF EXISTS idx_exercises_source_source_id_unique;
6+
7+
CREATE UNIQUE INDEX IF NOT EXISTS idx_exercises_user_source_source_id_unique
8+
ON exercises (user_id, source, source_id)
9+
WHERE source IS NOT NULL AND source_id IS NOT NULL;

SparkyFitnessServer/db/rls_policies.sql

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ SELECT create_library_policy('foods', 'shared_with_public', ARRAY['can_view_food
371371
SELECT create_library_policy('meals', 'is_public', ARRAY['can_view_food_library', 'can_manage_diary']);
372372
SELECT create_library_policy('meal_plan_templates', 'false', ARRAY['can_view_food_library']);
373373
SELECT create_library_policy('workout_plan_templates', 'false', ARRAY['can_view_exercise_library']);
374-
SELECT create_library_policy('workout_presets', 'false', ARRAY['can_view_exercise_library']);
374+
SELECT create_library_policy('workout_presets', 'is_public', ARRAY['can_view_exercise_library']);
375375

376376

377377
-- Custom policies for special cases
@@ -498,4 +498,4 @@ WITH CHECK (
498498
-- Sleep Science tables
499499
SELECT create_owner_policy('sleep_need_calculations');
500500
SELECT create_owner_policy('daily_sleep_need');
501-
SELECT create_owner_policy('day_classification_cache');
501+
SELECT create_owner_policy('day_classification_cache');

SparkyFitnessServer/integrations/polar/polarDataProcessor.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@ async function processPolarExercises(
9494
const exerciseSourceId = `polar-workout-${exerciseId}`;
9595
let exerciseDef = await exerciseRepository.getExerciseBySourceAndSourceId(
9696
'Polar',
97-
exerciseSourceId
97+
exerciseSourceId,
98+
userId
9899
);
99100
if (!exerciseDef) {
100101
// Search by name if source not found

SparkyFitnessServer/integrations/withings/withingsDataProcessor.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -959,7 +959,8 @@ async function processWithingsWorkouts(
959959
const exerciseSourceId = `withings-workout-${workoutCategory}`;
960960
let exercise = await exerciseRepository.getExerciseBySourceAndSourceId(
961961
'Withings',
962-
exerciseSourceId
962+
exerciseSourceId,
963+
userId
963964
); // Corrected variable name
964965
if (!exercise) {
965966
// If not found by source and sourceId, try to find by name (for user-created exercises)

SparkyFitnessServer/models/exercise.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -606,17 +606,23 @@ async function getTopExercises(userId: any, limit: any) {
606606
client.release();
607607
}
608608
}
609-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
610-
async function getExerciseBySourceAndSourceId(source: any, sourceId: any) {
609+
async function getExerciseBySourceAndSourceId(
610+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
611+
source: any,
612+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
613+
sourceId: any,
614+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
615+
userId: any
616+
) {
611617
const client = await getSystemClient();
612618
try {
613619
const result = await client.query(
614620
`SELECT id, source, source_id, name, force, level, mechanic, equipment,
615621
primary_muscles, secondary_muscles, instructions, category, images,
616622
calories_per_hour, description, user_id, is_custom, shared_with_public,
617623
created_at, updated_at
618-
FROM exercises WHERE source = $1 AND source_id = $2`,
619-
[source, sourceId]
624+
FROM exercises WHERE source = $1 AND source_id = $2 AND user_id = $3`,
625+
[source, sourceId, userId]
620626
);
621627
const exercise = result.rows[0];
622628
if (exercise && exercise.images) {

SparkyFitnessServer/services/exerciseService.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,8 @@ async function prepareExerciseEntryForCreate(
192192
entryData: any
193193
) {
194194
const resolvedExerciseId = await resolveExerciseIdToUuid(
195-
entryData.exercise_id
195+
entryData.exercise_id,
196+
authenticatedUserId
196197
);
197198
const exercise = await exerciseDb.getExerciseById(
198199
resolvedExerciseId,
@@ -1298,7 +1299,10 @@ async function getExerciseHistory(
12981299
limit: any
12991300
) {
13001301
try {
1301-
const resolvedExerciseId = await resolveExerciseIdToUuid(exerciseId);
1302+
const resolvedExerciseId = await resolveExerciseIdToUuid(
1303+
exerciseId,
1304+
authenticatedUserId
1305+
);
13021306
// getExerciseHistory is implemented in the exerciseEntry module
13031307
const history = await exerciseEntryDb.getExerciseHistory(
13041308
authenticatedUserId,

SparkyFitnessServer/services/workoutPresetService.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { resolveExerciseIdToUuid } from '../utils/uuidUtils.js';
66
async function createWorkoutPreset(userId: any, presetData: any) {
77
// Validate and resolve exercise_ids
88
for (const ex of presetData.exercises) {
9-
ex.exercise_id = await resolveExerciseIdToUuid(ex.exercise_id); // Resolve to UUID
9+
ex.exercise_id = await resolveExerciseIdToUuid(ex.exercise_id, userId); // Resolve to UUID
1010
const exercise = await exerciseRepository.getExerciseById(
1111
ex.exercise_id,
1212
userId
@@ -71,7 +71,7 @@ async function updateWorkoutPreset(
7171
// Validate and resolve exercise_ids if exercises are being updated
7272
if (updateData.exercises) {
7373
for (const ex of updateData.exercises) {
74-
ex.exercise_id = await resolveExerciseIdToUuid(ex.exercise_id); // Resolve to UUID
74+
ex.exercise_id = await resolveExerciseIdToUuid(ex.exercise_id, userId); // Resolve to UUID
7575
const exercise = await exerciseRepository.getExerciseById(
7676
ex.exercise_id,
7777
userId
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { vi, afterEach, beforeEach, describe, expect, it } from 'vitest';
2+
import { v4 as uuidv4 } from 'uuid';
3+
import exerciseDb from '../models/exercise.js';
4+
import { getSystemClient } from '../db/poolManager.js';
5+
import { resolveExerciseIdToUuid } from '../utils/uuidUtils.js';
6+
import exerciseRepository from '../models/exerciseRepository.js';
7+
8+
vi.mock('../db/poolManager', () => ({
9+
getClient: vi.fn(),
10+
getSystemClient: vi.fn(),
11+
}));
12+
13+
vi.mock('../config/logging', () => ({
14+
log: vi.fn(),
15+
}));
16+
17+
vi.mock('../models/exerciseRepository', () => ({
18+
default: {
19+
getExerciseBySourceAndSourceId: vi.fn(),
20+
},
21+
}));
22+
23+
describe('exercise source/source_id scoping', () => {
24+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
25+
let mockClient: any;
26+
27+
beforeEach(() => {
28+
mockClient = { query: vi.fn(), release: vi.fn() };
29+
// @ts-expect-error mock typing
30+
getSystemClient.mockResolvedValue(mockClient);
31+
});
32+
33+
afterEach(() => {
34+
vi.clearAllMocks();
35+
});
36+
37+
describe('exerciseDb.getExerciseBySourceAndSourceId', () => {
38+
it('filters on user_id when userId is provided', async () => {
39+
const userId = uuidv4();
40+
const rowId = uuidv4();
41+
mockClient.query.mockResolvedValueOnce({
42+
rows: [
43+
{
44+
id: rowId,
45+
source: 'free-exercise-db',
46+
source_id: 'Barbell_Bench_Press',
47+
user_id: userId,
48+
images: null,
49+
},
50+
],
51+
});
52+
53+
const result = await exerciseDb.getExerciseBySourceAndSourceId(
54+
'free-exercise-db',
55+
'Barbell_Bench_Press',
56+
userId
57+
);
58+
59+
expect(result.id).toBe(rowId);
60+
expect(mockClient.query).toHaveBeenCalledTimes(1);
61+
const [sql, params] = mockClient.query.mock.calls[0];
62+
expect(sql).toContain('AND user_id = $3');
63+
expect(params).toEqual([
64+
'free-exercise-db',
65+
'Barbell_Bench_Press',
66+
userId,
67+
]);
68+
});
69+
70+
it('returns undefined when the requested user has no copy even if another user does', async () => {
71+
const userId = uuidv4();
72+
mockClient.query.mockResolvedValueOnce({ rows: [] });
73+
74+
const result = await exerciseDb.getExerciseBySourceAndSourceId(
75+
'free-exercise-db',
76+
'Barbell_Bench_Press',
77+
userId
78+
);
79+
80+
expect(result).toBeUndefined();
81+
const [sql, params] = mockClient.query.mock.calls[0];
82+
expect(sql).toContain('AND user_id = $3');
83+
expect(params[2]).toBe(userId);
84+
});
85+
});
86+
87+
describe('resolveExerciseIdToUuid', () => {
88+
it('returns the input unchanged when it is already a UUID', async () => {
89+
const existingUuid = uuidv4();
90+
91+
const result = await resolveExerciseIdToUuid(existingUuid, uuidv4());
92+
93+
expect(result).toBe(existingUuid);
94+
expect(
95+
exerciseRepository.getExerciseBySourceAndSourceId
96+
).not.toHaveBeenCalled();
97+
});
98+
99+
it('forwards the userId to getExerciseBySourceAndSourceId so each user resolves to their own copy', async () => {
100+
const userId = uuidv4();
101+
const resolved = uuidv4();
102+
exerciseRepository.getExerciseBySourceAndSourceId.mockResolvedValueOnce({
103+
id: resolved,
104+
});
105+
106+
const result = await resolveExerciseIdToUuid(
107+
'Barbell_Bench_Press',
108+
userId
109+
);
110+
111+
expect(result).toBe(resolved);
112+
expect(
113+
exerciseRepository.getExerciseBySourceAndSourceId
114+
).toHaveBeenCalledWith('free-exercise-db', 'Barbell_Bench_Press', userId);
115+
});
116+
117+
it('throws when no exercise is found for the caller', async () => {
118+
exerciseRepository.getExerciseBySourceAndSourceId.mockResolvedValueOnce(
119+
undefined
120+
);
121+
122+
await expect(
123+
resolveExerciseIdToUuid('Barbell_Bench_Press', uuidv4())
124+
).rejects.toThrow(/not found or is not a valid UUID/);
125+
});
126+
});
127+
});

SparkyFitnessServer/utils/uuidUtils.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,22 @@ const isValidUuid = (uuid: any) => {
77
return uuidRegex.test(uuid);
88
};
99
// Helper function to resolve exercise ID to a UUID
10-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
11-
async function resolveExerciseIdToUuid(exerciseId: any) {
10+
async function resolveExerciseIdToUuid(
11+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
12+
exerciseId: any,
13+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
14+
userId: any
15+
) {
1216
if (isValidUuid(exerciseId)) {
1317
return exerciseId;
1418
}
15-
// If not a UUID, assume it's an integer ID from a source like FreeExerciseDB
16-
// We need to find the corresponding exercise in our DB that has this source_id
19+
// If not a UUID, assume it's an ID from a source like FreeExerciseDB.
20+
// Each user may have their own per-user copy of that source exercise, so
21+
// callers pass userId to select the row owned by the current user.
1722
const exercise = await exerciseRepository.getExerciseBySourceAndSourceId(
1823
'free-exercise-db',
19-
exerciseId
24+
exerciseId,
25+
userId
2026
);
2127
if (exercise) {
2228
return exercise.id;

0 commit comments

Comments
 (0)