Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions apps/api/src/features/exercises/exercise.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ const exerciseList = [builtInExercise, customExercise];
function createTestApp(
createExercise = vi.fn(() => Promise.resolve(customExercise)),
listExercises = vi.fn(() => Promise.resolve(exerciseList)),
updateExercise = vi.fn((): Promise<typeof customExercise | null> =>
Promise.resolve(customExercise),
),
) {
const app = express();
app.use(express.json());
Expand All @@ -39,10 +42,10 @@ function createTestApp(
app.use(establishAuthenticatedTestSession());
app.use(
'/exercises',
createExerciseRouter({ createExercise, listExercises }),
createExerciseRouter({ createExercise, listExercises, updateExercise }),
);

return { app, createExercise, listExercises };
return { app, createExercise, listExercises, updateExercise };
}

describe('POST /exercises', () => {
Expand Down Expand Up @@ -97,6 +100,36 @@ describe('POST /exercises', () => {
});
});

describe('PATCH /exercises/:exerciseId', () => {
it('updates an exercise belonging to the authenticated user', async () => {
const { app, updateExercise } = createTestApp();

const response = await request(app)
.patch(`/exercises/${customExercise.id}`)
.set('x-test-authenticated-user-id', userId)
.send({ name: ' Renamed Carry ' });

expect(response.status).toBe(200);
expect(response.body).toEqual(customExercise);
expect(updateExercise).toHaveBeenCalledWith(userId, customExercise.id, {
name: 'Renamed Carry',
});
});

it('returns not found when the exercise is not owned by the user', async () => {
const updateExercise = vi.fn(() => Promise.resolve(null));
const { app } = createTestApp(undefined, undefined, updateExercise);

const response = await request(app)
.patch(`/exercises/${builtInExercise.id}`)
.set('x-test-authenticated-user-id', userId)
.send({ name: 'Changed Built In' });

expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'exercise_not_found' });
});
});

describe('GET /exercises', () => {
it('rejects anonymous requests', async () => {
const { app, listExercises } = createTestApp();
Expand Down
42 changes: 42 additions & 0 deletions apps/api/src/features/exercises/exercise.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import {
createExerciseInputSchema,
exerciseListResponseSchema,
exerciseSummarySchema,
updateExerciseInputSchema,
type CreateExerciseInput,
type ExerciseSummary,
type UpdateExerciseInput,
} from '@goforlift/contracts';
import { Router } from 'express';

Expand All @@ -19,11 +21,17 @@ type ExerciseRouterDependencies = {
input: CreateExerciseInput,
) => Promise<ExerciseSummary>;
listExercises: (userId: string) => Promise<ExerciseSummary[]>;
updateExercise: (
userId: string,
exerciseId: string,
input: UpdateExerciseInput,
) => Promise<ExerciseSummary | null>;
};

export function createExerciseRouter({
createExercise,
listExercises,
updateExercise,
}: ExerciseRouterDependencies) {
const router = Router();

Expand Down Expand Up @@ -60,5 +68,39 @@ export function createExerciseRouter({
}
});

router.patch(
'/:exerciseId',
requireAuthentication,
async (request, response, next) => {
const exerciseIdResult = exerciseSummarySchema.shape.id.safeParse(
request.params.exerciseId,
);
const inputResult = updateExerciseInputSchema.safeParse(request.body);

if (!exerciseIdResult.success || !inputResult.success) {
response.status(400).json({ error: 'invalid_request' });
return;
}

try {
const userId = getAuthenticatedUserId(request);
const exercise = await updateExercise(
userId,
exerciseIdResult.data,
inputResult.data,
);

if (!exercise) {
response.status(404).json({ error: 'exercise_not_found' });
return;
}

response.status(200).json(exerciseSummarySchema.parse(exercise));
} catch (error) {
next(error);
}
},
);

return router;
}
30 changes: 30 additions & 0 deletions apps/api/src/features/exercises/exercise.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { exercises } from '../../db/schema/index.js';
import {
createExerciseForUser,
listExercisesForUser,
updateExerciseForUser,
} from './exercise.service.js';

describe('createExerciseForUser', () => {
Expand Down Expand Up @@ -87,3 +88,32 @@ describe('listExercisesForUser', () => {
]);
});
});

describe('updateExerciseForUser', () => {
it('updates only an exercise owned by the authenticated user', async () => {
const updatedExercise = {
id: 'f29f209d-d1f9-4988-b693-69b291917b0f',
name: 'Renamed Carry',
description: null,
};
const returning = vi.fn().mockResolvedValue([updatedExercise]);
const where = vi.fn(() => ({ returning }));
const set = vi.fn(() => ({ where }));
const update = vi.fn(() => ({ set }));
const database = { update } as unknown as NodePgDatabase;

const result = await updateExerciseForUser(
database,
'26d34dc0-8e4c-4bd0-9e3b-7b839b44e486',
updatedExercise.id,
{ name: updatedExercise.name },
);

expect(update).toHaveBeenCalledWith(exercises);
expect(set).toHaveBeenCalledWith(
expect.objectContaining({ name: updatedExercise.name }),
);
expect(where).toHaveBeenCalledOnce();
expect(result).toEqual({ ...updatedExercise, isCustom: true });
});
});
22 changes: 21 additions & 1 deletion apps/api/src/features/exercises/exercise.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
import type {
CreateExerciseInput,
ExerciseSummary,
UpdateExerciseInput,
} from '@goforlift/contracts';
import { asc, eq, isNull, or } from 'drizzle-orm';
import { and, asc, eq, isNull, or } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';

import { exercises } from '../../db/schema/index.js';
Expand Down Expand Up @@ -56,3 +57,22 @@ export async function listExercisesForUser(
isCustom: ownerUserId !== null,
}));
}

export async function updateExerciseForUser(
database: NodePgDatabase,
userId: string,
exerciseId: string,
input: UpdateExerciseInput,
): Promise<ExerciseSummary | null> {
const [updatedExercise] = await database
.update(exercises)
.set({ name: input.name, updatedAt: new Date() })
.where(and(eq(exercises.id, exerciseId), eq(exercises.ownerUserId, userId)))
.returning({
id: exercises.id,
name: exercises.name,
description: exercises.description,
});

return updatedExercise ? { ...updatedExercise, isCustom: true } : null;
}
3 changes: 3 additions & 0 deletions apps/api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { createExerciseRouter } from './features/exercises/exercise.routes.js';
import {
createExerciseForUser,
listExercisesForUser,
updateExerciseForUser,
} from './features/exercises/exercise.service.js';
import { createRoutineRouter } from './features/routines/routine.routes.js';
import {
Expand Down Expand Up @@ -53,6 +54,8 @@ authRouter.use(
const exerciseRouter = createExerciseRouter({
createExercise: (userId, input) => createExerciseForUser(db, userId, input),
listExercises: (userId) => listExercisesForUser(db, userId),
updateExercise: (userId, exerciseId, input) =>
updateExerciseForUser(db, userId, exerciseId, input),
});

const routineRouter = createRoutineRouter({
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/features/exercises/exercises.api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
createExercise,
ExercisesApiError,
fetchExercises,
updateExerciseName,
} from './exercises.api';

afterEach(() => {
Expand Down Expand Up @@ -82,3 +83,32 @@ it('creates and validates a custom exercise', async () => {
expect.objectContaining({ method: 'POST' }),
);
});

it('updates a custom exercise name', async () => {
const exerciseId = '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486';
const fetchMock = vi
.fn()
.mockResolvedValueOnce(Response.json({ csrfToken: 'csrf-token' }))
.mockResolvedValueOnce(
Response.json({
id: exerciseId,
name: 'Renamed Pullover',
description: null,
isCustom: true,
}),
);
vi.stubGlobal('fetch', fetchMock);

await expect(
updateExerciseName({
exerciseId,
input: { name: 'Renamed Pullover' },
}),
).resolves.toMatchObject({ name: 'Renamed Pullover' });

expect(fetchMock).toHaveBeenNthCalledWith(
2,
`/api/exercises/${exerciseId}`,
expect.objectContaining({ method: 'PATCH' }),
);
});
20 changes: 20 additions & 0 deletions apps/web/src/features/exercises/exercises.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import {
createExerciseInputSchema,
exerciseListResponseSchema,
exerciseSummarySchema,
updateExerciseInputSchema,
type CreateExerciseInput,
type ExerciseListResponse,
type ExerciseSummary,
type UpdateExerciseInput,
} from '@goforlift/contracts';

import { requestWithCsrf } from '@/features/auth/auth.api';
Expand Down Expand Up @@ -46,3 +48,21 @@ export async function createExercise(

return exerciseSummarySchema.parse(data);
}

export async function updateExerciseName({
exerciseId,
input,
}: {
exerciseId: string;
input: UpdateExerciseInput;
}): Promise<ExerciseSummary> {
const validatedInput = updateExerciseInputSchema.parse(input);
const response = await requestWithCsrf(`/api/exercises/${exerciseId}`, {
body: JSON.stringify(validatedInput),
headers: { 'Content-Type': 'application/json' },
method: 'PATCH',
});
const data: unknown = await response.json();

return exerciseSummarySchema.parse(data);
}
21 changes: 20 additions & 1 deletion apps/web/src/features/exercises/exercises.mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import type { ExerciseListResponse } from '@goforlift/contracts';
import { useMutation, useQueryClient } from '@tanstack/react-query';

import { createExercise } from './exercises.api';
import { createExercise, updateExerciseName } from './exercises.api';
import { exercisesQueryKey } from './exercises.query';

export function useCreateExerciseMutation() {
Expand All @@ -22,3 +22,22 @@ export function useCreateExerciseMutation() {
},
});
}

export function useUpdateExerciseNameMutation() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: updateExerciseName,
onSuccess: (updatedExercise) => {
queryClient.setQueryData<ExerciseListResponse>(
exercisesQueryKey,
(currentData) =>
currentData && {
exercises: currentData.exercises.map((exercise) =>
exercise.id === updatedExercise.id ? updatedExercise : exercise,
),
},
);
},
});
}
22 changes: 21 additions & 1 deletion apps/web/src/features/routines/create/editor/routine-editor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/** Presents the responsive visual shell for creating a registered-user routine. */
import { useQuery } from '@tanstack/react-query';
import { Link, useNavigate } from '@tanstack/react-router';
import { ArrowLeft, Plus } from 'lucide-react';
import { useState } from 'react';
Expand All @@ -9,6 +10,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PageTitle } from '@/components/ui/page-title';
import { exercisesQueryOptions } from '@/features/exercises/exercises.query';
import { useCreateRoutineMutation } from '@/features/routines/api/routines.mutation';
import { cn } from '@/lib/utils';
import type {
Expand Down Expand Up @@ -199,6 +201,24 @@ function ExerciseSection({
setValue,
} = useFormContext<RoutineDraftFormValues>();
const exercises = useWatch({ control, name: 'exercises' });

const exercisesQuery = useQuery({
...exercisesQueryOptions,
enabled: false,
});

const exerciseNames = new Map(
exercisesQuery.data?.exercises.map((exercise) => [
exercise.id,
exercise.name,
]),
);

const displayedExercises = exercises.map((exercise) => ({
...exercise,
name: exerciseNames.get(exercise.exerciseId) ?? 'Exercise unavailable',
}));

const errorMessage = errors.exercises?.message;

function removeExercise(position: number) {
Expand Down Expand Up @@ -240,7 +260,7 @@ function ExerciseSection({

{exercises.length > 0 && (
<RoutineExerciseList
exercises={exercises}
exercises={displayedExercises}
onEdit={onEditExercise}
onRemove={removeExercise}
onReorder={reorderExercises}
Expand Down
Loading