feat(blocked_slots): add blocked slots
This commit is contained in:
parent
ce27923118
commit
016b4371c2
17 changed files with 1038 additions and 36 deletions
10
src/app/(main)/blocked_slots/[slotId]/page.tsx
Normal file
10
src/app/(main)/blocked_slots/[slotId]/page.tsx
Normal 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} />;
|
||||
}
|
5
src/app/(main)/blocked_slots/new/page.tsx
Normal file
5
src/app/(main)/blocked_slots/new/page.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import BlockedSlotForm from '@/components/forms/blocked-slot-form';
|
||||
|
||||
export default function NewBlockedSlotPage() {
|
||||
return <BlockedSlotForm />;
|
||||
}
|
56
src/app/(main)/blocked_slots/page.tsx
Normal file
56
src/app/(main)/blocked_slots/page.tsx
Normal 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't have any blocked slots right now
|
||||
</Label>
|
||||
<RedirectButton
|
||||
redirectUrl='/blocked_slots/new'
|
||||
buttonText='create Blocked Slot'
|
||||
className='mt-4'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
165
src/app/api/blocked_slots/[slotID]/route.ts
Normal file
165
src/app/api/blocked_slots/[slotID]/route.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
});
|
90
src/app/api/blocked_slots/[slotID]/swagger.ts
Normal file
90
src/app/api/blocked_slots/[slotID]/swagger.ts
Normal 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'],
|
||||
});
|
||||
}
|
127
src/app/api/blocked_slots/route.ts
Normal file
127
src/app/api/blocked_slots/route.ts
Normal 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,
|
||||
});
|
||||
});
|
66
src/app/api/blocked_slots/swagger.ts
Normal file
66
src/app/api/blocked_slots/swagger.ts
Normal 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'],
|
||||
});
|
||||
}
|
52
src/app/api/blocked_slots/validation.ts
Normal file
52
src/app/api/blocked_slots/validation.ts
Normal 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(),
|
||||
});
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
}),
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue