diff --git a/backend/src/modules/organization/organization.controller.ts b/backend/src/modules/organization/organization.controller.ts index 7e56c60..7f0fc68 100644 --- a/backend/src/modules/organization/organization.controller.ts +++ b/backend/src/modules/organization/organization.controller.ts @@ -64,6 +64,14 @@ export class OrganizationController { return this.organizationService.searchCounterpartOrganizations(req.user.id, organizationId, q); } + @Get('connections/pending-count') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Count incoming pending connection requests for sidebar badge' }) + countPendingConnections(@Req() req: { user: { id: string; organizationId?: string } }) { + const organizationId = this.organizationService.getOrganizationIdFromUser(req.user); + return this.organizationService.countIncomingPendingConnections(req.user.id, organizationId); + } + @Get('connections') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'List counterpart connections for current organization' }) diff --git a/backend/src/modules/organization/organization.service.ts b/backend/src/modules/organization/organization.service.ts index 5198d77..6945d09 100644 --- a/backend/src/modules/organization/organization.service.ts +++ b/backend/src/modules/organization/organization.service.ts @@ -160,6 +160,29 @@ export class OrganizationService { }; } + /** Lightweight count for sidebar badge — incoming PENDING requests only. */ + async countIncomingPendingConnections(userId: string, organizationId: string) { + const actor = await this.getActorMembership(userId, organizationId); + if (!actor || !this.canEditOrganizations(actor)) { + throw new ForbiddenException('You do not have permission to manage organizations'); + } + + const links = await this.prisma.organizationLink.findMany({ + where: { + status: LinkStatus.PENDING, + OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }], + }, + select: { sharedDataTypes: true }, + }); + + const count = links.filter((link) => { + const requesterOrgId = this.getRequesterOrganizationId(link.sharedDataTypes); + return requesterOrgId !== null && requesterOrgId !== organizationId; + }).length; + + return { success: true, data: { count } }; + } + async listInvitationHistory(userId: string, organizationId: string) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditOrganizations(actor)) { diff --git a/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx b/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx index 87b9f47..c0ffcfc 100644 --- a/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx @@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl'; import { useToast } from '@/lib/hooks/useToast'; import { Check, Trash2, UserPlus, X } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; +import { notifyPendingConnectionsChanged } from '@/lib/hooks/usePendingConnectionsCount'; import { useOrganizationInviteLinkCopy } from '@/lib/hooks/useOrganizationInviteLinkCopy'; import { organizationApi, @@ -254,6 +255,7 @@ export default function OrganizationsPage() { toast.showSuccess( action === 'ACCEPT' ? t('successAccepted') : t('successDeclined'), ); + notifyPendingConnectionsChanged(); await loadList(); } catch (e) { toast.showError(formatApiMessage(e)); diff --git a/frontend/src/components/ui/shared/Sidebar.tsx b/frontend/src/components/ui/shared/Sidebar.tsx index ce64b41..6e55a0c 100644 --- a/frontend/src/components/ui/shared/Sidebar.tsx +++ b/frontend/src/components/ui/shared/Sidebar.tsx @@ -13,6 +13,7 @@ import { CreditCard, } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; +import { usePendingConnectionsCount } from '@/lib/hooks/usePendingConnectionsCount'; import { canAccessAppointmentsSection, canViewTab } from '@/components/shared/permissions'; import { counterpartOrganizationType, @@ -24,6 +25,8 @@ function Sidebar() { const tCommon = useTranslations('common'); const pathname = usePathname(); const { currentOrganization } = useAuth(); + const pendingConnectionsCount = usePendingConnectionsCount(); + const menu = useMemo( () => [ @@ -66,6 +69,8 @@ function Sidebar() { {visibleMenu.map((item) => { const Icon = item.icon; const isActive = pathname === item.path; + const showPendingBadge = + item.path === '/organizations' && pendingConnectionsCount > 0; return ( - {item.name} + {item.name} + {showPendingBadge && ( + + {pendingConnectionsCount} + + )} ); })} diff --git a/frontend/src/lib/api/organization.ts b/frontend/src/lib/api/organization.ts index 7c940ce..0e54cd8 100644 --- a/frontend/src/lib/api/organization.ts +++ b/frontend/src/lib/api/organization.ts @@ -46,6 +46,11 @@ export const organizationApi = { return response.data; }, + pendingIncomingCount: async (): Promise<{ success: boolean; data: { count: number } }> => { + const response = await apiClient.get('/organizations/connections/pending-count'); + return response.data; + }, + listInvitations: async (): Promise<{ success: boolean; data: { items: OrganizationInvitationHistoryItemDto[] }; diff --git a/frontend/src/lib/hooks/usePendingConnectionsCount.ts b/frontend/src/lib/hooks/usePendingConnectionsCount.ts new file mode 100644 index 0000000..4be203b --- /dev/null +++ b/frontend/src/lib/hooks/usePendingConnectionsCount.ts @@ -0,0 +1,48 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { usePathname } from 'next/navigation'; +import { canViewTab } from '@/components/shared/permissions'; +import { organizationApi } from '@/lib/api/organization'; +import { useAuth } from '@/lib/hooks/useAuth'; + +/** Tell the sidebar badge to refetch after accept/decline on the organizations page. */ +export function notifyPendingConnectionsChanged() { + window.dispatchEvent(new Event('pending-connections-changed')); +} + +/** + * Sidebar-only: fetches GET /organizations/connections/pending-count. + * Independent from the organizations tab list API. + */ +export function usePendingConnectionsCount(): number { + const pathname = usePathname(); + const { currentOrganization } = useAuth(); + const [count, setCount] = useState(0); + + const fetchCount = useCallback(async () => { + if (!currentOrganization?.id || !canViewTab(currentOrganization, 'TAB_ORGANIZATIONS_READ')) { + setCount(0); + return; + } + + try { + const res = await organizationApi.pendingIncomingCount(); + setCount(res.data.count); + } catch { + setCount(0); + } + }, [currentOrganization]); + + useEffect(() => { + void fetchCount(); + }, [fetchCount, pathname]); + + useEffect(() => { + const onChanged = () => void fetchCount(); + window.addEventListener('pending-connections-changed', onChanged); + return () => window.removeEventListener('pending-connections-changed', onChanged); + }, [fetchCount]); + + return count; +}