feat: add notifications support
This commit is contained in:
parent
1a9a299c9c
commit
511acbca62
12 changed files with 483 additions and 33 deletions
|
@ -45,6 +45,12 @@ enum notification_type {
|
|||
CALENDAR_SYNC_ERROR
|
||||
}
|
||||
|
||||
enum entity_type {
|
||||
USER
|
||||
MEETING
|
||||
GROUP
|
||||
}
|
||||
|
||||
enum group_member_role {
|
||||
ADMIN
|
||||
MEMBER
|
||||
|
@ -253,8 +259,8 @@ model Notification {
|
|||
id String @id @default(cuid())
|
||||
user_id String
|
||||
type notification_type
|
||||
related_entity_type String?
|
||||
related_entity_id String?
|
||||
related_entity_type entity_type
|
||||
related_entity_id String
|
||||
message String
|
||||
is_read Boolean @default(false)
|
||||
created_at DateTime @default(now())
|
||||
|
|
|
@ -129,6 +129,17 @@ export const DELETE = auth(async (req, { params }) => {
|
|||
{ participants: { some: { user_id: dbUser.id } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
participants: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!event)
|
||||
|
@ -151,6 +162,18 @@ export const DELETE = auth(async (req, { params }) => {
|
|||
},
|
||||
});
|
||||
|
||||
for (const participant of event.participants) {
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
user_id: participant.user.id,
|
||||
type: 'MEETING_CANCEL',
|
||||
related_entity_id: eventID,
|
||||
related_entity_type: 'MEETING',
|
||||
message: `The event "${event.title}" has been cancelled by the organizer.`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return returnZodTypeCheckedResponse(
|
||||
SuccessResponseSchema,
|
||||
{ success: true, message: 'Event deleted successfully' },
|
||||
|
@ -190,6 +213,17 @@ export const PATCH = auth(async (req, { params }) => {
|
|||
{ participants: { some: { user_id: dbUser.id } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
participants: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!event)
|
||||
|
@ -244,8 +278,33 @@ export const PATCH = auth(async (req, { params }) => {
|
|||
},
|
||||
update: {},
|
||||
});
|
||||
if (event.participants.some((p) => p.user.id === participant)) {
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
user_id: participant,
|
||||
type: 'MEETING_UPDATE',
|
||||
related_entity_id: eventID,
|
||||
related_entity_type: 'MEETING',
|
||||
message: `The event "${event.title}" has been updated.`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const participant of event.participants) {
|
||||
if (participants && !participants.includes(participant.user.id)) {
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
user_id: participant.user.id,
|
||||
type: 'MEETING_CANCEL',
|
||||
related_entity_id: eventID,
|
||||
related_entity_type: 'MEETING',
|
||||
message: `You have been removed from the event "${event.title}".`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updatedEvent = await prisma.meeting.update({
|
||||
where: {
|
||||
id: eventID,
|
||||
|
|
|
@ -167,6 +167,18 @@ export const POST = auth(async (req) => {
|
|||
},
|
||||
});
|
||||
|
||||
for (const participant of newEvent.participants) {
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
user_id: participant.user.id,
|
||||
type: 'MEETING_INVITE',
|
||||
related_entity_type: 'MEETING',
|
||||
related_entity_id: newEvent.id,
|
||||
message: `You have been invited to the meeting "${newEvent.title}" by ${newEvent.organizer.first_name} ${newEvent.organizer.last_name}.`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return returnZodTypeCheckedResponse(
|
||||
EventResponseSchema,
|
||||
{
|
||||
|
|
77
src/app/api/notifications/[notification]/route.ts
Normal file
77
src/app/api/notifications/[notification]/route.ts
Normal file
|
@ -0,0 +1,77 @@
|
|||
import { auth } from '@/auth';
|
||||
import { prisma } from '@/prisma';
|
||||
import {
|
||||
getNotificationResponseSchema,
|
||||
updateNotificationRequestSchema,
|
||||
} from '../validation';
|
||||
import {
|
||||
returnZodTypeCheckedResponse,
|
||||
userAuthenticated,
|
||||
} from '@/lib/apiHelpers';
|
||||
import {
|
||||
ErrorResponseSchema,
|
||||
ZodErrorResponseSchema,
|
||||
} from '@/app/api/validation';
|
||||
|
||||
export const PATCH = auth(async function GET(req, { params }: { params: Promise<{ notification: string }> }) {
|
||||
const authCheck = userAuthenticated(req);
|
||||
if (!authCheck.continue)
|
||||
return returnZodTypeCheckedResponse(
|
||||
ErrorResponseSchema,
|
||||
authCheck.response,
|
||||
authCheck.metadata,
|
||||
);
|
||||
|
||||
const userId = authCheck.user.id;
|
||||
|
||||
const notificationId = (await params).notification;
|
||||
|
||||
const dataRaw = await req.json();
|
||||
const data = await updateNotificationRequestSchema.safeParseAsync(dataRaw);
|
||||
if (!data.success)
|
||||
return returnZodTypeCheckedResponse(
|
||||
ZodErrorResponseSchema,
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid request data',
|
||||
errors: data.error.issues,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
const { is_read } = data.data;
|
||||
|
||||
const notification = await prisma.notification.update({
|
||||
where: {
|
||||
id: notificationId,
|
||||
user_id: userId,
|
||||
},
|
||||
data: {
|
||||
is_read,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
related_entity_id: true,
|
||||
related_entity_type: true,
|
||||
message: true,
|
||||
is_read: true,
|
||||
created_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!notification)
|
||||
return returnZodTypeCheckedResponse(
|
||||
ErrorResponseSchema,
|
||||
{ success: false, message: 'Notification not found or you do not have permission to update it' },
|
||||
{ status: 404 },
|
||||
);
|
||||
|
||||
return returnZodTypeCheckedResponse(
|
||||
getNotificationResponseSchema,
|
||||
{
|
||||
success: true,
|
||||
notification,
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
});
|
44
src/app/api/notifications/[notification]/swagger.ts
Normal file
44
src/app/api/notifications/[notification]/swagger.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
|
||||
import {
|
||||
invalidRequestDataResponse,
|
||||
notAuthenticatedResponse,
|
||||
serverReturnedDataValidationErrorResponse,
|
||||
userNotFoundResponse,
|
||||
} from '@/lib/defaultApiResponses';
|
||||
import { getNotificationResponseSchema, updateNotificationRequestSchema } from '../validation';
|
||||
import zod from 'zod/v4';
|
||||
import { NotificationIdSchema } from '../../validation';
|
||||
|
||||
export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
|
||||
registry.registerPath({
|
||||
method: 'patch',
|
||||
path: '/api/notifications/{notification}',
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: updateNotificationRequestSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
params: zod.object({
|
||||
notification: NotificationIdSchema,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Notification updated successfully',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: getNotificationResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
...invalidRequestDataResponse,
|
||||
...notAuthenticatedResponse,
|
||||
...userNotFoundResponse,
|
||||
...serverReturnedDataValidationErrorResponse,
|
||||
},
|
||||
tags: ['Notifications'],
|
||||
});
|
||||
}
|
80
src/app/api/notifications/route.ts
Normal file
80
src/app/api/notifications/route.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
import { auth } from '@/auth';
|
||||
import { prisma } from '@/prisma';
|
||||
import {
|
||||
getNotificationsRequestSchema,
|
||||
getNotificationsResponseSchema,
|
||||
} from './validation';
|
||||
import {
|
||||
returnZodTypeCheckedResponse,
|
||||
userAuthenticated,
|
||||
} from '@/lib/apiHelpers';
|
||||
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 userId = authCheck.user.id;
|
||||
|
||||
const dataRaw = Object.fromEntries(new URL(req.url).searchParams);
|
||||
const data = await getNotificationsRequestSchema.safeParseAsync(dataRaw);
|
||||
if (!data.success)
|
||||
return returnZodTypeCheckedResponse(
|
||||
ZodErrorResponseSchema,
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid request data',
|
||||
errors: data.error.issues,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
const { skip, all } = data.data;
|
||||
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: {
|
||||
user_id: userId,
|
||||
created_at: all
|
||||
? undefined
|
||||
: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
|
||||
},
|
||||
orderBy: { created_at: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
related_entity_id: true,
|
||||
related_entity_type: true,
|
||||
message: true,
|
||||
is_read: true,
|
||||
created_at: true,
|
||||
},
|
||||
take: 50,
|
||||
skip,
|
||||
});
|
||||
|
||||
const total_count = await prisma.notification.count({
|
||||
where: { user_id: userId },
|
||||
});
|
||||
|
||||
const unread_count = await prisma.notification.count({
|
||||
where: { user_id: userId, is_read: false },
|
||||
});
|
||||
|
||||
return returnZodTypeCheckedResponse(
|
||||
getNotificationsResponseSchema,
|
||||
{
|
||||
success: true,
|
||||
notifications,
|
||||
total_count,
|
||||
unread_count,
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
});
|
36
src/app/api/notifications/swagger.ts
Normal file
36
src/app/api/notifications/swagger.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
|
||||
import {
|
||||
getNotificationsRequestSchema,
|
||||
getNotificationsResponseSchema,
|
||||
} from './validation';
|
||||
import {
|
||||
invalidRequestDataResponse,
|
||||
notAuthenticatedResponse,
|
||||
serverReturnedDataValidationErrorResponse,
|
||||
userNotFoundResponse,
|
||||
} from '@/lib/defaultApiResponses';
|
||||
|
||||
export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
|
||||
registry.registerPath({
|
||||
method: 'get',
|
||||
path: '/api/notifications',
|
||||
request: {
|
||||
query: getNotificationsRequestSchema,
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'List of notifications for the authenticated user',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: getNotificationsResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
...invalidRequestDataResponse,
|
||||
...notAuthenticatedResponse,
|
||||
...userNotFoundResponse,
|
||||
...serverReturnedDataValidationErrorResponse,
|
||||
},
|
||||
tags: ['Notifications'],
|
||||
});
|
||||
}
|
44
src/app/api/notifications/validation.ts
Normal file
44
src/app/api/notifications/validation.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import zod from 'zod/v4';
|
||||
|
||||
export const notificationsSchema = zod.object({
|
||||
id: zod.string(),
|
||||
type: zod.enum([
|
||||
'FRIEND_REQUEST',
|
||||
'FRIEND_ACCEPT',
|
||||
'MEETING_INVITE',
|
||||
'MEETING_UPDATE',
|
||||
'MEETING_CANCEL',
|
||||
'MEETING_REMINDER',
|
||||
'GROUP_MEMBER_ADDED',
|
||||
'CALENDAR_SYNC_ERROR',
|
||||
]),
|
||||
related_entity_id: zod.string().nullable(),
|
||||
related_entity_type: zod.enum(['USER', 'MEETING', 'GROUP']),
|
||||
message: zod.string().max(500),
|
||||
is_read: zod.boolean(),
|
||||
created_at: zod.date(),
|
||||
}).openapi('Notification');
|
||||
|
||||
export const getNotificationsResponseSchema = zod.object({
|
||||
success: zod.boolean(),
|
||||
notifications: zod.array(notificationsSchema),
|
||||
total_count: zod.number(),
|
||||
unread_count: zod.number(),
|
||||
});
|
||||
|
||||
export const getNotificationResponseSchema = zod.object({
|
||||
success: zod.boolean(),
|
||||
notification: notificationsSchema,
|
||||
}).openapi('GetNotificationResponse');
|
||||
|
||||
export const getNotificationsRequestSchema = zod.object({
|
||||
skip: zod.string().transform((val) => {
|
||||
const num = parseInt(val, 10);
|
||||
return isNaN(num) ? 0 : num;
|
||||
}).default(0),
|
||||
all: zod.boolean().default(false),
|
||||
}).openapi('GetNotificationsRequest');
|
||||
|
||||
export const updateNotificationRequestSchema = zod.object({
|
||||
is_read: zod.boolean(),
|
||||
}).openapi('UpdateNotificationRequest');
|
|
@ -85,3 +85,14 @@ export const EventIdParamSchema = registry.registerParameter(
|
|||
example: '67890',
|
||||
}),
|
||||
);
|
||||
|
||||
export const NotificationIdSchema = registry.registerParameter(
|
||||
'NotificationIdParam',
|
||||
zod.string().openapi({
|
||||
param: {
|
||||
name: 'notification',
|
||||
in: 'path',
|
||||
},
|
||||
example: '12345',
|
||||
}),
|
||||
);
|
|
@ -8,24 +8,28 @@ import { NDot, NotificationDot } from '@/components/misc/notification-dot';
|
|||
|
||||
export function NotificationButton({
|
||||
dotVariant,
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
dotVariant?: NDot;
|
||||
children: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
} & React.ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type='button' variant='outline_primary' {...props}>
|
||||
{children}
|
||||
{icon}
|
||||
<NotificationDot
|
||||
dotVariant={dotVariant}
|
||||
className='absolute ml-[30px] mt-[30px]'
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'></DropdownMenuContent>
|
||||
<DropdownMenuContent align='end'>
|
||||
{children}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,28 +1,34 @@
|
|||
'use client';
|
||||
|
||||
import { SidebarTrigger } from '@/components/custom-ui/sidebar';
|
||||
import { ThemePicker } from '@/components/misc/theme-picker';
|
||||
import { NotificationButton } from '@/components/buttons/notification-button';
|
||||
|
||||
import { BellRing, Inbox } from 'lucide-react';
|
||||
import UserDropdown from '@/components/misc/user-dropdown';
|
||||
|
||||
const items = [
|
||||
{
|
||||
title: 'Calendar',
|
||||
url: '#',
|
||||
icon: Inbox,
|
||||
},
|
||||
{
|
||||
title: 'Friends',
|
||||
url: '#',
|
||||
icon: BellRing,
|
||||
},
|
||||
];
|
||||
import {
|
||||
useGetApiNotifications,
|
||||
usePatchApiNotificationsNotification,
|
||||
} from '@/generated/api/notifications/notifications';
|
||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function Header({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const router = useRouter();
|
||||
const {
|
||||
data: notifications,
|
||||
isLoading: notificationsLoading,
|
||||
refetch,
|
||||
} = useGetApiNotifications();
|
||||
|
||||
const markNotificationAsRead = usePatchApiNotificationsNotification();
|
||||
|
||||
return (
|
||||
<div className='w-full grid grid-rows-[50px_1fr] h-screen'>
|
||||
<header className='border-b-1 grid-cols-[1fr_3fr_1fr] grid items-center px-2 shadow-md'>
|
||||
|
@ -32,16 +38,85 @@ export default function Header({
|
|||
<span className='flex justify-center'>Search</span>
|
||||
<span className='flex gap-1 justify-end'>
|
||||
<ThemePicker />
|
||||
{items.map((item) => (
|
||||
<NotificationButton
|
||||
key={item.title}
|
||||
variant='outline_primary'
|
||||
dotVariant='hidden'
|
||||
size='icon'
|
||||
>
|
||||
<item.icon />
|
||||
</NotificationButton>
|
||||
))}
|
||||
<NotificationButton
|
||||
variant='outline_primary'
|
||||
dotVariant={
|
||||
!notificationsLoading && notifications?.data.unread_count
|
||||
? 'active'
|
||||
: notifications?.data.notifications.length
|
||||
? 'neutral'
|
||||
: 'hidden'
|
||||
}
|
||||
size='icon'
|
||||
icon={<Inbox />}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
{notificationsLoading
|
||||
? 'Loading...'
|
||||
: `${notifications?.data.unread_count} unread Notifications`}
|
||||
</DropdownMenuItem>
|
||||
{notifications?.data.notifications.map((notification) => (
|
||||
<DropdownMenuItem
|
||||
key={notification.id}
|
||||
className={cn('grid grid-rows-[auto-auto-auto] gap-2', {
|
||||
'font-bold': !notification.is_read,
|
||||
})}
|
||||
>
|
||||
<p className='text-sm'>{notification.message}</p>
|
||||
<p className='text-xs text-gray-500'>
|
||||
{new Date(notification.created_at).toLocaleString()}
|
||||
</p>
|
||||
<div className='grid auto-cols-fr grid-flow-col gap-2'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
markNotificationAsRead.mutate(
|
||||
{
|
||||
notification: notification.id,
|
||||
data: {
|
||||
is_read: !notification.is_read,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
variant={
|
||||
notification.is_read ? 'outline_primary' : 'primary'
|
||||
}
|
||||
>
|
||||
{notification.is_read ? 'Read' : 'Mark as Read'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => {
|
||||
switch (notification.related_entity_type) {
|
||||
case "MEETING":
|
||||
router.push(`/events/${notification.related_entity_id}`);
|
||||
break;
|
||||
case "USER":
|
||||
router.push(`/users/${notification.related_entity_id}`);
|
||||
break;
|
||||
case "GROUP":
|
||||
router.push(`/groups/${notification.related_entity_id}`);
|
||||
break;
|
||||
default:
|
||||
console.warn('Unknown notification type:', notification.related_entity_type);
|
||||
break;
|
||||
}
|
||||
}}>
|
||||
View Details
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</NotificationButton>
|
||||
<NotificationButton
|
||||
variant='outline_primary'
|
||||
dotVariant='hidden'
|
||||
size='icon'
|
||||
icon={<BellRing />}
|
||||
></NotificationButton>
|
||||
<UserDropdown />
|
||||
</span>
|
||||
</header>
|
||||
|
|
|
@ -41,11 +41,13 @@ export default function UserDropdown() {
|
|||
<UserCard />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Settings</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<Link href='/logout'>Logout</Link>
|
||||
</DropdownMenuItem>
|
||||
<Link href='/settings'>
|
||||
<DropdownMenuItem>Settings</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</Link>
|
||||
<Link href='/logout'>
|
||||
<DropdownMenuItem>Logout</DropdownMenuItem>
|
||||
</Link>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue