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