Added SSE function and add readAt for notification
This commit is contained in:
@@ -50,7 +50,8 @@
|
|||||||
"tailwindcss": "^4.0.6",
|
"tailwindcss": "^4.0.6",
|
||||||
"tw-animate-css": "^1.3.6",
|
"tw-animate-css": "^1.3.6",
|
||||||
"vite-tsconfig-paths": "^6.0.5",
|
"vite-tsconfig-paths": "^6.0.5",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@inlang/paraglide-js": "2.10.0",
|
"@inlang/paraglide-js": "2.10.0",
|
||||||
|
|||||||
27
pnpm-lock.yaml
generated
27
pnpm-lock.yaml
generated
@@ -104,6 +104,9 @@ importers:
|
|||||||
zod:
|
zod:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 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:
|
devDependencies:
|
||||||
'@inlang/paraglide-js':
|
'@inlang/paraglide-js':
|
||||||
specifier: 2.10.0
|
specifier: 2.10.0
|
||||||
@@ -4866,6 +4869,24 @@ packages:
|
|||||||
zod@4.3.6:
|
zod@4.3.6:
|
||||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
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:
|
snapshots:
|
||||||
|
|
||||||
'@acemir/cssom@0.9.31': {}
|
'@acemir/cssom@0.9.31': {}
|
||||||
@@ -9690,3 +9711,9 @@ snapshots:
|
|||||||
zod@3.25.76: {}
|
zod@3.25.76: {}
|
||||||
|
|
||||||
zod@4.3.6: {}
|
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',
|
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
|
-- 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
|
-- AddForeignKey
|
||||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
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)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId, readAt])
|
||||||
|
@@index([readAt])
|
||||||
@@map("notification")
|
@@map("notification")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { auth } from '@lib/auth';
|
import { auth } from '@lib/auth';
|
||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
import { PrismaClient } from '../src/generated/prisma/client.js';
|
import { PrismaClient } from '../src/generated/prisma/client.js';
|
||||||
import { settingsData } from './data.js';
|
import { settingsData, userData } from './data.js';
|
||||||
|
|
||||||
const adapter = new PrismaPg({
|
const adapter = new PrismaPg({
|
||||||
connectionString: process.env.DATABASE_URL!,
|
connectionString: process.env.DATABASE_URL!,
|
||||||
@@ -32,6 +32,19 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('---------------Created admin user-----------------');
|
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();
|
await prisma.setting.deleteMany();
|
||||||
|
|
||||||
const listSettings = [
|
const listSettings = [
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { updateReadedNotification } from '@/service/notify.api';
|
||||||
import { notificationQueries } from '@/service/queries';
|
import { notificationQueries } from '@/service/queries';
|
||||||
|
import useNotificationStore from '@/store/useNotificationStore';
|
||||||
import { formatTimeAgo } from '@/utils/helper';
|
import { formatTimeAgo } from '@/utils/helper';
|
||||||
import { cn } from '@lib/utils';
|
import { cn } from '@lib/utils';
|
||||||
import { m } from '@paraglide/messages';
|
import { m } from '@paraglide/messages';
|
||||||
import { BellIcon } from '@phosphor-icons/react';
|
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 { Link } from '@tanstack/react-router';
|
||||||
import { Button } from '@ui/button';
|
import { Button } from '@ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -15,15 +17,42 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@ui/dropdown-menu';
|
} from '@ui/dropdown-menu';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Item, ItemContent, ItemDescription, ItemTitle } from './ui/item';
|
import { Item, ItemContent, ItemDescription, ItemTitle } from './ui/item';
|
||||||
|
|
||||||
const Notification = () => {
|
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 (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu open={open} onOpenChange={onOpenNotification}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
size="icon-lg"
|
size="icon-lg"
|
||||||
@@ -32,9 +61,9 @@ const Notification = () => {
|
|||||||
>
|
>
|
||||||
<BellIcon
|
<BellIcon
|
||||||
size={32}
|
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="absolute top-1 right-1 rounded-full w-2 h-2 bg-red-600"></span>
|
||||||
)}
|
)}
|
||||||
<span className="sr-only">{m.ui_label_notifications()}</span>
|
<span className="sr-only">{m.ui_label_notifications()}</span>
|
||||||
@@ -46,7 +75,8 @@ const Notification = () => {
|
|||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
{notifications.map((notify) => {
|
{data.list && data.list.length > 0 ? (
|
||||||
|
data.list.map((notify) => {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem key={notify.id}>
|
<DropdownMenuItem key={notify.id}>
|
||||||
<Item className="p-0">
|
<Item className="p-0">
|
||||||
@@ -65,7 +95,12 @@ const Notification = () => {
|
|||||||
</Item>
|
</Item>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem className="py-10 justify-center">
|
||||||
|
{m.common_no_notify()}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const config: runtime.GetPrismaClientConfig = {
|
|||||||
"clientVersion": "7.3.0",
|
"clientVersion": "7.3.0",
|
||||||
"engineVersion": "9d6ad21cbbceab97458517b147a6a09ff43aa735",
|
"engineVersion": "9d6ad21cbbceab97458517b147a6a09ff43aa735",
|
||||||
"activeProvider": "postgresql",
|
"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": {
|
"runtimeDataModel": {
|
||||||
"models": {},
|
"models": {},
|
||||||
"enums": {},
|
"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 rootRouteImport } from './routes/__root'
|
||||||
import { Route as appRouteRouteImport } from './routes/(app)/route'
|
import { Route as appRouteRouteImport } from './routes/(app)/route'
|
||||||
import { Route as appIndexRouteImport } from './routes/(app)/index'
|
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 authSignInRouteImport } from './routes/(auth)/sign-in'
|
||||||
import { Route as appauthRouteRouteImport } from './routes/(app)/(auth)/route'
|
import { Route as appauthRouteRouteImport } from './routes/(app)/(auth)/route'
|
||||||
import { Route as ApiAuthSplatRouteImport } from './routes/api.auth.$'
|
import { Route as ApiAuthSplatRouteImport } from './routes/api.auth.$'
|
||||||
@@ -40,6 +41,11 @@ const appIndexRoute = appIndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => appRouteRoute,
|
getParentRoute: () => appRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiNotifyRoute = ApiNotifyRouteImport.update({
|
||||||
|
id: '/api/notify',
|
||||||
|
path: '/api/notify',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const authSignInRoute = authSignInRouteImport.update({
|
const authSignInRoute = authSignInRouteImport.update({
|
||||||
id: '/(auth)/sign-in',
|
id: '/(auth)/sign-in',
|
||||||
path: '/sign-in',
|
path: '/sign-in',
|
||||||
@@ -140,6 +146,7 @@ const appauthAccountChangePasswordRoute =
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/sign-in': typeof authSignInRoute
|
'/sign-in': typeof authSignInRoute
|
||||||
|
'/api/notify': typeof ApiNotifyRoute
|
||||||
'/': typeof appIndexRoute
|
'/': typeof appIndexRoute
|
||||||
'/account': typeof appauthAccountRouteRouteWithChildren
|
'/account': typeof appauthAccountRouteRouteWithChildren
|
||||||
'/kanri': typeof appauthKanriRouteRouteWithChildren
|
'/kanri': typeof appauthKanriRouteRouteWithChildren
|
||||||
@@ -161,6 +168,7 @@ export interface FileRoutesByFullPath {
|
|||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/sign-in': typeof authSignInRoute
|
'/sign-in': typeof authSignInRoute
|
||||||
|
'/api/notify': typeof ApiNotifyRoute
|
||||||
'/': typeof appIndexRoute
|
'/': typeof appIndexRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/account/change-password': typeof appauthAccountChangePasswordRoute
|
'/account/change-password': typeof appauthAccountChangePasswordRoute
|
||||||
@@ -182,6 +190,7 @@ export interface FileRoutesById {
|
|||||||
'/(app)': typeof appRouteRouteWithChildren
|
'/(app)': typeof appRouteRouteWithChildren
|
||||||
'/(app)/(auth)': typeof appauthRouteRouteWithChildren
|
'/(app)/(auth)': typeof appauthRouteRouteWithChildren
|
||||||
'/(auth)/sign-in': typeof authSignInRoute
|
'/(auth)/sign-in': typeof authSignInRoute
|
||||||
|
'/api/notify': typeof ApiNotifyRoute
|
||||||
'/(app)/': typeof appIndexRoute
|
'/(app)/': typeof appIndexRoute
|
||||||
'/(app)/(auth)/account': typeof appauthAccountRouteRouteWithChildren
|
'/(app)/(auth)/account': typeof appauthAccountRouteRouteWithChildren
|
||||||
'/(app)/(auth)/kanri': typeof appauthKanriRouteRouteWithChildren
|
'/(app)/(auth)/kanri': typeof appauthKanriRouteRouteWithChildren
|
||||||
@@ -205,6 +214,7 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/sign-in'
|
| '/sign-in'
|
||||||
|
| '/api/notify'
|
||||||
| '/'
|
| '/'
|
||||||
| '/account'
|
| '/account'
|
||||||
| '/kanri'
|
| '/kanri'
|
||||||
@@ -226,6 +236,7 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/sign-in'
|
| '/sign-in'
|
||||||
|
| '/api/notify'
|
||||||
| '/'
|
| '/'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/account/change-password'
|
| '/account/change-password'
|
||||||
@@ -246,6 +257,7 @@ export interface FileRouteTypes {
|
|||||||
| '/(app)'
|
| '/(app)'
|
||||||
| '/(app)/(auth)'
|
| '/(app)/(auth)'
|
||||||
| '/(auth)/sign-in'
|
| '/(auth)/sign-in'
|
||||||
|
| '/api/notify'
|
||||||
| '/(app)/'
|
| '/(app)/'
|
||||||
| '/(app)/(auth)/account'
|
| '/(app)/(auth)/account'
|
||||||
| '/(app)/(auth)/kanri'
|
| '/(app)/(auth)/kanri'
|
||||||
@@ -269,6 +281,7 @@ export interface FileRouteTypes {
|
|||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
appRouteRoute: typeof appRouteRouteWithChildren
|
appRouteRoute: typeof appRouteRouteWithChildren
|
||||||
authSignInRoute: typeof authSignInRoute
|
authSignInRoute: typeof authSignInRoute
|
||||||
|
ApiNotifyRoute: typeof ApiNotifyRoute
|
||||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +301,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof appIndexRouteImport
|
preLoaderRoute: typeof appIndexRouteImport
|
||||||
parentRoute: typeof appRouteRoute
|
parentRoute: typeof appRouteRoute
|
||||||
}
|
}
|
||||||
|
'/api/notify': {
|
||||||
|
id: '/api/notify'
|
||||||
|
path: '/api/notify'
|
||||||
|
fullPath: '/api/notify'
|
||||||
|
preLoaderRoute: typeof ApiNotifyRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/(auth)/sign-in': {
|
'/(auth)/sign-in': {
|
||||||
id: '/(auth)/sign-in'
|
id: '/(auth)/sign-in'
|
||||||
path: '/sign-in'
|
path: '/sign-in'
|
||||||
@@ -513,6 +533,7 @@ const appRouteRouteWithChildren = appRouteRoute._addFileChildren(
|
|||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
appRouteRoute: appRouteRouteWithChildren,
|
appRouteRoute: appRouteRouteWithChildren,
|
||||||
authSignInRoute: authSignInRoute,
|
authSignInRoute: authSignInRoute,
|
||||||
|
ApiNotifyRoute: ApiNotifyRoute,
|
||||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
export const Route = createFileRoute('/(app)/(auth)/management/notifications')({
|
export const Route = createFileRoute('/(app)/(auth)/management/notifications')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
staticData: { breadcrumb: () => m.ui_label_notifications() },
|
||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { AuthProvider } from '@/components/auth/auth-provider';
|
|||||||
import Header from '@/components/Header';
|
import Header from '@/components/Header';
|
||||||
import AppSidebar from '@/components/sidebar/app-sidebar';
|
import AppSidebar from '@/components/sidebar/app-sidebar';
|
||||||
import { SidebarInset, SidebarProvider } from '@/components/ui/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 { Locale, setLocale } from '@paraglide/runtime';
|
||||||
import { settingQueries } from '@service/queries';
|
import { notificationQueries, settingQueries } from '@service/queries';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { createFileRoute, Outlet } from '@tanstack/react-router';
|
import { createFileRoute, Outlet } from '@tanstack/react-router';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
@@ -13,14 +15,25 @@ export const Route = createFileRoute('/(app)')({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
|
const setHasNew = useNotificationStore((state) => state.setHasNew);
|
||||||
const { data: language } = useQuery(
|
const { data: language } = useQuery(
|
||||||
settingQueries.getCurrentUserLanguageSetting(),
|
settingQueries.getCurrentUserLanguageSetting(),
|
||||||
);
|
);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
useSSE((data) => {
|
||||||
|
if (data.type === 'NEW_NOTIFICATION') {
|
||||||
|
setHasNew(true);
|
||||||
|
queryClient.invalidateQueries(notificationQueries.topFive());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (language) {
|
if (language) {
|
||||||
setLocale(language as Locale);
|
setLocale(language as Locale);
|
||||||
}
|
}
|
||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SidebarProvider>
|
<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 { prisma } from '@/db';
|
||||||
|
import { Notification } from '@/generated/prisma/client';
|
||||||
import { parseError } from '@lib/errors';
|
import { parseError } from '@lib/errors';
|
||||||
import { authMiddleware } from '@lib/middleware';
|
import { authMiddleware } from '@lib/middleware';
|
||||||
import { createServerFn } from '@tanstack/react-start';
|
import { createServerFn } from '@tanstack/react-start';
|
||||||
@@ -8,7 +9,9 @@ export const getTopFiveNotification = createServerFn({ method: 'GET' })
|
|||||||
.middleware([authMiddleware])
|
.middleware([authMiddleware])
|
||||||
.handler(async ({ context: { user } }) => {
|
.handler(async ({ context: { user } }) => {
|
||||||
try {
|
try {
|
||||||
const list = await prisma.notification.findMany({
|
const [list, total]: [Notification[], number] = await prisma.$transaction(
|
||||||
|
[
|
||||||
|
prisma.notification.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
},
|
},
|
||||||
@@ -16,9 +19,20 @@ export const getTopFiveNotification = createServerFn({ method: 'GET' })
|
|||||||
createdAt: 'asc',
|
createdAt: 'asc',
|
||||||
},
|
},
|
||||||
take: 5,
|
take: 5,
|
||||||
});
|
}),
|
||||||
|
prisma.notification.count({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
readAt: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return list;
|
return {
|
||||||
|
list,
|
||||||
|
hasNewNotify: total > 0,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
const { message, code } = parseError(error);
|
const { message, code } = parseError(error);
|
||||||
@@ -78,3 +92,24 @@ export const getAllNotifications = createServerFn({ method: 'GET' })
|
|||||||
throw { message, code };
|
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 { prisma } from '@/db';
|
||||||
import { Audit, Notification, Setting } from '@/generated/prisma/client';
|
import { Audit, Notification, Setting } from '@/generated/prisma/client';
|
||||||
|
import { sendToUser } from '@/lib/notification';
|
||||||
|
|
||||||
type AdminSettingValue = Pick<Setting, 'id' | 'key' | 'value'>;
|
type AdminSettingValue = Pick<Setting, 'id' | 'key' | 'value'>;
|
||||||
|
|
||||||
@@ -70,4 +71,10 @@ export const createNotification = async (
|
|||||||
...data,
|
...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