fix(blocker): disallow blocker end times before start time
All checks were successful
container-scan / Container Scan (pull_request) Successful in 2m50s
docker-build / docker (pull_request) Successful in 6m16s
tests / Tests (pull_request) Successful in 3m50s

This commit is contained in:
Dominik 2025-07-01 14:40:41 +02:00
parent 6ae22a23c7
commit c014b77f9b
Signed by: dominik
GPG key ID: 06A4003FC5049644
2 changed files with 169 additions and 110 deletions

View file

@ -25,14 +25,6 @@ export const BlockedSlotsSchema = zod
created_at: zod.date(), created_at: zod.date(),
updated_at: zod.date(), updated_at: zod.date(),
}) })
.refine(
(data) => {
return new Date(data.start_time) < new Date(data.end_time);
},
{
message: 'Start time must be before end time',
},
)
.openapi('BlockedSlotsSchema', { .openapi('BlockedSlotsSchema', {
description: 'Blocked time slot in the user calendar', description: 'Blocked time slot in the user calendar',
}); });
@ -47,11 +39,37 @@ export const BlockedSlotResponseSchema = zod.object({
blocked_slot: BlockedSlotsSchema, blocked_slot: BlockedSlotsSchema,
}); });
export const createBlockedSlotSchema = BlockedSlotsSchema.omit({ export const createBlockedSlotSchema = zod
id: true, .object({
created_at: true, start_time: eventStartTimeSchema,
updated_at: true, end_time: eventEndTimeSchema,
}); reason: zod.string().nullish(),
})
.refine(
(data) => {
return new Date(data.start_time) < new Date(data.end_time);
},
{
message: 'Start time must be before end time',
path: ['end_time'],
},
);
export const createBlockedSlotClientSchema = zod
.object({
start_time: zod.iso.datetime({ local: true }),
end_time: zod.iso.datetime({ local: true }),
reason: zod.string().nullish(),
})
.refine(
(data) => {
return new Date(data.start_time) < new Date(data.end_time);
},
{
message: 'Start time must be before end time',
path: ['end_time'],
},
);
export const updateBlockedSlotSchema = zod.object({ export const updateBlockedSlotSchema = zod.object({
start_time: eventStartTimeSchema.optional(), start_time: eventStartTimeSchema.optional(),

View file

@ -3,7 +3,7 @@
import useZodForm from '@/lib/hooks/useZodForm'; import useZodForm from '@/lib/hooks/useZodForm';
import { import {
updateBlockedSlotSchema, updateBlockedSlotSchema,
createBlockedSlotSchema, createBlockedSlotClientSchema,
} from '@/app/api/blocked_slots/validation'; } from '@/app/api/blocked_slots/validation';
import { import {
useGetApiBlockedSlotsSlotID, useGetApiBlockedSlotsSlotID,
@ -40,12 +40,7 @@ export default function BlockedSlotForm({
handleSubmit: handleCreateSubmit, handleSubmit: handleCreateSubmit,
formState: formStateCreate, formState: formStateCreate,
reset: resetCreate, reset: resetCreate,
} = useZodForm( } = useZodForm(createBlockedSlotClientSchema);
createBlockedSlotSchema.extend({
start_time: eventStartTimeSchema.or(zod.iso.datetime({ local: true })),
end_time: eventStartTimeSchema.or(zod.iso.datetime({ local: true })),
}),
);
const { const {
register: registerUpdate, register: registerUpdate,
@ -145,6 +140,7 @@ export default function BlockedSlotForm({
}); });
}); });
if (existingBlockedSlotId)
return ( return (
<div className='flex items-center justify-center h-full'> <div className='flex items-center justify-center h-full'>
<Card className='w-[max(80%, 500px)] max-w-screen p-0 gap-0 max-xl:w-[95%] mx-auto'> <Card className='w-[max(80%, 500px)] max-w-screen p-0 gap-0 max-xl:w-[95%] mx-auto'>
@ -154,55 +150,36 @@ export default function BlockedSlotForm({
<Logo colorType='monochrome' logoType='submark' width={50} /> <Logo colorType='monochrome' logoType='submark' width={50} />
</div> </div>
<div className='items-center ml-auto mr-auto max-sm:mb-6 max-sm:w-full max-sm:flex max-sm:justify-center'> <div className='items-center ml-auto mr-auto max-sm:mb-6 max-sm:w-full max-sm:flex max-sm:justify-center'>
<h1 className='text-center'> <h1 className='text-center'>{'Update Blocker'}</h1>
{existingBlockedSlotId ? 'Update Blocker' : 'Create Blocker'}
</h1>
</div> </div>
<div className='w-0 sm:w-[100px]'></div> <div className='w-0 sm:w-[100px]'></div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form <form onSubmit={onUpdateSubmit}>
onSubmit={existingBlockedSlotId ? onUpdateSubmit : onCreateSubmit}
>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3'> <div className='grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3'>
<LabeledInput <LabeledInput
label='Start Time' label='Start Time'
type='datetime-local' type='datetime-local'
id='start_time' id='start_time'
{...(existingBlockedSlotId {...registerUpdate('start_time')}
? registerUpdate('start_time') error={formStateUpdate.errors.start_time?.message}
: registerCreate('start_time'))}
error={
formStateCreate.errors.start_time?.message ||
formStateUpdate.errors.start_time?.message
}
required required
/> />
<LabeledInput <LabeledInput
label='End Time' label='End Time'
type='datetime-local' type='datetime-local'
id='end_time' id='end_time'
{...(existingBlockedSlotId {...registerUpdate('end_time')}
? registerUpdate('end_time') error={formStateUpdate.errors.end_time?.message}
: registerCreate('end_time'))}
error={
formStateCreate.errors.end_time?.message ||
formStateUpdate.errors.end_time?.message
}
required required
/> />
<LabeledInput <LabeledInput
label='Reason' label='Reason'
type='text' type='text'
id='reason' id='reason'
{...(existingBlockedSlotId {...registerUpdate('reason')}
? registerUpdate('reason') error={formStateUpdate.errors.reason?.message}
: registerCreate('reason'))}
error={
formStateCreate.errors.reason?.message ||
formStateUpdate.errors.reason?.message
}
placeholder='Optional reason for blocking this slot' placeholder='Optional reason for blocking this slot'
/> />
</div> </div>
@ -210,11 +187,80 @@ export default function BlockedSlotForm({
<Button <Button
type='submit' type='submit'
variant='primary' variant='primary'
disabled={ disabled={formStateUpdate.isSubmitting}
formStateCreate.isSubmitting || formStateUpdate.isSubmitting
}
> >
{existingBlockedSlotId ? 'Update Blocker' : 'Create Blocker'} {'Update Blocker'}
</Button>
{existingBlockedSlotId && (
<Button
type='button'
variant='destructive'
onClick={onDeleteSubmit}
>
Delete Blocker
</Button>
)}
</div>
{formStateUpdate.errors.root && (
<p className='text-red-500 text-sm mt-1'>
{formStateUpdate.errors.root.message}
</p>
)}
</form>
</CardContent>
</Card>
</div>
);
else
return (
<div className='flex items-center justify-center h-full'>
<Card className='w-[max(80%, 500px)] max-w-screen p-0 gap-0 max-xl:w-[95%] mx-auto'>
<CardHeader className='p-0 m-0 gap-0 px-6'>
<div className='h-full mt-0 ml-2 mb-16 flex items-center justify-between max-sm:grid max-sm:grid-row-start:auto max-sm:mb-6 max-sm:mt-10 max-sm:ml-0'>
<div className='w-[100px] max-sm:w-full max-sm:flex max-sm:justify-center'>
<Logo colorType='monochrome' logoType='submark' width={50} />
</div>
<div className='items-center ml-auto mr-auto max-sm:mb-6 max-sm:w-full max-sm:flex max-sm:justify-center'>
<h1 className='text-center'>{'Create Blocker'}</h1>
</div>
<div className='w-0 sm:w-[100px]'></div>
</div>
</CardHeader>
<CardContent>
<form onSubmit={onCreateSubmit}>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3'>
<LabeledInput
label='Start Time'
type='datetime-local'
id='start_time'
{...registerCreate('start_time')}
error={formStateCreate.errors.start_time?.message}
required
/>
<LabeledInput
label='End Time'
type='datetime-local'
id='end_time'
{...registerCreate('end_time')}
error={formStateCreate.errors.end_time?.message}
required
/>
<LabeledInput
label='Reason'
type='text'
id='reason'
{...registerCreate('reason')}
error={formStateCreate.errors.reason?.message}
placeholder='Optional reason for blocking this slot'
/>
</div>
<div className='flex justify-end gap-2 p-4'>
<Button
type='submit'
variant='primary'
disabled={formStateCreate.isSubmitting}
>
{'Create Blocker'}
</Button> </Button>
{existingBlockedSlotId && ( {existingBlockedSlotId && (
<Button <Button
@ -231,11 +277,6 @@ export default function BlockedSlotForm({
{formStateCreate.errors.root.message} {formStateCreate.errors.root.message}
</p> </p>
)} )}
{formStateUpdate.errors.root && (
<p className='text-red-500 text-sm mt-1'>
{formStateUpdate.errors.root.message}
</p>
)}
</form> </form>
</CardContent> </CardContent>
</Card> </Card>