diff --git a/apps/api/src/frameworks/frameworks-scores.helper.ts b/apps/api/src/frameworks/frameworks-scores.helper.ts index 468f0f92f..ea843a86d 100644 --- a/apps/api/src/frameworks/frameworks-scores.helper.ts +++ b/apps/api/src/frameworks/frameworks-scores.helper.ts @@ -5,6 +5,7 @@ import { toExternalEvidenceFormType, } from '@trycompai/company'; import { db } from '@db'; +import { ISO27001_FRAMEWORK_NAMES } from '../soa/utils/constants'; import { filterComplianceMembers } from '../utils/compliance-filters'; const SIX_MONTHS_MS = 6 * 30 * 24 * 60 * 60 * 1000; @@ -185,11 +186,24 @@ export async function getOverviewScores(organizationId: string) { } async function computeDocumentsScore(organizationId: string) { - const groupedStatuses = await db.evidenceSubmission.groupBy({ - by: ['formType'], - where: { organizationId }, - _max: { submittedAt: true }, - }); + const [groupedStatuses, isoFrameworkInstances] = await Promise.all([ + db.evidenceSubmission.groupBy({ + by: ['formType'], + where: { organizationId }, + _max: { submittedAt: true }, + }), + db.frameworkInstance.findMany({ + where: { + organizationId, + framework: { + name: { + in: ISO27001_FRAMEWORK_NAMES, + }, + }, + }, + select: { frameworkId: true }, + }), + ]); const statuses: Record = {}; for (const form of evidenceFormDefinitionList) { @@ -204,8 +218,7 @@ async function computeDocumentsScore(organizationId: string) { const includedForms = evidenceFormDefinitionList.filter( (f) => !f.hidden && !f.optional, ); - const totalDocuments = includedForms.length; - const outstandingDocuments = includedForms.reduce((count, form) => { + const nonSOAOutstandingDocuments = includedForms.reduce((count, form) => { if (form.type === 'meeting') { const allMeetingsOutstanding = meetingSubTypeValues.every((subType) => { const lastSubmitted = statuses[subType]?.lastSubmittedAt; @@ -223,6 +236,37 @@ async function computeDocumentsScore(organizationId: string) { return isOutstanding ? count + 1 : count; }, 0); + const isoFrameworkIds = isoFrameworkInstances + .map((instance) => instance.frameworkId) + .filter((id): id is string => !!id); + const hasSOADocumentRequirement = isoFrameworkIds.length > 0; + + let soaCompleted = false; + if (hasSOADocumentRequirement) { + const latestSOADocument = await db.sOADocument.findFirst({ + where: { + organizationId, + isLatest: true, + frameworkId: { in: isoFrameworkIds }, + }, + select: { + approvedAt: true, + status: true, + }, + orderBy: { + updatedAt: 'desc', + }, + }); + soaCompleted = + latestSOADocument?.status === 'completed' && + !!latestSOADocument.approvedAt; + } + + const soaTotalDocuments = hasSOADocumentRequirement ? 1 : 0; + const soaOutstandingDocuments = hasSOADocumentRequirement && !soaCompleted ? 1 : 0; + const totalDocuments = includedForms.length + soaTotalDocuments; + const outstandingDocuments = nonSOAOutstandingDocuments + soaOutstandingDocuments; + return { totalDocuments, completedDocuments: totalDocuments - outstandingDocuments, diff --git a/apps/api/src/soa/dto/export-soa-document.dto.ts b/apps/api/src/soa/dto/export-soa-document.dto.ts new file mode 100644 index 000000000..498d79fe2 --- /dev/null +++ b/apps/api/src/soa/dto/export-soa-document.dto.ts @@ -0,0 +1,15 @@ +import { IsIn, IsNotEmpty, IsString } from 'class-validator'; + +export class ExportSOADocumentDto { + @IsString() + @IsNotEmpty() + documentId!: string; + + @IsString() + @IsNotEmpty() + organizationId!: string; + + @IsIn(['pdf']) + format!: 'pdf'; +} + diff --git a/apps/api/src/soa/soa.controller.spec.ts b/apps/api/src/soa/soa.controller.spec.ts index f2dd111ce..83fa7db00 100644 --- a/apps/api/src/soa/soa.controller.spec.ts +++ b/apps/api/src/soa/soa.controller.spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BadRequestException } from '@nestjs/common'; +import type { Response } from 'express'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; import { PermissionGuard } from '../auth/permission.guard'; import type { AuthContext } from '../auth/types'; @@ -9,6 +10,15 @@ import { SOAService } from './soa.service'; jest.mock('../auth/auth.server', () => ({ auth: { api: { getSession: jest.fn() } }, })); +jest.mock('../auth/hybrid-auth.guard', () => ({ + HybridAuthGuard: class MockHybridAuthGuard {}, +})); +jest.mock('../auth/permission.guard', () => ({ + PermissionGuard: class MockPermissionGuard {}, +})); +jest.mock('./soa.service', () => ({ + SOAService: class MockSOAService {}, +})); jest.mock('@trycompai/auth', () => ({ statement: {}, @@ -37,6 +47,7 @@ describe('SOAController', () => { approveDocument: jest.fn(), declineDocument: jest.fn(), submitForApproval: jest.fn(), + exportDocument: jest.fn(), }; const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; @@ -210,4 +221,40 @@ describe('SOAController', () => { expect(result).toEqual(submitted); }); }); + + describe('exportDocument', () => { + const dto = { + documentId: 'doc_1', + format: 'pdf', + }; + + it('should call soaService.exportDocument, set headers, and send file buffer', async () => { + const fileBuffer = Buffer.from('pdf-data'); + mockSOAService.exportDocument.mockResolvedValue({ + fileBuffer, + mimeType: 'application/pdf', + filename: 'soa-export.pdf', + }); + const res = { + setHeader: jest.fn(), + send: jest.fn(), + } as unknown as Response; + + await controller.exportDocument(dto as never, res, 'org_123'); + + expect(soaService.exportDocument).toHaveBeenCalledWith({ + ...dto, + organizationId: 'org_123', + }); + expect(res.setHeader).toHaveBeenCalledWith( + 'Content-Type', + 'application/pdf', + ); + expect(res.setHeader).toHaveBeenCalledWith( + 'Content-Disposition', + 'attachment; filename="soa-export.pdf"', + ); + expect(res.send).toHaveBeenCalledWith(fileBuffer); + }); + }); }); diff --git a/apps/api/src/soa/soa.controller.ts b/apps/api/src/soa/soa.controller.ts index 64d1bed7c..451c93476 100644 --- a/apps/api/src/soa/soa.controller.ts +++ b/apps/api/src/soa/soa.controller.ts @@ -25,6 +25,7 @@ import { EnsureSOASetupDto } from './dto/ensure-soa-setup.dto'; import { ApproveSOADocumentDto } from './dto/approve-soa-document.dto'; import { DeclineSOADocumentDto } from './dto/decline-soa-document.dto'; import { SubmitSOAForApprovalDto } from './dto/submit-soa-for-approval.dto'; +import { ExportSOADocumentDto } from './dto/export-soa-document.dto'; import { syncOrganizationEmbeddings } from '@/vector-store/lib'; import { OrganizationId } from '@/auth/auth-context.decorator'; import { AuthContext } from '@/auth/auth-context.decorator'; @@ -395,4 +396,29 @@ export class SOAController { ) { return this.soaService.submitForApproval(dto); } + + @Post('export') + @RequirePermission('audit', 'read') + @ApiOperation({ summary: 'Export a SOA document' }) + @ApiConsumes('application/json') + @ApiProduces('application/pdf') + @ApiOkResponse({ + description: 'Export SOA document to PDF', + }) + async exportDocument( + @Body() dto: ExportSOADocumentDto, + @Res({ passthrough: true }) res: Response, + @OrganizationId() organizationId: string, + ): Promise { + dto.organizationId = organizationId; + const result = await this.soaService.exportDocument(dto); + + res.setHeader('Content-Type', result.mimeType); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${result.filename}"`, + ); + + res.send(result.fileBuffer); + } } diff --git a/apps/api/src/soa/soa.service.spec.ts b/apps/api/src/soa/soa.service.spec.ts index 8c87ad045..91b35aabb 100644 --- a/apps/api/src/soa/soa.service.spec.ts +++ b/apps/api/src/soa/soa.service.spec.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/common'; import { db } from '@db'; import { SOAService } from './soa.service'; +import { generateSOAExportFile } from './utils/export-generator'; jest.mock('@db', () => ({ db: { @@ -51,8 +52,12 @@ jest.mock('./utils/soa-storage', () => ({ updateDocumentAnsweredCount: jest.fn(), checkIfFullyRemote: jest.fn(), })); +jest.mock('./utils/export-generator', () => ({ + generateSOAExportFile: jest.fn(), +})); const mockDb = jest.mocked(db); +const mockGenerateSOAExportFile = jest.mocked(generateSOAExportFile); describe('SOAService', () => { let service: SOAService; @@ -207,6 +212,105 @@ describe('SOAService', () => { }); const result = await service.approveDocument(dto, userId); expect(result.success).toBe(true); + expect(mockDb.sOADocument.update).toHaveBeenCalledWith({ + where: { id: dto.documentId }, + data: expect.objectContaining({ + status: 'completed', + declinedAt: null, + }), + }); + }); + }); + + describe('declineDocument', () => { + const dto = { + documentId: 'doc-1', + organizationId: 'org-1', + reason: 'Needs changes', + }; + const userId = 'user-1'; + const ownerMember = { id: 'mem-1', role: 'owner' }; + + it('throws NotFoundException when member not found', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null); + + await expect(service.declineDocument(dto, userId)).rejects.toThrow( + NotFoundException, + ); + }); + + it('throws ForbiddenException when user is not owner/admin', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ + id: 'mem-1', + role: 'employee', + }); + + await expect(service.declineDocument(dto, userId)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('throws NotFoundException when document not found', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(ownerMember); + (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue(null); + + await expect(service.declineDocument(dto, userId)).rejects.toThrow( + NotFoundException, + ); + }); + + it('throws ForbiddenException when not pending approval for this user', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(ownerMember); + (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc-1', + approverId: 'other-member', + status: 'needs_review', + }); + + await expect(service.declineDocument(dto, userId)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('throws BadRequestException when not in needs_review status', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(ownerMember); + (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc-1', + approverId: 'mem-1', + status: 'draft', + }); + + await expect(service.declineDocument(dto, userId)).rejects.toThrow( + BadRequestException, + ); + }); + + it('declines document and sets declinedAt', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(ownerMember); + (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc-1', + approverId: 'mem-1', + status: 'needs_review', + }); + (mockDb.sOADocument.update as jest.Mock).mockResolvedValue({ + id: 'doc-1', + status: 'completed', + }); + + const result = await service.declineDocument(dto, userId); + + expect(result.success).toBe(true); + expect(mockDb.sOADocument.update).toHaveBeenCalledWith({ + where: { id: dto.documentId }, + data: expect.objectContaining({ + approverId: null, + approvedAt: null, + status: 'completed', + }), + }); + expect((mockDb.sOADocument.update as jest.Mock).mock.calls[0][0].data.declinedAt).toBeInstanceOf( + Date, + ); }); }); @@ -296,6 +400,121 @@ describe('SOAService', () => { }); const result = await service.submitForApproval(dto); expect(result.success).toBe(true); + expect(mockDb.sOADocument.update).toHaveBeenCalledWith({ + where: { id: dto.documentId }, + data: expect.objectContaining({ + approverId: dto.approverId, + status: 'needs_review', + approvedAt: null, + declinedAt: null, + }), + }); + }); + }); + + describe('exportDocument', () => { + const dto = { + documentId: 'doc-1', + organizationId: 'org-1', + format: 'pdf' as const, + }; + + it('throws NotFoundException when document not found', async () => { + (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue(null); + + await expect(service.exportDocument(dto)).rejects.toThrow(NotFoundException); + }); + + it('maps document data and delegates to generateSOAExportFile', async () => { + const generated = { + fileBuffer: Buffer.from('pdf'), + mimeType: 'application/pdf', + filename: 'statement-of-applicability-iso-27001-v2.pdf', + }; + mockGenerateSOAExportFile.mockReturnValue(generated); + (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc-1', + organizationId: 'org-1', + preparedBy: 'Compliance Lead', + answeredQuestions: 3, + totalQuestions: 5, + approvedAt: null, + declinedAt: new Date('2026-04-20T00:00:00.000Z'), + status: 'declined', + version: 2, + framework: { name: 'ISO 27001' }, + configuration: { + questions: [ + { + id: 'q-1', + text: 'Control 1', + columnMapping: { + closure: 'A.5', + title: 'Control title', + control_objective: 'Objective', + isApplicable: true, + justification: 'Mapped justification', + }, + }, + { + id: 'q-2', + text: 'Control 2', + columnMapping: {}, + }, + ], + }, + approver: { + user: { + name: 'Approver Name', + email: 'approver@example.com', + }, + }, + answers: [{ questionId: 'q-2', answer: 'Fallback answer' }], + }); + + const result = await service.exportDocument(dto); + + expect(mockGenerateSOAExportFile).toHaveBeenCalledWith( + [ + { + id: 'q-1', + text: 'Control 1', + columnMapping: { + closure: 'A.5', + title: 'Control title', + control_objective: 'Objective', + isApplicable: true, + justification: 'Mapped justification', + }, + answer: null, + }, + { + id: 'q-2', + text: 'Control 2', + columnMapping: { + closure: null, + title: null, + control_objective: null, + isApplicable: null, + justification: null, + }, + answer: 'Fallback answer', + }, + ], + 'ISO 27001', + 2, + { + preparedBy: 'Compliance Lead', + answeredQuestions: 3, + totalQuestions: 5, + approvedAt: null, + declinedAt: new Date('2026-04-20T00:00:00.000Z'), + status: 'declined', + approverName: 'Approver Name', + }, + 'pdf', + ); + expect(result).toEqual(generated); }); }); }); diff --git a/apps/api/src/soa/soa.service.ts b/apps/api/src/soa/soa.service.ts index aa085444a..ef43aea18 100644 --- a/apps/api/src/soa/soa.service.ts +++ b/apps/api/src/soa/soa.service.ts @@ -13,9 +13,15 @@ import { EnsureSOASetupDto } from './dto/ensure-soa-setup.dto'; import { ApproveSOADocumentDto } from './dto/approve-soa-document.dto'; import { DeclineSOADocumentDto } from './dto/decline-soa-document.dto'; import { SubmitSOAForApprovalDto } from './dto/submit-soa-for-approval.dto'; +import { ExportSOADocumentDto } from './dto/export-soa-document.dto'; import type { SimilarContentResult } from '@/vector-store/lib'; import { loadISOConfig } from './utils/transform-iso-config'; import { ISO27001_FRAMEWORK_NAMES } from './utils/constants'; +import { + generateSOAExportFile, + type SOAExportMetadata, + type SOAExportQuestion, +} from './utils/export-generator'; import { batchSearchSOAQuestions, generateSOAAnswerWithRAG, @@ -340,6 +346,7 @@ export class SOAService { data: { status: 'completed', approvedAt: new Date(), + declinedAt: null, }, }); @@ -374,6 +381,7 @@ export class SOAService { approverId: null, approvedAt: null, status: 'completed', + declinedAt: new Date(), }, }); @@ -430,6 +438,8 @@ export class SOAService { where: { id: dto.documentId }, data: { approverId: dto.approverId, + approvedAt: null, + declinedAt: null, status: 'needs_review', }, }); @@ -437,6 +447,86 @@ export class SOAService { return { success: true, data: updatedDocument }; } + async exportDocument(dto: ExportSOADocumentDto): Promise<{ + fileBuffer: Buffer; + mimeType: string; + filename: string; + }> { + const document = await db.sOADocument.findFirst({ + where: { + id: dto.documentId, + organizationId: dto.organizationId, + }, + include: { + configuration: true, + framework: { + select: { name: true }, + }, + approver: { + select: { + user: { + select: { + name: true, + email: true, + }, + }, + }, + }, + answers: { + where: { isLatestAnswer: true }, + select: { + questionId: true, + answer: true, + }, + }, + }, + }); + + if (!document) { + throw new NotFoundException('SOA document not found'); + } + + const questions = + (document.configuration.questions as unknown as SOAQuestion[]) ?? []; + const answersByQuestionId = new Map( + document.answers.map((answer) => [answer.questionId, answer.answer]), + ); + + const exportQuestions: SOAExportQuestion[] = questions.map((question) => ({ + id: question.id, + text: question.text, + columnMapping: { + closure: question.columnMapping?.closure ?? null, + title: question.columnMapping?.title ?? null, + control_objective: question.columnMapping?.control_objective ?? null, + isApplicable: question.columnMapping?.isApplicable ?? null, + justification: question.columnMapping?.justification ?? null, + }, + answer: answersByQuestionId.get(question.id) ?? null, + })); + + const exportMetadata: SOAExportMetadata = { + preparedBy: (document.preparedBy as string | null) ?? null, + answeredQuestions: document.answeredQuestions, + totalQuestions: document.totalQuestions, + approvedAt: document.approvedAt ?? null, + declinedAt: (document as { declinedAt?: Date | null }).declinedAt ?? null, + status: document.status, + approverName: + document.approver?.user?.name || + document.approver?.user?.email || + null, + }; + + return generateSOAExportFile( + exportQuestions, + document.framework.name || 'ISO 27001', + document.version, + exportMetadata, + dto.format, + ); + } + // Auto-fill related methods (delegating to utilities) async checkIfFullyRemote(organizationId: string): Promise { diff --git a/apps/api/src/soa/utils/export-generator.ts b/apps/api/src/soa/utils/export-generator.ts new file mode 100644 index 000000000..f1fc30fc8 --- /dev/null +++ b/apps/api/src/soa/utils/export-generator.ts @@ -0,0 +1,191 @@ +import { jsPDF } from 'jspdf'; + +export type SOAExportFormat = 'pdf'; + +export interface SOAExportQuestion { + id: string; + text: string; + columnMapping: { + closure?: string | null; + title: string | null; + control_objective: string | null; + isApplicable: boolean | null; + justification: string | null; + }; + answer: string | null; +} + +export interface SOAExportMetadata { + preparedBy: string | null; + answeredQuestions: number; + totalQuestions: number; + approvedAt?: Date | string | null; + declinedAt?: Date | string | null; + status?: string | null; + approverName?: string | null; +} + +export interface SOAExportResult { + fileBuffer: Buffer; + mimeType: string; + filename: string; +} + +export function generateSOAExportFile( + questions: SOAExportQuestion[], + frameworkName: string, + version: number, + metadata: SOAExportMetadata, + format: SOAExportFormat = 'pdf', +): SOAExportResult { + if (format !== 'pdf') { + throw new Error(`Unsupported SOA export format: ${format}`); + } + + return { + fileBuffer: generateSOAPDF(questions, frameworkName, version, metadata), + mimeType: 'application/pdf', + filename: `statement-of-applicability-${sanitizeFrameworkName(frameworkName)}-v${version}.pdf`, + }; +} + +function generateSOAPDF( + questions: SOAExportQuestion[], + frameworkName: string, + version: number, + metadata: SOAExportMetadata, +): Buffer { + const pdf = new jsPDF(); + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + const margin = 20; + const contentWidth = pageWidth - margin * 2; + const lineHeight = 7; + let y = margin; + + const ensureSpace = (requiredHeight: number) => { + if (y + requiredHeight > pageHeight - margin) { + pdf.addPage(); + y = margin; + } + }; + const writeLines = ( + lines: string[], + fontStyle: 'normal' | 'bold' = 'normal', + ) => { + if (lines.length === 0) return; + pdf.setFont('helvetica', fontStyle); + for (const line of lines) { + ensureSpace(lineHeight); + pdf.text(line, margin, y); + y += lineHeight; + } + }; + + pdf.setFont('helvetica', 'bold'); + pdf.setFontSize(16); + pdf.text('Statement of Applicability', margin, y); + y += lineHeight * 1.8; + + pdf.setFont('helvetica', 'normal'); + pdf.setFontSize(10); + pdf.text(`Framework: ${frameworkName}`, margin, y); + y += lineHeight; + pdf.text(`Version: v${version}`, margin, y); + y += lineHeight; + const progressPercentage = + metadata.totalQuestions > 0 + ? Math.round((metadata.answeredQuestions / metadata.totalQuestions) * 100) + : 0; + pdf.text( + `Progress: ${metadata.answeredQuestions} / ${metadata.totalQuestions} (${progressPercentage}%)`, + margin, + y, + ); + y += lineHeight; + pdf.text(`Prepared by: ${metadata.preparedBy || 'Comp AI'}`, margin, y); + y += lineHeight; + const approvalStatusText = metadata.approvedAt + ? `Approved on ${new Date(metadata.approvedAt).toLocaleDateString()}` + : metadata.declinedAt + ? `Declined on ${new Date(metadata.declinedAt).toLocaleDateString()}` + : metadata.status === 'needs_review' + ? 'Pending approval' + : 'Not approved'; + const approvalActorLabel = metadata.approvedAt + ? 'Approved by' + : metadata.declinedAt + ? 'Declined by' + : metadata.status === 'needs_review' + ? 'Pending approval by' + : 'Approver'; + pdf.text(`Approval status: ${approvalStatusText}`, margin, y); + y += lineHeight; + pdf.text(`${approvalActorLabel}: ${metadata.approverName || 'N/A'}`, margin, y); + y += lineHeight; + pdf.text(`Exported: ${new Date().toLocaleDateString()}`, margin, y); + y += lineHeight * 2; + + pdf.setFontSize(11); + for (let i = 0; i < questions.length; i++) { + const question = questions[i]; + const mapped = question.columnMapping ?? { + title: null, + control_objective: null, + isApplicable: null, + justification: null, + }; + + const isApplicableLabel = + mapped.isApplicable === true + ? 'Yes' + : mapped.isApplicable === false + ? 'No' + : 'N/A'; + + const justification = + typeof mapped.justification === 'string' && mapped.justification.trim() + ? mapped.justification + : question.answer || 'No justification provided'; + + const title = `${i + 1}. ${mapped.title || question.text || 'Untitled Control'}`; + const closure = mapped.closure + ? `Closure: ${mapped.closure}` + : null; + const objective = mapped.control_objective + ? `Objective: ${mapped.control_objective}` + : null; + const applicability = `Applicable: ${isApplicableLabel}`; + const justificationText = `Justification: ${justification}`; + + const titleLines = pdf.splitTextToSize(title, contentWidth); + const closureLines = closure + ? pdf.splitTextToSize(closure, contentWidth) + : []; + const objectiveLines = objective + ? pdf.splitTextToSize(objective, contentWidth) + : []; + const applicabilityLines = pdf.splitTextToSize(applicability, contentWidth); + const justificationLines = pdf.splitTextToSize( + justificationText, + contentWidth, + ); + writeLines(titleLines, 'bold'); + writeLines(closureLines); + writeLines(objectiveLines); + writeLines(applicabilityLines); + writeLines(justificationLines); + ensureSpace(lineHeight * 0.5); + y += lineHeight * 0.5; + } + + return Buffer.from(pdf.output('arraybuffer')); +} + +function sanitizeFrameworkName(frameworkName: string): string { + return (frameworkName || 'soa') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); +} + diff --git a/apps/api/src/soa/utils/soa-storage.ts b/apps/api/src/soa/utils/soa-storage.ts index f36fa621d..e1e64309c 100644 --- a/apps/api/src/soa/utils/soa-storage.ts +++ b/apps/api/src/soa/utils/soa-storage.ts @@ -131,6 +131,7 @@ export async function updateDocumentAfterAutoFill( completedAt: answeredCount === totalQuestions ? new Date() : null, approverId: null, approvedAt: null, + declinedAt: null, }, }); } @@ -174,6 +175,7 @@ export async function updateDocumentAnsweredCount( // Clear approval when answers are edited approverId: null, approvedAt: null, + declinedAt: null, }, }); } diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx index ee969a944..b617918b7 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx @@ -18,10 +18,24 @@ import Link from 'next/link'; import { useMemo } from 'react'; import useSWR from 'swr'; import { evidenceFormDefinitionList, meetingSubTypeValues } from '../forms'; +import { SOAOverviewCard } from './SOAOverviewCard'; type FormStatuses = Record; +type FrameworkListResponse = { + data: Array<{ + id: string; + frameworkId: string; + framework: { + id: string; + name: string; + description: string | null; + visible: boolean; + }; + }>; +}; const SIX_MONTHS_MS = 6 * 30 * 24 * 60 * 60 * 1000; +const ISO27001_NAMES = ['ISO 27001', 'iso27001', 'ISO27001']; const MEETING_SUB_TYPES = meetingSubTypeValues; const MEETING_ALL_TYPES = new Set([...MEETING_SUB_TYPES, 'meeting']); @@ -106,6 +120,16 @@ export function CompanyOverviewCards({ organizationId }: { organizationId: strin ); const { data: findingsResponse } = useOrganizationFindings(); + const { data: frameworksResponse } = useSWR( + ['/v1/frameworks', organizationId] as const, + async ([endpoint]: readonly [string, string]) => { + const response = await api.get(endpoint); + if (response.error || !response.data) { + throw new Error(response.error ?? 'Failed to load frameworks'); + } + return response.data; + }, + ); const activeIssueCounts = useMemo(() => { const counts: Record = {}; @@ -141,8 +165,24 @@ export function CompanyOverviewCards({ organizationId }: { organizationId: strin return map; }, [visibleForms]); + const iso27001Framework = useMemo(() => { + const frameworks = frameworksResponse?.data ?? []; + return frameworks.find( + (frameworkInstance) => + !!frameworkInstance.framework?.name && + ISO27001_NAMES.includes(frameworkInstance.framework.name), + ); + }, [frameworksResponse]); + const iso27001FrameworkId = iso27001Framework?.frameworkId ?? null; + return ( + {iso27001FrameworkId && ( + + )} {Array.from(categories.entries()).map(([category, forms]) => (
diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx new file mode 100644 index 000000000..62aa66464 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx @@ -0,0 +1,156 @@ +import { + Badge, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Text, +} from '@trycompai/design-system'; +import { api } from '@/lib/api-client'; +import Link from 'next/link'; +import { useMemo } from 'react'; +import useSWR from 'swr'; + +const STATEMENT_OF_APPLICABILITY_FORM = { + type: 'statement-of-applicability', + title: 'Statement of Applicability', + description: + "Auto-complete Statement of Applicability for ISO 27001. Generate answers based on your organization's policies and documentation.", +} as const; + +interface SOAOverviewCardProps { + organizationId: string; + iso27001FrameworkId: string; +} + +type SOASetupResponse = { + success: boolean; + configuration: Record | null; + document: { + status?: string | null; + approvedAt?: string | Date | null; + approverId?: string | null; + declinedAt?: string | Date | null; + } | null; +}; + +type SOAApprovalStatus = + | 'Approved' + | 'Declined' + | 'Pending' + | 'Not approved' + | 'Loading' + | 'Unavailable'; + +function SOAApprovalStatusBadge({ status }: { status: SOAApprovalStatus }) { + const statusConfig: Record< + SOAApprovalStatus, + { label: SOAApprovalStatus; className: string } + > = { + Loading: { + label: 'Loading', + className: + 'bg-slate-100 text-slate-700 dark:bg-slate-950/30 dark:text-slate-300', + }, + Unavailable: { + label: 'Unavailable', + className: + 'bg-slate-100 text-slate-700 dark:bg-slate-950/30 dark:text-slate-300', + }, + Approved: { + label: 'Approved', + className: + 'bg-green-100 text-green-800 dark:bg-green-950/30 dark:text-green-400', + }, + Pending: { + label: 'Pending', + className: + 'bg-amber-100 text-amber-800 dark:bg-amber-950/30 dark:text-amber-400', + }, + Declined: { + label: 'Declined', + className: 'bg-red-100 text-red-800 dark:bg-red-950/30 dark:text-red-400', + }, + 'Not approved': { + label: 'Not approved', + className: + 'bg-slate-100 text-slate-800 dark:bg-slate-950/30 dark:text-slate-400', + }, + }; + + const { label, className } = statusConfig[status]; + return ( + + {label} + + ); +} + +export function SOAOverviewCard({ + organizationId, + iso27001FrameworkId, +}: SOAOverviewCardProps) { + const form = STATEMENT_OF_APPLICABILITY_FORM; + const { data: soaSetupResponse, error: soaSetupError, isLoading: isLoadingSOASetup } = + useSWR( + ['/v1/soa/ensure-setup', organizationId, iso27001FrameworkId], + async ([endpoint, orgId, frameworkId]: readonly [string, string, string]) => { + const response = await api.post(endpoint, { + organizationId: orgId, + frameworkId, + }); + if (response.error || !response.data) { + throw new Error(response.error ?? 'Failed to load SOA status'); + } + return response.data; + }, + { + revalidateOnFocus: true, + }, + ); + + const document = soaSetupResponse?.document; + const approvalStatus = useMemo(() => { + if (isLoadingSOASetup) return 'Loading'; + if (soaSetupError || !soaSetupResponse?.success) return 'Unavailable'; + if (!document) return 'Not approved'; + if (document.approvedAt) return 'Approved'; + if (document.declinedAt) return 'Declined'; + if ( + document.status === 'needs_review' || + !!document.approverId + ) { + return 'Pending'; + } + return 'Not approved'; + }, [document, isLoadingSOASetup, soaSetupError, soaSetupResponse?.success]); + + return ( +
+
+ + {form.title} + + 1 +
+
+ + + + {form.title} +
+ {form.description} +
+
+ + + +
+ +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/ApplicableSwatch.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/ApplicableSwatch.tsx similarity index 100% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/ApplicableSwatch.tsx rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/ApplicableSwatch.tsx diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/CreateSOADocument.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/CreateSOADocument.test.tsx similarity index 98% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/CreateSOADocument.test.tsx rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/CreateSOADocument.test.tsx index 5260abbd6..2c8756fc5 100644 --- a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/CreateSOADocument.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/CreateSOADocument.test.tsx @@ -18,7 +18,7 @@ vi.mock('@/hooks/use-permissions', () => ({ // Mock createSOADocument const mockCreateSOADocument = vi.fn(); -vi.mock('../../hooks/useSOADocument', () => ({ +vi.mock('../hooks/useSOADocument', () => ({ createSOADocument: (...args: any[]) => mockCreateSOADocument(...args), })); diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/CreateSOADocument.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/CreateSOADocument.tsx similarity index 93% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/CreateSOADocument.tsx rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/CreateSOADocument.tsx index 3bb979c5e..bac605459 100644 --- a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/CreateSOADocument.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/CreateSOADocument.tsx @@ -7,7 +7,7 @@ import { Plus, Loader2 } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; import { toast } from 'sonner'; -import { createSOADocument } from '../../hooks/useSOADocument'; +import { createSOADocument } from '../hooks/useSOADocument'; interface CreateSOADocumentProps { frameworkId: string; @@ -31,7 +31,7 @@ export function CreateSOADocument({ try { const result = await createSOADocument({ frameworkId, organizationId }); toast.success('SOA document created successfully'); - router.push(`/${organizationId}/questionnaire/soa/${result.id}`); + router.push(`/${organizationId}/documents/statement-of-applicability/${result.id}`); } catch (error) { toast.error( error instanceof Error ? error.message : 'An error occurred while creating the SOA document', diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/EditableSOAFields.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.tsx similarity index 99% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/EditableSOAFields.tsx rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.tsx index 7a081d629..aa2dc5d14 100644 --- a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/EditableSOAFields.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.tsx @@ -20,7 +20,7 @@ import { } from '@trycompai/ui/dialog'; import { X, Loader2, Edit2 } from 'lucide-react'; import { toast } from 'sonner'; -import { useSOADocument } from '../../hooks/useSOADocument'; +import { useSOADocument } from '../hooks/useSOADocument'; import { ApplicableReadOnlyDisplay, ApplicableSwatchRow } from './ApplicableSwatch'; import type { SOAFieldSavePayload } from './soa-field-types'; diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOADocumentInfo.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOADocumentInfo.tsx similarity index 94% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOADocumentInfo.tsx rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOADocumentInfo.tsx index 0dbe4dd83..35ca9848c 100644 --- a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOADocumentInfo.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOADocumentInfo.tsx @@ -42,9 +42,9 @@ export function SOADocumentInfo({ const approvalStatusText = document.approvedAt ? `Approved on ${new Date(document.approvedAt).toLocaleDateString()}` - : document.status === 'needs_review' && document.declinedAt + : document.declinedAt ? `Declined on ${new Date(document.declinedAt).toLocaleDateString()}` - : approver + : document.status === 'needs_review' ? 'Pending approval' : 'Not approved'; @@ -93,13 +93,16 @@ export function SOADocumentInfo({ )} - {approver && !document.approvedAt && document.status !== 'needs_review' && ( + {approver && + !document.approvedAt && + !document.declinedAt && + document.status === 'needs_review' && ( <>
)} - {document.status === 'needs_review' && document.declinedAt && ( + {document.declinedAt && ( <>
m.id === derivedApproverId) ?? approver - : approver; + : null; const derivedCanCurrentUserApprove = derivedIsPendingApproval && derivedApproverId === currentMemberId; const columns = configuration.columns as SOAColumn[]; @@ -105,19 +107,28 @@ export function SOAFrameworkTable({ ); }); - // Update answersMap when document changes + // Update answersMap when the live document changes useEffect(() => { + if (!Array.isArray(resolvedDocument?.answers)) { + return; + } setAnswersMap( new Map( - (document?.answers || []).map((answer: { questionId: string; answer: string | null; answerVersion: number }) => [ + resolvedDocument.answers.map((answer: { questionId: string; answer: string | null; answerVersion: number }) => [ answer.questionId, { answer: answer.answer, answerVersion: answer.answerVersion }, ]) ) ); - }, [document?.answers]); + }, [resolvedDocument?.answers]); const handleAnswerUpdate = (questionId: string, payload: SOAFieldSavePayload) => { + const previousIsApplicable = + answersMap.get(questionId)?.savedIsApplicable ?? + processedResults.get(questionId)?.isApplicable ?? + questions.find((q) => q.id === questionId)?.columnMapping.isApplicable ?? + null; + setAnswersMap((prev) => { const newMap = new Map(prev); const existing = newMap.get(questionId); @@ -128,6 +139,40 @@ export function SOAFrameworkTable({ }); return newMap; }); + + void mutateSOADocument((current) => { + if (!current) return current; + + const totalQuestions = current.totalQuestions as number | undefined; + const currentAnsweredQuestions = current.answeredQuestions as number | undefined; + + if ( + typeof totalQuestions !== 'number' || + typeof currentAnsweredQuestions !== 'number' + ) { + return current; + } + + const nextIsApplicable = payload.isApplicable ?? null; + let answeredQuestions = currentAnsweredQuestions; + + if (previousIsApplicable === null && nextIsApplicable !== null) { + answeredQuestions += 1; + } else if (previousIsApplicable !== null && nextIsApplicable === null) { + answeredQuestions -= 1; + } + + answeredQuestions = Math.max(0, Math.min(totalQuestions, answeredQuestions)); + + return { + ...current, + answeredQuestions, + status: answeredQuestions === totalQuestions ? 'completed' : 'in_progress', + approverId: null, + approvedAt: null, + declinedAt: null, + }; + }, false); }; const [isSubmitApprovalDialogOpen, setIsSubmitApprovalDialogOpen] = useState(false); @@ -151,9 +196,32 @@ export function SOAFrameworkTable({ questions: questionsForHook, documentId: document?.id || '', organizationId, - onUpdate: () => { - // Revalidate SWR cache instead of full page reload - void mutateSOADocument(); + onUpdate: ({ total, answered } = {}) => { + // Keep SOA info card in sync immediately after auto-fill completion. + void mutateSOADocument((current) => { + if (!current) return current; + const totalQuestions = + typeof total === 'number' ? total : (current.totalQuestions as number | undefined); + const answeredQuestions = + typeof answered === 'number' + ? answered + : (current.answeredQuestions as number | undefined); + + if (typeof totalQuestions !== 'number' || typeof answeredQuestions !== 'number') { + return current; + } + + return { + ...current, + totalQuestions, + answeredQuestions, + status: + answeredQuestions === totalQuestions ? 'completed' : 'in_progress', + approverId: null, + approvedAt: null, + declinedAt: null, + }; + }, false); }, }); @@ -190,10 +258,8 @@ export function SOAFrameworkTable({ ); } - // The document comes from the Prisma SOADocument type which has all necessary fields. - // We cast to the SOADocumentInfo's expected type for the info panel. - const docForInfo = document as unknown as SOADocumentInfoDocument; - const approverId = (document as Record).approverId as string | null | undefined; + // Use the resolved SWR document so approval status updates instantly without page refresh. + const docForInfo = resolvedDocument as unknown as SOADocumentInfoDocument; const handleAutoFill = async () => { if (!document) return; @@ -278,7 +344,7 @@ export function SOAFrameworkTable({ isFullyRemote={isFullyRemote} isExpanded={isExpanded} onToggleExpand={() => setIsExpanded(!isExpanded)} - documentId={document.id} + documentId={resolvedDocument?.id ?? document.id} isPendingApproval={derivedIsPendingApproval} organizationId={organizationId} onAnswerUpdate={handleAnswerUpdate} diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOAFrameworkTabs.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTabs.tsx similarity index 99% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOAFrameworkTabs.tsx rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTabs.tsx index 2cdf8814c..bb9071a83 100644 --- a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOAFrameworkTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTabs.tsx @@ -5,7 +5,7 @@ import { useState, useTransition } from 'react'; import { toast } from 'sonner'; import { Loader2, ShieldCheck } from 'lucide-react'; import { SOAFrameworkTable } from './SOAFrameworkTable'; -import { ensureSOASetup } from '../../hooks/useSOADocument'; +import { ensureSOASetup } from '../hooks/useSOADocument'; import type { FrameworkWithSOAData } from '../types'; interface SOAFrameworkTabsProps { diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOAMobileRow.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAMobileRow.tsx similarity index 100% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOAMobileRow.tsx rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAMobileRow.tsx diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOAPendingApprovalAlert.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAPendingApprovalAlert.tsx similarity index 100% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOAPendingApprovalAlert.tsx rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAPendingApprovalAlert.tsx diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOATable.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATable.tsx similarity index 100% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOATable.tsx rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATable.tsx diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOATableRow.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATableRow.tsx similarity index 100% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOATableRow.tsx rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATableRow.tsx diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/StatementOfApplicabilitySection.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/StatementOfApplicabilitySection.tsx new file mode 100644 index 000000000..7c496e409 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/StatementOfApplicabilitySection.tsx @@ -0,0 +1,140 @@ +'use client'; + +import { Button, PageHeader, Text } from '@trycompai/design-system'; +import { Download } from '@trycompai/design-system/icons'; +import { useSOADocument } from '../hooks/useSOADocument'; +import { SOAFrameworkTable } from './SOAFrameworkTable'; + +type SOAFrameworkTableProps = Parameters[0]; + +export interface SOAData { + framework: SOAFrameworkTableProps['framework']; + configuration: SOAFrameworkTableProps['configuration']; + document: SOAFrameworkTableProps['document']; + isFullyRemote: boolean; + canApprove: boolean; + approver: SOAFrameworkTableProps['approver']; + isPendingApproval: boolean; + canCurrentUserApprove: boolean; + currentMemberId: string | null; + ownerAdminMembers: SOAFrameworkTableProps['ownerAdminMembers']; +} + +interface StatementOfApplicabilitySectionProps { + organizationId: string; + soaData?: SOAData | null; + soaError?: string | null; +} + +function SectionHeader({ + onExport, + isExporting, + canExport, +}: { + onExport: () => void; + isExporting: boolean; + canExport: boolean; +}) { + return ( + <> + } + onClick={onExport} + disabled={!canExport || isExporting} + > + {isExporting ? 'Exporting...' : 'Export PDF'} + + } + /> +
+ + Auto-complete Statement of Applicability for ISO 27001. Generate answers based on your + organization's policies and documentation. + +
+ + ); +} + +export function StatementOfApplicabilitySection({ + organizationId, + soaData, + soaError, +}: StatementOfApplicabilitySectionProps) { + const soaDocumentId = + ((soaData?.document as { id?: string | null } | null | undefined)?.id ?? + null); + const { handleExport, isExporting } = useSOADocument({ + documentId: soaDocumentId, + organizationId, + fallbackData: + (soaData?.document as Parameters[0]['fallbackData']) ?? + null, + }); + + if (soaError) { + return ( +
+ { + void handleExport('pdf'); + }} + isExporting={isExporting} + canExport={false} + /> +
+ {soaError} +
+
+ ); + } + + if (soaData) { + return ( +
+ { + void handleExport('pdf'); + }} + isExporting={isExporting} + canExport={!!soaDocumentId} + /> + [0]['approver']} + isPendingApproval={soaData.isPendingApproval} + canCurrentUserApprove={soaData.canCurrentUserApprove} + currentMemberId={soaData.currentMemberId} + ownerAdminMembers={ + soaData.ownerAdminMembers as Parameters[0]['ownerAdminMembers'] + } + /> +
+ ); + } + + return ( +
+ { + void handleExport('pdf'); + }} + isExporting={isExporting} + canExport={false} + /> +
+
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SubmitApprovalDialog.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SubmitApprovalDialog.tsx similarity index 100% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SubmitApprovalDialog.tsx rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SubmitApprovalDialog.tsx diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/index.ts b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/index.ts new file mode 100644 index 000000000..56e5d52b1 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/index.ts @@ -0,0 +1 @@ +export { StatementOfApplicabilitySection, type SOAData } from './StatementOfApplicabilitySection'; diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/soa-field-types.ts b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-field-types.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/soa-field-types.ts rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-field-types.ts diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/hooks/useSOAAutoFill.ts b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOAAutoFill.ts similarity index 94% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/hooks/useSOAAutoFill.ts rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOAAutoFill.ts index 4359b844a..63efa6961 100644 --- a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/hooks/useSOAAutoFill.ts +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOAAutoFill.ts @@ -17,7 +17,7 @@ interface UseSOAAutoFillProps { }>; documentId: string; organizationId: string; - onUpdate: () => void; + onUpdate: (payload?: { total?: number; answered?: number }) => void; } export function useSOAAutoFill({ questions, documentId, organizationId, onUpdate }: UseSOAAutoFillProps) { @@ -115,7 +115,10 @@ export function useSOAAutoFill({ questions, documentId, organizationId, onUpdate // All questions completed toast.success(`Auto-filled ${data.answered} questions`); setIsAutoFilling(false); - onUpdate(); + onUpdate({ + total: typeof data.total === 'number' ? data.total : undefined, + answered: typeof data.answered === 'number' ? data.answered : undefined, + }); } else if (data.type === 'error') { toast.error(data.error || 'Failed to auto-fill SOA'); setIsAutoFilling(false); diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/hooks/useSOADocument.ts b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOADocument.ts similarity index 64% rename from apps/app/src/app/(app)/[orgId]/questionnaire/hooks/useSOADocument.ts rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOADocument.ts index 790d5ecc6..91fa1d7e6 100644 --- a/apps/app/src/app/(app)/[orgId]/questionnaire/hooks/useSOADocument.ts +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOADocument.ts @@ -1,8 +1,10 @@ 'use client'; import useSWR from 'swr'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { api } from '@/lib/api-client'; +import { env } from '@/env.mjs'; +import { toast } from 'sonner'; interface SOADocumentData { id: string; @@ -28,6 +30,7 @@ function buildKey(documentId: string | null) { } export function useSOADocument({ documentId, organizationId, fallbackData }: UseSOADocumentOptions) { + const [isExporting, setIsExporting] = useState(false); const { data, error, isLoading, mutate } = useSWR( buildKey(documentId), null, @@ -67,14 +70,13 @@ export function useSOADocument({ documentId, organizationId, fallbackData }: Use if (response.error) throw new Error(response.error); if (!response.data?.success) throw new Error('Failed to save answer'); - await mutate(); return true; }; const approve = async (): Promise => { if (!documentId) throw new Error('No document ID'); - const response = await api.post<{ success: boolean; data?: unknown }>( + const response = await api.post<{ success: boolean; data?: SOADocumentData }>( '/v1/soa/approve', { organizationId, documentId }, ); @@ -82,8 +84,8 @@ export function useSOADocument({ documentId, organizationId, fallbackData }: Use if (response.error) throw new Error(response.error || 'Failed to approve SOA document'); if (!response.data?.success) throw new Error('Failed to approve SOA document'); - if (data) { - await mutate({ ...data, status: 'approved', approvedAt: new Date().toISOString() }, false); + if (response.data?.data) { + await mutate(response.data.data, false); } return true; }; @@ -91,7 +93,7 @@ export function useSOADocument({ documentId, organizationId, fallbackData }: Use const decline = async (): Promise => { if (!documentId) throw new Error('No document ID'); - const response = await api.post<{ success: boolean; data?: unknown }>( + const response = await api.post<{ success: boolean; data?: SOADocumentData }>( '/v1/soa/decline', { organizationId, documentId }, ); @@ -99,8 +101,8 @@ export function useSOADocument({ documentId, organizationId, fallbackData }: Use if (response.error) throw new Error(response.error || 'Failed to decline SOA document'); if (!response.data?.success) throw new Error('Failed to decline SOA document'); - if (data) { - await mutate({ ...data, status: 'needs_review', declinedAt: new Date().toISOString() }, false); + if (response.data?.data) { + await mutate(response.data.data, false); } return true; }; @@ -108,7 +110,7 @@ export function useSOADocument({ documentId, organizationId, fallbackData }: Use const submitForApproval = async (approverId: string): Promise => { if (!documentId) throw new Error('No document ID'); - const response = await api.post<{ success: boolean; data?: unknown }>( + const response = await api.post<{ success: boolean; data?: SOADocumentData }>( '/v1/soa/submit-for-approval', { organizationId, documentId, approverId }, ); @@ -116,22 +118,82 @@ export function useSOADocument({ documentId, organizationId, fallbackData }: Use if (response.error) throw new Error(response.error || 'Failed to submit for approval'); if (!response.data?.success) throw new Error('Failed to submit for approval'); - // Optimistically update cached document status - if (data) { - await mutate({ ...data, status: 'pending_approval', approverId }, false); + if (response.data?.data) { + await mutate(response.data.data, false); } return true; }; + const handleExport = async (format: 'pdf' = 'pdf'): Promise => { + if (!documentId) { + toast.error('No SOA document to export'); + return; + } + + setIsExporting(true); + try { + const response = await fetch( + `${env.NEXT_PUBLIC_API_URL || 'http://localhost:3333'}/v1/soa/export`, + { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + documentId, + organizationId, + format, + }), + }, + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || 'Failed to export SOA document'); + } + + const contentDisposition = response.headers.get('Content-Disposition'); + let filename = `statement-of-applicability.${format}`; + if (contentDisposition) { + const match = contentDisposition.match(/filename="(.+)"/); + if (match) { + filename = match[1]; + } + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + toast.success(`Exported as ${filename}`); + } catch (error) { + console.error('SOA export error:', error); + toast.error( + error instanceof Error ? error.message : 'Failed to export SOA document', + ); + } finally { + setIsExporting(false); + } + }; + return { document: data ?? null, error, isLoading, + isExporting, mutate, saveAnswer, approve, decline, submitForApproval, + handleExport, }; } diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/page.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/page.tsx new file mode 100644 index 000000000..a9a9ed12a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/page.tsx @@ -0,0 +1,190 @@ +import { serverApi } from '@/lib/api-server'; +import { parseRolesString } from '@/lib/permissions'; +import { auth } from '@/utils/auth'; +import { Breadcrumb, PageLayout } from '@trycompai/design-system'; +import { headers } from 'next/headers'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { StatementOfApplicabilitySection, type SOAData } from './components'; + +const ISO27001_NAMES = ['ISO 27001', 'iso27001', 'ISO27001']; + +interface FrameworkApiResponse { + data: Array<{ + id: string; + frameworkId: string; + framework: { + id: string; + name: string; + description: string | null; + visible: boolean; + }; + }>; +} + +interface PeopleApiResponse { + data: Array<{ + id: string; + role: string; + userId: string; + deactivated: boolean; + user: { + id: string; + name: string | null; + email: string; + image: string | null; + }; + }>; +} + +interface ContextApiResponse { + data: Array<{ + id: string; + question: string; + answer: string | null; + tags: string[]; + createdAt: string; + updatedAt: string; + }>; +} + +export default async function StatementOfApplicabilityPage({ + params, +}: { + params: Promise<{ orgId: string }>; +}) { + const { orgId } = await params; + + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user?.id || !session?.session?.activeOrganizationId) { + return notFound(); + } + + const organizationId = session.session.activeOrganizationId; + + const [frameworksResult, peopleResult, contextResult] = await Promise.all([ + serverApi.get('/v1/frameworks'), + serverApi.get('/v1/people'), + serverApi.get('/v1/context'), + ]); + + let soaData: SOAData | null = null; + let soaError: string | null = null; + + if (frameworksResult.error) { + soaError = 'Failed to load frameworks. Please try again later.'; + } + + const frameworks = frameworksResult.data?.data ?? []; + const isoFrameworkInstance = frameworks.find( + (fi) => fi.framework?.name && ISO27001_NAMES.includes(fi.framework.name), + ); + + const people = peopleResult.data?.data ?? []; + const contextEntries = contextResult.data?.data ?? []; + + if (!soaError && isoFrameworkInstance) { + try { + const { frameworkId, framework } = isoFrameworkInstance; + + const setupResult = await serverApi.post<{ + success: boolean; + error?: string; + configuration: Record | null; + document: Record | null; + }>('/v1/soa/ensure-setup', { frameworkId, organizationId }); + + const setupData = setupResult.data; + if (!setupData?.success) { + soaError = setupData?.error || 'Failed to setup SOA. Please try again later.'; + } + + const configuration = setupData?.configuration; + const document = setupData?.document; + + if (!soaError && configuration && document) { + let approver = null; + const approverId = document.approverId as string | undefined; + if (approverId) { + approver = people.find((p) => p.id === approverId) ?? null; + } + + const currentMember = + people.find((p) => p.userId === session.user.id && !p.deactivated) ?? null; + + const currentMemberRoles = parseRolesString(currentMember?.role); + const canApprove = currentMemberRoles.some( + (role) => role === 'owner' || role === 'admin', + ); + + const isPendingApproval = document.status === 'needs_review'; + const canCurrentUserApprove = isPendingApproval && approverId === currentMember?.id; + + const ownerAdminMembers = people + .filter( + (p) => + !p.deactivated && + parseRolesString(p.role).some( + (role) => role === 'owner' || role === 'admin', + ), + ) + .sort((a, b) => (a.user?.name ?? '').localeCompare(b.user?.name ?? '')); + + let isFullyRemote = false; + const teamWorkContext = contextEntries.find((c) => + c.question?.toLowerCase().includes('how does your team work'), + ); + if (teamWorkContext?.answer) { + const answerLower = teamWorkContext.answer.toLowerCase(); + isFullyRemote = + answerLower.includes('fully remote') || answerLower.includes('fully-remote'); + } + + soaData = { + framework, + configuration, + document, + isFullyRemote, + canApprove, + approver: approver ? { ...approver, user: approver.user } : null, + isPendingApproval, + canCurrentUserApprove, + currentMemberId: currentMember?.id || null, + ownerAdminMembers, + } as SOAData; + } else if (!soaError) { + soaError = + 'SOA setup did not return required configuration data. Please try again later.'; + } + } catch (error) { + console.error('Failed to setup SOA:', error); + soaError = 'Failed to setup SOA. Please try again later.'; + } + } else if (!soaError) { + soaError = + 'ISO 27001 framework not found. Please add ISO 27001 framework to your organization to get started.'; + } + + return ( + + }, + }, + { label: 'Statement of Applicability', isCurrent: true }, + ]} + /> + + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/types.ts b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/types.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/types.ts rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/types.ts diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/components/QuestionnaireTabs.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/components/QuestionnaireTabs.tsx index 48af923c8..189512d6b 100644 --- a/apps/app/src/app/(app)/[orgId]/questionnaire/components/QuestionnaireTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/components/QuestionnaireTabs.tsx @@ -8,14 +8,12 @@ import { TabsContent, TabsList, TabsTrigger, - Text, } from '@trycompai/design-system'; import { AdditionalDocumentsSection } from '../knowledge-base/additional-documents/components'; import { KnowledgeBaseHeader } from '../knowledge-base/components/KnowledgeBaseHeader'; import { ContextSection } from '../knowledge-base/context/components'; import { ManualAnswersSection } from '../knowledge-base/manual-answers/components'; import { PublishedPoliciesSection } from '../knowledge-base/published-policies/components'; -import { SOAFrameworkTable } from '../soa/components/SOAFrameworkTable'; import { QuestionnaireOverview } from '../start_page/components'; import type { ContextEntry, @@ -25,31 +23,11 @@ import type { QuestionnaireListItem, } from './types'; -// Use type inference from SOAFrameworkTable props -type SOAFrameworkTableProps = Parameters[0]; - -interface SOAData { - framework: SOAFrameworkTableProps['framework']; - configuration: SOAFrameworkTableProps['configuration']; - document: SOAFrameworkTableProps['document']; - isFullyRemote: boolean; - canApprove: boolean; - approver: SOAFrameworkTableProps['approver']; - isPendingApproval: boolean; - canCurrentUserApprove: boolean; - currentMemberId: string | null; - ownerAdminMembers: SOAFrameworkTableProps['ownerAdminMembers']; -} - interface QuestionnaireTabsProps { organizationId: string; // Questionnaires tab questionnaires: QuestionnaireListItem[]; hasPublishedPolicies: boolean; - // SOA tab (conditional) - showSOATab: boolean; - soaData?: SOAData | null; - soaError?: string | null; // Knowledge Base tab policies: PublishedPolicy[]; contextEntries: ContextEntry[]; @@ -61,9 +39,6 @@ export function QuestionnaireTabs({ organizationId, questionnaires, hasPublishedPolicies, - showSOATab, - soaData, - soaError, policies, contextEntries, manualAnswers, @@ -116,7 +91,6 @@ export function QuestionnaireTabs({ tabs={ Security Questionnaire - {showSOATab && Statement of Applicability} Knowledge Base } @@ -128,72 +102,6 @@ export function QuestionnaireTabs({ - {/* SOA Tab (conditional) */} - {showSOATab && ( - - {soaError ? ( -
-
-

- Statement of Applicability -

- - Auto-complete Statement of Applicability for ISO 27001. Generate answers based - on your organization's policies and documentation. - -
-
- {soaError} -
-
- ) : soaData ? ( -
-
-

- Statement of Applicability -

- - Auto-complete Statement of Applicability for ISO 27001. Generate answers based - on your organization's policies and documentation. - -
- [0]['approver']} - isPendingApproval={soaData.isPendingApproval} - canCurrentUserApprove={soaData.canCurrentUserApprove} - currentMemberId={soaData.currentMemberId} - ownerAdminMembers={ - soaData.ownerAdminMembers as Parameters< - typeof SOAFrameworkTable - >[0]['ownerAdminMembers'] - } - /> -
- ) : ( -
-
-

- Statement of Applicability -

- - Auto-complete Statement of Applicability for ISO 27001. Generate answers based - on your organization's policies and documentation. - -
-
-
-
-
- )} - - )} - {/* Knowledge Base Tab */} diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/page.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/page.tsx index 5fd0b08b2..59c00c84d 100644 --- a/apps/app/src/app/(app)/[orgId]/questionnaire/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/page.tsx @@ -5,8 +5,6 @@ import { headers } from 'next/headers'; import { notFound } from 'next/navigation'; import { QuestionnaireTabs } from './components/QuestionnaireTabs'; -const ISO27001_NAMES = ['ISO 27001', 'iso27001', 'ISO27001']; - interface PolicyApiResponse { data: Array<{ id: string; @@ -40,34 +38,6 @@ interface QuestionnaireApiResponse { }>; } -interface FrameworkApiResponse { - data: Array<{ - id: string; - frameworkId: string; - framework: { - id: string; - name: string; - description: string | null; - visible: boolean; - }; - }>; -} - -interface PeopleApiResponse { - data: Array<{ - id: string; - role: string; - userId: string; - deactivated: boolean; - user: { - id: string; - name: string | null; - email: string; - image: string | null; - }; - }>; -} - interface ContextApiResponse { data: Array<{ id: string; @@ -101,13 +71,7 @@ interface KBDocumentApiResponse { updatedAt: string; } -export default async function SecurityQuestionnairePage({ - params, -}: { - params: Promise<{ orgId: string }>; -}) { - const { orgId } = await params; - +export default async function SecurityQuestionnairePage() { const session = await auth.api.getSession({ headers: await headers(), }); @@ -129,16 +93,12 @@ export default async function SecurityQuestionnairePage({ const [ policiesResult, questionnairesResult, - frameworksResult, - peopleResult, contextResult, manualAnswersResult, kbDocumentsResult, ] = await Promise.all([ serverApi.get('/v1/policies'), serverApi.get('/v1/questionnaire'), - serverApi.get('/v1/frameworks'), - serverApi.get('/v1/people'), serverApi.get('/v1/context'), serverApi.get('/v1/knowledge-base/manual-answers'), serverApi.get('/v1/knowledge-base/documents'), @@ -154,18 +114,6 @@ export default async function SecurityQuestionnairePage({ // Questionnaires list const questionnaires = questionnairesResult.data?.data ?? []; - // Check ISO 27001 framework - const frameworks = frameworksResult.data?.data ?? []; - const isoFrameworkInstance = frameworks.find((fi) => { - return fi.framework?.name && ISO27001_NAMES.includes(fi.framework.name); - }); - - const hasISO27001 = !!isoFrameworkInstance; - const showSOATab = hasISO27001; - - // People data - const people = peopleResult.data?.data ?? []; - // Context data const contextEntries = contextResult.data?.data ?? []; @@ -177,101 +125,11 @@ export default async function SecurityQuestionnairePage({ ? kbDocumentsResult.data : []; - // Build SOA data if needed - let soaData = null; - let soaError: string | null = null; - - if (showSOATab && isoFrameworkInstance) { - try { - const { frameworkId, framework } = isoFrameworkInstance; - - const setupResult = await serverApi.post<{ - success: boolean; - error?: string; - configuration: Record | null; - document: Record | null; - }>('/v1/soa/ensure-setup', { frameworkId, organizationId }); - - const configuration = setupResult.data?.configuration; - const document = setupResult.data?.document; - - if (configuration && document) { - // Find approver from people list - let approver = null; - const approverId = document.approverId as string | undefined; - if (approverId) { - approver = - people.find((p) => p.id === approverId) ?? null; - } - - // Find current member - const currentMember = - people.find( - (p) => p.userId === session.user.id && !p.deactivated, - ) ?? null; - - const canApprove = currentMember - ? currentMember.role.includes('owner') || - currentMember.role.includes('admin') - : false; - - const isPendingApproval = document.status === 'needs_review'; - const canCurrentUserApprove = - isPendingApproval && approverId === currentMember?.id; - - // Filter owner/admin members - const ownerAdminMembers = people - .filter( - (p) => - !p.deactivated && - (p.role.includes('owner') || p.role.includes('admin')), - ) - .sort((a, b) => - (a.user?.name ?? '').localeCompare(b.user?.name ?? ''), - ); - - // Check if fully remote from context - let isFullyRemote = false; - const teamWorkContext = contextEntries.find((c) => - c.question?.toLowerCase().includes('how does your team work'), - ); - if (teamWorkContext?.answer) { - const answerLower = teamWorkContext.answer.toLowerCase(); - isFullyRemote = - answerLower.includes('fully remote') || - answerLower.includes('fully-remote'); - } - - soaData = { - framework, - configuration, - document, - isFullyRemote, - canApprove, - approver: approver ? { ...approver, user: approver.user } : null, - isPendingApproval, - canCurrentUserApprove, - currentMemberId: currentMember?.id || null, - ownerAdminMembers, - }; - } - } catch (error) { - console.error('Failed to setup SOA:', error); - soaError = 'Failed to setup SOA. Please try again later.'; - } - } else if (showSOATab && !isoFrameworkInstance) { - soaError = - 'ISO 27001 framework not found. Please add ISO 27001 framework to your organization to get started.'; - } - return ( [0]['soaData']} - soaError={soaError} policies={publishedPolicies} contextEntries={contextEntries} manualAnswers={manualAnswers} diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/page.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/page.tsx index f16119300..97478811c 100644 --- a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/page.tsx @@ -4,8 +4,9 @@ interface SOAPageProps { params: Promise<{ orgId: string }>; } -// Redirect to main questionnaire page - SOA is now a tab +// Redirect to the Statement of Applicability page under Documents. +// SOA was previously a tab on the Questionnaires page; it now lives under Documents. export default async function SOAPage({ params }: SOAPageProps) { const { orgId } = await params; - redirect(`/${orgId}/questionnaire`); + redirect(`/${orgId}/documents/statement-of-applicability`); } diff --git a/apps/framework-editor/app/(pages)/controls/document-type-options.ts b/apps/framework-editor/app/(pages)/controls/document-type-options.ts index 332eb364b..32b81c582 100644 --- a/apps/framework-editor/app/(pages)/controls/document-type-options.ts +++ b/apps/framework-editor/app/(pages)/controls/document-type-options.ts @@ -10,6 +10,11 @@ export const DOCUMENT_TYPE_OPTIONS: MultiSelectOption[] = [ { value: 'rbac_matrix', label: 'RBAC Matrix', category: 'Security' }, { value: 'infrastructure_inventory', label: 'Infrastructure Inventory', category: 'Security' }, { value: 'network_diagram', label: 'Network Diagram', category: 'Security' }, + { + value: 'statement_of_applicability', + label: 'Statement of Applicability', + category: 'SOA', + }, { value: 'tabletop_exercise', label: 'Incident Response Tabletop Exercise', category: 'Security' }, { value: 'whistleblower_report', label: 'Whistleblower Report', category: 'People' }, { value: 'employee_performance_evaluation', label: 'Employee Performance Evaluation', category: 'People' }, diff --git a/apps/framework-editor/app/(pages)/documents/DocumentControlsCell.tsx b/apps/framework-editor/app/(pages)/documents/DocumentControlsCell.tsx index e52c35ee1..38b520944 100644 --- a/apps/framework-editor/app/(pages)/documents/DocumentControlsCell.tsx +++ b/apps/framework-editor/app/(pages)/documents/DocumentControlsCell.tsx @@ -24,6 +24,7 @@ export function DocumentControlsCell({ onControlLinked, onControlUnlinked, }: DocumentControlsCellProps) { + const isSOADocument = documentType === 'statement_of_applicability'; const [isExpanded, setIsExpanded] = useState(false); const [isSearching, setIsSearching] = useState(false); const [search, setSearch] = useState(''); @@ -67,6 +68,11 @@ export function DocumentControlsCell({ const handleLink = useCallback( async (control: { id: string; name: string }) => { + if (isSOADocument) { + toast.info('Linking controls is disabled for Statement of Applicability'); + return; + } + try { const current = await apiClient(`/control-template/${control.id}`); const currentTypes: string[] = Array.isArray(current.documentTypes) @@ -86,7 +92,7 @@ export function DocumentControlsCell({ setSearch(''); setIsSearching(false); }, - [documentType, onControlLinked], + [documentType, isSOADocument, onControlLinked], ); const handleUnlink = useCallback( @@ -211,12 +217,18 @@ export function DocumentControlsCell({ )} + {isSOADocument && ( +

+ Linking controls is disabled for Statement of Applicability. +

+ )}
); diff --git a/packages/db/prisma/migrations/20260424134500_add_soa_declined_status_and_timestamp/migration.sql b/packages/db/prisma/migrations/20260424134500_add_soa_declined_status_and_timestamp/migration.sql new file mode 100644 index 000000000..d449df524 --- /dev/null +++ b/packages/db/prisma/migrations/20260424134500_add_soa_declined_status_and_timestamp/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable: Track when SOA document was declined +ALTER TABLE "SOADocument" +ADD COLUMN "declinedAt" TIMESTAMP(3); diff --git a/packages/db/prisma/schema/soa.prisma b/packages/db/prisma/schema/soa.prisma index d8b1de155..bc3c1e9f4 100644 --- a/packages/db/prisma/schema/soa.prisma +++ b/packages/db/prisma/schema/soa.prisma @@ -53,7 +53,7 @@ model SOADocument { isLatest Boolean @default(true) // Whether this is the latest version // Document status - status SOADocumentStatus @default(draft) // draft, in_progress, completed + status SOADocumentStatus @default(draft) // draft, in_progress, needs_review, completed // Document metadata totalQuestions Int @default(0) // Total number of questions in this document @@ -64,6 +64,7 @@ model SOADocument { approverId String? // Member ID who will approve this document (set when submitted for approval) approver Member? @relation("SOADocumentApprover", fields: [approverId], references: [id], onDelete: SetNull, onUpdate: Cascade) approvedAt DateTime? // When document was approved + declinedAt DateTime? // When document was declined // Dates completedAt DateTime? // When document was completed