Added SSE function and add readAt for notification
This commit is contained in:
@@ -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
27
pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
@@ -176,6 +176,7 @@ model Notification {
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([userId, readAt])
|
||||
@@index([readAt])
|
||||
@@map("notification")
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
24
src/hooks/use-sse.ts
Normal 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
52
src/lib/notification.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
48
src/routes/api/notify.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
14
src/store/useNotificationStore.ts
Normal file
14
src/store/useNotificationStore.ts
Normal 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;
|
||||
Reference in New Issue
Block a user