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
48 changes: 45 additions & 3 deletions apps/api/src/features/exercises/exercise.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof customExercise | null> =>
Promise.resolve(customExercise),
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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}`)
Expand All @@ -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();
Expand Down
31 changes: 31 additions & 0 deletions apps/api/src/features/exercises/exercise.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type ExerciseRouterDependencies = {
input: CreateExerciseInput,
) => Promise<ExerciseSummary>;
listExercises: (userId: string) => Promise<ExerciseSummary[]>;
deleteExercise: (userId: string, exerciseId: string) => Promise<boolean>;
updateExercise: (
userId: string,
exerciseId: string,
Expand All @@ -30,6 +31,7 @@ type ExerciseRouterDependencies = {

export function createExerciseRouter({
createExercise,
deleteExercise,
listExercises,
updateExercise,
}: ExerciseRouterDependencies) {
Expand Down Expand Up @@ -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;
}
20 changes: 20 additions & 0 deletions apps/api/src/features/exercises/exercise.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
});
13 changes: 13 additions & 0 deletions apps/api/src/features/exercises/exercise.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
const deletedExercises = await database
.delete(exercises)
.where(and(eq(exercises.id, exerciseId), eq(exercises.ownerUserId, userId)))
.returning({ id: exercises.id });

return deletedExercises.length > 0;
}
3 changes: 3 additions & 0 deletions apps/api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/features/exercises/exercises.api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { afterEach, expect, it, vi } from 'vitest';

import {
createExercise,
deleteExercise,
ExercisesApiError,
fetchExercises,
updateExerciseName,
Expand Down Expand Up @@ -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' }),
);
});
6 changes: 6 additions & 0 deletions apps/web/src/features/exercises/exercises.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,9 @@ export async function updateExerciseName({

return exerciseSummarySchema.parse(data);
}

export async function deleteExercise(exerciseId: string): Promise<void> {
await requestWithCsrf(`/api/exercises/${exerciseId}`, {
method: 'DELETE',
});
}
25 changes: 24 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,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() {
Expand Down Expand Up @@ -41,3 +45,22 @@ export function useUpdateExerciseNameMutation() {
},
});
}

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

return useMutation({
mutationFn: deleteExercise,
onSuccess: (_, deletedExerciseId) => {
queryClient.setQueryData<ExerciseListResponse>(
exercisesQueryKey,
(currentData) =>
currentData && {
exercises: currentData.exercises.filter(
(exercise) => exercise.id !== deletedExerciseId,
),
},
);
},
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/** 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';
import { cn } from '@/lib/utils';

type DeleteExerciseDialogProps = {
exerciseId: string;
name: string;
onDeleted: () => Promise<void>;
};

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 (
<Dialog onOpenChange={handleOpenChange} open={isOpen}>
<DialogTrigger asChild>
<button
className={cn(
'text-left',
'text-sm font-medium text-primary',
'hover:underline focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
type="button"
>
Delete exercise
</button>
</DialogTrigger>

<DialogContent>
<DialogTitle>Delete Custom Exercise?</DialogTitle>
<DialogDescription className="mt-2">
“{name}” will be removed from this routine draft and your exercise
list. This action cannot be undone.
</DialogDescription>

{deleteExerciseMutation.isError && (
<p className="mt-4 text-sm text-destructive" role="alert">
Exercise could not be deleted. Please try again.
</p>
)}

<div className="mt-6 grid grid-cols-2 gap-3">
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<Button
disabled={deleteExerciseMutation.isPending}
onClick={() => void deleteExercise()}
type="button"
variant="destructive"
>
{deleteExerciseMutation.isPending ? 'Deleting…' : 'Delete'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,14 @@ export function EditExerciseNameDialog({
return (
<Dialog onOpenChange={handleOpenChange} open={isOpen}>
<DialogTrigger asChild>
<button className="mt-1 text-sm text-primary" type="button">
<button
className={cn(
'text-left',
'text-sm font-medium text-primary',
'hover:underline focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
type="button"
>
Edit name
</button>
</DialogTrigger>
Expand Down
Loading