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

Configure Feed

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

feat(server): add CV renderer, data assembler, PDF download, and template seeds

+757 -58
+4
apps/server/prisma/migrations/20260210004726_add_cv_template_content_fields/migration.sql
··· 1 + -- AlterTable 2 + ALTER TABLE "cv_templates" ADD COLUMN "body" TEXT, 3 + ADD COLUMN "css" TEXT, 4 + ADD COLUMN "engine" TEXT NOT NULL DEFAULT 'handlebars';
+147
apps/server/src/modules/cv-template/cv-data-assembler.service.ts
··· 1 + import { PrismaService } from "@cv/system"; 2 + import { Injectable } from "@nestjs/common"; 3 + import { ProfileService } from "@/modules/profile/profile.service"; 4 + 5 + export interface CVRenderContext { 6 + cv: { title: string; introduction: string | null }; 7 + profile: { 8 + name: string; 9 + headline: string | null; 10 + phone: string | null; 11 + city: string | null; 12 + country: string | null; 13 + website: string | null; 14 + linkedInUrl: string | null; 15 + summary: string | null; 16 + email: string | null; 17 + }; 18 + experience: Array<{ 19 + company: string; 20 + role: string; 21 + level: string | null; 22 + startDate: string; 23 + endDate: string | null; 24 + duration: string; 25 + description: string | null; 26 + skills: string[]; 27 + }>; 28 + education: Array<{ 29 + institution: string; 30 + degree: string; 31 + fieldOfStudy: string | null; 32 + startDate: string; 33 + endDate: string | null; 34 + duration: string; 35 + description: string | null; 36 + skills: string[]; 37 + }>; 38 + allSkills: string[]; 39 + generatedAt: string; 40 + } 41 + 42 + const MONTHS = [ 43 + "Jan", "Feb", "Mar", "Apr", "May", "Jun", 44 + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", 45 + ] as const; 46 + 47 + const formatDate = (date: Date): string => 48 + `${MONTHS[date.getMonth()]} ${date.getFullYear()}`; 49 + 50 + const computeDuration = (start: Date, end: Date | null): string => { 51 + const endDate = end ?? new Date(); 52 + const totalMonths = 53 + (endDate.getFullYear() - start.getFullYear()) * 12 + 54 + (endDate.getMonth() - start.getMonth()); 55 + 56 + const years = Math.floor(totalMonths / 12); 57 + const months = totalMonths % 12; 58 + 59 + const parts: string[] = []; 60 + if (years > 0) parts.push(`${years}y`); 61 + if (months > 0) parts.push(`${months}m`); 62 + 63 + return parts.length === 0 ? "<1m" : parts.join(" "); 64 + }; 65 + 66 + @Injectable() 67 + export class CVDataAssemblerService { 68 + constructor( 69 + private readonly prisma: PrismaService, 70 + private readonly profileService: ProfileService, 71 + ) {} 72 + 73 + async assemble(cvId: string): Promise<CVRenderContext> { 74 + const cv = await this.prisma.cV.findUniqueOrThrow({ where: { id: cvId } }); 75 + 76 + const [profile, experiences, educations] = await Promise.all([ 77 + this.profileService.findByIdOrFail(cv.profileId), 78 + this.prisma.userJobExperience.findMany({ 79 + where: { profileId: cv.profileId }, 80 + orderBy: { startDate: "desc" }, 81 + include: { company: true, role: true, level: true, skills: true }, 82 + }), 83 + this.prisma.education.findMany({ 84 + where: { profileId: cv.profileId }, 85 + orderBy: { startDate: "desc" }, 86 + include: { institution: true, skills: true }, 87 + }), 88 + ]); 89 + 90 + const profileRecord = await this.prisma.profile.findUniqueOrThrow({ 91 + where: { id: cv.profileId }, 92 + select: { userId: true }, 93 + }); 94 + 95 + const credential = await this.prisma.credentials.findFirst({ 96 + where: { userId: profileRecord.userId }, 97 + select: { email: true }, 98 + }); 99 + 100 + const experienceItems = experiences.map((exp) => ({ 101 + company: exp.company.name, 102 + role: exp.role.name, 103 + level: exp.level?.name ?? null, 104 + startDate: formatDate(exp.startDate), 105 + endDate: exp.endDate ? formatDate(exp.endDate) : null, 106 + duration: computeDuration(exp.startDate, exp.endDate ?? null), 107 + description: exp.description ?? null, 108 + skills: exp.skills.map((s) => s.name), 109 + })); 110 + 111 + const educationItems = educations.map((edu) => ({ 112 + institution: edu.institution.name, 113 + degree: edu.degree, 114 + fieldOfStudy: edu.fieldOfStudy ?? null, 115 + startDate: formatDate(edu.startDate), 116 + endDate: edu.endDate ? formatDate(edu.endDate) : null, 117 + duration: computeDuration(edu.startDate, edu.endDate ?? null), 118 + description: edu.description ?? null, 119 + skills: edu.skills.map((s) => s.name), 120 + })); 121 + 122 + const allSkillNames = [ 123 + ...experienceItems.flatMap((e) => e.skills), 124 + ...educationItems.flatMap((e) => e.skills), 125 + ]; 126 + const allSkills = [...new Set(allSkillNames)].sort(); 127 + 128 + return { 129 + cv: { title: cv.title, introduction: cv.introduction ?? null }, 130 + profile: { 131 + name: profile.fullName ?? profile.name, 132 + headline: profile.headline ?? null, 133 + phone: profile.phone ?? null, 134 + city: profile.city ?? null, 135 + country: profile.country ?? null, 136 + website: profile.website ?? null, 137 + linkedInUrl: profile.linkedInUrl ?? null, 138 + summary: profile.summary ?? null, 139 + email: credential?.email ?? null, 140 + }, 141 + experience: experienceItems, 142 + education: educationItems, 143 + allSkills, 144 + generatedAt: new Date().toISOString(), 145 + }; 146 + } 147 + }
+38
apps/server/src/modules/cv-template/cv-renderer.service.ts
··· 1 + import { 2 + type TemplateEngine, 3 + HandlebarsEngine, 4 + wrapInDocument, 5 + } from "@cv/cv-renderer"; 6 + import { Injectable } from "@nestjs/common"; 7 + import { CVDataAssemblerService } from "./cv-data-assembler.service"; 8 + import { CVTemplateService } from "./cv-template.service"; 9 + 10 + const engines: Record<string, TemplateEngine> = { 11 + handlebars: new HandlebarsEngine(), 12 + }; 13 + 14 + @Injectable() 15 + export class CVRendererService { 16 + constructor( 17 + private readonly cvTemplateService: CVTemplateService, 18 + private readonly dataAssembler: CVDataAssemblerService, 19 + ) {} 20 + 21 + async render(cvId: string, templateId: string): Promise<string> { 22 + const template = await this.cvTemplateService.findByIdOrFail(templateId); 23 + 24 + if (!template.body) { 25 + throw new Error(`Template "${template.name}" has no body content`); 26 + } 27 + 28 + const engine = engines[template.engine]; 29 + if (!engine) { 30 + throw new Error(`Unknown template engine: ${template.engine}`); 31 + } 32 + 33 + const context = await this.dataAssembler.assemble(cvId); 34 + const rendered = engine.render(template.body, context); 35 + 36 + return wrapInDocument(rendered, template.css ?? ""); 37 + } 38 + }
+8 -7
apps/server/src/modules/cv-template/cv-template.mapper.ts
··· 2 2 import { CVTemplate } from "./graphql/cv-template.type"; 3 3 4 4 export const cvTemplateMapper = { 5 - toDomain: (template: PrismaCVTemplate): CVTemplate => { 6 - return new CVTemplate( 5 + toDomain: (template: PrismaCVTemplate): CVTemplate => 6 + new CVTemplate( 7 7 template.id, 8 8 template.name, 9 9 template.createdAt, 10 10 template.updatedAt, 11 11 template.description, 12 - ); 13 - }, 12 + template.body, 13 + template.css, 14 + template.engine, 15 + ), 14 16 15 - mapToDomain: (templates: PrismaCVTemplate[]): CVTemplate[] => { 16 - return templates.map((template) => cvTemplateMapper.toDomain(template)); 17 - }, 17 + mapToDomain: (templates: PrismaCVTemplate[]): CVTemplate[] => 18 + templates.map((template) => cvTemplateMapper.toDomain(template)), 18 19 };
+9 -1
apps/server/src/modules/cv-template/cv-template.module.ts
··· 1 1 import { AuthorizationModule } from "@cv/auth"; 2 2 import { BaseModule, DatabaseModule } from "@cv/system"; 3 - import { Module } from "@nestjs/common"; 3 + import { Module, forwardRef } from "@nestjs/common"; 4 4 import { AuthenticationModule } from "@/modules/authentication/authentication.module"; 5 + import { ProfileModule } from "@/modules/profile/profile.module"; 6 + import { CVDataAssemblerService } from "./cv-data-assembler.service"; 7 + import { CVRendererService } from "./cv-renderer.service"; 5 8 import { CVDataLoaderService } from "./cv.dataloader"; 6 9 import { CVPolicy } from "./cv.policy"; 7 10 import { CVService } from "./cv.service"; ··· 9 12 import { CVTemplateService } from "./cv-template.service"; 10 13 import { CVResolver, CVTemplateResolver } from "./graphql/cv-template.resolver"; 11 14 import { CVUserFieldResolver } from "./graphql/user-field.resolver"; 15 + import { PdfDownloadController } from "./pdf-download.controller"; 12 16 import { CVTemplateSeedService } from "./seed/cv-template.seed"; 13 17 14 18 @Module({ ··· 17 21 BaseModule, 18 22 AuthenticationModule, 19 23 AuthorizationModule, 24 + forwardRef(() => ProfileModule), 20 25 ], 26 + controllers: [PdfDownloadController], 21 27 providers: [ 22 28 CVTemplateService, 23 29 CVService, ··· 28 34 CVTemplateSeedService, 29 35 CVUserFieldResolver, 30 36 CVDataLoaderService, 37 + CVDataAssemblerService, 38 + CVRendererService, 31 39 ], 32 40 exports: [CVTemplateService, CVService, CVDataLoaderService], 33 41 })
+1 -1
apps/server/src/modules/cv-template/cv.mapper.ts
··· 13 13 toDomain: (cv: PrismaCVWithTemplate): CV => { 14 14 return new CV( 15 15 cv.id, 16 - cv.userId, 16 + cv.profileId, 17 17 cv.title, 18 18 cv.createdAt, 19 19 cv.updatedAt,
+7 -2
apps/server/src/modules/cv-template/cv.policy.ts
··· 1 - import { Policy, UserOwnedResourcePolicy } from "@cv/auth"; 1 + import { Policy, ProfileOwnedResourcePolicy } from "@cv/auth"; 2 + import { PrismaService } from "@cv/system"; 2 3 import { Injectable } from "@nestjs/common"; 3 4 import { CV } from "./graphql/cv.type"; 4 5 5 6 @Injectable() 6 7 @Policy(CV) 7 - export class CVPolicy extends UserOwnedResourcePolicy<CV> {} 8 + export class CVPolicy extends ProfileOwnedResourcePolicy<CV> { 9 + constructor(prisma: PrismaService) { 10 + super(prisma); 11 + } 12 + }
+48 -19
apps/server/src/modules/cv-template/cv.service.ts
··· 1 1 import { notFound } from "@cv/auth"; 2 - import { type EntityService, PrismaService } from "@cv/system"; 2 + import type { PaginationOptions, PaginationResult } from "@cv/system"; 3 + import { type EntityService, PaginationService, PrismaService } from "@cv/system"; 3 4 import { Injectable } from "@nestjs/common"; 4 5 import type { Prisma } from "@prisma/client"; 5 6 import { cvMapper } from "./cv.mapper"; 6 7 import { CV } from "./graphql/cv.type"; 7 8 8 9 type CVFilters = { 9 - userId?: string; 10 + profileId?: string | string[]; 10 11 id?: string | string[]; 11 12 }; 12 13 13 14 type CVWhereInput = { 14 - userId?: string; 15 + profileId?: { in: string[] } | string; 15 16 id?: { in: string[] } | string; 16 17 }; 17 18 ··· 21 22 template: true, 22 23 } satisfies Prisma.CVInclude; 23 24 24 - constructor(private readonly prisma: PrismaService) {} 25 + constructor( 26 + private readonly prisma: PrismaService, 27 + private readonly paginationService: PaginationService, 28 + ) {} 25 29 26 30 async findById(id: string): Promise<CV | null> { 27 31 const cv = await this.prisma.cV.findUnique({ ··· 40 44 private buildWhere(filters: CVFilters): CVWhereInput { 41 45 const where: CVWhereInput = {}; 42 46 43 - if (filters.userId) { 44 - where.userId = filters.userId; 47 + if (filters.profileId !== undefined) { 48 + where.profileId = Array.isArray(filters.profileId) 49 + ? { in: filters.profileId } 50 + : filters.profileId; 45 51 } 46 52 47 53 if (filters.id !== undefined) { ··· 61 67 return cvMapper.mapToDomain(cvs); 62 68 } 63 69 70 + async findManyForProfile( 71 + profileId: string, 72 + options: PaginationOptions = {}, 73 + ): Promise<PaginationResult<CV>> { 74 + const where = { profileId }; 75 + const orderBy = { updatedAt: "desc" as const }; 76 + const queryOptions = this.paginationService.buildQueryOptions( 77 + where, 78 + orderBy, 79 + options, 80 + ); 81 + 82 + const [items, totalCount] = await Promise.all([ 83 + this.prisma.cV.findMany({ 84 + ...queryOptions, 85 + include: this.cvInclude, 86 + }), 87 + this.prisma.cV.count({ where }), 88 + ]); 89 + 90 + return this.paginationService.buildPaginationResult( 91 + cvMapper.mapToDomain(items), 92 + totalCount, 93 + options, 94 + ); 95 + } 96 + 64 97 async count(filters: CVFilters): Promise<number> { 65 98 return this.prisma.cV.count({ where: this.buildWhere(filters) }); 66 99 } 67 100 68 - private buildPrismaData(entity: CV): { 69 - title: string; 70 - introduction: string | null; 71 - templateId: string; 72 - } { 73 - return { 101 + async save(entity: CV): Promise<CV> { 102 + const shared = { 74 103 title: entity.title, 75 104 introduction: entity.introduction, 76 - templateId: entity.template.id, 77 105 }; 78 - } 79 106 80 - async save(entity: CV): Promise<CV> { 81 - const data = this.buildPrismaData(entity); 82 107 const cv = await this.prisma.cV.upsert({ 83 108 where: { id: entity.id }, 84 109 create: { 85 110 id: entity.id, 86 - userId: entity.userId, 87 - ...data, 111 + ...shared, 112 + profile: { connect: { id: entity.profileId } }, 113 + template: { connect: { id: entity.template.id } }, 88 114 }, 89 - update: data, 115 + update: { 116 + ...shared, 117 + template: { connect: { id: entity.template.id } }, 118 + }, 90 119 include: this.cvInclude, 91 120 }); 92 121 const domain = cvMapper.toDomain(cv);
+3
apps/server/src/modules/cv-template/graphql/cv-input.type.ts
··· 3 3 @InputType() 4 4 export class CreateCVInput { 5 5 @Field(() => String) 6 + profileId!: string; 7 + 8 + @Field(() => String) 6 9 templateId!: string; 7 10 8 11 @Field(() => String)
+50 -4
apps/server/src/modules/cv-template/graphql/cv-template.resolver.ts
··· 7 7 import { PaginationService, UuidFactoryService } from "@cv/system"; 8 8 import { UseGuards } from "@nestjs/common"; 9 9 import { Args, Mutation, Query, Resolver } from "@nestjs/graphql"; 10 + import type { MessageBus } from "@riotbyte/project-q-core"; 11 + import { InjectMessageBus } from "@riotbyte/project-q-nestjs"; 10 12 import { CurrentUser } from "@/modules/current-user/current-user.decorator"; 13 + import { RenderPdfMessage } from "@/modules/messenger/messages/render-pdf.message"; 14 + import { CVRendererService } from "../cv-renderer.service"; 11 15 import { CVService } from "../cv.service"; 12 16 import { CVTemplateService } from "../cv-template.service"; 13 17 import { CV } from "./cv.type"; 14 18 import { CreateCVInput, UpdateCVInput } from "./cv-input.type"; 15 - import { CVTemplate, CVTemplateConnection } from "./cv-template.type"; 19 + import { 20 + CVTemplate, 21 + CVTemplateConnection, 22 + RenderedCV, 23 + } from "./cv-template.type"; 16 24 import { CVTemplateArgs } from "./cv-template-args.type"; 17 25 18 26 @Resolver(() => CVTemplate) ··· 57 65 constructor( 58 66 private readonly cvService: CVService, 59 67 private readonly cvTemplateService: CVTemplateService, 68 + private readonly cvRendererService: CVRendererService, 60 69 private readonly uuidFactory: UuidFactoryService, 61 70 readonly _paginationService: PaginationService, 62 71 private readonly authorizationService: AuthorizationService, 72 + @InjectMessageBus() private readonly messageBus: MessageBus, 63 73 ) {} 64 74 65 75 @Query(() => CV, { nullable: true }) ··· 70 80 return cv; 71 81 } 72 82 83 + @Query(() => RenderedCV) 84 + @UseGuards(JwtAuthGuard, VerifiedScopeGuard) 85 + async renderCV( 86 + @CurrentUser() user: DomainUser, 87 + @Args("cvId") cvId: string, 88 + ): Promise<RenderedCV> { 89 + const cv = await this.cvService.findByIdOrFail(cvId); 90 + await this.authorizationService.canView(user, cv, CV); 91 + const html = await this.cvRendererService.render(cv.id, cv.template.id); 92 + return new RenderedCV(html); 93 + } 94 + 73 95 @Mutation(() => CV) 74 96 @UseGuards(JwtAuthGuard, VerifiedScopeGuard) 75 97 async createCV( 76 98 @CurrentUser() user: DomainUser, 77 99 @Args("input") input: CreateCVInput, 78 100 ) { 79 - await this.authorizationService.canCreate(user, CV, { userId: user.id }); 101 + await this.authorizationService.canCreate(user, CV, { 102 + profileId: input.profileId, 103 + }); 80 104 81 105 const template = await this.cvTemplateService.findByIdOrFail( 82 106 input.templateId, ··· 84 108 const now = new Date(); 85 109 const cv = new CV( 86 110 this.uuidFactory.generate(), 87 - user.id, 111 + input.profileId, 88 112 input.title, 89 113 now, 90 114 now, ··· 107 131 108 132 const updated = new CV( 109 133 existing.id, 110 - existing.userId, 134 + existing.profileId, 111 135 input.title ?? existing.title, 112 136 existing.createdAt, 113 137 new Date(), ··· 124 148 const cv = await this.cvService.findByIdOrFail(id); 125 149 await this.authorizationService.canDelete(user, cv, CV); 126 150 await this.cvService.destroy(cv); 151 + return true; 152 + } 153 + 154 + @Mutation(() => Boolean) 155 + @UseGuards(JwtAuthGuard, VerifiedScopeGuard) 156 + async generatePdf( 157 + @CurrentUser() user: DomainUser, 158 + @Args("cvId") cvId: string, 159 + ): Promise<boolean> { 160 + const cv = await this.cvService.findByIdOrFail(cvId); 161 + await this.authorizationService.canView(user, cv, CV); 162 + 163 + const html = await this.cvRendererService.render(cv.id, cv.template.id); 164 + 165 + await this.messageBus.dispatch( 166 + RenderPdfMessage.create({ 167 + cvId: cv.id, 168 + html, 169 + requestedBy: user.id, 170 + }), 171 + ); 172 + 127 173 return true; 128 174 } 129 175 }
+25
apps/server/src/modules/cv-template/graphql/cv-template.type.ts
··· 13 13 @Field(() => String, { nullable: true }) 14 14 description?: string | null; 15 15 16 + @Field(() => String, { nullable: true }) 17 + body?: string | null; 18 + 19 + @Field(() => String, { nullable: true }) 20 + css?: string | null; 21 + 22 + @Field(() => String) 23 + engine: string; 24 + 16 25 @Field(() => Date) 17 26 declare createdAt: Date; 18 27 ··· 25 34 createdAt: Date, 26 35 updatedAt: Date, 27 36 description?: string | null, 37 + body?: string | null, 38 + css?: string | null, 39 + engine: string = "handlebars", 28 40 ) { 29 41 super(id, createdAt, updatedAt); 30 42 this.name = name; 31 43 this.description = description ?? null; 44 + this.body = body ?? null; 45 + this.css = css ?? null; 46 + this.engine = engine; 47 + } 48 + } 49 + 50 + @ObjectType() 51 + export class RenderedCV { 52 + @Field(() => String) 53 + html: string; 54 + 55 + constructor(html: string) { 56 + this.html = html; 32 57 } 33 58 } 34 59
+3 -3
apps/server/src/modules/cv-template/graphql/cv.type.ts
··· 9 9 declare id: string; 10 10 11 11 @Field(() => String) 12 - userId: string; 12 + profileId: string; 13 13 14 14 @Field(() => String) 15 15 title: string; ··· 28 28 29 29 constructor( 30 30 id: string, 31 - userId: string, 31 + profileId: string, 32 32 title: string, 33 33 createdAt: Date, 34 34 updatedAt: Date, ··· 36 36 introduction?: string | null, 37 37 ) { 38 38 super(id, createdAt, updatedAt); 39 - this.userId = userId; 39 + this.profileId = profileId; 40 40 this.title = title; 41 41 this.introduction = introduction ?? null; 42 42 this.template = template;
+10 -3
apps/server/src/modules/cv-template/graphql/user-field.resolver.ts
··· 1 1 import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth"; 2 - import { PaginationService } from "@cv/system"; 2 + import { PaginationService, PrismaService } from "@cv/system"; 3 3 import { UseGuards } from "@nestjs/common"; 4 4 import { Args, Parent, ResolveField, Resolver } from "@nestjs/graphql"; 5 5 import { User } from "@/modules/user/user.type"; ··· 13 13 constructor( 14 14 private readonly cvService: CVService, 15 15 private readonly paginationService: PaginationService, 16 + private readonly prisma: PrismaService, 16 17 ) {} 17 18 18 19 @ResolveField(() => CVConnection, { nullable: true }) ··· 20 21 @Parent() user: User, 21 22 @Args() args: CVArgs = {}, 22 23 ): Promise<CVConnection> { 24 + const profiles = await this.prisma.profile.findMany({ 25 + where: { userId: user.id }, 26 + select: { id: true }, 27 + }); 28 + const profileIds = profiles.map((p) => p.id); 29 + 23 30 const options = this.paginationService.parsePaginationArgs(args); 24 31 const [items, totalCount] = await Promise.all([ 25 - this.cvService.findMany({ userId: user.id }), 26 - this.cvService.count({ userId: user.id }), 32 + this.cvService.findMany({ profileId: profileIds }), 33 + this.cvService.count({ profileId: profileIds }), 27 34 ]); 28 35 const result = this.paginationService.buildPaginationResult( 29 36 items,
+51
apps/server/src/modules/cv-template/pdf-download.controller.ts
··· 1 + import { existsSync } from "node:fs"; 2 + import { join } from "node:path"; 3 + import type { User as DomainUser } from "@cv/auth"; 4 + import { AuthorizationService, JwtAuthGuard } from "@cv/auth"; 5 + import { 6 + Controller, 7 + Get, 8 + NotFoundException, 9 + Param, 10 + Req, 11 + Res, 12 + UseGuards, 13 + } from "@nestjs/common"; 14 + import { ConfigService } from "@nestjs/config"; 15 + import type { Response } from "express"; 16 + import type { Env } from "@/config/env.validation"; 17 + import { CVService } from "./cv.service"; 18 + import { CV } from "./graphql/cv.type"; 19 + 20 + @Controller("api/cv") 21 + @UseGuards(JwtAuthGuard) 22 + export class PdfDownloadController { 23 + private readonly pdfOutputDir: string; 24 + 25 + constructor( 26 + private readonly cvService: CVService, 27 + private readonly authorizationService: AuthorizationService, 28 + configService: ConfigService<Env, true>, 29 + ) { 30 + this.pdfOutputDir = configService.get("PDF_OUTPUT_DIR"); 31 + } 32 + 33 + @Get(":id/pdf") 34 + async downloadPdf( 35 + @Req() req: { user: DomainUser }, 36 + @Param("id") id: string, 37 + @Res() res: Response, 38 + ): Promise<void> { 39 + const cv = await this.cvService.findByIdOrFail(id); 40 + await this.authorizationService.canView(req.user, cv, CV); 41 + 42 + const filePath = join(this.pdfOutputDir, `${id}.pdf`); 43 + 44 + if (!existsSync(filePath)) { 45 + throw new NotFoundException("PDF not yet available"); 46 + } 47 + 48 + const safeTitle = cv.title.replace(/[^a-zA-Z0-9_\- ]/g, ""); 49 + res.download(filePath, `${safeTitle}.pdf`); 50 + } 51 + }
+49 -18
apps/server/src/modules/cv-template/seed/cv-template.seed.ts
··· 1 + import { readFileSync } from "node:fs"; 2 + import { join } from "node:path"; 1 3 import { PrismaService } from "@cv/system"; 2 4 import { Injectable, Logger } from "@nestjs/common"; 3 5 import { Seeder } from "@/modules/database/seed/seed.service"; 4 6 import { Seeder as SeederDecorator } from "@/modules/database/seed/seeder.decorator"; 5 7 8 + const TEMPLATES_DIR = join(__dirname, "templates"); 9 + 10 + const readTemplate = (name: string) => ({ 11 + body: readFileSync(join(TEMPLATES_DIR, `${name}.hbs`), "utf-8"), 12 + css: readFileSync(join(TEMPLATES_DIR, `${name}.css`), "utf-8"), 13 + }); 14 + 15 + interface TemplateData { 16 + name: string; 17 + description: string; 18 + body: string; 19 + css: string; 20 + engine: string; 21 + } 22 + 23 + const templates: TemplateData[] = [ 24 + { 25 + name: "Modern Professional", 26 + description: "A clean, modern template perfect for tech professionals", 27 + ...readTemplate("modern-professional"), 28 + engine: "handlebars", 29 + }, 30 + { 31 + name: "Classic Executive", 32 + description: "A traditional template suitable for executive positions", 33 + ...readTemplate("classic-executive"), 34 + engine: "handlebars", 35 + }, 36 + { 37 + name: "Creative Portfolio", 38 + description: "A creative template for designers and artists", 39 + ...readTemplate("creative-portfolio"), 40 + engine: "handlebars", 41 + }, 42 + ]; 43 + 6 44 @Injectable() 7 45 @SeederDecorator("CV Templates") 8 46 export class CVTemplateSeedService implements Seeder { ··· 13 51 async seed(prisma: PrismaService): Promise<void> { 14 52 this.logger.log("Seeding CV templates..."); 15 53 16 - const templates = [ 17 - { 18 - name: "Modern Professional", 19 - description: "A clean, modern template perfect for tech professionals", 20 - }, 21 - { 22 - name: "Classic Executive", 23 - description: "A traditional template suitable for executive positions", 24 - }, 25 - { 26 - name: "Creative Portfolio", 27 - description: "A creative template for designers and artists", 28 - }, 29 - ]; 30 - 31 54 await Promise.all( 32 55 templates.map(async (template) => { 33 56 const existing = await prisma["cVTemplate"].findFirst({ 34 57 where: { name: template.name }, 35 58 }); 36 59 37 - if (!existing) { 38 - await prisma["cVTemplate"].create({ 39 - data: template, 60 + if (existing) { 61 + await prisma["cVTemplate"].update({ 62 + where: { id: existing.id }, 63 + data: { 64 + description: template.description, 65 + body: template.body, 66 + css: template.css, 67 + engine: template.engine, 68 + }, 40 69 }); 70 + } else { 71 + await prisma["cVTemplate"].create({ data: template }); 41 72 } 42 73 }), 43 74 );
+24
apps/server/src/modules/cv-template/seed/templates/classic-executive.css
··· 1 + body { 2 + font-family: Georgia, 'Times New Roman', serif; 3 + color: #222; 4 + line-height: 1.5; 5 + font-size: 14px; 6 + padding: 2rem; 7 + } 8 + .cv { max-width: 780px; margin: 0 auto; } 9 + .header { text-align: center; border-bottom: 2px solid #222; padding-bottom: 1rem; margin-bottom: 1.5rem; } 10 + .header h1 { font-size: 2rem; font-weight: 400; letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 0.25rem; } 11 + .headline { font-style: italic; color: #555; font-size: 1rem; margin-bottom: 0.5rem; } 12 + .contact-row { display: flex; justify-content: center; flex-wrap: wrap; gap: 1.2rem; font-size: 0.85rem; color: #555; } 13 + .section { margin-bottom: 1.5rem; } 14 + .section h2 { font-size: 1.1rem; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 1px solid #999; padding-bottom: 0.2rem; margin-bottom: 0.75rem; color: #333; } 15 + .entry { margin-bottom: 1rem; } 16 + .entry-header { display: flex; justify-content: space-between; align-items: baseline; } 17 + .org { color: #555; font-size: 0.9rem; margin-bottom: 0.25rem; } 18 + .dates { font-size: 0.85rem; color: #555; white-space: nowrap; } 19 + .description { margin-top: 0.3rem; color: #333; white-space: pre-line; } 20 + .skill-line { font-size: 0.9rem; color: #444; margin-top: 0.3rem; } 21 + .intro { font-style: italic; color: #444; } 22 + @media print { 23 + body { padding: 0; font-size: 11pt; } 24 + }
+66
apps/server/src/modules/cv-template/seed/templates/classic-executive.hbs
··· 1 + <div class="cv"> 2 + <header class="header"> 3 + <h1>{{profile.name}}</h1> 4 + {{#if profile.headline}}<p class="headline">{{profile.headline}}</p>{{/if}} 5 + <div class="contact-row"> 6 + {{#if profile.email}}<span>{{profile.email}}</span>{{/if}} 7 + {{#if profile.phone}}<span>{{profile.phone}}</span>{{/if}} 8 + {{#if profile.city}}<span>{{profile.city}}{{#if profile.country}}, {{profile.country}}{{/if}}</span>{{/if}} 9 + {{#if profile.website}}<span>{{profile.website}}</span>{{/if}} 10 + {{#if profile.linkedInUrl}}<span>{{profile.linkedInUrl}}</span>{{/if}} 11 + </div> 12 + </header> 13 + 14 + {{#if profile.summary}} 15 + <section class="section"> 16 + <h2>Professional Summary</h2> 17 + <p>{{profile.summary}}</p> 18 + </section> 19 + {{/if}} 20 + 21 + {{#if cv.introduction}} 22 + <section class="section"> 23 + <p class="intro">{{cv.introduction}}</p> 24 + </section> 25 + {{/if}} 26 + 27 + {{#if (hasItems experience)}} 28 + <section class="section"> 29 + <h2>Professional Experience</h2> 30 + {{#each experience}} 31 + <div class="entry"> 32 + <div class="entry-header"> 33 + <strong>{{this.role}}</strong> 34 + <span class="dates">{{this.startDate}} &ndash; {{#if this.endDate}}{{this.endDate}}{{else}}Present{{/if}} ({{this.duration}})</span> 35 + </div> 36 + <p class="org">{{this.company}}{{#if this.level}}, {{this.level}}{{/if}}</p> 37 + {{#if this.description}}<p class="description">{{this.description}}</p>{{/if}} 38 + {{#if (hasItems this.skills)}}<p class="skill-line"><em>Key skills:</em> {{join this.skills ", "}}</p>{{/if}} 39 + </div> 40 + {{/each}} 41 + </section> 42 + {{/if}} 43 + 44 + {{#if (hasItems education)}} 45 + <section class="section"> 46 + <h2>Education</h2> 47 + {{#each education}} 48 + <div class="entry"> 49 + <div class="entry-header"> 50 + <strong>{{this.degree}}{{#if this.fieldOfStudy}}, {{this.fieldOfStudy}}{{/if}}</strong> 51 + <span class="dates">{{this.startDate}} &ndash; {{#if this.endDate}}{{this.endDate}}{{else}}Present{{/if}}</span> 52 + </div> 53 + <p class="org">{{this.institution}}</p> 54 + {{#if this.description}}<p class="description">{{this.description}}</p>{{/if}} 55 + </div> 56 + {{/each}} 57 + </section> 58 + {{/if}} 59 + 60 + {{#if (hasItems allSkills)}} 61 + <section class="section"> 62 + <h2>Core Competencies</h2> 63 + <p>{{join allSkills " &bull; "}}</p> 64 + </section> 65 + {{/if}} 66 + </div>
+35
apps/server/src/modules/cv-template/seed/templates/creative-portfolio.css
··· 1 + body { 2 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 3 + color: #2d3748; 4 + line-height: 1.6; 5 + font-size: 14px; 6 + margin: 0; 7 + padding: 0; 8 + } 9 + .cv { display: flex; min-height: 100vh; } 10 + .sidebar { width: 280px; background: #1a1a2e; color: #e2e8f0; padding: 2rem 1.5rem; flex-shrink: 0; } 11 + .sidebar-header h1 { font-size: 1.5rem; font-weight: 700; color: #fff; margin-bottom: 0.25rem; } 12 + .headline { color: #a78bfa; font-size: 0.95rem; } 13 + .sidebar-section { margin-top: 1.5rem; } 14 + .sidebar-section h3 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.1em; color: #a78bfa; margin-bottom: 0.5rem; } 15 + .sidebar-section p { font-size: 0.85rem; margin-bottom: 0.25rem; } 16 + .sidebar-section a { color: #c4b5fd; text-decoration: none; } 17 + .skill-tags { display: flex; flex-wrap: wrap; gap: 0.3rem; } 18 + .tag { background: rgba(167,139,250,0.2); color: #c4b5fd; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; } 19 + .main { flex: 1; padding: 2rem; } 20 + .section { margin-bottom: 1.5rem; } 21 + .section h2 { font-size: 1.15rem; color: #1a1a2e; border-bottom: 2px solid #a78bfa; padding-bottom: 0.2rem; margin-bottom: 0.75rem; } 22 + .entry { margin-bottom: 1rem; } 23 + .entry-header { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; } 24 + .entry-header h3 { font-size: 1rem; font-weight: 600; } 25 + .org { color: #718096; font-size: 0.9rem; } 26 + .dates { font-size: 0.85rem; color: #718096; white-space: nowrap; } 27 + .description { margin-top: 0.3rem; color: #4a5568; white-space: pre-line; } 28 + .intro { color: #718096; font-style: italic; } 29 + .inline-skills { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.4rem; } 30 + .inline-tag { background: #ede9fe; color: #6d28d9; padding: 0.1rem 0.5rem; border-radius: 4px; font-size: 0.75rem; } 31 + @media print { 32 + .cv { display: flex; } 33 + .sidebar { background: #1a1a2e !important; color: #e2e8f0 !important; } 34 + .tag { border: 1px solid #a78bfa; } 35 + }
+72
apps/server/src/modules/cv-template/seed/templates/creative-portfolio.hbs
··· 1 + <div class="cv"> 2 + <aside class="sidebar"> 3 + <div class="sidebar-header"> 4 + <h1>{{profile.name}}</h1> 5 + {{#if profile.headline}}<p class="headline">{{profile.headline}}</p>{{/if}} 6 + </div> 7 + 8 + <div class="sidebar-section"> 9 + <h3>Contact</h3> 10 + {{#if profile.email}}<p>{{profile.email}}</p>{{/if}} 11 + {{#if profile.phone}}<p>{{profile.phone}}</p>{{/if}} 12 + {{#if profile.city}}<p>{{profile.city}}{{#if profile.country}}, {{profile.country}}{{/if}}</p>{{/if}} 13 + {{#if profile.website}}<p><a href="{{profile.website}}">{{profile.website}}</a></p>{{/if}} 14 + {{#if profile.linkedInUrl}}<p><a href="{{profile.linkedInUrl}}">LinkedIn</a></p>{{/if}} 15 + </div> 16 + 17 + {{#if (hasItems allSkills)}} 18 + <div class="sidebar-section"> 19 + <h3>Skills</h3> 20 + <div class="skill-tags">{{#each allSkills}}<span class="tag">{{this}}</span>{{/each}}</div> 21 + </div> 22 + {{/if}} 23 + </aside> 24 + 25 + <main class="main"> 26 + {{#if profile.summary}} 27 + <section class="section"> 28 + <h2>Profile</h2> 29 + <p>{{profile.summary}}</p> 30 + </section> 31 + {{/if}} 32 + 33 + {{#if cv.introduction}} 34 + <section class="section"> 35 + <p class="intro">{{cv.introduction}}</p> 36 + </section> 37 + {{/if}} 38 + 39 + {{#if (hasItems experience)}} 40 + <section class="section"> 41 + <h2>Experience</h2> 42 + {{#each experience}} 43 + <div class="entry"> 44 + <div class="entry-header"> 45 + <h3>{{this.role}}</h3> 46 + <span class="dates">{{this.startDate}} &ndash; {{#if this.endDate}}{{this.endDate}}{{else}}Present{{/if}} &middot; {{this.duration}}</span> 47 + </div> 48 + <p class="org">{{this.company}}{{#if this.level}} &middot; {{this.level}}{{/if}}</p> 49 + {{#if this.description}}<p class="description">{{this.description}}</p>{{/if}} 50 + {{#if (hasItems this.skills)}}<div class="inline-skills">{{#each this.skills}}<span class="inline-tag">{{this}}</span>{{/each}}</div>{{/if}} 51 + </div> 52 + {{/each}} 53 + </section> 54 + {{/if}} 55 + 56 + {{#if (hasItems education)}} 57 + <section class="section"> 58 + <h2>Education</h2> 59 + {{#each education}} 60 + <div class="entry"> 61 + <div class="entry-header"> 62 + <h3>{{this.degree}}</h3> 63 + <span class="dates">{{this.startDate}} &ndash; {{#if this.endDate}}{{this.endDate}}{{else}}Present{{/if}}</span> 64 + </div> 65 + <p class="org">{{this.institution}}{{#if this.fieldOfStudy}} &middot; {{this.fieldOfStudy}}{{/if}}</p> 66 + {{#if this.description}}<p class="description">{{this.description}}</p>{{/if}} 67 + </div> 68 + {{/each}} 69 + </section> 70 + {{/if}} 71 + </main> 72 + </div>
+29
apps/server/src/modules/cv-template/seed/templates/modern-professional.css
··· 1 + body { 2 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 3 + color: #1a1a2e; 4 + line-height: 1.6; 5 + font-size: 14px; 6 + padding: 2rem; 7 + } 8 + .cv { max-width: 800px; margin: 0 auto; } 9 + .header { border-bottom: 2px solid #2563eb; padding-bottom: 1rem; margin-bottom: 1.5rem; } 10 + .header h1 { font-size: 2rem; font-weight: 700; margin-bottom: 0.25rem; } 11 + .headline { color: #2563eb; font-size: 1.1rem; margin-bottom: 0.5rem; } 12 + .contact-row { display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.85rem; color: #555; } 13 + .contact-row a { color: #2563eb; text-decoration: none; } 14 + .section { margin-bottom: 1.5rem; } 15 + .section h2 { font-size: 1.2rem; text-transform: uppercase; letter-spacing: 0.05em; color: #2563eb; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.25rem; margin-bottom: 0.75rem; } 16 + .entry { margin-bottom: 1rem; } 17 + .entry-header { display: flex; justify-content: space-between; align-items: flex-start; } 18 + .entry-header h3 { font-size: 1rem; font-weight: 600; } 19 + .company { color: #555; font-size: 0.9rem; } 20 + .dates { text-align: right; font-size: 0.85rem; color: #555; white-space: nowrap; } 21 + .duration { display: block; font-size: 0.8rem; color: #888; } 22 + .description { margin-top: 0.4rem; color: #333; white-space: pre-line; } 23 + .skills { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.5rem; } 24 + .tag { background: #eff6ff; color: #2563eb; padding: 0.15rem 0.6rem; border-radius: 9999px; font-size: 0.8rem; } 25 + @media print { 26 + body { padding: 0; font-size: 11pt; } 27 + .header { border-color: #2563eb; } 28 + .tag { border: 1px solid #2563eb; background: transparent; } 29 + }
+78
apps/server/src/modules/cv-template/seed/templates/modern-professional.hbs
··· 1 + <div class="cv"> 2 + <header class="header"> 3 + <h1>{{profile.name}}</h1> 4 + {{#if profile.headline}}<p class="headline">{{profile.headline}}</p>{{/if}} 5 + <div class="contact-row"> 6 + {{#if profile.email}}<span>{{profile.email}}</span>{{/if}} 7 + {{#if profile.phone}}<span>{{profile.phone}}</span>{{/if}} 8 + {{#if profile.city}}<span>{{profile.city}}{{#if profile.country}}, {{profile.country}}{{/if}}</span>{{/if}} 9 + {{#if profile.website}}<span><a href="{{profile.website}}">{{profile.website}}</a></span>{{/if}} 10 + {{#if profile.linkedInUrl}}<span><a href="{{profile.linkedInUrl}}">LinkedIn</a></span>{{/if}} 11 + </div> 12 + </header> 13 + 14 + {{#if profile.summary}} 15 + <section class="section"> 16 + <h2>Profile</h2> 17 + <p>{{profile.summary}}</p> 18 + </section> 19 + {{/if}} 20 + 21 + {{#if cv.introduction}} 22 + <section class="section"> 23 + <h2>About This CV</h2> 24 + <p>{{cv.introduction}}</p> 25 + </section> 26 + {{/if}} 27 + 28 + {{#if (hasItems experience)}} 29 + <section class="section"> 30 + <h2>Experience</h2> 31 + {{#each experience}} 32 + <div class="entry"> 33 + <div class="entry-header"> 34 + <div> 35 + <h3>{{this.role}}</h3> 36 + <p class="company">{{this.company}}{{#if this.level}} &middot; {{this.level}}{{/if}}</p> 37 + </div> 38 + <div class="dates"> 39 + <span>{{this.startDate}} &ndash; {{#if this.endDate}}{{this.endDate}}{{else}}Present{{/if}}</span> 40 + <span class="duration">{{this.duration}}</span> 41 + </div> 42 + </div> 43 + {{#if this.description}}<p class="description">{{this.description}}</p>{{/if}} 44 + {{#if (hasItems this.skills)}}<div class="skills">{{#each this.skills}}<span class="tag">{{this}}</span>{{/each}}</div>{{/if}} 45 + </div> 46 + {{/each}} 47 + </section> 48 + {{/if}} 49 + 50 + {{#if (hasItems education)}} 51 + <section class="section"> 52 + <h2>Education</h2> 53 + {{#each education}} 54 + <div class="entry"> 55 + <div class="entry-header"> 56 + <div> 57 + <h3>{{this.degree}}</h3> 58 + <p class="company">{{this.institution}}{{#if this.fieldOfStudy}} &middot; {{this.fieldOfStudy}}{{/if}}</p> 59 + </div> 60 + <div class="dates"> 61 + <span>{{this.startDate}} &ndash; {{#if this.endDate}}{{this.endDate}}{{else}}Present{{/if}}</span> 62 + <span class="duration">{{this.duration}}</span> 63 + </div> 64 + </div> 65 + {{#if this.description}}<p class="description">{{this.description}}</p>{{/if}} 66 + {{#if (hasItems this.skills)}}<div class="skills">{{#each this.skills}}<span class="tag">{{this}}</span>{{/each}}</div>{{/if}} 67 + </div> 68 + {{/each}} 69 + </section> 70 + {{/if}} 71 + 72 + {{#if (hasItems allSkills)}} 73 + <section class="section"> 74 + <h2>Skills</h2> 75 + <div class="skills">{{#each allSkills}}<span class="tag">{{this}}</span>{{/each}}</div> 76 + </section> 77 + {{/if}} 78 + </div>