diff --git a/apps/app/src/actions/organization/lib/utils.ts b/apps/app/src/actions/organization/lib/utils.ts index 4566e9451a..e8fc42ff5b 100644 --- a/apps/app/src/actions/organization/lib/utils.ts +++ b/apps/app/src/actions/organization/lib/utils.ts @@ -1,7 +1,7 @@ import type { - TemplateControl, - TemplateEvidence, - TemplatePolicy, + TemplateControl, + TemplateEvidence, + TemplatePolicy, } from "@comp/data"; import { controls, evidence, frameworks, policies } from "@comp/data"; import { db } from "@comp/db"; @@ -15,8 +15,8 @@ import type { InputJsonValue } from "@prisma/client/runtime/library"; * @returns The policy template or undefined if not found */ export function getPolicyById(id: string): TemplatePolicy | undefined { - // Use a type-safe approach to check if the key exists in the policies object - return Object.entries(policies).find(([key]) => key === id)?.[1]; + // Use a type-safe approach to check if the key exists in the policies object + return Object.entries(policies).find(([key]) => key === id)?.[1]; } /** @@ -26,8 +26,8 @@ export function getPolicyById(id: string): TemplatePolicy | undefined { * @returns The evidence template or undefined if not found */ export function getEvidenceById(id: string): TemplateEvidence | undefined { - // Use a type-safe approach to check if the key exists in the evidence object - return Object.entries(evidence).find(([key]) => key === id)?.[1]; + // Use a type-safe approach to check if the key exists in the evidence object + return Object.entries(evidence).find(([key]) => key === id)?.[1]; } /** @@ -42,13 +42,13 @@ export function getEvidenceById(id: string): TemplateEvidence | undefined { * @returns Array of control templates relevant to the selected frameworks */ export function getRelevantControls( - frameworkIds: FrameworkId[], + frameworkIds: FrameworkId[] ): TemplateControl[] { - return controls.filter((control) => - control.mappedRequirements.some((req) => - frameworkIds.includes(req.frameworkId), - ), - ); + return controls.filter((control) => + control.mappedRequirements.some((req) => + frameworkIds.includes(req.frameworkId) + ) + ); } /** @@ -67,104 +67,104 @@ export function getRelevantControls( * @returns The created framework instance record */ export async function createFrameworkInstance( - organizationId: string, - frameworkId: FrameworkId, + organizationId: string, + frameworkId: FrameworkId ) { - // First verify the organization exists - const organization = await db.organization.findUnique({ - where: { id: organizationId }, - }); - - if (!organization) { - console.error("Organization not found when creating framework", { - organizationId, - frameworkId, - }); - throw new Error(`Organization with ID ${organizationId} not found`); - } - - // Verify the framework exists - const framework = frameworks[frameworkId as FrameworkId]; - - if (!framework) { - console.error("Framework not found when creating organization framework", { - organizationId, - frameworkId, - }); - throw new Error(`Framework with ID ${frameworkId} not found`); - } - - // Check if a framework instance already exists - const existingFrameworkInstance = await db.frameworkInstance.findUnique({ - where: { - organizationId_frameworkId: { - organizationId, - frameworkId: frameworkId as FrameworkId, - }, - }, - }); - - // Create or update the framework instance - const frameworkInstance = existingFrameworkInstance - ? await db.frameworkInstance.update({ - where: { - id: existingFrameworkInstance.id, - }, - data: { - organizationId, - frameworkId: frameworkId as FrameworkId, - }, - }) - : await db.frameworkInstance.create({ - data: { - organizationId, - frameworkId: frameworkId as FrameworkId, - }, - }); - - console.info("Created/updated organization framework", { - organizationId, - frameworkId, - frameworkInstanceId: frameworkInstance.id, - }); - - // Find all controls that apply to this specific framework - const frameworkControls = controls.filter((control) => - control.mappedRequirements.some((req) => req.frameworkId === frameworkId), - ); - - // Create database records for each control - const createdControls = []; - for (const control of frameworkControls) { - const createdControl = await db.control.create({ - data: { - organizationId, - name: control.name, - description: control.description, - frameworkInstances: { - connect: { - id: frameworkInstance.id, - }, - }, - }, - }); - - createdControls.push(createdControl); - - console.info("Created control", { - controlName: control.name, - frameworkId, - }); - } - - // Create requirement maps for the controls - await createRequirementMaps( - { id: frameworkInstance.id, frameworkId }, - createdControls, - frameworkControls, - ); - - return frameworkInstance; + // First verify the organization exists + const organization = await db.organization.findUnique({ + where: { id: organizationId }, + }); + + if (!organization) { + console.error("Organization not found when creating framework", { + organizationId, + frameworkId, + }); + throw new Error(`Organization with ID ${organizationId} not found`); + } + + // Verify the framework exists + const framework = frameworks[frameworkId as FrameworkId]; + + if (!framework) { + console.error("Framework not found when creating organization framework", { + organizationId, + frameworkId, + }); + throw new Error(`Framework with ID ${frameworkId} not found`); + } + + // Check if a framework instance already exists + const existingFrameworkInstance = await db.frameworkInstance.findUnique({ + where: { + organizationId_frameworkId: { + organizationId, + frameworkId: frameworkId as FrameworkId, + }, + }, + }); + + // Create or update the framework instance + const frameworkInstance = existingFrameworkInstance + ? await db.frameworkInstance.update({ + where: { + id: existingFrameworkInstance.id, + }, + data: { + organizationId, + frameworkId: frameworkId as FrameworkId, + }, + }) + : await db.frameworkInstance.create({ + data: { + organizationId, + frameworkId: frameworkId as FrameworkId, + }, + }); + + console.info("Created/updated organization framework", { + organizationId, + frameworkId, + frameworkInstanceId: frameworkInstance.id, + }); + + // Find all controls that apply to this specific framework + const frameworkControls = controls.filter((control) => + control.mappedRequirements.some((req) => req.frameworkId === frameworkId) + ); + + // Create database records for each control + const createdControls = []; + for (const control of frameworkControls) { + const createdControl = await db.control.create({ + data: { + organizationId, + name: control.name, + description: control.description, + frameworkInstances: { + connect: { + id: frameworkInstance.id, + }, + }, + }, + }); + + createdControls.push(createdControl); + + console.info("Created control", { + controlName: control.name, + frameworkId, + }); + } + + // Create requirement maps for the controls + await createRequirementMaps( + { id: frameworkInstance.id, frameworkId }, + createdControls, + frameworkControls + ); + + return frameworkInstance; } /** @@ -184,64 +184,64 @@ export async function createFrameworkInstance( * @returns The number of requirement maps created */ export async function createRequirementMaps( - frameworkInstance: { id: string; frameworkId: FrameworkId }, - controls: { id: string; name: string }[], - templateControls: TemplateControl[], + frameworkInstance: { id: string; frameworkId: FrameworkId }, + controls: { id: string; name: string }[], + templateControls: TemplateControl[] ) { - console.info("Creating requirement maps", { - frameworkInstanceId: frameworkInstance.id, - controlCount: controls.length, - }); - - let requirementMapsCreated = 0; - - // Create a map for efficient control lookup - const controlMap = new Map( - controls.map((control) => [control.name, control]), - ); - - // For each template control - for (const templateControl of templateControls) { - // Find the corresponding created control - const control = controlMap.get(templateControl.name); - if (!control) { - console.warn(`Control not found for template: ${templateControl.name}`); - continue; - } - - // Get requirements for this framework - const frameworkRequirements = templateControl.mappedRequirements.filter( - (req) => req.frameworkId === frameworkInstance.frameworkId, - ); - - // Create requirement maps - for (const requirement of frameworkRequirements) { - try { - await db.requirementMap.create({ - data: { - controlId: control.id, - frameworkInstanceId: frameworkInstance.id, - requirementId: - `${frameworkInstance.frameworkId}_${requirement.requirementId}` as RequirementId, - }, - }); - - requirementMapsCreated++; - } catch (error) { - console.error( - `Error creating requirement map for control ${control.name}`, - { - error, - requirementId: requirement.requirementId, - }, - ); - } - } - } - - console.info(`Created ${requirementMapsCreated} requirement maps`); - - return requirementMapsCreated; + console.info("Creating requirement maps", { + frameworkInstanceId: frameworkInstance.id, + controlCount: controls.length, + }); + + let requirementMapsCreated = 0; + + // Create a map for efficient control lookup + const controlMap = new Map( + controls.map((control) => [control.name, control]) + ); + + // For each template control + for (const templateControl of templateControls) { + // Find the corresponding created control + const control = controlMap.get(templateControl.name); + if (!control) { + console.warn(`Control not found for template: ${templateControl.name}`); + continue; + } + + // Get requirements for this framework + const frameworkRequirements = templateControl.mappedRequirements.filter( + (req) => req.frameworkId === frameworkInstance.frameworkId + ); + + // Create requirement maps + for (const requirement of frameworkRequirements) { + try { + await db.requirementMap.create({ + data: { + controlId: control.id, + frameworkInstanceId: frameworkInstance.id, + requirementId: + `${frameworkInstance.frameworkId}_${requirement.requirementId}` as RequirementId, + }, + }); + + requirementMapsCreated++; + } catch (error) { + console.error( + `Error creating requirement map for control ${control.name}`, + { + error, + requirementId: requirement.requirementId, + } + ); + } + } + } + + console.info(`Created ${requirementMapsCreated} requirement maps`); + + return requirementMapsCreated; } /** @@ -262,74 +262,87 @@ export async function createRequirementMaps( * @returns Map of template policy IDs to created policy records */ export async function createOrganizationPolicies( - organizationId: string, - relevantControls: TemplateControl[], - userId: string, + organizationId: string, + relevantControls: TemplateControl[], + userId: string ) { - if (!organizationId) { - throw new Error("Not authorized - no organization found"); - } - - // Extract all policy IDs required by the controls - const policyIds = new Set(); - - for (const control of relevantControls) { - for (const artifact of control.mappedArtifacts) { - if (artifact.type === "policy") { - policyIds.add(artifact.policyId); - } - } - } - - console.info("Creating organization policies", { - organizationId, - policyCount: policyIds.size, - policyIds: Array.from(policyIds), - }); - - // Create policies for the organization - const createdPolicies = new Map(); - - // Process each policy using the type-safe accessor function - for (const policyId of policyIds) { - const policyTemplate = getPolicyById(policyId); - - if (!policyTemplate) { - console.warn(`Policy template not found: ${policyId}`); - continue; - } - - try { - // Create the policy record using template data - include only fields in the schema - const policy = await db.policy.create({ - data: { - organizationId, - name: policyTemplate.metadata.name, - description: policyTemplate.metadata.description, - status: "draft" as PolicyStatus, - content: policyTemplate.content as InputJsonValue[], - ownerId: userId, - frequency: policyTemplate.metadata.frequency, - department: policyTemplate.metadata.department, - }, - }); - - // Store both the policy record and template ID for later reference - createdPolicies.set(policyId, { - ...policy, - templateId: policyId, // Store the template ID for reference - }); - - console.info(`Created policy: ${policy.name}`, { - policyName: policy.name, - templateId: policyId, - }); - } catch (error) { - console.error(`Error creating policy ${policyId}`, { error }); - } - } - - return createdPolicies; + if (!organizationId) { + throw new Error("Not authorized - no organization found"); + } + + // Extract all policy IDs required by the controls + const policyIds = new Set(); + + for (const control of relevantControls) { + for (const artifact of control.mappedArtifacts) { + if (artifact.type === "policy") { + policyIds.add(artifact.policyId); + } + } + } + + console.info("Creating organization policies", { + organizationId, + policyCount: policyIds.size, + policyIds: Array.from(policyIds), + }); + + // Create policies for the organization + const createdPolicies = new Map(); + + // Process each policy using the type-safe accessor function + for (const policyId of policyIds) { + const policyTemplate = getPolicyById(policyId); + + if (!policyTemplate) { + console.warn(`Policy template not found: ${policyId}`); + continue; + } + + // Find the member record for the user in this organization + const memberRecord = await db.member.findFirst({ + where: { + organizationId, + userId, + }, + }); + + if (!memberRecord) { + console.warn(`Member record not found for user: ${userId}`); + continue; + } + + try { + // Create the policy record using template data - include only fields in the schema + const policy = await db.policy.create({ + data: { + organizationId, + name: policyTemplate.metadata.name, + description: policyTemplate.metadata.description, + status: "draft" as PolicyStatus, + content: policyTemplate.content as InputJsonValue[], + assigneeId: memberRecord?.id || null, + frequency: policyTemplate.metadata.frequency, + department: policyTemplate.metadata.department, + }, + }); + + // Store both the policy record and template ID for later reference + createdPolicies.set(policyId, { + ...policy, + templateId: policyId, // Store the template ID for reference + }); + + console.info(`Created policy: ${policy.name}`, { + policyName: policy.name, + templateId: policyId, + }); + } catch (error) { + console.error(`Error creating policy ${policyId}`, { error }); + } + } + + return createdPolicies; } /** @@ -349,83 +362,83 @@ export async function createOrganizationPolicies( * @returns Map of template evidence IDs to created evidence records */ export async function createOrganizationEvidence( - organizationId: string, - relevantControls: TemplateControl[], - userId: string, + organizationId: string, + relevantControls: TemplateControl[], + userId: string ) { - if (!organizationId) { - throw new Error("Not authorized - no organization found"); - } - - // Extract all evidence IDs required by the controls - const evidenceIds = new Set(); - - for (const control of relevantControls) { - for (const artifact of control.mappedArtifacts) { - if (artifact.type === "evidence") { - evidenceIds.add(artifact.evidenceId); - } - } - } - - console.info("Creating evidence record instances", { - organizationId, - evidenceCount: evidenceIds.size, - evidenceIds: Array.from(evidenceIds), - }); - - // Find the member record for the user in this organization - const memberRecord = await db.member.findFirst({ - where: { - organizationId, - userId, - isActive: true, - }, - }); - - // Create evidence records for the organization - const createdEvidence = new Map(); - - // Process each evidence using the type-safe accessor function - for (const evidenceId of evidenceIds) { - const evidenceTemplate = getEvidenceById(evidenceId); - - if (!evidenceTemplate) { - console.warn(`Evidence template not found: ${evidenceId}`); - continue; - } - - try { - // Create the evidence record using template data - include only fields in the schema - const evidenceRecord = await db.evidence.create({ - data: { - organizationId, - name: evidenceTemplate.name, - description: evidenceTemplate.description, - frequency: evidenceTemplate.frequency, - assigneeId: memberRecord?.id || null, // Use the member ID if found, otherwise null - department: evidenceTemplate.department, - additionalUrls: [], - fileUrls: [], - }, - }); - - // Store both the evidence record and template ID for later reference - createdEvidence.set(evidenceId, { - ...evidenceRecord, - templateId: evidenceId, // Store the template ID for reference - }); - - console.info(`Created evidence: ${evidenceRecord.name}`, { - evidenceId: evidenceRecord.id, - templateId: evidenceId, - }); - } catch (error) { - console.error(`Error creating evidence record ${evidenceId}`, { error }); - } - } - - return createdEvidence; + if (!organizationId) { + throw new Error("Not authorized - no organization found"); + } + + // Extract all evidence IDs required by the controls + const evidenceIds = new Set(); + + for (const control of relevantControls) { + for (const artifact of control.mappedArtifacts) { + if (artifact.type === "evidence") { + evidenceIds.add(artifact.evidenceId); + } + } + } + + console.info("Creating evidence record instances", { + organizationId, + evidenceCount: evidenceIds.size, + evidenceIds: Array.from(evidenceIds), + }); + + // Find the member record for the user in this organization + const memberRecord = await db.member.findFirst({ + where: { + organizationId, + userId, + isActive: true, + }, + }); + + // Create evidence records for the organization + const createdEvidence = new Map(); + + // Process each evidence using the type-safe accessor function + for (const evidenceId of evidenceIds) { + const evidenceTemplate = getEvidenceById(evidenceId); + + if (!evidenceTemplate) { + console.warn(`Evidence template not found: ${evidenceId}`); + continue; + } + + try { + // Create the evidence record using template data - include only fields in the schema + const evidenceRecord = await db.evidence.create({ + data: { + organizationId, + name: evidenceTemplate.name, + description: evidenceTemplate.description, + frequency: evidenceTemplate.frequency, + assigneeId: memberRecord?.id || null, // Use the member ID if found, otherwise null + department: evidenceTemplate.department, + additionalUrls: [], + fileUrls: [], + }, + }); + + // Store both the evidence record and template ID for later reference + createdEvidence.set(evidenceId, { + ...evidenceRecord, + templateId: evidenceId, // Store the template ID for reference + }); + + console.info(`Created evidence: ${evidenceRecord.name}`, { + evidenceId: evidenceRecord.id, + templateId: evidenceId, + }); + } catch (error) { + console.error(`Error creating evidence record ${evidenceId}`, { error }); + } + } + + return createdEvidence; } /** @@ -447,116 +460,116 @@ export async function createOrganizationEvidence( * @returns Object with success status and artifact count */ export async function createControlArtifacts( - organizationId: string, - frameworkInstanceIds: string[], - relevantControls: TemplateControl[], - createdPolicies: Map, - createdEvidence: Map, + organizationId: string, + frameworkInstanceIds: string[], + relevantControls: TemplateControl[], + createdPolicies: Map, + createdEvidence: Map ) { - if (!organizationId) { - throw new Error("Not authorized - no organization found"); - } - - console.info("Creating organization control artifacts", { - organizationId, - frameworkInstanceIds, - relevantControlsCount: relevantControls.length, - }); - - // Get all controls for this organization and framework instances - const dbControls = await db.control.findMany({ - where: { - organizationId, - frameworkInstances: { - some: { - id: { - in: frameworkInstanceIds, - }, - }, - }, - }, - }); - - // Create a mapping from control name to database control record for efficient lookup - const controlMap = new Map(); - for (const control of dbControls) { - controlMap.set(control.name, control); - } - - // Create artifacts for each control - let artifactsCreated = 0; - - for (const control of relevantControls) { - const dbControl = controlMap.get(control.name); - - if (!dbControl) { - console.warn(`Control not found in database: ${control.name}`); - continue; - } - - // Create artifacts for each policy and evidence mapped to this control - for (const artifact of control.mappedArtifacts) { - try { - if (artifact.type === "policy") { - // Link control to a policy - const policy = createdPolicies.get(artifact.policyId); - - if (!policy) { - console.warn(`Policy not found for artifact: ${artifact.policyId}`); - continue; - } - - // Create the policy artifact - await db.artifact.create({ - data: { - type: "policy", - policyId: policy.id, - organizationId, - controls: { - connect: { - id: dbControl.id, - }, - }, - }, - }); - - artifactsCreated++; - } else if (artifact.type === "evidence") { - // Link control to evidence - const evidenceRecord = createdEvidence.get(artifact.evidenceId); - - if (!evidenceRecord) { - console.warn( - `Evidence not found for artifact: ${artifact.evidenceId}`, - ); - continue; - } - - // Create the evidence artifact - await db.artifact.create({ - data: { - type: "evidence", - evidenceId: evidenceRecord.id, - organizationId, - controls: { - connect: { - id: dbControl.id, - }, - }, - }, - }); - - artifactsCreated++; - } - } catch (error) { - console.error(`Error creating artifact for control ${control.name}`, { - error, - }); - } - } - } - - console.info(`Created ${artifactsCreated} artifacts for controls`); - - return { success: true, artifactsCreated }; + if (!organizationId) { + throw new Error("Not authorized - no organization found"); + } + + console.info("Creating organization control artifacts", { + organizationId, + frameworkInstanceIds, + relevantControlsCount: relevantControls.length, + }); + + // Get all controls for this organization and framework instances + const dbControls = await db.control.findMany({ + where: { + organizationId, + frameworkInstances: { + some: { + id: { + in: frameworkInstanceIds, + }, + }, + }, + }, + }); + + // Create a mapping from control name to database control record for efficient lookup + const controlMap = new Map(); + for (const control of dbControls) { + controlMap.set(control.name, control); + } + + // Create artifacts for each control + let artifactsCreated = 0; + + for (const control of relevantControls) { + const dbControl = controlMap.get(control.name); + + if (!dbControl) { + console.warn(`Control not found in database: ${control.name}`); + continue; + } + + // Create artifacts for each policy and evidence mapped to this control + for (const artifact of control.mappedArtifacts) { + try { + if (artifact.type === "policy") { + // Link control to a policy + const policy = createdPolicies.get(artifact.policyId); + + if (!policy) { + console.warn(`Policy not found for artifact: ${artifact.policyId}`); + continue; + } + + // Create the policy artifact + await db.artifact.create({ + data: { + type: "policy", + policyId: policy.id, + organizationId, + controls: { + connect: { + id: dbControl.id, + }, + }, + }, + }); + + artifactsCreated++; + } else if (artifact.type === "evidence") { + // Link control to evidence + const evidenceRecord = createdEvidence.get(artifact.evidenceId); + + if (!evidenceRecord) { + console.warn( + `Evidence not found for artifact: ${artifact.evidenceId}` + ); + continue; + } + + // Create the evidence artifact + await db.artifact.create({ + data: { + type: "evidence", + evidenceId: evidenceRecord.id, + organizationId, + controls: { + connect: { + id: dbControl.id, + }, + }, + }, + }); + + artifactsCreated++; + } + } catch (error) { + console.error(`Error creating artifact for control ${control.name}`, { + error, + }); + } + } + } + + console.info(`Created ${artifactsCreated} artifacts for controls`); + + return { success: true, artifactsCreated }; } diff --git a/apps/app/src/actions/policies/archive-policy.ts b/apps/app/src/actions/policies/archive-policy.ts index 1a88d4e936..d02f5c1813 100644 --- a/apps/app/src/actions/policies/archive-policy.ts +++ b/apps/app/src/actions/policies/archive-policy.ts @@ -6,71 +6,70 @@ import { authActionClient } from "../safe-action"; import { z } from "zod"; const archivePolicySchema = z.object({ - id: z.string(), - action: z.enum(["archive", "restore"]).optional(), + id: z.string(), + action: z.enum(["archive", "restore"]).optional(), }); export const archivePolicyAction = authActionClient - .schema(archivePolicySchema) - .metadata({ - name: "archive-policy", - track: { - event: "archive-policy", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { id, action } = parsedInput; - const { activeOrganizationId } = ctx.session; + .schema(archivePolicySchema) + .metadata({ + name: "archive-policy", + track: { + event: "archive-policy", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { id, action } = parsedInput; + const { activeOrganizationId } = ctx.session; - if (!activeOrganizationId) { - return { - success: false, - error: "Not authorized", - }; - } + if (!activeOrganizationId) { + return { + success: false, + error: "Not authorized", + }; + } - try { - const policy = await db.policy.findUnique({ - where: { - id, - organizationId: activeOrganizationId, - }, - }); + try { + const policy = await db.policy.findUnique({ + where: { + id, + organizationId: activeOrganizationId, + }, + }); - if (!policy) { - return { - success: false, - error: "Policy not found", - }; - } + if (!policy) { + return { + success: false, + error: "Policy not found", + }; + } - // Determine if we should archive or restore based on action or current state - const shouldArchive = - action === "archive" || - (action === undefined && policy.status !== "archived"); + // Determine if we should archive or restore based on action or current state + const shouldArchive = + action === "archive" || (action === undefined && !policy.isArchived); - await db.policy.update({ - where: { id }, - data: { - status: shouldArchive ? "archived" : "published", - }, - }); + await db.policy.update({ + where: { id }, + data: { + isArchived: shouldArchive, + }, + }); - revalidatePath(`/${activeOrganizationId}/policies/all/${id}`); - revalidatePath(`/${activeOrganizationId}/policies/all`); - revalidatePath(`/${activeOrganizationId}/policies`); - revalidateTag("policies"); + revalidatePath(`/${activeOrganizationId}/policies/all/${id}`); + revalidatePath(`/${activeOrganizationId}/policies/all`); + revalidatePath(`/${activeOrganizationId}/policies`); + revalidateTag("policies"); - return { - success: true, - status: shouldArchive ? "archived" : "published", - }; - } catch (error) { - console.error(error); - return { - success: false, - error: "Failed to update policy archive status", - }; - } - }); + return { + success: true, + isArchived: shouldArchive, + }; + } catch (error) { + console.error(error); + return { + success: false, + error: "Failed to update policy archive status", + }; + } + }); diff --git a/apps/app/src/actions/policies/create-new-policy.ts b/apps/app/src/actions/policies/create-new-policy.ts index 07b2de7f35..d7a8efc190 100644 --- a/apps/app/src/actions/policies/create-new-policy.ts +++ b/apps/app/src/actions/policies/create-new-policy.ts @@ -7,92 +7,107 @@ import { authActionClient } from "../safe-action"; import { createPolicySchema } from "../schema"; export const createPolicyAction = authActionClient - .schema(createPolicySchema) - .metadata({ - name: "create-policy", - track: { - event: "create-policy", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { title, description, frameworkIds, controlIds } = parsedInput; - const { activeOrganizationId } = ctx.session; - const { user } = ctx; + .schema(createPolicySchema) + .metadata({ + name: "create-policy", + track: { + event: "create-policy", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { title, description, frameworkIds, controlIds } = parsedInput; + const { activeOrganizationId } = ctx.session; + const { user } = ctx; - if (!activeOrganizationId) { - return { - success: false, - error: "Not authorized", - }; - } + if (!activeOrganizationId) { + return { + success: false, + error: "Not authorized", + }; + } - if (!user) { - return { - success: false, - error: "Not authorized", - }; - } + if (!user) { + return { + success: false, + error: "Not authorized", + }; + } - try { - // Create the policy - const policy = await db.policy.create({ - data: { - name: title, - description, - organizationId: activeOrganizationId, - ownerId: user.id, - department: Departments.none, - frequency: Frequency.monthly, - content: [ - { type: "paragraph", content: [{ type: "text", text: "" }] }, - ], - }, - }); + // Find member id in the organization + const member = await db.member.findFirst({ + where: { + userId: user.id, + organizationId: activeOrganizationId, + }, + }); - // Create artifacts for each control - if (controlIds && controlIds.length > 0) { - // Create artifacts that link the policy to controls - await Promise.all( - controlIds.map(async (controlId) => { - // Create the artifact - const artifact = await db.artifact.create({ - data: { - type: "policy", - policyId: policy.id, - organizationId: activeOrganizationId, - }, - }); + if (!member) { + return { + success: false, + error: "Not authorized", + }; + } - // Connect the artifact to the control - await db.control.update({ - where: { id: controlId }, - data: { - artifacts: { - connect: { id: artifact.id }, - }, - }, - }); + try { + // Create the policy + const policy = await db.policy.create({ + data: { + name: title, + description, + organizationId: activeOrganizationId, + assigneeId: member.id, + department: Departments.none, + frequency: Frequency.monthly, + content: [ + { type: "paragraph", content: [{ type: "text", text: "" }] }, + ], + }, + }); - return artifact; - }), - ); - } + // Create artifacts for each control + if (controlIds && controlIds.length > 0) { + // Create artifacts that link the policy to controls + await Promise.all( + controlIds.map(async (controlId) => { + // Create the artifact + const artifact = await db.artifact.create({ + data: { + type: "policy", + policyId: policy.id, + organizationId: activeOrganizationId, + }, + }); - revalidatePath(`/${activeOrganizationId}/policies/all`); - revalidatePath(`/${activeOrganizationId}/policies`); - revalidateTag("policies"); + // Connect the artifact to the control + await db.control.update({ + where: { id: controlId }, + data: { + artifacts: { + connect: { id: artifact.id }, + }, + }, + }); - return { - success: true, - policyId: policy.id, - }; - } catch (error) { - console.error(error); + return artifact; + }) + ); + } - return { - success: false, - error: "Failed to create policy", - }; - } - }); + revalidatePath(`/${activeOrganizationId}/policies/all`); + revalidatePath(`/${activeOrganizationId}/policies`); + revalidateTag("policies"); + + return { + success: true, + policyId: policy.id, + }; + } catch (error) { + console.error(error); + + return { + success: false, + error: "Failed to create policy", + }; + } + }); diff --git a/apps/app/src/actions/policies/update-policy-form-action.ts b/apps/app/src/actions/policies/update-policy-form-action.ts index d9944efb25..6f2bdad24f 100644 --- a/apps/app/src/actions/policies/update-policy-form-action.ts +++ b/apps/app/src/actions/policies/update-policy-form-action.ts @@ -10,107 +10,107 @@ import { PolicyStatus } from "@comp/db/types"; // Helper function to calculate next review date based on frequency function calculateNextReviewDate( - frequency: string, - baseDate: Date = new Date(), + frequency: string, + baseDate: Date = new Date() ): Date { - const nextDate = new Date(baseDate); + const nextDate = new Date(baseDate); - switch (frequency) { - case "monthly": - nextDate.setMonth(nextDate.getMonth() + 1); - break; - case "quarterly": - nextDate.setMonth(nextDate.getMonth() + 3); - break; - case "yearly": - nextDate.setFullYear(nextDate.getFullYear() + 1); - break; - default: - // If frequency is not recognized, default to yearly - nextDate.setFullYear(nextDate.getFullYear() + 1); - } + switch (frequency) { + case "monthly": + nextDate.setMonth(nextDate.getMonth() + 1); + break; + case "quarterly": + nextDate.setMonth(nextDate.getMonth() + 3); + break; + case "yearly": + nextDate.setFullYear(nextDate.getFullYear() + 1); + break; + default: + // If frequency is not recognized, default to yearly + nextDate.setFullYear(nextDate.getFullYear() + 1); + } - return nextDate; + return nextDate; } export const updatePolicyFormAction = authActionClient - .schema(updatePolicyFormSchema) - .metadata({ - name: "update-policy-form", - track: { - event: "update-policy-form", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { - id, - status, - ownerId, - department, - review_frequency, - review_date, - isRequiredToSign, - } = parsedInput; - const { user, session } = ctx; + .schema(updatePolicyFormSchema) + .metadata({ + name: "update-policy-form", + track: { + event: "update-policy-form", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { + id, + status, + assigneeId, + department, + review_frequency, + review_date, + isRequiredToSign, + } = parsedInput; + const { user, session } = ctx; - if (!user.id || !session.activeOrganizationId) { - throw new Error("Unauthorized"); - } + if (!user.id || !session.activeOrganizationId) { + throw new Error("Unauthorized"); + } - try { - // Get the current policy to check if status is changing to published - const currentPolicy = await db.policy.findUnique({ - where: { - id, - organizationId: session.activeOrganizationId, - }, - select: { - status: true, - }, - }); + try { + // Get the current policy to check if status is changing to published + const currentPolicy = await db.policy.findUnique({ + where: { + id, + organizationId: session.activeOrganizationId, + }, + select: { + status: true, + }, + }); - // Determine if we need to update the review date - let reviewDate = review_date; - let lastPublishedAt = undefined; + // Determine if we need to update the review date + let reviewDate = review_date; + let lastPublishedAt = undefined; - // If status is changing to 'published', calculate next review date based on frequency - if ( - status === PolicyStatus.published && - currentPolicy?.status !== PolicyStatus.published - ) { - reviewDate = calculateNextReviewDate(review_frequency); - lastPublishedAt = new Date(); // Set lastPublishedAt to now when publishing - } + // If status is changing to 'published', calculate next review date based on frequency + if ( + status === PolicyStatus.published && + currentPolicy?.status !== PolicyStatus.published + ) { + reviewDate = calculateNextReviewDate(review_frequency); + lastPublishedAt = new Date(); // Set lastPublishedAt to now when publishing + } - await db.policy.update({ - where: { - id, - organizationId: session.activeOrganizationId, - }, - data: { - status, - ownerId, - department, - frequency: review_frequency, - reviewDate, - isRequiredToSign: isRequiredToSign === "required", - ...(lastPublishedAt && { lastPublishedAt }), - }, - }); + await db.policy.update({ + where: { + id, + organizationId: session.activeOrganizationId, + }, + data: { + status, + assigneeId, + department, + frequency: review_frequency, + reviewDate, + isRequiredToSign: isRequiredToSign === "required", + ...(lastPublishedAt && { lastPublishedAt }), + }, + }); - revalidatePath(`/${session.activeOrganizationId}/policies`); - revalidatePath(`/${session.activeOrganizationId}/policies/all/${id}`); - revalidateTag("policies"); + revalidatePath(`/${session.activeOrganizationId}/policies`); + revalidatePath(`/${session.activeOrganizationId}/policies/all/${id}`); + revalidateTag("policies"); - return { - success: true, - }; - } catch (error) { - console.error("Error updating policy:", error); + return { + success: true, + }; + } catch (error) { + console.error("Error updating policy:", error); - return { - success: false, - }; - } - }); + return { + success: false, + }; + } + }); diff --git a/apps/app/src/actions/schema.ts b/apps/app/src/actions/schema.ts index 8e8492526d..ed254a0fbf 100644 --- a/apps/app/src/actions/schema.ts +++ b/apps/app/src/actions/schema.ts @@ -1,300 +1,300 @@ import { - Departments, - Frequency, - PolicyStatus, - RiskCategory, - RiskStatus, - VendorCategory, - VendorStatus, - FrameworkId, - TaskStatus, - Impact, - Likelihood, + Departments, + Frequency, + PolicyStatus, + RiskCategory, + RiskStatus, + VendorCategory, + VendorStatus, + FrameworkId, + TaskStatus, + Impact, + Likelihood, } from "@comp/db/types"; import { z } from "zod"; export const organizationSchema = z.object({ - name: z.string().min(1, "Name is required"), - fullName: z.string().min(1, "Full name is required"), - website: z.string().url("Must be a valid URL").optional().or(z.literal("")), - frameworks: z - .array(z.nativeEnum(FrameworkId)) - .min(1, "Please select at least one framework to get started with"), + name: z.string().min(1, "Name is required"), + fullName: z.string().min(1, "Full name is required"), + website: z.string().url("Must be a valid URL").optional().or(z.literal("")), + frameworks: z + .array(z.nativeEnum(FrameworkId)) + .min(1, "Please select at least one framework to get started with"), }); export type OrganizationSchema = z.infer; export const organizationNameSchema = z.object({ - name: z - .string() - .min(1, "Organization name is required") - .max(255, "Organization name cannot exceed 255 characters"), + name: z + .string() + .min(1, "Organization name is required") + .max(255, "Organization name cannot exceed 255 characters"), }); export const subdomainAvailabilitySchema = z.object({ - subdomain: z - .string() - .min(1, "Subdomain is required") - .max(255, "Subdomain cannot exceed 255 characters") - .regex(/^[a-z0-9-]+$/, { - message: - "Subdomain can only contain lowercase letters, numbers, and hyphens", - }), + subdomain: z + .string() + .min(1, "Subdomain is required") + .max(255, "Subdomain cannot exceed 255 characters") + .regex(/^[a-z0-9-]+$/, { + message: + "Subdomain can only contain lowercase letters, numbers, and hyphens", + }), }); export const uploadSchema = z.object({ - file: z.instanceof(File), - organizationId: z.string(), + file: z.instanceof(File), + organizationId: z.string(), }); export const deleteOrganizationSchema = z.object({ - id: z.string(), - organizationId: z.string(), + id: z.string(), + organizationId: z.string(), }); export const sendFeedbackSchema = z.object({ - feedback: z.string(), + feedback: z.string(), }); export const updaterMenuSchema = z.array( - z.object({ - path: z.string(), - name: z.string(), - }), + z.object({ + path: z.string(), + name: z.string(), + }) ); export const organizationWebsiteSchema = z.object({ - website: z.string().url().max(255), + website: z.string().url().max(255), }); // Risks export const createRiskSchema = z.object({ - title: z - .string({ - required_error: "Risk name is required", - }) - .min(1, { - message: "Risk name should be at least 1 character", - }) - .max(100, { - message: "Risk name should be at most 100 characters", - }), - description: z - .string({ - required_error: "Risk description is required", - }) - .min(1, { - message: "Risk description should be at least 1 character", - }) - .max(255, { - message: "Risk description should be at most 255 characters", - }), - category: z.nativeEnum(RiskCategory, { - required_error: "Risk category is required", - }), - department: z.nativeEnum(Departments, { - required_error: "Risk department is required", - }), - ownerId: z.string({ - required_error: "You must assign an owner to the risk", - }), + title: z + .string({ + required_error: "Risk name is required", + }) + .min(1, { + message: "Risk name should be at least 1 character", + }) + .max(100, { + message: "Risk name should be at most 100 characters", + }), + description: z + .string({ + required_error: "Risk description is required", + }) + .min(1, { + message: "Risk description should be at least 1 character", + }) + .max(255, { + message: "Risk description should be at most 255 characters", + }), + category: z.nativeEnum(RiskCategory, { + required_error: "Risk category is required", + }), + department: z.nativeEnum(Departments, { + required_error: "Risk department is required", + }), + ownerId: z.string({ + required_error: "You must assign an owner to the risk", + }), }); export const updateRiskSchema = z.object({ - id: z.string().min(1, { - message: "Risk ID is required", - }), - title: z.string().min(1, { - message: "Risk title is required", - }), - description: z.string().min(1, { - message: "Risk description is required", - }), - category: z.nativeEnum(RiskCategory, { - required_error: "Risk category is required", - }), - department: z.nativeEnum(Departments, { - required_error: "Risk department is required", - }), - ownerId: z.string({ - required_error: "You must assign an owner to the risk", - }), - status: z.nativeEnum(RiskStatus, { - required_error: "Risk status is required", - }), + id: z.string().min(1, { + message: "Risk ID is required", + }), + title: z.string().min(1, { + message: "Risk title is required", + }), + description: z.string().min(1, { + message: "Risk description is required", + }), + category: z.nativeEnum(RiskCategory, { + required_error: "Risk category is required", + }), + department: z.nativeEnum(Departments, { + required_error: "Risk department is required", + }), + ownerId: z.string({ + required_error: "You must assign an owner to the risk", + }), + status: z.nativeEnum(RiskStatus, { + required_error: "Risk status is required", + }), }); export const createRiskCommentSchema = z.object({ - riskId: z.string().min(1, { - message: "Risk ID is required", - }), - content: z - .string() - .min(1, { - message: "Comment content is required", - }) - .max(1000, { - message: "Comment content should be at most 1000 characters", - }), + riskId: z.string().min(1, { + message: "Risk ID is required", + }), + content: z + .string() + .min(1, { + message: "Comment content is required", + }) + .max(1000, { + message: "Comment content should be at most 1000 characters", + }), }); export const createTaskSchema = z.object({ - riskId: z.string().min(1, { - message: "Risk ID is required", - }), - title: z.string().min(1, { - message: "Task title is required", - }), - description: z.string().min(1, { - message: "Task description is required", - }), - dueDate: z.date().optional(), - ownerId: z - .string({ - required_error: "You must assign an owner to the task", - }) - .min(1, { - message: "You must assign an owner to the task", - }), + riskId: z.string().min(1, { + message: "Risk ID is required", + }), + title: z.string().min(1, { + message: "Task title is required", + }), + description: z.string().min(1, { + message: "Task description is required", + }), + dueDate: z.date().optional(), + ownerId: z + .string({ + required_error: "You must assign an owner to the task", + }) + .min(1, { + message: "You must assign an owner to the task", + }), }); export const updateTaskSchema = z.object({ - id: z.string().min(1, { - message: "Task ID is required", - }), - title: z.string().optional(), - description: z.string().optional(), - dueDate: z.date().optional(), - status: z.nativeEnum(TaskStatus, { - required_error: "Task status is required", - }), - ownerId: z.string({ - required_error: "You must assign an owner to the task", - }), + id: z.string().min(1, { + message: "Task ID is required", + }), + title: z.string().optional(), + description: z.string().optional(), + dueDate: z.date().optional(), + status: z.nativeEnum(TaskStatus, { + required_error: "Task status is required", + }), + ownerId: z.string({ + required_error: "You must assign an owner to the task", + }), }); export const createTaskCommentSchema = z.object({ - riskId: z.string().min(1, { - message: "Risk ID is required", - }), - taskId: z.string().min(1, { - message: "Task ID is required", - }), - content: z - .string() - .min(1, { - message: "Comment content is required", - }) - .max(1000, { - message: "Comment content should be at most 1000 characters", - }), + riskId: z.string().min(1, { + message: "Risk ID is required", + }), + taskId: z.string().min(1, { + message: "Task ID is required", + }), + content: z + .string() + .min(1, { + message: "Comment content is required", + }) + .max(1000, { + message: "Comment content should be at most 1000 characters", + }), }); export const uploadTaskFileSchema = z.object({ - riskId: z.string().min(1, { - message: "Risk ID is required", - }), - taskId: z.string().min(1, { - message: "Task ID is required", - }), + riskId: z.string().min(1, { + message: "Risk ID is required", + }), + taskId: z.string().min(1, { + message: "Task ID is required", + }), }); // Integrations export const deleteIntegrationConnectionSchema = z.object({ - integrationId: z.string().min(1, { - message: "Integration ID is required", - }), + integrationId: z.string().min(1, { + message: "Integration ID is required", + }), }); export const createIntegrationSchema = z.object({ - integrationId: z.string().min(1, { - message: "Integration ID is required", - }), + integrationId: z.string().min(1, { + message: "Integration ID is required", + }), }); // Seed Data export const seedDataSchema = z.object({ - organizationId: z.string(), + organizationId: z.string(), }); export const updateInherentRiskSchema = z.object({ - id: z.string().min(1, { - message: "Risk ID is required", - }), - probability: z.nativeEnum(Likelihood), - impact: z.nativeEnum(Impact), + id: z.string().min(1, { + message: "Risk ID is required", + }), + probability: z.nativeEnum(Likelihood), + impact: z.nativeEnum(Impact), }); export const updateResidualRiskSchema = z.object({ - id: z.string().min(1, { - message: "Risk ID is required", - }), - probability: z.number().min(1).max(10), - impact: z.number().min(1).max(10), + id: z.string().min(1, { + message: "Risk ID is required", + }), + probability: z.number().min(1).max(10), + impact: z.number().min(1).max(10), }); // Policies export const createPolicySchema = z.object({ - title: z.string(), - description: z.string(), - frameworkIds: z.array(z.string()), - controlIds: z.array(z.string()), + title: z.string(), + description: z.string(), + frameworkIds: z.array(z.string()), + controlIds: z.array(z.string()), }); export const updatePolicySchema = z.object({ - id: z.string(), - content: z.any(), + id: z.string(), + content: z.any(), }); export const assistantSettingsSchema = z.object({ - enabled: z.boolean().optional(), + enabled: z.boolean().optional(), }); export const createEmployeeSchema = z.object({ - name: z.string().min(1, "Name is required"), - email: z.string().email("Invalid email address"), - department: z.nativeEnum(Departments, { - required_error: "Department is required", - }), - externalEmployeeId: z.string().optional(), - isActive: z.boolean().default(true), + name: z.string().min(1, "Name is required"), + email: z.string().email("Invalid email address"), + department: z.nativeEnum(Departments, { + required_error: "Department is required", + }), + externalEmployeeId: z.string().optional(), + isActive: z.boolean().default(true), }); export const updatePolicyOverviewSchema = z.object({ - id: z.string(), - title: z.string(), - description: z.string(), - isRequiredToSign: z.enum(["required", "not_required"]).optional(), + id: z.string(), + title: z.string(), + description: z.string(), + isRequiredToSign: z.enum(["required", "not_required"]).optional(), }); export const updatePolicyFormSchema = z.object({ - id: z.string(), - status: z.nativeEnum(PolicyStatus), - ownerId: z.string(), - department: z.nativeEnum(Departments), - review_frequency: z.nativeEnum(Frequency), - review_date: z.date(), - isRequiredToSign: z.enum(["required", "not_required"]), + id: z.string(), + status: z.nativeEnum(PolicyStatus), + assigneeId: z.string().nullable(), + department: z.nativeEnum(Departments), + review_frequency: z.nativeEnum(Frequency), + review_date: z.date(), + isRequiredToSign: z.enum(["required", "not_required"]), }); export const apiKeySchema = z.object({ - name: z - .string() - .min(1, { message: "Name is required" }) - .max(64, { message: "Name must be less than 64 characters" }), - expiresAt: z.enum(["30days", "90days", "1year", "never"]), + name: z + .string() + .min(1, { message: "Name is required" }) + .max(64, { message: "Name must be less than 64 characters" }), + expiresAt: z.enum(["30days", "90days", "1year", "never"]), }); export const createPolicyCommentSchema = z.object({ - policyId: z.string().min(1, { - message: "Policy ID is required", - }), - content: z - .string() - .min(1, { - message: "Comment content is required", - }) - .max(1000, { - message: "Comment content should be at most 1000 characters", - }), + policyId: z.string().min(1, { + message: "Policy ID is required", + }), + content: z + .string() + .min(1, { + message: "Comment content is required", + }) + .max(1000, { + message: "Comment content should be at most 1000 characters", + }), }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceAssigneeSection.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/components/SelectAssignee.tsx similarity index 66% rename from apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceAssigneeSection.tsx rename to apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/components/SelectAssignee.tsx index 9fcee72fe9..39866f2940 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceAssigneeSection.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/components/SelectAssignee.tsx @@ -1,36 +1,45 @@ -"use client"; - -import { Member, User } from "@comp/db/types"; -import { Avatar, AvatarFallback, AvatarImage } from "@comp/ui/avatar"; +import { Avatar, AvatarImage, AvatarFallback } from "@comp/ui/avatar"; import { Select, + SelectTrigger, SelectContent, SelectItem, - SelectTrigger, - SelectValue, } from "@comp/ui/select"; -import { User as UserIcon } from "lucide-react"; -import { useState } from "react"; +import { UserIcon } from "lucide-react"; +import { Member, User } from "@comp/db/types"; +import { useState, useEffect } from "react"; -interface AssigneeSectionProps { - onAssigneeChange: (value: string | null) => void; +interface SelectAssigneeProps { assigneeId: string | null; - assignees: (Member & { - user: User; - })[]; disabled?: boolean; + assignees: (Member & { user: User })[]; + onAssigneeChange: (value: string | null) => void; + withTitle?: boolean; } -export function EvidenceAssigneeSection({ +export const SelectAssignee = ({ + assigneeId, + disabled, assignees, onAssigneeChange, - assigneeId, - disabled = false, -}: AssigneeSectionProps) { + withTitle = true, +}: SelectAssigneeProps) => { const [selectedAssignee, setSelectedAssignee] = useState< (Member & { user: User }) | null >(null); + // Initialize selectedAssignee based on assigneeId prop + useEffect(() => { + if (assigneeId && assignees) { + const assignee = assignees.find((a) => a.id === assigneeId); + if (assignee) { + setSelectedAssignee(assignee); + } + } else { + setSelectedAssignee(null); + } + }, [assigneeId, assignees]); + const handleAssigneeChange = (value: string) => { const newAssigneeId = value === "none" ? null : value; onAssigneeChange(newAssigneeId); @@ -38,10 +47,7 @@ export function EvidenceAssigneeSection({ if (newAssigneeId && assignees) { const assignee = assignees.find((a) => a.id === newAssigneeId); if (assignee) { - setSelectedAssignee({ - ...assignee, - user: assignee.user, - }); + setSelectedAssignee(assignee); } else { setSelectedAssignee(null); } @@ -56,7 +62,6 @@ export function EvidenceAssigneeSection({ // If image is a relative URL, ensure it's properly formed if (image.startsWith("/")) { - // This handles the case where the URL might need to be prefixed with the base URL return image; } @@ -65,10 +70,14 @@ export function EvidenceAssigneeSection({ return (
-
- -

ASSIGNEE

-
+ {withTitle && ( +
+ +

+ ASSIGNEE +

+
+ )}
); -} +}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/ReviewSection.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/ReviewSection.tsx index 72e7952bfb..527923e185 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/ReviewSection.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/ReviewSection.tsx @@ -17,11 +17,11 @@ import { useAction } from "next-safe-action/hooks"; import { useState } from "react"; import { toast } from "sonner"; import { updateEvidenceDetails } from "../actions/updateEvidenceDetails"; -import { EvidenceAssigneeSection } from "./EvidenceAssigneeSection"; import { EvidenceDepartmentSection } from "./EvidenceDepartmentSection"; import { EvidenceFrequencySection } from "./EvidenceFrequencySection"; import { EvidenceNextReviewSection } from "./EvidenceNextReviewSection"; import { EvidenceStatusSection } from "./EvidenceStatusSection"; +import { SelectAssignee } from "../../../components/SelectAssignee"; interface ReviewSectionProps { evidence: Evidence & { @@ -106,10 +106,10 @@ export function ReviewSection({
- diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/page.tsx index 95e6ac33fa..b31713a4f5 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/page.tsx @@ -61,45 +61,53 @@ const getPoliciesOverview = async () => { where: { organizationId, status: "published", + isArchived: false, }, }), tx.policy.count({ where: { organizationId, status: "draft", + isArchived: false, }, }), tx.policy.count({ where: { organizationId, - status: "archived", + isArchived: true, }, }), tx.policy.count({ where: { organizationId, status: "needs_review", + isArchived: false, }, }), tx.policy.groupBy({ - by: ["ownerId"], + by: ["assigneeId"], _count: true, where: { organizationId, - ownerId: { not: null }, + assigneeId: { not: null }, }, }), tx.policy.findMany({ where: { organizationId, - ownerId: { not: null }, + assigneeId: { not: null }, }, select: { status: true, - owner: { + isArchived: true, + assignee: { select: { id: true, - name: true, + user: { + select: { + name: true, + }, + }, }, }, }, @@ -111,13 +119,13 @@ const getPoliciesOverview = async () => { const policyDataByOwner = new Map(); for (const policy of policiesByAssigneeStatus) { - if (!policy.owner) continue; + if (!policy.assignee) continue; - const ownerId = policy.owner.id; - if (!policyDataByOwner.has(ownerId)) { - policyDataByOwner.set(ownerId, { - id: ownerId, - name: policy.owner.name || "Unknown", + const assigneeId = policy.assignee.id; + if (!policyDataByOwner.has(assigneeId)) { + policyDataByOwner.set(assigneeId, { + id: assigneeId, + name: policy.assignee.user.name || "Unknown", total: 0, published: 0, draft: 0, @@ -126,19 +134,20 @@ const getPoliciesOverview = async () => { }); } - const ownerData = policyDataByOwner.get(ownerId); - ownerData.total += 1; + const assigneeData = policyDataByOwner.get(assigneeId); + assigneeData.total += 1; + + // Handle archived policies separately + if (policy.isArchived) { + assigneeData.archived += 1; + continue; + } // Handle each status type explicitly - const status = policy.status as - | "published" - | "draft" - | "archived" - | "needs_review"; - if (status === "published") ownerData.published += 1; - else if (status === "draft") ownerData.draft += 1; - else if (status === "archived") ownerData.archived += 1; - else if (status === "needs_review") ownerData.needs_review += 1; + const status = policy.status; + if (status === "published") assigneeData.published += 1; + else if (status === "draft") assigneeData.draft += 1; + else if (status === "needs_review") assigneeData.needs_review += 1; } const assigneeData = Array.from(policyDataByOwner.values()); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/[policyId]/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/[policyId]/page.tsx index 101bd793eb..bd51da859c 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/[policyId]/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/[policyId]/page.tsx @@ -6,7 +6,6 @@ import { auth } from "@comp/auth"; import type { Metadata } from "next"; import { setStaticParamsLocale } from "next-international/server"; import { headers } from "next/headers"; -import { redirect } from "next/navigation"; export default async function PolicyDetails({ params, @@ -16,10 +15,11 @@ export default async function PolicyDetails({ const { locale, policyId } = await params; setStaticParamsLocale(locale); const policy = await getPolicy(policyId); + const assignees = await getAssignees(); return (
- +
); } @@ -60,3 +60,29 @@ const getPolicy = cache(async (policyId: string) => { return policy; }); + +const getAssignees = cache(async () => { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const organizationId = session?.session.activeOrganizationId; + + if (!organizationId) { + return []; + } + + const assignees = await db.member.findMany({ + where: { + organizationId, + role: { + notIn: ["employee"], + }, + }, + include: { + user: true, + }, + }); + + return assignees; +}); diff --git a/apps/app/src/components/forms/policies/UpdatePolicyOverview.tsx b/apps/app/src/components/forms/policies/UpdatePolicyOverview.tsx new file mode 100644 index 0000000000..0dee910c2c --- /dev/null +++ b/apps/app/src/components/forms/policies/UpdatePolicyOverview.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { updatePolicyFormAction } from "@/actions/policies/update-policy-form-action"; +import { updatePolicyFormSchema } from "@/actions/schema"; +import { SelectAssignee } from "@/app/[locale]/(app)/(dashboard)/[orgId]/components/SelectAssignee"; +import { StatusPolicies, type StatusType } from "@/components/status-policies"; +import { useI18n } from "@/locales/client"; +import { + Departments, + Frequency, + Member, + User, + type Policy, + type PolicyStatus, +} from "@bubba/db/types"; +import { Button } from "@bubba/ui/button"; +import { Calendar } from "@bubba/ui/calendar"; +import { cn } from "@bubba/ui/cn"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@bubba/ui/form"; +import { Popover, PopoverContent, PopoverTrigger } from "@bubba/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@bubba/ui/select"; +import { Switch } from "@bubba/ui/switch"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format } from "date-fns"; +import { CalendarIcon, Loader2 } from "lucide-react"; +import { useAction } from "next-safe-action/hooks"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { z } from "zod"; + +const policyStatuses: PolicyStatus[] = [ + "draft", + "published", + "needs_review", +] as const; + +export function UpdatePolicyOverview({ + policy, + assignees, +}: { + policy: Policy; + assignees: (Member & { user: User })[]; +}) { + const t = useI18n(); + + const updatePolicyForm = useAction(updatePolicyFormAction, { + onSuccess: () => { + toast.success(t("policies.overview.form.update_policy_success")); + }, + onError: () => { + toast.error(t("policies.overview.form.update_policy_error")); + }, + }); + + const calculateReviewDate = (): Date => { + if (!policy.reviewDate) { + return new Date(); + } + return new Date(policy.reviewDate); + }; + + const reviewDate = calculateReviewDate(); + + const form = useForm>({ + resolver: zodResolver(updatePolicyFormSchema), + defaultValues: { + id: policy.id, + status: policy.status, + assigneeId: policy.assigneeId ?? "", + department: policy.department ?? Departments.admin, + review_frequency: policy.frequency ?? Frequency.monthly, + review_date: reviewDate, + isRequiredToSign: policy.isRequiredToSign ? "required" : "not_required", + }, + }); + + const onSubmit = (data: z.infer) => { + updatePolicyForm.execute({ + id: data.id, + status: data.status as PolicyStatus, + assigneeId: data.assigneeId, + department: data.department, + review_frequency: data.review_frequency, + review_date: data.review_date, + isRequiredToSign: data.isRequiredToSign, + }); + }; + + return ( +
+ +
+ ( + + {t("policies.overview.form.status")} + + + + + + )} + /> + ( + + + {t("policies.overview.form.review_frequency")} + + + + + + + )} + /> + ( + + + {t("policies.overview.form.policy_department")} + + + + + + + )} + /> + ( + + + {t("policies.overview.form.policy_assignee")} + + + + + + + )} + /> +
+
+ ( + + {t("policies.overview.form.review_date")} + + + +
+ +
+
+
+ + date <= new Date()} + initialFocus + /> + +
+ +
+ )} + /> + ( + + + {t("policies.overview.form.signature_requirement")} + + + { + field.onChange(checked ? "required" : "not_required"); + }} + /> + + + + )} + /> +
+
+ +
+
+ + ); +} diff --git a/apps/app/src/components/forms/policies/policy-overview.tsx b/apps/app/src/components/forms/policies/policy-overview.tsx index 368ffdb5a6..ae61145ae1 100644 --- a/apps/app/src/components/forms/policies/policy-overview.tsx +++ b/apps/app/src/components/forms/policies/policy-overview.tsx @@ -76,7 +76,7 @@ export function UpdatePolicyOverview({ defaultValues: { id: policy.id, status: policy.status, - ownerId: policy.ownerId ?? session.data?.user?.id, + assigneeId: policy.assigneeId ?? session.data?.user?.id, department: policy.department ?? Departments.admin, review_frequency: policy.frequency ?? Frequency.monthly, review_date: reviewDate, @@ -88,7 +88,7 @@ export function UpdatePolicyOverview({ updatePolicyForm.execute({ id: data.id, status: data.status as PolicyStatus, - ownerId: data.ownerId, + assigneeId: data.assigneeId, department: data.department, review_frequency: data.review_frequency, review_date: data.review_date, diff --git a/apps/app/src/components/policies/policy-overview.tsx b/apps/app/src/components/policies/policy-overview.tsx index c5d0168772..1bf2a38215 100644 --- a/apps/app/src/components/policies/policy-overview.tsx +++ b/apps/app/src/components/policies/policy-overview.tsx @@ -1,42 +1,39 @@ "use client"; import { useI18n } from "@/locales/client"; -import type { Policy, User } from "@comp/db/types"; +import type { Member, Policy, User } from "@comp/db/types"; import { Alert, AlertDescription, AlertTitle } from "@comp/ui/alert"; import { Button } from "@comp/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@comp/ui/card"; import { Icons } from "@comp/ui/icons"; -import { ArchiveIcon, PencilIcon, ArchiveRestoreIcon } from "lucide-react"; +import { format } from "date-fns"; +import { ArchiveIcon, ArchiveRestoreIcon, PencilIcon } from "lucide-react"; import { useQueryState } from "nuqs"; -import { PolicyOverviewSheet } from "./sheets/policy-overview-sheet"; -import { UpdatePolicyOverview } from "../forms/policies/policy-overview"; +import { UpdatePolicyOverview } from "../forms/policies/UpdatePolicyOverview"; import { PolicyArchiveSheet } from "./sheets/policy-archive-sheet"; -import { format } from "date-fns"; -import { useRouter } from "next/navigation"; +import { PolicyOverviewSheet } from "./sheets/policy-overview-sheet"; export function PolicyOverview({ policy, + assignees, }: { policy: Policy | null; + assignees: (Member & { user: User })[]; }) { const t = useI18n(); - const router = useRouter(); - const [open, setOpen] = useQueryState("policy-overview-sheet"); - const [archiveOpen, setArchiveOpen] = useQueryState("archive-policy-sheet"); + const [, setOpen] = useQueryState("policy-overview-sheet"); + const [, setArchiveOpen] = useQueryState("archive-policy-sheet"); return (
- {policy?.status === "archived" && ( - + {policy?.isArchived && ( +
{t("policies.archive.status")}
- {policy?.status === "archived" && ( + {policy?.isArchived && ( <> {t("policies.archive.archived_on")}{" "} {format(new Date(policy?.updatedAt ?? new Date()), "PPP")} @@ -67,12 +64,12 @@ export function PolicyOverview({ className="p-0 m-0 size-auto" onClick={() => setArchiveOpen("true")} title={ - policy?.status === "archived" + policy?.isArchived ? t("policies.archive.restore_tooltip") : t("policies.archive.tooltip") } > - {policy?.status === "archived" ? ( + {policy?.isArchived ? ( ) : ( @@ -104,7 +101,9 @@ export function PolicyOverview({ - {policy && } + {policy && ( + + )} diff --git a/apps/app/src/components/policies/sheets/policy-archive-sheet.tsx b/apps/app/src/components/policies/sheets/policy-archive-sheet.tsx index 3e366d44bf..5954c0c33f 100644 --- a/apps/app/src/components/policies/sheets/policy-archive-sheet.tsx +++ b/apps/app/src/components/policies/sheets/policy-archive-sheet.tsx @@ -29,11 +29,11 @@ export function PolicyArchiveSheet({ const isDesktop = useMediaQuery("(min-width: 768px)"); const [open, setOpen] = useQueryState("archive-policy-sheet"); const isOpen = Boolean(open); - const isArchived = policy.status === "archived"; + const isArchived = policy.isArchived; const archivePolicy = useAction(archivePolicyAction, { onSuccess: (result) => { - if (result.data?.status === "archived") { + if (result) { toast.success(t("policies.archive.success")); // Redirect to policies list after successful archive router.push(`/${policy.organizationId}/policies/all`); diff --git a/apps/app/src/locales/features/policies.ts b/apps/app/src/locales/features/policies.ts index 9cfbf41f13..03ac23c4c8 100644 --- a/apps/app/src/locales/features/policies.ts +++ b/apps/app/src/locales/features/policies.ts @@ -34,6 +34,8 @@ export const policies = { policy_department_placeholder: "Select a department", policy_status: "Policy Status", policy_status_placeholder: "Select a policy status", + policy_assignee: "Assignee", + policy_assignee_placeholder: "Select an assignee", }, }, new: { diff --git a/packages/db/prisma/migrations/20250403165218_archived_on_its_own/migration.sql b/packages/db/prisma/migrations/20250403165218_archived_on_its_own/migration.sql new file mode 100644 index 0000000000..346678aa0b --- /dev/null +++ b/packages/db/prisma/migrations/20250403165218_archived_on_its_own/migration.sql @@ -0,0 +1,29 @@ +/* + Warnings: + + - The values [archived] on the enum `PolicyStatus` will be removed. If these variants are still used in the database, this will fail. + - You are about to drop the column `ownerId` on the `Policy` table. All the data in the column will be lost. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "PolicyStatus_new" AS ENUM ('draft', 'published', 'needs_review'); +ALTER TABLE "Policy" ALTER COLUMN "status" DROP DEFAULT; +ALTER TABLE "Policy" ALTER COLUMN "status" TYPE "PolicyStatus_new" USING ("status"::text::"PolicyStatus_new"); +ALTER TYPE "PolicyStatus" RENAME TO "PolicyStatus_old"; +ALTER TYPE "PolicyStatus_new" RENAME TO "PolicyStatus"; +DROP TYPE "PolicyStatus_old"; +ALTER TABLE "Policy" ALTER COLUMN "status" SET DEFAULT 'draft'; +COMMIT; + +-- DropForeignKey +ALTER TABLE "Policy" DROP CONSTRAINT "Policy_ownerId_fkey"; + +-- AlterTable +ALTER TABLE "Policy" DROP COLUMN "ownerId", +ADD COLUMN "assigneeId" TEXT, +ADD COLUMN "isArchived" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "lastArchivedAt" TIMESTAMP(3); + +-- AddForeignKey +ALTER TABLE "Policy" ADD CONSTRAINT "Policy_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "Member"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma index 979e3e7795..c2905ae708 100644 --- a/packages/db/prisma/schema/auth.prisma +++ b/packages/db/prisma/schema/auth.prisma @@ -13,7 +13,6 @@ model User { integrationResults IntegrationResult[] invitations Invitation[] members Member[] - policy Policy[] risk Risk[] sessions Session[] vendor Vendor[] @@ -88,6 +87,7 @@ model Member { isActive Boolean @default(true) EmployeeTrainingVideoCompletion EmployeeTrainingVideoCompletion[] Evidence Evidence[] + Policy Policy[] } model Invitation { diff --git a/packages/db/prisma/schema/policy.prisma b/packages/db/prisma/schema/policy.prisma index 23dd92b8aa..f24d42e683 100644 --- a/packages/db/prisma/schema/policy.prisma +++ b/packages/db/prisma/schema/policy.prisma @@ -2,21 +2,27 @@ model Policy { id String @id @default(dbgenerated("generate_prefixed_cuid('pol'::text)")) name String description String? - organizationId String status PolicyStatus @default(draft) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt content Json[] frequency Frequency? - lastPublishedAt DateTime? + department Departments? isRequiredToSign Boolean @default(false) signedBy String[] @default([]) - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - ownerId String? - owner User? @relation(fields: [ownerId], references: [id]) - department Departments? reviewDate DateTime? - artifacts Artifact[] + isArchived Boolean @default(false) + + // Dates + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastArchivedAt DateTime? + lastPublishedAt DateTime? + + // Relationships + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + assigneeId String? + assignee Member? @relation(fields: [assigneeId], references: [id]) + artifacts Artifact[] @@index([organizationId]) } @@ -25,5 +31,4 @@ enum PolicyStatus { draft published needs_review - archived } diff --git a/yarn.lock b/yarn.lock index 4779ee735e..7f987c6cc6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1448,14 +1448,14 @@ picocolors "^1.0.0" sisteransi "^1.0.5" -"@comp/analytics@packages/analytics": +"@comp/analytics@^workspace:packages/analytics": version "workspace:packages/analytics" resolved "workspace:packages/analytics" dependencies: posthog-js "^1.215.3" posthog-node "^4.4.1" -"@comp/app@apps/app", "@comp/app@workspace:*": +"@comp/app@^workspace:apps/app", "@comp/app@workspace:*": version "workspace:apps/app" resolved "workspace:apps/app" devDependencies: @@ -1554,7 +1554,7 @@ yarn "^1.22.22" zustand "^5.0.3" -"@comp/auth@packages/auth", "@comp/auth@workspace:*": +"@comp/auth@^workspace:packages/auth", "@comp/auth@workspace:*": version "workspace:packages/auth" resolved "workspace:packages/auth" devDependencies: @@ -1567,7 +1567,7 @@ better-auth "^1.2.5" server-only "0.0.1" -"@comp/data@packages/data", "@comp/data@workspace:*": +"@comp/data@^workspace:packages/data", "@comp/data@workspace:*": version "workspace:packages/data" resolved "workspace:packages/data" devDependencies: @@ -1576,11 +1576,12 @@ tsup "^8.4.0" typescript "^5.8.2" -"@comp/db@packages/db", "@comp/db@workspace:*": +"@comp/db@^workspace:packages/db", "@comp/db@workspace:*": version "workspace:packages/db" resolved "workspace:packages/db" devDependencies: "@comp/tsconfig" "workspace:*" + devDependencies: typescript "^5.8.2" dependencies: "@auth/prisma-adapter" "^2.8.0" @@ -1590,7 +1591,7 @@ "@prisma/extension-accelerate" "^1.2.2" prisma "^6.5.0" -"@comp/email@packages/email", "@comp/email@workspace:*": +"@comp/email@^workspace:packages/email", "@comp/email@workspace:*": version "workspace:packages/email" resolved "workspace:packages/email" devDependencies: @@ -1601,6 +1602,7 @@ dependencies: "@comp/ui" "workspace:*" "@comp/utils" "workspace:*" + dependencies: "@react-email/components" "0.0.31" "@react-email/render" "0.0.10" "@react-email/tailwind" "1.0.4" @@ -1610,7 +1612,7 @@ react-email "3.0.4" responsive-react-email "^0.0.5" -"@comp/integrations@packages/integrations": +"@comp/integrations@^workspace:packages/integrations": version "workspace:packages/integrations" resolved "workspace:packages/integrations" devDependencies: @@ -1633,14 +1635,14 @@ sharp "^0.33.5" zod "^3.24.2" -"@comp/kv@packages/kv": +"@comp/kv@^workspace:packages/kv": version "workspace:packages/kv" resolved "workspace:packages/kv" dependencies: "@upstash/redis" "^1.34.2" server-only "0.0.1" -"@comp/notifications@packages/notifications", "@comp/notifications@workspace:*": +"@comp/notifications@^workspace:packages/notifications", "@comp/notifications@workspace:*": version "workspace:packages/notifications" resolved "workspace:packages/notifications" devDependencies: @@ -1649,12 +1651,13 @@ "@novu/node" "^2.0.1" nanoid "5.1.0" -"@comp/portal@apps/portal": +"@comp/portal@^workspace:apps/portal": version "workspace:apps/portal" resolved "workspace:apps/portal" devDependencies: "@comp/db" "workspace:*" "@comp/ui" "workspace:*" + devDependencies: "@types/node" "^22.13.2" "@types/react" "^19.0.8" "@types/react-dom" "^19.0.3" @@ -1669,11 +1672,11 @@ react-dom "^19.0.0" react-otp-input "^3.1.1" -"@comp/tsconfig@packages/tsconfig", "@comp/tsconfig@workspace:*": +"@comp/tsconfig@^workspace:packages/tsconfig", "@comp/tsconfig@workspace:*": version "workspace:packages/tsconfig" resolved "workspace:packages/tsconfig" -"@comp/ui@packages/ui", "@comp/ui@workspace:*": +"@comp/ui@^workspace:packages/ui", "@comp/ui@workspace:*": version "workspace:packages/ui" resolved "workspace:packages/ui" devDependencies: @@ -1737,7 +1740,7 @@ use-callback-ref "^1.3.3" vaul "^1.0.0" -"@comp/utils@packages/utils", "@comp/utils@workspace:*": +"@comp/utils@^workspace:packages/utils", "@comp/utils@workspace:*": version "workspace:packages/utils" resolved "workspace:packages/utils" devDependencies: