Smoke-test fixes: routing collision + XLSX parser + defaults

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) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-20 19:47:22 -04:00
parent a4107ae23d
commit fe3f91c7ef
12 changed files with 77 additions and 20 deletions

View file

@ -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 ─────────────────────────────────────────────────

View file

@ -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 }),

View file

@ -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 }),

View file

@ -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 }),

View file

@ -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 }),

View file

@ -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({

View file

@ -396,12 +396,40 @@ function buildColumnMap(
): Partial<Record<keyof Omit<ParsedRow, "_rowNumber">, number>> {
const map: Partial<Record<keyof Omit<ParsedRow, "_rowNumber">, number>> = {};
const normalized = headerCells.map((c) => normalizeHeader(c));
const used = new Set<number>();
// 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<ParsedRow, "_rowNumber">,
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<ParsedRow, "_rowNumber">,
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 = <K extends keyof Omit<ParsedRow, "_rowNumber">>(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<string, unknown>;
// 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(

View file

@ -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();
}