From 0b8068e13d80c0694df474c11f85bb1762f200c5 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 19 Jan 2026 21:25:28 -0800 Subject: [PATCH] add pending approvals count to sidebar --- .../routers/approvals/countApprovals.ts | 110 ++++++++++++++++++ server/private/routers/approvals/index.ts | 1 + server/private/routers/external.ts | 7 ++ src/components/LayoutSidebar.tsx | 23 ++++ src/components/SidebarNav.tsx | 96 +++++++++++---- src/lib/queries.ts | 12 ++ 6 files changed, 229 insertions(+), 20 deletions(-) create mode 100644 server/private/routers/approvals/countApprovals.ts diff --git a/server/private/routers/approvals/countApprovals.ts b/server/private/routers/approvals/countApprovals.ts new file mode 100644 index 00000000..c68e422a --- /dev/null +++ b/server/private/routers/approvals/countApprovals.ts @@ -0,0 +1,110 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +import type { Request, Response, NextFunction } from "express"; +import { approvals, db, type Approval } from "@server/db"; +import { eq, sql, and } from "drizzle-orm"; +import response from "@server/lib/response"; + +const paramsSchema = z.strictObject({ + orgId: z.string() +}); + +const querySchema = z.strictObject({ + approvalState: z + .enum(["pending", "approved", "denied", "all"]) + .optional() + .default("all") + .catch("all") +}); + +export type CountApprovalsResponse = { + count: number; +}; + +export async function countApprovals( + req: Request, + res: Response, + next: NextFunction +) { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const { approvalState } = parsedQuery.data; + const { orgId } = parsedParams.data; + + let state: Array = []; + switch (approvalState) { + case "pending": + state = ["pending"]; + break; + case "approved": + state = ["approved"]; + break; + case "denied": + state = ["denied"]; + break; + default: + state = ["approved", "denied", "pending"]; + } + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(approvals) + .where( + and( + eq(approvals.orgId, orgId), + sql`${approvals.decision} in ${state}` + ) + ); + + return response(res, { + data: { + count + }, + success: true, + error: false, + message: "Approval count retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/approvals/index.ts b/server/private/routers/approvals/index.ts index 40e59cc9..118b3d28 100644 --- a/server/private/routers/approvals/index.ts +++ b/server/private/routers/approvals/index.ts @@ -13,3 +13,4 @@ export * from "./listApprovals"; export * from "./processPendingApproval"; +export * from "./countApprovals"; diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 44af3fe9..cf6e58bc 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -321,6 +321,13 @@ authenticated.get( approval.listApprovals ); +authenticated.get( + "/org/:orgId/approvals/count", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listApprovals), + approval.countApprovals +); + authenticated.put( "/org/:orgId/approvals/:approvalId", verifyValidLicense, diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index 75038d37..15951402 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -14,7 +14,9 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { useUserContext } from "@app/hooks/useUserContext"; import { cn } from "@app/lib/cn"; +import { approvalQueries } from "@app/lib/queries"; import { build } from "@server/build"; +import { useQuery } from "@tanstack/react-query"; import { ListUserOrgsResponse } from "@server/routers/org"; import { ExternalLink, Server } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -57,6 +59,26 @@ export function LayoutSidebar({ const { env } = useEnvContext(); const t = useTranslations(); + // Fetch pending approval count if we have an orgId and it's not an admin page + const shouldFetchApprovalCount = + Boolean(orgId) && !isAdminPage && build !== "oss"; + const approvalCountQuery = orgId + ? approvalQueries.pendingCount(orgId) + : { + queryKey: ["APPROVALS", "", "COUNT", "pending"] as const, + queryFn: async () => 0 + }; + const { data: pendingApprovalCount } = useQuery({ + ...approvalCountQuery, + enabled: shouldFetchApprovalCount + }); + + // Map notification counts by navigation item title + const notificationCounts: Record = {}; + if (pendingApprovalCount !== undefined && pendingApprovalCount > 0) { + notificationCounts["sidebarApprovals"] = pendingApprovalCount; + } + const setSidebarStateCookie = (collapsed: boolean) => { if (typeof window !== "undefined") { const isSecure = window.location.protocol === "https:"; @@ -157,6 +179,7 @@ export function LayoutSidebar({ {/* Fade gradient at bottom to indicate scrollable content */} diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index fb76f451..39ae601f 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -46,6 +46,7 @@ export interface SidebarNavProps extends React.HTMLAttributes { disabled?: boolean; onItemClick?: () => void; isCollapsed?: boolean; + notificationCounts?: Record; } type CollapsibleNavItemProps = { @@ -59,6 +60,7 @@ type CollapsibleNavItemProps = { t: (key: string) => string; build: string; isUnlocked: () => boolean; + getNotificationCount: (item: SidebarNavItem) => number | undefined; }; function CollapsibleNavItem({ @@ -71,8 +73,10 @@ function CollapsibleNavItem({ renderNavItem, t, build, - isUnlocked + isUnlocked, + getNotificationCount }: CollapsibleNavItemProps) { + const notificationCount = getNotificationCount(item); const storageKey = `pangolin-sidebar-expanded-${item.title}`; // Get initial state from localStorage or use isChildActive @@ -139,6 +143,14 @@ function CollapsibleNavItem({ )}
+ {notificationCount !== undefined && + notificationCount > 0 && ( + + {notificationCount > 99 ? "99+" : notificationCount} + + )} {build === "enterprise" && item.showEE && !isUnlocked() && ( @@ -177,6 +189,7 @@ export function SidebarNav({ disabled = false, onItemClick, isCollapsed = false, + notificationCounts, ...props }: SidebarNavProps) { const pathname = usePathname(); @@ -191,6 +204,11 @@ export function SidebarNav({ const { user } = useUserContext(); const t = useTranslations(); + function getNotificationCount(item: SidebarNavItem): number | undefined { + if (!notificationCounts) return undefined; + return notificationCounts[item.title]; + } + function hydrateHref(val?: string): string | undefined { if (!val) return undefined; return val @@ -247,16 +265,19 @@ export function SidebarNav({ t={t} build={build} isUnlocked={isUnlocked} + getNotificationCount={getNotificationCount} /> ); } + const notificationCount = getNotificationCount(item); + // Regular item without nested items const itemContent = hydratedHref ? ( )}
- {build === "enterprise" && - item.showEE && - !isUnlocked() && ( - - {t("licenseBadge")} - - )} +
+ {notificationCount !== undefined && + notificationCount > 0 && ( + + {notificationCount > 99 + ? "99+" + : notificationCount} + + )} + {build === "enterprise" && + item.showEE && + !isUnlocked() && ( + + {t("licenseBadge")} + + )} +
)} + {isCollapsed && + notificationCount !== undefined && + notificationCount > 0 && ( + + {notificationCount > 99 ? "99+" : notificationCount} + + )} ) : (
)}
- {build === "enterprise" && item.showEE && !isUnlocked() && ( - - {t("licenseBadge")} - - )} +
+ {notificationCount !== undefined && + notificationCount > 0 && ( + + {notificationCount > 99 + ? "99+" + : notificationCount} + + )} + {build === "enterprise" && item.showEE && !isUnlocked() && ( + + {t("licenseBadge")} + + )} +
); diff --git a/src/lib/queries.ts b/src/lib/queries.ts index f6907d6b..f0dfa811 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -377,5 +377,17 @@ export const approvalQueries = { }); return res.data.data; } + }), + pendingCount: (orgId: string) => + queryOptions({ + queryKey: ["APPROVALS", orgId, "COUNT", "pending"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse<{ count: number }> + >(`/org/${orgId}/approvals/count?approvalState=pending`, { + signal + }); + return res.data.data.count; + } }) };