diff --git a/.env.example b/.env.example index 7239bb1..e2e5280 100644 --- a/.env.example +++ b/.env.example @@ -31,15 +31,26 @@ AUTH_GOOGLE_SECRET="" # First admin user email (auto-granted ADMIN role on first login) INITIAL_ADMIN_EMAIL="admin@scoutlane.local" +# Optional: restrict Google sign-in to one email domain (e.g. "yourcompany.com"). +# Leave empty to accept any Google account (they land as RECRUITER). +AUTH_ALLOWED_EMAIL_DOMAIN="" + # ── Application ────────────────────────────────────────────────────────────── # Public URL of the deployed app (used for emails, redirects, etc.) NEXT_PUBLIC_APP_URL="http://localhost:3000" -# Resume parsing strategy. Defaults to "queue-and-inline". -# "queue-and-inline" — parse immediately during submission AND enqueue for redundancy (best dev UX) +# Resume parsing strategy. Defaults to "queue" (async; applicant never waits). +# "queue" — dispatch via the job runner below (recommended) +# "queue-and-inline" — dispatch AND parse immediately during submission (redundant) # "inline" — parse synchronously during submission (blocks the response) -# "queue" — enqueue only; requires `pnpm worker:resume` running separately -RESUME_PARSE_MODE="queue-and-inline" +RESUME_PARSE_MODE="queue" + +# Background job runner for resume parsing + email delivery. +# "worker" — enqueue to pg-boss; requires `pnpm worker:resume` and `pnpm worker:emails` +# running on a long-lived host (Railway/Render/Fly). Default off Vercel. +# "inline" — run after the response in the same serverless invocation via next/server +# after(); no worker needed. Default on Vercel. +JOB_RUNNER="" # ── File Storage (Google Cloud Storage) ────────────────────────────────────── # Create a service account at https://console.cloud.google.com/iam-admin/serviceaccounts @@ -79,9 +90,10 @@ EMAIL_FROM="ScoutLane " # Get a key at https://openrouter.ai/keys OPENROUTER_API_KEY="" -# Model identifier. `openrouter/auto` keeps parsing available if a provider model is removed. -OPENROUTER_MODEL="openrouter/owl-alpha" -OPENROUTER_FALLBACK_MODELS="openrouter/free,openrouter/auto" +# Model identifier. Defaults to google/gemini-2.5-flash when unset. +# `openrouter/auto` keeps parsing available if a provider model is removed. +OPENROUTER_MODEL="google/gemini-2.5-flash" +OPENROUTER_FALLBACK_MODELS="google/gemini-2.5-flash-lite,openrouter/auto" # Attribution headers (optional, but help with OpenRouter rate-limit visibility) OPENROUTER_APP_URL="http://localhost:3000" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fd6029f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# Binary assets — never apply EOL conversion +*.pdf binary +*.docx binary +*.doc binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary diff --git a/docs/SETUP.md b/docs/SETUP.md index 7dea3bc..e8c9efe 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -132,7 +132,15 @@ GOOGLE_CLIENT_ID="123456789012-abcdefg.apps.googleusercontent.com" GOOGLE_CLIENT_SECRET="GOCSPX-xxxxxxxxxxxxxxxxxxxxx" ``` -Either pair is accepted. If you set both, `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET` takes priority. +Either pair is accepted. If you set both, `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET` takes priority. **Do not keep both pairs around** — a stale value in the losing convention is the classic cause of `invalid_client` after rotating credentials. The dev server logs a `[auth]` warning when both are set with different values. + +Optional: restrict who can sign in by email domain: + +``` +AUTH_ALLOWED_EMAIL_DOMAIN="yourcompany.com" +``` + +When set, Google sign-ins from any other domain are rejected. Leave unset to accept any Google account (they land as `RECRUITER`). ``` AUTH_SECRET="" @@ -157,6 +165,13 @@ pnpm dev If you see "Error 400: redirect_uri_mismatch" the URI you used does not match what is configured in Google Cloud — re-check `NEXT_PUBLIC_APP_URL` and the redirect URIs you saved. +**Troubleshooting `invalid_client` / "OAuth client was not found":** + +1. Check the dev server log for the `[auth] Google OAuth using clientId prefix …` line — that prefix must match an OAuth client that still exists under **APIs & Services → Credentials** in the GCP project. If the client was deleted, create a new one (§2.3) and update the env vars. +2. If the log warns that both `AUTH_GOOGLE_ID` and `GOOGLE_CLIENT_ID` are set with different values, delete the stale one — `AUTH_GOOGLE_ID` wins. +3. Empty strings count as unset: `AUTH_GOOGLE_ID=""` silently falls back to `GOOGLE_CLIENT_ID`. Keep exactly one pair populated. +4. On Vercel, set `AUTH_GOOGLE_ID`, `AUTH_GOOGLE_SECRET`, `AUTH_SECRET`, and `NEXT_PUBLIC_APP_URL` in the project's environment variables and redeploy — env edits do not apply to running deployments. + ### 2.6 Disabling the dev provider in production The dev Credentials provider is only registered when `NODE_ENV === "development"` OR `AUTH_GOOGLE_ID` is empty. In production, **always** set a real `AUTH_GOOGLE_ID` so the dev provider is suppressed. @@ -177,8 +192,8 @@ Resume parsing uses OpenRouter through the OpenAI-compatible SDK. If `OPENROUTER ``` OPENROUTER_API_KEY="sk-or-v1-xxxxxxxxxxxxxxxxxxxx" # Optional. Defaults to the current free OpenRouter fallback used by ScoutLane. -OPENROUTER_MODEL="openrouter/owl-alpha" -OPENROUTER_FALLBACK_MODELS="openrouter/free,openrouter/auto" +OPENROUTER_MODEL="google/gemini-2.5-flash" +OPENROUTER_FALLBACK_MODELS="google/gemini-2.5-flash-lite,openrouter/auto" ``` ### 3.3 What happens when parsing fails @@ -187,32 +202,43 @@ The applicant row is updated with `parsingStatus="FAILED"` and the error message --- -## 4. Background workers +## 4. Background jobs -ScoutLane runs two long-running worker processes off the web server. They cannot run on Vercel serverless — host them on Render, Railway, Fly, or any process supervisor with persistent execution. +Resume parsing and email delivery go through a job dispatcher (`src/server/jobs/dispatch.ts`) with two execution modes, controlled by `JOB_RUNNER`: + +| Mode | How it runs | When | +|---|---|---| +| `inline` | After the HTTP response in the same serverless invocation, via `next/server` `after()`. No worker process needed. | **Default on Vercel.** | +| `worker` | Enqueued to pg-boss; long-running workers process the queue. | **Default elsewhere.** Requires the workers below. | + +**Vercel deployments need no worker** — leave `JOB_RUNNER` unset and parsing + emails run inline after each response. Note the inline LLM call is bounded by the function's `maxDuration` (60s on the apply route); if it gets cut, the admin Retry button re-runs parsing. + +### Worker mode processes | Process | Command | Triggered by | |---|---|---| -| Resume parser | `pnpm worker:resume` | New applications → `enqueueResumeParseJob` | -| Email sender | `pnpm worker:emails` | New applications, admin sends, job alerts → `enqueueEmailJob` | +| Resume parser | `pnpm worker:resume` | New applications → resume parse jobs | +| Email sender | `pnpm worker:emails` | New applications → confirmation + admin notification jobs | Both workers share the same `DATABASE_URL` as the web app (pg-boss stores jobs in PostgreSQL). On startup they create their queues if missing and run forever. -**Resume parsing mode:** `RESUME_PARSE_MODE` controls how resume parsing runs. The default is `"queue-and-inline"` — parsing happens immediately during submission AND gets enqueued for redundancy. In production with high traffic, switch to `"queue"` to avoid blocking application submissions, and run `pnpm worker:resume` on a persistent host. +**Resume parsing mode:** `RESUME_PARSE_MODE` controls how parsing is triggered on submission. The default is `"queue"` — the applicant never waits; the dispatcher runs it via `JOB_RUNNER`. `"inline"` parses synchronously inside the request (local diagnostics), `"queue-and-inline"` does both. ### 4.1 Local dev -With the default `RESUME_PARSE_MODE=queue-and-inline`, you only need the email worker for local dev: +Simplest local setup — no workers at all: ``` +JOB_RUNNER=inline pnpm dev -pnpm worker:emails ``` -If you switch to `RESUME_PARSE_MODE=queue`, also start the resume worker: +Or keep `JOB_RUNNER=worker` (the non-Vercel default) and run both workers: ``` +pnpm dev pnpm worker:resume +pnpm worker:emails ``` ### 4.2 Hosted (recommended on Render) diff --git a/next.config.ts b/next.config.ts index a60cb78..32c85e5 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,7 +6,14 @@ const nextConfig: NextConfig = { bodySizeLimit: "10mb", }, }, - serverExternalPackages: ["@prisma/client", "pg", "@google-cloud/storage", "pdf-parse"], + serverExternalPackages: [ + "@prisma/client", + "pg", + "@google-cloud/storage", + "pdf-parse", + "pdfjs-dist", + "@napi-rs/canvas", + ], async headers() { return [ { diff --git a/src/app/(admin)/admin/jobs/[id]/applicants/[applicantId]/page.tsx b/src/app/(admin)/admin/jobs/[id]/applicants/[applicantId]/page.tsx index e3176df..e5e857f 100644 --- a/src/app/(admin)/admin/jobs/[id]/applicants/[applicantId]/page.tsx +++ b/src/app/(admin)/admin/jobs/[id]/applicants/[applicantId]/page.tsx @@ -13,7 +13,7 @@ import { ApplicantResumeDataEditor } from "./_components/ApplicantResumeDataEdit import { ApplicantCustomFields, type ConfiguredCustomField } from "./_components/ApplicantCustomFields"; import { DeleteApplicantButton } from "./_components/DeleteApplicantButton"; import { InterviewDatePicker } from "@/components/applicants/InterviewDatePicker"; -import { canEmbedResume } from "@/lib/resume/preview"; +import { getResumePreviewKind } from "@/lib/resume/preview"; function matchBadgeColor(score: number | null): string { if (score === null) return "bg-slate-100 text-slate-500"; @@ -132,7 +132,8 @@ export default async function ApplicantDetailPage({ params }: ApplicantDetailPag // Decide inline preview by the stored MIME type (resume URLs frequently lack // a usable extension), falling back to the URL extension for externally - // hosted files. PDFs/text embed; Word documents are download-only. + // hosted files. PDFs/text embed natively; Word documents render through the + // sanitized HTML preview route. const resumeObjectName = applicant.resumeUrl ? getResumeObjectName(applicant.resumeUrl) : null; @@ -142,14 +143,19 @@ export default async function ApplicantDetailPage({ params }: ApplicantDetailPag select: { contentType: true }, }) : null; + const resumePreviewKind = applicant.resumeUrl + ? getResumePreviewKind({ + contentType: resumeFile?.contentType, + pathname: getResumePathname(applicant.resumeUrl), + }) + : "none"; const resumeEmbedSrc = - applicant.resumeUrl && - canEmbedResume({ - contentType: resumeFile?.contentType, - pathname: getResumePathname(applicant.resumeUrl), - }) + resumePreviewKind === "native" ? applicant.resumeUrl - : null; + : resumePreviewKind === "docx-html" && resumeObjectName + ? `/api/resumes/preview/${resumeObjectName}` + : null; + const resumeEmbedSandbox = resumePreviewKind === "docx-html" ? "" : undefined; const confidenceColors: Record = { high: "bg-emerald-100 text-emerald-700", @@ -295,6 +301,7 @@ export default async function ApplicantDetailPage({ params }: ApplicantDetailPag