feat: add notifications support
Some checks failed
container-scan / Container Scan (pull_request) Failing after 6m2s
docker-build / docker (pull_request) Failing after 7m56s
tests / Tests (pull_request) Failing after 4m25s

This commit is contained in:
Dominik 2025-06-26 10:20:20 +02:00
parent 1a9a299c9c
commit 511acbca62
Signed by: dominik
GPG key ID: 06A4003FC5049644
12 changed files with 483 additions and 33 deletions

View file

@ -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())

View file

@ -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,

View file

@ -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,
{

View 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 },
);
});

View 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'],
});
}

View 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 },
);
});

View 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'],
});
}

View 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');

View file

@ -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',
}),
);

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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>
);