diff --git a/apps/api/src/features/exercises/exercise.routes.test.ts b/apps/api/src/features/exercises/exercise.routes.test.ts index 42aab11..1a5b417 100644 --- a/apps/api/src/features/exercises/exercise.routes.test.ts +++ b/apps/api/src/features/exercises/exercise.routes.test.ts @@ -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 => + Promise.resolve(customExercise), + ), ) { const app = express(); app.use(express.json()); @@ -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', () => { @@ -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(); diff --git a/apps/api/src/features/exercises/exercise.routes.ts b/apps/api/src/features/exercises/exercise.routes.ts index 5e60470..2f8c7f4 100644 --- a/apps/api/src/features/exercises/exercise.routes.ts +++ b/apps/api/src/features/exercises/exercise.routes.ts @@ -3,8 +3,10 @@ import { createExerciseInputSchema, exerciseListResponseSchema, exerciseSummarySchema, + updateExerciseInputSchema, type CreateExerciseInput, type ExerciseSummary, + type UpdateExerciseInput, } from '@goforlift/contracts'; import { Router } from 'express'; @@ -19,11 +21,17 @@ type ExerciseRouterDependencies = { input: CreateExerciseInput, ) => Promise; listExercises: (userId: string) => Promise; + updateExercise: ( + userId: string, + exerciseId: string, + input: UpdateExerciseInput, + ) => Promise; }; export function createExerciseRouter({ createExercise, listExercises, + updateExercise, }: ExerciseRouterDependencies) { const router = Router(); @@ -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; } diff --git a/apps/api/src/features/exercises/exercise.service.test.ts b/apps/api/src/features/exercises/exercise.service.test.ts index 12077d4..63349cf 100644 --- a/apps/api/src/features/exercises/exercise.service.test.ts +++ b/apps/api/src/features/exercises/exercise.service.test.ts @@ -6,6 +6,7 @@ import { exercises } from '../../db/schema/index.js'; import { createExerciseForUser, listExercisesForUser, + updateExerciseForUser, } from './exercise.service.js'; describe('createExerciseForUser', () => { @@ -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 }); + }); +}); diff --git a/apps/api/src/features/exercises/exercise.service.ts b/apps/api/src/features/exercises/exercise.service.ts index 3e84013..f8a58a8 100644 --- a/apps/api/src/features/exercises/exercise.service.ts +++ b/apps/api/src/features/exercises/exercise.service.ts @@ -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'; @@ -56,3 +57,22 @@ export async function listExercisesForUser( isCustom: ownerUserId !== null, })); } + +export async function updateExerciseForUser( + database: NodePgDatabase, + userId: string, + exerciseId: string, + input: UpdateExerciseInput, +): Promise { + 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; +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 9b5a17d..68be3d0 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -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 { @@ -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({ diff --git a/apps/web/src/features/exercises/exercises.api.test.ts b/apps/web/src/features/exercises/exercises.api.test.ts index 3db30b8..ab58bfa 100644 --- a/apps/web/src/features/exercises/exercises.api.test.ts +++ b/apps/web/src/features/exercises/exercises.api.test.ts @@ -5,6 +5,7 @@ import { createExercise, ExercisesApiError, fetchExercises, + updateExerciseName, } from './exercises.api'; afterEach(() => { @@ -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' }), + ); +}); diff --git a/apps/web/src/features/exercises/exercises.api.ts b/apps/web/src/features/exercises/exercises.api.ts index 4a2652c..cf97b6c 100644 --- a/apps/web/src/features/exercises/exercises.api.ts +++ b/apps/web/src/features/exercises/exercises.api.ts @@ -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'; @@ -46,3 +48,21 @@ export async function createExercise( return exerciseSummarySchema.parse(data); } + +export async function updateExerciseName({ + exerciseId, + input, +}: { + exerciseId: string; + input: UpdateExerciseInput; +}): Promise { + 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); +} diff --git a/apps/web/src/features/exercises/exercises.mutation.ts b/apps/web/src/features/exercises/exercises.mutation.ts index 2bfbfd4..21d95af 100644 --- a/apps/web/src/features/exercises/exercises.mutation.ts +++ b/apps/web/src/features/exercises/exercises.mutation.ts @@ -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() { @@ -22,3 +22,22 @@ export function useCreateExerciseMutation() { }, }); } + +export function useUpdateExerciseNameMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: updateExerciseName, + onSuccess: (updatedExercise) => { + queryClient.setQueryData( + exercisesQueryKey, + (currentData) => + currentData && { + exercises: currentData.exercises.map((exercise) => + exercise.id === updatedExercise.id ? updatedExercise : exercise, + ), + }, + ); + }, + }); +} diff --git a/apps/web/src/features/routines/create/editor/routine-editor.tsx b/apps/web/src/features/routines/create/editor/routine-editor.tsx index e624447..3c6c379 100644 --- a/apps/web/src/features/routines/create/editor/routine-editor.tsx +++ b/apps/web/src/features/routines/create/editor/routine-editor.tsx @@ -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'; @@ -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 { @@ -199,6 +201,24 @@ function ExerciseSection({ setValue, } = useFormContext(); 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) { @@ -240,7 +260,7 @@ function ExerciseSection({ {exercises.length > 0 && ( void; onRemove: (position: number) => void; onReorder: (fromIndex: number, toIndex: number) => void; @@ -89,7 +93,7 @@ function RoutineExerciseRow({ onEdit, onRemove, }: { - exercise: RoutineDraftExerciseFormValues; + exercise: DisplayRoutineDraftExercise; index: number; onEdit: (exercise: RoutineDraftExerciseFormValues) => void; onRemove: (position: number) => void; diff --git a/apps/web/src/features/routines/create/editor/routine-exercise-order.test.ts b/apps/web/src/features/routines/create/editor/routine-exercise-order.test.ts index 2bcca7e..149b18f 100644 --- a/apps/web/src/features/routines/create/editor/routine-exercise-order.test.ts +++ b/apps/web/src/features/routines/create/editor/routine-exercise-order.test.ts @@ -7,7 +7,6 @@ const exercises: RoutineDraftExerciseFormValues[] = [ { draftExerciseId: '06d34dc0-8e4c-4bd0-9e3b-7b839b44e481', exerciseId: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e481', - name: 'Bicep Curl', notes: null, position: 0, restAfterExerciseSeconds: 0, @@ -20,7 +19,6 @@ const exercises: RoutineDraftExerciseFormValues[] = [ { draftExerciseId: '06d34dc0-8e4c-4bd0-9e3b-7b839b44e482', exerciseId: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e482', - name: 'Lateral Raise', notes: null, position: 1, restAfterExerciseSeconds: 0, @@ -33,7 +31,6 @@ const exercises: RoutineDraftExerciseFormValues[] = [ { draftExerciseId: '06d34dc0-8e4c-4bd0-9e3b-7b839b44e483', exerciseId: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e483', - name: 'Shoulder Press', notes: null, position: 2, restAfterExerciseSeconds: 0, @@ -50,11 +47,23 @@ describe('reorderRoutineExercises', () => { const reorderedExercises = reorderRoutineExercises(exercises, 2, 0); expect( - reorderedExercises.map(({ name, position }) => ({ name, position })), + reorderedExercises.map(({ exerciseId, position }) => ({ + exerciseId, + position, + })), ).toEqual([ - { name: 'Shoulder Press', position: 0 }, - { name: 'Bicep Curl', position: 1 }, - { name: 'Lateral Raise', position: 2 }, + { + exerciseId: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e483', + position: 0, + }, + { + exerciseId: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e481', + position: 1, + }, + { + exerciseId: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e482', + position: 2, + }, ]); }); }); diff --git a/apps/web/src/features/routines/create/exercise-configuration/edit-exercise-name-dialog.tsx b/apps/web/src/features/routines/create/exercise-configuration/edit-exercise-name-dialog.tsx new file mode 100644 index 0000000..426f9ca --- /dev/null +++ b/apps/web/src/features/routines/create/exercise-configuration/edit-exercise-name-dialog.tsx @@ -0,0 +1,130 @@ +/** Renames a custom exercise owned by the current user. */ +import { zodResolver } from '@hookform/resolvers/zod'; +import { + updateExerciseInputSchema, + type UpdateExerciseInput, +} from '@goforlift/contracts'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useUpdateExerciseNameMutation } from '@/features/exercises/exercises.mutation'; +import { cn } from '@/lib/utils'; + +type EditExerciseNameDialogProps = { + exerciseId: string; + name: string; +}; + +export function EditExerciseNameDialog({ + exerciseId, + name, +}: EditExerciseNameDialogProps) { + const [isOpen, setIsOpen] = useState(false); + const updateNameMutation = useUpdateExerciseNameMutation(); + const { + formState: { errors }, + handleSubmit, + register, + reset, + } = useForm({ + defaultValues: { name }, + mode: 'onSubmit', + resolver: zodResolver(updateExerciseInputSchema), + }); + + function handleOpenChange(open: boolean) { + setIsOpen(open); + + if (open) { + reset({ name }); + } else { + updateNameMutation.reset(); + } + } + + async function saveName(input: UpdateExerciseInput) { + const exercise = await updateNameMutation + .mutateAsync({ exerciseId, input }) + .catch(() => null); + + if (!exercise) { + return; + } + + handleOpenChange(false); + } + + return ( + + + + + + + Edit Exercise Name + + This changes the name everywhere this custom exercise appears. + + +
{ + event.stopPropagation(); + void handleSubmit(saveName)(event); + }} + > +
+ + + {errors.name && ( +

+ {errors.name.message} +

+ )} +
+ + {updateNameMutation.isError && ( +

+ Exercise name could not be updated. 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 554ac98..6d700ad 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 @@ -141,3 +141,66 @@ it('shows the selected exercise and initial configuration fields', async () => { expect(await screen.findByLabelText('Search exercises')).toBeVisible(); expect(fetchMock).toHaveBeenCalledTimes(1); }); + +it('allows a custom exercise name to be edited', 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 === 'PATCH') { + return Promise.resolve( + Response.json({ + id: exerciseId, + name: 'Renamed Curl', + description: null, + isCustom: true, + }), + ); + } + + 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: 'Edit name' })); + + const nameInput = screen.getByLabelText('Exercise Name'); + await user.clear(nameInput); + await user.type(nameInput, 'Renamed Curl'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(await screen.findByText('Renamed Curl')).toBeVisible(); + expect( + screen.getByRole('heading', { name: 'Configure Exercise' }), + ).toBeVisible(); + expect(screen.getByLabelText('Sets')).toBeVisible(); + expect(fetchMock).toHaveBeenCalledWith( + `/api/exercises/${exerciseId}`, + expect.objectContaining({ method: 'PATCH' }), + ); + + await user.click(screen.getByRole('button', { name: 'Done' })); + + expect( + await screen.findByRole('heading', { name: 'Exercises (1)' }), + ).toBeVisible(); + expect(screen.getByText('Renamed Curl')).toBeVisible(); + expect(fetchMock).toHaveBeenCalledTimes(3); +}); 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 f37436d..3c40ff2 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 { EditExerciseNameDialog } from './edit-exercise-name-dialog'; type RoutineExerciseFormValues = z.input< typeof createRoutineExerciseInputSchema @@ -133,7 +134,6 @@ function ExerciseConfigurationForm({ const configuredExercise = { ...input, draftExerciseId: existingExercise?.draftExerciseId ?? crypto.randomUUID(), - name: exercise.name, }; const currentExercises = getValues('exercises'); const exercises = existingExercise @@ -184,13 +184,10 @@ function ExerciseIdentity({ exercise }: { exercise: ExerciseSummary }) {

{exercise.name}

{exercise.isCustom && ( - + )}
diff --git a/apps/web/src/features/routines/create/routine-draft-form.tsx b/apps/web/src/features/routines/create/routine-draft-form.tsx index b7c9edf..ccec595 100644 --- a/apps/web/src/features/routines/create/routine-draft-form.tsx +++ b/apps/web/src/features/routines/create/routine-draft-form.tsx @@ -4,21 +4,18 @@ import { createRoutineInputSchema, createRoutineExerciseInputSchema, createRoutineScheduleInputSchema, - exerciseSummarySchema, routineNameSchema, } from '@goforlift/contracts'; import type { ReactNode } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { z } from 'zod'; -// The form draft keeps UI-only fields that the create API does not need. The -// name displays the exercise. exerciseId identifies the exercise type, but the -// same exercise may be added more than once, so draftExerciseId uniquely tracks -// each configured row while its position changes during reordering. The save -// mapper removes both UI-only fields before sending the request. +// exerciseId identifies the server exercise, but the same exercise may appear +// more than once in a routine. draftExerciseId therefore gives each configured +// row a stable UI identity while its position changes during reordering. The +// save mapper removes this UI-only ID before sending the request. const routineDraftExerciseSchema = createRoutineExerciseInputSchema.extend({ draftExerciseId: z.uuid(), - name: exerciseSummarySchema.shape.name, }); const routineDraftExercisesSchema = z diff --git a/apps/web/src/features/routines/create/routine-draft.mapper.test.ts b/apps/web/src/features/routines/create/routine-draft.mapper.test.ts index ffc5bf4..0f32c69 100644 --- a/apps/web/src/features/routines/create/routine-draft.mapper.test.ts +++ b/apps/web/src/features/routines/create/routine-draft.mapper.test.ts @@ -10,7 +10,6 @@ describe('toCreateRoutineInput', () => { { draftExerciseId: '06d34dc0-8e4c-4bd0-9e3b-7b839b44e486', exerciseId: '16d34dc0-8e4c-4bd0-9e3b-7b839b44e486', - name: 'Bicep Curl', position: 0, sets: 3, targetReps: 8, @@ -36,6 +35,5 @@ describe('toCreateRoutineInput', () => { notes: null, }); expect(input.exercises[0]).not.toHaveProperty('draftExerciseId'); - expect(input.exercises[0]).not.toHaveProperty('name'); }); }); diff --git a/packages/contracts/src/exercises/exercise.contract.ts b/packages/contracts/src/exercises/exercise.contract.ts index fab75fc..c3ce9cb 100644 --- a/packages/contracts/src/exercises/exercise.contract.ts +++ b/packages/contracts/src/exercises/exercise.contract.ts @@ -17,6 +17,12 @@ export const createExerciseInputSchema = z }) .strict(); +export const updateExerciseInputSchema = z + .object({ + name: createExerciseInputSchema.shape.name, + }) + .strict(); + export const exerciseSummarySchema = z.object({ id: z.uuid(), name: z.string().min(1).max(EXERCISE_NAME_MAX_LENGTH), @@ -31,3 +37,4 @@ export const exerciseListResponseSchema = z.object({ export type ExerciseSummary = z.infer; export type ExerciseListResponse = z.infer; export type CreateExerciseInput = z.infer; +export type UpdateExerciseInput = z.infer;