diff --git a/apps/api/src/features/exercises/exercise.routes.test.ts b/apps/api/src/features/exercises/exercise.routes.test.ts index 1a5b417..1b65b25 100644 --- a/apps/api/src/features/exercises/exercise.routes.test.ts +++ b/apps/api/src/features/exercises/exercise.routes.test.ts @@ -26,6 +26,7 @@ const exerciseList = [builtInExercise, customExercise]; function createTestApp( createExercise = vi.fn(() => Promise.resolve(customExercise)), + deleteExercise = vi.fn(() => Promise.resolve(true)), listExercises = vi.fn(() => Promise.resolve(exerciseList)), updateExercise = vi.fn((): Promise => Promise.resolve(customExercise), @@ -42,10 +43,21 @@ function createTestApp( app.use(establishAuthenticatedTestSession()); app.use( '/exercises', - createExerciseRouter({ createExercise, listExercises, updateExercise }), + createExerciseRouter({ + createExercise, + deleteExercise, + listExercises, + updateExercise, + }), ); - return { app, createExercise, listExercises, updateExercise }; + return { + app, + createExercise, + deleteExercise, + listExercises, + updateExercise, + }; } describe('POST /exercises', () => { @@ -118,7 +130,12 @@ describe('PATCH /exercises/:exerciseId', () => { 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 { app } = createTestApp( + undefined, + undefined, + undefined, + updateExercise, + ); const response = await request(app) .patch(`/exercises/${builtInExercise.id}`) @@ -130,6 +147,31 @@ describe('PATCH /exercises/:exerciseId', () => { }); }); +describe('DELETE /exercises/:exerciseId', () => { + it('deletes an exercise belonging to the authenticated user', async () => { + const { app, deleteExercise } = createTestApp(); + + const response = await request(app) + .delete(`/exercises/${customExercise.id}`) + .set('x-test-authenticated-user-id', userId); + + expect(response.status).toBe(204); + expect(deleteExercise).toHaveBeenCalledWith(userId, customExercise.id); + }); + + it('returns not found when the exercise is not owned by the user', async () => { + const deleteExercise = vi.fn(() => Promise.resolve(false)); + const { app } = createTestApp(undefined, deleteExercise); + + const response = await request(app) + .delete(`/exercises/${builtInExercise.id}`) + .set('x-test-authenticated-user-id', userId); + + 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(); diff --git a/apps/api/src/features/exercises/exercise.routes.ts b/apps/api/src/features/exercises/exercise.routes.ts index 2f8c7f4..9dd76e2 100644 --- a/apps/api/src/features/exercises/exercise.routes.ts +++ b/apps/api/src/features/exercises/exercise.routes.ts @@ -21,6 +21,7 @@ type ExerciseRouterDependencies = { input: CreateExerciseInput, ) => Promise; listExercises: (userId: string) => Promise; + deleteExercise: (userId: string, exerciseId: string) => Promise; updateExercise: ( userId: string, exerciseId: string, @@ -30,6 +31,7 @@ type ExerciseRouterDependencies = { export function createExerciseRouter({ createExercise, + deleteExercise, listExercises, updateExercise, }: ExerciseRouterDependencies) { @@ -102,5 +104,34 @@ export function createExerciseRouter({ }, ); + router.delete( + '/:exerciseId', + requireAuthentication, + async (request, response, next) => { + const exerciseIdResult = exerciseSummarySchema.shape.id.safeParse( + request.params.exerciseId, + ); + + if (!exerciseIdResult.success) { + response.status(400).json({ error: 'invalid_request' }); + return; + } + + try { + const userId = getAuthenticatedUserId(request); + const wasDeleted = await deleteExercise(userId, exerciseIdResult.data); + + if (!wasDeleted) { + response.status(404).json({ error: 'exercise_not_found' }); + return; + } + + response.sendStatus(204); + } catch (error) { + next(error); + } + }, + ); + return router; } diff --git a/apps/api/src/features/exercises/exercise.service.test.ts b/apps/api/src/features/exercises/exercise.service.test.ts index 63349cf..b0e3007 100644 --- a/apps/api/src/features/exercises/exercise.service.test.ts +++ b/apps/api/src/features/exercises/exercise.service.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest'; import { exercises } from '../../db/schema/index.js'; import { createExerciseForUser, + deleteExerciseForUser, listExercisesForUser, updateExerciseForUser, } from './exercise.service.js'; @@ -117,3 +118,22 @@ describe('updateExerciseForUser', () => { expect(result).toEqual({ ...updatedExercise, isCustom: true }); }); }); + +describe('deleteExerciseForUser', () => { + it('deletes only an exercise owned by the authenticated user', async () => { + const returning = vi.fn().mockResolvedValue([{ id: 'exercise-id' }]); + const where = vi.fn(() => ({ returning })); + const deleteFrom = vi.fn(() => ({ where })); + const database = { delete: deleteFrom } as unknown as NodePgDatabase; + + const result = await deleteExerciseForUser( + database, + '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486', + 'f29f209d-d1f9-4988-b693-69b291917b0f', + ); + + expect(deleteFrom).toHaveBeenCalledWith(exercises); + expect(where).toHaveBeenCalledOnce(); + expect(result).toBe(true); + }); +}); diff --git a/apps/api/src/features/exercises/exercise.service.ts b/apps/api/src/features/exercises/exercise.service.ts index f8a58a8..ffda032 100644 --- a/apps/api/src/features/exercises/exercise.service.ts +++ b/apps/api/src/features/exercises/exercise.service.ts @@ -76,3 +76,16 @@ export async function updateExerciseForUser( return updatedExercise ? { ...updatedExercise, isCustom: true } : null; } + +export async function deleteExerciseForUser( + database: NodePgDatabase, + userId: string, + exerciseId: string, +): Promise { + const deletedExercises = await database + .delete(exercises) + .where(and(eq(exercises.id, exerciseId), eq(exercises.ownerUserId, userId))) + .returning({ id: exercises.id }); + + return deletedExercises.length > 0; +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 68be3d0..ca08976 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -20,6 +20,7 @@ import { createDatabase } from './db/client.js'; import { createExerciseRouter } from './features/exercises/exercise.routes.js'; import { createExerciseForUser, + deleteExerciseForUser, listExercisesForUser, updateExerciseForUser, } from './features/exercises/exercise.service.js'; @@ -53,6 +54,8 @@ authRouter.use( const exerciseRouter = createExerciseRouter({ createExercise: (userId, input) => createExerciseForUser(db, userId, input), + deleteExercise: (userId, exerciseId) => + deleteExerciseForUser(db, userId, exerciseId), listExercises: (userId) => listExercisesForUser(db, userId), updateExercise: (userId, exerciseId, input) => updateExerciseForUser(db, userId, exerciseId, input), diff --git a/apps/web/src/features/exercises/exercises.api.test.ts b/apps/web/src/features/exercises/exercises.api.test.ts index ab58bfa..d6a5772 100644 --- a/apps/web/src/features/exercises/exercises.api.test.ts +++ b/apps/web/src/features/exercises/exercises.api.test.ts @@ -3,6 +3,7 @@ import { afterEach, expect, it, vi } from 'vitest'; import { createExercise, + deleteExercise, ExercisesApiError, fetchExercises, updateExerciseName, @@ -112,3 +113,19 @@ it('updates a custom exercise name', async () => { expect.objectContaining({ method: 'PATCH' }), ); }); + +it('deletes a custom exercise', async () => { + const exerciseId = '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486'; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json({ csrfToken: 'csrf-token' })) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + vi.stubGlobal('fetch', fetchMock); + + await expect(deleteExercise(exerciseId)).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + `/api/exercises/${exerciseId}`, + expect.objectContaining({ method: 'DELETE' }), + ); +}); diff --git a/apps/web/src/features/exercises/exercises.api.ts b/apps/web/src/features/exercises/exercises.api.ts index cf97b6c..973d258 100644 --- a/apps/web/src/features/exercises/exercises.api.ts +++ b/apps/web/src/features/exercises/exercises.api.ts @@ -66,3 +66,9 @@ export async function updateExerciseName({ return exerciseSummarySchema.parse(data); } + +export async function deleteExercise(exerciseId: string): Promise { + await requestWithCsrf(`/api/exercises/${exerciseId}`, { + method: 'DELETE', + }); +} diff --git a/apps/web/src/features/exercises/exercises.mutation.ts b/apps/web/src/features/exercises/exercises.mutation.ts index 21d95af..01f68ce 100644 --- a/apps/web/src/features/exercises/exercises.mutation.ts +++ b/apps/web/src/features/exercises/exercises.mutation.ts @@ -2,7 +2,11 @@ import type { ExerciseListResponse } from '@goforlift/contracts'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { createExercise, updateExerciseName } from './exercises.api'; +import { + createExercise, + deleteExercise, + updateExerciseName, +} from './exercises.api'; import { exercisesQueryKey } from './exercises.query'; export function useCreateExerciseMutation() { @@ -41,3 +45,22 @@ export function useUpdateExerciseNameMutation() { }, }); } + +export function useDeleteExerciseMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deleteExercise, + onSuccess: (_, deletedExerciseId) => { + queryClient.setQueryData( + exercisesQueryKey, + (currentData) => + currentData && { + exercises: currentData.exercises.filter( + (exercise) => exercise.id !== deletedExerciseId, + ), + }, + ); + }, + }); +} diff --git a/apps/web/src/features/routines/create/exercise-configuration/delete-exercise-dialog.tsx b/apps/web/src/features/routines/create/exercise-configuration/delete-exercise-dialog.tsx new file mode 100644 index 0000000..9612f21 --- /dev/null +++ b/apps/web/src/features/routines/create/exercise-configuration/delete-exercise-dialog.tsx @@ -0,0 +1,90 @@ +/** Confirms and deletes a custom exercise owned by the current user. */ +import { useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { useDeleteExerciseMutation } from '@/features/exercises/exercises.mutation'; + +type DeleteExerciseDialogProps = { + exerciseId: string; + name: string; + onDeleted: () => Promise; +}; + +export function DeleteExerciseDialog({ + exerciseId, + name, + onDeleted, +}: DeleteExerciseDialogProps) { + const [isOpen, setIsOpen] = useState(false); + const deleteExerciseMutation = useDeleteExerciseMutation(); + + function handleOpenChange(open: boolean) { + setIsOpen(open); + + if (!open) { + deleteExerciseMutation.reset(); + } + } + + async function deleteExercise() { + const wasDeleted = await deleteExerciseMutation + .mutateAsync(exerciseId) + .then(() => true) + .catch(() => false); + + if (!wasDeleted) { + return; + } + + handleOpenChange(false); + await onDeleted(); + } + + return ( + + + + + + + Delete Custom Exercise? + + “{name}” will be removed from this routine draft and your exercise + list. This action cannot be undone. + + + {deleteExerciseMutation.isError && ( +

+ Exercise could not be deleted. Please try again. +

+ )} + +
+ + + + +
+
+
+ ); +} diff --git a/apps/web/src/features/routines/create/exercise-configuration/exercise-configuration.test.tsx b/apps/web/src/features/routines/create/exercise-configuration/exercise-configuration.test.tsx index 6d700ad..4509227 100644 --- a/apps/web/src/features/routines/create/exercise-configuration/exercise-configuration.test.tsx +++ b/apps/web/src/features/routines/create/exercise-configuration/exercise-configuration.test.tsx @@ -204,3 +204,56 @@ it('allows a custom exercise name to be edited', async () => { expect(screen.getByText('Renamed Curl')).toBeVisible(); expect(fetchMock).toHaveBeenCalledTimes(3); }); + +it('deletes a custom exercise and removes it from the routine draft', async () => { + const user = userEvent.setup(); + const exerciseId = '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486'; + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (input === '/auth/csrf-token') { + return Promise.resolve(Response.json({ csrfToken: 'csrf-token' })); + } + + if (init?.method === 'DELETE') { + return Promise.resolve(new Response(null, { status: 204 })); + } + + return Promise.resolve( + Response.json({ + exercises: [ + { + id: exerciseId, + name: 'Custom Curl', + description: null, + isCustom: true, + }, + ], + }), + ); + }); + vi.stubGlobal('fetch', fetchMock); + + renderConfiguration(exerciseId); + await user.click(await screen.findByRole('button', { name: 'Add Exercise' })); + await user.click(await screen.findByRole('link', { name: /Custom Curl/ })); + await user.click(await screen.findByRole('button', { name: 'Done' })); + await user.click( + await screen.findByRole('button', { name: 'Actions for Custom Curl' }), + ); + await user.click(screen.getByRole('menuitem', { name: 'Edit' })); + await user.click( + await screen.findByRole('button', { name: 'Delete exercise' }), + ); + + expect( + screen.getByRole('heading', { name: 'Delete Custom Exercise?' }), + ).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Delete' })); + + expect( + await screen.findByRole('heading', { name: 'Exercises (0)' }), + ).toBeVisible(); + expect(fetchMock).toHaveBeenCalledWith( + `/api/exercises/${exerciseId}`, + expect.objectContaining({ method: 'DELETE' }), + ); +}); diff --git a/apps/web/src/features/routines/create/exercise-configuration/exercise-configuration.tsx b/apps/web/src/features/routines/create/exercise-configuration/exercise-configuration.tsx index 3c40ff2..5147eae 100644 --- a/apps/web/src/features/routines/create/exercise-configuration/exercise-configuration.tsx +++ b/apps/web/src/features/routines/create/exercise-configuration/exercise-configuration.tsx @@ -25,6 +25,7 @@ import { useExercises } from '@/features/exercises/exercises.query'; import { z } from 'zod'; import type { RoutineDraftFormValues } from '../routine-draft-form'; +import { DeleteExerciseDialog } from './delete-exercise-dialog'; import { EditExerciseNameDialog } from './edit-exercise-name-dialog'; type RoutineExerciseFormValues = z.input< @@ -151,12 +152,27 @@ function ExerciseConfigurationForm({ await navigate({ to: '/routines/new' }); } + async function removeDeletedExerciseFromDraft() { + const exercises = getValues('exercises') + .filter((draftExercise) => draftExercise.exerciseId !== exercise.id) + .map((draftExercise, position) => ({ ...draftExercise, position })); + + setDraftValue('exercises', exercises, { + shouldDirty: true, + shouldValidate: true, + }); + await navigate({ to: '/routines/new' }); + } + return (
void handleSubmit(saveExercise)(event)} > - +
Promise; +}) { return (

{exercise.name}

{exercise.isCustom && ( - +
+ + +
)}