From 26edb5afcd805fccdbaf95ff2b55c26cb469cb75 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 3 Apr 2025 10:49:39 -0400 Subject: [PATCH 1/2] evidence --- .../[id]/actions/updateEvidenceDetails.ts | 76 +++++++ .../[id]/components/AssigneeSection.tsx | 200 ++++++++++-------- .../[id]/components/DepartmentSection.tsx | 114 +++++----- .../[id]/components/EvidenceDetails.tsx | 3 +- .../[id]/components/FrequencySection.tsx | 110 ++++------ .../[id]/components/ReviewSection.tsx | 136 ++++++++++-- .../[orgId]/evidence/[id]/page.tsx | 41 +++- .../[orgId]/evidence/[id]/types.ts | 4 + 8 files changed, 431 insertions(+), 253 deletions(-) create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/actions/updateEvidenceDetails.ts diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/actions/updateEvidenceDetails.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/actions/updateEvidenceDetails.ts new file mode 100644 index 0000000000..7cca661d3c --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/actions/updateEvidenceDetails.ts @@ -0,0 +1,76 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import { db } from "@bubba/db"; +import { Departments, Frequency } from "@bubba/db/types"; +import { z } from "zod"; +import type { ActionResponse } from "@/actions/types"; + +const schema = z.object({ + id: z.string(), + department: z.nativeEnum(Departments).nullable(), + frequency: z.nativeEnum(Frequency).nullable(), + assigneeId: z.string().nullable(), +}); + +export const updateEvidenceDetails = authActionClient + .schema(schema) + .metadata({ + name: "updateEvidenceDetails", + track: { + event: "update-evidence-details", + channel: "server", + }, + }) + .action(async ({ ctx, parsedInput }): Promise => { + const { session } = ctx; + const { id, department, frequency, assigneeId } = parsedInput; + + if (!session.activeOrganizationId) { + return { + success: false, + error: "Not authorized - no organization found", + }; + } + + try { + // Verify the evidence exists and belongs to the organization + const evidence = await db.evidence.findFirst({ + where: { + id, + organizationId: session.activeOrganizationId, + }, + }); + + if (!evidence) { + return { + success: false, + error: "Evidence not found", + }; + } + + // Update all evidence details in a single operation + const updatedEvidence = await db.evidence.update({ + where: { + id, + }, + data: { + department: department as Departments, + frequency, + assigneeId, + updatedAt: new Date(), + }, + }); + + return { + success: true, + data: updatedEvidence, + }; + } catch (error) { + console.error("Error updating evidence details:", error); + return { + success: false, + error: "Failed to update evidence details", + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/AssigneeSection.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/AssigneeSection.tsx index b2ac857217..7702976663 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/AssigneeSection.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/AssigneeSection.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useEffect } from "react"; +import { Member, User } from "@bubba/db/types"; +import { Avatar, AvatarFallback, AvatarImage } from "@bubba/ui/avatar"; import { Select, SelectContent, @@ -8,129 +9,144 @@ import { SelectTrigger, SelectValue, } from "@bubba/ui/select"; -import { Avatar, AvatarFallback, AvatarImage } from "@bubba/ui/avatar"; -import { useAction } from "next-safe-action/hooks"; -import { toast } from "sonner"; -import { assignEvidence } from "../actions/assignEvidence"; -import { useOrganizationAdmins } from "../../../hooks/useOrganizationAdmins"; -import type { Admin } from "../../../hooks/useOrganizationAdmins"; +import { useEffect, useState } from "react"; interface AssigneeSectionProps { evidenceId: string; currentAssigneeId: string | null | undefined; - onSuccess: () => Promise; + assignees: (Member & { + user: User; + })[]; + onAssigneeChange: (value: string | null) => void; + assigneeId: string | null; + disabled?: boolean; } export function AssigneeSection({ - evidenceId, currentAssigneeId, - onSuccess, + assignees, + onAssigneeChange, + assigneeId, + disabled = false, }: AssigneeSectionProps) { - const [assigneeId, setAssigneeId] = useState( - currentAssigneeId || null, - ); - const { data: admins, isLoading, error } = useOrganizationAdmins(); - const [selectedAdmin, setSelectedAdmin] = useState(null); - - const { execute: updateAssignee, isExecuting } = useAction(assignEvidence, { - onSuccess: async () => { - toast.success("Assignee updated successfully"); - await onSuccess(); - }, - onError: () => { - toast.error("Failed to update assignee"); - }, - }); + const [selectedAssignee, setSelectedAssignee] = useState< + (Member & { user: User }) | null + >(null); useEffect(() => { - setAssigneeId(currentAssigneeId || null); - }, [currentAssigneeId]); + if (assigneeId !== currentAssigneeId) { + onAssigneeChange(currentAssigneeId || null); + } + }, [currentAssigneeId, onAssigneeChange, assigneeId]); useEffect(() => { - if (admins && assigneeId) { - const admin = admins.find((a) => a.id === assigneeId); - setSelectedAdmin(admin || null); + if (assignees && assigneeId) { + const assignee = assignees.find((a) => a.id === assigneeId); + if (assignee) { + setSelectedAssignee({ + ...assignee, + user: assignee.user, + }); + } else { + setSelectedAssignee(null); + } } else { - setSelectedAdmin(null); + setSelectedAssignee(null); } - }, [admins, assigneeId]); + }, [assignees, assigneeId]); const handleAssigneeChange = (value: string) => { const newAssigneeId = value === "none" ? null : value; - setAssigneeId(newAssigneeId); + onAssigneeChange(newAssigneeId); - if (newAssigneeId && admins) { - const admin = admins.find((a) => a.id === newAssigneeId); - setSelectedAdmin(admin || null); + if (newAssigneeId && assignees) { + const assignee = assignees.find((a) => a.id === newAssigneeId); + if (assignee) { + setSelectedAssignee({ + ...assignee, + user: assignee.user, + }); + } else { + setSelectedAssignee(null); + } } else { - setSelectedAdmin(null); + setSelectedAssignee(null); } - - updateAssignee({ id: evidenceId, assigneeId: newAssigneeId }); }; - if (isLoading) { - return
; - } + // Function to safely prepare image URLs + const getImageUrl = (image: string | null) => { + if (!image) return ""; - if (error || !admins) { - return

Failed to load

; - } + // If image is a relative URL, ensure it's properly formed + if (image.startsWith("/")) { + // This handles the case where the URL might need to be prefixed with the base URL + return image; + } + + return image; + }; return ( -
- -
+ ))} + + ); } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/DepartmentSection.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/DepartmentSection.tsx index f6cc62c9d8..e0d9d57319 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/DepartmentSection.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/DepartmentSection.tsx @@ -2,77 +2,61 @@ import { useState, useEffect } from "react"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "@bubba/ui/select"; -import { useAction } from "next-safe-action/hooks"; -import { toast } from "sonner"; import { Departments } from "@bubba/db/types"; -import { updateEvidenceDepartment } from "../actions/updateEvidenceDepartment"; interface DepartmentSectionProps { - evidenceId: string; - currentDepartment: string | null; - onSuccess: () => Promise; + evidenceId: string; + currentDepartment: string | null; + onDepartmentChange: (value: string | null) => void; + department: string | null; + disabled?: boolean; } export function DepartmentSection({ - evidenceId, - currentDepartment, - onSuccess, + currentDepartment, + onDepartmentChange, + department, + disabled = false, }: DepartmentSectionProps) { - const [department, setDepartment] = useState( - currentDepartment || null - ); - - const { execute: updateDepartment, isExecuting } = useAction( - updateEvidenceDepartment, - { - onSuccess: async () => { - toast.success("Department updated successfully"); - await onSuccess(); - }, - onError: (error) => { - console.error("Error updating department:", error); - toast.error("Failed to update department"); - }, - } - ); - - useEffect(() => { - setDepartment(currentDepartment || null); - }, [currentDepartment]); - - const handleDepartmentChange = (value: string) => { - const newDepartment = value === "none" ? null : value; - setDepartment(newDepartment); - updateDepartment({ id: evidenceId, department: newDepartment }); - }; - - const departmentOptions = Object.values(Departments).filter( - (dept) => dept !== "none" - ); - - return ( - - ); + useEffect(() => { + if (department !== currentDepartment) { + onDepartmentChange(currentDepartment || null); + } + }, [currentDepartment, onDepartmentChange, department]); + + const handleDepartmentChange = (value: string) => { + const newDepartment = value === "none" ? null : value; + onDepartmentChange(newDepartment); + }; + + // Filter out 'none' from the displayed options as we handle it separately + const departmentOptions = Object.values(Departments).filter( + (dept) => dept !== "none", + ); + + return ( + + ); } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceDetails.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceDetails.tsx index d4f4f8e3b5..2b12cf683e 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceDetails.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceDetails.tsx @@ -8,7 +8,7 @@ import type { EvidenceDetailsProps } from "../types"; import { ReviewSection } from "./ReviewSection"; import { EditEvidenceForm } from "./EditEvidenceForm"; -export function EvidenceDetails({ id }: EvidenceDetailsProps) { +export function EvidenceDetails({ id, assignees }: EvidenceDetailsProps) { const { data, isLoading, mutate } = useEvidence({ id }); if (isLoading) { @@ -72,6 +72,7 @@ export function EvidenceDetails({ id }: EvidenceDetailsProps) { currentAssigneeId={evidence.assigneeId} onSuccess={handleMutate} id={id} + assignees={assignees} />
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/FrequencySection.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/FrequencySection.tsx index efb45eaf45..e9ee64a05d 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/FrequencySection.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/FrequencySection.tsx @@ -1,79 +1,53 @@ "use client"; -import { useState, useEffect } from "react"; +import type { Frequency } from "@bubba/db/types"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "@bubba/ui/select"; -import { useAction } from "next-safe-action/hooks"; -import { toast } from "sonner"; -import { updateEvidenceFrequency } from "../actions/updateEvidenceFrequency"; -import type { Frequency } from "@bubba/db/types"; interface FrequencySectionProps { - evidenceId: string; - currentFrequency: Frequency | null; - onSuccess: () => Promise; + onFrequencyChange: (value: Frequency | null) => void; + frequency: Frequency | null; + disabled?: boolean; } export function FrequencySection({ - evidenceId, - currentFrequency, - onSuccess, + onFrequencyChange, + frequency, + disabled = false, }: FrequencySectionProps) { - const [frequency, setFrequency] = useState( - currentFrequency || null - ); - - const { execute: updateFrequency, isExecuting } = useAction( - updateEvidenceFrequency, - { - onSuccess: async () => { - toast.success("Review frequency updated successfully"); - await onSuccess(); - }, - onError: () => { - toast.error("Failed to update review frequency"); - }, - } - ); - - useEffect(() => { - setFrequency(currentFrequency || null); - }, [currentFrequency]); - - const handleFrequencyChange = (value: string) => { - const newFrequency = value === "none" ? null : (value as Frequency); - setFrequency(newFrequency); - updateFrequency({ id: evidenceId, frequency: newFrequency }); - }; - - const frequencyOptions = [ - { value: "monthly", label: "Monthly" }, - { value: "quarterly", label: "Quarterly" }, - { value: "yearly", label: "Yearly" }, - ]; - - return ( - - ); + const handleFrequencyChange = (value: string) => { + const newFrequency = value === "none" ? null : (value as Frequency); + onFrequencyChange(newFrequency); + }; + + const frequencyOptions = [ + { value: "monthly", label: "Monthly" }, + { value: "quarterly", label: "Quarterly" }, + { value: "yearly", label: "Yearly" }, + ]; + + return ( + + ); } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/ReviewSection.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/ReviewSection.tsx index b8e028a70e..94d663b6e8 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/ReviewSection.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/ReviewSection.tsx @@ -1,19 +1,27 @@ "use client"; import { calculateNextReview } from "@/lib/utils/calculate-next-review"; -import type { Frequency, Evidence } from "@bubba/db/types"; +import type { + Frequency, + Evidence, + Member, + User as UserType, + Departments, +} from "@bubba/db/types"; import { Button } from "@bubba/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@bubba/ui/card"; import { format } from "date-fns"; -import { Building, CalendarClock, RefreshCw, User } from "lucide-react"; +import { Building, CalendarClock, RefreshCw, User, Save } from "lucide-react"; import { useAction } from "next-safe-action/hooks"; import { toast } from "sonner"; import { publishEvidence } from "../actions/publishEvidence"; import { toggleRelevance } from "../actions/toggleRelevance"; +import { updateEvidenceDetails } from "../actions/updateEvidenceDetails"; import { useEvidence } from "../hooks/useEvidence"; import { AssigneeSection } from "./AssigneeSection"; import { DepartmentSection } from "./DepartmentSection"; import { FrequencySection } from "./FrequencySection"; +import { useState, useEffect } from "react"; interface ReviewSectionProps { evidence: Evidence; @@ -24,20 +32,56 @@ interface ReviewSectionProps { currentAssigneeId: string | null | undefined; onSuccess: () => Promise; id: string; + assignees: (Member & { + user: UserType; + })[]; } export function ReviewSection({ evidenceId, lastPublishedAt, - frequency, - department, + frequency: initialFrequency, + department: initialDepartment, currentAssigneeId, onSuccess, id, evidence, + assignees, }: ReviewSectionProps) { const { mutate } = useEvidence({ id }); - const reviewInfo = calculateNextReview(lastPublishedAt, frequency); + const reviewInfo = calculateNextReview(lastPublishedAt, initialFrequency); + + // State for tracking form values + const [frequency, setFrequency] = useState( + initialFrequency, + ); + const [department, setDepartment] = useState( + initialDepartment as Departments | null, + ); + const [assigneeId, setAssigneeId] = useState( + currentAssigneeId || null, + ); + const [hasChanges, setHasChanges] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + // Check for changes whenever form values change + useEffect(() => { + const hasFrequencyChanged = frequency !== initialFrequency; + const hasDepartmentChanged = + department !== (initialDepartment as Departments | null); + const hasAssigneeChanged = assigneeId !== currentAssigneeId; + + setHasChanges( + hasFrequencyChanged || hasDepartmentChanged || hasAssigneeChanged, + ); + }, [ + frequency, + department, + assigneeId, + initialFrequency, + initialDepartment, + currentAssigneeId, + ]); const { execute: toggleRelevanceAction, isExecuting: isTogglingRelevance } = useAction(toggleRelevance, { @@ -50,16 +94,45 @@ export function ReviewSection({ }, }); - const { execute: publishAction, isExecuting } = useAction(publishEvidence, { - onSuccess: () => { - toast.success("Evidence published successfully"); - mutate(); + const { execute: publishAction, isExecuting: isPublishing } = useAction( + publishEvidence, + { + onSuccess: () => { + toast.success("Evidence published successfully"); + mutate(); + }, + onError: () => { + toast.error("Failed to publish evidence, please try again."); + }, + }, + ); + + const { execute: updateDetailsAction } = useAction(updateEvidenceDetails, { + onSuccess: async () => { + toast.success("Evidence details updated successfully"); + setIsSaving(false); + await onSuccess(); }, onError: () => { - toast.error("Failed to publish evidence, please try again."); + toast.error("Failed to update evidence details"); + setIsSaving(false); }, }); + const handleSaveChanges = () => { + setIsSaving(true); + updateDetailsAction({ + id, + frequency, + department, + assigneeId, + }); + }; + + const handleDepartmentChange = (value: string | null) => { + setDepartment(value as Departments | null); + }; + return ( @@ -100,8 +173,10 @@ export function ReviewSection({ @@ -113,9 +188,9 @@ export function ReviewSection({ @@ -147,20 +222,37 @@ export function ReviewSection({ - {!evidence.published && ( + +
- )} + + {!evidence.published && ( + + )} +
); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/page.tsx index 1e1b3aac38..f3ec462966 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/page.tsx @@ -1,13 +1,44 @@ +import { db } from "@bubba/db"; import { EvidenceDetails } from "./components/EvidenceDetails"; +import { headers } from "next/headers"; +import { auth } from "@bubba/auth"; interface EvidencePageProps { - params: Promise<{ - id: string; - }>; + params: Promise<{ + id: string; + }>; } export default async function EvidencePage({ params }: EvidencePageProps) { - const { id } = await params; + const { id } = await params; - return ; + const assignees = await getAssignees(); + + return ; } + +const getAssignees = async () => { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const orgId = session?.session.activeOrganizationId; + + if (!orgId) { + return []; + } + + const assignees = await db.member.findMany({ + where: { + organizationId: orgId, + role: { + notIn: ["employee"], + }, + }, + include: { + user: true, + }, + }); + + return assignees; +}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/types.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/types.ts index 9d3c9e8095..73ce9dd6cc 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/types.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/types.ts @@ -1,4 +1,5 @@ import type { ActionResponse } from "@/actions/types"; +import { Member, User } from "@bubba/db/types"; export interface UploadUrlResponse { uploadUrl: string; @@ -21,4 +22,7 @@ export interface UploadFileResponse export interface EvidenceDetailsProps { id: string; + assignees: (Member & { + user: User; + })[]; } From b7ae1c561b4bd4da22dc4797e5bbaba9bd2dcb83 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 3 Apr 2025 11:51:07 -0400 Subject: [PATCH 2/2] feat: refactor evidence management by introducing EvidenceStatus enum, updating related components, and enhancing data handling for improved clarity and functionality --- .../app/src/actions/organization/lib/utils.ts | 819 +++++++++--------- .../(overview)/constants/evidence-status.ts | 35 +- .../[orgId]/evidence/(overview)/page.tsx | 21 +- .../[id]/actions/updateEvidenceDetails.ts | 24 +- .../[id]/components/AssigneeSection.tsx | 152 ---- .../[id]/components/DepartmentSection.tsx | 62 -- .../components/EvidenceAssigneeSection.tsx | 134 +++ .../components/EvidenceDepartmentSection.tsx | 60 ++ .../[id]/components/EvidenceDetails.tsx | 64 +- .../components/EvidenceFrequencySection.tsx | 60 ++ .../components/EvidenceNextReviewSection.tsx | 33 + .../[id]/components/EvidenceStatusSection.tsx | 74 ++ .../[id]/components/FrequencySection.tsx | 53 -- .../[id]/components/ReviewSection.tsx | 238 ++--- .../[orgId]/evidence/[id]/page.tsx | 20 +- .../[orgId]/evidence/[id]/types.ts | 8 +- .../actions/getOrganizationEvidenceTasks.ts | 267 +++--- .../components/table/EvidenceListColumns.tsx | 74 +- .../list/hooks/useEvidenceTableContext.tsx | 4 +- .../[orgId]/evidence/list/types.ts | 14 +- .../migration.sql | 14 + .../20250403150143_update_enum/migration.sql | 16 + .../migration.sql | 5 + .../20250403153215_idk/migration.sql | 2 + packages/db/prisma/schema/auth.prisma | 2 +- packages/db/prisma/schema/evidence.prisma | 32 +- 26 files changed, 1156 insertions(+), 1131 deletions(-) delete mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/AssigneeSection.tsx delete mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/DepartmentSection.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceAssigneeSection.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceDepartmentSection.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceFrequencySection.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceNextReviewSection.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceStatusSection.tsx delete mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/FrequencySection.tsx create mode 100644 packages/db/prisma/migrations/20250403150029_update_evidence_statuses/migration.sql create mode 100644 packages/db/prisma/migrations/20250403150143_update_enum/migration.sql create mode 100644 packages/db/prisma/migrations/20250403152616_assignee_should_be_member_not_user/migration.sql create mode 100644 packages/db/prisma/migrations/20250403153215_idk/migration.sql diff --git a/apps/app/src/actions/organization/lib/utils.ts b/apps/app/src/actions/organization/lib/utils.ts index 443d1a470a..a8a9cea596 100644 --- a/apps/app/src/actions/organization/lib/utils.ts +++ b/apps/app/src/actions/organization/lib/utils.ts @@ -1,7 +1,7 @@ import type { - TemplateControl, - TemplateEvidence, - TemplatePolicy, + TemplateControl, + TemplateEvidence, + TemplatePolicy, } from "@bubba/data"; import { controls, evidence, frameworks, policies } from "@bubba/data"; import { db } from "@bubba/db"; @@ -15,8 +15,8 @@ import type { InputJsonValue } from "@prisma/client/runtime/library"; * @returns The policy template or undefined if not found */ export function getPolicyById(id: string): TemplatePolicy | undefined { - // Use a type-safe approach to check if the key exists in the policies object - return Object.entries(policies).find(([key]) => key === id)?.[1]; + // Use a type-safe approach to check if the key exists in the policies object + return Object.entries(policies).find(([key]) => key === id)?.[1]; } /** @@ -26,8 +26,8 @@ export function getPolicyById(id: string): TemplatePolicy | undefined { * @returns The evidence template or undefined if not found */ export function getEvidenceById(id: string): TemplateEvidence | undefined { - // Use a type-safe approach to check if the key exists in the evidence object - return Object.entries(evidence).find(([key]) => key === id)?.[1]; + // Use a type-safe approach to check if the key exists in the evidence object + return Object.entries(evidence).find(([key]) => key === id)?.[1]; } /** @@ -42,13 +42,13 @@ export function getEvidenceById(id: string): TemplateEvidence | undefined { * @returns Array of control templates relevant to the selected frameworks */ export function getRelevantControls( - frameworkIds: FrameworkId[], + frameworkIds: FrameworkId[] ): TemplateControl[] { - return controls.filter((control) => - control.mappedRequirements.some((req) => - frameworkIds.includes(req.frameworkId), - ), - ); + return controls.filter((control) => + control.mappedRequirements.some((req) => + frameworkIds.includes(req.frameworkId) + ) + ); } /** @@ -67,97 +67,97 @@ export function getRelevantControls( * @returns The created framework instance record */ export async function createFrameworkInstance( - organizationId: string, - frameworkId: FrameworkId, + organizationId: string, + frameworkId: FrameworkId ) { - // First verify the organization exists - const organization = await db.organization.findUnique({ - where: { id: organizationId }, - }); - - if (!organization) { - console.error("Organization not found when creating framework", { - organizationId, - frameworkId, - }); - throw new Error(`Organization with ID ${organizationId} not found`); - } - - // Verify the framework exists - const framework = frameworks[frameworkId as FrameworkId]; - - if (!framework) { - console.error("Framework not found when creating organization framework", { - organizationId, - frameworkId, - }); - throw new Error(`Framework with ID ${frameworkId} not found`); - } - - // Use upsert to handle the case where a record may already exist - const frameworkInstance = await db.frameworkInstance.upsert({ - where: { - organizationId_frameworkId: { - organizationId, - frameworkId: frameworkId as FrameworkId, - }, - }, - update: { - organizationId, - frameworkId: frameworkId as FrameworkId, - }, - create: { - organizationId, - frameworkId: frameworkId as FrameworkId, - }, - select: { - id: true, - }, - }); - - console.info("Created/updated organization framework", { - organizationId, - frameworkId, - frameworkInstanceId: frameworkInstance.id, - }); - - // Find all controls that apply to this specific framework - const frameworkControls = controls.filter((control) => - control.mappedRequirements.some((req) => req.frameworkId === frameworkId), - ); - - // Create database records for each control - const createdControls = []; - for (const control of frameworkControls) { - const createdControl = await db.control.create({ - data: { - organizationId, - name: control.name, - description: control.description, - frameworkInstances: { - connect: { - id: frameworkInstance.id, - }, - }, - }, - }); - - createdControls.push(createdControl); - - console.info("Created control", { - controlName: control.name, - frameworkId, - }); - } - - // Create requirement maps for the controls - await createRequirementMaps( - { id: frameworkInstance.id, frameworkId }, - createdControls, - frameworkControls, - ); - - return frameworkInstance; + // First verify the organization exists + const organization = await db.organization.findUnique({ + where: { id: organizationId }, + }); + + if (!organization) { + console.error("Organization not found when creating framework", { + organizationId, + frameworkId, + }); + throw new Error(`Organization with ID ${organizationId} not found`); + } + + // Verify the framework exists + const framework = frameworks[frameworkId as FrameworkId]; + + if (!framework) { + console.error("Framework not found when creating organization framework", { + organizationId, + frameworkId, + }); + throw new Error(`Framework with ID ${frameworkId} not found`); + } + + // Use upsert to handle the case where a record may already exist + const frameworkInstance = await db.frameworkInstance.upsert({ + where: { + organizationId_frameworkId: { + organizationId, + frameworkId: frameworkId as FrameworkId, + }, + }, + update: { + organizationId, + frameworkId: frameworkId as FrameworkId, + }, + create: { + organizationId, + frameworkId: frameworkId as FrameworkId, + }, + select: { + id: true, + }, + }); + + console.info("Created/updated organization framework", { + organizationId, + frameworkId, + frameworkInstanceId: frameworkInstance.id, + }); + + // Find all controls that apply to this specific framework + const frameworkControls = controls.filter((control) => + control.mappedRequirements.some((req) => req.frameworkId === frameworkId) + ); + + // Create database records for each control + const createdControls = []; + for (const control of frameworkControls) { + const createdControl = await db.control.create({ + data: { + organizationId, + name: control.name, + description: control.description, + frameworkInstances: { + connect: { + id: frameworkInstance.id, + }, + }, + }, + }); + + createdControls.push(createdControl); + + console.info("Created control", { + controlName: control.name, + frameworkId, + }); + } + + // Create requirement maps for the controls + await createRequirementMaps( + { id: frameworkInstance.id, frameworkId }, + createdControls, + frameworkControls + ); + + return frameworkInstance; } /** @@ -177,64 +177,64 @@ export async function createFrameworkInstance( * @returns The number of requirement maps created */ export async function createRequirementMaps( - frameworkInstance: { id: string; frameworkId: FrameworkId }, - controls: { id: string; name: string }[], - templateControls: TemplateControl[], + frameworkInstance: { id: string; frameworkId: FrameworkId }, + controls: { id: string; name: string }[], + templateControls: TemplateControl[] ) { - console.info("Creating requirement maps", { - frameworkInstanceId: frameworkInstance.id, - controlCount: controls.length, - }); - - let requirementMapsCreated = 0; - - // Create a map for efficient control lookup - const controlMap = new Map( - controls.map((control) => [control.name, control]), - ); - - // For each template control - for (const templateControl of templateControls) { - // Find the corresponding created control - const control = controlMap.get(templateControl.name); - if (!control) { - console.warn(`Control not found for template: ${templateControl.name}`); - continue; - } - - // Get requirements for this framework - const frameworkRequirements = templateControl.mappedRequirements.filter( - (req) => req.frameworkId === frameworkInstance.frameworkId, - ); - - // Create requirement maps - for (const requirement of frameworkRequirements) { - try { - await db.requirementMap.create({ - data: { - controlId: control.id, - frameworkInstanceId: frameworkInstance.id, - requirementId: - `${frameworkInstance.frameworkId}_${requirement.requirementId}` as RequirementId, - }, - }); - - requirementMapsCreated++; - } catch (error) { - console.error( - `Error creating requirement map for control ${control.name}`, - { - error, - requirementId: requirement.requirementId, - }, - ); - } - } - } - - console.info(`Created ${requirementMapsCreated} requirement maps`); - - return requirementMapsCreated; + console.info("Creating requirement maps", { + frameworkInstanceId: frameworkInstance.id, + controlCount: controls.length, + }); + + let requirementMapsCreated = 0; + + // Create a map for efficient control lookup + const controlMap = new Map( + controls.map((control) => [control.name, control]) + ); + + // For each template control + for (const templateControl of templateControls) { + // Find the corresponding created control + const control = controlMap.get(templateControl.name); + if (!control) { + console.warn(`Control not found for template: ${templateControl.name}`); + continue; + } + + // Get requirements for this framework + const frameworkRequirements = templateControl.mappedRequirements.filter( + (req) => req.frameworkId === frameworkInstance.frameworkId + ); + + // Create requirement maps + for (const requirement of frameworkRequirements) { + try { + await db.requirementMap.create({ + data: { + controlId: control.id, + frameworkInstanceId: frameworkInstance.id, + requirementId: + `${frameworkInstance.frameworkId}_${requirement.requirementId}` as RequirementId, + }, + }); + + requirementMapsCreated++; + } catch (error) { + console.error( + `Error creating requirement map for control ${control.name}`, + { + error, + requirementId: requirement.requirementId, + } + ); + } + } + } + + console.info(`Created ${requirementMapsCreated} requirement maps`); + + return requirementMapsCreated; } /** @@ -255,74 +255,74 @@ export async function createRequirementMaps( * @returns Map of template policy IDs to created policy records */ export async function createOrganizationPolicies( - organizationId: string, - relevantControls: TemplateControl[], - userId: string, + organizationId: string, + relevantControls: TemplateControl[], + userId: string ) { - if (!organizationId) { - throw new Error("Not authorized - no organization found"); - } - - // Extract all policy IDs required by the controls - const policyIds = new Set(); - - for (const control of relevantControls) { - for (const artifact of control.mappedArtifacts) { - if (artifact.type === "policy") { - policyIds.add(artifact.policyId); - } - } - } - - console.info("Creating organization policies", { - organizationId, - policyCount: policyIds.size, - policyIds: Array.from(policyIds), - }); - - // Create policies for the organization - const createdPolicies = new Map(); - - // Process each policy using the type-safe accessor function - for (const policyId of policyIds) { - const policyTemplate = getPolicyById(policyId); - - if (!policyTemplate) { - console.warn(`Policy template not found: ${policyId}`); - continue; - } - - try { - // Create the policy record using template data - include only fields in the schema - const policy = await db.policy.create({ - data: { - organizationId, - name: policyTemplate.metadata.name, - description: policyTemplate.metadata.description, - status: "draft" as PolicyStatus, - content: policyTemplate.content as InputJsonValue[], - ownerId: userId, - frequency: policyTemplate.metadata.frequency, - department: policyTemplate.metadata.department, - }, - }); - - // Store both the policy record and template ID for later reference - createdPolicies.set(policyId, { - ...policy, - templateId: policyId, // Store the template ID for reference - }); - - console.info(`Created policy: ${policy.name}`, { - policyName: policy.name, - templateId: policyId, - }); - } catch (error) { - console.error(`Error creating policy ${policyId}`, { error }); - } - } - - return createdPolicies; + if (!organizationId) { + throw new Error("Not authorized - no organization found"); + } + + // Extract all policy IDs required by the controls + const policyIds = new Set(); + + for (const control of relevantControls) { + for (const artifact of control.mappedArtifacts) { + if (artifact.type === "policy") { + policyIds.add(artifact.policyId); + } + } + } + + console.info("Creating organization policies", { + organizationId, + policyCount: policyIds.size, + policyIds: Array.from(policyIds), + }); + + // Create policies for the organization + const createdPolicies = new Map(); + + // Process each policy using the type-safe accessor function + for (const policyId of policyIds) { + const policyTemplate = getPolicyById(policyId); + + if (!policyTemplate) { + console.warn(`Policy template not found: ${policyId}`); + continue; + } + + try { + // Create the policy record using template data - include only fields in the schema + const policy = await db.policy.create({ + data: { + organizationId, + name: policyTemplate.metadata.name, + description: policyTemplate.metadata.description, + status: "draft" as PolicyStatus, + content: policyTemplate.content as InputJsonValue[], + ownerId: userId, + frequency: policyTemplate.metadata.frequency, + department: policyTemplate.metadata.department, + }, + }); + + // Store both the policy record and template ID for later reference + createdPolicies.set(policyId, { + ...policy, + templateId: policyId, // Store the template ID for reference + }); + + console.info(`Created policy: ${policy.name}`, { + policyName: policy.name, + templateId: policyId, + }); + } catch (error) { + console.error(`Error creating policy ${policyId}`, { error }); + } + } + + return createdPolicies; } /** @@ -342,74 +342,83 @@ export async function createOrganizationPolicies( * @returns Map of template evidence IDs to created evidence records */ export async function createOrganizationEvidence( - organizationId: string, - relevantControls: TemplateControl[], - userId: string, + organizationId: string, + relevantControls: TemplateControl[], + userId: string ) { - if (!organizationId) { - throw new Error("Not authorized - no organization found"); - } - - // Extract all evidence IDs required by the controls - const evidenceIds = new Set(); - - for (const control of relevantControls) { - for (const artifact of control.mappedArtifacts) { - if (artifact.type === "evidence") { - evidenceIds.add(artifact.evidenceId); - } - } - } - - console.info("Creating evidence record instances", { - organizationId, - evidenceCount: evidenceIds.size, - evidenceIds: Array.from(evidenceIds), - }); - - // Create evidence records for the organization - const createdEvidence = new Map(); - - // Process each evidence using the type-safe accessor function - for (const evidenceId of evidenceIds) { - const evidenceTemplate = getEvidenceById(evidenceId); - - if (!evidenceTemplate) { - console.warn(`Evidence template not found: ${evidenceId}`); - continue; - } - - try { - // Create the evidence record using template data - include only fields in the schema - const evidenceRecord = await db.evidence.create({ - data: { - organizationId, - name: evidenceTemplate.name, - description: evidenceTemplate.description, - frequency: evidenceTemplate.frequency, - assigneeId: userId, - department: evidenceTemplate.department, - additionalUrls: [], - fileUrls: [], - }, - }); - - // Store both the evidence record and template ID for later reference - createdEvidence.set(evidenceId, { - ...evidenceRecord, - templateId: evidenceId, // Store the template ID for reference - }); - - console.info(`Created evidence: ${evidenceRecord.name}`, { - evidenceId: evidenceRecord.id, - templateId: evidenceId, - }); - } catch (error) { - console.error(`Error creating evidence record ${evidenceId}`, { error }); - } - } - - return createdEvidence; + if (!organizationId) { + throw new Error("Not authorized - no organization found"); + } + + // Extract all evidence IDs required by the controls + const evidenceIds = new Set(); + + for (const control of relevantControls) { + for (const artifact of control.mappedArtifacts) { + if (artifact.type === "evidence") { + evidenceIds.add(artifact.evidenceId); + } + } + } + + console.info("Creating evidence record instances", { + organizationId, + evidenceCount: evidenceIds.size, + evidenceIds: Array.from(evidenceIds), + }); + + // Find the member record for the user in this organization + const memberRecord = await db.member.findFirst({ + where: { + organizationId, + userId, + isActive: true, + }, + }); + + // Create evidence records for the organization + const createdEvidence = new Map(); + + // Process each evidence using the type-safe accessor function + for (const evidenceId of evidenceIds) { + const evidenceTemplate = getEvidenceById(evidenceId); + + if (!evidenceTemplate) { + console.warn(`Evidence template not found: ${evidenceId}`); + continue; + } + + try { + // Create the evidence record using template data - include only fields in the schema + const evidenceRecord = await db.evidence.create({ + data: { + organizationId, + name: evidenceTemplate.name, + description: evidenceTemplate.description, + frequency: evidenceTemplate.frequency, + assigneeId: memberRecord?.id || null, // Use the member ID if found, otherwise null + department: evidenceTemplate.department, + additionalUrls: [], + fileUrls: [], + }, + }); + + // Store both the evidence record and template ID for later reference + createdEvidence.set(evidenceId, { + ...evidenceRecord, + templateId: evidenceId, // Store the template ID for reference + }); + + console.info(`Created evidence: ${evidenceRecord.name}`, { + evidenceId: evidenceRecord.id, + templateId: evidenceId, + }); + } catch (error) { + console.error(`Error creating evidence record ${evidenceId}`, { error }); + } + } + + return createdEvidence; } /** @@ -431,116 +440,116 @@ export async function createOrganizationEvidence( * @returns Object with success status and artifact count */ export async function createControlArtifacts( - organizationId: string, - frameworkInstanceIds: string[], - relevantControls: TemplateControl[], - createdPolicies: Map, - createdEvidence: Map, + organizationId: string, + frameworkInstanceIds: string[], + relevantControls: TemplateControl[], + createdPolicies: Map, + createdEvidence: Map ) { - if (!organizationId) { - throw new Error("Not authorized - no organization found"); - } - - console.info("Creating organization control artifacts", { - organizationId, - frameworkInstanceIds, - relevantControlsCount: relevantControls.length, - }); - - // Get all controls for this organization and framework instances - const dbControls = await db.control.findMany({ - where: { - organizationId, - frameworkInstances: { - some: { - id: { - in: frameworkInstanceIds, - }, - }, - }, - }, - }); - - // Create a mapping from control name to database control record for efficient lookup - const controlMap = new Map(); - for (const control of dbControls) { - controlMap.set(control.name, control); - } - - // Create artifacts for each control - let artifactsCreated = 0; - - for (const control of relevantControls) { - const dbControl = controlMap.get(control.name); - - if (!dbControl) { - console.warn(`Control not found in database: ${control.name}`); - continue; - } - - // Create artifacts for each policy and evidence mapped to this control - for (const artifact of control.mappedArtifacts) { - try { - if (artifact.type === "policy") { - // Link control to a policy - const policy = createdPolicies.get(artifact.policyId); - - if (!policy) { - console.warn(`Policy not found for artifact: ${artifact.policyId}`); - continue; - } - - // Create the policy artifact - await db.artifact.create({ - data: { - type: "policy", - policyId: policy.id, - organizationId, - controls: { - connect: { - id: dbControl.id, - }, - }, - }, - }); - - artifactsCreated++; - } else if (artifact.type === "evidence") { - // Link control to evidence - const evidenceRecord = createdEvidence.get(artifact.evidenceId); - - if (!evidenceRecord) { - console.warn( - `Evidence not found for artifact: ${artifact.evidenceId}`, - ); - continue; - } - - // Create the evidence artifact - await db.artifact.create({ - data: { - type: "evidence", - evidenceId: evidenceRecord.id, - organizationId, - controls: { - connect: { - id: dbControl.id, - }, - }, - }, - }); - - artifactsCreated++; - } - } catch (error) { - console.error(`Error creating artifact for control ${control.name}`, { - error, - }); - } - } - } - - console.info(`Created ${artifactsCreated} artifacts for controls`); - - return { success: true, artifactsCreated }; + if (!organizationId) { + throw new Error("Not authorized - no organization found"); + } + + console.info("Creating organization control artifacts", { + organizationId, + frameworkInstanceIds, + relevantControlsCount: relevantControls.length, + }); + + // Get all controls for this organization and framework instances + const dbControls = await db.control.findMany({ + where: { + organizationId, + frameworkInstances: { + some: { + id: { + in: frameworkInstanceIds, + }, + }, + }, + }, + }); + + // Create a mapping from control name to database control record for efficient lookup + const controlMap = new Map(); + for (const control of dbControls) { + controlMap.set(control.name, control); + } + + // Create artifacts for each control + let artifactsCreated = 0; + + for (const control of relevantControls) { + const dbControl = controlMap.get(control.name); + + if (!dbControl) { + console.warn(`Control not found in database: ${control.name}`); + continue; + } + + // Create artifacts for each policy and evidence mapped to this control + for (const artifact of control.mappedArtifacts) { + try { + if (artifact.type === "policy") { + // Link control to a policy + const policy = createdPolicies.get(artifact.policyId); + + if (!policy) { + console.warn(`Policy not found for artifact: ${artifact.policyId}`); + continue; + } + + // Create the policy artifact + await db.artifact.create({ + data: { + type: "policy", + policyId: policy.id, + organizationId, + controls: { + connect: { + id: dbControl.id, + }, + }, + }, + }); + + artifactsCreated++; + } else if (artifact.type === "evidence") { + // Link control to evidence + const evidenceRecord = createdEvidence.get(artifact.evidenceId); + + if (!evidenceRecord) { + console.warn( + `Evidence not found for artifact: ${artifact.evidenceId}` + ); + continue; + } + + // Create the evidence artifact + await db.artifact.create({ + data: { + type: "evidence", + evidenceId: evidenceRecord.id, + organizationId, + controls: { + connect: { + id: dbControl.id, + }, + }, + }, + }); + + artifactsCreated++; + } + } catch (error) { + console.error(`Error creating artifact for control ${control.name}`, { + error, + }); + } + } + } + + console.info(`Created ${artifactsCreated} artifacts for controls`); + + return { success: true, artifactsCreated }; } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/constants/evidence-status.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/constants/evidence-status.ts index e0f0a160b0..27345c84c6 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/constants/evidence-status.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/constants/evidence-status.ts @@ -2,35 +2,36 @@ * Evidence status types and constants */ +import { EvidenceStatus } from "@bubba/db/types"; + // Status priority and type definitions -export const STATUS_PRIORITY = [ - "upToDate", +export const EVIDENCE_STATUS_PRIORITY: EvidenceStatus[] = [ "draft", - "needsReview", - "empty", + "not_relevant", + "published", ] as const; -export type StatusType = (typeof STATUS_PRIORITY)[number]; +export type EvidenceStatusType = (typeof EVIDENCE_STATUS_PRIORITY)[number]; // Status color mapping for UI components -export const STATUS_COLORS = { - upToDate: "bg-primary", +export const EVIDENCE_STATUS_COLORS = { draft: "bg-[var(--chart-open)]", - needsReview: "bg-[hsl(var(--destructive))]", - empty: "bg-[var(--chart-pending)]", + not_relevant: "bg-[hsl(var(--destructive))]", + published: "bg-[hsl(var(--destructive))]", } as const; // Status color hex values for charts -export const STATUS_HEX_COLORS: Record = { - upToDate: "#10b981", +export const EVIDENCE_STATUS_HEX_COLORS: Record = { draft: "#f59e0b", - needsReview: "#ef4444", - empty: "#6b7280", + not_relevant: "#ef4444", + published: "#10b981", }; // Translation keys for evidence statuses -export const STATUS_TRANSLATION_KEYS: Record = { - upToDate: "evidence.status.up_to_date", - needsReview: "evidence.status.needs_review", +export const EVIDENCE_STATUS_TRANSLATION_KEYS: Record< + EvidenceStatusType, + string +> = { draft: "evidence.status.draft", - empty: "evidence.status.empty", + not_relevant: "evidence.status.not_relevant", + published: "evidence.status.published", }; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/page.tsx index f7db938458..cb47734f85 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/page.tsx @@ -60,19 +60,19 @@ const getEvidenceOverview = cache(async () => { tx.evidence.count({ where: { organizationId, - published: true, + status: "published", }, }), tx.evidence.count({ where: { organizationId, - published: false, + status: "draft", }, }), tx.evidence.count({ where: { organizationId, - isNotRelevant: true, + status: "not_relevant", }, }), tx.evidence.groupBy({ @@ -88,12 +88,15 @@ const getEvidenceOverview = cache(async () => { assigneeId: { not: null }, }, select: { - published: true, - isNotRelevant: true, + status: true, assignee: { select: { id: true, - name: true, + user: { + select: { + name: true, + }, + }, }, }, }, @@ -110,7 +113,7 @@ const getEvidenceOverview = cache(async () => { if (!evidenceAssigneeByStatus.has(assigneeId)) { evidenceAssigneeByStatus.set(assigneeId, { id: assigneeId, - name: evidence.assignee.name || "Unknown", + name: evidence.assignee.user.name || "Unknown", total: 0, published: 0, draft: 0, @@ -127,9 +130,9 @@ const getEvidenceOverview = cache(async () => { // status = isNotRelevant if published is false and isNotRelevant is true // status = needsReview if published is true and needs review - if (evidence.published) { + if (evidence.status === "published") { assigneeData.published += 1; - } else if (evidence.isNotRelevant) { + } else if (evidence.status === "not_relevant") { assigneeData.isNotRelevant += 1; } else { // If not published and not irrelevant, it's a draft diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/actions/updateEvidenceDetails.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/actions/updateEvidenceDetails.ts index 7cca661d3c..278cf2fac6 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/actions/updateEvidenceDetails.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/actions/updateEvidenceDetails.ts @@ -3,14 +3,16 @@ import { authActionClient } from "@/actions/safe-action"; import { db } from "@bubba/db"; import { Departments, Frequency } from "@bubba/db/types"; +import { Evidence, EvidenceStatus, Role } from "@prisma/client"; import { z } from "zod"; import type { ActionResponse } from "@/actions/types"; const schema = z.object({ id: z.string(), - department: z.nativeEnum(Departments).nullable(), + department: z.nativeEnum(Departments), frequency: z.nativeEnum(Frequency).nullable(), assigneeId: z.string().nullable(), + status: z.nativeEnum(EvidenceStatus).nullable(), }); export const updateEvidenceDetails = authActionClient @@ -24,7 +26,7 @@ export const updateEvidenceDetails = authActionClient }) .action(async ({ ctx, parsedInput }): Promise => { const { session } = ctx; - const { id, department, frequency, assigneeId } = parsedInput; + const { id, department, frequency, assigneeId, status } = parsedInput; if (!session.activeOrganizationId) { return { @@ -49,17 +51,23 @@ export const updateEvidenceDetails = authActionClient }; } + const payload: Pick< + Evidence, + "department" | "frequency" | "assigneeId" | "status" | "lastPublishedAt" + > = { + department, + frequency, + assigneeId, + lastPublishedAt: new Date(), + status, + }; + // Update all evidence details in a single operation const updatedEvidence = await db.evidence.update({ where: { id, }, - data: { - department: department as Departments, - frequency, - assigneeId, - updatedAt: new Date(), - }, + data: payload, }); return { diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/AssigneeSection.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/AssigneeSection.tsx deleted file mode 100644 index 7702976663..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/AssigneeSection.tsx +++ /dev/null @@ -1,152 +0,0 @@ -"use client"; - -import { Member, User } from "@bubba/db/types"; -import { Avatar, AvatarFallback, AvatarImage } from "@bubba/ui/avatar"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@bubba/ui/select"; -import { useEffect, useState } from "react"; - -interface AssigneeSectionProps { - evidenceId: string; - currentAssigneeId: string | null | undefined; - assignees: (Member & { - user: User; - })[]; - onAssigneeChange: (value: string | null) => void; - assigneeId: string | null; - disabled?: boolean; -} - -export function AssigneeSection({ - currentAssigneeId, - assignees, - onAssigneeChange, - assigneeId, - disabled = false, -}: AssigneeSectionProps) { - const [selectedAssignee, setSelectedAssignee] = useState< - (Member & { user: User }) | null - >(null); - - useEffect(() => { - if (assigneeId !== currentAssigneeId) { - onAssigneeChange(currentAssigneeId || null); - } - }, [currentAssigneeId, onAssigneeChange, assigneeId]); - - useEffect(() => { - if (assignees && assigneeId) { - const assignee = assignees.find((a) => a.id === assigneeId); - if (assignee) { - setSelectedAssignee({ - ...assignee, - user: assignee.user, - }); - } else { - setSelectedAssignee(null); - } - } else { - setSelectedAssignee(null); - } - }, [assignees, assigneeId]); - - const handleAssigneeChange = (value: string) => { - const newAssigneeId = value === "none" ? null : value; - onAssigneeChange(newAssigneeId); - - if (newAssigneeId && assignees) { - const assignee = assignees.find((a) => a.id === newAssigneeId); - if (assignee) { - setSelectedAssignee({ - ...assignee, - user: assignee.user, - }); - } else { - setSelectedAssignee(null); - } - } else { - setSelectedAssignee(null); - } - }; - - // Function to safely prepare image URLs - const getImageUrl = (image: string | null) => { - if (!image) return ""; - - // If image is a relative URL, ensure it's properly formed - if (image.startsWith("/")) { - // This handles the case where the URL might need to be prefixed with the base URL - return image; - } - - return image; - }; - - return ( - - ); -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/DepartmentSection.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/DepartmentSection.tsx deleted file mode 100644 index e0d9d57319..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/DepartmentSection.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@bubba/ui/select"; -import { Departments } from "@bubba/db/types"; - -interface DepartmentSectionProps { - evidenceId: string; - currentDepartment: string | null; - onDepartmentChange: (value: string | null) => void; - department: string | null; - disabled?: boolean; -} - -export function DepartmentSection({ - currentDepartment, - onDepartmentChange, - department, - disabled = false, -}: DepartmentSectionProps) { - useEffect(() => { - if (department !== currentDepartment) { - onDepartmentChange(currentDepartment || null); - } - }, [currentDepartment, onDepartmentChange, department]); - - const handleDepartmentChange = (value: string) => { - const newDepartment = value === "none" ? null : value; - onDepartmentChange(newDepartment); - }; - - // Filter out 'none' from the displayed options as we handle it separately - const departmentOptions = Object.values(Departments).filter( - (dept) => dept !== "none", - ); - - return ( - - ); -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceAssigneeSection.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceAssigneeSection.tsx new file mode 100644 index 0000000000..d34dc2930d --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceAssigneeSection.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { Member, User } from "@bubba/db/types"; +import { Avatar, AvatarFallback, AvatarImage } from "@bubba/ui/avatar"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@bubba/ui/select"; +import { User as UserIcon } from "lucide-react"; +import { useState } from "react"; + +interface AssigneeSectionProps { + onAssigneeChange: (value: string | null) => void; + assigneeId: string | null; + assignees: (Member & { + user: User; + })[]; + disabled?: boolean; +} + +export function EvidenceAssigneeSection({ + assignees, + onAssigneeChange, + assigneeId, + disabled = false, +}: AssigneeSectionProps) { + const [selectedAssignee, setSelectedAssignee] = useState< + (Member & { user: User }) | null + >(null); + + const handleAssigneeChange = (value: string) => { + const newAssigneeId = value === "none" ? null : value; + onAssigneeChange(newAssigneeId); + + if (newAssigneeId && assignees) { + const assignee = assignees.find((a) => a.id === newAssigneeId); + if (assignee) { + setSelectedAssignee({ + ...assignee, + user: assignee.user, + }); + } else { + setSelectedAssignee(null); + } + } else { + setSelectedAssignee(null); + } + }; + + // Function to safely prepare image URLs + const getImageUrl = (image: string | null) => { + if (!image) return ""; + + // If image is a relative URL, ensure it's properly formed + if (image.startsWith("/")) { + // This handles the case where the URL might need to be prefixed with the base URL + return image; + } + + return image; + }; + + return ( +
+
+ +

ASSIGNEE

+
+ +
+ ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceDepartmentSection.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceDepartmentSection.tsx new file mode 100644 index 0000000000..1f2047b8da --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceDepartmentSection.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@bubba/ui/select"; +import { Departments } from "@bubba/db/types"; +import { Building } from "lucide-react"; + +interface DepartmentSectionProps { + onDepartmentChange: (value: Departments) => void; + department: Departments; + disabled?: boolean; +} + +export function EvidenceDepartmentSection({ + onDepartmentChange, + department, + disabled = false, +}: DepartmentSectionProps) { + const handleDepartmentChange = (value: Departments) => { + onDepartmentChange(value); + }; + + // Filter out 'none' from the displayed options as we handle it separately + const departmentOptions = Object.values(Departments).filter( + (dept) => dept !== "none", + ); + + return ( +
+
+ +

+ DEPARTMENT +

+
+ +
+ ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceDetails.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceDetails.tsx index 2b12cf683e..10ed7226d8 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceDetails.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceDetails.tsx @@ -1,77 +1,33 @@ "use client"; import { Alert, AlertDescription, AlertTitle } from "@bubba/ui/alert"; -import { Skeleton } from "@bubba/ui/skeleton"; -import { CheckCircle2, FileIcon, XCircle } from "lucide-react"; -import { useEvidence } from "../hooks/useEvidence"; +import { FileIcon } from "lucide-react"; import type { EvidenceDetailsProps } from "../types"; -import { ReviewSection } from "./ReviewSection"; import { EditEvidenceForm } from "./EditEvidenceForm"; +import { ReviewSection } from "./ReviewSection"; -export function EvidenceDetails({ id, assignees }: EvidenceDetailsProps) { - const { data, isLoading, mutate } = useEvidence({ id }); - - if (isLoading) { - return ( -
- - - -
- ); - } - - if (!data?.data) return null; - - const evidence = data.data; - - const handleMutate = async () => { - await mutate(); - }; - +export function EvidenceDetails({ assignees, evidence }: EvidenceDetailsProps) { return (
- {/* Alert with evidence info and status */} + {evidence.status === "not_relevant" && ( +
+ This evidence has been marked as not relevant and will not be included + in compliance reports. +
+ )} - -
- {evidence.name} Evidence -
- {evidence.published ? ( -
- - Published -
- ) : ( -
- - Draft -
- )} -
-
-
+ {evidence.name} Evidence {evidence.description || "No description provided."} - - {evidence.isNotRelevant && ( -
- This evidence has been marked as not relevant and will not be - included in compliance reports. -
- )}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceFrequencySection.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceFrequencySection.tsx new file mode 100644 index 0000000000..aa36484038 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceFrequencySection.tsx @@ -0,0 +1,60 @@ +"use client"; + +import type { Frequency } from "@bubba/db/types"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@bubba/ui/select"; +import { RefreshCw } from "lucide-react"; + +interface FrequencySectionProps { + onFrequencyChange: (value: Frequency | null) => void; + frequency: Frequency | null; + disabled?: boolean; +} + +export function EvidenceFrequencySection({ + onFrequencyChange, + frequency, + disabled = false, +}: FrequencySectionProps) { + const handleFrequencyChange = (value: string) => { + const newFrequency = value === "none" ? null : (value as Frequency); + onFrequencyChange(newFrequency); + }; + + const frequencyOptions = [ + { value: "monthly", label: "Monthly" }, + { value: "quarterly", label: "Quarterly" }, + { value: "yearly", label: "Yearly" }, + ]; + + return ( +
+
+ +

FREQUENCY

+
+ +
+ ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceNextReviewSection.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceNextReviewSection.tsx new file mode 100644 index 0000000000..a84e9cb160 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceNextReviewSection.tsx @@ -0,0 +1,33 @@ +import { CalendarClock } from "lucide-react"; +import { format } from "date-fns"; + +export const EvidenceNextReviewSection = ({ + reviewInfo, +}: { + reviewInfo: { + daysUntil: number; + nextReviewDate: Date; + isUrgent: boolean; + } | null; +}) => { + return ( +
+
+ +

+ NEXT REVIEW +

+
+ {!reviewInfo ? ( +

ASAP

+ ) : ( +
+ {reviewInfo.daysUntil} days ( + {format(reviewInfo.nextReviewDate, "MM/dd/yyyy")}) +
+ )} +
+ ); +}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceStatusSection.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceStatusSection.tsx new file mode 100644 index 0000000000..02515b7eaf --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceStatusSection.tsx @@ -0,0 +1,74 @@ +import { cn } from "@bubba/ui/cn"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@bubba/ui/select"; +import { FileCheck } from "lucide-react"; +import { EvidenceStatus } from "@bubba/db/types"; +import { EVIDENCE_STATUS_HEX_COLORS } from "../../(overview)/constants/evidence-status"; + +const statusOptions: { value: EvidenceStatus; label: string }[] = [ + { value: "draft", label: "Draft" }, + { value: "published", label: "Published" }, + { value: "not_relevant", label: "Not Relevant" }, +]; + +export const EvidenceStatusSection = ({ + status, + handleStatusChange, + isSaving, +}: { + status: EvidenceStatus; + handleStatusChange: (value: EvidenceStatus) => void; + isSaving: boolean; +}) => { + return ( +
+
+ +

STATUS

+
+ +
+ ); +}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/FrequencySection.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/FrequencySection.tsx deleted file mode 100644 index e9ee64a05d..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/FrequencySection.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -import type { Frequency } from "@bubba/db/types"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@bubba/ui/select"; - -interface FrequencySectionProps { - onFrequencyChange: (value: Frequency | null) => void; - frequency: Frequency | null; - disabled?: boolean; -} - -export function FrequencySection({ - onFrequencyChange, - frequency, - disabled = false, -}: FrequencySectionProps) { - const handleFrequencyChange = (value: string) => { - const newFrequency = value === "none" ? null : (value as Frequency); - onFrequencyChange(newFrequency); - }; - - const frequencyOptions = [ - { value: "monthly", label: "Monthly" }, - { value: "quarterly", label: "Quarterly" }, - { value: "yearly", label: "Yearly" }, - ]; - - return ( - - ); -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/ReviewSection.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/ReviewSection.tsx index 94d663b6e8..596985b6f8 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/ReviewSection.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/ReviewSection.tsx @@ -2,116 +2,69 @@ import { calculateNextReview } from "@/lib/utils/calculate-next-review"; import type { - Frequency, + Departments, Evidence, + EvidenceStatus, + Frequency, Member, + User, User as UserType, - Departments, } from "@bubba/db/types"; import { Button } from "@bubba/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@bubba/ui/card"; -import { format } from "date-fns"; -import { Building, CalendarClock, RefreshCw, User, Save } from "lucide-react"; +import { Save } from "lucide-react"; import { useAction } from "next-safe-action/hooks"; +import { useState } from "react"; import { toast } from "sonner"; -import { publishEvidence } from "../actions/publishEvidence"; -import { toggleRelevance } from "../actions/toggleRelevance"; import { updateEvidenceDetails } from "../actions/updateEvidenceDetails"; -import { useEvidence } from "../hooks/useEvidence"; -import { AssigneeSection } from "./AssigneeSection"; -import { DepartmentSection } from "./DepartmentSection"; -import { FrequencySection } from "./FrequencySection"; -import { useState, useEffect } from "react"; +import { EvidenceAssigneeSection } from "./EvidenceAssigneeSection"; +import { EvidenceDepartmentSection } from "./EvidenceDepartmentSection"; +import { EvidenceFrequencySection } from "./EvidenceFrequencySection"; +import { EvidenceNextReviewSection } from "./EvidenceNextReviewSection"; +import { EvidenceStatusSection } from "./EvidenceStatusSection"; interface ReviewSectionProps { - evidence: Evidence; - evidenceId: string; + evidence: Evidence & { + assignee: Member & { + user: User; + }; + }; lastPublishedAt: Date | null; frequency: Frequency | null; - department: string | null; + department: Departments; currentAssigneeId: string | null | undefined; - onSuccess: () => Promise; - id: string; assignees: (Member & { user: UserType; })[]; } export function ReviewSection({ - evidenceId, lastPublishedAt, frequency: initialFrequency, department: initialDepartment, currentAssigneeId, - onSuccess, - id, evidence, assignees, }: ReviewSectionProps) { - const { mutate } = useEvidence({ id }); const reviewInfo = calculateNextReview(lastPublishedAt, initialFrequency); // State for tracking form values const [frequency, setFrequency] = useState( initialFrequency, ); - const [department, setDepartment] = useState( - initialDepartment as Departments | null, - ); + const [department, setDepartment] = useState(initialDepartment); const [assigneeId, setAssigneeId] = useState( currentAssigneeId || null, ); - const [hasChanges, setHasChanges] = useState(false); - const [isSaving, setIsSaving] = useState(false); - - // Check for changes whenever form values change - useEffect(() => { - const hasFrequencyChanged = frequency !== initialFrequency; - const hasDepartmentChanged = - department !== (initialDepartment as Departments | null); - const hasAssigneeChanged = assigneeId !== currentAssigneeId; - - setHasChanges( - hasFrequencyChanged || hasDepartmentChanged || hasAssigneeChanged, - ); - }, [ - frequency, - department, - assigneeId, - initialFrequency, - initialDepartment, - currentAssigneeId, - ]); - - const { execute: toggleRelevanceAction, isExecuting: isTogglingRelevance } = - useAction(toggleRelevance, { - onSuccess: () => { - toast.success("Evidence relevance updated successfully"); - mutate(); - }, - onError: () => { - toast.error("Failed to update evidence relevance, please try again."); - }, - }); - - const { execute: publishAction, isExecuting: isPublishing } = useAction( - publishEvidence, - { - onSuccess: () => { - toast.success("Evidence published successfully"); - mutate(); - }, - onError: () => { - toast.error("Failed to publish evidence, please try again."); - }, - }, + const [status, setStatus] = useState( + evidence.status || "draft", ); + const [isSaving, setIsSaving] = useState(false); const { execute: updateDetailsAction } = useAction(updateEvidenceDetails, { onSuccess: async () => { toast.success("Evidence details updated successfully"); setIsSaving(false); - await onSuccess(); }, onError: () => { toast.error("Failed to update evidence details"); @@ -122,15 +75,20 @@ export function ReviewSection({ const handleSaveChanges = () => { setIsSaving(true); updateDetailsAction({ - id, + id: evidence.id, frequency, department, assigneeId, + status, }); }; - const handleDepartmentChange = (value: string | null) => { - setDepartment(value as Departments | null); + const handleDepartmentChange = (value: Departments) => { + setDepartment(value); + }; + + const handleStatusChange = (value: EvidenceStatus) => { + setStatus(value); }; return ( @@ -144,115 +102,47 @@ export function ReviewSection({ review dates
-
-
-
- -

- DEPARTMENT -

-
- -
- -
-
- -

- FREQUENCY -

-
- -
- -
-
- -

- NEXT REVIEW -

-
- {!reviewInfo ? ( -

ASAP

- ) : ( -
- {reviewInfo.daysUntil} days ( - {format(reviewInfo.nextReviewDate, "MM/dd/yyyy")}) -
- )} -
-
-
- -

- ASSIGNEE -

-
- -
+ + + + + + + + +
-
- - - {!evidence.published && ( - - )} -
+
); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/page.tsx index f3ec462966..716bfc7b61 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/page.tsx @@ -13,8 +13,9 @@ export default async function EvidencePage({ params }: EvidencePageProps) { const { id } = await params; const assignees = await getAssignees(); + const evidence = await getEvidence(id); - return ; + return ; } const getAssignees = async () => { @@ -42,3 +43,20 @@ const getAssignees = async () => { return assignees; }; + +const getEvidence = async (id: string) => { + const evidence = await db.evidence.findUnique({ + where: { + id, + }, + include: { + assignee: true, + }, + }); + + if (!evidence) { + throw new Error("Evidence not found"); + } + + return evidence; +}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/types.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/types.ts index 73ce9dd6cc..cd5cd0c8e8 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/types.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/types.ts @@ -1,5 +1,5 @@ import type { ActionResponse } from "@/actions/types"; -import { Member, User } from "@bubba/db/types"; +import { Evidence, Member, User } from "@bubba/db/types"; export interface UploadUrlResponse { uploadUrl: string; @@ -21,8 +21,12 @@ export interface UploadFileResponse }> {} export interface EvidenceDetailsProps { - id: string; assignees: (Member & { user: User; })[]; + evidence: Evidence & { + assignee: Member & { + user: User; + }; + }; } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/actions/getOrganizationEvidenceTasks.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/actions/getOrganizationEvidenceTasks.ts index 25ba5b2acf..712a961f81 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/actions/getOrganizationEvidenceTasks.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/actions/getOrganizationEvidenceTasks.ts @@ -1,7 +1,7 @@ "use server"; import { authActionClient } from "@/actions/safe-action"; -import { Departments, Frequency } from "@bubba/db/types"; +import { Departments, EvidenceStatus, Frequency } from "@bubba/db/types"; import type { Evidence, Prisma } from "@bubba/db/types"; import { db } from "@bubba/db"; @@ -9,145 +9,150 @@ import { z } from "zod"; // Define the response types for better type safety export interface PaginationMetadata { - page: number; - pageSize: number; - totalCount: number; - totalPages: number; - hasNextPage: boolean; - hasPreviousPage: boolean; + page: number; + pageSize: number; + totalCount: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; } export interface EvidenceTasksData { - data: Evidence[]; - pagination: PaginationMetadata; + data: Evidence[]; + pagination: PaginationMetadata; } export const getOrganizationEvidenceTasks = authActionClient - .schema( - z.object({ - search: z.string().optional().nullable(), - status: z.enum(["published", "draft"]).optional().nullable(), - frequency: z.nativeEnum(Frequency).optional().nullable(), - department: z.nativeEnum(Departments).optional().nullable(), - assigneeId: z.string().optional().nullable(), - relevance: z.enum(["relevant", "not-relevant"]).optional().nullable(), - page: z.number().int().positive().optional().default(1), - pageSize: z.number().int().positive().optional().default(10), - }), - ) - .metadata({ - name: "getOrganizationEvidenceTasks", - track: { - event: "get-organization-evidence-tasks", - channel: "server", - }, - }) - .action(async ({ ctx, parsedInput }) => { - const { session } = ctx; - const { - search, - status, - frequency, - department, - assigneeId, - relevance, - page, - pageSize, - } = parsedInput; + .schema( + z.object({ + search: z.string().optional().nullable(), + status: z.nativeEnum(EvidenceStatus).optional().nullable(), + frequency: z.nativeEnum(Frequency).optional().nullable(), + department: z.nativeEnum(Departments).optional().nullable(), + assigneeId: z.string().optional().nullable(), + relevance: z.enum(["relevant", "not-relevant"]).optional().nullable(), + page: z.number().int().positive().optional().default(1), + pageSize: z.number().int().positive().optional().default(10), + }) + ) + .metadata({ + name: "getOrganizationEvidenceTasks", + track: { + event: "get-organization-evidence-tasks", + channel: "server", + }, + }) + .action(async ({ ctx, parsedInput }) => { + const { session } = ctx; + const { + search, + status, + frequency, + department, + assigneeId, + relevance, + page, + pageSize, + } = parsedInput; - if (!session.activeOrganizationId) { - console.error("Not authorized - no organization found"); - return { - success: false, - error: "Not authorized - no organization found", - }; - } + if (!session.activeOrganizationId) { + console.error("Not authorized - no organization found"); + return { + success: false, + error: "Not authorized - no organization found", + }; + } - try { - // Create the where clause for both count and data queries - const whereClause: Prisma.EvidenceWhereInput = { - organizationId: session.activeOrganizationId, - // Status filter - ...(status === "published" ? { published: true } : {}), - ...(status === "draft" ? { published: false } : {}), - // Frequency filter - ...(frequency ? { frequency } : {}), - // Department filter - ...(department ? { department } : {}), - // Assignee filter - ...(assigneeId ? { assigneeId } : {}), - // Relevance filter - ...(relevance === "relevant" ? { isNotRelevant: false } : {}), - ...(relevance === "not-relevant" ? { isNotRelevant: true } : {}), - // Search filter - ...(search - ? { - OR: [ - { - name: { - contains: search, - mode: "insensitive" as Prisma.QueryMode, - }, - }, - { - description: { - contains: search, - mode: "insensitive" as Prisma.QueryMode, - }, - }, - ], - } - : {}), - }; + try { + // Create the where clause for both count and data queries + const whereClause: Prisma.EvidenceWhereInput = { + organizationId: session.activeOrganizationId, + // Status filter + ...(status === "published" ? { status: "published" } : {}), + ...(status === "draft" ? { status: "draft" } : {}), + ...(status === "not_relevant" ? { status: "not_relevant" } : {}), + // Frequency filter + ...(frequency ? { frequency } : {}), + // Department filter + ...(department ? { department } : {}), + // Assignee filter + ...(assigneeId ? { assigneeId } : {}), + // Relevance filter + ...(relevance === "relevant" ? { status: "published" } : {}), + ...(relevance === "not-relevant" ? { status: "not_relevant" } : {}), + // Search filter + ...(search + ? { + OR: [ + { + name: { + contains: search, + mode: "insensitive" as Prisma.QueryMode, + }, + }, + { + description: { + contains: search, + mode: "insensitive" as Prisma.QueryMode, + }, + }, + ], + } + : {}), + }; - // Get total count for pagination - const totalCount = await db.evidence.count({ - where: whereClause, - }); + // Get total count for pagination + const totalCount = await db.evidence.count({ + where: whereClause, + }); - // Calculate pagination values - const skip = (page - 1) * pageSize; - const totalPages = Math.ceil(totalCount / pageSize); + // Calculate pagination values + const skip = (page - 1) * pageSize; + const totalPages = Math.ceil(totalCount / pageSize); - // Get paginated data - const evidenceTasks = await db.evidence.findMany({ - where: whereClause, - include: { - assignee: { - select: { - id: true, - name: true, - email: true, - image: true, - }, - }, - }, - skip, - take: pageSize, - orderBy: { - updatedAt: "desc", // Most recently updated first - }, - }); + // Get paginated data + const evidenceTasks = await db.evidence.findMany({ + where: whereClause, + include: { + assignee: { + select: { + id: true, + user: { + select: { + name: true, + email: true, + image: true, + }, + }, + }, + }, + }, + skip, + take: pageSize, + orderBy: { + updatedAt: "desc", // Most recently updated first + }, + }); - return { - success: true, - data: { - data: evidenceTasks, - pagination: { - page, - pageSize, - totalCount, - totalPages, - hasNextPage: page < totalPages, - hasPreviousPage: page > 1, - }, - }, - }; - } catch (error) { - console.error("Error fetching evidence tasks:", error); - return { - success: false, - error: "Failed to fetch evidence tasks", - }; - } - }); + return { + success: true, + data: { + data: evidenceTasks, + pagination: { + page, + pageSize, + totalCount, + totalPages, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1, + }, + }, + }; + } 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)/[orgId]/evidence/list/components/table/EvidenceListColumns.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/list/components/table/EvidenceListColumns.tsx index 4d53067831..2181a165e6 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/list/components/table/EvidenceListColumns.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/list/components/table/EvidenceListColumns.tsx @@ -1,13 +1,13 @@ "use client"; -import { StatusPolicies } from "@/components/status-policies"; import { calculateNextReview } from "@/lib/utils/calculate-next-review"; import { Avatar, AvatarFallback, AvatarImage } from "@bubba/ui/avatar"; +import { Badge } from "@bubba/ui/badge"; +import { cn } from "@bubba/ui/cn"; import type { ColumnDef } from "@tanstack/react-table"; import { format } from "date-fns"; -import { AlertTriangle, Building, CheckCircle2 } from "lucide-react"; +import { EVIDENCE_STATUS_HEX_COLORS } from "../../../(overview)/constants/evidence-status"; import type { EvidenceTaskRow } from "../../types"; -import { Badge } from "@bubba/ui/badge"; export const EvidenceListColumns: ColumnDef[] = [ { @@ -22,9 +22,19 @@ export const EvidenceListColumns: ColumnDef[] = [
{row.original.name}
- +
+
+
+
+
), @@ -38,11 +48,23 @@ export const EvidenceListColumns: ColumnDef[] = [ size: 150, minSize: 120, cell: ({ row }) => { - const isPublished = row.original.published; + const status = row.original.status; return (
- +
+
+ {status + ?.split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ")} +
); }, @@ -147,42 +169,14 @@ export const EvidenceListColumns: ColumnDef[] = [
- {assignee.name ? assignee.name.charAt(0) : "?"} + {assignee.user.name ? assignee.user.name.charAt(0) : "?"} - {assignee.name} -
- ); - }, - }, - { - id: "relevance", - accessorKey: "isNotRelevant", - header: "Relevance", - enableResizing: true, - enableSorting: false, - size: 150, - minSize: 120, - cell: ({ row }) => { - const isNotRelevant = row.original.isNotRelevant; - - if (!isNotRelevant) { - return ( -
- - Relevant -
- ); - } - - return ( -
- - Not Relevant + {assignee.user.name}
); }, diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/list/hooks/useEvidenceTableContext.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/list/hooks/useEvidenceTableContext.tsx index 4cf61ab339..49144e2788 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/list/hooks/useEvidenceTableContext.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/list/hooks/useEvidenceTableContext.tsx @@ -193,8 +193,8 @@ export function EvidenceTableProvider({ children }: { children: ReactNode }) { if (task.assignee) { uniqueAssignees.set(task.assignee.id, { id: task.assignee.id, - name: task.assignee.name, - image: task.assignee.image, + name: task.assignee.user.name, + image: task.assignee.user.image, }); } } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/list/types.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/list/types.ts index dd26b4e190..fbadd28d58 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/list/types.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/list/types.ts @@ -1,10 +1,12 @@ import type { Evidence } from "@bubba/db/types"; export type EvidenceTaskRow = Evidence & { - assignee?: { - id: string; - name: string | null; - email: string | null; - image: string | null; - } | null; + assignee?: { + id: string; + user: { + name: string | null; + email: string | null; + image: string | null; + }; + } | null; }; diff --git a/packages/db/prisma/migrations/20250403150029_update_evidence_statuses/migration.sql b/packages/db/prisma/migrations/20250403150029_update_evidence_statuses/migration.sql new file mode 100644 index 0000000000..4fe0d4cc98 --- /dev/null +++ b/packages/db/prisma/migrations/20250403150029_update_evidence_statuses/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - You are about to drop the column `isNotRelevant` on the `Evidence` table. All the data in the column will be lost. + - You are about to drop the column `published` on the `Evidence` table. All the data in the column will be lost. + +*/ +-- CreateEnum +CREATE TYPE "EvidenceStatus" AS ENUM ('draft', 'published', 'notRelevant'); + +-- AlterTable +ALTER TABLE "Evidence" DROP COLUMN "isNotRelevant", +DROP COLUMN "published", +ADD COLUMN "status" "EvidenceStatus" DEFAULT 'draft'; diff --git a/packages/db/prisma/migrations/20250403150143_update_enum/migration.sql b/packages/db/prisma/migrations/20250403150143_update_enum/migration.sql new file mode 100644 index 0000000000..2c83f4aa1a --- /dev/null +++ b/packages/db/prisma/migrations/20250403150143_update_enum/migration.sql @@ -0,0 +1,16 @@ +/* + Warnings: + + - The values [notRelevant] on the enum `EvidenceStatus` will be removed. If these variants are still used in the database, this will fail. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "EvidenceStatus_new" AS ENUM ('draft', 'published', 'not_relevant'); +ALTER TABLE "Evidence" ALTER COLUMN "status" DROP DEFAULT; +ALTER TABLE "Evidence" ALTER COLUMN "status" TYPE "EvidenceStatus_new" USING ("status"::text::"EvidenceStatus_new"); +ALTER TYPE "EvidenceStatus" RENAME TO "EvidenceStatus_old"; +ALTER TYPE "EvidenceStatus_new" RENAME TO "EvidenceStatus"; +DROP TYPE "EvidenceStatus_old"; +ALTER TABLE "Evidence" ALTER COLUMN "status" SET DEFAULT 'draft'; +COMMIT; diff --git a/packages/db/prisma/migrations/20250403152616_assignee_should_be_member_not_user/migration.sql b/packages/db/prisma/migrations/20250403152616_assignee_should_be_member_not_user/migration.sql new file mode 100644 index 0000000000..d3a6fd8576 --- /dev/null +++ b/packages/db/prisma/migrations/20250403152616_assignee_should_be_member_not_user/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE "Evidence" DROP CONSTRAINT "Evidence_assigneeId_fkey"; + +-- AddForeignKey +ALTER TABLE "Evidence" ADD CONSTRAINT "Evidence_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "Member"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20250403153215_idk/migration.sql b/packages/db/prisma/migrations/20250403153215_idk/migration.sql new file mode 100644 index 0000000000..b4c23e1d7c --- /dev/null +++ b/packages/db/prisma/migrations/20250403153215_idk/migration.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX "Evidence_assigneeId_idx"; diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma index da5917e311..f1990f62dc 100644 --- a/packages/db/prisma/schema/auth.prisma +++ b/packages/db/prisma/schema/auth.prisma @@ -9,7 +9,6 @@ model User { lastLogin DateTime? accounts Account[] - assignedEvidence Evidence[] auditLog AuditLog[] integrationResults IntegrationResult[] invitations Invitation[] @@ -88,6 +87,7 @@ model Member { department Departments @default(none) isActive Boolean @default(true) EmployeeTrainingVideoCompletion EmployeeTrainingVideoCompletion[] + Evidence Evidence[] } model Invitation { diff --git a/packages/db/prisma/schema/evidence.prisma b/packages/db/prisma/schema/evidence.prisma index 64a56763c4..d35d919b33 100644 --- a/packages/db/prisma/schema/evidence.prisma +++ b/packages/db/prisma/schema/evidence.prisma @@ -1,27 +1,31 @@ model Evidence { // Metadata - id String @id @unique @default(cuid()) - name String - description String - published Boolean @default(false) - isNotRelevant Boolean @default(false) - additionalUrls String[] - fileUrls String[] - lastPublishedAt DateTime? - frequency Frequency? - department Departments @default(none) + id String @id @unique @default(cuid()) + name String + description String + additionalUrls String[] + fileUrls String[] + frequency Frequency? + department Departments @default(none) + status EvidenceStatus? @default(draft) // Dates - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastPublishedAt DateTime? // Relationships assigneeId String? - assignee User? @relation(fields: [assigneeId], references: [id]) + assignee Member? @relation(fields: [assigneeId], references: [id]) organizationId String organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) artifacts Artifact[] @@index([organizationId]) - @@index([assigneeId]) +} + +enum EvidenceStatus { + draft + published + not_relevant }