because I got bored of customising my CV for every job
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(server): add data import module with file upload and import jobs

+1006
+39
apps/server/prisma/migrations/20260209211505_add_data_import_models/migration.sql
··· 1 + -- CreateTable 2 + CREATE TABLE "user_files" ( 3 + "id" TEXT NOT NULL, 4 + "userId" TEXT NOT NULL, 5 + "fileName" TEXT NOT NULL, 6 + "mimeType" TEXT NOT NULL, 7 + "sizeBytes" INTEGER NOT NULL, 8 + "source" TEXT NOT NULL, 9 + "status" TEXT NOT NULL DEFAULT 'pending', 10 + "statusMessage" TEXT NOT NULL DEFAULT '', 11 + "resultJson" TEXT, 12 + "error" TEXT, 13 + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 14 + "updatedAt" TIMESTAMP(3) NOT NULL, 15 + 16 + CONSTRAINT "user_files_pkey" PRIMARY KEY ("id") 17 + ); 18 + 19 + -- CreateTable 20 + CREATE TABLE "import_jobs" ( 21 + "id" TEXT NOT NULL, 22 + "userFileId" TEXT NOT NULL, 23 + "source" TEXT NOT NULL, 24 + "status" TEXT NOT NULL DEFAULT 'pending', 25 + "statusMessage" TEXT NOT NULL DEFAULT '', 26 + "error" TEXT, 27 + "startedAt" TIMESTAMP(3), 28 + "completedAt" TIMESTAMP(3), 29 + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 30 + "updatedAt" TIMESTAMP(3) NOT NULL, 31 + 32 + CONSTRAINT "import_jobs_pkey" PRIMARY KEY ("id") 33 + ); 34 + 35 + -- AddForeignKey 36 + ALTER TABLE "user_files" ADD CONSTRAINT "user_files_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 37 + 38 + -- AddForeignKey 39 + ALTER TABLE "import_jobs" ADD CONSTRAINT "import_jobs_userFileId_fkey" FOREIGN KEY ("userFileId") REFERENCES "user_files"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+2
apps/server/prisma/migrations/20260217120000_add_file_hash/migration.sql
··· 1 + -- Add fingerprint for file deduplication 2 + ALTER TABLE "user_files" ADD COLUMN "fingerprint" TEXT;
+37
apps/server/prisma/models/data-import.prisma
··· 1 + model UserFile { 2 + id String @id @default(cuid()) 3 + profileId String 4 + fingerprint String? 5 + fileName String 6 + mimeType String 7 + sizeBytes Int 8 + source String 9 + status String @default("pending") 10 + statusMessage String @default("") 11 + resultJson String? 12 + error String? 13 + createdAt DateTime @default(now()) 14 + updatedAt DateTime @updatedAt 15 + 16 + profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade) 17 + importJobs ImportJob[] 18 + 19 + @@map("user_files") 20 + } 21 + 22 + model ImportJob { 23 + id String @id @default(cuid()) 24 + userFileId String 25 + source String 26 + status String @default("pending") 27 + statusMessage String @default("") 28 + error String? 29 + startedAt DateTime? 30 + completedAt DateTime? 31 + createdAt DateTime @default(now()) 32 + updatedAt DateTime @updatedAt 33 + 34 + userFile UserFile @relation(fields: [userFileId], references: [id], onDelete: Cascade) 35 + 36 + @@map("import_jobs") 37 + }
+16
apps/server/src/modules/cv-parser/graphql/upload-file.input.ts
··· 1 + import { Field, ID, InputType } from "@nestjs/graphql"; 2 + 3 + @InputType() 4 + export class UploadFileInput { 5 + @Field(() => ID, { nullable: true, description: "Target profile. If omitted, auto-creates a Default profile." }) 6 + profileId?: string; 7 + 8 + @Field() 9 + fileName!: string; 10 + 11 + @Field() 12 + mimeType!: string; 13 + 14 + @Field({ description: "Base64-encoded file content" }) 15 + content!: string; 16 + }
+79
apps/server/src/modules/cv-parser/graphql/upload-file.resolver.ts
··· 1 + import { createHash } from "node:crypto"; 2 + import type { User as DomainUser } from "@cv/auth"; 3 + import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth"; 4 + import { validateFile } from "@cv/file-upload"; 5 + import { BadRequestException, UseGuards } from "@nestjs/common"; 6 + import { Args, Mutation, Resolver } from "@nestjs/graphql"; 7 + import { CurrentUser } from "@/modules/current-user/current-user.decorator"; 8 + import { UserFileType } from "@/modules/data-import/graphql/user-file.type"; 9 + import { ImportService } from "@/modules/data-import/import.service"; 10 + import { FileImportSource } from "@/modules/data-import/sources/file-import-source"; 11 + import { ProfileService } from "@/modules/profile/profile.service"; 12 + import { UploadFileInput } from "./upload-file.input"; 13 + 14 + const MAX_DECODED_SIZE = 10 * 1024 * 1024; // 10MB 15 + 16 + @Resolver() 17 + @UseGuards(JwtAuthGuard, VerifiedScopeGuard) 18 + export class UploadFileResolver { 19 + constructor( 20 + private readonly importService: ImportService, 21 + private readonly fileImportSource: FileImportSource, 22 + private readonly profileService: ProfileService, 23 + ) {} 24 + 25 + @Mutation(() => UserFileType) 26 + async uploadFile( 27 + @CurrentUser() user: DomainUser, 28 + @Args("input") input: UploadFileInput, 29 + ): Promise<UserFileType> { 30 + const buffer = Buffer.from(input.content, "base64"); 31 + 32 + if (buffer.byteLength > MAX_DECODED_SIZE) { 33 + throw new BadRequestException( 34 + `File exceeds maximum size of ${MAX_DECODED_SIZE / (1024 * 1024)}MB`, 35 + ); 36 + } 37 + 38 + const validation = validateFile({ 39 + buffer, 40 + mimeType: input.mimeType, 41 + originalName: input.fileName, 42 + sizeBytes: buffer.byteLength, 43 + }); 44 + 45 + if (!validation.valid) { 46 + throw new BadRequestException(validation.error); 47 + } 48 + 49 + const fingerprint = createHash("sha256").update(buffer).digest("hex"); 50 + 51 + const duplicate = await this.importService.findDuplicateForUser( 52 + user.id, 53 + fingerprint, 54 + ); 55 + 56 + if (duplicate) { 57 + return UserFileType.fromDomain(duplicate, { isDuplicate: true }); 58 + } 59 + 60 + const profile = input.profileId 61 + ? await this.profileService.findByIdAndUserOrFail(input.profileId, user.id) 62 + : await this.profileService.getOrCreateDefaultProfile(user.id); 63 + 64 + const userFile = await this.importService.createImport( 65 + user, 66 + profile.id, 67 + this.fileImportSource, 68 + { 69 + fileName: input.fileName, 70 + mimeType: input.mimeType, 71 + sizeBytes: buffer.byteLength, 72 + fingerprint, 73 + }, 74 + { buffer, mimeType: input.mimeType }, 75 + ); 76 + 77 + return UserFileType.fromDomain(userFile); 78 + } 79 + }
+11
apps/server/src/modules/data-import/data-import-source.interface.ts
··· 1 + import type { ParsedCVData } from "@cv/ai-parser"; 2 + import type { User } from "@cv/auth"; 3 + 4 + export interface DataImportSource { 5 + readonly name: string; 6 + execute( 7 + user: User, 8 + params: Record<string, unknown>, 9 + onStatus: (message: string) => Promise<void>, 10 + ): Promise<ParsedCVData>; 11 + }
+29
apps/server/src/modules/data-import/data-import.module.ts
··· 1 + import { AuthorizationModule } from "@cv/auth"; 2 + import { DatabaseModule } from "@cv/system"; 3 + import { Module } from "@nestjs/common"; 4 + import { EventEmitterModule } from "@nestjs/event-emitter"; 5 + import { EntityResolverService } from "@/modules/cv-parser/entity-resolver.service"; 6 + import { ProfileModule } from "@/modules/profile/profile.module"; 7 + import { UserFileResolver } from "./graphql/user-file.resolver"; 8 + import { ImportService } from "./import.service"; 9 + import { ImportJobMapper } from "./import-job.mapper"; 10 + import { ImportJobPolicy } from "./import-job.policy"; 11 + import { ImportJobListener } from "./listeners/import-job.listener"; 12 + import { UserFileMapper } from "./user-file.mapper"; 13 + import { UserFilePolicy } from "./user-file.policy"; 14 + 15 + @Module({ 16 + imports: [DatabaseModule, AuthorizationModule, EventEmitterModule.forRoot(), ProfileModule], 17 + providers: [ 18 + ImportService, 19 + EntityResolverService, 20 + UserFileMapper, 21 + UserFilePolicy, 22 + ImportJobMapper, 23 + ImportJobPolicy, 24 + UserFileResolver, 25 + ImportJobListener, 26 + ], 27 + exports: [ImportService, UserFileMapper, ImportJobMapper], 28 + }) 29 + export class DataImportModule {}
+13
apps/server/src/modules/data-import/events/user-file-created.event.ts
··· 1 + import type { User } from "@cv/auth"; 2 + import type { DataImportSource } from "../data-import-source.interface"; 3 + 4 + export class UserFileCreatedEvent { 5 + static readonly event = "user-file.created"; 6 + 7 + constructor( 8 + public readonly userFileId: string, 9 + public readonly user: User, 10 + public readonly source: DataImportSource, 11 + public readonly params: Record<string, unknown>, 12 + ) {} 13 + }
+54
apps/server/src/modules/data-import/graphql/user-file.resolver.ts
··· 1 + import type { User as DomainUser } from "@cv/auth"; 2 + import { 3 + AuthorizationService, 4 + JwtAuthGuard, 5 + VerifiedScopeGuard, 6 + } from "@cv/auth"; 7 + import { UseGuards } from "@nestjs/common"; 8 + import { Args, Mutation, Query, Resolver } from "@nestjs/graphql"; 9 + import { CurrentUser } from "@/modules/current-user/current-user.decorator"; 10 + import { ImportService } from "../import.service"; 11 + import { UserFile as UserFileEntity } from "../user-file.entity"; 12 + import { UserFileType } from "./user-file.type"; 13 + 14 + @Resolver(() => UserFileType) 15 + @UseGuards(JwtAuthGuard, VerifiedScopeGuard) 16 + export class UserFileResolver { 17 + constructor( 18 + private readonly importService: ImportService, 19 + private readonly authorizationService: AuthorizationService, 20 + ) {} 21 + 22 + @Query(() => UserFileType, { nullable: true }) 23 + async userFile( 24 + @CurrentUser() user: DomainUser, 25 + @Args("id") id: string, 26 + ): Promise<UserFileType | null> { 27 + const file = await this.importService.findUserFileById(id); 28 + if (!file) return null; 29 + 30 + await this.authorizationService.canView(user, file, UserFileEntity); 31 + 32 + return UserFileType.fromDomain(file); 33 + } 34 + 35 + @Query(() => [UserFileType]) 36 + async myUserFiles(@CurrentUser() user: DomainUser): Promise<UserFileType[]> { 37 + const files = await this.importService.findUserFilesForUser(user.id); 38 + return files.map((f) => UserFileType.fromDomain(f)); 39 + } 40 + 41 + @Mutation(() => Boolean) 42 + async deleteUserFile( 43 + @CurrentUser() user: DomainUser, 44 + @Args("id") id: string, 45 + ): Promise<boolean> { 46 + const file = await this.importService.findUserFileById(id); 47 + if (!file) return false; 48 + 49 + await this.authorizationService.canDelete(user, file, UserFileEntity); 50 + await this.importService.deleteUserFile(id); 51 + 52 + return true; 53 + } 54 + }
+66
apps/server/src/modules/data-import/graphql/user-file.type.ts
··· 1 + import { Field, Int, ObjectType } from "@nestjs/graphql"; 2 + import GraphQLJSON from "graphql-type-json"; 3 + import type { UserFile as UserFileDomain } from "../user-file.entity"; 4 + 5 + @ObjectType() 6 + export class UserFileType { 7 + @Field() 8 + id!: string; 9 + 10 + @Field() 11 + profileId!: string; 12 + 13 + @Field() 14 + fileName!: string; 15 + 16 + @Field() 17 + mimeType!: string; 18 + 19 + @Field(() => Int) 20 + sizeBytes!: number; 21 + 22 + @Field() 23 + source!: string; 24 + 25 + @Field() 26 + status!: string; 27 + 28 + @Field() 29 + statusMessage!: string; 30 + 31 + @Field(() => GraphQLJSON, { nullable: true }) 32 + result?: unknown; 33 + 34 + @Field(() => String, { nullable: true }) 35 + error?: string | null; 36 + 37 + @Field(() => Boolean) 38 + isDuplicate!: boolean; 39 + 40 + @Field() 41 + createdAt!: Date; 42 + 43 + @Field() 44 + updatedAt!: Date; 45 + 46 + static fromDomain( 47 + entity: UserFileDomain, 48 + opts?: { isDuplicate?: boolean }, 49 + ): UserFileType { 50 + const type = new UserFileType(); 51 + type.id = entity.id; 52 + type.profileId = entity.profileId; 53 + type.fileName = entity.fileName; 54 + type.mimeType = entity.mimeType; 55 + type.sizeBytes = entity.sizeBytes; 56 + type.source = entity.source; 57 + type.status = entity.status; 58 + type.statusMessage = entity.statusMessage; 59 + type.result = entity.resultJson ? JSON.parse(entity.resultJson) : undefined; 60 + type.error = entity.error; 61 + type.isDuplicate = opts?.isDuplicate ?? false; 62 + type.createdAt = entity.createdAt; 63 + type.updatedAt = entity.updatedAt; 64 + return type; 65 + } 66 + }
+18
apps/server/src/modules/data-import/import-job.entity.ts
··· 1 + import { BaseEntity } from "@cv/system"; 2 + 3 + export class ImportJob extends BaseEntity { 4 + constructor( 5 + id: string, 6 + public readonly userFileId: string, 7 + public readonly source: string, 8 + public readonly status: string, 9 + public readonly statusMessage: string, 10 + public readonly error: string | null, 11 + public readonly startedAt: Date | null, 12 + public readonly completedAt: Date | null, 13 + createdAt: Date, 14 + updatedAt: Date, 15 + ) { 16 + super(id, createdAt, updatedAt); 17 + } 18 + }
+36
apps/server/src/modules/data-import/import-job.mapper.ts
··· 1 + import type { BaseMapper } from "@cv/system"; 2 + import { Injectable } from "@nestjs/common"; 3 + import type { Prisma } from "@prisma/client"; 4 + 5 + type PrismaImportJob = Prisma.ImportJobGetPayload<object>; 6 + 7 + import { ImportJob } from "./import-job.entity"; 8 + 9 + @Injectable() 10 + export class ImportJobMapper implements BaseMapper<PrismaImportJob, ImportJob> { 11 + toDomain(prisma: null): null; 12 + toDomain(prisma: PrismaImportJob): ImportJob; 13 + toDomain(prisma: PrismaImportJob | null): ImportJob | null; 14 + toDomain(prisma: PrismaImportJob | null): ImportJob | null { 15 + if (!prisma) return null; 16 + 17 + return new ImportJob( 18 + prisma.id, 19 + prisma.userFileId, 20 + prisma.source, 21 + prisma.status, 22 + prisma.statusMessage, 23 + prisma.error, 24 + prisma.startedAt, 25 + prisma.completedAt, 26 + prisma.createdAt, 27 + prisma.updatedAt, 28 + ); 29 + } 30 + 31 + mapToDomain(items: PrismaImportJob[]): ImportJob[] { 32 + return items 33 + .map((item) => this.toDomain(item)) 34 + .filter((item): item is ImportJob => item !== null); 35 + } 36 + }
+28
apps/server/src/modules/data-import/import-job.policy.ts
··· 1 + import type { IPolicy, User } from "@cv/auth"; 2 + import { Policy } from "@cv/auth"; 3 + import { Injectable } from "@nestjs/common"; 4 + import { ImportJob } from "./import-job.entity"; 5 + 6 + /** 7 + * ImportJob is an internal/admin-only entity. 8 + * All user-facing operations go through UserFile instead. 9 + */ 10 + @Injectable() 11 + @Policy(ImportJob) 12 + export class ImportJobPolicy implements IPolicy<ImportJob> { 13 + view(_user: User, _resource: ImportJob): boolean { 14 + return false; 15 + } 16 + 17 + create(_user: User, _resource?: Partial<ImportJob>): boolean { 18 + return false; 19 + } 20 + 21 + update(_user: User, _resource: ImportJob): boolean { 22 + return false; 23 + } 24 + 25 + delete(_user: User, _resource: ImportJob): boolean { 26 + return false; 27 + } 28 + }
+86
apps/server/src/modules/data-import/import.service.ts
··· 1 + import type { User } from "@cv/auth"; 2 + import { PrismaService } from "@cv/system"; 3 + import { Injectable } from "@nestjs/common"; 4 + import { EventEmitter2 } from "@nestjs/event-emitter"; 5 + import type { DataImportSource } from "./data-import-source.interface"; 6 + import { UserFileCreatedEvent } from "./events/user-file-created.event"; 7 + import { UserFile } from "./user-file.entity"; 8 + import { UserFileMapper } from "./user-file.mapper"; 9 + 10 + @Injectable() 11 + export class ImportService { 12 + constructor( 13 + private readonly prisma: PrismaService, 14 + private readonly userFileMapper: UserFileMapper, 15 + private readonly eventEmitter: EventEmitter2, 16 + ) {} 17 + 18 + /** 19 + * Find an existing completed UserFile with the same fingerprint for this user. 20 + */ 21 + async findDuplicateForUser( 22 + userId: string, 23 + fingerprint: string, 24 + ): Promise<UserFile | null> { 25 + const record = await this.prisma.userFile.findFirst({ 26 + where: { 27 + fingerprint, 28 + profile: { userId }, 29 + status: "completed", 30 + }, 31 + orderBy: { createdAt: "desc" }, 32 + }); 33 + return this.userFileMapper.toDomain(record); 34 + } 35 + 36 + /** 37 + * Create a UserFile and emit an event for async processing. 38 + */ 39 + async createImport( 40 + user: User, 41 + profileId: string, 42 + source: DataImportSource, 43 + file: { fileName: string; mimeType: string; sizeBytes: number; fingerprint?: string }, 44 + params: Record<string, unknown>, 45 + ): Promise<UserFile> { 46 + const record = await this.prisma.userFile.create({ 47 + data: { 48 + profile: { connect: { id: profileId } }, 49 + fingerprint: file.fingerprint ?? null, 50 + fileName: file.fileName, 51 + mimeType: file.mimeType, 52 + sizeBytes: file.sizeBytes, 53 + source: source.name, 54 + status: "pending", 55 + statusMessage: "Queued for processing", 56 + }, 57 + }); 58 + 59 + this.eventEmitter.emit( 60 + UserFileCreatedEvent.event, 61 + new UserFileCreatedEvent(record.id, user, source, params), 62 + ); 63 + 64 + return this.userFileMapper.toDomain(record) as UserFile; 65 + } 66 + 67 + /** 68 + * Delete a UserFile and its cascading ImportJobs. 69 + */ 70 + async deleteUserFile(id: string): Promise<void> { 71 + await this.prisma.userFile.delete({ where: { id } }); 72 + } 73 + 74 + async findUserFileById(id: string): Promise<UserFile | null> { 75 + const record = await this.prisma.userFile.findUnique({ where: { id } }); 76 + return this.userFileMapper.toDomain(record); 77 + } 78 + 79 + async findUserFilesForUser(userId: string): Promise<UserFile[]> { 80 + const records = await this.prisma.userFile.findMany({ 81 + where: { profile: { userId } }, 82 + orderBy: { createdAt: "desc" }, 83 + }); 84 + return this.userFileMapper.mapToDomain(records); 85 + } 86 + }
+268
apps/server/src/modules/data-import/listeners/import-job.listener.ts
··· 1 + import type { ParsedCVData } from "@cv/ai-parser"; 2 + import { PrismaService } from "@cv/system"; 3 + import { Injectable, Logger } from "@nestjs/common"; 4 + import { OnEvent } from "@nestjs/event-emitter"; 5 + import { 6 + EntityResolverService, 7 + type ResolvedEducation, 8 + type ResolvedJobExperience, 9 + } from "@/modules/cv-parser/entity-resolver.service"; 10 + import { ProfileService } from "@/modules/profile/profile.service"; 11 + import { UserFileCreatedEvent } from "../events/user-file-created.event"; 12 + 13 + interface ResolvedResult { 14 + personalInfo?: { 15 + name?: string | undefined; 16 + headline?: string | undefined; 17 + introduction?: string | undefined; 18 + city?: string | undefined; 19 + country?: string | undefined; 20 + phone?: string | undefined; 21 + website?: string | undefined; 22 + linkedInUrl?: string | undefined; 23 + }; 24 + jobExperiences: ResolvedJobExperience[]; 25 + education: ResolvedEducation[]; 26 + } 27 + 28 + @Injectable() 29 + export class ImportJobListener { 30 + private readonly logger = new Logger(ImportJobListener.name); 31 + 32 + constructor( 33 + private readonly prisma: PrismaService, 34 + private readonly entityResolver: EntityResolverService, 35 + private readonly profileService: ProfileService, 36 + ) {} 37 + 38 + @OnEvent(UserFileCreatedEvent.event, { async: true }) 39 + async handleUserFileCreated(event: UserFileCreatedEvent): Promise<void> { 40 + const job = await this.prisma.importJob.create({ 41 + data: { 42 + userFileId: event.userFileId, 43 + source: event.source.name, 44 + status: "pending", 45 + statusMessage: "Queued", 46 + }, 47 + }); 48 + 49 + await this.processJob(job.id, event); 50 + } 51 + 52 + private async processJob( 53 + jobId: string, 54 + event: UserFileCreatedEvent, 55 + ): Promise<void> { 56 + const { userFileId, user, source, params } = event; 57 + const tag = `job=${jobId} source=${source.name}`; 58 + 59 + try { 60 + await this.startJob(jobId, userFileId); 61 + 62 + const userFile = await this.prisma.userFile.findUniqueOrThrow({ 63 + where: { id: userFileId }, 64 + select: { profileId: true }, 65 + }); 66 + 67 + const onStatus = async (message: string) => { 68 + this.logger.log(`${tag} ${message}`); 69 + await this.updateStatusMessage(jobId, userFileId, message); 70 + }; 71 + 72 + const parsed: ParsedCVData = await source.execute(user, params, onStatus); 73 + 74 + this.logger.log( 75 + `${tag} Parsed: ${parsed.jobExperiences.length} job(s), ` + 76 + `${parsed.education.length} education(s), ${parsed.skills.length} skill(s)`, 77 + ); 78 + this.logger.debug(`${tag} Parsed result: ${JSON.stringify(parsed, null, 2)}`); 79 + 80 + await this.updateStatusMessage(jobId, userFileId, "Resolving entities"); 81 + const resolved = await this.resolveEntities(parsed); 82 + 83 + if (parsed.personalInfo) { 84 + resolved.personalInfo = parsed.personalInfo; 85 + } 86 + 87 + this.logger.log(`${tag} Entity resolution complete`); 88 + 89 + await this.populateProfile(userFile.profileId, parsed, tag); 90 + await this.completeJob(jobId, userFileId, resolved); 91 + } catch (err) { 92 + const message = err instanceof Error ? err.message : "Processing failed"; 93 + this.logger.error(`${tag} Import failed: ${message}`, err instanceof Error ? err.stack : undefined); 94 + await this.failJob(jobId, userFileId, message); 95 + } 96 + } 97 + 98 + private async resolveEntities(parsed: ParsedCVData): Promise<ResolvedResult> { 99 + const [jobExperiences, education] = await Promise.all([ 100 + Promise.all( 101 + parsed.jobExperiences.map(async (job) => { 102 + const [company, role, level, skills] = await Promise.all([ 103 + this.entityResolver.resolveCompany(job.companyName), 104 + this.entityResolver.resolveRole(job.roleName), 105 + this.entityResolver.resolveLevel(job.levelName), 106 + this.entityResolver.resolveSkills(job.skills ?? []), 107 + ]); 108 + 109 + return { 110 + company, 111 + role, 112 + level, 113 + skills, 114 + startDate: new Date(job.startDate), 115 + endDate: job.endDate ? new Date(job.endDate) : null, 116 + description: job.description ?? null, 117 + }; 118 + }), 119 + ), 120 + Promise.all( 121 + parsed.education.map(async (edu) => { 122 + const [institution, skills] = await Promise.all([ 123 + this.entityResolver.resolveInstitution(edu.institutionName), 124 + this.entityResolver.resolveSkills(edu.skills ?? []), 125 + ]); 126 + 127 + return { 128 + institution, 129 + degree: edu.degree, 130 + fieldOfStudy: edu.fieldOfStudy ?? null, 131 + skills, 132 + startDate: new Date(edu.startDate), 133 + endDate: edu.endDate ? new Date(edu.endDate) : null, 134 + description: edu.description ?? null, 135 + }; 136 + }), 137 + ), 138 + ]); 139 + 140 + return { jobExperiences, education }; 141 + } 142 + 143 + /** 144 + * Auto-populate empty profile fields from imported personalInfo. 145 + * Only fills fields that are currently empty -- never overwrites existing data. 146 + */ 147 + private async populateProfile( 148 + profileId: string, 149 + parsed: ParsedCVData, 150 + tag: string, 151 + ): Promise<void> { 152 + const info = parsed.personalInfo; 153 + if (!info) return; 154 + 155 + try { 156 + const existing = await this.profileService.findByIdOrFail(profileId); 157 + const updates: Record<string, string> = {}; 158 + 159 + const fieldMap: Record<string, string | undefined> = { 160 + fullName: info.name, 161 + headline: info.headline, 162 + summary: info.introduction, 163 + city: info.city, 164 + country: info.country, 165 + phone: info.phone, 166 + website: info.website, 167 + linkedInUrl: info.linkedInUrl, 168 + }; 169 + 170 + for (const [field, value] of Object.entries(fieldMap)) { 171 + if (value && !existing[field as keyof typeof existing]) { 172 + updates[field] = value; 173 + } 174 + } 175 + 176 + if (Object.keys(updates).length === 0) { 177 + this.logger.debug(`${tag} Profile already has data, skipping auto-populate`); 178 + return; 179 + } 180 + 181 + await this.profileService.updateProfile(profileId, updates); 182 + this.logger.log(`${tag} Auto-populated profile: ${Object.keys(updates).join(", ")}`); 183 + } catch (err) { 184 + this.logger.warn(`${tag} Failed to auto-populate profile (non-fatal): ${err instanceof Error ? err.message : err}`); 185 + } 186 + } 187 + 188 + private async startJob(jobId: string, userFileId: string): Promise<void> { 189 + await Promise.all([ 190 + this.prisma.importJob.update({ 191 + where: { id: jobId }, 192 + data: { 193 + status: "processing", 194 + statusMessage: "Starting import", 195 + startedAt: new Date(), 196 + }, 197 + }), 198 + this.prisma.userFile.update({ 199 + where: { id: userFileId }, 200 + data: { status: "processing", statusMessage: "Starting import" }, 201 + }), 202 + ]); 203 + } 204 + 205 + private async updateStatusMessage( 206 + jobId: string, 207 + userFileId: string, 208 + message: string, 209 + ): Promise<void> { 210 + await Promise.all([ 211 + this.prisma.importJob.update({ 212 + where: { id: jobId }, 213 + data: { statusMessage: message }, 214 + }), 215 + this.prisma.userFile.update({ 216 + where: { id: userFileId }, 217 + data: { statusMessage: message }, 218 + }), 219 + ]); 220 + } 221 + 222 + private async completeJob( 223 + jobId: string, 224 + userFileId: string, 225 + result: ResolvedResult, 226 + ): Promise<void> { 227 + await Promise.all([ 228 + this.prisma.importJob.update({ 229 + where: { id: jobId }, 230 + data: { 231 + status: "completed", 232 + statusMessage: "Import completed", 233 + completedAt: new Date(), 234 + }, 235 + }), 236 + this.prisma.userFile.update({ 237 + where: { id: userFileId }, 238 + data: { 239 + status: "completed", 240 + statusMessage: "Import completed", 241 + resultJson: JSON.stringify(result), 242 + }, 243 + }), 244 + ]); 245 + } 246 + 247 + private async failJob( 248 + jobId: string, 249 + userFileId: string, 250 + error: string, 251 + ): Promise<void> { 252 + await Promise.all([ 253 + this.prisma.importJob.update({ 254 + where: { id: jobId }, 255 + data: { 256 + status: "failed", 257 + statusMessage: "Import failed", 258 + error, 259 + completedAt: new Date(), 260 + }, 261 + }), 262 + this.prisma.userFile.update({ 263 + where: { id: userFileId }, 264 + data: { status: "failed", statusMessage: "Import failed", error }, 265 + }), 266 + ]); 267 + } 268 + }
+152
apps/server/src/modules/data-import/sources/file-import-source.ts
··· 1 + import { 2 + CVParserService as CVParser, 3 + type ExistingUserContext, 4 + type ParsedCVData, 5 + } from "@cv/ai-parser"; 6 + import type { User } from "@cv/auth"; 7 + import { PrismaService } from "@cv/system"; 8 + import { TextExtractorRegistry, TEXT_EXTRACTOR_REGISTRY } from "@cv/file-upload"; 9 + import { Inject, Injectable, Logger } from "@nestjs/common"; 10 + import { EducationService } from "@/modules/education/education.service"; 11 + import { AIProviderResolverService } from "@/modules/cv-parser/ai-provider-resolver.service"; 12 + import { UserJobExperienceService } from "@/modules/job-experience/employment/user-job-experience.service"; 13 + import { ProfileService } from "@/modules/profile/profile.service"; 14 + import type { DataImportSource } from "../data-import-source.interface"; 15 + 16 + /** 17 + * Build ExistingUserContext from domain models for AI prompt enrichment. 18 + */ 19 + const 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() 64 + export 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 + }
+21
apps/server/src/modules/data-import/user-file.entity.ts
··· 1 + import { BaseEntity } from "@cv/system"; 2 + 3 + export class UserFile extends BaseEntity { 4 + constructor( 5 + id: string, 6 + public readonly profileId: string, 7 + public readonly fingerprint: string | null, 8 + public readonly fileName: string, 9 + public readonly mimeType: string, 10 + public readonly sizeBytes: number, 11 + public readonly source: string, 12 + public readonly status: string, 13 + public readonly statusMessage: string, 14 + public readonly resultJson: string | null, 15 + public readonly error: string | null, 16 + createdAt: Date, 17 + updatedAt: Date, 18 + ) { 19 + super(id, createdAt, updatedAt); 20 + } 21 + }
+39
apps/server/src/modules/data-import/user-file.mapper.ts
··· 1 + import type { BaseMapper } from "@cv/system"; 2 + import { Injectable } from "@nestjs/common"; 3 + import type { Prisma } from "@prisma/client"; 4 + 5 + type PrismaUserFile = Prisma.UserFileGetPayload<object>; 6 + 7 + import { UserFile } from "./user-file.entity"; 8 + 9 + @Injectable() 10 + export class UserFileMapper implements BaseMapper<PrismaUserFile, UserFile> { 11 + toDomain(prisma: null): null; 12 + toDomain(prisma: PrismaUserFile): UserFile; 13 + toDomain(prisma: PrismaUserFile | null): UserFile | null; 14 + toDomain(prisma: PrismaUserFile | null): UserFile | null { 15 + if (!prisma) return null; 16 + 17 + return new UserFile( 18 + prisma.id, 19 + prisma.profileId, 20 + prisma.fingerprint, 21 + prisma.fileName, 22 + prisma.mimeType, 23 + prisma.sizeBytes, 24 + prisma.source, 25 + prisma.status, 26 + prisma.statusMessage, 27 + prisma.resultJson, 28 + prisma.error, 29 + prisma.createdAt, 30 + prisma.updatedAt, 31 + ); 32 + } 33 + 34 + mapToDomain(items: PrismaUserFile[]): UserFile[] { 35 + return items 36 + .map((item) => this.toDomain(item)) 37 + .filter((item): item is UserFile => item !== null); 38 + } 39 + }
+12
apps/server/src/modules/data-import/user-file.policy.ts
··· 1 + import { Policy, ProfileOwnedResourcePolicy } from "@cv/auth"; 2 + import { PrismaService } from "@cv/system"; 3 + import { Injectable } from "@nestjs/common"; 4 + import { UserFile } from "./user-file.entity"; 5 + 6 + @Injectable() 7 + @Policy(UserFile) 8 + export class UserFilePolicy extends ProfileOwnedResourcePolicy<UserFile> { 9 + constructor(prisma: PrismaService) { 10 + super(prisma); 11 + } 12 + }