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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ RESUME_PARSE_MODE="queue"
# after(); no worker needed. Default on Vercel.
JOB_RUNNER=""

# Dev-login escape hatch for local production-build smoke tests ONLY.
# The dev provider grants ADMIN to any email -- NEVER set this on a deployed
# environment. Dev login is otherwise enabled only under `next dev`.
ALLOW_DEV_LOGIN=""

# ── File Storage (Google Cloud Storage) ──────────────────────────────────────
# Create a service account at https://console.cloud.google.com/iam-admin/serviceaccounts
# and grant the Storage Object Admin role on the bucket.
Expand Down
7 changes: 4 additions & 3 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,10 @@ After deploying or rotating keys, **restart both workers** so the new envs take

### 4.3 What happens without the workers

- With `RESUME_PARSE_MODE=queue-and-inline` (default), parsing happens inline during submission — the resume worker is optional for redundancy only.
- With `RESUME_PARSE_MODE=queue`, `enqueueResumeParseJob` returns immediately; the row stays in `PENDING` until the resume worker picks it up.
- `enqueueEmailJob` returns immediately; admin notification and applicant confirmation emails are never sent without the email worker — the applicant submission still succeeds.
- With `JOB_RUNNER=inline` (default on Vercel), no workers are needed: resume parsing and email delivery run after the response in the same serverless invocation.
- With `RESUME_PARSE_MODE=queue` (default), parsing is deferred to the job runner: in `worker` mode the row stays in `PENDING` until the resume worker picks it up; in `inline` mode it completes post-response.
- `RESUME_PARSE_MODE=queue-and-inline` additionally parses during submission — redundant unless you want a synchronous result alongside the queue.
- In `worker` mode, `enqueueEmailJob` returns immediately; admin notification and applicant confirmation emails are never sent without the email worker — the applicant submission still succeeds.

Both are visible in `/admin/notifications` (parsing failures + email skips).

Expand Down
38 changes: 38 additions & 0 deletions docs/session-reports/2026-06-11-resume-pipeline-oauth-emails.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# 2026-06-11 — Resume pipeline, OAuth, emails, MCP cleanup

## Goal

"fix mcp, connect to composio … fix scoutlane in the parsing whenever i upload a pdf it does not parse correctly … preferable gemini 2.5 flash … word is not embed, pdf is embeded, word cna be download, but pdf not found. The embeed needs fixing, then the login with my google email … fix the bugs with notifications and setting up the emails directly for people onces they submit their application."

## What landed (PR #89, branch `fix/resume-pipeline-and-notifications`)

- `5ae6980` fix(resume): pdf-parse via dynamic import compatible with Next bundling (+ committed PDF fixture, un-skipped test)
- `133fa2d` chore: .gitattributes marking binary assets (prevents fixture corruption)
- `8d8d3a4` feat(llm): default model `google/gemini-2.5-flash` + fallbacks, tightened prompt
- (storage) feat(resumes): shared disk→DB resolution, Content-Disposition, clearer 404
- (preview) feat(resumes): DOCX→sanitized-HTML inline preview, authenticated route, sandboxed iframe
- (jobs) feat(jobs): `JOB_RUNNER=worker|inline` dispatcher; inline `after()` path = no workers on Vercel
- `ac86c72` fix(auth): conflicting Google env warning, Google users upserted with org, `AUTH_ALLOWED_EMAIL_DOMAIN`
- `b9ddcef` fix(jobs): sequential inline admin fan-out (Resend rate limit)
- MCP: removed broken `vercel` (SSE) + misconfigured `caveman-shrink`; Composio already connected
- Local `.env`: Google creds migrated legacy→`AUTH_GOOGLE_*` (root cause of broken login: empty `AUTH_GOOGLE_*` shadowing real legacy values), `JOB_RUNNER="inline"`, model updated

E2E smoke verified (prod build, no workers): PDF parse COMPLETED via gemini-2.5-flash; DOCX parse COMPLETED; preview route 401 logged-out; EmailLog rows written for confirmation + admin fan-out.

CodeRabbit review triage (`afa2270`): fixed SETUP.md §4.3 (RESUME_PARSE_MODE default is `queue`; documented JOB_RUNNER=inline), added domain-allowlist bypass regression test and inline fan-out failure-behavior test. Skipped as invalid: preview-route contentType null guard (type is always `string`), endsWith "subdomain bypass" (the `@` anchor already enforces exact domain), synchronous inline sends (fire-and-forget by design; failures land in EmailLog).

## Still open

- Merge PR #89 → main, then main → master to deploy
- Vercel env: set `AUTH_GOOGLE_ID/SECRET`, `AUTH_SECRET`, `OPENROUTER_API_KEY`, `RESEND_API_KEY`; verify GCP redirect URI for prod domain
- Verify a Resend domain for production `EMAIL_FROM` (test sender only delivers to account owner)
- Follow-up: `/api/resumes/*` unauthenticated (pre-existing)

## Next recommended action

Merge PR #89, set the Vercel env vars above, then smoke a PDF application on production.

## Risks/blockers encountered

- Resend free tier: unverified domain blocks delivery to non-owner addresses (logged in EmailLog, expected)
- Inline LLM call bounded by serverless maxDuration (60s); Retry button is the recovery path
22 changes: 22 additions & 0 deletions src/app/(admin)/_components/AdminPageSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Skeleton } from "@/components/ui/skeleton";

/**
* Generic route-segment fallback for admin pages: a header row plus a few
* card-shaped blocks. Shown by Next.js while the server component streams.
*/
export function AdminPageSkeleton({ cards = 3 }: { cards?: number }) {
return (
<div className="space-y-6" aria-busy="true" aria-label="Loading page">
<div className="space-y-2">
<Skeleton className="h-7 w-48" />
<Skeleton className="h-4 w-72" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: cards }).map((_, i) => (
<Skeleton key={i} className="h-28 rounded-2xl" />
))}
</div>
<Skeleton className="h-64 rounded-2xl" />
</div>
);
}
5 changes: 5 additions & 0 deletions src/app/(admin)/admin/applicants/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AdminPageSkeleton } from "@/app/(admin)/_components/AdminPageSkeleton";

export default function Loading() {
return <AdminPageSkeleton />;
}
5 changes: 5 additions & 0 deletions src/app/(admin)/admin/email-templates/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AdminPageSkeleton } from "@/app/(admin)/_components/AdminPageSkeleton";

export default function Loading() {
return <AdminPageSkeleton />;
}
5 changes: 5 additions & 0 deletions src/app/(admin)/admin/integrations/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AdminPageSkeleton } from "@/app/(admin)/_components/AdminPageSkeleton";

export default function Loading() {
return <AdminPageSkeleton />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Sparkles } from "lucide-react";
import { toast } from "sonner";

interface RescoreButtonProps {
applicantId: string;
Expand All @@ -15,8 +16,20 @@ export function RescoreButton({ applicantId, disabled }: RescoreButtonProps) {

function handleClick() {
startTransition(async () => {
await fetch(`/api/admin/applicants/${applicantId}/rescore`, { method: "POST" });
router.refresh();
try {
const response = await fetch(`/api/admin/applicants/${applicantId}/rescore`, {
method: "POST",
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { error?: string } | null;
toast.error(body?.error ?? "Re-scoring failed.");
return;
}
toast.success("Match score updated.");
router.refresh();
} catch {
toast.error("Re-scoring failed. Check your connection and try again.");
}
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { RefreshCw, Loader2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";

interface RetryParsingButtonProps {
applicantId: string;
Expand All @@ -26,6 +27,9 @@ export function RetryParsingButton({ applicantId, status }: RetryParsingButtonPr
const body = (await response.json().catch(() => null)) as { error?: string } | null;
if (!response.ok) {
setError(body?.error ?? "Resume parsing failed.");
toast.error(body?.error ?? "Resume parsing failed.");
} else {
toast.success("Resume parsed. Refreshing applicant data…");
}
router.refresh();
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AdminPageSkeleton } from "@/app/(admin)/_components/AdminPageSkeleton";

export default function Loading() {
return <AdminPageSkeleton />;
}
5 changes: 5 additions & 0 deletions src/app/(admin)/admin/jobs/[id]/applicants/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AdminPageSkeleton } from "@/app/(admin)/_components/AdminPageSkeleton";

export default function Loading() {
return <AdminPageSkeleton />;
}
5 changes: 5 additions & 0 deletions src/app/(admin)/admin/jobs/[id]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AdminPageSkeleton } from "@/app/(admin)/_components/AdminPageSkeleton";

export default function Loading() {
return <AdminPageSkeleton />;
}
5 changes: 5 additions & 0 deletions src/app/(admin)/admin/jobs/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AdminPageSkeleton } from "@/app/(admin)/_components/AdminPageSkeleton";

export default function Loading() {
return <AdminPageSkeleton />;
}
5 changes: 5 additions & 0 deletions src/app/(admin)/admin/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AdminPageSkeleton } from "@/app/(admin)/_components/AdminPageSkeleton";

export default function Loading() {
return <AdminPageSkeleton />;
}
5 changes: 5 additions & 0 deletions src/app/(admin)/admin/notifications/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AdminPageSkeleton } from "@/app/(admin)/_components/AdminPageSkeleton";

export default function Loading() {
return <AdminPageSkeleton />;
}
5 changes: 5 additions & 0 deletions src/app/(admin)/admin/settings/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AdminPageSkeleton } from "@/app/(admin)/_components/AdminPageSkeleton";

export default function Loading() {
return <AdminPageSkeleton />;
}
5 changes: 5 additions & 0 deletions src/app/(admin)/admin/templates/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AdminPageSkeleton } from "@/app/(admin)/_components/AdminPageSkeleton";

export default function Loading() {
return <AdminPageSkeleton />;
}
26 changes: 26 additions & 0 deletions src/app/(public)/careers/[slug]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export default function Loading() {
return (
<div
className="flex min-h-screen items-center justify-center"
style={{ background: "#0c1529" }}
aria-busy="true"
aria-label="Loading job posting"
>
<div className="flex flex-col items-center gap-4">
<div
className="inline-flex h-14 w-14 animate-pulse items-center justify-center rounded-[16px] text-white"
style={{
background: "linear-gradient(135deg, #1B2CC1, #161fa8)",
fontFamily: "var(--font-display)",
fontWeight: 700,
fontSize: "24px",
letterSpacing: "-0.04em",
}}
>
SL
</div>
<p className="text-sm text-slate-400">Loading position…</p>
</div>
</div>
);
}
26 changes: 26 additions & 0 deletions src/app/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export default function Loading() {
return (
<div
className="flex min-h-screen items-center justify-center"
style={{ background: "#0c1529" }}
aria-busy="true"
aria-label="Loading"
>
<div className="flex flex-col items-center gap-4">
<div
className="inline-flex h-14 w-14 animate-pulse items-center justify-center rounded-[16px] text-white"
style={{
background: "linear-gradient(135deg, #1B2CC1, #161fa8)",
fontFamily: "var(--font-display)",
fontWeight: 700,
fontSize: "24px",
letterSpacing: "-0.04em",
}}
>
SL
</div>
<p className="text-sm text-slate-400">Loading…</p>
</div>
</div>
);
}
36 changes: 35 additions & 1 deletion src/app/signin/_components/SignInForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,27 @@ import { AnimatedBackground } from "@/components/AnimatedBackground";

interface SignInFormProps {
showDevLogin: boolean;
googleEnabled: boolean;
}

export function SignInForm({ showDevLogin }: SignInFormProps) {
const AUTH_ERROR_MESSAGES: Record<string, string> = {
AccessDenied:
"Sign-in was denied. Your account is not allowed to access this workspace.",
OAuthCallbackError:
"Google sign-in could not be completed. Please try again.",
OAuthSignInError: "Could not start Google sign-in. Please try again.",
Configuration:
"Sign-in is misconfigured on the server. Contact an administrator.",
Verification: "The sign-in link is no longer valid. Please try again.",
};

export function SignInForm({ showDevLogin, googleEnabled }: SignInFormProps) {
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") || "/admin";
const errorCode = searchParams.get("error");
const errorMessage = errorCode
? AUTH_ERROR_MESSAGES[errorCode] ?? "Sign-in failed. Please try again."
: null;
const [email, setEmail] = useState("admin@scoutlane.local");
const [loading, setLoading] = useState(false);

Expand Down Expand Up @@ -48,6 +64,23 @@ export function SignInForm({ showDevLogin }: SignInFormProps) {
</div>

<div className="rounded-2xl border border-slate-700/60 bg-slate-900/60 p-6 backdrop-blur-sm">
{errorMessage && (
<div
role="alert"
className="mb-3 rounded-xl border border-red-500/40 bg-red-950/40 px-3 py-2.5 text-sm text-red-300"
>
{errorMessage}
</div>
)}

{!googleEnabled && !showDevLogin && (
<p className="mb-3 rounded-xl border border-amber-500/40 bg-amber-950/40 px-3 py-2.5 text-sm text-amber-300">
Sign-in is not configured on this deployment. An administrator
must set the Google OAuth environment variables.
</p>
)}

{googleEnabled && (
<button
onClick={() => signIn("google", { redirectTo: callbackUrl })}
className="mb-3 flex w-full items-center justify-center gap-2 rounded-xl border border-slate-600 bg-white px-4 py-2.5 text-sm font-medium text-slate-900 transition hover:bg-slate-100"
Expand All @@ -60,6 +93,7 @@ export function SignInForm({ showDevLogin }: SignInFormProps) {
</svg>
Sign in with Google
</button>
)}

{showDevLogin && (
<>
Expand Down
5 changes: 3 additions & 2 deletions src/app/signin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { Suspense } from "react";
import { isDevLoginAllowed } from "@/lib/auth/auth.config";
import { isDevLoginAllowed, isGoogleAuthConfigured } from "@/lib/auth/auth.config";
import { SignInForm } from "./_components/SignInForm";

export default function SignInPage() {
const showDevLogin = isDevLoginAllowed();
const googleEnabled = isGoogleAuthConfigured();

return (
<Suspense>
<SignInForm showDevLogin={showDevLogin} />
<SignInForm showDevLogin={showDevLogin} googleEnabled={googleEnabled} />
</Suspense>
);
}
4 changes: 2 additions & 2 deletions src/components/public/ApplicationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,15 +241,15 @@ export function ApplicationForm({ jobSlug, customFields = [] }: ApplicationFormP
<Input
{...field}
type="file"
accept=".pdf,.doc,.docx,.csv"
accept=".pdf,.doc,.docx,.csv,.txt"
className="h-11 border-[#cbd5e1] bg-white text-[#0c1529] file:text-[#0c1529]"
onChange={(event) => {
onChange(event.target.files?.[0]);
}}
/>
<div className="mt-3 flex items-center gap-2 text-xs text-[#475569]">
<Upload className="h-3.5 w-3.5" />
PDF, DOC, DOCX, or CSV up to 5 MB
PDF, DOC, DOCX, TXT, or CSV up to 5 MB
</div>
</>
)}
Expand Down
13 changes: 13 additions & 0 deletions src/components/ui/skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils/cn";

function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-slate-200/80", className)}
{...props}
/>
);
}

export { Skeleton };
14 changes: 13 additions & 1 deletion src/lib/auth/auth.config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,22 @@ describe("isDevLoginAllowed", () => {
expect(isDevLoginAllowed()).toBe(false);
});

it("allows dev login in production when Google auth is NOT configured", () => {
it("disallows dev login in production even when Google auth is NOT configured", () => {
vi.stubEnv("NODE_ENV", "production");
expect(isDevLoginAllowed()).toBe(false);
});

it("allows dev login in production only with the explicit ALLOW_DEV_LOGIN=1 escape hatch", () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("ALLOW_DEV_LOGIN", "1");
expect(isDevLoginAllowed()).toBe(true);
});

it("ignores ALLOW_DEV_LOGIN values other than \"1\"", () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("ALLOW_DEV_LOGIN", "true");
expect(isDevLoginAllowed()).toBe(false);
});
});

describe("authConfig.callbacks.jwt", () => {
Expand Down
Loading
Loading