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.

refactor(server): update application and cv-parser for profile model, migrate env to Zod

+232 -495
+103 -88
apps/server/src/config/env.validation.ts
··· 1 - import * as Joi from "joi"; 1 + import { z } from "zod/v4"; 2 2 3 - // Custom validator for JWT expiry format (e.g., "15m", "7d", "1h") 4 - const jwtExpirySchema = Joi.string() 5 - .pattern(/^(\d+)([smhd])$/) 6 - .messages({ 7 - "string.pattern.base": 8 - 'JWT expiry must be in format: number + unit (s=seconds, m=minutes, h=hours, d=days). Example: "15m", "7d"', 9 - }); 3 + const expiryFormat = z 4 + .string() 5 + .regex( 6 + /^(\d+)([smhd])$/, 7 + 'Must be in format: number + unit (s=seconds, m=minutes, h=hours, d=days). Example: "15m", "7d"', 8 + ); 10 9 11 - export const envValidationSchema = Joi.object({ 12 - // Database Configuration 13 - POSTGRES_USER: Joi.string().default("cv"), 14 - POSTGRES_PASSWORD: Joi.string().default("cv"), 15 - POSTGRES_DB: Joi.string().default("cv"), 16 - DATABASE_URL: Joi.string() 17 - .uri() 18 - .default("postgresql://cv:cv@localhost:5432/cv"), 10 + const optionalUrl = z.string().url().optional(); 19 11 20 - // Server Configuration 21 - PORT: Joi.number().default(3000), 22 - SERVER_PORT: Joi.number().default(3000), 23 - NODE_ENV: Joi.string() 24 - .valid("development", "production", "test") 25 - .default("development"), 12 + export const envSchema = z 13 + .object({ 14 + // Database Configuration 15 + POSTGRES_USER: z.string().default("cv"), 16 + POSTGRES_PASSWORD: z.string().default("cv"), 17 + POSTGRES_DB: z.string().default("cv"), 18 + DATABASE_URL: z 19 + .string() 20 + .url() 21 + .default("postgresql://cv:cv@localhost:5432/cv"), 26 22 27 - // JWT Configuration 28 - JWT_SECRET: Joi.string() 29 - .min(16) 30 - .default("dev-jwt-secret-change-in-production") 31 - .messages({ 32 - "string.min": 33 - "JWT_SECRET must be at least 16 characters long for security", 34 - }), 35 - JWT_ACCESS_TOKEN_EXPIRY: jwtExpirySchema.default("15m"), 36 - JWT_REFRESH_TOKEN_EXPIRY: jwtExpirySchema.default("7d"), 23 + // Server Configuration 24 + PORT: z.coerce.number().default(3000), 25 + SERVER_PORT: z.coerce.number().default(3000), 26 + NODE_ENV: z 27 + .enum(["development", "production", "test"]) 28 + .default("development"), 37 29 38 - // Prisma Configuration 39 - PRISMA_ENABLE_TRACING: Joi.boolean().default(false), 30 + // JWT Configuration 31 + JWT_SECRET: z 32 + .string() 33 + .min(16, "JWT_SECRET must be at least 16 characters long for security") 34 + .default("dev-jwt-secret-change-in-production"), 35 + JWT_ACCESS_TOKEN_EXPIRY: expiryFormat.default("15m"), 36 + JWT_REFRESH_TOKEN_EXPIRY: expiryFormat.default("7d"), 40 37 41 - // Client Configuration (optional for server) 42 - CLIENT_PORT: Joi.number().optional(), 43 - VITE_SERVER_URL: Joi.string().uri().optional(), 44 - GRAPHQL_SCHEMA_URL: Joi.string().uri().optional(), 45 - CLIENT_ORIGIN: Joi.string().uri().optional(), 46 - DOCS_ORIGIN: Joi.string().uri().optional(), 47 - ALLOWED_ORIGINS: Joi.string().optional(), 38 + // Prisma Configuration 39 + PRISMA_ENABLE_TRACING: z.coerce.boolean().default(false), 48 40 49 - // UI Configuration (optional for server) 50 - UI_PORT: Joi.number().optional(), 41 + // Client Configuration (optional for server) 42 + CLIENT_PORT: z.coerce.number().optional(), 43 + VITE_SERVER_URL: optionalUrl, 44 + GRAPHQL_SCHEMA_URL: optionalUrl, 45 + CLIENT_ORIGIN: optionalUrl, 46 + DOCS_ORIGIN: optionalUrl, 47 + ALLOWED_ORIGINS: z.string().optional(), 51 48 52 - // Database Port 53 - DB_PORT: Joi.number().default(5432), 49 + // UI Configuration (optional for server) 50 + UI_PORT: z.coerce.number().optional(), 54 51 55 - // Resend Configuration (optional in dev - emails will be logged to console) 56 - RESEND_API_KEY: Joi.string().allow("").default(""), 52 + // Database Port 53 + DB_PORT: z.coerce.number().default(5432), 57 54 58 - // Email Configuration 59 - EMAIL_FROM_ADDRESS: Joi.string().email().optional(), 60 - EMAIL_FROM_NAME: Joi.string().optional(), 61 - CLIENT_URL: Joi.string().uri().optional(), 62 - EMAIL_VERIFICATION_TOKEN_EXPIRY: jwtExpirySchema.default("24h"), 63 - PASSWORD_RESET_TOKEN_EXPIRY: jwtExpirySchema.default("1h"), 55 + // Resend Configuration (optional in dev - emails will be logged to console) 56 + RESEND_API_KEY: z.string().default(""), 57 + 58 + // Email Configuration 59 + EMAIL_FROM_ADDRESS: z.string().email().optional(), 60 + EMAIL_FROM_NAME: z.string().optional(), 61 + CLIENT_URL: optionalUrl, 62 + EMAIL_VERIFICATION_TOKEN_EXPIRY: expiryFormat.default("24h"), 63 + PASSWORD_RESET_TOKEN_EXPIRY: expiryFormat.default("1h"), 64 64 65 - // Encryption Configuration 66 - ENCRYPTION_KEY: Joi.string() 67 - .min(32) 68 - .default("dev-encryption-key-32-chars-long!") 69 - .messages({ 70 - "string.min": 65 + // Encryption Configuration 66 + ENCRYPTION_KEY: z 67 + .string() 68 + .min( 69 + 32, 71 70 "ENCRYPTION_KEY must be at least 32 characters long for security", 72 - }), 71 + ) 72 + .default("dev-encryption-key-32-chars-long!"), 73 73 74 - // AI Provider Configuration 75 - AI_PROVIDER: Joi.string() 76 - .valid("llama-cpp", "openai", "anthropic") 77 - .default("llama-cpp"), 78 - AI_TEMPERATURE: Joi.number().min(0).max(2).default(0.1), 79 - AI_MAX_TOKENS: Joi.number().integer().min(1).default(2048), 80 - AI_TIMEOUT: Joi.number().integer().min(1000).default(60000), 74 + // AI Provider Configuration 75 + AI_PROVIDER: z 76 + .enum(["llama-cpp", "openai", "anthropic"]) 77 + .default("llama-cpp"), 78 + AI_TEMPERATURE: z.coerce.number().min(0).max(2).default(0.1), 79 + AI_MAX_TOKENS: z.coerce.number().int().min(1).default(8192), 80 + AI_TIMEOUT: z.coerce.number().int().min(1000).default(300000), 81 + 82 + // Llama.cpp 83 + LLAMA_URL: z.string().url().default("http://localhost:8080"), 84 + 85 + // OpenAI (conditionally required via superRefine) 86 + OPENAI_API_KEY: z.string().optional().default(""), 87 + OPENAI_BASE_URL: z.string().url().default("https://api.openai.com"), 88 + OPENAI_MODEL: z.string().default("gpt-4o-mini"), 89 + 90 + // Anthropic (conditionally required via superRefine) 91 + ANTHROPIC_API_KEY: z.string().optional().default(""), 92 + ANTHROPIC_BASE_URL: z.string().url().default("https://api.anthropic.com"), 93 + ANTHROPIC_MODEL: z.string().default("claude-sonnet-4-5-20250929"), 94 + 95 + // PDF Output 96 + PDF_OUTPUT_DIR: z.string().default("./pdf-output"), 97 + }) 98 + .superRefine((data, ctx) => { 99 + if (data.AI_PROVIDER === "openai" && !data.OPENAI_API_KEY) { 100 + ctx.addIssue({ 101 + code: z.ZodIssueCode.custom, 102 + message: "OPENAI_API_KEY is required when AI_PROVIDER=openai", 103 + path: ["OPENAI_API_KEY"], 104 + }); 105 + } 81 106 82 - // Llama.cpp 83 - LLAMA_URL: Joi.string().uri().default("http://llama:8080"), 107 + if (data.AI_PROVIDER === "anthropic" && !data.ANTHROPIC_API_KEY) { 108 + ctx.addIssue({ 109 + code: z.ZodIssueCode.custom, 110 + message: "ANTHROPIC_API_KEY is required when AI_PROVIDER=anthropic", 111 + path: ["ANTHROPIC_API_KEY"], 112 + }); 113 + } 114 + }); 84 115 85 - // OpenAI (required when AI_PROVIDER=openai) 86 - OPENAI_API_KEY: Joi.string().when("AI_PROVIDER", { 87 - is: "openai", 88 - then: Joi.string().required(), 89 - otherwise: Joi.string().allow("").optional(), 90 - }), 91 - OPENAI_BASE_URL: Joi.string().uri().default("https://api.openai.com"), 92 - OPENAI_MODEL: Joi.string().default("gpt-4o-mini"), 116 + export type Env = z.infer<typeof envSchema>; 93 117 94 - // Anthropic (required when AI_PROVIDER=anthropic) 95 - ANTHROPIC_API_KEY: Joi.string().when("AI_PROVIDER", { 96 - is: "anthropic", 97 - then: Joi.string().required(), 98 - otherwise: Joi.string().allow("").optional(), 99 - }), 100 - ANTHROPIC_BASE_URL: Joi.string() 101 - .uri() 102 - .default("https://api.anthropic.com"), 103 - ANTHROPIC_MODEL: Joi.string().default("claude-sonnet-4-5-20250929"), 104 - }); 118 + export const validate = (config: Record<string, unknown>): Env => 119 + envSchema.parse(config);
+3
apps/server/src/modules/application/application.entity.ts
··· 12 12 13 13 export class Application extends BaseEntity { 14 14 userId: string; 15 + profileId: string; 15 16 vacancy: Vacancy; 16 17 cv: CV | null; 17 18 coverLetter?: string; ··· 22 23 constructor( 23 24 id: string, 24 25 userId: string, 26 + profileId: string, 25 27 vacancy: Vacancy, 26 28 statusId: string, 27 29 status: ApplicationStatusRelation, ··· 33 35 ) { 34 36 super(id, createdAt, updatedAt); 35 37 this.userId = userId; 38 + this.profileId = profileId; 36 39 this.vacancy = vacancy; 37 40 this.statusId = statusId; 38 41 this.status = status;
+1
apps/server/src/modules/application/application.mapper.ts
··· 59 59 return new Application( 60 60 prismaApplication.id, 61 61 prismaApplication.userId, 62 + prismaApplication.profileId, 62 63 vacancy, 63 64 prismaApplication.statusId, 64 65 status,
+1
apps/server/src/modules/application/application.service.ts
··· 104 104 create: { 105 105 id: entity.id, 106 106 userId: entity.userId, 107 + profileId: entity.profileId, 107 108 vacancyId: entity.vacancy.id, 108 109 ...data, 109 110 },
+3
apps/server/src/modules/application/graphql/application.input.ts
··· 3 3 @InputType() 4 4 export class CreateApplicationInput { 5 5 @Field(() => ID) 6 + profileId!: string; 7 + 8 + @Field(() => ID) 6 9 vacancyId!: string; 7 10 8 11 @Field(() => ID, { nullable: true })
+2
apps/server/src/modules/application/graphql/application.resolver.ts
··· 93 93 const application = await this.prisma.application.create({ 94 94 data: { 95 95 userId: user.id, 96 + profileId: input.profileId, 96 97 vacancyId: input.vacancyId, 97 98 statusId: appliedStatus.id, 98 99 cvId: input.cvId ?? null, ··· 133 134 const updatedApplication = new ApplicationEntity( 134 135 existingApplication.id, 135 136 existingApplication.userId, 137 + existingApplication.profileId, 136 138 existingApplication.vacancy, 137 139 input.statusId, 138 140 {
+6
apps/server/src/modules/application/graphql/application.type.ts
··· 117 117 @Field(() => ID) 118 118 userId: string; 119 119 120 + @Field(() => ID) 121 + profileId: string; 122 + 120 123 @Field(() => String, { nullable: true }) 121 124 coverLetter: string | null; 122 125 ··· 150 153 constructor(data: { 151 154 id: string; 152 155 userId: string; 156 + profileId: string; 153 157 coverLetter?: string | null; 154 158 statusId: string; 155 159 vacancyId: string; ··· 160 164 }) { 161 165 this.id = data.id; 162 166 this.userId = data.userId; 167 + this.profileId = data.profileId; 163 168 this.coverLetter = data.coverLetter ?? null; 164 169 this.statusId = data.statusId; 165 170 this.vacancyId = data.vacancyId; ··· 173 178 return new Application({ 174 179 id: domainApplication.id, 175 180 userId: domainApplication.userId, 181 + profileId: domainApplication.profileId, 176 182 coverLetter: domainApplication.coverLetter ?? null, 177 183 statusId: domainApplication.statusId, 178 184 vacancyId: domainApplication.vacancy.id,
+12
apps/server/src/modules/cv-parser/__tests__/fixtures/mock-ai-responses.ts
··· 6 6 export const mockJohnSmithParsedCV: ParsedCVData = { 7 7 personalInfo: { 8 8 name: "John Smith", 9 + headline: undefined, 9 10 introduction: 10 11 "Experienced software engineer with 8+ years of experience building scalable web applications and distributed systems.", 12 + city: undefined, 13 + country: undefined, 14 + phone: undefined, 15 + website: undefined, 16 + linkedInUrl: undefined, 11 17 }, 12 18 jobExperiences: [ 13 19 { ··· 74 80 export const mockJaneDoeParsedCV: ParsedCVData = { 75 81 personalInfo: { 76 82 name: "Jane Doe", 83 + headline: undefined, 77 84 introduction: 78 85 "Passionate full-stack developer with 6 years of experience in building modern web applications.", 86 + city: undefined, 87 + country: undefined, 88 + phone: undefined, 89 + website: undefined, 90 + linkedInUrl: undefined, 79 91 }, 80 92 jobExperiences: [ 81 93 {
+22 -6
apps/server/src/modules/cv-parser/cv-parser.module.ts
··· 2 2 import { mkdirSync } from "node:fs"; 3 3 import { join } from "node:path"; 4 4 import { CVParserModule as CVParserCoreModule } from "@cv/ai-parser"; 5 + import { AIModule } from "@cv/ai-provider"; 5 6 import { FileExtractionModule } from "@cv/file-upload"; 6 7 import { DatabaseModule } from "@cv/system"; 7 8 import { Module } from "@nestjs/common"; 8 9 import { MulterModule } from "@nestjs/platform-express"; 9 10 import { diskStorage } from "multer"; 11 + import { DataImportModule } from "@/modules/data-import/data-import.module"; 12 + import { FileImportSource } from "@/modules/data-import/sources/file-import-source"; 13 + import { EducationModule } from "@/modules/education/education.module"; 14 + import { EmploymentModule } from "@/modules/job-experience/employment/employment.module"; 15 + import { ProfileModule } from "@/modules/profile/profile.module"; 16 + import { UserAiSettingsModule } from "@/modules/user-settings/user-ai-settings.module"; 17 + import { AIProviderResolverService } from "./ai-provider-resolver.service"; 10 18 import { CVParserResolver } from "./cv-parser.resolver"; 11 19 import { CVParserService } from "./cv-parser.service"; 12 20 import { EntityResolverService } from "./entity-resolver.service"; 13 21 import { FileUploadController } from "./file-upload.controller"; 14 - import { CVParserPersistenceService } from "./persistence.service"; 22 + import { UploadFileResolver } from "./graphql/upload-file.resolver"; 23 + import { ImportOnboardingStep } from "./onboarding/import.step"; 15 24 16 25 @Module({ 17 26 imports: [ 18 - CVParserCoreModule.forRoot({ 19 - type: (process.env.AI_PROVIDER as "llama-cpp" | "openai" | "anthropic") || "llama-cpp", 20 - }), 27 + CVParserCoreModule.forConfig(), 28 + AIModule.forConfig(), 21 29 FileExtractionModule.forRoot(), 22 30 DatabaseModule, 31 + UserAiSettingsModule, 32 + ProfileModule, 33 + EmploymentModule, 34 + EducationModule, 35 + DataImportModule, 23 36 MulterModule.register({ 24 37 storage: diskStorage({ 25 38 destination: (_req, _file, cb) => { ··· 59 72 ], 60 73 providers: [ 61 74 EntityResolverService, 75 + AIProviderResolverService, 62 76 CVParserService, 63 - CVParserPersistenceService, 64 77 CVParserResolver, 78 + UploadFileResolver, 79 + ImportOnboardingStep, 80 + FileImportSource, 65 81 ], 66 82 controllers: [FileUploadController], 67 - exports: [CVParserService, CVParserPersistenceService], 83 + exports: [CVParserService], 68 84 }) 69 85 export class CVParserModule {}
+6 -23
apps/server/src/modules/cv-parser/cv-parser.resolver.ts
··· 4 4 import { Args, Mutation, Resolver } from "@nestjs/graphql"; 5 5 import { CurrentUser } from "../current-user/current-user.decorator"; 6 6 import { CVParserService } from "./cv-parser.service"; 7 - import { CVParserPersistenceService } from "./persistence.service"; 8 7 import { 9 8 DraftEducationType, 10 9 DraftJobExperienceType, 11 10 ParsedCVDataWithResolutionType, 12 11 } from "./types/draft.types"; 13 - import { ParsedCVDataInput } from "./types/input.types"; 14 12 15 13 @Resolver() 16 14 export class CVParserResolver { 17 - constructor( 18 - private readonly cvParserService: CVParserService, 19 - private readonly persistenceService: CVParserPersistenceService, 20 - ) {} 15 + constructor(private readonly cvParserService: CVParserService) {} 21 16 22 17 /** 23 18 * Parse story text and resolve entities to existing database records ··· 26 21 @Mutation(() => ParsedCVDataWithResolutionType) 27 22 @UseGuards(JwtAuthGuard) 28 23 async parseStory( 29 - @CurrentUser() _user: DomainUser, 24 + @CurrentUser() user: DomainUser, 30 25 @Args("storyText") storyText: string, 31 26 ): Promise<ParsedCVDataWithResolutionType> { 32 - const resolved = 33 - await this.cvParserService.parseStoryWithResolution(storyText); 27 + const resolved = await this.cvParserService.parseStoryWithResolution( 28 + user, 29 + storyText, 30 + ); 34 31 35 32 return new ParsedCVDataWithResolutionType( 36 33 resolved.jobExperiences.map(DraftJobExperienceType.fromResolved), 37 34 resolved.education.map(DraftEducationType.fromResolved), 38 35 ); 39 - } 40 - 41 - /** 42 - * Save approved parsed data to the database 43 - * Creates entities that don't exist and links them to the user 44 - */ 45 - @Mutation(() => Boolean) 46 - @UseGuards(JwtAuthGuard) 47 - async approveParsedData( 48 - @CurrentUser() user: DomainUser, 49 - @Args("data", { type: () => ParsedCVDataInput }) data: ParsedCVDataInput, 50 - ): Promise<boolean> { 51 - await this.persistenceService.saveParsedData(user.id, data); 52 - return true; 53 36 } 54 37 }
+20 -12
apps/server/src/modules/cv-parser/cv-parser.service.ts
··· 1 - import { 2 - CV_PARSER_SERVICE, 3 - CVParserService as CVParser, 4 - type ParsedCVData, 5 - } from "@cv/ai-parser"; 1 + import { CVParserService as CVParser, type ParsedCVData } from "@cv/ai-parser"; 2 + import type { User } from "@cv/auth"; 6 3 import { 7 4 TEXT_EXTRACTOR_REGISTRY, 8 5 type TextExtractorRegistry, 9 6 validateFile, 10 7 } from "@cv/file-upload"; 11 8 import { Inject, Injectable } from "@nestjs/common"; 9 + import { AIProviderResolverService } from "./ai-provider-resolver.service"; 12 10 import { 13 11 EntityResolverService, 14 12 type ResolvedEducation, ··· 26 24 @Injectable() 27 25 export class CVParserService { 28 26 constructor( 29 - @Inject(CV_PARSER_SERVICE) 30 - private readonly cvParser: CVParser, 31 27 @Inject(TEXT_EXTRACTOR_REGISTRY) 32 28 private readonly textExtractorRegistry: TextExtractorRegistry, 33 29 private readonly entityResolver: EntityResolverService, 30 + private readonly providerResolver: AIProviderResolverService, 34 31 ) {} 35 32 33 + /** Create a per-request CVParser using the user's resolved provider */ 34 + private async createParser(user: User): Promise<CVParser> { 35 + const provider = await this.providerResolver.resolveForUser(user); 36 + return new CVParser(provider); 37 + } 38 + 36 39 async parseFile( 40 + user: User, 37 41 buffer: Buffer, 38 42 mimeType: string, 39 43 originalName: string, ··· 62 66 throw new Error("Could not extract any text from the file"); 63 67 } 64 68 65 - return this.cvParser.parseCVText(extraction.text); 69 + const parser = await this.createParser(user); 70 + return parser.parseCVText(extraction.text); 66 71 } 67 72 68 - async parseStory(storyText: string): Promise<ParsedCVData> { 73 + async parseStory(user: User, storyText: string): Promise<ParsedCVData> { 69 74 if (!storyText || storyText.trim().length === 0) { 70 75 throw new Error("Story text cannot be empty"); 71 76 } ··· 74 79 throw new Error("Story text is too long (max 50,000 characters)"); 75 80 } 76 81 77 - return this.cvParser.parseCVText(storyText); 82 + const parser = await this.createParser(user); 83 + return parser.parseCVText(storyText); 78 84 } 79 85 80 86 /** ··· 82 88 * Returns draft data with entity IDs where matches were found 83 89 */ 84 90 async parseStoryWithResolution( 91 + user: User, 85 92 storyText: string, 86 93 ): Promise<ParsedCVDataWithResolution> { 87 - const parsed = await this.parseStory(storyText); 94 + const parsed = await this.parseStory(user, storyText); 88 95 return this.resolveEntities(parsed); 89 96 } 90 97 ··· 92 99 * Parse file and resolve entities to existing database records 93 100 */ 94 101 async parseFileWithResolution( 102 + user: User, 95 103 buffer: Buffer, 96 104 mimeType: string, 97 105 originalName: string, 98 106 ): Promise<ParsedCVDataWithResolution> { 99 - const parsed = await this.parseFile(buffer, mimeType, originalName); 107 + const parsed = await this.parseFile(user, buffer, mimeType, originalName); 100 108 return this.resolveEntities(parsed); 101 109 } 102 110
+53 -27
apps/server/src/modules/cv-parser/file-upload.controller.ts
··· 1 - import { unlink } from "node:fs/promises"; 1 + import { createHash } from "node:crypto"; 2 + import { readFile, unlink } from "node:fs/promises"; 3 + import type { User as DomainUser } from "@cv/auth"; 2 4 import { JwtAuthGuard } from "@cv/auth"; 5 + import { validateFile } from "@cv/file-upload"; 3 6 import { 4 7 BadRequestException, 8 + Body, 5 9 Controller, 6 - InternalServerErrorException, 7 10 Logger, 8 11 Post, 12 + Req, 9 13 UploadedFile, 10 14 UseGuards, 11 15 UseInterceptors, 12 16 } from "@nestjs/common"; 13 17 import { FileInterceptor } from "@nestjs/platform-express"; 14 - import { CVParserService } from "./cv-parser.service"; 18 + import { FileImportSource } from "@/modules/data-import/sources/file-import-source"; 19 + import { ImportService } from "@/modules/data-import/import.service"; 15 20 16 21 @Controller("api/cv-parser") 17 22 export class FileUploadController { 18 23 private readonly logger = new Logger(FileUploadController.name); 19 24 20 - constructor(private cvParserService: CVParserService) {} 25 + constructor( 26 + private readonly importService: ImportService, 27 + private readonly fileImportSource: FileImportSource, 28 + ) {} 21 29 22 30 @Post("upload") 23 31 @UseGuards(JwtAuthGuard) 24 32 @UseInterceptors(FileInterceptor("file")) 25 - async uploadAndParse(@UploadedFile() file?: Express.Multer.File) { 33 + async upload( 34 + @Req() req: { user: DomainUser }, 35 + @Body("profileId") profileId: string, 36 + @UploadedFile() file?: Express.Multer.File, 37 + ) { 26 38 if (!file) { 27 39 throw new BadRequestException("No file provided"); 28 40 } 29 41 42 + if (!profileId) { 43 + throw new BadRequestException("profileId is required"); 44 + } 45 + 30 46 const filePath = file.path; 31 47 32 48 try { 33 - // Read file buffer 34 - const buffer = 35 - file.buffer ?? (await require("node:fs/promises").readFile(filePath)); 49 + const buffer = file.buffer ?? (await readFile(filePath)); 36 50 37 - // Parse the file 38 - const parsedData = await this.cvParserService.parseFile( 51 + const validation = validateFile({ 39 52 buffer, 40 - file.mimetype, 41 - file.originalname, 53 + mimeType: file.mimetype, 54 + originalName: file.originalname, 55 + sizeBytes: file.size, 56 + }); 57 + 58 + if (!validation.valid) { 59 + throw new Error(`File validation failed: ${validation.error}`); 60 + } 61 + 62 + const fingerprint = createHash("sha256").update(buffer).digest("hex"); 63 + 64 + const userFile = await this.importService.createImport( 65 + req.user, 66 + profileId, 67 + this.fileImportSource, 68 + { 69 + fileName: file.originalname, 70 + mimeType: file.mimetype, 71 + sizeBytes: file.size, 72 + fingerprint, 73 + }, 74 + { buffer, mimeType: file.mimetype }, 42 75 ); 43 76 44 77 return { 45 - success: true, 46 - data: parsedData, 47 - fileName: file.originalname, 48 - fileSize: file.size, 78 + userFileId: userFile.id, 79 + status: userFile.status, 80 + fileName: userFile.fileName, 81 + sizeBytes: userFile.sizeBytes, 49 82 }; 50 83 } catch (error) { 51 - this.logger.error("File parsing error:", error); 84 + this.logger.error(`File upload failed: ${error instanceof Error ? error.message : error}`, error instanceof Error ? error.stack : undefined); 52 85 53 - const errorMessage = 86 + const message = 54 87 error instanceof Error ? error.message : "Failed to process file"; 55 88 56 - throw new InternalServerErrorException({ 57 - message: "File parsing failed", 58 - details: errorMessage, 59 - }); 89 + throw new BadRequestException(message); 60 90 } finally { 61 - // Clean up uploaded file 62 91 if (filePath) { 63 92 try { 64 93 await unlink(filePath); 65 94 } catch (err) { 66 - this.logger.warn( 67 - `Failed to clean up temporary file ${filePath}:`, 68 - err, 69 - ); 95 + this.logger.warn(`Failed to clean up temporary file ${filePath}: ${err instanceof Error ? err.message : err}`); 70 96 } 71 97 } 72 98 }
-260
apps/server/src/modules/cv-parser/persistence.service.ts
··· 1 - import { PrismaService } from "@cv/system"; 2 - import { Injectable } from "@nestjs/common"; 3 - import type { ParsedCVDataInput } from "./types/input.types"; 4 - 5 - /** 6 - * Service for persisting parsed CV data to the database 7 - * Handles entity resolution (creating or finding existing companies, institutions, etc.) 8 - */ 9 - @Injectable() 10 - export class CVParserPersistenceService { 11 - constructor(private prisma: PrismaService) {} 12 - 13 - /** 14 - * Save parsed CV data to the database 15 - * Creates job experiences and education entries 16 - */ 17 - async saveParsedData( 18 - userId: string, 19 - data: ParsedCVDataInput, 20 - ): Promise<{ 21 - jobExperiencesCreated: number; 22 - educationCreated: number; 23 - errors: string[]; 24 - }> { 25 - const result = { 26 - jobExperiencesCreated: 0, 27 - educationCreated: 0, 28 - errors: [] as string[], 29 - }; 30 - 31 - // Save job experiences 32 - for (const job of data.jobExperiences) { 33 - try { 34 - await this.saveJobExperience(userId, job); 35 - result.jobExperiencesCreated++; 36 - } catch (error) { 37 - const message = 38 - error instanceof Error ? error.message : "Unknown error"; 39 - result.errors.push(`Job (${job.companyName}): ${message}`); 40 - } 41 - } 42 - 43 - // Save education entries 44 - for (const edu of data.education) { 45 - try { 46 - await this.saveEducation(userId, edu); 47 - result.educationCreated++; 48 - } catch (error) { 49 - const message = 50 - error instanceof Error ? error.message : "Unknown error"; 51 - result.errors.push(`Education (${edu.institutionName}): ${message}`); 52 - } 53 - } 54 - 55 - return result; 56 - } 57 - 58 - /** 59 - * Save a single job experience 60 - */ 61 - private async saveJobExperience( 62 - userId: string, 63 - job: { 64 - companyName: string; 65 - roleName: string; 66 - levelName?: string; 67 - startDate: string; 68 - endDate?: string; 69 - description?: string; 70 - skills: string[]; 71 - }, 72 - ) { 73 - // Find or create company 74 - let company = await this.prisma.company.findFirst({ 75 - where: { 76 - name: { 77 - equals: job.companyName, 78 - mode: "insensitive", 79 - }, 80 - }, 81 - }); 82 - 83 - if (!company) { 84 - company = await this.prisma.company.create({ 85 - data: { 86 - name: job.companyName, 87 - }, 88 - }); 89 - } 90 - 91 - // Find or create role 92 - let role = await this.prisma.role.findFirst({ 93 - where: { 94 - name: { 95 - equals: job.roleName, 96 - mode: "insensitive", 97 - }, 98 - }, 99 - }); 100 - 101 - if (!role) { 102 - role = await this.prisma.role.create({ 103 - data: { 104 - name: job.roleName, 105 - }, 106 - }); 107 - } 108 - 109 - // Find or create level (with default "Mid-level" if not found) 110 - let level = await this.prisma.level.findFirst({ 111 - where: { 112 - name: { 113 - equals: job.levelName || "Mid-level", 114 - mode: "insensitive", 115 - }, 116 - }, 117 - }); 118 - 119 - if (!level) { 120 - level = await this.prisma.level.create({ 121 - data: { 122 - name: job.levelName ?? "Mid-level", 123 - }, 124 - }); 125 - } 126 - 127 - // Create job experience 128 - const jobExperience = await this.prisma.userJobExperience.create({ 129 - data: { 130 - userId, 131 - companyId: company.id, 132 - roleId: role.id, 133 - levelId: level.id, 134 - startDate: new Date(job.startDate), 135 - endDate: job.endDate ? new Date(job.endDate) : null, 136 - description: job.description || null, 137 - }, 138 - }); 139 - 140 - // Add skills if provided 141 - if (job.skills && job.skills.length > 0) { 142 - const skillIds = await this.resolveSkillIds(job.skills); 143 - 144 - await this.prisma.userJobExperience.update({ 145 - where: { id: jobExperience.id }, 146 - data: { 147 - skills: { 148 - connect: skillIds.map((id) => ({ id })), 149 - }, 150 - }, 151 - }); 152 - } 153 - } 154 - 155 - /** 156 - * Save a single education entry 157 - */ 158 - private async saveEducation( 159 - userId: string, 160 - edu: { 161 - institutionName: string; 162 - degree: string; 163 - fieldOfStudy?: string; 164 - startDate: string; 165 - endDate?: string; 166 - description?: string; 167 - skills: string[]; 168 - }, 169 - ) { 170 - // Find or create institution 171 - let institution = await this.prisma.institution.findFirst({ 172 - where: { 173 - name: { 174 - equals: edu.institutionName, 175 - mode: "insensitive", 176 - }, 177 - }, 178 - }); 179 - 180 - if (!institution) { 181 - institution = await this.prisma.institution.create({ 182 - data: { 183 - name: edu.institutionName, 184 - }, 185 - }); 186 - } 187 - 188 - // Create education entry 189 - const education = await this.prisma.education.create({ 190 - data: { 191 - userId, 192 - institutionId: institution.id, 193 - degree: edu.degree, 194 - fieldOfStudy: edu.fieldOfStudy || null, 195 - startDate: new Date(edu.startDate), 196 - endDate: edu.endDate ? new Date(edu.endDate) : null, 197 - description: edu.description || null, 198 - }, 199 - }); 200 - 201 - // Add skills if provided 202 - if (edu.skills && edu.skills.length > 0) { 203 - const skillIds = await this.resolveSkillIds(edu.skills); 204 - 205 - await this.prisma.education.update({ 206 - where: { id: education.id }, 207 - data: { 208 - skills: { 209 - connect: skillIds.map((id) => ({ id })), 210 - }, 211 - }, 212 - }); 213 - } 214 - } 215 - 216 - /** 217 - * Resolve skill names to IDs, creating new skills if needed 218 - */ 219 - private async resolveSkillIds(skillNames: string[]): Promise<string[]> { 220 - const uniqueSkills = [...new Set(skillNames)].filter((s) => s?.trim()); 221 - 222 - if (uniqueSkills.length === 0) { 223 - return []; 224 - } 225 - 226 - // Find existing skills 227 - const existingSkills = await this.prisma.skill.findMany({ 228 - where: { 229 - name: { 230 - in: uniqueSkills, 231 - mode: "insensitive", 232 - }, 233 - }, 234 - }); 235 - 236 - const existingSkillIds = new Set(existingSkills.map((s) => s.id)); 237 - const existingSkillNames = new Set( 238 - existingSkills.map((s) => s.name.toLowerCase()), 239 - ); 240 - 241 - // Create missing skills 242 - const skillsToCreate = uniqueSkills.filter( 243 - (name) => !existingSkillNames.has(name.toLowerCase()), 244 - ); 245 - 246 - const createdSkills = await Promise.all( 247 - skillsToCreate.map((name) => 248 - this.prisma.skill.create({ 249 - data: { 250 - name: name.trim(), 251 - }, 252 - }), 253 - ), 254 - ); 255 - 256 - const createdSkillIds = new Set(createdSkills.map((s) => s.id)); 257 - 258 - return [...Array.from(existingSkillIds), ...Array.from(createdSkillIds)]; 259 - } 260 - }
-79
apps/server/src/modules/cv-parser/types/input.types.ts
··· 1 - import { Field, InputType } from "@nestjs/graphql"; 2 - 3 - /** 4 - * Input types for the approveParsedData mutation 5 - * These accept raw string names (not entity IDs) and the persistence layer 6 - * handles find-or-create for entities 7 - */ 8 - 9 - @InputType() 10 - export class ParsedPersonalInfoInput { 11 - @Field({ nullable: true }) 12 - name?: string; 13 - 14 - @Field({ nullable: true }) 15 - introduction?: string; 16 - } 17 - 18 - @InputType() 19 - export class ParsedJobExperienceInput { 20 - @Field() 21 - companyName: string = ""; 22 - 23 - @Field() 24 - roleName: string = ""; 25 - 26 - @Field({ nullable: true }) 27 - levelName?: string; 28 - 29 - @Field() 30 - startDate: string = ""; 31 - 32 - @Field({ nullable: true }) 33 - endDate?: string; 34 - 35 - @Field({ nullable: true }) 36 - description?: string; 37 - 38 - @Field(() => [String]) 39 - skills: string[] = []; 40 - } 41 - 42 - @InputType() 43 - export class ParsedEducationInput { 44 - @Field() 45 - institutionName: string = ""; 46 - 47 - @Field() 48 - degree: string = ""; 49 - 50 - @Field({ nullable: true }) 51 - fieldOfStudy?: string; 52 - 53 - @Field() 54 - startDate: string = ""; 55 - 56 - @Field({ nullable: true }) 57 - endDate?: string; 58 - 59 - @Field({ nullable: true }) 60 - description?: string; 61 - 62 - @Field(() => [String]) 63 - skills: string[] = []; 64 - } 65 - 66 - @InputType() 67 - export class ParsedCVDataInput { 68 - @Field(() => ParsedPersonalInfoInput, { nullable: true }) 69 - personalInfo?: ParsedPersonalInfoInput; 70 - 71 - @Field(() => [ParsedJobExperienceInput]) 72 - jobExperiences: ParsedJobExperienceInput[] = []; 73 - 74 - @Field(() => [ParsedEducationInput]) 75 - education: ParsedEducationInput[] = []; 76 - 77 - @Field(() => [String]) 78 - skills: string[] = []; 79 - }