because I got bored of customising my CV for every job
1import {
2 CVParserService as CVParser,
3 type ExistingUserContext,
4 type ParsedCVData,
5} from "@cv/ai-parser";
6import type { User } from "@cv/auth";
7import { PrismaService } from "@cv/system";
8import { TextExtractorRegistry, TEXT_EXTRACTOR_REGISTRY } from "@cv/file-upload";
9import { Inject, Injectable, Logger } from "@nestjs/common";
10import { EducationService } from "@/modules/education/education.service";
11import { AIProviderResolverService } from "@/modules/cv-parser/ai-provider-resolver.service";
12import { UserJobExperienceService } from "@/modules/job-experience/employment/user-job-experience.service";
13import { ProfileService } from "@/modules/profile/profile.service";
14import type { DataImportSource } from "../data-import-source.interface";
15
16/**
17 * Build ExistingUserContext from domain models for AI prompt enrichment.
18 */
19const buildExistingUserContext = (
20 profile: { fullName?: string | null; headline?: string | null; city?: string | null; country?: string | null } | null,
21 jobs: Array<{ company: { name: string }; role: { name: string }; startDate: Date; endDate?: Date }>,
22 educations: Array<{ institution: { name: string }; degree: string; startDate: Date; endDate: Date | null }>,
23): ExistingUserContext | undefined => {
24 const context: ExistingUserContext = {};
25
26 if (profile?.fullName) context.name = profile.fullName;
27 if (profile?.headline) context.headline = profile.headline;
28 if (profile?.city) context.city = profile.city;
29 if (profile?.country) context.country = profile.country;
30
31 if (jobs.length > 0) {
32 context.jobs = jobs.map((j) => {
33 const entry: { company: string; role: string; startDate: string; endDate?: string } = {
34 company: j.company.name,
35 role: j.role.name,
36 startDate: j.startDate.toISOString().slice(0, 10),
37 };
38 if (j.endDate) entry.endDate = j.endDate.toISOString().slice(0, 10);
39 return entry;
40 });
41 }
42
43 if (educations.length > 0) {
44 context.education = educations.map((e) => {
45 const entry: { institution: string; degree: string; startDate: string; endDate?: string } = {
46 institution: e.institution.name,
47 degree: e.degree,
48 startDate: e.startDate.toISOString().slice(0, 10),
49 };
50 if (e.endDate) entry.endDate = e.endDate.toISOString().slice(0, 10);
51 return entry;
52 });
53 }
54
55 const hasData = Object.keys(context).length > 0;
56 return hasData ? context : undefined;
57};
58
59/**
60 * Wraps the existing file extraction + AI parsing pipeline
61 * as a DataImportSource.
62 */
63@Injectable()
64export class FileImportSource implements DataImportSource {
65 readonly name = "file-upload";
66 private readonly logger = new Logger(FileImportSource.name);
67
68 constructor(
69 @Inject(TEXT_EXTRACTOR_REGISTRY)
70 private readonly textExtractorRegistry: TextExtractorRegistry,
71 private readonly providerResolver: AIProviderResolverService,
72 private readonly profileService: ProfileService,
73 private readonly jobExperienceService: UserJobExperienceService,
74 private readonly educationService: EducationService,
75 private readonly prisma: PrismaService,
76 ) {}
77
78 async execute(
79 user: User,
80 params: Record<string, unknown>,
81 onStatus: (message: string) => Promise<void>,
82 ): Promise<ParsedCVData> {
83 const buffer = params["buffer"] as Buffer;
84 const mimeType = params["mimeType"] as string;
85
86 await onStatus("Extracting text from file");
87
88 const extraction = await this.textExtractorRegistry.extract(
89 buffer,
90 mimeType,
91 );
92
93 if (!extraction.success) {
94 throw new Error(`Text extraction failed: ${extraction.error}`);
95 }
96
97 if (!extraction.text || extraction.text.trim().length === 0) {
98 throw new Error("Could not extract any text from the file");
99 }
100
101 this.logger.log(`Extracted ${extraction.text.length} chars from ${mimeType}`);
102 this.logger.debug(`Extracted text preview: ${extraction.text.slice(0, 500)}`);
103
104 await onStatus("Gathering existing profile data");
105
106 const existingContext = await this.gatherUserContext(user);
107
108 await onStatus("Analyzing with AI");
109
110 const provider = await this.providerResolver.resolveForUser(user);
111 this.logger.log(`AI provider: ${provider.constructor.name}`);
112
113 const parser = new CVParser(provider);
114 const result = await parser.parseCVText(extraction.text, existingContext);
115
116 return result;
117 }
118
119 private async gatherUserContext(user: User): Promise<ExistingUserContext | undefined> {
120 try {
121 const firstProfile = await this.prisma.profile.findFirst({
122 where: { userId: user.id },
123 select: { id: true },
124 });
125
126 if (!firstProfile) return undefined;
127
128 const [profile, jobs, educationResult] = await Promise.all([
129 this.profileService.findByIdOrFail(firstProfile.id),
130 this.jobExperienceService.findForProfile(firstProfile.id),
131 this.educationService.findManyForProfile(firstProfile.id),
132 ]);
133
134 const context = buildExistingUserContext(
135 profile,
136 jobs,
137 educationResult.edges.map((e) => e.node),
138 );
139
140 if (context) {
141 this.logger.debug(`User context: ${JSON.stringify(context)}`);
142 }
143
144 return context;
145 } catch (err) {
146 this.logger.warn(
147 `Failed to gather user context (non-fatal): ${err instanceof Error ? err.message : err}`,
148 );
149 return undefined;
150 }
151 }
152}