Files
srbhr--resume-matcher/apps/frontend/lib/api/resume.ts
T
wehub-resource-sync 5bdf4cc89a
Publish Docker Image / publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:36 +08:00

394 lines
13 KiB
TypeScript

import type {
ImprovedResult,
InterviewPrepData,
} from '@/components/common/resume_previewer_context';
import type { ResumeData } from '@/components/dashboard/resume-component';
import { type TemplateSettings } from '@/lib/types/template-settings';
import { type Locale } from '@/i18n/config';
import { API_BASE, DEFAULT_TIMEOUT_MS, apiPost, apiPatch, apiDelete, apiFetch } from './client';
// Matches backend schemas/models.py ResumeData
interface ProcessedResume {
personalInfo?: {
name?: string;
title?: string;
email?: string;
phone?: string;
location?: string;
website?: string | null;
linkedin?: string | null;
github?: string | null;
};
summary?: string;
workExperience?: Array<{
id: number;
title?: string;
company?: string;
location?: string | null;
years?: string;
description?: string[];
}>;
education?: Array<{
id: number;
institution?: string;
degree?: string;
years?: string;
description?: string | null;
}>;
personalProjects?: Array<{
id: number;
name?: string;
role?: string;
years?: string;
github?: string | null;
website?: string | null;
description?: string[];
}>;
additional?: {
technicalSkills?: string[];
languages?: string[];
certificationsTraining?: string[];
awards?: string[];
};
}
interface ResumeResponse {
request_id: string;
data: {
resume_id: string;
raw_resume: {
id: number | null;
content: string;
content_type: string;
created_at: string;
processing_status: 'pending' | 'processing' | 'ready' | 'failed';
};
processed_resume: ProcessedResume | null;
cover_letter?: string | null;
outreach_message?: string | null;
interview_prep?: InterviewPrepData | null;
parent_id?: string | null; // For determining if resume is tailored
title?: string | null;
};
}
/** Response from resume upload endpoint */
export interface ResumeUploadResponse {
message: string;
request_id: string;
resume_id: string;
processing_status: 'pending' | 'processing' | 'ready' | 'failed';
is_master: boolean;
}
interface ImproveResumeConfirmRequest {
resume_id: string;
job_id: string;
improved_data: ResumeData;
improvements: Array<{
suggestion: string;
lineNumber?: number | null;
}>;
}
function normalizeResumeId(resumeId: string): string {
const normalized = resumeId.trim();
if (!normalized) {
throw new Error('Resume ID is required.');
}
return normalized;
}
export interface ResumeListItem {
resume_id: string;
filename: string | null;
is_master: boolean;
parent_id: string | null;
processing_status: 'pending' | 'processing' | 'ready' | 'failed';
created_at: string;
updated_at: string;
title?: string | null;
// Optional lightweight snippet of associated job description (populated client-side)
jobSnippet?: string;
}
async function postImprove(
endpoint: string,
payload: Record<string, unknown>
): Promise<ImprovedResult> {
let response: Response;
try {
// Use the configurable request timeout so NEXT_PUBLIC_REQUEST_TIMEOUT_MS
// actually applies to the long-running improve/preview/confirm calls (#776).
response = await apiPost(endpoint, payload, DEFAULT_TIMEOUT_MS);
} catch (networkError) {
console.error(`Network error during ${endpoint}:`, networkError);
throw networkError;
}
const text = await response.text();
if (!response.ok) {
console.error('Improve failed response body:', text);
throw new Error(`Improve failed with status ${response.status}: ${text}`);
}
try {
return JSON.parse(text) as ImprovedResult;
} catch (parseError) {
console.error('Failed to parse improve response:', parseError, 'Raw response:', text);
throw parseError;
}
}
/** Uploads job descriptions and returns a job_id */
export async function uploadJobDescriptions(
descriptions: string[],
resumeId: string
): Promise<string> {
const res = await apiPost('/jobs/upload', {
job_descriptions: descriptions,
resume_id: resumeId,
});
if (!res.ok) throw new Error(`Upload failed with status ${res.status}`);
const data = await res.json();
return data.job_id[0];
}
/** Improves the resume and returns the full preview object */
export async function improveResume(
resumeId: string,
jobId: string,
promptId?: string
): Promise<ImprovedResult> {
return postImprove('/resumes/improve', {
resume_id: resumeId,
job_id: jobId,
prompt_id: promptId ?? null,
});
}
/** Previews the resume improvement without saving */
export async function previewImproveResume(
resumeId: string,
jobId: string,
promptId?: string
): Promise<ImprovedResult> {
return postImprove('/resumes/improve/preview', {
resume_id: resumeId,
job_id: jobId,
prompt_id: promptId ?? null,
});
}
/** Confirms and saves a tailored resume */
export async function confirmImproveResume(
payload: ImproveResumeConfirmRequest
): Promise<ImprovedResult> {
return postImprove('/resumes/improve/confirm', payload as unknown as Record<string, unknown>);
}
/** Fetches a raw resume record for previewing the original upload */
export async function fetchResume(resumeId: string): Promise<ResumeResponse['data']> {
const res = await apiFetch(`/resumes?resume_id=${encodeURIComponent(resumeId)}`);
if (!res.ok) {
throw new Error(`Failed to load resume (status ${res.status}).`);
}
const payload = (await res.json()) as ResumeResponse;
// Support both raw_resume content (initial) and processed_resume (if available)
// The viewer/builder logic should prioritize processed data if present
return payload.data;
}
export async function fetchResumeList(includeMaster = false): Promise<ResumeListItem[]> {
const res = await apiFetch(`/resumes/list?include_master=${includeMaster ? 'true' : 'false'}`);
if (!res.ok) {
throw new Error(`Failed to load resumes list (status ${res.status}).`);
}
const payload = (await res.json()) as { data: ResumeListItem[] };
return payload.data;
}
export async function updateResume(
resumeId: string,
resumeData: ProcessedResume
): Promise<ResumeResponse['data']> {
const res = await apiPatch(`/resumes/${encodeURIComponent(resumeId)}`, resumeData);
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Failed to update resume (status ${res.status}): ${text}`);
}
const payload = (await res.json()) as ResumeResponse;
return payload.data;
}
export function getResumePdfUrl(
resumeId: string,
settings?: TemplateSettings,
locale?: Locale
): string {
const normalizedId = normalizeResumeId(resumeId);
const params = new URLSearchParams();
if (settings) {
params.set('template', settings.template);
params.set('pageSize', settings.pageSize);
params.set('marginTop', String(settings.margins.top));
params.set('marginBottom', String(settings.margins.bottom));
params.set('marginLeft', String(settings.margins.left));
params.set('marginRight', String(settings.margins.right));
params.set('sectionSpacing', String(settings.spacing.section));
params.set('itemSpacing', String(settings.spacing.item));
params.set('lineHeight', String(settings.spacing.lineHeight));
params.set('fontSize', String(settings.fontSize.base));
params.set('headerScale', String(settings.fontSize.headerScale));
params.set('headerFont', settings.fontSize.headerFont);
params.set('bodyFont', settings.fontSize.bodyFont);
params.set('compactMode', String(settings.compactMode));
params.set('showContactIcons', String(settings.showContactIcons));
params.set('accentColor', settings.accentColor);
} else {
params.set('template', 'swiss-single');
params.set('pageSize', 'A4');
}
if (locale) {
params.set('lang', locale);
}
return `${API_BASE}/resumes/${encodeURIComponent(normalizedId)}/pdf?${params.toString()}`;
}
export async function downloadResumePdf(
resumeId: string,
settings?: TemplateSettings,
locale?: Locale
): Promise<Blob> {
const url = getResumePdfUrl(resumeId, settings, locale);
const res = await apiFetch(url);
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Failed to download resume (status ${res.status}): ${text}`);
}
return await res.blob();
}
/** Deletes a resume by ID */
export async function deleteResume(resumeId: string): Promise<void> {
const res = await apiDelete(`/resumes/${encodeURIComponent(resumeId)}`);
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Failed to delete resume (status ${res.status}): ${text}`);
}
}
/** Updates the cover letter for a resume */
export async function updateCoverLetter(resumeId: string, content: string): Promise<void> {
const res = await apiPatch(`/resumes/${encodeURIComponent(resumeId)}/cover-letter`, { content });
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Failed to update cover letter (status ${res.status}): ${text}`);
}
}
/** Updates the outreach message for a resume */
export async function updateOutreachMessage(resumeId: string, content: string): Promise<void> {
const res = await apiPatch(`/resumes/${encodeURIComponent(resumeId)}/outreach-message`, {
content,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Failed to update outreach message (status ${res.status}): ${text}`);
}
}
/** Renames a resume by updating its title */
export async function renameResume(resumeId: string, title: string): Promise<void> {
const res = await apiPatch(`/resumes/${encodeURIComponent(resumeId)}/title`, { title });
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Failed to rename resume (status ${res.status}): ${text}`);
}
}
/** Downloads cover letter as PDF */
export function getCoverLetterPdfUrl(
resumeId: string,
pageSize: 'A4' | 'LETTER' = 'A4',
locale?: Locale
): string {
const normalizedId = normalizeResumeId(resumeId);
const params = new URLSearchParams({ pageSize });
if (locale) {
params.set('lang', locale);
}
return `${API_BASE}/resumes/${encodeURIComponent(normalizedId)}/cover-letter/pdf?${params.toString()}`;
}
export async function downloadCoverLetterPdf(
resumeId: string,
pageSize: 'A4' | 'LETTER' = 'A4',
locale?: Locale
): Promise<Blob> {
const url = getCoverLetterPdfUrl(resumeId, pageSize, locale);
const res = await apiFetch(url);
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Failed to download cover letter (status ${res.status}): ${text}`);
}
return await res.blob();
}
/** Generates a cover letter on-demand for a tailored resume */
export async function generateCoverLetter(resumeId: string): Promise<string> {
const res = await apiPost(`/resumes/${encodeURIComponent(resumeId)}/generate-cover-letter`, {});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Failed to generate cover letter (status ${res.status}): ${text}`);
}
const data = await res.json();
return data.content;
}
/** Generates an outreach message on-demand for a tailored resume */
export async function generateOutreachMessage(resumeId: string): Promise<string> {
const res = await apiPost(`/resumes/${encodeURIComponent(resumeId)}/generate-outreach`, {});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Failed to generate outreach message (status ${res.status}): ${text}`);
}
const data = await res.json();
return data.content;
}
/** Generates interview preparation on-demand for a tailored resume */
export async function generateInterviewPrep(resumeId: string): Promise<InterviewPrepData> {
const res = await apiPost(`/resumes/${encodeURIComponent(resumeId)}/generate-interview-prep`, {});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Failed to generate interview preparation (status ${res.status}): ${text}`);
}
const data = (await res.json()) as { interview_prep: InterviewPrepData };
return data.interview_prep;
}
/** Retries AI processing for a failed resume */
export async function retryProcessing(resumeId: string): Promise<ResumeUploadResponse> {
const res = await apiPost(`/resumes/${encodeURIComponent(resumeId)}/retry-processing`, {});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Failed to retry processing (status ${res.status}): ${text}`);
}
return res.json();
}
/** Fetches the job description used to tailor a resume */
export async function fetchJobDescription(
resumeId: string
): Promise<{ job_id: string; content: string }> {
const res = await apiFetch(`/resumes/${encodeURIComponent(resumeId)}/job-description`);
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Failed to fetch job description (status ${res.status}): ${text}`);
}
return res.json();
}