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
26 changes: 19 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -79,9 +90,10 @@ EMAIL_FROM="ScoutLane <onboarding@resend.dev>"
# 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"
Expand Down
11 changes: 11 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
48 changes: 37 additions & 11 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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="<generate with: openssl rand -base64 32>"
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
{
Expand Down
23 changes: 15 additions & 8 deletions src/app/(admin)/admin/jobs/[id]/applicants/[applicantId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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<string, string> = {
high: "bg-emerald-100 text-emerald-700",
Expand Down Expand Up @@ -295,6 +301,7 @@ export default async function ApplicantDetailPage({ params }: ApplicantDetailPag
<iframe
title={`${applicant.name} resume`}
src={resumeEmbedSrc}
sandbox={resumeEmbedSandbox}
className="h-[720px] w-full bg-white"
/>
</div>
Expand Down
4 changes: 4 additions & 0 deletions src/app/(public)/careers/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import type { Metadata } from "next";
import Link from "next/link";
import { MapPin, Briefcase, DollarSign, ArrowLeft, Building, LogIn } from "lucide-react";

// Inline job runner work (resume parse + email sends via after()) can outlive
// the default serverless duration; give the apply action room to finish.
export const maxDuration = 60;

interface Props {
params: Promise<{ slug: string }>;
}
Expand Down
4 changes: 2 additions & 2 deletions src/app/api/admin/jobs/parse-retry/[applicantId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db/prisma";
import { auth } from "@/lib/auth/auth";
import { parseApplicantResumeFromUrl } from "@/lib/resume/parseApplicantResume";
import { enqueueResumeParseJob } from "@/server/queues/resume";
import { dispatchResumeParse } from "@/server/jobs/dispatch";

export const runtime = "nodejs";
export const maxDuration = 60;
Expand Down Expand Up @@ -47,7 +47,7 @@ export async function POST(
return NextResponse.json({ success: true, status: "COMPLETED" });
}

await enqueueResumeParseJob({ applicantId, resumeUrl: applicant.resumeUrl });
await dispatchResumeParse({ applicantId, resumeUrl: applicant.resumeUrl });
return NextResponse.json({ success: true, status: "PENDING" });
} catch (error) {
console.error("[parse-retry] parse failed:", error);
Expand Down
81 changes: 32 additions & 49 deletions src/app/api/resumes/[...objectName]/route.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,48 @@
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db/prisma";
import { LOCAL_RESUME_STORAGE_DIR } from "@/lib/storage/upload";
import { canEmbedResume } from "@/lib/resume/preview";
import { buildContentDisposition, readResumeObject } from "@/lib/resume/storage-read";

export const dynamic = "force-dynamic";

const CONTENT_TYPES: Record<string, string> = {
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".pdf": "application/pdf",
".csv": "text/csv; charset=utf-8",
};

function resolveLocalResumePath(parts: string[]): string | null {
const root = path.resolve(LOCAL_RESUME_STORAGE_DIR);
const filePath = path.resolve(root, ...parts);
return filePath === root || !filePath.startsWith(`${root}${path.sep}`) ? null : filePath;
}

export async function GET(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ objectName: string[] }> },
) {
const { objectName } = await params;
const storedObjectName = objectName.join("/");
const filePath = resolveLocalResumePath(objectName);

if (!filePath) {
return NextResponse.json({ error: "Invalid resume path" }, { status: 400 });
}

let resume;
try {
const [file, info] = await Promise.all([readFile(filePath), stat(filePath)]);
const ext = path.extname(filePath).toLowerCase();

return new NextResponse(file, {
headers: {
"Cache-Control": "private, max-age=0, no-cache",
"Content-Length": String(info.size),
"Content-Type": CONTENT_TYPES[ext] ?? "application/octet-stream",
"X-Content-Type-Options": "nosniff",
},
});
resume = await readResumeObject(storedObjectName);
} catch {
const stored = await prisma.resumeFile.findUnique({
where: { objectName: storedObjectName },
select: { contentType: true, data: true, size: true },
});

if (!stored) {
return NextResponse.json({ error: "Resume not found" }, { status: 404 });
}
return NextResponse.json({ error: "Invalid resume path" }, { status: 400 });
}

return new NextResponse(Buffer.from(stored.data), {
headers: {
"Cache-Control": "private, max-age=0, no-cache",
"Content-Length": String(stored.size),
"Content-Type": stored.contentType,
"X-Content-Type-Options": "nosniff",
if (!resume) {
return NextResponse.json(
{
error:
"Resume file not found. It may have been uploaded in a different environment or before durable storage was enabled.",
},
});
{ status: 404 },
);
}

// request.nextUrl is absent when invoked with a plain Request (e.g. tests).
const requestUrl = request.nextUrl ?? new URL(request.url);
const forceDownload = requestUrl.searchParams.get("download") === "1";
const disposition =
!forceDownload && canEmbedResume({ contentType: resume.contentType })
? "inline"
: "attachment";

return new NextResponse(new Uint8Array(resume.buffer), {
headers: {
"Cache-Control": "private, max-age=0, no-cache",
"Content-Disposition": buildContentDisposition(disposition, resume.filename),
"Content-Length": String(resume.size),
"Content-Type": resume.contentType,
"X-Content-Type-Options": "nosniff",
},
});
}
Loading
Loading