diff --git a/apps/api/src/modules/cv-parser/cv-parser.module.ts b/apps/api/src/modules/cv-parser/cv-parser.module.ts index ed320f6..a6cf61d 100644 --- a/apps/api/src/modules/cv-parser/cv-parser.module.ts +++ b/apps/api/src/modules/cv-parser/cv-parser.module.ts @@ -3,7 +3,12 @@ import { mkdirSync } from "node:fs"; import { join } from "node:path"; import { CVParserModule as CVParserCoreModule } from "@cv/ai-parser"; import { AIModule } from "@cv/ai-provider"; -import { AIResolutionModule, BaseModule, DatabaseModule } from "@cv/core"; +import { + AIResolutionModule, + BaseModule, + DatabaseModule, + UserModule, +} from "@cv/core"; import { FileExtractionModule } from "@cv/file-upload"; import { Module } from "@nestjs/common"; import { MulterModule } from "@nestjs/platform-express"; @@ -22,6 +27,7 @@ import { EnqueueParseCvResolver } from "./enqueue-parse-cv.resolver"; import { EntityResolverService } from "./entity-resolver.service"; import { FileUploadController } from "./file-upload.controller"; import { UploadFileResolver } from "./graphql/upload-file.resolver"; +import { ImportJobListener } from "./import-job.listener"; import { ImportOnboardingStep } from "./onboarding/import.step"; import { ParseCvJobResolver } from "./parse-cv-job.resolver"; @@ -38,6 +44,7 @@ import { ParseCvJobResolver } from "./parse-cv-job.resolver"; EmploymentModule, EducationModule, DataImportModule, + UserModule, AsyncJobGraphQLModule, MulterModule.register({ storage: diskStorage({ @@ -86,6 +93,7 @@ import { ParseCvJobResolver } from "./parse-cv-job.resolver"; UploadFileResolver, ImportOnboardingStep, FileImportSource, + ImportJobListener, ], controllers: [FileUploadController], exports: [CVParserService], diff --git a/apps/api/src/modules/cv-parser/file-upload.controller.ts b/apps/api/src/modules/cv-parser/file-upload.controller.ts index 39175b7..8d355f5 100644 --- a/apps/api/src/modules/cv-parser/file-upload.controller.ts +++ b/apps/api/src/modules/cv-parser/file-upload.controller.ts @@ -15,17 +15,13 @@ import { UseInterceptors, } from "@nestjs/common"; import { FileInterceptor } from "@nestjs/platform-express"; -import { ImportService } from "@/modules/data-import/import.service"; -import { FileImportSource } from "@/modules/data-import/sources/file-import-source"; +import { UserFileService } from "@/modules/data-import/user-file.service"; @Controller("api/cv-parser") export class FileUploadController { private readonly logger = new Logger(FileUploadController.name); - constructor( - private readonly importService: ImportService, - private readonly fileImportSource: FileImportSource, - ) {} + constructor(private readonly userFileService: UserFileService) {} @Post("upload") @UseGuards(JwtAuthGuard) @@ -61,18 +57,13 @@ export class FileUploadController { const fingerprint = createHash("sha256").update(buffer).digest("hex"); - const userFile = await this.importService.createImport( - req.user, - profileId, - this.fileImportSource, - { - fileName: file.originalname, - mimeType: file.mimetype, - sizeBytes: file.size, - fingerprint, - }, - { buffer, mimeType: file.mimetype }, - ); + const userFile = await this.userFileService.upload(req.user, profileId, { + fileName: file.originalname, + mimeType: file.mimetype, + fingerprint, + source: "cv-document", + buffer, + }); return { userFileId: userFile.id, diff --git a/apps/api/src/modules/cv-parser/graphql/upload-file.input.ts b/apps/api/src/modules/cv-parser/graphql/upload-file.input.ts index 78f607a..1a18fc9 100644 --- a/apps/api/src/modules/cv-parser/graphql/upload-file.input.ts +++ b/apps/api/src/modules/cv-parser/graphql/upload-file.input.ts @@ -16,4 +16,11 @@ export class UploadFileInput { @Field({ description: "Base64-encoded file content" }) content!: string; + + @Field(() => String, { + nullable: true, + description: + "Intent tag carried through to UserFile.source and the upload event. Defaults to `cv-document` (parser pipeline). Use `profile-image` for avatar uploads.", + }) + source?: string; } diff --git a/apps/api/src/modules/cv-parser/graphql/upload-file.resolver.ts b/apps/api/src/modules/cv-parser/graphql/upload-file.resolver.ts index a4b8752..dc32e9c 100644 --- a/apps/api/src/modules/cv-parser/graphql/upload-file.resolver.ts +++ b/apps/api/src/modules/cv-parser/graphql/upload-file.resolver.ts @@ -1,23 +1,27 @@ import { createHash } from "node:crypto"; import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth"; import { User as DomainUser, ProfileService } from "@cv/core"; -import { validateFile } from "@cv/file-upload"; +import { + isSupportedImageMimeType, + validateFile, + validateImage, +} from "@cv/file-upload"; import { BadRequestException, UseGuards } from "@nestjs/common"; import { Args, Mutation, Resolver } from "@nestjs/graphql"; import { CurrentUser } from "@/modules/current-user/current-user.decorator"; import { UserFileType } from "@/modules/data-import/graphql/user-file.type"; -import { ImportService } from "@/modules/data-import/import.service"; -import { FileImportSource } from "@/modules/data-import/sources/file-import-source"; +import { UserFileService } from "@/modules/data-import/user-file.service"; import { UploadFileInput } from "./upload-file.input"; const MAX_DECODED_SIZE = 10 * 1024 * 1024; // 10MB +const DEFAULT_SOURCE = "cv-document"; + @Resolver() @UseGuards(JwtAuthGuard, VerifiedScopeGuard) export class UploadFileResolver { constructor( - private readonly importService: ImportService, - private readonly fileImportSource: FileImportSource, + private readonly userFileService: UserFileService, private readonly profileService: ProfileService, ) {} @@ -34,7 +38,10 @@ export class UploadFileResolver { ); } - const validation = validateFile({ + const validate = isSupportedImageMimeType(input.mimeType) + ? validateImage + : validateFile; + const validation = validate({ buffer, mimeType: input.mimeType, originalName: input.fileName, @@ -47,7 +54,7 @@ export class UploadFileResolver { const fingerprint = createHash("sha256").update(buffer).digest("hex"); - const duplicate = await this.importService.findDuplicateForUser( + const duplicate = await this.userFileService.findDuplicateForUser( user.id, fingerprint, ); @@ -63,18 +70,13 @@ export class UploadFileResolver { ) : await this.profileService.getOrCreateDefaultProfile(user.id); - const userFile = await this.importService.createImport( - user, - profile.id, - this.fileImportSource, - { - fileName: input.fileName, - mimeType: input.mimeType, - sizeBytes: buffer.byteLength, - fingerprint, - }, - { buffer, mimeType: input.mimeType }, - ); + const userFile = await this.userFileService.upload(user, profile.id, { + fileName: input.fileName, + mimeType: input.mimeType, + fingerprint, + source: input.source ?? DEFAULT_SOURCE, + buffer, + }); return UserFileType.fromDomain(userFile); } diff --git a/apps/api/src/modules/data-import/listeners/import-job.listener.ts b/apps/api/src/modules/cv-parser/import-job.listener.ts similarity index 85% rename from apps/api/src/modules/data-import/listeners/import-job.listener.ts rename to apps/api/src/modules/cv-parser/import-job.listener.ts index 47aeed5..7aeb1e6 100644 --- a/apps/api/src/modules/data-import/listeners/import-job.listener.ts +++ b/apps/api/src/modules/cv-parser/import-job.listener.ts @@ -1,13 +1,22 @@ import { ParsedCVData } from "@cv/ai-parser"; -import { ClockService, PrismaService, ProfileService } from "@cv/core"; -import { Injectable, Logger } from "@nestjs/common"; +import { + ClockService, + PrismaService, + ProfileService, + UserService, +} from "@cv/core"; +import { FILE_STORAGE, type FileStorage } from "@cv/file-storage"; +import { Inject, Injectable, Logger } from "@nestjs/common"; import { OnEvent } from "@nestjs/event-emitter"; +import { UserFileUploadedEvent } from "@/modules/data-import/events/user-file-uploaded.event"; +import { FileImportSource } from "@/modules/data-import/sources/file-import-source"; import { EntityResolverService, type ResolvedEducation, type ResolvedJobExperience, -} from "@/modules/cv-parser/entity-resolver.service"; -import { UserFileCreatedEvent } from "../events/user-file-created.event"; +} from "./entity-resolver.service"; + +const CV_DOCUMENT_SOURCE = "cv-document"; interface ResolvedResult { personalInfo?: { @@ -32,15 +41,22 @@ export class ImportJobListener { private readonly prisma: PrismaService, private readonly entityResolver: EntityResolverService, private readonly profileService: ProfileService, + private readonly userService: UserService, + private readonly fileImportSource: FileImportSource, private readonly clock: ClockService, + @Inject(FILE_STORAGE) private readonly storage: FileStorage, ) {} - @OnEvent(UserFileCreatedEvent.eventName, { async: true }) - async handleUserFileCreated(event: UserFileCreatedEvent): Promise { + @OnEvent(UserFileUploadedEvent.eventName, { async: true }) + async handleUserFileUploaded(event: UserFileUploadedEvent): Promise { + if (event.payload.source !== CV_DOCUMENT_SOURCE) { + return; + } + const job = await this.prisma.importJob.create({ data: { userFileId: event.payload.userFileId, - source: event.payload.source.name, + source: this.fileImportSource.name, status: "pending", statusMessage: "Queued", }, @@ -51,10 +67,10 @@ export class ImportJobListener { private async processJob( jobId: string, - event: UserFileCreatedEvent, + event: UserFileUploadedEvent, ): Promise { - const { userFileId, user, source, params } = event.payload; - const tag = `job=${jobId} source=${source.name}`; + const { userFileId, userId, mimeType, storageKey } = event.payload; + const tag = `job=${jobId} source=${this.fileImportSource.name}`; try { await this.startJob(jobId, userFileId); @@ -64,12 +80,19 @@ export class ImportJobListener { select: { profileId: true }, }); + const user = await this.userService.findByIdOrFail(userId); + const buffer = await this.storage.read(storageKey); + const onStatus = async (message: string) => { this.logger.log(`${tag} ${message}`); await this.updateStatusMessage(jobId, userFileId, message); }; - const parsed: ParsedCVData = await source.execute(user, params, onStatus); + const parsed: ParsedCVData = await this.fileImportSource.execute( + user, + { buffer, mimeType }, + onStatus, + ); this.logger.log( `${tag} Parsed: ${parsed.jobExperiences.length} job(s), ` + diff --git a/apps/api/src/modules/data-import/data-import.module.ts b/apps/api/src/modules/data-import/data-import.module.ts index 4375eb3..e5ea0c7 100644 --- a/apps/api/src/modules/data-import/data-import.module.ts +++ b/apps/api/src/modules/data-import/data-import.module.ts @@ -1,27 +1,27 @@ import { AuthorizationModule, BaseModule, DatabaseModule } from "@cv/core"; import { Module } from "@nestjs/common"; -import { EntityResolverService } from "@/modules/cv-parser/entity-resolver.service"; import { ProfileModule } from "@/modules/profile/profile.module"; +import { ProfileAvatarFieldResolver } from "./graphql/profile-avatar-field.resolver"; import { UserFileResolver } from "./graphql/user-file.resolver"; -import { ImportService } from "./import.service"; import { ImportJobMapper } from "./import-job.mapper"; import { ImportJobPolicy } from "./import-job.policy"; -import { ImportJobListener } from "./listeners/import-job.listener"; +import { ImageConversionListener } from "./listeners/image-conversion.listener"; import { UserFileMapper } from "./user-file.mapper"; import { UserFilePolicy } from "./user-file.policy"; +import { UserFileService } from "./user-file.service"; @Module({ imports: [DatabaseModule, AuthorizationModule, BaseModule, ProfileModule], providers: [ - ImportService, - EntityResolverService, + UserFileService, UserFileMapper, UserFilePolicy, ImportJobMapper, ImportJobPolicy, UserFileResolver, - ImportJobListener, + ProfileAvatarFieldResolver, + ImageConversionListener, ], - exports: [ImportService, UserFileMapper, ImportJobMapper], + exports: [UserFileService, UserFileMapper, ImportJobMapper], }) export class DataImportModule {} diff --git a/apps/api/src/modules/data-import/events/user-file-created.event.ts b/apps/api/src/modules/data-import/events/user-file-created.event.ts deleted file mode 100644 index 1123986..0000000 --- a/apps/api/src/modules/data-import/events/user-file-created.event.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { DomainEvent, User } from "@cv/core"; -import { DataImportSource } from "../data-import-source.interface"; - -interface UserFileCreatedPayload { - userFileId: string; - user: User; - source: DataImportSource; - params: Record; -} - -export class UserFileCreatedEvent extends DomainEvent {} diff --git a/apps/api/src/modules/data-import/events/user-file-uploaded.event.ts b/apps/api/src/modules/data-import/events/user-file-uploaded.event.ts new file mode 100644 index 0000000..b45fd98 --- /dev/null +++ b/apps/api/src/modules/data-import/events/user-file-uploaded.event.ts @@ -0,0 +1,12 @@ +import { DomainEvent } from "@cv/core"; + +interface UserFileUploadedPayload { + userFileId: string; + profileId: string; + userId: string; + source: string; + mimeType: string; + storageKey: string; +} + +export class UserFileUploadedEvent extends DomainEvent {} diff --git a/apps/api/src/modules/data-import/graphql/profile-avatar-field.resolver.ts b/apps/api/src/modules/data-import/graphql/profile-avatar-field.resolver.ts new file mode 100644 index 0000000..1662051 --- /dev/null +++ b/apps/api/src/modules/data-import/graphql/profile-avatar-field.resolver.ts @@ -0,0 +1,28 @@ +import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth"; +import { UseGuards } from "@nestjs/common"; +import { Parent, ResolveField, Resolver } from "@nestjs/graphql"; +import { ProfileType } from "@/modules/profile/graphql/profile.type"; +import { UserFileService } from "../user-file.service"; +import { UserFileType } from "./user-file.type"; + +/** + * Resolves Profile.avatarFile from data-import's side so ProfileModule + * doesn't need to import DataImportModule (which already imports Profile; + * the reverse edge would be a cycle). Same trick as ProfileCVFieldResolver. + */ +@Resolver(() => ProfileType) +@UseGuards(JwtAuthGuard, VerifiedScopeGuard) +export class ProfileAvatarFieldResolver { + constructor(private readonly userFileService: UserFileService) {} + + @ResolveField(() => UserFileType, { nullable: true }) + async avatarFile( + @Parent() profile: ProfileType, + ): Promise { + if (!profile.avatarFileId) { + return null; + } + const userFile = await this.userFileService.findById(profile.avatarFileId); + return userFile ? UserFileType.fromDomain(userFile) : null; + } +} diff --git a/apps/api/src/modules/data-import/graphql/user-file.resolver.ts b/apps/api/src/modules/data-import/graphql/user-file.resolver.ts index 2c3a169..8b79cb9 100644 --- a/apps/api/src/modules/data-import/graphql/user-file.resolver.ts +++ b/apps/api/src/modules/data-import/graphql/user-file.resolver.ts @@ -3,15 +3,15 @@ import { AuthorizationService, User as DomainUser } from "@cv/core"; import { UseGuards } from "@nestjs/common"; import { Args, Mutation, Query, Resolver } from "@nestjs/graphql"; import { CurrentUser } from "@/modules/current-user/current-user.decorator"; -import { ImportService } from "../import.service"; import { UserFile as UserFileEntity } from "../user-file.entity"; +import { UserFileService } from "../user-file.service"; import { UserFileType } from "./user-file.type"; @Resolver(() => UserFileType) @UseGuards(JwtAuthGuard, VerifiedScopeGuard) export class UserFileResolver { constructor( - private readonly importService: ImportService, + private readonly userFileService: UserFileService, private readonly authorizationService: AuthorizationService, ) {} @@ -20,7 +20,7 @@ export class UserFileResolver { @CurrentUser() user: DomainUser, @Args("id") id: string, ): Promise { - const file = await this.importService.findUserFileById(id); + const file = await this.userFileService.findById(id); if (!file) return null; await this.authorizationService.canView(user, file, UserFileEntity); @@ -30,7 +30,7 @@ export class UserFileResolver { @Query(() => [UserFileType]) async myUserFiles(@CurrentUser() user: DomainUser): Promise { - const files = await this.importService.findUserFilesForUser(user.id); + const files = await this.userFileService.findManyForUser(user.id); return files.map((f) => UserFileType.fromDomain(f)); } @@ -39,11 +39,11 @@ export class UserFileResolver { @CurrentUser() user: DomainUser, @Args("id") id: string, ): Promise { - const file = await this.importService.findUserFileById(id); + const file = await this.userFileService.findById(id); if (!file) return false; await this.authorizationService.canDelete(user, file, UserFileEntity); - await this.importService.deleteUserFile(id); + await this.userFileService.deleteById(id); return true; } diff --git a/apps/api/src/modules/data-import/listeners/image-conversion.listener.ts b/apps/api/src/modules/data-import/listeners/image-conversion.listener.ts new file mode 100644 index 0000000..7467bad --- /dev/null +++ b/apps/api/src/modules/data-import/listeners/image-conversion.listener.ts @@ -0,0 +1,50 @@ +import { + ConvertImageMessage, + InjectMessageBus, + type MessageBus, + PrismaService, +} from "@cv/core"; +import { Injectable, Logger } from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; +import { UserFileUploadedEvent } from "../events/user-file-uploaded.event"; + +const HEIC_MIMES = new Set(["image/heic", "image/heif"]); + +@Injectable() +export class ImageConversionListener { + private readonly logger = new Logger(ImageConversionListener.name); + + constructor( + private readonly prisma: PrismaService, + @InjectMessageBus() private readonly messageBus: MessageBus, + ) {} + + @OnEvent(UserFileUploadedEvent.eventName, { async: true }) + async handle(event: UserFileUploadedEvent): Promise { + const { userFileId, mimeType, storageKey } = event.payload; + if (!HEIC_MIMES.has(mimeType)) { + return; + } + + this.logger.log( + `Queueing HEIC conversion for userFile ${userFileId} (${mimeType})`, + ); + + await this.prisma.userFile.update({ + where: { id: userFileId }, + data: { + status: "converting", + statusMessage: "Converting from HEIC to JPEG", + }, + }); + + await this.messageBus.dispatch( + ConvertImageMessage.create({ + userFileId, + sourceMime: mimeType, + targetMime: "image/jpeg", + storageKey, + }), + ); + } +} diff --git a/apps/api/src/modules/data-import/user-file.entity.ts b/apps/api/src/modules/data-import/user-file.entity.ts index 859d0f1..30911b5 100644 --- a/apps/api/src/modules/data-import/user-file.entity.ts +++ b/apps/api/src/modules/data-import/user-file.entity.ts @@ -9,6 +9,7 @@ export class UserFile extends BaseEntity { public readonly mimeType: string, public readonly sizeBytes: number, public readonly source: string, + public readonly storageKey: string | null, public readonly status: string, public readonly statusMessage: string, public readonly resultJson: string | null, diff --git a/apps/api/src/modules/data-import/user-file.mapper.ts b/apps/api/src/modules/data-import/user-file.mapper.ts index f68f96c..6bb4ddd 100644 --- a/apps/api/src/modules/data-import/user-file.mapper.ts +++ b/apps/api/src/modules/data-import/user-file.mapper.ts @@ -22,6 +22,7 @@ export class UserFileMapper implements BaseMapper { prisma.mimeType, prisma.sizeBytes, prisma.source, + prisma.storageKey, prisma.status, prisma.statusMessage, prisma.resultJson, diff --git a/apps/api/src/modules/data-import/import.service.ts b/apps/api/src/modules/data-import/user-file.service.ts similarity index 54% rename from apps/api/src/modules/data-import/import.service.ts rename to apps/api/src/modules/data-import/user-file.service.ts index 920b216..0a5d483 100644 --- a/apps/api/src/modules/data-import/import.service.ts +++ b/apps/api/src/modules/data-import/user-file.service.ts @@ -1,21 +1,23 @@ +import { randomUUID } from "node:crypto"; import { EventService, PrismaService, User } from "@cv/core"; -import { Injectable } from "@nestjs/common"; -import { DataImportSource } from "./data-import-source.interface"; -import { UserFileCreatedEvent } from "./events/user-file-created.event"; +import { FILE_STORAGE, type FileStorage } from "@cv/file-storage"; +import { Inject, Injectable } from "@nestjs/common"; +import { UserFileUploadedEvent } from "./events/user-file-uploaded.event"; import { UserFile } from "./user-file.entity"; import { UserFileMapper } from "./user-file.mapper"; +const storageKeyFor = (userFileId: string): string => + `user-files/${userFileId}`; + @Injectable() -export class ImportService { +export class UserFileService { constructor( private readonly prisma: PrismaService, private readonly userFileMapper: UserFileMapper, private readonly eventService: EventService, + @Inject(FILE_STORAGE) private readonly storage: FileStorage, ) {} - /** - * Find an existing completed UserFile with the same fingerprint for this user. - */ async findDuplicateForUser( userId: string, fingerprint: string, @@ -31,54 +33,61 @@ export class ImportService { return this.userFileMapper.toDomain(record); } - /** - * Create a UserFile and emit an event for async processing. - */ - async createImport( + async upload( user: User, profileId: string, - source: DataImportSource, file: { fileName: string; mimeType: string; - sizeBytes: number; - fingerprint?: string; + fingerprint: string; + source: string; + buffer: Buffer; }, - params: Record, ): Promise { + const id = randomUUID(); + const storageKey = storageKeyFor(id); + const record = await this.prisma.userFile.create({ data: { + id, profile: { connect: { id: profileId } }, - fingerprint: file.fingerprint ?? null, + fingerprint: file.fingerprint, fileName: file.fileName, mimeType: file.mimeType, - sizeBytes: file.sizeBytes, - source: source.name, - status: "pending", - statusMessage: "Queued for processing", + sizeBytes: file.buffer.length, + source: file.source, + status: "uploaded", + statusMessage: "Stored", + storageKey, }, }); + await this.storage.write(storageKey, file.buffer); + this.eventService.emit( - new UserFileCreatedEvent({ userFileId: record.id, user, source, params }), + new UserFileUploadedEvent({ + userFileId: record.id, + profileId, + userId: user.id, + source: file.source, + mimeType: file.mimeType, + storageKey, + }), ); - return this.userFileMapper.toDomain(record) as UserFile; + return this.userFileMapper.toDomain(record); } - /** - * Delete a UserFile and its cascading ImportJobs. - */ - async deleteUserFile(id: string): Promise { + async deleteById(id: string): Promise { await this.prisma.userFile.delete({ where: { id } }); } - async findUserFileById(id: string): Promise { + async findById(id: string): Promise { const record = await this.prisma.userFile.findUnique({ where: { id } }); return this.userFileMapper.toDomain(record); } - async findUserFilesForUser(userId: string): Promise { + async findManyForUser(userId: string): Promise { const records = await this.prisma.userFile.findMany({ where: { profile: { userId } }, orderBy: { createdAt: "desc" }, diff --git a/apps/api/src/modules/profile/graphql/profile.type.ts b/apps/api/src/modules/profile/graphql/profile.type.ts index 35132f3..cdec2b0 100644 --- a/apps/api/src/modules/profile/graphql/profile.type.ts +++ b/apps/api/src/modules/profile/graphql/profile.type.ts @@ -10,6 +10,7 @@ interface DecryptedProfile { address: string | null; postalCode: string | null; locationId: string | null; + avatarFileId: string | null; website: string | null; linkedInUrl: string | null; summary: string | null; @@ -42,6 +43,9 @@ export class ProfileType { locationId: string | null; + @Field(() => ID, { nullable: true }) + avatarFileId: string | null; + @Field(() => String, { nullable: true }) website: string | null; @@ -66,6 +70,7 @@ export class ProfileType { this.address = profile.address; this.postalCode = profile.postalCode; this.locationId = profile.locationId; + this.avatarFileId = profile.avatarFileId; this.website = profile.website; this.linkedInUrl = profile.linkedInUrl; this.summary = profile.summary; @@ -101,6 +106,9 @@ export class CreateProfileInput { @Field(() => String, { nullable: true }) locationId?: string; + @Field(() => ID, { nullable: true }) + avatarFileId?: string | null; + @Field(() => String, { nullable: true }) website?: string; @@ -134,6 +142,9 @@ export class UpdateProfileInput { @Field(() => String, { nullable: true }) locationId?: string; + @Field(() => ID, { nullable: true }) + avatarFileId?: string | null; + @Field(() => String, { nullable: true }) website?: string; diff --git a/packages/core/prisma/migrations/20260530120000_add_profile_avatar_file_id/migration.sql b/packages/core/prisma/migrations/20260530120000_add_profile_avatar_file_id/migration.sql new file mode 100644 index 0000000..1a46f3d --- /dev/null +++ b/packages/core/prisma/migrations/20260530120000_add_profile_avatar_file_id/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "profiles" ADD COLUMN "avatarFileId" TEXT; + +-- AddForeignKey +ALTER TABLE "profiles" ADD CONSTRAINT "profiles_avatarFileId_fkey" FOREIGN KEY ("avatarFileId") REFERENCES "user_files"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- CreateIndex +CREATE INDEX "profiles_avatarFileId_idx" ON "profiles"("avatarFileId"); diff --git a/packages/core/prisma/migrations/20260530121500_add_user_file_storage_key/migration.sql b/packages/core/prisma/migrations/20260530121500_add_user_file_storage_key/migration.sql new file mode 100644 index 0000000..1ed0856 --- /dev/null +++ b/packages/core/prisma/migrations/20260530121500_add_user_file_storage_key/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "user_files" ADD COLUMN "storageKey" TEXT; diff --git a/packages/core/prisma/models/data-import.prisma b/packages/core/prisma/models/data-import.prisma index 135dd87..1bcad7e 100644 --- a/packages/core/prisma/models/data-import.prisma +++ b/packages/core/prisma/models/data-import.prisma @@ -6,6 +6,7 @@ model UserFile { mimeType String sizeBytes Int source String + storageKey String? status String @default("pending") statusMessage String @default("") resultJson String? @@ -13,8 +14,9 @@ model UserFile { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade) - importJobs ImportJob[] + profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade) + importJobs ImportJob[] + profilesAsAvatar Profile[] @relation("ProfileAvatar") @@map("user_files") } diff --git a/packages/core/prisma/models/profile.prisma b/packages/core/prisma/models/profile.prisma index 74b2c58..1216174 100644 --- a/packages/core/prisma/models/profile.prisma +++ b/packages/core/prisma/models/profile.prisma @@ -8,6 +8,7 @@ model Profile { encryptedAddress String? encryptedPostalCode String? locationId String? + avatarFileId String? website String? linkedInUrl String? summary String? @@ -16,6 +17,7 @@ model Profile { user User @relation(fields: [userId], references: [id], onDelete: Cascade) location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull) + avatarFile UserFile? @relation("ProfileAvatar", fields: [avatarFileId], references: [id], onDelete: SetNull) educations Education[] jobExperiences UserJobExperience[] skills ProfileSkill[] diff --git a/packages/core/src/modules/ai-resolution/ai-call-log-persistence.service.ts b/packages/core/src/modules/ai-resolution/ai-call-log-persistence.service.ts index 645d126..e2608e7 100644 --- a/packages/core/src/modules/ai-resolution/ai-call-log-persistence.service.ts +++ b/packages/core/src/modules/ai-resolution/ai-call-log-persistence.service.ts @@ -45,9 +45,7 @@ export class AiCallLogPersistenceService { return this.prisma.aiCallLog.findMany({ where: { ...(options.status ? { status: options.status } : {}), - ...(options.providerName - ? { providerName: options.providerName } - : {}), + ...(options.providerName ? { providerName: options.providerName } : {}), }, orderBy: { createdAt: "desc" }, take: options.limit ?? 100, diff --git a/packages/core/src/modules/ai-resolution/ai-provider-resolver.service.ts b/packages/core/src/modules/ai-resolution/ai-provider-resolver.service.ts index e78c851..c2d378c 100644 --- a/packages/core/src/modules/ai-resolution/ai-provider-resolver.service.ts +++ b/packages/core/src/modules/ai-resolution/ai-provider-resolver.service.ts @@ -29,10 +29,11 @@ const DEFAULT_BASE_URLS: Record = { openai: "https://api.openai.com", }; -const PROVIDER_FACTORIES: Record AIProvider> = { - anthropic: (cfg) => new AnthropicProvider(cfg), - openai: (cfg) => new OpenAIProvider(cfg), -}; +const PROVIDER_FACTORIES: Record AIProvider> = + { + anthropic: (cfg) => new AnthropicProvider(cfg), + openai: (cfg) => new OpenAIProvider(cfg), + }; /** * Public surface of `AIProviderResolverService`. Consumers depend on this so diff --git a/packages/core/src/modules/ai-resolution/ai-resolution.module.ts b/packages/core/src/modules/ai-resolution/ai-resolution.module.ts index d3c7484..8d7fa6c 100644 --- a/packages/core/src/modules/ai-resolution/ai-resolution.module.ts +++ b/packages/core/src/modules/ai-resolution/ai-resolution.module.ts @@ -3,8 +3,8 @@ import { Module } from "@nestjs/common"; import { BaseModule } from "../../shared/base.module"; import { UserModule } from "../auth/user/user.module"; import { DatabaseModule } from "../database/database.module"; -import { AiCallLogPersistenceService } from "./ai-call-log-persistence.service"; import { AiCallLogService } from "./ai-call-log.service"; +import { AiCallLogPersistenceService } from "./ai-call-log-persistence.service"; import { AIProviderResolverService } from "./ai-provider-resolver.service"; import { UserAiSettingsReader } from "./user-ai-settings.reader"; diff --git a/packages/core/src/modules/ai-resolution/index.ts b/packages/core/src/modules/ai-resolution/index.ts index f262fb6..ff3dfc8 100644 --- a/packages/core/src/modules/ai-resolution/index.ts +++ b/packages/core/src/modules/ai-resolution/index.ts @@ -1,10 +1,8 @@ -export { - AiCallLogPersistenceService, -} from "./ai-call-log-persistence.service"; export { type AiCallLogEntry, AiCallLogService, } from "./ai-call-log.service"; +export { AiCallLogPersistenceService } from "./ai-call-log-persistence.service"; export { type AIProviderResolver, AIProviderResolverService, diff --git a/packages/core/src/modules/async-job/__tests__/async-job.service.spec.ts b/packages/core/src/modules/async-job/__tests__/async-job.service.spec.ts index 137d3d8..43b81af 100644 --- a/packages/core/src/modules/async-job/__tests__/async-job.service.spec.ts +++ b/packages/core/src/modules/async-job/__tests__/async-job.service.spec.ts @@ -1,6 +1,6 @@ import { AsyncJobKind } from "@prisma/client"; -import { describe, expect, it, vi } from "vitest"; import type { Mocked } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { Authorizer } from "../../auth/authorization/authorization.service"; import { CannotViewError } from "../../auth/errors/authorization.error"; import { EntityNotFoundError } from "../../auth/errors/not-found.util"; @@ -72,8 +72,8 @@ describe("AsyncJobService.findByIdForUser", () => { new CannotViewError("AsyncJobEntity"), ); - await expect( - service.findByIdForUser("job-123", otherUser), - ).rejects.toThrow(CannotViewError); + await expect(service.findByIdForUser("job-123", otherUser)).rejects.toThrow( + CannotViewError, + ); }); }); diff --git a/packages/core/src/modules/async-job/__tests__/async-job.store.spec.ts b/packages/core/src/modules/async-job/__tests__/async-job.store.spec.ts index 763107e..e375ed1 100644 --- a/packages/core/src/modules/async-job/__tests__/async-job.store.spec.ts +++ b/packages/core/src/modules/async-job/__tests__/async-job.store.spec.ts @@ -1,6 +1,6 @@ import { AsyncJobKind, Prisma } from "@prisma/client"; -import { describe, expect, it, vi } from "vitest"; import type { Mock } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { CursorService } from "../../../shared/cursor.service"; import { frozenClockAt } from "../../../shared/testing"; import type { PrismaService } from "../../database/prisma.service"; diff --git a/packages/core/src/modules/async-job/async-job.mapper.ts b/packages/core/src/modules/async-job/async-job.mapper.ts index 9dc1522..5967fff 100644 --- a/packages/core/src/modules/async-job/async-job.mapper.ts +++ b/packages/core/src/modules/async-job/async-job.mapper.ts @@ -6,7 +6,9 @@ import { AsyncJobEntity } from "./async-job.entity"; type PrismaAsyncJob = Prisma.AsyncJobGetPayload; @Injectable() -export class AsyncJobMapper implements BaseMapper { +export class AsyncJobMapper + implements BaseMapper +{ toDomain(prisma: null): null; toDomain(prisma: PrismaAsyncJob): AsyncJobEntity; toDomain(prisma: PrismaAsyncJob | null): AsyncJobEntity | null; diff --git a/packages/core/src/modules/async-job/async-job.module.ts b/packages/core/src/modules/async-job/async-job.module.ts index 61e7a03..b901125 100644 --- a/packages/core/src/modules/async-job/async-job.module.ts +++ b/packages/core/src/modules/async-job/async-job.module.ts @@ -1,8 +1,8 @@ import { Module } from "@nestjs/common"; import { AuthorizationModule } from "../auth/authorization/authorization.module"; -import { AsyncJobStoreModule } from "./async-job-store.module"; import { AsyncJobPolicy } from "./async-job.policy"; import { AsyncJobService } from "./async-job.service"; +import { AsyncJobStoreModule } from "./async-job-store.module"; /** * Full AsyncJob domain layer for consumers that need authz-wrapped access. diff --git a/packages/core/src/modules/async-job/async-job.service.ts b/packages/core/src/modules/async-job/async-job.service.ts index 214b90a..1ac9950 100644 --- a/packages/core/src/modules/async-job/async-job.service.ts +++ b/packages/core/src/modules/async-job/async-job.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from "@nestjs/common"; import { - type Authorizer, AuthorizationService, + type Authorizer, } from "../auth/authorization/authorization.service"; import { EntityNotFoundError } from "../auth/errors/not-found.util"; import { User } from "../auth/user/user.entity"; diff --git a/packages/core/src/modules/auth/providers/password/password-authentication.service.ts b/packages/core/src/modules/auth/providers/password/password-authentication.service.ts index 9e288a7..57ca61e 100644 --- a/packages/core/src/modules/auth/providers/password/password-authentication.service.ts +++ b/packages/core/src/modules/auth/providers/password/password-authentication.service.ts @@ -1,6 +1,8 @@ import { Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import * as bcrypt from "bcryptjs"; +import { ClockService, UuidFactoryService } from "../../../../shared"; +import { EventService } from "../../../events"; import { JwtConfigService } from "../../config/jwt.config"; import { CurrentPasswordIncorrectError, @@ -18,8 +20,6 @@ import { CredentialsService } from "../../user/credentials.service"; import { generateSecureToken } from "../../user/credentials-token.util"; import { User, UserRole } from "../../user/user.entity"; import { UserService } from "../../user/user.service"; -import { EventService } from "../../../events"; -import { ClockService, UuidFactoryService } from "../../../../shared"; import { PasswordResetRequestedEvent } from "./credentials/events/password-reset-requested.event"; import { RegistrationAttemptOnExistingEmailEvent } from "./credentials/events/registration-attempt-on-existing-email.event"; import { VerificationEmailRequestedEvent } from "./credentials/events/verification-email-requested.event"; diff --git a/packages/core/src/modules/auth/providers/password/password-provider.module.ts b/packages/core/src/modules/auth/providers/password/password-provider.module.ts index 2721f03..ec46623 100644 --- a/packages/core/src/modules/auth/providers/password/password-provider.module.ts +++ b/packages/core/src/modules/auth/providers/password/password-provider.module.ts @@ -6,10 +6,10 @@ import { } from "@cv/mail"; import { Module, type OnModuleInit } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; +import { BaseModule } from "../../../../shared"; import { AuthorizationModule } from "../../authorization/authorization.module"; import { TokenModule } from "../../token/token.module"; import { UserModule } from "../../user/user.module"; -import { BaseModule } from "../../../../shared"; import { EMAIL_CONFIG_TOKEN, EmailConfig } from "./credentials/email.config"; import { PasswordResetEmailListener } from "./credentials/listeners/password-reset-email.listener"; import { RegistrationAttemptEmailListener } from "./credentials/listeners/registration-attempt-email.listener"; diff --git a/packages/core/src/modules/auth/token/refresh-token.service.ts b/packages/core/src/modules/auth/token/refresh-token.service.ts index e95ccb5..51419d5 100644 --- a/packages/core/src/modules/auth/token/refresh-token.service.ts +++ b/packages/core/src/modules/auth/token/refresh-token.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@nestjs/common"; -import { PrismaService } from "../../database"; import { ClockService } from "../../../shared"; +import { PrismaService } from "../../database"; import { InvalidRefreshTokenError, notFound } from "../errors"; import { DeviceIdentificationService } from "../metadata/device-identification.service"; import { LocationService } from "../metadata/location.service"; diff --git a/packages/core/src/modules/auth/token/token.module.ts b/packages/core/src/modules/auth/token/token.module.ts index b474352..684a490 100644 --- a/packages/core/src/modules/auth/token/token.module.ts +++ b/packages/core/src/modules/auth/token/token.module.ts @@ -1,7 +1,7 @@ import { Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; -import { DatabaseModule } from "../../database"; import { BaseModule } from "../../../shared"; +import { DatabaseModule } from "../../database"; import { JwtConfigService } from "../config/jwt.config"; import { CookieService } from "../cookie/cookie.service"; import { DeviceIdentificationService } from "../metadata/device-identification.service"; diff --git a/packages/core/src/modules/auth/user/credentials.service.ts b/packages/core/src/modules/auth/user/credentials.service.ts index d5d916e..c1b131a 100644 --- a/packages/core/src/modules/auth/user/credentials.service.ts +++ b/packages/core/src/modules/auth/user/credentials.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@nestjs/common"; -import { PrismaService } from "../../database"; import { ClockService } from "../../../shared"; +import { PrismaService } from "../../database"; import { notFound } from "../errors"; import { EmailAlreadyVerifiedError, diff --git a/packages/core/src/modules/auth/user/user.module.ts b/packages/core/src/modules/auth/user/user.module.ts index 7dc0f8b..2a9d0a9 100644 --- a/packages/core/src/modules/auth/user/user.module.ts +++ b/packages/core/src/modules/auth/user/user.module.ts @@ -1,7 +1,7 @@ import { Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; -import { DatabaseModule } from "../../database"; import { BaseModule } from "../../../shared"; +import { DatabaseModule } from "../../database"; import { CredentialsMapper } from "./credentials.mapper"; import { CredentialsService } from "./credentials.service"; import { TokenEncryptionService } from "./token-encryption.service"; diff --git a/packages/core/src/modules/cv-template/__tests__/cv-data-assembler.service.spec.ts b/packages/core/src/modules/cv-template/__tests__/cv-data-assembler.service.spec.ts index cdaa6db..8abe927 100644 --- a/packages/core/src/modules/cv-template/__tests__/cv-data-assembler.service.spec.ts +++ b/packages/core/src/modules/cv-template/__tests__/cv-data-assembler.service.spec.ts @@ -9,6 +9,14 @@ const buildMockPrisma = () => ({ education: { findMany: vi.fn() }, profile: { findUniqueOrThrow: vi.fn() }, credentials: { findFirst: vi.fn() }, + userFile: { findUnique: vi.fn() }, +}); + +const buildMockStorage = () => ({ + read: vi.fn(), + write: vi.fn(), + delete: vi.fn(), + exists: vi.fn(), }); const buildMockProfileService = () => ({ @@ -110,6 +118,7 @@ describe("CVDataAssemblerService", () => { linkedInUrl: "https://www.linkedin.com/in/janedoe", summary: "Backend engineer.", email: "jane@example.com", + avatarDataUri: null, }); expect(result.experience).toHaveLength(2); expect(result.education).toHaveLength(1); @@ -236,3 +245,121 @@ describe("CVDataAssemblerService", () => { expect(result.cv.introduction).toBeNull(); }); }); + +describe("CVDataAssemblerService avatar inlining", () => { + const buildHappyPath = (overrides?: { avatarFileId?: string | null }) => { + const prisma = buildMockPrisma(); + const profileService = buildMockProfileService(); + const storage = buildMockStorage(); + prisma.cV.findUniqueOrThrow.mockResolvedValue({ + id: "cv-1", + profileId: "profile-1", + title: "x", + introduction: null, + }); + profileService.findByIdOrFail.mockResolvedValue({ + id: "profile-1", + name: "Jane", + fullName: "Jane Doe", + headline: null, + phone: null, + locationId: null, + website: null, + linkedInUrl: null, + summary: null, + avatarFileId: overrides?.avatarFileId ?? null, + }); + prisma.profile.findUniqueOrThrow.mockResolvedValue({ userId: "user-1" }); + prisma.credentials.findFirst.mockResolvedValue(null); + prisma.userJobExperience.findMany.mockResolvedValue([]); + prisma.education.findMany.mockResolvedValue([]); + return { prisma, profileService, storage }; + }; + + const buildService = ( + prisma: ReturnType, + profileService: ReturnType, + storage?: ReturnType, + ) => + new CVDataAssemblerService( + prisma as never, + profileService as never, + frozenClockAt("2026-05-14T12:00:00.000Z"), + new FrequencySkillPrioritiser(), + storage as never, + ); + + it("inlines the avatar bytes as a data URI when storage is bound and the file has a storageKey", async () => { + const { prisma, profileService, storage } = buildHappyPath({ + avatarFileId: "file-1", + }); + prisma.userFile.findUnique.mockResolvedValue({ + mimeType: "image/png", + storageKey: "user-files/file-1", + }); + storage.read.mockResolvedValue(Buffer.from([1, 2, 3])); + const service = buildService(prisma, profileService, storage); + + const result = await service.assemble("cv-1"); + + expect(storage.read).toHaveBeenCalledWith("user-files/file-1"); + expect(result.profile.avatarDataUri).toBe( + `data:image/png;base64,${Buffer.from([1, 2, 3]).toString("base64")}`, + ); + }); + + it("returns avatarDataUri null when the file row has no storageKey", async () => { + const { prisma, profileService, storage } = buildHappyPath({ + avatarFileId: "file-1", + }); + prisma.userFile.findUnique.mockResolvedValue({ + mimeType: "image/png", + storageKey: null, + }); + const service = buildService(prisma, profileService, storage); + + const result = await service.assemble("cv-1"); + + expect(storage.read).not.toHaveBeenCalled(); + expect(result.profile.avatarDataUri).toBeNull(); + }); + + it("returns avatarDataUri null when no avatarFileId is set", async () => { + const { prisma, profileService, storage } = buildHappyPath({ + avatarFileId: null, + }); + const service = buildService(prisma, profileService, storage); + + const result = await service.assemble("cv-1"); + + expect(storage.read).not.toHaveBeenCalled(); + expect(result.profile.avatarDataUri).toBeNull(); + }); + + it("returns avatarDataUri null when FileStorage is not bound (e.g. test envs)", async () => { + const { prisma, profileService } = buildHappyPath({ + avatarFileId: "file-1", + }); + const service = buildService(prisma, profileService, undefined); + + const result = await service.assemble("cv-1"); + + expect(result.profile.avatarDataUri).toBeNull(); + }); + + it("returns avatarDataUri null when storage.read throws (renders proceed without the avatar)", async () => { + const { prisma, profileService, storage } = buildHappyPath({ + avatarFileId: "file-1", + }); + prisma.userFile.findUnique.mockResolvedValue({ + mimeType: "image/png", + storageKey: "user-files/file-1", + }); + storage.read.mockRejectedValue(new Error("not found")); + const service = buildService(prisma, profileService, storage); + + const result = await service.assemble("cv-1"); + + expect(result.profile.avatarDataUri).toBeNull(); + }); +}); diff --git a/packages/core/src/modules/cv-template/cv-data-assembler.service.ts b/packages/core/src/modules/cv-template/cv-data-assembler.service.ts index c95e2c5..d3f5eac 100644 --- a/packages/core/src/modules/cv-template/cv-data-assembler.service.ts +++ b/packages/core/src/modules/cv-template/cv-data-assembler.service.ts @@ -1,5 +1,6 @@ import { CVRenderContext } from "@cv/cv-renderer"; -import { Inject, Injectable } from "@nestjs/common"; +import { FILE_STORAGE, type FileStorage } from "@cv/file-storage"; +import { Inject, Injectable, Logger, Optional } from "@nestjs/common"; import { ClockService } from "../../shared"; import { PrismaService } from "../database"; import { ProfileService } from "../profile/profile.service"; @@ -54,14 +55,41 @@ export interface AssembleContext { @Injectable() export class CVDataAssemblerService { + private readonly logger = new Logger(CVDataAssemblerService.name); + constructor( private readonly prisma: PrismaService, private readonly profileService: ProfileService, private readonly clock: ClockService, @Inject(SKILL_PRIORITISER) private readonly skillPrioritiser: SkillPrioritiser, + @Optional() @Inject(FILE_STORAGE) private readonly storage?: FileStorage, ) {} + private async loadAvatarDataUri( + avatarFileId: string | null, + ): Promise { + if (!(avatarFileId && this.storage)) { + return null; + } + try { + const userFile = await this.prisma.userFile.findUnique({ + where: { id: avatarFileId }, + select: { mimeType: true, storageKey: true }, + }); + if (!userFile?.storageKey) { + return null; + } + const bytes = await this.storage.read(userFile.storageKey); + return `data:${userFile.mimeType};base64,${bytes.toString("base64")}`; + } catch (err) { + this.logger.warn( + `Failed to load avatar ${avatarFileId}: ${err instanceof Error ? err.message : err}`, + ); + return null; + } + } + async assemble( cvId: string, context?: AssembleContext, @@ -121,6 +149,10 @@ export class CVDataAssemblerService { vacancySkills ? { vacancySkills } : {}, ); + const avatarDataUri = await this.loadAvatarDataUri( + profile.avatarFileId ?? null, + ); + return { cv: { title: cv.title, introduction: cv.introduction ?? null }, profile: { @@ -132,6 +164,7 @@ export class CVDataAssemblerService { linkedInUrl: profile.linkedInUrl ?? null, summary: profile.summary ?? null, email: credential?.email ?? null, + avatarDataUri, }, experience: experienceItems, education: educationItems, diff --git a/packages/core/src/modules/cv-template/seed/templates/modern-professional.css b/packages/core/src/modules/cv-template/seed/templates/modern-professional.css index 0840be5..0ae55b6 100644 --- a/packages/core/src/modules/cv-template/seed/templates/modern-professional.css +++ b/packages/core/src/modules/cv-template/seed/templates/modern-professional.css @@ -6,7 +6,9 @@ body { padding: 2rem; } .cv { max-width: 800px; margin: 0 auto; } -.header { border-bottom: 2px solid #2563eb; padding-bottom: 1rem; margin-bottom: 1.5rem; } +.header { display: flex; align-items: center; gap: 1.25rem; border-bottom: 2px solid #2563eb; padding-bottom: 1rem; margin-bottom: 1.5rem; } +.header-text { flex: 1; min-width: 0; } +.avatar { width: 96px; height: 96px; border-radius: 50%; object-fit: cover; flex-shrink: 0; } .header h1 { font-size: 2rem; font-weight: 700; margin-bottom: 0.25rem; } .headline { color: #2563eb; font-size: 1.1rem; margin-bottom: 0.5rem; } .contact-row { display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.85rem; color: #555; } diff --git a/packages/core/src/modules/cv-template/seed/templates/modern-professional.hbs b/packages/core/src/modules/cv-template/seed/templates/modern-professional.hbs index e45310b..fa8a7eb 100644 --- a/packages/core/src/modules/cv-template/seed/templates/modern-professional.hbs +++ b/packages/core/src/modules/cv-template/seed/templates/modern-professional.hbs @@ -1,13 +1,16 @@
-

{{profile.name}}

- {{#if profile.headline}}

{{profile.headline}}

{{/if}} -
- {{#if profile.email}}{{profile.email}}{{/if}} - {{#if profile.phone}}{{profile.phone}}{{/if}} - {{#if profile.city}}{{profile.city}}{{#if profile.country}}, {{profile.country}}{{/if}}{{/if}} - {{#if (safeUrl profile.website)}}{{profile.website}}{{/if}} - {{#if (safeUrl profile.linkedInUrl)}}LinkedIn{{/if}} + {{#if profile.avatarDataUri}}{{/if}} +
+

{{profile.name}}

+ {{#if profile.headline}}

{{profile.headline}}

{{/if}} +
+ {{#if profile.email}}{{profile.email}}{{/if}} + {{#if profile.phone}}{{profile.phone}}{{/if}} + {{#if profile.city}}{{profile.city}}{{#if profile.country}}, {{profile.country}}{{/if}}{{/if}} + {{#if (safeUrl profile.website)}}{{profile.website}}{{/if}} + {{#if (safeUrl profile.linkedInUrl)}}LinkedIn{{/if}} +
diff --git a/packages/core/src/modules/education/education.service.ts b/packages/core/src/modules/education/education.service.ts index 746c3e9..f14aae4 100644 --- a/packages/core/src/modules/education/education.service.ts +++ b/packages/core/src/modules/education/education.service.ts @@ -1,6 +1,10 @@ import { Injectable } from "@nestjs/common"; import { Prisma } from "@prisma/client"; -import { PaginationOptions, PaginationResult, PaginationService } from "../../shared"; +import { + PaginationOptions, + PaginationResult, + PaginationService, +} from "../../shared"; import { notFound } from "../auth"; import { PrismaService } from "../database"; import { Education } from "./education.entity"; diff --git a/packages/core/src/modules/job-experience/employment/user-job-experience.service.ts b/packages/core/src/modules/job-experience/employment/user-job-experience.service.ts index 3d69453..e76b821 100644 --- a/packages/core/src/modules/job-experience/employment/user-job-experience.service.ts +++ b/packages/core/src/modules/job-experience/employment/user-job-experience.service.ts @@ -1,6 +1,10 @@ import { Injectable } from "@nestjs/common"; import { Prisma } from "@prisma/client"; -import { PaginationOptions, PaginationResult, PaginationService } from "../../../shared"; +import { + PaginationOptions, + PaginationResult, + PaginationService, +} from "../../../shared"; import { notFound } from "../../auth"; import { PrismaService } from "../../database"; import { diff --git a/packages/core/src/modules/messenger/index.ts b/packages/core/src/modules/messenger/index.ts index 5ef8e8f..95d80d6 100644 --- a/packages/core/src/modules/messenger/index.ts +++ b/packages/core/src/modules/messenger/index.ts @@ -1,11 +1,12 @@ // Re-exports so apps don't need a direct dep on @riotbyte-com/project-q-*. export type { Envelope } from "@riotbyte-com/project-q-core"; -export type { MessageBus } from "./message-bus.interface"; export { HandlerTag, InjectMessageBus, } from "@riotbyte-com/project-q-nestjs"; export { NestProjectQLogger } from "./logger.provider"; +export type { MessageBus } from "./message-bus.interface"; +export { ConvertImageMessage } from "./messages/convert-image.message"; export { type ParseCVInput, ParseCVMessage, diff --git a/packages/core/src/modules/messenger/messages/convert-image.message.ts b/packages/core/src/modules/messenger/messages/convert-image.message.ts new file mode 100644 index 0000000..b99f567 --- /dev/null +++ b/packages/core/src/modules/messenger/messages/convert-image.message.ts @@ -0,0 +1,12 @@ +import { defineZodMessage } from "@riotbyte-com/project-q-core"; +import { z } from "zod/v4"; + +export const ConvertImageMessage = defineZodMessage( + "convert-image", + z.object({ + userFileId: z.string(), + sourceMime: z.string(), + targetMime: z.literal("image/jpeg"), + storageKey: z.string(), + }), +); diff --git a/packages/core/src/modules/messenger/messenger.module.ts b/packages/core/src/modules/messenger/messenger.module.ts index 1d227b9..be31e18 100644 --- a/packages/core/src/modules/messenger/messenger.module.ts +++ b/packages/core/src/modules/messenger/messenger.module.ts @@ -33,6 +33,7 @@ export class ProjectQMessagingModule { routing: new Map([ ["render-pdf", "async"], ["parse-cv", "async"], + ["convert-image", "async"], ]), }), ], diff --git a/packages/core/src/modules/profile/__tests__/profile.service.avatar.spec.ts b/packages/core/src/modules/profile/__tests__/profile.service.avatar.spec.ts new file mode 100644 index 0000000..3b4013f --- /dev/null +++ b/packages/core/src/modules/profile/__tests__/profile.service.avatar.spec.ts @@ -0,0 +1,128 @@ +import { NotFoundException } from "@nestjs/common"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ProfileService } from "../profile.service"; + +const buildMockPrisma = () => ({ + userFile: { findFirst: vi.fn() }, + profile: { update: vi.fn() }, +}); + +const buildMockEncryption = () => ({ + encrypt: vi.fn((s: string) => `enc(${s})`), + decrypt: vi.fn((s: string) => s.replace(/^enc\(|\)$/g, "")), +}); + +type MockPrisma = ReturnType; + +const buildService = () => { + const prisma = buildMockPrisma(); + const encryption = buildMockEncryption(); + const service = new ProfileService(prisma as never, encryption as never); + return { service, prisma, encryption }; +}; + +describe("ProfileService.assertImageFileForProfile", () => { + let prisma: MockPrisma; + let service: ProfileService; + + beforeEach(() => { + ({ prisma, service } = buildService()); + }); + + it("returns silently for image/* mime", async () => { + prisma.userFile.findFirst.mockResolvedValue({ mimeType: "image/png" }); + await expect( + service.assertImageFileForProfile("profile-1", "file-1"), + ).resolves.toBeUndefined(); + expect(prisma.userFile.findFirst).toHaveBeenCalledWith({ + where: { id: "file-1", profileId: "profile-1" }, + select: { mimeType: true }, + }); + }); + + it("throws NotFoundException when the file is not owned by the profile", async () => { + prisma.userFile.findFirst.mockResolvedValue(null); + await expect( + service.assertImageFileForProfile("profile-1", "file-1"), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it("throws on non-image mime types (e.g. application/pdf)", async () => { + prisma.userFile.findFirst.mockResolvedValue({ + mimeType: "application/pdf", + }); + await expect( + service.assertImageFileForProfile("profile-1", "file-1"), + ).rejects.toThrow(/not an image/); + }); +}); + +describe("ProfileService.updateProfile (avatar gating)", () => { + let prisma: MockPrisma; + let service: ProfileService; + + beforeEach(() => { + ({ prisma, service } = buildService()); + prisma.profile.update.mockResolvedValue({ + id: "p1", + userId: "u1", + name: "x", + fullName: null, + headline: null, + encryptedPhone: null, + encryptedAddress: null, + encryptedPostalCode: null, + locationId: null, + avatarFileId: "file-1", + website: null, + linkedInUrl: null, + summary: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + }); + + it("runs the image-file check before writing when avatarFileId is set", async () => { + prisma.userFile.findFirst.mockResolvedValue({ mimeType: "image/jpeg" }); + await service.updateProfile("p1", { avatarFileId: "file-1" }); + expect(prisma.userFile.findFirst).toHaveBeenCalledOnce(); + expect(prisma.profile.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "p1" }, + data: expect.objectContaining({ avatarFileId: "file-1" }), + }), + ); + }); + + it("skips the image-file check when avatarFileId is null (clearing the binding)", async () => { + prisma.profile.update.mockResolvedValueOnce({ + id: "p1", + userId: "u1", + name: "x", + fullName: null, + headline: null, + encryptedPhone: null, + encryptedAddress: null, + encryptedPostalCode: null, + locationId: null, + avatarFileId: null, + website: null, + linkedInUrl: null, + summary: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + await service.updateProfile("p1", { avatarFileId: null }); + expect(prisma.userFile.findFirst).not.toHaveBeenCalled(); + expect(prisma.profile.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ avatarFileId: null }), + }), + ); + }); + + it("skips the image-file check when avatarFileId is undefined (untouched)", async () => { + await service.updateProfile("p1", { headline: "x" }); + expect(prisma.userFile.findFirst).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/modules/profile/profile.service.ts b/packages/core/src/modules/profile/profile.service.ts index d2f665e..f38462e 100644 --- a/packages/core/src/modules/profile/profile.service.ts +++ b/packages/core/src/modules/profile/profile.service.ts @@ -10,6 +10,7 @@ interface CreateProfileInput { address?: string; postalCode?: string; locationId?: string; + avatarFileId?: string | null; website?: string; linkedInUrl?: string; summary?: string; @@ -23,6 +24,7 @@ interface UpdateProfileInput { address?: string; postalCode?: string; locationId?: string; + avatarFileId?: string | null; website?: string; linkedInUrl?: string; summary?: string; @@ -38,6 +40,7 @@ export interface DecryptedProfile { address: string | null; postalCode: string | null; locationId: string | null; + avatarFileId: string | null; website: string | null; linkedInUrl: string | null; summary: string | null; @@ -45,6 +48,8 @@ export interface DecryptedProfile { updatedAt: Date; } +const IMAGE_MIME_PREFIX = "image/"; + @Injectable() export class ProfileService { constructor( @@ -69,6 +74,28 @@ export class ProfileService { return this.decrypt(profile); } + async assertImageFileForProfile( + profileId: string, + userFileId: string, + ): Promise { + const userFile = await this.prisma.userFile.findFirst({ + where: { id: userFileId, profileId }, + select: { mimeType: true }, + }); + + if (!userFile) { + throw new NotFoundException( + `UserFile ${userFileId} not found for profile ${profileId}`, + ); + } + + if (!userFile.mimeType.startsWith(IMAGE_MIME_PREFIX)) { + throw new Error( + `UserFile ${userFileId} mimeType "${userFile.mimeType}" is not an image`, + ); + } + } + async getProfilesForUser(userId: string): Promise { const profiles = await this.prisma.profile.findMany({ where: { userId }, @@ -105,6 +132,10 @@ export class ProfileService { profileId: string, data: UpdateProfileInput, ): Promise { + if (data.avatarFileId !== undefined && data.avatarFileId !== null) { + await this.assertImageFileForProfile(profileId, data.avatarFileId); + } + const encrypted = this.encryptFields(data); if (data.name !== undefined) { @@ -160,6 +191,7 @@ export class ProfileService { encryptedAddress: string | null; encryptedPostalCode: string | null; locationId: string | null; + avatarFileId: string | null; website: string | null; linkedInUrl: string | null; summary: string | null; @@ -182,6 +214,7 @@ export class ProfileService { ? this.encryption.decrypt(profile.encryptedPostalCode) : null, locationId: profile.locationId, + avatarFileId: profile.avatarFileId, website: profile.website, linkedInUrl: profile.linkedInUrl, summary: profile.summary, @@ -199,6 +232,8 @@ export class ProfileService { if (data.headline !== undefined) result["headline"] = data.headline || null; if (data.locationId !== undefined) result["locationId"] = data.locationId || null; + if (data.avatarFileId !== undefined) + result["avatarFileId"] = data.avatarFileId || null; if (data.website !== undefined) result["website"] = data.website || null; if (data.linkedInUrl !== undefined) result["linkedInUrl"] = data.linkedInUrl || null; diff --git a/packages/core/src/modules/vacancies/job-type/job-type.factory.ts b/packages/core/src/modules/vacancies/job-type/job-type.factory.ts index 3bb6bd6..1fb0c0f 100644 --- a/packages/core/src/modules/vacancies/job-type/job-type.factory.ts +++ b/packages/core/src/modules/vacancies/job-type/job-type.factory.ts @@ -1,9 +1,5 @@ import { Injectable } from "@nestjs/common"; -import { - ClockService, - Factory, - UuidFactoryService, -} from "../../../shared"; +import { ClockService, Factory, UuidFactoryService } from "../../../shared"; import { CreateJobTypeDto } from "./job-type.dto"; import { JobType } from "./job-type.entity"; diff --git a/packages/cv-renderer/src/__test-fixtures__/index.ts b/packages/cv-renderer/src/__test-fixtures__/index.ts index de7ce0f..4d1fe31 100644 --- a/packages/cv-renderer/src/__test-fixtures__/index.ts +++ b/packages/cv-renderer/src/__test-fixtures__/index.ts @@ -18,6 +18,7 @@ const baseProfile: CVRenderContext["profile"] = { linkedInUrl: null, summary: null, email: null, + avatarDataUri: null, }; export const minimal: CVRenderContext = { @@ -64,6 +65,7 @@ export const full: CVRenderContext = { summary: "10+ years building backend systems for fintech and e-commerce. Specialised in NestJS, GraphQL, and Postgres. Mentor and tech lead.", email: "jane@example.com", + avatarDataUri: null, }, experience: [ { @@ -125,6 +127,7 @@ export const developer: CVRenderContext = { summary: "Backend engineer focused on `TypeScript` and `Postgres`. Strong opinions about `null` vs `undefined`.", email: "dev@example.com", + avatarDataUri: null, }, experience: [ { @@ -183,6 +186,7 @@ export const executive: CVRenderContext = { summary: "Engineering executive with 15 years of experience building and leading high-performing teams. Track record across fintech, healthcare, and consumer SaaS. Board advisor at three early-stage companies.", email: "exec@example.com", + avatarDataUri: null, }, experience: [ { @@ -221,6 +225,7 @@ export const unicodeHeavy: CVRenderContext = { headline: "Émojí enthusiast 🎯", summary: "中文 / 日本語 / العربية / Ελληνικά / עברית — all in one CV.", email: "test+unicode@example.com", + avatarDataUri: null, }, experience: [ { @@ -253,6 +258,7 @@ export const adversarial: CVRenderContext = { summary: "Markdown with raw HTML: \n\n[Click](javascript:alert('link'))\n\n![img](javascript:alert('img-md'))\n\nlink", email: "evil@example.com", + avatarDataUri: null, website: "javascript:alert('website')", linkedInUrl: "data:text/html,", }, diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/adversarial.html b/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/adversarial.html index 07689f6..2d66f48 100644 --- a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/adversarial.html +++ b/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/adversarial.html @@ -1,13 +1,16 @@
-

<b>Evil</b> <script>alert(1)</script> Person

-

<svg onload=alert('headline')>

-
- evil@example.com - - - - + +
+

<b>Evil</b> <script>alert(1)</script> Person

+

<svg onload=alert('headline')>

+
+ evil@example.com + + + + +
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/developer.html b/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/developer.html index eed057d..093f0ec 100644 --- a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/developer.html +++ b/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/developer.html @@ -1,13 +1,16 @@
-

Dev Coder

-

Backend Developer

-
- dev@example.com - - - - + +
+

Dev Coder

+

Backend Developer

+
+ dev@example.com + + + + +
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/empty.html b/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/empty.html index 8c466ff..0a5b6cf 100644 --- a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/empty.html +++ b/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/empty.html @@ -1,13 +1,16 @@
-

-
- - - - +
+

+
+ + + + + +
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/executive.html b/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/executive.html index 35c65d0..8dd97d3 100644 --- a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/executive.html +++ b/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/executive.html @@ -1,13 +1,16 @@
-

Exec Leader

-

VP Engineering, MBA

-
- exec@example.com - - - - + +
+

Exec Leader

+

VP Engineering, MBA

+
+ exec@example.com + + + + +
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/full.html b/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/full.html index 04f46b0..de2c003 100644 --- a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/full.html +++ b/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/full.html @@ -1,13 +1,16 @@
-

Jane Doe

-

Senior Software Engineer

-
- jane@example.com - +31 6 12345678 - - https://janedoe.dev - LinkedIn + +
+

Jane Doe

+

Senior Software Engineer

+
+ jane@example.com + +31 6 12345678 + + https://janedoe.dev + LinkedIn +
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/minimal.html b/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/minimal.html index 331535c..34262f3 100644 --- a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/minimal.html +++ b/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/minimal.html @@ -1,13 +1,16 @@
-

Min Person

-
- - - - +
+

Min Person

+
+ + + + + +
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/unicodeHeavy.html b/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/unicodeHeavy.html index dd12333..981d0de 100644 --- a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/unicodeHeavy.html +++ b/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/unicodeHeavy.html @@ -1,13 +1,16 @@
-

Ünicödé Pérsön 山田太郎 🚀

-

Émojí enthusiast 🎯

-
- test+unicode@example.com - - - - + +
+

Ünicödé Pérsön 山田太郎 🚀

+

Émojí enthusiast 🎯

+
+ test+unicode@example.com + + + + +
diff --git a/packages/cv-renderer/src/render-context.ts b/packages/cv-renderer/src/render-context.ts index 9eddaea..03f5678 100644 --- a/packages/cv-renderer/src/render-context.ts +++ b/packages/cv-renderer/src/render-context.ts @@ -17,6 +17,7 @@ export interface CVRenderContext { linkedInUrl: string | null; summary: string | null; email: string | null; + avatarDataUri: string | null; }; experience: Array<{ company: string; diff --git a/packages/file-upload/src/__tests__/validate-image.spec.ts b/packages/file-upload/src/__tests__/validate-image.spec.ts new file mode 100644 index 0000000..a2f1e98 --- /dev/null +++ b/packages/file-upload/src/__tests__/validate-image.spec.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { + isSupportedImageMimeType, + SupportedImageMimeTypes, + validateImage, +} from "../validators"; + +const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); +const WEBP = Buffer.concat([ + Buffer.from("RIFF"), + Buffer.from([0x00, 0x00, 0x00, 0x00]), + Buffer.from("WEBP"), +]); +const HEIC = Buffer.concat([ + Buffer.from([0x00, 0x00, 0x00, 0x20]), + Buffer.from("ftyp"), + Buffer.from("heic"), +]); +const HEIC_MIF1 = Buffer.concat([ + Buffer.from([0x00, 0x00, 0x00, 0x20]), + Buffer.from("ftyp"), + Buffer.from("mif1"), +]); + +const valid = (buffer: Buffer, mimeType: string) => + validateImage({ + buffer, + mimeType, + originalName: "x.bin", + sizeBytes: buffer.byteLength, + }); + +describe("isSupportedImageMimeType", () => { + it("recognises every member of SupportedImageMimeTypes", () => { + for (const mime of Object.values(SupportedImageMimeTypes)) { + expect(isSupportedImageMimeType(mime)).toBe(true); + } + }); + + it("rejects mimes outside the set", () => { + expect(isSupportedImageMimeType("image/gif")).toBe(false); + expect(isSupportedImageMimeType("application/pdf")).toBe(false); + }); +}); + +describe("validateImage", () => { + it("accepts PNG with matching magic bytes", () => { + expect(valid(PNG, "image/png")).toEqual({ valid: true }); + }); + + it("accepts JPEG", () => { + expect(valid(JPEG, "image/jpeg")).toEqual({ valid: true }); + }); + + it("accepts WEBP (RIFF + WEBP)", () => { + expect(valid(WEBP, "image/webp")).toEqual({ valid: true }); + }); + + it("accepts HEIC with the `heic` brand at offset 8", () => { + expect(valid(HEIC, "image/heic")).toEqual({ valid: true }); + }); + + it("accepts HEIC with the `mif1` brand (HEIF still images)", () => { + expect(valid(HEIC_MIF1, "image/heic")).toEqual({ valid: true }); + }); + + it("rejects mime/byte mismatch (PNG bytes claimed as JPEG)", () => { + const result = valid(PNG, "image/jpeg"); + expect(result.valid).toBe(false); + }); + + it("rejects an unsupported image mime", () => { + const result = valid(PNG, "image/gif"); + expect(result.valid).toBe(false); + }); + + it("rejects an empty buffer", () => { + const result = valid(Buffer.alloc(0), "image/png"); + expect(result.valid).toBe(false); + }); +}); diff --git a/packages/file-upload/src/extractors/pdf/pdf.extractor.ts b/packages/file-upload/src/extractors/pdf/pdf.extractor.ts index a5fa1b4..318ad7b 100644 --- a/packages/file-upload/src/extractors/pdf/pdf.extractor.ts +++ b/packages/file-upload/src/extractors/pdf/pdf.extractor.ts @@ -1,7 +1,7 @@ import { Logger } from "@nestjs/common"; +import { TextExtractorProvider } from "../../text-extractor.tag"; import type { TextExtractionResult } from "../../types"; import { BaseTextExtractor } from "../base-extractor"; -import { TextExtractorProvider } from "../../text-extractor.tag"; import { PdfParseStrategy } from "./strategies/pdf-parse.strategy"; import { Pdf2JsonStrategy } from "./strategies/pdf2json.strategy"; import { TesseractOcrStrategy } from "./strategies/tesseract-ocr.strategy"; diff --git a/packages/file-upload/src/index.ts b/packages/file-upload/src/index.ts index 76daa50..83f98d7 100644 --- a/packages/file-upload/src/index.ts +++ b/packages/file-upload/src/index.ts @@ -45,10 +45,14 @@ export type { export { FileUploadSchema, SupportedMimeTypes } from "./types"; // File validators export { + isSupportedImageMimeType, isSupportedMimeType, + type SupportedImageMimeType, + SupportedImageMimeTypes, validateFile, validateFileName, validateFileSize, + validateImage, validateMimeType, validateStoryText, } from "./validators"; diff --git a/packages/file-upload/src/validators.ts b/packages/file-upload/src/validators.ts index dc5ea9a..962ff70 100644 --- a/packages/file-upload/src/validators.ts +++ b/packages/file-upload/src/validators.ts @@ -136,6 +136,111 @@ export const validateMimeType = (mimeType: string): FileValidationResult => { return { valid: true }; }; +export const SupportedImageMimeTypes = { + PNG: "image/png", + JPEG: "image/jpeg", + WEBP: "image/webp", + HEIC: "image/heic", +} as const; + +export type SupportedImageMimeType = + (typeof SupportedImageMimeTypes)[keyof typeof SupportedImageMimeTypes]; + +const PNG_SIGNATURE = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); +const JPEG_SIGNATURE = Buffer.from([0xff, 0xd8, 0xff]); +const WEBP_RIFF = Buffer.from("RIFF"); +const WEBP_FORMAT = Buffer.from("WEBP"); +const HEIC_FTYP = Buffer.from("ftyp"); +const HEIC_BRANDS = ["heic", "heix", "mif1", "msf1"].map((b) => Buffer.from(b)); + +const isHeic = (b: Buffer): boolean => { + if (b.length < 12) { + return false; + } + if (!b.subarray(4, 8).equals(HEIC_FTYP)) { + return false; + } + const brand = b.subarray(8, 12); + return HEIC_BRANDS.some((known) => brand.equals(known)); +}; + +const IMAGE_RULES: Record = { + [SupportedImageMimeTypes.PNG]: { + check: (b) => startsWith(b, PNG_SIGNATURE), + error: "File contents do not look like a PNG", + }, + [SupportedImageMimeTypes.JPEG]: { + check: (b) => startsWith(b, JPEG_SIGNATURE), + error: "File contents do not look like a JPEG", + }, + [SupportedImageMimeTypes.WEBP]: { + check: (b) => + b.length >= 12 && + startsWith(b, WEBP_RIFF) && + b.subarray(8, 12).equals(WEBP_FORMAT), + error: "File contents do not look like a WEBP", + }, + [SupportedImageMimeTypes.HEIC]: { + check: isHeic, + error: "File contents do not look like a HEIC", + }, +}; + +export const isSupportedImageMimeType = ( + mimeType: string, +): mimeType is SupportedImageMimeType => + Object.values(SupportedImageMimeTypes).includes( + mimeType as SupportedImageMimeType, + ); + +export const validateImage = ( + file: Partial, +): FileValidationResult => { + if (!file.originalName) { + return { valid: false, error: "File name is required" }; + } + + const nameValidation = validateFileName(file.originalName); + if (!nameValidation.valid) { + return nameValidation; + } + + if (!file.mimeType) { + return { valid: false, error: "MIME type is required" }; + } + + if (!isSupportedImageMimeType(file.mimeType)) { + return { + valid: false, + error: `Unsupported image type. Supported: ${Object.values(SupportedImageMimeTypes).join(", ")}`, + }; + } + + if (file.sizeBytes === undefined) { + return { valid: false, error: "File size is required" }; + } + + const sizeValidation = validateFileSize(file.sizeBytes); + if (!sizeValidation.valid) { + return sizeValidation; + } + + if (!file.buffer) { + return { valid: false, error: "File buffer is required" }; + } + + if (file.buffer.length === 0) { + return { valid: false, error: "File is empty" }; + } + + const rule = IMAGE_RULES[file.mimeType]; + return rule.check(file.buffer) + ? { valid: true } + : { valid: false, error: rule.error }; +}; + /** * Validate inline story text (used by the parse-cv "story" source). Mirrors * `validateFile` shape so callers can route both file and story inputs diff --git a/packages/handlers/package.json b/packages/handlers/package.json index 4c6c561..a23dfbb 100644 --- a/packages/handlers/package.json +++ b/packages/handlers/package.json @@ -19,11 +19,13 @@ "@nestjs/common": "^11.1.18", "@riotbyte-com/project-q-core": "1.0.19-rc.5", "@riotbyte-com/project-q-nestjs": "1.0.19-rc.5", + "heic-convert": "^2.1.0", "playwright": "^1.52.0", "playwright-core": "^1.52.0" }, "devDependencies": { "@cv/tsconfig": "*", + "@types/heic-convert": "^1.2.3", "@types/node": "^22.7.5", "typescript": "^5.3.3", "vitest": "^4.0.0" diff --git a/packages/handlers/src/__tests__/convert-image.handler.spec.ts b/packages/handlers/src/__tests__/convert-image.handler.spec.ts new file mode 100644 index 0000000..1adae73 --- /dev/null +++ b/packages/handlers/src/__tests__/convert-image.handler.spec.ts @@ -0,0 +1,84 @@ +import type { Envelope } from "@riotbyte-com/project-q-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("heic-convert", () => ({ + default: vi.fn(async () => + Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]).buffer.slice(0), + ), +})); + +import convert from "heic-convert"; +import { ConvertImageHandler } from "../convert-image.handler"; + +const buildHandler = () => { + const prisma = { userFile: { update: vi.fn().mockResolvedValue({}) } }; + const storage = { + read: vi.fn().mockResolvedValue(Buffer.from([0x00, 0x00, 0x00, 0x20])), + write: vi.fn().mockResolvedValue(undefined), + delete: vi.fn(), + exists: vi.fn(), + }; + const handler = new ConvertImageHandler(prisma as never, storage as never); + return { handler, prisma, storage }; +}; + +const envelope = (data: unknown): Envelope => + ({ message: { name: "convert-image", data } }) as Envelope; + +describe("ConvertImageHandler", () => { + beforeEach(() => { + vi.mocked(convert).mockClear(); + }); + + it("reads from FileStorage by storageKey, converts, writes back to the same key, and flips the row", async () => { + const { handler, prisma, storage } = buildHandler(); + + await handler.handle( + envelope({ + userFileId: "file-1", + sourceMime: "image/heic", + targetMime: "image/jpeg", + storageKey: "user-files/file-1", + }), + ); + + expect(storage.read).toHaveBeenCalledWith("user-files/file-1"); + expect(convert).toHaveBeenCalledOnce(); + expect(storage.write).toHaveBeenCalledWith( + "user-files/file-1", + expect.any(Buffer), + ); + expect(prisma.userFile.update).toHaveBeenCalledWith({ + where: { id: "file-1" }, + data: expect.objectContaining({ + mimeType: "image/jpeg", + status: "completed", + }), + }); + }); + + it("marks the UserFile failed and rethrows when conversion errors", async () => { + const { handler, prisma, storage } = buildHandler(); + vi.mocked(convert).mockRejectedValueOnce(new Error("bad heic")); + + await expect( + handler.handle( + envelope({ + userFileId: "file-1", + sourceMime: "image/heic", + targetMime: "image/jpeg", + storageKey: "user-files/file-1", + }), + ), + ).rejects.toThrow("bad heic"); + + expect(storage.write).not.toHaveBeenCalled(); + expect(prisma.userFile.update).toHaveBeenCalledWith({ + where: { id: "file-1" }, + data: expect.objectContaining({ + status: "failed", + error: "bad heic", + }), + }); + }); +}); diff --git a/packages/handlers/src/__tests__/parse-cv.handler.spec.ts b/packages/handlers/src/__tests__/parse-cv.handler.spec.ts index d60b8cc..e358010 100644 --- a/packages/handlers/src/__tests__/parse-cv.handler.spec.ts +++ b/packages/handlers/src/__tests__/parse-cv.handler.spec.ts @@ -19,13 +19,15 @@ interface HandlerSetup { handler: ParseCvHandler; } -const setup = (overrides: { - job?: AsyncJobEntity | null; - parseResult?: unknown; - parseError?: Error; - markCompletedError?: Error; - markFailedError?: Error; -} = {}): HandlerSetup => { +const setup = ( + overrides: { + job?: AsyncJobEntity | null; + parseResult?: unknown; + parseError?: Error; + markCompletedError?: Error; + markFailedError?: Error; + } = {}, +): HandlerSetup => { const job = overrides.job === undefined ? ({ @@ -57,9 +59,11 @@ const setup = (overrides: { const parser = { parseForUser: overrides.parseError ? vi.fn().mockRejectedValue(overrides.parseError) - : vi.fn().mockResolvedValue( - overrides.parseResult ?? { jobExperiences: [], education: [] }, - ), + : vi + .fn() + .mockResolvedValue( + overrides.parseResult ?? { jobExperiences: [], education: [] }, + ), }; const handler = new ParseCvHandler( @@ -86,7 +90,10 @@ describe("ParseCvHandler", () => { source: "story", text: "I worked at ACME as an engineer.", }); - expect(parser.parseForUser).toHaveBeenCalledWith("user-1", "extracted text"); + expect(parser.parseForUser).toHaveBeenCalledWith( + "user-1", + "extracted text", + ); expect(asyncJobs.markCompleted).toHaveBeenCalledWith("job-1", { jobExperiences: [], education: [], @@ -108,7 +115,10 @@ describe("ParseCvHandler", () => { ), ).rejects.toThrow("provider broken"); - expect(asyncJobs.markFailed).toHaveBeenCalledWith("job-1", "provider broken"); + expect(asyncJobs.markFailed).toHaveBeenCalledWith( + "job-1", + "provider broken", + ); expect(asyncJobs.markCompleted).not.toHaveBeenCalled(); }); diff --git a/packages/handlers/src/convert-image.handler.ts b/packages/handlers/src/convert-image.handler.ts new file mode 100644 index 0000000..5d4fc49 --- /dev/null +++ b/packages/handlers/src/convert-image.handler.ts @@ -0,0 +1,77 @@ +import { ConvertImageMessage, PrismaService } from "@cv/core"; +import { FILE_STORAGE, type FileStorage } from "@cv/file-storage"; +import { Inject, Injectable, Logger } from "@nestjs/common"; +import { Envelope, Handler } from "@riotbyte-com/project-q-core"; +import { HandlerTag } from "@riotbyte-com/project-q-nestjs"; +import convert from "heic-convert"; + +@Injectable() +@HandlerTag.decorator({ handles: "convert-image" }) +export class ConvertImageHandler implements Handler { + private readonly logger = new Logger(ConvertImageHandler.name); + + constructor( + private readonly prisma: PrismaService, + @Inject(FILE_STORAGE) private readonly storage: FileStorage, + ) {} + + async handle(envelope: Envelope): Promise { + const { userFileId, sourceMime, targetMime, storageKey } = + ConvertImageMessage.parse(envelope.message).data; + + this.logger.log( + `Converting userFile ${userFileId} from ${sourceMime} to ${targetMime}`, + ); + + try { + const sourceBytes = await this.storage.read(storageKey); + const converted = await convert({ + buffer: sourceBytes.buffer.slice( + sourceBytes.byteOffset, + sourceBytes.byteOffset + sourceBytes.byteLength, + ), + format: "JPEG", + quality: 0.85, + }); + const jpegBytes = Buffer.from(converted); + + await this.storage.write(storageKey, jpegBytes); + await this.prisma.userFile.update({ + where: { id: userFileId }, + data: { + mimeType: targetMime, + sizeBytes: jpegBytes.length, + status: "completed", + statusMessage: `Converted from ${sourceMime} to ${targetMime}`, + }, + }); + + this.logger.log( + `Converted userFile ${userFileId}: ${sourceBytes.length} → ${jpegBytes.length} bytes`, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.error( + `Failed to convert userFile ${userFileId}: ${message}`, + err instanceof Error ? err.stack : undefined, + ); + await this.prisma.userFile + .update({ + where: { id: userFileId }, + data: { + status: "failed", + statusMessage: "Conversion failed", + error: message, + }, + }) + .catch((writeErr) => { + this.logger.error( + `Failed to persist error onto userFile ${userFileId}: ${ + writeErr instanceof Error ? writeErr.message : String(writeErr) + }`, + ); + }); + throw err; + } + } +} diff --git a/packages/handlers/src/handlers.module.ts b/packages/handlers/src/handlers.module.ts index 399903d..998fa31 100644 --- a/packages/handlers/src/handlers.module.ts +++ b/packages/handlers/src/handlers.module.ts @@ -6,6 +6,7 @@ import { } from "@cv/core"; import { FileExtractionModule } from "@cv/file-upload"; import { DynamicModule, Module } from "@nestjs/common"; +import { ConvertImageHandler } from "./convert-image.handler"; import { ParseCvHandler } from "./parse-cv.handler"; import { HtmlToPdfService } from "./pdf/html-to-pdf.service"; import { PDF_SERVICE_CONFIG, PdfServiceConfig } from "./pdf/pdf-service.config"; @@ -34,8 +35,14 @@ export class HandlersModule { TextSourceResolver, UserCvParserService, ParseCvHandler, + ConvertImageHandler, + ], + exports: [ + HtmlToPdfService, + RenderPdfHandler, + ParseCvHandler, + ConvertImageHandler, ], - exports: [HtmlToPdfService, RenderPdfHandler, ParseCvHandler], }; } } diff --git a/packages/handlers/src/index.ts b/packages/handlers/src/index.ts index 0da7f7f..fd1e61e 100644 --- a/packages/handlers/src/index.ts +++ b/packages/handlers/src/index.ts @@ -1,7 +1,8 @@ +export { ConvertImageHandler } from "./convert-image.handler"; export { HandlersModule } from "./handlers.module"; +export { ParseCvHandler } from "./parse-cv.handler"; export { HtmlToPdfService, type PdfServiceConfig, } from "./pdf/html-to-pdf.service"; export { RenderPdfHandler } from "./render-pdf.handler"; -export { ParseCvHandler } from "./parse-cv.handler"; diff --git a/packages/handlers/src/parse-cv.handler.ts b/packages/handlers/src/parse-cv.handler.ts index 3d9531b..636006f 100644 --- a/packages/handlers/src/parse-cv.handler.ts +++ b/packages/handlers/src/parse-cv.handler.ts @@ -3,7 +3,10 @@ import { Inject, Injectable, Logger } from "@nestjs/common"; import { type Envelope, type Handler } from "@riotbyte-com/project-q-core"; import { HandlerTag } from "@riotbyte-com/project-q-nestjs"; import { type TextSource, TextSourceResolver } from "./text-source.resolver"; -import { type UserCvParser, UserCvParserService } from "./user-cv-parser.service"; +import { + type UserCvParser, + UserCvParserService, +} from "./user-cv-parser.service"; @Injectable() @HandlerTag.decorator({ handles: "parse-cv" }) diff --git a/packages/handlers/src/text-source.resolver.ts b/packages/handlers/src/text-source.resolver.ts index b1ad474..6f9c1dc 100644 --- a/packages/handlers/src/text-source.resolver.ts +++ b/packages/handlers/src/text-source.resolver.ts @@ -1,6 +1,9 @@ import type { ParseCVInput } from "@cv/core"; import { FILE_STORAGE, type FileStorage } from "@cv/file-storage"; -import { TEXT_EXTRACTOR_REGISTRY, TextExtractorRegistry } from "@cv/file-upload"; +import { + TEXT_EXTRACTOR_REGISTRY, + TextExtractorRegistry, +} from "@cv/file-upload"; import { Inject, Injectable } from "@nestjs/common"; /** diff --git a/packages/handlers/src/user-cv-parser.service.ts b/packages/handlers/src/user-cv-parser.service.ts index 620bfb8..1ff0c84 100644 --- a/packages/handlers/src/user-cv-parser.service.ts +++ b/packages/handlers/src/user-cv-parser.service.ts @@ -18,9 +18,7 @@ export interface UserCvParser { */ @Injectable() export class UserCvParserService implements UserCvParser { - constructor( - private readonly providerResolver: AIProviderResolverService, - ) {} + constructor(private readonly providerResolver: AIProviderResolverService) {} async parseForUser(userId: string, text: string): Promise { const provider = await this.providerResolver.resolveForUser(userId); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 897461a..499cd29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1070,6 +1070,9 @@ importers: '@riotbyte-com/project-q-nestjs': specifier: 1.0.19-rc.5 version: 1.0.19-rc.5(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18))(@riotbyte-com/nest-service-locator@0.3.0-rc.8(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(reflect-metadata@0.2.2))(eventemitter2@6.4.9)(nest-commander@3.16.0(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(@types/inquirer@8.2.12)(typescript@5.9.3)) + heic-convert: + specifier: ^2.1.0 + version: 2.1.0 playwright: specifier: ^1.52.0 version: 1.58.2 @@ -1080,6 +1083,9 @@ importers: '@cv/tsconfig': specifier: '*' version: link:../tsconfig + '@types/heic-convert': + specifier: ^1.2.3 + version: 1.2.3 '@types/node': specifier: ^22.7.5 version: 22.19.3 @@ -4514,6 +4520,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/heic-convert@1.2.3': + resolution: {integrity: sha512-5LJ2fGuVk/gnOLihoT56xJwrXxfnNepGvrHwlW5ZtT3HS4jO1AqBaAHCxXUpnY9UaD3zYcyxXMRM2fNN1AFF/Q==} + '@types/history@4.7.11': resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==} @@ -6222,6 +6231,14 @@ packages: header-case@2.0.4: resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==} + heic-convert@2.1.0: + resolution: {integrity: sha512-1qDuRvEHifTVAj3pFIgkqGgJIr0M3X7cxEPjEp0oG4mo8GFjq99DpCo8Eg3kg17Cy0MTjxpFdoBHOatj7ZVKtg==} + engines: {node: '>=12.0.0'} + + heic-decode@2.1.0: + resolution: {integrity: sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==} + engines: {node: '>=8.0.0'} + helmet@8.1.0: resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} engines: {node: '>=18.0.0'} @@ -6597,6 +6614,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jpeg-js@0.4.4: + resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -6738,6 +6758,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + libheif-js@1.19.8: + resolution: {integrity: sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ==} + engines: {node: '>=8.0.0'} + libnpmaccess@8.0.6: resolution: {integrity: sha512-uM8DHDEfYG6G5gVivVl+yQd4pH3uRclHC59lzIbSvy7b5FEwR+mU49Zq1jEyRtRFv7+M99mUW9S0wL/4laT4lw==} engines: {node: ^16.14.0 || >=18.0.0} @@ -7915,6 +7939,10 @@ packages: resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==} engines: {node: '>=12'} + pngjs@6.0.0: + resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} + engines: {node: '>=12.13.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -13570,6 +13598,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/heic-convert@1.2.3': {} + '@types/history@4.7.11': {} '@types/http-errors@2.0.5': {} @@ -15423,6 +15453,16 @@ snapshots: capital-case: 1.0.4 tslib: 2.8.1 + heic-convert@2.1.0: + dependencies: + heic-decode: 2.1.0 + jpeg-js: 0.4.4 + pngjs: 6.0.0 + + heic-decode@2.1.0: + dependencies: + libheif-js: 1.19.8 + helmet@8.1.0: {} highlight.js@11.11.1: {} @@ -15787,6 +15827,8 @@ snapshots: jiti@2.7.0: {} + jpeg-js@0.4.4: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -16024,6 +16066,8 @@ snapshots: - encoding - supports-color + libheif-js@1.19.8: {} + libnpmaccess@8.0.6: dependencies: npm-package-arg: 11.0.2 @@ -17501,6 +17545,8 @@ snapshots: dependencies: queue-lit: 1.5.2 + pngjs@6.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-selector-parser@6.1.2: