From 542cc0f93ce3a1237a946c2d9dfab4947ce64afa Mon Sep 17 00:00:00 2001 From: x032205 Date: Sun, 31 Aug 2025 22:53:12 -0400 Subject: [PATCH] feat(notifications): User Notifications --- backend/src/@types/fastify.d.ts | 2 + backend/src/@types/knex.d.ts | 10 ++ .../20250829203610_user-notifications.ts | 48 +++++++ backend/src/db/schemas/models.ts | 1 + backend/src/db/schemas/user-notifications.ts | 24 ++++ .../access-approval-request-service.ts | 19 ++- backend/src/queue/queue-service.ts | 11 +- backend/src/server/routes/index.ts | 20 ++- backend/src/server/routes/v1/index.ts | 2 + .../server/routes/v1/notification-router.ts | 106 ++++++++++++++++ .../notification/notification-queue.ts | 43 +++++++ .../notification/notification-service.ts | 70 +++++++++++ .../notification/notification-types.ts | 22 ++++ .../notification/user-notification-dal.ts | 118 ++++++++++++++++++ .../resource-cleanup-queue.ts | 6 +- .../v2/ContentLoader/ContentLoader.tsx | 9 +- .../src/components/v2/Tooltip/Tooltip.tsx | 4 +- .../src/hooks/api/notifications/mutations.tsx | 56 +++++++++ .../src/hooks/api/notifications/queries.tsx | 25 ++++ frontend/src/hooks/api/notifications/types.ts | 10 ++ .../components/NavBar/Navbar.tsx | 106 +++++++++++++++- .../components/NavBar/Notification.tsx | 59 +++++++++ 22 files changed, 756 insertions(+), 15 deletions(-) create mode 100644 backend/src/db/migrations/20250829203610_user-notifications.ts create mode 100644 backend/src/db/schemas/user-notifications.ts create mode 100644 backend/src/server/routes/v1/notification-router.ts create mode 100644 backend/src/services/notification/notification-queue.ts create mode 100644 backend/src/services/notification/notification-service.ts create mode 100644 backend/src/services/notification/notification-types.ts create mode 100644 backend/src/services/notification/user-notification-dal.ts create mode 100644 frontend/src/hooks/api/notifications/mutations.tsx create mode 100644 frontend/src/hooks/api/notifications/queries.tsx create mode 100644 frontend/src/hooks/api/notifications/types.ts create mode 100644 frontend/src/layouts/OrganizationLayout/components/NavBar/Notification.tsx diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index c25d8d4d1..01194351d 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -83,6 +83,7 @@ import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua import { TIntegrationServiceFactory } from "@app/services/integration/integration-service"; import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; +import { TNotificationServiceFactory } from "@app/services/notification/notification-service"; import { TOrgRoleServiceFactory } from "@app/services/org/org-role-service"; import { TOrgServiceFactory } from "@app/services/org/org-service"; import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; @@ -303,6 +304,7 @@ declare module "fastify" { bus: TEventBusService; sse: TServerSentEventsService; identityAuthTemplate: TIdentityAuthTemplateServiceFactory; + notification: TNotificationServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index f645cb8f2..525ad1619 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -530,6 +530,11 @@ import { TSecretReminderRecipientsInsert, TSecretReminderRecipientsUpdate } from "@app/db/schemas/secret-reminder-recipients"; +import { + TUserNotifications, + TUserNotificationsInsert, + TUserNotificationsUpdate +} from "@app/db/schemas/user-notifications"; declare module "knex" { namespace Knex { @@ -1254,5 +1259,10 @@ declare module "knex/types/tables" { TRemindersRecipientsInsert, TRemindersRecipientsUpdate >; + [TableName.UserNotifications]: KnexOriginal.CompositeTableType< + TUserNotifications, + TUserNotificationsInsert, + TUserNotificationsUpdate + >; } } diff --git a/backend/src/db/migrations/20250829203610_user-notifications.ts b/backend/src/db/migrations/20250829203610_user-notifications.ts new file mode 100644 index 000000000..8595a313e --- /dev/null +++ b/backend/src/db/migrations/20250829203610_user-notifications.ts @@ -0,0 +1,48 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.UserNotifications))) { + const createTableSql = knex.schema + .createTable(TableName.UserNotifications, (t) => { + t.uuid("id").defaultTo(knex.fn.uuid()); + t.uuid("userId").notNullable(); + + t.string("type").notNullable(); + t.string("title").notNullable(); // Markdown + t.text("body").nullable(); // Markdown + t.string("link").nullable(); + t.boolean("isRead").notNullable().defaultTo(false); + + t.timestamps(true, true, true); + + t.primary(["id", "createdAt"]); + }) + .toString(); + + await knex.schema.raw(` + ${createTableSql} PARTITION BY RANGE ("createdAt"); + `); + + await knex.schema.raw( + `CREATE TABLE ${TableName.UserNotifications}_default PARTITION OF ${TableName.UserNotifications} DEFAULT` + ); + + await knex.schema.alterTable(TableName.UserNotifications, (t) => { + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + + t.index("type"); + t.index(["userId", "isRead"]); + t.index(["userId", "createdAt"]); + }); + + await createOnUpdateTrigger(knex, TableName.UserNotifications); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.UserNotifications); + await dropOnUpdateTrigger(knex, TableName.UserNotifications); +} diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 855934b28..3e3e81fd0 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -131,6 +131,7 @@ export enum TableName { SecretApprovalRequestSecretTagV2 = "secret_approval_request_secret_tags_v2", SnapshotSecretV2 = "secret_snapshot_secrets_v2", ProjectSplitBackfillIds = "project_split_backfill_ids", + UserNotifications = "user_notifications", // Gateway OrgGatewayConfig = "org_gateway_config", Gateway = "gateways", diff --git a/backend/src/db/schemas/user-notifications.ts b/backend/src/db/schemas/user-notifications.ts new file mode 100644 index 000000000..2c0337358 --- /dev/null +++ b/backend/src/db/schemas/user-notifications.ts @@ -0,0 +1,24 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const UserNotificationsSchema = z.object({ + id: z.string().uuid(), + userId: z.string().uuid(), + type: z.string(), + title: z.string(), + body: z.string().nullable().optional(), + link: z.string().nullable().optional(), + isRead: z.boolean().default(false), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TUserNotifications = z.infer; +export type TUserNotificationsInsert = Omit, TImmutableDBKeys>; +export type TUserNotificationsUpdate = Partial, TImmutableDBKeys>>; 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 0f05bd5af..49ddf28ec 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 @@ -20,6 +20,8 @@ import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { TNotificationServiceFactory } from "../../../services/notification/notification-service"; +import { NotificationType } from "../../../services/notification/notification-types"; import { TAccessApprovalPolicyApproverDALFactory } from "../access-approval-policy/access-approval-policy-approver-dal"; import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal"; import { TGroupDALFactory } from "../group/group-dal"; @@ -67,6 +69,7 @@ type TSecretApprovalRequestServiceFactoryDep = { projectSlackConfigDAL: Pick; microsoftTeamsService: Pick; projectMicrosoftTeamsConfigDAL: Pick; + notificationService: Pick; }; export const accessApprovalRequestServiceFactory = ({ @@ -84,7 +87,8 @@ export const accessApprovalRequestServiceFactory = ({ kmsService, microsoftTeamsService, projectMicrosoftTeamsConfigDAL, - projectSlackConfigDAL + projectSlackConfigDAL, + notificationService }: TSecretApprovalRequestServiceFactoryDep): TAccessApprovalRequestServiceFactory => { const $getEnvironmentFromPermissions = (permissions: unknown): string | null => { if (!Array.isArray(permissions) || permissions.length === 0) { @@ -245,7 +249,8 @@ export const accessApprovalRequestServiceFactory = ({ ); const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`; - const approvalUrl = `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval`; + const approvalPath = `/projects/secret-management/${project.id}/approval`; + const approvalUrl = `${cfg.SITE_URL}${approvalPath}`; await triggerWorkflowIntegrationNotification({ input: { @@ -274,6 +279,16 @@ export const accessApprovalRequestServiceFactory = ({ } }); + for await (const approver of approverUsers) { + await notificationService.createUserNotification({ + userId: approver.id, + 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}**.`, + link: approvalPath + }); + } + await smtpService.sendMail({ recipients: approverUsers.filter((approver) => approver.email).map((approver) => approver.email!), subjectLine: "Access Approval Request", diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index f4c49d15a..c0cb014eb 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -23,6 +23,7 @@ import { logger } from "@app/lib/logger"; import { QueueWorkerProfile } from "@app/lib/types"; import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { ExternalPlatforms } from "@app/services/external-migration/external-migration-types"; +import { TCreateUserNotificationDTO } from "@app/services/notification/notification-types"; import { TFailedIntegrationSyncEmailsPayload, TIntegrationSyncPayload, @@ -67,7 +68,8 @@ export enum QueueName { SecretScanningV2 = "secret-scanning-v2", TelemetryAggregatedEvents = "telemetry-aggregated-events", DailyReminders = "daily-reminders", - SecretReminderMigration = "secret-reminder-migration" + SecretReminderMigration = "secret-reminder-migration", + UserNotification = "user-notification" } export enum QueueJobs { @@ -109,7 +111,8 @@ export enum QueueJobs { PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal", TelemetryAggregatedEvents = "telemetry-aggregated-events", DailyReminders = "daily-reminders", - SecretReminderMigration = "secret-reminder-migration" + SecretReminderMigration = "secret-reminder-migration", + UserNotification = "user-notification-job" } export type TQueueJobTypes = { @@ -313,6 +316,10 @@ export type TQueueJobTypes = { name: QueueJobs.TelemetryAggregatedEvents; payload: undefined; }; + [QueueName.UserNotification]: { + name: QueueJobs.UserNotification; + payload: TCreateUserNotificationDTO; + }; }; const SECRET_SCANNING_JOBS = [ diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 3c1038ded..367f2f7c3 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -218,6 +218,9 @@ import { kmsServiceFactory } from "@app/services/kms/kms-service"; import { microsoftTeamsIntegrationDALFactory } from "@app/services/microsoft-teams/microsoft-teams-integration-dal"; import { microsoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; import { projectMicrosoftTeamsConfigDALFactory } from "@app/services/microsoft-teams/project-microsoft-teams-config-dal"; +import { notificationQueueServiceFactory } from "@app/services/notification/notification-queue"; +import { notificationServiceFactory } from "@app/services/notification/notification-service"; +import { userNotificationDALFactory } from "@app/services/notification/user-notification-dal"; import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal"; import { orgBotDALFactory } from "@app/services/org/org-bot-dal"; import { orgDALFactory } from "@app/services/org/org-dal"; @@ -418,6 +421,7 @@ export const registerRoutes = async ( const telemetryDAL = telemetryDALFactory(db); const appConnectionDAL = appConnectionDALFactory(db); const secretSyncDAL = secretSyncDALFactory(db, folderDAL); + const userNotificationDAL = userNotificationDALFactory(db); // ee db layer ops const permissionDAL = permissionDALFactory(db); @@ -563,6 +567,13 @@ export const registerRoutes = async ( auditLogStreamDAL }); + const notificationQueue = await notificationQueueServiceFactory({ + userNotificationDAL, + queueService + }); + + const notificationService = notificationServiceFactory({ notificationQueue, userNotificationDAL }); + const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue }); const auditLogStreamService = auditLogStreamServiceFactory({ licenseService, @@ -1368,7 +1379,8 @@ export const registerRoutes = async ( kmsService, groupDAL, microsoftTeamsService, - projectMicrosoftTeamsConfigDAL + projectMicrosoftTeamsConfigDAL, + notificationService }); const secretReplicationService = secretReplicationServiceFactory({ @@ -1666,7 +1678,8 @@ export const registerRoutes = async ( secretVersionV2DAL: secretVersionV2BridgeDAL, identityUniversalAuthClientSecretDAL: identityUaClientSecretDAL, serviceTokenService, - orgService + orgService, + userNotificationDAL }); const dailyReminderQueueService = dailyReminderQueueServiceFactory({ @@ -2094,7 +2107,8 @@ export const registerRoutes = async ( secretScanningV2: secretScanningV2Service, reminder: reminderService, bus: eventBusService, - sse: sseService + sse: sseService, + notification: notificationService }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 458efd194..6108be32b 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -33,6 +33,7 @@ import { registerIntegrationAuthRouter } from "./integration-auth-router"; import { registerIntegrationRouter } from "./integration-router"; import { registerInviteOrgRouter } from "./invite-org-router"; import { registerMicrosoftTeamsRouter } from "./microsoft-teams-router"; +import { registerNotificationRouter } from "./notification-router"; import { registerOrgAdminRouter } from "./org-admin-router"; import { registerOrgRouter } from "./organization-router"; import { registerPasswordRouter } from "./password-router"; @@ -83,6 +84,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerAdminRouter, { prefix: "/admin" }); await server.register(registerOrgAdminRouter, { prefix: "/organization-admin" }); await server.register(registerUserRouter, { prefix: "/user" }); + await server.register(registerNotificationRouter, { prefix: "/notifications" }); await server.register(registerInviteOrgRouter, { prefix: "/invite-org" }); await server.register(registerUserActionRouter, { prefix: "/user-action" }); await server.register(registerSecretImportRouter, { prefix: "/secret-imports" }); diff --git a/backend/src/server/routes/v1/notification-router.ts b/backend/src/server/routes/v1/notification-router.ts new file mode 100644 index 000000000..5a5dd588e --- /dev/null +++ b/backend/src/server/routes/v1/notification-router.ts @@ -0,0 +1,106 @@ +import { z } from "zod"; + +import { UserNotificationsSchema } from "@app/db/schemas/user-notifications"; +import { UnauthorizedError } from "@app/lib/errors"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerNotificationRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/user", + config: { + rateLimit: readLimit + }, + method: "GET", + schema: { + response: { + 200: z.object({ + notifications: UserNotificationsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + if (req.auth.authMode !== AuthMode.JWT) { + throw new UnauthorizedError({ message: "This endpoint can only be accessed by users" }); + } + + const notifications = await server.services.notification.listUserNotifications({ userId: req.auth.userId }); + + return { notifications }; + } + }); + + server.route({ + url: "/user/:notificationId", + config: { + rateLimit: writeLimit + }, + method: "DELETE", + schema: { + params: z.object({ + notificationId: z.string() + }), + response: { + 200: z.object({ + notification: UserNotificationsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + if (req.auth.authMode !== AuthMode.JWT) { + throw new UnauthorizedError({ message: "This endpoint can only be accessed by users" }); + } + + const notification = await server.services.notification.deleteUserNotification({ + notificationId: req.params.notificationId, + userId: req.auth.userId + }); + + return { notification }; + } + }); + + server.route({ + url: "/user/:notificationId/mark-as-read", + config: { + rateLimit: writeLimit + }, + method: "POST", + schema: { + params: z.object({ + notificationId: z.string() + }) + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + if (req.auth.authMode !== AuthMode.JWT) { + throw new UnauthorizedError({ message: "This endpoint can only be accessed by users" }); + } + + await server.services.notification.markUserNotificationAsRead({ + notificationId: req.params.notificationId, + userId: req.auth.userId + }); + } + }); + + // Mark all user notifications as read + server.route({ + url: "/user/mark-as-read", + config: { + rateLimit: writeLimit + }, + method: "POST", + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + if (req.auth.authMode !== AuthMode.JWT) { + throw new UnauthorizedError({ message: "This endpoint can only be accessed by users" }); + } + + await server.services.notification.markUserNotificationsAsRead({ userId: req.auth.userId }); + } + }); +}; diff --git a/backend/src/services/notification/notification-queue.ts b/backend/src/services/notification/notification-queue.ts new file mode 100644 index 000000000..2d9f6dff5 --- /dev/null +++ b/backend/src/services/notification/notification-queue.ts @@ -0,0 +1,43 @@ +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +import { TCreateUserNotificationDTO } from "./notification-types"; +import { TUserNotificationDALFactory } from "./user-notification-dal"; + +type TNotificationQueueServiceFactoryDep = { + userNotificationDAL: Pick; + queueService: TQueueServiceFactory; +}; + +export type TNotificationQueueServiceFactory = { + pushUserNotification: (data: TCreateUserNotificationDTO) => Promise; +}; + +export const notificationQueueServiceFactory = async ({ + userNotificationDAL, + queueService +}: TNotificationQueueServiceFactoryDep): Promise => { + const pushUserNotification = async (data: TCreateUserNotificationDTO) => { + await queueService.queue(QueueName.UserNotification, QueueJobs.UserNotification, data, { + removeOnFail: { + count: 3 + }, + removeOnComplete: true + }); + }; + + queueService.start(QueueName.UserNotification, async (job) => { + const { userId, type, title, body, link } = job.data; + + await userNotificationDAL.create({ + userId, + type, + title, + body, + link + }); + }); + + return { + pushUserNotification + }; +}; diff --git a/backend/src/services/notification/notification-service.ts b/backend/src/services/notification/notification-service.ts new file mode 100644 index 000000000..673d02a5e --- /dev/null +++ b/backend/src/services/notification/notification-service.ts @@ -0,0 +1,70 @@ +import { NotFoundError, UnauthorizedError } from "@app/lib/errors"; + +import { TNotificationQueueServiceFactory } from "./notification-queue"; +import { TCreateUserNotificationDTO } from "./notification-types"; +import { TUserNotificationDALFactory } from "./user-notification-dal"; + +type TNotificationServiceFactoryDep = { + notificationQueue: TNotificationQueueServiceFactory; + userNotificationDAL: TUserNotificationDALFactory; +}; + +export type TNotificationServiceFactory = ReturnType; + +export const notificationServiceFactory = ({ + notificationQueue, + userNotificationDAL +}: TNotificationServiceFactoryDep) => { + const listUserNotifications = async ({ userId }: { userId: string }) => { + const now = new Date(); + const threeMonthsAgo = new Date(); + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); + + const notifications = await userNotificationDAL.find({ + userId, + startDate: threeMonthsAgo.toISOString(), + endDate: now.toISOString() + }); + + return notifications; + }; + + const createUserNotification = async (data: TCreateUserNotificationDTO) => { + return notificationQueue.pushUserNotification(data); + }; + + const deleteUserNotification = async ({ userId, notificationId }: { userId: string; notificationId: string }) => { + if (!userId) throw new UnauthorizedError({ message: "Invalid userId" }); + + const deletedNotifications = await userNotificationDAL.delete({ id: notificationId, userId }); + + if (deletedNotifications.length <= 0) throw new NotFoundError({ message: "Notification not found" }); + + return deletedNotifications[0]; + }; + + const markUserNotificationsAsRead = async ({ userId }: { userId: string }) => { + await userNotificationDAL.markAllNotificationsAsRead(userId); + }; + + const markUserNotificationAsRead = async ({ userId, notificationId }: { userId: string; notificationId: string }) => { + await userNotificationDAL.update( + { + id: notificationId, + userId, + isRead: false + }, + { + isRead: true + } + ); + }; + + return { + listUserNotifications, + createUserNotification, + deleteUserNotification, + markUserNotificationsAsRead, + markUserNotificationAsRead + }; +}; diff --git a/backend/src/services/notification/notification-types.ts b/backend/src/services/notification/notification-types.ts new file mode 100644 index 000000000..ecf3cafcd --- /dev/null +++ b/backend/src/services/notification/notification-types.ts @@ -0,0 +1,22 @@ +export enum NotificationType { + ACCESS_APPROVAL_REQUEST = "access-approval-request" +} + +export interface TUserNotification { + id: string; + userId: string; + type: string; + title: string; + body?: string | null; + link?: string | null; + isRead: boolean; + createdAt: Date; +} + +export interface TCreateUserNotificationDTO { + userId: string; + type: NotificationType; + title: string; + body?: string; + link?: string; +} diff --git a/backend/src/services/notification/user-notification-dal.ts b/backend/src/services/notification/user-notification-dal.ts new file mode 100644 index 000000000..7163118f5 --- /dev/null +++ b/backend/src/services/notification/user-notification-dal.ts @@ -0,0 +1,118 @@ +import knex from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError, GatewayTimeoutError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { logger } from "@app/lib/logger"; +import { QueueName } from "@app/queue"; + +export type TUserNotificationDALFactory = ReturnType; + +const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes +const PRUNE_BATCH_SIZE = 10000; +const MAX_RETRY_ON_FAILURE = 3; + +export const userNotificationDALFactory = (db: TDbClient) => { + const notificationOrm = ormify(db, TableName.UserNotifications); + + const find = async ( + { + userId, + startDate, + endDate, + limit = 10000, + offset = 0 + }: { + userId: string; + startDate: string; + endDate: string; + limit?: number; + offset?: number; + }, + tx?: knex.Knex + ) => { + try { + const docs = await (tx || db.replicaNode())(TableName.UserNotifications) + .where(`${TableName.UserNotifications}.userId`, userId) + .whereRaw(`"${TableName.UserNotifications}"."createdAt" >= ?::timestamptz`, [startDate]) + .andWhereRaw(`"${TableName.UserNotifications}"."createdAt" < ?::timestamptz`, [endDate]) + .select(selectAllTableCols(TableName.UserNotifications)) + .limit(limit) + .offset(offset) + .orderBy(`${TableName.UserNotifications}.createdAt`, "desc") + .timeout(1000 * 120); // 2 minutes timeout + + return docs; + } catch (error) { + if (error instanceof knex.KnexTimeoutError) { + throw new GatewayTimeoutError({ + error, + message: "Failed to fetch notifications due to timeout." + }); + } + + throw new DatabaseError({ error }); + } + }; + + // delete all notifications older than 3 months + const pruneNotifications = async () => { + const threeMonthsAgo = new Date(); + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); + let deletedNotificationIds: { id: string }[] = []; + let numberOfRetryOnFailure = 0; + + logger.info(`${QueueName.DailyResourceCleanUp}: prune notifications started`); + do { + try { + // eslint-disable-next-line no-await-in-loop + deletedNotificationIds = await db.transaction(async (trx) => { + await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`); + + const findExpiredNotificationSubQuery = trx(TableName.UserNotifications) + .where("createdAt", "<", threeMonthsAgo) + .orderBy(`${TableName.UserNotifications}.createdAt`, "desc") + .select("id") + .limit(PRUNE_BATCH_SIZE); + + // eslint-disable-next-line no-await-in-loop + const results = await trx(TableName.UserNotifications) + .whereIn("id", findExpiredNotificationSubQuery) + .del() + .returning("id"); + + return results; + }); + + numberOfRetryOnFailure = 0; + } catch (error) { + numberOfRetryOnFailure += 1; + deletedNotificationIds = []; + logger.error(error, "Failed to delete notification on pruning. Retrying..."); + } finally { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + } + } while ( + deletedNotificationIds.length > 0 || + (numberOfRetryOnFailure > 0 && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE) + ); + + if (numberOfRetryOnFailure >= MAX_RETRY_ON_FAILURE) { + logger.error( + `${QueueName.DailyResourceCleanUp}: prune notifications completed with persistent errors after ${MAX_RETRY_ON_FAILURE} retries. Some notifications might not have been pruned.` + ); + } else { + logger.info(`${QueueName.DailyResourceCleanUp}: prune notifications completed`); + } + }; + + const markAllNotificationsAsRead = async (userId: string) => { + await db(TableName.UserNotifications).where({ userId }).update({ isRead: true }); + }; + + return { ...notificationOrm, pruneNotifications, find, markAllNotificationsAsRead }; +}; diff --git a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts index dcfa7ea0d..fbd9d3fd6 100644 --- a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -3,6 +3,7 @@ import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-d import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { TUserNotificationDALFactory } from "@app/services/notification/user-notification-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityUaClientSecretDALFactory } from "../identity-ua/identity-ua-client-secret-dal"; @@ -25,6 +26,7 @@ type TDailyResourceCleanUpQueueServiceFactoryDep = { serviceTokenService: Pick; queueService: TQueueServiceFactory; orgService: TOrgServiceFactory; + userNotificationDAL: Pick; }; export type TDailyResourceCleanUpQueueServiceFactory = ReturnType; @@ -40,7 +42,8 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ secretVersionV2DAL, identityUniversalAuthClientSecretDAL, serviceTokenService, - orgService + orgService, + userNotificationDAL }: TDailyResourceCleanUpQueueServiceFactoryDep) => { const appCfg = getConfig(); @@ -78,6 +81,7 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ await serviceTokenService.notifyExpiringTokens(); await orgService.notifyInvitedUsers(); await auditLogDAL.pruneAuditLog(); + await userNotificationDAL.pruneNotifications(); logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`); } catch (error) { logger.error(error, `${QueueName.DailyResourceCleanUp}: resource cleanup failed`); diff --git a/frontend/src/components/v2/ContentLoader/ContentLoader.tsx b/frontend/src/components/v2/ContentLoader/ContentLoader.tsx index f60668aec..c9a3a1bea 100644 --- a/frontend/src/components/v2/ContentLoader/ContentLoader.tsx +++ b/frontend/src/components/v2/ContentLoader/ContentLoader.tsx @@ -11,9 +11,10 @@ type Props = { text?: string | string[]; frequency?: number; className?: string; + lottieClassName?: string; }; -export const ContentLoader = ({ text, frequency = 2000, className }: Props) => { +export const ContentLoader = ({ text, frequency = 2000, className, lottieClassName }: Props) => { const [pos, setPos] = useState(0); const isTextArray = Array.isArray(text); useEffect(() => { @@ -33,7 +34,11 @@ export const ContentLoader = ({ text, frequency = 2000, className }: Props) => { className )} > - + {text && isTextArray && ( // just render children if tooltip content is empty content ? ( { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => { + await apiRequest.post("/api/v1/notifications/user/mark-as-read"); + }, + onSuccess: () => { + queryClient.setQueryData(notificationKeys.list(), (oldData) => { + if (!oldData) return oldData; + return oldData.map((notification) => ({ + ...notification, + isRead: true + })); + }); + } + }); +}; + +export const useMarkNotificationAsRead = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (notificationId: string) => { + await apiRequest.post(`/api/v1/notifications/user/${notificationId}/mark-as-read`); + }, + onSuccess: (_, notificationId) => { + queryClient.setQueryData(notificationKeys.list(), (oldData) => { + if (!oldData) return oldData; + return oldData.map((notification) => + notification.id === notificationId ? { ...notification, isRead: true } : notification + ); + }); + } + }); +}; + +export const useDeleteNotification = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (notificationId: string) => { + await apiRequest.delete(`/api/v1/notifications/user/${notificationId}`); + }, + onSuccess: (_, notificationId) => { + queryClient.setQueryData(notificationKeys.list(), (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 new file mode 100644 index 000000000..66bbee7a8 --- /dev/null +++ b/frontend/src/hooks/api/notifications/queries.tsx @@ -0,0 +1,25 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TUserNotification } from "./types"; + +export const notificationKeys = { + all: ["notifications"] as const, + list: () => [...notificationKeys.all, "list"] as const +}; + +export const useGetMyNotifications = () => { + return useQuery({ + queryKey: notificationKeys.list(), + queryFn: async () => { + const { + data: { notifications } + } = await apiRequest.get<{ notifications: TUserNotification[] }>( + "/api/v1/notifications/user" + ); + return notifications; + }, + refetchInterval: 10 * 1000 // Poll every 10 seconds + }); +}; diff --git a/frontend/src/hooks/api/notifications/types.ts b/frontend/src/hooks/api/notifications/types.ts new file mode 100644 index 000000000..8d5663a7c --- /dev/null +++ b/frontend/src/hooks/api/notifications/types.ts @@ -0,0 +1,10 @@ +export interface TUserNotification { + id: string; + userId: string; + type: string; + title: string; + body?: string | null; + link?: string | null; + isRead: boolean; + createdAt: Date; +} diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index 5c37d4404..3bda912dd 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -1,6 +1,6 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { faGithub, faSlack } from "@fortawesome/free-brands-svg-icons"; -import { faCircleQuestion, faUserCircle } from "@fortawesome/free-regular-svg-icons"; +import { faBell, faCircleQuestion, faUserCircle } from "@fortawesome/free-regular-svg-icons"; import { faArrowUpRightFromSquare, faBook, @@ -24,6 +24,7 @@ import SecurityClient from "@app/components/utilities/SecurityClient"; import { BreadcrumbContainer, Button, + ContentLoader, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -41,12 +42,19 @@ import { useToggle } from "@app/hooks"; import { useGetOrganizations, useLogoutUser, workspaceKeys } from "@app/hooks/api"; import { authKeys, selectOrganization } from "@app/hooks/api/auth/queries"; import { MfaMethod } from "@app/hooks/api/auth/types"; +import { + useDeleteNotification, + useMarkAllNotificationsAsRead, + useMarkNotificationAsRead +} from "@app/hooks/api/notifications/mutations"; +import { useGetMyNotifications } from "@app/hooks/api/notifications/queries"; import { getAuthToken } from "@app/hooks/api/reactQuery"; import { SubscriptionPlan } from "@app/hooks/api/types"; import { AuthMethod } from "@app/hooks/api/users/types"; import { navigateUserToOrg } from "@app/pages/auth/LoginPage/Login.utils"; import { ServerAdminsPanel } from "../ServerAdminsPanel/ServerAdminsPanel"; +import { Notification } from "./Notification"; const getPlan = (subscription: SubscriptionPlan) => { if (subscription.groups) return "Enterprise"; @@ -118,6 +126,16 @@ export const Navbar = () => { const router = useRouter(); const queryClient = useQueryClient(); + const { data: notifications, isLoading } = useGetMyNotifications(); + const { mutate: markAllAsRead } = useMarkAllNotificationsAsRead(); + const { mutate: markNotificationAsRead } = useMarkNotificationAsRead(); + const { mutate: deleteNotification } = useDeleteNotification(); + + const unreadCount = useMemo( + () => notifications?.filter((n) => !n.isRead).length || 0, + [notifications] + ); + const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context }); const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined; @@ -280,7 +298,7 @@ export const Navbar = () => {
-
+
@@ -334,9 +352,89 @@ export const Navbar = () => { )} + + +
+ + {unreadCount > 0 && ( + + {unreadCount > 99 ? "99+" : unreadCount} + + )} +
+
+ +
+
+ Notifications + +
+
+ {isLoading && ( +
+ +
+ )} + {!isLoading && notifications?.length === 0 && ( +
+ + No new notifications + + We'll let you know when something important happens. + +
+ )} + {!isLoading && notifications && notifications.length > 0 && ( +
+ {notifications.map((notification) => ( +
{ + if (!notification.isRead) { + markNotificationAsRead(notification.id); + } + if (notification.link) { + router.navigate({ to: notification.link }); + } + }} + onKeyDown={(e) => { + if (e.key !== "Enter") return; + if (!notification.isRead) { + markNotificationAsRead(notification.id); + } + if (notification.link) { + router.navigate({ to: notification.link }); + } + }} + > + +
+ ))} +
+ )} +
+
+
+
-
+
diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Notification.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Notification.tsx new file mode 100644 index 000000000..85803e043 --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Notification.tsx @@ -0,0 +1,59 @@ +import Markdown from "react-markdown"; +import { faCircle, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { formatDistance } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { IconButton, Tooltip } from "@app/components/v2"; +import { TUserNotification } from "@app/hooks/api/notifications/types"; + +type Props = { + notification: TUserNotification; + onDelete: (notificationId: string) => void; +}; + +export const Notification = ({ notification, onDelete }: Props) => { + return ( +
+
+
+ {!notification.isRead && ( + + )} + + + {notification.title} + + + + {formatDistance(notification.createdAt, new Date())} ago + +
+ {notification.body && ( + + {notification.body} + + )} +
+
+ { + e.stopPropagation(); + onDelete(notification.id); + }} + > + + +
+
+ ); +};