diff --git a/app/(main)/resume/page.tsx b/app/(main)/resume/page.tsx new file mode 100644 index 0000000..763d4ec --- /dev/null +++ b/app/(main)/resume/page.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ResumeChatColumn } from "@/components/resume/ResumeChatColumn"; +import { ResumePanel } from "@/components/resume/ResumePanel"; +import { + useCreateResumeDraft, + useDeleteResumeDraft, + useResumeDraft, + useResumeDrafts, + useResumeUsage, + useSendResumeMessage, +} from "@/hooks/useResume"; +import { useCurrentUser } from "@/hooks/useProfile"; +import { + ResumeBusyError, + ResumeLimitError, +} from "@/services/resume"; +import { + RESUME_DAILY_MESSAGE_LIMIT, + emptyResume, + isResumeEmpty, +} from "@/lib/resume/schema"; + +/** + * AI Resume Builder — split screen: conversation (left) + live-rendering resume + * (right). Mirrors the Companion two-pane flex; on mobile it's master/detail + * (toggle between chat and resume). Drafts + the daily cap are local-only. + */ +export default function ResumePage() { + const { t } = useTranslation(); + const [activeId, setActiveId] = useState(null); + const [sendError, setSendError] = useState(null); + // Mobile master/detail: false = chat visible, true = resume visible. + const [mobileShowResume, setMobileShowResume] = useState(false); + + const draftsQuery = useResumeDrafts(); + const drafts = useMemo(() => draftsQuery.data ?? [], [draftsQuery.data]); + const currentUserQuery = useCurrentUser(); + // The shown draft: the explicitly-selected one, else the newest. Deriving it + // (rather than syncing via setState in an effect) auto-follows list changes — + // a freshly created draft becomes drafts[0] and is picked up here. + const effectiveActiveId = activeId ?? drafts[0]?.id ?? null; + const draftQuery = useResumeDraft(effectiveActiveId); + const draft = draftQuery.data ?? null; + const usageQuery = useResumeUsage(); + + const createDraft = useCreateResumeDraft(); + const sendMessage = useSendResumeMessage(); + const deleteDraft = useDeleteResumeDraft(); + + const remaining = usageQuery.data?.remaining ?? RESUME_DAILY_MESSAGE_LIMIT; + const limitReached = remaining <= 0; + + // Bootstrap: when the user has no drafts at all, create a first one (once the + // current user has loaded so contact prefill is populated). No setState here — + // the new draft surfaces via `effectiveActiveId`'s drafts[0] fallback. + const bootstrapping = useRef(false); + useEffect(() => { + if (bootstrapping.current) return; + if (!draftsQuery.isSuccess || drafts.length > 0) return; + if (!currentUserQuery.isFetched) return; + bootstrapping.current = true; + createDraft.mutateAsync().catch(() => { + bootstrapping.current = false; + }); + }, [draftsQuery.isSuccess, drafts.length, currentUserQuery.isFetched, createDraft]); + + async function handleSend(text: string) { + if (!effectiveActiveId) return; + // Serialize turns: ignore a new send while one is still in flight so two + // mutations can't read the same draft and overwrite each other. + if (sendMessage.isPending) return; + setSendError(null); + try { + await sendMessage.mutateAsync({ draftId: effectiveActiveId, text }); + } catch (err) { + if (err instanceof ResumeLimitError) { + setSendError(t("resume.limitReachedToast")); + } else if (err instanceof ResumeBusyError) { + setSendError(t("resume.busy")); + } else { + console.error("Resume: failed to send message", err); + setSendError(t("resume.sendFailed")); + } + } + } + + async function handleNewDraft() { + setSendError(null); + try { + const created = await createDraft.mutateAsync(); + setActiveId(created.id); + setMobileShowResume(false); + } catch (err) { + console.error("Resume: failed to create draft", err); + } + } + + function handleSelectDraft(id: string) { + setSendError(null); + setActiveId(id); + setMobileShowResume(false); + } + + async function handleDeleteDraft(id: string) { + try { + await deleteDraft.mutateAsync(id); + if (id === effectiveActiveId) { + // Drop the explicit selection; effectiveActiveId falls back to the next + // newest draft. If none remain, re-arm the bootstrap to create a fresh one. + setActiveId(null); + if (drafts.filter((d) => d.id !== id).length === 0) { + bootstrapping.current = false; + } + } + } catch (err) { + console.error("Resume: failed to delete draft", err); + } + } + + const resumeData = draft?.resume ?? emptyResume(); + + return ( +
+ setMobileShowResume(true)} + /> + setMobileShowResume(false)} + /> +
+ ); +} diff --git a/app/api/resume/route.ts b/app/api/resume/route.ts new file mode 100644 index 0000000..7be5713 --- /dev/null +++ b/app/api/resume/route.ts @@ -0,0 +1,172 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { generateResumeTurn, ResumeUpstreamError } from "@/lib/resume/generateTurn"; +import { checkAndIncrementResumeUsage } from "@/lib/resume/serverRateLimit"; +import { + normalizeResumeData, + MAX_RESUME_MESSAGE_LEN, + RESUME_HISTORY_TURNS, +} from "@/lib/resume/schema"; +import { isSupportedLanguage, DEFAULT_LANGUAGE } from "@/lib/i18n/config"; +import type { + ResumeChatRole, + ResumeProfileContext, + ResumeTurnRequest, +} from "@/types/resume"; +import type { Persona, Stage } from "@/types"; + +/** + * Server-side turn generator for the AI Resume Builder (local prototype path). + * + * The browser POSTs the conversation so far + the resume built so far; this + * route asks DeepSeek (via OpenRouter, pinned to deepseek/deepseek-v4-flash) for + * the next structured turn and returns { reply, suggestions, resume, complete }. + * + * PROTOTYPE NOTE: production would move this to the resume-chat Supabase edge + * function (see supabase/functions/resume-chat/) and invoke it server→server — + * the same shape as /api/companion → rag-query. It runs here directly against + * OpenRouter so the prototype works with no Docker / functions-serve. Auth is + * still required (the endpoint spends the shared OpenRouter budget). Node + * runtime; maxDuration mirrors /api/companion (LLM completion with a timeout). + */ +export const runtime = "nodejs"; +export const maxDuration = 60; + +const VALID_PERSONAS: Persona[] = [ + "international_student", + "skilled_worker", + "refugee", + "other", +]; + +function clampProfile(raw: unknown): ResumeProfileContext { + const p = ( + raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {} + ) as Record; + const persona = + typeof p.persona === "string" && VALID_PERSONAS.includes(p.persona as Persona) + ? (p.persona as Persona) + : null; + // Require an actual integer — Number("0")/Number(false)/Number("") all coerce + // to 0 and would otherwise sneak through as a valid stage. + const stage: Stage | null = + typeof p.stage === "number" && + Number.isInteger(p.stage) && + p.stage >= 0 && + p.stage <= 4 + ? (p.stage as Stage) + : null; + const responseLanguage = isSupportedLanguage(p.responseLanguage) + ? p.responseLanguage + : DEFAULT_LANGUAGE; + const asString = (v: unknown, max: number): string | null => + typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null; + return { + firstName: asString(p.firstName, 80), + persona, + stage, + city: asString(p.city, 80), + province: asString(p.province, 40), + email: asString(p.email, 160), + responseLanguage, + }; +} + +function clampHistory( + raw: unknown, +): { role: ResumeChatRole; content: string }[] { + if (!Array.isArray(raw)) return []; + // Walk newest-first and keep only the last RESUME_HISTORY_TURNS valid entries + // (in chronological order), so a huge client history isn't fully allocated + // just to be sliced away. Matches services/resume.ts + the edge function. + const out: { role: ResumeChatRole; content: string }[] = []; + for ( + let i = raw.length - 1; + i >= 0 && out.length < RESUME_HISTORY_TURNS; + i -= 1 + ) { + const r = (raw[i] ?? {}) as Record; + const role: ResumeChatRole = r.role === "assistant" ? "assistant" : "user"; + // Trim before the truthiness check so whitespace-only entries don't count + // toward RESUME_HISTORY_TURNS and displace real history. + const content = + typeof r.content === "string" ? r.content.trim().slice(0, 4000) : ""; + if (content) out.unshift({ role, content }); + } + return out; +} + +export async function POST(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: Record; + try { + const parsed = await req.json(); + // `null`, arrays, and primitives parse as valid JSON but aren't request + // objects — reject them with a 400 instead of throwing a 500 on field access. + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + body = parsed as Record; + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const message = typeof body.message === "string" ? body.message.trim() : ""; + if (!message) { + return NextResponse.json({ error: "Message is required" }, { status: 400 }); + } + if (message.length > MAX_RESUME_MESSAGE_LEN) { + return NextResponse.json( + { error: "Message is too long" }, + { status: 413 }, + ); + } + + // Server-side daily cap keyed by user (the client localStorage cap is + // bypassable). Checked before spending an OpenRouter call. + if (!checkAndIncrementResumeUsage(user.id)) { + return NextResponse.json( + { error: "Daily resume-builder limit reached.", code: "daily_limit_reached" }, + { status: 429 }, + ); + } + + const turn: ResumeTurnRequest = { + message, + history: clampHistory(body.history), + currentResume: normalizeResumeData(body.currentResume), + profile: clampProfile(body.profile), + }; + + try { + const result = await generateResumeTurn(turn); + return NextResponse.json(result); + } catch (error) { + if (error instanceof ResumeUpstreamError) { + console.error("Resume: upstream failure", { + status: error.status, + retryable: error.retryable, + message: error.message, + }); + // 503 for retryable upstream trouble (429 / 5xx / timeout); preserve a + // non-retryable 500 (e.g. missing key) instead of masking it as 502. + const status = error.retryable ? 503 : error.status === 500 ? 500 : 502; + return NextResponse.json( + { error: "The resume assistant is busy. Please try again.", retryable: error.retryable }, + { status }, + ); + } + console.error("Resume: turn generation failed", error); + return NextResponse.json( + { error: "Failed to generate a reply." }, + { status: 500 }, + ); + } +} diff --git a/app/globals.css b/app/globals.css index b33519a..2c2325b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -309,3 +309,38 @@ input:-webkit-autofill:active { scroll-behavior: auto !important; } } + +/* + * Resume Builder — "Download PDF" prints ONLY the rendered resume, not the app + * shell or the chat. Classic single-element isolation: hide everything, re-show + * the .resume-paper subtree, and float it to the page origin. Text stays real + + * selectable (ATS-friendly) — no rasterization. The user picks "Save as PDF". + */ +@media print { + body * { + visibility: hidden !important; + } + .resume-paper, + .resume-paper * { + visibility: visible !important; + } + .resume-paper { + position: absolute !important; + inset-inline-start: 0 !important; + top: 0 !important; + margin: 0 !important; + width: 100% !important; + max-width: 100% !important; + /* Physical page margins for print — absolute units (inches) are paper + geometry, intentionally not part of the screen brand-spacing scale. */ + padding: 0.5in 0.55in !important; + box-shadow: none !important; /* also clears the Tailwind ring (ring == box-shadow) */ + + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + @page { + size: letter; + margin: 0; + } +} diff --git a/components/companion/ChatInput.tsx b/components/companion/ChatInput.tsx index 8293dda..86430d8 100644 --- a/components/companion/ChatInput.tsx +++ b/components/companion/ChatInput.tsx @@ -10,6 +10,8 @@ interface ChatInputProps { onSend: (text: string) => void; placeholder?: string; inputRef?: React.Ref; + /** Blocks input + send while a turn is in flight (prevents concurrent sends). */ + disabled?: boolean; } /** Companion message input — controlled; Enter sends, Shift+Enter inserts a newline. */ @@ -19,9 +21,10 @@ export function ChatInput({ onSend, placeholder, inputRef, + disabled = false, }: ChatInputProps) { const { t } = useTranslation(); - const canSend = value.trim().length > 0; + const canSend = !disabled && value.trim().length > 0; function send() { if (!canSend) return; @@ -33,6 +36,7 @@ export function ChatInput({