Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/api/src/frameworks/frameworks.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ export class FrameworksController {
return this.frameworksService.getScores(organizationId, authContext.userId);
}

@Get('update-statuses')
@RequirePermission('framework', 'read')
@ApiOperation({ summary: 'Get update statuses for all framework instances' })
async getAllUpdateStatuses(@OrganizationId() organizationId: string) {
const data =
await this.frameworksService.getAllUpdateStatuses(organizationId);
return { data, count: data.length };
}

@Get(':id')
@RequirePermission('framework', 'read')
@ApiOperation({ summary: 'Get a single framework instance with full detail' })
Expand Down
62 changes: 62 additions & 0 deletions apps/api/src/frameworks/frameworks.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,68 @@ export class FrameworksService {
return { success: true };
}

async getAllUpdateStatuses(organizationId: string) {
const instances = await db.frameworkInstance.findMany({
where: { organizationId, frameworkId: { not: null } },
include: {
currentVersion: { select: { id: true, version: true } },
framework: { select: { id: true, name: true } },
},
});

if (instances.length === 0) return [];

const frameworkIds = [
...new Set(instances.map((i) => i.frameworkId).filter(Boolean)),
] as string[];

const latestVersions = await Promise.all(
frameworkIds.map((fid) =>
db.frameworkVersion.findFirst({
where: { frameworkId: fid },
orderBy: { publishedAt: 'desc' },
select: {
id: true,
version: true,
publishedAt: true,
releaseNotes: true,
frameworkId: true,
},
}),
),
);

const latestByFramework = new Map(
latestVersions
.filter(Boolean)
.map((v) => [v!.frameworkId, v!]),
);

return instances
.map((instance) => {
const latest = latestByFramework.get(instance.frameworkId!) ?? null;
const updateAvailable =
latest !== null && latest.id !== instance.currentVersion?.id;
if (!updateAvailable) return null;

return {
frameworkInstanceId: instance.id,
frameworkName: instance.framework?.name ?? null,
currentVersion: instance.currentVersion,
latestVersion: latest
? {
id: latest.id,
version: latest.version,
publishedAt: latest.publishedAt,
releaseNotes: latest.releaseNotes,
}
: null,
updateAvailable,
};
})
.filter(Boolean);
}

async getUpdateStatus(params: {
organizationId: string;
frameworkInstanceId: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
'use client';

import { useFrameworkUpdateStatuses } from '@/hooks/use-framework-update-statuses';
import { usePermissions } from '@/hooks/use-permissions';
import {
Badge,
Button,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
HStack,
Text,
} from '@trycompai/design-system';
import { ChevronUp, Upgrade } from '@trycompai/design-system/icons';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';

export function FrameworkUpdatesBanner() {
const { data: statuses } = useFrameworkUpdateStatuses();
const { hasPermission } = usePermissions();
const router = useRouter();
const { orgId } = useParams<{ orgId: string }>();
const [open, setOpen] = useState(true);

const canUpdate = hasPermission('framework', 'update');

if (!statuses || statuses.length === 0) return null;

const count = statuses.length;

return (
<div className="mx-auto w-full max-w-[1200px] pb-8">
<Collapsible open={open} onOpenChange={setOpen}>
<div className="rounded-lg border bg-card">
<div className="flex items-center justify-between rounded-t-lg bg-secondary px-4 py-3">
<HStack gap="3" align="center">
<div className="flex size-7 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Upgrade size={16} />
</div>
<Text size="sm" weight="medium">
{count} framework {count === 1 ? 'update' : 'updates'} available
</Text>
<Badge variant="default">NEW</Badge>
</HStack>
<CollapsibleTrigger className="flex cursor-pointer items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors">
{open ? `Hide ${count}` : `Show ${count}`}
<ChevronUp
size={16}
className={`transition-transform ${open ? '' : 'rotate-180'}`}
/>
</CollapsibleTrigger>
</div>

<CollapsibleContent>
<div className="border-t">
{statuses.map((status, index) => (
<div
key={status.frameworkInstanceId}
className={`flex items-center justify-between px-4 py-3 ${
index < count - 1 ? 'border-b' : ''
}`}
>
<HStack gap="4" align="center">
<Text size="sm" weight="medium">
{status.frameworkName ?? 'Framework'}
</Text>
<Text size="sm" variant="muted">
v{status.currentVersion?.version ?? '—'} → v
{status.latestVersion?.version}
</Text>
</HStack>
{canUpdate && (
<Button
size="sm"
variant="outline"
onClick={() =>
router.push(
`/${orgId}/frameworks/${status.frameworkInstanceId}/review-update`,
)
}
>
Review update
</Button>
)}
</div>
))}
</div>
</CollapsibleContent>
</div>
</Collapsible>
</div>
);
}
10 changes: 7 additions & 3 deletions apps/app/src/app/(app)/[orgId]/overview/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { serverApi } from '@/lib/api-server';
import type { FrameworkEditorFramework, Policy, Task } from '@db';
import { PageHeader, PageLayout } from '@trycompai/design-system';
import { FrameworkUpdatesBanner } from './components/FrameworkUpdatesBanner';
import { Overview } from './components/Overview';
import { OverviewTabs } from './components/OverviewTabs';
import type { FrameworkInstanceWithControls } from '@/lib/types/framework';
Expand Down Expand Up @@ -52,8 +53,10 @@ export default async function OverviewPage({ params }: { params: Promise<{ orgId
}));

return (
<PageLayout header={<PageHeader title="Overview" tabs={<OverviewTabs />} />}>
<Overview
<>
<FrameworkUpdatesBanner />
<PageLayout header={<PageHeader title="Overview" tabs={<OverviewTabs />} />}>
<Overview
frameworksWithControls={frameworksWithControls}
frameworksWithCompliance={frameworksWithCompliance}
allFrameworks={allFrameworks}
Expand Down Expand Up @@ -82,6 +85,7 @@ export default async function OverviewPage({ params }: { params: Promise<{ orgId
currentMember={scores?.currentMember ?? null}
onboardingTriggerJobId={scores?.onboardingTriggerJobId ?? null}
/>
</PageLayout>
</PageLayout>
</>
);
}
2 changes: 2 additions & 0 deletions apps/app/src/hooks/use-framework-rollback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState } from 'react';
import { apiClient } from '@/lib/api-client';
import { mutate } from 'swr';
import { FRAMEWORK_UPDATE_STATUSES_KEY } from './use-framework-update-statuses';

interface RollbackResult {
rollbackOperationId: string;
Expand All @@ -24,6 +25,7 @@ export function useFrameworkRollback(frameworkInstanceId: string) {
mutate(`/v1/frameworks/${frameworkInstanceId}/update-preview`),
mutate(`/v1/frameworks/${frameworkInstanceId}/sync-history`),
mutate(`/v1/frameworks/${frameworkInstanceId}`),
mutate(FRAMEWORK_UPDATE_STATUSES_KEY),
]);
return res.data?.data as RollbackResult;
} finally {
Expand Down
2 changes: 2 additions & 0 deletions apps/app/src/hooks/use-framework-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState } from 'react';
import { apiClient } from '@/lib/api-client';
import { mutate } from 'swr';
import { FRAMEWORK_UPDATE_STATUSES_KEY } from './use-framework-update-statuses';

interface SyncResult {
syncOperationId: string;
Expand Down Expand Up @@ -30,6 +31,7 @@ export function useFrameworkSync(frameworkInstanceId: string) {
mutate(`/v1/frameworks/${frameworkInstanceId}/update-status`),
mutate(`/v1/frameworks/${frameworkInstanceId}/sync-history`),
mutate(`/v1/frameworks/${frameworkInstanceId}`),
mutate(FRAMEWORK_UPDATE_STATUSES_KEY),
]);
return res.data?.data as SyncResult;
} finally {
Expand Down
34 changes: 34 additions & 0 deletions apps/app/src/hooks/use-framework-update-statuses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use client';

import useSWR from 'swr';
import { apiClient } from '@/lib/api-client';

export const FRAMEWORK_UPDATE_STATUSES_KEY = '/v1/frameworks/update-statuses';

export interface FrameworkUpdateStatusItem {
frameworkInstanceId: string;
frameworkName: string | null;
currentVersion: { id: string; version: string } | null;
latestVersion: {
id: string;
version: string;
publishedAt: string;
releaseNotes: string | null;
} | null;
updateAvailable: boolean;
}

export function useFrameworkUpdateStatuses() {
return useSWR<FrameworkUpdateStatusItem[]>(
FRAMEWORK_UPDATE_STATUSES_KEY,
async (url: string) => {
const res = await apiClient.get<{ data: FrameworkUpdateStatusItem[] }>(url);
if (res.error) throw new Error(res.error);
return res.data?.data ?? [];
},
{
revalidateOnMount: true,
revalidateOnFocus: true,
},
);
}
Loading