Loading grants...
;
- }
-
- if (!data || data.length === 0) {
- return
@@ -30,35 +23,70 @@ export function GrantsTab({ orgId }: { orgId: string }) {
- {data.map((grant) => (
-
- {grant.subjectEmail}
-
-
- {grant.status}
-
-
- {new Date(grant.expiresAt).toLocaleDateString()}
-
- {grant.revokedAt ? new Date(grant.revokedAt).toLocaleDateString() : '-'}
-
-
- {grant.status === 'active' && (
-
- )}
-
-
- ))}
+ {isLoading
+ ? Array.from({ length: 5 }).map((_, index) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))
+ : data && data.length > 0
+ ? data.map((grant) => (
+
+ {grant.subjectEmail}
+
+
+ {grant.status}
+
+
+ {new Date(grant.expiresAt).toLocaleDateString()}
+
+ {grant.revokedAt ? new Date(grant.revokedAt).toLocaleDateString() : '-'}
+
+
+ {grant.status === 'active' && (
+
+ )}
+
+
+ ))
+ : (
+
+
+ No access grants yet
+
+
+ )}
{revokeId && (
diff --git a/apps/app/src/app/(app)/[orgId]/trust/components/request-tab.tsx b/apps/app/src/app/(app)/[orgId]/trust/components/request-tab.tsx
index 10d5630d9..7ab59c057 100644
--- a/apps/app/src/app/(app)/[orgId]/trust/components/request-tab.tsx
+++ b/apps/app/src/app/(app)/[orgId]/trust/components/request-tab.tsx
@@ -1,6 +1,7 @@
import { useAccessRequests, usePreviewNda, useResendNda } from '@/hooks/use-access-requests';
import { Badge } from '@comp/ui/badge';
import { Button } from '@comp/ui/button';
+import { Skeleton } from '@comp/ui/skeleton';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@comp/ui/table';
import { useState } from 'react';
import { toast } from 'sonner';
@@ -36,14 +37,6 @@ export function RequestsTab({ orgId }: { orgId: string }) {
);
};
- if (isLoading) {
- return
Loading requests...
;
- }
-
- if (!data || data.length === 0) {
- return
No access requests yet
;
- }
-
return (
@@ -61,72 +54,116 @@ export function RequestsTab({ orgId }: { orgId: string }) {
- {data.map((request) => {
- const ndaPending = request.status === 'approved' && !request.grant;
- return (
-
- {new Date(request.createdAt).toLocaleDateString()}
- {request.name}
- {request.email}
- {request.company || '-'}
- {request.purpose || '-'}
- {request.requestedDurationDays ?? 30}d
-
-
- {request.status}
-
+ {isLoading ? (
+ Array.from({ length: 5 }).map((_, index) => (
+
+
+
-
- {ndaPending ? (
- pending
- ) : request.grant ? (
- signed
- ) : (
- '-'
- )}
+
+
-
-
-
-
- {ndaPending && (
+ {request.status}
+
+
+
+ {ndaPending ? (
+ pending
+ ) : request.grant ? (
+ signed
+ ) : (
+ '-'
+ )}
+
+
+
- )}
-
-
-
-
- );
- })}
+
+ {ndaPending && (
+
+ )}
+
+
+
+
+ );
+ })
+ ) : (
+
+
+ No access requests yet
+
+
+ )}
{approveId && (
diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/actions/get-vendors-action.ts b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/actions/get-vendors-action.ts
new file mode 100644
index 000000000..7fd49867f
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/actions/get-vendors-action.ts
@@ -0,0 +1,19 @@
+'use server';
+
+import { auth } from '@/utils/auth';
+import { headers } from 'next/headers';
+import { getVendors } from '../data/queries';
+import type { GetVendorsSchema } from '../data/validations';
+
+export async function getVendorsAction(input: GetVendorsSchema) {
+ const session = await auth.api.getSession({
+ headers: await headers(),
+ });
+
+ if (!session?.session.activeOrganizationId) {
+ return { data: [], pageCount: 0 };
+ }
+
+ return await getVendors(session.session.activeOrganizationId, input);
+}
+
diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorColumns.tsx b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorColumns.tsx
index 8e81d6ff4..09c48b390 100644
--- a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorColumns.tsx
+++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorColumns.tsx
@@ -2,15 +2,60 @@ import { DataTableColumnHeader } from '@/components/data-table/data-table-column
import { VendorStatus } from '@/components/vendor-status';
import { Avatar, AvatarFallback, AvatarImage } from '@comp/ui/avatar';
import { Badge } from '@comp/ui/badge';
-import type { ColumnDef } from '@tanstack/react-table';
-import { UserIcon } from 'lucide-react';
+import type { ColumnDef, Row } from '@tanstack/react-table';
+import { Loader2, UserIcon } from 'lucide-react';
import Link from 'next/link';
-import type { GetVendorsResult } from '../data/queries';
+import { useVendorOnboardingStatus } from './vendor-onboarding-context';
import { VendorDeleteCell } from './VendorDeleteCell';
+import type { VendorRow } from './VendorsTable';
-type VendorRow = GetVendorsResult['data'][number];
+function VendorNameCell({ row, orgId }: { row: Row
; orgId: string }) {
+ const vendorId = row.original.id;
+ const onboardingStatus = useVendorOnboardingStatus();
+ const status = onboardingStatus[vendorId];
+ const isPending = row.original.isPending || status === 'pending' || status === 'processing';
+ const isAssessing = row.original.isAssessing || status === 'assessing';
+ const isResolved = row.original.status === 'assessed';
-export const columns: ColumnDef[] = [
+ if ((isPending || isAssessing) && !isResolved) {
+ return (
+
+
+ {row.original.name}
+
+ );
+ }
+ return {row.original.name};
+}
+
+function VendorStatusCell({ row }: { row: Row }) {
+ const vendorId = row.original.id;
+ const onboardingStatus = useVendorOnboardingStatus();
+ const status = onboardingStatus[vendorId];
+ const isPending = row.original.isPending || status === 'pending' || status === 'processing';
+ const isAssessing = row.original.isAssessing || status === 'assessing';
+ const isResolved = row.original.status === 'assessed';
+
+ if (isPending && !isResolved) {
+ return (
+
+
+ Creating...
+
+ );
+ }
+ if (isAssessing && !isResolved) {
+ return (
+
+
+ Assessing...
+
+ );
+ }
+ return ;
+}
+
+export const columns = (orgId: string): ColumnDef[] => [
{
id: 'name',
accessorKey: 'name',
@@ -18,11 +63,7 @@ export const columns: ColumnDef[] = [
return ;
},
cell: ({ row }) => {
- return (
-
- {row.original.name}
-
- );
+ return ;
},
meta: {
label: 'Vendor Name',
@@ -41,7 +82,7 @@ export const columns: ColumnDef[] = [
return ;
},
cell: ({ row }) => {
- return ;
+ return ;
},
meta: {
label: 'Status',
diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx
index 0da8d6548..7cd700543 100644
--- a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx
+++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx
@@ -2,47 +2,382 @@
import { DataTable } from '@/components/data-table/data-table';
import { DataTableToolbar } from '@/components/data-table/data-table-toolbar';
+import { OnboardingLoadingAnimation } from '@/components/onboarding-loading-animation';
import { useDataTable } from '@/hooks/use-data-table';
-import { useParams } from 'next/navigation';
-import * as React from 'react';
+import { getFiltersStateParser, getSortingStateParser } from '@/lib/parsers';
+import { useSession } from '@/utils/auth-client';
+import { Departments, Vendor } from '@db';
+import { ColumnDef } from '@tanstack/react-table';
+import { Loader2 } from 'lucide-react';
+import { parseAsInteger, parseAsString, parseAsStringEnum, useQueryState } from 'nuqs';
+import { useCallback, useMemo } from 'react';
+import useSWR from 'swr';
import { CreateVendorSheet } from '../../components/create-vendor-sheet';
+import { getVendorsAction } from '../actions/get-vendors-action';
import type { GetAssigneesResult, GetVendorsResult } from '../data/queries';
-import { columns } from './VendorColumns';
+import type { GetVendorsSchema } from '../data/validations';
+import { useOnboardingStatus } from '../hooks/use-onboarding-status';
+import { VendorOnboardingProvider } from './vendor-onboarding-context';
+import { columns as getColumns } from './VendorColumns';
+
+export type VendorRow = GetVendorsResult['data'][number] & {
+ isPending?: boolean;
+ isAssessing?: boolean;
+};
+
+const ACTIVE_STATUSES: Array<'pending' | 'processing' | 'created' | 'assessing'> = [
+ 'pending',
+ 'processing',
+ 'created',
+ 'assessing',
+];
interface VendorsTableProps {
- promises: Promise<[GetVendorsResult, GetAssigneesResult]>;
+ vendors: GetVendorsResult['data'];
+ pageCount: number;
+ assignees: GetAssigneesResult;
+ onboardingRunId?: string | null;
+ searchParams: GetVendorsSchema;
}
-export function VendorsTable({ promises }: VendorsTableProps) {
- const { orgId } = useParams();
+export function VendorsTable({
+ vendors: initialVendors,
+ pageCount: initialPageCount,
+ assignees,
+ onboardingRunId,
+ searchParams: initialSearchParams,
+}: VendorsTableProps) {
+ const session = useSession();
+ const orgId = session?.data?.session?.activeOrganizationId;
+
+ const { itemStatuses, progress, itemsInfo, isActive, isLoading } = useOnboardingStatus(
+ onboardingRunId,
+ 'vendors',
+ );
+
+ // Read current search params from URL (synced with table state via useDataTable)
+ const [page] = useQueryState('page', parseAsInteger.withDefault(1));
+ const [perPage] = useQueryState('perPage', parseAsInteger.withDefault(50));
+ const [name] = useQueryState('name', parseAsString.withDefault(''));
+ const [status] = useQueryState(
+ 'status',
+ parseAsStringEnum(['not_assessed', 'assessed'] as const),
+ );
+ const [department] = useQueryState(
+ 'department',
+ parseAsStringEnum(Object.values(Departments)),
+ );
+ const [assigneeId] = useQueryState('assigneeId', parseAsString);
+ const [sort] = useQueryState(
+ 'sort',
+ getSortingStateParser().withDefault([{ id: 'name', desc: false }]),
+ );
+ const [filters] = useQueryState('filters', getFiltersStateParser().withDefault([]));
+ const [joinOperator] = useQueryState(
+ 'joinOperator',
+ parseAsStringEnum(['and', 'or']).withDefault('and'),
+ );
+
+ // Build current search params from URL state
+ const currentSearchParams = useMemo(() => {
+ return {
+ page,
+ perPage,
+ name,
+ status: status ?? null,
+ department: department ?? null,
+ assigneeId: assigneeId ?? null,
+ sort,
+ filters,
+ joinOperator,
+ };
+ }, [page, perPage, name, status, department, assigneeId, sort, filters, joinOperator]);
+
+ // Create stable SWR key from current search params
+ const swrKey = useMemo(() => {
+ if (!orgId) return null;
+ // Serialize search params to create a stable key
+ const key = JSON.stringify(currentSearchParams);
+ return ['vendors', orgId, key] as const;
+ }, [orgId, currentSearchParams]);
+
+ // Fetcher function for SWR
+ const fetcher = useCallback(async () => {
+ return await getVendorsAction(currentSearchParams);
+ }, [currentSearchParams]);
+
+ // Use SWR to fetch vendors with polling when onboarding is active
+ const { data: vendorsData } = useSWR(swrKey, fetcher, {
+ fallbackData: { data: initialVendors, pageCount: initialPageCount },
+ refreshInterval: isActive ? 1000 : 0, // Poll every 1 second when onboarding is active
+ revalidateOnFocus: false,
+ revalidateOnReconnect: true,
+ keepPreviousData: true,
+ });
+
+ const vendors = vendorsData?.data || initialVendors;
+ const pageCount = vendorsData?.pageCount ?? initialPageCount;
+
+ // Check if all vendors are done assessing
+ const allVendorsDoneAssessing = useMemo(() => {
+ // If no vendors exist yet, we're not done
+ if (vendors.length === 0) {
+ // But check if there are vendors in metadata that should exist
+ if (itemsInfo.length > 0) return false;
+ return false;
+ }
+
+ // Check if we're still creating vendors by comparing DB count with expected total
+ // If progress.total exists and vendors.length < progress.total, we're still creating
+ if (progress && vendors.length < progress.total) {
+ return false;
+ }
+
+ // If there are pending/processing vendors in metadata that aren't in DB yet, we're not done
+ const hasPendingVendors = itemsInfo.some((item) => {
+ const status = itemStatuses[item.id];
+ return (
+ (status === 'pending' ||
+ status === 'processing' ||
+ status === 'created' ||
+ status === 'assessing') &&
+ !vendors.some((v) => v.id === item.id)
+ );
+ });
+
+ if (hasPendingVendors) return false;
+
+ // Check if all vendors in DB are either:
+ // 1. Completed in metadata (status === 'completed')
+ // 2. Assessed in database (status === 'assessed')
+ const allDbVendorsDone = vendors.every((vendor) => {
+ const metadataStatus = itemStatuses[vendor.id];
+ return metadataStatus === 'completed' || vendor.status === 'assessed';
+ });
+
+ // Also check if there are any vendors in metadata that are still assessing
+ const hasAssessingVendors = Object.values(itemStatuses).some(
+ (status) => status === 'assessing' || status === 'processing',
+ );
- // Resolve the promise data here
- const [{ data: vendors, pageCount }, assignees] = React.use(promises);
+ return allDbVendorsDone && !hasAssessingVendors;
+ }, [vendors, itemStatuses, itemsInfo, progress]);
- // Define columns memoized
- const memoizedColumns = React.useMemo(() => columns, []);
+ // Merge DB vendors with metadata vendors (pending ones)
+ const mergedVendors = useMemo(() => {
+ const dbVendorIds = new Set(vendors.map((v) => v.id));
+
+ // Mark vendors in DB as "assessing" if they're not_assessed and onboarding is active
+ // Don't mark as assessing if vendor is already assessed (resolved)
+ const vendorsWithStatus = vendors.map((vendor) => {
+ const metadataStatus = itemStatuses[vendor.id];
+ // If vendor exists in DB but status is not_assessed and onboarding is active, it's being assessed
+ // Only mark as assessing if status is not_assessed (not assessed)
+ if (vendor.status === 'not_assessed' && isActive && onboardingRunId && !metadataStatus) {
+ return { ...vendor, isAssessing: true };
+ }
+ return vendor;
+ });
+
+ const pendingVendors: VendorRow[] = itemsInfo
+ .filter((item) => {
+ // Only show items that are pending/processing and not yet in DB
+ const status = itemStatuses[item.id];
+ return (
+ (status === 'pending' || status === 'processing') &&
+ !dbVendorIds.has(item.id) &&
+ !item.id.startsWith('temp_')
+ );
+ })
+ .map((item) => {
+ // Create a placeholder vendor row for pending items
+ const status = itemStatuses[item.id];
+ return {
+ id: item.id,
+ name: item.name,
+ description: 'Being researched and created by AI...',
+ category: 'other' as const,
+ status: 'not_assessed' as const,
+ inherentProbability: 'very_unlikely' as const,
+ inherentImpact: 'insignificant' as const,
+ residualProbability: 'very_unlikely' as const,
+ residualImpact: 'insignificant' as const,
+ website: null,
+ organizationId: orgId || '',
+ assigneeId: null,
+ assignee: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ isPending: true,
+ } as VendorRow;
+ });
+
+ // Also handle temp IDs (vendors being created)
+ const tempVendors: VendorRow[] = itemsInfo
+ .filter((item) => item.id.startsWith('temp_'))
+ .map((item) => {
+ const status = itemStatuses[item.id];
+ return {
+ id: item.id,
+ name: item.name,
+ description: 'Being researched and created by AI...',
+ category: 'other' as const,
+ status: 'not_assessed' as const,
+ inherentProbability: 'very_unlikely' as const,
+ inherentImpact: 'insignificant' as const,
+ residualProbability: 'very_unlikely' as const,
+ residualImpact: 'insignificant' as const,
+ website: null,
+ organizationId: orgId || '',
+ assigneeId: null,
+ assignee: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ isPending: true,
+ } as VendorRow;
+ });
+
+ return [...vendorsWithStatus, ...pendingVendors, ...tempVendors];
+ }, [vendors, itemsInfo, itemStatuses, orgId, isActive, onboardingRunId]);
+
+ const columns = useMemo[]>(() => getColumns(orgId ?? ''), [orgId]);
const { table } = useDataTable({
- data: vendors,
- columns: memoizedColumns,
- pageCount: pageCount,
+ data: mergedVendors,
+ columns,
+ pageCount,
getRowId: (row) => row.id,
initialState: {
pagination: {
- pageIndex: 0,
pageSize: 50,
+ pageIndex: 0,
},
- sorting: [{ id: 'name', desc: true }],
+ sorting: [{ id: 'name', desc: false }],
+ columnPinning: { right: ['delete-vendor'] },
},
shallow: false,
clearOnDefault: true,
});
+ const getRowProps = useMemo(
+ () => (vendor: VendorRow) => {
+ const status = itemStatuses[vendor.id] || (vendor.isPending ? 'pending' : undefined);
+ const isAssessing = vendor.isAssessing || status === 'assessing';
+ const isBlocked =
+ (status &&
+ ACTIVE_STATUSES.includes(status as 'pending' | 'processing' | 'created' | 'assessing')) ||
+ isAssessing;
+
+ if (!isBlocked) {
+ return {};
+ }
+
+ return {
+ disabled: true,
+ className:
+ 'relative bg-muted/40 opacity-70 pointer-events-none after:absolute after:inset-0 after:bg-background/40 after:content-[""] after:animate-pulse',
+ };
+ },
+ [itemStatuses],
+ );
+
+ // Calculate actual assessment progress
+ const assessmentProgress = useMemo(() => {
+ if (!progress || !itemsInfo.length) {
+ return null;
+ }
+
+ // Count vendors that are completed (either 'completed' in metadata or 'assessed' in DB)
+ const completedCount = vendors.filter((vendor) => {
+ const metadataStatus = itemStatuses[vendor.id];
+ return metadataStatus === 'completed' || vendor.status === 'assessed';
+ }).length;
+
+ // Also count vendors in metadata that are completed but not yet in DB
+ const completedInMetadata = Object.values(itemStatuses).filter(
+ (status) => status === 'completed',
+ ).length;
+
+ // Total is the max of progress.total, itemsInfo.length, or actual vendors created
+ const total = Math.max(progress.total, itemsInfo.length, vendors.length);
+
+ // Completed is the max of DB assessed vendors or metadata completed
+ const completed = Math.max(completedCount, completedInMetadata);
+
+ return { total, completed };
+ }, [progress, itemsInfo, vendors, itemStatuses]);
+
+ const isEmpty = mergedVendors.length === 0;
+ // Show empty state if onboarding is active (even if progress metadata isn't set yet)
+ const showEmptyState = isEmpty && onboardingRunId && isActive;
+
+ // Prevent flicker: if we're loading onboarding status and have a runId, render null
+ // Once we know the status, show animation if empty and active, otherwise show table
+ if (onboardingRunId && isLoading) {
+ return null;
+ }
+
+ // Show loading animation instead of table when empty and onboarding is active
+ if (showEmptyState) {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
return (
<>
- row.id} rowClickBasePath={`/${orgId}/vendors`}>
-
-
+
+ row.id}
+ rowClickBasePath={`/${orgId}/vendors`}
+ getRowProps={getRowProps}
+ >
+ <>
+
+ {isActive && !allVendorsDoneAssessing && (
+
+
+
+
+
+
+ {assessmentProgress
+ ? assessmentProgress.completed === 0
+ ? 'Researching and creating vendors'
+ : assessmentProgress.completed < assessmentProgress.total
+ ? 'Assessing vendors and generating risk assessments'
+ : 'Assessing vendors and generating risk assessments'
+ : progress
+ ? progress.completed === 0
+ ? 'Researching and creating vendors'
+ : 'Assessing vendors and generating risk assessments'
+ : 'Researching and creating vendors'}
+
+
+ {assessmentProgress
+ ? assessmentProgress.completed === 0
+ ? 'AI is analyzing your organization...'
+ : `${assessmentProgress.completed}/${assessmentProgress.total} vendors assessed`
+ : progress
+ ? progress.completed === 0
+ ? 'AI is analyzing your organization...'
+ : `${progress.completed}/${progress.total} vendors created`
+ : 'AI is analyzing your organization...'}
+
+
+
+ )}
+ >
+
+
>
);
diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/vendor-onboarding-context.tsx b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/vendor-onboarding-context.tsx
new file mode 100644
index 000000000..7afeb6897
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/vendor-onboarding-context.tsx
@@ -0,0 +1,35 @@
+'use client';
+
+import { createContext, useContext } from 'react';
+import type { OnboardingItemStatus } from '../../../risk/(overview)/hooks/use-onboarding-status';
+
+export type VendorOnboardingStatus = Record;
+
+interface VendorOnboardingContextValue {
+ statuses: VendorOnboardingStatus;
+}
+
+const VendorOnboardingContext = createContext(undefined);
+
+export function VendorOnboardingProvider({
+ children,
+ statuses,
+}: {
+ children: React.ReactNode;
+ statuses: VendorOnboardingStatus;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function useVendorOnboardingStatus() {
+ const context = useContext(VendorOnboardingContext);
+ if (!context) {
+ return {};
+ }
+ return context.statuses;
+}
+
diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/hooks/use-onboarding-status.ts b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/hooks/use-onboarding-status.ts
new file mode 100644
index 000000000..a7c39322f
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/hooks/use-onboarding-status.ts
@@ -0,0 +1,4 @@
+'use client';
+
+export { useOnboardingStatus } from '../../../risk/(overview)/hooks/use-onboarding-status';
+
diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/page.tsx b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/page.tsx
index 017547cee..fd7e4116a 100644
--- a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/page.tsx
+++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/page.tsx
@@ -1,6 +1,7 @@
import { AppOnboarding } from '@/components/app-onboarding';
import PageWithBreadcrumb from '@/components/pages/PageWithBreadcrumb';
import type { SearchParams } from '@/types';
+import { db } from '@db';
import type { Metadata } from 'next';
import { CreateVendorSheet } from '../components/create-vendor-sheet';
import { VendorsTable } from './components/VendorsTable';
@@ -19,9 +20,13 @@ export default async function Page({
const parsedSearchParams = await vendorsSearchParamsCache.parse(searchParams);
- const [vendorsResult, assignees] = await Promise.all([
+ const [vendorsResult, assignees, onboarding] = await Promise.all([
getVendors(orgId, parsedSearchParams),
getAssignees(orgId),
+ db.onboarding.findFirst({
+ where: { organizationId: orgId },
+ select: { triggerJobId: true },
+ }),
]);
// Helper function to check if the current view is the default, unfiltered one
@@ -36,8 +41,12 @@ export default async function Page({
);
}
- // Show onboarding only if the view is default/unfiltered and there's no data
- if (vendorsResult.data.length === 0 && isDefaultView(parsedSearchParams)) {
+ const isEmpty = vendorsResult.data.length === 0;
+ const isDefault = isDefaultView(parsedSearchParams);
+ const isOnboardingActive = Boolean(onboarding?.triggerJobId);
+
+ // Show AppOnboarding only if empty, default view, AND onboarding is not active
+ if (isEmpty && isDefault && !isOnboardingActive) {
return (
);
diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/actions/regenerate-vendor-mitigation.ts b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/actions/regenerate-vendor-mitigation.ts
index 1d01d08e3..eb21d3d0e 100644
--- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/actions/regenerate-vendor-mitigation.ts
+++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/actions/regenerate-vendor-mitigation.ts
@@ -2,6 +2,11 @@
import { authActionClient } from '@/actions/safe-action';
import { generateVendorMitigation } from '@/jobs/tasks/onboarding/generate-vendor-mitigation';
+import {
+ findCommentAuthor,
+ type PolicyContext,
+} from '@/jobs/tasks/onboarding/onboard-organization-helpers';
+import { db } from '@db';
import { tasks } from '@trigger.dev/sdk';
import { z } from 'zod';
@@ -26,9 +31,30 @@ export const regenerateVendorMitigationAction = authActionClient
throw new Error('No active organization');
}
+ const organizationId = session.activeOrganizationId;
+
+ const [author, policyRows] = await Promise.all([
+ findCommentAuthor(organizationId),
+ db.policy.findMany({
+ where: { organizationId },
+ select: { name: true, description: true },
+ }),
+ ]);
+
+ if (!author) {
+ throw new Error('No eligible author found to regenerate the mitigation');
+ }
+
+ const policies: PolicyContext[] = policyRows.map((policy) => ({
+ name: policy.name,
+ description: policy.description,
+ }));
+
await tasks.trigger('generate-vendor-mitigation', {
- organizationId: session.activeOrganizationId,
+ organizationId,
vendorId,
+ authorId: author.id,
+ policies,
});
return { success: true };
diff --git a/apps/app/src/components/data-table/data-table.tsx b/apps/app/src/components/data-table/data-table.tsx
index 642cf56ad..1899bd5a8 100644
--- a/apps/app/src/components/data-table/data-table.tsx
+++ b/apps/app/src/components/data-table/data-table.tsx
@@ -14,6 +14,7 @@ interface DataTableProps extends React.ComponentProps<'div'> {
rowClickBasePath?: string;
tableId?: string;
onRowClick?: (row: TData) => void;
+ getRowProps?: (row: TData) => { disabled?: boolean; className?: string };
}
export function DataTable({
@@ -25,6 +26,7 @@ export function DataTable({
rowClickBasePath,
tableId,
onRowClick,
+ getRowProps,
...props
}: DataTableProps) {
const router = useRouter();
@@ -45,8 +47,8 @@ export function DataTable({
return (
{children}
-
-
+
+
{table.getHeaderGroups().map((headerGroup) => (
@@ -74,33 +76,47 @@ export function DataTable({
{filteredRows.length ? (
- filteredRows.map((row) => (
- handleRowClick(row.original) : undefined}
- >
- {row.getVisibleCells().map((cell, index) => {
- return (
-
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
-
- );
- })}
-
- ))
+ filteredRows.map((row) => {
+ const customRowProps = getRowProps?.(row.original);
+ const isDisabled = Boolean(customRowProps?.disabled);
+ const rowClassName = cn(
+ isRowClickable && 'hover:bg-muted/50 cursor-pointer',
+ isDisabled && 'pointer-events-none cursor-not-allowed opacity-60',
+ customRowProps?.className,
+ );
+
+ return (
+ handleRowClick(row.original) : undefined
+ }
+ >
+ {row.getVisibleCells().map((cell, index) => {
+ return (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ );
+ })}
+
+ );
+ })
) : (
+ {/* Main Animation Container */}
+
+ {/* Background Grid Pattern */}
+
+
+ {Array.from({ length: 16 }).map((_, i) => (
+
+ ))}
+
+
+
+ {/* Floating Item Cards Animation */}
+
+ {[0, 1, 2, 3].map((index) => (
+
+ {/* Item Card */}
+
+
+ {/* Icon */}
+
+
+
+
+ {/* Content Skeleton */}
+
+
+ {/* Sparkle Effect */}
+
+
+
+
+
+
+ {/* Arrow/Connection */}
+ {index < 3 && (
+
+ )}
+
+ ))}
+
+
+ {/* Central AI Processing Indicator */}
+
+
+
+
+
+ {/* Text Content */}
+
+ {title}
+ {description}
+
+
+ );
+}
+
diff --git a/apps/app/src/env.mjs b/apps/app/src/env.mjs
index 17d65a68a..25e1e707d 100644
--- a/apps/app/src/env.mjs
+++ b/apps/app/src/env.mjs
@@ -10,6 +10,7 @@ export const env = createEnv({
AUTH_SECRET: z.string(),
DATABASE_URL: z.string().min(1),
OPENAI_API_KEY: z.string().optional(),
+ GROQ_API_KEY: z.string().optional(),
RESEND_API_KEY: z.string(),
UPSTASH_REDIS_REST_URL: z.string().optional(),
UPSTASH_REDIS_REST_TOKEN: z.string().optional(),
@@ -62,6 +63,7 @@ export const env = createEnv({
AUTH_SECRET: process.env.AUTH_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
+ GROQ_API_KEY: process.env.GROQ_API_KEY,
RESEND_API_KEY: process.env.RESEND_API_KEY,
UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL,
UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN,
diff --git a/apps/app/src/jobs/tasks/onboarding/generate-full-policies.ts b/apps/app/src/jobs/tasks/onboarding/generate-full-policies.ts
index 04caad31b..6d45810b7 100644
--- a/apps/app/src/jobs/tasks/onboarding/generate-full-policies.ts
+++ b/apps/app/src/jobs/tasks/onboarding/generate-full-policies.ts
@@ -5,7 +5,7 @@ import { getOrganizationContext, triggerPolicyUpdates } from './onboard-organiza
// v4 queues must be declared in advance
const generateFullPoliciesQueue = queue({
name: 'generate-full-policies',
- concurrencyLimit: 100,
+ concurrencyLimit: 50,
});
export const generateFullPolicies = task({
diff --git a/apps/app/src/jobs/tasks/onboarding/generate-risk-mitigation.ts b/apps/app/src/jobs/tasks/onboarding/generate-risk-mitigation.ts
index d719ebeb1..fc26e8213 100644
--- a/apps/app/src/jobs/tasks/onboarding/generate-risk-mitigation.ts
+++ b/apps/app/src/jobs/tasks/onboarding/generate-risk-mitigation.ts
@@ -1,5 +1,5 @@
import { RiskStatus, db } from '@db';
-import { logger, queue, task } from '@trigger.dev/sdk';
+import { logger, metadata, queue, task } from '@trigger.dev/sdk';
import axios from 'axios';
import {
createRiskMitigationComment,
@@ -8,8 +8,8 @@ import {
} from './onboard-organization-helpers';
// Queues
-const riskMitigationQueue = queue({ name: 'risk-mitigations', concurrencyLimit: 100 });
-const riskMitigationFanoutQueue = queue({ name: 'risk-mitigations-fanout', concurrencyLimit: 100 });
+const riskMitigationQueue = queue({ name: 'risk-mitigations', concurrencyLimit: 50 });
+const riskMitigationFanoutQueue = queue({ name: 'risk-mitigations-fanout', concurrencyLimit: 50 });
export const generateRiskMitigation = task({
id: 'generate-risk-mitigation',
@@ -17,39 +17,45 @@ export const generateRiskMitigation = task({
retry: {
maxAttempts: 5,
},
- run: async (payload: { organizationId: string; riskId: string }) => {
- const { organizationId, riskId } = payload;
+ run: async (payload: {
+ organizationId: string;
+ riskId: string;
+ authorId: string;
+ policies: PolicyContext[];
+ }) => {
+ const { organizationId, riskId, authorId, policies } = payload;
logger.info(`Generating risk mitigation for risk ${riskId} in org ${organizationId}`);
- const [risk, policies, author] = await Promise.all([
- db.risk.findFirst({ where: { id: riskId, organizationId } }),
- db.policy.findMany({ where: { organizationId }, select: { name: true, description: true } }),
- findCommentAuthor(organizationId),
- ]);
+ const risk = await db.risk.findFirst({ where: { id: riskId, organizationId } });
if (!risk) {
logger.warn(`Risk ${riskId} not found in org ${organizationId}`);
return;
}
- if (!author) {
- logger.warn(
- `No eligible author found for org ${organizationId}; skipping mitigation for risk ${riskId}`,
- );
- return;
- }
+ // Mark as processing before generating mitigation
+ // Update root onboarding task metadata if available (when triggered from onboarding)
+ // Try root first (onboarding task), then parent (fanout task), then own metadata
+ const metadataHandle = metadata.root ?? metadata.parent ?? metadata;
+ metadataHandle.set(`risk_${riskId}_status`, 'processing');
- await createRiskMitigationComment(risk, policies as PolicyContext[], organizationId, author.id);
+ await createRiskMitigationComment(risk, policies, organizationId, authorId);
// Mark risk as closed and assign to owner/admin
await db.risk.update({
where: { id: risk.id, organizationId },
data: {
status: RiskStatus.closed,
- assigneeId: author.id,
+ assigneeId: authorId,
},
});
+ // Mark as completed after mitigation is done
+ // Update root onboarding task metadata if available
+ metadataHandle.set(`risk_${riskId}_status`, 'completed');
+ metadataHandle.increment('risksCompleted', 1);
+ metadataHandle.decrement('risksRemaining', 1);
+
// Revalidate only the risk detail page in the individual job
try {
const detailPath = `/${organizationId}/risk/${riskId}`;
@@ -81,15 +87,37 @@ export const generateRiskMitigationsForOrg = task({
const { organizationId } = payload;
logger.info(`Fan-out risk mitigations for org ${organizationId}`);
- const risks = await db.risk.findMany({ where: { organizationId } });
+ const [risks, policyRows, author] = await Promise.all([
+ db.risk.findMany({ where: { organizationId } }),
+ db.policy.findMany({
+ where: { organizationId },
+ select: { name: true, description: true },
+ }),
+ findCommentAuthor(organizationId),
+ ]);
+
if (risks.length === 0) {
logger.info(`No risks found for org ${organizationId}`);
return;
}
+ if (!author) {
+ logger.warn(
+ `No onboarding author found for org ${organizationId}; skipping risk mitigations`,
+ );
+ return;
+ }
+
+ const policies = policyRows.map((p) => ({ name: p.name, description: p.description }));
+
await generateRiskMitigation.batchTrigger(
risks.map((r) => ({
- payload: { organizationId, riskId: r.id },
+ payload: {
+ organizationId,
+ riskId: r.id,
+ authorId: author.id,
+ policies,
+ },
concurrencyKey: `${organizationId}:${r.id}`,
})),
);
diff --git a/apps/app/src/jobs/tasks/onboarding/generate-vendor-mitigation.ts b/apps/app/src/jobs/tasks/onboarding/generate-vendor-mitigation.ts
index 134f18691..e1aa5776b 100644
--- a/apps/app/src/jobs/tasks/onboarding/generate-vendor-mitigation.ts
+++ b/apps/app/src/jobs/tasks/onboarding/generate-vendor-mitigation.ts
@@ -1,5 +1,5 @@
import { VendorStatus, db } from '@db';
-import { logger, queue, task } from '@trigger.dev/sdk';
+import { logger, metadata, queue, task } from '@trigger.dev/sdk';
import axios from 'axios';
import {
createVendorRiskComment,
@@ -8,10 +8,10 @@ import {
} from './onboard-organization-helpers';
// Queues
-const vendorMitigationQueue = queue({ name: 'vendor-risk-mitigations', concurrencyLimit: 100 });
+const vendorMitigationQueue = queue({ name: 'vendor-risk-mitigations', concurrencyLimit: 50 });
const vendorMitigationFanoutQueue = queue({
name: 'vendor-risk-mitigations-fanout',
- concurrencyLimit: 100,
+ concurrencyLimit: 50,
});
export const generateVendorMitigation = task({
@@ -20,39 +20,45 @@ export const generateVendorMitigation = task({
retry: {
maxAttempts: 5,
},
- run: async (payload: { organizationId: string; vendorId: string }) => {
- const { organizationId, vendorId } = payload;
+ run: async (payload: {
+ organizationId: string;
+ vendorId: string;
+ authorId: string;
+ policies: PolicyContext[];
+ }) => {
+ const { organizationId, vendorId, authorId, policies } = payload;
logger.info(`Generating vendor mitigation for vendor ${vendorId} in org ${organizationId}`);
- const [vendor, policies, author] = await Promise.all([
- db.vendor.findFirst({ where: { id: vendorId, organizationId } }),
- db.policy.findMany({ where: { organizationId }, select: { name: true, description: true } }),
- findCommentAuthor(organizationId),
- ]);
+ const vendor = await db.vendor.findFirst({ where: { id: vendorId, organizationId } });
if (!vendor) {
logger.warn(`Vendor ${vendorId} not found in org ${organizationId}`);
return;
}
- if (!author) {
- logger.warn(
- `No eligible author found for org ${organizationId}; skipping mitigation for vendor ${vendorId}`,
- );
- return;
- }
+ // Mark as processing before generating mitigation
+ // Update root onboarding task metadata if available (when triggered from onboarding)
+ // Try root first (onboarding task), then parent (fanout task), then own metadata
+ const metadataHandle = metadata.root ?? metadata.parent ?? metadata;
+ metadataHandle.set(`vendor_${vendorId}_status`, 'processing');
- await createVendorRiskComment(vendor, policies as PolicyContext[], organizationId, author.id);
+ await createVendorRiskComment(vendor, policies, organizationId, authorId);
// Mark vendor as assessed and assign to owner/admin
await db.vendor.update({
where: { id: vendor.id, organizationId },
data: {
status: VendorStatus.assessed,
- assigneeId: author.id,
+ assigneeId: authorId,
},
});
+ // Mark as completed after mitigation is done
+ // Update root onboarding task metadata if available
+ metadataHandle.set(`vendor_${vendorId}_status`, 'completed');
+ metadataHandle.increment('vendorsCompleted', 1);
+ metadataHandle.decrement('vendorsRemaining', 1);
+
// Revalidate the vendor detail page so the new comment shows up
try {
const detailPath = `/${organizationId}/vendors/${vendorId}`;
@@ -74,15 +80,37 @@ export const generateVendorMitigationsForOrg = task({
const { organizationId } = payload;
logger.info(`Fan-out vendor mitigations for org ${organizationId}`);
- const vendors = await db.vendor.findMany({ where: { organizationId } });
+ const [vendors, policyRows, author] = await Promise.all([
+ db.vendor.findMany({ where: { organizationId } }),
+ db.policy.findMany({
+ where: { organizationId },
+ select: { name: true, description: true },
+ }),
+ findCommentAuthor(organizationId),
+ ]);
+
if (vendors.length === 0) {
logger.info(`No vendors found for org ${organizationId}`);
return;
}
+ if (!author) {
+ logger.warn(
+ `No onboarding author found for org ${organizationId}; skipping vendor mitigations`,
+ );
+ return;
+ }
+
+ const policies = policyRows.map((p) => ({ name: p.name, description: p.description }));
+
await generateVendorMitigation.batchTrigger(
vendors.map((v) => ({
- payload: { organizationId, vendorId: v.id },
+ payload: {
+ organizationId,
+ vendorId: v.id,
+ authorId: author.id,
+ policies,
+ },
concurrencyKey: `${organizationId}:${v.id}`,
})),
);
diff --git a/apps/app/src/jobs/tasks/onboarding/onboard-organization-helpers.ts b/apps/app/src/jobs/tasks/onboarding/onboard-organization-helpers.ts
index 28dd8e52a..d010b7f1c 100644
--- a/apps/app/src/jobs/tasks/onboarding/onboard-organization-helpers.ts
+++ b/apps/app/src/jobs/tasks/onboarding/onboard-organization-helpers.ts
@@ -53,6 +53,14 @@ export type RiskData = {
department: Departments;
};
+type OrganizationRecord = NonNullable>>;
+
+type OrganizationContextResult = {
+ organization: OrganizationRecord;
+ questionsAndAnswers: ContextItem[];
+ policies: { id: string; name: string; description: string | null }[];
+};
+
// Baseline risks that must always exist for every organization regardless of frameworks
const BASELINE_RISKS: Array<{
title: string;
@@ -134,7 +142,9 @@ export async function revalidateOrganizationPath(organizationId: string): Promis
/**
* Fetches organization data and context
*/
-export async function getOrganizationContext(organizationId: string) {
+export async function getOrganizationContext(
+ organizationId: string,
+): Promise {
const [organization, contextHub, policies] = await Promise.all([
db.organization.findUnique({
where: { id: organizationId },
@@ -144,7 +154,7 @@ export async function getOrganizationContext(organizationId: string) {
}),
db.policy.findMany({
where: { organizationId },
- select: { name: true, description: true },
+ select: { id: true, name: true, description: true },
}),
]);
@@ -157,7 +167,13 @@ export async function getOrganizationContext(organizationId: string) {
answer: context.answer,
}));
- return { organization, questionsAndAnswers, policies };
+ const typedPolicies = policies as Array<{
+ id: string;
+ name: string;
+ description: string | null;
+ }>;
+
+ return { organization, questionsAndAnswers, policies: typedPolicies };
}
/**
@@ -272,9 +288,13 @@ export async function createVendorsFromData(
vendorData: VendorData[],
organizationId: string,
): Promise {
- const createdVendors = [];
+ // Mark all vendors as processing before creation
+ vendorData.forEach((_, index) => {
+ metadata.set(`vendor_temp_${index}_status`, 'processing');
+ });
- for (const vendor of vendorData) {
+ // Check for existing vendors and create new ones concurrently
+ const vendorPromises = vendorData.map(async (vendor, index) => {
const existingVendor = await db.vendor.findMany({
where: {
organizationId,
@@ -284,7 +304,10 @@ export async function createVendorsFromData(
if (existingVendor.length > 0) {
logger.info(`Vendor ${vendor.vendor_name} already exists`);
- continue;
+ // Mark as completed if it already exists
+ const existing = existingVendor[0];
+ metadata.set(`vendor_${existing.id}_status`, 'completed');
+ return existing;
}
const createdVendor = await db.vendor.create({
@@ -301,9 +324,24 @@ export async function createVendorsFromData(
},
});
- createdVendors.push(createdVendor);
logger.info(`Created vendor: ${createdVendor.id} (${createdVendor.name})`);
- }
+ return createdVendor;
+ });
+
+ const createdVendors = await Promise.all(vendorPromises);
+
+ // Update metadata with all real IDs and mark as created (will be marked as assessing after all are created)
+ createdVendors.forEach((vendor) => {
+ const status = metadata.get(`vendor_${vendor.id}_status`);
+ if (status === 'completed') {
+ // Already marked as completed (existing vendor)
+ return;
+ }
+ // New vendor, mark as created
+ metadata.set(`vendor_${vendor.id}_status`, 'created');
+ });
+
+ // Note: vendorsCompleted is incremented when mitigation is generated, not when created
return createdVendors;
}
@@ -469,15 +507,20 @@ export async function getExistingRisks(organizationId: string) {
}
/**
- * Creates risks from extracted data
+ * Creates risks from extracted data (AI-generated risks only)
*/
export async function createRisksFromData(
riskData: RiskData[],
organizationId: string,
): Promise {
- const createdRisks: Risk[] = [];
- for (const risk of riskData) {
- const createdRisk = await db.risk.create({
+ // Mark all risks as processing before creation
+ riskData.forEach((_, index) => {
+ metadata.set(`risk_temp_${index}_status`, 'processing');
+ });
+
+ // Create all risks concurrently
+ const createPromises = riskData.map((risk) =>
+ db.risk.create({
data: {
title: risk.risk_name,
description: risk.risk_description,
@@ -489,16 +532,84 @@ export async function createRisksFromData(
treatmentStrategyDescription: risk.risk_treatment_strategy_description,
organizationId,
},
- });
+ }),
+ );
- createdRisks.push(createdRisk);
+ const createdRisks = await Promise.all(createPromises);
+
+ // Update metadata with all real IDs and mark as created (will be marked as assessing after all are created)
+ createdRisks.forEach((createdRisk) => {
+ metadata.set(`risk_${createdRisk.id}_status`, 'created');
logger.info(`Created risk: ${createdRisk.id} (${createdRisk.title})`);
- }
+ });
+
+ // Note: risksCompleted is incremented when mitigation is generated, not when created
logger.info(`Created ${riskData.length} risks`);
return createdRisks;
}
+/**
+ * Creates risks from combined baseline and AI-generated data
+ */
+async function createRisksFromDataWithBaseline(
+ allRisksToCreate: Array<{
+ isBaseline: boolean;
+ baselineData: (typeof BASELINE_RISKS)[0] | null;
+ riskData: RiskData | null;
+ }>,
+ organizationId: string,
+): Promise {
+ // Mark all risks as processing before creation
+ allRisksToCreate.forEach((_, index) => {
+ metadata.set(`risk_temp_${index}_status`, 'processing');
+ });
+
+ // Create all risks concurrently (baseline + AI-generated)
+ const createPromises = allRisksToCreate.map((risk) => {
+ if (risk.isBaseline && risk.baselineData) {
+ return db.risk.create({
+ data: {
+ title: risk.baselineData.title,
+ description: risk.baselineData.description,
+ category: risk.baselineData.category,
+ department: risk.baselineData.department,
+ status: risk.baselineData.status,
+ organizationId,
+ },
+ });
+ } else if (risk.riskData) {
+ return db.risk.create({
+ data: {
+ title: risk.riskData.risk_name,
+ description: risk.riskData.risk_description,
+ category: risk.riskData.category,
+ department: risk.riskData.department,
+ likelihood: risk.riskData.risk_residual_probability,
+ impact: risk.riskData.risk_residual_impact,
+ treatmentStrategy: risk.riskData.risk_treatment_strategy,
+ treatmentStrategyDescription: risk.riskData.risk_treatment_strategy_description,
+ organizationId,
+ },
+ });
+ }
+ throw new Error('Invalid risk data');
+ });
+
+ const createdRisks = await Promise.all(createPromises);
+
+ // Update metadata with all real IDs and mark as created (will be marked as assessing after all are created)
+ createdRisks.forEach((createdRisk) => {
+ metadata.set(`risk_${createdRisk.id}_status`, 'created');
+ logger.info(`Created risk: ${createdRisk.id} (${createdRisk.title})`);
+ });
+
+ // Note: risksCompleted is incremented when mitigation is generated, not when created
+
+ logger.info(`Created ${allRisksToCreate.length} risks (including baseline)`);
+ return createdRisks;
+}
+
/**
* Gets all policies for an organization
*/
@@ -524,12 +635,15 @@ export async function triggerPolicyUpdates(
metadata.set('policiesCompleted', 0);
metadata.set('policiesRemaining', policies.length);
// Store policy info for tracking individual policies
- metadata.set('policiesInfo', policies.map((p) => ({ id: p.id, name: p.name })));
-
- // Initialize individual policy statuses - all start as 'pending'
+ metadata.set(
+ 'policiesInfo',
+ policies.map((p) => ({ id: p.id, name: p.name })),
+ );
+
+ // Initialize individual policy statuses - all start as 'queued'
// Each policy gets its own metadata key: policy_{id}_status
policies.forEach((policy) => {
- metadata.set(`policy_${policy.id}_status`, 'pending');
+ metadata.set(`policy_${policy.id}_status`, 'queued');
});
await updatePolicy.batchTriggerAndWait(
@@ -557,7 +671,7 @@ export async function createVendors(
vendorData?: VendorData[],
): Promise {
// Extract vendors using AI if not provided
- const vendorsToCreate = vendorData || await extractVendorsFromContext(questionsAndAnswers);
+ const vendorsToCreate = vendorData || (await extractVendorsFromContext(questionsAndAnswers));
// Create vendor records in database
const createdVendors = await createVendorsFromData(vendorsToCreate, organizationId);
@@ -591,11 +705,11 @@ export async function createRisks(
organizationId: string,
organizationName: string,
): Promise {
- // Ensure baseline risks exist first so the AI doesn't recreate them
- await ensureBaselineRisks(organizationId);
-
- // Get existing risks to avoid duplicates (includes baseline)
+ // Check if baseline risks need to be created (but don't create them yet)
const existingRisks = await getExistingRisks(organizationId);
+ const baselineRisksToCreate = BASELINE_RISKS.filter(
+ (base) => !existingRisks.some((r) => r.title === base.title),
+ );
// Extract risks using AI
const riskData = await extractRisksFromContext(
@@ -604,9 +718,51 @@ export async function createRisks(
existingRisks,
);
- // Create risk records in database
- const risks = await createRisksFromData(riskData, organizationId);
- return risks;
+ // Combine baseline risks and AI-generated risks for tracking
+ const allRisksToCreate = [
+ ...baselineRisksToCreate.map((base) => ({
+ isBaseline: true,
+ baselineData: base,
+ riskData: null as RiskData | null,
+ })),
+ ...riskData.map((risk) => ({
+ isBaseline: false,
+ baselineData: null as (typeof BASELINE_RISKS)[0] | null,
+ riskData: risk,
+ })),
+ ];
+
+ // Track all risks immediately as "pending" before creation
+ if (allRisksToCreate.length > 0) {
+ metadata.set('risksTotal', allRisksToCreate.length);
+ metadata.set('risksCompleted', 0);
+ metadata.set('risksRemaining', allRisksToCreate.length);
+ // Use temporary IDs based on index until we have real IDs
+ metadata.set(
+ 'risksInfo',
+ allRisksToCreate.map((r, index) => ({
+ id: `temp_${index}`,
+ name: r.isBaseline ? r.baselineData!.title : r.riskData!.risk_name,
+ })),
+ );
+ // Mark all as pending initially
+ allRisksToCreate.forEach((_, index) => {
+ metadata.set(`risk_temp_${index}_status`, 'pending');
+ });
+ }
+
+ // Create all risks together (baseline + AI-generated) in one batch
+ const createdRisks = await createRisksFromDataWithBaseline(allRisksToCreate, organizationId);
+
+ // Update tracking with real risk IDs
+ if (createdRisks.length > 0) {
+ metadata.set(
+ 'risksInfo',
+ createdRisks.map((r) => ({ id: r.id, name: r.title })),
+ );
+ }
+
+ return createdRisks;
}
/**
diff --git a/apps/app/src/jobs/tasks/onboarding/onboard-organization.ts b/apps/app/src/jobs/tasks/onboarding/onboard-organization.ts
index f7c8935bd..c7bc12638 100644
--- a/apps/app/src/jobs/tasks/onboarding/onboard-organization.ts
+++ b/apps/app/src/jobs/tasks/onboarding/onboard-organization.ts
@@ -12,7 +12,7 @@ import {
} from './onboard-organization-helpers';
// v4 queues must be declared in advance
-const onboardOrgQueue = queue({ name: 'onboard-organization', concurrencyLimit: 100 });
+const onboardOrgQueue = queue({ name: 'onboard-organization', concurrencyLimit: 50 });
export const onboardOrganization = task({
id: 'onboard-organization',
@@ -31,9 +31,32 @@ export const onboardOrganization = task({
try {
// Get organization context
- const { organization, questionsAndAnswers, policies } = await getOrganizationContext(
+ const {
+ organization,
+ questionsAndAnswers,
+ policies,
+ }: Awaited> = await getOrganizationContext(
payload.organizationId,
);
+ const policyList = policies ?? [];
+ // Initialize policy metadata immediately so UI can reflect pending status
+ if (policyList.length > 0) {
+ metadata.set('policiesTotal', policyList.length);
+ metadata.set('policiesCompleted', 0);
+ metadata.set('policiesRemaining', policyList.length);
+ metadata.set(
+ 'policiesInfo',
+ policyList.map((policy) => ({ id: policy.id, name: policy.name })),
+ );
+ policyList.forEach((policy) => {
+ metadata.set(`policy_${policy.id}_status`, 'queued');
+ });
+ } else {
+ metadata.set('policiesTotal', 0);
+ metadata.set('policiesCompleted', 0);
+ metadata.set('policiesRemaining', 0);
+ metadata.set('policiesInfo', []);
+ }
const frameworkInstances = await db.frameworkInstance.findMany({
where: {
@@ -114,19 +137,18 @@ export const onboardOrganization = task({
}
// Create vendors (pass extracted data to avoid re-extraction)
+ // Tracking is handled inside createVendors -> createVendorsFromData
const vendors = await createVendors(questionsAndAnswers, payload.organizationId, vendorData);
- // Update tracking with real vendor IDs and mark as completed
+ // Update tracking with real vendor IDs (tracking during creation uses temp IDs)
if (vendors.length > 0) {
- metadata.set('vendorsCompleted', vendors.length);
- metadata.set('vendorsRemaining', 0);
metadata.set(
'vendorsInfo',
vendors.map((v) => ({ id: v.id, name: v.name })),
);
- // Mark all as completed
+ // Mark all created vendors as "assessing" since they need mitigation
vendors.forEach((vendor) => {
- metadata.set(`vendor_${vendor.id}_status`, 'completed');
+ metadata.set(`vendor_${vendor.id}_status`, 'assessing');
});
}
@@ -142,25 +164,17 @@ export const onboardOrganization = task({
},
);
- // Create risks
+ // Create risks (tracking is handled inside createRisks)
const risks = await createRisks(
questionsAndAnswers,
payload.organizationId,
organization.name,
);
- // Track risks with metadata for real-time tracking
+ // Mark all created risks as "assessing" since they need mitigation
if (risks.length > 0) {
- metadata.set('risksTotal', risks.length);
- metadata.set('risksCompleted', risks.length);
- metadata.set('risksRemaining', 0);
- metadata.set(
- 'risksInfo',
- risks.map((r) => ({ id: r.id, name: r.title })),
- );
- // All risks are created immediately, so mark them all as completed
risks.forEach((risk) => {
- metadata.set(`risk_${risk.id}_status`, 'completed');
+ metadata.set(`risk_${risk.id}_status`, 'assessing');
});
}
@@ -168,9 +182,7 @@ export const onboardOrganization = task({
metadata.set('risk', true);
// Get policy count for the step message
- const policyCount = await db.policy.count({
- where: { organizationId: payload.organizationId },
- });
+ const policyCount = policyList.length;
metadata.set('currentStep', `Tailoring Policies... (0/${policyCount})`);
// Fan-out risk mitigations as separate jobs
diff --git a/apps/app/src/jobs/tasks/onboarding/update-policy.ts b/apps/app/src/jobs/tasks/onboarding/update-policy.ts
index 97755c551..8cf2ea475 100644
--- a/apps/app/src/jobs/tasks/onboarding/update-policy.ts
+++ b/apps/app/src/jobs/tasks/onboarding/update-policy.ts
@@ -7,7 +7,7 @@ if (!process.env.OPENAI_API_KEY) {
}
// v4: define queue ahead of time
-export const updatePolicyQueue = queue({ name: 'update-policy', concurrencyLimit: 100 });
+export const updatePolicyQueue = queue({ name: 'update-policy', concurrencyLimit: 50 });
export const updatePolicy = schemaTask({
id: 'update-policy',
@@ -48,10 +48,10 @@ export const updatePolicy = schemaTask({
if (metadata.parent) {
// Update this policy's status to completed using individual key
metadata.parent.set(`policy_${params.policyId}_status`, 'completed');
-
+
// Increment completed count
metadata.parent.increment('policiesCompleted', 1);
-
+
// Decrement remaining count
metadata.parent.increment('policiesRemaining', -1);
}
diff --git a/apps/app/tsconfig.json b/apps/app/tsconfig.json
index 730f9b0ef..13886308c 100644
--- a/apps/app/tsconfig.json
+++ b/apps/app/tsconfig.json
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
- "lib": ["dom", "dom.iterable", "esnext"],
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
- "jsx": "preserve",
+ "jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -19,36 +23,102 @@
}
],
"paths": {
- "@/*": ["./src/*"],
- "@db": ["./prisma"],
- "@/jobs": ["./src/jobs"],
- "@/jobs/*": ["./src/jobs/*"],
- "@comp/email": ["../../packages/email/index.ts"],
- "@comp/email/*": ["../../packages/email/*"],
- "@comp/utils": ["../../packages/utils/src/index.ts"],
- "@comp/utils/*": ["../../packages/utils/src/*"],
- "@comp/integrations": ["../../packages/integrations/src/index.ts"],
- "@comp/integrations/*": ["../../packages/integrations/src/*"],
- "@comp/analytics": ["../../packages/analytics/src/index.ts"],
- "@comp/analytics/*": ["../../packages/analytics/src/*"],
- "@comp/ui": ["../../packages/ui/src/components/index.ts"],
- "@comp/ui/*": ["../../packages/ui/src/components/*"],
- "@comp/ui/hooks": ["../../packages/ui/src/hooks/index.ts"],
- "@comp/ui/hooks/*": ["../../packages/ui/src/hooks/*"],
- "@comp/ui/utils/*": ["../../packages/ui/src/utils/*"],
- "@comp/ui/cn": ["../../packages/ui/src/utils/cn.ts"],
- "@comp/ui/truncate": ["../../packages/ui/src/utils/truncate.ts"],
- "@comp/ui/globals.css": ["../../packages/ui/src/globals.css"],
- "@comp/ui/editor.css": ["../../packages/ui/src/editor.css"],
- "@comp/ui/tailwind.config": ["../../packages/ui/tailwind.config.ts"],
- "@comp/kv": ["../../packages/kv/src/index.ts"],
- "@comp/kv/*": ["../../packages/kv/src/*"],
- "@comp/tsconfig": ["../../packages/tsconfig/index.ts"],
- "@comp/tsconfig/*": ["../../packages/tsconfig/*"],
- "@trycompai/email": ["../../packages/email/index.ts"],
- "@trycompai/email/*": ["../../packages/email/*"]
+ "@/*": [
+ "./src/*"
+ ],
+ "@db": [
+ "./prisma"
+ ],
+ "@/jobs": [
+ "./src/jobs"
+ ],
+ "@/jobs/*": [
+ "./src/jobs/*"
+ ],
+ "@comp/email": [
+ "../../packages/email/index.ts"
+ ],
+ "@comp/email/*": [
+ "../../packages/email/*"
+ ],
+ "@comp/utils": [
+ "../../packages/utils/src/index.ts"
+ ],
+ "@comp/utils/*": [
+ "../../packages/utils/src/*"
+ ],
+ "@comp/integrations": [
+ "../../packages/integrations/src/index.ts"
+ ],
+ "@comp/integrations/*": [
+ "../../packages/integrations/src/*"
+ ],
+ "@comp/analytics": [
+ "../../packages/analytics/src/index.ts"
+ ],
+ "@comp/analytics/*": [
+ "../../packages/analytics/src/*"
+ ],
+ "@comp/ui": [
+ "../../packages/ui/src/components/index.ts"
+ ],
+ "@comp/ui/*": [
+ "../../packages/ui/src/components/*"
+ ],
+ "@comp/ui/hooks": [
+ "../../packages/ui/src/hooks/index.ts"
+ ],
+ "@comp/ui/hooks/*": [
+ "../../packages/ui/src/hooks/*"
+ ],
+ "@comp/ui/utils/*": [
+ "../../packages/ui/src/utils/*"
+ ],
+ "@comp/ui/cn": [
+ "../../packages/ui/src/utils/cn.ts"
+ ],
+ "@comp/ui/truncate": [
+ "../../packages/ui/src/utils/truncate.ts"
+ ],
+ "@comp/ui/globals.css": [
+ "../../packages/ui/src/globals.css"
+ ],
+ "@comp/ui/editor.css": [
+ "../../packages/ui/src/editor.css"
+ ],
+ "@comp/ui/tailwind.config": [
+ "../../packages/ui/tailwind.config.ts"
+ ],
+ "@comp/kv": [
+ "../../packages/kv/src/index.ts"
+ ],
+ "@comp/kv/*": [
+ "../../packages/kv/src/*"
+ ],
+ "@comp/tsconfig": [
+ "../../packages/tsconfig/index.ts"
+ ],
+ "@comp/tsconfig/*": [
+ "../../packages/tsconfig/*"
+ ],
+ "@trycompai/email": [
+ "../../packages/email/index.ts"
+ ],
+ "@trycompai/email/*": [
+ "../../packages/email/*"
+ ]
}
},
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "trigger.config.ts"],
- "exclude": ["node_modules", ".next"]
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ "trigger.config.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules",
+ ".next"
+ ]
}
diff --git a/apps/portal/src/app/api/download-agent/scripts.ts b/apps/portal/src/app/api/download-agent/scripts.ts
index 11e3d7c6b..64e9dec2f 100644
--- a/apps/portal/src/app/api/download-agent/scripts.ts
+++ b/apps/portal/src/app/api/download-agent/scripts.ts
@@ -1,10 +1,8 @@
import { getPackageFilename, getReadmeContent, getScriptFilename } from './scripts/common';
import { generateMacScript } from './scripts/mac';
-import { generateWindowsScript } from './scripts/windows';
export {
generateMacScript,
- generateWindowsScript,
getPackageFilename,
getReadmeContent,
getScriptFilename,
diff --git a/apps/portal/src/app/api/download-agent/scripts/index.ts b/apps/portal/src/app/api/download-agent/scripts/index.ts
index 613fb7274..78b6f6892 100644
--- a/apps/portal/src/app/api/download-agent/scripts/index.ts
+++ b/apps/portal/src/app/api/download-agent/scripts/index.ts
@@ -1,3 +1,2 @@
export { getPackageFilename, getReadmeContent, getScriptFilename } from './common';
export { generateMacScript } from './mac';
-export { generateWindowsScript } from './windows';
diff --git a/apps/portal/src/app/api/download-agent/scripts/windows.ts b/apps/portal/src/app/api/download-agent/scripts/windows.ts
deleted file mode 100644
index 62183d425..000000000
--- a/apps/portal/src/app/api/download-agent/scripts/windows.ts
+++ /dev/null
@@ -1,222 +0,0 @@
-import type { ScriptConfig } from '../types';
-
-export function generateWindowsScript(config: ScriptConfig): string {
- const { orgId, employeeId, fleetDevicePath } = config;
-
- const script = `@echo off
-title CompAI Device Setup
-setlocal EnableExtensions EnableDelayedExpansion
-color 0A
-
-REM =========================
-REM Variables
-REM =========================
-set "ORG_ID=${orgId}"
-set "EMPLOYEE_ID=${employeeId}"
-set "PRIMARY_DIR=${fleetDevicePath}"
-set "FALLBACK_DIR=C:\\Users\\Public\\CompAI\\Fleet"
-set "CHOSEN_DIR="
-set "LOG_FILE="
-set "HAS_ERROR=0"
-set "ERRORS="
-set "EXIT_CODE=0"
-REM newline token (exactly this 2-line shape)
-set "nl=^
-"
-
-REM --- bootstrap log (updated once CHOSEN_DIR is known) ---
-set "LOG_FILE=%~dp0setup.log"
-
-goto :main
-
-REM =======================================================
-REM Subroutines (placed AFTER main to avoid early execution)
-REM =======================================================
-:log_msg
-setlocal EnableDelayedExpansion
-set "msg=%~1"
-echo [%date% %time%] !msg!
->>"%LOG_FILE%" echo [%date% %time%] !msg!
-endlocal & exit /b 0
-
-:log_run
-setlocal EnableDelayedExpansion
-set "cmdline=%*"
-echo [%date% %time%] CMD: !cmdline!
->>"%LOG_FILE%" echo [%date% %time%] CMD: !cmdline!
-%*
-set "rc=!errorlevel!"
-if not "!rc!"=="0" (
- echo [%date% %time%] ERR !rc!: !cmdline!
- >>"%LOG_FILE%" echo [%date% %time%] ERR !rc!: !cmdline!
-)
-endlocal & set "LAST_RC=%rc%"
-exit /b %LAST_RC%
-
-REM =========================
-REM Main
-REM =========================
-:main
-call :log_msg "Script starting"
-
-REM Admin check
-whoami /groups | find "S-1-16-12288" >nul 2>&1
-if errorlevel 1 (
- color 0E
- echo This script must be run as Administrator.
- echo Please right-click the file and select "Run as administrator".
- echo.
- echo Press any key to exit, then try again with Administrator privileges.
- pause
- exit /b 5
-)
-
-REM Relaunch persistent window
-if not "%PERSIST%"=="1" (
- set "PERSIST=1"
- call :log_msg "Re-launching in a persistent window"
- start "CompAI Device Setup" cmd /k "%~f0 %*"
- exit /b
-)
-
-call :log_msg "Running with administrator privileges"
-call :log_msg "Current directory: %cd%"
-call :log_msg "Script path: %~f0"
-call :log_msg "Switching working directory to script folder"
-cd /d "%~dp0"
-call :log_msg "New current directory: %cd%"
-echo.
-
-REM Choose writable directory
-call :log_msg "Choosing destination directory; primary=%PRIMARY_DIR% fallback=%FALLBACK_DIR%"
-if exist "%PRIMARY_DIR%\\*" set "CHOSEN_DIR=%PRIMARY_DIR%"
-if not defined CHOSEN_DIR call :log_run mkdir "%PRIMARY_DIR%"
-if not defined CHOSEN_DIR if exist "%PRIMARY_DIR%\\*" set "CHOSEN_DIR=%PRIMARY_DIR%"
-
-if not defined CHOSEN_DIR call :log_msg "Primary not available; trying fallback"
-if not defined CHOSEN_DIR if exist "%FALLBACK_DIR%\\*" set "CHOSEN_DIR=%FALLBACK_DIR%"
-if not defined CHOSEN_DIR call :log_run mkdir "%FALLBACK_DIR%"
-if not defined CHOSEN_DIR if exist "%FALLBACK_DIR%\\*" set "CHOSEN_DIR=%FALLBACK_DIR%"
-
-if not defined CHOSEN_DIR (
- color 0E
- call :log_msg "WARNING: No writable directory found"
- echo Primary attempted: "%PRIMARY_DIR%"
- echo Fallback attempted: "%FALLBACK_DIR%"
- echo [%date% %time%] No writable directory found. Primary: %PRIMARY_DIR%, Fallback: %FALLBACK_DIR% >> "%~dp0setup.log"
- set "LOG_FILE=%~dp0setup.log"
- set "HAS_ERROR=1"
- set "ERRORS=!ERRORS!- No writable directory found (Primary: %PRIMARY_DIR%, Fallback: %FALLBACK_DIR%).!nl!"
- set "EXIT_CODE=1"
-) else (
- set "MARKER_DIR=%CHOSEN_DIR%"
- if not "!MARKER_DIR:~-1!"=="\\" set "MARKER_DIR=!MARKER_DIR!\\"
-
- REM switch the log file to the chosen directory, carry over bootstrap logs
- set "FINAL_LOG=!MARKER_DIR!setup.log"
- if /i not "%LOG_FILE%"=="%FINAL_LOG%" (
- call :log_msg "Switching log to !FINAL_LOG!"
- if exist "%LOG_FILE%" type "%LOG_FILE%" >> "!FINAL_LOG!" & del "%LOG_FILE%"
- set "LOG_FILE=!FINAL_LOG!"
- )
- call :log_msg "Using directory: !MARKER_DIR!"
-)
-echo Logs will be written to: !LOG_FILE!
-echo.
-
-REM Write marker files
-if defined CHOSEN_DIR (
- call :log_msg "Writing organization marker file"
- call :log_msg "Preparing to write org marker to !MARKER_DIR!!ORG_ID!"
- call :log_run cmd /c "(echo %ORG_ID%) > \"!MARKER_DIR!!ORG_ID!\""
- if errorlevel 1 (
- color 0E
- call :log_msg "WARNING: Failed writing organization marker file to !MARKER_DIR!"
- echo [%date% %time%] Failed writing org marker file >> "%LOG_FILE%"
- set "HAS_ERROR=1"
- set "ERRORS=!ERRORS!- Failed writing organization marker file.!nl!"
- set "EXIT_CODE=1"
- ) else (
- call :log_msg "[OK] Organization marker file: !MARKER_DIR!!ORG_ID!"
- )
-
- call :log_msg "Writing employee marker file"
- call :log_msg "Preparing to write employee marker to !MARKER_DIR!!EMPLOYEE_ID!"
- call :log_run cmd /c "(echo %EMPLOYEE_ID%) > \"!MARKER_DIR!!EMPLOYEE_ID!\""
- if errorlevel 1 (
- color 0E
- call :log_msg "WARNING: Failed writing employee marker file to !MARKER_DIR!"
- echo [%date% %time%] Failed writing employee marker file >> "%LOG_FILE%"
- set "HAS_ERROR=1"
- set "ERRORS=!ERRORS!- Failed writing employee marker file.!nl!"
- set "EXIT_CODE=1"
- ) else (
- call :log_msg "[OK] Employee marker file: !MARKER_DIR!!EMPLOYEE_ID!"
- )
-)
-
-REM Permissions
-if defined CHOSEN_DIR (
- call :log_msg "Setting permissions on marker directory"
- call :log_run icacls "!MARKER_DIR!" /inheritance:e
-
- call :log_msg "Granting read to SYSTEM and Administrators on org marker"
- call :log_run icacls "!MARKER_DIR!!ORG_ID!" /grant *S-1-5-18:R *S-1-5-32-544:R
-
- call :log_msg "Granting read to SYSTEM and Administrators on employee marker"
- call :log_run icacls "!MARKER_DIR!!EMPLOYEE_ID!" /grant *S-1-5-18:R *S-1-5-32-544:R
-)
-
-REM Verify
-echo.
-echo Verifying markers...
-if defined CHOSEN_DIR (
- call :log_msg "Verifying marker exists: !MARKER_DIR!!EMPLOYEE_ID!"
- if not exist "!MARKER_DIR!!EMPLOYEE_ID!" (
- color 0E
- call :log_msg "WARNING: Employee marker file missing at !MARKER_DIR!!EMPLOYEE_ID!"
- echo [%date% %time%] Verification failed: employee marker file missing >> "!LOG_FILE!"
- set "HAS_ERROR=1"
- set "ERRORS=!ERRORS!- Employee marker file missing at !MARKER_DIR!!EMPLOYEE_ID!!.!nl!"
- set "EXIT_CODE=2"
- ) else (
- call :log_msg "[OK] Employee marker file present: !MARKER_DIR!!EMPLOYEE_ID!"
- )
-)
-rem Skipping registry checks per request
-
-REM Result / Exit
-echo.
-echo ------------------------------------------------------------
-if "%HAS_ERROR%"=="0" (
- color 0A
- echo RESULT: SUCCESS
- echo Setup completed successfully for %EMPLOYEE_ID%.
- if defined CHOSEN_DIR echo Files created in: !CHOSEN_DIR!
- echo Log file: !LOG_FILE!
- call :log_msg "RESULT: SUCCESS"
-) else (
- color 0C
- echo RESULT: COMPLETED WITH ISSUES
- echo One or more steps did not complete successfully. Details:
- echo.
- echo !ERRORS!
- echo.
- echo Next steps:
- echo - Take a screenshot of this window.
- echo - Attach the log file from: !LOG_FILE!
- echo - Share both with your CompAI support contact.
- call :log_msg "RESULT: COMPLETED WITH ISSUES (exit=%EXIT_CODE%)"
-)
-echo ------------------------------------------------------------
-echo.
-echo Press any key to close this window. This will not affect installation.
-pause
-if "%HAS_ERROR%"=="0" (exit /b 0) else (exit /b %EXIT_CODE%)
-
-REM End of main
-goto :eof
-`;
-
- return script.replace(/\n/g, '\r\n');
-}
diff --git a/bun.lock b/bun.lock
index 68ac8e758..1ef558a93 100644
--- a/bun.lock
+++ b/bun.lock
@@ -76,8 +76,8 @@
"@nestjs/platform-express": "^11.1.5",
"@nestjs/swagger": "^11.2.0",
"@prisma/client": "^6.13.0",
+ "@react-email/components": "^0.0.41",
"@trycompai/db": "^1.3.17",
- "@trycompai/email": "workspace:*",
"archiver": "^7.0.1",
"axios": "^1.12.2",
"better-auth": "^1.3.27",
@@ -89,6 +89,8 @@
"nanoid": "^5.1.6",
"pdf-lib": "^1.17.1",
"prisma": "^6.13.0",
+ "react": "^19.1.1",
+ "react-dom": "^19.1.0",
"reflect-metadata": "^0.2.2",
"resend": "^6.4.2",
"rxjs": "^7.8.1",
@@ -328,6 +330,21 @@
"typescript": "^5.8.3",
},
},
+ "packages/db": {
+ "name": "@trycompai/db",
+ "version": "1.3.17",
+ "dependencies": {
+ "@prisma/client": "^6.13.0",
+ "dotenv": "^16.4.5",
+ "zod": "^4.1.12",
+ },
+ "devDependencies": {
+ "@types/node": "^24.2.0",
+ "prisma": "^6.13.0",
+ "ts-node": "^10.9.2",
+ "typescript": "^5.9.2",
+ },
+ },
"packages/email": {
"name": "@trycompai/email",
"version": "1.0.0",
@@ -1966,7 +1983,7 @@
"@trycompai/analytics": ["@trycompai/analytics@workspace:packages/analytics"],
- "@trycompai/db": ["@trycompai/db@1.3.17", "", { "dependencies": { "@prisma/client": "^6.13.0", "dotenv": "^16.4.5" } }, "sha512-vrKf+/YGdQhpP470xWhysL3RDL8v16pS90AafF718YcRI6mI/XUqlirNMS43+XtOksrc5CHITyBLLOd848bFDA=="],
+ "@trycompai/db": ["@trycompai/db@workspace:packages/db"],
"@trycompai/email": ["@trycompai/email@workspace:packages/email"],
@@ -4620,7 +4637,7 @@
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
- "scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="],
+ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"schema-utils": ["schema-utils@3.3.0", "", { "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", "ajv-keywords": "^3.5.2" } }, "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg=="],
@@ -5560,6 +5577,8 @@
"@react-email/components/@react-email/render": ["@react-email/render@1.1.2", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3", "react-promise-suspense": "^0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw=="],
+ "@react-three/fiber/scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="],
+
"@react-three/postprocessing/maath": ["maath@0.6.0", "", { "peerDependencies": { "@types/three": ">=0.144.0", "three": ">=0.144.0" } }, "sha512-dSb2xQuP7vDnaYqfoKzlApeRcR2xtN8/f7WV/TMAkBC8552TwTLtOO0JTcSygkYMjNDPoo6V01jTw/aPi4JrMw=="],
"@semantic-release/git/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
@@ -5624,7 +5643,9 @@
"@trigger.dev/sdk/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
- "@trycompai/db/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
+ "@trycompai/db/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
+
+ "@trycompai/db/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
"@trycompai/email/resend": ["resend@4.8.0", "", { "dependencies": { "@react-email/render": "1.1.2" } }, "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA=="],
@@ -6308,12 +6329,12 @@
"rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
- "react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
-
"react-dropzone/file-selector": ["file-selector@2.1.2", "", { "dependencies": { "tslib": "^2.7.0" } }, "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig=="],
"react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
+ "react-reconciler/scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="],
+
"read-cache/pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
"read-yaml-file/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
diff --git a/package.json b/package.json
index 23973f52a..8496c16f0 100644
--- a/package.json
+++ b/package.json
@@ -84,13 +84,7 @@
},
"workspaces": [
"apps/*",
- "packages/analytics",
- "packages/email",
- "packages/integrations",
- "packages/kv",
- "packages/tsconfig",
- "packages/ui",
- "packages/utils"
+ "packages/*"
],
"dependencies": {
"@types/cheerio": "^1.0.0",
@@ -102,4 +96,4 @@
"xlsx": "^0.18.5",
"zod": "^3.25.76"
}
-}
\ No newline at end of file
+}
diff --git a/packages/email/index.ts b/packages/email/index.ts
index 91ddd7747..34515beef 100644
--- a/packages/email/index.ts
+++ b/packages/email/index.ts
@@ -1,12 +1,9 @@
// Email templates
-export * from './emails/access-granted';
-export * from './emails/access-reclaim';
export * from './emails/all-policy-notification';
export * from './emails/invite';
export * from './emails/invite-portal';
export * from './emails/magic-link';
export * from './emails/marketing/welcome';
-export * from './emails/nda-signing';
export * from './emails/otp';
export * from './emails/policy-notification';
export * from './emails/waitlist';
diff --git a/turbo.json b/turbo.json
index 7eb312055..e1cfe7ada 100644
--- a/turbo.json
+++ b/turbo.json
@@ -1,90 +1,25 @@
{
"$schema": "https://turborepo.org/schema.json",
- "globalDependencies": [
- "**/.env"
- ],
+ "globalDependencies": ["**/.env"],
"ui": "stream",
"tasks": {
"prisma:generate": {
"cache": false,
- "outputs": [
- "prisma/schema.prisma",
- "node_modules/.prisma/**"
- ]
+ "outputs": ["prisma/schema.prisma", "node_modules/.prisma/**"]
},
"build": {
- "dependsOn": [
- "^build",
- "prisma:generate"
- ],
- "env": [
- "AUTH_GOOGLE_ID",
- "AUTH_GOOGLE_SECRET",
- "AUTH_GITHUB_ID",
- "AUTH_GITHUB_SECRET",
- "AUTH_SECRET",
- "DATABASE_URL",
- "OPENAI_API_KEY",
- "RESEND_API_KEY",
- "UPSTASH_REDIS_REST_URL",
- "UPSTASH_REDIS_REST_TOKEN",
- "TRIGGER_SECRET_KEY",
- "TRIGGER_API_KEY",
- "TRIGGER_API_URL",
- "REVALIDATION_SECRET",
- "VERCEL_ACCESS_TOKEN",
- "VERCEL_TEAM_ID",
- "VERCEL_PROJECT_ID",
- "TRUST_PORTAL_PROJECT_ID",
- "NODE_ENV",
- "APP_AWS_ACCESS_KEY_ID",
- "APP_AWS_SECRET_ACCESS_KEY",
- "APP_AWS_REGION",
- "APP_AWS_BUCKET_NAME",
- "NEXT_PUBLIC_PORTAL_URL",
- "FIRECRAWL_API_KEY",
- "FLEET_URL",
- "FLEET_TOKEN",
- "DUB_API_KEY",
- "DUB_REFER_URL",
- "GA4_API_SECRET",
- "GA4_MEASUREMENT_ID",
- "LINKEDIN_CONVERSIONS_ACCESS_TOKEN",
- "NEXT_PUBLIC_POSTHOG_KEY",
- "NEXT_PUBLIC_POSTHOG_HOST",
- "NEXT_PUBLIC_IS_DUB_ENABLED",
- "NEXT_PUBLIC_GTM_ID",
- "NEXT_PUBLIC_LINKEDIN_PARTNER_ID",
- "NEXT_PUBLIC_LINKEDIN_CONVERSION_ID",
- "NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL",
- "NEXT_PUBLIC_API_URL",
- "NEXT_PUBLIC_BETTER_AUTH_URL"
- ],
- "inputs": [
- "$TURBO_DEFAULT$",
- ".env"
- ],
- "outputs": [
- ".next/**",
- "!.next/cache/**",
- "next-env.d.ts"
- ]
+ "dependsOn": ["^build", "prisma:generate"],
+ "inputs": ["$TURBO_DEFAULT$", ".env*"],
+ "outputs": [".next/**", "!.next/cache/**", "next-env.d.ts", "dist/**"]
},
"lint": {
- "dependsOn": [
- "^lint"
- ]
+ "dependsOn": ["^lint"]
},
"typecheck": {
- "outputs": [
- "node_modules/.cache/tsbuildinfo.json"
- ]
+ "outputs": ["node_modules/.cache/tsbuildinfo.json"]
},
"dev": {
- "inputs": [
- "$TURBO_DEFAULT$",
- ".env"
- ],
+ "inputs": ["$TURBO_DEFAULT$", ".env"],
"persistent": true,
"cache": false
},
@@ -96,13 +31,8 @@
},
"test": {
"cache": false,
- "outputs": [
- "coverage/**"
- ],
- "inputs": [
- "$TURBO_DEFAULT$",
- ".env"
- ]
+ "outputs": ["coverage/**"],
+ "inputs": ["$TURBO_DEFAULT$", ".env"]
}
}
}