diff --git a/messages/en.json b/messages/en.json
index 28d3b6a..05ca731 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -13,7 +13,15 @@
"common_per_page": "Show",
"common_select_page_size": "Select page size",
"common_no_list": "Currently there is no data!",
+ "common_no_notify": "Không có thống báo",
"common_is_required": "{field} is required.",
+ "common_time_ago_second": "{value} giây trước",
+ "common_time_ago_minute": "{value} phút trước",
+ "common_time_ago_hour": "{value} giờ trước",
+ "common_time_ago_day": "{value} ngày trước",
+ "common_time_ago_week": "{value} tuần trước",
+ "common_time_ago_month": "{value} tháng trước",
+ "common_time_ago_year": "{value} năm trước",
"role_tags": [
{
"match": {
@@ -170,23 +178,42 @@
"houses_page_message_house_not_found": "House not found!",
"houses_page_message_update_house_success": "Updated house successfully!",
"houses_page_message_delete_house_success": "Delete house successfully!",
+ "houses_page_message_invite_member_success": "Invite member successfully!",
+ "houses_page_message_cancel_invitation_success": "Cancel invitation successfully!",
"houses_page_house_active_btn": "Active",
"houses_user_page_message_active_house_success": "Active \"{house}\" successfully!",
"houses_user_page_block_action_title": "Action",
"houses_user_page_action_invite_user": "Invite member",
"houses_user_page_invite_label_to": "To",
"houses_user_page_invite_label_status": "Status",
+ "invitation_not_found": "Invitation not found!",
+ "notification_page_notify_not_found": "Notification not found!",
"invite_status": [
{
"match": {
"status=pending": "Pending",
- "status=accept": "Accept",
- "status=reject": "Reject",
+ "status=accepted": "Accept",
+ "status=rejected": "Reject",
"status=expired": "Expired",
"status=canceled": "Cancel"
}
}
],
+ "templates_title_notification": [
+ {
+ "match": {
+ "title=INVITATION_HOUSE": "Invite to join house"
+ }
+ }
+ ],
+ "templates_message_notification": [
+ {
+ "match": {
+ "message=INVITATION_HOUSE": "You have been invited to join house: {name}, do you accept?"
+ }
+ }
+ ],
+ "notification_page_message_invitation_success": "You have been invited to join house!",
"backend_message": [
{
"match": {
@@ -197,7 +224,8 @@
"code=BANNED_USER": "Your account get banned, please contact administrator for more information!",
"code=VALIDATION_ERROR": "Some field value invalid!",
"code=USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION": "User is not a member of the house",
- "code=USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION": "This member has already been invited, waiting for the member to join!"
+ "code=USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION": "This member has already been invited, waiting for the member to join!",
+ "code=INVITATION_NOT_FOUND": "Invitation not found!"
}
}
]
diff --git a/messages/vi.json b/messages/vi.json
index c579f5c..eaf4c0f 100644
--- a/messages/vi.json
+++ b/messages/vi.json
@@ -13,7 +13,15 @@
"common_per_page": "Hiển thị",
"common_select_page_size": "Chọn số lượng",
"common_no_list": "Hiện tại chưa có dữ liệu nào!",
+ "common_no_notify": "Không có thống báo",
"common_is_required": "{field} là bắt buộc.",
+ "common_time_ago_second": "{value} giây trước",
+ "common_time_ago_minute": "{value} phút trước",
+ "common_time_ago_hour": "{value} giờ trước",
+ "common_time_ago_day": "{value} ngày trước",
+ "common_time_ago_week": "{value} tuần trước",
+ "common_time_ago_month": "{value} tháng trước",
+ "common_time_ago_year": "{value} năm trước",
"role_tags": [
{
"match": {
@@ -50,6 +58,8 @@
"ui_ban_btn": "Khóa",
"ui_unban_btn": "Mở khóa",
"ui_invite_btn": "Mời",
+ "ui_agree_btn": "Đồng ý",
+ "ui_reject_btn": "Từ chối",
"ui_update_password_btn": "Đặt lại mật khẩu",
"ui_change_role_btn": "Đặt lại quyền hạn",
"ui_edit_user_btn": "Chỉnh sửa người dùng",
@@ -172,23 +182,43 @@
"houses_page_message_house_not_found": "Không tìm thấy nhà này!",
"houses_page_message_update_house_success": "Cập nhật nhà thành công!",
"houses_page_message_delete_house_success": "Xóa nhà thành công!",
+ "houses_page_message_invite_member_success": "Mời thành viên thành công!",
+ "houses_page_message_cancel_invitation_success": "Hủy lời mời thành công!",
"houses_page_house_active_btn": "Kích hoạt",
"houses_user_page_message_active_house_success": "Kích hoạt \"{house}\" thành công!",
"houses_user_page_block_action_title": "Hành động",
"houses_user_page_action_invite_user": "Mời thành viên",
"houses_user_page_invite_label_to": "Đến",
"houses_user_page_invite_label_status": "Trạng thái",
+ "invitation_not_found": "Không tìm thấy lời mời!",
+ "notification_page_notify_not_found": "Không tìm thấy thống báo!",
"invite_status": [
{
"match": {
"status=pending": "Đang chờ",
- "status=accept": "Đồng ý",
- "status=reject": "Không đồng ý",
+ "status=accepted": "Đồng ý",
+ "status=rejected": "Không đồng ý",
"status=expired": "Hết hạn",
"status=canceled": "Đã hủy"
}
}
],
+ "templates_title_notification": [
+ {
+ "match": {
+ "title=INVITATION_HOUSE": "Mời tham gia nhà"
+ }
+ }
+ ],
+ "templates_message_notification": [
+ {
+ "match": {
+ "message=INVITATION_HOUSE": "mời bạn tham gia nhà: {name}, bạn có đồng ý không?"
+ }
+ }
+ ],
+ "notification_page_message_invitation_success": "Bạn đã đồng ý tham gia nhà!",
+ "notification_page_message_invitation_rejected": "Bạn đã từ chối tham gia nhà!",
"backend_message": [
{
"match": {
@@ -199,7 +229,8 @@
"code=BANNED_USER": "Bạn đã bị quản trị viên khóa tài khoản, hãy liên hệ quản trị viên để tìm hiểu thêm!",
"code=VALIDATION_ERROR": "Có giá trị không hợp lệ!",
"code=USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION": "Người dùng này không phải thành viên nhà này",
- "code=USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION": "Thành viên này đã được mời rồi, còn đang đợi thành viên đồng ý!"
+ "code=USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION": "Thành viên này đã được mời rồi, còn đang đợi thành viên đồng ý!",
+ "code=INVITATION_NOT_FOUND": "Không tìm thấy lời mời!"
}
}
]
diff --git a/package.json b/package.json
index 2498727..70089b1 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"format": "prettier",
"check": "prettier --write . && eslint --fix",
"post-cta-init": "npx create-db@latest",
+ "db:reset": "dotenv -e .env.local -- prisma migrate reset",
"db:generate": "dotenv -e .env.local -- prisma generate",
"db:push": "dotenv -e .env.local -- prisma db push",
"db:migrate": "dotenv -e .env.local -- prisma migrate dev",
diff --git a/prisma/migrations/20260214113407_notification/migration.sql b/prisma/migrations/20260214113407_notification/migration.sql
new file mode 100644
index 0000000..f284c89
--- /dev/null
+++ b/prisma/migrations/20260214113407_notification/migration.sql
@@ -0,0 +1,54 @@
+-- AlterTable
+ALTER TABLE "account" ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMPTZ,
+ALTER COLUMN "updatedAt" SET DATA TYPE TIMESTAMPTZ;
+
+-- AlterTable
+ALTER TABLE "audit" ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMPTZ;
+
+-- AlterTable
+ALTER TABLE "invitation" ALTER COLUMN "expiresAt" SET DATA TYPE TIMESTAMPTZ,
+ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMPTZ;
+
+-- AlterTable
+ALTER TABLE "member" ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMPTZ;
+
+-- AlterTable
+ALTER TABLE "organization" ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMPTZ;
+
+-- AlterTable
+ALTER TABLE "session" ALTER COLUMN "expiresAt" SET DATA TYPE TIMESTAMPTZ,
+ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMPTZ,
+ALTER COLUMN "updatedAt" SET DATA TYPE TIMESTAMPTZ;
+
+-- AlterTable
+ALTER TABLE "setting" ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMPTZ,
+ALTER COLUMN "updatedAt" SET DATA TYPE TIMESTAMPTZ;
+
+-- AlterTable
+ALTER TABLE "user" ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMPTZ,
+ALTER COLUMN "updatedAt" SET DATA TYPE TIMESTAMPTZ;
+
+-- AlterTable
+ALTER TABLE "verification" ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMPTZ,
+ALTER COLUMN "updatedAt" SET DATA TYPE TIMESTAMPTZ;
+
+-- CreateTable
+CREATE TABLE "notification" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "message" TEXT NOT NULL,
+ "type" TEXT NOT NULL DEFAULT 'system',
+ "link" TEXT,
+ "metadata" TEXT,
+ "createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "readAt" TIMESTAMPTZ,
+
+ CONSTRAINT "notification_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "notification_userId_idx" ON "notification"("userId");
+
+-- AddForeignKey
+ALTER TABLE "notification" ADD CONSTRAINT "notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 585f92e..10ec0c2 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -14,11 +14,12 @@ model User {
email String
emailVerified Boolean @default(false)
image String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now()) @db.Timestamptz
+ updatedAt DateTime @updatedAt @db.Timestamptz
sessions Session[]
accounts Account[]
audit Audit[]
+ notification Notification[]
role String?
banned Boolean? @default(false)
@@ -34,10 +35,10 @@ model User {
model Session {
id String @id @default(uuid())
- expiresAt DateTime
+ expiresAt DateTime @db.Timestamptz
token String
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now()) @db.Timestamptz
+ updatedAt DateTime @updatedAt @db.Timestamptz
ipAddress String?
userAgent String?
userId String
@@ -65,8 +66,8 @@ model Account {
refreshTokenExpiresAt DateTime?
scope String?
password String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now()) @db.Timestamptz
+ updatedAt DateTime @updatedAt @db.Timestamptz
@@index([userId])
@@map("account")
@@ -77,8 +78,8 @@ model Verification {
identifier String
value String
expiresAt DateTime
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now()) @db.Timestamptz
+ updatedAt DateTime @updatedAt @db.Timestamptz
@@index([identifier])
@@map("verification")
@@ -89,7 +90,7 @@ model Organization {
name String
slug String
logo String?
- createdAt DateTime
+ createdAt DateTime @db.Timestamptz
metadata String?
members Member[]
invitations Invitation[]
@@ -107,7 +108,7 @@ model Member {
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role String @default("member")
- createdAt DateTime
+ createdAt DateTime @db.Timestamptz
@@index([organizationId])
@@index([userId])
@@ -121,8 +122,8 @@ model Invitation {
email String
role String?
status String @default("pending")
- expiresAt DateTime
- createdAt DateTime @default(now())
+ expiresAt DateTime @db.Timestamptz
+ createdAt DateTime @default(now()) @db.Timestamptz
inviterId String
user User @relation(fields: [inviterId], references: [id], onDelete: Cascade)
@@ -138,8 +139,8 @@ model Setting {
description String
relation String @default("admin")
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now()) @db.Timestamptz
+ updatedAt DateTime @updatedAt @db.Timestamptz
@@map("setting")
}
@@ -152,9 +153,29 @@ model Audit {
recordId String
oldValue String?
newValue String?
- createdAt DateTime @default(now())
+ createdAt DateTime @default(now()) @db.Timestamptz
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("audit")
}
+
+model Notification {
+ id String @id @default(uuid())
+ userId String
+
+ title String
+ message String
+ type String @default("system")
+
+ link String?
+ metadata String?
+
+ createdAt DateTime @default(now()) @db.Timestamptz
+ readAt DateTime? @db.Timestamptz
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([userId])
+ @@map("notification")
+}
diff --git a/project.inlang/settings.json b/project.inlang/settings.json
index 89c8bd3..87c83ef 100644
--- a/project.inlang/settings.json
+++ b/project.inlang/settings.json
@@ -3,8 +3,8 @@
"baseLocale": "en",
"locales": ["en", "vi"],
"modules": [
- "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
- "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
+ "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@latest/dist/index.js",
+ "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@latest/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index a684b31..fe1b699 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -1,19 +1,7 @@
import { Separator } from '@base-ui/react/separator';
-import { m } from '@paraglide/messages';
-import { BellIcon } from '@phosphor-icons/react';
-import { Badge } from '@ui/badge';
-import { Button } from '@ui/button';
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuGroup,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from '@ui/dropdown-menu';
import { SidebarTrigger } from '@ui/sidebar';
import { useAuth } from './auth/auth-provider';
+import Notification from './Notification';
import RouterBreadcrumb from './sidebar/router-breadcrumb';
export default function Header() {
@@ -30,48 +18,7 @@ export default function Header() {
/>
-
- {session?.user && (
-
-
-
-
-
-
- {m.ui_label_notifications()}
-
-
-
-
-
-
System
-
- 1 hour ago
-
-
-
-
-
-
-
- {m.ui_view_all_notifications()}
-
-
-
-
- )}
-
+ {session?.user && }
>
);
diff --git a/src/components/Notification.tsx b/src/components/Notification.tsx
new file mode 100644
index 0000000..dbee7cd
--- /dev/null
+++ b/src/components/Notification.tsx
@@ -0,0 +1,83 @@
+import { notificationQueries } from '@/service/queries';
+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 { Link } from '@tanstack/react-router';
+import { Button } from '@ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@ui/dropdown-menu';
+import { Item, ItemContent, ItemDescription, ItemTitle } from './ui/item';
+
+const Notification = () => {
+ const { data: notifications } = useQuery(notificationQueries.topFive());
+
+ if (!notifications) return null;
+
+ return (
+
+
+
+
+
+
+ {m.ui_label_notifications()}
+
+
+
+ {notifications.map((notify) => {
+ return (
+
+ -
+
+
+ {m.templates_title_notification({
+ title: notify.title as Parameters<
+ typeof m.templates_title_notification
+ >[0]['title'],
+ })}
+
+
+ {formatTimeAgo(new Date(notify.createdAt))}
+
+
+
+
+ );
+ })}
+
+
+
+
+
+ {m.ui_view_all_notifications()}
+
+
+
+
+
+ );
+};
+
+export default Notification;
diff --git a/src/components/form/house/user-invite-member-form.tsx b/src/components/form/house/user-invite-member-form.tsx
index 7e27c0d..c9891fd 100644
--- a/src/components/form/house/user-invite-member-form.tsx
+++ b/src/components/form/house/user-invite-member-form.tsx
@@ -38,7 +38,7 @@ const UserInviteMemberForm = ({ onSubmit }: FormProps) => {
});
onSubmit(false);
refetch();
- toast.success(m.houses_page_message_create_house_success(), {
+ toast.success(m.houses_page_message_invite_member_success(), {
richColors: true,
});
},
diff --git a/src/components/house/current-user-invitation-list.tsx b/src/components/house/current-user-invitation-list.tsx
index b6f58e2..7aec2c8 100644
--- a/src/components/house/current-user-invitation-list.tsx
+++ b/src/components/house/current-user-invitation-list.tsx
@@ -1,3 +1,4 @@
+import useHasPermission from '@/hooks/use-has-permission';
import { cancelInvitation } from '@/service/house.api';
import { INVITE_STATUS } from '@/types/enum';
import { authClient } from '@lib/auth-client';
@@ -23,13 +24,17 @@ type InvitationListProps = {
const CurrentUserInvitationList = ({ activeHouse }: InvitationListProps) => {
const { refetch } = authClient.useActiveOrganization();
+ const { hasPermission, isLoading } = useHasPermission(
+ 'invitation',
+ 'cancel',
+ true,
+ );
const { mutate: cancelInvitationMutation } = useMutation({
mutationFn: cancelInvitation,
onSuccess: () => {
refetch();
- // _setOpen(false);
- toast.success(m.houses_page_message_delete_house_success(), {
+ toast.success(m.houses_page_message_cancel_invitation_success(), {
richColors: true,
});
},
@@ -66,7 +71,7 @@ const CurrentUserInvitationList = ({ activeHouse }: InvitationListProps) => {
{activeHouse.invitations.length > 0 ? (
activeHouse.invitations.map((item) => (
-
+
-
@@ -87,7 +92,7 @@ const CurrentUserInvitationList = ({ activeHouse }: InvitationListProps) => {
- {item.status !== INVITE_STATUS.CANCELED && (
+ {item.status === INVITE_STATUS.PENDING && hasPermission && (