Added SSE function and add readAt for notification

This commit is contained in:
2026-02-21 22:34:29 +07:00
parent fa689ea4aa
commit ab745e6a2f
17 changed files with 349 additions and 43 deletions

View File

@@ -50,7 +50,8 @@
"tailwindcss": "^4.0.6",
"tw-animate-css": "^1.3.6",
"vite-tsconfig-paths": "^6.0.5",
"zod": "^4.3.6"
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
"devDependencies": {
"@inlang/paraglide-js": "2.10.0",

27
pnpm-lock.yaml generated
View File

@@ -104,6 +104,9 @@ importers:
zod:
specifier: ^4.3.6
version: 4.3.6
zustand:
specifier: ^5.0.11
version: 5.0.11(@types/react@19.2.10)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
devDependencies:
'@inlang/paraglide-js':
specifier: 2.10.0
@@ -4866,6 +4869,24 @@ packages:
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
zustand@5.0.11:
resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
snapshots:
'@acemir/cssom@0.9.31': {}
@@ -9690,3 +9711,9 @@ snapshots:
zod@3.25.76: {}
zod@4.3.6: {}
zustand@5.0.11(@types/react@19.2.10)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
optionalDependencies:
'@types/react': 19.2.10
react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4)

View File

@@ -15,3 +15,14 @@ export const settingsData = [
description: 'The keywords of the site',
},
];
export const userData = [
{
name: 'Raysam',
email: 'raysam024@gmail.com',
},
{
name: 'Raysam',
email: 'juines.liu@gmail.com',
},
];

View File

@@ -48,7 +48,10 @@ CREATE TABLE "notification" (
);
-- CreateIndex
CREATE INDEX "notification_userId_idx" ON "notification"("userId");
CREATE INDEX "notification_userId_readAt_idx" ON "notification"("userId", "readAt");
-- CreateIndex
CREATE INDEX "notification_readAt_idx" ON "notification"("readAt");
-- AddForeignKey
ALTER TABLE "notification" ADD CONSTRAINT "notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -176,6 +176,7 @@ model Notification {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([userId, readAt])
@@index([readAt])
@@map("notification")
}

View File

@@ -1,7 +1,7 @@
import { auth } from '@lib/auth';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../src/generated/prisma/client.js';
import { settingsData } from './data.js';
import { settingsData, userData } from './data.js';
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
@@ -32,6 +32,19 @@ async function main() {
}
console.log('---------------Created admin user-----------------');
userData.map(async (user) => {
await auth.api.createUser({
body: {
email: user.email,
password: 'Th@m!S@m!040390',
name: user.name,
role: 'user',
},
});
});
console.log('---------------Created member user-----------------');
await prisma.setting.deleteMany();
const listSettings = [

View File

@@ -1,9 +1,11 @@
import { updateReadedNotification } from '@/service/notify.api';
import { notificationQueries } from '@/service/queries';
import useNotificationStore from '@/store/useNotificationStore';
import { formatTimeAgo } from '@/utils/helper';
import { cn } from '@lib/utils';
import { m } from '@paraglide/messages';
import { BellIcon } from '@phosphor-icons/react';
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { Button } from '@ui/button';
import {
@@ -15,15 +17,42 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@ui/dropdown-menu';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Item, ItemContent, ItemDescription, ItemTitle } from './ui/item';
const Notification = () => {
const { data: notifications } = useQuery(notificationQueries.topFive());
const [open, _setOpen] = useState(false);
const { hasNew, setHasNew } = useNotificationStore((state) => state);
const { data } = useQuery(notificationQueries.topFive());
if (!notifications) return null;
const { mutate: updateReaded } = useMutation({
mutationFn: () => updateReadedNotification(),
onError: (error: ReturnError) => {
const code = error.code as Parameters<
typeof m.backend_message
>[0]['code'];
toast.error(m.backend_message({ code }), {
richColors: true,
});
},
});
const onOpenNotification = (isOpen: boolean) => {
_setOpen(isOpen);
updateReaded();
};
useEffect(() => {
if (data) {
setHasNew(data.hasNewNotify);
}
}, [data]);
if (!data) return null;
return (
<DropdownMenu>
<DropdownMenu open={open} onOpenChange={onOpenNotification}>
<DropdownMenuTrigger asChild>
<Button
size="icon-lg"
@@ -32,9 +61,9 @@ const Notification = () => {
>
<BellIcon
size={32}
className={cn('origin-top', { 'animate-bell-ring': true })}
className={cn('origin-top', { 'animate-bell-ring': hasNew })}
/>
{true && (
{hasNew && (
<span className="absolute top-1 right-1 rounded-full w-2 h-2 bg-red-600"></span>
)}
<span className="sr-only">{m.ui_label_notifications()}</span>
@@ -46,26 +75,32 @@ const Notification = () => {
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{notifications.map((notify) => {
return (
<DropdownMenuItem key={notify.id}>
<Item className="p-0">
<ItemContent>
<ItemTitle className="text-sm">
{m.templates_title_notification({
title: notify.title as Parameters<
typeof m.templates_title_notification
>[0]['title'],
})}
</ItemTitle>
<ItemDescription>
{formatTimeAgo(new Date(notify.createdAt))}
</ItemDescription>
</ItemContent>
</Item>
</DropdownMenuItem>
);
})}
{data.list && data.list.length > 0 ? (
data.list.map((notify) => {
return (
<DropdownMenuItem key={notify.id}>
<Item className="p-0">
<ItemContent>
<ItemTitle className="text-sm">
{m.templates_title_notification({
title: notify.title as Parameters<
typeof m.templates_title_notification
>[0]['title'],
})}
</ItemTitle>
<ItemDescription>
{formatTimeAgo(new Date(notify.createdAt))}
</ItemDescription>
</ItemContent>
</Item>
</DropdownMenuItem>
);
})
) : (
<DropdownMenuItem className="py-10 justify-center">
{m.common_no_notify()}
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuGroup>
<DropdownMenuSeparator />

View File

@@ -20,7 +20,7 @@ const config: runtime.GetPrismaClientConfig = {
"clientVersion": "7.3.0",
"engineVersion": "9d6ad21cbbceab97458517b147a6a09ff43aa735",
"activeProvider": "postgresql",
"inlineSchema": "generator client {\n provider = \"prisma-client\"\n output = \"../src/generated/prisma\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nmodel User {\n id String @id @default(uuid())\n name String\n email String\n emailVerified Boolean @default(false)\n image String?\n createdAt DateTime @default(now()) @db.Timestamptz\n updatedAt DateTime @updatedAt @db.Timestamptz\n sessions Session[]\n accounts Account[]\n audit Audit[]\n notification Notification[]\n\n role String?\n banned Boolean? @default(false)\n banReason String?\n banExpires DateTime?\n\n members Member[]\n invitations Invitation[]\n\n @@unique([email])\n @@map(\"user\")\n}\n\nmodel Session {\n id String @id @default(uuid())\n expiresAt DateTime @db.Timestamptz\n token String\n createdAt DateTime @default(now()) @db.Timestamptz\n updatedAt DateTime @updatedAt @db.Timestamptz\n ipAddress String?\n userAgent String?\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n impersonatedBy String?\n\n activeOrganizationId String?\n\n @@unique([token])\n @@index([userId])\n @@map(\"session\")\n}\n\nmodel Account {\n id String @id @default(uuid())\n accountId String\n providerId String\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n accessToken String?\n refreshToken String?\n idToken String?\n accessTokenExpiresAt DateTime?\n refreshTokenExpiresAt DateTime?\n scope String?\n password String?\n createdAt DateTime @default(now()) @db.Timestamptz\n updatedAt DateTime @updatedAt @db.Timestamptz\n\n @@index([userId])\n @@map(\"account\")\n}\n\nmodel Verification {\n id String @id @default(uuid())\n identifier String\n value String\n expiresAt DateTime\n createdAt DateTime @default(now()) @db.Timestamptz\n updatedAt DateTime @updatedAt @db.Timestamptz\n\n @@index([identifier])\n @@map(\"verification\")\n}\n\nmodel Organization {\n id String @id @default(uuid())\n name String\n slug String\n logo String?\n createdAt DateTime @db.Timestamptz\n metadata String?\n members Member[]\n invitations Invitation[]\n\n color String? @default(\"#000000\")\n\n @@unique([slug])\n @@map(\"organization\")\n}\n\nmodel Member {\n id String @id @default(uuid())\n organizationId String\n organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n role String @default(\"member\")\n createdAt DateTime @db.Timestamptz\n\n @@index([organizationId])\n @@index([userId])\n @@map(\"member\")\n}\n\nmodel Invitation {\n id String @id @default(uuid())\n organizationId String\n organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)\n email String\n role String?\n status String @default(\"pending\")\n expiresAt DateTime @db.Timestamptz\n createdAt DateTime @default(now()) @db.Timestamptz\n inviterId String\n user User @relation(fields: [inviterId], references: [id], onDelete: Cascade)\n\n @@index([organizationId])\n @@index([email])\n @@map(\"invitation\")\n}\n\nmodel Setting {\n id String @id @default(uuid())\n key String @unique\n value String\n description String\n relation String @default(\"admin\")\n\n createdAt DateTime @default(now()) @db.Timestamptz\n updatedAt DateTime @updatedAt @db.Timestamptz\n\n @@map(\"setting\")\n}\n\nmodel Audit {\n id String @id @default(uuid())\n userId String\n action String\n tableName String\n recordId String\n oldValue String?\n newValue String?\n createdAt DateTime @default(now()) @db.Timestamptz\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@map(\"audit\")\n}\n\nmodel Notification {\n id String @id @default(uuid())\n userId String\n\n title String\n message String\n type String @default(\"system\")\n\n link String?\n metadata String?\n\n createdAt DateTime @default(now()) @db.Timestamptz\n readAt DateTime? @db.Timestamptz\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@index([userId])\n @@map(\"notification\")\n}\n",
"inlineSchema": "generator client {\n provider = \"prisma-client\"\n output = \"../src/generated/prisma\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nmodel User {\n id String @id @default(uuid())\n name String\n email String\n emailVerified Boolean @default(false)\n image String?\n createdAt DateTime @default(now()) @db.Timestamptz\n updatedAt DateTime @updatedAt @db.Timestamptz\n sessions Session[]\n accounts Account[]\n audit Audit[]\n notification Notification[]\n\n role String?\n banned Boolean? @default(false)\n banReason String?\n banExpires DateTime?\n\n members Member[]\n invitations Invitation[]\n\n @@unique([email])\n @@map(\"user\")\n}\n\nmodel Session {\n id String @id @default(uuid())\n expiresAt DateTime @db.Timestamptz\n token String\n createdAt DateTime @default(now()) @db.Timestamptz\n updatedAt DateTime @updatedAt @db.Timestamptz\n ipAddress String?\n userAgent String?\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n impersonatedBy String?\n\n activeOrganizationId String?\n\n @@unique([token])\n @@index([userId])\n @@map(\"session\")\n}\n\nmodel Account {\n id String @id @default(uuid())\n accountId String\n providerId String\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n accessToken String?\n refreshToken String?\n idToken String?\n accessTokenExpiresAt DateTime?\n refreshTokenExpiresAt DateTime?\n scope String?\n password String?\n createdAt DateTime @default(now()) @db.Timestamptz\n updatedAt DateTime @updatedAt @db.Timestamptz\n\n @@index([userId])\n @@map(\"account\")\n}\n\nmodel Verification {\n id String @id @default(uuid())\n identifier String\n value String\n expiresAt DateTime\n createdAt DateTime @default(now()) @db.Timestamptz\n updatedAt DateTime @updatedAt @db.Timestamptz\n\n @@index([identifier])\n @@map(\"verification\")\n}\n\nmodel Organization {\n id String @id @default(uuid())\n name String\n slug String\n logo String?\n createdAt DateTime @db.Timestamptz\n metadata String?\n members Member[]\n invitations Invitation[]\n\n color String? @default(\"#000000\")\n\n @@unique([slug])\n @@map(\"organization\")\n}\n\nmodel Member {\n id String @id @default(uuid())\n organizationId String\n organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n role String @default(\"member\")\n createdAt DateTime @db.Timestamptz\n\n @@index([organizationId])\n @@index([userId])\n @@map(\"member\")\n}\n\nmodel Invitation {\n id String @id @default(uuid())\n organizationId String\n organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)\n email String\n role String?\n status String @default(\"pending\")\n expiresAt DateTime @db.Timestamptz\n createdAt DateTime @default(now()) @db.Timestamptz\n inviterId String\n user User @relation(fields: [inviterId], references: [id], onDelete: Cascade)\n\n @@index([organizationId])\n @@index([email])\n @@map(\"invitation\")\n}\n\nmodel Setting {\n id String @id @default(uuid())\n key String @unique\n value String\n description String\n relation String @default(\"admin\")\n\n createdAt DateTime @default(now()) @db.Timestamptz\n updatedAt DateTime @updatedAt @db.Timestamptz\n\n @@map(\"setting\")\n}\n\nmodel Audit {\n id String @id @default(uuid())\n userId String\n action String\n tableName String\n recordId String\n oldValue String?\n newValue String?\n createdAt DateTime @default(now()) @db.Timestamptz\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@map(\"audit\")\n}\n\nmodel Notification {\n id String @id @default(uuid())\n userId String\n\n title String\n message String\n type String @default(\"system\")\n\n link String?\n metadata String?\n\n createdAt DateTime @default(now()) @db.Timestamptz\n readAt DateTime? @db.Timestamptz\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@index([userId, readAt])\n @@index([readAt])\n @@map(\"notification\")\n}\n",
"runtimeDataModel": {
"models": {},
"enums": {},

24
src/hooks/use-sse.ts Normal file
View File

@@ -0,0 +1,24 @@
import { useEffect } from 'react';
export function useSSE(onMessage: (data: any) => void) {
useEffect(() => {
const eventSource = new EventSource('/api/notify');
eventSource.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data);
onMessage(parsed);
} catch (err) {
console.error('Invalid SSE data', err);
}
};
eventSource.onerror = () => {
eventSource.close();
};
return () => {
eventSource.close();
};
}, [onMessage]);
}

52
src/lib/notification.ts Normal file
View File

@@ -0,0 +1,52 @@
type Controller = ReadableStreamDefaultController;
const userClients = new Map<string, Set<Controller>>();
/**
* Thêm connection cho user
*/
export function addClient(userId: string, controller: Controller) {
if (!userClients.has(userId)) {
userClients.set(userId, new Set());
}
userClients.get(userId)!.add(controller);
}
/**
* Xoá connection khi tab đóng
*/
export function removeClient(userId: string, controller: Controller) {
const controllers = userClients.get(userId);
if (!controllers) return;
controllers.delete(controller);
// nếu user không còn tab nào mở thì xoá luôn
if (controllers.size === 0) {
userClients.delete(userId);
}
}
/**
* Gửi notification cho 1 user
*/
export function sendToUser(userId: string, data: any) {
const controllers = userClients.get(userId);
if (!controllers) return;
for (const controller of controllers) {
controller.enqueue(`data: ${JSON.stringify(data)}\n\n`);
}
}
/**
* Gửi cho tất cả user
*/
export function broadcast(data: any) {
for (const controllers of userClients.values()) {
for (const controller of controllers) {
controller.enqueue(`data: ${JSON.stringify(data)}\n\n`);
}
}
}

View File

@@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as appRouteRouteImport } from './routes/(app)/route'
import { Route as appIndexRouteImport } from './routes/(app)/index'
import { Route as ApiNotifyRouteImport } from './routes/api/notify'
import { Route as authSignInRouteImport } from './routes/(auth)/sign-in'
import { Route as appauthRouteRouteImport } from './routes/(app)/(auth)/route'
import { Route as ApiAuthSplatRouteImport } from './routes/api.auth.$'
@@ -40,6 +41,11 @@ const appIndexRoute = appIndexRouteImport.update({
path: '/',
getParentRoute: () => appRouteRoute,
} as any)
const ApiNotifyRoute = ApiNotifyRouteImport.update({
id: '/api/notify',
path: '/api/notify',
getParentRoute: () => rootRouteImport,
} as any)
const authSignInRoute = authSignInRouteImport.update({
id: '/(auth)/sign-in',
path: '/sign-in',
@@ -140,6 +146,7 @@ const appauthAccountChangePasswordRoute =
export interface FileRoutesByFullPath {
'/sign-in': typeof authSignInRoute
'/api/notify': typeof ApiNotifyRoute
'/': typeof appIndexRoute
'/account': typeof appauthAccountRouteRouteWithChildren
'/kanri': typeof appauthKanriRouteRouteWithChildren
@@ -161,6 +168,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/sign-in': typeof authSignInRoute
'/api/notify': typeof ApiNotifyRoute
'/': typeof appIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/account/change-password': typeof appauthAccountChangePasswordRoute
@@ -182,6 +190,7 @@ export interface FileRoutesById {
'/(app)': typeof appRouteRouteWithChildren
'/(app)/(auth)': typeof appauthRouteRouteWithChildren
'/(auth)/sign-in': typeof authSignInRoute
'/api/notify': typeof ApiNotifyRoute
'/(app)/': typeof appIndexRoute
'/(app)/(auth)/account': typeof appauthAccountRouteRouteWithChildren
'/(app)/(auth)/kanri': typeof appauthKanriRouteRouteWithChildren
@@ -205,6 +214,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/sign-in'
| '/api/notify'
| '/'
| '/account'
| '/kanri'
@@ -226,6 +236,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/sign-in'
| '/api/notify'
| '/'
| '/api/auth/$'
| '/account/change-password'
@@ -246,6 +257,7 @@ export interface FileRouteTypes {
| '/(app)'
| '/(app)/(auth)'
| '/(auth)/sign-in'
| '/api/notify'
| '/(app)/'
| '/(app)/(auth)/account'
| '/(app)/(auth)/kanri'
@@ -269,6 +281,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
appRouteRoute: typeof appRouteRouteWithChildren
authSignInRoute: typeof authSignInRoute
ApiNotifyRoute: typeof ApiNotifyRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
}
@@ -288,6 +301,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof appIndexRouteImport
parentRoute: typeof appRouteRoute
}
'/api/notify': {
id: '/api/notify'
path: '/api/notify'
fullPath: '/api/notify'
preLoaderRoute: typeof ApiNotifyRouteImport
parentRoute: typeof rootRouteImport
}
'/(auth)/sign-in': {
id: '/(auth)/sign-in'
path: '/sign-in'
@@ -513,6 +533,7 @@ const appRouteRouteWithChildren = appRouteRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = {
appRouteRoute: appRouteRouteWithChildren,
authSignInRoute: authSignInRoute,
ApiNotifyRoute: ApiNotifyRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
}
export const routeTree = rootRouteImport

View File

@@ -10,6 +10,7 @@ import { useState } from 'react';
export const Route = createFileRoute('/(app)/(auth)/management/notifications')({
component: RouteComponent,
staticData: { breadcrumb: () => m.ui_label_notifications() },
});
function RouteComponent() {

View File

@@ -2,9 +2,11 @@ import { AuthProvider } from '@/components/auth/auth-provider';
import Header from '@/components/Header';
import AppSidebar from '@/components/sidebar/app-sidebar';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { useSSE } from '@/hooks/use-sse';
import useNotificationStore from '@/store/useNotificationStore';
import { Locale, setLocale } from '@paraglide/runtime';
import { settingQueries } from '@service/queries';
import { useQuery } from '@tanstack/react-query';
import { notificationQueries, settingQueries } from '@service/queries';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { createFileRoute, Outlet } from '@tanstack/react-router';
import { useEffect } from 'react';
@@ -13,14 +15,25 @@ export const Route = createFileRoute('/(app)')({
});
function RouteComponent() {
const setHasNew = useNotificationStore((state) => state.setHasNew);
const { data: language } = useQuery(
settingQueries.getCurrentUserLanguageSetting(),
);
const queryClient = useQueryClient();
useSSE((data) => {
if (data.type === 'NEW_NOTIFICATION') {
setHasNew(true);
queryClient.invalidateQueries(notificationQueries.topFive());
}
});
useEffect(() => {
if (language) {
setLocale(language as Locale);
}
}, [language]);
return (
<AuthProvider>
<SidebarProvider>

48
src/routes/api/notify.ts Normal file
View File

@@ -0,0 +1,48 @@
import { auth } from '@/lib/auth';
import { addClient, removeClient } from '@/lib/notification';
import { createFileRoute } from '@tanstack/react-router';
import { getRequestHeaders } from '@tanstack/react-start/server';
export const Route = createFileRoute('/api/notify')({
server: {
handlers: {
GET: async ({ request }) => {
const headers = getRequestHeaders();
const session = await auth.api.getSession({ headers });
if (!session?.session.userId) {
return new Response('Unauthorized', { status: 401 });
}
const clientId = session.session.userId;
const stream = new ReadableStream({
start(controller) {
addClient(clientId, controller);
controller.enqueue(
`data: ${JSON.stringify({ type: `connected` })}\n\n`,
);
const heartbeat = setInterval(() => {
controller.enqueue(`: keepalive\n\n`);
}, 20000);
request.signal.addEventListener('abort', () => {
clearInterval(heartbeat);
removeClient(clientId, controller);
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
},
},
},
});

View File

@@ -1,4 +1,5 @@
import { prisma } from '@/db';
import { Notification } from '@/generated/prisma/client';
import { parseError } from '@lib/errors';
import { authMiddleware } from '@lib/middleware';
import { createServerFn } from '@tanstack/react-start';
@@ -8,17 +9,30 @@ export const getTopFiveNotification = createServerFn({ method: 'GET' })
.middleware([authMiddleware])
.handler(async ({ context: { user } }) => {
try {
const list = await prisma.notification.findMany({
where: {
userId: user.id,
},
orderBy: {
createdAt: 'asc',
},
take: 5,
});
const [list, total]: [Notification[], number] = await prisma.$transaction(
[
prisma.notification.findMany({
where: {
userId: user.id,
},
orderBy: {
createdAt: 'asc',
},
take: 5,
}),
prisma.notification.count({
where: {
userId: user.id,
readAt: null,
},
}),
],
);
return list;
return {
list,
hasNewNotify: total > 0,
};
} catch (error) {
console.error(error);
const { message, code } = parseError(error);
@@ -78,3 +92,24 @@ export const getAllNotifications = createServerFn({ method: 'GET' })
throw { message, code };
}
});
export const updateReadedNotification = createServerFn({ method: 'POST' })
.middleware([authMiddleware])
.handler(async ({ context: { user } }) => {
try {
const result = await prisma.notification.findMany({
where: { userId: user.id, readAt: null },
});
if (result.length > 0) {
await prisma.notification.updateMany({
where: { userId: user.id, readAt: null },
data: { readAt: new Date() },
});
}
} catch (error) {
console.error(error);
const { message, code } = parseError(error);
throw { message, code };
}
});

View File

@@ -1,5 +1,6 @@
import { prisma } from '@/db';
import { Audit, Notification, Setting } from '@/generated/prisma/client';
import { sendToUser } from '@/lib/notification';
type AdminSettingValue = Pick<Setting, 'id' | 'key' | 'value'>;
@@ -70,4 +71,10 @@ export const createNotification = async (
...data,
},
});
sendToUser(data.userId, {
type: 'NEW_NOTIFICATION',
title: data.title,
message: data.message,
});
};

View File

@@ -0,0 +1,14 @@
import { create } from 'zustand';
type NotifyState = {
hasNew: boolean;
setHasNew: (hasNew: boolean) => void;
};
const useNotificationStore = create<NotifyState>((set) => ({
hasNew: false,
setHasNew: (hasNew: boolean) => set({ hasNew }),
toggleNew: () => set((state) => ({ hasNew: !state.hasNew })),
}));
export default useNotificationStore;