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
151 changes: 151 additions & 0 deletions app/(main)/resume/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const [sendError, setSendError] = useState<string | null>(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 (
<div className="flex h-[calc(100dvh_-_3.5rem_-_env(safe-area-inset-bottom))] animate-fade-in md:h-dvh">
<ResumeChatColumn
draft={draft}
drafts={drafts}
activeId={effectiveActiveId}
isTyping={sendMessage.isPending}
errorMessage={sendError}
remaining={remaining}
limitReached={limitReached}
onSend={handleSend}
onSelectDraft={handleSelectDraft}
onNewDraft={handleNewDraft}
onDeleteDraft={handleDeleteDraft}
mobileActive={!mobileShowResume}
onShowResume={() => setMobileShowResume(true)}
/>
<ResumePanel
data={resumeData}
isEmpty={isResumeEmpty(resumeData)}
complete={draft?.complete ?? false}
mobileActive={mobileShowResume}
onBackToChat={() => setMobileShowResume(false)}
/>
</div>
);
}
172 changes: 172 additions & 0 deletions app/api/resume/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>;
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 });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return out;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<string, unknown>;
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<string, unknown>;
} 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),
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 },
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
console.error("Resume: turn generation failed", error);
return NextResponse.json(
{ error: "Failed to generate a reply." },
{ status: 500 },
);
}
}
35 changes: 35 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
@page {
size: letter;
margin: 0;
}
}
6 changes: 5 additions & 1 deletion components/companion/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ interface ChatInputProps {
onSend: (text: string) => void;
placeholder?: string;
inputRef?: React.Ref<HTMLTextAreaElement>;
/** 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. */
Expand All @@ -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;
Expand All @@ -33,6 +36,7 @@ export function ChatInput({
<textarea
ref={inputRef}
value={value}
disabled={disabled}
onChange={(event) => onValueChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
Expand Down
Loading