add pending approvals count to sidebar

This commit is contained in:
miloschwartz
2026-01-19 21:25:28 -08:00
parent f143d2e214
commit 0b8068e13d
6 changed files with 229 additions and 20 deletions

View File

@@ -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<Approval["decision"]> = [];
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<number>`count(*)` })
.from(approvals)
.where(
and(
eq(approvals.orgId, orgId),
sql`${approvals.decision} in ${state}`
)
);
return response<CountApprovalsResponse>(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")
);
}
}

View File

@@ -13,3 +13,4 @@
export * from "./listApprovals"; export * from "./listApprovals";
export * from "./processPendingApproval"; export * from "./processPendingApproval";
export * from "./countApprovals";

View File

@@ -321,6 +321,13 @@ authenticated.get(
approval.listApprovals approval.listApprovals
); );
authenticated.get(
"/org/:orgId/approvals/count",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listApprovals),
approval.countApprovals
);
authenticated.put( authenticated.put(
"/org/:orgId/approvals/:approvalId", "/org/:orgId/approvals/:approvalId",
verifyValidLicense, verifyValidLicense,

View File

@@ -14,7 +14,9 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useUserContext } from "@app/hooks/useUserContext"; import { useUserContext } from "@app/hooks/useUserContext";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { approvalQueries } from "@app/lib/queries";
import { build } from "@server/build"; import { build } from "@server/build";
import { useQuery } from "@tanstack/react-query";
import { ListUserOrgsResponse } from "@server/routers/org"; import { ListUserOrgsResponse } from "@server/routers/org";
import { ExternalLink, Server } from "lucide-react"; import { ExternalLink, Server } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -57,6 +59,26 @@ export function LayoutSidebar({
const { env } = useEnvContext(); const { env } = useEnvContext();
const t = useTranslations(); 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<string, number | undefined> = {};
if (pendingApprovalCount !== undefined && pendingApprovalCount > 0) {
notificationCounts["sidebarApprovals"] = pendingApprovalCount;
}
const setSidebarStateCookie = (collapsed: boolean) => { const setSidebarStateCookie = (collapsed: boolean) => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const isSecure = window.location.protocol === "https:"; const isSecure = window.location.protocol === "https:";
@@ -157,6 +179,7 @@ export function LayoutSidebar({
<SidebarNav <SidebarNav
sections={navItems} sections={navItems}
isCollapsed={isSidebarCollapsed} isCollapsed={isSidebarCollapsed}
notificationCounts={notificationCounts}
/> />
</div> </div>
{/* Fade gradient at bottom to indicate scrollable content */} {/* Fade gradient at bottom to indicate scrollable content */}

View File

@@ -46,6 +46,7 @@ export interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
disabled?: boolean; disabled?: boolean;
onItemClick?: () => void; onItemClick?: () => void;
isCollapsed?: boolean; isCollapsed?: boolean;
notificationCounts?: Record<string, number | undefined>;
} }
type CollapsibleNavItemProps = { type CollapsibleNavItemProps = {
@@ -59,6 +60,7 @@ type CollapsibleNavItemProps = {
t: (key: string) => string; t: (key: string) => string;
build: string; build: string;
isUnlocked: () => boolean; isUnlocked: () => boolean;
getNotificationCount: (item: SidebarNavItem) => number | undefined;
}; };
function CollapsibleNavItem({ function CollapsibleNavItem({
@@ -71,8 +73,10 @@ function CollapsibleNavItem({
renderNavItem, renderNavItem,
t, t,
build, build,
isUnlocked isUnlocked,
getNotificationCount
}: CollapsibleNavItemProps) { }: CollapsibleNavItemProps) {
const notificationCount = getNotificationCount(item);
const storageKey = `pangolin-sidebar-expanded-${item.title}`; const storageKey = `pangolin-sidebar-expanded-${item.title}`;
// Get initial state from localStorage or use isChildActive // Get initial state from localStorage or use isChildActive
@@ -139,6 +143,14 @@ function CollapsibleNavItem({
)} )}
</div> </div>
<div className="flex items-center gap-1.5 flex-shrink-0 ml-2"> <div className="flex items-center gap-1.5 flex-shrink-0 ml-2">
{notificationCount !== undefined &&
notificationCount > 0 && (
<Badge
variant="secondary"
>
{notificationCount > 99 ? "99+" : notificationCount}
</Badge>
)}
{build === "enterprise" && {build === "enterprise" &&
item.showEE && item.showEE &&
!isUnlocked() && ( !isUnlocked() && (
@@ -177,6 +189,7 @@ export function SidebarNav({
disabled = false, disabled = false,
onItemClick, onItemClick,
isCollapsed = false, isCollapsed = false,
notificationCounts,
...props ...props
}: SidebarNavProps) { }: SidebarNavProps) {
const pathname = usePathname(); const pathname = usePathname();
@@ -191,6 +204,11 @@ export function SidebarNav({
const { user } = useUserContext(); const { user } = useUserContext();
const t = useTranslations(); const t = useTranslations();
function getNotificationCount(item: SidebarNavItem): number | undefined {
if (!notificationCounts) return undefined;
return notificationCounts[item.title];
}
function hydrateHref(val?: string): string | undefined { function hydrateHref(val?: string): string | undefined {
if (!val) return undefined; if (!val) return undefined;
return val return val
@@ -247,16 +265,19 @@ export function SidebarNav({
t={t} t={t}
build={build} build={build}
isUnlocked={isUnlocked} isUnlocked={isUnlocked}
getNotificationCount={getNotificationCount}
/> />
); );
} }
const notificationCount = getNotificationCount(item);
// Regular item without nested items // Regular item without nested items
const itemContent = hydratedHref ? ( const itemContent = hydratedHref ? (
<Link <Link
href={isDisabled ? "#" : hydratedHref} href={isDisabled ? "#" : hydratedHref}
className={cn( className={cn(
"flex items-center rounded-md transition-colors", "flex items-center rounded-md transition-colors relative",
isCollapsed isCollapsed
? "px-2 py-2 justify-center" ? "px-2 py-2 justify-center"
: level === 0 : level === 0
@@ -297,18 +318,40 @@ export function SidebarNav({
</span> </span>
)} )}
</div> </div>
{build === "enterprise" && <div className="flex items-center gap-1.5 flex-shrink-0">
item.showEE && {notificationCount !== undefined &&
!isUnlocked() && ( notificationCount > 0 && (
<Badge <Badge
variant="outlinePrimary" variant="secondary"
className="flex-shrink-0" >
> {notificationCount > 99
{t("licenseBadge")} ? "99+"
</Badge> : notificationCount}
)} </Badge>
)}
{build === "enterprise" &&
item.showEE &&
!isUnlocked() && (
<Badge
variant="outlinePrimary"
className="flex-shrink-0"
>
{t("licenseBadge")}
</Badge>
)}
</div>
</> </>
)} )}
{isCollapsed &&
notificationCount !== undefined &&
notificationCount > 0 && (
<Badge
variant="default"
className="absolute -top-1 -right-1 h-5 min-w-5 px-1.5 flex items-center justify-center text-xs bg-primary text-primary-foreground"
>
{notificationCount > 99 ? "99+" : notificationCount}
</Badge>
)}
</Link> </Link>
) : ( ) : (
<div <div
@@ -332,14 +375,27 @@ export function SidebarNav({
</span> </span>
)} )}
</div> </div>
{build === "enterprise" && item.showEE && !isUnlocked() && ( <div className="flex items-center gap-1.5 flex-shrink-0 ml-2">
<Badge {notificationCount !== undefined &&
variant="outlinePrimary" notificationCount > 0 && (
className="flex-shrink-0 ml-2" <Badge
> variant="default"
{t("licenseBadge")} className="flex-shrink-0 bg-primary text-primary-foreground"
</Badge> >
)} {notificationCount > 99
? "99+"
: notificationCount}
</Badge>
)}
{build === "enterprise" && item.showEE && !isUnlocked() && (
<Badge
variant="outlinePrimary"
className="flex-shrink-0"
>
{t("licenseBadge")}
</Badge>
)}
</div>
</div> </div>
); );

View File

@@ -377,5 +377,17 @@ export const approvalQueries = {
}); });
return res.data.data; 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;
}
}) })
}; };