diff --git a/apps/app/languine.lock b/apps/app/languine.lock index 71a13f3fcd..5690fb78c1 100644 --- a/apps/app/languine.lock +++ b/apps/app/languine.lock @@ -468,6 +468,8 @@ files: editor.ai_selector.replace: 05fdc089c4c320832588a69475ff5e79 editor.ai_selector.insert: 3005820694a4944d91c4c4e8b8a016d3 editor.ai_selector.discard: d94b42030b9785fd754d5c1754961269 + evidence.title: 63b1eb1a39194d82271ec937fba19714 + evidence.description: c8fcd9bc15a38dcd7f6d0cbdeb65f33c src/locales/es.ts: language.title: c88b33a3250b4e28f34e1ececba69206 language.description: b74d060770289b75ba34d38ea1e05251 diff --git a/apps/app/package.json b/apps/app/package.json index 09d8446105..e97e3e3f80 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -12,6 +12,7 @@ "dependencies": { "@ai-sdk/openai": "^1.1.12", "@ai-sdk/provider": "^1.0.7", + "@aws-sdk/s3-request-presigner": "^3.750.0", "@browserbasehq/sdk": "^2.3.0", "@bubba/notifications": "workspace:*", "@date-fns/tz": "^1.2.0", diff --git a/apps/app/src/actions/framework/select-frameworks-action.ts b/apps/app/src/actions/framework/select-frameworks-action.ts index 4b51886b63..fdb076e575 100644 --- a/apps/app/src/actions/framework/select-frameworks-action.ts +++ b/apps/app/src/actions/framework/select-frameworks-action.ts @@ -1,6 +1,6 @@ "use server"; -import { db, type Policy, type User } from "@bubba/db"; +import { db, RequirementType, type Policy, type User } from "@bubba/db"; import { authActionClient } from "../safe-action"; import { z } from "zod"; import type { ActionData } from "../types"; @@ -30,25 +30,28 @@ export const selectFrameworksAction = authActionClient } try { - // First create categories + // Create categories await createOrganizationCategories(user as User, frameworkIds); - // Then create frameworks and controls + // Create frameworks and controls const organizationFrameworks = await Promise.all( frameworkIds.map((frameworkId) => createOrganizationFramework(user as User, frameworkId) ) ); - // Finally create policies + // Create policies await createOrganizationPolicy(user as User, frameworkIds); - // Finally create control requirements + // Create control requirements await createOrganizationControlRequirements( user as User, organizationFrameworks.map((framework) => framework.id) ); + // Create organization evidence + await createOrganizationEvidence(user as User); + return { data: true, }; @@ -244,3 +247,26 @@ const createOrganizationControlRequirements = async ( return controlRequirements; }; + +const createOrganizationEvidence = async (user: User) => { + if (!user.organizationId) { + throw new Error("Not authorized - no organization found"); + } + + const evidence = await db.controlRequirement.findMany({ + where: { + type: RequirementType.evidence, + }, + }); + + const organizationEvidence = await db.organizationEvidence.createMany({ + data: evidence.map((evidence) => ({ + organizationId: user.organizationId!, + evidenceId: evidence.id, + name: evidence.name, + description: evidence.description, + })), + }); + + return organizationEvidence; +}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/layout.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/layout.tsx index 328828257b..b4017ae2e6 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/layout.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/layout.tsx @@ -9,7 +9,7 @@ export default async function Layout({ const t = await getI18n(); return ( -
+
{children}
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 3ceadba27d..d71abd9a25 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 @@ -8,11 +8,11 @@ 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 { ArrowLeft } from "lucide-react"; import { useRouter } from "next/navigation"; import { useOrganizationControlRequirements } from "../hooks/useOrganizationControlRequirements"; -import Link from "next/link"; import { useOrganizationControlProgress } from "../hooks/useOrganizationControlProgress"; +import { DataTable } from "./data-table/data-table"; interface SingleControlProps { controlId: string; @@ -66,53 +66,7 @@ export const SingleControl = ({ controlId }: SingleControlProps) => {

Requirements

- - - - - - - - - - {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 ( - - - - - - ); - })} - -
TypeDescriptionStatus
- - {requirement.type} - - - - {requirement.description} - - - {isCompleted ? ( - - ) : ( - - )} -
+ {requirements && }
); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Components/data-table/columns.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Components/data-table/columns.tsx new file mode 100644 index 0000000000..1b48950d9e --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Components/data-table/columns.tsx @@ -0,0 +1,56 @@ +"use client"; + +import type { ColumnDef } from "@tanstack/react-table"; +import { CheckCircle2, XCircle } from "lucide-react"; + +export interface RequirementType { + id: string; + type: string; + description: string | null; + organizationPolicy?: { + policy?: { + id: string; + name: string; + }; + status?: string; + } | null; +} + +export const columns: ColumnDef[] = [ + { + id: "type", + accessorKey: "type", + header: "Type", + cell: ({ row }) => row.original.type, + }, + { + id: "description", + accessorKey: "description", + header: "Description", + cell: ({ row }) => ( +
{row.original.description}
+ ), + }, + { + id: "status", + accessorKey: "organizationPolicy.status", + header: "Status", + cell: ({ row }) => { + const requirement = row.original; + const isCompleted = + requirement.type === "policy" + ? requirement.organizationPolicy?.status === "published" + : false; + + return ( +
+ {isCompleted ? ( + + ) : ( + + )} +
+ ); + }, + }, +]; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Components/data-table/data-table-header.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Components/data-table/data-table-header.tsx new file mode 100644 index 0000000000..6c9291a35a --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Components/data-table/data-table-header.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { TableHead, TableHeader, TableRow } from "@bubba/ui/table"; + +type Props = { + table?: { + getIsAllPageRowsSelected: () => boolean; + getIsSomePageRowsSelected: () => boolean; + getAllLeafColumns: () => { + id: string; + getIsVisible: () => boolean; + }[]; + toggleAllPageRowsSelected: (value: boolean) => void; + }; + loading?: boolean; +}; + +export function DataTableHeader({ table, loading }: Props) { + const isVisible = (id: string) => + loading || + table + ?.getAllLeafColumns() + .find((col) => col.id === id) + ?.getIsVisible(); + + return ( + + + {isVisible("type") && ( + + Type + + )} + {isVisible("description") && ( + + Description + + )} + {isVisible("status") && ( + + Status + + )} + + + ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Components/data-table/data-table.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Components/data-table/data-table.tsx new file mode 100644 index 0000000000..72d55841bc --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Components/data-table/data-table.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; + +import { Table, TableBody, TableCell, TableRow } from "@bubba/ui/table"; +import { type RequirementType, columns } from "./columns"; +import { DataTableHeader } from "./data-table-header"; +import { useRouter } from "next/navigation"; + +interface DataTableProps { + data: RequirementType[]; +} + +export function DataTable({ data }: DataTableProps) { + const router = useRouter(); + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + const onRowClick = (requirement: RequirementType) => { + if ( + requirement.type === "policy" && + requirement.organizationPolicy?.policy?.id + ) { + router.push(`/policies/${requirement.organizationPolicy.policy.id}`); + } + }; + + return ( +
+
+ + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + onRowClick(row.original)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No requirements found. + + + )} + +
+
+
+ ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/page.tsx deleted file mode 100644 index 0f3ced7431..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function ControlsPage() { - return
Controls
; -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getAllOrganizationControlRequirements.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getAllOrganizationControlRequirements.ts new file mode 100644 index 0000000000..999086fdaa --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getAllOrganizationControlRequirements.ts @@ -0,0 +1,69 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import { db } from "@bubba/db"; +import { z } from "zod"; + +export const getAllOrganizationControlRequirements = authActionClient + .schema( + z.object({ + search: z.string().optional().nullable(), + }) + ) + .metadata({ + name: "getAllOrganizationControlRequirements", + track: { + event: "get-all-organization-control-requirements", + channel: "server", + }, + }) + .action(async ({ ctx, parsedInput }) => { + const { user } = ctx; + const { search } = parsedInput; + + if (!user.organizationId) { + return { + error: "Not authorized - no organization found", + }; + } + + try { + const organizationControlRequirements = + await db.organizationControlRequirement.findMany({ + where: { + organizationControl: { + organizationId: user.organizationId, + }, + }, + include: { + organizationPolicy: { + include: { + policy: true, + }, + }, + organizationControl: { + include: { + control: true, + }, + }, + }, + }); + + if (!organizationControlRequirements) { + return { + error: "Organization control requirements not found", + }; + } + + return { + data: { + organizationControlRequirements, + }, + }; + } catch (error) { + console.error("Error fetching organization control requirements:", error); + return { + error: "Failed to fetch organization control requirements", + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getEvidence.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getEvidence.ts new file mode 100644 index 0000000000..fd1ef21d70 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getEvidence.ts @@ -0,0 +1,56 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import { db } from "@bubba/db"; +import { z } from "zod"; + +export const getEvidence = authActionClient + .schema( + z.object({ + evidenceId: z.string(), + }) + ) + .metadata({ + name: "getEvidence", + track: { + event: "get-evidence", + channel: "server", + }, + }) + .action(async ({ ctx, parsedInput }) => { + const { user } = ctx; + const { evidenceId } = parsedInput; + + if (!user.organizationId) { + return { + success: false, + error: "Not authorized - no organization found", + }; + } + + try { + const evidence = await db.organizationEvidence.findFirst({ + where: { + id: evidenceId, + }, + }); + + if (!evidence) { + return { + success: false, + error: "Evidence not found", + }; + } + + return { + success: true, + data: evidence, + }; + } catch (error) { + console.error("Error fetching evidence:", error); + return { + success: false, + error: "Failed to fetch evidence", + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getOrganizationEvidence.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getOrganizationEvidence.ts new file mode 100644 index 0000000000..68d681ec47 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getOrganizationEvidence.ts @@ -0,0 +1,60 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import { db } from "@bubba/db"; +import { z } from "zod"; + +export const getOrganizationEvidenceById = authActionClient + .schema( + z.object({ + id: z.string(), + }) + ) + .metadata({ + name: "getOrganizationEvidenceById", + track: { + event: "get-organization-evidence-by-id", + channel: "server", + }, + }) + .action(async ({ ctx, parsedInput }) => { + const { user } = ctx; + const { id } = parsedInput; + + if (!user.organizationId) { + return { + success: false, + error: "Not authorized - no organization found", + }; + } + + try { + const evidence = await db.organizationEvidence.findFirst({ + where: { + id, + organizationId: user.organizationId, + }, + include: { + evidence: true, + }, + }); + + if (!evidence) { + return { + success: false, + error: "Evidence not found", + }; + } + + return { + success: true, + data: evidence, + }; + } catch (error) { + console.error("Error fetching evidence:", error); + return { + success: false, + error: "Failed to fetch evidence", + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getOrganizationEvidenceTasks.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getOrganizationEvidenceTasks.ts new file mode 100644 index 0000000000..24561bc084 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getOrganizationEvidenceTasks.ts @@ -0,0 +1,78 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import { db } from "@bubba/db"; +import { z } from "zod"; + +export const getOrganizationEvidenceTasks = authActionClient + .schema( + z.object({ + search: z.string().optional().nullable(), + }) + ) + .metadata({ + name: "getOrganizationEvidenceTasks", + track: { + event: "get-organization-evidence-tasks", + channel: "server", + }, + }) + .action(async ({ ctx, parsedInput }) => { + const { user } = ctx; + const { search } = parsedInput; + + if (!user.organizationId) { + return { + success: false, + error: "Not authorized - no organization found", + }; + } + + try { + const evidenceTasks = await db.organizationEvidence.findMany({ + where: { + organizationId: user.organizationId, + ...(search + ? { + OR: [ + { + name: { + contains: search, + mode: "insensitive", + }, + }, + { + description: { + contains: search, + mode: "insensitive", + }, + }, + { + evidence: { + name: { + contains: search, + mode: "insensitive", + }, + }, + }, + ], + } + : {}), + }, + include: { + evidence: true, + }, + }); + + return { + success: true, + data: evidenceTasks, + }; + } catch (error) { + console.error("Error fetching evidence tasks:", error); + return { + success: false, + error: "Failed to fetch evidence tasks", + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceList.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceList.tsx new file mode 100644 index 0000000000..c50383fde3 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceList.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useOrganizationEvidenceTasks } from "../hooks/useEvidenceTasks"; +import { DataTable } from "./data-table/data-table"; +import { Input } from "@bubba/ui/input"; +import { useQueryState } from "nuqs"; +import { useCallback } from "react"; +import { debounce } from "lodash"; +import { useI18n } from "@/locales/client"; +import { SkeletonTable } from "./SkeletonTable"; + +export const EvidenceList = () => { + const t = useI18n(); + const [search, setSearch] = useQueryState("search"); + const { + data: evidenceTasks, + isLoading, + error, + } = useOrganizationEvidenceTasks({ search }); + + const handleSearch = useCallback( + debounce((value: string) => { + setSearch(value || null); + }, 300), + [setSearch] + ); + + if (error) return
Error loading evidence tasks
; + if (!evidenceTasks && !isLoading) return null; + + return ( +
+
+

Review and upload evidence

+
+ handleSearch(e.target.value)} + defaultValue={search || ""} + className="max-w-sm" + /> +
+
+ {isLoading ? : } +
+ ); +}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/SkeletonTable.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/SkeletonTable.tsx new file mode 100644 index 0000000000..1961ba46b5 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/SkeletonTable.tsx @@ -0,0 +1,52 @@ +import { Skeleton } from "@bubba/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@bubba/ui/table"; + +const SKELETON_ROWS = [ + "skeleton-1", + "skeleton-2", + "skeleton-3", + "skeleton-4", + "skeleton-5", +] as const; + +export const SkeletonTable = () => { + return ( + + + + + + + + + + + + + + + + {SKELETON_ROWS.map((key) => ( + + + + + + + + + + + + ))} + +
+ ); +}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/columns.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/columns.tsx new file mode 100644 index 0000000000..3dc313822a --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/columns.tsx @@ -0,0 +1,96 @@ +"use client"; + +import type { ColumnDef } from "@tanstack/react-table"; +import { CheckCircle2, XCircle } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@bubba/ui/tooltip"; +import type { EvidenceTaskRow } from "./types"; + +export const columns: ColumnDef[] = [ + { + id: "name", + accessorKey: "name", + header: "Name", + size: 200, + cell: ({ row }) => ( + + + +
{row.original.name}
+
+ {row.original.name} +
+
+ ), + }, + { + id: "description", + accessorKey: "description", + header: "Description", + size: 300, + cell: ({ row }) => { + const description = row.original.description; + if (!description) return null; + + return ( + + + +
{description}
+
+ {description} +
+
+ ); + }, + }, + { + id: "evidence", + accessorKey: "evidence.name", + header: "Evidence Type", + size: 150, + cell: ({ row }) => ( + + + +
+ {row.original.evidence.name} +
+
+ {row.original.evidence.name} +
+
+ ), + }, + { + id: "status", + accessorKey: "published", + header: "Status", + size: 100, + cell: ({ row }) => { + const isPublished = row.original.published; + const label = isPublished ? "Published" : "Draft"; + + return ( +
+ {isPublished ? ( + + ) : ( + + )} + + {label} + +
+ ); + }, + }, +]; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/data-table-header.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/data-table-header.tsx new file mode 100644 index 0000000000..1be2eda65e --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/data-table-header.tsx @@ -0,0 +1,19 @@ +"use client"; + +import type { Table } from "@tanstack/react-table"; +import { TableHead, TableHeader, TableRow } from "@bubba/ui/table"; +import type { EvidenceTaskRow } from "./types"; + +export function DataTableHeader({ table }: { table: Table }) { + return ( + + + {table.getAllColumns().map((column) => ( + + {column.columnDef.header as string} + + ))} + + + ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/data-table.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/data-table.tsx new file mode 100644 index 0000000000..57fc3a1316 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/data-table.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { Table, TableBody, TableCell, TableRow } from "@bubba/ui/table"; +import { columns } from "./columns"; +import { DataTableHeader } from "./data-table-header"; +import type { EvidenceTaskRow } from "./types"; +import { useRouter } from "next/navigation"; + +export function DataTable({ data }: { data: EvidenceTaskRow[] }) { + const router = useRouter(); + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( +
+
+ + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + router.push(`/evidence/${row.original.id}`)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No evidence tasks found. + + + )} + +
+
+
+ ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/types.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/types.ts new file mode 100644 index 0000000000..eef8d49e8b --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/types.ts @@ -0,0 +1,7 @@ +import type { OrganizationEvidence } from "@bubba/db"; + +export type EvidenceTaskRow = OrganizationEvidence & { + evidence: { + name: string; + }; +}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/deleteEvidenceFile.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/deleteEvidenceFile.ts new file mode 100644 index 0000000000..845aa1a451 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/deleteEvidenceFile.ts @@ -0,0 +1,82 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import { db } from "@bubba/db"; +import { z } from "zod"; + +const schema = z.object({ + evidenceId: z.string(), + fileUrl: z.string(), +}); + +interface SuccessResponse { + success: true; +} + +interface ErrorResponse { + success: false; + error: string; +} + +type ActionResponse = SuccessResponse | ErrorResponse; + +export const deleteEvidenceFile = authActionClient + .schema(schema) + .metadata({ + name: "deleteEvidenceFile", + track: { + event: "delete-evidence-file", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }): Promise => { + const { user } = ctx; + const { evidenceId, fileUrl } = parsedInput; + + if (!user.organizationId) { + return { + success: false, + error: "Not authorized - no organization found", + }; + } + + try { + // Check if evidence exists and belongs to organization + const evidence = await db.organizationEvidence.findFirst({ + where: { + id: evidenceId, + organizationId: user.organizationId, + fileUrls: { + has: fileUrl, + }, + }, + }); + + if (!evidence) { + return { + success: false, + error: "Evidence or file not found", + }; + } + + // Remove the file URL from the evidence record + await db.organizationEvidence.update({ + where: { id: evidenceId }, + data: { + fileUrls: { + set: evidence.fileUrls.filter((url) => url !== fileUrl), + }, + }, + }); + + return { + success: true, + }; + } catch (error) { + console.error("Error deleting file:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Failed to delete file", + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/getEvidenceFileUrl.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/getEvidenceFileUrl.ts new file mode 100644 index 0000000000..03c313e899 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/getEvidenceFileUrl.ts @@ -0,0 +1,112 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import { db } from "@bubba/db"; +import { z } from "zod"; +import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; + +if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) { + throw new Error("AWS credentials are not set"); +} + +if (!process.env.AWS_BUCKET_NAME) { + throw new Error("AWS bucket name is not set"); +} + +if (!process.env.AWS_REGION) { + throw new Error("AWS region is not set"); +} + +const s3Client = new S3Client({ + region: process.env.AWS_REGION, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + }, +}); + +const schema = z.object({ + evidenceId: z.string(), + fileUrl: z.string(), +}); + +function extractS3KeyFromUrl(url: string): string { + // Try to extract the key using the full URL pattern + const fullUrlMatch = url.match(/amazonaws\.com\/(.+)$/); + if (fullUrlMatch?.[1]) { + return decodeURIComponent(fullUrlMatch[1]); + } + + // If it's already just the key (not a full URL), use it directly + if (!url.includes("amazonaws.com")) { + return url; + } + + throw new Error("Invalid S3 URL format"); +} + +export const getEvidenceFileUrl = authActionClient + .schema(schema) + .metadata({ + name: "getEvidenceFileUrl", + track: { + event: "get-evidence-file-url", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { user } = ctx; + const { evidenceId, fileUrl } = parsedInput; + + if (!user.organizationId) { + throw new Error("Not authorized - no organization found"); + } + + try { + // Check if evidence exists and belongs to organization + const evidence = await db.organizationEvidence.findFirst({ + where: { + id: evidenceId, + organizationId: user.organizationId, + fileUrls: { + has: fileUrl, + }, + }, + }); + + if (!evidence) { + throw new Error("Evidence or file not found"); + } + + try { + const key = extractS3KeyFromUrl(fileUrl); + console.log("Extracted S3 key:", key); // Debug log + + const command = new GetObjectCommand({ + Bucket: process.env.AWS_BUCKET_NAME, + Key: key, + }); + + const signedUrl = await getSignedUrl(s3Client, command, { + expiresIn: 3600, // URL expires in 1 hour + }); + + if (!signedUrl) { + throw new Error("Failed to generate signed URL"); + } + + return { signedUrl }; + } catch (error) { + console.error("S3 Error:", error); + throw new Error( + `Failed to access file: ${error instanceof Error ? error.message : "unknown error"}` + ); + } + } catch (error) { + console.error("Server Error:", error); + throw error instanceof Error + ? error + : new Error("Failed to generate signed URL"); + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/updateEvidenceUrls.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/updateEvidenceUrls.ts new file mode 100644 index 0000000000..4c2221034e --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/updateEvidenceUrls.ts @@ -0,0 +1,68 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import { db } from "@bubba/db"; +import { z } from "zod"; + +export const updateEvidenceUrls = authActionClient + .schema( + z.object({ + evidenceId: z.string(), + urls: z.array(z.string().url()), + }) + ) + .metadata({ + name: "updateEvidenceUrls", + track: { + event: "update-evidence-urls", + channel: "server", + }, + }) + .action(async ({ ctx, parsedInput }) => { + const { user } = ctx; + const { evidenceId, urls } = parsedInput; + + if (!user.organizationId) { + return { + success: false, + error: "Not authorized - no organization found", + } as const; + } + + try { + const evidence = await db.organizationEvidence.findFirst({ + where: { + id: evidenceId, + organizationId: user.organizationId, + }, + }); + + if (!evidence) { + return { + success: false, + error: "Evidence not found", + } as const; + } + + const updatedEvidence = await db.organizationEvidence.update({ + where: { id: evidenceId }, + data: { + additionalUrls: urls, + }, + include: { + evidence: true, + }, + }); + + return { + success: true, + data: updatedEvidence, + } as const; + } catch (error) { + console.error("Error updating evidence URLs:", error); + return { + success: false, + error: "Failed to update evidence URLs", + } as const; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/uploadEvidenceFile.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/uploadEvidenceFile.ts new file mode 100644 index 0000000000..6b4c1bc773 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/uploadEvidenceFile.ts @@ -0,0 +1,104 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import { db } from "@bubba/db"; +import { z } from "zod"; +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; + +if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) { + throw new Error("AWS credentials are not set"); +} + +const s3Client = new S3Client({ + region: process.env.AWS_REGION!, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + }, +}); + +export const getUploadUrl = authActionClient + .schema( + z.object({ + evidenceId: z.string(), + fileName: z.string(), + fileType: z.string(), + }) + ) + .metadata({ + name: "getUploadUrl", + track: { + event: "get-upload-url", + channel: "server", + }, + }) + .action(async ({ ctx, parsedInput }) => { + const { user } = ctx; + const { evidenceId, fileName, fileType } = parsedInput; + + if (!user.organizationId) { + return { + success: false, + error: "Not authorized - no organization found", + } as const; + } + + try { + // Check if evidence exists and belongs to organization + const evidence = await db.organizationEvidence.findFirst({ + where: { + id: evidenceId, + organizationId: user.organizationId, + }, + }); + + if (!evidence) { + return { + success: false, + error: "Evidence not found", + } as const; + } + + // Generate a unique file key + const timestamp = Date.now(); + const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, "_"); + const key = `${user.organizationId}/${evidenceId}/${timestamp}-${sanitizedFileName}`; + + const command = new PutObjectCommand({ + Bucket: process.env.AWS_BUCKET_NAME!, + Key: key, + ContentType: fileType, + }); + + const uploadUrl = await getSignedUrl(s3Client, command, { + expiresIn: 3600, // URL expires in 1 hour + }); + + const fileUrl = `https://${process.env.AWS_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`; + + // Pre-register the file URL in the database + await db.organizationEvidence.update({ + where: { id: evidenceId }, + data: { + fileUrls: { + push: fileUrl, + }, + }, + }); + + return { + success: true, + data: { + uploadUrl, + fileUrl, + }, + } as const; + } catch (error) { + console.error("Error generating upload URL:", error); + return { + success: false, + error: "Failed to generate upload URL", + } as const; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/EvidenceDetails.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/EvidenceDetails.tsx new file mode 100644 index 0000000000..05c4c055ea --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/EvidenceDetails.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@bubba/ui/button"; +import { ArrowLeft, CheckCircle2, XCircle } from "lucide-react"; +import { Card, CardContent, CardHeader } from "@bubba/ui/card"; +import { Skeleton } from "@bubba/ui/skeleton"; +import { useI18n } from "@/locales/client"; +import { useOrganizationEvidence } from "../hooks/useOrganizationEvidence"; +import { FileSection } from "./FileSection"; +import { UrlSection } from "./UrlSection"; +import type { EvidenceDetailsProps } from "../types"; + +export function EvidenceDetails({ id }: EvidenceDetailsProps) { + const t = useI18n(); + const router = useRouter(); + const { data, isLoading, error, mutate } = useOrganizationEvidence({ id }); + + useEffect(() => { + if (error) { + router.push("/evidence"); + } + }, [error, router]); + + const handleMutate = async () => { + await mutate(); + }; + + if (isLoading) { + return ( +
+
+ + +
+ + + + + + + + + +
+ ); + } + + if (!data?.data) return null; + + const evidence = data.data; + + return ( +
+
+ +

{evidence.name}

+
+ + + +
{evidence.evidence.name}
+
+ {evidence.published ? ( + <> + + Published + + ) : ( + <> + + Draft + + )} +
+
+ + {evidence.description && ( +

{evidence.description}

+ )} + + + + +
+
+
+ ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/FileCard.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/FileCard.tsx new file mode 100644 index 0000000000..84aaada97e --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/FileCard.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { Button } from "@bubba/ui/button"; +import { Card, CardContent, CardFooter } from "@bubba/ui/card"; +import { Dialog, DialogContent, DialogTrigger } from "@bubba/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@bubba/ui/alert-dialog"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@bubba/ui/tooltip"; +import { ExternalLink, Loader2, Trash } from "lucide-react"; +import Image from "next/image"; +import { FileIcon } from "./FileIcon"; + +interface FilePreviewState { + url: string | null; + isLoading: boolean; +} + +interface FileCardProps { + url: string; + previewState: FilePreviewState; + isDialogOpen: boolean; + onOpenChange: (open: boolean) => void; + onPreviewClick: (url: string) => Promise; + onDelete: (url: string) => Promise; +} + +export function FileCard({ + url, + previewState, + isDialogOpen, + onOpenChange, + onPreviewClick, + onDelete, +}: FileCardProps) { + const fileName = url.split("/").pop() || url; + const isImage = /\.(jpg|jpeg|png|gif|webp)$/i.test(fileName); + const isPdf = /\.pdf$/i.test(fileName); + + return ( + + + + + + + + {previewState.url ? ( +
+ {isImage ? ( +
+ {fileName} +
+ ) : isPdf ? ( +