Step 0 β XLSX Ingestion (Implementation Spec)
Hand-off-ready spec for Step 0 of the multi-chart plan (docs/admin/org-chart-multi-chart-design.md, issue #1549). Self-contained and independently shippable. Does not implement multi-tab UX, the org/view split, or transclusion β it only adds spreadsheet file ingestion so those later steps have a real spreadsheet path to build on.
All line references verified against main as of 2026-07-13. Confirm they havenβt drifted before editing.
Goal
Accept .xlsx (and, free via the chosen library, .xls/.ods) uploads on the existing chart-creation path, parsing them into the same normalized people rows that CSV already produces β so the entire downstream insert/hierarchy pipeline is unchanged.
Scope
In scope
- Add SheetJS (
xlsx) as a dependency ofworkers/api(Node/Lambda runtime only). - New util
parseOrgChartSpreadsheet(buffer)inworkers/api/src/utils/that reads a workbook, parses the first non-empty sheet, and returns the exact same{ people, errors }shape asparseOrgChartCSV, reusing the existing column-matching/normalization logic. - Extend the MIME/extension allowlists in
packages/org-chart-shared/src/constants.ts. - Wire spreadsheet detection + parsing into
POST /api/orgcharts(workers/api/src/routes/org-chart/orgcharts.ts). - Add a file-size cap to the CSV and spreadsheet branches (currently only image/PDF is capped β see Β§βBug to fix in passingβ).
- Update client
acceptfilters and type-gates (dashboard/page.tsx,wizard/page.tsx). - Unit tests for the new parser + a route-level test for the spreadsheet branch.
Out of scope (later steps β do NOT build here)
- Multi-tab handling / one-chart-per-tab / tabβdepartment mapping (Step 1). Step 0 parses the first sheet only and reports ignored sheets so Step 1 can build on it.
- Import mapping/preview UI (Step 1).
- ID-based hierarchy (
employeeId+managerId) β larger data-model change; defer to Step 1/2. - An
.xlsxdownload template (keep the existing CSV template). ;/,delimiter fix β already shipped in Phase 1 (apps/org-chart/src/lib/csv.ts,MULTI_VALUE_DELIMITER = ';'with legacy comma fallback). Do not re-do it.
Current ingestion path (verified)
- Client uploads via
FormData(file+name) toPOST /api/orgcharts(apps/org-chart/src/lib/api.ts:263-298). CSV is parsed server-side, not in the browser. - Route
workers/api/src/routes/org-chart/orgcharts.ts:orgcharts.ts:45β CSV detection:file.type === 'text/csv' || file.name.endsWith('.csv').orgcharts.ts:47-64β CSV branch:await file.text()βparseOrgChartCSV(csvText)β{ people, errors }β chart insert + people batch insert (first/second-pass manager-name resolution,orgcharts.ts:84-232).orgcharts.ts:241-272β image/PDF branch: checksALL_ALLOWED_MIME_TYPES(:244) andMAX_FILE_SIZE(:249).
- Parser
workers/api/src/utils/csv-parser.ts:parseOrgChartCSV(csvText): { people: ParsedPerson[]; errors: string[] }(:158).normalizePersonRow(row: Record<string,string>): { normalized; customFields }(:282) β reused per row.STANDARD_FIELDS(:22),FIELD_MAP(:61),fuzzyMatchColumn(:126) β the column-matching layer to reuse.
- Constants
packages/org-chart-shared/src/constants.ts:MAX_FILE_SIZE = 10 * 1024 * 1024(:12).ALLOWED_MIME_TYPES = { pdf, image }(:15),ALLOWED_EXTENSIONS(:21),ALL_ALLOWED_MIME_TYPES(:27).
- Client
acceptfilters:dashboard/page.tsx:420,wizard/page.tsx:409(image/*,application/pdf,.csv,text/csv); type-gates atdashboard/page.tsx:165,261-262andwizard/page.tsx:152,198,201.
Library decision
SheetJS (xlsx) β one API reads .xlsx/.xls/.ods; legacy support is free. Community build is heavy CJS but runs fine on Node/Lambda. Must not run at the Cloudflare edge β see Β§Runtime. (Fallback if bundle size is unacceptable: ExcelJS, .xlsx-only; then reject .xls with an actionable message. Do not hand-roll a binary .xls parser.)
Install pinned to a specific version; run npm audit after (SheetJS has had advisories β pull from the official distribution/registry per their guidance and verify no high/critical findings).
Implementation
1. packages/org-chart-shared/src/constants.ts
Add a spreadsheet group and fold it into the flat arrays:
ALLOWED_MIME_TYPES = { pdf: ['application/pdf'], image: ['image/png', 'image/jpeg', 'image/bmp'], spreadsheet: [ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx 'application/vnd.ms-excel', // .xls 'application/vnd.oasis.opendocument.spreadsheet', // .ods ],}ALLOWED_EXTENSIONS.spreadsheet = ['.xlsx', '.xls', '.ods']Keep ALL_ALLOWED_MIME_TYPES as image+pdf (it gates the AI-vision branch). Export a separate SPREADSHEET_MIME_TYPES / SPREADSHEET_EXTENSIONS for the structured branch. Rebuild the shared package (@org-chart/shared) so workers/api picks up the new exports.
Browser MIME reporting for spreadsheets is unreliable (empty or
application/octet-stream). Detection MUST fall back to file extension, exactly as the CSV check already does.
2. New util workers/api/src/utils/spreadsheet-parser.ts
export function parseOrgChartSpreadsheet(buffer: ArrayBuffer | Uint8Array): { people: ParsedPerson[]; // same type parseOrgChartCSV returns errors: string[]; sheetNames: string[]; // all sheet names in the workbook parsedSheet: string; // the sheet actually used ignoredSheets: string[]; // sheetNames minus parsedSheet (for Step 1)}XLSX.read(buffer, { type: 'array', cellFormula: false, cellHTML: false })β do not evaluate formulas.- Pick the first sheet whose used range has β₯1 non-empty row after the header; record the rest in
ignoredSheets. - Convert that sheet to row objects with
XLSX.utils.sheet_to_json(ws, { header: 1, raw: false, defval: '' })(array-of-arrays), then map the header row through the samefuzzyMatchColumn/FIELD_MAPpathparseOrgChartCSVuses, and each data row throughnormalizePersonRow. RefactorparseOrgChartCSVto extract a sharedrowsToPeople(headerRow, dataRows)core and have both CSV and spreadsheet parsers call it β no duplicated column logic. - Sanitize each cell to a string; strip leading control chars; treat leading
= + - @cautiously (import stores values only β never evaluate β but keep parity with the CSV export sanitization rule). - Errors use the same human-readable style as
parseOrgChartCSV(row-numbered).
3. workers/api/src/routes/org-chart/orgcharts.ts
- Add detection alongside
isCSV(:45):const isSpreadsheet = SPREADSHEET_MIME_TYPES.includes(file.type)|| SPREADSHEET_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext)); - Size cap first, for both structured branches: before reading,
if (file.size > MAX_FILE_SIZE) return validationError(c, 'File exceeds the 10MB limit'). (Fixes the current CSV gap.) - New branch mirroring the CSV branch:
const buf = await file.arrayBuffer(); const { people, errors, ignoredSheets } = parseOrgChartSpreadsheet(buf);then feedpeople/errorsinto the existing insert path (lines 66-232) β factor that path into a helper if needed so CSV and spreadsheet share it verbatim. - If
ignoredSheets.length > 0,log.info('Spreadsheet had additional sheets (ignored in Step 0)', { chartId, ignoredSheets })and include them in the responsewarningsso the UI can surface βonly sheet X was importedβ (sets up Step 1). - Keep the same error semantics: all-rows-failed β
validationError; partial β insert +warnings.
4. Client (apps/org-chart/src/app/dashboard/page.tsx, wizard/page.tsx)
- Extend both
acceptattributes (:420,:409) to add.xlsx,.xls,.ods,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet. - Extend the type-gates (
dashboard/page.tsx:165,261-262;wizard/page.tsx:152,198,201) to treat spreadsheet extensions as a valid structured upload (same UX branch as CSV β no AI extraction). - No new client parsing β the file is posted as-is; the server parses.
Runtime & deploy
POST /api/orgchartsis served by the Node API (.4) and Lambda β both Node runtimes, where SheetJS works. This is an existing route: no new route registration, but confirm it is mounted in bothindex.ts(Node) andindex-aws.ts(Lambda) β it is, since CSV works in prod. (Dual-entry gotcha applies to new routes; this isnβt one.)- Do not move spreadsheet parsing into any Cloudflare Worker (edge) path β SheetJSβs CJS bundle and Node deps wonβt run there. If a Worker ever needs it, thatβs a separate design.
- New npm dep β follow the
workers/apiDockerfile workspace-deps rule (the.4api-nodes image needs the dep present or it crash-loops; Lambda bundles so itβs unaffected). Verify the container builds beforenpm run rebuild. - After merge: Lambda + CF auto-deploy; Node needs manual
npm run rebuildon10.1.1.4.
Bug to fix in passing
The CSV branch (orgcharts.ts:47-64) reads file.text() with no size check β only image/PDF is capped. Add the MAX_FILE_SIZE guard to both structured branches (see Β§3). Small, contained, in the code weβre already touching.
Tests
- Unit β
workers/api/src/__tests__/utils/spreadsheet-parser.test.ts(new): a well-formed.xlsxfixture parses to the expected people/managers; multi-sheet workbook β first sheet used +ignoredSheetspopulated;.xlsand.odsfixtures parse; empty workbook β clear error; malformed/non-spreadsheet bytes β error, no throw; fuzzy headers (Reports To,Job Title) map correctly; position-only rows (title, no name) handled; parity check that the same tabular data yields the samepeopleasparseOrgChartCSV. - Route β extend the
orgchartscreate test (per the route-test-coverage rule): posting an.xlsxFormData creates a chart with people; oversized file β 400; additional-sheets warning surfaces in the response. - Existing
csv.test.ts/ csv-parser tests must still pass after therowsToPeoplerefactor. npm run typecheck(via npm, not bare tsc) +test:cigreen; coverage not decreased.
Acceptance criteria
- Uploading a valid
.xlsxon the create-chart screen produces a chart with the correct people and reporting lines β no AI extraction, same result as the equivalent CSV. .xlsand.odsalso import (via SheetJS); browser-empty MIME still works via extension fallback.- A multi-sheet workbook imports the first sheet and the response/UI states that other sheets were ignored.
- Files over 10MB are rejected with an actionable message on the CSV and spreadsheet paths.
- Formulas are never evaluated; only cell values are stored.
workers/apicontainer builds with the new dep;npm auditshows no new high/critical; all tests + typecheck pass.- No change to the image/PDF vision path or to existing CSV behavior.
Rollout
Single PR off a feature/org-chart-xlsx-ingestion branch β tests + container build green β merge β npm run rebuild on .4 β smoke-test an .xlsx upload in prod. Then Step 1 (mapping/preview UI + multi-tab) builds on ignoredSheets.