From fe3f91c7efd31d9567502990b2a969b594df6e1d Mon Sep 17 00:00:00 2001 From: DJP Date: Mon, 20 Apr 2026 19:47:22 -0400 Subject: [PATCH] Smoke-test fixes: routing collision + XLSX parser + defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Actually ran this for the first time. Three real bugs + two polish items. 1. NextAuth catch-all was eating local-auth routes src/app/api/auth/[...nextauth]/route.ts is a catch-all that claims everything under /api/auth/*. When AUTH_SECRET is set (i.e. outside of DEV_BYPASS_AUTH), NextAuth's handlers absorbed my static /api/auth/login, /api/auth/change-password etc. routes and returned 404 for them. → Moved to /api/local-auth/*. Updated all four client pages to match. Added /api/local-auth to the middleware's authn-bypass allow-list alongside /api/auth. 2. XLSX header matcher too greedy on "team" The HEADER_MATCHERS entry for clientTeamRaw was ["team"], and findIndex used substring match. That matched "Creative Team Member Deliverable is Assigned to" (assignee column) BEFORE the literal "Team" column. Result: client-team values on imported projects were the assignee names ("gabrielle", "matt", "sergio"). → Two-pass buildColumnMap: exact equality first (claims the literal "Team" cell for clientTeamRaw), substring fallback second (handles the verbose "Creative Team Member…" header for assignee). Already-claimed columns are excluded from subsequent passes. 3. exceljs hyperlink cells not unwrapped Project Name cells in the Dow tracker are a mix of plain strings (for rows Dow edited manually) and exceljs hyperlink objects (rows auto-linked to the OMG brief — shape `{ text, hyperlink }`). The old extractColumns only unwrapped richText and formula.result; hyperlink objects fell through and Zod rejected them with "projectName: Invalid input". 24 of 27 rows from the real XLSX failed with this before; now 26/27 pass (the 1 remaining error is a genuinely missing omgNumber, correctly flagged). → Extracted unwrapCell() that handles hyperlink, richText, formula, error, and Date cells. 4. DEV_BYPASS_AUTH defaulted to "true" in .env.example Anyone copying .env.example verbatim got a mock session pointing at the HP-era "dev-user-001" which doesn't exist in the Dow DB, causing mysterious P2025 errors on user.update. Also leaves the app wide open — nobody's auth is actually checked. → Default to "false" in .env.example with a DANGEROUS warning. 5. layout.tsx metadata description still said "HP CG department" → Fixed to "the Dow Jones studio". Verified end-to-end on a fresh local DB: - Login as seeded admin ✓ - Forced password change on first login ✓ - XLSX import: 27 rows → 26 created, 1 error (missing omg number) ✓ - 267 deliverables across 5 client teams ✓ - Invited a CLIENT_VIEWER, assigned to Brand team only ✓ - Brand tester sees 1 project; admin sees 18 ✓ - Brand tester gets 403 on POST /api/projects ✓ Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 8 +- src/app/(auth)/change-password/page.tsx | 2 +- src/app/(auth)/forgot-password/page.tsx | 2 +- src/app/(auth)/login/CredentialsLogin.tsx | 2 +- .../(auth)/reset-password/[token]/page.tsx | 2 +- .../change-password/route.ts | 0 .../forgot-password/route.ts | 0 .../api/{auth => local-auth}/login/route.ts | 0 .../reset-password/route.ts | 0 src/app/layout.tsx | 2 +- src/lib/services/dow-excel-service.ts | 73 ++++++++++++++++--- src/middleware.ts | 6 +- 12 files changed, 77 insertions(+), 20 deletions(-) rename src/app/api/{auth => local-auth}/change-password/route.ts (100%) rename src/app/api/{auth => local-auth}/forgot-password/route.ts (100%) rename src/app/api/{auth => local-auth}/login/route.ts (100%) rename src/app/api/{auth => local-auth}/reset-password/route.ts (100%) diff --git a/.env.example b/.env.example index 6c8c34b..9ddb577 100644 --- a/.env.example +++ b/.env.example @@ -14,9 +14,11 @@ AZURE_REDIRECT_URI="" # No client secret — SPA registrations use PKCE in the browser (no AUTH_URL needed) # ─── Dev Auth Bypass (local development only) ─────────── -# Set to "true" to skip SSO and auto-login as dev admin user. -# Ignored when NODE_ENV=production. -DEV_BYPASS_AUTH="true" +# Set to "true" to skip all auth and auto-login as the DEV_USER_ID user. +# DANGEROUS — leaves the app wide open. Ignored in production. +# Default is "false" so the real local-auth flow is exercised on first +# run (log in as the seed admin — see DEPLOY.md / seed-dow.ts). +DEV_BYPASS_AUTH="false" DEV_USER_ID="dev-user-001" # ─── App ───────────────────────────────────────────────── diff --git a/src/app/(auth)/change-password/page.tsx b/src/app/(auth)/change-password/page.tsx index 9f2cbd6..5916755 100644 --- a/src/app/(auth)/change-password/page.tsx +++ b/src/app/(auth)/change-password/page.tsx @@ -34,7 +34,7 @@ export default function ChangePasswordPage() { setSubmitting(true); try { - const res = await fetch(`${BASE_PATH}/api/auth/change-password`, { + const res = await fetch(`${BASE_PATH}/api/local-auth/change-password`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentPassword, newPassword }), diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx index 7e59a5f..d7b3ad2 100644 --- a/src/app/(auth)/forgot-password/page.tsx +++ b/src/app/(auth)/forgot-password/page.tsx @@ -19,7 +19,7 @@ export default function ForgotPasswordPage() { setSubmitting(true); try { - const res = await fetch(`${BASE_PATH}/api/auth/forgot-password`, { + const res = await fetch(`${BASE_PATH}/api/local-auth/forgot-password`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }), diff --git a/src/app/(auth)/login/CredentialsLogin.tsx b/src/app/(auth)/login/CredentialsLogin.tsx index 587ca3f..16cf7c2 100644 --- a/src/app/(auth)/login/CredentialsLogin.tsx +++ b/src/app/(auth)/login/CredentialsLogin.tsx @@ -22,7 +22,7 @@ export function CredentialsLogin() { setSubmitting(true); try { - const res = await fetch(`${BASE_PATH}/api/auth/login`, { + const res = await fetch(`${BASE_PATH}/api/local-auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), diff --git a/src/app/(auth)/reset-password/[token]/page.tsx b/src/app/(auth)/reset-password/[token]/page.tsx index 86a349a..9d6d875 100644 --- a/src/app/(auth)/reset-password/[token]/page.tsx +++ b/src/app/(auth)/reset-password/[token]/page.tsx @@ -36,7 +36,7 @@ export default function ResetPasswordPage({ params }: Props) { setSubmitting(true); try { - const res = await fetch(`${BASE_PATH}/api/auth/reset-password`, { + const res = await fetch(`${BASE_PATH}/api/local-auth/reset-password`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token, newPassword }), diff --git a/src/app/api/auth/change-password/route.ts b/src/app/api/local-auth/change-password/route.ts similarity index 100% rename from src/app/api/auth/change-password/route.ts rename to src/app/api/local-auth/change-password/route.ts diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/local-auth/forgot-password/route.ts similarity index 100% rename from src/app/api/auth/forgot-password/route.ts rename to src/app/api/local-auth/forgot-password/route.ts diff --git a/src/app/api/auth/login/route.ts b/src/app/api/local-auth/login/route.ts similarity index 100% rename from src/app/api/auth/login/route.ts rename to src/app/api/local-auth/login/route.ts diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/local-auth/reset-password/route.ts similarity index 100% rename from src/app/api/auth/reset-password/route.ts rename to src/app/api/local-auth/reset-password/route.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7b49fb8..15ca3a2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -26,7 +26,7 @@ const jetbrainsMono = JetBrains_Mono({ export const metadata: Metadata = { title: "Dow Jones Studio Tracker", - description: "Production pipeline tracker for HP CG department", + description: "Production pipeline tracker for the Dow Jones studio", }; export default function RootLayout({ diff --git a/src/lib/services/dow-excel-service.ts b/src/lib/services/dow-excel-service.ts index af1f4a9..178c112 100644 --- a/src/lib/services/dow-excel-service.ts +++ b/src/lib/services/dow-excel-service.ts @@ -396,12 +396,40 @@ function buildColumnMap( ): Partial, number>> { const map: Partial, number>> = {}; const normalized = headerCells.map((c) => normalizeHeader(c)); + const used = new Set(); + + // Two-pass matching so the more-specific headers claim their column first. + // PASS 1: exact match (after normalization). This is how "Team" → clientTeamRaw + // wins over "Creative Team Member Deliverable is Assigned to" (which has "team" + // as a substring but isn't exactly "team"). for (const [key, matchers] of Object.entries(HEADER_MATCHERS) as [ keyof Omit, string[], ][]) { - const idx = normalized.findIndex((h) => matchers.some((m) => h.includes(m))); - if (idx !== -1) map[key] = idx; + const idx = normalized.findIndex( + (h, i) => !used.has(i) && matchers.some((m) => h === m) + ); + if (idx !== -1) { + map[key] = idx; + used.add(idx); + } + } + + // PASS 2: substring match for keys that didn't get an exact hit (verbose + // headers like "Creative Team Member Deliverable is Assigned to"). Only + // columns not already claimed above are considered. + for (const [key, matchers] of Object.entries(HEADER_MATCHERS) as [ + keyof Omit, + string[], + ][]) { + if (map[key] !== undefined) continue; + const idx = normalized.findIndex( + (h, i) => !used.has(i) && matchers.some((m) => h.includes(m)) + ); + if (idx !== -1) { + map[key] = idx; + used.add(idx); + } } return map; } @@ -416,15 +444,7 @@ function extractColumns( const get = >(key: K): unknown => { const idx = columnMap[key]; if (idx === undefined) return undefined; - const cell = data[idx]; - // Unwrap exceljs rich-text objects - if (cell && typeof cell === "object" && "richText" in (cell as any)) { - return (cell as any).richText.map((t: any) => t.text).join(""); - } - if (cell && typeof cell === "object" && "result" in (cell as any)) { - return (cell as any).result; - } - return cell; + return unwrapCell(data[idx]); }; return { @@ -445,6 +465,37 @@ function extractColumns( }; } +/** + * Unwrap an exceljs cell value to a primitive string/number/Date that Zod can + * validate. exceljs returns rich objects for hyperlinks, rich text, formulas, + * and errors — each shape surfaces the useful value on a different property. + */ +function unwrapCell(cell: unknown): unknown { + if (cell === null || cell === undefined) return cell; + if (typeof cell !== "object") return cell; + if (cell instanceof Date) return cell; + + const c = cell as Record; + + // Hyperlink cells: { text: "label", hyperlink: "url" } + if (typeof c.text === "string") return c.text; + + // Rich text: { richText: [{ text, font }, ...] } + if (Array.isArray(c.richText)) { + return (c.richText as Array<{ text?: string }>) + .map((r) => r.text ?? "") + .join(""); + } + + // Formula result: { formula, result } + if ("result" in c) return c.result; + + // Error cells: { error: "#N/A" } — treat as empty + if (typeof c.error === "string") return null; + + return cell; +} + function isEmptyRow(row: ParsedRow): boolean { const { _rowNumber: _r, ...fields } = row; return Object.values(fields).every( diff --git a/src/middleware.ts b/src/middleware.ts index bc126f6..e2b2416 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -39,12 +39,16 @@ export function middleware(request: NextRequest) { pathname.startsWith("/reset-password"); const isPendingPage = pathname.startsWith("/pending"); const isApiAuth = pathname.startsWith("/api/auth"); + // Local-auth endpoints live under /api/local-auth/ to avoid colliding with + // NextAuth's [...nextauth] catch-all. forgot-password and reset-password are + // accessible without a session; login is obviously pre-session. + const isLocalAuth = pathname.startsWith("/api/local-auth/"); const isHealthCheck = pathname === "/api/health"; // Webhooks are authenticated per-endpoint via HMAC (see /api/webhooks/omg). const isWebhook = pathname.startsWith("/api/webhooks/"); // Always allow auth API routes, health checks, and webhook receivers - if (isApiAuth || isHealthCheck || isWebhook) { + if (isApiAuth || isLocalAuth || isHealthCheck || isWebhook) { return NextResponse.next(); }