diff --git a/Clients/src/application/hooks/useTaskEntityLinks.ts b/Clients/src/application/hooks/useTaskEntityLinks.ts new file mode 100644 index 0000000000..0d49f5fe1e --- /dev/null +++ b/Clients/src/application/hooks/useTaskEntityLinks.ts @@ -0,0 +1,84 @@ +import { + useQuery, + useMutation, + useQueryClient, + UseQueryResult, +} from "@tanstack/react-query"; +import { + getTaskEntityLinks, + addTaskEntityLink, + removeTaskEntityLink, + ITaskEntityLink, + EntityType, +} from "../repository/taskEntityLink.repository"; + +// Query keys for task entity links +export const taskEntityLinkQueryKeys = { + all: ["taskEntityLinks"] as const, + byTask: (taskId: number) => + [...taskEntityLinkQueryKeys.all, "byTask", taskId] as const, +}; + +/** + * Hook to fetch entity links for a task + */ +export const useTaskEntityLinks = ( + taskId: number | undefined +): UseQueryResult => { + return useQuery({ + queryKey: taskEntityLinkQueryKeys.byTask(taskId!), + queryFn: async () => { + if (!taskId) return []; + return await getTaskEntityLinks(taskId); + }, + enabled: !!taskId, + staleTime: 2 * 60 * 1000, // 2 minutes + gcTime: 5 * 60 * 1000, // 5 minutes + }); +}; + +/** + * Hook to add an entity link to a task + */ +export const useAddTaskEntityLink = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + taskId, + entityId, + entityType, + }: { + taskId: number; + entityId: number; + entityType: EntityType; + }) => { + return await addTaskEntityLink(taskId, entityId, entityType); + }, + onSuccess: (_, { taskId }) => { + queryClient.invalidateQueries({ + queryKey: taskEntityLinkQueryKeys.byTask(taskId), + }); + }, + }); +}; + +/** + * Hook to remove an entity link from a task + */ +export const useRemoveTaskEntityLink = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ taskId, linkId }: { taskId: number; linkId: number }) => { + await removeTaskEntityLink(taskId, linkId); + }, + onSuccess: (_, { taskId }) => { + queryClient.invalidateQueries({ + queryKey: taskEntityLinkQueryKeys.byTask(taskId), + }); + }, + }); +}; + +export type { ITaskEntityLink, EntityType }; diff --git a/Clients/src/application/repository/intakeForm.repository.ts b/Clients/src/application/repository/intakeForm.repository.ts index d96371bffd..bfde403d02 100644 --- a/Clients/src/application/repository/intakeForm.repository.ts +++ b/Clients/src/application/repository/intakeForm.repository.ts @@ -4,6 +4,7 @@ import { IntakeEntityType, IntakeSubmissionStatus, } from "../../domain/intake/enums"; +import type { FieldType, FormDesignSettings } from "../../presentation/pages/IntakeFormBuilder/types"; // Re-export enums for convenience export { IntakeFormStatus, IntakeEntityType, IntakeSubmissionStatus }; @@ -39,7 +40,7 @@ export interface FieldValidation { */ export interface FormField { id: string; - type: string; + type: FieldType; label: string; placeholder?: string; helpText?: string; @@ -78,7 +79,7 @@ export interface IntakeForm { riskAssessmentConfig?: Record | null; llmKeyId?: number | null; suggestedQuestionsEnabled?: boolean; - designSettings?: Record | null; + designSettings?: FormDesignSettings | null; createdBy: number; createdAt: Date; updatedAt: Date; @@ -165,7 +166,7 @@ export async function getAllIntakeForms( const url = `${BASE_URL}/forms${queryParams.toString() ? `?${queryParams}` : ""}`; const response = await apiServices.get(url, { signal }); - return response.data; + return response.data as { data: IntakeForm[]; pagination?: { total: number; page: number; limit: number } }; } /** @@ -176,7 +177,7 @@ export async function getIntakeForm( signal?: AbortSignal ): Promise<{ data: IntakeForm }> { const response = await apiServices.get(`${BASE_URL}/forms/${formId}`, { signal }); - return response.data; + return response.data as { data: IntakeForm }; } /** @@ -196,12 +197,12 @@ export async function createIntakeForm( riskTierSystem?: string; llmKeyId?: number | null; suggestedQuestionsEnabled?: boolean; - designSettings?: Record | null; + designSettings?: FormDesignSettings | null; }, signal?: AbortSignal ): Promise<{ data: IntakeForm }> { const response = await apiServices.post(`${BASE_URL}/forms`, data, { signal }); - return response.data; + return response.data as { data: IntakeForm }; } /** @@ -221,12 +222,12 @@ export async function updateIntakeForm( riskTierSystem?: string; llmKeyId?: number | null; suggestedQuestionsEnabled?: boolean; - designSettings?: Record | null; + designSettings?: FormDesignSettings | null; }, signal?: AbortSignal ): Promise<{ data: IntakeForm }> { const response = await apiServices.patch(`${BASE_URL}/forms/${formId}`, data, { signal }); - return response.data; + return response.data as { data: IntakeForm }; } /** @@ -237,7 +238,7 @@ export async function deleteIntakeForm( signal?: AbortSignal ): Promise<{ data: null }> { const response = await apiServices.delete(`${BASE_URL}/forms/${formId}`, { signal }); - return response.data; + return response.data as { data: null }; } /** @@ -248,7 +249,7 @@ export async function archiveIntakeForm( signal?: AbortSignal ): Promise<{ data: IntakeForm }> { const response = await apiServices.post(`${BASE_URL}/forms/${formId}/archive`, undefined, { signal }); - return response.data; + return response.data as { data: IntakeForm }; } // ============================================================================ @@ -273,7 +274,7 @@ export async function getPendingSubmissions( const url = `${BASE_URL}/submissions${queryParams.toString() ? `?${queryParams}` : ""}`; const response = await apiServices.get(url, { signal }); - return response.data; + return response.data as { data: IntakeSubmission[]; pagination?: { total: number; page: number; limit: number } }; } /** @@ -299,7 +300,22 @@ export async function getSubmissionPreview( }; }> { const response = await apiServices.get(`${BASE_URL}/submissions/${submissionId}/preview`, { signal }); - return response.data; + return response.data as { + data: { + submission: IntakeSubmission; + riskAssessment: RiskAssessment | null; + entityPreview: Record; + form: { + id: number; + name: string; + entityType: string; + schema: FormSchema; + riskTierSystem?: string; + }; + riskTier?: string | null; + riskOverride?: RiskOverride | null; + }; + }; } /** @@ -322,7 +338,7 @@ export async function approveSubmission( data || {}, { signal } ); - return response.data; + return response.data as { data: { submission: IntakeSubmission; createdEntity: unknown } }; } /** @@ -338,7 +354,7 @@ export async function rejectSubmission( { reason }, { signal } ); - return response.data; + return response.data as { data: IntakeSubmission }; } // ============================================================================ @@ -350,7 +366,7 @@ export async function rejectSubmission( */ export async function getCaptcha(): Promise<{ data: { question: string; token: string } }> { const response = await apiServices.get(`${BASE_URL}/public/captcha`); - return response.data; + return response.data as { data: { question: string; token: string } }; } /** @@ -370,7 +386,7 @@ export async function getPublicForm( entityType: IntakeEntityType; schema: FormSchema; submitButtonText: string; - designSettings?: Record | null; + designSettings?: FormDesignSettings | null; }; previousData?: Record; previousSubmitterName?: string; @@ -379,7 +395,23 @@ export async function getPublicForm( }> { const queryParams = resubmissionToken ? `?token=${resubmissionToken}` : ""; const response = await apiServices.get(`${BASE_URL}/public/${tenantSlug}/${formSlug}${queryParams}`); - return response.data; + return response.data as { + data: { + form: { + id: number; + name: string; + description: string; + slug: string; + entityType: IntakeEntityType; + schema: FormSchema; + submitButtonText: string; + designSettings?: FormDesignSettings | null; + }; + previousData?: Record; + previousSubmitterName?: string; + previousSubmitterEmail?: string; + }; + }; } /** @@ -404,7 +436,7 @@ export async function submitPublicForm( }; }> { const response = await apiServices.post(`${BASE_URL}/public/${tenantSlug}/${formSlug}`, data); - return response.data; + return response.data as { data: { submissionId: number; resubmissionToken: string; message: string } }; } /** @@ -423,7 +455,7 @@ export async function getPublicFormById( entityType: IntakeEntityType; schema: FormSchema; submitButtonText: string; - designSettings?: Record | null; + designSettings?: FormDesignSettings | null; }; previousData?: Record; previousSubmitterName?: string; @@ -432,7 +464,23 @@ export async function getPublicFormById( }> { const queryParams = resubmissionToken ? `?token=${resubmissionToken}` : ""; const response = await apiServices.get(`${BASE_URL}/public/by-id/${publicId}${queryParams}`); - return response.data; + return response.data as { + data: { + form: { + id: number; + name: string; + description: string; + slug: string; + entityType: IntakeEntityType; + schema: FormSchema; + submitButtonText: string; + designSettings?: FormDesignSettings | null; + }; + previousData?: Record; + previousSubmitterName?: string; + previousSubmitterEmail?: string; + }; + }; } // ============================================================================ @@ -460,5 +508,5 @@ export async function submitPublicFormById( }; }> { const response = await apiServices.post(`${BASE_URL}/public/by-id/${publicId}`, data); - return response.data; + return response.data as { data: { submissionId: number; resubmissionToken: string; message: string } }; } diff --git a/Clients/src/application/repository/taskEntityLink.repository.ts b/Clients/src/application/repository/taskEntityLink.repository.ts new file mode 100644 index 0000000000..0f2f17b2c9 --- /dev/null +++ b/Clients/src/application/repository/taskEntityLink.repository.ts @@ -0,0 +1,95 @@ +import { apiServices } from "../../infrastructure/api/networkServices"; +import { APIError } from "../tools/error"; + +export type EntityType = + | "vendor" + | "model" + | "policy" + | "nist_subcategory" + | "iso42001_subclause" + | "iso42001_annexcategory" + | "iso27001_subclause" + | "iso27001_annexcontrol" + | "eu_control" + | "eu_subcontrol"; + +export interface ITaskEntityLink { + id: number; + task_id: number; + entity_id: number; + entity_type: EntityType; + entity_name?: string; + created_at?: string; + updated_at?: string; +} + +function extractData(response: { data: { data: T } }): T { + return response.data.data; +} + +/** + * Get all entity links for a task + */ +export async function getTaskEntityLinks( + taskId: number +): Promise { + try { + const response = await apiServices.get<{ + message: string; + data: ITaskEntityLink[]; + }>(`/tasks/${taskId}/entities`); + return extractData(response); + } catch (error: any) { + throw new APIError( + "Failed to fetch task entity links", + error?.response?.status, + error + ); + } +} + +/** + * Add an entity link to a task + */ +export async function addTaskEntityLink( + taskId: number, + entityId: number, + entityType: EntityType, + entityName?: string +): Promise { + try { + const response = await apiServices.post<{ + message: string; + data: ITaskEntityLink; + }>(`/tasks/${taskId}/entities`, { + entity_id: entityId, + entity_type: entityType, + entity_name: entityName, + }); + return extractData(response); + } catch (error: any) { + throw new APIError( + "Failed to add entity link to task", + error?.response?.status, + error + ); + } +} + +/** + * Remove an entity link from a task + */ +export async function removeTaskEntityLink( + taskId: number, + linkId: number +): Promise { + try { + await apiServices.delete(`/tasks/${taskId}/entities/${linkId}`); + } catch (error: any) { + throw new APIError( + "Failed to remove entity link from task", + error?.response?.status, + error + ); + } +} diff --git a/Clients/src/domain/interfaces/i.task.ts b/Clients/src/domain/interfaces/i.task.ts index b1ead13e92..a3746fb530 100644 --- a/Clients/src/domain/interfaces/i.task.ts +++ b/Clients/src/domain/interfaces/i.task.ts @@ -1,5 +1,23 @@ import { TaskPriority, TaskStatus } from "../enums/task.enum"; +export type EntityLinkType = + | "vendor" + | "model" + | "policy" + | "nist_subcategory" + | "iso42001_subclause" + | "iso42001_annexcategory" + | "iso27001_subclause" + | "iso27001_annexcontrol" + | "eu_control" + | "eu_subcontrol"; + +export interface IEntityLink { + entity_id: number; + entity_type: EntityLinkType; + entity_name?: string; +} + export interface ITask { id?: number; title: string; @@ -53,6 +71,7 @@ export interface ICreateTaskFormValues { email: string; }>; categories: string[]; + entity_links: IEntityLink[]; } // Note: ICreateTaskProps and ICreateTaskFormErrors have been moved to: presentation/types/interfaces/i.task.ts diff --git a/Clients/src/presentation/components/EntityLinkSelector/index.tsx b/Clients/src/presentation/components/EntityLinkSelector/index.tsx new file mode 100644 index 0000000000..2d76f2f07a --- /dev/null +++ b/Clients/src/presentation/components/EntityLinkSelector/index.tsx @@ -0,0 +1,908 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { Stack, Typography, useTheme, Box, IconButton } from "@mui/material"; +import { Plus, X, RotateCcw } from "lucide-react"; +import SelectComponent from "../Inputs/Select"; +import { getAllEntities } from "../../../application/repository/entity.repository"; +import { getAllVendors } from "../../../application/repository/vendor.repository"; +import { getAllPolicies } from "../../../application/repository/policy.repository"; +import { EntityType } from "../../../application/hooks/useTaskEntityLinks"; + +interface EntityLink { + entity_id: number; + entity_type: EntityType; + entity_name?: string; +} + +interface EntityLinkSelectorProps { + value: EntityLink[]; + onChange: (links: EntityLink[]) => void; + disabled?: boolean; +} + +// Top-level entity type options +const ENTITY_TYPE_OPTIONS = [ + { _id: "vendor", name: "Vendor" }, + { _id: "model", name: "Model" }, + { _id: "policy", name: "Policy" }, + { _id: "use_case", name: "Use-case" }, + { _id: "framework", name: "Framework (Organizational)" }, +]; + +const EntityLinkSelector: React.FC = ({ + value = [], + onChange, + disabled = false, +}) => { + const theme = useTheme(); + + // State for the cascading selection + const [selectedTopLevel, setSelectedTopLevel] = useState(""); + const [selectedProject, setSelectedProject] = useState(""); + const [selectedFramework, setSelectedFramework] = useState(""); + const [selectedEntityId, setSelectedEntityId] = useState(""); + + // Data states + const [vendors, setVendors] = useState([]); + const [models, setModels] = useState([]); + const [policies, setPolicies] = useState([]); + const [projects, setProjects] = useState([]); + const [frameworks, setFrameworks] = useState([]); + const [projectFrameworks, setProjectFrameworks] = useState([]); + const [subEntities, setSubEntities] = useState([]); + + // Loading states + const [loadingEntities, setLoadingEntities] = useState(false); + + // Track links that are pending removal (shown with strikethrough until saved) + const [pendingRemovals, setPendingRemovals] = useState([]); + + // Fetch vendors + const fetchVendors = useCallback(async () => { + try { + const response = await getAllVendors(); + setVendors(response?.data || []); + } catch (error) { + console.error("Error fetching vendors:", error); + } + }, []); + + // Fetch models + const fetchModels = useCallback(async () => { + try { + const response = await getAllEntities({ routeUrl: "/modelInventory" }); + setModels(response?.data || response || []); + } catch (error) { + console.error("Error fetching models:", error); + } + }, []); + + // Fetch policies + const fetchPolicies = useCallback(async () => { + try { + const data = await getAllPolicies(); + setPolicies(data || []); + } catch (error) { + console.error("Error fetching policies:", error); + } + }, []); + + // Fetch projects (use-cases) + const fetchProjects = useCallback(async () => { + try { + const response = await getAllEntities({ routeUrl: "/projects" }); + // Filter to only non-organizational projects (use-cases) + const allProjects = response?.data || []; + setProjects(allProjects.filter((p: any) => !p.is_organizational)); + } catch (error) { + console.error("Error fetching projects:", error); + } + }, []); + + // Fetch organizational frameworks (these are organizational projects with is_organizational = true) + const fetchFrameworks = useCallback(async () => { + try { + const response = await getAllEntities({ routeUrl: "/projects" }); + // Filter to only organizational projects (organizational frameworks) + const allProjects = response?.data || []; + const orgProjects = allProjects.filter((p: any) => p.is_organizational); + + // For each organizational project, we need its framework info + // Fetch full details for each to get framework association + const orgFrameworksWithDetails: any[] = []; + + await Promise.all( + orgProjects.map(async (project: any) => { + try { + const detailResponse = await getAllEntities({ routeUrl: `/projects/${project.id}` }); + // Handle nested data structure: response could be {data: project} or project directly + const projectData = detailResponse?.data || detailResponse; + // Framework array could be at different levels depending on serialization + const frameworks = projectData?.framework + || projectData?.dataValues?.framework + || (projectData?.data?.framework) + || (projectData?.data?.dataValues?.framework) + || []; + + // Add ALL frameworks from this organizational project, not just the first one + frameworks.forEach((framework: any) => { + // Extract project_framework_id - might be named differently + const pfId = framework?.project_framework_id + || framework?.projectFrameworkId + || framework?.id; + + if (pfId && framework?.framework_id) { + orgFrameworksWithDetails.push({ + id: project.id, + // Use framework name for display + name: framework?.name || `Framework ${framework?.framework_id || project.id}`, + framework_id: framework?.framework_id, + project_framework_id: pfId, + // Create a unique key combining project id and framework id + uniqueKey: `${project.id}_${framework?.framework_id}`, + }); + } + }); + } catch (err) { + console.error(`Error fetching project ${project.id} details:`, err); + orgFrameworksWithDetails.push({ + id: project.id, + name: `Framework ${project.id}`, + uniqueKey: `${project.id}_unknown`, + }); + } + }) + ); + + setFrameworks(orgFrameworksWithDetails); + } catch (error) { + console.error("Error fetching organizational frameworks:", error); + } + }, []); + + // Fetch frameworks for a specific project + const fetchProjectFrameworks = useCallback(async (projectId: number) => { + try { + const response = await getAllEntities({ routeUrl: `/projects/${projectId}` }); + // Handle both { data: project } and project directly + const project = response?.data || response; + const frameworks = project?.framework || project?.dataValues?.framework || []; + setProjectFrameworks(frameworks); + } catch (error) { + console.error("Error fetching project frameworks:", error); + setProjectFrameworks([]); + } + }, []); + + // Fetch sub-entities for a framework + const fetchSubEntities = useCallback(async (frameworkId: number, projectFrameworkId?: number) => { + setLoadingEntities(true); + try { + let subEntityList: any[] = []; + + switch (frameworkId) { + case 1: // EU AI Act + if (projectFrameworkId) { + const euResponse = await getAllEntities({ + routeUrl: `/eu-ai-act/compliances/byProjectId/${projectFrameworkId}` + }); + // Response is array of control categories, each with controls + const euData = euResponse?.data || euResponse || []; + euData.forEach((category: any) => { + const categoryNo = category.order_no || category.id || ""; + const controls = category.controls || []; + controls.forEach((control: any) => { + // API returns 'control_id' as the actual control ID from controls_eu table + const controlId = control.control_id; + const controlNo = control.order_no || ""; + const controlTitle = control.title; + // Format: "1.1 - Control Title" or "Category - Control Title" + const displayName = categoryNo && controlNo + ? `${categoryNo}.${controlNo} - ${controlTitle}` + : `${category.title} - ${controlTitle}`; + subEntityList.push({ + _id: `eu_control_${controlId}`, + entity_id: controlId, + name: displayName, + type: "eu_control", + }); + const subControls = control.subControls || control.dataValues?.subControls || []; + subControls.forEach((sub: any) => { + const subId = sub.id || sub.dataValues?.id; + const subNo = sub.order_no || sub.dataValues?.order_no || ""; + const subTitle = sub.title || sub.dataValues?.title || "Subcontrol"; + // Format: "1.1.a - Subcontrol Title" + const subDisplayName = categoryNo && controlNo && subNo + ? `${categoryNo}.${controlNo}.${subNo} - ${subTitle}` + : `${controlTitle || "Control"} - ${subTitle}`; + subEntityList.push({ + _id: `eu_subcontrol_${subId}`, + entity_id: subId, + name: subDisplayName, + type: "eu_subcontrol", + }); + }); + }); + }); + } + break; + + case 2: // ISO 42001 + if (projectFrameworkId) { + // Fetch subclauses + const isoClausesResponse = await getAllEntities({ + routeUrl: `/iso-42001/clauses/byProjectId/${projectFrameworkId}` + }); + const isoClauses = isoClausesResponse?.data || isoClausesResponse || []; + isoClauses.forEach((clause: any) => { + const clauseData = clause.dataValues || clause; + const subClauses = clauseData.subClauses || []; + subClauses.forEach((sub: any) => { + const subData = sub.dataValues || sub; + // Format: "Clause 4.1 - Understanding the organization..." + // Fields: clauseData.clause_no, subData.order_no + subEntityList.push({ + _id: `iso42001_subclause_${subData.id}`, + entity_id: subData.id, + name: `Clause ${clauseData.clause_no}.${subData.order_no} - ${subData.title}`, + type: "iso42001_subclause", + }); + }); + }); + + // Fetch annex categories - backend returns data in 'subClauses' field + const isoAnnexesResponse = await getAllEntities({ + routeUrl: `/iso-42001/annexes/byProjectId/${projectFrameworkId}` + }); + const isoAnnexes = isoAnnexesResponse?.data || isoAnnexesResponse || []; + isoAnnexes.forEach((annex: any) => { + const annexData = annex.dataValues || annex; + // Backend uses 'subClauses' for annex categories (naming inconsistency with getReferenceControlsQuery) + const annexCategories = annexData.subClauses || annexData.annexCategories || []; + annexCategories.forEach((cat: any) => { + const catData = cat.dataValues || cat; + // Format: "Annex A.5.1 - Policies for AI" + // Fields: annexData.annex_no, catData.order_no + subEntityList.push({ + _id: `iso42001_annexcategory_${catData.id}`, + entity_id: catData.id, + name: `Annex ${annexData.annex_no}.${catData.order_no} - ${catData.title}`, + type: "iso42001_annexcategory", + }); + }); + }); + } + break; + + case 3: // ISO 27001 + if (projectFrameworkId) { + // Fetch subclauses + const iso27ClausesResponse = await getAllEntities({ + routeUrl: `/iso-27001/clauses/byProjectId/${projectFrameworkId}` + }); + const iso27Clauses = iso27ClausesResponse?.data || iso27ClausesResponse || []; + iso27Clauses.forEach((clause: any) => { + const clauseData = clause.dataValues || clause; + const subClauses = clauseData.subClauses || []; + subClauses.forEach((sub: any) => { + const subData = sub.dataValues || sub; + // Format: "Clause 4.1 - Context of the organization" + // Fields: clauseData.arrangement, subData.order_no + subEntityList.push({ + _id: `iso27001_subclause_${subData.id}`, + entity_id: subData.id, + name: `Clause ${clauseData.arrangement}.${subData.order_no} - ${subData.title}`, + type: "iso27001_subclause", + }); + }); + }); + + // Fetch annex controls - backend uses 'subClauses' field (naming inconsistency) + const iso27AnnexesResponse = await getAllEntities({ + routeUrl: `/iso-27001/annexes/byProjectId/${projectFrameworkId}` + }); + const iso27Annexes = iso27AnnexesResponse?.data || iso27AnnexesResponse || []; + iso27Annexes.forEach((annex: any) => { + const annexData = annex.dataValues || annex; + const annexControls = annexData.subClauses || annexData.annexControls || []; + annexControls.forEach((ctrl: any) => { + const ctrlData = ctrl.dataValues || ctrl; + // Format: "A.5.1 - Policies for information security" + // Fields: annexData.arrangement, annexData.order_no, ctrlData.order_no + subEntityList.push({ + _id: `iso27001_annexcontrol_${ctrlData.id}`, + entity_id: ctrlData.id, + name: `${annexData.arrangement}.${annexData.order_no}.${ctrlData.order_no} - ${ctrlData.title}`, + type: "iso27001_annexcontrol", + }); + }); + }); + } + break; + + case 4: // NIST AI RMF + if (projectFrameworkId) { + const nistResponse = await getAllEntities({ + routeUrl: `/nist-ai-rmf/overview` + }); + const nistData = nistResponse?.data || nistResponse; + const functions = nistData?.functions || []; + functions.forEach((func: any) => { + const funcData = func.dataValues || func; + const funcType = funcData.type?.toUpperCase() || "FUNCTION"; + const categories = funcData.categories || []; + categories.forEach((cat: any, catIndex: number) => { + const catData = cat.dataValues || cat; + const subcategories = catData.subcategories || []; + // Always use function type + category index for unique category label + const categoryLabel = `${funcType} ${catIndex + 1}`; + subcategories.forEach((sub: any, subIndex: number) => { + const subData = sub.dataValues || sub; + const subTitle = subData.title || ""; + // Format: "GOVERN 1.1: Description..." or just "GOVERN 1.1" if no title + const subLabel = subTitle + ? `${categoryLabel}.${subIndex + 1}: ${subTitle.substring(0, 60)}${subTitle.length > 60 ? "..." : ""}` + : `${categoryLabel}.${subIndex + 1}`; + subEntityList.push({ + _id: `nist_subcategory_${subData.id}`, + entity_id: subData.id, + name: subLabel, + type: "nist_subcategory", + }); + }); + }); + }); + } + break; + } + + setSubEntities(subEntityList); + } catch (error) { + console.error("Error fetching sub-entities:", error); + setSubEntities([]); + } finally { + setLoadingEntities(false); + } + }, []); + + // Load initial data + useEffect(() => { + fetchVendors(); + fetchModels(); + fetchPolicies(); + fetchProjects(); + fetchFrameworks(); + }, [fetchVendors, fetchModels, fetchPolicies, fetchProjects, fetchFrameworks]); + + // Load project frameworks when project is selected + useEffect(() => { + if (selectedTopLevel === "use_case" && selectedProject) { + fetchProjectFrameworks(selectedProject as number); + } else { + setProjectFrameworks([]); + } + setSelectedFramework(""); + setSubEntities([]); + setSelectedEntityId(""); + }, [selectedTopLevel, selectedProject, fetchProjectFrameworks]); + + // Load sub-entities when framework is selected + useEffect(() => { + if (selectedFramework) { + let frameworkId: number | undefined; + let projectFrameworkId: number | undefined; + + if (selectedTopLevel === "use_case") { + // For use-case, selectedFramework is the project_framework_id + const pf = projectFrameworks.find((f: any) => + f.project_framework_id === selectedFramework || f.id === selectedFramework + ); + frameworkId = pf?.framework_id; + projectFrameworkId = selectedFramework as number; + } else { + // For organizational framework, selectedFramework is the uniqueKey (projectId_frameworkId) + // We need to get framework_id and project_framework_id from the frameworks array + const orgFramework = frameworks.find((f: any) => f.uniqueKey === selectedFramework || f.id === selectedFramework); + frameworkId = orgFramework?.framework_id; + projectFrameworkId = orgFramework?.project_framework_id; + } + + if (frameworkId) { + fetchSubEntities(frameworkId, projectFrameworkId); + } else { + setSubEntities([]); + } + } else { + setSubEntities([]); + } + setSelectedEntityId(""); + }, [selectedFramework, selectedTopLevel, projectFrameworks, frameworks, fetchSubEntities]); + + // Reset cascading selections when top level changes + useEffect(() => { + setSelectedProject(""); + setSelectedFramework(""); + setSelectedEntityId(""); + setSubEntities([]); + }, [selectedTopLevel]); + + // Get entity options for direct selection (vendor, model, policy) + const directEntityOptions = useMemo(() => { + switch (selectedTopLevel) { + case "vendor": + return vendors.map((v) => ({ + _id: v.id, + name: v.vendor_name || v.name || `Vendor ${v.id}`, + })); + case "model": + return models.map((m) => ({ + _id: m.id, + name: `${m.provider || ""} ${m.model || ""}`.trim() || `Model ${m.id}`, + })); + case "policy": + return policies.map((p) => ({ + _id: p.id, + name: p.title || `Policy ${p.id}`, + })); + default: + return []; + } + }, [selectedTopLevel, vendors, models, policies]); + + // Get project options + const projectOptions = useMemo(() => { + return projects.map((p) => ({ + _id: p.id, + name: p.project_title || p.name || `Project ${p.id}`, + })); + }, [projects]); + + // Get framework options (for organizational frameworks) + const frameworkOptions = useMemo(() => { + return frameworks.map((f) => ({ + _id: f.uniqueKey || f.id, + name: f.name || `Framework ${f.id}`, + })); + }, [frameworks]); + + // Get project framework options + const projectFrameworkOptions = useMemo(() => { + return projectFrameworks.map((pf: any) => ({ + _id: pf.project_framework_id || pf.id, + name: pf.name || `Framework ${pf.framework_id}`, + })); + }, [projectFrameworks]); + + // Determine if we can add a link + const canAddLink = useMemo(() => { + if (!selectedTopLevel) return false; + + if (["vendor", "model", "policy"].includes(selectedTopLevel)) { + return !!selectedEntityId; + } + + if (selectedTopLevel === "use_case" || selectedTopLevel === "framework") { + return !!selectedEntityId; + } + + return false; + }, [selectedTopLevel, selectedEntityId]); + + // Handle adding a new entity link + const handleAddLink = () => { + if (!canAddLink) return; + + let entityType: EntityType; + let entityName: string = ""; + let entityId: number; + + if (["vendor", "model", "policy"].includes(selectedTopLevel)) { + entityType = selectedTopLevel as EntityType; + const option = directEntityOptions.find((opt) => opt._id === selectedEntityId); + entityName = option?.name || `${selectedTopLevel} #${selectedEntityId}`; + entityId = Number(selectedEntityId); + } else { + // For use-case or framework, get the type and actual entity_id from sub-entity + const subEntity = subEntities.find((s) => s._id === selectedEntityId); + entityType = subEntity?.type as EntityType; + entityName = subEntity?.name || `Sub-entity #${selectedEntityId}`; + entityId = subEntity?.entity_id; + } + + const newLink: EntityLink = { + entity_id: entityId, + entity_type: entityType, + entity_name: entityName, + }; + + // Check for duplicates + const isDuplicate = value.some( + (link) => + link.entity_id === newLink.entity_id && + link.entity_type === newLink.entity_type + ); + + if (!isDuplicate) { + onChange([...value, newLink]); + } + + // Reset selection + setSelectedTopLevel(""); + setSelectedProject(""); + setSelectedFramework(""); + setSelectedEntityId(""); + }; + + // Handle removing an entity link (marks as pending removal) + const handleRemoveLink = (index: number) => { + const linkToRemove = value[index]; + // Add to pending removals for strikethrough display + setPendingRemovals((prev) => [...prev, linkToRemove]); + // Remove from actual value + const newLinks = value.filter((_, i) => i !== index); + onChange(newLinks); + }; + + // Handle undoing a pending removal + const handleUndoRemove = (link: EntityLink) => { + // Remove from pending removals + setPendingRemovals((prev) => + prev.filter( + (l) => + !(l.entity_id === link.entity_id && l.entity_type === link.entity_type) + ) + ); + // Add back to value + onChange([...value, link]); + }; + + // Get display name for entity type + const getEntityTypeDisplayName = (type: EntityType): string => { + const displayNames: Record = { + vendor: "Vendor", + model: "Model", + policy: "Policy", + nist_subcategory: "NIST AI RMF", + iso42001_subclause: "ISO 42001 Clause", + iso42001_annexcategory: "ISO 42001 Annex", + iso27001_subclause: "ISO 27001 Clause", + iso27001_annexcontrol: "ISO 27001 Annex", + eu_control: "EU AI Act Control", + eu_subcontrol: "EU AI Act Subcontrol", + }; + return displayNames[type] || type; + }; + + // Render the appropriate dropdowns based on selection + const renderSelectionDropdowns = () => { + const dropdowns: React.ReactNode[] = []; + + // First dropdown - Entity Type + dropdowns.push( + setSelectedTopLevel(e.target.value)} + disabled={disabled} + sx={{ + width: "200px", + backgroundColor: theme.palette.background.main, + }} + /> + ); + + if (!selectedTopLevel) return dropdowns; + + // Direct entity selection (vendor, model, policy) + if (["vendor", "model", "policy"].includes(selectedTopLevel)) { + dropdowns.push( + setSelectedEntityId(e.target.value)} + disabled={disabled || directEntityOptions.length === 0} + sx={{ + width: "280px", + backgroundColor: theme.palette.background.main, + }} + /> + ); + } + + // Use-case selection (Project → Framework → Sub-entity) + if (selectedTopLevel === "use_case") { + dropdowns.push( + setSelectedProject(e.target.value)} + disabled={disabled || projectOptions.length === 0} + sx={{ + width: "200px", + backgroundColor: theme.palette.background.main, + }} + /> + ); + + if (selectedProject) { + dropdowns.push( + setSelectedFramework(e.target.value)} + disabled={disabled || projectFrameworkOptions.length === 0} + sx={{ + width: "180px", + backgroundColor: theme.palette.background.main, + }} + /> + ); + } + + if (selectedFramework && subEntities.length > 0) { + dropdowns.push( + setSelectedEntityId(e.target.value)} + disabled={disabled || loadingEntities} + sx={{ + width: "280px", + backgroundColor: theme.palette.background.main, + }} + /> + ); + } + } + + // Framework (Organizational) selection (Framework → Sub-entity) + if (selectedTopLevel === "framework") { + dropdowns.push( + setSelectedFramework(e.target.value)} + disabled={disabled || frameworkOptions.length === 0} + sx={{ + width: "200px", + backgroundColor: theme.palette.background.main, + }} + /> + ); + + if (selectedFramework && subEntities.length > 0) { + dropdowns.push( + setSelectedEntityId(e.target.value)} + disabled={disabled || loadingEntities} + sx={{ + width: "280px", + backgroundColor: theme.palette.background.main, + }} + /> + ); + } + } + + return dropdowns; + }; + + return ( + + + Linked Items + + + {/* Display existing links */} + {(value.length > 0 || pendingRemovals.length > 0) && ( + + {/* Active links */} + {value.map((link, index) => ( + + + + {getEntityTypeDisplayName(link.entity_type)}: + + + {link.entity_name || `#${link.entity_id}`} + + + handleRemoveLink(index)} + disabled={disabled} + sx={{ padding: "2px" }} + > + + + + ))} + + {/* Pending removal links (strikethrough) */} + {pendingRemovals.map((link) => ( + + + + {getEntityTypeDisplayName(link.entity_type)}: + + + {link.entity_name || `#${link.entity_id}`} + + + (will be removed) + + + handleUndoRemove(link)} + disabled={disabled} + sx={{ + padding: "2px", + color: theme.palette.primary.main, + }} + title="Undo removal" + > + + + + ))} + + )} + + {/* Add new link - cascading dropdowns */} + + {renderSelectionDropdowns()} + + {canAddLink && ( + + + + )} + + + {/* Helper messages */} + {selectedTopLevel === "use_case" && projectOptions.length === 0 && ( + + No use-cases available + + )} + + {selectedTopLevel === "framework" && frameworkOptions.length === 0 && ( + + No organizational frameworks available + + )} + + {selectedFramework && !loadingEntities && subEntities.length === 0 && ( + + No sub-entities found for this framework + + )} + + ); +}; + +export default EntityLinkSelector; +export type { EntityLink }; diff --git a/Clients/src/presentation/components/Modals/CreateTask/index.tsx b/Clients/src/presentation/components/Modals/CreateTask/index.tsx index 816867053b..b46288b62e 100644 --- a/Clients/src/presentation/components/Modals/CreateTask/index.tsx +++ b/Clients/src/presentation/components/Modals/CreateTask/index.tsx @@ -20,6 +20,7 @@ const Field = lazy(() => import("../../Inputs/Field")); const DatePicker = lazy(() => import("../../Inputs/Datepicker")); import SelectComponent from "../../Inputs/Select"; import ChipInput from "../../Inputs/ChipInput"; +import EntityLinkSelector, { EntityLink } from "../../EntityLinkSelector"; import { ChevronDown as GreyDownArrowIcon, Flag } from "lucide-react"; import StandardModal from "../StandardModal"; import TabBar from "../../TabBar"; @@ -48,6 +49,7 @@ const initialState: ICreateTaskFormValues = { due_date: "", assignees: [], categories: [], + entity_links: [], }; const statusOptions = [ @@ -138,6 +140,7 @@ const CreateTask: FC = ({ ); })(), categories: initialData.categories || [], + entity_links: (initialData as any).entity_links || [], }); } else { setValues(initialState); @@ -200,6 +203,13 @@ const CreateTask: FC = ({ } }, []); + const handleEntityLinksChange = useCallback((newLinks: EntityLink[]) => { + setValues((prev) => ({ + ...prev, + entity_links: newLinks, + })); + }, []); + const validateForm = (): boolean => { const newErrors: ICreateTaskFormErrors = {}; @@ -548,13 +558,13 @@ const CreateTask: FC = ({ - {/* Row 4: Description (full width = 350px + 350px + 48px gap) */} - + {/* Row 4: Description (full width) */} + Loading...}> = ({ placeholder="Enter description" /> - + + + {/* Row 5: Entity Links */} + + + + ); diff --git a/Clients/src/presentation/pages/ComplianceTracker/1.0ComplianceTracker/ControlCategory.tsx b/Clients/src/presentation/pages/ComplianceTracker/1.0ComplianceTracker/ControlCategory.tsx index c9306fd897..acadc03c2c 100644 --- a/Clients/src/presentation/pages/ComplianceTracker/1.0ComplianceTracker/ControlCategory.tsx +++ b/Clients/src/presentation/pages/ComplianceTracker/1.0ComplianceTracker/ControlCategory.tsx @@ -12,7 +12,7 @@ import { Typography, } from "@mui/material"; import { ControlCategory as ControlCategoryModel } from "../../../../domain/types/ControlCategory"; -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import { ChevronRight } from "lucide-react"; import ControlsTable from "./ControlsTable"; @@ -32,6 +32,7 @@ interface ControlCategoryProps { ownerFilter?: string; approverFilter?: string; dueDateFilter?: string; + initialControlCategoryId?: number | null; } const ControlCategoryTile: React.FC = ({ @@ -42,11 +43,34 @@ const ControlCategoryTile: React.FC = ({ statusFilter, ownerFilter, approverFilter, - dueDateFilter + dueDateFilter, + initialControlCategoryId, }) => { - const [expanded, setExpanded] = useState(false); + const accordionRef = useRef(null); + + // Auto-expand if this category matches the initialControlCategoryId + const [expanded, setExpanded] = useState(() => { + if (initialControlCategoryId && controlCategory.id === initialControlCategoryId) { + return controlCategory.id; + } + return false; + }); const [filteredControlsCount, setFilteredControlsCount] = useState(null); + // Update expanded state and scroll into view when initialControlCategoryId changes + useEffect(() => { + if (initialControlCategoryId && controlCategory.id === initialControlCategoryId) { + setExpanded(controlCategory.id); + // Scroll into view after a short delay to allow accordion expansion animation + setTimeout(() => { + accordionRef.current?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + }, 100); + } + }, [initialControlCategoryId, controlCategory.id]); + const handleAccordionChange = (panel: number) => (_: React.SyntheticEvent, isExpanded: boolean) => { setExpanded(isExpanded ? panel : false); @@ -57,7 +81,7 @@ const ControlCategoryTile: React.FC = ({ : { bg: "#FFF8E1", color: "#795548" }; return ( - + = ({ useEffect(() => { if (controlId) { + // URL param 'controlId' is the tenant table ID (controls_eu.id) + // Find control by matching control_id (tenant table ID) const controlExists = controls.find( - (control) => control.id === Number(controlId) + (control: any) => control.control_id === Number(controlId) ); if (controlExists) { (async () => { + // API expects struct table ID (control.id), not tenant table ID (control.control_id) const subControlsResponse = await getControlByIdAndProject({ - controlId: controlExists.id!, + controlId: (controlExists as any).id, projectFrameworkId, owner: ownerFilter, approver: approverFilter, diff --git a/Clients/src/presentation/pages/ComplianceTracker/1.0ComplianceTracker/index.tsx b/Clients/src/presentation/pages/ComplianceTracker/1.0ComplianceTracker/index.tsx index ef34a2f6a3..5b14e87df9 100644 --- a/Clients/src/presentation/pages/ComplianceTracker/1.0ComplianceTracker/index.tsx +++ b/Clients/src/presentation/pages/ComplianceTracker/1.0ComplianceTracker/index.tsx @@ -1,5 +1,6 @@ -import { useContext, useEffect, useState } from "react"; +import { useContext, useEffect, useState, useCallback } from "react"; import { Stack, Typography } from "@mui/material"; +import { useSearchParams } from "react-router-dom"; import { pageHeadingStyle } from "../../Assessment/1.0AssessmentTracker/index.style"; import { getEntityById } from "../../../../application/repository/entity.repository"; import { StatsCard } from "../../../components/Cards/StatsCard"; @@ -12,7 +13,7 @@ import useMultipleOnScreen from "../../../../application/hooks/useMultipleOnScre import { VerifyWiseContext } from "../../../../application/contexts/VerifyWise.context"; import { ComplianceData } from "../../../../domain/interfaces/i.compliance"; import { Project } from "../../../../domain/types/Project"; -import { getComplianceProgress } from "../../../../application/repository/control_eu_act.repository"; +import { getComplianceProgress, getControlsByControlCategoryId } from "../../../../application/repository/control_eu_act.repository"; const ComplianceTracker = ({ project, @@ -27,6 +28,9 @@ const ComplianceTracker = ({ approverFilter?: string; dueDateFilter?: string; }) => { + const [searchParams] = useSearchParams(); + const controlId = searchParams.get("controlId"); + const currentProjectId = project?.id; const currentProjectFramework = project.framework?.filter( (p) => p.framework_id === 1 @@ -39,6 +43,39 @@ const ComplianceTracker = ({ const { componentsVisible, changeComponentVisibility } = useContext(VerifyWiseContext); const [runComplianceTour, setRunComplianceTour] = useState(false); + const [initialControlCategoryId, setInitialControlCategoryId] = useState(null); + + // Find which control category contains the target controlId + const findControlCategoryId = useCallback(async () => { + if (!controlId || !currentProjectFramework || !controlCategories) { + return; + } + + for (const category of controlCategories) { + if (!category.id) continue; + try { + const controls = await getControlsByControlCategoryId({ + controlCategoryId: category.id, + projectFrameworkId: currentProjectFramework, + }); + // API returns 'control_id' as the actual control ID from controls_eu table + const found = controls?.find((c: any) => c.control_id === Number(controlId)); + if (found) { + setInitialControlCategoryId(category.id); + return; + } + } catch (err) { + console.error("Error finding control category:", err); + } + } + }, [controlId, currentProjectFramework, controlCategories]); + + // Find the control's category when controlId is present and categories are loaded + useEffect(() => { + if (controlId && controlCategories && controlCategories.length > 0) { + findControlCategoryId(); + } + }, [controlId, controlCategories, findControlCategoryId]); const { refs, allVisible } = useMultipleOnScreen({ countToTrigger: 2, @@ -177,6 +214,7 @@ const ComplianceTracker = ({ ownerFilter={ownerFilter} approverFilter={approverFilter} dueDateFilter={dueDateFilter} + initialControlCategoryId={initialControlCategoryId} /> ) : ( @@ -190,6 +228,7 @@ const ComplianceTracker = ({ ownerFilter={ownerFilter} approverFilter={approverFilter} dueDateFilter={dueDateFilter} + initialControlCategoryId={initialControlCategoryId} /> ) )} diff --git a/Clients/src/presentation/pages/Framework/ISO27001/Annex/index.tsx b/Clients/src/presentation/pages/Framework/ISO27001/Annex/index.tsx index f24c11b671..5848ecb874 100644 --- a/Clients/src/presentation/pages/Framework/ISO27001/Annex/index.tsx +++ b/Clients/src/presentation/pages/Framework/ISO27001/Annex/index.tsx @@ -82,6 +82,7 @@ const ISO27001Annex = ({ const [searchParams, setSearchParams] = useSearchParams(); const annexId = initialAnnexId; const annexControlId = initialAnnexControlId; + const [lastProcessedLink, setLastProcessedLink] = useState(null); // Shared function to filter controls based on all active filters const filterControls = useCallback((controls: any[]) => { @@ -188,18 +189,23 @@ const ISO27001Annex = ({ const activeAnnexId = initialAnnexId || annexId; const activeAnnexControlId = initialAnnexControlId || annexControlId; - if (activeAnnexId && annexes && annexes.length > 0) { - const annex = annexes.find((a: any) => a.id === Number(activeAnnexId)); - if (annex) { - handleAccordionChange(annex.id)(new Event("click") as any, true); - const annexControl = annex.annexControls?.find( - (ac: any) => ac.id === Number(activeAnnexControlId), - ); - if (annexControl) handleControlClick(annex, annexControl); + if (!activeAnnexId || !activeAnnexControlId || !annexes || annexes.length === 0) return; + + const linkKey = `${activeAnnexId}-${activeAnnexControlId}`; + if (lastProcessedLink === linkKey) return; + + const annex = annexes.find((a: any) => a.id === Number(activeAnnexId)); + if (annex) { + handleAccordionChange(annex.id)(new Event("click") as any, true); + const annexControl = annex.annexControls?.find( + (ac: any) => ac.id === Number(activeAnnexControlId), + ); + if (annexControl) { + setLastProcessedLink(linkKey); + handleControlClick(annex, annexControl); } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [annexId, annexes, annexControlId, annexId, annexControlId]); + }, [annexId, annexes, annexControlId, initialAnnexId, initialAnnexControlId, lastProcessedLink]); const filteredAnnexes = useMemo(() => { diff --git a/Clients/src/presentation/pages/Framework/ISO27001/Clause/index.tsx b/Clients/src/presentation/pages/Framework/ISO27001/Clause/index.tsx index e405b282ae..4a6277ab48 100644 --- a/Clients/src/presentation/pages/Framework/ISO27001/Clause/index.tsx +++ b/Clients/src/presentation/pages/Framework/ISO27001/Clause/index.tsx @@ -87,6 +87,7 @@ const ISO27001Clause = ({ const [searchParams, setSearchParams] = useSearchParams(); const clauseId = initialClauseId; const subClauseId = initialSubClauseId; + const [lastProcessedLink, setLastProcessedLink] = useState(null); const filterSubClauses = useCallback((subClauses: any[]) => { let filtered = subClauses; @@ -367,16 +368,44 @@ const ISO27001Clause = ({ ); } + // Deep link - Step 1: Expand the clause accordion to trigger subclause fetching useEffect(() => { - if (clauseId && subClauseId && clauses.length > 0) { - const clause = clauses.find((c) => c.id === parseInt(clauseId)); - const idx = clause?.subClauses.findIndex( - (sc: any) => sc.id === parseInt(subClauseId), - ); - handleSubClauseClick(clause, {id: parseInt(subClauseId)}, idx ?? 0); + if (!clauseId || !subClauseId || !clauses || clauses.length === 0) return; + + const linkKey = `${clauseId}-${subClauseId}`; + if (lastProcessedLink === linkKey) return; + + const parsedClauseId = parseInt(clauseId); + const clause = clauses.find((c) => c.id === parsedClauseId); + if (clause) { + // Expand the clause to trigger subclause fetch + setExpanded(parsedClauseId); + } + }, [clauseId, subClauseId, clauses, lastProcessedLink]); + + // Deep link - Step 2: Open the drawer when subclauses are loaded + useEffect(() => { + if (!clauseId || !subClauseId) return; + + const linkKey = `${clauseId}-${subClauseId}`; + if (lastProcessedLink === linkKey) return; + + const parsedClauseId = parseInt(clauseId); + const parsedSubClauseId = parseInt(subClauseId); + const subClauses = subClausesMap[parsedClauseId]; + + if (subClauses && subClauses.length > 0) { + const clause = clauses.find((c) => c.id === parsedClauseId); + const subClause = subClauses.find((sc: any) => sc.id === parsedSubClauseId); + + if (clause && subClause) { + const idx = subClauses.findIndex((sc: any) => sc.id === parsedSubClauseId); + setLastProcessedLink(linkKey); + handleSubClauseClick(clause, subClause, idx); + } } - }, [clauseId, subClauseId, initialClauseId, initialSubClauseId, clauses, projectFrameworkId, handleSubClauseClick]); - + }, [clauseId, subClauseId, subClausesMap, clauses, lastProcessedLink, handleSubClauseClick]); + return ( {alert && ( diff --git a/Clients/src/presentation/pages/Framework/ISO42001/Annex/index.tsx b/Clients/src/presentation/pages/Framework/ISO42001/Annex/index.tsx index 5348a4d381..db25cb5d4d 100644 --- a/Clients/src/presentation/pages/Framework/ISO42001/Annex/index.tsx +++ b/Clients/src/presentation/pages/Framework/ISO42001/Annex/index.tsx @@ -78,6 +78,7 @@ const ISO42001Annex = ({ const annexId = initialAnnexId; const annexControlId = initialAnnexCategoryId; + const [lastProcessedLink, setLastProcessedLink] = useState(null); // Shared function to filter controls based on all active filters const filterControls = useCallback((controls: any[]) => { @@ -179,18 +180,23 @@ const ISO42001Annex = ({ useEffect(() => { // Use initialAnnexId/initialAnnexCategoryId props first, fallback to URL params + if (!annexId || !annexControlId || !annexes || annexes.length === 0) return; - if (annexId && annexes && annexes.length > 0) { - const annex = annexes.find((a: any) => a.id === Number(annexId)); - if (annex) { - handleAccordionChange(annex.id)(new Event("click") as any, true); - const annexCategory = annex.annexCategories?.find( - (ac: any) => ac.id === Number(annexControlId), - ); - if (annexCategory) handleControlClick(annex, annexCategory); + const linkKey = `${annexId}-${annexControlId}`; + if (lastProcessedLink === linkKey) return; + + const annex = annexes.find((a: any) => a.id === Number(annexId)); + if (annex) { + handleAccordionChange(annex.id)(new Event("click") as any, true); + const annexCategory = annex.annexCategories?.find( + (ac: any) => ac.id === Number(annexControlId), + ); + if (annexCategory) { + setLastProcessedLink(linkKey); + handleControlClick(annex, annexCategory); } } - }, [annexId, annexes, annexControlId, initialAnnexId, initialAnnexCategoryId]); + }, [annexId, annexes, annexControlId, lastProcessedLink]); const filteredAnnexes = useMemo(() => { diff --git a/Clients/src/presentation/pages/Framework/ISO42001/Clause/index.tsx b/Clients/src/presentation/pages/Framework/ISO42001/Clause/index.tsx index 96fde12a66..b7e6eeb28a 100644 --- a/Clients/src/presentation/pages/Framework/ISO42001/Clause/index.tsx +++ b/Clients/src/presentation/pages/Framework/ISO42001/Clause/index.tsx @@ -86,6 +86,7 @@ const ISO42001Clause = ({ const [searchParams, setSearchParams] = useSearchParams(); const clauseId = initialClauseId; const subClauseId = initialSubClauseId; + const [lastProcessedLink, setLastProcessedLink] = useState(null); // Shared function to filter subclauses based on all active filters const filterSubClauses = useCallback((subClauses: any[]) => { @@ -364,15 +365,43 @@ const ISO42001Clause = ({ ); } + // Deep link - Step 1: Expand the clause accordion to trigger subclause fetching useEffect(() => { - if (clauseId && subClauseId && clauses.length > 0) { - const clause = clauses.find((c) => c.id === parseInt(clauseId)); - const idx = clause?.subClauses.findIndex( - (sc: any) => sc.id === parseInt(subClauseId), - ); - handleSubClauseClick(clause, {id: parseInt(subClauseId)}, idx ?? 0); + if (!clauseId || !subClauseId || !clauses || clauses.length === 0) return; + + const linkKey = `${clauseId}-${subClauseId}`; + if (lastProcessedLink === linkKey) return; + + const parsedClauseId = parseInt(clauseId); + const clause = clauses.find((c) => c.id === parsedClauseId); + if (clause) { + // Expand the clause to trigger subclause fetch + setExpanded(parsedClauseId); + } + }, [clauseId, subClauseId, clauses, lastProcessedLink]); + + // Deep link - Step 2: Open the drawer when subclauses are loaded + useEffect(() => { + if (!clauseId || !subClauseId) return; + + const linkKey = `${clauseId}-${subClauseId}`; + if (lastProcessedLink === linkKey) return; + + const parsedClauseId = parseInt(clauseId); + const parsedSubClauseId = parseInt(subClauseId); + const subClauses = subClausesMap[parsedClauseId]; + + if (subClauses && subClauses.length > 0) { + const clause = clauses.find((c) => c.id === parsedClauseId); + const subClause = subClauses.find((sc: any) => sc.id === parsedSubClauseId); + + if (clause && subClause) { + const idx = subClauses.findIndex((sc: any) => sc.id === parsedSubClauseId); + setLastProcessedLink(linkKey); + handleSubClauseClick(clause, subClause, idx); + } } - }, [clauseId, subClauseId, clauses]); + }, [clauseId, subClauseId, subClausesMap, clauses, lastProcessedLink, handleSubClauseClick]); return ( diff --git a/Clients/src/presentation/pages/Framework/NIST-AI-RMF/Govern/index.tsx b/Clients/src/presentation/pages/Framework/NIST-AI-RMF/Govern/index.tsx index 8f9d42533c..5dc9f0e065 100644 --- a/Clients/src/presentation/pages/Framework/NIST-AI-RMF/Govern/index.tsx +++ b/Clients/src/presentation/pages/Framework/NIST-AI-RMF/Govern/index.tsx @@ -7,7 +7,7 @@ import { Typography, } from "@mui/material"; import { getEntityById } from "../../../../../application/repository/entity.repository"; -import { useCallback, useEffect, useState, useMemo } from "react"; +import { useCallback, useEffect, useState, useMemo, useRef } from "react"; import { updateNISTAIRMFSubcategoryStatus } from "../../../../components/StatusDropdown/statusUpdateApi"; import { styles } from "../../ISO27001/Clause/style"; import { ArrowRight as RightArrowBlack } from "lucide-react"; @@ -31,6 +31,8 @@ interface NISTAIRMFGovernProps { statusOptions?: { value: string; label: string }[]; searchTerm?: string; onSearchTermChange?: (term: string) => void; + initialCategoryId?: string | null; + initialSubcategoryId?: string | null; } const NISTAIRMFGovern = ({ @@ -41,6 +43,8 @@ const NISTAIRMFGovern = ({ statusOptions, searchTerm = "", onSearchTermChange, + initialCategoryId, + initialSubcategoryId, }: NISTAIRMFGovernProps) => { const { userId: _userId, userRoleName } = useAuth(); const [categories, setCategories] = useState([]); @@ -62,6 +66,9 @@ const NISTAIRMFGovern = ({ const [selectedSubcategory, setSelectedSubcategory] = useState(null); const [selectedCategory, setSelectedCategory] = useState(null); + // Ref to track if auto-open has been triggered + const hasAutoOpenedRef = useRef(false); + const fetchCategories = useCallback(async () => { try { const response = await getEntityById({ @@ -148,8 +155,48 @@ const NISTAIRMFGovern = ({ setDrawerOpen(false); setSelectedSubcategory(null); setSelectedCategory(null); + // Clear URL params when drawer closes + if (initialCategoryId && initialSubcategoryId) { + searchParams.delete("categoryId"); + searchParams.delete("subcategoryId"); + searchParams.delete("framework"); + searchParams.delete("functionId"); + setSearchParams(searchParams); + } }; + // Auto-open drawer when initialCategoryId and initialSubcategoryId are provided + useEffect(() => { + // Skip if already auto-opened or missing params + if (hasAutoOpenedRef.current || !initialCategoryId || !initialSubcategoryId || categories.length === 0) { + return; + } + + const catId = parseInt(initialCategoryId); + const subCatId = parseInt(initialSubcategoryId); + + // Expand the category accordion + setExpanded(catId); + + // Find the category + const category = categories.find((c) => c.id === catId); + if (!category) return; + + // Check if subcategories are already loaded + const subcategories = subcategoriesMap[catId]; + if (subcategories && subcategories.length > 0) { + // Find the subcategory and open drawer + const subcategory = subcategories.find((sc: any) => sc.id === subCatId); + if (subcategory) { + handleSubcategoryClick(category, subcategory, 0); + hasAutoOpenedRef.current = true; + } + } else if (!loadingSubcategories[catId]) { + // Trigger fetch if not already loading + fetchSubcategories(catId, "GOVERN"); + } + }, [initialCategoryId, initialSubcategoryId, categories, subcategoriesMap, loadingSubcategories, fetchSubcategories, handleSubcategoryClick]); + const handleDrawerSaveSuccess = (success: boolean, _message?: string, savedSubcategoryId?: number) => { if (success && savedSubcategoryId) { // Set flashing row ID for green highlighting diff --git a/Clients/src/presentation/pages/Framework/NIST-AI-RMF/Manage/index.tsx b/Clients/src/presentation/pages/Framework/NIST-AI-RMF/Manage/index.tsx index 78fb599c17..e36c581867 100644 --- a/Clients/src/presentation/pages/Framework/NIST-AI-RMF/Manage/index.tsx +++ b/Clients/src/presentation/pages/Framework/NIST-AI-RMF/Manage/index.tsx @@ -7,7 +7,7 @@ import { Typography, } from "@mui/material"; import { getEntityById } from "../../../../../application/repository/entity.repository"; -import { useCallback, useEffect, useState, useMemo } from "react"; +import { useCallback, useEffect, useState, useMemo, useRef } from "react"; import { updateNISTAIRMFSubcategoryStatus } from "../../../../components/StatusDropdown/statusUpdateApi"; import { styles } from "../../ISO27001/Clause/style"; import { ArrowRight as RightArrowBlack } from "lucide-react"; @@ -31,6 +31,8 @@ interface NISTAIRMFManageProps { statusOptions?: { value: string; label: string }[]; searchTerm?: string; onSearchTermChange?: (term: string) => void; + initialCategoryId?: string | null; + initialSubcategoryId?: string | null; } const NISTAIRMFManage = ({ @@ -41,6 +43,8 @@ const NISTAIRMFManage = ({ statusOptions, searchTerm = "", onSearchTermChange, + initialCategoryId, + initialSubcategoryId, }: NISTAIRMFManageProps) => { const { userId: _userId, userRoleName } = useAuth(); const [categories, setCategories] = useState([]); @@ -62,6 +66,9 @@ const NISTAIRMFManage = ({ const [selectedSubcategory, setSelectedSubcategory] = useState(null); const [selectedCategory, setSelectedCategory] = useState(null); + // Ref to track if auto-open has been triggered + const hasAutoOpenedRef = useRef(false); + const fetchCategories = useCallback(async () => { try { const response = await getEntityById({ @@ -148,8 +155,48 @@ const NISTAIRMFManage = ({ setDrawerOpen(false); setSelectedSubcategory(null); setSelectedCategory(null); + // Clear URL params when drawer closes + if (initialCategoryId && initialSubcategoryId) { + searchParams.delete("categoryId"); + searchParams.delete("subcategoryId"); + searchParams.delete("framework"); + searchParams.delete("functionId"); + setSearchParams(searchParams); + } }; + // Auto-open drawer when initialCategoryId and initialSubcategoryId are provided + useEffect(() => { + // Skip if already auto-opened or missing params + if (hasAutoOpenedRef.current || !initialCategoryId || !initialSubcategoryId || categories.length === 0) { + return; + } + + const catId = parseInt(initialCategoryId); + const subCatId = parseInt(initialSubcategoryId); + + // Expand the category accordion + setExpanded(catId); + + // Find the category + const category = categories.find((c) => c.id === catId); + if (!category) return; + + // Check if subcategories are already loaded + const subcategories = subcategoriesMap[catId]; + if (subcategories && subcategories.length > 0) { + // Find the subcategory and open drawer + const subcategory = subcategories.find((sc: any) => sc.id === subCatId); + if (subcategory) { + handleSubcategoryClick(category, subcategory, 0); + hasAutoOpenedRef.current = true; + } + } else if (!loadingSubcategories[catId]) { + // Trigger fetch if not already loading + fetchSubcategories(catId, "MANAGE"); + } + }, [initialCategoryId, initialSubcategoryId, categories, subcategoriesMap, loadingSubcategories, fetchSubcategories, handleSubcategoryClick]); + const handleDrawerSaveSuccess = (success: boolean, _message?: string, savedSubcategoryId?: number) => { if (success && savedSubcategoryId) { // Set flashing row ID for green highlighting diff --git a/Clients/src/presentation/pages/Framework/NIST-AI-RMF/Map/index.tsx b/Clients/src/presentation/pages/Framework/NIST-AI-RMF/Map/index.tsx index d0ddb91ce8..de9e8b8a2e 100644 --- a/Clients/src/presentation/pages/Framework/NIST-AI-RMF/Map/index.tsx +++ b/Clients/src/presentation/pages/Framework/NIST-AI-RMF/Map/index.tsx @@ -7,7 +7,7 @@ import { Typography, } from "@mui/material"; import { getEntityById } from "../../../../../application/repository/entity.repository"; -import { useCallback, useEffect, useState, useMemo } from "react"; +import { useCallback, useEffect, useState, useMemo, useRef } from "react"; import { updateNISTAIRMFSubcategoryStatus } from "../../../../components/StatusDropdown/statusUpdateApi"; import { styles } from "../../ISO27001/Clause/style"; import { ArrowRight as RightArrowBlack } from "lucide-react"; @@ -31,6 +31,8 @@ interface NISTAIRMFMapProps { statusOptions?: { value: string; label: string }[]; searchTerm?: string; onSearchTermChange?: (term: string) => void; + initialCategoryId?: string | null; + initialSubcategoryId?: string | null; } const NISTAIRMFMap = ({ @@ -41,6 +43,8 @@ const NISTAIRMFMap = ({ statusOptions, searchTerm = "", onSearchTermChange, + initialCategoryId, + initialSubcategoryId, }: NISTAIRMFMapProps) => { const { userId: _userId, userRoleName } = useAuth(); const [categories, setCategories] = useState([]); @@ -62,6 +66,9 @@ const NISTAIRMFMap = ({ const [selectedSubcategory, setSelectedSubcategory] = useState(null); const [selectedCategory, setSelectedCategory] = useState(null); + // Ref to track if auto-open has been triggered + const hasAutoOpenedRef = useRef(false); + const fetchCategories = useCallback(async () => { try { const response = await getEntityById({ @@ -148,8 +155,48 @@ const NISTAIRMFMap = ({ setDrawerOpen(false); setSelectedSubcategory(null); setSelectedCategory(null); + // Clear URL params when drawer closes + if (initialCategoryId && initialSubcategoryId) { + searchParams.delete("categoryId"); + searchParams.delete("subcategoryId"); + searchParams.delete("framework"); + searchParams.delete("functionId"); + setSearchParams(searchParams); + } }; + // Auto-open drawer when initialCategoryId and initialSubcategoryId are provided + useEffect(() => { + // Skip if already auto-opened or missing params + if (hasAutoOpenedRef.current || !initialCategoryId || !initialSubcategoryId || categories.length === 0) { + return; + } + + const catId = parseInt(initialCategoryId); + const subCatId = parseInt(initialSubcategoryId); + + // Expand the category accordion + setExpanded(catId); + + // Find the category + const category = categories.find((c) => c.id === catId); + if (!category) return; + + // Check if subcategories are already loaded + const subcategories = subcategoriesMap[catId]; + if (subcategories && subcategories.length > 0) { + // Find the subcategory and open drawer + const subcategory = subcategories.find((sc: any) => sc.id === subCatId); + if (subcategory) { + handleSubcategoryClick(category, subcategory, 0); + hasAutoOpenedRef.current = true; + } + } else if (!loadingSubcategories[catId]) { + // Trigger fetch if not already loading + fetchSubcategories(catId, "MAP"); + } + }, [initialCategoryId, initialSubcategoryId, categories, subcategoriesMap, loadingSubcategories, fetchSubcategories, handleSubcategoryClick]); + const handleDrawerSaveSuccess = (success: boolean, _message?: string, savedSubcategoryId?: number) => { if (success && savedSubcategoryId) { // Set flashing row ID for green highlighting diff --git a/Clients/src/presentation/pages/Framework/NIST-AI-RMF/Measure/index.tsx b/Clients/src/presentation/pages/Framework/NIST-AI-RMF/Measure/index.tsx index c30d37dfb6..11cf2f2105 100644 --- a/Clients/src/presentation/pages/Framework/NIST-AI-RMF/Measure/index.tsx +++ b/Clients/src/presentation/pages/Framework/NIST-AI-RMF/Measure/index.tsx @@ -7,7 +7,7 @@ import { Typography, } from "@mui/material"; import { getEntityById } from "../../../../../application/repository/entity.repository"; -import { useCallback, useEffect, useState, useMemo } from "react"; +import { useCallback, useEffect, useState, useMemo, useRef } from "react"; import { updateNISTAIRMFSubcategoryStatus } from "../../../../components/StatusDropdown/statusUpdateApi"; import { styles } from "../../ISO27001/Clause/style"; import { ArrowRight as RightArrowBlack } from "lucide-react"; @@ -31,6 +31,8 @@ interface NISTAIRMFMeasureProps { statusOptions?: { value: string; label: string }[]; searchTerm?: string; onSearchTermChange?: (term: string) => void; + initialCategoryId?: string | null; + initialSubcategoryId?: string | null; } const NISTAIRMFMeasure = ({ @@ -41,6 +43,8 @@ const NISTAIRMFMeasure = ({ statusOptions, searchTerm = "", onSearchTermChange, + initialCategoryId, + initialSubcategoryId, }: NISTAIRMFMeasureProps) => { const { userId: _userId, userRoleName } = useAuth(); const [categories, setCategories] = useState([]); @@ -62,6 +66,9 @@ const NISTAIRMFMeasure = ({ const [selectedSubcategory, setSelectedSubcategory] = useState(null); const [selectedCategory, setSelectedCategory] = useState(null); + // Ref to track if auto-open has been triggered + const hasAutoOpenedRef = useRef(false); + const fetchCategories = useCallback(async () => { try { const response = await getEntityById({ @@ -148,8 +155,48 @@ const NISTAIRMFMeasure = ({ setDrawerOpen(false); setSelectedSubcategory(null); setSelectedCategory(null); + // Clear URL params when drawer closes + if (initialCategoryId && initialSubcategoryId) { + searchParams.delete("categoryId"); + searchParams.delete("subcategoryId"); + searchParams.delete("framework"); + searchParams.delete("functionId"); + setSearchParams(searchParams); + } }; + // Auto-open drawer when initialCategoryId and initialSubcategoryId are provided + useEffect(() => { + // Skip if already auto-opened or missing params + if (hasAutoOpenedRef.current || !initialCategoryId || !initialSubcategoryId || categories.length === 0) { + return; + } + + const catId = parseInt(initialCategoryId); + const subCatId = parseInt(initialSubcategoryId); + + // Expand the category accordion + setExpanded(catId); + + // Find the category + const category = categories.find((c) => c.id === catId); + if (!category) return; + + // Check if subcategories are already loaded + const subcategories = subcategoriesMap[catId]; + if (subcategories && subcategories.length > 0) { + // Find the subcategory and open drawer + const subcategory = subcategories.find((sc: any) => sc.id === subCatId); + if (subcategory) { + handleSubcategoryClick(category, subcategory, 0); + hasAutoOpenedRef.current = true; + } + } else if (!loadingSubcategories[catId]) { + // Trigger fetch if not already loading + fetchSubcategories(catId, "MEASURE"); + } + }, [initialCategoryId, initialSubcategoryId, categories, subcategoriesMap, loadingSubcategories, fetchSubcategories, handleSubcategoryClick]); + const handleDrawerSaveSuccess = (success: boolean, _message?: string, savedSubcategoryId?: number) => { if (success && savedSubcategoryId) { // Set flashing row ID for green highlighting diff --git a/Clients/src/presentation/pages/Framework/index.tsx b/Clients/src/presentation/pages/Framework/index.tsx index 0a113eb890..dcb09e0f3c 100644 --- a/Clients/src/presentation/pages/Framework/index.tsx +++ b/Clients/src/presentation/pages/Framework/index.tsx @@ -425,11 +425,13 @@ const Framework = () => { setSelectedFramework(nistAiRmfIndex); } - // Set tab based on parameters (simplified since we combined functions/categories) - if (subcategoryId) { - setNistAiRmfTabValue("subcategories"); - } else { - setNistAiRmfTabValue("functions"); + // Set tab based on functionId parameter (govern, map, measure, manage) + if (functionId) { + const validTabs = ["govern", "map", "measure", "manage"]; + const normalizedFunctionId = functionId.toLowerCase(); + if (validTabs.includes(normalizedFunctionId)) { + setNistAiRmfTabValue(normalizedFunctionId); + } } } }, [ @@ -792,6 +794,8 @@ const Framework = () => { statusOptions={frameworkStatusOptions} searchTerm={searchTerm} onSearchTermChange={setSearchTerm} + initialCategoryId={functionId?.toLowerCase() === "govern" ? categoryId : null} + initialSubcategoryId={functionId?.toLowerCase() === "govern" ? subcategoryId : null} /> @@ -803,6 +807,8 @@ const Framework = () => { statusOptions={frameworkStatusOptions} searchTerm={searchTerm} onSearchTermChange={setSearchTerm} + initialCategoryId={functionId?.toLowerCase() === "map" ? categoryId : null} + initialSubcategoryId={functionId?.toLowerCase() === "map" ? subcategoryId : null} /> @@ -814,6 +820,8 @@ const Framework = () => { statusOptions={frameworkStatusOptions} searchTerm={searchTerm} onSearchTermChange={setSearchTerm} + initialCategoryId={functionId?.toLowerCase() === "measure" ? categoryId : null} + initialSubcategoryId={functionId?.toLowerCase() === "measure" ? subcategoryId : null} /> @@ -825,6 +833,8 @@ const Framework = () => { statusOptions={frameworkStatusOptions} searchTerm={searchTerm} onSearchTermChange={setSearchTerm} + initialCategoryId={functionId?.toLowerCase() === "manage" ? categoryId : null} + initialSubcategoryId={functionId?.toLowerCase() === "manage" ? subcategoryId : null} /> diff --git a/Clients/src/presentation/pages/IntakeFormBuilder/DesignPanel.tsx b/Clients/src/presentation/pages/IntakeFormBuilder/DesignPanel.tsx index b86821c368..cde963a44f 100644 --- a/Clients/src/presentation/pages/IntakeFormBuilder/DesignPanel.tsx +++ b/Clients/src/presentation/pages/IntakeFormBuilder/DesignPanel.tsx @@ -394,7 +394,7 @@ export function DesignPanel({ settings, onChange }: DesignPanelProps) { id="design-font-inline" label="" value={s.fontFamily} - onChange={(e) => update({ fontFamily: e.target.value })} + onChange={(e) => update({ fontFamily: String(e.target.value) })} items={FONT_OPTIONS} sx={{ "& .MuiOutlinedInput-root": { diff --git a/Clients/src/presentation/pages/IntakeFormBuilder/FieldEditor.tsx b/Clients/src/presentation/pages/IntakeFormBuilder/FieldEditor.tsx index 1e65e47d2f..483aa868bc 100644 --- a/Clients/src/presentation/pages/IntakeFormBuilder/FieldEditor.tsx +++ b/Clients/src/presentation/pages/IntakeFormBuilder/FieldEditor.tsx @@ -139,7 +139,7 @@ interface FieldEditorProps { export function FieldEditor({ field, entityType, usedEntityMappings = [], onChange, onClose }: FieldEditorProps) { const theme = useTheme(); const [localField, setLocalField] = useState(field); - const debounceTimerRef = useRef>(); + const debounceTimerRef = useRef | null>(null); // Clear pending debounce when field changes to prevent stale updates useEffect(() => { diff --git a/Clients/src/presentation/pages/IntakeFormBuilder/IntakeFormsListPage.tsx b/Clients/src/presentation/pages/IntakeFormBuilder/IntakeFormsListPage.tsx index 436aeaadbe..a371e7048a 100644 --- a/Clients/src/presentation/pages/IntakeFormBuilder/IntakeFormsListPage.tsx +++ b/Clients/src/presentation/pages/IntakeFormBuilder/IntakeFormsListPage.tsx @@ -495,7 +495,7 @@ export function IntakeFormsListPage() { e.stopPropagation()}> handleMenuOpen(e, form)} diff --git a/Clients/src/presentation/pages/IntakeFormBuilder/SubmissionPreviewModal.tsx b/Clients/src/presentation/pages/IntakeFormBuilder/SubmissionPreviewModal.tsx index 4ad8642ea4..fff219c2e7 100644 --- a/Clients/src/presentation/pages/IntakeFormBuilder/SubmissionPreviewModal.tsx +++ b/Clients/src/presentation/pages/IntakeFormBuilder/SubmissionPreviewModal.tsx @@ -186,8 +186,7 @@ function OverrideSection({ value={overrideJustification} onChange={(e) => onJustificationChange(e.target.value)} rows={2} - error={overrideJustification.length > 0 && overrideJustification.length < 10} - helperText={ + error={ overrideJustification.length > 0 && overrideJustification.length < 10 ? "Justification must be at least 10 characters" : undefined diff --git a/Clients/src/presentation/pages/IntakeFormBuilder/index.tsx b/Clients/src/presentation/pages/IntakeFormBuilder/index.tsx index be09314645..9670818817 100644 --- a/Clients/src/presentation/pages/IntakeFormBuilder/index.tsx +++ b/Clients/src/presentation/pages/IntakeFormBuilder/index.tsx @@ -43,7 +43,6 @@ import { DesignPanel } from "./DesignPanel"; import CustomizableMultiSelect from "../../components/Inputs/Select/Multi"; import { FormField, - FormDesignSettings, IntakeForm, createEmptyForm, generateFieldId, diff --git a/Clients/src/presentation/pages/IntakeFormBuilder/types.ts b/Clients/src/presentation/pages/IntakeFormBuilder/types.ts index f4e3fb9646..f9d3fdf09c 100644 --- a/Clients/src/presentation/pages/IntakeFormBuilder/types.ts +++ b/Clients/src/presentation/pages/IntakeFormBuilder/types.ts @@ -103,7 +103,7 @@ export interface IntakeForm { riskAssessmentConfig?: Record | null; llmKeyId?: number | null; suggestedQuestionsEnabled?: boolean; - designSettings?: FormDesignSettings; + designSettings?: FormDesignSettings | null; createdBy?: number; createdAt?: Date; updatedAt?: Date; @@ -125,7 +125,7 @@ export interface CreateIntakeFormInput { riskTierSystem?: string; llmKeyId?: number | null; suggestedQuestionsEnabled?: boolean; - designSettings?: FormDesignSettings; + designSettings?: FormDesignSettings | null; } /** @@ -143,7 +143,7 @@ export interface UpdateIntakeFormInput { riskTierSystem?: string; llmKeyId?: number | null; suggestedQuestionsEnabled?: boolean; - designSettings?: FormDesignSettings; + designSettings?: FormDesignSettings | null; } /** diff --git a/Clients/src/presentation/pages/PublicIntakeForm/FormFieldRenderer.tsx b/Clients/src/presentation/pages/PublicIntakeForm/FormFieldRenderer.tsx index 62c3af251d..a1b4e11618 100644 --- a/Clients/src/presentation/pages/PublicIntakeForm/FormFieldRenderer.tsx +++ b/Clients/src/presentation/pages/PublicIntakeForm/FormFieldRenderer.tsx @@ -123,9 +123,10 @@ export function FormFieldRenderer({ field, control, errors }: FormFieldRendererP id={`field-${field.id}`} label="" {...fieldProps} + value={fieldProps.value as string} placeholder={field.placeholder} type={field.type === "email" ? "email" : field.type === "url" ? "url" : "text"} - error={!!error} + error={errorMessage} helperText={errorMessage || field.helpText} /> @@ -158,9 +159,10 @@ export function FormFieldRenderer({ field, control, errors }: FormFieldRendererP id={`field-${field.id}`} label="" {...fieldProps} + value={fieldProps.value as string} placeholder={field.placeholder} rows={4} - error={!!error} + error={errorMessage} helperText={errorMessage || field.helpText} /> @@ -193,9 +195,10 @@ export function FormFieldRenderer({ field, control, errors }: FormFieldRendererP id={`field-${field.id}`} label="" {...fieldProps} + value={fieldProps.value as string | number} type="number" placeholder={field.placeholder} - error={!!error} + error={errorMessage} helperText={errorMessage || field.helpText} /> @@ -222,8 +225,9 @@ export function FormFieldRenderer({ field, control, errors }: FormFieldRendererP id={`field-${field.id}`} label="" {...fieldProps} + value={fieldProps.value as string} type="date" - error={!!error} + error={errorMessage} helperText={errorMessage || field.helpText} /> diff --git a/Clients/src/presentation/pages/PublicIntakeForm/MathCaptcha.tsx b/Clients/src/presentation/pages/PublicIntakeForm/MathCaptcha.tsx index 2d7c8dc598..ea4d00d947 100644 --- a/Clients/src/presentation/pages/PublicIntakeForm/MathCaptcha.tsx +++ b/Clients/src/presentation/pages/PublicIntakeForm/MathCaptcha.tsx @@ -116,7 +116,7 @@ export function MathCaptcha({ value, onChange, error, refreshTrigger }: MathCapt placeholder="?" type="number" disabled={isLoading} - error={!!error} + error={error} sx={{ width: 80, "& .MuiOutlinedInput-root": { diff --git a/Clients/src/presentation/pages/PublicIntakeForm/index.tsx b/Clients/src/presentation/pages/PublicIntakeForm/index.tsx index 854ea0ca34..63dba04c51 100644 --- a/Clients/src/presentation/pages/PublicIntakeForm/index.tsx +++ b/Clients/src/presentation/pages/PublicIntakeForm/index.tsx @@ -142,7 +142,7 @@ export function PublicIntakeForm() { ? await getPublicFormById(publicId!, resubmissionToken) : await getPublicForm(tenantSlug!, formSlug!, resubmissionToken); if (response.data) { - setFormData(response.data.form); + setFormData(response.data.form as PublicFormData); if (response.data.previousData) { setPreviousData(response.data.previousData); // Pre-fill form with previous data @@ -471,7 +471,7 @@ export function PublicIntakeForm() { setSubmitterEmail(e.target.value); setEmailError(null); }} - error={!!emailError} + error={emailError || undefined} helperText={emailError || "We'll send you updates about your submission"} sx={{ "& .MuiOutlinedInput-root": { diff --git a/Clients/src/presentation/pages/Tasks/index.tsx b/Clients/src/presentation/pages/Tasks/index.tsx index 1f0d84fe23..ba7a9dd8bd 100644 --- a/Clients/src/presentation/pages/Tasks/index.tsx +++ b/Clients/src/presentation/pages/Tasks/index.tsx @@ -28,6 +28,11 @@ import { hardDeleteTask, updateTaskPriority, } from "../../../application/repository/task.repository"; +import { + addTaskEntityLink, + removeTaskEntityLink, + getTaskEntityLinks, +} from "../../../application/repository/taskEntityLink.repository"; import TaskSummaryCards from "./TaskSummaryCards"; import CreateTask from "../../components/Modals/CreateTask"; import useUsers from "../../../application/hooks/useUsers"; @@ -331,8 +336,37 @@ const Tasks: React.FC = () => { const handleTaskCreated = async (formData: any) => { try { - const response = await createTask({ body: formData }); + // Extract entity_links - we'll pass them to the API for notification purposes + // but also sync them separately after creation + const { entity_links, ...taskData } = formData; + + const response = await createTask({ + body: { + ...taskData, + // Include entity_links for notification email (backend uses these immediately) + entity_links: entity_links || [], + }, + }); if (response && response.data) { + const newTaskId = response.data.id; + + // Save entity links if any + if (entity_links && entity_links.length > 0 && newTaskId) { + try { + for (const link of entity_links) { + await addTaskEntityLink( + newTaskId, + link.entity_id, + link.entity_type, + link.entity_name + ); + } + } catch (linkError) { + console.error("Error saving entity links:", linkError); + // Don't fail the whole operation, just log the error + } + } + // Add the new task to the list setTasks((prev) => [response.data, ...prev]); setAlert({ @@ -386,14 +420,73 @@ const Tasks: React.FC = () => { if (!editingTask) return; try { + // Extract entity_links - we'll pass them to the API for notification purposes + // but also sync them separately after the update + const { entity_links: newEntityLinks, ...taskData } = formData; + const response = await updateTask({ id: editingTask.id!, - body: formData, + body: { + ...taskData, + // Include entity_links for notification email (backend uses these immediately) + entity_links: newEntityLinks || [], + }, }); if (response && response.data) { + // Sync entity links: get existing, compare, remove old, add new + if (newEntityLinks) { + try { + const existingLinks = await getTaskEntityLinks(editingTask.id!); + + // Find links to remove (in existing but not in new) + const linksToRemove = existingLinks.filter( + (existing) => + !newEntityLinks.some( + (newLink: any) => + newLink.entity_id === existing.entity_id && + newLink.entity_type === existing.entity_type + ) + ); + + // Find links to add (in new but not in existing) + const linksToAdd = newEntityLinks.filter( + (newLink: any) => + !existingLinks.some( + (existing) => + existing.entity_id === newLink.entity_id && + existing.entity_type === newLink.entity_type + ) + ); + + // Remove old links + for (const link of linksToRemove) { + await removeTaskEntityLink(editingTask.id!, link.id); + } + + // Add new links + for (const link of linksToAdd) { + await addTaskEntityLink( + editingTask.id!, + link.entity_id, + link.entity_type, + link.entity_name + ); + } + } catch (linkError) { + console.error("Error syncing entity links:", linkError); + // Don't fail the whole operation, just log the error + } + } + + // Update task in state with new entity links + const updatedTaskWithLinks = { + ...response.data, + entity_links: newEntityLinks || [], + }; + setTasks((prev) => prev.map((task) => - task.id === editingTask.id ? response.data : task + task.id === editingTask.id ? updatedTaskWithLinks : task ) ); diff --git a/Clients/src/presentation/types/interfaces/i.task.ts b/Clients/src/presentation/types/interfaces/i.task.ts index 2946fbde61..cffed997c8 100644 --- a/Clients/src/presentation/types/interfaces/i.task.ts +++ b/Clients/src/presentation/types/interfaces/i.task.ts @@ -12,6 +12,8 @@ export type { TaskSummary, TaskFilters, ICreateTaskFormValues, + IEntityLink, + EntityLinkType, } from "../../../domain/interfaces/i.task"; /** diff --git a/Servers/constants/emailTemplates.ts b/Servers/constants/emailTemplates.ts index 7049581737..66251fd8e5 100644 --- a/Servers/constants/emailTemplates.ts +++ b/Servers/constants/emailTemplates.ts @@ -33,6 +33,7 @@ export const EMAIL_TEMPLATES = { // Task templates TASK_ASSIGNED: "task-assigned.mjml", + TASK_UPDATED: "task-updated.mjml", // Review templates REVIEW_REQUESTED: "review-requested.mjml", @@ -59,6 +60,9 @@ export const EMAIL_TEMPLATES = { INTAKE_SUBMISSION_APPROVED: "intake-submission-approved.mjml", INTAKE_SUBMISSION_REJECTED: "intake-submission-rejected.mjml", INTAKE_NEW_SUBMISSION_ADMIN: "intake-new-submission-admin.mjml", + + // Assignment notification template + ASSIGNMENT_NOTIFICATION: "assignment-notification.mjml", } as const; // Type-safe template keys diff --git a/Servers/controllers/eu.ctrl.ts b/Servers/controllers/eu.ctrl.ts index bff72f75f7..80f578d36c 100644 --- a/Servers/controllers/eu.ctrl.ts +++ b/Servers/controllers/eu.ctrl.ts @@ -1,5 +1,7 @@ import { Request, Response } from "express"; +import { QueryTypes } from "sequelize"; import { ControlEU } from "../domain.layer/frameworks/EU-AI-Act/controlEU.model"; +import { notifyUserAssigned, AssignmentRoleType } from "../services/inAppNotification.service"; import { FileType } from "../domain.layer/models/file/file.model"; import { uploadFile } from "../utils/fileUpload.utils"; import { @@ -34,6 +36,112 @@ import { import logger from "../utils/logger/fileLogger"; import { hasPendingApprovalQuery } from "../utils/approvalRequest.utils"; +// Helper function to get user name +async function getUserNameById(userId: number): Promise { + const result = await sequelize.query<{ name: string; surname: string }>( + `SELECT name, surname FROM public.users WHERE id = :userId`, + { replacements: { userId }, type: QueryTypes.SELECT } + ); + if (result[0]) { + return `${result[0].name} ${result[0].surname}`.trim(); + } + return "Someone"; +} + +// Helper function to notify assignment changes for EU AI Act entities +async function notifyEuAiActAssignment( + req: Request | RequestWithFile, + entityType: "EU AI Act Subcontrol", + entityId: number, + entityName: string, + roleType: AssignmentRoleType, + newUserId: number, + oldUserId: number | null | undefined, + projectId?: number, + controlId?: number +): Promise { + // Only notify if assigned to a new user + if (newUserId && newUserId !== oldUserId) { + const assignerName = await getUserNameById(req.userId!); + const baseUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + + let urlPath: string; + let controlName: string | undefined; + let projectName: string | undefined; + let description: string | undefined; + let resolvedControlId = controlId; + + // Query for subcontrol description and order info from struct table + const subcontrolResult = await sequelize.query<{ description: string; control_id: number; subcontrol_order_no: number; control_order_no: number }>( + `SELECT scs.description, sc.control_id, scs.order_no as subcontrol_order_no, cs.order_no as control_order_no + FROM "${req.tenantId!}".subcontrols_eu sc + JOIN public.subcontrols_struct_eu scs ON sc.subcontrol_meta_id = scs.id + JOIN public.controls_struct_eu cs ON scs.control_id = cs.id + WHERE sc.id = :entityId`, + { replacements: { entityId }, type: QueryTypes.SELECT } + ); + description = subcontrolResult[0]?.description; + // Build subcontrol identifier like "1.1 Subcontrol title" (control.order_no.subcontrol.order_no) + if (subcontrolResult[0]) { + entityName = `${subcontrolResult[0].control_order_no}.${subcontrolResult[0].subcontrol_order_no} ${entityName}`; + } + if (!controlId && subcontrolResult[0]?.control_id) { + resolvedControlId = subcontrolResult[0].control_id; + } + + // Query for additional context: control name and project name + if (projectId) { + // Get project name + const projectResult = await sequelize.query<{ project_title: string }>( + `SELECT project_title FROM "${req.tenantId!}".projects WHERE id = :projectId`, + { replacements: { projectId }, type: QueryTypes.SELECT } + ); + projectName = projectResult[0]?.project_title; + + // Get control name from struct table + if (resolvedControlId) { + const controlResult = await sequelize.query<{ title: string }>( + `SELECT cs.title + FROM "${req.tenantId!}".controls_eu c + JOIN public.controls_struct_eu cs ON c.control_meta_id = cs.id + WHERE c.id = :controlId`, + { replacements: { controlId: resolvedControlId }, type: QueryTypes.SELECT } + ); + controlName = controlResult[0]?.title; + } + } + + if (projectId && resolvedControlId) { + urlPath = `/project-view?projectId=${projectId}&tab=frameworks&framework=eu-ai-act&subtab=compliance&controlId=${resolvedControlId}&subControlId=${entityId}`; + } else if (projectId) { + urlPath = `/project-view?projectId=${projectId}&tab=frameworks&framework=eu-ai-act&subtab=compliance`; + } else { + urlPath = `/project-view`; + } + + notifyUserAssigned( + req.tenantId!, + newUserId, + { + entityType, + entityId, + entityName, + roleType, + entityUrl: `${baseUrl}${urlPath}`, + }, + assignerName, + baseUrl, + { + frameworkName: "EU AI Act", + projectName, + parentType: controlName ? "Control" : undefined, + parentName: controlName, + description, + } + ).catch((err) => console.error(`Failed to send ${roleType} notification:`, err)); + } +} + export async function getAssessmentsByProjectId( req: Request, res: Response @@ -322,8 +430,32 @@ export async function saveControls( // now we need to iterate over subcontrols inside the control, and create a subcontrol for each subcontrol const subControlResp = []; + // Track assignment changes for notifications (sent after transaction commits) + const assignmentChanges: Array<{ + subcontrolId: number; + entityName: string; + roleType: AssignmentRoleType; + newUserId: number; + oldUserId: number | null; + }> = []; + if (Control.subControls) { for (const subcontrol of JSON.parse(Control.subControls)) { + // Get current subcontrol data for assignment change detection + const currentSubcontrolResult = (await sequelize.query( + `SELECT sc.owner, sc.reviewer, sc.approver, scs.title + FROM "${req.tenantId!}".subcontrols_eu sc + JOIN public.subcontrols_struct_eu scs ON sc.subcontrol_meta_id = scs.id + WHERE sc.id = :id;`, + { + replacements: { id: parseInt(subcontrol.id) }, + transaction, + type: QueryTypes.SELECT, + } + )) as { owner: number | null; reviewer: number | null; approver: number | null; title: string }[]; + + const currentSubcontrol = currentSubcontrolResult[0] || { owner: null, reviewer: null, approver: null, title: '' }; + const evidenceFiles = ((req.files as UploadedFile[]) || []).filter( (f) => f.fieldname === `evidence_files_${parseInt(subcontrol.id)}` ); @@ -404,6 +536,22 @@ export async function saveControls( ); if (subcontrolToSave) { subControlResp.push(subcontrolToSave); + + // Track assignment changes for notification + const entityName = currentSubcontrol.title || `Subcontrol #${subcontrol.id}`; + const newOwner = subcontrol.owner ? parseInt(String(subcontrol.owner)) : null; + const newReviewer = subcontrol.reviewer ? parseInt(String(subcontrol.reviewer)) : null; + const newApprover = subcontrol.approver ? parseInt(String(subcontrol.approver)) : null; + + if (newOwner && newOwner !== currentSubcontrol.owner) { + assignmentChanges.push({ subcontrolId: parseInt(subcontrol.id), entityName, roleType: "Owner", newUserId: newOwner, oldUserId: currentSubcontrol.owner }); + } + if (newReviewer && newReviewer !== currentSubcontrol.reviewer) { + assignmentChanges.push({ subcontrolId: parseInt(subcontrol.id), entityName, roleType: "Reviewer", newUserId: newReviewer, oldUserId: currentSubcontrol.reviewer }); + } + if (newApprover && newApprover !== currentSubcontrol.approver) { + assignmentChanges.push({ subcontrolId: parseInt(subcontrol.id), entityName, roleType: "Approver", newUserId: newApprover, oldUserId: currentSubcontrol.approver }); + } } } } @@ -419,6 +567,21 @@ export async function saveControls( ); await transaction.commit(); + // Send assignment notifications after transaction commits + for (const change of assignmentChanges) { + notifyEuAiActAssignment( + req, + "EU AI Act Subcontrol", + change.subcontrolId, + change.entityName, + change.roleType, + change.newUserId, + change.oldUserId, + Control.project_id, + controlId + ); + } + await logSuccess({ eventType: "Update", description: `Successfully saved controls for control ID ${controlId}`, diff --git a/Servers/controllers/iso27001.ctrl.ts b/Servers/controllers/iso27001.ctrl.ts index 0f8dfd0973..26bc95d046 100644 --- a/Servers/controllers/iso27001.ctrl.ts +++ b/Servers/controllers/iso27001.ctrl.ts @@ -1,5 +1,7 @@ import { Request, Response } from "express"; +import { QueryTypes } from "sequelize"; import { sequelize } from "../database/db"; +import { notifyUserAssigned, AssignmentRoleType } from "../services/inAppNotification.service"; import { uploadFile } from "../utils/fileUpload.utils"; import { RequestWithFile, UploadedFile } from "../utils/question.utils"; import { STATUS_CODE } from "../utils/statusCode.utils"; @@ -38,6 +40,104 @@ import logger from "../utils/logger/fileLogger"; import { IISO27001SubClause } from "../domain.layer/interfaces/i.ISO27001SubClause"; import { IISO27001AnnexControl } from "../domain.layer/interfaces/i.iso27001AnnexControl"; +// Helper function to get user name +async function getUserNameById(userId: number): Promise { + const result = await sequelize.query<{ name: string; surname: string }>( + `SELECT name, surname FROM public.users WHERE id = :userId`, + { replacements: { userId }, type: QueryTypes.SELECT } + ); + if (result[0]) { + return `${result[0].name} ${result[0].surname}`.trim(); + } + return "Someone"; +} + +// Helper function to notify assignment changes for ISO 27001 entities +async function notifyIso27001Assignment( + req: Request | RequestWithFile, + entityType: "ISO 27001 Subclause" | "ISO 27001 Annex Control", + entityId: number, + entityName: string, + roleType: AssignmentRoleType, + newUserId: number, + oldUserId: number | null | undefined +): Promise { + // Only notify if assigned to a new user + if (newUserId && newUserId !== oldUserId) { + const assignerName = await getUserNameById(req.userId!); + const baseUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + + let urlPath: string; + let parentType: string | undefined; + let parentName: string | undefined; + let description: string | undefined; + + if (entityType === "ISO 27001 Subclause") { + // Query for parent clause info, subclause order_no for full identifier (e.g., "4.1 Understanding the organization"), and subclause description + const result = await sequelize.query<{ clause_id: number; clause_arrangement: number; clause_title: string; subclause_order_no: number; requirement_summary: string }>( + `SELECT scs.clause_id, c.arrangement as clause_arrangement, c.title as clause_title, scs.order_no as subclause_order_no, scs.requirement_summary + FROM "${req.tenantId!}".subclauses_iso27001 sc + JOIN public.subclauses_struct_iso27001 scs ON sc.subclause_meta_id = scs.id + JOIN public.clauses_struct_iso27001 c ON scs.clause_id = c.id + WHERE sc.id = :entityId`, + { replacements: { entityId }, type: QueryTypes.SELECT } + ); + const clauseId = result[0]?.clause_id; + parentType = "Clause"; + parentName = result[0] ? `${result[0].clause_arrangement}. ${result[0].clause_title}` : undefined; + // Build full subclause identifier like "4.1 Understanding the organization and its context" + if (result[0]) { + entityName = `${result[0].clause_arrangement}.${result[0].subclause_order_no} ${entityName}`; + } + description = result[0]?.requirement_summary; + urlPath = clauseId + ? `/framework?framework=iso-27001&clause27001Id=${clauseId}&subClause27001Id=${entityId}` + : `/framework?framework=iso-27001&subClause27001Id=${entityId}`; + } else { + // Query for parent annex info, control order_no for full identifier (e.g., "A.5.1 Policies for information security"), and control description + const result = await sequelize.query<{ annex_id: number; annex_arrangement: string; annex_order_no: number; annex_title: string; control_order_no: number; requirement_summary: string }>( + `SELECT acs.annex_id, a.arrangement as annex_arrangement, a.order_no as annex_order_no, a.title as annex_title, acs.order_no as control_order_no, acs.requirement_summary + FROM "${req.tenantId!}".annexcontrols_iso27001 ac + JOIN public.annexcontrols_struct_iso27001 acs ON ac.annexcontrol_meta_id = acs.id + JOIN public.annex_struct_iso27001 a ON acs.annex_id = a.id + WHERE ac.id = :entityId`, + { replacements: { entityId }, type: QueryTypes.SELECT } + ); + const annexId = result[0]?.annex_id; + parentType = "Annex"; + parentName = result[0] ? `${result[0].annex_arrangement}.${result[0].annex_order_no} ${result[0].annex_title}` : undefined; + // Build full control identifier like "A.5.1 Policies for information security" + if (result[0]) { + entityName = `${result[0].annex_arrangement}.${result[0].annex_order_no}.${result[0].control_order_no} ${entityName}`; + } + description = result[0]?.requirement_summary; + urlPath = annexId + ? `/framework?framework=iso-27001&annex27001Id=${annexId}&annexControl27001Id=${entityId}` + : `/framework?framework=iso-27001&annexControl27001Id=${entityId}`; + } + + notifyUserAssigned( + req.tenantId!, + newUserId, + { + entityType, + entityId, + entityName, + roleType, + entityUrl: `${baseUrl}${urlPath}`, + }, + assignerName, + baseUrl, + { + frameworkName: "ISO 27001", + parentType, + parentName, + description, + } + ).catch((err) => console.error(`Failed to send ${roleType} notification:`, err)); + } +} + export async function getAllClauses(req: Request, res: Response): Promise { logProcessing({ description: "starting getAllClauses", @@ -478,6 +578,7 @@ export async function getClausesByProjectId( }); return res.status(400).json(STATUS_CODE[400]("No sub clauses found")); } catch (error) { + console.error(`[ISO27001 Ctrl] ERROR in getClausesByProjectId:`, error); await logFailure({ eventType: "Read", description: `Failed to retrieve clauses for project framework ID ${projectFrameworkId}`, @@ -615,6 +716,21 @@ export async function saveClauses( // Files to unlink (not delete) - the actual file stays in file manager const filesToUnlink = JSON.parse(subClause.delete || "[]") as number[]; + // Get current subclause data for assignment change detection + const currentSubClauseResult = (await sequelize.query( + `SELECT sc.owner, sc.reviewer, sc.approver, scs.title as title + FROM "${req.tenantId!}".subclauses_iso27001 sc + LEFT JOIN public.subclauses_struct_iso27001 scs ON scs.id = sc.subclause_meta_id + WHERE sc.id = :id;`, + { + replacements: { id: subClauseId }, + transaction, + type: QueryTypes.SELECT, + } + )) as { owner: number | null; reviewer: number | null; approver: number | null; title: string }[]; + + const currentData = currentSubClauseResult[0] || { owner: null, reviewer: null, approver: null, title: '' }; + // // Get project_id from subclause // const projectIdResult = (await sequelize.query( // `SELECT pf.project_id as id FROM "${req.tenantId!}".subclauses_iso27001 sc JOIN "${req.tenantId!}".projects_frameworks pf ON pf.id = sc.projects_frameworks_id WHERE sc.id = :id;`, @@ -660,6 +776,22 @@ export async function saveClauses( ); await transaction.commit(); + // Notify owner, reviewer, approver if changed + const entityName = currentData.title || `Subclause #${subClauseId}`; + const newOwner = subClause.owner ? parseInt(String(subClause.owner)) : null; + const newReviewer = subClause.reviewer ? parseInt(String(subClause.reviewer)) : null; + const newApprover = subClause.approver ? parseInt(String(subClause.approver)) : null; + + if (newOwner) { + notifyIso27001Assignment(req, "ISO 27001 Subclause", subClauseId, entityName, "Owner", newOwner, currentData.owner); + } + if (newReviewer) { + notifyIso27001Assignment(req, "ISO 27001 Subclause", subClauseId, entityName, "Reviewer", newReviewer, currentData.reviewer); + } + if (newApprover) { + notifyIso27001Assignment(req, "ISO 27001 Subclause", subClauseId, entityName, "Approver", newApprover, currentData.approver); + } + await logSuccess({ eventType: "Update", description: `Successfully saved clauses for sub-clause ID ${subClauseId}`, @@ -722,6 +854,21 @@ export async function saveAnnexes( // Files to unlink (not delete) - the actual file stays in file manager const filesToUnlink = JSON.parse(annexControl.delete || "[]") as number[]; + // Get current annex control data for assignment change detection + const currentAnnexResult = (await sequelize.query( + `SELECT ac.owner, ac.reviewer, ac.approver, acs.title as control_title + FROM "${req.tenantId!}".annexcontrols_iso27001 ac + LEFT JOIN public.annexcontrols_struct_iso27001 acs ON acs.id = ac.annexcontrol_meta_id + WHERE ac.id = :id;`, + { + replacements: { id: annexControlId }, + transaction, + type: QueryTypes.SELECT, + } + )) as { owner: number | null; reviewer: number | null; approver: number | null; control_title: string }[]; + + const currentAnnexData = currentAnnexResult[0] || { owner: null, reviewer: null, approver: null, control_title: '' }; + // // Get project_id from annex control // const projectIdResult = (await sequelize.query( // `SELECT pf.project_id as id FROM "${req.tenantId!}".annexcontrols_iso27001 ac JOIN "${req.tenantId!}".projects_frameworks pf ON pf.id = ac.projects_frameworks_id WHERE ac.id = :id;`, @@ -774,6 +921,22 @@ export async function saveAnnexes( } await transaction.commit(); + // Notify owner, reviewer, approver if changed + const annexEntityName = currentAnnexData.control_title || `Annex Control #${annexControlId}`; + const newAnnexOwner = annexControl.owner ? parseInt(String(annexControl.owner)) : null; + const newAnnexReviewer = annexControl.reviewer ? parseInt(String(annexControl.reviewer)) : null; + const newAnnexApprover = annexControl.approver ? parseInt(String(annexControl.approver)) : null; + + if (newAnnexOwner) { + notifyIso27001Assignment(req, "ISO 27001 Annex Control", annexControlId, annexEntityName, "Owner", newAnnexOwner, currentAnnexData.owner); + } + if (newAnnexReviewer) { + notifyIso27001Assignment(req, "ISO 27001 Annex Control", annexControlId, annexEntityName, "Reviewer", newAnnexReviewer, currentAnnexData.reviewer); + } + if (newAnnexApprover) { + notifyIso27001Assignment(req, "ISO 27001 Annex Control", annexControlId, annexEntityName, "Approver", newAnnexApprover, currentAnnexData.approver); + } + await logSuccess({ eventType: "Update", description: `Successfully saved annexes for annex control ID ${annexControlId}`, diff --git a/Servers/controllers/iso42001.ctrl.ts b/Servers/controllers/iso42001.ctrl.ts index 6eec4fbf74..127f361405 100644 --- a/Servers/controllers/iso42001.ctrl.ts +++ b/Servers/controllers/iso42001.ctrl.ts @@ -1,5 +1,7 @@ import { Request, Response } from "express"; +import { QueryTypes } from "sequelize"; import { sequelize } from "../database/db"; +import { notifyUserAssigned, AssignmentRoleType } from "../services/inAppNotification.service"; import { SubClauseISO } from "../domain.layer/frameworks/ISO-42001/subClauseISO.model"; import { uploadFile } from "../utils/fileUpload.utils"; import { RequestWithFile, UploadedFile } from "../utils/question.utils"; @@ -40,6 +42,104 @@ import { } from "../utils/logger/logHelper"; import logger from "../utils/logger/fileLogger"; +// Helper function to get user name +async function getUserNameById(userId: number): Promise { + const result = await sequelize.query<{ name: string; surname: string }>( + `SELECT name, surname FROM public.users WHERE id = :userId`, + { replacements: { userId }, type: QueryTypes.SELECT } + ); + if (result[0]) { + return `${result[0].name} ${result[0].surname}`.trim(); + } + return "Someone"; +} + +// Helper function to notify assignment changes for ISO 42001 entities +async function notifyIso42001Assignment( + req: Request | RequestWithFile, + entityType: "ISO 42001 Subclause" | "ISO 42001 Annex", + entityId: number, + entityName: string, + roleType: AssignmentRoleType, + newUserId: number, + oldUserId: number | null | undefined +): Promise { + // Only notify if assigned to a new user + if (newUserId && newUserId !== oldUserId) { + const assignerName = await getUserNameById(req.userId!); + const baseUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + + let urlPath: string; + let parentType: string | undefined; + let parentName: string | undefined; + let description: string | undefined; + + if (entityType === "ISO 42001 Subclause") { + // Query for parent clause info, subclause order_no for full identifier (e.g., "4.1 Understanding the organization"), and subclause summary + const result = await sequelize.query<{ clause_id: number; clause_no: number; clause_title: string; subclause_order_no: number; summary: string }>( + `SELECT scs.clause_id, c.clause_no, c.title as clause_title, scs.order_no as subclause_order_no, scs.summary + FROM "${req.tenantId!}".subclauses_iso sc + JOIN public.subclauses_struct_iso scs ON sc.subclause_meta_id = scs.id + JOIN public.clauses_struct_iso c ON scs.clause_id = c.id + WHERE sc.id = :entityId`, + { replacements: { entityId }, type: QueryTypes.SELECT } + ); + const clauseId = result[0]?.clause_id; + parentType = "Clause"; + parentName = result[0]?.clause_title; + // Build full subclause identifier like "4.1 Understanding the organization and its context" + if (result[0]) { + entityName = `${result[0].clause_no}.${result[0].subclause_order_no} ${entityName}`; + } + description = result[0]?.summary; + urlPath = clauseId + ? `/framework?framework=iso-42001&clauseId=${clauseId}&subClauseId=${entityId}` + : `/framework?framework=iso-42001&subClauseId=${entityId}`; + } else { + // Query for parent annex info, category sub_id for full identifier (e.g., "A.5.1 Policies for AI"), and category description + const result = await sequelize.query<{ annex_id: number; annex_no: number; annex_title: string; category_sub_id: number; category_description: string }>( + `SELECT acs.annex_id, a.annex_no, a.title as annex_title, acs.sub_id as category_sub_id, acs.description as category_description + FROM "${req.tenantId!}".annexcategories_iso ac + JOIN public.annexcategories_struct_iso acs ON ac.annexcategory_meta_id = acs.id + JOIN public.annex_struct_iso a ON acs.annex_id = a.id + WHERE ac.id = :entityId`, + { replacements: { entityId }, type: QueryTypes.SELECT } + ); + const annexId = result[0]?.annex_id; + parentType = "Annex"; + parentName = result[0]?.annex_title; + // Build full annex category identifier like "A.5.1 Policies for AI" + if (result[0]) { + entityName = `A.${result[0].annex_no}.${result[0].category_sub_id} ${entityName}`; + } + description = result[0]?.category_description; + urlPath = annexId + ? `/framework?framework=iso-42001&annexId=${annexId}&annexCategoryId=${entityId}` + : `/framework?framework=iso-42001&annexCategoryId=${entityId}`; + } + + notifyUserAssigned( + req.tenantId!, + newUserId, + { + entityType, + entityId, + entityName, + roleType, + entityUrl: `${baseUrl}${urlPath}`, + }, + assignerName, + baseUrl, + { + frameworkName: "ISO 42001", + parentType, + parentName, + description, + } + ).catch((err) => console.error(`Failed to send ${roleType} notification:`, err)); + } +} + export async function getAllClauses(req: Request, res: Response): Promise { logProcessing({ description: "starting getAllClauses", @@ -735,20 +835,26 @@ export async function saveClauses( .filter((id: number) => !isNaN(id)) : []; - // Get project_id from subclause - const projectIdResult = (await sequelize.query( - `SELECT pf.project_id as id FROM "${req.tenantId!}".subclauses_iso sc JOIN "${req.tenantId!}".projects_frameworks pf ON pf.id = sc.projects_frameworks_id WHERE sc.id = :id;`, + // Get current subclause data for assignment change detection + const currentSubClauseResult = (await sequelize.query( + `SELECT sc.owner, sc.reviewer, sc.approver, pf.project_id as project_id, scs.title as title + FROM "${req.tenantId!}".subclauses_iso sc + JOIN "${req.tenantId!}".projects_frameworks pf ON pf.id = sc.projects_frameworks_id + LEFT JOIN public.subclauses_struct_iso scs ON scs.id = sc.subclause_meta_id + WHERE sc.id = :id;`, { replacements: { id: subClauseId }, transaction, + type: QueryTypes.SELECT, } - )) as [{ id: number }[], number]; + )) as { project_id: number; owner: number | null; reviewer: number | null; approver: number | null; title: string }[]; - if (projectIdResult[0].length === 0) { - throw new Error("Project ID not found for subclause"); + if (currentSubClauseResult.length === 0) { + throw new Error("Subclause not found"); } - const projectId = projectIdResult[0][0].id; + const currentData = currentSubClauseResult[0]; + const projectId = currentData.project_id; let uploadedFiles: FileType[] = []; if (req.files && Array.isArray(req.files) && req.files.length > 0) { @@ -780,6 +886,22 @@ export async function saveClauses( ); await transaction.commit(); + // Notify owner, reviewer, approver if changed + const entityName = currentData.title || `Subclause #${subClauseId}`; + const newOwner = subClause.owner ? parseInt(String(subClause.owner)) : null; + const newReviewer = subClause.reviewer ? parseInt(String(subClause.reviewer)) : null; + const newApprover = subClause.approver ? parseInt(String(subClause.approver)) : null; + + if (newOwner) { + notifyIso42001Assignment(req, "ISO 42001 Subclause", subClauseId, entityName, "Owner", newOwner, currentData.owner); + } + if (newReviewer) { + notifyIso42001Assignment(req, "ISO 42001 Subclause", subClauseId, entityName, "Reviewer", newReviewer, currentData.reviewer); + } + if (newApprover) { + notifyIso42001Assignment(req, "ISO 42001 Subclause", subClauseId, entityName, "Approver", newApprover, currentData.approver); + } + await logSuccess({ eventType: "Update", description: `Successfully saved clauses for sub-clause ID ${subClauseId}`, @@ -839,20 +961,26 @@ export async function saveAnnexes( .filter((id: number) => !isNaN(id)) : []; - // Get project_id from annex category - const projectIdResult = (await sequelize.query( - `SELECT pf.project_id as id FROM "${req.tenantId!}".annexcategories_iso ac JOIN "${req.tenantId!}".projects_frameworks pf ON pf.id = ac.projects_frameworks_id WHERE ac.id = :id;`, + // Get current annex category data for assignment change detection + const currentAnnexResult = (await sequelize.query( + `SELECT ac.owner, ac.reviewer, ac.approver, pf.project_id as project_id, acs.title as title + FROM "${req.tenantId!}".annexcategories_iso ac + JOIN "${req.tenantId!}".projects_frameworks pf ON pf.id = ac.projects_frameworks_id + LEFT JOIN public.annexcategories_struct_iso acs ON acs.id = ac.annexcategory_meta_id + WHERE ac.id = :id;`, { replacements: { id: annexCategoryId }, transaction, + type: QueryTypes.SELECT, } - )) as [{ id: number }[], number]; + )) as { project_id: number; owner: number | null; reviewer: number | null; approver: number | null; title: string }[]; - if (projectIdResult[0].length === 0) { - throw new Error("Project ID not found for annex category"); + if (currentAnnexResult.length === 0) { + throw new Error("Annex category not found"); } - const projectId = projectIdResult[0][0].id; + const currentAnnexData = currentAnnexResult[0]; + const projectId = currentAnnexData.project_id; let uploadedFiles: FileType[] = []; if (req.files && Array.isArray(req.files) && req.files.length > 0) { @@ -884,6 +1012,22 @@ export async function saveAnnexes( ); await transaction.commit(); + // Notify owner, reviewer, approver if changed + const annexEntityName = currentAnnexData.title || `Annex Category #${annexCategoryId}`; + const newAnnexOwner = annexCategory.owner ? parseInt(String(annexCategory.owner)) : null; + const newAnnexReviewer = annexCategory.reviewer ? parseInt(String(annexCategory.reviewer)) : null; + const newAnnexApprover = annexCategory.approver ? parseInt(String(annexCategory.approver)) : null; + + if (newAnnexOwner) { + notifyIso42001Assignment(req, "ISO 42001 Annex", annexCategoryId, annexEntityName, "Owner", newAnnexOwner, currentAnnexData.owner); + } + if (newAnnexReviewer) { + notifyIso42001Assignment(req, "ISO 42001 Annex", annexCategoryId, annexEntityName, "Reviewer", newAnnexReviewer, currentAnnexData.reviewer); + } + if (newAnnexApprover) { + notifyIso42001Assignment(req, "ISO 42001 Annex", annexCategoryId, annexEntityName, "Approver", newAnnexApprover, currentAnnexData.approver); + } + await logSuccess({ eventType: "Update", description: `Successfully saved annexes for annex category ID ${annexCategoryId}`, diff --git a/Servers/controllers/modelInventory.ctrl.ts b/Servers/controllers/modelInventory.ctrl.ts index 07a9141e84..369f8a6d6b 100644 --- a/Servers/controllers/modelInventory.ctrl.ts +++ b/Servers/controllers/modelInventory.ctrl.ts @@ -1,6 +1,7 @@ import { Request, Response } from "express"; -import { Transaction } from "sequelize"; +import { QueryTypes, Transaction } from "sequelize"; import { sequelize } from "../database/db"; +import { notifyUserAssigned } from "../services/inAppNotification.service"; import { ModelInventoryModel } from "../domain.layer/models/modelInventory/modelInventory.model"; import { getAllModelInventoriesQuery, @@ -20,6 +21,18 @@ import { import { STATUS_CODE } from "../utils/statusCode.utils"; import logger, { logStructured } from "../utils/logger/fileLogger"; +// Helper function to get user name +async function getUserNameById(userId: number): Promise { + const result = await sequelize.query<{ name: string; surname: string }>( + `SELECT name, surname FROM public.users WHERE id = :userId`, + { replacements: { userId }, type: QueryTypes.SELECT } + ); + if (result[0]) { + return `${result[0].name} ${result[0].surname}`.trim(); + } + return "Someone"; +} + export async function getAllModelInventories(req: Request, res: Response) { logStructured( "processing", @@ -280,6 +293,35 @@ export async function createNewModelInventory(req: Request, res: Response) { await transaction.commit(); + // Notify approver if assigned + if (approver) { + const assignerName = await getUserNameById(req.userId!); + const baseUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + + // Build model context + const modelDetails = [ + savedModelInventory.provider ? `Provider: ${savedModelInventory.provider}` : null, + savedModelInventory.version ? `Version: ${savedModelInventory.version}` : null, + ].filter(Boolean).join(" | "); + + notifyUserAssigned( + req.tenantId!, + approver, + { + entityType: "Model Inventory", + entityId: savedModelInventory.id!, + entityName: savedModelInventory.model || `Model #${savedModelInventory.id}`, + roleType: "Approver", + entityUrl: `${baseUrl}/model-inventory?modelId=${savedModelInventory.id}`, + }, + assignerName, + baseUrl, + { + description: modelDetails || undefined, + } + ).catch((err) => console.error("Failed to send approver notification:", err)); + } + logStructured( "successful", "new model inventory created", @@ -442,6 +484,36 @@ export async function updateModelInventoryById(req: Request, res: Response) { await transaction.commit(); + // Notify approver if changed to a new user + const oldApprover = (currentModelInventory as any).approver; + if (approver && approver !== oldApprover) { + const assignerName = await getUserNameById(req.userId!); + const baseUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + + // Build model context + const modelDetails = [ + savedModelInventory.provider ? `Provider: ${savedModelInventory.provider}` : null, + savedModelInventory.version ? `Version: ${savedModelInventory.version}` : null, + ].filter(Boolean).join(" | "); + + notifyUserAssigned( + req.tenantId!, + approver, + { + entityType: "Model Inventory", + entityId: modelInventoryId, + entityName: savedModelInventory.model || `Model #${modelInventoryId}`, + roleType: "Approver", + entityUrl: `${baseUrl}/model-inventory?modelId=${modelInventoryId}`, + }, + assignerName, + baseUrl, + { + description: modelDetails || undefined, + } + ).catch((err) => console.error("Failed to send approver notification:", err)); + } + logStructured( "successful", "model inventory updated", diff --git a/Servers/controllers/nist_ai_rmf.subcategory.ctrl.ts b/Servers/controllers/nist_ai_rmf.subcategory.ctrl.ts index 24c5ab0a69..fda36cd602 100644 --- a/Servers/controllers/nist_ai_rmf.subcategory.ctrl.ts +++ b/Servers/controllers/nist_ai_rmf.subcategory.ctrl.ts @@ -1,5 +1,7 @@ import { Request, Response } from "express"; +import { QueryTypes } from "sequelize"; import logger, { logStructured } from "../utils/logger/fileLogger"; +import { notifyUserAssigned, AssignmentRoleType } from "../services/inAppNotification.service"; import { getAllNISTAIRMFSubcategoriesBycategoryIdAndtitleQuery, getNISTAIRMFSubcategoryByIdQuery, @@ -29,6 +31,83 @@ import { getUserProjects } from "../utils/user.utils"; // Note: Files are only unlinked from evidence_links, not deleted from file manager // This allows the same file to be used as evidence in multiple places +// Helper function to get user name +async function getUserNameById(userId: number): Promise { + const result = await sequelize.query<{ name: string; surname: string }>( + `SELECT name, surname FROM public.users WHERE id = :userId`, + { replacements: { userId }, type: QueryTypes.SELECT } + ); + if (result[0]) { + return `${result[0].name} ${result[0].surname}`.trim(); + } + return "Someone"; +} + +// Helper function to notify assignment changes for NIST AI RMF entities +async function notifyNistAiRmfAssignment( + req: Request | RequestWithFile, + entityId: number, + entityName: string, + roleType: AssignmentRoleType, + newUserId: number, + oldUserId: number | null | undefined +): Promise { + // Only notify if assigned to a new user + if (newUserId && newUserId !== oldUserId) { + const assignerName = await getUserNameById(req.userId!); + const baseUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + + // Query for function type, category info, subcategory index and description + const result = await sequelize.query<{ func_type: string; category_id: number; category_index: number; category_name: string; subcategory_index: number; subcategory_description: string }>( + `SELECT f.type as func_type, c.id as category_id, c.index as category_index, c.title as category_name, s.index as subcategory_index, s.description as subcategory_description + FROM "${req.tenantId!}".nist_ai_rmf_subcategories s + JOIN public.nist_ai_rmf_categories c ON s.category_id = c.id + JOIN public.nist_ai_rmf_functions f ON c.function_id = f.id + WHERE s.id = :entityId`, + { replacements: { entityId }, type: QueryTypes.SELECT } + ); + + let urlPath: string; + let functionName: string | undefined; + let categoryName: string | undefined; + let description: string | undefined; + + if (result[0]) { + // Tabs expect lowercase: govern, map, measure, manage + const funcType = result[0].func_type.toLowerCase(); + functionName = result[0].func_type; // Original case for display + categoryName = result[0].category_name; + description = result[0].subcategory_description; + // Build subcategory identifier like "GOVERN 1.1" or "GV-1.1" + const funcAbbrev = result[0].func_type.substring(0, 2).toUpperCase(); + entityName = `${funcAbbrev}-${result[0].category_index}.${result[0].subcategory_index} ${entityName}`; + urlPath = `/framework?framework=nist-ai-rmf&functionId=${funcType}&categoryId=${result[0].category_id}&subcategoryId=${entityId}`; + } else { + urlPath = `/framework?framework=nist-ai-rmf&subcategoryId=${entityId}`; + } + + notifyUserAssigned( + req.tenantId!, + newUserId, + { + entityType: "NIST AI RMF Subcategory", + entityId, + entityName, + roleType, + entityUrl: `${baseUrl}${urlPath}`, + }, + assignerName, + baseUrl, + { + frameworkName: "NIST AI RMF", + parentType: functionName ? "Function / Category" : undefined, + parentName: functionName && categoryName ? `${functionName} → ${categoryName}` : undefined, + description, + } + ).catch((err) => console.error(`Failed to send ${roleType} notification:`, err)); + } +} + export async function getAllNISTAIRMFSubcategoriesBycategoryIdAndtitle( req: Request, res: Response @@ -205,6 +284,18 @@ export async function updateNISTAIRMFSubcategoryById( risksMitigated?: string; // JSON string of risk IDs to add }; + // Get current subcategory data for assignment change detection + const currentSubcategoryResult = (await sequelize.query( + `SELECT owner, reviewer, approver, title FROM "${req.tenantId!}".nist_ai_rmf_subcategories WHERE id = :id;`, + { + replacements: { id: subcategoryId }, + transaction, + type: QueryTypes.SELECT, + } + )) as { owner: number | null; reviewer: number | null; approver: number | null; title: string }[]; + + const currentData = currentSubcategoryResult[0] || { owner: null, reviewer: null, approver: null, title: '' }; + // Parse tags from JSON string if present if (subcategory.tags && typeof subcategory.tags === "string") { try { @@ -292,6 +383,23 @@ export async function updateNISTAIRMFSubcategoryById( ); await transaction.commit(); + + // Notify owner, reviewer, approver if changed + const entityName = currentData.title || `Subcategory #${subcategoryId}`; + const newOwner = subcategory.owner ? parseInt(String(subcategory.owner)) : null; + const newReviewer = subcategory.reviewer ? parseInt(String(subcategory.reviewer)) : null; + const newApprover = subcategory.approver ? parseInt(String(subcategory.approver)) : null; + + if (newOwner) { + notifyNistAiRmfAssignment(req, subcategoryId, entityName, "Owner", newOwner, currentData.owner); + } + if (newReviewer) { + notifyNistAiRmfAssignment(req, subcategoryId, entityName, "Reviewer", newReviewer, currentData.reviewer); + } + if (newApprover) { + notifyNistAiRmfAssignment(req, subcategoryId, entityName, "Approver", newApprover, currentData.approver); + } + await logEvent( "Update", `NIST AI RMF subcategory updated: ID ${subcategoryId}, title: ${updatedSubcategory.title}`, diff --git a/Servers/controllers/project.ctrl.ts b/Servers/controllers/project.ctrl.ts index 3c93d91e35..da8990d934 100644 --- a/Servers/controllers/project.ctrl.ts +++ b/Servers/controllers/project.ctrl.ts @@ -54,6 +54,7 @@ import { createApprovalRequestQuery, hasPendingApprovalQuery, getPendingApproval // SSE notifications disabled for now - can be re-enabled later if needed // import { notifyStepApprovers } from "../services/notification.service"; import { ApprovalRequestStatus } from "../domain.layer/enums/approval-workflow.enum"; +import { notifyUserAssigned } from "../services/inAppNotification.service"; export async function getAllProjects( req: Request, @@ -463,6 +464,25 @@ export async function createProject(req: Request, res: Response): Promise { }); }); + // Send owner assignment notification + const baseUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + if (createdProject.owner) { + const assignerName = `${actor.name} ${actor.surname}`.trim(); + notifyUserAssigned( + req.tenantId!, + createdProject.owner, + { + entityType: "project", + entityId: createdProject.id!, + entityName: createdProject.project_title, + roleType: "Owner", + entityUrl: `/project-view?projectId=${createdProject.id}`, + }, + assignerName, + baseUrl + ).catch((err) => console.error("Failed to send owner notification:", err)); + } + return res.status(201).json( STATUS_CODE[201]({ project: createdProject, @@ -631,6 +651,28 @@ export async function updateProjectById( tenantId: req.tenantId!, }); + // Send owner assignment notification if owner changed + const baseUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const oldOwner = existingProject.owner; + const newOwner = project.owner; + if (newOwner && newOwner !== oldOwner && newOwner !== userId) { + const assigner = await getUserByIdQuery(userId); + const assignerName = assigner ? `${assigner.name} ${assigner.surname}`.trim() : "Someone"; + notifyUserAssigned( + req.tenantId!, + newOwner, + { + entityType: "project", + entityId: projectId, + entityName: project.project_title, + roleType: "Owner", + entityUrl: `/project-view?projectId=${projectId}`, + }, + assignerName, + baseUrl + ).catch((err) => console.error("Failed to send owner notification:", err)); + } + // Calculate which members actually got added (both new and re-added) // This includes users who weren't in currentMembers but are now in the final project const finalMembers = project.members || []; diff --git a/Servers/controllers/risks.ctrl.ts b/Servers/controllers/risks.ctrl.ts index ba36c5996c..488b891aa0 100644 --- a/Servers/controllers/risks.ctrl.ts +++ b/Servers/controllers/risks.ctrl.ts @@ -24,6 +24,20 @@ import { trackProjectRiskChanges, recordProjectRiskDeletion, } from "../utils/projectRiskChangeHistory.utils"; +import { notifyUserAssigned } from "../services/inAppNotification.service"; +import { QueryTypes } from "sequelize"; + +// Helper function to get user name +async function getUserNameById(userId: number): Promise { + const result = await sequelize.query<{ name: string; surname: string }>( + `SELECT name, surname FROM public.users WHERE id = :userId`, + { replacements: { userId }, type: QueryTypes.SELECT } + ); + if (result[0]) { + return `${result[0].name} ${result[0].surname}`.trim(); + } + return "Someone"; +} export async function getAllRisks( req: Request, @@ -303,6 +317,42 @@ export async function createRisk( req.userId!, req.tenantId! ); + + // Send risk owner assignment notification (fire-and-forget) + if (newProjectRisk.risk_owner) { + const baseUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const assignerName = await getUserNameById(req.userId!); + + // Get project name for context (use first associated project if available) + let projectName: string | undefined; + const projects = req.body.projects || []; + if (projects.length > 0) { + const projectResult = await sequelize.query<{ project_title: string }>( + `SELECT project_title FROM "${req.tenantId!}".projects WHERE id = :projectId`, + { replacements: { projectId: projects[0] }, type: QueryTypes.SELECT } + ); + projectName = projectResult[0]?.project_title; + } + + notifyUserAssigned( + req.tenantId!, + newProjectRisk.risk_owner, + { + entityType: "project_risk", + entityId: newProjectRisk.id!, + entityName: newProjectRisk.risk_name, + roleType: "Risk Owner", + entityUrl: `/risk-management?riskId=${newProjectRisk.id}`, + }, + assignerName, + baseUrl, + { + projectName, + description: newProjectRisk.risk_description, + } + ).catch((err) => console.error("Failed to send risk owner notification:", err)); + } + return res.status(201).json(STATUS_CODE[201](newProjectRisk)); } @@ -448,6 +498,43 @@ export async function updateRiskById( "projectRisks.ctrl.ts" ); await logEvent("Update", `Project risk updated: ID ${projectRiskId}`, req.userId!, req.tenantId!); + + // Send risk owner assignment notification if owner changed (fire-and-forget) + const oldRiskOwner = existingProjectRisk.risk_owner; + const newRiskOwner = updatedProjectRisk.risk_owner; + if (newRiskOwner && newRiskOwner !== oldRiskOwner) { + const baseUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const assignerName = await getUserNameById(req.userId!); + + // Get project name for context + let projectName: string | undefined; + const projectsResult = await sequelize.query<{ project_title: string }>( + `SELECT p.project_title FROM "${req.tenantId!}".projects p + JOIN "${req.tenantId!}".project_risk_links prl ON p.id = prl.project_id + WHERE prl.risk_id = :riskId LIMIT 1`, + { replacements: { riskId: projectRiskId }, type: QueryTypes.SELECT } + ); + projectName = projectsResult[0]?.project_title; + + notifyUserAssigned( + req.tenantId!, + newRiskOwner, + { + entityType: "project_risk", + entityId: projectRiskId, + entityName: updatedProjectRisk.risk_name, + roleType: "Risk Owner", + entityUrl: `/risk-management?riskId=${projectRiskId}`, + }, + assignerName, + baseUrl, + { + projectName, + description: updatedProjectRisk.risk_description, + } + ).catch((err) => console.error("Failed to send risk owner notification:", err)); + } + return res.status(200).json(STATUS_CODE[200](updatedProjectRisk)); } diff --git a/Servers/controllers/task.ctrl.ts b/Servers/controllers/task.ctrl.ts index 4177fe068c..4491ae18ba 100644 --- a/Servers/controllers/task.ctrl.ts +++ b/Servers/controllers/task.ctrl.ts @@ -24,7 +24,8 @@ import { BusinessLogicException, ForbiddenException, } from "../domain.layer/exceptions/custom.exception"; -import { notifyTaskAssigned } from "../services/inAppNotification.service"; +import { notifyTaskAssigned, notifyTaskUpdated, ITaskEntityLinkForEmail } from "../services/inAppNotification.service"; +import { getTaskEntityLinksQuery } from "../utils/taskEntityLink.utils"; import { recordEntityCreation, trackEntityChanges, @@ -56,6 +57,7 @@ export async function createTask(req: Request, res: Response): Promise { status, categories, assignees, + entity_links, // Entity links passed for notification purposes } = req.body; // Create task with current user as creator @@ -122,23 +124,32 @@ export async function createTask(req: Request, res: Response): Promise { // Get base URL from env or default const baseUrl = process.env.FRONTEND_URL || "http://localhost:3000"; - // Notify each assignee (except the creator) + // Use entity links from request body if provided (frontend knows the current state) + let entityLinksForEmail: ITaskEntityLinkForEmail[] = []; + if (entity_links && Array.isArray(entity_links) && entity_links.length > 0) { + entityLinksForEmail = entity_links.map((link: any) => ({ + entity_id: link.entity_id, + entity_type: link.entity_type, + entity_name: link.entity_name || `${link.entity_type} #${link.entity_id}`, + })); + } + + // Notify each assignee for (const assigneeId of taskAssignees) { - if (assigneeId !== userId) { - await notifyTaskAssigned( - req.tenantId!, - assigneeId, - { - id: task.id!, - title: task.title, - description: task.description || undefined, - priority: task.priority || TaskPriority.MEDIUM, - due_date: task.due_date?.toISOString(), - }, - creatorName, - baseUrl - ); - } + await notifyTaskAssigned( + req.tenantId!, + assigneeId, + { + id: task.id!, + title: task.title, + description: task.description || undefined, + priority: task.priority || TaskPriority.MEDIUM, + due_date: task.due_date?.toISOString(), + entity_links: entityLinksForEmail, + }, + creatorName, + baseUrl + ); } } catch (notifyError) { await logFailure({ @@ -276,10 +287,11 @@ export async function getAllTasks(req: Request, res: Response): Promise { hasPrev: pageNum > 1, }; - // Add assignees to each task response (manually from dataValues) + // Add assignees and entity_links to each task response (manually from dataValues) const tasksWithAssignees = tasks.map((task) => ({ ...task.toJSON(), assignees: (task.dataValues as any)["assignees"] || [], + entity_links: (task.dataValues as any)["entity_links"] || [], })); await logSuccess({ @@ -337,10 +349,11 @@ export async function getTaskById(req: Request, res: Response): Promise { ); if (task) { - // Add assignees to response (manually from dataValues) + // Add assignees and entity_links to response (manually from dataValues) const taskResponse = { ...task.toJSON(), assignees: (task.dataValues as any)["assignees"] || [], + entity_links: (task.dataValues as any)["entity_links"] || [], }; await logSuccess({ @@ -425,6 +438,7 @@ export async function updateTask(req: Request, res: Response): Promise { status, categories, assignees, + entity_links, // Entity links passed for notification purposes } = req.body; // Only include fields that are being updated @@ -482,58 +496,100 @@ export async function updateTask(req: Request, res: Response): Promise { assignees: newAssignees, }; - // Send notifications to newly assigned users (async, don't block response) - if (assignees && assignees.length > 0) { - const newlyAssigned = newAssignees.filter( - (id: number) => !existingAssignees.includes(id) - ); + // Send notifications (async, don't block response) + (async () => { + try { + // Get updater name + const updaterResult = await sequelize.query<{ name: string; surname: string }>( + `SELECT name, surname FROM public.users WHERE id = :userId`, + { replacements: { userId }, type: QueryTypes.SELECT } + ); + const updater = updaterResult[0]; + const updaterName = updater ? `${updater.name} ${updater.surname}`.trim() : "Someone"; + + // Get base URL from env or default + const baseUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + + // Use entity links from request body if provided (for immediate notification), + // otherwise fall back to fetching from DB + let entityLinksForEmail: ITaskEntityLinkForEmail[] = []; + if (entity_links && Array.isArray(entity_links) && entity_links.length > 0) { + // Use passed entity links (frontend knows the current state) + entityLinksForEmail = entity_links.map((link: any) => ({ + entity_id: link.entity_id, + entity_type: link.entity_type, + entity_name: link.entity_name || `${link.entity_type} #${link.entity_id}`, + })); + } else { + // Fall back to fetching from DB (for backwards compatibility) + const dbEntityLinks = await getTaskEntityLinksQuery(updatedTask.id!, req.tenantId!); + entityLinksForEmail = dbEntityLinks.map(link => ({ + entity_id: link.entity_id, + entity_type: link.entity_type, + entity_name: link.entity_name || `${link.entity_type} #${link.entity_id}`, + })); + } - if (newlyAssigned.length > 0) { - (async () => { - try { - // Get updater name - const updaterResult = await sequelize.query<{ name: string; surname: string }>( - `SELECT name, surname FROM public.users WHERE id = :userId`, - { replacements: { userId }, type: QueryTypes.SELECT } - ); - const updater = updaterResult[0]; - const updaterName = updater ? `${updater.name} ${updater.surname}`.trim() : "Someone"; - - // Get base URL from env or default - const baseUrl = process.env.FRONTEND_URL || "http://localhost:3000"; - - // Notify each newly assigned user (except the updater) - for (const assigneeId of newlyAssigned) { - if (assigneeId !== userId) { - await notifyTaskAssigned( - req.tenantId!, - assigneeId, - { - id: updatedTask.id!, - title: updatedTask.title, - description: updatedTask.description || undefined, - priority: updatedTask.priority || TaskPriority.MEDIUM, - due_date: updatedTask.due_date?.toISOString(), - }, - updaterName, - baseUrl - ); - } - } - } catch (notifyError) { - await logFailure({ - eventType: "Update", - description: `Failed to send task assignment notifications: ${notifyError}`, - functionName: "updateTaskById", - fileName: "task.ctrl.ts", - error: notifyError as Error, - userId: req.userId!, - tenantId: req.tenantId!, - }); - } - })(); + // Determine newly assigned vs existing assignees + // Ensure both arrays contain numbers for proper comparison + const newAssigneesNumeric = newAssignees.map((id: any) => Number(id)); + const existingAssigneesNumeric = existingAssignees.map((id: any) => Number(id)); + + const newlyAssigned = newAssigneesNumeric.filter( + (id: number) => !existingAssigneesNumeric.includes(id) + ); + const existingAssigneesStillAssigned = newAssigneesNumeric.filter( + (id: number) => existingAssigneesNumeric.includes(id) + ); + + // Notify newly assigned users with TASK_ASSIGNED email + for (const assigneeId of newlyAssigned) { + await notifyTaskAssigned( + req.tenantId!, + assigneeId, + { + id: updatedTask.id!, + title: updatedTask.title, + description: updatedTask.description || undefined, + priority: updatedTask.priority || TaskPriority.MEDIUM, + due_date: updatedTask.due_date?.toISOString(), + entity_links: entityLinksForEmail, + }, + updaterName, + baseUrl + ); + } + + // Notify existing assignees with TASK_UPDATED email + for (const assigneeId of existingAssigneesStillAssigned) { + await notifyTaskUpdated( + req.tenantId!, + assigneeId, + { + id: updatedTask.id!, + title: updatedTask.title, + description: updatedTask.description || undefined, + priority: updatedTask.priority || TaskPriority.MEDIUM, + status: updatedTask.status || TaskStatus.OPEN, + due_date: updatedTask.due_date?.toISOString(), + entity_links: entityLinksForEmail, + }, + updaterName, + baseUrl + ); + } + } catch (notifyError) { + await logFailure({ + eventType: "Update", + description: `Failed to send task notifications: ${notifyError}`, + functionName: "updateTaskById", + fileName: "task.ctrl.ts", + error: notifyError as Error, + userId: req.userId!, + tenantId: req.tenantId!, + }); } - } + })(); return res.status(200).json(STATUS_CODE[200](taskResponse)); } catch (error) { diff --git a/Servers/controllers/taskEntityLink.ctrl.ts b/Servers/controllers/taskEntityLink.ctrl.ts new file mode 100644 index 0000000000..e0cd0a1df7 --- /dev/null +++ b/Servers/controllers/taskEntityLink.ctrl.ts @@ -0,0 +1,297 @@ +import { Request, Response } from "express"; +import { STATUS_CODE } from "../utils/statusCode.utils"; +import { + createTaskEntityLinkQuery, + getTaskEntityLinksQuery, + deleteTaskEntityLinkQuery, + isValidEntityType, + entityExistsQuery, + linkExistsQuery, + EntityType, +} from "../utils/taskEntityLink.utils"; +import { getTaskByIdQuery } from "../utils/task.utils"; +import { sequelize } from "../database/db"; +import { + logProcessing, + logSuccess, + logFailure, +} from "../utils/logger/logHelper"; + +/** + * Add an entity link to a task + * POST /tasks/:id/entities + */ +export async function addTaskEntityLink( + req: Request, + res: Response +): Promise { + const taskId = parseInt( + Array.isArray(req.params.id) ? req.params.id[0] : req.params.id + ); + + logProcessing({ + description: `starting addTaskEntityLink for task ID ${taskId}`, + functionName: "addTaskEntityLink", + fileName: "taskEntityLink.ctrl.ts", + userId: req.userId!, + tenantId: req.tenantId!, + }); + + const transaction = await sequelize.transaction(); + try { + const { userId, role } = req; + if (!userId || !role) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const { entity_id, entity_type, entity_name } = req.body; + + // Validate entity_type + if (!entity_type || !isValidEntityType(entity_type)) { + await transaction.rollback(); + return res.status(400).json( + STATUS_CODE[400](`Invalid entity_type: ${entity_type}`) + ); + } + + // Validate entity_id + if (!entity_id || typeof entity_id !== "number") { + await transaction.rollback(); + return res.status(400).json(STATUS_CODE[400]("entity_id is required")); + } + + // Check if task exists + const task = await getTaskByIdQuery( + taskId, + { userId, role }, + req.tenantId!, + req.organizationId! + ); + + if (!task) { + await transaction.rollback(); + return res.status(404).json(STATUS_CODE[404]("Task not found")); + } + + // Check if entity exists + const entityExists = await entityExistsQuery( + entity_id, + entity_type as EntityType, + req.tenantId!, + transaction + ); + + if (!entityExists) { + await transaction.rollback(); + return res.status(404).json( + STATUS_CODE[404](`${entity_type} with id ${entity_id} not found`) + ); + } + + // Check if link already exists + const linkExists = await linkExistsQuery( + taskId, + entity_id, + entity_type as EntityType, + req.tenantId!, + transaction + ); + + if (linkExists) { + await transaction.rollback(); + return res.status(409).json( + STATUS_CODE[409]("This entity is already linked to this task") + ); + } + + // Create the link + const link = await createTaskEntityLinkQuery( + { + task_id: taskId, + entity_id, + entity_type: entity_type as EntityType, + entity_name: entity_name || undefined, + }, + req.tenantId!, + transaction + ); + + await transaction.commit(); + + await logSuccess({ + eventType: "Create", + description: `Added ${entity_type} entity link to task ${taskId}`, + functionName: "addTaskEntityLink", + fileName: "taskEntityLink.ctrl.ts", + userId: req.userId!, + tenantId: req.tenantId!, + }); + + return res.status(201).json(STATUS_CODE[201](link)); + } catch (error) { + await transaction.rollback(); + + await logFailure({ + eventType: "Create", + description: "Failed to add entity link to task", + functionName: "addTaskEntityLink", + fileName: "taskEntityLink.ctrl.ts", + error: error as Error, + userId: req.userId!, + tenantId: req.tenantId!, + }); + + return res.status(500).json(STATUS_CODE[500]((error as Error).message)); + } +} + +/** + * Get all entity links for a task + * GET /tasks/:id/entities + */ +export async function getTaskEntityLinks( + req: Request, + res: Response +): Promise { + const taskId = parseInt( + Array.isArray(req.params.id) ? req.params.id[0] : req.params.id + ); + + logProcessing({ + description: `starting getTaskEntityLinks for task ID ${taskId}`, + functionName: "getTaskEntityLinks", + fileName: "taskEntityLink.ctrl.ts", + userId: req.userId!, + tenantId: req.tenantId!, + }); + + try { + const { userId, role } = req; + if (!userId || !role) { + return res.status(401).json({ message: "Unauthorized" }); + } + + // Check if task exists + const task = await getTaskByIdQuery( + taskId, + { userId, role }, + req.tenantId!, + req.organizationId! + ); + + if (!task) { + return res.status(404).json(STATUS_CODE[404]("Task not found")); + } + + const links = await getTaskEntityLinksQuery(taskId, req.tenantId!); + + await logSuccess({ + eventType: "Read", + description: `Retrieved entity links for task ${taskId}`, + functionName: "getTaskEntityLinks", + fileName: "taskEntityLink.ctrl.ts", + userId: req.userId!, + tenantId: req.tenantId!, + }); + + return res.status(200).json(STATUS_CODE[200](links)); + } catch (error) { + await logFailure({ + eventType: "Read", + description: "Failed to get entity links for task", + functionName: "getTaskEntityLinks", + fileName: "taskEntityLink.ctrl.ts", + error: error as Error, + userId: req.userId!, + tenantId: req.tenantId!, + }); + + return res.status(500).json(STATUS_CODE[500]((error as Error).message)); + } +} + +/** + * Remove an entity link from a task + * DELETE /tasks/:id/entities/:linkId + */ +export async function removeTaskEntityLink( + req: Request, + res: Response +): Promise { + const taskId = parseInt( + Array.isArray(req.params.id) ? req.params.id[0] : req.params.id + ); + const linkId = parseInt( + Array.isArray(req.params.linkId) ? req.params.linkId[0] : req.params.linkId + ); + + logProcessing({ + description: `starting removeTaskEntityLink for task ID ${taskId}, link ID ${linkId}`, + functionName: "removeTaskEntityLink", + fileName: "taskEntityLink.ctrl.ts", + userId: req.userId!, + tenantId: req.tenantId!, + }); + + const transaction = await sequelize.transaction(); + try { + const { userId, role } = req; + if (!userId || !role) { + return res.status(401).json({ message: "Unauthorized" }); + } + + // Check if task exists + const task = await getTaskByIdQuery( + taskId, + { userId, role }, + req.tenantId!, + req.organizationId! + ); + + if (!task) { + await transaction.rollback(); + return res.status(404).json(STATUS_CODE[404]("Task not found")); + } + + const deleted = await deleteTaskEntityLinkQuery( + linkId, + taskId, + req.tenantId!, + transaction + ); + + if (!deleted) { + await transaction.rollback(); + return res.status(404).json(STATUS_CODE[404]("Entity link not found")); + } + + await transaction.commit(); + + await logSuccess({ + eventType: "Delete", + description: `Removed entity link ${linkId} from task ${taskId}`, + functionName: "removeTaskEntityLink", + fileName: "taskEntityLink.ctrl.ts", + userId: req.userId!, + tenantId: req.tenantId!, + }); + + return res.status(200).json( + STATUS_CODE[200]({ message: "Entity link removed successfully" }) + ); + } catch (error) { + await transaction.rollback(); + + await logFailure({ + eventType: "Delete", + description: "Failed to remove entity link from task", + functionName: "removeTaskEntityLink", + fileName: "taskEntityLink.ctrl.ts", + error: error as Error, + userId: req.userId!, + tenantId: req.tenantId!, + }); + + return res.status(500).json(STATUS_CODE[500]((error as Error).message)); + } +} diff --git a/Servers/controllers/vendor.ctrl.ts b/Servers/controllers/vendor.ctrl.ts index 8abd384a13..b7eb9bf069 100644 --- a/Servers/controllers/vendor.ctrl.ts +++ b/Servers/controllers/vendor.ctrl.ts @@ -25,6 +25,20 @@ import { trackVendorChanges, recordMultipleFieldChanges, } from "../utils/vendorChangeHistory.utils"; +import { notifyUserAssigned } from "../services/inAppNotification.service"; +import { QueryTypes } from "sequelize"; + +// Helper function to get user name +async function getUserNameById(userId: number): Promise { + const result = await sequelize.query<{ name: string; surname: string }>( + `SELECT name, surname FROM public.users WHERE id = :userId`, + { replacements: { userId }, type: QueryTypes.SELECT } + ); + if (result[0]) { + return `${result[0].name} ${result[0].surname}`.trim(); + } + return "Someone"; +} export async function getAllVendors(req: Request, res: Response): Promise { logProcessing({ @@ -241,6 +255,52 @@ export async function createVendor(req: Request, res: Response): Promise { userId: req.userId!, tenantId: req.tenantId!, }); + + // Send assignment notifications (fire-and-forget) + const baseUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const assignerName = await getUserNameById(req.userId!); + + // Build entity context for vendor + const vendorContext = { + description: createdVendor.vendor_provides || undefined, + }; + + // Notify assignee if assigned + if (createdVendor.assignee) { + notifyUserAssigned( + req.tenantId!, + createdVendor.assignee, + { + entityType: "vendor", + entityId: createdVendor.id!, + entityName: createdVendor.vendor_name, + roleType: "Assignee", + entityUrl: `/vendors?vendorId=${createdVendor.id}`, + }, + assignerName, + baseUrl, + vendorContext + ).catch((err) => console.error("Failed to send assignee notification:", err)); + } + + // Notify reviewer if assigned + if (createdVendor.reviewer) { + notifyUserAssigned( + req.tenantId!, + createdVendor.reviewer, + { + entityType: "vendor", + entityId: createdVendor.id!, + entityName: createdVendor.vendor_name, + roleType: "Reviewer", + entityUrl: `/vendors?vendorId=${createdVendor.id}`, + }, + assignerName, + baseUrl, + vendorContext + ).catch((err) => console.error("Failed to send reviewer notification:", err)); + } + return res.status(201).json(STATUS_CODE[201](createdVendor)); } @@ -402,6 +462,56 @@ export async function updateVendorById( userId: req.userId!, tenantId: req.tenantId!, }); + + // Send assignment notifications for newly assigned users (fire-and-forget) + const baseUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const assignerName = await getUserNameById(userId); + + // Build entity context for vendor + const vendorContext = { + description: vendor.vendor_provides || undefined, + }; + + // Check if assignee changed + const oldAssignee = existingVendor.assignee; + const newAssignee = vendor.assignee; + if (newAssignee && newAssignee !== oldAssignee && newAssignee !== userId) { + notifyUserAssigned( + req.tenantId!, + newAssignee, + { + entityType: "vendor", + entityId: vendorId, + entityName: vendor.vendor_name, + roleType: "Assignee", + entityUrl: `/vendors?vendorId=${vendorId}`, + }, + assignerName, + baseUrl, + vendorContext + ).catch((err) => console.error("Failed to send assignee notification:", err)); + } + + // Check if reviewer changed + const oldReviewer = existingVendor.reviewer; + const newReviewer = vendor.reviewer; + if (newReviewer && newReviewer !== oldReviewer && newReviewer !== userId) { + notifyUserAssigned( + req.tenantId!, + newReviewer, + { + entityType: "vendor", + entityId: vendorId, + entityName: vendor.vendor_name, + roleType: "Reviewer", + entityUrl: `/vendors?vendorId=${vendorId}`, + }, + assignerName, + baseUrl, + vendorContext + ).catch((err) => console.error("Failed to send reviewer notification:", err)); + } + return res.status(202).json(STATUS_CODE[202](vendor)); } diff --git a/Servers/controllers/vendorRisk.ctrl.ts b/Servers/controllers/vendorRisk.ctrl.ts index 1ba98de0ec..ee9a84d1d3 100644 --- a/Servers/controllers/vendorRisk.ctrl.ts +++ b/Servers/controllers/vendorRisk.ctrl.ts @@ -17,6 +17,20 @@ import { trackVendorRiskChanges, recordMultipleFieldChanges, } from "../utils/vendorRiskChangeHistory.utils"; +import { notifyUserAssigned } from "../services/inAppNotification.service"; +import { QueryTypes } from "sequelize"; + +// Helper function to get user name +async function getUserNameById(userId: number): Promise { + const result = await sequelize.query<{ name: string; surname: string }>( + `SELECT name, surname FROM public.users WHERE id = :userId`, + { replacements: { userId }, type: QueryTypes.SELECT } + ); + if (result[0]) { + return `${result[0].name} ${result[0].surname}`.trim(); + } + return "Someone"; +} export async function getAllVendorRisksAllProjects( req: Request, @@ -259,6 +273,43 @@ export async function createVendorRisk( } await transaction.commit(); + + // Notify action_owner if assigned + const actionOwnerId = createdVendorRisk.action_owner; + if (actionOwnerId && createdVendorRisk.id) { + const assignerName = await getUserNameById(req.userId!); + const baseUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + + // Get vendor name for context + let vendorName: string | undefined; + if (createdVendorRisk.vendor_id) { + const vendorResult = await sequelize.query<{ vendor_name: string }>( + `SELECT vendor_name FROM "${req.tenantId!}".vendors WHERE id = :vendorId`, + { replacements: { vendorId: createdVendorRisk.vendor_id }, type: QueryTypes.SELECT } + ); + vendorName = vendorResult[0]?.vendor_name; + } + + notifyUserAssigned( + req.tenantId!, + actionOwnerId, + { + entityType: "Vendor Risk", + entityId: createdVendorRisk.id, + entityName: createdVendorRisk.risk_description || `Vendor Risk #${createdVendorRisk.id}`, + roleType: "Action Owner", + entityUrl: `${baseUrl}/vendors?vendorRiskId=${createdVendorRisk.id}`, + }, + assignerName, + baseUrl, + { + parentType: vendorName ? "Vendor" : undefined, + parentName: vendorName, + description: createdVendorRisk.impact_description || undefined, + } + ).catch((err) => console.error("Failed to send action owner notification:", err)); + } + await logSuccess({ eventType: 'Create', description: 'Created new vendor risk', @@ -357,6 +408,44 @@ export async function updateVendorRiskById( } await transaction.commit(); + + // Notify action_owner if changed to a new user + const oldActionOwner = (existingVendorRisk as any).action_owner; + const newActionOwner = updatedVendorRisk.action_owner; + if (newActionOwner && newActionOwner !== oldActionOwner) { + const assignerName = await getUserNameById(req.userId!); + const baseUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + + // Get vendor name for context + let vendorName: string | undefined; + if (vendorRisk.vendor_id) { + const vendorResult = await sequelize.query<{ vendor_name: string }>( + `SELECT vendor_name FROM "${req.tenantId!}".vendors WHERE id = :vendorId`, + { replacements: { vendorId: vendorRisk.vendor_id }, type: QueryTypes.SELECT } + ); + vendorName = vendorResult[0]?.vendor_name; + } + + notifyUserAssigned( + req.tenantId!, + newActionOwner, + { + entityType: "Vendor Risk", + entityId: vendorRiskId, + entityName: vendorRisk.risk_description || `Vendor Risk #${vendorRiskId}`, + roleType: "Action Owner", + entityUrl: `${baseUrl}/vendors?vendorRiskId=${vendorRiskId}`, + }, + assignerName, + baseUrl, + { + parentType: vendorName ? "Vendor" : undefined, + parentName: vendorName, + description: vendorRisk.impact_description || undefined, + } + ).catch((err) => console.error("Failed to send action owner notification:", err)); + } + await logSuccess({ eventType: 'Update', description: `Updated vendor risk ID ${vendorRiskId}`, diff --git a/Servers/database/migrations/20260223192856-add-task-entity-links-table.js b/Servers/database/migrations/20260223192856-add-task-entity-links-table.js new file mode 100644 index 0000000000..fd4b5bc44a --- /dev/null +++ b/Servers/database/migrations/20260223192856-add-task-entity-links-table.js @@ -0,0 +1,192 @@ +'use strict'; +const { getTenantHash } = require("../../dist/tools/getTenantHash"); + +/** + * Migration to add task_entity_links table to all existing tenant schemas + * + * This table links tasks to various entities: + * - vendor: Links to vendors table + * - model: Links to model_inventories table + * - policy: Links to policy_manager table + * - nist_subcategory: Links to nist_ai_rmf_subcategories table + * - iso42001_subclause: Links to subclauses_iso table + * - iso42001_annexcategory: Links to annexcategories_iso table + * - iso27001_subclause: Links to subclauses_iso27001 table + * - iso27001_annexcontrol: Links to annexcontrols_iso27001 table + * - eu_control: Links to controls_eu table + * - eu_subcontrol: Links to subcontrols_eu table + * + * @type {import('sequelize-cli').Migration} + */ + +const logger = { + info: (msg) => console.log(`[MIGRATION-INFO] ${new Date().toISOString()} - ${msg}`), + error: (msg, error) => console.error(`[MIGRATION-ERROR] ${new Date().toISOString()} - ${msg}`, error ? error.stack || error : ''), + success: (msg) => console.log(`[MIGRATION-SUCCESS] ${new Date().toISOString()} - ${msg}`), + warn: (msg) => console.warn(`[MIGRATION-WARN] ${new Date().toISOString()} - ${msg}`) +}; + +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + try { + logger.info('Starting task_entity_links table migration for existing tenants'); + + // Create ENUM type for entity_type + await createEntityTypeEnumIfNeeded(queryInterface, transaction); + + // Get all organizations for tenant processing + const organizations = await queryInterface.sequelize.query(`SELECT id FROM organizations;`, { transaction }); + + if (organizations[0].length === 0) { + logger.warn('No organizations found. Skipping tenant table creation.'); + await transaction.commit(); + return; + } + + logger.info(`Processing ${organizations[0].length} tenant schemas`); + + let successCount = 0; + let errorCount = 0; + + for (let organization of organizations[0]) { + try { + const tenantHash = getTenantHash(organization.id); + logger.info(`Processing tenant: ${tenantHash} (org_id: ${organization.id})`); + + await createTaskEntityLinksForTenant(queryInterface, tenantHash, transaction); + successCount++; + + } catch (tenantError) { + errorCount++; + logger.error(`Failed to process tenant for org_id ${organization.id}:`, tenantError); + } + } + + await transaction.commit(); + logger.success(`Migration completed. Success: ${successCount}, Errors: ${errorCount}`); + + } catch (error) { + await transaction.rollback(); + logger.error('Migration failed and was rolled back:', error); + throw error; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + try { + logger.info('Starting rollback of task_entity_links table from existing tenants'); + + const organizations = await queryInterface.sequelize.query(`SELECT id FROM organizations;`, { transaction }); + + for (let organization of organizations[0]) { + const tenantHash = getTenantHash(organization.id); + logger.info(`Removing task_entity_links table from tenant: ${tenantHash}`); + + await queryInterface.sequelize.query(`DROP TABLE IF EXISTS "${tenantHash}".task_entity_links CASCADE;`, { transaction }); + } + + await transaction.commit(); + logger.success('Successfully rolled back task_entity_links table from all tenant schemas'); + } catch (error) { + await transaction.rollback(); + logger.error('Rollback failed:', error); + throw error; + } + } +}; + +/** + * Create ENUM type for entity_type if it doesn't exist + */ +async function createEntityTypeEnumIfNeeded(queryInterface, transaction) { + try { + const [enumExists] = await queryInterface.sequelize.query(` + SELECT 1 FROM pg_type WHERE typname = 'enum_task_entity_links_entity_type' + `, { transaction, type: queryInterface.sequelize.QueryTypes.SELECT }); + + if (!enumExists) { + logger.info('Creating enum_task_entity_links_entity_type ENUM type'); + await queryInterface.sequelize.query(` + CREATE TYPE enum_task_entity_links_entity_type AS ENUM ( + 'vendor', + 'model', + 'policy', + 'nist_subcategory', + 'iso42001_subclause', + 'iso42001_annexcategory', + 'iso27001_subclause', + 'iso27001_annexcontrol', + 'eu_control', + 'eu_subcontrol' + ); + `, { transaction }); + } else { + logger.info('enum_task_entity_links_entity_type already exists, skipping creation'); + } + } catch (error) { + logger.error('Failed to create entity_type ENUM:', error); + throw error; + } +} + +/** + * Creates task_entity_links table for a specific tenant + */ +async function createTaskEntityLinksForTenant(queryInterface, tenantHash, transaction) { + // Check if table already exists + const [tableExists] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.tables + WHERE table_schema = '${tenantHash}' AND table_name = 'task_entity_links' + `, { transaction, type: queryInterface.sequelize.QueryTypes.SELECT }); + + if (tableExists) { + logger.info(`task_entity_links table already exists for tenant ${tenantHash}, skipping creation`); + return; + } + + // Check if tasks table exists for this tenant + const [tasksTableExists] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.tables + WHERE table_schema = '${tenantHash}' AND table_name = 'tasks' + `, { transaction, type: queryInterface.sequelize.QueryTypes.SELECT }); + + if (!tasksTableExists) { + logger.warn(`tasks table does not exist for tenant ${tenantHash}, skipping task_entity_links creation`); + return; + } + + const queries = [ + // Task entity links table creation + `CREATE TABLE "${tenantHash}".task_entity_links ( + id SERIAL PRIMARY KEY, + task_id INTEGER NOT NULL, + entity_id INTEGER NOT NULL, + entity_type enum_task_entity_links_entity_type NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT task_entity_links_task_id_fkey FOREIGN KEY (task_id) + REFERENCES "${tenantHash}".tasks (id) MATCH SIMPLE + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT unique_task_entity_link UNIQUE (task_id, entity_id, entity_type) + );`, + + // Indexes for efficient querying + `CREATE INDEX "${tenantHash}_task_entity_links_task_id_idx" ON "${tenantHash}".task_entity_links (task_id);`, + `CREATE INDEX "${tenantHash}_task_entity_links_entity_type_idx" ON "${tenantHash}".task_entity_links (entity_type);`, + `CREATE INDEX "${tenantHash}_task_entity_links_entity_id_entity_type_idx" ON "${tenantHash}".task_entity_links (entity_id, entity_type);` + ]; + + for (const [index, query] of queries.entries()) { + try { + await queryInterface.sequelize.query(query, { transaction }); + } catch (queryError) { + logger.error(`Failed to execute query ${index + 1} for tenant ${tenantHash}:`, queryError); + logger.error(`Query was: ${query}`); + throw queryError; + } + } + + logger.success(`Successfully created task_entity_links table for tenant: ${tenantHash}`); +} diff --git a/Servers/database/migrations/20260223203508-add-entity-name-to-task-entity-links.js b/Servers/database/migrations/20260223203508-add-entity-name-to-task-entity-links.js new file mode 100644 index 0000000000..588c2e34bd --- /dev/null +++ b/Servers/database/migrations/20260223203508-add-entity-name-to-task-entity-links.js @@ -0,0 +1,128 @@ +'use strict'; +const { getTenantHash } = require("../../dist/tools/getTenantHash"); + +/** + * Migration to add entity_name column to task_entity_links table + * This stores the display name for the linked entity + * + * @type {import('sequelize-cli').Migration} + */ + +const logger = { + info: (msg) => console.log(`[MIGRATION-INFO] ${new Date().toISOString()} - ${msg}`), + error: (msg, error) => console.error(`[MIGRATION-ERROR] ${new Date().toISOString()} - ${msg}`, error ? error.stack || error : ''), + success: (msg) => console.log(`[MIGRATION-SUCCESS] ${new Date().toISOString()} - ${msg}`), + warn: (msg) => console.warn(`[MIGRATION-WARN] ${new Date().toISOString()} - ${msg}`) +}; + +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + try { + logger.info('Starting entity_name column addition to task_entity_links'); + + // Get all organizations for tenant processing + const organizations = await queryInterface.sequelize.query(`SELECT id FROM organizations;`, { transaction }); + + if (organizations[0].length === 0) { + logger.warn('No organizations found. Skipping.'); + await transaction.commit(); + return; + } + + logger.info(`Processing ${organizations[0].length} tenant schemas`); + + let successCount = 0; + let skipCount = 0; + + for (let organization of organizations[0]) { + try { + const tenantHash = getTenantHash(organization.id); + + // Check if table exists + const [tableExists] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.tables + WHERE table_schema = '${tenantHash}' AND table_name = 'task_entity_links' + `, { transaction, type: queryInterface.sequelize.QueryTypes.SELECT }); + + if (!tableExists) { + logger.warn(`task_entity_links table does not exist for tenant ${tenantHash}, skipping`); + skipCount++; + continue; + } + + // Check if column already exists + const [columnExists] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.columns + WHERE table_schema = '${tenantHash}' + AND table_name = 'task_entity_links' + AND column_name = 'entity_name' + `, { transaction, type: queryInterface.sequelize.QueryTypes.SELECT }); + + if (columnExists) { + logger.info(`entity_name column already exists for tenant ${tenantHash}, skipping`); + skipCount++; + continue; + } + + // Add entity_name column + await queryInterface.sequelize.query(` + ALTER TABLE "${tenantHash}".task_entity_links + ADD COLUMN entity_name VARCHAR(500); + `, { transaction }); + + successCount++; + logger.success(`Added entity_name column for tenant: ${tenantHash}`); + + } catch (tenantError) { + logger.error(`Failed to process tenant for org_id ${organization.id}:`, tenantError); + throw tenantError; + } + } + + await transaction.commit(); + logger.success(`Migration completed. Added: ${successCount}, Skipped: ${skipCount}`); + + } catch (error) { + await transaction.rollback(); + logger.error('Migration failed and was rolled back:', error); + throw error; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + try { + logger.info('Starting rollback of entity_name column from task_entity_links'); + + const organizations = await queryInterface.sequelize.query(`SELECT id FROM organizations;`, { transaction }); + + for (let organization of organizations[0]) { + const tenantHash = getTenantHash(organization.id); + + // Check if column exists before trying to drop + const [columnExists] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.columns + WHERE table_schema = '${tenantHash}' + AND table_name = 'task_entity_links' + AND column_name = 'entity_name' + `, { transaction, type: queryInterface.sequelize.QueryTypes.SELECT }); + + if (columnExists) { + await queryInterface.sequelize.query(` + ALTER TABLE "${tenantHash}".task_entity_links + DROP COLUMN entity_name; + `, { transaction }); + logger.info(`Removed entity_name column from tenant: ${tenantHash}`); + } + } + + await transaction.commit(); + logger.success('Successfully rolled back entity_name column from all tenant schemas'); + } catch (error) { + await transaction.rollback(); + logger.error('Rollback failed:', error); + throw error; + } + } +}; diff --git a/Servers/database/migrations/20260224003057-add-task-updated-notification-type.js b/Servers/database/migrations/20260224003057-add-task-updated-notification-type.js new file mode 100644 index 0000000000..d763e57c1f --- /dev/null +++ b/Servers/database/migrations/20260224003057-add-task-updated-notification-type.js @@ -0,0 +1,34 @@ +"use strict"; +const { getTenantHash } = require("../../dist/tools/getTenantHash"); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Get all organizations + const organizations = await queryInterface.sequelize.query( + `SELECT id FROM organizations;`, + { type: Sequelize.QueryTypes.SELECT } + ); + + for (const org of organizations) { + const tenantHash = getTenantHash(org.id); + + // ALTER TYPE ADD VALUE cannot run inside a transaction + // Use IF NOT EXISTS to make it idempotent + try { + await queryInterface.sequelize.query( + `ALTER TYPE "${tenantHash}".enum_notification_type ADD VALUE IF NOT EXISTS 'task_updated';` + ); + console.log(`Added task_updated to enum_notification_type for tenant: ${tenantHash}`); + } catch (error) { + // Value might already exist + console.log(`Skipping ${tenantHash}: ${error.message}`); + } + } + }, + + async down(queryInterface, Sequelize) { + // PostgreSQL doesn't support removing values from enums easily + console.log("Skipping down migration - cannot remove enum values in PostgreSQL"); + }, +}; diff --git a/Servers/database/migrations/20260224111838-add-assignment-notification-types.js b/Servers/database/migrations/20260224111838-add-assignment-notification-types.js new file mode 100644 index 0000000000..da9d66d18b --- /dev/null +++ b/Servers/database/migrations/20260224111838-add-assignment-notification-types.js @@ -0,0 +1,68 @@ +'use strict'; + +const { getTenantHash } = require("../../dist/tools/getTenantHash"); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Add new notification type enum values for assignment notifications + // NOTE: ALTER TYPE ... ADD VALUE cannot run inside a transaction in PostgreSQL + + const newValues = [ + 'assignment_owner', + 'assignment_reviewer', + 'assignment_approver', + 'assignment_member', + 'assignment_assignee', + 'assignment_action_owner', + 'assignment_risk_owner' + ]; + + // Get all organizations + const [organizations] = await queryInterface.sequelize.query( + `SELECT id FROM public.organizations;` + ); + + if (organizations.length === 0) { + console.log('No organizations found, skipping migration'); + return; + } + + // Process each tenant + for (const org of organizations) { + const tenantHash = getTenantHash(org.id); + + for (const value of newValues) { + try { + // Check if value already exists + const [existing] = await queryInterface.sequelize.query( + `SELECT 1 FROM pg_enum + WHERE enumtypid = (SELECT oid FROM pg_type WHERE typname = 'enum_notification_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = '${tenantHash}')) + AND enumlabel = '${value}'` + ); + + if (existing.length === 0) { + // Value doesn't exist, add it (must be outside transaction) + await queryInterface.sequelize.query( + `ALTER TYPE "${tenantHash}".enum_notification_type ADD VALUE '${value}';` + ); + console.log(`Added '${value}' to ${tenantHash}.enum_notification_type`); + } + } catch (error) { + // Value might already exist or enum might not exist + console.log(`Note for ${tenantHash}: ${error.message}`); + } + } + + console.log(`Processed tenant ${tenantHash}`); + } + + console.log('Migration completed: Added assignment notification types'); + }, + + async down(queryInterface, Sequelize) { + // PostgreSQL doesn't support removing enum values directly + // To revert, you would need to recreate the enum type + console.log('Note: Cannot remove enum values in PostgreSQL. Manual intervention required if needed.'); + } +}; diff --git a/Servers/domain.layer/interfaces/i.notification.ts b/Servers/domain.layer/interfaces/i.notification.ts index b508183d29..bf14c11b3f 100644 --- a/Servers/domain.layer/interfaces/i.notification.ts +++ b/Servers/domain.layer/interfaces/i.notification.ts @@ -5,6 +5,7 @@ export enum NotificationType { // Task notifications TASK_ASSIGNED = "task_assigned", + TASK_UPDATED = "task_updated", TASK_COMPLETED = "task_completed", // Review notifications @@ -41,6 +42,14 @@ export enum NotificationType { // System notifications SYSTEM = "system", + + // Assignment notifications + ASSIGNMENT_OWNER = "assignment_owner", + ASSIGNMENT_REVIEWER = "assignment_reviewer", + ASSIGNMENT_APPROVER = "assignment_approver", + ASSIGNMENT_MEMBER = "assignment_member", + ASSIGNMENT_ASSIGNEE = "assignment_assignee", + ASSIGNMENT_ACTION_OWNER = "assignment_action_owner", } /** diff --git a/Servers/routes/task.route.ts b/Servers/routes/task.route.ts index c3516aac58..e2a75364e1 100644 --- a/Servers/routes/task.route.ts +++ b/Servers/routes/task.route.ts @@ -11,14 +11,22 @@ import { hardDeleteTask, } from "../controllers/task.ctrl"; +import { + addTaskEntityLink, + getTaskEntityLinks, + removeTaskEntityLink, +} from "../controllers/taskEntityLink.ctrl"; + import authenticateJWT from "../middleware/auth.middleware"; // GET requests router.get("/", authenticateJWT, getAllTasks); router.get("/:id", authenticateJWT, getTaskById); +router.get("/:id/entities", authenticateJWT, getTaskEntityLinks); // POST requests router.post("/", authenticateJWT, createTask); +router.post("/:id/entities", authenticateJWT, addTaskEntityLink); // PUT requests // Note: More specific routes must come before generic /:id routes @@ -28,6 +36,7 @@ router.put("/:id", authenticateJWT, updateTask); // DELETE requests // Note: More specific routes must come before generic /:id routes router.delete("/:id/hard", authenticateJWT, hardDeleteTask); +router.delete("/:id/entities/:linkId", authenticateJWT, removeTaskEntityLink); router.delete("/:id", authenticateJWT, deleteTask); export default router; \ No newline at end of file diff --git a/Servers/scripts/createNewTenant.ts b/Servers/scripts/createNewTenant.ts index 533b5c3268..c1392af90a 100644 --- a/Servers/scripts/createNewTenant.ts +++ b/Servers/scripts/createNewTenant.ts @@ -2804,7 +2804,14 @@ export const createNewTenant = async ( 'file_uploaded', 'comment_added', 'mention', - 'system' + 'system', + 'assignment_owner', + 'assignment_reviewer', + 'assignment_approver', + 'assignment_member', + 'assignment_assignee', + 'assignment_action_owner', + 'assignment_risk_owner' ); EXCEPTION WHEN duplicate_object THEN null; diff --git a/Servers/services/inAppNotification.service.ts b/Servers/services/inAppNotification.service.ts index baa705feab..9a459249bf 100644 --- a/Servers/services/inAppNotification.service.ts +++ b/Servers/services/inAppNotification.service.ts @@ -125,6 +125,218 @@ export const sendBulkInAppNotifications = async ( } }; +/** + * Entity link for task notifications + */ +export interface ITaskEntityLinkForEmail { + entity_id: number; + entity_type: string; + entity_name: string; +} + +/** + * Format a date string for display in emails + */ +function formatDateForEmail(dateStr?: string): string { + if (!dateStr) return "No due date"; + try { + const date = new Date(dateStr); + return date.toLocaleDateString("en-US", { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + }); + } catch { + return dateStr; + } +} + +/** + * Get a human-readable label for entity type + */ +function getEntityTypeLabel(entityType: string): string { + const labels: Record = { + vendor: "Vendor", + model: "Model", + policy: "Policy", + nist_subcategory: "NIST AI RMF", + iso42001_subclause: "ISO 42001", + iso42001_annexcategory: "ISO 42001 Annex", + iso27001_subclause: "ISO 27001", + iso27001_annexcontrol: "ISO 27001 Annex", + eu_control: "EU AI Act", + eu_subcontrol: "EU AI Act", + }; + return labels[entityType] || entityType; +} + +/** + * Build URL for entities - queries DB for parent context when needed + */ +async function buildEntityUrlAsync( + baseUrl: string, + entityType: string, + entityId: number, + tenantId: string +): Promise { + switch (entityType) { + case "vendor": + return `${baseUrl}/vendors?vendorId=${entityId}`; + case "model": + return `${baseUrl}/model-inventory?modelId=${entityId}`; + case "policy": + return `${baseUrl}/policies?policyId=${entityId}`; + + case "nist_subcategory": { + // Get function type and category info for NIST + const result = await sequelize.query<{ func_type: string; category_id: number }>( + `SELECT f.type as func_type, c.id as category_id + FROM "${tenantId}".nist_ai_rmf_subcategories s + JOIN public.nist_ai_rmf_categories c ON s.category_id = c.id + JOIN public.nist_ai_rmf_functions f ON c.function_id = f.id + WHERE s.id = :entityId`, + { replacements: { entityId }, type: QueryTypes.SELECT } + ); + if (result[0]) { + // Tabs expect lowercase: govern, map, measure, manage + const funcType = result[0].func_type.toLowerCase(); + return `${baseUrl}/framework?framework=nist-ai-rmf&functionId=${funcType}&categoryId=${result[0].category_id}&subcategoryId=${entityId}`; + } + return null; + } + + case "iso42001_subclause": { + // Get clause ID for ISO 42001 subclause - join with struct table + const result = await sequelize.query<{ clause_id: number }>( + `SELECT scs.clause_id + FROM "${tenantId}".subclauses_iso sc + JOIN public.subclauses_struct_iso scs ON sc.subclause_meta_id = scs.id + WHERE sc.id = :entityId`, + { replacements: { entityId }, type: QueryTypes.SELECT } + ); + if (result[0]) { + return `${baseUrl}/framework?framework=iso-42001&clauseId=${result[0].clause_id}&subClauseId=${entityId}`; + } + return null; + } + + case "iso42001_annexcategory": { + // Get annex ID for ISO 42001 annex category - join with struct table + const result = await sequelize.query<{ annex_id: number }>( + `SELECT acs.annex_id + FROM "${tenantId}".annexcategories_iso ac + JOIN public.annexcategories_struct_iso acs ON ac.annexcategory_meta_id = acs.id + WHERE ac.id = :entityId`, + { replacements: { entityId }, type: QueryTypes.SELECT } + ); + if (result[0]) { + return `${baseUrl}/framework?framework=iso-42001&annexId=${result[0].annex_id}&annexCategoryId=${entityId}`; + } + return null; + } + + case "iso27001_subclause": { + // Get clause ID for ISO 27001 subclause - join with struct table + const result = await sequelize.query<{ clause_id: number }>( + `SELECT scs.clause_id + FROM "${tenantId}".subclauses_iso27001 sc + JOIN public.subclauses_struct_iso27001 scs ON sc.subclause_meta_id = scs.id + WHERE sc.id = :entityId`, + { replacements: { entityId }, type: QueryTypes.SELECT } + ); + if (result[0]) { + return `${baseUrl}/framework?framework=iso-27001&clause27001Id=${result[0].clause_id}&subClause27001Id=${entityId}`; + } + return null; + } + + case "iso27001_annexcontrol": { + // Get annex ID for ISO 27001 annex control - join with struct table + const result = await sequelize.query<{ annex_id: number }>( + `SELECT acs.annex_id + FROM "${tenantId}".annexcontrols_iso27001 ac + JOIN public.annexcontrols_struct_iso27001 acs ON ac.annexcontrol_meta_id = acs.id + WHERE ac.id = :entityId`, + { replacements: { entityId }, type: QueryTypes.SELECT } + ); + if (result[0]) { + return `${baseUrl}/framework?framework=iso-27001&annex27001Id=${result[0].annex_id}&annexControl27001Id=${entityId}`; + } + return null; + } + + case "eu_control": { + // EU AI Act controls - need project_id via projects_frameworks join + const result = await sequelize.query<{ project_id: number }>( + `SELECT pf.project_id + FROM "${tenantId}".controls_eu c + JOIN "${tenantId}".projects_frameworks pf ON c.projects_frameworks_id = pf.id + WHERE c.id = :entityId`, + { replacements: { entityId }, type: QueryTypes.SELECT } + ); + if (result[0]) { + return `${baseUrl}/project-view?projectId=${result[0].project_id}&tab=frameworks&framework=eu-ai-act&subtab=compliance&controlId=${entityId}`; + } + return null; + } + + case "eu_subcontrol": { + // EU AI Act subcontrols - need project_id via controls_eu and projects_frameworks join + const result = await sequelize.query<{ project_id: number; control_id: number }>( + `SELECT pf.project_id, sc.control_id + FROM "${tenantId}".subcontrols_eu sc + JOIN "${tenantId}".controls_eu c ON sc.control_id = c.id + JOIN "${tenantId}".projects_frameworks pf ON c.projects_frameworks_id = pf.id + WHERE sc.id = :entityId`, + { replacements: { entityId }, type: QueryTypes.SELECT } + ); + if (result[0]) { + return `${baseUrl}/project-view?projectId=${result[0].project_id}&tab=frameworks&framework=eu-ai-act&subtab=compliance&controlId=${result[0].control_id}&subControlId=${entityId}`; + } + return null; + } + + default: + return null; + } +} + +/** + * Build HTML for entity links to include in email + * All entity types get clickable links (queries DB for parent context when needed) + */ +async function buildEntityLinksHtml( + baseUrl: string, + entityLinks: ITaskEntityLinkForEmail[], + tenantId: string +): Promise { + if (!entityLinks || entityLinks.length === 0) { + return ""; + } + + const linkRows: string[] = []; + for (const link of entityLinks) { + const displayName = link.entity_name || `${link.entity_type} #${link.entity_id}`; + const typeLabel = getEntityTypeLabel(link.entity_type); + + let url: string | null = null; + try { + url = await buildEntityUrlAsync(baseUrl, link.entity_type, link.entity_id, tenantId); + } catch (error) { + console.error(`Failed to build URL for ${link.entity_type}:${link.entity_id}:`, error); + } + + const nameHtml = url + ? `${displayName}` + : `${displayName}`; + + linkRows.push(`${typeLabel}:${nameHtml}`); + } + + return `${linkRows.join("")}
Items to complete:
`; +} + /** * Notify task assignment */ @@ -137,12 +349,16 @@ export const notifyTaskAssigned = async ( description?: string; priority: string; due_date?: string; + entity_links?: ITaskEntityLinkForEmail[]; }, assignerName: string, baseUrl: string ): Promise => { const assignee = await getUserById(assigneeId); + // Build entity links HTML for email (async - queries DB for parent context) + const entityLinksHtml = await buildEntityLinksHtml(baseUrl, task.entity_links || [], tenantId); + await sendInAppNotification( tenantId, { @@ -165,8 +381,63 @@ export const notifyTaskAssigned = async ( task_title: task.title, task_description: task.description || "No description provided", task_priority: task.priority, - task_due_date: task.due_date || "No due date", + task_due_date: formatDateForEmail(task.due_date), task_url: `${baseUrl}/tasks?taskId=${task.id}`, + entity_links_html: entityLinksHtml, + }, + } + ); +}; + +/** + * Notify task updated + */ +export const notifyTaskUpdated = async ( + tenantId: string, + assigneeId: number, + task: { + id: number; + title: string; + description?: string; + priority: string; + status: string; + due_date?: string; + entity_links?: ITaskEntityLinkForEmail[]; + }, + updaterName: string, + baseUrl: string +): Promise => { + const assignee = await getUserById(assigneeId); + + // Build entity links HTML for email (async - queries DB for parent context) + const entityLinksHtml = await buildEntityLinksHtml(baseUrl, task.entity_links || [], tenantId); + + await sendInAppNotification( + tenantId, + { + user_id: assigneeId, + type: NotificationType.TASK_UPDATED, + title: "Task updated", + message: `${updaterName} updated task: ${task.title}`, + entity_type: NotificationEntityType.TASK, + entity_id: task.id, + entity_name: task.title, + action_url: `/tasks?taskId=${task.id}`, + }, + true, + { + template: EMAIL_TEMPLATES.TASK_UPDATED, + subject: `Task updated: ${task.title}`, + variables: { + assignee_name: assignee ? `${assignee.name}` : "User", + updater_name: updaterName, + task_title: task.title, + task_description: task.description || "No description provided", + task_priority: task.priority, + task_status: task.status, + task_due_date: formatDateForEmail(task.due_date), + task_url: `${baseUrl}/tasks?taskId=${task.id}`, + entity_links_html: entityLinksHtml, }, } ); @@ -577,3 +848,233 @@ async function getUserEmails(userIds: number[]): Promise = { + vendor: "Vendor", + vendor_risk: "Vendor Risk", + project_risk: "Use Case Risk", + project: "Use Case", + model_inventory: "Model", + iso42001_subclause: "ISO 42001 Subclause", + iso42001_annexcategory: "ISO 42001 Annex", + iso27001_subclause: "ISO 27001 Subclause", + iso27001_annexcontrol: "ISO 27001 Annex Control", + eu_control: "EU AI Act Control", + eu_subcontrol: "EU AI Act Subcontrol", + nist_subcategory: "NIST AI RMF Subcategory", +}; + +/** + * Map role type to notification type + */ +function getNotificationTypeForRole(roleType: AssignmentRoleType): NotificationType { + switch (roleType) { + case "Owner": + case "Risk Owner": + return NotificationType.ASSIGNMENT_OWNER; + case "Reviewer": + return NotificationType.ASSIGNMENT_REVIEWER; + case "Approver": + return NotificationType.ASSIGNMENT_APPROVER; + case "Member": + return NotificationType.ASSIGNMENT_MEMBER; + case "Assignee": + return NotificationType.ASSIGNMENT_ASSIGNEE; + case "Action Owner": + return NotificationType.ASSIGNMENT_ACTION_OWNER; + default: + return NotificationType.ASSIGNMENT_OWNER; + } +} + +/** + * Get description of what each role is responsible for + */ +function getRoleDescription(roleType: AssignmentRoleType): string { + switch (roleType) { + case "Owner": + return "As the Owner, you are responsible for completing this item and ensuring all requirements are met."; + case "Risk Owner": + return "As the Risk Owner, you are responsible for managing and mitigating this risk."; + case "Reviewer": + return "As the Reviewer, you are responsible for reviewing the work and providing feedback before approval."; + case "Approver": + return "As the Approver, you have the authority to approve or reject this item once it's ready."; + case "Member": + return "As a Member, you have access to view and contribute to this project."; + case "Assignee": + return "As the Assignee, you are responsible for completing this task."; + case "Action Owner": + return "As the Action Owner, you are responsible for taking action to address this item."; + default: + return "You have been assigned to this item."; + } +} + +/** + * Format date for email display + */ +function formatAssignmentDate(): string { + return new Date().toLocaleDateString("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +/** + * Map entity type string to NotificationEntityType enum + */ +function getNotificationEntityType(entityType: string): NotificationEntityType { + switch (entityType) { + case "vendor": + return NotificationEntityType.VENDOR; + case "project": + return NotificationEntityType.PROJECT; + case "model_inventory": + return NotificationEntityType.MODEL; + case "project_risk": + case "vendor_risk": + return NotificationEntityType.RISK; + default: + return NotificationEntityType.PROJECT; + } +} + +/** + * Entity context for additional details in assignment emails + */ +export interface AssignmentEntityContext { + frameworkName?: string; // e.g., "ISO 27001", "NIST AI RMF" + parentType?: string; // e.g., "Annex", "Clause", "Control" + parentName?: string; // e.g., "A.5 Organizational controls" + projectName?: string; // Project this belongs to + description?: string; // Brief description of the entity +} + +/** + * Build HTML for entity context to include in email + */ +function buildEntityContextHtml(context?: AssignmentEntityContext): string { + if (!context) return ""; + + const rows: string[] = []; + + if (context.frameworkName) { + rows.push(`Framework:${context.frameworkName}`); + } + + if (context.projectName) { + rows.push(`Project:${context.projectName}`); + } + + if (context.parentType && context.parentName) { + rows.push(`${context.parentType}:${context.parentName}`); + } + + if (context.description) { + rows.push(`About:${context.description}`); + } + + if (rows.length === 0) return ""; + + return `${rows.join("")}
`; +} + +/** + * Notify a user when they are assigned to an entity + * + * @param tenantId - Tenant ID for multi-tenancy + * @param assigneeId - User ID being assigned + * @param assignment - Assignment details including entity info and role + * @param assignerName - Name of the person making the assignment + * @param baseUrl - Base URL for building entity links + * @param entityContext - Optional additional context about the entity + */ +export const notifyUserAssigned = async ( + tenantId: string, + assigneeId: number, + assignment: { + entityType: string; // "vendor", "project", "model_inventory", etc. + entityId: number; + entityName: string; + roleType: AssignmentRoleType; + entityUrl: string; // Full URL path (relative or absolute) + }, + assignerName: string, + baseUrl: string, + entityContext?: AssignmentEntityContext +): Promise => { + try { + const assignee = await getUserById(assigneeId); + if (!assignee) { + console.warn(`Cannot send assignment notification: user ${assigneeId} not found`); + return; + } + + const displayEntityType = ENTITY_TYPE_DISPLAY_LABELS[assignment.entityType] || assignment.entityType; + const notificationType = getNotificationTypeForRole(assignment.roleType); + const notificationEntityType = getNotificationEntityType(assignment.entityType); + + // Build full URL + const fullUrl = assignment.entityUrl.startsWith("http") + ? assignment.entityUrl + : `${baseUrl}${assignment.entityUrl.startsWith("/") ? "" : "/"}${assignment.entityUrl}`; + + // Build entity context HTML for email + const entityContextHtml = buildEntityContextHtml(entityContext); + + await sendInAppNotification( + tenantId, + { + user_id: assigneeId, + type: notificationType, + title: `Assigned as ${assignment.roleType}`, + message: `${assignerName} assigned you as ${assignment.roleType} for ${displayEntityType}: ${assignment.entityName}`, + entity_type: notificationEntityType, + entity_id: assignment.entityId, + entity_name: assignment.entityName, + action_url: assignment.entityUrl, + }, + true, + { + template: EMAIL_TEMPLATES.ASSIGNMENT_NOTIFICATION, + subject: `You've been assigned as ${assignment.roleType}: ${assignment.entityName}`, + variables: { + recipient_name: assignee.name || "User", + assigner_name: assignerName, + role_type: assignment.roleType, + entity_type: displayEntityType, + entity_name: assignment.entityName, + entity_url: fullUrl, + assignment_date: formatAssignmentDate(), + role_description: getRoleDescription(assignment.roleType), + entity_context_html: entityContextHtml, + }, + } + ); + + console.log(`📧 Assignment notification sent to user ${assigneeId} as ${assignment.roleType} for ${assignment.entityType} ${assignment.entityId}`); + } catch (error) { + console.error(`Failed to send assignment notification to user ${assigneeId}:`, error); + // Don't rethrow - notifications should not break the main flow + } +}; diff --git a/Servers/templates/assignment-notification.mjml b/Servers/templates/assignment-notification.mjml new file mode 100644 index 0000000000..763fbcfc91 --- /dev/null +++ b/Servers/templates/assignment-notification.mjml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + +

You've been assigned as {{role_type}}

+

Hi {{recipient_name}},

+

{{assigner_name}} has assigned you as {{role_type}} for the following {{entity_type}}:

+
+

{{entity_name}}

+

{{entity_type}}

+ {{entity_context_html}} +
+ + + + + + + + + + + + + +
Assigned on:{{assignment_date}}
Assigned by:{{assigner_name}}
Your role:{{role_type}}
+
+
+
+

{{role_description}}

+
+
+ + View {{entity_type}} + + +

+ Sent from VerifyWise +

+
+
+
+
+
diff --git a/Servers/templates/task-assigned.mjml b/Servers/templates/task-assigned.mjml index 9dbf0cf844..b5b22da8ef 100644 --- a/Servers/templates/task-assigned.mjml +++ b/Servers/templates/task-assigned.mjml @@ -28,9 +28,12 @@ {{task_due_date}} -

- View task -

+ {{entity_links_html}} + + + View task + +

Sent from VerifyWise

diff --git a/Servers/templates/task-updated.mjml b/Servers/templates/task-updated.mjml new file mode 100644 index 0000000000..d0e031b68e --- /dev/null +++ b/Servers/templates/task-updated.mjml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + +

Task updated

+

Hi {{assignee_name}},

+

{{updater_name}} has updated a task you're assigned to:

+
+

{{task_title}}

+

{{task_description}}

+
+ + + + + + + + + + + + + +
Priority:{{task_priority}}
Status:{{task_status}}
Due date:{{task_due_date}}
+ {{entity_links_html}} +
+ + View task + + +

+ Sent from VerifyWise +

+
+
+
+
+
diff --git a/Servers/utils/iso27001.utils.ts b/Servers/utils/iso27001.utils.ts index d8ba88eab3..47c6f335dc 100644 --- a/Servers/utils/iso27001.utils.ts +++ b/Servers/utils/iso27001.utils.ts @@ -193,6 +193,7 @@ export const getAllClausesWithSubClauseQuery = async ( Partial[], number, ]; + ( clause as ISO27001ClauseStructModel & { subClauses: Partial< @@ -300,6 +301,7 @@ export const getSubClauseByIdQuery = async ( scs.key_questions AS key_questions, scs.evidence_examples AS evidence_examples, scs.clause_id AS clause_id, + scs.order_no AS order_no, sc.id AS id, sc.implementation_description AS implementation_description, sc.evidence_links AS evidence_links, @@ -365,11 +367,18 @@ export const getMainClausesQuery = async ( transaction )) as (ISO27001ClauseStructModel & Partial[])[]; // wrong type + + // Convert Sequelize models to plain objects to ensure subClauses survives JSON serialization + const clausesPlain = clausesStruct.map((clause: any) => ({ + ...clause.dataValues, + subClauses: [] as any[], + })); + let clausesStructMap = new Map(); - for (let [i, clauseStruct] of clausesStruct.entries()) { - (clauseStruct.dataValues as any).subClauses = []; - clausesStructMap.set(clauseStruct.id, i); + for (let [i, clause] of clausesPlain.entries()) { + clausesStructMap.set(clause.id, i); } + for (let subClauseId of subClauseIds) { const subClause = await getSubClauseByIdQuery( subClauseId, @@ -377,12 +386,14 @@ export const getMainClausesQuery = async ( transaction ); if (subClause) { - (clausesStruct as any)[ - clausesStructMap.get(subClause.clause_id!) - ].dataValues.subClauses.push(subClause); + const clauseIndex = clausesStructMap.get(subClause.clause_id!); + if (clauseIndex !== undefined) { + clausesPlain[clauseIndex].subClauses.push(subClause); + } } } - return clausesStruct; + + return clausesPlain; }; export const getAllAnnexesQuery = async ( @@ -509,6 +520,7 @@ export const getAnnexControlsByIdQuery = async ( acs.key_questions AS key_questions, acs.evidence_examples AS evidence_examples, acs.annex_id AS annex_id, + acs.order_no AS order_no, ac.id AS id, ac.implementation_description AS implementation_description, ac.evidence_links AS evidence_links, diff --git a/Servers/utils/iso42001.utils.ts b/Servers/utils/iso42001.utils.ts index 228eae59f7..033e76196e 100644 --- a/Servers/utils/iso42001.utils.ts +++ b/Servers/utils/iso42001.utils.ts @@ -299,6 +299,7 @@ export const getSubClauseByIdQuery = async ( scs.questions AS questions, scs.evidence_examples AS evidence_examples, scs.clause_id AS clause_id, + scs.order_no AS order_no, sc.id AS id, sc.implementation_description AS implementation_description, sc.evidence_links AS evidence_links, @@ -504,6 +505,7 @@ export const getAnnexCategoriesByIdQuery = async ( acs.description AS description, acs.guidance AS guidance, acs.annex_id AS annex_id, + acs.order_no AS order_no, ac.id AS id, ac.is_applicable AS is_applicable, ac.justification_for_exclusion AS justification_for_exclusion, diff --git a/Servers/utils/task.utils.ts b/Servers/utils/task.utils.ts index 909ab8f15f..e4ea16a706 100644 --- a/Servers/utils/task.utils.ts +++ b/Servers/utils/task.utils.ts @@ -18,6 +18,7 @@ import { } from "./automation/task.automation.utils"; import { replaceTemplateVariables } from "./automation/automation.utils"; import { enqueueAutomationAction } from "../services/automations/automationProducer"; +import { getTaskEntityLinksQuery } from "./taskEntityLink.utils"; interface GetTasksOptions { userId: number; @@ -385,7 +386,7 @@ export const getTasksQuery = async ( type: QueryTypes.SELECT, }); - // Add assignees to each task following the project members pattern + // Add assignees and entity links to each task following the project members pattern for (const task of tasks) { const assignees = await sequelize.query( `SELECT user_id FROM "${tenant}".task_assignees WHERE task_id = :task_id`, @@ -398,6 +399,15 @@ export const getTasksQuery = async ( (task.dataValues as any)["assignees"] = assignees.map( (a: any) => a.user_id ); + + // Fetch entity links for this task + const entityLinks = await getTaskEntityLinksQuery(task.id!, tenant); + (task.dataValues as any)["entity_links"] = entityLinks.map((link) => ({ + id: link.id, + entity_id: link.entity_id, + entity_type: link.entity_type, + entity_name: link.entity_name, + })); } return tasks as TasksModel[]; @@ -476,6 +486,15 @@ export const getTaskByIdQuery = async ( (a) => a.full_name ); + // Fetch entity links for this task + const entityLinks = await getTaskEntityLinksQuery(task.id!, tenant); + (task.dataValues as any)["entity_links"] = entityLinks.map((link) => ({ + id: link.id, + entity_id: link.entity_id, + entity_type: link.entity_type, + entity_name: link.entity_name, + })); + return task; } diff --git a/Servers/utils/taskEntityLink.utils.ts b/Servers/utils/taskEntityLink.utils.ts new file mode 100644 index 0000000000..7be4746f9a --- /dev/null +++ b/Servers/utils/taskEntityLink.utils.ts @@ -0,0 +1,318 @@ +import { QueryTypes, Transaction } from "sequelize"; +import { sequelize } from "../database/db"; + +/** + * Valid entity types for task entity links + */ +export const VALID_ENTITY_TYPES = [ + "vendor", + "model", + "policy", + "nist_subcategory", + "iso42001_subclause", + "iso42001_annexcategory", + "iso27001_subclause", + "iso27001_annexcontrol", + "eu_control", + "eu_subcontrol", +] as const; + +export type EntityType = (typeof VALID_ENTITY_TYPES)[number]; + +export interface ITaskEntityLink { + id?: number; + task_id: number; + entity_id: number; + entity_type: EntityType; + created_at?: Date; + updated_at?: Date; +} + +export interface ITaskEntityLinkWithDetails extends ITaskEntityLink { + entity_name?: string; + entity_title?: string; +} + +/** + * Validate that an entity type is valid + */ +export function isValidEntityType(type: string): type is EntityType { + return VALID_ENTITY_TYPES.includes(type as EntityType); +} + +/** + * Get the table name for an entity type + */ +function getEntityTableName(entityType: EntityType): string { + const tableMap: Record = { + vendor: "vendors", + model: "model_inventories", + policy: "policy_manager", + nist_subcategory: "nist_ai_rmf_subcategories", + iso42001_subclause: "subclauses_iso", + iso42001_annexcategory: "annexcategories_iso", + iso27001_subclause: "subclauses_iso27001", + iso27001_annexcontrol: "annexcontrols_iso27001", + eu_control: "controls_eu", + eu_subcontrol: "subcontrols_eu", + }; + return tableMap[entityType]; +} + +/** + * Get the name/title column for an entity type + */ +function getEntityNameColumn(entityType: EntityType): string { + const columnMap: Record = { + vendor: "vendor_name", + model: "model", + policy: "title", + nist_subcategory: "title", + iso42001_subclause: "implementation_description", + iso42001_annexcategory: "implementation_description", + iso27001_subclause: "implementation_description", + iso27001_annexcontrol: "implementation_description", + eu_control: "implementation_details", + eu_subcontrol: "implementation_details", + }; + return columnMap[entityType]; +} + +/** + * Check if an entity exists + */ +export async function entityExistsQuery( + entityId: number, + entityType: EntityType, + tenantHash: string, + transaction?: Transaction +): Promise { + const tableName = getEntityTableName(entityType); + + const result = await sequelize.query<{ exists: boolean }>( + `SELECT EXISTS(SELECT 1 FROM "${tenantHash}".${tableName} WHERE id = :entityId) as exists`, + { + replacements: { entityId }, + type: QueryTypes.SELECT, + transaction, + } + ); + + return result[0]?.exists ?? false; +} + +/** + * Create a task entity link + */ +export async function createTaskEntityLinkQuery( + link: Omit & { entity_name?: string }, + tenantHash: string, + transaction?: Transaction +): Promise { + const result = await sequelize.query( + `INSERT INTO "${tenantHash}".task_entity_links (task_id, entity_id, entity_type, entity_name, created_at, updated_at) + VALUES (:task_id, :entity_id, :entity_type, :entity_name, NOW(), NOW()) + RETURNING *`, + { + replacements: { + task_id: link.task_id, + entity_id: link.entity_id, + entity_type: link.entity_type, + entity_name: link.entity_name || null, + }, + type: QueryTypes.SELECT, + transaction, + } + ); + + return result[0]; +} + +/** + * Get all entity links for a task + */ +export async function getTaskEntityLinksQuery( + taskId: number, + tenantHash: string, + transaction?: Transaction +): Promise { + const links = await sequelize.query( + `SELECT * FROM "${tenantHash}".task_entity_links + WHERE task_id = :taskId + ORDER BY created_at DESC`, + { + replacements: { taskId }, + type: QueryTypes.SELECT, + transaction, + } + ); + + // Enrich each link with entity details (only if entity_name not already stored) + const enrichedLinks: ITaskEntityLinkWithDetails[] = []; + for (const link of links) { + // If entity_name is already stored, use it + if (link.entity_name) { + enrichedLinks.push({ + ...link, + entity_name: link.entity_name, + }); + continue; + } + + // Otherwise, fetch from entity table with special handling for complex types + try { + let entityName: string | null = null; + + // Special handling for NIST subcategories to build detailed name + if (link.entity_type === "nist_subcategory") { + // Functions and categories are in public schema, subcategories in tenant schema + const nistResult = await sequelize.query<{ + sub_title: string; + sub_index: number; + cat_index: number; + func_type: string; + }>( + `SELECT + s.title as sub_title, + s.index as sub_index, + c.index as cat_index, + f.type as func_type + FROM "${tenantHash}".nist_ai_rmf_subcategories s + JOIN public.nist_ai_rmf_categories c ON s.category_id = c.id + JOIN public.nist_ai_rmf_functions f ON c.function_id = f.id + WHERE s.id = :entityId`, + { + replacements: { entityId: link.entity_id }, + type: QueryTypes.SELECT, + transaction, + } + ); + if (nistResult[0]) { + const { func_type, cat_index, sub_index, sub_title } = nistResult[0]; + // Build detailed name like "GOVERN 1.1: Subcategory description" + const funcUpper = func_type?.toUpperCase() || "FUNC"; + const catNum = cat_index || 1; + const subNum = sub_index || 1; + const titlePart = sub_title ? `: ${sub_title.substring(0, 50)}${sub_title.length > 50 ? "..." : ""}` : ""; + entityName = `${funcUpper} ${catNum}.${subNum}${titlePart}`.trim(); + } + } else { + // Default handling for other entity types + const tableName = getEntityTableName(link.entity_type); + const nameColumn = getEntityNameColumn(link.entity_type); + const entityResult = await sequelize.query<{ name: string }>( + `SELECT ${nameColumn} as name FROM "${tenantHash}".${tableName} WHERE id = :entityId`, + { + replacements: { entityId: link.entity_id }, + type: QueryTypes.SELECT, + transaction, + } + ); + entityName = entityResult[0]?.name || null; + } + + enrichedLinks.push({ + ...link, + entity_name: entityName || `${link.entity_type} #${link.entity_id}`, + }); + } catch { + // If entity doesn't exist anymore, still include the link + enrichedLinks.push({ + ...link, + entity_name: `${link.entity_type} #${link.entity_id} (deleted)`, + }); + } + } + + return enrichedLinks; +} + +/** + * Delete a task entity link + */ +export async function deleteTaskEntityLinkQuery( + linkId: number, + taskId: number, + tenantHash: string, + transaction?: Transaction +): Promise { + const result = await sequelize.query( + `DELETE FROM "${tenantHash}".task_entity_links + WHERE id = :linkId AND task_id = :taskId + RETURNING *`, + { + replacements: { linkId, taskId }, + type: QueryTypes.SELECT, + transaction, + } + ); + + return result.length > 0; +} + +/** + * Delete all entity links for a task + */ +export async function deleteAllTaskEntityLinksQuery( + taskId: number, + tenantHash: string, + transaction?: Transaction +): Promise { + const result = await sequelize.query( + `DELETE FROM "${tenantHash}".task_entity_links WHERE task_id = :taskId`, + { + replacements: { taskId }, + type: QueryTypes.DELETE, + transaction, + } + ); + + return (result as unknown as [unknown, number])[1]; +} + +/** + * Check if a link already exists + */ +export async function linkExistsQuery( + taskId: number, + entityId: number, + entityType: EntityType, + tenantHash: string, + transaction?: Transaction +): Promise { + const result = await sequelize.query<{ exists: boolean }>( + `SELECT EXISTS( + SELECT 1 FROM "${tenantHash}".task_entity_links + WHERE task_id = :taskId AND entity_id = :entityId AND entity_type = :entityType + ) as exists`, + { + replacements: { taskId, entityId, entityType }, + type: QueryTypes.SELECT, + transaction, + } + ); + + return result[0]?.exists ?? false; +} + +/** + * Get all tasks linked to a specific entity + */ +export async function getTasksForEntityQuery( + entityId: number, + entityType: EntityType, + tenantHash: string, + transaction?: Transaction +): Promise { + const result = await sequelize.query<{ task_id: number }>( + `SELECT task_id FROM "${tenantHash}".task_entity_links + WHERE entity_id = :entityId AND entity_type = :entityType`, + { + replacements: { entityId, entityType }, + type: QueryTypes.SELECT, + transaction, + } + ); + + return result.map((r) => r.task_id); +}