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: add AI parser, provider, and file-upload packages

+5020 -154
+22 -15
apps/server/src/config/env.validation.ts
··· 10 10 11 11 export const envValidationSchema = Joi.object({ 12 12 // Database Configuration 13 - POSTGRES_USER: Joi.string().required(), 14 - POSTGRES_PASSWORD: Joi.string().required(), 15 - POSTGRES_DB: Joi.string().required(), 16 - DATABASE_URL: Joi.string().uri().required(), 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"), 17 19 18 20 // Server Configuration 19 21 PORT: Joi.number().default(3000), ··· 23 25 .default("development"), 24 26 25 27 // JWT Configuration 26 - JWT_SECRET: Joi.string().min(16).required().messages({ 27 - "string.min": "JWT_SECRET must be at least 16 characters long for security", 28 - "any.required": "JWT_SECRET is required", 29 - }), 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 + }), 30 35 JWT_ACCESS_TOKEN_EXPIRY: jwtExpirySchema.default("15m"), 31 36 JWT_REFRESH_TOKEN_EXPIRY: jwtExpirySchema.default("7d"), 32 37 ··· 47 52 // Database Port 48 53 DB_PORT: Joi.number().default(5432), 49 54 50 - // Resend Configuration 51 - RESEND_API_KEY: Joi.string().required(), 55 + // Resend Configuration (optional in dev - emails will be logged to console) 56 + RESEND_API_KEY: Joi.string().allow("").default(""), 52 57 53 58 // Email Configuration 54 59 EMAIL_FROM_ADDRESS: Joi.string().email().optional(), ··· 58 63 PASSWORD_RESET_TOKEN_EXPIRY: jwtExpirySchema.default("1h"), 59 64 60 65 // Encryption Configuration 61 - ENCRYPTION_KEY: Joi.string().min(32).required().messages({ 62 - "string.min": 63 - "ENCRYPTION_KEY must be at least 32 characters long for security", 64 - "any.required": "ENCRYPTION_KEY is required for token encryption", 65 - }), 66 + ENCRYPTION_KEY: Joi.string() 67 + .min(32) 68 + .default("dev-encryption-key-32-chars-long!") 69 + .messages({ 70 + "string.min": 71 + "ENCRYPTION_KEY must be at least 32 characters long for security", 72 + }), 66 73 });
+2
apps/server/src/modules/app.module.ts
··· 17 17 import { ApplicationModule } from "./application/application.module"; 18 18 import { AuthenticationModule } from "./authentication/authentication.module"; 19 19 import { CurrentUserModule } from "./current-user/current-user.module"; 20 + import { CVParserModule } from "./cv-parser/cv-parser.module"; 20 21 import { CVTemplateModule } from "./cv-template/cv-template.module"; 21 22 import { SeedModule } from "./database/seed/seed.module"; 22 23 import { EducationModule } from "./education/education.module"; ··· 78 79 OrganizationModule, 79 80 VacancyModule, 80 81 ApplicationModule, 82 + CVParserModule, 81 83 CVTemplateModule, 82 84 EducationModule, 83 85 SeedModule,
+2
apps/server/src/modules/app/app.module.ts
··· 1 1 import { Module } from "@nestjs/common"; 2 2 import { AppResolver } from "./app.resolver"; 3 3 import { AppService } from "./app.service"; 4 + import { HealthController } from "./health.controller"; 4 5 5 6 @Module({ 7 + controllers: [HealthController], 6 8 providers: [AppService, AppResolver], 7 9 exports: [AppService], 8 10 })
+12
apps/server/src/modules/app/health.controller.ts
··· 1 + import { Controller, Get } from "@nestjs/common"; 2 + import { AppService } from "./app.service"; 3 + 4 + @Controller("health") 5 + export class HealthController { 6 + constructor(private readonly appService: AppService) {} 7 + 8 + @Get() 9 + check() { 10 + return this.appService.getHealth(); 11 + } 12 + }
+333
apps/server/src/modules/cv-parser/__tests__/cv-parser.service.spec.ts
··· 1 + import { readFileSync } from "node:fs"; 2 + import { join } from "node:path"; 3 + import { 4 + CV_PARSER_SERVICE, 5 + type CVParserService as CVParser, 6 + } from "@cv/ai-parser"; 7 + import { TEXT_EXTRACTOR_REGISTRY } from "@cv/file-upload"; 8 + import { Test, type TestingModule } from "@nestjs/testing"; 9 + import { CVParserService } from "../cv-parser.service"; 10 + import { EntityResolverService } from "../entity-resolver.service"; 11 + import { 12 + mockJaneDoeParsedCV, 13 + mockJohnSmithParsedCV, 14 + } from "./fixtures/mock-ai-responses"; 15 + 16 + describe("CVParserService", () => { 17 + let service: CVParserService; 18 + let mockCVParser: jest.Mocked<CVParser>; 19 + let mockTextExtractorRegistry: jest.Mocked<{ 20 + extract: ( 21 + buffer: Buffer, 22 + mimeType: string, 23 + ) => Promise<{ 24 + success: boolean; 25 + text?: string; 26 + error?: string; 27 + }>; 28 + }>; 29 + let mockEntityResolver: jest.Mocked<EntityResolverService>; 30 + 31 + const fixturesPath = join(__dirname, "fixtures"); 32 + const sampleTxtCV = readFileSync( 33 + join(fixturesPath, "sample-cv.txt"), 34 + "utf-8", 35 + ); 36 + const sampleMdCV = readFileSync(join(fixturesPath, "sample-cv.md"), "utf-8"); 37 + 38 + beforeEach(async () => { 39 + mockCVParser = { 40 + parseCVText: jest.fn(), 41 + } as unknown as jest.Mocked<CVParser>; 42 + 43 + mockTextExtractorRegistry = { 44 + extract: jest.fn(), 45 + }; 46 + 47 + mockEntityResolver = { 48 + resolveCompany: jest.fn(), 49 + resolveRole: jest.fn(), 50 + resolveLevel: jest.fn(), 51 + resolveSkill: jest.fn(), 52 + resolveSkills: jest.fn(), 53 + resolveInstitution: jest.fn(), 54 + } as unknown as jest.Mocked<EntityResolverService>; 55 + 56 + const module: TestingModule = await Test.createTestingModule({ 57 + providers: [ 58 + CVParserService, 59 + { 60 + provide: CV_PARSER_SERVICE, 61 + useValue: mockCVParser, 62 + }, 63 + { 64 + provide: TEXT_EXTRACTOR_REGISTRY, 65 + useValue: mockTextExtractorRegistry, 66 + }, 67 + { 68 + provide: EntityResolverService, 69 + useValue: mockEntityResolver, 70 + }, 71 + ], 72 + }).compile(); 73 + 74 + service = module.get<CVParserService>(CVParserService); 75 + }); 76 + 77 + describe("parseStory", () => { 78 + it("parses a career narrative from plain text", async () => { 79 + mockCVParser.parseCVText.mockResolvedValue(mockJohnSmithParsedCV); 80 + 81 + const result = await service.parseStory(sampleTxtCV); 82 + 83 + expect(mockCVParser.parseCVText).toHaveBeenCalledWith(sampleTxtCV); 84 + expect(result).toEqual(mockJohnSmithParsedCV); 85 + expect(result.personalInfo?.name).toBe("John Smith"); 86 + expect(result.jobExperiences).toHaveLength(3); 87 + expect(result.education).toHaveLength(1); 88 + }); 89 + 90 + it("parses a career narrative from markdown", async () => { 91 + mockCVParser.parseCVText.mockResolvedValue(mockJaneDoeParsedCV); 92 + 93 + const result = await service.parseStory(sampleMdCV); 94 + 95 + expect(mockCVParser.parseCVText).toHaveBeenCalledWith(sampleMdCV); 96 + expect(result).toEqual(mockJaneDoeParsedCV); 97 + expect(result.personalInfo?.name).toBe("Jane Doe"); 98 + expect(result.jobExperiences).toHaveLength(3); 99 + expect(result.education).toHaveLength(2); 100 + }); 101 + 102 + it("throws error when story text is empty", async () => { 103 + await expect(service.parseStory("")).rejects.toThrow( 104 + "Story text cannot be empty", 105 + ); 106 + 107 + expect(mockCVParser.parseCVText).not.toHaveBeenCalled(); 108 + }); 109 + 110 + it("throws error when story text is only whitespace", async () => { 111 + await expect(service.parseStory(" \n\t ")).rejects.toThrow( 112 + "Story text cannot be empty", 113 + ); 114 + 115 + expect(mockCVParser.parseCVText).not.toHaveBeenCalled(); 116 + }); 117 + 118 + it("throws error when story text exceeds maximum length", async () => { 119 + const longText = "a".repeat(50001); 120 + 121 + await expect(service.parseStory(longText)).rejects.toThrow( 122 + "Story text is too long (max 50,000 characters)", 123 + ); 124 + 125 + expect(mockCVParser.parseCVText).not.toHaveBeenCalled(); 126 + }); 127 + 128 + it("accepts story text at maximum length boundary", async () => { 129 + const maxLengthText = "a".repeat(50000); 130 + mockCVParser.parseCVText.mockResolvedValue({ 131 + jobExperiences: [], 132 + education: [], 133 + skills: [], 134 + }); 135 + 136 + await service.parseStory(maxLengthText); 137 + 138 + expect(mockCVParser.parseCVText).toHaveBeenCalledWith(maxLengthText); 139 + }); 140 + 141 + it("extracts job experiences with correct structure", async () => { 142 + mockCVParser.parseCVText.mockResolvedValue(mockJohnSmithParsedCV); 143 + 144 + const result = await service.parseStory(sampleTxtCV); 145 + 146 + const firstJob = result.jobExperiences[0]; 147 + expect(firstJob.companyName).toBe("Acme Corporation"); 148 + expect(firstJob.roleName).toBe("Senior Software Engineer"); 149 + expect(firstJob.levelName).toBe("Senior"); 150 + expect(firstJob.startDate).toBe("2020-01-01"); 151 + expect(firstJob.endDate).toBeNull(); 152 + expect(firstJob.skills).toContain("TypeScript"); 153 + expect(firstJob.skills).toContain("Kubernetes"); 154 + }); 155 + 156 + it("extracts education with correct structure", async () => { 157 + mockCVParser.parseCVText.mockResolvedValue(mockJohnSmithParsedCV); 158 + 159 + const result = await service.parseStory(sampleTxtCV); 160 + 161 + const education = result.education[0]; 162 + expect(education.institutionName).toBe("Stanford University"); 163 + expect(education.degree).toBe("Bachelor of Science"); 164 + expect(education.fieldOfStudy).toBe("Computer Science"); 165 + expect(education.startDate).toBe("2010-09-01"); 166 + expect(education.endDate).toBe("2014-06-30"); 167 + }); 168 + 169 + it("extracts overall skills list", async () => { 170 + mockCVParser.parseCVText.mockResolvedValue(mockJohnSmithParsedCV); 171 + 172 + const result = await service.parseStory(sampleTxtCV); 173 + 174 + expect(result.skills).toContain("TypeScript"); 175 + expect(result.skills).toContain("Docker"); 176 + expect(result.skills).toContain("AWS"); 177 + expect(result.skills.length).toBeGreaterThan(0); 178 + }); 179 + }); 180 + 181 + describe("parseFile", () => { 182 + const mockBuffer = Buffer.from("mock file content"); 183 + const mockMimeType = "text/plain"; 184 + const mockFileName = "resume.txt"; 185 + 186 + it("parses a valid text file", async () => { 187 + mockTextExtractorRegistry.extract.mockResolvedValue({ 188 + success: true, 189 + text: sampleTxtCV, 190 + }); 191 + mockCVParser.parseCVText.mockResolvedValue(mockJohnSmithParsedCV); 192 + 193 + const result = await service.parseFile( 194 + mockBuffer, 195 + mockMimeType, 196 + mockFileName, 197 + ); 198 + 199 + expect(mockTextExtractorRegistry.extract).toHaveBeenCalledWith( 200 + mockBuffer, 201 + mockMimeType, 202 + ); 203 + expect(mockCVParser.parseCVText).toHaveBeenCalledWith(sampleTxtCV); 204 + expect(result).toEqual(mockJohnSmithParsedCV); 205 + }); 206 + 207 + it("throws error when text extraction fails", async () => { 208 + mockTextExtractorRegistry.extract.mockResolvedValue({ 209 + success: false, 210 + error: "Unsupported file format", 211 + }); 212 + 213 + await expect( 214 + service.parseFile(mockBuffer, mockMimeType, mockFileName), 215 + ).rejects.toThrow("Text extraction failed: Unsupported file format"); 216 + }); 217 + 218 + it("throws error when extracted text is empty", async () => { 219 + mockTextExtractorRegistry.extract.mockResolvedValue({ 220 + success: true, 221 + text: "", 222 + }); 223 + 224 + await expect( 225 + service.parseFile(mockBuffer, mockMimeType, mockFileName), 226 + ).rejects.toThrow("Could not extract any text from the file"); 227 + }); 228 + 229 + it("throws error when extracted text is only whitespace", async () => { 230 + mockTextExtractorRegistry.extract.mockResolvedValue({ 231 + success: true, 232 + text: " \n\t ", 233 + }); 234 + 235 + await expect( 236 + service.parseFile(mockBuffer, mockMimeType, mockFileName), 237 + ).rejects.toThrow("Could not extract any text from the file"); 238 + }); 239 + }); 240 + 241 + describe("parseStoryWithResolution", () => { 242 + beforeEach(() => { 243 + mockCVParser.parseCVText.mockResolvedValue(mockJohnSmithParsedCV); 244 + 245 + mockEntityResolver.resolveCompany.mockImplementation(async (name) => ({ 246 + id: name === "Acme Corporation" ? "company-1" : null, 247 + name, 248 + })); 249 + 250 + mockEntityResolver.resolveRole.mockImplementation(async (name) => ({ 251 + id: name === "Senior Software Engineer" ? "role-1" : null, 252 + name, 253 + })); 254 + 255 + mockEntityResolver.resolveLevel.mockImplementation(async (name) => ({ 256 + id: name === "Senior" ? "level-1" : null, 257 + name: name ?? "Mid-level", 258 + })); 259 + 260 + mockEntityResolver.resolveInstitution.mockImplementation( 261 + async (name) => ({ 262 + id: name === "Stanford University" ? "institution-1" : null, 263 + name, 264 + }), 265 + ); 266 + 267 + mockEntityResolver.resolveSkills.mockImplementation(async (names) => 268 + names.map((name) => ({ 269 + id: ["TypeScript", "JavaScript"].includes(name) 270 + ? `skill-${name}` 271 + : null, 272 + name, 273 + })), 274 + ); 275 + }); 276 + 277 + it("resolves entities for parsed CV data", async () => { 278 + const result = await service.parseStoryWithResolution(sampleTxtCV); 279 + 280 + expect(result.jobExperiences).toHaveLength(3); 281 + expect(result.education).toHaveLength(1); 282 + 283 + const firstJob = result.jobExperiences[0]; 284 + expect(firstJob.company.id).toBe("company-1"); 285 + expect(firstJob.company.name).toBe("Acme Corporation"); 286 + expect(firstJob.role.id).toBe("role-1"); 287 + expect(firstJob.level.id).toBe("level-1"); 288 + }); 289 + 290 + it("returns null IDs for unmatched entities", async () => { 291 + const result = await service.parseStoryWithResolution(sampleTxtCV); 292 + 293 + const secondJob = result.jobExperiences[1]; 294 + expect(secondJob.company.id).toBeNull(); 295 + expect(secondJob.company.name).toBe("TechStart Inc"); 296 + }); 297 + 298 + it("converts date strings to Date objects", async () => { 299 + const result = await service.parseStoryWithResolution(sampleTxtCV); 300 + 301 + const firstJob = result.jobExperiences[0]; 302 + expect(firstJob.startDate).toBeInstanceOf(Date); 303 + expect(firstJob.endDate).toBeNull(); 304 + 305 + const lastJob = result.jobExperiences[2]; 306 + expect(lastJob.endDate).toBeInstanceOf(Date); 307 + }); 308 + 309 + it("resolves education institutions", async () => { 310 + const result = await service.parseStoryWithResolution(sampleTxtCV); 311 + 312 + const education = result.education[0]; 313 + expect(education.institution.id).toBe("institution-1"); 314 + expect(education.institution.name).toBe("Stanford University"); 315 + expect(education.degree).toBe("Bachelor of Science"); 316 + }); 317 + 318 + it("resolves skills for both jobs and education", async () => { 319 + const result = await service.parseStoryWithResolution(sampleTxtCV); 320 + 321 + const firstJob = result.jobExperiences[0]; 322 + const typescriptSkill = firstJob.skills.find( 323 + (s) => s.name === "TypeScript", 324 + ); 325 + expect(typescriptSkill?.id).toBe("skill-TypeScript"); 326 + 327 + const kubernetesSkill = firstJob.skills.find( 328 + (s) => s.name === "Kubernetes", 329 + ); 330 + expect(kubernetesSkill?.id).toBeNull(); 331 + }); 332 + }); 333 + });
+345
apps/server/src/modules/cv-parser/__tests__/entity-resolver.service.spec.ts
··· 1 + import { PrismaService } from "@cv/system"; 2 + import { Test, type TestingModule } from "@nestjs/testing"; 3 + import { EntityResolverService } from "../entity-resolver.service"; 4 + 5 + describe("EntityResolverService", () => { 6 + let service: EntityResolverService; 7 + let mockPrisma: jest.Mocked<PrismaService>; 8 + 9 + beforeEach(async () => { 10 + mockPrisma = { 11 + company: { 12 + findFirst: jest.fn(), 13 + }, 14 + role: { 15 + findFirst: jest.fn(), 16 + }, 17 + level: { 18 + findFirst: jest.fn(), 19 + }, 20 + skill: { 21 + findFirst: jest.fn(), 22 + }, 23 + institution: { 24 + findFirst: jest.fn(), 25 + }, 26 + } as unknown as jest.Mocked<PrismaService>; 27 + 28 + const module: TestingModule = await Test.createTestingModule({ 29 + providers: [ 30 + EntityResolverService, 31 + { 32 + provide: PrismaService, 33 + useValue: mockPrisma, 34 + }, 35 + ], 36 + }).compile(); 37 + 38 + service = module.get<EntityResolverService>(EntityResolverService); 39 + }); 40 + 41 + describe("resolveCompany", () => { 42 + it("returns matched company with ID when found", async () => { 43 + const mockCompany = { id: "company-123", name: "Acme Corporation" }; 44 + (mockPrisma.company.findFirst as jest.Mock).mockResolvedValue( 45 + mockCompany, 46 + ); 47 + 48 + const result = await service.resolveCompany("Acme Corporation"); 49 + 50 + expect(mockPrisma.company.findFirst).toHaveBeenCalledWith({ 51 + where: { name: { equals: "Acme Corporation", mode: "insensitive" } }, 52 + }); 53 + expect(result).toEqual({ id: "company-123", name: "Acme Corporation" }); 54 + }); 55 + 56 + it("returns draft entity with null ID when not found", async () => { 57 + (mockPrisma.company.findFirst as jest.Mock).mockResolvedValue(null); 58 + 59 + const result = await service.resolveCompany("Unknown Company"); 60 + 61 + expect(result).toEqual({ id: null, name: "Unknown Company" }); 62 + }); 63 + 64 + it("performs case-insensitive matching", async () => { 65 + const mockCompany = { id: "company-123", name: "ACME Corporation" }; 66 + (mockPrisma.company.findFirst as jest.Mock).mockResolvedValue( 67 + mockCompany, 68 + ); 69 + 70 + const result = await service.resolveCompany("acme corporation"); 71 + 72 + expect(mockPrisma.company.findFirst).toHaveBeenCalledWith({ 73 + where: { name: { equals: "acme corporation", mode: "insensitive" } }, 74 + }); 75 + expect(result.id).toBe("company-123"); 76 + expect(result.name).toBe("ACME Corporation"); 77 + }); 78 + }); 79 + 80 + describe("resolveRole", () => { 81 + it("returns matched role with ID when found", async () => { 82 + const mockRole = { id: "role-456", name: "Senior Software Engineer" }; 83 + (mockPrisma.role.findFirst as jest.Mock).mockResolvedValue(mockRole); 84 + 85 + const result = await service.resolveRole("Senior Software Engineer"); 86 + 87 + expect(mockPrisma.role.findFirst).toHaveBeenCalledWith({ 88 + where: { 89 + name: { equals: "Senior Software Engineer", mode: "insensitive" }, 90 + }, 91 + }); 92 + expect(result).toEqual({ 93 + id: "role-456", 94 + name: "Senior Software Engineer", 95 + }); 96 + }); 97 + 98 + it("returns draft entity with null ID when not found", async () => { 99 + (mockPrisma.role.findFirst as jest.Mock).mockResolvedValue(null); 100 + 101 + const result = await service.resolveRole("Chief Happiness Officer"); 102 + 103 + expect(result).toEqual({ id: null, name: "Chief Happiness Officer" }); 104 + }); 105 + }); 106 + 107 + describe("resolveLevel", () => { 108 + it("returns matched level with ID when found", async () => { 109 + const mockLevel = { id: "level-789", name: "Senior" }; 110 + (mockPrisma.level.findFirst as jest.Mock).mockResolvedValue(mockLevel); 111 + 112 + const result = await service.resolveLevel("Senior"); 113 + 114 + expect(mockPrisma.level.findFirst).toHaveBeenCalledWith({ 115 + where: { name: { equals: "Senior", mode: "insensitive" } }, 116 + }); 117 + expect(result).toEqual({ id: "level-789", name: "Senior" }); 118 + }); 119 + 120 + it("defaults to Mid-level when no name provided", async () => { 121 + const mockLevel = { id: "level-mid", name: "Mid-level" }; 122 + (mockPrisma.level.findFirst as jest.Mock).mockResolvedValue(mockLevel); 123 + 124 + const result = await service.resolveLevel(undefined); 125 + 126 + expect(mockPrisma.level.findFirst).toHaveBeenCalledWith({ 127 + where: { name: { equals: "Mid-level", mode: "insensitive" } }, 128 + }); 129 + expect(result).toEqual({ id: "level-mid", name: "Mid-level" }); 130 + }); 131 + 132 + it("defaults to Mid-level when empty string provided", async () => { 133 + (mockPrisma.level.findFirst as jest.Mock).mockResolvedValue(null); 134 + 135 + const result = await service.resolveLevel(""); 136 + 137 + expect(mockPrisma.level.findFirst).toHaveBeenCalledWith({ 138 + where: { name: { equals: "Mid-level", mode: "insensitive" } }, 139 + }); 140 + expect(result).toEqual({ id: null, name: "Mid-level" }); 141 + }); 142 + 143 + it("returns draft entity with null ID when level not found", async () => { 144 + (mockPrisma.level.findFirst as jest.Mock).mockResolvedValue(null); 145 + 146 + const result = await service.resolveLevel("Principal"); 147 + 148 + expect(result).toEqual({ id: null, name: "Principal" }); 149 + }); 150 + }); 151 + 152 + describe("resolveSkill", () => { 153 + it("returns matched skill with ID when found", async () => { 154 + const mockSkill = { id: "skill-ts", name: "TypeScript" }; 155 + (mockPrisma.skill.findFirst as jest.Mock).mockResolvedValue(mockSkill); 156 + 157 + const result = await service.resolveSkill("TypeScript"); 158 + 159 + expect(mockPrisma.skill.findFirst).toHaveBeenCalledWith({ 160 + where: { name: { equals: "TypeScript", mode: "insensitive" } }, 161 + }); 162 + expect(result).toEqual({ id: "skill-ts", name: "TypeScript" }); 163 + }); 164 + 165 + it("returns draft entity with null ID when not found", async () => { 166 + (mockPrisma.skill.findFirst as jest.Mock).mockResolvedValue(null); 167 + 168 + const result = await service.resolveSkill("ObscureFramework"); 169 + 170 + expect(result).toEqual({ id: null, name: "ObscureFramework" }); 171 + }); 172 + 173 + it("trims whitespace from skill name", async () => { 174 + const mockSkill = { id: "skill-ts", name: "TypeScript" }; 175 + (mockPrisma.skill.findFirst as jest.Mock).mockResolvedValue(mockSkill); 176 + 177 + const result = await service.resolveSkill(" TypeScript "); 178 + 179 + expect(mockPrisma.skill.findFirst).toHaveBeenCalledWith({ 180 + where: { name: { equals: "TypeScript", mode: "insensitive" } }, 181 + }); 182 + expect(result).toEqual({ id: "skill-ts", name: "TypeScript" }); 183 + }); 184 + 185 + it("returns empty draft entity for empty/whitespace-only input", async () => { 186 + const result = await service.resolveSkill(" "); 187 + 188 + expect(mockPrisma.skill.findFirst).not.toHaveBeenCalled(); 189 + expect(result).toEqual({ id: null, name: "" }); 190 + }); 191 + }); 192 + 193 + describe("resolveInstitution", () => { 194 + it("returns matched institution with ID when found", async () => { 195 + const mockInstitution = { 196 + id: "inst-stanford", 197 + name: "Stanford University", 198 + }; 199 + (mockPrisma.institution.findFirst as jest.Mock).mockResolvedValue( 200 + mockInstitution, 201 + ); 202 + 203 + const result = await service.resolveInstitution("Stanford University"); 204 + 205 + expect(mockPrisma.institution.findFirst).toHaveBeenCalledWith({ 206 + where: { name: { equals: "Stanford University", mode: "insensitive" } }, 207 + }); 208 + expect(result).toEqual({ 209 + id: "inst-stanford", 210 + name: "Stanford University", 211 + }); 212 + }); 213 + 214 + it("returns draft entity with null ID when not found", async () => { 215 + (mockPrisma.institution.findFirst as jest.Mock).mockResolvedValue(null); 216 + 217 + const result = await service.resolveInstitution("Unknown University"); 218 + 219 + expect(result).toEqual({ id: null, name: "Unknown University" }); 220 + }); 221 + }); 222 + 223 + describe("resolveSkills", () => { 224 + it("resolves multiple skills in parallel", async () => { 225 + (mockPrisma.skill.findFirst as jest.Mock) 226 + .mockResolvedValueOnce({ id: "skill-ts", name: "TypeScript" }) 227 + .mockResolvedValueOnce({ id: "skill-js", name: "JavaScript" }) 228 + .mockResolvedValueOnce(null); 229 + 230 + const result = await service.resolveSkills([ 231 + "TypeScript", 232 + "JavaScript", 233 + "Rust", 234 + ]); 235 + 236 + expect(mockPrisma.skill.findFirst).toHaveBeenCalledTimes(3); 237 + expect(result).toHaveLength(3); 238 + expect(result[0]).toEqual({ id: "skill-ts", name: "TypeScript" }); 239 + expect(result[1]).toEqual({ id: "skill-js", name: "JavaScript" }); 240 + expect(result[2]).toEqual({ id: null, name: "Rust" }); 241 + }); 242 + 243 + it("deduplicates skill names before resolving", async () => { 244 + (mockPrisma.skill.findFirst as jest.Mock).mockResolvedValue({ 245 + id: "skill-ts", 246 + name: "TypeScript", 247 + }); 248 + 249 + const result = await service.resolveSkills([ 250 + "TypeScript", 251 + "TypeScript", 252 + "TypeScript", 253 + ]); 254 + 255 + expect(mockPrisma.skill.findFirst).toHaveBeenCalledTimes(1); 256 + expect(result).toHaveLength(1); 257 + }); 258 + 259 + it("filters out empty and whitespace-only skill names", async () => { 260 + (mockPrisma.skill.findFirst as jest.Mock).mockResolvedValue({ 261 + id: "skill-ts", 262 + name: "TypeScript", 263 + }); 264 + 265 + const result = await service.resolveSkills([ 266 + "TypeScript", 267 + "", 268 + " ", 269 + null as unknown as string, 270 + undefined as unknown as string, 271 + ]); 272 + 273 + expect(mockPrisma.skill.findFirst).toHaveBeenCalledTimes(1); 274 + expect(result).toHaveLength(1); 275 + expect(result[0]).toEqual({ id: "skill-ts", name: "TypeScript" }); 276 + }); 277 + 278 + it("returns empty array for empty input", async () => { 279 + const result = await service.resolveSkills([]); 280 + 281 + expect(mockPrisma.skill.findFirst).not.toHaveBeenCalled(); 282 + expect(result).toEqual([]); 283 + }); 284 + 285 + it("handles mixed matching and non-matching skills", async () => { 286 + (mockPrisma.skill.findFirst as jest.Mock) 287 + .mockResolvedValueOnce({ id: "skill-1", name: "React" }) 288 + .mockResolvedValueOnce(null) 289 + .mockResolvedValueOnce({ id: "skill-2", name: "Node.js" }) 290 + .mockResolvedValueOnce(null); 291 + 292 + const result = await service.resolveSkills([ 293 + "React", 294 + "SomeObscureLib", 295 + "Node.js", 296 + "AnotherRareSkill", 297 + ]); 298 + 299 + expect(result).toHaveLength(4); 300 + expect(result[0].id).toBe("skill-1"); 301 + expect(result[1].id).toBeNull(); 302 + expect(result[2].id).toBe("skill-2"); 303 + expect(result[3].id).toBeNull(); 304 + }); 305 + }); 306 + 307 + describe("fuzzy matching scenarios", () => { 308 + it("matches company names case-insensitively", async () => { 309 + const mockCompany = { id: "c-1", name: "Google LLC" }; 310 + (mockPrisma.company.findFirst as jest.Mock).mockResolvedValue( 311 + mockCompany, 312 + ); 313 + 314 + const variations = [ 315 + "google llc", 316 + "GOOGLE LLC", 317 + "Google LLC", 318 + "GoOgLe LlC", 319 + ]; 320 + 321 + for (const variation of variations) { 322 + const result = await service.resolveCompany(variation); 323 + expect(result.id).toBe("c-1"); 324 + } 325 + }); 326 + 327 + it("handles abbreviated vs full company names", async () => { 328 + (mockPrisma.company.findFirst as jest.Mock).mockResolvedValue(null); 329 + 330 + const result = await service.resolveCompany("IBM"); 331 + 332 + expect(result.id).toBeNull(); 333 + expect(result.name).toBe("IBM"); 334 + }); 335 + 336 + it("preserves original name from database when matched", async () => { 337 + const mockSkill = { id: "skill-1", name: "Node.js" }; 338 + (mockPrisma.skill.findFirst as jest.Mock).mockResolvedValue(mockSkill); 339 + 340 + const result = await service.resolveSkill("nodejs"); 341 + 342 + expect(result.name).toBe("Node.js"); 343 + }); 344 + }); 345 + });
+172
apps/server/src/modules/cv-parser/__tests__/fixtures/mock-ai-responses.ts
··· 1 + import type { ParsedCVData } from "@cv/ai-parser"; 2 + 3 + /** 4 + * Mock AI response for the plain text CV (John Smith) 5 + */ 6 + export const mockJohnSmithParsedCV: ParsedCVData = { 7 + personalInfo: { 8 + name: "John Smith", 9 + introduction: 10 + "Experienced software engineer with 8+ years of experience building scalable web applications and distributed systems.", 11 + }, 12 + jobExperiences: [ 13 + { 14 + companyName: "Acme Corporation", 15 + roleName: "Senior Software Engineer", 16 + levelName: "Senior", 17 + startDate: "2020-01-01", 18 + endDate: null, 19 + description: 20 + "Led development of microservices architecture serving 10M+ users. Mentored team of 5 junior developers.", 21 + skills: ["TypeScript", "Node.js", "Kubernetes", "PostgreSQL", "Redis"], 22 + }, 23 + { 24 + companyName: "TechStart Inc", 25 + roleName: "Software Engineer", 26 + levelName: "Mid-level", 27 + startDate: "2016-03-01", 28 + endDate: "2019-12-31", 29 + description: 30 + "Built REST APIs and GraphQL endpoints for e-commerce platform. Optimized database queries.", 31 + skills: ["JavaScript", "Python", "MySQL", "Docker", "AWS"], 32 + }, 33 + { 34 + companyName: "WebDev Agency", 35 + roleName: "Junior Developer", 36 + levelName: "Junior", 37 + startDate: "2014-06-01", 38 + endDate: "2016-02-28", 39 + description: "Developed responsive websites for various clients.", 40 + skills: ["PHP", "JavaScript", "HTML", "CSS"], 41 + }, 42 + ], 43 + education: [ 44 + { 45 + institutionName: "Stanford University", 46 + degree: "Bachelor of Science", 47 + fieldOfStudy: "Computer Science", 48 + startDate: "2010-09-01", 49 + endDate: "2014-06-30", 50 + description: "GPA: 3.8/4.0, Dean's List", 51 + skills: ["Algorithms", "Data Structures", "C++"], 52 + }, 53 + ], 54 + skills: [ 55 + "TypeScript", 56 + "JavaScript", 57 + "Python", 58 + "Node.js", 59 + "React", 60 + "GraphQL", 61 + "PostgreSQL", 62 + "MongoDB", 63 + "Redis", 64 + "Docker", 65 + "Kubernetes", 66 + "AWS", 67 + "Git", 68 + ], 69 + }; 70 + 71 + /** 72 + * Mock AI response for the markdown CV (Jane Doe) 73 + */ 74 + export const mockJaneDoeParsedCV: ParsedCVData = { 75 + personalInfo: { 76 + name: "Jane Doe", 77 + introduction: 78 + "Passionate full-stack developer with 6 years of experience in building modern web applications.", 79 + }, 80 + jobExperiences: [ 81 + { 82 + companyName: "Global Tech Solutions", 83 + roleName: "Lead Developer", 84 + levelName: "Lead", 85 + startDate: "2021-04-01", 86 + endDate: null, 87 + description: 88 + "Architected and led development of customer portal used by 50K+ enterprises.", 89 + skills: [ 90 + "React", 91 + "TypeScript", 92 + "NestJS", 93 + "PostgreSQL", 94 + "RabbitMQ", 95 + "AWS", 96 + ], 97 + }, 98 + { 99 + companyName: "StartupXYZ", 100 + roleName: "Software Engineer", 101 + levelName: "Mid-level", 102 + startDate: "2018-08-01", 103 + endDate: "2021-03-31", 104 + description: "Built real-time collaboration features using WebSockets.", 105 + skills: ["React", "React Native", "Node.js", "MongoDB", "Firebase"], 106 + }, 107 + { 108 + companyName: "Digital Creations Ltd", 109 + roleName: "Junior Software Developer", 110 + levelName: "Junior", 111 + startDate: "2017-01-01", 112 + endDate: "2018-07-31", 113 + description: "Created RESTful APIs for content management system.", 114 + skills: ["Express.js", "JavaScript", "MySQL", "Jest"], 115 + }, 116 + ], 117 + education: [ 118 + { 119 + institutionName: "Columbia University", 120 + degree: "Master of Science", 121 + fieldOfStudy: "Software Engineering", 122 + startDate: "2015-09-01", 123 + endDate: "2017-05-31", 124 + description: "Focus: Distributed Systems and Cloud Computing", 125 + skills: [], 126 + }, 127 + { 128 + institutionName: "NYU", 129 + degree: "Bachelor of Arts", 130 + fieldOfStudy: "Computer Science", 131 + startDate: "2011-09-01", 132 + endDate: "2015-05-31", 133 + description: "Minor: Mathematics, Graduated Magna Cum Laude", 134 + skills: [], 135 + }, 136 + ], 137 + skills: [ 138 + "TypeScript", 139 + "JavaScript", 140 + "Python", 141 + "Go", 142 + "React", 143 + "React Native", 144 + "Next.js", 145 + "Vue.js", 146 + "Node.js", 147 + "NestJS", 148 + "Express", 149 + "GraphQL", 150 + "PostgreSQL", 151 + "MongoDB", 152 + "Redis", 153 + "AWS", 154 + "Docker", 155 + "Kubernetes", 156 + "Terraform", 157 + ], 158 + }; 159 + 160 + /** 161 + * Mock AI response wrapped in markdown code block (common LLM output format) 162 + */ 163 + export const createMockAIJsonResponse = (data: ParsedCVData): string => 164 + `\`\`\`json 165 + ${JSON.stringify(data, null, 2)} 166 + \`\`\``; 167 + 168 + /** 169 + * Mock AI response as raw JSON (alternative LLM output format) 170 + */ 171 + export const createMockAIRawJsonResponse = (data: ParsedCVData): string => 172 + JSON.stringify(data);
+62
apps/server/src/modules/cv-parser/__tests__/fixtures/sample-cv.md
··· 1 + # Jane Doe 2 + **Full Stack Developer** | New York, NY | jane.doe@email.com 3 + 4 + --- 5 + 6 + ## Summary 7 + Passionate full-stack developer with 6 years of experience in building modern web applications. Strong background in React, Node.js, and cloud technologies. Committed to writing maintainable, well-tested code. 8 + 9 + --- 10 + 11 + ## Experience 12 + 13 + ### Lead Developer 14 + **Global Tech Solutions** | April 2021 - Present 15 + 16 + - Architected and led development of customer portal used by 50K+ enterprises 17 + - Established coding standards and code review processes for team of 8 engineers 18 + - Migrated legacy monolith to event-driven microservices architecture 19 + 20 + **Technologies:** React, TypeScript, NestJS, PostgreSQL, RabbitMQ, AWS 21 + 22 + ### Software Engineer 23 + **StartupXYZ** | August 2018 - March 2021 24 + 25 + - Built real-time collaboration features using WebSockets 26 + - Developed mobile-responsive UI components with React Native 27 + - Integrated third-party payment and authentication services 28 + 29 + **Technologies:** React, React Native, Node.js, MongoDB, Firebase 30 + 31 + ### Junior Software Developer 32 + **Digital Creations Ltd** | January 2017 - July 2018 33 + 34 + - Created RESTful APIs for content management system 35 + - Wrote unit and integration tests achieving 85% code coverage 36 + - Participated in agile ceremonies and sprint planning 37 + 38 + **Technologies:** Express.js, JavaScript, MySQL, Jest 39 + 40 + --- 41 + 42 + ## Education 43 + 44 + ### Master of Science in Software Engineering 45 + **Columbia University** | September 2015 - May 2017 46 + - Focus: Distributed Systems and Cloud Computing 47 + - Thesis: "Scalable Event-Driven Architectures for Real-Time Applications" 48 + 49 + ### Bachelor of Arts in Computer Science 50 + **NYU** | September 2011 - May 2015 51 + - Minor: Mathematics 52 + - Graduated Magna Cum Laude 53 + 54 + --- 55 + 56 + ## Skills 57 + 58 + **Languages:** TypeScript, JavaScript, Python, Go 59 + **Frontend:** React, React Native, Next.js, Vue.js 60 + **Backend:** Node.js, NestJS, Express, GraphQL 61 + **Databases:** PostgreSQL, MongoDB, Redis 62 + **Cloud & DevOps:** AWS, Docker, Kubernetes, Terraform, CI/CD
+42
apps/server/src/modules/cv-parser/__tests__/fixtures/sample-cv.txt
··· 1 + John Smith 2 + Software Engineer | San Francisco, CA 3 + 4 + PROFESSIONAL SUMMARY 5 + Experienced software engineer with 8+ years of experience building scalable web applications and distributed systems. Passionate about clean code, testing, and mentoring junior developers. 6 + 7 + WORK EXPERIENCE 8 + 9 + Senior Software Engineer 10 + Acme Corporation 11 + January 2020 - Present 12 + - Led development of microservices architecture serving 10M+ users 13 + - Mentored team of 5 junior developers 14 + - Implemented CI/CD pipelines reducing deployment time by 60% 15 + Skills: TypeScript, Node.js, Kubernetes, PostgreSQL, Redis 16 + 17 + Software Engineer 18 + TechStart Inc 19 + March 2016 - December 2019 20 + - Built REST APIs and GraphQL endpoints for e-commerce platform 21 + - Optimized database queries improving response times by 40% 22 + - Collaborated with product team on feature specifications 23 + Skills: JavaScript, Python, MySQL, Docker, AWS 24 + 25 + Junior Developer 26 + WebDev Agency 27 + June 2014 - February 2016 28 + - Developed responsive websites for various clients 29 + - Maintained legacy PHP applications 30 + Skills: PHP, JavaScript, HTML, CSS 31 + 32 + EDUCATION 33 + 34 + Bachelor of Science in Computer Science 35 + Stanford University 36 + September 2010 - June 2014 37 + - GPA: 3.8/4.0 38 + - Dean's List 39 + Skills: Algorithms, Data Structures, C++ 40 + 41 + SKILLS 42 + TypeScript, JavaScript, Python, Node.js, React, GraphQL, PostgreSQL, MongoDB, Redis, Docker, Kubernetes, AWS, Git
+67
apps/server/src/modules/cv-parser/cv-parser.module.ts
··· 1 + import { randomBytes } from "node:crypto"; 2 + import { mkdirSync } from "node:fs"; 3 + import { join } from "node:path"; 4 + import { CVParserModule as CVParserCoreModule } from "@cv/ai-parser"; 5 + import { FileExtractionModule } from "@cv/file-upload"; 6 + import { DatabaseModule } from "@cv/system"; 7 + import { Module } from "@nestjs/common"; 8 + import { MulterModule } from "@nestjs/platform-express"; 9 + import { diskStorage } from "multer"; 10 + import { CVParserResolver } from "./cv-parser.resolver"; 11 + import { CVParserService } from "./cv-parser.service"; 12 + import { EntityResolverService } from "./entity-resolver.service"; 13 + import { FileUploadController } from "./file-upload.controller"; 14 + import { CVParserPersistenceService } from "./persistence.service"; 15 + 16 + @Module({ 17 + imports: [ 18 + CVParserCoreModule.forRoot({ type: "llama-cpp" }), 19 + FileExtractionModule.forRoot(), 20 + DatabaseModule, 21 + MulterModule.register({ 22 + storage: diskStorage({ 23 + destination: (_req, _file, cb) => { 24 + const tmpDir = join(process.cwd(), "tmp", "cv-uploads"); 25 + mkdirSync(tmpDir, { recursive: true }); 26 + cb(null, tmpDir); 27 + }, 28 + filename: (_req, file, cb) => { 29 + const uniqueSuffix = `${Date.now()}-${randomBytes(8).toString("hex")}`; 30 + const ext = file.originalname.split(".").pop() ?? ""; 31 + cb(null, `${uniqueSuffix}.${ext}`); 32 + }, 33 + }), 34 + limits: { 35 + fileSize: 10 * 1024 * 1024, // 10MB 36 + }, 37 + fileFilter: (_req, file, cb) => { 38 + const allowedMimes = [ 39 + "application/pdf", 40 + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 41 + "text/plain", 42 + "text/markdown", 43 + ]; 44 + 45 + if (allowedMimes.includes(file.mimetype)) { 46 + cb(null, true); 47 + } else { 48 + cb( 49 + new Error( 50 + `File type ${file.mimetype} not allowed. Supported: PDF, DOCX, TXT, MD`, 51 + ), 52 + false, 53 + ); 54 + } 55 + }, 56 + }), 57 + ], 58 + providers: [ 59 + EntityResolverService, 60 + CVParserService, 61 + CVParserPersistenceService, 62 + CVParserResolver, 63 + ], 64 + controllers: [FileUploadController], 65 + exports: [CVParserService, CVParserPersistenceService], 66 + }) 67 + export class CVParserModule {}
+54
apps/server/src/modules/cv-parser/cv-parser.resolver.ts
··· 1 + import type { User as DomainUser } from "@cv/auth"; 2 + import { JwtAuthGuard } from "@cv/auth"; 3 + import { UseGuards } from "@nestjs/common"; 4 + import { Args, Mutation, Resolver } from "@nestjs/graphql"; 5 + import { CurrentUser } from "../current-user/current-user.decorator"; 6 + import { CVParserService } from "./cv-parser.service"; 7 + import { CVParserPersistenceService } from "./persistence.service"; 8 + import { 9 + DraftEducationType, 10 + DraftJobExperienceType, 11 + ParsedCVDataWithResolutionType, 12 + } from "./types/draft.types"; 13 + import { ParsedCVDataInput } from "./types/input.types"; 14 + 15 + @Resolver() 16 + export class CVParserResolver { 17 + constructor( 18 + private readonly cvParserService: CVParserService, 19 + private readonly persistenceService: CVParserPersistenceService, 20 + ) {} 21 + 22 + /** 23 + * Parse story text and resolve entities to existing database records 24 + * Returns draft data with entity IDs where matches were found 25 + */ 26 + @Mutation(() => ParsedCVDataWithResolutionType) 27 + @UseGuards(JwtAuthGuard) 28 + async parseStory( 29 + @CurrentUser() _user: DomainUser, 30 + @Args("storyText") storyText: string, 31 + ): Promise<ParsedCVDataWithResolutionType> { 32 + const resolved = 33 + await this.cvParserService.parseStoryWithResolution(storyText); 34 + 35 + return new ParsedCVDataWithResolutionType( 36 + resolved.jobExperiences.map(DraftJobExperienceType.fromResolved), 37 + resolved.education.map(DraftEducationType.fromResolved), 38 + ); 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 + } 54 + }
+170
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"; 6 + import { 7 + TEXT_EXTRACTOR_REGISTRY, 8 + type TextExtractorRegistry, 9 + validateFile, 10 + } from "@cv/file-upload"; 11 + import { Inject, Injectable } from "@nestjs/common"; 12 + import { 13 + EntityResolverService, 14 + type ResolvedEducation, 15 + type ResolvedJobExperience, 16 + } from "./entity-resolver.service"; 17 + 18 + /** 19 + * Parsed CV data with resolved entities 20 + */ 21 + export interface ParsedCVDataWithResolution { 22 + jobExperiences: ResolvedJobExperience[]; 23 + education: ResolvedEducation[]; 24 + } 25 + 26 + @Injectable() 27 + export class CVParserService { 28 + constructor( 29 + @Inject(CV_PARSER_SERVICE) 30 + private readonly cvParser: CVParser, 31 + @Inject(TEXT_EXTRACTOR_REGISTRY) 32 + private readonly textExtractorRegistry: TextExtractorRegistry, 33 + private readonly entityResolver: EntityResolverService, 34 + ) {} 35 + 36 + async parseFile( 37 + buffer: Buffer, 38 + mimeType: string, 39 + originalName: string, 40 + ): Promise<ParsedCVData> { 41 + const validation = validateFile({ 42 + buffer, 43 + mimeType, 44 + originalName, 45 + sizeBytes: buffer.length, 46 + }); 47 + 48 + if (!validation.valid) { 49 + throw new Error(`File validation failed: ${validation.error}`); 50 + } 51 + 52 + const extraction = await this.textExtractorRegistry.extract( 53 + buffer, 54 + mimeType, 55 + ); 56 + 57 + if (!extraction.success) { 58 + throw new Error(`Text extraction failed: ${extraction.error}`); 59 + } 60 + 61 + if (!extraction.text || extraction.text.trim().length === 0) { 62 + throw new Error("Could not extract any text from the file"); 63 + } 64 + 65 + return this.cvParser.parseCVText(extraction.text); 66 + } 67 + 68 + async parseStory(storyText: string): Promise<ParsedCVData> { 69 + if (!storyText || storyText.trim().length === 0) { 70 + throw new Error("Story text cannot be empty"); 71 + } 72 + 73 + if (storyText.trim().length > 50000) { 74 + throw new Error("Story text is too long (max 50,000 characters)"); 75 + } 76 + 77 + return this.cvParser.parseCVText(storyText); 78 + } 79 + 80 + /** 81 + * Parse story text and resolve entities to existing database records 82 + * Returns draft data with entity IDs where matches were found 83 + */ 84 + async parseStoryWithResolution( 85 + storyText: string, 86 + ): Promise<ParsedCVDataWithResolution> { 87 + const parsed = await this.parseStory(storyText); 88 + return this.resolveEntities(parsed); 89 + } 90 + 91 + /** 92 + * Parse file and resolve entities to existing database records 93 + */ 94 + async parseFileWithResolution( 95 + buffer: Buffer, 96 + mimeType: string, 97 + originalName: string, 98 + ): Promise<ParsedCVDataWithResolution> { 99 + const parsed = await this.parseFile(buffer, mimeType, originalName); 100 + return this.resolveEntities(parsed); 101 + } 102 + 103 + /** 104 + * Resolve entity names to existing database records 105 + */ 106 + private async resolveEntities( 107 + parsed: ParsedCVData, 108 + ): Promise<ParsedCVDataWithResolution> { 109 + const [jobExperiences, education] = await Promise.all([ 110 + this.resolveJobExperiences(parsed.jobExperiences), 111 + this.resolveEducation(parsed.education), 112 + ]); 113 + 114 + return { jobExperiences, education }; 115 + } 116 + 117 + /** 118 + * Resolve job experience entities 119 + */ 120 + private async resolveJobExperiences( 121 + jobs: ParsedCVData["jobExperiences"], 122 + ): Promise<ResolvedJobExperience[]> { 123 + return Promise.all( 124 + jobs.map(async (job) => { 125 + const [company, role, level, skills] = await Promise.all([ 126 + this.entityResolver.resolveCompany(job.companyName), 127 + this.entityResolver.resolveRole(job.roleName), 128 + this.entityResolver.resolveLevel(job.levelName), 129 + this.entityResolver.resolveSkills(job.skills ?? []), 130 + ]); 131 + 132 + return { 133 + company, 134 + role, 135 + level, 136 + skills, 137 + startDate: new Date(job.startDate), 138 + endDate: job.endDate ? new Date(job.endDate) : null, 139 + description: job.description ?? null, 140 + }; 141 + }), 142 + ); 143 + } 144 + 145 + /** 146 + * Resolve education entities 147 + */ 148 + private async resolveEducation( 149 + education: ParsedCVData["education"], 150 + ): Promise<ResolvedEducation[]> { 151 + return Promise.all( 152 + education.map(async (edu) => { 153 + const [institution, skills] = await Promise.all([ 154 + this.entityResolver.resolveInstitution(edu.institutionName), 155 + this.entityResolver.resolveSkills(edu.skills ?? []), 156 + ]); 157 + 158 + return { 159 + institution, 160 + degree: edu.degree, 161 + fieldOfStudy: edu.fieldOfStudy ?? null, 162 + skills, 163 + startDate: new Date(edu.startDate), 164 + endDate: edu.endDate ? new Date(edu.endDate) : null, 165 + description: edu.description ?? null, 166 + }; 167 + }), 168 + ); 169 + } 170 + }
+120
apps/server/src/modules/cv-parser/entity-resolver.service.ts
··· 1 + import { PrismaService } from "@cv/system"; 2 + import { Injectable } from "@nestjs/common"; 3 + 4 + /** 5 + * Draft entity representation - ID is null if no match found 6 + */ 7 + export interface DraftEntity { 8 + id: string | null; 9 + name: string; 10 + } 11 + 12 + /** 13 + * Resolved job experience with draft entities 14 + */ 15 + export interface ResolvedJobExperience { 16 + company: DraftEntity; 17 + role: DraftEntity; 18 + level: DraftEntity; 19 + skills: DraftEntity[]; 20 + startDate: Date; 21 + endDate: Date | null; 22 + description: string | null; 23 + } 24 + 25 + /** 26 + * Resolved education with draft entities 27 + */ 28 + export interface ResolvedEducation { 29 + institution: DraftEntity; 30 + degree: string; 31 + fieldOfStudy: string | null; 32 + skills: DraftEntity[]; 33 + startDate: Date; 34 + endDate: Date | null; 35 + description: string | null; 36 + } 37 + 38 + /** 39 + * Service for resolving entity names to existing database entities 40 + * Returns stubs with null IDs for unmatched names 41 + */ 42 + @Injectable() 43 + export class EntityResolverService { 44 + constructor(private readonly prisma: PrismaService) {} 45 + 46 + /** 47 + * Resolve a company name to an existing entity 48 + */ 49 + async resolveCompany(name: string): Promise<DraftEntity> { 50 + const match = await this.prisma.company.findFirst({ 51 + where: { name: { equals: name, mode: "insensitive" } }, 52 + }); 53 + 54 + return match ? { id: match.id, name: match.name } : { id: null, name }; 55 + } 56 + 57 + /** 58 + * Resolve a role name to an existing entity 59 + */ 60 + async resolveRole(name: string): Promise<DraftEntity> { 61 + const match = await this.prisma.role.findFirst({ 62 + where: { name: { equals: name, mode: "insensitive" } }, 63 + }); 64 + 65 + return match ? { id: match.id, name: match.name } : { id: null, name }; 66 + } 67 + 68 + /** 69 + * Resolve a level name to an existing entity 70 + * Falls back to "Mid-level" if not provided 71 + */ 72 + async resolveLevel(name?: string): Promise<DraftEntity> { 73 + const searchName = name || "Mid-level"; 74 + const match = await this.prisma.level.findFirst({ 75 + where: { name: { equals: searchName, mode: "insensitive" } }, 76 + }); 77 + 78 + return match 79 + ? { id: match.id, name: match.name } 80 + : { id: null, name: searchName }; 81 + } 82 + 83 + /** 84 + * Resolve a skill name to an existing entity 85 + */ 86 + async resolveSkill(name: string): Promise<DraftEntity> { 87 + const trimmed = name.trim(); 88 + if (!trimmed) { 89 + return { id: null, name: "" }; 90 + } 91 + 92 + const match = await this.prisma.skill.findFirst({ 93 + where: { name: { equals: trimmed, mode: "insensitive" } }, 94 + }); 95 + 96 + return match 97 + ? { id: match.id, name: match.name } 98 + : { id: null, name: trimmed }; 99 + } 100 + 101 + /** 102 + * Resolve an institution name to an existing entity 103 + */ 104 + async resolveInstitution(name: string): Promise<DraftEntity> { 105 + const match = await this.prisma.institution.findFirst({ 106 + where: { name: { equals: name, mode: "insensitive" } }, 107 + }); 108 + 109 + return match ? { id: match.id, name: match.name } : { id: null, name }; 110 + } 111 + 112 + /** 113 + * Resolve multiple skills in parallel 114 + */ 115 + async resolveSkills(names: string[]): Promise<DraftEntity[]> { 116 + const uniqueNames = [...new Set(names.filter((s) => s?.trim()))]; 117 + 118 + return Promise.all(uniqueNames.map((name) => this.resolveSkill(name))); 119 + } 120 + }
+74
apps/server/src/modules/cv-parser/file-upload.controller.ts
··· 1 + import { unlink } from "node:fs/promises"; 2 + import { JwtAuthGuard } from "@cv/auth"; 3 + import { 4 + BadRequestException, 5 + Controller, 6 + InternalServerErrorException, 7 + Logger, 8 + Post, 9 + UploadedFile, 10 + UseGuards, 11 + UseInterceptors, 12 + } from "@nestjs/common"; 13 + import { FileInterceptor } from "@nestjs/platform-express"; 14 + import { CVParserService } from "./cv-parser.service"; 15 + 16 + @Controller("api/cv-parser") 17 + export class FileUploadController { 18 + private readonly logger = new Logger(FileUploadController.name); 19 + 20 + constructor(private cvParserService: CVParserService) {} 21 + 22 + @Post("upload") 23 + @UseGuards(JwtAuthGuard) 24 + @UseInterceptors(FileInterceptor("file")) 25 + async uploadAndParse(@UploadedFile() file?: Express.Multer.File) { 26 + if (!file) { 27 + throw new BadRequestException("No file provided"); 28 + } 29 + 30 + const filePath = file.path; 31 + 32 + try { 33 + // Read file buffer 34 + const buffer = 35 + file.buffer ?? (await require("node:fs/promises").readFile(filePath)); 36 + 37 + // Parse the file 38 + const parsedData = await this.cvParserService.parseFile( 39 + buffer, 40 + file.mimetype, 41 + file.originalname, 42 + ); 43 + 44 + return { 45 + success: true, 46 + data: parsedData, 47 + fileName: file.originalname, 48 + fileSize: file.size, 49 + }; 50 + } catch (error) { 51 + this.logger.error("File parsing error:", error); 52 + 53 + const errorMessage = 54 + error instanceof Error ? error.message : "Failed to process file"; 55 + 56 + throw new InternalServerErrorException({ 57 + message: "File parsing failed", 58 + details: errorMessage, 59 + }); 60 + } finally { 61 + // Clean up uploaded file 62 + if (filePath) { 63 + try { 64 + await unlink(filePath); 65 + } catch (err) { 66 + this.logger.warn( 67 + `Failed to clean up temporary file ${filePath}:`, 68 + err, 69 + ); 70 + } 71 + } 72 + } 73 + } 74 + }
+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 + }
+162
apps/server/src/modules/cv-parser/types/draft.types.ts
··· 1 + import { Field, ID, ObjectType } from "@nestjs/graphql"; 2 + import type { 3 + DraftEntity, 4 + ResolvedEducation, 5 + ResolvedJobExperience, 6 + } from "../entity-resolver.service"; 7 + 8 + /** 9 + * Draft entity - represents an entity that may or may not exist in the database 10 + * If id is null, the entity needs to be created on save 11 + */ 12 + @ObjectType() 13 + export class DraftEntityType { 14 + @Field(() => ID, { nullable: true }) 15 + id: string | null; 16 + 17 + @Field() 18 + name: string; 19 + 20 + constructor(id: string | null, name: string) { 21 + this.id = id; 22 + this.name = name; 23 + } 24 + 25 + static fromDraft(draft: DraftEntity): DraftEntityType { 26 + return new DraftEntityType(draft.id, draft.name); 27 + } 28 + } 29 + 30 + /** 31 + * Draft job experience - parsed from CV with resolved entities 32 + */ 33 + @ObjectType() 34 + export class DraftJobExperienceType { 35 + @Field(() => DraftEntityType) 36 + company: DraftEntityType; 37 + 38 + @Field(() => DraftEntityType) 39 + role: DraftEntityType; 40 + 41 + @Field(() => DraftEntityType) 42 + level: DraftEntityType; 43 + 44 + @Field(() => [DraftEntityType]) 45 + skills: DraftEntityType[]; 46 + 47 + @Field() 48 + startDate: Date; 49 + 50 + @Field(() => Date, { nullable: true }) 51 + endDate: Date | null; 52 + 53 + @Field(() => String, { nullable: true }) 54 + description: string | null; 55 + 56 + constructor( 57 + company: DraftEntityType, 58 + role: DraftEntityType, 59 + level: DraftEntityType, 60 + skills: DraftEntityType[], 61 + startDate: Date, 62 + endDate: Date | null, 63 + description: string | null, 64 + ) { 65 + this.company = company; 66 + this.role = role; 67 + this.level = level; 68 + this.skills = skills; 69 + this.startDate = startDate; 70 + this.endDate = endDate; 71 + this.description = description; 72 + } 73 + 74 + static fromResolved(resolved: ResolvedJobExperience): DraftJobExperienceType { 75 + return new DraftJobExperienceType( 76 + DraftEntityType.fromDraft(resolved.company), 77 + DraftEntityType.fromDraft(resolved.role), 78 + DraftEntityType.fromDraft(resolved.level), 79 + resolved.skills.map(DraftEntityType.fromDraft), 80 + resolved.startDate, 81 + resolved.endDate, 82 + resolved.description, 83 + ); 84 + } 85 + } 86 + 87 + /** 88 + * Draft education - parsed from CV with resolved entities 89 + */ 90 + @ObjectType() 91 + export class DraftEducationType { 92 + @Field(() => DraftEntityType) 93 + institution: DraftEntityType; 94 + 95 + @Field() 96 + degree: string; 97 + 98 + @Field(() => String, { nullable: true }) 99 + fieldOfStudy: string | null; 100 + 101 + @Field(() => [DraftEntityType]) 102 + skills: DraftEntityType[]; 103 + 104 + @Field() 105 + startDate: Date; 106 + 107 + @Field(() => Date, { nullable: true }) 108 + endDate: Date | null; 109 + 110 + @Field(() => String, { nullable: true }) 111 + description: string | null; 112 + 113 + constructor( 114 + institution: DraftEntityType, 115 + degree: string, 116 + fieldOfStudy: string | null, 117 + skills: DraftEntityType[], 118 + startDate: Date, 119 + endDate: Date | null, 120 + description: string | null, 121 + ) { 122 + this.institution = institution; 123 + this.degree = degree; 124 + this.fieldOfStudy = fieldOfStudy; 125 + this.skills = skills; 126 + this.startDate = startDate; 127 + this.endDate = endDate; 128 + this.description = description; 129 + } 130 + 131 + static fromResolved(resolved: ResolvedEducation): DraftEducationType { 132 + return new DraftEducationType( 133 + DraftEntityType.fromDraft(resolved.institution), 134 + resolved.degree, 135 + resolved.fieldOfStudy, 136 + resolved.skills.map(DraftEntityType.fromDraft), 137 + resolved.startDate, 138 + resolved.endDate, 139 + resolved.description, 140 + ); 141 + } 142 + } 143 + 144 + /** 145 + * Parsed CV data with resolved entities 146 + */ 147 + @ObjectType() 148 + export class ParsedCVDataWithResolutionType { 149 + @Field(() => [DraftJobExperienceType]) 150 + jobExperiences: DraftJobExperienceType[]; 151 + 152 + @Field(() => [DraftEducationType]) 153 + education: DraftEducationType[]; 154 + 155 + constructor( 156 + jobExperiences: DraftJobExperienceType[], 157 + education: DraftEducationType[], 158 + ) { 159 + this.jobExperiences = jobExperiences; 160 + this.education = education; 161 + } 162 + }
+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 + }
+12 -12
apps/server/test/auth-integration.e2e-spec.ts
··· 42 42 }); 43 43 44 44 expect(registerResponse.body.data.register).toBeDefined(); 45 - expect(registerResponse.body.data.register.access_token).toBeDefined(); 46 45 expect(registerResponse.body.data.register.user.email).toBe( 47 46 newTestUser.email, 48 47 ); ··· 50 49 newTestUser.name, 51 50 ); 52 51 53 - const { access_token } = registerResponse.body.data.register; 52 + const cookies = registerResponse.headers["set-cookie"]; 53 + expect(cookies).toBeDefined(); 54 54 55 55 // Step 2: Login with the registered user 56 56 const loginResponse = await makeUnauthenticatedRequest(app) ··· 64 64 .expect(200); 65 65 66 66 expect(loginResponse.body.data.login).toBeDefined(); 67 - expect(loginResponse.body.data.login.access_token).toBeDefined(); 68 67 expect(loginResponse.body.data.login.user.email).toBe(newTestUser.email); 69 68 expect(loginResponse.body.data.login.user.name).toBe(newTestUser.name); 70 69 71 - // Step 3: Use the token to get current user info 72 - const meResponse = await makeAuthenticatedRequest(app, access_token) 70 + // Step 3: Use the cookies to get current user info 71 + const loginCookies = loginResponse.headers["set-cookie"]; 72 + const meResponse = await makeAuthenticatedRequest(app, loginCookies) 73 73 .send({ 74 74 query: BASIC_ME_QUERY, 75 75 }) ··· 89 89 .expect(200); 90 90 91 91 expect(response.body.errors).toBeDefined(); 92 - expect(response.body.errors[0].message).toContain("No token provided"); 92 + expect(response.body.errors[0].message).toContain("No authentication token provided"); 93 93 }); 94 94 95 95 it("should fail login with invalid credentials", async () => { ··· 146 146 147 147 describe("Me Query Optimization", () => { 148 148 it("should return basic user info without loading organizations", async () => { 149 - const response = await makeAuthenticatedRequest(app, testUser.accessToken) 149 + const response = await makeAuthenticatedRequest(app, testUser.cookies) 150 150 .send({ 151 151 query: BASIC_ME_QUERY, 152 152 }) ··· 163 163 }); 164 164 165 165 it("should load organizations when explicitly requested", async () => { 166 - const response = await makeAuthenticatedRequest(app, testUser.accessToken) 166 + const response = await makeAuthenticatedRequest(app, testUser.cookies) 167 167 .send({ 168 168 query: ME_WITH_ORGANIZATIONS_QUERY, 169 169 }) ··· 181 181 }); 182 182 183 183 it("should handle partial organization field requests", async () => { 184 - const response = await makeAuthenticatedRequest(app, testUser.accessToken) 184 + const response = await makeAuthenticatedRequest(app, testUser.cookies) 185 185 .send({ 186 186 query: ME_WITH_ORGANIZATIONS_QUERY, 187 187 }) ··· 203 203 }); 204 204 205 205 it("should return empty organizations array for user with no organizations", async () => { 206 - const response = await makeAuthenticatedRequest(app, testUser.accessToken) 206 + const response = await makeAuthenticatedRequest(app, testUser.cookies) 207 207 .send({ 208 208 query: ME_WITH_ORGANIZATIONS_QUERY, 209 209 }) ··· 224 224 225 225 describe("Complex User Query with Organizations", () => { 226 226 it("should return user with organizations", async () => { 227 - const response = await makeAuthenticatedRequest(app, testUser.accessToken) 227 + const response = await makeAuthenticatedRequest(app, testUser.cookies) 228 228 .send({ 229 229 query: COMPLEX_ME_QUERY, 230 230 }) ··· 262 262 .expect(200); 263 263 264 264 expect(response.body.errors).toBeDefined(); 265 - expect(response.body.errors[0].message).toContain("No token provided"); 265 + expect(response.body.errors[0].message).toContain("No authentication token provided"); 266 266 }); 267 267 }); 268 268 });
+7 -6
apps/server/test/auth.e2e-spec.ts
··· 68 68 } 69 69 70 70 expect(registerResponse.body.data.register).toBeDefined(); 71 - expect(registerResponse.body.data.register.access_token).toBeDefined(); 72 71 expect(registerResponse.body.data.register.user.email).toBe( 73 72 testUser.email, 74 73 ); 75 74 expect(registerResponse.body.data.register.user.name).toBe(testUser.name); 76 75 77 - const { access_token } = registerResponse.body.data.register; 76 + // Extract cookies from the response 77 + const cookies = registerResponse.headers["set-cookie"]; 78 + expect(cookies).toBeDefined(); 78 79 79 80 // Step 2: Login with the registered user 80 81 const loginResponse = await request(app.getHttpServer()) ··· 89 90 .expect(200); 90 91 91 92 expect(loginResponse.body.data.login).toBeDefined(); 92 - expect(loginResponse.body.data.login.access_token).toBeDefined(); 93 93 expect(loginResponse.body.data.login.user.email).toBe(testUser.email); 94 94 expect(loginResponse.body.data.login.user.name).toBe(testUser.name); 95 95 96 - // Step 3: Use the token to get current user info 96 + // Step 3: Use the cookies to get current user info 97 + const loginCookies = loginResponse.headers["set-cookie"]; 97 98 const meResponse = await request(app.getHttpServer()) 98 99 .post("/graphql") 99 - .set("Authorization", `Bearer ${access_token}`) 100 + .set("Cookie", loginCookies) 100 101 .send({ 101 102 query: BASIC_ME_QUERY, 102 103 }) ··· 117 118 .expect(200); 118 119 119 120 expect(response.body.errors).toBeDefined(); 120 - expect(response.body.errors[0].message).toContain("No token provided"); 121 + expect(response.body.errors[0].message).toContain("No authentication token provided"); 121 122 }); 122 123 123 124 it("should fail login with invalid credentials", async () => {
+1 -1
apps/server/test/coverage-unit/lcov-report/index.html
··· 86 86 <div class='footer quiet pad2 space-top1 center small'> 87 87 Code coverage generated by 88 88 <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a> 89 - at 2025-11-03T21:19:29.728Z 89 + at 2026-01-10T20:21:56.043Z 90 90 </div> 91 91 <script src="prettify.js"></script> 92 92 <script>
+349
apps/server/test/cv-upload-integration.e2e-spec.ts
··· 1 + import { readFileSync } from "node:fs"; 2 + import { join } from "node:path"; 3 + import request from "supertest"; 4 + 5 + /** 6 + * Integration tests for CV file upload and parsing 7 + * 8 + * Prerequisites: 9 + * - Server must be running on http://localhost:3000 10 + * - llama-server must be running on port 8080 (or LLAMA_URL env var) 11 + * 12 + * Run with: pnpm test:e2e cv-upload-integration 13 + * 14 + * This test connects to a running server instead of bootstrapping 15 + * the full NestJS application to avoid module loading issues. 16 + */ 17 + describe("CV Upload Integration Tests", () => { 18 + const SERVER_URL = process.env.SERVER_URL || "http://localhost:3000"; 19 + let accessToken: string; 20 + 21 + const TEST_USER = { 22 + name: "CV Integration Test", 23 + email: `cv-integration-${Date.now()}@example.com`, 24 + password: "TestPassword123!", 25 + }; 26 + 27 + beforeAll(async () => { 28 + // Check if server is running 29 + try { 30 + const healthResponse = await request(SERVER_URL).get("/health"); 31 + expect(healthResponse.status).toBe(200); 32 + console.log(`\n✅ Server is running at ${SERVER_URL}`); 33 + } catch (error) { 34 + console.error(`\n❌ Server not running at ${SERVER_URL}`); 35 + console.error(` Start with: docker compose up -d`); 36 + throw error; 37 + } 38 + 39 + // Register test user 40 + try { 41 + const registerResponse = await request(SERVER_URL) 42 + .post("/api/auth/password/register") 43 + .send(TEST_USER) 44 + .timeout(10000); 45 + 46 + if (registerResponse.status === 201 && registerResponse.body.accessToken) { 47 + accessToken = registerResponse.body.accessToken; 48 + console.log(`✅ Test user registered`); 49 + } else if (registerResponse.status === 409) { 50 + // User exists, try login 51 + const loginResponse = await request(SERVER_URL) 52 + .post("/api/auth/password/login") 53 + .send({ 54 + email: TEST_USER.email, 55 + password: TEST_USER.password, 56 + }) 57 + .timeout(10000); 58 + 59 + accessToken = loginResponse.body.accessToken; 60 + console.log(`✅ Test user logged in`); 61 + } else { 62 + throw new Error( 63 + `Unexpected registration response: ${registerResponse.status}`, 64 + ); 65 + } 66 + 67 + expect(accessToken).toBeDefined(); 68 + } catch (error) { 69 + console.error(`❌ Authentication failed:`, error); 70 + throw error; 71 + } 72 + }); 73 + 74 + describe("Text File Parsing", () => { 75 + it("should parse test-cv.txt successfully", async () => { 76 + const cvPath = join(__dirname, "../../../test-cv.txt"); 77 + const cvBuffer = readFileSync(cvPath); 78 + 79 + console.log(`\n📄 Testing: test-cv.txt`); 80 + const startTime = Date.now(); 81 + 82 + const response = await request(SERVER_URL) 83 + .post("/api/cv-parser/upload") 84 + .set("Authorization", `Bearer ${accessToken}`) 85 + .attach("file", cvBuffer, "test-cv.txt") 86 + .timeout(360000) // 6 minutes 87 + .expect(201); 88 + 89 + const duration = (Date.now() - startTime) / 1000; 90 + 91 + expect(response.body.success).toBe(true); 92 + expect(response.body.data).toBeDefined(); 93 + expect(response.body.data.personalInfo).toBeDefined(); 94 + 95 + const { data } = response.body; 96 + 97 + console.log(`\n⏱️ Results:`); 98 + console.log(` Duration: ${duration.toFixed(1)}s`); 99 + console.log(` Name: ${data.personalInfo.name || "N/A"}`); 100 + console.log(` Email: ${data.personalInfo.email || "N/A"}`); 101 + console.log(` Job Experiences: ${data.jobExperiences?.length || 0}`); 102 + console.log(` Education: ${data.education?.length || 0}`); 103 + console.log(` Skills: ${data.skills?.length || 0}`); 104 + 105 + // Performance feedback 106 + if (duration < 30) { 107 + console.log(` 🚀 Excellent! (GPU-accelerated)`); 108 + } else if (duration < 60) { 109 + console.log(` ✅ Good performance`); 110 + } else if (duration < 180) { 111 + console.log(` ⚠️ Slower than expected (CPU-only?)`); 112 + } else { 113 + console.log(` ❌ Very slow - check llama-server`); 114 + } 115 + 116 + // Verify parsed data structure 117 + expect(data.personalInfo).toHaveProperty("name"); 118 + expect(data.jobExperiences).toBeInstanceOf(Array); 119 + expect(data.education).toBeInstanceOf(Array); 120 + }, 360000); 121 + 122 + it("should parse test-cv-tiny.md quickly", async () => { 123 + const cvPath = join(__dirname, "../../../test-cv-tiny.md"); 124 + const cvBuffer = readFileSync(cvPath); 125 + 126 + console.log(`\n📄 Testing: test-cv-tiny.md`); 127 + const startTime = Date.now(); 128 + 129 + const response = await request(SERVER_URL) 130 + .post("/api/cv-parser/upload") 131 + .set("Authorization", `Bearer ${accessToken}`) 132 + .attach("file", cvBuffer, "test-cv-tiny.md") 133 + .timeout(120000) // 2 minutes 134 + .expect(201); 135 + 136 + const duration = (Date.now() - startTime) / 1000; 137 + 138 + console.log(`\n⏱️ Results:`); 139 + console.log(` Duration: ${duration.toFixed(1)}s`); 140 + console.log(` Name: ${response.body.data.personalInfo.name || "N/A"}`); 141 + 142 + expect(response.body.success).toBe(true); 143 + expect(duration).toBeLessThan(60); // Tiny CV should parse in <1 min 144 + }, 120000); 145 + }); 146 + 147 + describe("Real PDF Files", () => { 148 + it("should parse real CV from Downloads folder", async () => { 149 + const downloadsPath = join(process.env.HOME || "~", "Downloads"); 150 + const testFiles = [ 151 + "CV Niels Mokkenstorm [EN].pdf", 152 + "CV Niels Mokkenstorm [EN]-1.pdf", 153 + ]; 154 + 155 + for (const fileName of testFiles) { 156 + const filePath = join(downloadsPath, fileName); 157 + 158 + try { 159 + const buffer = readFileSync(filePath); 160 + 161 + console.log(`\n📄 Testing: ${fileName}`); 162 + const startTime = Date.now(); 163 + 164 + const response = await request(SERVER_URL) 165 + .post("/api/cv-parser/upload") 166 + .set("Authorization", `Bearer ${accessToken}`) 167 + .attach("file", buffer, fileName) 168 + .timeout(360000) 169 + .expect(201); 170 + 171 + const duration = (Date.now() - startTime) / 1000; 172 + 173 + console.log(`\n⏱️ Results:`); 174 + console.log(` Duration: ${duration.toFixed(1)}s`); 175 + console.log( 176 + ` Name: ${response.body.data.personalInfo.name || "N/A"}`, 177 + ); 178 + console.log( 179 + ` Email: ${response.body.data.personalInfo.email || "N/A"}`, 180 + ); 181 + console.log( 182 + ` Phone: ${response.body.data.personalInfo.phone || "N/A"}`, 183 + ); 184 + console.log( 185 + ` Job Experiences: ${response.body.data.jobExperiences?.length || 0}`, 186 + ); 187 + console.log( 188 + ` Education: ${response.body.data.education?.length || 0}`, 189 + ); 190 + console.log( 191 + ` Skills: ${response.body.data.skills?.length || 0}`, 192 + ); 193 + 194 + if (duration < 30) { 195 + console.log(` 🚀 Excellent performance!`); 196 + } else if (duration < 60) { 197 + console.log(` ✅ Good performance`); 198 + } else { 199 + console.log(` ⚠️ Slower than target`); 200 + } 201 + 202 + expect(response.body.success).toBe(true); 203 + 204 + // Only test one file if both exist 205 + break; 206 + } catch (error) { 207 + if (error instanceof Error && "code" in error && error.code === "ENOENT") { 208 + console.log(` ⚠️ Skipping ${fileName}: not found`); 209 + } else { 210 + throw error; 211 + } 212 + } 213 + } 214 + }, 360000); 215 + 216 + it("should parse DOCX file if available", async () => { 217 + const downloadsPath = join(process.env.HOME || "~", "Downloads"); 218 + const fileName = "CV Niels Mokkenstorm [EN].docx"; 219 + const filePath = join(downloadsPath, fileName); 220 + 221 + try { 222 + const buffer = readFileSync(filePath); 223 + 224 + console.log(`\n📄 Testing: ${fileName}`); 225 + const startTime = Date.now(); 226 + 227 + const response = await request(SERVER_URL) 228 + .post("/api/cv-parser/upload") 229 + .set("Authorization", `Bearer ${accessToken}`) 230 + .attach("file", buffer, fileName) 231 + .timeout(360000) 232 + .expect(201); 233 + 234 + const duration = (Date.now() - startTime) / 1000; 235 + 236 + console.log(`\n⏱️ Results:`); 237 + console.log(` Duration: ${duration.toFixed(1)}s`); 238 + console.log(` Name: ${response.body.data.personalInfo.name || "N/A"}`); 239 + console.log( 240 + ` Job Experiences: ${response.body.data.jobExperiences?.length || 0}`, 241 + ); 242 + 243 + expect(response.body.success).toBe(true); 244 + } catch (error) { 245 + if (error instanceof Error && "code" in error && error.code === "ENOENT") { 246 + console.log(` ⚠️ Skipping ${fileName}: not found`); 247 + } else { 248 + throw error; 249 + } 250 + } 251 + }, 360000); 252 + }); 253 + 254 + describe("Error Handling", () => { 255 + it("should reject upload without authentication", async () => { 256 + const cvPath = join(__dirname, "../../../test-cv-tiny.md"); 257 + const cvBuffer = readFileSync(cvPath); 258 + 259 + await request(SERVER_URL) 260 + .post("/api/cv-parser/upload") 261 + .attach("file", cvBuffer, "test.txt") 262 + .expect(401); 263 + }); 264 + 265 + it("should reject upload without file", async () => { 266 + await request(SERVER_URL) 267 + .post("/api/cv-parser/upload") 268 + .set("Authorization", `Bearer ${accessToken}`) 269 + .expect(400); 270 + }); 271 + }); 272 + 273 + describe("Performance Benchmarks", () => { 274 + it("should provide parsing statistics for all test files", async () => { 275 + const results: Array<{ file: string; duration: number; success: boolean }> = 276 + []; 277 + 278 + const testFiles = [ 279 + "test-cv-tiny.md", 280 + "test-cv.txt", 281 + "test-cv.md", 282 + ]; 283 + 284 + for (const fileName of testFiles) { 285 + const filePath = join(__dirname, "../../../", fileName); 286 + 287 + try { 288 + const buffer = readFileSync(filePath); 289 + const startTime = Date.now(); 290 + 291 + const response = await request(SERVER_URL) 292 + .post("/api/cv-parser/upload") 293 + .set("Authorization", `Bearer ${accessToken}`) 294 + .attach("file", buffer, fileName) 295 + .timeout(360000); 296 + 297 + const duration = (Date.now() - startTime) / 1000; 298 + results.push({ 299 + file: fileName, 300 + duration, 301 + success: response.status === 201, 302 + }); 303 + } catch (error) { 304 + if (error instanceof Error && "code" in error && error.code === "ENOENT") { 305 + console.log(` ⚠️ Skipping ${fileName}: not found`); 306 + } else { 307 + results.push({ file: fileName, duration: 0, success: false }); 308 + } 309 + } 310 + } 311 + 312 + // Print benchmark summary 313 + if (results.length > 0) { 314 + console.log(`\n${"=".repeat(50)}`); 315 + console.log(`📊 Performance Benchmark Summary`); 316 + console.log(`${"=".repeat(50)}`); 317 + console.log(`${"File".padEnd(25)} | Duration | Status`); 318 + console.log(`${"-".repeat(25)} | -------- | ------`); 319 + 320 + for (const result of results) { 321 + const status = result.success ? "✅" : "❌"; 322 + console.log( 323 + `${result.file.padEnd(25)} | ${result.duration.toFixed(1).padStart(6)}s | ${status}`, 324 + ); 325 + } 326 + 327 + const successfulResults = results.filter((r) => r.success); 328 + if (successfulResults.length > 0) { 329 + const avgDuration = 330 + successfulResults.reduce((sum, r) => sum + r.duration, 0) / 331 + successfulResults.length; 332 + console.log(`\nAverage: ${avgDuration.toFixed(1)}s`); 333 + 334 + if (avgDuration < 30) { 335 + console.log(`🚀 GPU acceleration detected`); 336 + } else if (avgDuration < 60) { 337 + console.log(`✅ Acceptable performance`); 338 + } else { 339 + console.log(`⚠️ Running on CPU (slow)`); 340 + } 341 + } 342 + console.log(`${"=".repeat(50)}\n`); 343 + } 344 + 345 + // At least one should succeed 346 + expect(results.some((r) => r.success)).toBe(true); 347 + }, 600000); 348 + }); 349 + });
+313
apps/server/test/cv-upload.e2e-spec.ts
··· 1 + import { readFileSync } from "node:fs"; 2 + import { join } from "node:path"; 3 + import type { INestApplication } from "@nestjs/common"; 4 + import { ConfigModule } from "@nestjs/config"; 5 + import { Test, type TestingModule } from "@nestjs/testing"; 6 + import request from "supertest"; 7 + import { AppModule } from "../src/modules/app.module"; 8 + 9 + /** 10 + * E2E tests for CV file upload and parsing through llama.cpp 11 + * 12 + * Prerequisites: 13 + * - llama-server must be running on port 8080 (or LLAMA_URL env var) 14 + * - Database must be available 15 + * 16 + * Run with: pnpm test:e2e cv-upload.e2e-spec.ts 17 + * 18 + * Performance targets: 19 + * - Metal GPU: <30 seconds 20 + * - CPU only: <300 seconds 21 + */ 22 + describe("CV Upload and Parsing (e2e)", () => { 23 + let app: INestApplication; 24 + let accessToken: string; 25 + 26 + const TEST_USER = { 27 + name: "CV Test User", 28 + email: `cv-test-${Date.now()}@example.com`, 29 + password: "TestPassword123!", 30 + }; 31 + 32 + beforeAll(async () => { 33 + // Set test environment variables 34 + process.env.JWT_SECRET = "test-secret-key-for-testing-only"; 35 + process.env.PORT = "3001"; 36 + process.env.LLAMA_URL = process.env.LLAMA_URL || "http://localhost:8080"; 37 + 38 + const moduleFixture: TestingModule = await Test.createTestingModule({ 39 + imports: [ 40 + ConfigModule.forRoot({ 41 + isGlobal: true, 42 + }), 43 + AppModule, 44 + ], 45 + }).compile(); 46 + 47 + app = moduleFixture.createNestApplication(); 48 + 49 + // Enable CORS for testing 50 + app.enableCors({ 51 + origin: true, 52 + credentials: true, 53 + }); 54 + 55 + await app.init(); 56 + 57 + // Register and login test user 58 + const registerResponse = await request(app.getHttpServer()) 59 + .post("/api/auth/password/register") 60 + .send(TEST_USER) 61 + .expect(201); 62 + 63 + expect(registerResponse.body.accessToken).toBeDefined(); 64 + accessToken = registerResponse.body.accessToken; 65 + }); 66 + 67 + afterAll(async () => { 68 + await app.close(); 69 + }); 70 + 71 + describe("Text File Upload", () => { 72 + it("should parse a plain text CV successfully", async () => { 73 + // Load test CV file 74 + const cvPath = join(__dirname, "../../../test-cv.txt"); 75 + const cvBuffer = readFileSync(cvPath); 76 + 77 + const startTime = Date.now(); 78 + 79 + const response = await request(app.getHttpServer()) 80 + .post("/api/cv-parser/upload") 81 + .set("Authorization", `Bearer ${accessToken}`) 82 + .attach("file", cvBuffer, "test-cv.txt") 83 + .expect(201); 84 + 85 + const duration = (Date.now() - startTime) / 1000; 86 + 87 + // Verify response structure 88 + expect(response.body.success).toBe(true); 89 + expect(response.body.data).toBeDefined(); 90 + expect(response.body.fileName).toBe("test-cv.txt"); 91 + 92 + // Verify parsed data 93 + const { data } = response.body; 94 + expect(data.personalInfo).toBeDefined(); 95 + expect(data.personalInfo.name).toBeTruthy(); 96 + expect(data.jobExperiences).toBeInstanceOf(Array); 97 + expect(data.education).toBeInstanceOf(Array); 98 + 99 + // Log performance metrics 100 + console.log(`\n⏱️ CV Parsing Performance:`); 101 + console.log(` Duration: ${duration.toFixed(1)}s`); 102 + console.log(` Name: ${data.personalInfo.name}`); 103 + console.log(` Job Experiences: ${data.jobExperiences.length}`); 104 + console.log(` Education: ${data.education.length}`); 105 + console.log( 106 + ` Skills: ${data.skills?.length || 0}`, 107 + ); 108 + 109 + // Performance assertions 110 + if (duration < 30) { 111 + console.log(` 🚀 Excellent! (GPU-accelerated)`); 112 + } else if (duration < 60) { 113 + console.log(` ✅ Good performance`); 114 + } else if (duration < 180) { 115 + console.log(` ⚠️ Slower than expected (CPU-only?)`); 116 + } else { 117 + console.log(` ❌ Very slow - check llama-server`); 118 + } 119 + 120 + // Don't fail on slow performance in CI/CPU environments 121 + // but warn if extremely slow 122 + if (duration > 300) { 123 + console.warn(` ⚠️ Warning: Parsing took ${duration}s (>5 minutes)`); 124 + } 125 + }, 360000); // 6 minute timeout for CPU fallback 126 + 127 + it("should parse a tiny test CV quickly", async () => { 128 + const cvPath = join(__dirname, "../../../test-cv-tiny.md"); 129 + const cvBuffer = readFileSync(cvPath); 130 + 131 + const startTime = Date.now(); 132 + 133 + const response = await request(app.getHttpServer()) 134 + .post("/api/cv-parser/upload") 135 + .set("Authorization", `Bearer ${accessToken}`) 136 + .attach("file", cvBuffer, "test-cv-tiny.md") 137 + .expect(201); 138 + 139 + const duration = (Date.now() - startTime) / 1000; 140 + 141 + expect(response.body.success).toBe(true); 142 + expect(response.body.data.personalInfo).toBeDefined(); 143 + 144 + console.log(`\n⏱️ Tiny CV Parsing: ${duration.toFixed(1)}s`); 145 + 146 + // Tiny CVs should parse faster 147 + expect(duration).toBeLessThan(60); 148 + }, 120000); 149 + }); 150 + 151 + describe("PDF File Upload", () => { 152 + it("should parse a PDF CV successfully", async () => { 153 + const pdfPath = join( 154 + __dirname, 155 + "../../../test-files/sample-cv.pdf", 156 + ); 157 + 158 + // Skip if test PDF doesn't exist 159 + try { 160 + const pdfBuffer = readFileSync(pdfPath); 161 + 162 + const startTime = Date.now(); 163 + 164 + const response = await request(app.getHttpServer()) 165 + .post("/api/cv-parser/upload") 166 + .set("Authorization", `Bearer ${accessToken}`) 167 + .attach("file", pdfBuffer, "sample-cv.pdf") 168 + .expect(201); 169 + 170 + const duration = (Date.now() - startTime) / 1000; 171 + 172 + expect(response.body.success).toBe(true); 173 + expect(response.body.data).toBeDefined(); 174 + 175 + console.log(`\n⏱️ PDF Parsing: ${duration.toFixed(1)}s`); 176 + } catch (error) { 177 + console.log(` ⚠️ Skipping PDF test: test-files/sample-cv.pdf not found`); 178 + } 179 + }, 360000); 180 + }); 181 + 182 + describe("Error Handling", () => { 183 + it("should reject upload without authentication", async () => { 184 + const cvPath = join(__dirname, "../../../test-cv-tiny.md"); 185 + const cvBuffer = readFileSync(cvPath); 186 + 187 + await request(app.getHttpServer()) 188 + .post("/api/cv-parser/upload") 189 + .attach("file", cvBuffer, "test.txt") 190 + .expect(401); 191 + }); 192 + 193 + it("should reject upload without file", async () => { 194 + await request(app.getHttpServer()) 195 + .post("/api/cv-parser/upload") 196 + .set("Authorization", `Bearer ${accessToken}`) 197 + .expect(400); 198 + }); 199 + 200 + it("should handle invalid file gracefully", async () => { 201 + const invalidBuffer = Buffer.from("not a valid CV format"); 202 + 203 + const response = await request(app.getHttpServer()) 204 + .post("/api/cv-parser/upload") 205 + .set("Authorization", `Bearer ${accessToken}`) 206 + .attach("file", invalidBuffer, "invalid.xyz") 207 + .expect(500); 208 + 209 + expect(response.body.message).toContain("parsing failed"); 210 + }); 211 + }); 212 + 213 + describe("Performance Benchmarks", () => { 214 + it("should provide parsing statistics", async () => { 215 + const results: Array<{ file: string; duration: number }> = []; 216 + 217 + // Test with multiple file sizes 218 + const testFiles = [ 219 + "test-cv-tiny.md", 220 + "test-cv.txt", 221 + "test-cv.md", 222 + ]; 223 + 224 + for (const fileName of testFiles) { 225 + const filePath = join(__dirname, "../../../", fileName); 226 + 227 + try { 228 + const buffer = readFileSync(filePath); 229 + const startTime = Date.now(); 230 + 231 + await request(app.getHttpServer()) 232 + .post("/api/cv-parser/upload") 233 + .set("Authorization", `Bearer ${accessToken}`) 234 + .attach("file", buffer, fileName) 235 + .expect(201); 236 + 237 + const duration = (Date.now() - startTime) / 1000; 238 + results.push({ file: fileName, duration }); 239 + } catch { 240 + console.log(` ⚠️ Skipping ${fileName}: file not found`); 241 + } 242 + } 243 + 244 + // Print benchmark summary 245 + if (results.length > 0) { 246 + console.log(`\n📊 Performance Benchmark Summary:`); 247 + console.log(` ${"File".padEnd(20)} | Duration`); 248 + console.log(` ${"-".repeat(20)} | ${"-".repeat(10)}`); 249 + 250 + for (const result of results) { 251 + console.log( 252 + ` ${result.file.padEnd(20)} | ${result.duration.toFixed(1)}s`, 253 + ); 254 + } 255 + 256 + const avgDuration = 257 + results.reduce((sum, r) => sum + r.duration, 0) / results.length; 258 + console.log(`\n Average: ${avgDuration.toFixed(1)}s`); 259 + 260 + if (avgDuration < 30) { 261 + console.log(` 🚀 GPU acceleration detected`); 262 + } else if (avgDuration < 60) { 263 + console.log(` ✅ Acceptable performance`); 264 + } else { 265 + console.log(` ⚠️ Running on CPU (slow)`); 266 + } 267 + } 268 + }, 600000); // 10 minute timeout for multiple files 269 + }); 270 + 271 + describe("Real-world CV Tests", () => { 272 + it("should parse CVs from ~/Downloads if available", async () => { 273 + const downloadsPath = join(process.env.HOME || "~", "Downloads"); 274 + const testCVs = [ 275 + "CV Niels Mokkenstorm [EN].pdf", 276 + "CV Niels Mokkenstorm [EN].docx", 277 + ]; 278 + 279 + for (const fileName of testCVs) { 280 + const filePath = join(downloadsPath, fileName); 281 + 282 + try { 283 + const buffer = readFileSync(filePath); 284 + const startTime = Date.now(); 285 + 286 + const response = await request(app.getHttpServer()) 287 + .post("/api/cv-parser/upload") 288 + .set("Authorization", `Bearer ${accessToken}`) 289 + .attach("file", buffer, fileName) 290 + .expect(201); 291 + 292 + const duration = (Date.now() - startTime) / 1000; 293 + 294 + console.log(`\n📄 Parsed: ${fileName}`); 295 + console.log(` Duration: ${duration.toFixed(1)}s`); 296 + console.log( 297 + ` Name: ${response.body.data.personalInfo.name || "N/A"}`, 298 + ); 299 + console.log( 300 + ` Jobs: ${response.body.data.jobExperiences.length}`, 301 + ); 302 + console.log( 303 + ` Education: ${response.body.data.education.length}`, 304 + ); 305 + 306 + expect(response.body.success).toBe(true); 307 + } catch { 308 + console.log(` ⚠️ Skipping ${fileName}: not found in Downloads`); 309 + } 310 + } 311 + }, 600000); // 10 minute timeout for real CVs 312 + }); 313 + });
+416
apps/server/test/cv-upload.integration.ts
··· 1 + /** 2 + * CV Upload Integration Tests with Vitest 3 + * 4 + * Prerequisites: 5 + * - Server running at http://localhost:3000 6 + * - llama-server running at http://localhost:8080 7 + * 8 + * Run: pnpm vitest cv-upload.integration 9 + */ 10 + import { readFileSync } from "node:fs"; 11 + import { join } from "node:path"; 12 + import { afterAll, beforeAll, describe, expect, it } from "vitest"; 13 + 14 + const SERVER_URL = process.env.SERVER_URL || "http://localhost:3000"; 15 + 16 + interface CVData { 17 + personalInfo: { 18 + name?: string; 19 + email?: string; 20 + phone?: string; 21 + location?: string; 22 + }; 23 + jobExperiences?: Array<{ 24 + title?: string; 25 + company?: string; 26 + startDate?: string; 27 + endDate?: string; 28 + }>; 29 + education?: Array<{ 30 + degree?: string; 31 + institution?: string; 32 + }>; 33 + skills?: string[]; 34 + } 35 + 36 + interface UploadResponse { 37 + success: boolean; 38 + data: CVData; 39 + fileName: string; 40 + fileSize: number; 41 + } 42 + 43 + describe("CV Upload Integration", () => { 44 + let accessToken: string; 45 + 46 + const TEST_USER = { 47 + name: "Vitest Integration", 48 + email: `vitest-${Date.now()}@example.com`, 49 + password: "TestPassword123!", 50 + }; 51 + 52 + beforeAll(async () => { 53 + // Check server health 54 + const healthResponse = await fetch(`${SERVER_URL}/health`); 55 + expect(healthResponse.ok).toBe(true); 56 + console.log(`\n✅ Server is running at ${SERVER_URL}`); 57 + 58 + // Register user via GraphQL (using snake_case for GraphQL schema) 59 + const REGISTER_MUTATION = ` 60 + mutation Register($name: String!, $email: String!, $password: String!) { 61 + register(name: $name, email: $email, password: $password) { 62 + access_token 63 + user { 64 + id 65 + name 66 + email 67 + } 68 + } 69 + } 70 + `; 71 + 72 + const LOGIN_MUTATION = ` 73 + mutation Login($email: String!, $password: String!) { 74 + login(email: $email, password: $password) { 75 + access_token 76 + user { 77 + id 78 + name 79 + email 80 + } 81 + } 82 + } 83 + `; 84 + 85 + try { 86 + const registerResponse = await fetch(`${SERVER_URL}/graphql`, { 87 + method: "POST", 88 + headers: { "Content-Type": "application/json" }, 89 + body: JSON.stringify({ 90 + query: REGISTER_MUTATION, 91 + variables: TEST_USER, 92 + }), 93 + }); 94 + 95 + const registerData = await registerResponse.json(); 96 + 97 + if (registerData.data?.register?.access_token) { 98 + accessToken = registerData.data.register.access_token; 99 + console.log(`✅ User registered via GraphQL`); 100 + } else if (registerData.errors) { 101 + // User might exist, try login 102 + const loginResponse = await fetch(`${SERVER_URL}/graphql`, { 103 + method: "POST", 104 + headers: { "Content-Type": "application/json" }, 105 + body: JSON.stringify({ 106 + query: LOGIN_MUTATION, 107 + variables: { 108 + email: TEST_USER.email, 109 + password: TEST_USER.password, 110 + }, 111 + }), 112 + }); 113 + 114 + const loginData = await loginResponse.json(); 115 + 116 + if (loginData.data?.login?.access_token) { 117 + accessToken = loginData.data.login.access_token; 118 + console.log(`✅ User logged in via GraphQL`); 119 + } else { 120 + throw new Error(`Login failed: ${JSON.stringify(loginData)}`); 121 + } 122 + } 123 + } catch (error) { 124 + console.error("❌ Authentication failed:", error); 125 + throw error; 126 + } 127 + 128 + expect(accessToken).toBeDefined(); 129 + }); 130 + 131 + async function uploadCV( 132 + filePath: string, 133 + fileName: string, 134 + ): Promise<{ response: Response; duration: number }> { 135 + const buffer = readFileSync(filePath); 136 + 137 + // Create form data 138 + const formData = new FormData(); 139 + const blob = new Blob([buffer], { type: "text/plain" }); 140 + formData.append("file", blob, fileName); 141 + 142 + const startTime = Date.now(); 143 + 144 + const response = await fetch(`${SERVER_URL}/api/cv-parser/upload`, { 145 + method: "POST", 146 + headers: { 147 + Authorization: `Bearer ${accessToken}`, 148 + }, 149 + body: formData, 150 + }); 151 + 152 + const duration = (Date.now() - startTime) / 1000; 153 + 154 + return { response, duration }; 155 + } 156 + 157 + describe("Text Files", () => { 158 + it("should parse test-cv.txt", async () => { 159 + const filePath = join(__dirname, "../../../test-cv.txt"); 160 + 161 + console.log(`\n📄 Testing: test-cv.txt`); 162 + const { response, duration } = await uploadCV(filePath, "test-cv.txt"); 163 + 164 + expect(response.ok).toBe(true); 165 + 166 + const result = (await response.json()) as UploadResponse; 167 + 168 + expect(result.success).toBe(true); 169 + expect(result.data.personalInfo).toBeDefined(); 170 + 171 + console.log(`\n⏱️ Results:`); 172 + console.log(` Duration: ${duration.toFixed(1)}s`); 173 + console.log(` Name: ${result.data.personalInfo.name || "N/A"}`); 174 + console.log(` Email: ${result.data.personalInfo.email || "N/A"}`); 175 + console.log( 176 + ` Job Experiences: ${result.data.jobExperiences?.length || 0}`, 177 + ); 178 + console.log(` Education: ${result.data.education?.length || 0}`); 179 + console.log(` Skills: ${result.data.skills?.length || 0}`); 180 + 181 + if (duration < 30) { 182 + console.log(` 🚀 Excellent! (GPU-accelerated)`); 183 + } else if (duration < 60) { 184 + console.log(` ✅ Good performance`); 185 + } else { 186 + console.log(` ⚠️ Slower than target`); 187 + } 188 + }); 189 + 190 + it("should parse test-cv-tiny.md quickly", async () => { 191 + const filePath = join(__dirname, "../../../test-cv-tiny.md"); 192 + 193 + console.log(`\n📄 Testing: test-cv-tiny.md`); 194 + const { response, duration } = await uploadCV( 195 + filePath, 196 + "test-cv-tiny.md", 197 + ); 198 + 199 + expect(response.ok).toBe(true); 200 + 201 + const result = (await response.json()) as UploadResponse; 202 + 203 + console.log(`\n⏱️ Results:`); 204 + console.log(` Duration: ${duration.toFixed(1)}s`); 205 + console.log(` Name: ${result.data.personalInfo.name || "N/A"}`); 206 + 207 + expect(result.success).toBe(true); 208 + expect(duration).toBeLessThan(60); 209 + }); 210 + }); 211 + 212 + describe("Real PDFs from Downloads", () => { 213 + it("should parse Niels CV (PDF)", async () => { 214 + const downloadsPath = join(process.env.HOME || "~", "Downloads"); 215 + const testFiles = [ 216 + "CV Niels Mokkenstorm [EN].pdf", 217 + "CV Niels Mokkenstorm [EN]-1.pdf", 218 + ]; 219 + 220 + for (const fileName of testFiles) { 221 + const filePath = join(downloadsPath, fileName); 222 + 223 + try { 224 + console.log(`\n📄 Testing: ${fileName}`); 225 + const buffer = readFileSync(filePath); 226 + 227 + // Create form data with correct PDF mime type 228 + const formData = new FormData(); 229 + const blob = new Blob([buffer], { type: "application/pdf" }); 230 + formData.append("file", blob, fileName); 231 + 232 + const startTime = Date.now(); 233 + 234 + const response = await fetch(`${SERVER_URL}/api/cv-parser/upload`, { 235 + method: "POST", 236 + headers: { 237 + Authorization: `Bearer ${accessToken}`, 238 + }, 239 + body: formData, 240 + }); 241 + 242 + const duration = (Date.now() - startTime) / 1000; 243 + 244 + expect(response.ok).toBe(true); 245 + 246 + const result = (await response.json()) as UploadResponse; 247 + 248 + console.log(`\n⏱️ Results:`); 249 + console.log(` Duration: ${duration.toFixed(1)}s`); 250 + console.log(` Name: ${result.data.personalInfo.name || "N/A"}`); 251 + console.log(` Email: ${result.data.personalInfo.email || "N/A"}`); 252 + console.log(` Phone: ${result.data.personalInfo.phone || "N/A"}`); 253 + console.log( 254 + ` Job Experiences: ${result.data.jobExperiences?.length || 0}`, 255 + ); 256 + console.log(` Education: ${result.data.education?.length || 0}`); 257 + console.log(` Skills: ${result.data.skills?.length || 0}`); 258 + 259 + if (duration < 30) { 260 + console.log(` 🚀 Excellent!`); 261 + } else if (duration < 60) { 262 + console.log(` ✅ Good`); 263 + } 264 + 265 + expect(result.success).toBe(true); 266 + 267 + // Test one file and break 268 + break; 269 + } catch (error) { 270 + if (error instanceof Error && "code" in error && error.code === "ENOENT") { 271 + console.log(` ⚠️ Skipping ${fileName}: not found`); 272 + continue; 273 + } 274 + throw error; 275 + } 276 + } 277 + }); 278 + 279 + it("should parse Niels CV (DOCX)", async () => { 280 + const downloadsPath = join(process.env.HOME || "~", "Downloads"); 281 + const fileName = "CV Niels Mokkenstorm [EN].docx"; 282 + const filePath = join(downloadsPath, fileName); 283 + 284 + try { 285 + console.log(`\n📄 Testing: ${fileName}`); 286 + const buffer = readFileSync(filePath); 287 + 288 + // Create form data with DOCX mime type 289 + const formData = new FormData(); 290 + const blob = new Blob([buffer], { 291 + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 292 + }); 293 + formData.append("file", blob, fileName); 294 + 295 + const startTime = Date.now(); 296 + 297 + const response = await fetch(`${SERVER_URL}/api/cv-parser/upload`, { 298 + method: "POST", 299 + headers: { 300 + Authorization: `Bearer ${accessToken}`, 301 + }, 302 + body: formData, 303 + }); 304 + 305 + const duration = (Date.now() - startTime) / 1000; 306 + 307 + expect(response.ok).toBe(true); 308 + 309 + const result = (await response.json()) as UploadResponse; 310 + 311 + console.log(`\n⏱️ Results:`); 312 + console.log(` Duration: ${duration.toFixed(1)}s`); 313 + console.log(` Name: ${result.data.personalInfo.name || "N/A"}`); 314 + console.log( 315 + ` Job Experiences: ${result.data.jobExperiences?.length || 0}`, 316 + ); 317 + 318 + expect(result.success).toBe(true); 319 + } catch (error) { 320 + if (error instanceof Error && "code" in error && error.code === "ENOENT") { 321 + console.log(` ⚠️ Skipping ${fileName}: not found`); 322 + } else { 323 + throw error; 324 + } 325 + } 326 + }); 327 + }); 328 + 329 + describe("Error Handling", () => { 330 + it("should reject upload without auth", async () => { 331 + const filePath = join(__dirname, "../../../test-cv-tiny.md"); 332 + const buffer = readFileSync(filePath); 333 + 334 + const formData = new FormData(); 335 + const blob = new Blob([buffer]); 336 + formData.append("file", blob, "test.txt"); 337 + 338 + const response = await fetch(`${SERVER_URL}/api/cv-parser/upload`, { 339 + method: "POST", 340 + body: formData, 341 + }); 342 + 343 + expect(response.status).toBe(401); 344 + }); 345 + 346 + it("should reject upload without file", async () => { 347 + const response = await fetch(`${SERVER_URL}/api/cv-parser/upload`, { 348 + method: "POST", 349 + headers: { 350 + Authorization: `Bearer ${accessToken}`, 351 + }, 352 + }); 353 + 354 + expect(response.status).toBe(400); 355 + }); 356 + }); 357 + 358 + describe("Performance Benchmark", () => { 359 + it("should test all files and report stats", async () => { 360 + const testFiles = ["test-cv-tiny.md", "test-cv.txt", "test-cv.md"]; 361 + 362 + const results: Array<{ 363 + file: string; 364 + duration: number; 365 + success: boolean; 366 + }> = []; 367 + 368 + for (const fileName of testFiles) { 369 + const filePath = join(__dirname, "../../../", fileName); 370 + 371 + try { 372 + const { response, duration } = await uploadCV(filePath, fileName); 373 + results.push({ 374 + file: fileName, 375 + duration, 376 + success: response.ok, 377 + }); 378 + } catch (error) { 379 + if (error instanceof Error && "code" in error && error.code === "ENOENT") { 380 + console.log(` ⚠️ Skipping ${fileName}: not found`); 381 + } 382 + } 383 + } 384 + 385 + if (results.length > 0) { 386 + console.log(`\n${"=".repeat(50)}`); 387 + console.log(`📊 Performance Benchmark`); 388 + console.log(`${"=".repeat(50)}`); 389 + console.log(`${"File".padEnd(25)} | Duration | Status`); 390 + console.log(`${"-".repeat(25)} | -------- | ------`); 391 + 392 + for (const r of results) { 393 + const status = r.success ? "✅" : "❌"; 394 + console.log( 395 + `${r.file.padEnd(25)} | ${r.duration.toFixed(1).padStart(6)}s | ${status}`, 396 + ); 397 + } 398 + 399 + const successful = results.filter((r) => r.success); 400 + if (successful.length > 0) { 401 + const avg = 402 + successful.reduce((sum, r) => sum + r.duration, 0) / 403 + successful.length; 404 + console.log(`\nAverage: ${avg.toFixed(1)}s`); 405 + 406 + if (avg < 30) console.log(`🚀 GPU acceleration detected`); 407 + else if (avg < 60) console.log(`✅ Acceptable performance`); 408 + else console.log(`⚠️ Running on CPU`); 409 + } 410 + console.log(`${"=".repeat(50)}\n`); 411 + } 412 + 413 + expect(results.some((r) => r.success)).toBe(true); 414 + }); 415 + }); 416 + });
+1 -1
apps/server/test/jest-e2e.json
··· 19 19 "moduleNameMapper": { 20 20 "^@/(.*)$": "<rootDir>/../src/$1" 21 21 }, 22 - "transformIgnorePatterns": ["node_modules/(?!(@faker-js/faker)/)"], 22 + "transformIgnorePatterns": ["node_modules/(?!(@faker-js|@cv)/)"], 23 23 "collectCoverage": true, 24 24 "collectCoverageFrom": [ 25 25 "<rootDir>/../src/**/*.{ts,tsx}",
+1 -1
apps/server/test/jest-unit.json
··· 19 19 "moduleNameMapper": { 20 20 "^@/(.*)$": "<rootDir>/../src/$1" 21 21 }, 22 - "transformIgnorePatterns": ["node_modules/(?!(@faker-js/faker)/)"], 22 + "transformIgnorePatterns": ["node_modules/(?!(@faker-js|@cv)/)"], 23 23 "collectCoverage": true, 24 24 "collectCoverageFrom": [ 25 25 "<rootDir>/../src/**/*.{ts,tsx}",
+3 -1
apps/server/test/queries/login-mutation.graphql
··· 1 1 mutation Login($email: String!, $password: String!) { 2 2 login(email: $email, password: $password) { 3 - access_token 4 3 user { 5 4 id 6 5 email 7 6 name 7 + } 8 + accessTokenExpiration { 9 + expiresAt 8 10 } 9 11 } 10 12 }
+3 -1
apps/server/test/queries/register-mutation.graphql
··· 1 1 mutation Register($name: String!, $email: String!, $password: String!) { 2 2 register(name: $name, email: $email, password: $password) { 3 - access_token 4 3 user { 5 4 id 6 5 email 7 6 name 7 + } 8 + accessTokenExpiration { 9 + expiresAt 8 10 } 9 11 } 10 12 }
+11 -7
apps/server/test/test-utils.ts
··· 9 9 id: string; 10 10 email: string; 11 11 name: string; 12 - accessToken: string; 12 + cookies: string[]; 13 13 } 14 14 15 15 export interface TestContext { ··· 79 79 ); 80 80 } 81 81 82 + const cookies = registerResponse.headers["set-cookie"]; 83 + if (!cookies) { 84 + throw new Error("No cookies received from registration"); 85 + } 86 + 82 87 return { 83 88 id: registerResponse.body.data.register.user.id, 84 89 email: registerResponse.body.data.register.user.email, 85 90 name: registerResponse.body.data.register.user.name, 86 - accessToken: registerResponse.body.data.register.access_token, 91 + cookies, 87 92 }; 88 93 } 89 94 ··· 198 203 } 199 204 200 205 /** 201 - * Makes an authenticated GraphQL request 206 + * Makes an authenticated GraphQL request using cookies 202 207 */ 203 208 export function makeAuthenticatedRequest( 204 209 app: INestApplication, 205 - accessToken: string, 210 + cookies: string[] | string, 206 211 ) { 207 - return getRequester(app) 208 - .post("/graphql") 209 - .set("Authorization", `Bearer ${accessToken}`); 212 + const cookieHeader = Array.isArray(cookies) ? cookies : [cookies]; 213 + return getRequester(app).post("/graphql").set("Cookie", cookieHeader); 210 214 } 211 215 212 216 /**
+12
apps/server/vitest.config.ts
··· 1 + import { defineConfig } from "vitest/config"; 2 + 3 + export default defineConfig({ 4 + test: { 5 + globals: true, 6 + environment: "node", 7 + testTimeout: 60000, // 60 seconds default 8 + hookTimeout: 60000, 9 + include: ["**/*.{test,spec,integration}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], 10 + exclude: ["**/node_modules/**", "**/dist/**", "**/build/**", "**/__tests__/**/*.spec.ts"], // Exclude Jest tests 11 + }, 12 + });
+8 -1
biome.json
··· 74 74 } 75 75 }, 76 76 { 77 - "includes": ["apps/server/**/*", "packages/auth/**/*"], 77 + "includes": [ 78 + "apps/server/**/*", 79 + "packages/auth/**/*", 80 + "packages/system/**/*", 81 + "packages/ai-parser/**/*", 82 + "packages/ai-provider/**/*", 83 + "packages/file-upload/**/*" 84 + ], 78 85 "linter": { 79 86 "rules": { 80 87 "correctness": {
+31
packages/ai-parser/jest.config.ts
··· 1 + import type { Config } from "jest"; 2 + 3 + const config: Config = { 4 + preset: "ts-jest", 5 + testEnvironment: "node", 6 + rootDir: ".", 7 + moduleFileExtensions: ["ts", "tsx", "js", "json"], 8 + testMatch: ["<rootDir>/src/**/*.spec.ts", "<rootDir>/src/**/*.test.ts"], 9 + transform: { 10 + "^.+\\.(t|j)sx?$": [ 11 + "ts-jest", 12 + { 13 + diagnostics: false, 14 + tsconfig: { 15 + sourceMap: true, 16 + inlineSourceMap: true, 17 + }, 18 + }, 19 + ], 20 + }, 21 + collectCoverage: true, 22 + collectCoverageFrom: [ 23 + "<rootDir>/src/**/*.ts", 24 + "!<rootDir>/src/**/index.ts", 25 + "!<rootDir>/src/**/__tests__/**", 26 + ], 27 + coverageDirectory: "<rootDir>/coverage", 28 + coverageReporters: ["text", "lcov"], 29 + }; 30 + 31 + export default config;
+29
packages/ai-parser/package.json
··· 1 + { 2 + "name": "@cv/ai-parser", 3 + "version": "0.0.0", 4 + "private": true, 5 + "main": "./src/index.ts", 6 + "types": "./src/index.ts", 7 + "scripts": { 8 + "typecheck": "tsc --noEmit", 9 + "test": "jest", 10 + "test:watch": "jest --watch", 11 + "test:coverage": "jest --coverage" 12 + }, 13 + "dependencies": { 14 + "@cv/ai-provider": "*", 15 + "@nestjs/common": "^11.1.3", 16 + "zod": "^3.23.8" 17 + }, 18 + "peerDependencies": { 19 + "@nestjs/common": "^11.0.0" 20 + }, 21 + "devDependencies": { 22 + "@cv/tsconfig": "*", 23 + "@types/jest": "^29.5.0", 24 + "@types/node": "^22.7.5", 25 + "jest": "^29.7.0", 26 + "ts-jest": "^29.1.1", 27 + "typescript": "^5.3.3" 28 + } 29 + }
+407
packages/ai-parser/src/__tests__/ai-parser.service.spec.ts
··· 1 + import type { AIProvider, AICompletionResponse } from "@cv/ai-provider"; 2 + import { CVParserService } from "../ai-parser.service"; 3 + import type { ParsedCVData } from "../schemas"; 4 + 5 + describe("CVParserService", () => { 6 + let service: CVParserService; 7 + let mockProvider: jest.Mocked<AIProvider>; 8 + 9 + const validParsedCVData: ParsedCVData = { 10 + personalInfo: { 11 + name: "John Doe", 12 + introduction: "Experienced software engineer", 13 + }, 14 + jobExperiences: [ 15 + { 16 + companyName: "Tech Corp", 17 + roleName: "Senior Engineer", 18 + levelName: "Senior", 19 + startDate: "2020-01-15", 20 + endDate: null, 21 + description: "Led development of microservices", 22 + skills: ["TypeScript", "Kubernetes"], 23 + }, 24 + ], 25 + education: [ 26 + { 27 + institutionName: "MIT", 28 + degree: "Bachelor of Science", 29 + fieldOfStudy: "Computer Science", 30 + startDate: "2012-09-01", 31 + endDate: "2016-05-31", 32 + skills: ["Algorithms", "Data Structures"], 33 + }, 34 + ], 35 + skills: ["TypeScript", "Kubernetes", "Algorithms"], 36 + }; 37 + 38 + beforeEach(() => { 39 + mockProvider = { 40 + name: "mock-provider", 41 + complete: jest.fn(), 42 + isHealthy: jest.fn().mockResolvedValue(true), 43 + }; 44 + 45 + service = new CVParserService(mockProvider); 46 + }); 47 + 48 + describe("parseCVText", () => { 49 + it("parses valid CV text and returns structured data", async () => { 50 + const mockResponse: AICompletionResponse = { 51 + content: JSON.stringify(validParsedCVData), 52 + finishReason: "stop", 53 + }; 54 + mockProvider.complete.mockResolvedValue(mockResponse); 55 + 56 + const result = await service.parseCVText("Sample CV text here"); 57 + 58 + expect(mockProvider.complete).toHaveBeenCalledWith( 59 + expect.objectContaining({ 60 + prompt: expect.stringContaining("Sample CV text here"), 61 + temperature: 0.1, 62 + maxTokens: 2048, 63 + }), 64 + ); 65 + expect(result.personalInfo?.name).toBe("John Doe"); 66 + expect(result.jobExperiences).toHaveLength(1); 67 + expect(result.education).toHaveLength(1); 68 + }); 69 + 70 + it("extracts JSON from markdown code block", async () => { 71 + const mockResponse: AICompletionResponse = { 72 + content: `Here's the parsed CV: 73 + \`\`\`json 74 + ${JSON.stringify(validParsedCVData)} 75 + \`\`\``, 76 + finishReason: "stop", 77 + }; 78 + mockProvider.complete.mockResolvedValue(mockResponse); 79 + 80 + const result = await service.parseCVText("Sample CV text"); 81 + 82 + expect(result.personalInfo?.name).toBe("John Doe"); 83 + }); 84 + 85 + it("extracts JSON from code block without language specifier", async () => { 86 + const mockResponse: AICompletionResponse = { 87 + content: `\`\`\` 88 + ${JSON.stringify(validParsedCVData)} 89 + \`\`\``, 90 + finishReason: "stop", 91 + }; 92 + mockProvider.complete.mockResolvedValue(mockResponse); 93 + 94 + const result = await service.parseCVText("Sample CV text"); 95 + 96 + expect(result.jobExperiences).toHaveLength(1); 97 + }); 98 + 99 + it("extracts raw JSON object from response", async () => { 100 + const mockResponse: AICompletionResponse = { 101 + content: `The parsed data is: ${JSON.stringify(validParsedCVData)}`, 102 + finishReason: "stop", 103 + }; 104 + mockProvider.complete.mockResolvedValue(mockResponse); 105 + 106 + const result = await service.parseCVText("Sample CV text"); 107 + 108 + expect(result.education).toHaveLength(1); 109 + }); 110 + 111 + it("throws error when CV text is empty", async () => { 112 + await expect(service.parseCVText("")).rejects.toThrow( 113 + "CV text cannot be empty", 114 + ); 115 + 116 + expect(mockProvider.complete).not.toHaveBeenCalled(); 117 + }); 118 + 119 + it("throws error when CV text is only whitespace", async () => { 120 + await expect(service.parseCVText(" \n\t ")).rejects.toThrow( 121 + "CV text cannot be empty", 122 + ); 123 + 124 + expect(mockProvider.complete).not.toHaveBeenCalled(); 125 + }); 126 + 127 + it("throws error when LLM returns invalid JSON", async () => { 128 + const mockResponse: AICompletionResponse = { 129 + content: "This is not valid JSON at all", 130 + finishReason: "stop", 131 + }; 132 + mockProvider.complete.mockResolvedValue(mockResponse); 133 + 134 + await expect(service.parseCVText("Sample CV text")).rejects.toThrow( 135 + "Failed to parse LLM response as JSON", 136 + ); 137 + }); 138 + 139 + it("throws error when LLM returns incomplete JSON", async () => { 140 + const mockResponse: AICompletionResponse = { 141 + content: '{"jobExperiences": [{"companyName": "Tech', 142 + finishReason: "length", 143 + }; 144 + mockProvider.complete.mockResolvedValue(mockResponse); 145 + 146 + await expect(service.parseCVText("Sample CV text")).rejects.toThrow( 147 + "Failed to parse LLM response as JSON", 148 + ); 149 + }); 150 + 151 + it("throws error when parsed data fails Zod validation", async () => { 152 + const invalidData = { 153 + jobExperiences: [ 154 + { 155 + companyName: "", 156 + roleName: "Engineer", 157 + startDate: "2020-01-01", 158 + }, 159 + ], 160 + }; 161 + const mockResponse: AICompletionResponse = { 162 + content: JSON.stringify(invalidData), 163 + finishReason: "stop", 164 + }; 165 + mockProvider.complete.mockResolvedValue(mockResponse); 166 + 167 + await expect(service.parseCVText("Sample CV text")).rejects.toThrow( 168 + "CV data validation failed", 169 + ); 170 + }); 171 + 172 + it("handles missing optional fields gracefully", async () => { 173 + const minimalData = { 174 + jobExperiences: [ 175 + { 176 + companyName: "Tech Corp", 177 + roleName: "Engineer", 178 + startDate: "2020-01-01", 179 + }, 180 + ], 181 + education: [], 182 + }; 183 + const mockResponse: AICompletionResponse = { 184 + content: JSON.stringify(minimalData), 185 + finishReason: "stop", 186 + }; 187 + mockProvider.complete.mockResolvedValue(mockResponse); 188 + 189 + const result = await service.parseCVText("Sample CV text"); 190 + 191 + expect(result.personalInfo).toBeUndefined(); 192 + expect(result.jobExperiences).toHaveLength(1); 193 + expect(result.jobExperiences[0].skills).toEqual([]); 194 + expect(result.education).toEqual([]); 195 + expect(result.skills).toEqual([]); 196 + }); 197 + 198 + it("handles null endDate for current positions", async () => { 199 + const dataWithNullEnd = { 200 + jobExperiences: [ 201 + { 202 + companyName: "Current Company", 203 + roleName: "Current Role", 204 + startDate: "2023-01-01", 205 + endDate: null, 206 + }, 207 + ], 208 + }; 209 + const mockResponse: AICompletionResponse = { 210 + content: JSON.stringify(dataWithNullEnd), 211 + finishReason: "stop", 212 + }; 213 + mockProvider.complete.mockResolvedValue(mockResponse); 214 + 215 + const result = await service.parseCVText("Sample CV text"); 216 + 217 + expect(result.jobExperiences[0].endDate).toBeNull(); 218 + }); 219 + 220 + it("trims whitespace from optional string fields", async () => { 221 + const dataWithWhitespace = { 222 + personalInfo: { 223 + name: " John Doe ", 224 + introduction: " Senior developer ", 225 + }, 226 + jobExperiences: [ 227 + { 228 + companyName: "Tech Corp", 229 + roleName: "Engineer", 230 + levelName: " Senior ", 231 + startDate: "2020-01-01", 232 + description: " Led team ", 233 + }, 234 + ], 235 + }; 236 + const mockResponse: AICompletionResponse = { 237 + content: JSON.stringify(dataWithWhitespace), 238 + finishReason: "stop", 239 + }; 240 + mockProvider.complete.mockResolvedValue(mockResponse); 241 + 242 + const result = await service.parseCVText("Sample CV text"); 243 + 244 + expect(result.personalInfo?.name).toBe("John Doe"); 245 + expect(result.personalInfo?.introduction).toBe("Senior developer"); 246 + expect(result.jobExperiences[0].levelName).toBe("Senior"); 247 + expect(result.jobExperiences[0].description).toBe("Led team"); 248 + }); 249 + }); 250 + 251 + describe("configuration", () => { 252 + it("uses default temperature of 0.1", async () => { 253 + const mockResponse: AICompletionResponse = { 254 + content: JSON.stringify(validParsedCVData), 255 + finishReason: "stop", 256 + }; 257 + mockProvider.complete.mockResolvedValue(mockResponse); 258 + 259 + await service.parseCVText("Sample CV"); 260 + 261 + expect(mockProvider.complete).toHaveBeenCalledWith( 262 + expect.objectContaining({ 263 + temperature: 0.1, 264 + }), 265 + ); 266 + }); 267 + 268 + it("uses default maxTokens of 2048", async () => { 269 + const mockResponse: AICompletionResponse = { 270 + content: JSON.stringify(validParsedCVData), 271 + finishReason: "stop", 272 + }; 273 + mockProvider.complete.mockResolvedValue(mockResponse); 274 + 275 + await service.parseCVText("Sample CV"); 276 + 277 + expect(mockProvider.complete).toHaveBeenCalledWith( 278 + expect.objectContaining({ 279 + maxTokens: 2048, 280 + }), 281 + ); 282 + }); 283 + 284 + it("allows custom temperature configuration", async () => { 285 + const customService = new CVParserService(mockProvider, { 286 + temperature: 0.5, 287 + }); 288 + const mockResponse: AICompletionResponse = { 289 + content: JSON.stringify(validParsedCVData), 290 + finishReason: "stop", 291 + }; 292 + mockProvider.complete.mockResolvedValue(mockResponse); 293 + 294 + await customService.parseCVText("Sample CV"); 295 + 296 + expect(mockProvider.complete).toHaveBeenCalledWith( 297 + expect.objectContaining({ 298 + temperature: 0.5, 299 + }), 300 + ); 301 + }); 302 + 303 + it("allows custom maxTokens configuration", async () => { 304 + const customService = new CVParserService(mockProvider, { 305 + maxTokens: 4096, 306 + }); 307 + const mockResponse: AICompletionResponse = { 308 + content: JSON.stringify(validParsedCVData), 309 + finishReason: "stop", 310 + }; 311 + mockProvider.complete.mockResolvedValue(mockResponse); 312 + 313 + await customService.parseCVText("Sample CV"); 314 + 315 + expect(mockProvider.complete).toHaveBeenCalledWith( 316 + expect.objectContaining({ 317 + maxTokens: 4096, 318 + }), 319 + ); 320 + }); 321 + 322 + it("includes stop sequences in request", async () => { 323 + const mockResponse: AICompletionResponse = { 324 + content: JSON.stringify(validParsedCVData), 325 + finishReason: "stop", 326 + }; 327 + mockProvider.complete.mockResolvedValue(mockResponse); 328 + 329 + await service.parseCVText("Sample CV"); 330 + 331 + expect(mockProvider.complete).toHaveBeenCalledWith( 332 + expect.objectContaining({ 333 + stopSequences: ["</s>"], 334 + }), 335 + ); 336 + }); 337 + }); 338 + 339 + describe("prompt generation", () => { 340 + it("includes CV text in the prompt", async () => { 341 + const cvText = "This is my career story with specific details"; 342 + const mockResponse: AICompletionResponse = { 343 + content: JSON.stringify(validParsedCVData), 344 + finishReason: "stop", 345 + }; 346 + mockProvider.complete.mockResolvedValue(mockResponse); 347 + 348 + await service.parseCVText(cvText); 349 + 350 + expect(mockProvider.complete).toHaveBeenCalledWith( 351 + expect.objectContaining({ 352 + prompt: expect.stringContaining(cvText), 353 + }), 354 + ); 355 + }); 356 + 357 + it("includes JSON structure example in prompt", async () => { 358 + const mockResponse: AICompletionResponse = { 359 + content: JSON.stringify(validParsedCVData), 360 + finishReason: "stop", 361 + }; 362 + mockProvider.complete.mockResolvedValue(mockResponse); 363 + 364 + await service.parseCVText("Sample CV"); 365 + 366 + expect(mockProvider.complete).toHaveBeenCalledWith( 367 + expect.objectContaining({ 368 + prompt: expect.stringContaining("jobExperiences"), 369 + }), 370 + ); 371 + }); 372 + 373 + it("includes parsing instructions in prompt", async () => { 374 + const mockResponse: AICompletionResponse = { 375 + content: JSON.stringify(validParsedCVData), 376 + finishReason: "stop", 377 + }; 378 + mockProvider.complete.mockResolvedValue(mockResponse); 379 + 380 + await service.parseCVText("Sample CV"); 381 + 382 + expect(mockProvider.complete).toHaveBeenCalledWith( 383 + expect.objectContaining({ 384 + prompt: expect.stringContaining("ISO 8601"), 385 + }), 386 + ); 387 + }); 388 + }); 389 + 390 + describe("error handling", () => { 391 + it("propagates provider errors", async () => { 392 + mockProvider.complete.mockRejectedValue(new Error("Provider unavailable")); 393 + 394 + await expect(service.parseCVText("Sample CV")).rejects.toThrow( 395 + "Provider unavailable", 396 + ); 397 + }); 398 + 399 + it("handles network timeout errors", async () => { 400 + mockProvider.complete.mockRejectedValue(new Error("Request timeout")); 401 + 402 + await expect(service.parseCVText("Sample CV")).rejects.toThrow( 403 + "Request timeout", 404 + ); 405 + }); 406 + }); 407 + });
+162
packages/ai-parser/src/ai-parser.service.test.ts
··· 1 + import { describe, it, expect, jest } from '@jest/globals'; 2 + import type { AIProvider, AICompletionResponse } from '@cv/ai-provider'; 3 + import { CVParserService } from './ai-parser.service'; 4 + 5 + describe('CVParserService Unit Tests', () => { 6 + const createMockProvider = ( 7 + mockResponse: string 8 + ): AIProvider => ({ 9 + name: 'mock', 10 + complete: jest.fn(async () => ({ 11 + content: mockResponse, 12 + model: 'mock-model', 13 + finishReason: 'stop', 14 + } as AICompletionResponse)), 15 + isHealthy: jest.fn(async () => true), 16 + }); 17 + 18 + describe('JSON Extraction', () => { 19 + it('should extract JSON from markdown code blocks', async () => { 20 + const mockProvider = createMockProvider(` 21 + \`\`\`json 22 + { 23 + "personalInfo": { 24 + "name": "John Doe" 25 + }, 26 + "jobExperiences": [], 27 + "education": [], 28 + "skills": [] 29 + } 30 + \`\`\` 31 + `); 32 + 33 + const parser = new CVParserService(mockProvider); 34 + const result = await parser.parseCVText('Sample CV text'); 35 + 36 + expect(result.personalInfo.name).toBe('John Doe'); 37 + expect(mockProvider.complete).toHaveBeenCalledWith( 38 + expect.objectContaining({ 39 + stopSequences: ['</s>'], // Should NOT include '\n\n' 40 + }) 41 + ); 42 + }); 43 + 44 + it('should extract raw JSON without markdown', async () => { 45 + const mockProvider = createMockProvider(` 46 + { 47 + "personalInfo": { 48 + "name": "Jane Smith" 49 + }, 50 + "jobExperiences": [], 51 + "education": [], 52 + "skills": [] 53 + } 54 + `); 55 + 56 + const parser = new CVParserService(mockProvider); 57 + const result = await parser.parseCVText('Sample CV text'); 58 + 59 + expect(result.personalInfo.name).toBe('Jane Smith'); 60 + }); 61 + }); 62 + 63 + describe('Stop Sequences', () => { 64 + it('should NOT use double newline as stop sequence', async () => { 65 + const mockProvider = createMockProvider(` 66 + { 67 + "personalInfo": {"name": "Test"}, 68 + "jobExperiences": [], 69 + "education": [], 70 + "skills": [] 71 + } 72 + `); 73 + 74 + const parser = new CVParserService(mockProvider); 75 + await parser.parseCVText('Sample CV'); 76 + 77 + // Verify that '\n\n' is NOT in stop sequences 78 + expect(mockProvider.complete).toHaveBeenCalledWith( 79 + expect.objectContaining({ 80 + stopSequences: expect.not.arrayContaining(['\n\n']), 81 + }) 82 + ); 83 + }); 84 + }); 85 + 86 + describe('Error Handling', () => { 87 + it('should throw clear error for invalid JSON', async () => { 88 + const mockProvider = createMockProvider('This is not JSON'); 89 + 90 + const parser = new CVParserService(mockProvider); 91 + 92 + await expect( 93 + parser.parseCVText('Sample CV') 94 + ).rejects.toThrow(/Failed to parse LLM response as JSON/); 95 + }); 96 + 97 + it('should throw clear error for incomplete JSON', async () => { 98 + const mockProvider = createMockProvider('{"personalInfo": {'); 99 + 100 + const parser = new CVParserService(mockProvider); 101 + 102 + await expect( 103 + parser.parseCVText('Sample CV') 104 + ).rejects.toThrow(/Failed to parse LLM response as JSON/); 105 + }); 106 + 107 + it('should throw error for empty CV text', async () => { 108 + const mockProvider = createMockProvider('{}'); 109 + 110 + const parser = new CVParserService(mockProvider); 111 + 112 + await expect( 113 + parser.parseCVText('') 114 + ).rejects.toThrow(/CV text cannot be empty/); 115 + }); 116 + }); 117 + 118 + describe('Full CV Parsing', () => { 119 + it('should parse complete CV data', async () => { 120 + const mockProvider = createMockProvider(` 121 + { 122 + "personalInfo": { 123 + "name": "John Doe", 124 + "introduction": "Software Engineer with 10 years experience" 125 + }, 126 + "jobExperiences": [ 127 + { 128 + "companyName": "Tech Corp", 129 + "roleName": "Senior Engineer", 130 + "levelName": "Senior", 131 + "startDate": "2020-01-01", 132 + "endDate": null, 133 + "description": "Led development of microservices", 134 + "skills": ["Python", "Kubernetes"] 135 + } 136 + ], 137 + "education": [ 138 + { 139 + "institutionName": "MIT", 140 + "degree": "Bachelor of Science", 141 + "fieldOfStudy": "Computer Science", 142 + "startDate": "2010-09-01", 143 + "endDate": "2014-05-31", 144 + "skills": ["C++", "Algorithms"] 145 + } 146 + ], 147 + "skills": ["Python", "Kubernetes", "C++", "Algorithms"] 148 + } 149 + `); 150 + 151 + const parser = new CVParserService(mockProvider); 152 + const result = await parser.parseCVText('Sample CV text'); 153 + 154 + expect(result.personalInfo.name).toBe('John Doe'); 155 + expect(result.jobExperiences).toHaveLength(1); 156 + expect(result.jobExperiences[0].companyName).toBe('Tech Corp'); 157 + expect(result.jobExperiences[0].endDate).toBeNull(); 158 + expect(result.education).toHaveLength(1); 159 + expect(result.skills).toHaveLength(4); 160 + }); 161 + }); 162 + });
+93
packages/ai-parser/src/ai-parser.service.ts
··· 1 + import type { AIProvider } from '@cv/ai-provider'; 2 + import { ParsedCVDataSchema, type ParsedCVData } from './schemas'; 3 + import { getCV_PARSING_PROMPT } from './prompts'; 4 + 5 + /** 6 + * Configuration for CV parser service 7 + */ 8 + export interface CVParserConfig { 9 + /** Temperature for AI completions */ 10 + temperature?: number; 11 + /** Maximum tokens for AI completions */ 12 + maxTokens?: number; 13 + } 14 + 15 + /** 16 + * Service for parsing CV text using AI 17 + * Uses dependency injection for the AI provider 18 + */ 19 + export class CVParserService { 20 + private provider: AIProvider; 21 + private temperature: number; 22 + private maxTokens: number; 23 + 24 + constructor(provider: AIProvider, config?: CVParserConfig) { 25 + this.provider = provider; 26 + this.temperature = config?.temperature ?? 0.1; 27 + this.maxTokens = config?.maxTokens ?? 2048; 28 + } 29 + 30 + /** 31 + * Parse CV text using the AI provider 32 + * @param cvText Raw text from CV (extracted from PDF, DOCX, etc.) 33 + * @returns Structured CV data matching ParsedCVDataSchema 34 + */ 35 + async parseCVText(cvText: string): Promise<ParsedCVData> { 36 + if (!cvText || cvText.trim().length === 0) { 37 + throw new Error('CV text cannot be empty'); 38 + } 39 + 40 + const prompt = getCV_PARSING_PROMPT(cvText); 41 + 42 + try { 43 + const response = await this.provider.complete({ 44 + prompt, 45 + temperature: this.temperature, 46 + maxTokens: this.maxTokens, 47 + stopSequences: ['</s>'], 48 + }); 49 + 50 + // Extract JSON from response (handle markdown code blocks) 51 + const rawJson = this.extractJson(response.content); 52 + 53 + // Parse and validate with Zod 54 + const parsed = ParsedCVDataSchema.parse(JSON.parse(rawJson)); 55 + 56 + return parsed; 57 + } catch (error) { 58 + if (error instanceof SyntaxError) { 59 + throw new Error( 60 + `Failed to parse LLM response as JSON: ${error.message}` 61 + ); 62 + } 63 + 64 + if (error instanceof Error && 'issues' in error) { 65 + // Zod validation error 66 + throw new Error(`CV data validation failed: ${error.message}`); 67 + } 68 + 69 + throw error; 70 + } 71 + } 72 + 73 + /** 74 + * Extract JSON from LLM response 75 + * Handles markdown code blocks and other formatting 76 + */ 77 + private extractJson(text: string): string { 78 + // Try to extract from markdown code block 79 + const codeBlockMatch = text.match(/```(?:json)?\n?([\s\S]*?)```/); 80 + if (codeBlockMatch?.[1]) { 81 + return codeBlockMatch[1].trim(); 82 + } 83 + 84 + // Try to extract raw JSON object 85 + const jsonMatch = text.match(/\{[\s\S]*\}/); 86 + if (jsonMatch) { 87 + return jsonMatch[0]; 88 + } 89 + 90 + // If no JSON found, return as-is and let JSON.parse fail with clear error 91 + return text; 92 + } 93 + }
+24
packages/ai-parser/src/cv-parser.module.ts
··· 1 + import { DynamicModule, Module } from '@nestjs/common'; 2 + import { AI_PROVIDER, AIModule, type AIModuleOptions, type AIProvider } from '@cv/ai-provider'; 3 + import { CVParserService } from './ai-parser.service'; 4 + 5 + export const CV_PARSER_SERVICE = Symbol('CV_PARSER_SERVICE'); 6 + 7 + @Module({}) 8 + export class CVParserModule { 9 + static forRoot(aiOptions: AIModuleOptions): DynamicModule { 10 + return { 11 + module: CVParserModule, 12 + imports: [AIModule.forRoot(aiOptions)], 13 + providers: [ 14 + { 15 + provide: CV_PARSER_SERVICE, 16 + inject: [AI_PROVIDER], 17 + useFactory: (aiProvider: AIProvider): CVParserService => 18 + new CVParserService(aiProvider), 19 + }, 20 + ], 21 + exports: [CV_PARSER_SERVICE], 22 + }; 23 + } 24 + }
+18
packages/ai-parser/src/index.ts
··· 1 + // Schemas and types 2 + export { 3 + ParsedCVDataSchema, 4 + ParsedJobExperienceSchema, 5 + ParsedEducationSchema, 6 + type ParsedCVData, 7 + type ParsedJobExperience, 8 + type ParsedEducation, 9 + } from './schemas'; 10 + 11 + // Prompts 12 + export { CV_PARSING_PROMPT, getCV_PARSING_PROMPT } from './prompts'; 13 + 14 + // Service 15 + export { CVParserService, type CVParserConfig } from './ai-parser.service'; 16 + 17 + // NestJS Module 18 + export { CVParserModule, CV_PARSER_SERVICE } from './cv-parser.module';
+61
packages/ai-parser/src/prompts.ts
··· 1 + /** 2 + * System prompt for CV parsing 3 + * Instructs the LLM to extract structured information from CV text 4 + */ 5 + export const CV_PARSING_PROMPT = `You are a professional CV parser. Your task is to extract structured information from the provided CV text and return it as a JSON object. 6 + 7 + Extract the following information: 8 + 1. Personal info: name and introduction/summary 9 + 2. Work experience: for each job, extract company, role, level, dates, description, and skills mentioned 10 + 3. Education: for each entry, extract institution, degree, field of study, dates, description, and skills 11 + 4. Skills: list of all mentioned skills 12 + 13 + IMPORTANT RULES: 14 + - Return ONLY valid JSON, no other text 15 + - All dates must be in ISO 8601 format (YYYY-MM-DD) 16 + - For current positions, set endDate to null 17 + - Skills should be extracted as an array of strings 18 + - If a field is not found, omit it from the object (except for arrays, which default to []) 19 + - Company/institution names should be exact as written in the CV 20 + - Keep descriptions concise (1-2 sentences) 21 + 22 + Example JSON structure: 23 + { 24 + "personalInfo": { 25 + "name": "John Doe", 26 + "introduction": "Software engineer with 10 years of experience" 27 + }, 28 + "jobExperiences": [ 29 + { 30 + "companyName": "Tech Corp", 31 + "roleName": "Senior Software Engineer", 32 + "levelName": "Senior", 33 + "startDate": "2020-01-15", 34 + "endDate": null, 35 + "description": "Led development of microservices architecture using Kubernetes", 36 + "skills": ["Kubernetes", "Go", "Docker", "PostgreSQL"] 37 + } 38 + ], 39 + "education": [ 40 + { 41 + "institutionName": "MIT", 42 + "degree": "Bachelor of Science", 43 + "fieldOfStudy": "Computer Science", 44 + "startDate": "2012-09-01", 45 + "endDate": "2016-05-31", 46 + "skills": ["C++", "Algorithms", "Data Structures"] 47 + } 48 + ], 49 + "skills": ["Kubernetes", "Go", "Docker", "PostgreSQL", "C++", "Algorithms"] 50 + } 51 + 52 + CV Text to parse: 53 + --- 54 + {cvText} 55 + --- 56 + 57 + Return only the JSON object.`; 58 + 59 + export const getCV_PARSING_PROMPT = (cvText: string): string => { 60 + return CV_PARSING_PROMPT.replace('{cvText}', cvText); 61 + };
+66
packages/ai-parser/src/schemas.ts
··· 1 + import { z } from 'zod'; 2 + 3 + /** 4 + * Schema for parsed job experience extracted from CV text 5 + */ 6 + export const ParsedJobExperienceSchema = z.object({ 7 + companyName: z.string().min(1, 'Company name is required'), 8 + roleName: z.string().min(1, 'Role name is required'), 9 + levelName: z 10 + .string() 11 + .optional() 12 + .transform((val) => val?.trim() || undefined), 13 + startDate: z.string().min(1, 'Start date is required'), // ISO date string YYYY-MM-DD 14 + endDate: z.string().nullable().optional(), // ISO date string or null for current position 15 + description: z 16 + .string() 17 + .optional() 18 + .transform((val) => val?.trim() || undefined), 19 + skills: z.array(z.string()).default([]), 20 + }); 21 + 22 + export type ParsedJobExperience = z.infer<typeof ParsedJobExperienceSchema>; 23 + 24 + /** 25 + * Schema for parsed education extracted from CV text 26 + */ 27 + export const ParsedEducationSchema = z.object({ 28 + institutionName: z.string().min(1, 'Institution name is required'), 29 + degree: z.string().min(1, 'Degree is required'), 30 + fieldOfStudy: z 31 + .string() 32 + .optional() 33 + .transform((val) => val?.trim() || undefined), 34 + startDate: z.string().min(1, 'Start date is required'), // ISO date string YYYY-MM-DD 35 + endDate: z.string().nullable().optional(), // ISO date string or null for currently studying 36 + description: z 37 + .string() 38 + .optional() 39 + .transform((val) => val?.trim() || undefined), 40 + skills: z.array(z.string()).default([]), 41 + }); 42 + 43 + export type ParsedEducation = z.infer<typeof ParsedEducationSchema>; 44 + 45 + /** 46 + * Schema for complete CV data parsed from text 47 + */ 48 + export const ParsedCVDataSchema = z.object({ 49 + personalInfo: z 50 + .object({ 51 + name: z 52 + .string() 53 + .optional() 54 + .transform((val) => val?.trim() || undefined), 55 + introduction: z 56 + .string() 57 + .optional() 58 + .transform((val) => val?.trim() || undefined), 59 + }) 60 + .optional(), 61 + jobExperiences: z.array(ParsedJobExperienceSchema).default([]), 62 + education: z.array(ParsedEducationSchema).default([]), 63 + skills: z.array(z.string()).default([]), 64 + }); 65 + 66 + export type ParsedCVData = z.infer<typeof ParsedCVDataSchema>;
+8
packages/ai-parser/tsconfig.json
··· 1 + { 2 + "extends": "@cv/tsconfig/tsconfig.library.json", 3 + "compilerOptions": { 4 + "outDir": "./dist", 5 + "rootDir": "./src" 6 + }, 7 + "include": ["src/**/*"] 8 + }
+70
packages/ai-provider/TEST_SETUP.md
··· 1 + # AI Provider Test Setup 2 + 3 + ## Overview 4 + 5 + This package has two types of tests: 6 + 7 + 1. **Unit Tests** (`.test.ts`) - Fast, mocked tests that don't require llama.cpp 8 + 2. **Integration Tests** (`.integration.test.ts`) - Slower tests that hit the actual llama.cpp service 9 + 10 + ## Running Tests 11 + 12 + ```bash 13 + # Run fast unit tests only (default) 14 + pnpm test 15 + 16 + # Run in watch mode during development 17 + pnpm test:watch 18 + 19 + # Run integration tests (requires llama.cpp running) 20 + pnpm test:integration 21 + 22 + # Run ALL tests (unit + integration) 23 + pnpm test:all 24 + ``` 25 + 26 + ## Integration Test Requirements 27 + 28 + Integration tests require: 29 + - Docker Compose services running (`docker compose up -d llama`) 30 + - llama.cpp healthy on `http://localhost:8080` 31 + - Model downloaded (~4.4GB Mistral 7B) 32 + 33 + **Note**: Integration tests are slow (~30-60 seconds) because they hit the real model. 34 + 35 + ## Test Structure 36 + 37 + ### Unit Tests (`ai-parser.service.test.ts`) 38 + - Mock the AI provider 39 + - Test JSON extraction logic 40 + - Test stop sequence configuration (caught the `'\n\n'` bug!) 41 + - Test error handling 42 + - **Fast**: Run in milliseconds 43 + 44 + ### Integration Tests (`llama-cpp.provider.integration.test.ts`) 45 + - Hit real llama.cpp service 46 + - Test health checks 47 + - Test basic completions 48 + - Test JSON generation 49 + - Test timeout handling 50 + - **Slow**: Run in 30+ seconds 51 + 52 + ## What These Tests Caught 53 + 54 + ✅ **Stop Sequence Bug**: Unit tests verify `'\n\n'` is NOT in stop sequences 55 + ✅ **Timeout Issues**: Integration tests verify timeouts work correctly 56 + ✅ **JSON Parsing**: Both test JSON extraction from various response formats 57 + ✅ **Error Handling**: Tests verify clear error messages for invalid responses 58 + 59 + ## CI/CD Recommendations 60 + 61 + In CI, run: 62 + ```bash 63 + # Fast feedback (unit tests only) 64 + pnpm test 65 + 66 + # Full validation (run integration tests separately, allow failures) 67 + pnpm test:integration || echo "Integration tests skipped or failed" 68 + ``` 69 + 70 + Integration tests can be flaky (model availability, performance), so don't block CI on them.
+25
packages/ai-provider/jest.config.ts
··· 1 + import type { Config } from 'jest'; 2 + 3 + const config: Config = { 4 + displayName: 'ai-provider', 5 + preset: 'ts-jest', 6 + testEnvironment: 'node', 7 + roots: ['<rootDir>/src'], 8 + testMatch: ['**/*.test.ts'], 9 + 10 + // Skip integration tests by default (they're slow) 11 + testPathIgnorePatterns: ['/node_modules/', '.integration.test.ts$'], 12 + 13 + collectCoverageFrom: [ 14 + 'src/**/*.ts', 15 + '!src/**/*.test.ts', 16 + '!src/**/*.integration.test.ts', 17 + '!src/**/*.d.ts', 18 + ], 19 + 20 + moduleNameMapper: { 21 + '^@cv/(.*)$': '<rootDir>/../$1/src', 22 + }, 23 + }; 24 + 25 + export default config;
+30
packages/ai-provider/package.json
··· 1 + { 2 + "name": "@cv/ai-provider", 3 + "version": "0.0.0", 4 + "private": true, 5 + "main": "./src/index.ts", 6 + "types": "./src/index.ts", 7 + "scripts": { 8 + "typecheck": "tsc --noEmit", 9 + "test": "jest", 10 + "test:watch": "jest --watch", 11 + "test:integration": "jest --testPathPattern='.integration.test.ts$' --runInBand", 12 + "test:all": "jest --testPathIgnorePatterns=[]" 13 + }, 14 + "dependencies": { 15 + "@nestjs/common": "^11.1.3", 16 + "@nestjs/config": "^4.0.2" 17 + }, 18 + "peerDependencies": { 19 + "@nestjs/common": "^11.0.0", 20 + "@nestjs/config": "^4.0.0" 21 + }, 22 + "devDependencies": { 23 + "@cv/tsconfig": "*", 24 + "@types/jest": "^29.5.12", 25 + "@types/node": "^22.7.5", 26 + "jest": "^29.7.0", 27 + "ts-jest": "^29.1.2", 28 + "typescript": "^5.3.3" 29 + } 30 + }
+54
packages/ai-provider/src/ai.module.ts
··· 1 + import { DynamicModule, Module } from '@nestjs/common'; 2 + import { ConfigModule, ConfigService } from '@nestjs/config'; 3 + import type { AIProvider } from './types'; 4 + import { LlamaCppProvider, type LlamaCppConfig } from './providers'; 5 + 6 + export const AI_PROVIDER = Symbol('AI_PROVIDER'); 7 + 8 + export type AIProviderType = 'llama-cpp'; 9 + 10 + export interface AIModuleOptions { 11 + type: AIProviderType; 12 + } 13 + 14 + @Module({}) 15 + export class AIModule { 16 + static forRoot(options: AIModuleOptions): DynamicModule { 17 + return { 18 + module: AIModule, 19 + imports: [ConfigModule], 20 + providers: [ 21 + { 22 + provide: AI_PROVIDER, 23 + inject: [ConfigService], 24 + useFactory: (configService: ConfigService): AIProvider => 25 + AIModule.createProvider(options.type, configService), 26 + }, 27 + ], 28 + exports: [AI_PROVIDER], 29 + }; 30 + } 31 + 32 + private static createProvider( 33 + type: AIProviderType, 34 + configService: ConfigService, 35 + ): AIProvider { 36 + switch (type) { 37 + case 'llama-cpp': 38 + return AIModule.createLlamaCppProvider(configService); 39 + default: 40 + throw new Error(`Unknown AI provider type: ${type}`); 41 + } 42 + } 43 + 44 + private static createLlamaCppProvider(configService: ConfigService): AIProvider { 45 + const config: LlamaCppConfig = { 46 + baseUrl: configService.get<string>('LLAMA_URL', 'http://llama:8080'), 47 + defaultTemperature: configService.get<number>('AI_TEMPERATURE', 0.1), 48 + defaultMaxTokens: configService.get<number>('AI_MAX_TOKENS', 2048), 49 + timeout: configService.get<number>('AI_TIMEOUT', 60000), 50 + }; 51 + 52 + return new LlamaCppProvider(config); 53 + } 54 + }
+13
packages/ai-provider/src/index.ts
··· 1 + // Types 2 + export type { 3 + AIProvider, 4 + AIProviderConfig, 5 + AICompletionRequest, 6 + AICompletionResponse, 7 + } from './types'; 8 + 9 + // Providers 10 + export { LlamaCppProvider, type LlamaCppConfig } from './providers'; 11 + 12 + // NestJS Module 13 + export { AIModule, AI_PROVIDER, type AIModuleOptions, type AIProviderType } from './ai.module';
+1
packages/ai-provider/src/providers/index.ts
··· 1 + export { LlamaCppProvider, type LlamaCppConfig } from './llama-cpp.provider';
+97
packages/ai-provider/src/providers/llama-cpp.provider.integration.test.ts
··· 1 + import { describe, it, expect, beforeAll } from '@jest/globals'; 2 + import { LlamaCppProvider } from './llama-cpp.provider'; 3 + 4 + describe('LlamaCppProvider Integration Tests', () => { 5 + let provider: LlamaCppProvider; 6 + 7 + beforeAll(() => { 8 + provider = new LlamaCppProvider({ 9 + baseUrl: process.env.LLAMA_URL || 'http://localhost:8080', 10 + defaultTemperature: 0.1, 11 + defaultMaxTokens: 100, 12 + timeout: 10000, // 10 second timeout for tests 13 + }); 14 + }); 15 + 16 + describe('Health Check', () => { 17 + it('should report healthy when llama.cpp is running', async () => { 18 + const isHealthy = await provider.isHealthy(); 19 + expect(isHealthy).toBe(true); 20 + }, 15000); 21 + }); 22 + 23 + describe('Basic Completion', () => { 24 + it('should generate a simple completion', async () => { 25 + const response = await provider.complete({ 26 + prompt: 'Say "Hello World" and nothing else.', 27 + temperature: 0.1, 28 + maxTokens: 50, 29 + stopSequences: ['</s>'], 30 + }); 31 + 32 + expect(response.content).toBeTruthy(); 33 + expect(response.content.length).toBeGreaterThan(0); 34 + expect(response.finishReason).toBeDefined(); 35 + }, 30000); 36 + 37 + it('should handle stop sequences correctly', async () => { 38 + const response = await provider.complete({ 39 + prompt: 'Count: 1, 2, 3', 40 + temperature: 0.1, 41 + maxTokens: 100, 42 + stopSequences: ['</s>'], // Should NOT include '\n\n' as that causes early termination 43 + }); 44 + 45 + expect(response.content).toBeTruthy(); 46 + expect(response.finishReason).toBeDefined(); 47 + }, 30000); 48 + }); 49 + 50 + describe('JSON Generation', () => { 51 + it('should generate valid JSON', async () => { 52 + const response = await provider.complete({ 53 + prompt: 'Return ONLY this JSON object: {"name": "Test", "value": 123}', 54 + temperature: 0.1, 55 + maxTokens: 100, 56 + stopSequences: ['</s>'], 57 + }); 58 + 59 + expect(response.content).toBeTruthy(); 60 + 61 + // Extract JSON (handle markdown code blocks) 62 + const jsonMatch = response.content.match(/\{[\s\S]*\}/); 63 + expect(jsonMatch).toBeTruthy(); 64 + 65 + if (jsonMatch) { 66 + const parsed = JSON.parse(jsonMatch[0]); 67 + expect(parsed).toHaveProperty('name'); 68 + } 69 + }, 30000); 70 + }); 71 + 72 + describe('Error Handling', () => { 73 + it('should timeout for requests exceeding configured timeout', async () => { 74 + const slowProvider = new LlamaCppProvider({ 75 + baseUrl: process.env.LLAMA_URL || 'http://localhost:8080', 76 + timeout: 1000, // 1 second timeout 77 + }); 78 + 79 + await expect( 80 + slowProvider.complete({ 81 + prompt: 'Write a very long essay about the history of computing. Include detailed information about every decade from the 1940s to present day.', 82 + maxTokens: 2000, 83 + }) 84 + ).rejects.toThrow(); 85 + }, 5000); 86 + 87 + it('should handle invalid base URL gracefully', async () => { 88 + const badProvider = new LlamaCppProvider({ 89 + baseUrl: 'http://localhost:9999', // Invalid port 90 + timeout: 2000, 91 + }); 92 + 93 + const isHealthy = await badProvider.isHealthy(); 94 + expect(isHealthy).toBe(false); 95 + }, 5000); 96 + }); 97 + });
+117
packages/ai-provider/src/providers/llama-cpp.provider.ts
··· 1 + import type { 2 + AIProvider, 3 + AIProviderConfig, 4 + AICompletionRequest, 5 + AICompletionResponse, 6 + } from '../types'; 7 + 8 + /** 9 + * Configuration specific to llama.cpp server 10 + */ 11 + export interface LlamaCppConfig extends AIProviderConfig { 12 + /** Model path on server (informational only) */ 13 + modelPath?: string; 14 + } 15 + 16 + /** 17 + * AI provider implementation for llama.cpp server 18 + * https://github.com/ggml-org/llama.cpp 19 + */ 20 + export class LlamaCppProvider implements AIProvider { 21 + readonly name = 'llama-cpp'; 22 + 23 + private baseUrl: string; 24 + private defaultTemperature: number; 25 + private defaultMaxTokens: number; 26 + private timeout: number; 27 + 28 + constructor(config: LlamaCppConfig) { 29 + this.baseUrl = config.baseUrl.replace(/\/$/, ''); 30 + this.defaultTemperature = config.defaultTemperature ?? 0.1; 31 + this.defaultMaxTokens = config.defaultMaxTokens ?? 2048; 32 + this.timeout = config.timeout ?? 60000; 33 + } 34 + 35 + async complete(request: AICompletionRequest): Promise<AICompletionResponse> { 36 + const prompt = request.systemPrompt 37 + ? `${request.systemPrompt}\n\n${request.prompt}` 38 + : request.prompt; 39 + 40 + const controller = new AbortController(); 41 + const timeoutId = setTimeout(() => controller.abort(), this.timeout); 42 + 43 + try { 44 + const fetchResponse = await fetch(`${this.baseUrl}/completion`, { 45 + method: 'POST', 46 + headers: { 'Content-Type': 'application/json' }, 47 + body: JSON.stringify({ 48 + prompt, 49 + temperature: request.temperature ?? this.defaultTemperature, 50 + n_predict: request.maxTokens ?? this.defaultMaxTokens, 51 + stop: request.stopSequences ?? ['</s>'], 52 + }), 53 + signal: controller.signal, 54 + }); 55 + 56 + if (!fetchResponse.ok) { 57 + throw new Error( 58 + `llama.cpp API error: ${fetchResponse.status} ${fetchResponse.statusText}` 59 + ); 60 + } 61 + 62 + const result = (await fetchResponse.json()) as { 63 + content?: string; 64 + tokens_predicted?: number; 65 + tokens_evaluated?: number; 66 + stop_type?: string; 67 + }; 68 + 69 + const aiResponse: AICompletionResponse = { 70 + content: result.content ?? '', 71 + model: 'llama-cpp-local', 72 + finishReason: result.stop_type === 'word' ? 'stop' : 'length', 73 + }; 74 + 75 + if (result.tokens_predicted !== undefined) { 76 + aiResponse.completionTokens = result.tokens_predicted; 77 + } 78 + if (result.tokens_evaluated !== undefined) { 79 + aiResponse.promptTokens = result.tokens_evaluated; 80 + } 81 + 82 + return aiResponse; 83 + } finally { 84 + clearTimeout(timeoutId); 85 + } 86 + } 87 + 88 + async isHealthy(): Promise<boolean> { 89 + try { 90 + const controller = new AbortController(); 91 + const timeoutId = setTimeout(() => controller.abort(), 5000); 92 + 93 + const response = await fetch(`${this.baseUrl}/health`, { 94 + signal: controller.signal, 95 + }); 96 + 97 + clearTimeout(timeoutId); 98 + return response.ok; 99 + } catch { 100 + return false; 101 + } 102 + } 103 + 104 + /** 105 + * Create a LlamaCppProvider from environment variables 106 + */ 107 + static fromEnv(): LlamaCppProvider { 108 + const baseUrl = process.env['LLAMA_URL'] ?? 'http://localhost:8080'; 109 + 110 + return new LlamaCppProvider({ 111 + baseUrl, 112 + defaultTemperature: 0.1, 113 + defaultMaxTokens: 2048, 114 + timeout: 60000, 115 + }); 116 + } 117 + }
+69
packages/ai-provider/src/types.ts
··· 1 + /** 2 + * Request for AI completion 3 + */ 4 + export interface AICompletionRequest { 5 + /** System prompt providing context/instructions */ 6 + systemPrompt?: string; 7 + /** User prompt to complete */ 8 + prompt: string; 9 + /** Temperature for randomness (0.0 = deterministic, 1.0 = creative) */ 10 + temperature?: number; 11 + /** Maximum tokens to generate */ 12 + maxTokens?: number; 13 + /** Stop sequences to end generation */ 14 + stopSequences?: string[]; 15 + } 16 + 17 + /** 18 + * Response from AI completion 19 + */ 20 + export interface AICompletionResponse { 21 + /** Generated text content */ 22 + content: string; 23 + /** Number of tokens used in prompt */ 24 + promptTokens?: number; 25 + /** Number of tokens generated */ 26 + completionTokens?: number; 27 + /** Model used for completion */ 28 + model?: string; 29 + /** Whether generation was cut off */ 30 + finishReason?: 'stop' | 'length' | 'content_filter' | 'error'; 31 + } 32 + 33 + /** 34 + * Configuration for AI provider 35 + */ 36 + export interface AIProviderConfig { 37 + /** Base URL for API */ 38 + baseUrl: string; 39 + /** API key for authentication (optional for local providers) */ 40 + apiKey?: string; 41 + /** Default temperature */ 42 + defaultTemperature?: number; 43 + /** Default max tokens */ 44 + defaultMaxTokens?: number; 45 + /** Request timeout in milliseconds */ 46 + timeout?: number; 47 + } 48 + 49 + /** 50 + * Abstract AI provider interface 51 + * Implementations can use different backends (llama.cpp, OpenAI, Anthropic, etc.) 52 + */ 53 + export interface AIProvider { 54 + /** Provider name for identification */ 55 + readonly name: string; 56 + 57 + /** 58 + * Complete a prompt using the AI model 59 + * @param request Completion request parameters 60 + * @returns Promise resolving to completion response 61 + */ 62 + complete(request: AICompletionRequest): Promise<AICompletionResponse>; 63 + 64 + /** 65 + * Check if the provider is available/healthy 66 + * @returns Promise resolving to health status 67 + */ 68 + isHealthy(): Promise<boolean>; 69 + }
+8
packages/ai-provider/tsconfig.json
··· 1 + { 2 + "extends": "@cv/tsconfig/tsconfig.library.json", 3 + "compilerOptions": { 4 + "outDir": "./dist", 5 + "rootDir": "./src" 6 + }, 7 + "include": ["src/**/*"] 8 + }
+1 -1
packages/auth/src/authorization/authorization.service.ts
··· 7 7 CannotViewError, 8 8 } from "../errors/authorization.error"; 9 9 import type { User } from "../user/user.entity"; 10 - import type { PolicyRegistry } from "./policy-registry.service"; 10 + import { PolicyRegistry } from "./policy-registry.service"; 11 11 12 12 @Injectable() 13 13 export class AuthorizationService {
+1 -1
packages/auth/src/authorization/policy-registry.service.ts
··· 1 1 import { raise } from "@cv/system"; 2 2 import { Injectable, type OnModuleInit, type Type } from "@nestjs/common"; 3 - import type { DiscoveryService, Reflector } from "@nestjs/core"; 3 + import { DiscoveryService, Reflector } from "@nestjs/core"; 4 4 import { POLICY_RESOURCE_KEY } from "./policy.decorator"; 5 5 import type { Policy } from "./policy.interface"; 6 6
+1 -1
packages/auth/src/config/jwt.config.ts
··· 1 1 import { Injectable } from "@nestjs/common"; 2 - import type { ConfigService } from "@nestjs/config"; 2 + import { ConfigService } from "@nestjs/config"; 3 3 4 4 @Injectable() 5 5 export class JwtConfigService {
+2 -6
packages/auth/src/cookie/cookie.service.ts
··· 1 - import { Injectable, Logger } from "@nestjs/common"; 2 - import type { ConfigService } from "@nestjs/config"; 1 + import { Injectable } from "@nestjs/common"; 2 + import { ConfigService } from "@nestjs/config"; 3 3 import type { Response } from "express"; 4 4 5 5 type CookieOptionsInput = { ··· 22 22 23 23 @Injectable() 24 24 export class CookieService { 25 - private readonly logger = new Logger(CookieService.name); 26 - 27 25 constructor(private readonly configService: ConfigService) {} 28 26 29 27 private getCookieOptions(options?: CookieOptionsInput): CookieOptions { ··· 56 54 ): void { 57 55 const cookieOptions = this.getCookieOptions(options); 58 56 res.cookie(name, value, cookieOptions); 59 - this.logger.debug(`Set cookie: ${name}`, cookieOptions); 60 57 } 61 58 62 59 clearCookie(res: Response, name: string, options?: CookieOptionsInput): void { ··· 68 65 69 66 res.clearCookie(name, clearOptions); 70 67 res.cookie(name, "", clearOptions); 71 - this.logger.debug(`Cleared cookie: ${name}`, clearOptions); 72 68 } 73 69 }
+2 -2
packages/auth/src/guards/jwt-auth.guard.ts
··· 4 4 Injectable, 5 5 } from "@nestjs/common"; 6 6 import { GqlExecutionContext } from "@nestjs/graphql"; 7 - import type { JwtService } from "@nestjs/jwt"; 7 + import { JwtService } from "@nestjs/jwt"; 8 8 import { EntityNotFoundError } from "../errors"; 9 9 import { 10 10 InvalidTokenError, 11 11 NoTokenError, 12 12 } from "../errors/authentication.error"; 13 - import type { UserService } from "../user/user.service"; 13 + import { UserService } from "../user/user.service"; 14 14 15 15 @Injectable() 16 16 export class JwtAuthGuard implements CanActivate {
+1 -1
packages/auth/src/guards/verified-scope.guard.ts
··· 5 5 Injectable, 6 6 } from "@nestjs/common"; 7 7 import { GqlExecutionContext } from "@nestjs/graphql"; 8 - import type { JwtService } from "@nestjs/jwt"; 8 + import { JwtService } from "@nestjs/jwt"; 9 9 import { JwtScope } from "../jwt/jwt-scope.enum"; 10 10 11 11 @Injectable()
+1 -1
packages/auth/src/identity-provider-registry.service.ts
··· 1 1 import { raise } from "@cv/system"; 2 2 import { Injectable, type OnModuleInit } from "@nestjs/common"; 3 - import type { DiscoveryService, Reflector } from "@nestjs/core"; 3 + import { DiscoveryService, Reflector } from "@nestjs/core"; 4 4 import { z } from "zod"; 5 5 import { 6 6 IDENTITY_PROVIDER_KEY,
+1 -1
packages/auth/src/providers/password/credentials/listeners/password-reset-email.listener.ts
··· 4 4 PASSWORD_RESET_REQUESTED, 5 5 type PasswordResetRequestedEvent, 6 6 } from "../events/password-reset-requested.event"; 7 - import type { TemplatedEmailService } from "../templated-email.service"; 7 + import { TemplatedEmailService } from "../templated-email.service"; 8 8 9 9 @Injectable() 10 10 export class PasswordResetEmailListener {
+1 -1
packages/auth/src/providers/password/credentials/listeners/registration-attempt-email.listener.ts
··· 4 4 REGISTRATION_ATTEMPT_ON_EXISTING_EMAIL, 5 5 type RegistrationAttemptOnExistingEmailEvent, 6 6 } from "../events/registration-attempt-on-existing-email.event"; 7 - import type { TemplatedEmailService } from "../templated-email.service"; 7 + import { TemplatedEmailService } from "../templated-email.service"; 8 8 9 9 @Injectable() 10 10 export class RegistrationAttemptEmailListener {
+1 -1
packages/auth/src/providers/password/credentials/listeners/verification-email.listener.ts
··· 4 4 VERIFICATION_EMAIL_REQUESTED, 5 5 type VerificationEmailRequestedEvent, 6 6 } from "../events/verification-email-requested.event"; 7 - import type { TemplatedEmailService } from "../templated-email.service"; 7 + import { TemplatedEmailService } from "../templated-email.service"; 8 8 9 9 @Injectable() 10 10 export class VerificationEmailListener {
+6 -6
packages/auth/src/providers/password/password-authentication.service.ts
··· 1 1 import { Injectable } from "@nestjs/common"; 2 - import type { EventEmitter2 } from "@nestjs/event-emitter"; 2 + import { EventEmitter2 } from "@nestjs/event-emitter"; 3 3 import * as bcrypt from "bcryptjs"; 4 4 import { 5 5 CurrentPasswordIncorrectError, ··· 8 8 InvalidVerificationTokenError, 9 9 PasswordIncorrectError, 10 10 } from "../../errors/authentication.error"; 11 - import type { IdentityProviderRegistry } from "../../identity-provider-registry.service"; 11 + import { IdentityProviderRegistry } from "../../identity-provider-registry.service"; 12 12 import { JwtScope } from "../../jwt/jwt-scope.enum"; 13 13 import type { RequestMetadata } from "../../request/request-metadata.decorator"; 14 - import type { TokenService } from "../../token/token.service"; 15 - import type { TokenExpiryConfigService } from "../../token/token-expiry.config"; 16 - import type { CredentialsService } from "../../user/credentials.service"; 14 + import { TokenService } from "../../token/token.service"; 15 + import { TokenExpiryConfigService } from "../../token/token-expiry.config"; 16 + import { CredentialsService } from "../../user/credentials.service"; 17 17 import { generateSecureToken } from "../../user/credentials-token.util"; 18 18 import type { User } from "../../user/user.entity"; 19 - import type { UserService } from "../../user/user.service"; 19 + import { UserService } from "../../user/user.service"; 20 20 import { 21 21 PASSWORD_RESET_REQUESTED, 22 22 PasswordResetRequestedEvent,
+1 -1
packages/auth/src/providers/password/password-identity-provider.ts
··· 3 3 import * as bcrypt from "bcryptjs"; 4 4 import { InvalidCredentialsError } from "../../errors/authentication.error"; 5 5 import type { IdentityProvider as IIdentityProvider } from "../../identity-provider.interface"; 6 - import type { CredentialsService } from "../../user/credentials.service"; 6 + import { CredentialsService } from "../../user/credentials.service"; 7 7 import type { User } from "../../user/user.entity"; 8 8 9 9 export const PASSWORD_PROVIDER_NAME = Symbol("password");
+1 -1
packages/auth/src/providers/password/password-provider.module.ts
··· 2 2 import { 3 3 ResendModule, 4 4 TemplateModule, 5 - type TemplateRegistryService, 5 + TemplateRegistryService, 6 6 } from "@cv/system"; 7 7 import { Module, type OnModuleInit } from "@nestjs/common"; 8 8 import { ConfigModule, ConfigService } from "@nestjs/config";
+10 -52
packages/auth/src/token/auth-cookie.service.ts
··· 1 - import { Injectable, Logger } from "@nestjs/common"; 1 + import { Injectable } from "@nestjs/common"; 2 2 import type { Response } from "express"; 3 - import type { JwtConfigService } from "../config/jwt.config"; 4 - import type { CookieService } from "../cookie/cookie.service"; 3 + import { JwtConfigService } from "../config/jwt.config"; 4 + import { CookieService } from "../cookie/cookie.service"; 5 5 6 6 @Injectable() 7 7 export class AuthCookieService { 8 - private readonly logger = new Logger(AuthCookieService.name); 9 - 10 8 constructor( 11 9 private readonly cookieService: CookieService, 12 10 private readonly jwtConfig: JwtConfigService, 13 11 ) {} 14 12 15 13 private parseExpiryToSeconds(expiryString: string): number { 16 - this.logger.debug(`Parsing expiry string: ${expiryString}`); 17 - 18 14 if (!expiryString || typeof expiryString !== "string") { 19 15 throw new Error( 20 16 `Invalid expiry string: ${JSON.stringify(expiryString)}. Expected format like "15m", "1h", "7d"`, ··· 37 33 ); 38 34 } 39 35 40 - let seconds: number; 41 36 switch (unit) { 42 37 case "s": 43 - seconds = value; 44 - break; 38 + return value; 45 39 case "m": 46 - seconds = value * 60; 47 - break; 40 + return value * 60; 48 41 case "h": 49 - seconds = value * 60 * 60; 50 - break; 42 + return value * 60 * 60; 51 43 case "d": 52 - seconds = value * 60 * 60 * 24; 53 - break; 44 + return value * 60 * 60 * 24; 54 45 default: 55 46 throw new Error(`Invalid expiry unit: ${unit}. Expected s, m, h, or d`); 56 47 } 57 - 58 - this.logger.debug( 59 - `Parsed expiry: ${expiryString} = ${seconds} seconds (${seconds / 60} minutes)`, 60 - ); 61 - 62 - return seconds; 63 48 } 64 49 65 50 setAuthCookies( ··· 67 52 accessToken: string, 68 53 refreshToken: string, 69 54 ): void { 70 - this.logger.log("Setting authentication cookies"); 71 - const accessTokenExpiryString = this.jwtConfig.getAccessTokenExpiry(); 72 - this.logger.debug(`Access token expiry string: ${accessTokenExpiryString}`); 73 - this.logger.debug( 74 - `Access token length: ${accessToken.length}, Refresh token length: ${refreshToken.length}`, 75 - ); 76 - 77 55 const accessTokenMaxAgeSeconds = this.parseExpiryToSeconds( 78 - accessTokenExpiryString, 56 + this.jwtConfig.getAccessTokenExpiry(), 79 57 ); 80 58 81 59 if (accessTokenMaxAgeSeconds <= 0) { 82 - throw new Error( 83 - `Invalid access token expiry: ${accessTokenExpiryString}. Calculated maxAge: ${accessTokenMaxAgeSeconds}s`, 84 - ); 60 + throw new Error("Invalid access token expiry"); 85 61 } 86 62 87 63 const accessTokenMaxAgeMs = accessTokenMaxAgeSeconds * 1000; 88 - const refreshTokenMaxAgeMs = 60 * 60 * 24 * 7 * 1000; // 7 days in milliseconds 89 - 90 - this.logger.debug( 91 - `Access token maxAge: ${accessTokenMaxAgeSeconds}s (${accessTokenMaxAgeMs}ms)`, 92 - ); 93 - this.logger.debug( 94 - `Refresh token maxAge: ${refreshTokenMaxAgeMs / 1000}s (${refreshTokenMaxAgeMs}ms)`, 95 - ); 64 + const refreshTokenMaxAgeMs = 60 * 60 * 24 * 7 * 1000; // 7 days 96 65 97 66 this.cookieService.setCookie(res, "access_token", accessToken, { 98 67 maxAge: accessTokenMaxAgeMs, ··· 102 71 maxAge: refreshTokenMaxAgeMs, 103 72 path: "/api/auth/credentials/refresh", 104 73 }); 105 - 106 - this.logger.log("Authentication cookies set successfully"); 107 74 } 108 75 109 76 clearAuthCookies(res: Response): void { 110 - this.logger.log("Clearing authentication cookies"); 111 - 112 77 this.cookieService.clearCookie(res, "access_token"); 113 78 this.cookieService.clearCookie(res, "refresh_token", { 114 79 path: "/api/auth/credentials/refresh", 115 80 }); 116 - 117 - const setCookieHeaders = res.getHeader("Set-Cookie"); 118 - this.logger.debug( 119 - `Set-Cookie headers after clearing: ${JSON.stringify(setCookieHeaders, null, 2)}`, 120 - ); 121 - 122 - this.logger.log("Authentication cookies cleared successfully"); 123 81 } 124 82 }
+5 -5
packages/auth/src/token/refresh-token.service.ts
··· 1 - import type { PrismaService } from "@cv/system"; 1 + import { PrismaService } from "@cv/system"; 2 2 import { Injectable } from "@nestjs/common"; 3 3 import { InvalidRefreshTokenError, notFound } from "../errors"; 4 - import type { DeviceIdentificationService } from "../metadata/device-identification.service"; 5 - import type { LocationService } from "../metadata/location.service"; 4 + import { DeviceIdentificationService } from "../metadata/device-identification.service"; 5 + import { LocationService } from "../metadata/location.service"; 6 6 import { hashToken } from "../user/credentials-token.util"; 7 - import type { TokenEncryptionService } from "../user/token-encryption.service"; 7 + import { TokenEncryptionService } from "../user/token-encryption.service"; 8 8 import type { RefreshToken } from "./refresh-token.entity"; 9 - import type { RefreshTokenMapper } from "./refresh-token.mapper"; 9 + import { RefreshTokenMapper } from "./refresh-token.mapper"; 10 10 11 11 @Injectable() 12 12 export class RefreshTokenService {
+1 -1
packages/auth/src/token/token-expiry.config.ts
··· 1 1 import { Injectable } from "@nestjs/common"; 2 - import type { ConfigService } from "@nestjs/config"; 2 + import { ConfigService } from "@nestjs/config"; 3 3 4 4 @Injectable() 5 5 export class TokenExpiryConfigService {
+5 -5
packages/auth/src/token/token.service.ts
··· 1 1 import { Injectable } from "@nestjs/common"; 2 - import type { JwtService } from "@nestjs/jwt"; 3 - import type { JwtConfigService } from "../config/jwt.config"; 2 + import { JwtService } from "@nestjs/jwt"; 3 + import { JwtConfigService } from "../config/jwt.config"; 4 4 import { InvalidRefreshTokenError } from "../errors/authentication.error"; 5 5 import { JwtScope } from "../jwt/jwt-scope.enum"; 6 6 import type { RequestMetadata } from "../request/request-metadata.decorator"; 7 7 import type { User } from "../user/user.entity"; 8 - import type { UserService } from "../user/user.service"; 9 - import type { RefreshTokenService } from "./refresh-token.service"; 10 - import type { TokenExpiryConfigService } from "./token-expiry.config"; 8 + import { UserService } from "../user/user.service"; 9 + import { RefreshTokenService } from "./refresh-token.service"; 10 + import { TokenExpiryConfigService } from "./token-expiry.config"; 11 11 12 12 @Injectable() 13 13 export class TokenService {
+4 -4
packages/auth/src/user/credentials.service.ts
··· 1 - import type { PrismaService } from "@cv/system"; 1 + import { PrismaService } from "@cv/system"; 2 2 import { Injectable } from "@nestjs/common"; 3 3 import { notFound } from "../errors"; 4 4 import { ··· 9 9 VerificationTokenExpiredError, 10 10 } from "../errors/authentication.error"; 11 11 import type { Credentials } from "./credentials.entity"; 12 - import type { CredentialsMapper } from "./credentials.mapper"; 12 + import { CredentialsMapper } from "./credentials.mapper"; 13 13 import { hashToken, verifyToken } from "./credentials-token.util"; 14 - import type { TokenEncryptionService } from "./token-encryption.service"; 14 + import { TokenEncryptionService } from "./token-encryption.service"; 15 15 import type { User } from "./user.entity"; 16 - import type { UserMapper } from "./user.mapper"; 16 + import { UserMapper } from "./user.mapper"; 17 17 18 18 @Injectable() 19 19 export class CredentialsService {
+1 -1
packages/auth/src/user/token-encryption.service.ts
··· 5 5 scryptSync, 6 6 } from "node:crypto"; 7 7 import { Injectable } from "@nestjs/common"; 8 - import type { ConfigService } from "@nestjs/config"; 8 + import { ConfigService } from "@nestjs/config"; 9 9 10 10 @Injectable() 11 11 export class TokenEncryptionService {
+1 -1
packages/auth/src/user/user.mapper.ts
··· 1 1 import type { BaseMapper } from "@cv/system"; 2 2 import { Injectable } from "@nestjs/common"; 3 3 import type { Prisma } from "@prisma/client"; 4 - import type { CredentialsMapper } from "./credentials.mapper"; 4 + import { CredentialsMapper } from "./credentials.mapper"; 5 5 import { User } from "./user.entity"; 6 6 7 7 type PrismaUserWithCredentials = Prisma.UserGetPayload<{
+2 -2
packages/auth/src/user/user.service.ts
··· 1 - import type { PrismaService } from "@cv/system"; 1 + import { PrismaService } from "@cv/system"; 2 2 import { Injectable } from "@nestjs/common"; 3 3 import { notFound } from "../errors"; 4 4 import type { User } from "./user.entity"; 5 - import type { UserMapper } from "./user.mapper"; 5 + import { UserMapper } from "./user.mapper"; 6 6 7 7 @Injectable() 8 8 export class UserService {
+29
packages/file-upload/package.json
··· 1 + { 2 + "name": "@cv/file-upload", 3 + "version": "0.0.0", 4 + "private": true, 5 + "main": "./src/index.ts", 6 + "types": "./src/index.ts", 7 + "scripts": { 8 + "typecheck": "tsc --noEmit", 9 + "test": "jest" 10 + }, 11 + "dependencies": { 12 + "@nestjs/common": "^11.1.3", 13 + "mammoth": "^1.6.0", 14 + "pdf-parse": "^1.1.1", 15 + "zod": "^3.23.8" 16 + }, 17 + "peerDependencies": { 18 + "@nestjs/common": "^11.0.0" 19 + }, 20 + "devDependencies": { 21 + "@cv/tsconfig": "*", 22 + "@types/jest": "^29.5.0", 23 + "@types/node": "^22.7.5", 24 + "@types/pdf-parse": "^1.1.5", 25 + "jest": "^29.7.0", 26 + "ts-jest": "^29.1.1", 27 + "typescript": "^5.3.3" 28 + } 29 + }
+54
packages/file-upload/src/extractor-registry.ts
··· 1 + import type { TextExtractor, TextExtractionResult } from './types'; 2 + 3 + /** 4 + * Registry for text extractors 5 + * Manages extractors and routes extraction requests to the appropriate handler 6 + */ 7 + export class TextExtractorRegistry { 8 + private extractors: Map<string, TextExtractor> = new Map(); 9 + 10 + register(extractor: TextExtractor): this { 11 + if (this.extractors.has(extractor.name)) { 12 + throw new Error(`Extractor '${extractor.name}' is already registered`); 13 + } 14 + this.extractors.set(extractor.name, extractor); 15 + return this; 16 + } 17 + 18 + unregister(name: string): boolean { 19 + return this.extractors.delete(name); 20 + } 21 + 22 + getAll(): TextExtractor[] { 23 + return Array.from(this.extractors.values()); 24 + } 25 + 26 + get(name: string): TextExtractor | undefined { 27 + return this.extractors.get(name); 28 + } 29 + 30 + findForMimeType(mimeType: string): TextExtractor | undefined { 31 + return this.getAll().find((extractor) => extractor.canHandle(mimeType)); 32 + } 33 + 34 + getSupportedMimeTypes(): string[] { 35 + return this.getAll().flatMap((extractor) => [...extractor.supportedMimeTypes]); 36 + } 37 + 38 + isSupported(mimeType: string): boolean { 39 + return this.findForMimeType(mimeType) !== undefined; 40 + } 41 + 42 + async extract(buffer: Buffer, mimeType: string): Promise<TextExtractionResult> { 43 + const extractor = this.findForMimeType(mimeType); 44 + 45 + if (!extractor) { 46 + return { 47 + success: false, 48 + error: `Unsupported file type: ${mimeType}. Supported types: ${this.getSupportedMimeTypes().join(', ')}`, 49 + }; 50 + } 51 + 52 + return extractor.extract(buffer); 53 + } 54 + }
+28
packages/file-upload/src/extractors/docx.extractor.ts
··· 1 + import * as mammoth from 'mammoth'; 2 + import type { TextExtractor, TextExtractionResult } from '../types'; 3 + 4 + /** 5 + * Text extractor for DOCX files 6 + */ 7 + export class DOCXExtractor implements TextExtractor { 8 + readonly name = 'docx'; 9 + readonly supportedMimeTypes = [ 10 + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 11 + ] as const; 12 + 13 + canHandle(mimeType: string): boolean { 14 + return this.supportedMimeTypes.includes(mimeType as typeof this.supportedMimeTypes[number]); 15 + } 16 + 17 + async extract(buffer: Buffer): Promise<TextExtractionResult> { 18 + try { 19 + const result = await mammoth.extractRawText({ buffer }); 20 + return { success: true, text: result.value }; 21 + } catch (error) { 22 + return { 23 + success: false, 24 + error: `DOCX extraction failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 25 + }; 26 + } 27 + } 28 + }
+28
packages/file-upload/src/extractors/pdf.extractor.ts
··· 1 + import type { TextExtractor, TextExtractionResult } from '../types'; 2 + 3 + // eslint-disable-next-line @typescript-eslint/no-require-imports 4 + const pdfParse = require('pdf-parse') as (buffer: Buffer) => Promise<{ text: string }>; 5 + 6 + /** 7 + * Text extractor for PDF files 8 + */ 9 + export class PDFExtractor implements TextExtractor { 10 + readonly name = 'pdf'; 11 + readonly supportedMimeTypes = ['application/pdf'] as const; 12 + 13 + canHandle(mimeType: string): boolean { 14 + return this.supportedMimeTypes.includes(mimeType as typeof this.supportedMimeTypes[number]); 15 + } 16 + 17 + async extract(buffer: Buffer): Promise<TextExtractionResult> { 18 + try { 19 + const data = await pdfParse(buffer); 20 + return { success: true, text: data.text }; 21 + } catch (error) { 22 + return { 23 + success: false, 24 + error: `PDF extraction failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 25 + }; 26 + } 27 + } 28 + }
+25
packages/file-upload/src/extractors/plain-text.extractor.ts
··· 1 + import type { TextExtractor, TextExtractionResult } from '../types'; 2 + 3 + /** 4 + * Text extractor for plain text files (TXT, MD) 5 + */ 6 + export class PlainTextExtractor implements TextExtractor { 7 + readonly name = 'plain-text'; 8 + readonly supportedMimeTypes = ['text/plain', 'text/markdown'] as const; 9 + 10 + canHandle(mimeType: string): boolean { 11 + return this.supportedMimeTypes.includes(mimeType as typeof this.supportedMimeTypes[number]); 12 + } 13 + 14 + async extract(buffer: Buffer): Promise<TextExtractionResult> { 15 + try { 16 + const text = buffer.toString('utf-8'); 17 + return { success: true, text }; 18 + } catch (error) { 19 + return { 20 + success: false, 21 + error: `Text extraction failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 22 + }; 23 + } 24 + } 25 + }
+42
packages/file-upload/src/file-extraction.module.ts
··· 1 + import { DynamicModule, Module } from '@nestjs/common'; 2 + import type { TextExtractor } from './types'; 3 + import { TextExtractorRegistry } from './extractor-registry'; 4 + import { PDFExtractor } from './extractors/pdf.extractor'; 5 + import { DOCXExtractor } from './extractors/docx.extractor'; 6 + import { PlainTextExtractor } from './extractors/plain-text.extractor'; 7 + 8 + export const TEXT_EXTRACTOR_REGISTRY = Symbol('TEXT_EXTRACTOR_REGISTRY'); 9 + 10 + export interface FileExtractionModuleOptions { 11 + extractors?: TextExtractor[]; 12 + replaceDefaults?: boolean; 13 + } 14 + 15 + @Module({}) 16 + export class FileExtractionModule { 17 + static forRoot(options: FileExtractionModuleOptions = {}): DynamicModule { 18 + return { 19 + module: FileExtractionModule, 20 + providers: [ 21 + { 22 + provide: TEXT_EXTRACTOR_REGISTRY, 23 + useFactory: (): TextExtractorRegistry => { 24 + const registry = new TextExtractorRegistry(); 25 + 26 + if (!options.replaceDefaults) { 27 + registry 28 + .register(new PDFExtractor()) 29 + .register(new DOCXExtractor()) 30 + .register(new PlainTextExtractor()); 31 + } 32 + 33 + options.extractors?.forEach((extractor) => registry.register(extractor)); 34 + 35 + return registry; 36 + }, 37 + }, 38 + ], 39 + exports: [TEXT_EXTRACTOR_REGISTRY], 40 + }; 41 + } 42 + }
+34
packages/file-upload/src/index.ts
··· 1 + // Types and schemas 2 + export type { 3 + TextExtractor, 4 + TextExtractionResult, 5 + FileValidationResult, 6 + FileUpload, 7 + FileUploadInput, 8 + SupportedMimeType, 9 + } from './types'; 10 + export { SupportedMimeTypes, FileUploadSchema } from './types'; 11 + 12 + // Extractor implementations 13 + export { PDFExtractor } from './extractors/pdf.extractor'; 14 + export { DOCXExtractor } from './extractors/docx.extractor'; 15 + export { PlainTextExtractor } from './extractors/plain-text.extractor'; 16 + 17 + // Extractor registry 18 + export { TextExtractorRegistry } from './extractor-registry'; 19 + 20 + // File validators 21 + export { 22 + validateFile, 23 + validateFileName, 24 + validateFileSize, 25 + validateMimeType, 26 + isSupportedMimeType, 27 + } from './validators'; 28 + 29 + // NestJS Module 30 + export { 31 + FileExtractionModule, 32 + TEXT_EXTRACTOR_REGISTRY, 33 + type FileExtractionModuleOptions, 34 + } from './file-extraction.module';
+83
packages/file-upload/src/types.ts
··· 1 + import { z } from 'zod'; 2 + 3 + /** 4 + * Supported MIME types for CV uploads 5 + */ 6 + export const SupportedMimeTypes = { 7 + PDF: 'application/pdf', 8 + DOCX: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 9 + TXT: 'text/plain', 10 + MD: 'text/markdown', 11 + } as const; 12 + 13 + export type SupportedMimeType = 14 + (typeof SupportedMimeTypes)[keyof typeof SupportedMimeTypes]; 15 + 16 + /** 17 + * Zod schema for validating uploaded files 18 + */ 19 + export const FileUploadSchema = z.object({ 20 + originalName: z 21 + .string() 22 + .min(1, 'File name is required') 23 + .max(255, 'File name is too long'), 24 + mimeType: z.enum([ 25 + SupportedMimeTypes.PDF, 26 + SupportedMimeTypes.DOCX, 27 + SupportedMimeTypes.TXT, 28 + SupportedMimeTypes.MD, 29 + ] as const), 30 + sizeBytes: z 31 + .number() 32 + .positive('File size must be positive') 33 + .max(10 * 1024 * 1024, 'File size must not exceed 10MB'), 34 + buffer: z.instanceof(Buffer), 35 + }); 36 + 37 + export type FileUpload = z.infer<typeof FileUploadSchema>; 38 + 39 + /** 40 + * Input for file validation (accepts any string for mimeType, validated internally) 41 + */ 42 + export interface FileUploadInput { 43 + originalName: string; 44 + mimeType: string; 45 + sizeBytes: number; 46 + buffer: Buffer; 47 + } 48 + 49 + /** 50 + * Result of text extraction from a file 51 + */ 52 + export type TextExtractionResult = 53 + | { success: true; text: string } 54 + | { success: false; error: string }; 55 + 56 + /** 57 + * File validation result 58 + */ 59 + export type FileValidationResult = 60 + | { valid: true } 61 + | { valid: false; error: string }; 62 + 63 + /** 64 + * Text extractor interface (Strategy pattern) 65 + * Implement this interface to add support for additional file formats 66 + */ 67 + export interface TextExtractor { 68 + /** Unique name for this extractor */ 69 + readonly name: string; 70 + 71 + /** MIME types this extractor can handle */ 72 + readonly supportedMimeTypes: readonly string[]; 73 + 74 + /** 75 + * Check if this extractor can handle the given MIME type 76 + */ 77 + canHandle(mimeType: string): boolean; 78 + 79 + /** 80 + * Extract text content from the file buffer 81 + */ 82 + extract(buffer: Buffer): Promise<TextExtractionResult>; 83 + }
+107
packages/file-upload/src/validators.ts
··· 1 + import type { FileUploadInput, FileValidationResult, SupportedMimeType } from './types'; 2 + import { SupportedMimeTypes, FileUploadSchema } from './types'; 3 + 4 + const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB 5 + 6 + /** 7 + * Validate that MIME type is supported 8 + */ 9 + export const isSupportedMimeType = (mimeType: string): boolean => 10 + Object.values(SupportedMimeTypes).includes(mimeType as SupportedMimeType); 11 + 12 + /** 13 + * Validate file size 14 + */ 15 + export const validateFileSize = (sizeBytes: number): FileValidationResult => { 16 + if (sizeBytes <= 0) { 17 + return { valid: false, error: 'File size must be positive' }; 18 + } 19 + 20 + if (sizeBytes > MAX_FILE_SIZE) { 21 + return { 22 + valid: false, 23 + error: `File exceeds maximum size of ${MAX_FILE_SIZE / (1024 * 1024)}MB`, 24 + }; 25 + } 26 + 27 + return { valid: true }; 28 + }; 29 + 30 + /** 31 + * Validate MIME type 32 + */ 33 + export const validateMimeType = (mimeType: string): FileValidationResult => { 34 + if (!mimeType || mimeType.trim().length === 0) { 35 + return { valid: false, error: 'MIME type is required' }; 36 + } 37 + 38 + if (!isSupportedMimeType(mimeType)) { 39 + return { 40 + valid: false, 41 + error: `Unsupported file type. Supported types: ${Object.values(SupportedMimeTypes).join(', ')}`, 42 + }; 43 + } 44 + 45 + return { valid: true }; 46 + }; 47 + 48 + /** 49 + * Validate file name 50 + */ 51 + export const validateFileName = (fileName: string): FileValidationResult => { 52 + if (!fileName || fileName.trim().length === 0) { 53 + return { valid: false, error: 'File name is required' }; 54 + } 55 + 56 + if (fileName.length > 255) { 57 + return { valid: false, error: 'File name is too long (max 255 characters)' }; 58 + } 59 + 60 + return { valid: true }; 61 + }; 62 + 63 + /** 64 + * Comprehensive file validation 65 + */ 66 + export const validateFile = (file: Partial<FileUploadInput>): FileValidationResult => { 67 + if (!file.originalName) { 68 + return { valid: false, error: 'File name is required' }; 69 + } 70 + 71 + const nameValidation = validateFileName(file.originalName); 72 + if (!nameValidation.valid) { 73 + return nameValidation; 74 + } 75 + 76 + if (!file.mimeType) { 77 + return { valid: false, error: 'MIME type is required' }; 78 + } 79 + 80 + const mimeValidation = validateMimeType(file.mimeType); 81 + if (!mimeValidation.valid) { 82 + return mimeValidation; 83 + } 84 + 85 + if (file.sizeBytes === undefined) { 86 + return { valid: false, error: 'File size is required' }; 87 + } 88 + 89 + const sizeValidation = validateFileSize(file.sizeBytes); 90 + if (!sizeValidation.valid) { 91 + return sizeValidation; 92 + } 93 + 94 + if (!file.buffer) { 95 + return { valid: false, error: 'File buffer is required' }; 96 + } 97 + 98 + try { 99 + FileUploadSchema.parse(file); 100 + return { valid: true }; 101 + } catch (error) { 102 + return { 103 + valid: false, 104 + error: error instanceof Error ? error.message : 'Validation failed', 105 + }; 106 + } 107 + };
+8
packages/file-upload/tsconfig.json
··· 1 + { 2 + "extends": "@cv/tsconfig/tsconfig.library.json", 3 + "compilerOptions": { 4 + "outDir": "./dist", 5 + "rootDir": "./src" 6 + }, 7 + "include": ["src/**/*"] 8 + }
+1 -1
packages/system/src/base/named-entity.service.ts
··· 1 - import type { PrismaService } from "../database/prisma.service"; 1 + import { PrismaService } from "../database/prisma.service"; 2 2 import type { EntityService } from "./entity-service.interface"; 3 3 import type { BaseMapper } from "./mapper.interface"; 4 4 import type { NamedEntity } from "./named-entity";
+1 -1
packages/system/src/base/pagination.service.ts
··· 1 1 import { Injectable } from "@nestjs/common"; 2 2 import type { BaseEntity } from "./base.entity"; 3 - import type { CursorService } from "./cursor.service"; 3 + import { CursorService } from "./cursor.service"; 4 4 import { 5 5 PageInfo, 6 6 type PaginationOptions,
+4 -4
packages/system/src/base/pagination.types.ts
··· 1 - import { ArgsType, Field, InputType, Int, ObjectType } from "@nestjs/graphql"; 1 + import { ArgsType, Field, Int, ObjectType } from "@nestjs/graphql"; 2 2 import { GraphQLString } from "graphql"; 3 3 4 4 @ObjectType() ··· 28 28 } 29 29 } 30 30 31 - @InputType() 31 + @ArgsType() 32 32 export abstract class BasePaginationArgs { 33 33 @Field(() => Int, { nullable: true }) 34 34 first?: number | null; ··· 46 46 @ArgsType() 47 47 export class PaginationArgs extends BasePaginationArgs {} 48 48 49 - @InputType() 49 + @ArgsType() 50 50 export abstract class SearchablePaginationArgs extends BasePaginationArgs { 51 51 @Field(() => GraphQLString, { nullable: true }) 52 52 searchTerm?: string | null; 53 53 } 54 54 55 - @InputType() 55 + @ArgsType() 56 56 export abstract class SortablePaginationArgs extends SearchablePaginationArgs { 57 57 @Field(() => GraphQLString, { nullable: true }) 58 58 sortBy?: string | null;
+1 -1
packages/system/src/database/prisma.service.ts
··· 3 3 type OnModuleDestroy, 4 4 type OnModuleInit, 5 5 } from "@nestjs/common"; 6 - import type { ConfigService } from "@nestjs/config"; 6 + import { ConfigService } from "@nestjs/config"; 7 7 import { PrismaPg } from "@prisma/adapter-pg"; 8 8 import { PrismaClient } from "@prisma/client"; 9 9 import { Pool } from "pg";
+9 -2
packages/system/src/mail/resend/resend.module.ts
··· 1 - import { Global, Module } from "@nestjs/common"; 1 + import { Global, Logger, Module } from "@nestjs/common"; 2 2 import { ConfigService } from "@nestjs/config"; 3 3 import { 4 4 MAIL_SERVICE_TOKEN, 5 5 type MailService, 6 6 } from "../mail.service.interface"; 7 + import { ConsoleMailService } from "../providers/console-mail.service"; 7 8 import { ResendMailService } from "./resend-mail.service"; 8 9 9 10 @Global() ··· 12 13 { 13 14 provide: MAIL_SERVICE_TOKEN, 14 15 useFactory: (configService: ConfigService): MailService => { 15 - const apiKey = configService.getOrThrow<string>("RESEND_API_KEY"); 16 + const apiKey = configService.get<string>("RESEND_API_KEY"); 17 + if (!apiKey) { 18 + new Logger("ResendModule").warn( 19 + "RESEND_API_KEY not set - using console mail service", 20 + ); 21 + return new ConsoleMailService(); 22 + } 16 23 return new ResendMailService(apiKey); 17 24 }, 18 25 inject: [ConfigService],
+1 -1
packages/system/src/mail/template/handlebars-template.service.ts
··· 2 2 import { join } from "node:path"; 3 3 import { Injectable } from "@nestjs/common"; 4 4 import Handlebars from "handlebars"; 5 - import type { TemplateRegistryService } from "./template-registry.service"; 5 + import { TemplateRegistryService } from "./template-registry.service"; 6 6 7 7 @Injectable() 8 8 export class HandlebarsTemplateService {
+3
packages/ui/src/components/Calendar.tsx
··· 101 101 ); 102 102 103 103 interface CalendarProps { 104 + id?: string; 104 105 value?: Date | null; 105 106 onChange: (date: Date | null) => void; 106 107 placeholder?: string; ··· 112 113 } 113 114 114 115 export const Calendar = ({ 116 + id, 115 117 value, 116 118 onChange, 117 119 placeholder = "Select date", ··· 282 284 <div className={cn("relative", className)}> 283 285 <div className="relative"> 284 286 <input 287 + id={id} 285 288 ref={inputRef} 286 289 type="text" 287 290 value={value ? formatDisplayDate(value, format) : ""}
+14 -3
packages/ui/src/components/SearchableSelect.tsx
··· 43 43 allowAddNew?: boolean; 44 44 onAddNew?: (label: string) => void; 45 45 addNewLabel?: string; 46 + // Pre-fill search value when no selection is made (for draft entities) 47 + defaultSearchValue?: string; 46 48 } 47 49 48 50 export const SearchableSelect = ({ ··· 62 64 allowAddNew = false, 63 65 onAddNew, 64 66 addNewLabel = "Add", 67 + defaultSearchValue, 65 68 }: SearchableSelectProps) => { 66 69 const [isOpen, setIsOpen] = useState(false); 67 - const [searchTerm, setSearchTerm] = useState(""); 70 + // Initialize search term with defaultSearchValue when no value is selected 71 + const [searchTerm, setSearchTerm] = useState( 72 + !value && defaultSearchValue ? defaultSearchValue : "", 73 + ); 68 74 const listRef = useRef<HTMLDivElement>(null); 69 75 70 76 const selectId = ··· 122 128 123 129 const handleFocus = () => { 124 130 setIsOpen(true); 125 - setSearchTerm(""); 131 + // When focusing, show defaultSearchValue if no value is selected 132 + setSearchTerm(!value && defaultSearchValue ? defaultSearchValue : ""); 126 133 }; 127 134 128 135 return ( ··· 136 143 <input 137 144 id={selectId} 138 145 type="text" 139 - value={isOpen ? searchTerm : selectedOption?.label || ""} 146 + value={ 147 + isOpen 148 + ? searchTerm 149 + : selectedOption?.label || defaultSearchValue || "" 150 + } 140 151 onChange={(e) => setSearchTerm(e.target.value)} 141 152 onFocus={handleFocus} 142 153 onBlur={() => {
+1
packages/ui/src/index.ts
··· 5 5 // Form Components 6 6 export { Button } from "./components/Button"; 7 7 export { Calendar } from "./components/Calendar"; 8 + export { Card } from "./components/Card"; 8 9 export { Checkbox } from "./components/Checkbox"; 9 10 export { ConfirmationModal } from "./components/ConfirmationModal"; 10 11 // Date Components