Skip to content

Commit 47bd6d8

Browse files
github-actions[bot]Marfuenclaude
authored
feat(frameworks): add framework updates banner to overview page (#2790)
Add a bulk GET /v1/frameworks/update-statuses endpoint and display an info banner on the overview page when framework updates are available. Each framework update links to its review-update page. SWR cache is invalidated on sync and rollback so the banner disappears immediately. Co-authored-by: Mariano <marfuen98@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6b96503 commit 47bd6d8

7 files changed

Lines changed: 209 additions & 3 deletions

File tree

apps/api/src/frameworks/frameworks.controller.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,15 @@ export class FrameworksController {
8585
return this.frameworksService.getScores(organizationId, authContext.userId);
8686
}
8787

88+
@Get('update-statuses')
89+
@RequirePermission('framework', 'read')
90+
@ApiOperation({ summary: 'Get update statuses for all framework instances' })
91+
async getAllUpdateStatuses(@OrganizationId() organizationId: string) {
92+
const data =
93+
await this.frameworksService.getAllUpdateStatuses(organizationId);
94+
return { data, count: data.length };
95+
}
96+
8897
@Get(':id')
8998
@RequirePermission('framework', 'read')
9099
@ApiOperation({ summary: 'Get a single framework instance with full detail' })

apps/api/src/frameworks/frameworks.service.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,68 @@ export class FrameworksService {
699699
return { success: true };
700700
}
701701

702+
async getAllUpdateStatuses(organizationId: string) {
703+
const instances = await db.frameworkInstance.findMany({
704+
where: { organizationId, frameworkId: { not: null } },
705+
include: {
706+
currentVersion: { select: { id: true, version: true } },
707+
framework: { select: { id: true, name: true } },
708+
},
709+
});
710+
711+
if (instances.length === 0) return [];
712+
713+
const frameworkIds = [
714+
...new Set(instances.map((i) => i.frameworkId).filter(Boolean)),
715+
] as string[];
716+
717+
const latestVersions = await Promise.all(
718+
frameworkIds.map((fid) =>
719+
db.frameworkVersion.findFirst({
720+
where: { frameworkId: fid },
721+
orderBy: { publishedAt: 'desc' },
722+
select: {
723+
id: true,
724+
version: true,
725+
publishedAt: true,
726+
releaseNotes: true,
727+
frameworkId: true,
728+
},
729+
}),
730+
),
731+
);
732+
733+
const latestByFramework = new Map(
734+
latestVersions
735+
.filter(Boolean)
736+
.map((v) => [v!.frameworkId, v!]),
737+
);
738+
739+
return instances
740+
.map((instance) => {
741+
const latest = latestByFramework.get(instance.frameworkId!) ?? null;
742+
const updateAvailable =
743+
latest !== null && latest.id !== instance.currentVersion?.id;
744+
if (!updateAvailable) return null;
745+
746+
return {
747+
frameworkInstanceId: instance.id,
748+
frameworkName: instance.framework?.name ?? null,
749+
currentVersion: instance.currentVersion,
750+
latestVersion: latest
751+
? {
752+
id: latest.id,
753+
version: latest.version,
754+
publishedAt: latest.publishedAt,
755+
releaseNotes: latest.releaseNotes,
756+
}
757+
: null,
758+
updateAvailable,
759+
};
760+
})
761+
.filter(Boolean);
762+
}
763+
702764
async getUpdateStatus(params: {
703765
organizationId: string;
704766
frameworkInstanceId: string;
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
'use client';
2+
3+
import { useFrameworkUpdateStatuses } from '@/hooks/use-framework-update-statuses';
4+
import { usePermissions } from '@/hooks/use-permissions';
5+
import {
6+
Badge,
7+
Button,
8+
Collapsible,
9+
CollapsibleContent,
10+
CollapsibleTrigger,
11+
HStack,
12+
Text,
13+
} from '@trycompai/design-system';
14+
import { ChevronUp, Upgrade } from '@trycompai/design-system/icons';
15+
import { useParams, useRouter } from 'next/navigation';
16+
import { useState } from 'react';
17+
18+
export function FrameworkUpdatesBanner() {
19+
const { data: statuses } = useFrameworkUpdateStatuses();
20+
const { hasPermission } = usePermissions();
21+
const router = useRouter();
22+
const { orgId } = useParams<{ orgId: string }>();
23+
const [open, setOpen] = useState(true);
24+
25+
const canUpdate = hasPermission('framework', 'update');
26+
27+
if (!statuses || statuses.length === 0) return null;
28+
29+
const count = statuses.length;
30+
31+
return (
32+
<div className="mx-auto w-full max-w-[1200px] pb-8">
33+
<Collapsible open={open} onOpenChange={setOpen}>
34+
<div className="rounded-lg border bg-card">
35+
<div className="flex items-center justify-between rounded-t-lg bg-secondary px-4 py-3">
36+
<HStack gap="3" align="center">
37+
<div className="flex size-7 items-center justify-center rounded-full bg-primary text-primary-foreground">
38+
<Upgrade size={16} />
39+
</div>
40+
<Text size="sm" weight="medium">
41+
{count} framework {count === 1 ? 'update' : 'updates'} available
42+
</Text>
43+
<Badge variant="default">NEW</Badge>
44+
</HStack>
45+
<CollapsibleTrigger className="flex cursor-pointer items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors">
46+
{open ? `Hide ${count}` : `Show ${count}`}
47+
<ChevronUp
48+
size={16}
49+
className={`transition-transform ${open ? '' : 'rotate-180'}`}
50+
/>
51+
</CollapsibleTrigger>
52+
</div>
53+
54+
<CollapsibleContent>
55+
<div className="border-t">
56+
{statuses.map((status, index) => (
57+
<div
58+
key={status.frameworkInstanceId}
59+
className={`flex items-center justify-between px-4 py-3 ${
60+
index < count - 1 ? 'border-b' : ''
61+
}`}
62+
>
63+
<HStack gap="4" align="center">
64+
<Text size="sm" weight="medium">
65+
{status.frameworkName ?? 'Framework'}
66+
</Text>
67+
<Text size="sm" variant="muted">
68+
v{status.currentVersion?.version ?? '—'} → v
69+
{status.latestVersion?.version}
70+
</Text>
71+
</HStack>
72+
{canUpdate && (
73+
<Button
74+
size="sm"
75+
variant="outline"
76+
onClick={() =>
77+
router.push(
78+
`/${orgId}/frameworks/${status.frameworkInstanceId}/review-update`,
79+
)
80+
}
81+
>
82+
Review update
83+
</Button>
84+
)}
85+
</div>
86+
))}
87+
</div>
88+
</CollapsibleContent>
89+
</div>
90+
</Collapsible>
91+
</div>
92+
);
93+
}

apps/app/src/app/(app)/[orgId]/overview/page.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { serverApi } from '@/lib/api-server';
22
import type { FrameworkEditorFramework, Policy, Task } from '@db';
33
import { PageHeader, PageLayout } from '@trycompai/design-system';
4+
import { FrameworkUpdatesBanner } from './components/FrameworkUpdatesBanner';
45
import { Overview } from './components/Overview';
56
import { OverviewTabs } from './components/OverviewTabs';
67
import type { FrameworkInstanceWithControls } from '@/lib/types/framework';
@@ -52,8 +53,10 @@ export default async function OverviewPage({ params }: { params: Promise<{ orgId
5253
}));
5354

5455
return (
55-
<PageLayout header={<PageHeader title="Overview" tabs={<OverviewTabs />} />}>
56-
<Overview
56+
<>
57+
<FrameworkUpdatesBanner />
58+
<PageLayout header={<PageHeader title="Overview" tabs={<OverviewTabs />} />}>
59+
<Overview
5760
frameworksWithControls={frameworksWithControls}
5861
frameworksWithCompliance={frameworksWithCompliance}
5962
allFrameworks={allFrameworks}
@@ -82,6 +85,7 @@ export default async function OverviewPage({ params }: { params: Promise<{ orgId
8285
currentMember={scores?.currentMember ?? null}
8386
onboardingTriggerJobId={scores?.onboardingTriggerJobId ?? null}
8487
/>
85-
</PageLayout>
88+
</PageLayout>
89+
</>
8690
);
8791
}

apps/app/src/hooks/use-framework-rollback.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useState } from 'react';
44
import { apiClient } from '@/lib/api-client';
55
import { mutate } from 'swr';
6+
import { FRAMEWORK_UPDATE_STATUSES_KEY } from './use-framework-update-statuses';
67

78
interface RollbackResult {
89
rollbackOperationId: string;
@@ -24,6 +25,7 @@ export function useFrameworkRollback(frameworkInstanceId: string) {
2425
mutate(`/v1/frameworks/${frameworkInstanceId}/update-preview`),
2526
mutate(`/v1/frameworks/${frameworkInstanceId}/sync-history`),
2627
mutate(`/v1/frameworks/${frameworkInstanceId}`),
28+
mutate(FRAMEWORK_UPDATE_STATUSES_KEY),
2729
]);
2830
return res.data?.data as RollbackResult;
2931
} finally {

apps/app/src/hooks/use-framework-sync.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useState } from 'react';
44
import { apiClient } from '@/lib/api-client';
55
import { mutate } from 'swr';
6+
import { FRAMEWORK_UPDATE_STATUSES_KEY } from './use-framework-update-statuses';
67

78
interface SyncResult {
89
syncOperationId: string;
@@ -30,6 +31,7 @@ export function useFrameworkSync(frameworkInstanceId: string) {
3031
mutate(`/v1/frameworks/${frameworkInstanceId}/update-status`),
3132
mutate(`/v1/frameworks/${frameworkInstanceId}/sync-history`),
3233
mutate(`/v1/frameworks/${frameworkInstanceId}`),
34+
mutate(FRAMEWORK_UPDATE_STATUSES_KEY),
3335
]);
3436
return res.data?.data as SyncResult;
3537
} finally {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
'use client';
2+
3+
import useSWR from 'swr';
4+
import { apiClient } from '@/lib/api-client';
5+
6+
export const FRAMEWORK_UPDATE_STATUSES_KEY = '/v1/frameworks/update-statuses';
7+
8+
export interface FrameworkUpdateStatusItem {
9+
frameworkInstanceId: string;
10+
frameworkName: string | null;
11+
currentVersion: { id: string; version: string } | null;
12+
latestVersion: {
13+
id: string;
14+
version: string;
15+
publishedAt: string;
16+
releaseNotes: string | null;
17+
} | null;
18+
updateAvailable: boolean;
19+
}
20+
21+
export function useFrameworkUpdateStatuses() {
22+
return useSWR<FrameworkUpdateStatusItem[]>(
23+
FRAMEWORK_UPDATE_STATUSES_KEY,
24+
async (url: string) => {
25+
const res = await apiClient.get<{ data: FrameworkUpdateStatusItem[] }>(url);
26+
if (res.error) throw new Error(res.error);
27+
return res.data?.data ?? [];
28+
},
29+
{
30+
revalidateOnMount: true,
31+
revalidateOnFocus: true,
32+
},
33+
);
34+
}

0 commit comments

Comments
 (0)