diff --git a/apps/app/languine.lock b/apps/app/languine.lock
index 3676a59d21..71a13f3fcd 100644
--- a/apps/app/languine.lock
+++ b/apps/app/languine.lock
@@ -401,9 +401,9 @@ files:
frameworks.controls.table.control: a1595abbb4c3a326636dd178757cd6c1
frameworks.controls.table.artifacts: 08004d9d4c599f44814a861568d200cb
frameworks.controls.table.actions: 06df33001c1d7187fdd81ea1f5b277aa
+ frameworks.controls.statuses.completed: 07ca5050e697392c9ed47e6453f1453f
+ frameworks.controls.statuses.in_progress: c9413dd7682a45da9ecc9efa8cea47d7
frameworks.controls.statuses.not_started: 10db56f8b10dc989c43fbe33202e5339
- frameworks.controls.statuses.compliant: 952358fc53c0167a1ea7354f338ed5af
- frameworks.controls.statuses.non_compliant: a0ecb19f7c90c0d5c1e919aec4d33aac
vendor.title: 2938c7f7e560ed972f8a4f68e80ff834
vendor.register_title: 97f6291ea1000272b34fdfaeb3a9e2d2
vendor.dashboard.title: 2938c7f7e560ed972f8a4f68e80ff834
diff --git a/apps/app/package.json b/apps/app/package.json
index 69dff64985..09d8446105 100644
--- a/apps/app/package.json
+++ b/apps/app/package.json
@@ -16,6 +16,7 @@
"@bubba/notifications": "workspace:*",
"@date-fns/tz": "^1.2.0",
"@hookform/resolvers": "^3.10.0",
+ "@nangohq/frontend": "^0.53.1",
"@novu/headless": "^2.6.5",
"@novu/react": "^2.6.5",
"@prisma/instrumentation": "^6.3.1",
@@ -45,6 +46,7 @@
"next-intl": "^3.26.3",
"next-safe-action": "^7.10.3",
"next-themes": "^0.4.4",
+ "novel": "^1.0.2",
"novu": "^2.6.5",
"nuqs": "^2.3.2",
"playwright-core": "^1.50.1",
diff --git a/apps/app/src/actions/framework/select-frameworks-action.ts b/apps/app/src/actions/framework/select-frameworks-action.ts
index 7939a7d4a7..4b51886b63 100644
--- a/apps/app/src/actions/framework/select-frameworks-action.ts
+++ b/apps/app/src/actions/framework/select-frameworks-action.ts
@@ -34,7 +34,7 @@ export const selectFrameworksAction = authActionClient
await createOrganizationCategories(user as User, frameworkIds);
// Then create frameworks and controls
- await Promise.all(
+ const organizationFrameworks = await Promise.all(
frameworkIds.map((frameworkId) =>
createOrganizationFramework(user as User, frameworkId)
)
@@ -43,6 +43,12 @@ export const selectFrameworksAction = authActionClient
// Finally create policies
await createOrganizationPolicy(user as User, frameworkIds);
+ // Finally create control requirements
+ await createOrganizationControlRequirements(
+ user as User,
+ organizationFrameworks.map((framework) => framework.id)
+ );
+
return {
data: true,
};
@@ -105,6 +111,8 @@ const createOrganizationFramework = async (user: User, frameworkId: string) => {
})),
});
}
+
+ return organizationFramework;
};
const createOrganizationPolicy = async (user: User, frameworkIds: string[]) => {
@@ -171,3 +179,68 @@ const createOrganizationCategories = async (
return organizationCategories;
};
+
+const createOrganizationControlRequirements = async (
+ user: User,
+ organizationFrameworkIds: string[]
+) => {
+ if (!user.organizationId) {
+ throw new Error("Not authorized - no organization found");
+ }
+
+ const controls = await db.organizationControl.findMany({
+ where: {
+ organizationId: user.organizationId!,
+ organizationFrameworkId: {
+ in: organizationFrameworkIds,
+ },
+ },
+ include: {
+ control: true,
+ },
+ });
+
+ // Create control requirements for each control
+ const controlRequirements = await db.controlRequirement.findMany({
+ where: {
+ controlId: { in: controls.map((control) => control.controlId) },
+ },
+ include: {
+ policy: true, // Include the policy to get its ID
+ },
+ });
+
+ // Get all organization policies for this organization
+ const organizationPolicies = await db.organizationPolicy.findMany({
+ where: {
+ organizationId: user.organizationId,
+ },
+ });
+
+ for (const control of controls) {
+ const requirements = controlRequirements.filter(
+ (req) => req.controlId === control.controlId
+ );
+
+ await db.organizationControlRequirement.createMany({
+ data: requirements.map((requirement) => {
+ // Find the corresponding organization policy if this is a policy requirement
+ const policyId =
+ requirement.type === "policy" ? requirement.policy?.id : null;
+ const organizationPolicy = policyId
+ ? organizationPolicies.find((op) => op.policyId === policyId)
+ : null;
+
+ return {
+ organizationControlId: control.id,
+ controlRequirementId: requirement.id,
+ type: requirement.type,
+ description: requirement.description,
+ organizationPolicyId: organizationPolicy?.id || null,
+ };
+ }),
+ });
+ }
+
+ return controlRequirements;
+};
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Actions/getOrganizationControl.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Actions/getOrganizationControl.ts
index 9dd4a2c9a0..c7ff34ad39 100644
--- a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Actions/getOrganizationControl.ts
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Actions/getOrganizationControl.ts
@@ -1,7 +1,7 @@
"use server";
-import { db, OrganizationControl, Control } from "@bubba/db";
import { authActionClient } from "@/actions/safe-action";
+import { db, type Control, type OrganizationControl } from "@bubba/db";
import { z } from "zod";
export interface OrganizationControlResponse {
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Actions/getOrganizationControlProgress.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Actions/getOrganizationControlProgress.ts
new file mode 100644
index 0000000000..e694f8ee79
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Actions/getOrganizationControlProgress.ts
@@ -0,0 +1,106 @@
+"use server";
+
+import { authActionClient } from "@/actions/safe-action";
+import { db } from "@bubba/db";
+import { z } from "zod";
+
+export interface ControlProgressResponse {
+ total: number;
+ completed: number;
+ progress: number;
+ byType: {
+ [key: string]: {
+ total: number;
+ completed: number;
+ };
+ };
+}
+
+export const getOrganizationControlProgress = authActionClient
+ .schema(z.object({ controlId: z.string() }))
+ .metadata({
+ name: "getOrganizationControlProgress",
+ track: {
+ event: "get-organization-control-progress",
+ channel: "server",
+ },
+ })
+ .action(async ({ ctx, parsedInput }) => {
+ const { user } = ctx;
+ const { controlId } = parsedInput;
+
+ if (!user.organizationId) {
+ return {
+ error: "Not authorized - no organization found",
+ };
+ }
+
+ try {
+ const requirements = await db.organizationControlRequirement.findMany({
+ where: {
+ organizationControlId: controlId,
+ },
+ include: {
+ organizationPolicy: true,
+ },
+ });
+
+ const progress: ControlProgressResponse = {
+ total: requirements.length,
+ completed: 0,
+ progress: 0,
+ byType: {},
+ };
+
+ for (const requirement of requirements) {
+ // Initialize type counters if not exists
+ if (!progress.byType[requirement.type]) {
+ progress.byType[requirement.type] = {
+ total: 0,
+ completed: 0,
+ };
+ }
+
+ progress.byType[requirement.type].total++;
+
+ // Check completion based on requirement type
+ let isCompleted = false;
+ switch (requirement.type) {
+ case "policy":
+ isCompleted =
+ requirement.organizationPolicy?.status === "published";
+ break;
+ case "file":
+ isCompleted = !!requirement.fileUrl;
+ break;
+ case "evidence":
+ isCompleted = !!requirement.content;
+ break;
+ default:
+ isCompleted = requirement.published;
+ }
+
+ if (isCompleted) {
+ progress.completed++;
+ progress.byType[requirement.type].completed++;
+ }
+ }
+
+ // Calculate overall progress percentage
+ progress.progress =
+ progress.total > 0
+ ? Math.round((progress.completed / progress.total) * 100)
+ : 0;
+
+ return {
+ data: {
+ progress,
+ },
+ };
+ } catch (error) {
+ console.error("Error fetching control progress:", error);
+ return {
+ error: "Failed to fetch control progress",
+ };
+ }
+ });
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Actions/getOrganizationControlRequirements.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Actions/getOrganizationControlRequirements.ts
new file mode 100644
index 0000000000..fb3a3d24a2
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Actions/getOrganizationControlRequirements.ts
@@ -0,0 +1,63 @@
+"use server";
+
+import { authActionClient } from "@/actions/safe-action";
+import type {
+ ControlRequirement,
+ OrganizationControlRequirement,
+ Policy,
+} from "@bubba/db";
+import { db } from "@bubba/db";
+import { z } from "zod";
+
+export const getOrganizationControlRequirements = authActionClient
+ .schema(z.object({ controlId: z.string() }))
+ .metadata({
+ name: "getOrganizationControlRequirements",
+ track: {
+ event: "get-organization-control-requirements",
+ channel: "server",
+ },
+ })
+ .action(async ({ ctx, parsedInput }) => {
+ const { user } = ctx;
+ const { controlId } = parsedInput;
+
+ if (!user.organizationId) {
+ return {
+ error: "Not authorized - no organization found",
+ };
+ }
+
+ try {
+ const organizationControlRequirements =
+ await db.organizationControlRequirement.findMany({
+ where: {
+ organizationControlId: controlId,
+ },
+ include: {
+ organizationPolicy: {
+ include: {
+ policy: true,
+ },
+ },
+ },
+ });
+
+ if (!organizationControlRequirements) {
+ return {
+ error: "Organization control requirements not found",
+ };
+ }
+
+ return {
+ data: {
+ organizationControlRequirements,
+ },
+ };
+ } catch (error) {
+ console.error("Error fetching organization control:", error);
+ return {
+ error: "Failed to fetch organization control",
+ };
+ }
+ });
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Components/SingleControl.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Components/SingleControl.tsx
index c21286c5f4..3ceadba27d 100644
--- a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Components/SingleControl.tsx
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Components/SingleControl.tsx
@@ -2,11 +2,17 @@
import {
DisplayFrameworkStatus,
- StatusType,
+ type StatusType,
} from "@/components/frameworks/framework-status";
import { useOrganizationControl } from "../hooks/useOrganizationControl";
import { Card } from "@bubba/ui/card";
import { Label } from "@bubba/ui/label";
+import { Button } from "@bubba/ui/button";
+import { ArrowLeft, CheckCircle2, XCircle } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useOrganizationControlRequirements } from "../hooks/useOrganizationControlRequirements";
+import Link from "next/link";
+import { useOrganizationControlProgress } from "../hooks/useOrganizationControlProgress";
interface SingleControlProps {
controlId: string;
@@ -14,15 +20,30 @@ interface SingleControlProps {
export const SingleControl = ({ controlId }: SingleControlProps) => {
const { data: control } = useOrganizationControl(controlId);
- if (!control) return null;
+ const { data: requirements } = useOrganizationControlRequirements(controlId);
+ const { data: controlProgress } = useOrganizationControlProgress(controlId);
+ if (!control || !controlProgress) return null;
+
+ const router = useRouter();
+
+ const progressStatus =
+ controlProgress?.progress?.completed > 0
+ ? "in_progress"
+ : controlProgress?.progress?.completed === 0
+ ? "not_started"
+ : "completed";
return (
-
+
+
+
+
{control?.control.name}
-
+
@@ -42,6 +63,57 @@ export const SingleControl = ({ controlId }: SingleControlProps) => {
+
+
+
Requirements
+
+
+
+ | Type |
+ Description |
+ Status |
+
+
+
+ {requirements?.map((requirement) => {
+ const url =
+ requirement.type === "policy"
+ ? `/policies/${requirement.organizationPolicy?.policy?.id}`
+ : "_blank";
+
+ const isCompleted =
+ requirement.type === "policy"
+ ? requirement.organizationPolicy?.status === "published"
+ : false;
+
+ return (
+
+ |
+
+ {requirement.type}
+
+ |
+
+
+ {requirement.description}
+
+ |
+
+ {isCompleted ? (
+
+ ) : (
+
+ )}
+ |
+
+ );
+ })}
+
+
+
);
};
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControl.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControl.ts
similarity index 100%
rename from apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControl.tsx
rename to apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControl.ts
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControlProgress.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControlProgress.ts
new file mode 100644
index 0000000000..0b28787f3f
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControlProgress.ts
@@ -0,0 +1,32 @@
+"use client";
+
+import useSWR from "swr";
+import { getOrganizationControlProgress } from "../Actions/getOrganizationControlProgress";
+
+async function fetchOrganizationControlProgress(controlId: string) {
+ const result = await getOrganizationControlProgress({ controlId });
+
+ if (!result || "error" in result || !result.data) {
+ throw new Error("Failed to fetch control progress");
+ }
+
+ return result.data.data;
+}
+
+export function useOrganizationControlProgress(controlId: string) {
+ const { data, error, isLoading, mutate } = useSWR(
+ ["organization-control-progress", controlId],
+ () => fetchOrganizationControlProgress(controlId),
+ {
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ }
+ );
+
+ return {
+ data,
+ isLoading,
+ error,
+ mutate,
+ };
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControlRequirements.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControlRequirements.ts
new file mode 100644
index 0000000000..49180a184b
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControlRequirements.ts
@@ -0,0 +1,37 @@
+"use client";
+
+import useSWR from "swr";
+import { getOrganizationControlRequirements } from "../Actions/getOrganizationControlRequirements";
+
+async function fetchOrganizationControlRequirements(controlId: string) {
+ const result = await getOrganizationControlRequirements({ controlId });
+
+ if (!result) {
+ throw new Error("Failed to fetch control");
+ }
+
+ const data = result.data?.data;
+ if (!data) {
+ throw new Error("Invalid response from server");
+ }
+
+ return data.organizationControlRequirements;
+}
+
+export function useOrganizationControlRequirements(controlId: string) {
+ const { data, error, isLoading, mutate } = useSWR(
+ ["organization-control-requirements", controlId],
+ () => fetchOrganizationControlRequirements(controlId),
+ {
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ }
+ );
+
+ return {
+ data,
+ isLoading,
+ error,
+ mutate,
+ };
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/Actions/getOrganizationCategories.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/Actions/getOrganizationCategories.ts
index b9b1765a2e..a71f50d4f4 100644
--- a/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/Actions/getOrganizationCategories.ts
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/Actions/getOrganizationCategories.ts
@@ -8,12 +8,17 @@ import type {
OrganizationFramework,
OrganizationCategory,
Control,
+ OrganizationControlRequirement,
+ OrganizationPolicy,
} from "@bubba/db";
import { z } from "zod";
export type OrganizationCategoryWithControls = OrganizationCategory & {
organizationControl: (OrganizationControl & {
control: Control;
+ OrganizationControlRequirement: (OrganizationControlRequirement & {
+ organizationPolicy: Pick | null;
+ })[];
})[];
};
@@ -50,6 +55,15 @@ export const getOrganizationCategories = authActionClient
organizationControl: {
include: {
control: true,
+ OrganizationControlRequirement: {
+ include: {
+ organizationPolicy: {
+ select: {
+ status: true,
+ },
+ },
+ },
+ },
},
},
},
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/Actions/getOrganizationControlsProgress.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/Actions/getOrganizationControlsProgress.ts
new file mode 100644
index 0000000000..2a3daea462
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/Actions/getOrganizationControlsProgress.ts
@@ -0,0 +1,122 @@
+"use server";
+
+import { authActionClient } from "@/actions/safe-action";
+import { db } from "@bubba/db";
+import { z } from "zod";
+
+export interface ControlProgress {
+ controlId: string;
+ total: number;
+ completed: number;
+ progress: number;
+ byType: {
+ [key: string]: {
+ total: number;
+ completed: number;
+ };
+ };
+}
+
+export const getOrganizationControlsProgress = authActionClient
+ .schema(z.object({ controlIds: z.array(z.string()) }))
+ .metadata({
+ name: "getOrganizationControlsProgress",
+ track: {
+ event: "get-organization-controls-progress",
+ channel: "server",
+ },
+ })
+ .action(async ({ ctx, parsedInput }) => {
+ const { user } = ctx;
+ const { controlIds } = parsedInput;
+
+ if (!user.organizationId) {
+ return {
+ error: "Not authorized - no organization found",
+ };
+ }
+
+ try {
+ const requirements = await db.organizationControlRequirement.findMany({
+ where: {
+ organizationControlId: {
+ in: controlIds,
+ },
+ },
+ include: {
+ organizationPolicy: true,
+ },
+ });
+
+ const progressByControl = new Map();
+
+ // Initialize progress for all controls
+ for (const controlId of controlIds) {
+ progressByControl.set(controlId, {
+ controlId,
+ total: 0,
+ completed: 0,
+ progress: 0,
+ byType: {},
+ });
+ }
+
+ // Calculate progress for each control
+ for (const requirement of requirements) {
+ const controlId = requirement.organizationControlId;
+ const progress = progressByControl.get(controlId)!;
+
+ // Initialize type counters if not exists
+ if (!progress.byType[requirement.type]) {
+ progress.byType[requirement.type] = {
+ total: 0,
+ completed: 0,
+ };
+ }
+
+ progress.total++;
+ progress.byType[requirement.type].total++;
+
+ // Check completion based on requirement type
+ let isCompleted = false;
+ switch (requirement.type) {
+ case "policy":
+ isCompleted =
+ requirement.organizationPolicy?.status === "published";
+ break;
+ case "file":
+ isCompleted = !!requirement.fileUrl;
+ break;
+ case "evidence":
+ isCompleted = !!requirement.content;
+ break;
+ default:
+ isCompleted = requirement.published;
+ }
+
+ if (isCompleted) {
+ progress.completed++;
+ progress.byType[requirement.type].completed++;
+ }
+ }
+
+ // Calculate progress percentage for each control
+ for (const progress of progressByControl.values()) {
+ progress.progress =
+ progress.total > 0
+ ? Math.round((progress.completed / progress.total) * 100)
+ : 0;
+ }
+
+ return {
+ data: {
+ progress: Array.from(progressByControl.values()),
+ },
+ };
+ } catch (error) {
+ console.error("Error fetching controls progress:", error);
+ return {
+ error: "Failed to fetch controls progress",
+ };
+ }
+ });
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/Components/FrameworkControls.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/Components/FrameworkControls.tsx
index 139b897688..fccd25bde7 100644
--- a/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/Components/FrameworkControls.tsx
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/Components/FrameworkControls.tsx
@@ -1,6 +1,5 @@
"use client";
-import type { TransformedCategory } from "@/types/framework";
import {
Card,
CardContent,
@@ -10,8 +9,9 @@ import {
} from "@bubba/ui/card";
import { DataTable } from "@/components/tables/frameworks/data-table";
-import { useOrganizationFramework } from "@/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/hooks/useOrganizationFramework";
import { useOrganizationCategories } from "@/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/hooks/useOrganizationCategories";
+import { useMemo } from "react";
+import type { OrganizationControlType } from "@/components/tables/frameworks/columns";
interface FrameworkControlsProps {
frameworkId: string;
@@ -21,6 +21,23 @@ export function FrameworkControls({ frameworkId }: FrameworkControlsProps) {
const { data: organizationCategories } =
useOrganizationCategories(frameworkId);
+ const allControls = useMemo(() => {
+ if (!organizationCategories) return [];
+
+ return organizationCategories.flatMap((category) =>
+ category.organizationControl.map((control) => ({
+ code: control.control.code,
+ description: control.control.description,
+ name: control.control.name,
+ status: control.status,
+ id: control.id,
+ frameworkId,
+ category: category.name,
+ requirements: control.OrganizationControlRequirement,
+ }))
+ );
+ }, [organizationCategories, frameworkId]) as OrganizationControlType[];
+
if (!organizationCategories) {
return null;
}
@@ -32,21 +49,7 @@ export function FrameworkControls({ frameworkId }: FrameworkControlsProps) {
Review and manage compliance controls
- {organizationCategories.map((category) => (
-
-
{category.name}
- ({
- code: control.control.code,
- description: control.control.description,
- name: control.control.name,
- status: control.status,
- id: control.id,
- frameworkId,
- }))}
- />
-
- ))}
+
);
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/hooks/useOrganizationCategories.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/hooks/useOrganizationCategories.ts
index 4329e213c9..88e554d282 100644
--- a/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/hooks/useOrganizationCategories.ts
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/hooks/useOrganizationCategories.ts
@@ -1,18 +1,11 @@
"use client";
import useSWR from "swr";
-import {
- getOrganizationCategories,
- OrganizationCategoryWithControls,
-} from "../Actions/getOrganizationCategories";
+import { getOrganizationCategories } from "../Actions/getOrganizationCategories";
-async function fetchOrganizationCategories(
- frameworkId: string
-): Promise {
+async function fetchOrganizationCategories(frameworkId: string) {
const result = await getOrganizationCategories({ frameworkId });
- console.log({ result });
-
if (!result) {
throw new Error("Failed to fetch frameworks");
}
@@ -26,9 +19,7 @@ async function fetchOrganizationCategories(
}
export function useOrganizationCategories(frameworkId: string) {
- const { data, error, isLoading, mutate } = useSWR<
- OrganizationCategoryWithControls[]
- >(
+ const { data, error, isLoading, mutate } = useSWR(
["organization-categories", frameworkId],
() => fetchOrganizationCategories(frameworkId),
{
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/actions/publish-policy.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/actions/publish-policy.ts
index ead1296cdb..05a4362bfb 100644
--- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/actions/publish-policy.ts
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/actions/publish-policy.ts
@@ -34,10 +34,27 @@ export const publishPolicy = authActionClient
};
}
+ const policy = await db.organizationPolicy.findFirst({
+ where: {
+ policyId: id,
+ organizationId: user.organizationId!,
+ },
+ select: {
+ id: true,
+ },
+ });
+
+ if (!policy?.id) {
+ return {
+ success: false,
+ error: "Policy not found",
+ };
+ }
+
try {
- const policy = await db.organizationPolicy.update({
+ const organizationPolicy = await db.organizationPolicy.update({
where: {
- id,
+ id: policy.id,
organizationId: user.organizationId!,
},
data: {
@@ -48,7 +65,7 @@ export const publishPolicy = authActionClient
return {
success: true,
- data: policy,
+ data: organizationPolicy,
};
} catch (error) {
return {
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/page.tsx
index 72b3dd021f..65812cfdbf 100644
--- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/page.tsx
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/page.tsx
@@ -22,11 +22,13 @@ export default function PolicyPage() {
onError: () => {
toast.error("Failed to publish policy, please try again.");
},
- },
+ }
);
const { data: policy } = usePolicy({ policyId: id as string });
+ console.log({ policy });
+
if (!policy) return null;
const content = policy.content as JSONContent;
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/actions/get-policy.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/actions/get-policy.ts
index 4c678e084b..8f5ef40dc5 100644
--- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/actions/get-policy.ts
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/actions/get-policy.ts
@@ -38,7 +38,7 @@ export const getPolicy = authActionClient
const policy = await db.organizationPolicy.findFirst({
where: {
organizationId: user.organizationId!,
- id: policyId,
+ policyId,
},
include: {
policy: true,
diff --git a/apps/app/src/components/frameworks/framework-status.tsx b/apps/app/src/components/frameworks/framework-status.tsx
index 5a4172463a..78dd9569c2 100644
--- a/apps/app/src/components/frameworks/framework-status.tsx
+++ b/apps/app/src/components/frameworks/framework-status.tsx
@@ -2,8 +2,8 @@ import { useI18n } from "@/locales/client";
import { cn } from "@bubba/ui/cn";
export const STATUS_TYPES = [
- "compliant",
- "non_compliant",
+ "completed",
+ "in_progress",
"not_started",
] as const;
@@ -13,8 +13,8 @@ export type StatusType = Exclude<
>;
const STATUS_COLORS: Record = {
- compliant: "#22c55e",
- non_compliant: "#f43f5e",
+ completed: "#22c55e",
+ in_progress: "#eab308",
not_started: "#f43f5e",
} as const;
diff --git a/apps/app/src/components/tables/frameworks/columns.tsx b/apps/app/src/components/tables/frameworks/columns.tsx
index fdb0a11127..0cf713bf1a 100644
--- a/apps/app/src/components/tables/frameworks/columns.tsx
+++ b/apps/app/src/components/tables/frameworks/columns.tsx
@@ -5,19 +5,59 @@ import {
type StatusType,
} from "@/components/frameworks/framework-status";
import { useI18n } from "@/locales/client";
-import type { ArtifactType, ComplianceStatus } from "@bubba/db";
+import type {
+ ArtifactType,
+ ComplianceStatus,
+ OrganizationControlRequirement,
+} from "@bubba/db";
import type { ColumnDef } from "@tanstack/react-table";
import Link from "next/link";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@bubba/ui/tooltip";
export type OrganizationControlType = {
- id: string;
- name: string;
code: string;
description: string | null;
+ name: string;
status: ComplianceStatus;
+ id: string;
frameworkId: string;
+ category: string;
+ requirements: (OrganizationControlRequirement & {
+ organizationPolicy: {
+ status: "draft" | "published" | "archived";
+ } | null;
+ })[];
};
+function getControlStatus(
+ requirements: OrganizationControlType["requirements"]
+): StatusType {
+ if (!requirements || requirements.length === 0) return "not_started";
+
+ const totalRequirements = requirements.length;
+ const completedRequirements = requirements.filter((req) => {
+ switch (req.type) {
+ case "policy":
+ return req.organizationPolicy?.status === "published";
+ case "file":
+ return !!req.fileUrl;
+ case "evidence":
+ return !!req.content;
+ default:
+ return req.published;
+ }
+ }).length;
+
+ if (completedRequirements === 0) return "not_started";
+ if (completedRequirements === totalRequirements) return "completed";
+ return "in_progress";
+}
+
export function columns(): ColumnDef[] {
const t = useI18n();
@@ -42,18 +82,63 @@ export function columns(): ColumnDef[] {
);
},
},
+ {
+ id: "category",
+ accessorKey: "category",
+ header: t("risk.vendor.table.category"),
+ cell: ({ row }) => (
+
+ {row.original.category}
+
+ ),
+ },
{
id: "status",
accessorKey: "status",
header: t("frameworks.controls.table.status"),
cell: ({ row }) => {
- const status = row.original.status;
+ const requirements = row.original.requirements;
+ const status = getControlStatus(requirements);
+
+ const totalRequirements = requirements.length;
+ const completedRequirements = requirements.filter((req) => {
+ switch (req.type) {
+ case "policy":
+ return req.organizationPolicy?.status === "published";
+ case "file":
+ return !!req.fileUrl;
+ case "evidence":
+ return !!req.content;
+ default:
+ return req.published;
+ }
+ }).length;
+
return (
-
-
-
+
+
+
+
+
+
+
+
+
+
+ Progress:{" "}
+ {Math.round(
+ (completedRequirements / totalRequirements) * 100
+ ) || 0}
+ %
+
+
+ Completed: {completedRequirements}/{totalRequirements}{" "}
+ requirements
+
+
+
+
+
);
},
},
diff --git a/apps/app/src/components/tables/frameworks/data-table-header.tsx b/apps/app/src/components/tables/frameworks/data-table-header.tsx
index 4861fbd7b0..c54fbf9292 100644
--- a/apps/app/src/components/tables/frameworks/data-table-header.tsx
+++ b/apps/app/src/components/tables/frameworks/data-table-header.tsx
@@ -45,7 +45,7 @@ export function DataTableHeader({ table, loading }: Props) {
router.replace(`${pathname}?${params.toString()}`);
},
- [searchParams, router, pathname],
+ [searchParams, router, pathname]
);
const isVisible = (id: string) =>
@@ -72,6 +72,24 @@ export function DataTableHeader({ table, loading }: Props) {
)}
+ {isVisible("category") && (
+
+
+
+ )}
+
{isVisible("status") && (