diff --git a/backend/src/db/migrations/20250829203610_user-notifications.ts b/backend/src/db/migrations/20250829203610_user-notifications.ts index 8595a313e..6fabfa882 100644 --- a/backend/src/db/migrations/20250829203610_user-notifications.ts +++ b/backend/src/db/migrations/20250829203610_user-notifications.ts @@ -9,6 +9,7 @@ export async function up(knex: Knex): Promise { .createTable(TableName.UserNotifications, (t) => { t.uuid("id").defaultTo(knex.fn.uuid()); t.uuid("userId").notNullable(); + t.uuid("orgId").nullable(); t.string("type").notNullable(); t.string("title").notNullable(); // Markdown @@ -32,10 +33,11 @@ export async function up(knex: Knex): Promise { await knex.schema.alterTable(TableName.UserNotifications, (t) => { t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); t.index("type"); t.index(["userId", "isRead"]); - t.index(["userId", "createdAt"]); + t.index(["userId", "createdAt", "orgId"]); }); await createOnUpdateTrigger(knex, TableName.UserNotifications); diff --git a/backend/src/db/schemas/user-notifications.ts b/backend/src/db/schemas/user-notifications.ts index 2c0337358..146526600 100644 --- a/backend/src/db/schemas/user-notifications.ts +++ b/backend/src/db/schemas/user-notifications.ts @@ -10,6 +10,7 @@ import { TImmutableDBKeys } from "./models"; export const UserNotificationsSchema = z.object({ id: z.string().uuid(), userId: z.string().uuid(), + orgId: z.string().uuid().nullable().optional(), type: z.string(), title: z.string(), body: z.string().nullable().optional(), diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index f2b9f4e8d..008b61919 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -282,6 +282,7 @@ export const accessApprovalRequestServiceFactory = ({ await notificationService.createUserNotifications( approverUsers.map((approver) => ({ userId: approver.id, + orgId: actorOrgId, type: NotificationType.ACCESS_APPROVAL_REQUEST, title: "Access Approval Request", body: `**${requesterFullName}** (${requestedByUser.email}) has requested ${isTemporary ? "temporary" : "permanent"} access to **${secretPath}** in the **${envSlug}** environment for project **${project.name}**.`, @@ -443,6 +444,7 @@ export const accessApprovalRequestServiceFactory = ({ .filter((approver) => Boolean(approver.userId) && approver.userId !== editedByUser.id) .map((approver) => ({ userId: approver.userId!, + orgId: actorOrgId, type: NotificationType.ACCESS_APPROVAL_REQUEST_UPDATED, title: "Access Approval Request Updated", body: `**${editorFullName}** (${editedByUser.email}) has updated the access request submitted by **${requesterFullName}** (${requestedByUser.email}) for **${secretPath}** in the **${envSlug}** environment for project **${project.name}**.`, diff --git a/backend/src/server/routes/v1/notification-router.ts b/backend/src/server/routes/v1/notification-router.ts index edf28e799..5f72b88d6 100644 --- a/backend/src/server/routes/v1/notification-router.ts +++ b/backend/src/server/routes/v1/notification-router.ts @@ -26,7 +26,10 @@ export const registerNotificationRouter = async (server: FastifyZodProvider) => throw new UnauthorizedError({ message: "This endpoint can only be accessed by users" }); } - const notifications = await server.services.notification.listUserNotifications({ userId: req.auth.userId }); + const notifications = await server.services.notification.listUserNotifications({ + userId: req.auth.userId, + orgId: req.auth.orgId + }); return { notifications }; } @@ -111,7 +114,10 @@ export const registerNotificationRouter = async (server: FastifyZodProvider) => throw new UnauthorizedError({ message: "This endpoint can only be accessed by users" }); } - await server.services.notification.markUserNotificationsAsRead({ userId: req.auth.userId }); + await server.services.notification.markUserNotificationsAsRead({ + userId: req.auth.userId, + orgId: req.auth.orgId + }); } }); }; diff --git a/backend/src/services/notification/notification-service.ts b/backend/src/services/notification/notification-service.ts index 7ad24d30b..ed50c3f1c 100644 --- a/backend/src/services/notification/notification-service.ts +++ b/backend/src/services/notification/notification-service.ts @@ -15,13 +15,14 @@ export const notificationServiceFactory = ({ notificationQueue, userNotificationDAL }: TNotificationServiceFactoryDep) => { - const listUserNotifications = async ({ userId }: { userId: string }) => { + const listUserNotifications = async ({ userId, orgId }: { userId: string; orgId: string }) => { const now = new Date(); const threeMonthsAgo = new Date(); threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); const notifications = await userNotificationDAL.find({ userId, + orgId, startDate: threeMonthsAgo.toISOString(), endDate: now.toISOString() }); @@ -43,8 +44,8 @@ export const notificationServiceFactory = ({ return deletedNotifications[0]; }; - const markUserNotificationsAsRead = async ({ userId }: { userId: string }) => { - await userNotificationDAL.markAllNotificationsAsRead(userId); + const markUserNotificationsAsRead = async ({ userId, orgId }: { userId: string; orgId: string }) => { + await userNotificationDAL.markAllNotificationsAsRead(userId, orgId); }; const updateUserNotification = async ({ diff --git a/backend/src/services/notification/notification-types.ts b/backend/src/services/notification/notification-types.ts index eb451a761..30bc87244 100644 --- a/backend/src/services/notification/notification-types.ts +++ b/backend/src/services/notification/notification-types.ts @@ -5,6 +5,9 @@ export enum NotificationType { export interface TCreateUserNotificationDTO { userId: string; + // Adding an orgId will make the notification only show up when a user is in a certain org. Otherwise, it shows up in all orgs. + // Keep in mind that org-scoped links for a notification will break if orgId is missing and the user is in the wrong org + orgId?: string; type: NotificationType; title: string; body?: string; diff --git a/backend/src/services/notification/user-notification-dal.ts b/backend/src/services/notification/user-notification-dal.ts index efa5e7f0a..6dcb2e3d0 100644 --- a/backend/src/services/notification/user-notification-dal.ts +++ b/backend/src/services/notification/user-notification-dal.ts @@ -19,12 +19,14 @@ export const userNotificationDALFactory = (db: TDbClient) => { const find = async ( { userId, + orgId, startDate, endDate, limit = 1000, offset = 0 }: { userId: string; + orgId: string; startDate: string; endDate: string; limit?: number; @@ -35,6 +37,11 @@ export const userNotificationDALFactory = (db: TDbClient) => { try { const docs = await (tx || db.replicaNode())(TableName.UserNotifications) .where(`${TableName.UserNotifications}.userId`, userId) + .andWhere((qb) => { + void qb + .where(`${TableName.UserNotifications}.orgId`, orgId) + .orWhereNull(`${TableName.UserNotifications}.orgId`); + }) .whereRaw(`"${TableName.UserNotifications}"."createdAt" >= ?::timestamptz`, [startDate]) .andWhereRaw(`"${TableName.UserNotifications}"."createdAt" < ?::timestamptz`, [endDate]) .select(selectAllTableCols(TableName.UserNotifications)) @@ -110,8 +117,13 @@ export const userNotificationDALFactory = (db: TDbClient) => { } }; - const markAllNotificationsAsRead = async (userId: string) => { - await db(TableName.UserNotifications).where({ userId }).update({ isRead: true }); + const markAllNotificationsAsRead = async (userId: string, orgId: string) => { + await db(TableName.UserNotifications) + .where({ userId }) + .andWhere((qb) => { + void qb.where({ orgId }).orWhereNull("orgId"); + }) + .update({ isRead: true }); }; return { ...notificationOrm, pruneNotifications, find, markAllNotificationsAsRead }; diff --git a/frontend/src/hooks/api/notifications/mutations.tsx b/frontend/src/hooks/api/notifications/mutations.tsx index 596e54fe6..34ab7aa26 100644 --- a/frontend/src/hooks/api/notifications/mutations.tsx +++ b/frontend/src/hooks/api/notifications/mutations.tsx @@ -1,18 +1,22 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { useOrganization } from "@app/context"; import { notificationKeys } from "./queries"; import { TUserNotification } from "./types"; export const useMarkAllNotificationsAsRead = () => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg.id || ""; + const queryClient = useQueryClient(); return useMutation({ mutationFn: async () => { await apiRequest.post("/api/v1/notifications/user/mark-as-read"); }, onSuccess: () => { - queryClient.setQueryData(notificationKeys.list(), (oldData) => { + queryClient.setQueryData(notificationKeys.list(orgId), (oldData) => { if (!oldData) return oldData; return oldData.map((notification) => ({ ...notification, @@ -24,6 +28,9 @@ export const useMarkAllNotificationsAsRead = () => { }; export const useUpdateNotification = () => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg.id || ""; + const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ notificationId, isRead }: { notificationId: string; isRead: boolean }) => { @@ -34,7 +41,7 @@ export const useUpdateNotification = () => { return data.notification; }, onSuccess: (updatedNotification) => { - queryClient.setQueryData(notificationKeys.list(), (oldData) => { + queryClient.setQueryData(notificationKeys.list(orgId), (oldData) => { if (!oldData) return oldData; return oldData.map((notification) => notification.id === updatedNotification.id ? updatedNotification : notification @@ -45,13 +52,16 @@ export const useUpdateNotification = () => { }; export const useDeleteNotification = () => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg.id || ""; + const queryClient = useQueryClient(); return useMutation({ mutationFn: async (notificationId: string) => { await apiRequest.delete(`/api/v1/notifications/user/${notificationId}`); }, onSuccess: (_, notificationId) => { - queryClient.setQueryData(notificationKeys.list(), (oldData) => { + queryClient.setQueryData(notificationKeys.list(orgId), (oldData) => { if (!oldData) return oldData; return oldData.filter((notification) => notification.id !== notificationId); }); diff --git a/frontend/src/hooks/api/notifications/queries.tsx b/frontend/src/hooks/api/notifications/queries.tsx index 1fcde5d3f..f7a31afe6 100644 --- a/frontend/src/hooks/api/notifications/queries.tsx +++ b/frontend/src/hooks/api/notifications/queries.tsx @@ -1,17 +1,21 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { useOrganization } from "@app/context"; import { TUserNotification } from "./types"; export const notificationKeys = { all: ["notifications"] as const, - list: () => [...notificationKeys.all, "list"] as const + list: (orgId: string) => [...notificationKeys.all, "list", { orgId }] as const }; export const useGetMyNotifications = () => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg.id || ""; + return useQuery({ - queryKey: notificationKeys.list(), + queryKey: notificationKeys.list(orgId), queryFn: async () => { const { data: { notifications }