Skip to content

fix(documents): allow CSV and Excel file uploads for evidence forms#2471

Merged
tofikwest merged 7 commits intomainfrom
worktree-fix+evidence-upload-csv-xlsx
Apr 7, 2026
Merged

fix(documents): allow CSV and Excel file uploads for evidence forms#2471
tofikwest merged 7 commits intomainfrom
worktree-fix+evidence-upload-csv-xlsx

Conversation

@tofikwest
Copy link
Copy Markdown
Contributor

Summary

  • Added CSV, XLSX, and XLS to the accepted file types in the whistleblower-report and tabletop-exercise evidence form definitions
  • Added MIME type mappings for .csv, .xlsx, and .xls in the CompanySubmissionWizard file-type mapper so the dropzone recognizes these extensions
  • Added an optional spreadsheet upload field to the RBAC matrix form so users can upload a CSV/Excel file instead of entering rows manually

Customers reported that evidence uploads only accept PDFs and images. The backend (attachments.service.ts) already allows these file types — only the frontend was blocking them.

Test plan

  • Open a whistleblower-report document and verify the file upload accepts .csv, .xlsx, .xls files
  • Open an RBAC matrix document and verify the new "Or upload a spreadsheet" field appears and accepts .csv, .xlsx, .xls, .pdf
  • Open a tabletop-exercise document and verify the file upload accepts .csv, .xlsx, .xls files
  • Verify penetration-test still only accepts .pdf (unchanged)
  • Verify network-diagram still only accepts visual formats (unchanged)
  • Upload a real .xlsx and .csv file end-to-end and confirm they land in S3 correctly

🤖 Generated with Claude Code

Customers could only upload PDFs and images when submitting evidence.
Added CSV, XLSX, and XLS support to the file type mapper and evidence
form definitions (whistleblower-report, tabletop-exercise). Also added
an optional spreadsheet upload field to the RBAC matrix form so users
can upload their matrix as a file instead of entering rows manually.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@cursor
Copy link
Copy Markdown

cursor bot commented Apr 7, 2026

PR Summary

Medium Risk
Medium risk because it changes client-side file type allowlists and RBAC matrix validation rules, which could inadvertently block submissions or accept unexpected file types if misconfigured.

Overview
Expands evidence form uploads to support spreadsheet formats by adding .csv, .xlsx, and .xls to relevant accept lists and updating CompanySubmissionWizard’s extension→MIME mapping so the dropzone recognizes these files.

Adds an optional matrixFile upload option for the rbac-matrix form and relaxes matrixRows requirements; both frontend matrix cell validation and the Zod submission schema now treat either a filled row set or an uploaded file as sufficient, with row-level validation only applied to non-empty rows.

Reviewed by Cursor Bugbot for commit 77d71bd. Bugbot is set up for automated code reviews on this repo. Configure here.

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
app Ready Ready Preview, Comment Apr 7, 2026 4:29pm
comp-framework-editor Ready Ready Preview, Comment Apr 7, 2026 4:29pm
portal Ready Ready Preview, Comment Apr 7, 2026 4:29pm

Request Review

…apper

- Add matrixFile to rbacMatrixDataSchema with .refine() so the uploaded
  file isn't silently stripped by Zod
- Make matrixRows optional when a companion file is uploaded (both in
  the Zod schema and the validateRequiredMatrixCells guard)
- Add CSV/XLSX/XLS MIME mappings to the step 3 file uploader (four-step
  forms like tabletop-exercise) which was missed in the prior commit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Zod refinement blocked by default empty matrix row
    • I preprocess matrixRows to treat all-empty rows as undefined so row-level validation no longer blocks file-only submissions and the object-level refine can enforce rows-or-file correctly.
  • ✅ Fixed: File upload renders above matrix, contradicting "rows above"
    • I reordered step-2 rendering so matrix fields are displayed before extended/file fields, making the 'rows above' description accurate and the 'Or upload' label logical.

View PR

Or push these changes by commenting:

@cursor push 59b3ddd323
Preview (59b3ddd323)
diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionWizard.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionWizard.tsx
--- a/apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionWizard.tsx
+++ b/apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionWizard.tsx
@@ -618,6 +618,78 @@
                     )}
                   />
                 ))}
+                {matrixFields.map((field) => {
+                  const rows = normalizeMatrixRows(watch(field.key as never));
+                  const rowValues = rows.length > 0 ? rows : [createEmptyMatrixRow(field.columns)];
+                  const matrixError = errors[field.key as keyof typeof errors];
+                  return (
+                    <Field key={field.key}>
+                      <FieldLabel>{field.label}</FieldLabel>
+                      {field.description && (
+                        <Text size="sm" variant="muted">
+                          {field.description}
+                        </Text>
+                      )}
+                      <div className="space-y-3">
+                        {rowValues.map((row, rowIndex) => (
+                          <div
+                            key={`${field.key}-${rowIndex}`}
+                            className="rounded-md border border-border p-3"
+                          >
+                            <div className="mb-3 flex items-center justify-between">
+                              <Text size="sm" weight="medium">
+                                Row {rowIndex + 1}
+                              </Text>
+                              <Button
+                                type="button"
+                                variant="ghost"
+                                onClick={() => removeMatrixRow(field, rowIndex)}
+                                disabled={rowValues.length <= 1}
+                              >
+                                Remove row
+                              </Button>
+                            </div>
+                            <div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
+                              {field.columns.map((column) => (
+                                <Field key={`${field.key}-${rowIndex}-${column.key}`}>
+                                  <FieldLabel htmlFor={`${field.key}-${rowIndex}-${column.key}`}>
+                                    {column.label}
+                                  </FieldLabel>
+                                  {column.description && (
+                                    <Text size="sm" variant="muted">
+                                      {column.description}
+                                    </Text>
+                                  )}
+                                  <Input
+                                    id={`${field.key}-${rowIndex}-${column.key}`}
+                                    value={row[column.key] ?? ''}
+                                    onChange={(event) =>
+                                      updateMatrixCell(
+                                        field,
+                                        rowIndex,
+                                        column.key,
+                                        event.target.value,
+                                      )
+                                    }
+                                    placeholder={column.placeholder}
+                                  />
+                                </Field>
+                              ))}
+                            </div>
+                          </div>
+                        ))}
+                        <Button
+                          type="button"
+                          variant="secondary"
+                          onClick={() => addMatrixRow(field)}
+                        >
+                          {field.addRowLabel ?? 'Add row'}
+                        </Button>
+                      </div>
+                      <FieldError errors={[matrixError as never]} />
+                    </Field>
+                  );
+                })}
                 {extendedFields.map((field) => (
                   <Controller
                     key={field.key}
@@ -726,78 +798,6 @@
                     )}
                   />
                 ))}
-                {matrixFields.map((field) => {
-                  const rows = normalizeMatrixRows(watch(field.key as never));
-                  const rowValues = rows.length > 0 ? rows : [createEmptyMatrixRow(field.columns)];
-                  const matrixError = errors[field.key as keyof typeof errors];
-                  return (
-                    <Field key={field.key}>
-                      <FieldLabel>{field.label}</FieldLabel>
-                      {field.description && (
-                        <Text size="sm" variant="muted">
-                          {field.description}
-                        </Text>
-                      )}
-                      <div className="space-y-3">
-                        {rowValues.map((row, rowIndex) => (
-                          <div
-                            key={`${field.key}-${rowIndex}`}
-                            className="rounded-md border border-border p-3"
-                          >
-                            <div className="mb-3 flex items-center justify-between">
-                              <Text size="sm" weight="medium">
-                                Row {rowIndex + 1}
-                              </Text>
-                              <Button
-                                type="button"
-                                variant="ghost"
-                                onClick={() => removeMatrixRow(field, rowIndex)}
-                                disabled={rowValues.length <= 1}
-                              >
-                                Remove row
-                              </Button>
-                            </div>
-                            <div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
-                              {field.columns.map((column) => (
-                                <Field key={`${field.key}-${rowIndex}-${column.key}`}>
-                                  <FieldLabel htmlFor={`${field.key}-${rowIndex}-${column.key}`}>
-                                    {column.label}
-                                  </FieldLabel>
-                                  {column.description && (
-                                    <Text size="sm" variant="muted">
-                                      {column.description}
-                                    </Text>
-                                  )}
-                                  <Input
-                                    id={`${field.key}-${rowIndex}-${column.key}`}
-                                    value={row[column.key] ?? ''}
-                                    onChange={(event) =>
-                                      updateMatrixCell(
-                                        field,
-                                        rowIndex,
-                                        column.key,
-                                        event.target.value,
-                                      )
-                                    }
-                                    placeholder={column.placeholder}
-                                  />
-                                </Field>
-                              ))}
-                            </div>
-                          </div>
-                        ))}
-                        <Button
-                          type="button"
-                          variant="secondary"
-                          onClick={() => addMatrixRow(field)}
-                        >
-                          {field.addRowLabel ?? 'Add row'}
-                        </Button>
-                      </div>
-                      <FieldError errors={[matrixError as never]} />
-                    </Field>
-                  );
-                })}
               </>
             )}
           </FieldGroup>

diff --git a/packages/company/src/evidence-forms/submission-schemas.ts b/packages/company/src/evidence-forms/submission-schemas.ts
--- a/packages/company/src/evidence-forms/submission-schemas.ts
+++ b/packages/company/src/evidence-forms/submission-schemas.ts
@@ -58,10 +58,25 @@
   lastReviewed: requiredTrimmed('Last reviewed'),
 });
 
+const emptyMatrixRowsToUndefined = (value: unknown): unknown => {
+  if (!Array.isArray(value)) return value;
+
+  const nonEmptyRows = value.filter((row) => {
+    if (!row || typeof row !== 'object') return true;
+
+    return Object.values(row).some((cell) => {
+      if (typeof cell === 'string') return cell.trim().length > 0;
+      return Boolean(cell);
+    });
+  });
+
+  return nonEmptyRows.length > 0 ? nonEmptyRows : undefined;
+};
+
 const rbacMatrixDataSchema = z
   .object({
     submissionDate: required('Submission date'),
-    matrixRows: z.array(rbacMatrixRowSchema).optional(),
+    matrixRows: z.preprocess(emptyMatrixRowsToUndefined, z.array(rbacMatrixRowSchema).optional()),
     matrixFile: evidenceFormFileSchema.optional(),
   })
   .refine((data) => (data.matrixRows && data.matrixRows.length > 0) || data.matrixFile, {

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

@cursor
Copy link
Copy Markdown

cursor bot commented Apr 7, 2026

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: RBAC matrix file upload silently lost on submission
    • I added matrixFile: evidenceFormFileSchema.optional() to rbacMatrixDataSchema so uploaded matrix files are preserved through Zod parsing.
  • ✅ Fixed: Matrix rows still required despite upload alternative
    • I changed RBAC validation to require either a non-empty/valid matrix row set or matrixFile and updated wizard matrix-cell checks to skip row enforcement when matrixFile is present.
  • ✅ Fixed: Step 3 MIME mapper missing CSV/Excel mappings
    • I deduplicated file accept mapping into a shared helper used by both step 2 and step 3 uploaders, including .csv, .xlsx, and .xls MIME mappings.

View PR

Or push these changes by commenting:

@cursor push c6b30e932c
Preview (c6b30e932c)
diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionWizard.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionWizard.tsx
--- a/apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionWizard.tsx
+++ b/apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionWizard.tsx
@@ -63,6 +63,37 @@
   return Object.fromEntries(columns.map((column) => [column.key, '']));
 }
 
+function buildFileAcceptMap(accept?: string): Record<string, string[]> {
+  if (!accept) {
+    return { 'application/pdf': [], 'image/*': [], 'text/*': [] };
+  }
+
+  return Object.fromEntries(
+    accept
+      .split(',')
+      .map((ext): [string, string[]] | null => {
+        const trimmed = ext.trim().toLowerCase();
+        if (trimmed === '.pdf') return ['application/pdf', []];
+        if (trimmed === '.doc') return ['application/msword', []];
+        if (trimmed === '.docx') {
+          return ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', []];
+        }
+        if (trimmed === '.png') return ['image/png', []];
+        if (trimmed === '.jpg' || trimmed === '.jpeg') return ['image/jpeg', []];
+        if (trimmed === '.txt') return ['text/plain', []];
+        if (trimmed === '.svg') return ['image/svg+xml', []];
+        if (trimmed === '.vsdx') return ['application/vnd.visio', []];
+        if (trimmed === '.csv') return ['text/csv', []];
+        if (trimmed === '.xlsx') {
+          return ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', []];
+        }
+        if (trimmed === '.xls') return ['application/vnd.ms-excel', []];
+        return null;
+      })
+      .filter((entry): entry is [string, string[]] => entry !== null),
+  );
+}
+
 export function CompanySubmissionWizard({
   organizationId,
   formType,
@@ -251,6 +282,15 @@
 
   const validateRequiredMatrixCells = () => {
     for (const field of matrixFields) {
+      if (field.key === 'matrixRows') {
+        const matrixFileValue = getValues('matrixFile' as never);
+        const hasMatrixFile =
+          typeof matrixFileValue === 'object' &&
+          matrixFileValue !== null &&
+          'fileName' in matrixFileValue;
+        if (hasMatrixFile) continue;
+      }
+
       const rows = normalizeMatrixRows(getValues(field.key as never));
       const rowValues = rows.length > 0 ? rows : [createEmptyMatrixRow(field.columns)];
 
@@ -649,46 +689,7 @@
                             <FileUploader
                               maxFileCount={1}
                               maxSize={100 * 1024 * 1024}
-                              accept={
-                                field.accept
-                                  ? Object.fromEntries(
-                                      field.accept
-                                        .split(',')
-                                        .map((ext): [string, string[]] | null => {
-                                          const trimmed = ext.trim().toLowerCase();
-                                          if (trimmed === '.pdf') return ['application/pdf', []];
-                                          if (trimmed === '.doc') return ['application/msword', []];
-                                          if (trimmed === '.docx') {
-                                            return [
-                                              'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
-                                              [],
-                                            ];
-                                          }
-                                          if (trimmed === '.png') return ['image/png', []];
-                                          if (trimmed === '.jpg' || trimmed === '.jpeg') {
-                                            return ['image/jpeg', []];
-                                          }
-                                          if (trimmed === '.txt') return ['text/plain', []];
-                                          if (trimmed === '.svg') return ['image/svg+xml', []];
-                                          if (trimmed === '.vsdx')
-                                            return ['application/vnd.visio', []];
-                                          if (trimmed === '.csv') return ['text/csv', []];
-                                          if (trimmed === '.xlsx') {
-                                            return [
-                                              'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
-                                              [],
-                                            ];
-                                          }
-                                          if (trimmed === '.xls')
-                                            return ['application/vnd.ms-excel', []];
-                                          return null;
-                                        })
-                                        .filter(
-                                          (entry): entry is [string, string[]] => entry !== null,
-                                        ),
-                                    )
-                                  : { 'application/pdf': [], 'image/*': [], 'text/*': [] }
-                              }
+                              accept={buildFileAcceptMap(field.accept)}
                               disabled={uploadingField === field.key}
                               onUpload={async (files) => {
                                 const file = files[0];
@@ -817,34 +818,7 @@
                       <FileUploader
                         maxFileCount={1}
                         maxSize={100 * 1024 * 1024}
-                        accept={
-                          field.accept
-                            ? Object.fromEntries(
-                                field.accept
-                                  .split(',')
-                                  .map((ext): [string, string[]] | null => {
-                                    const trimmed = ext.trim().toLowerCase();
-                                    if (trimmed === '.pdf') return ['application/pdf', []];
-                                    if (trimmed === '.doc') return ['application/msword', []];
-                                    if (trimmed === '.docx') {
-                                      return [
-                                        'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
-                                        [],
-                                      ];
-                                    }
-                                    if (trimmed === '.png') return ['image/png', []];
-                                    if (trimmed === '.jpg' || trimmed === '.jpeg') {
-                                      return ['image/jpeg', []];
-                                    }
-                                    if (trimmed === '.txt') return ['text/plain', []];
-                                    if (trimmed === '.svg') return ['image/svg+xml', []];
-                                    if (trimmed === '.vsdx') return ['application/vnd.visio', []];
-                                    return null;
-                                  })
-                                  .filter((entry): entry is [string, string[]] => entry !== null),
-                              )
-                            : { 'application/pdf': [], 'image/*': [], 'text/*': [] }
-                        }
+                        accept={buildFileAcceptMap(field.accept)}
                         disabled={uploadingField === field.key}
                         onUpload={async (files) => {
                           const file = files[0];

diff --git a/packages/company/src/evidence-forms/submission-schemas.ts b/packages/company/src/evidence-forms/submission-schemas.ts
--- a/packages/company/src/evidence-forms/submission-schemas.ts
+++ b/packages/company/src/evidence-forms/submission-schemas.ts
@@ -58,11 +58,55 @@
   lastReviewed: requiredTrimmed('Last reviewed'),
 });
 
-const rbacMatrixDataSchema = z.object({
-  submissionDate: required('Submission date'),
-  matrixRows: z.array(rbacMatrixRowSchema).min(1, 'At least one RBAC entry is required'),
+const optionalRbacMatrixRowSchema = z.object({
+  system: z.string().trim().optional(),
+  roleName: z.string().trim().optional(),
+  permissionsScope: z.string().trim().optional(),
+  approvedBy: z.string().trim().optional(),
+  lastReviewed: z.string().trim().optional(),
 });
 
+const rbacMatrixDataSchema = z
+  .object({
+    submissionDate: required('Submission date'),
+    matrixRows: z.array(optionalRbacMatrixRowSchema).optional(),
+    matrixFile: evidenceFormFileSchema.optional(),
+  })
+  .superRefine((data, ctx) => {
+    if (data.matrixFile) return;
+
+    const rows = data.matrixRows ?? [];
+    const nonEmptyRows = rows.filter((row) =>
+      Object.values(row).some((value) => typeof value === 'string' && value.trim().length > 0),
+    );
+
+    if (nonEmptyRows.length === 0) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        message: 'At least one RBAC entry is required',
+        path: ['matrixRows'],
+      });
+      return;
+    }
+
+    rows.forEach((row, rowIndex) => {
+      const hasAnyValue = Object.values(row).some(
+        (value) => typeof value === 'string' && value.trim().length > 0,
+      );
+      if (!hasAnyValue) return;
+
+      const parsedRow = rbacMatrixRowSchema.safeParse(row);
+      if (parsedRow.success) return;
+
+      parsedRow.error.issues.forEach((issue) => {
+        ctx.addIssue({
+          ...issue,
+          path: ['matrixRows', rowIndex, ...issue.path],
+        });
+      });
+    });
+  });
+
 const infrastructureInventoryRowSchema = z.object({
   assetId: requiredTrimmed('Asset ID'),
   systemType: requiredTrimmed('System type'),

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

…lidation

The default empty matrix row blocked Zod parsing before the refine
could check for a file upload. Switch to superRefine with a lenient
base schema that accepts empty strings, then validate non-empty rows
strictly only when no file is present. Also fix description text that
incorrectly referenced "rows above" when the file field renders first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit edababd. Configure here.

Iterate over the original matrixRows array instead of a filtered copy
so error paths reference the correct row index in the form UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claudfuen
Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 3.17.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants