mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-10 20:02:26 +00:00
add pending approvals count to sidebar
This commit is contained in:
110
server/private/routers/approvals/countApprovals.ts
Normal file
110
server/private/routers/approvals/countApprovals.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,3 +13,4 @@
|
|||||||
|
|
||||||
export * from "./listApprovals";
|
export * from "./listApprovals";
|
||||||
export * from "./processPendingApproval";
|
export * from "./processPendingApproval";
|
||||||
|
export * from "./countApprovals";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user