Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 9 additions & 47 deletions SESSION.md
Original file line number Diff line number Diff line change
@@ -1,47 +1,9 @@
# Session: Gmail Schema Availability Fix

## Linked work

- GitHub issue: https://github.com/brycejohnson1417/Picc-web-app/issues/181
- Branch: `codex/181-gmail-schema-error`
- Production evidence: the signed-in Contacts page exposes a Prisma missing-table error for `public.GmailConnection` after clicking **Find contacts**.

## Scope

- Trace the Gmail lookup from Contacts through its server boundary and migration history.
- Add a focused regression test that proves database internals cannot leak through the Gmail suggestions API.
- Return a stable, actionable integration-unavailable state when the schema is not ready.
- Identify the exact pending production migration action required to restore Gmail lookup.

## Out of scope

- Gmail message sending or wider mailbox permissions.
- OAuth consent-screen, auth/RLS, or tenant access-control changes.
- Unrelated Prisma migrations, data backfills, or schema cleanup.
- Changes to `/Users/brycejohnson/Code/map-app`.

## Constraints and architecture check

- Keep Prisma access behind the existing Gmail server boundary.
- Preserve the current read-only Gmail integration and the Settings-based connection flow.
- Production schema changes are approval-lane work and cannot be run or merged without Bryce's explicit approval.
- Owned paths: `lib/server/gmail-connection.ts`, focused Gmail route tests, migration/deployment configuration only if evidence requires it, and `SESSION.md`.
- Open PRs checked: #166, #144, #135, and #82. None owns the Gmail integration or Prisma migration paths.

## Validation plan

- RED: reproduce the raw Prisma missing-table error leaking from the Gmail suggestions route.
- GREEN: map unavailable-schema failures to a stable user-safe response while retaining actionable Settings guidance.
- Run focused unit tests, then `npm run verify`.
- Run the targeted signed-in Contacts and Settings browser flow.
- After separately approved production migration/deployment work, verify the live route no longer reports a missing table.

## Current state

- Root-cause evidence: the Gmail migration exists at `prisma/migrations/202608141300_add_gmail_connections/migration.sql`, while the Vercel build command is only `npm run build`; the production browser reports the table absent.
- RED confirmed: route coverage reproduced raw missing-table leakage; browser coverage reproduced the Settings card's endless loading state.
- GREEN confirmed: Gmail status/suggestions return a stable 503 without Prisma details, and Settings renders a retryable setup state that recovers to the real connection control.
- `npm run verify`: passed lint, typecheck, 48 Vitest files / 207 tests, Prisma validation, and production build.
- Targeted Gmail browser flow: passed at 390x844 with error and recovered screenshots under `.agents/issue-181/`.
- Full `npm run test:e2e`: 34 passed / 3 unrelated failures (`/home` timeout, Google Map unavailable in the Safari-shell test, subway reload timeout). Serial rerun passed `/home` and subway; the Google Map availability assertion remained failed and is outside this PR's owned paths.
- No production schema changes have been run.
# Issue 107: Spreadsheet parser hardening

Issue: https://github.com/brycejohnson1417/Picc-web-app/issues/107
Branch: codex/107-spreadsheet-hardening, from main 4fde001.
Scope: replace the vulnerable registry SheetJS version with the official fixed distribution; bound spreadsheet inputs and retain required workbook/CSV behavior.
Owned: package.json, package-lock.json, lib/integrations/sheets.ts, lib/integrations/spreadsheet-input.ts and tests, lib/server/preferred-partner-proposal.ts, SESSION.md.
Out of scope: Prisma tooling, provider changes, schema, production data, UI redesign.
Overlap checked: #195/#194/#189/#182/#166/#135/#82. No source overlap with current changes except SESSION; inspect #135 proposal overlap before implementation and sequence/rebase as needed. Dependency updates exclusive.
Validation: RED malformed/oversized input tests first; GREEN representative workbook/CSV tests; clean npm ci, npm audit, npm run verify. Existing SheetJS API adapter retained; no new parser abstraction beyond input validation.
32 changes: 32 additions & 0 deletions lib/integrations/sheets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { afterEach, expect, it } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import * as XLSX from 'xlsx';
import { inspectNabisWorkbook } from './sheets';

const folders: string[] = [];
function file(name: string, content: string | Buffer) {
const folder = mkdtempSync(join(tmpdir(), 'picc-sheet-test-'));
folders.push(folder);
const path = join(folder, name);
writeFileSync(path, content);
return path;
}
afterEach(() => folders.splice(0).forEach((folder) => rmSync(folder, { recursive: true })));
it('rejects unsupported file types before parsing', () => {
expect(() => inspectNabisWorkbook(file('report.html', '<table><tr><td>Accounts</td></tr></table>'))).toThrow('file type');
});
it('rejects an oversized workbook before parsing', () => {
expect(() => inspectNabisWorkbook(file('report.xlsx', Buffer.alloc(10 * 1024 * 1024 + 1)))).toThrow('10 MB');
});
it('rejects text disguised as a workbook', () => {
expect(() => inspectNabisWorkbook(file('report.xlsx', 'Account,Value\nExample,12'))).toThrow('valid Excel');
});
it('preserves workbook tab coverage and row samples', () => {
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([['Name','Value'],['Example',12]]), 'orders');
const result = inspectNabisWorkbook(file('report.xlsx', XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' })));
expect(result.tabs).toEqual(['orders']);
expect(result.sample).toContainEqual({ tab:'orders', header:['Name','Value'], firstDataRow:['Example',12], rowCount:2 });
});
28 changes: 27 additions & 1 deletion lib/integrations/sheets.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,34 @@
import { readFileSync, statSync } from 'node:fs';
import { extname } from 'node:path';
import { MAX_WORKBOOK_BYTES, MAX_SHEET_ROWS } from './spreadsheet-input';
import * as XLSX from 'xlsx';
import { NABIS_SCHEMA_MAPPING, REQUIRED_NABIS_TABS } from '@/lib/data/sheets-schema';

export function inspectNabisWorkbook(path: string) {
const workbook = XLSX.readFile(path, { cellDates: true });
const extension = extname(path).toLowerCase();
if (!['.xlsx', '.xls', '.csv'].includes(extension)) {
throw new Error('Unsupported spreadsheet file type. Use XLSX, XLS, or CSV.');
}
const file = statSync(path);
if (!file.isFile() || file.size > MAX_WORKBOOK_BYTES) {
throw new Error('Workbook must be a regular file of at most 10 MB.');
}
const bytes = readFileSync(path);
const zip = bytes.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x03, 0x04]));
const ole = bytes.subarray(0, 8).equals(Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]));
if ((extension === '.xlsx' && !zip) || (extension === '.xls' && !ole)) {
throw new Error('File is not a valid Excel workbook.');
}
const workbook = XLSX.read(bytes, { type: 'buffer', cellDates: true, sheetRows: MAX_SHEET_ROWS + 1 });
for (const sheet of Object.values(workbook.Sheets)) {
const range = sheet['!fullref'] || sheet['!ref'];
if (range && XLSX.utils.decode_range(range).e.c >= 256) {
throw new Error('Workbook exceeds the 256 column limit per sheet.');
}
if (range && XLSX.utils.decode_range(range).e.r >= MAX_SHEET_ROWS) {
throw new Error('Workbook exceeds the 50,000 row limit per sheet.');
}
}
const tabs = workbook.SheetNames;

const requiredCoverage = REQUIRED_NABIS_TABS.map((tab) => ({
Expand Down
22 changes: 22 additions & 0 deletions lib/integrations/spreadsheet-input.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { expect, it } from 'vitest';
import { requireReportSize, parseReportRows } from './spreadsheet-input';

it('accepts a normal CSV with quoted commas and Unicode', () => {
expect(() => requireReportSize('Name,Price\n"Café, large",12')).not.toThrow();
});
it('rejects oversized text by bytes rather than character count', () => {
expect(() => requireReportSize('é'.repeat(1024 * 1024 + 1))).toThrow('2 MB');
});

it('preserves quoted commas, Unicode, leading zeros, and prices in CSV reports', () => {
expect(parseReportRows('Name,SKU,Price\n"Café, large",0012,12.50')).toEqual({
format: 'csv', rows: [{ Name: 'Café, large', SKU: '0012', Price: '12.50' }],
});
});
it('retains JSON rows and rejects malformed JSON', () => {
expect(parseReportRows('[{"Name":"Example","Price":12}]').rows).toEqual([{Name:'Example',Price:12}]);
expect(() => parseReportRows('[broken')).toThrow();
});
it('rejects excessive columns without returning a truncated report', () => {
expect(() => parseReportRows(Array.from({length:257}, (_,i) => `column${i}`).join(','))).toThrow('256 column');
});
51 changes: 51 additions & 0 deletions lib/integrations/spreadsheet-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import * as XLSX from 'xlsx';

export const MAX_WORKBOOK_BYTES = 10 * 1024 * 1024;
export const MAX_REPORT_BYTES = 2 * 1024 * 1024;
export const MAX_SHEET_ROWS = 50_000;

export function requireReportSize(input: string) {
if (Buffer.byteLength(input, 'utf8') > MAX_REPORT_BYTES) {
const error = new Error('Report exceeds the 2 MB limit. Split the export into smaller reports.');
Object.assign(error, { statusCode: 400 });
throw error;
}
}

export function parseReportRows(rawInput: string) {
requireReportSize(rawInput);
const trimmed = rawInput.trim();
if (!trimmed) {
return { format: 'json' as const, rows: [] as Record<string, unknown>[] };
}

if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
const parsed = JSON.parse(trimmed) as unknown;
if (Array.isArray(parsed)) {
return {
format: 'json' as const,
rows: parsed.filter((row): row is Record<string, unknown> => Boolean(row) && typeof row === 'object'),
};
}
throw new Error('Expected a JSON array of Headset rows.');
}

const workbook = XLSX.read(trimmed, { type: 'string', raw: false, sheetRows: MAX_SHEET_ROWS + 1 });
const firstSheetName = workbook.SheetNames[0];
if (!firstSheetName) {
return { format: 'csv' as const, rows: [] as Record<string, unknown>[] };
}
const sheet = workbook.Sheets[firstSheetName];
const range = sheet['!fullref'] || sheet['!ref'];
if (range && XLSX.utils.decode_range(range).e.c >= 256) {
throw Object.assign(new Error('Report exceeds the 256 column limit.'), { statusCode: 400 });
}
if (range && XLSX.utils.decode_range(range).e.r >= MAX_SHEET_ROWS) {
throw Object.assign(new Error('Report exceeds the 50,000 row limit.'), { statusCode: 400 });
}
return {
format: 'csv' as const,
rows: XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: null, raw: false }),
};
}

32 changes: 2 additions & 30 deletions lib/server/preferred-partner-proposal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { parseReportRows } from '@/lib/integrations/spreadsheet-input';
import 'server-only';

import * as XLSX from 'xlsx';
import { resolveAccountIdentity } from '@/lib/server/account-identity';
import { loadNabisDaysOffRows, loadNyInventoryRows, loadNyWarehouseRows } from '@/lib/server/nabis-api';
import { loadTerritoryStoreDetail } from '@/lib/server/notion-territory';
Expand Down Expand Up @@ -375,34 +375,6 @@ function calculateEarliestDeliveryDate(generatedAt: Date, daysOffRows: Array<Rec
return formatNyDateKey(candidate);
}

function parseInputRows(rawInput: string) {
const trimmed = rawInput.trim();
if (!trimmed) {
return { format: 'json' as const, rows: [] as InputRow[] };
}

if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
const parsed = JSON.parse(trimmed) as unknown;
if (Array.isArray(parsed)) {
return {
format: 'json' as const,
rows: parsed.filter((row): row is InputRow => Boolean(row) && typeof row === 'object'),
};
}
throw new Error('Expected a JSON array of Headset rows.');
}

const workbook = XLSX.read(trimmed, { type: 'string', raw: false });
const firstSheetName = workbook.SheetNames[0];
if (!firstSheetName) {
return { format: 'csv' as const, rows: [] as InputRow[] };
}
const sheet = workbook.Sheets[firstSheetName];
return {
format: 'csv' as const,
rows: XLSX.utils.sheet_to_json<InputRow>(sheet, { defval: null, raw: false }),
};
}

function normalizeHeadsetRows(rawRows: InputRow[]) {
return rawRows
Expand Down Expand Up @@ -1143,7 +1115,7 @@ export async function getPreferredPartnerProposal(input: {
accountIdOrPageId: string;
rawReport: string;
}) {
const { format, rows: rawRows } = parseInputRows(input.rawReport);
const { format, rows: rawRows } = parseReportRows(input.rawReport);
const parsedRows = normalizeHeadsetRows(rawRows);
if (parsedRows.length === 0) {
const error = new Error('Paste a Headset JSON array or CSV export before generating a PPP proposal.');
Expand Down
Loading
Loading