feat(blocked_slots): add blocked slots

This commit is contained in:
Dominik 2025-06-30 20:13:56 +02:00
parent ce27923118
commit 016b4371c2
Signed by: dominik
GPG key ID: 06A4003FC5049644
17 changed files with 1038 additions and 36 deletions

View file

@ -0,0 +1,10 @@
import BlockedSlotForm from '@/components/forms/blocked-slot-form';
export default async function NewBlockedSlotPage({
params,
}: {
params: Promise<{ slotId?: string }>;
}) {
const resolvedParams = await params;
return <BlockedSlotForm existingBlockedSlotId={resolvedParams.slotId} />;
}

View file

@ -0,0 +1,5 @@
import BlockedSlotForm from '@/components/forms/blocked-slot-form';
export default function NewBlockedSlotPage() {
return <BlockedSlotForm />;
}

View file

@ -0,0 +1,56 @@
'use client';
import { RedirectButton } from '@/components/buttons/redirect-button';
import BlockedSlotListEntry from '@/components/custom-ui/blocked-slot-list-entry';
import { Label } from '@/components/ui/label';
import { useGetApiBlockedSlots } from '@/generated/api/blocked-slots/blocked-slots';
export default function BlockedSlots() {
const { data: blockedSlotsData, isLoading, error } = useGetApiBlockedSlots();
if (isLoading) return <div className='text-center mt-10'>Loading...</div>;
if (error)
return (
<div className='text-center mt-10 text-red-500'>
Error loading blocked slots
</div>
);
const blockedSlots = blockedSlotsData?.data?.blocked_slots || [];
return (
<div className='relative h-full flex flex-col items-center'>
{/* Heading */}
<h1 className='text-3xl font-bold mt-8 mb-4 text-center z-10'>
My Blocked Slots
</h1>
{/* Scrollable blocked slot list */}
<div className='w-full flex justify-center overflow-hidden'>
<div className='grid gap-8 w-[max(90%, 500px)] p-6 overflow-y-auto'>
{blockedSlots.length > 0 ? (
blockedSlots.map((slot) => (
<BlockedSlotListEntry
key={slot.id}
{...slot}
updated_at={new Date(slot.updated_at)}
created_at={new Date(slot.created_at)}
/>
))
) : (
<div className='flex flex-1 flex-col items-center justify-center min-h-[300px]'>
<Label size='large' className='justify-center text-center'>
You don&#39;t have any blocked slots right now
</Label>
<RedirectButton
redirectUrl='/blocked_slots/new'
buttonText='create Blocked Slot'
className='mt-4'
/>
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -155,7 +155,11 @@ export default function ShowEvent() {
</Label>{' '}
<div className='grid grid-cols-1 mt-3 sm:max-h-60 sm:grid-cols-2 sm:overflow-y-auto sm:mb-0'>
{event.participants?.map((user) => (
<ParticipantListEntry key={user.user.id} {...user} />
<ParticipantListEntry
key={user.user.id}
{...user}
eventID={event.id}
/>
))}
</div>
</div>

View file

@ -0,0 +1,165 @@
import { auth } from '@/auth';
import { prisma } from '@/prisma';
import {
returnZodTypeCheckedResponse,
userAuthenticated,
} from '@/lib/apiHelpers';
import {
updateBlockedSlotSchema,
BlockedSlotResponseSchema,
} from '@/app/api/blocked_slots/validation';
import {
ErrorResponseSchema,
SuccessResponseSchema,
ZodErrorResponseSchema,
} from '@/app/api/validation';
export const GET = auth(async function GET(req, { params }) {
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const slotID = (await params).slotID;
const blockedSlot = await prisma.blockedSlot.findUnique({
where: {
id: slotID,
user_id: authCheck.user.id,
},
select: {
id: true,
start_time: true,
end_time: true,
reason: true,
created_at: true,
updated_at: true,
is_recurring: true,
recurrence_end_date: true,
rrule: true,
},
});
if (!blockedSlot) {
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{
success: false,
message: 'Blocked slot not found or not owned by user',
},
{ status: 404 },
);
}
return returnZodTypeCheckedResponse(
BlockedSlotResponseSchema,
{
blocked_slot: blockedSlot,
},
{
status: 200,
},
);
});
export const PATCH = auth(async function PATCH(req, { params }) {
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const slotID = (await params).slotID;
const dataRaw = await req.json();
const data = await updateBlockedSlotSchema.safeParseAsync(dataRaw);
if (!data.success)
return returnZodTypeCheckedResponse(
ZodErrorResponseSchema,
{
success: false,
message: 'Invalid request data',
errors: data.error.issues,
},
{ status: 400 },
);
const blockedSlot = await prisma.blockedSlot.update({
where: {
id: slotID,
user_id: authCheck.user.id,
},
data: data.data,
select: {
id: true,
start_time: true,
end_time: true,
reason: true,
created_at: true,
updated_at: true,
is_recurring: true,
recurrence_end_date: true,
rrule: true,
},
});
if (!blockedSlot) {
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{
success: false,
message: 'Blocked slot not found or not owned by user',
},
{ status: 404 },
);
}
return returnZodTypeCheckedResponse(
BlockedSlotResponseSchema,
{ success: true, blocked_slot: blockedSlot },
{ status: 200 },
);
});
export const DELETE = auth(async function DELETE(req, { params }) {
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const slotID = (await params).slotID;
const deletedSlot = await prisma.blockedSlot.delete({
where: {
id: slotID,
user_id: authCheck.user.id,
},
});
if (!deletedSlot) {
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{
success: false,
message: 'Blocked slot not found or not owned by user',
},
{ status: 404 },
);
}
return returnZodTypeCheckedResponse(
SuccessResponseSchema,
{ success: true },
{
status: 200,
},
);
});

View file

@ -0,0 +1,90 @@
import {
updateBlockedSlotSchema,
BlockedSlotResponseSchema,
} from '@/app/api/blocked_slots/validation';
import {
notAuthenticatedResponse,
serverReturnedDataValidationErrorResponse,
userNotFoundResponse,
invalidRequestDataResponse,
} from '@/lib/defaultApiResponses';
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import { SlotIdParamSchema } from '@/app/api/validation';
import zod from 'zod/v4';
export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
registry.registerPath({
method: 'get',
path: '/api/blocked_slots/{slotID}',
request: {
params: zod.object({
slotID: SlotIdParamSchema,
}),
},
responses: {
200: {
description: 'Blocked slot retrieved successfully',
content: {
'application/json': {
schema: BlockedSlotResponseSchema,
},
},
},
...userNotFoundResponse,
...notAuthenticatedResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['Blocked Slots'],
});
registry.registerPath({
method: 'delete',
path: '/api/blocked_slots/{slotID}',
request: {
params: zod.object({
slotID: SlotIdParamSchema,
}),
},
responses: {
204: {
description: 'Blocked slot deleted successfully',
},
...userNotFoundResponse,
...notAuthenticatedResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['Blocked Slots'],
});
registry.registerPath({
method: 'patch',
path: '/api/blocked_slots/{slotID}',
request: {
params: zod.object({
slotID: SlotIdParamSchema,
}),
body: {
content: {
'application/json': {
schema: updateBlockedSlotSchema,
},
},
},
},
responses: {
200: {
description: 'Blocked slot updated successfully',
content: {
'application/json': {
schema: BlockedSlotResponseSchema,
},
},
},
...userNotFoundResponse,
...notAuthenticatedResponse,
...serverReturnedDataValidationErrorResponse,
...invalidRequestDataResponse,
},
tags: ['Blocked Slots'],
});
}

View file

@ -0,0 +1,127 @@
import { auth } from '@/auth';
import { prisma } from '@/prisma';
import {
returnZodTypeCheckedResponse,
userAuthenticated,
} from '@/lib/apiHelpers';
import {
blockedSlotsQuerySchema,
BlockedSlotsResponseSchema,
BlockedSlotsSchema,
createBlockedSlotSchema,
} from './validation';
import {
ErrorResponseSchema,
ZodErrorResponseSchema,
} from '@/app/api/validation';
export const GET = auth(async function GET(req) {
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const dataRaw: Record<string, string | string[]> = {};
for (const [key, value] of req.nextUrl.searchParams.entries()) {
if (key.endsWith('[]')) {
const cleanKey = key.slice(0, -2);
if (!dataRaw[cleanKey]) {
dataRaw[cleanKey] = [];
}
if (Array.isArray(dataRaw[cleanKey])) {
(dataRaw[cleanKey] as string[]).push(value);
} else {
dataRaw[cleanKey] = [dataRaw[cleanKey] as string, value];
}
} else {
dataRaw[key] = value;
}
}
const data = await blockedSlotsQuerySchema.safeParseAsync(dataRaw);
if (!data.success)
return returnZodTypeCheckedResponse(
ZodErrorResponseSchema,
{
success: false,
message: 'Invalid request data',
errors: data.error.issues,
},
{ status: 400 },
);
const { start, end } = data.data;
const requestUserId = authCheck.user.id;
const blockedSlots = await prisma.blockedSlot.findMany({
where: {
user_id: requestUserId,
start_time: { gte: start },
end_time: { lte: end },
},
orderBy: { start_time: 'asc' },
select: {
id: true,
start_time: true,
end_time: true,
reason: true,
created_at: true,
updated_at: true,
},
});
return returnZodTypeCheckedResponse(
BlockedSlotsResponseSchema,
{ success: true, blocked_slots: blockedSlots },
{ status: 200 },
);
});
export const POST = auth(async function POST(req) {
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const dataRaw = await req.json();
const data = await createBlockedSlotSchema.safeParseAsync(dataRaw);
if (!data.success)
return returnZodTypeCheckedResponse(
ZodErrorResponseSchema,
{
success: false,
message: 'Invalid request data',
errors: data.error.issues,
},
{ status: 400 },
);
const requestUserId = authCheck.user.id;
if (!requestUserId) {
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{
success: false,
message: 'User not authenticated',
},
{ status: 401 },
);
}
const blockedSlot = await prisma.blockedSlot.create({
data: {
...data.data,
user_id: requestUserId,
},
});
return returnZodTypeCheckedResponse(BlockedSlotsSchema, blockedSlot, {
status: 201,
});
});

View file

@ -0,0 +1,66 @@
import {
BlockedSlotResponseSchema,
BlockedSlotsResponseSchema,
blockedSlotsQuerySchema,
createBlockedSlotSchema,
} from './validation';
import {
invalidRequestDataResponse,
notAuthenticatedResponse,
serverReturnedDataValidationErrorResponse,
userNotFoundResponse,
} from '@/lib/defaultApiResponses';
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
registry.registerPath({
method: 'get',
path: '/api/blocked_slots',
request: {
query: blockedSlotsQuerySchema,
},
responses: {
200: {
description: 'Blocked slots retrieved successfully.',
content: {
'application/json': {
schema: BlockedSlotsResponseSchema,
},
},
},
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['Blocked Slots'],
});
registry.registerPath({
method: 'post',
path: '/api/blocked_slots',
request: {
body: {
content: {
'application/json': {
schema: createBlockedSlotSchema,
},
},
},
},
responses: {
201: {
description: 'Blocked slot created successfully.',
content: {
'application/json': {
schema: BlockedSlotResponseSchema,
},
},
},
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
...invalidRequestDataResponse,
},
tags: ['Blocked Slots'],
});
}

View file

@ -0,0 +1,52 @@
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
import zod from 'zod/v4';
import {
eventEndTimeSchema,
eventStartTimeSchema,
} from '@/app/api/event/validation';
extendZodWithOpenApi(zod);
export const blockedSlotsQuerySchema = zod.object({
start: eventStartTimeSchema.optional(),
end: eventEndTimeSchema.optional(),
});
export const blockedSlotRecurrenceEndDateSchema = zod.iso
.datetime()
.or(zod.date().transform((date) => date.toISOString()));
export const BlockedSlotsSchema = zod
.object({
start_time: eventStartTimeSchema,
end_time: eventEndTimeSchema,
id: zod.string(),
reason: zod.string().nullish(),
created_at: zod.date(),
updated_at: zod.date(),
})
.openapi('BlockedSlotsSchema', {
description: 'Blocked time slot in the user calendar',
});
export const BlockedSlotsResponseSchema = zod.object({
success: zod.boolean().default(true),
blocked_slots: zod.array(BlockedSlotsSchema),
});
export const BlockedSlotResponseSchema = zod.object({
success: zod.boolean().default(true),
blocked_slot: BlockedSlotsSchema,
});
export const createBlockedSlotSchema = BlockedSlotsSchema.omit({
id: true,
created_at: true,
updated_at: true,
});
export const updateBlockedSlotSchema = zod.object({
start_time: eventStartTimeSchema.optional(),
end_time: eventEndTimeSchema.optional(),
reason: zod.string().optional(),
});

View file

@ -48,6 +48,7 @@ export const VisibleSlotSchema = EventSchema.omit({
type: zod.literal('event'),
users: zod.string().array(),
user_id: zod.string().optional(),
organizer_id: zod.string().optional(),
})
.openapi('VisibleSlotSchema', {
description: 'Visible time slot in the user calendar',

View file

@ -85,3 +85,14 @@ export const EventIdParamSchema = registry.registerParameter(
example: '67890',
}),
);
export const SlotIdParamSchema = registry.registerParameter(
'SlotIdParam',
zod.string().openapi({
param: {
name: 'slotID',
in: 'path',
},
example: 'abcde12345',
}),
);