diff --git a/apps/app/languine.lock b/apps/app/languine.lock
index 07998f1393..ec5411ce0e 100644
--- a/apps/app/languine.lock
+++ b/apps/app/languine.lock
@@ -451,8 +451,8 @@ files:
settings.api_keys.description: a73bc7101920a4a2a821a6640ae95fc5
settings.api_keys.list_title: b4717f6ab3c2357c13c0c2cd32dfc4ca
settings.api_keys.list_description: 43aeedd6a4d60ad7a7c81cd81386e63e
- settings.api_keys.create: 35cd846f199e312c7fc78d231c4152c5
- settings.api_keys.create_title: 35cd846f199e312c7fc78d231c4152c5
+ settings.api_keys.create: 17dcee9df9da51b6808235edb471700a
+ settings.api_keys.create_title: 17dcee9df9da51b6808235edb471700a
settings.api_keys.create_description: b4e48d0aee029d3a32fb5ca5e2e82a3a
settings.api_keys.created_title: 46906edf876a3f62534fe9dd55b42e80
settings.api_keys.created_description: 445e205d7cc8d54e322b0b1270060b31
@@ -560,56 +560,6 @@ files:
frameworks.controls.statuses.completed: 07ca5050e697392c9ed47e6453f1453f
frameworks.controls.statuses.in_progress: c9413dd7682a45da9ecc9efa8cea47d7
frameworks.controls.statuses.not_started: 10db56f8b10dc989c43fbe33202e5339
- vendor.title: 2938c7f7e560ed972f8a4f68e80ff834
- vendor.register_title: 97f6291ea1000272b34fdfaeb3a9e2d2
- vendor.dashboard.title: 2938c7f7e560ed972f8a4f68e80ff834
- vendor.dashboard.overview: 003dd1a6f6efb2ac33fa8519c89dedcc
- vendor.dashboard.vendor_status: 17029fbe5ad4d2af5feaf37bb4604225
- vendor.dashboard.vendor_category: 273bcc0a45ba3b2256086f10966dfb99
- vendor.dashboard.vendors_by_assignee: efbb5ed45de13ae274678ea91b656d08
- vendor.dashboard.inherent_risk_description: f65c38062d37923bfd4850e78e25772a
- vendor.dashboard.residual_risk_description: 7e1b11b661aa2704e378dd1ca7f33757
- vendor.register.title: 122b14e8fa33bc9d4f389700439dfc71
- vendor.register.table.name: 49ee3087348e8d44e1feda1917443987
- vendor.register.table.category: 3adbdb3ac060038aa0e6e6c138ef9873
- vendor.register.table.status: ec53a8c4f07baed5d8825072c89799be
- vendor.register.table.owner: b6f4a2ec6356bbd56d49f2096bf9d3d3
- vendor.category.cloud: c8e2277ecfb1427838c488c217f99466
- vendor.category.infrastructure: cba4f4cee5c6a3e6d8be8da68fa1234e
- vendor.category.software_as_a_service: b0ca9e41d61885345eef09f928c9ea58
- vendor.category.finance: c482980d384a9d0e7bc39e1140270870
- vendor.category.marketing: 7cb15e416d62919b1b40298324fbe30b
- vendor.category.sales: 11ff9f68afb6b8b5b8eda218d7c83a65
- vendor.category.hr: fd4c638da5f85d025963f99fe90b1b1a
- vendor.category.other: 6311ae17c1ee52b36e68aaf4ad066387
- vendor.vendors: 2bc28e228525b75e14c07dbc14850c34
- vendor.form.vendor_details: c594485dedf99a5bbae0ce0b1e08c57a
- vendor.form.vendor_name: 49ee3087348e8d44e1feda1917443987
- vendor.form.vendor_name_placeholder: 76d22a64aa21a6f1fe356624bb42487e
- vendor.form.vendor_website: 15bbb9d0bbf25e8d2978de1168c749dc
- vendor.form.vendor_website_placeholder: 0edbf3ae0d36cb35fdae8df56396e8f0
- vendor.form.vendor_description: b5a7adde1af5c87d7fd797b6245c2a39
- vendor.form.vendor_description_placeholder: edd59fcb97919e8a914f29d1b8c94a84
- vendor.form.vendor_category: 3adbdb3ac060038aa0e6e6c138ef9873
- vendor.form.vendor_category_placeholder: 03368e3c1eb4d2a9048775874301b19f
- vendor.form.vendor_status: ec53a8c4f07baed5d8825072c89799be
- vendor.form.vendor_status_placeholder: 9aadb017cc5b335be77ba090d5029259
- vendor.form.create_vendor_success: 86783216c2055e4b01d9a6d920e418c7
- vendor.form.create_vendor_error: ca4cedd93fdb023d6f9748dd3b66b456
- vendor.form.update_vendor_success: eaedfa5b3ca431e1ca8900a8f0d5c924
- vendor.form.update_vendor_error: 92b965a71c44a134e2eae6d71493e4d9
- vendor.form.contacts: be184fa710244f6c783e6eb68fd427d7
- vendor.form.contact_name: 7cfd883d5d55fccfcfb13b3fcf29e3a6
- vendor.form.contact_email: 3c2292f30e636ecf522743c3fa73749d
- vendor.form.contact_role: ccde9b292717d5a0133304d27acfa669
- vendor.form.add_contact: 4e58c159d031b956f9ee227f87df3fcf
- vendor.form.new_contact: 636c575a94e9a0c957277e432fb1d49b
- vendor.form.min_one_contact_required: 92b341dc8c72591d66252794dfb00f48
- vendor.empty_states.no_vendors.title: 69c843f9cf71049dacacff0c48edb7b2
- vendor.empty_states.no_vendors.description: 955707a384c1cfc46fafbdebc508f9e0
- vendor.empty_states.no_results.title: e576c23d915755d83e2d1f47bd9f6c22
- vendor.empty_states.no_results.description: d7fd5c52726c5016a59fb01e74069441
- vendor.empty_states.no_results.description_with_filters: 2b70f835ed5a08e817ec9388ce25b41a
errors.unexpected: 325a357f60b95f37fec18b15cb39fbb0
editor.ai.thinking: aba464039b337eb2e75c2520039de58f
editor.ai.thinking_spinner: aba464039b337eb2e75c2520039de58f
@@ -648,3 +598,10 @@ files:
evidence.departments.qms: 3b88c0772d938028236e5098c096b2d5
evidence.details.review_section: 659514b42799e5982d28793d9e003ae3
evidence.details.content: 393ae95538cd395eb8d1f7521f652b10
+ vendors.title: dfcc5efe778e6340336015dc02617922
+ vendors.register.title: 122b14e8fa33bc9d4f389700439dfc71
+ vendors.dashboard.title: 003dd1a6f6efb2ac33fa8519c89dedcc
+ dashboard.risk_status: 6ce5e6fa832289748023cb773a4e89ac
+ dashboard.risks_by_department: 0ef508d7e840bc5f85547c3b25e5d87b
+ dashboard.vendor_status: 17029fbe5ad4d2af5feaf37bb4604225
+ dashboard.vendors_by_category: cac07152524b0e5195fbf87c36646b2b
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/settings/layout.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/settings/layout.tsx
index 5f1625c5ac..8e70f1159c 100644
--- a/apps/app/src/app/[locale]/(app)/(dashboard)/settings/layout.tsx
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/settings/layout.tsx
@@ -10,7 +10,7 @@ export default async function Layout({
const t = await getI18n();
return (
-
+
Loading...
}>
+
+
+ {children}
+
+ );
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/(overview)/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/(overview)/page.tsx
new file mode 100644
index 0000000000..eb880f6f6c
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/(overview)/page.tsx
@@ -0,0 +1,64 @@
+import { auth } from "@/auth";
+import { VendorOverview } from "@/components/vendors/charts/vendors-overview";
+import { getI18n } from "@/locales/server";
+import { db } from "@bubba/db";
+import type { Metadata } from "next";
+import { setStaticParamsLocale } from "next-international/server";
+import { unstable_cache } from "next/cache";
+import { redirect } from "next/navigation";
+
+export default async function VendorOverviewPage({
+ params,
+}: {
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+ setStaticParamsLocale(locale);
+
+ const session = await auth();
+
+ if (!session?.user?.organizationId) {
+ redirect("/onboarding");
+ }
+
+ const overview = await getVendorOverview(session.user.organizationId);
+
+ return (
+
+ );
+}
+
+const getVendorOverview = unstable_cache(
+ async (organizationId: string) => {
+ return await db.$transaction(async (tx) => {
+ const [vendors] = await Promise.all([
+ tx.vendor.count({
+ where: { organizationId },
+ }),
+ ]);
+
+ return {
+ vendors,
+ };
+ });
+ },
+ ["vendor-overview-cache"],
+);
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ locale: string }>;
+}): Promise {
+ const { locale } = await params;
+ setStaticParamsLocale(locale);
+ const t = await getI18n();
+
+ return {
+ title: t("sidebar.vendors"),
+ };
+}
diff --git a/apps/app/src/components/main-menu.tsx b/apps/app/src/components/main-menu.tsx
index 99b4b04de6..e39e5bf5f6 100644
--- a/apps/app/src/components/main-menu.tsx
+++ b/apps/app/src/components/main-menu.tsx
@@ -174,13 +174,18 @@ export function MainMenu({ initialItems, onSelect }: Props) {
disabled: false,
},
{
- path: "/policies",
- name: t("sidebar.policies"),
+ path: "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/evidence/overview",
+ name: t("sidebar.evidence"),
disabled: false,
},
{
- path: "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/evidence/overview",
- name: t("sidebar.evidence"),
+ path: "/tests",
+ name: t("sidebar.tests"),
+ disabled: false,
+ },
+ {
+ path: "/policies",
+ name: t("sidebar.policies"),
disabled: false,
},
{
@@ -193,15 +198,10 @@ export function MainMenu({ initialItems, onSelect }: Props) {
name: t("sidebar.risk"),
disabled: false,
},
- {
- path: "/tests",
- name: t("sidebar.tests"),
- disabled: false,
- },
{
path: "/vendors",
name: t("sidebar.vendors"),
- disabled: true,
+ disabled: false,
},
{
path: "/integrations",
diff --git a/apps/app/src/components/risks/charts/risks-by-department.tsx b/apps/app/src/components/risks/charts/risks-by-department.tsx
index 8263668970..bc111cfb47 100644
--- a/apps/app/src/components/risks/charts/risks-by-department.tsx
+++ b/apps/app/src/components/risks/charts/risks-by-department.tsx
@@ -2,65 +2,68 @@ import { db } from "@bubba/db";
import { Card, CardContent, CardHeader, CardTitle } from "@bubba/ui/card";
import { DepartmentChart } from "./department-chart";
import { unstable_cache } from "next/cache";
+import { getI18n } from "@/locales/server";
const ALL_DEPARTMENTS = ["none", "admin", "gov", "hr", "it", "itsm", "qms"];
interface Props {
- organizationId: string;
+ organizationId: string;
}
export async function RisksByDepartment({ organizationId }: Props) {
- const risks = await getRisksByDepartment(organizationId);
+ const t = await getI18n();
- const data = ALL_DEPARTMENTS.map((dept) => {
- const found = risks.find(
- (risk) =>
- (risk.department || "none").toLowerCase() === dept.toLowerCase(),
- );
+ const risks = await getRisksByDepartment(organizationId);
- return {
- name: dept === "none" ? "None" : dept.toUpperCase(),
- value: found ? found._count : 0,
- };
- }).sort((a, b) => b.value - a.value);
+ const data = ALL_DEPARTMENTS.map((dept) => {
+ const found = risks.find(
+ (risk) =>
+ (risk.department || "none").toLowerCase() === dept.toLowerCase(),
+ );
- // Separate departments with values > 0 and departments with values = 0
- const departmentsWithValues = data.filter((dept) => dept.value > 0);
- const departmentsWithoutValues = data.filter((dept) => dept.value === 0);
+ return {
+ name: dept === "none" ? "None" : dept.toUpperCase(),
+ value: found ? found._count : 0,
+ };
+ }).sort((a, b) => b.value - a.value);
- // Determine which departments to show
- let departmentsToShow = [...departmentsWithValues];
+ // Separate departments with values > 0 and departments with values = 0
+ const departmentsWithValues = data.filter((dept) => dept.value > 0);
+ const departmentsWithoutValues = data.filter((dept) => dept.value === 0);
- // If we have fewer than 4 departments with values, show up to 2 departments with no values
- if (departmentsWithValues.length < 4 && departmentsWithoutValues.length > 0) {
- departmentsToShow = [
- ...departmentsWithValues,
- ...departmentsWithoutValues.slice(0, 2),
- ];
- }
+ // Determine which departments to show
+ let departmentsToShow = [...departmentsWithValues];
- return (
-
-
- Risks by Department
-
-
-
-
-
- );
+ // If we have fewer than 4 departments with values, show up to 2 departments with no values
+ if (departmentsWithValues.length < 4 && departmentsWithoutValues.length > 0) {
+ departmentsToShow = [
+ ...departmentsWithValues,
+ ...departmentsWithoutValues.slice(0, 2),
+ ];
+ }
+
+ return (
+
+
+ {t("dashboard.risks_by_department")}
+
+
+
+
+
+ );
}
const getRisksByDepartment = unstable_cache(
- async (organizationId: string) => {
- const risksByDepartment = await db.risk.groupBy({
- by: ["department"],
- where: { organizationId },
- _count: true,
- });
+ async (organizationId: string) => {
+ const risksByDepartment = await db.risk.groupBy({
+ by: ["department"],
+ where: { organizationId },
+ _count: true,
+ });
- return risksByDepartment;
- },
- ["risks-by-department"],
- { tags: ["risks", "departments"] },
+ return risksByDepartment;
+ },
+ ["risks-by-department"],
+ { tags: ["risks", "departments"] },
);
diff --git a/apps/app/src/components/risks/charts/risks-by-status.tsx b/apps/app/src/components/risks/charts/risks-by-status.tsx
index bdb91242b0..011fd64ed7 100644
--- a/apps/app/src/components/risks/charts/risks-by-status.tsx
+++ b/apps/app/src/components/risks/charts/risks-by-status.tsx
@@ -2,39 +2,42 @@ import { db } from "@bubba/db";
import { StatusChart } from "./status-chart";
import { unstable_cache } from "next/cache";
import { Card, CardHeader, CardTitle, CardContent } from "@bubba/ui/card";
+import { getI18n } from "@/locales/server";
interface Props {
- organizationId: string;
+ organizationId: string;
}
export async function RisksByStatus({ organizationId }: Props) {
- const risks = await getRisksByStatus(organizationId);
+ const t = await getI18n();
- const data = risks.map((risk) => ({
- name: risk.status,
- value: risk._count,
- }));
+ const risks = await getRisksByStatus(organizationId);
- return (
-
-
- Risks by Status
-
-
-
-
-
- );
+ const data = risks.map((risk) => ({
+ name: risk.status,
+ value: risk._count,
+ }));
+
+ return (
+
+
+ {t("dashboard.risk_status")}
+
+
+
+
+
+ );
}
const getRisksByStatus = unstable_cache(
- async (organizationId: string) => {
- return await db.risk.groupBy({
- by: ["status"],
- where: { organizationId },
- _count: true,
- });
- },
- ["risks-by-status"],
- { tags: ["risks", "status"] },
+ async (organizationId: string) => {
+ return await db.risk.groupBy({
+ by: ["status"],
+ where: { organizationId },
+ _count: true,
+ });
+ },
+ ["risks-by-status"],
+ { tags: ["risks", "status"] },
);
diff --git a/apps/app/src/components/tables/api-keys/create-api-key-dialog.tsx b/apps/app/src/components/tables/api-keys/create-api-key-dialog.tsx
index 0de66e024c..b6fd0f1e8c 100644
--- a/apps/app/src/components/tables/api-keys/create-api-key-dialog.tsx
+++ b/apps/app/src/components/tables/api-keys/create-api-key-dialog.tsx
@@ -211,7 +211,7 @@ export function CreateApiKeyDialog({
-
+
{t("common.actions.cancel")}
diff --git a/apps/app/src/components/tables/api-keys/index.tsx b/apps/app/src/components/tables/api-keys/index.tsx
index cd29f78b9e..898624e5f5 100644
--- a/apps/app/src/components/tables/api-keys/index.tsx
+++ b/apps/app/src/components/tables/api-keys/index.tsx
@@ -99,7 +99,7 @@ export function ApiKeysTable() {
return (
<>
-
+
{t("settings.api_keys.list_title")}
diff --git a/apps/app/src/components/vendors/charts/category-chart.tsx b/apps/app/src/components/vendors/charts/category-chart.tsx
new file mode 100644
index 0000000000..f1288b341a
--- /dev/null
+++ b/apps/app/src/components/vendors/charts/category-chart.tsx
@@ -0,0 +1,209 @@
+"use client";
+
+import React, { type CSSProperties } from "react";
+import { scaleBand, scaleLinear, max, format } from "d3";
+import { ClientTooltip } from "@bubba/ui/chart-tooltip";
+
+const VENDOR_CATEGORY_COLORS = {
+ cloud: "bg-[var(--chart-open)]",
+ infrastructure: "bg-[var(--chart-pending)]",
+ software_as_a_service: "bg-[var(--chart-closed)]",
+ finance: "bg-[var(--chart-open)]",
+ marketing: "bg-[var(--chart-pending)]",
+ sales: "bg-[var(--chart-closed)]",
+ hr: "bg-[var(--chart-open)]",
+ other: "bg-[var(--chart-pending)]",
+};
+
+interface VendorCategoryData {
+ name: string;
+ value: number;
+}
+
+interface VendorCategoryChartProps {
+ data: VendorCategoryData[];
+ showEmptyDepartments?: boolean;
+}
+
+export function VendorCategoryChart({
+ data,
+ showEmptyDepartments = true,
+}: VendorCategoryChartProps) {
+ const filteredData = showEmptyDepartments
+ ? data
+ : data.filter((dept) => dept.value > 0);
+
+ const sortedData = [...filteredData].sort((a, b) => b.value - a.value);
+
+ // Return early with a message if no departments have risks
+ if (sortedData.length === 0) {
+ return (
+
+ No departments with risks found
+
+ );
+ }
+
+ // Define fixed settings for consistent bar sizing
+ const barHeight = 40; // Fixed height for each bar in pixels
+ const barGap = 16; // Gap between bars in pixels
+ const minChartHeight = 300; // Minimum chart height
+
+ // Calculate dynamic chart height based on number of departments
+ const chartHeight = Math.max(
+ minChartHeight,
+ (barHeight + barGap) * sortedData.length + 40, // Extra padding at top/bottom
+ );
+
+ // Get the maximum value for scaling
+ const maxValue = max(sortedData.map((d) => d.value)) ?? 1;
+
+ // Scales
+ const yScale = scaleBand()
+ .domain(sortedData.map((d) => d.name))
+ .range([0, 100])
+ .padding(0.25);
+
+ const xScale = scaleLinear().domain([0, maxValue]).range([0, 100]);
+
+ const marginLeft = 70;
+ const marginRight = 20;
+ const marginBottom = 20;
+
+ const getBarKey = (item: VendorCategoryData) =>
+ `bar-${item.name}-${item.value}`;
+ const getTickKey = (value: number) => `tick-${value}`;
+ const getGridKey = (value: number, position = 0) =>
+ `grid-${value.toString().replace(".", "-")}-${position}`;
+ const getLabelKey = (item: VendorCategoryData) => `label-${item.name}`;
+
+ const getCategoryColor = (categoryName: string) => {
+ const normalizedName = categoryName.toLowerCase();
+ return (
+ VENDOR_CATEGORY_COLORS[
+ normalizedName as keyof typeof VENDOR_CATEGORY_COLORS
+ ] || "bg-gray-400"
+ );
+ };
+
+ // Generate appropriate tick values based on max value
+ const generateTickValues = () => {
+ // For small values, generate specific tick values
+ if (maxValue <= 1) return [0, 1];
+ if (maxValue <= 2) return [0, 1, 2];
+ if (maxValue <= 5) return [0, 1, 2, 3, 4, 5];
+ if (maxValue <= 10) return [0, 2, 4, 6, 8, 10];
+
+ // For larger values, let D3 handle it with a reasonable number of ticks
+ const tickCount = Math.min(maxValue, 6); // Limit number of ticks
+ return xScale.ticks(tickCount).map(Number);
+ };
+
+ const tickValues = generateTickValues();
+
+ return (
+
+
+
+ {sortedData.map((d, index) => {
+ const barWidth = d.value === 0 ? 3 : xScale(d.value);
+ const fixedBarHeightPercentage = (barHeight / chartHeight) * 100;
+
+ // Calculate exact position to align with labels
+ // Get center point of the band for this item
+ const bandCenter = yScale(d.name)! + yScale.bandwidth() / 2;
+ // Position bar so its center aligns with the band center
+ const barTopPosition = bandCenter - fixedBarHeightPercentage / 2;
+
+ return (
+
+ );
+ })}
+
+ {tickValues.map((value, position) => {
+ const uniqueKey = getGridKey(value, position);
+ return (
+
+
+
+ );
+ })}
+
+
+ {tickValues.map((value) => (
+
+ {Number.isInteger(value) ? format(",")(value) : value.toFixed(2)}
+
+ ))}
+
+
+
+ {sortedData.map((entry) => (
+
+ {entry.name}
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/app/src/components/vendors/charts/status-chart.tsx b/apps/app/src/components/vendors/charts/status-chart.tsx
new file mode 100644
index 0000000000..984aa5a2e6
--- /dev/null
+++ b/apps/app/src/components/vendors/charts/status-chart.tsx
@@ -0,0 +1,213 @@
+"use client";
+
+import React, { type CSSProperties } from "react";
+import { scaleBand, scaleLinear, max, format } from "d3";
+import { ClientTooltip } from "@bubba/ui/chart-tooltip";
+
+const STATUS_COLORS = {
+ not_assessed: "bg-[var(--chart-open)]",
+ in_progress: "bg-[var(--chart-pending)]",
+ assessed: "bg-[var(--chart-closed)]",
+};
+
+interface StatusData {
+ name: string;
+ value: number;
+}
+
+interface StatusChartProps {
+ data: StatusData[];
+}
+
+export function StatusChart({ data }: StatusChartProps) {
+ const ensureAllStatuses = (inputData: StatusData[]): StatusData[] => {
+ const result = inputData.map((item) => ({
+ ...item,
+ name:
+ item.name.charAt(0).toUpperCase() + item.name.slice(1).toLowerCase(),
+ }));
+
+ const statusNames = Object.keys(STATUS_COLORS);
+
+ for (const status of statusNames) {
+ const capitalized = status.charAt(0).toUpperCase() + status.slice(1);
+ if (!result.some((item) => item.name.toLowerCase() === status)) {
+ result.push({ name: capitalized, value: 0 });
+ }
+ }
+
+ return result;
+ };
+
+ const sortedData = [...ensureAllStatuses(data)].sort(
+ (a, b) => b.value - a.value,
+ );
+
+ if (sortedData.length === 0) {
+ return (
+
+ No statuses with risks found
+
+ );
+ }
+
+ const barHeight = 40; // Fixed height for each bar in pixels
+ const barGap = 16; // Gap between bars in pixels
+ const minChartHeight = 300; // Minimum chart height
+
+ // Calculate dynamic chart height based on number of departments
+ const chartHeight = Math.max(
+ minChartHeight,
+ (barHeight + barGap) * sortedData.length + 40, // Extra padding at top/bottom
+ );
+
+ // Get the maximum value for scaling
+ const maxValue = max(sortedData.map((d) => d.value)) ?? 1;
+
+ // Scales
+ const yScale = scaleBand()
+ .domain(sortedData.map((d) => d.name))
+ .range([0, 100])
+ .padding(0.25);
+
+ const xScale = scaleLinear().domain([0, maxValue]).range([0, 100]);
+
+ const marginLeft = 70;
+ const marginRight = 20;
+ const marginBottom = 20;
+
+ const getBarKey = (item: StatusData) => `bar-${item.name}-${item.value}`;
+ const getTickKey = (value: number) => `tick-${value}`;
+ const getGridKey = (value: number, position = 0) =>
+ `grid-${value.toString().replace(".", "-")}-${position}`;
+ const getLabelKey = (item: StatusData) => `label-${item.name}`;
+
+ const getStatusColor = (statusName: string) => {
+ const normalizedName = statusName.toLowerCase();
+ return (
+ STATUS_COLORS[normalizedName as keyof typeof STATUS_COLORS] ||
+ "bg-gray-400"
+ );
+ };
+
+ // Generate appropriate tick values based on max value
+ const generateTickValues = () => {
+ // For small values, generate specific tick values
+ if (maxValue <= 1) return [0, 1];
+ if (maxValue <= 2) return [0, 1, 2];
+ if (maxValue <= 5) return [0, 1, 2, 3, 4, 5];
+ if (maxValue <= 10) return [0, 2, 4, 6, 8, 10];
+
+ // For larger values, let D3 handle it with a reasonable number of ticks
+ const tickCount = Math.min(maxValue, 6); // Limit number of ticks
+ return xScale.ticks(tickCount).map(Number);
+ };
+
+ const tickValues = generateTickValues();
+
+ return (
+
+
+
+ {sortedData.map((d, index) => {
+ const barWidth = d.value === 0 ? 3 : xScale(d.value);
+ const fixedBarHeightPercentage = (barHeight / chartHeight) * 100;
+
+ // Calculate exact position to align with labels
+ // Get center point of the band for this item
+ const bandCenter = yScale(d.name)! + yScale.bandwidth() / 2;
+ // Position bar so its center aligns with the band center
+ const barTopPosition = bandCenter - fixedBarHeightPercentage / 2;
+
+ return (
+
+ );
+ })}
+
+ {tickValues.map((value, position) => {
+ const uniqueKey = getGridKey(value, position);
+ return (
+
+
+
+ );
+ })}
+
+
+ {tickValues.map((value) => (
+
+ {Number.isInteger(value) ? format(",")(value) : value.toFixed(2)}
+
+ ))}
+
+
+
+ {sortedData.map((entry) => (
+
+ {entry.name}
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/app/src/components/vendors/charts/vendors-by-category.tsx b/apps/app/src/components/vendors/charts/vendors-by-category.tsx
new file mode 100644
index 0000000000..55ee5d30de
--- /dev/null
+++ b/apps/app/src/components/vendors/charts/vendors-by-category.tsx
@@ -0,0 +1,73 @@
+import { db } from "@bubba/db";
+import { VendorCategory } from "@bubba/db";
+import { Card, CardContent, CardHeader, CardTitle } from "@bubba/ui/card";
+import { VendorCategoryChart } from "./category-chart";
+import { unstable_cache } from "next/cache";
+import { getI18n } from "@/locales/server";
+
+const VENDOR_CATEGORIES = Object.values(VendorCategory);
+
+interface Props {
+ organizationId: string;
+}
+
+export async function VendorsByCategory({ organizationId }: Props) {
+ const t = await getI18n();
+
+ const vendors = await getVendorsByCategory(organizationId);
+
+ const data = VENDOR_CATEGORIES.map((category) => {
+ const found = vendors.find(
+ (vendor) =>
+ (vendor.category || "other").toLowerCase() === category.toLowerCase(),
+ );
+
+ return {
+ name: category === "other" ? "Other" : category.toUpperCase(),
+ value: found ? found._count : 0,
+ };
+ }).sort((a, b) => b.value - a.value);
+
+ // Separate departments with values > 0 and departments with values = 0
+ const categoriesWithValues = data.filter((dept) => dept.value > 0);
+ const categoriesWithoutValues = data.filter((dept) => dept.value === 0);
+
+ // Determine which departments to show
+ let categoriesToShow = [...categoriesWithValues];
+
+ // If we have fewer than 4 departments with values, show up to 2 departments with no values
+ if (categoriesWithValues.length < 4 && categoriesWithoutValues.length > 0) {
+ categoriesToShow = [
+ ...categoriesWithValues,
+ ...categoriesWithoutValues.slice(0, 2),
+ ];
+ }
+
+ return (
+
+
+ {t("dashboard.vendors_by_category")}
+
+
+
+
+
+ );
+}
+
+const getVendorsByCategory = unstable_cache(
+ async (organizationId: string) => {
+ const vendorsByCategory = await db.vendor.groupBy({
+ by: ["category"],
+ where: { organizationId },
+ _count: true,
+ });
+
+ return vendorsByCategory;
+ },
+ ["vendors-by-category"],
+ { tags: ["vendors", "categories"] },
+);
diff --git a/apps/app/src/components/vendors/charts/vendors-by-status.tsx b/apps/app/src/components/vendors/charts/vendors-by-status.tsx
new file mode 100644
index 0000000000..6e5df999f9
--- /dev/null
+++ b/apps/app/src/components/vendors/charts/vendors-by-status.tsx
@@ -0,0 +1,42 @@
+import { db } from "@bubba/db";
+import { StatusChart } from "./status-chart";
+import { unstable_cache } from "next/cache";
+import { Card, CardHeader, CardTitle, CardContent } from "@bubba/ui/card";
+import { getI18n } from "@/locales/server";
+interface Props {
+ organizationId: string;
+}
+
+export async function VendorsByStatus({ organizationId }: Props) {
+ const t = await getI18n();
+
+ const vendors = await getVendorsByStatus(organizationId);
+
+ const data = vendors.map((vendor) => ({
+ name: vendor.status,
+ value: vendor._count,
+ }));
+
+ return (
+
+
+ {t("dashboard.vendor_status")}
+
+
+
+
+
+ );
+}
+
+const getVendorsByStatus = unstable_cache(
+ async (organizationId: string) => {
+ return await db.vendor.groupBy({
+ by: ["status"],
+ where: { organizationId },
+ _count: true,
+ });
+ },
+ ["vendors-by-status"],
+ { tags: ["vendors", "status"] },
+);
diff --git a/apps/app/src/components/vendors/charts/vendors-overview.tsx b/apps/app/src/components/vendors/charts/vendors-overview.tsx
new file mode 100644
index 0000000000..57d082dd68
--- /dev/null
+++ b/apps/app/src/components/vendors/charts/vendors-overview.tsx
@@ -0,0 +1,15 @@
+import { VendorsByCategory } from "./vendors-by-category";
+import { VendorsByStatus } from "./vendors-by-status";
+
+interface VendorOverviewProps {
+ organizationId: string;
+}
+
+export function VendorOverview({ organizationId }: VendorOverviewProps) {
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/apps/app/src/locales/en.ts b/apps/app/src/locales/en.ts
index c00d910d9c..f4011ec632 100644
--- a/apps/app/src/locales/en.ts
+++ b/apps/app/src/locales/en.ts
@@ -684,8 +684,8 @@ export default {
list_title: "API Keys",
list_description:
"API keys allow secure access to your organization's data via our API.",
- create: "Create API Key",
- create_title: "Create API Key",
+ create: "New API Key",
+ create_title: "New API Key",
create_description:
"Create a new API key for programmatic access to your organization's data.",
created_title: "API Key Created",
@@ -857,76 +857,6 @@ export default {
},
},
},
- vendor: {
- title: "Dashboard",
- register_title: "Vendor Management",
- dashboard: {
- title: "Dashboard",
- overview: "Vendor Overview",
- vendor_status: "Vendor Status",
- vendor_category: "Vendor Categories",
- vendors_by_assignee: "Vendors by Assignee",
- inherent_risk_description:
- "Initial risk level before any controls are applied",
- residual_risk_description:
- "Remaining risk level after controls are applied",
- },
- register: {
- title: "Vendor Register",
- table: {
- name: "Name",
- category: "Category",
- status: "Status",
- owner: "Owner",
- },
- },
- category: {
- cloud: "Cloud",
- infrastructure: "Infrastructure",
- software_as_a_service: "SaaS",
- finance: "Finance",
- marketing: "Marketing",
- sales: "Sales",
- hr: "HR",
- other: "Other",
- },
- vendors: "vendors",
- form: {
- vendor_details: "Vendor Details",
- vendor_name: "Name",
- vendor_name_placeholder: "Enter vendor name",
- vendor_website: "Website",
- vendor_website_placeholder: "Enter vendor website",
- vendor_description: "Description",
- vendor_description_placeholder: "Enter vendor description",
- vendor_category: "Category",
- vendor_category_placeholder: "Select category",
- vendor_status: "Status",
- vendor_status_placeholder: "Select status",
- create_vendor_success: "Vendor created successfully",
- create_vendor_error: "Failed to create vendor",
- update_vendor_success: "Vendor updated successfully",
- update_vendor_error: "Failed to update vendor",
- contacts: "Vendor Contacts",
- contact_name: "Contact Name",
- contact_email: "Contact Email",
- contact_role: "Contact Role",
- add_contact: "Add Contact",
- new_contact: "New Contact",
- min_one_contact_required: "A vendor must have at least one contact",
- },
- empty_states: {
- no_vendors: {
- title: "No vendors yet",
- description: "Get started by creating your first vendor",
- },
- no_results: {
- title: "No results found",
- description: "No vendors match your search",
- description_with_filters: "Try adjusting your filters",
- },
- },
- },
errors: {
unexpected: "Something went wrong, please try again",
},
@@ -983,4 +913,19 @@ export default {
content: "Evidence Content",
},
},
+ vendors: {
+ title: "Vendors",
+ register: {
+ title: "Vendor Register",
+ },
+ dashboard: {
+ title: "Vendor Overview",
+ },
+ },
+ dashboard: {
+ risk_status: "Risk Status",
+ risks_by_department: "Risks by Department",
+ vendor_status: "Vendor Status",
+ vendors_by_category: "Vendors by Category",
+ },
} as const;
diff --git a/apps/app/src/locales/es.ts b/apps/app/src/locales/es.ts
index c5fbcdbacc..d78de610eb 100644
--- a/apps/app/src/locales/es.ts
+++ b/apps/app/src/locales/es.ts
@@ -580,8 +580,8 @@ export default {
description: "Administra las claves API para el acceso programático a los datos de tu organización.",
list_title: "Claves API",
list_description: "Las claves API permiten el acceso seguro a los datos de tu organización a través de nuestra API.",
- create: "Crear clave API",
- create_title: "Crear clave API",
+ create: "Nueva clave de API",
+ create_title: "Nueva clave de API",
create_description: "Crea una nueva clave API para el acceso programático a los datos de tu organización.",
created_title: "Clave API creada",
created_description: "Tu clave API ha sido creada. Asegúrate de copiarla ahora, ya que no podrás volver a verla.",
@@ -990,5 +990,20 @@ export default {
no_results: "No se encontraron resultados",
status: "Estado"
}
+ },
+ vendors: {
+ title: "Proveedores",
+ register: {
+ title: "Registro de proveedores"
+ },
+ dashboard: {
+ title: "Resumen de proveedores"
+ }
+ },
+ dashboard: {
+ risk_status: "Estado de riesgo",
+ risks_by_department: "Riesgos por departamento",
+ vendor_status: "Estado del proveedor",
+ vendors_by_category: "Proveedores por categoría"
}
} as const;
diff --git a/apps/app/src/locales/fr.ts b/apps/app/src/locales/fr.ts
index 438fe9a6cc..afdd85f673 100644
--- a/apps/app/src/locales/fr.ts
+++ b/apps/app/src/locales/fr.ts
@@ -580,8 +580,8 @@ export default {
description: "Gérez les clés API pour un accès programmatique aux données de votre organisation.",
list_title: "Clés API",
list_description: "Les clés API permettent un accès sécurisé aux données de votre organisation via notre API.",
- create: "Créer une clé API",
- create_title: "Créer une clé API",
+ create: "Nouvelle clé API",
+ create_title: "Nouvelle clé API",
create_description: "Créez une nouvelle clé API pour un accès programmatique aux données de votre organisation.",
created_title: "Clé API créée",
created_description: "Votre clé API a été créée. Assurez-vous de la copier maintenant car vous ne pourrez plus la voir.",
@@ -990,5 +990,20 @@ export default {
no_results: "Aucun résultat trouvé",
status: "Statut"
}
+ },
+ vendors: {
+ title: "Fournisseurs",
+ register: {
+ title: "Enregistrement des fournisseurs"
+ },
+ dashboard: {
+ title: "Aperçu des fournisseurs"
+ }
+ },
+ dashboard: {
+ risk_status: "Statut de risque",
+ risks_by_department: "Risques par département",
+ vendor_status: "Statut des fournisseurs",
+ vendors_by_category: "Fournisseurs par catégorie"
}
} as const;
diff --git a/apps/app/src/locales/no.ts b/apps/app/src/locales/no.ts
index e169a19ac5..5a4f1bf4a0 100644
--- a/apps/app/src/locales/no.ts
+++ b/apps/app/src/locales/no.ts
@@ -580,8 +580,8 @@ export default {
description: "Administrer API-nøkler for programmatisk tilgang til dataene i organisasjonen din.",
list_title: "API-nøkler",
list_description: "API-nøkler gir sikker tilgang til dataene i organisasjonen din via vårt API.",
- create: "Opprett API-nøkkel",
- create_title: "Opprett API-nøkkel",
+ create: "Ny API-nøkkel",
+ create_title: "Ny API-nøkkel",
create_description: "Opprett en ny API-nøkkel for programmatisk tilgang til dataene i organisasjonen din.",
created_title: "API-nøkkel opprettet",
created_description: "API-nøkkelen din har blitt opprettet. Sørg for å kopiere den nå, da du ikke vil kunne se den igjen.",
@@ -990,5 +990,20 @@ export default {
no_results: "Ingen resultater funnet",
status: "Status"
}
+ },
+ vendors: {
+ title: "Leverandører",
+ register: {
+ title: "Leverandørregister"
+ },
+ dashboard: {
+ title: "Oversikt over leverandører"
+ }
+ },
+ dashboard: {
+ risk_status: "Risiko Status",
+ risks_by_department: "Risikoer etter avdeling",
+ vendor_status: "Leverandørstatus",
+ vendors_by_category: "Leverandører etter kategori"
}
} as const;
diff --git a/apps/app/src/locales/pt.ts b/apps/app/src/locales/pt.ts
index bff6d09a9c..2786d9bf64 100644
--- a/apps/app/src/locales/pt.ts
+++ b/apps/app/src/locales/pt.ts
@@ -580,8 +580,8 @@ export default {
description: "Gerencie chaves da API para acesso programático aos dados da sua organização.",
list_title: "Chaves da API",
list_description: "As chaves da API permitem acesso seguro aos dados da sua organização através da nossa API.",
- create: "Criar Chave da API",
- create_title: "Criar Chave da API",
+ create: "Nova Chave da API",
+ create_title: "Nova Chave da API",
create_description: "Crie uma nova chave da API para acesso programático aos dados da sua organização.",
created_title: "Chave da API Criada",
created_description: "Sua chave da API foi criada. Certifique-se de copiá-la agora, pois você não poderá vê-la novamente.",
@@ -961,5 +961,20 @@ export default {
no_results: "Nenhum resultado encontrado",
status: "Status"
}
+ },
+ vendors: {
+ title: "Fornecedores",
+ register: {
+ title: "Registro de Fornecedor"
+ },
+ dashboard: {
+ title: "Visão Geral do Fornecedor"
+ }
+ },
+ dashboard: {
+ risk_status: "Status de Risco",
+ risks_by_department: "Riscos por Departamento",
+ vendor_status: "Status do Fornecedor",
+ vendors_by_category: "Fornecedores por Categoria"
}
} as const;
diff --git a/packages/db/prisma/migrations/20250306175455_vendor_management/migration.sql b/packages/db/prisma/migrations/20250306175455_vendor_management/migration.sql
new file mode 100644
index 0000000000..225b8432df
--- /dev/null
+++ b/packages/db/prisma/migrations/20250306175455_vendor_management/migration.sql
@@ -0,0 +1,168 @@
+-- CreateTable
+CREATE TABLE "Vendor" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "description" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "organizationId" TEXT NOT NULL,
+
+ CONSTRAINT "Vendor_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "VendorContact" (
+ "id" TEXT NOT NULL,
+ "vendorId" TEXT NOT NULL,
+ "organizationId" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "email" TEXT NOT NULL,
+ "phone" TEXT NOT NULL,
+
+ CONSTRAINT "VendorContact_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "VendorComment" (
+ "id" TEXT NOT NULL,
+ "vendorId" TEXT NOT NULL,
+ "organizationId" TEXT NOT NULL,
+ "content" TEXT NOT NULL,
+
+ CONSTRAINT "VendorComment_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "VendorAttachment" (
+ "id" TEXT NOT NULL,
+ "vendorId" TEXT NOT NULL,
+ "organizationId" TEXT NOT NULL,
+
+ CONSTRAINT "VendorAttachment_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "VendorTask" (
+ "id" TEXT NOT NULL,
+ "vendorId" TEXT NOT NULL,
+ "organizationId" TEXT NOT NULL,
+
+ CONSTRAINT "VendorTask_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "VendorTaskAttachment" (
+ "id" TEXT NOT NULL,
+ "vendorId" TEXT NOT NULL,
+ "organizationId" TEXT NOT NULL,
+
+ CONSTRAINT "VendorTaskAttachment_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "VendorTaskComment" (
+ "id" TEXT NOT NULL,
+ "vendorId" TEXT NOT NULL,
+ "organizationId" TEXT NOT NULL,
+
+ CONSTRAINT "VendorTaskComment_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "VendorTaskAssignment" (
+ "id" TEXT NOT NULL,
+ "vendorId" TEXT NOT NULL,
+ "organizationId" TEXT NOT NULL,
+
+ CONSTRAINT "VendorTaskAssignment_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "VendorContact_organizationId_idx" ON "VendorContact"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "VendorContact_vendorId_idx" ON "VendorContact"("vendorId");
+
+-- CreateIndex
+CREATE INDEX "VendorComment_organizationId_idx" ON "VendorComment"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "VendorComment_vendorId_idx" ON "VendorComment"("vendorId");
+
+-- CreateIndex
+CREATE INDEX "VendorAttachment_organizationId_idx" ON "VendorAttachment"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "VendorAttachment_vendorId_idx" ON "VendorAttachment"("vendorId");
+
+-- CreateIndex
+CREATE INDEX "VendorTask_organizationId_idx" ON "VendorTask"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "VendorTask_vendorId_idx" ON "VendorTask"("vendorId");
+
+-- CreateIndex
+CREATE INDEX "VendorTaskAttachment_organizationId_idx" ON "VendorTaskAttachment"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "VendorTaskAttachment_vendorId_idx" ON "VendorTaskAttachment"("vendorId");
+
+-- CreateIndex
+CREATE INDEX "VendorTaskComment_organizationId_idx" ON "VendorTaskComment"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "VendorTaskComment_vendorId_idx" ON "VendorTaskComment"("vendorId");
+
+-- CreateIndex
+CREATE INDEX "VendorTaskAssignment_organizationId_idx" ON "VendorTaskAssignment"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "VendorTaskAssignment_vendorId_idx" ON "VendorTaskAssignment"("vendorId");
+
+-- CreateIndex
+CREATE INDEX "Organization_id_idx" ON "Organization"("id");
+
+-- AddForeignKey
+ALTER TABLE "Vendor" ADD CONSTRAINT "Vendor_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "VendorContact" ADD CONSTRAINT "VendorContact_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "VendorContact" ADD CONSTRAINT "VendorContact_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "VendorComment" ADD CONSTRAINT "VendorComment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "VendorComment" ADD CONSTRAINT "VendorComment_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "VendorAttachment" ADD CONSTRAINT "VendorAttachment_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "VendorAttachment" ADD CONSTRAINT "VendorAttachment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "VendorTask" ADD CONSTRAINT "VendorTask_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "VendorTask" ADD CONSTRAINT "VendorTask_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "VendorTaskAttachment" ADD CONSTRAINT "VendorTaskAttachment_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "VendorTaskAttachment" ADD CONSTRAINT "VendorTaskAttachment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "VendorTaskComment" ADD CONSTRAINT "VendorTaskComment_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "VendorTaskComment" ADD CONSTRAINT "VendorTaskComment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "VendorTaskAssignment" ADD CONSTRAINT "VendorTaskAssignment_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "VendorTaskAssignment" ADD CONSTRAINT "VendorTaskAssignment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/packages/db/prisma/migrations/20250306181056_vendors/migration.sql b/packages/db/prisma/migrations/20250306181056_vendors/migration.sql
new file mode 100644
index 0000000000..de9f1fca14
--- /dev/null
+++ b/packages/db/prisma/migrations/20250306181056_vendors/migration.sql
@@ -0,0 +1,20 @@
+-- AlterTable
+ALTER TABLE "Vendor" ADD COLUMN "category" "VendorCategory" NOT NULL DEFAULT 'other',
+ADD COLUMN "inherentRisk" "VendorInherentRisk" NOT NULL DEFAULT 'unknown',
+ADD COLUMN "residualRisk" "VendorResidualRisk" NOT NULL DEFAULT 'unknown',
+ADD COLUMN "status" "VendorStatus" NOT NULL DEFAULT 'not_assessed';
+
+-- CreateIndex
+CREATE INDEX "Vendor_organizationId_idx" ON "Vendor"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "Vendor_category_idx" ON "Vendor"("category");
+
+-- CreateIndex
+CREATE INDEX "Vendor_status_idx" ON "Vendor"("status");
+
+-- CreateIndex
+CREATE INDEX "Vendor_inherentRisk_idx" ON "Vendor"("inherentRisk");
+
+-- CreateIndex
+CREATE INDEX "Vendor_residualRisk_idx" ON "Vendor"("residualRisk");
diff --git a/packages/db/prisma/schema/schema.prisma b/packages/db/prisma/schema/schema.prisma
index 4bb6f4fef2..898e0f7e96 100644
--- a/packages/db/prisma/schema/schema.prisma
+++ b/packages/db/prisma/schema/schema.prisma
@@ -103,8 +103,17 @@ model Organization {
PortalUser PortalUser[]
apiKeys OrganizationApiKey[]
OrganizationIntegrationResults OrganizationIntegrationResults[]
+ vendors Vendor[]
+ VendorContact VendorContact[]
+ VendorComment VendorComment[]
+ VendorAttachment VendorAttachment[]
+ VendorTask VendorTask[]
+ VendorTaskAttachment VendorTaskAttachment[]
+ VendorTaskComment VendorTaskComment[]
+ VendorTaskAssignment VendorTaskAssignment[]
@@index([stripeCustomerId])
+ @@index([id])
}
model VerificationToken {
@@ -275,37 +284,6 @@ enum RiskAttachmentType {
url
}
-enum VendorCategory {
- cloud
- infrastructure
- software_as_a_service
- finance
- marketing
- sales
- hr
- other
-}
-
-enum VendorStatus {
- not_assessed
- in_progress
- assessed
-}
-
-enum VendorInherentRisk {
- low
- medium
- high
- unknown
-}
-
-enum VendorResidualRisk {
- low
- medium
- high
- unknown
-}
-
enum MembershipRole {
owner
admin
diff --git a/packages/db/prisma/schema/vendor.prisma b/packages/db/prisma/schema/vendor.prisma
index 8b13789179..69fcaf8c73 100644
--- a/packages/db/prisma/schema/vendor.prisma
+++ b/packages/db/prisma/schema/vendor.prisma
@@ -1 +1,138 @@
+model Vendor {
+ id String @id @default(cuid())
+ name String
+ description String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ organizationId String
+ organization Organization @relation(fields: [organizationId], references: [id])
+ contacts VendorContact[]
+ comments VendorComment[]
+ attachments VendorAttachment[]
+ tasks VendorTask[]
+ taskAttachments VendorTaskAttachment[]
+ taskComments VendorTaskComment[]
+ taskAssignments VendorTaskAssignment[]
+ category VendorCategory @default(other)
+ status VendorStatus @default(not_assessed)
+ inherentRisk VendorInherentRisk @default(unknown)
+ residualRisk VendorResidualRisk @default(unknown)
+ @@index([organizationId])
+ @@index([category])
+ @@index([status])
+ @@index([inherentRisk])
+ @@index([residualRisk])
+}
+
+model VendorContact {
+ id String @id @default(cuid())
+ vendorId String
+ organizationId String
+ name String
+ email String
+ phone String
+ organization Organization @relation(fields: [organizationId], references: [id])
+ Vendor Vendor @relation(fields: [vendorId], references: [id])
+
+ @@index([organizationId])
+ @@index([vendorId])
+}
+
+model VendorComment {
+ id String @id @default(cuid())
+ vendorId String
+ organizationId String
+ content String
+ organization Organization @relation(fields: [organizationId], references: [id])
+ Vendor Vendor @relation(fields: [vendorId], references: [id])
+
+ @@index([organizationId])
+ @@index([vendorId])
+}
+
+model VendorAttachment {
+ id String @id @default(cuid())
+ vendorId String
+ organizationId String
+ Vendor Vendor? @relation(fields: [vendorId], references: [id])
+ Organization Organization @relation(fields: [organizationId], references: [id])
+
+ @@index([organizationId])
+ @@index([vendorId])
+}
+
+model VendorTask {
+ id String @id @default(cuid())
+ vendorId String
+ organizationId String
+ Vendor Vendor? @relation(fields: [vendorId], references: [id])
+ Organization Organization @relation(fields: [organizationId], references: [id])
+
+ @@index([organizationId])
+ @@index([vendorId])
+}
+
+model VendorTaskAttachment {
+ id String @id @default(cuid())
+ vendorId String
+ organizationId String
+ Vendor Vendor? @relation(fields: [vendorId], references: [id])
+ Organization Organization @relation(fields: [organizationId], references: [id])
+
+ @@index([organizationId])
+ @@index([vendorId])
+}
+
+model VendorTaskComment {
+ id String @id @default(cuid())
+ vendorId String
+ organizationId String
+ Vendor Vendor? @relation(fields: [vendorId], references: [id])
+ Organization Organization @relation(fields: [organizationId], references: [id])
+
+ @@index([organizationId])
+ @@index([vendorId])
+}
+
+model VendorTaskAssignment {
+ id String @id @default(cuid())
+ vendorId String
+ organizationId String
+ Vendor Vendor? @relation(fields: [vendorId], references: [id])
+ Organization Organization @relation(fields: [organizationId], references: [id])
+
+ @@index([organizationId])
+ @@index([vendorId])
+}
+
+enum VendorCategory {
+ cloud
+ infrastructure
+ software_as_a_service
+ finance
+ marketing
+ sales
+ hr
+ other
+}
+
+enum VendorStatus {
+ not_assessed
+ in_progress
+ assessed
+}
+
+enum VendorInherentRisk {
+ low
+ medium
+ high
+ unknown
+}
+
+enum VendorResidualRisk {
+ low
+ medium
+ high
+ unknown
+}
diff --git a/packages/integrations/src/aws/config.ts b/packages/integrations/src/aws/config.ts
index c2dee8c59e..b3d153cff2 100644
--- a/packages/integrations/src/aws/config.ts
+++ b/packages/integrations/src/aws/config.ts
@@ -2,41 +2,40 @@ import image from "./assets/image.png";
import { Logo } from "./assets/logo";
export default {
- name: "AWS",
- id: "aws",
- active: true,
- logo: Logo,
- short_description:
- "Connect your AWS account to Comp AI to automate evidence collection for cloud resources",
- description:
- "Integrating with AWS allows you to automate evidence collection. This compliance analysis tool enables organizations to more quickly articulate their compliance posture and also generate supporting evidence artifacts",
- images: [image],
- settings: [
- {
- id: "access_key_id",
- label: "AWS access key ID",
- description: "The API access key ID for your AWS account",
- type: "text",
- required: true,
- value: "",
- },
- {
- id: "secret_access_key",
- label: "AWS secret access key",
- description: "The API secret access key for your AWS account",
- type: "text",
- required: true,
- value: "",
- },
- {
- id: "session_token",
- label: "AWS session token",
- description: "The API session token AWS account",
- type: "text",
- required: true,
- value: "",
- },
- ],
- config: {},
- category: "Cloud"
+ name: "AWS",
+ id: "aws",
+ active: true,
+ logo: Logo,
+ short_description:
+ "Connect your AWS account to Comp AI to automate evidence collection for cloud resources",
+ description:
+ "Integrating with AWS allows you to automate evidence collection. This compliance analysis tool enables organizations to more quickly articulate their compliance posture and also generate supporting evidence artifacts",
+ images: [image],
+ settings: [
+ {
+ id: "access_key_id",
+ label: "AWS access key ID",
+ description: "The API access key ID for your AWS account",
+ type: "text",
+ required: true,
+ value: "",
+ },
+ {
+ id: "secret_access_key",
+ label: "AWS secret access key",
+ description: "The API secret access key for your AWS account",
+ type: "text",
+ required: true,
+ value: "",
+ },
+ {
+ id: "session_token",
+ label: "AWS session token",
+ description: "The API session token AWS account",
+ type: "text",
+ required: true,
+ value: "",
+ },
+ ],
+ category: "Cloud",
};