because I got bored of customising my CV for every job

feat(server): add CV template system with GraphQL support

+38
apps/server/prisma/migrations/20251025120015_add_cv_template_system/migration.sql
··· 1 + -- CreateTable 2 + CREATE TABLE "cv_templates" ( 3 + "id" TEXT NOT NULL, 4 + "name" TEXT NOT NULL, 5 + "description" TEXT, 6 + "category" TEXT NOT NULL, 7 + "isDefault" BOOLEAN NOT NULL DEFAULT false, 8 + "isActive" BOOLEAN NOT NULL DEFAULT true, 9 + "thumbnail" TEXT, 10 + "config" JSONB NOT NULL, 11 + "html" TEXT NOT NULL, 12 + "css" TEXT NOT NULL, 13 + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 14 + "updatedAt" TIMESTAMP(3) NOT NULL, 15 + 16 + CONSTRAINT "cv_templates_pkey" PRIMARY KEY ("id") 17 + ); 18 + 19 + -- CreateTable 20 + CREATE TABLE "cvs" ( 21 + "id" TEXT NOT NULL, 22 + "userId" TEXT NOT NULL, 23 + "templateId" TEXT NOT NULL, 24 + "title" TEXT NOT NULL, 25 + "content" JSONB NOT NULL, 26 + "isPublic" BOOLEAN NOT NULL DEFAULT false, 27 + "isActive" BOOLEAN NOT NULL DEFAULT true, 28 + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 29 + "updatedAt" TIMESTAMP(3) NOT NULL, 30 + 31 + CONSTRAINT "cvs_pkey" PRIMARY KEY ("id") 32 + ); 33 + 34 + -- AddForeignKey 35 + ALTER TABLE "cvs" ADD CONSTRAINT "cvs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 36 + 37 + -- AddForeignKey 38 + ALTER TABLE "cvs" ADD CONSTRAINT "cvs_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "cv_templates"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+14
apps/server/prisma/migrations/20251026154204_simplify_cv_template_schema/migration.sql
··· 1 + -- AlterTable 2 + ALTER TABLE "cv_templates" DROP COLUMN "category", 3 + DROP COLUMN "config", 4 + DROP COLUMN "css", 5 + DROP COLUMN "html", 6 + DROP COLUMN "isActive", 7 + DROP COLUMN "isDefault", 8 + DROP COLUMN "thumbnail"; 9 + 10 + -- AlterTable 11 + ALTER TABLE "cvs" DROP COLUMN "content", 12 + DROP COLUMN "isActive", 13 + DROP COLUMN "isPublic"; 14 +
+18
apps/server/prisma/migrations/20251026203242_add_cv_fields/migration.sql
··· 1 + -- AlterTable 2 + ALTER TABLE "cvs" ADD COLUMN "education" JSONB, 3 + ADD COLUMN "introduction" TEXT; 4 + 5 + -- AlterTable 6 + ALTER TABLE "memberships" RENAME CONSTRAINT "user_organizations_pkey" TO "memberships_pkey"; 7 + 8 + -- RenameForeignKey 9 + ALTER TABLE "memberships" RENAME CONSTRAINT "user_organizations_organizationId_fkey" TO "memberships_organizationId_fkey"; 10 + 11 + -- RenameForeignKey 12 + ALTER TABLE "memberships" RENAME CONSTRAINT "user_organizations_organizationRoleId_fkey" TO "memberships_organizationRoleId_fkey"; 13 + 14 + -- RenameForeignKey 15 + ALTER TABLE "memberships" RENAME CONSTRAINT "user_organizations_userId_fkey" TO "memberships_userId_fkey"; 16 + 17 + -- RenameIndex 18 + ALTER INDEX "user_organizations_userId_organizationId_key" RENAME TO "memberships_userId_organizationId_key";
+18
apps/server/src/modules/cv-template/cv-template.mapper.ts
··· 1 + import type { CVTemplate as PrismaCVTemplate } from "@prisma/client"; 2 + import { CVTemplate } from "./graphql/cv-template.type"; 3 + 4 + export const cvTemplateMapper = { 5 + toDomain: (template: PrismaCVTemplate): CVTemplate => { 6 + return new CVTemplate({ 7 + id: template.id, 8 + name: template.name, 9 + description: template.description, 10 + createdAt: template.createdAt, 11 + updatedAt: template.updatedAt, 12 + }); 13 + }, 14 + 15 + mapToDomain: (templates: PrismaCVTemplate[]): CVTemplate[] => { 16 + return templates.map((template) => cvTemplateMapper.toDomain(template)); 17 + }, 18 + };
+23
apps/server/src/modules/cv-template/cv-template.module.ts
··· 1 + import { Module } from "@nestjs/common"; 2 + import { AuthModule } from "@/modules/auth/auth.module"; 3 + import { BaseModule } from "@/modules/base/base.module"; 4 + import { DatabaseModule } from "@/modules/database/database.module"; 5 + import { CVService } from "./cv.service"; 6 + import { CVTemplateService } from "./cv-template.service"; 7 + import { CVResolver, CVTemplateResolver } from "./graphql/cv-template.resolver"; 8 + import { CVUserFieldResolver } from "./graphql/user-field.resolver"; 9 + import { CVTemplateSeedService } from "./seed/cv-template.seed"; 10 + 11 + @Module({ 12 + imports: [DatabaseModule, BaseModule, AuthModule], 13 + providers: [ 14 + CVTemplateService, 15 + CVService, 16 + CVTemplateResolver, 17 + CVResolver, 18 + CVTemplateSeedService, 19 + CVUserFieldResolver, 20 + ], 21 + exports: [CVTemplateService, CVService], 22 + }) 23 + export class CVTemplateModule {}
+36
apps/server/src/modules/cv-template/cv-template.service.ts
··· 1 + import { Injectable } from "@nestjs/common"; 2 + import { notFound } from "@/modules/base/not-found.util"; 3 + import { PrismaService } from "@/modules/database/prisma.service"; 4 + import { cvTemplateMapper } from "./cv-template.mapper"; 5 + 6 + type CVTemplateFilters = Record<string, never>; 7 + 8 + @Injectable() 9 + export class CVTemplateService { 10 + constructor(private readonly prisma: PrismaService) {} 11 + 12 + async findMany(filters: CVTemplateFilters = {}) { 13 + const templates = await this.prisma["cVTemplate"].findMany({ 14 + orderBy: { createdAt: "desc" }, 15 + }); 16 + 17 + return cvTemplateMapper.mapToDomain(templates); 18 + } 19 + 20 + async count(filters: CVTemplateFilters = {}) { 21 + return this.prisma["cVTemplate"].count(); 22 + } 23 + 24 + async findById(id: string) { 25 + const template = await this.prisma["cVTemplate"].findUnique({ 26 + where: { id }, 27 + }); 28 + 29 + return template ? cvTemplateMapper.toDomain(template) : null; 30 + } 31 + 32 + async findByIdOrFail(id: string) { 33 + const template = await this.findById(id); 34 + return template ?? notFound("CVTemplate", "id", id); 35 + } 36 + }
+27
apps/server/src/modules/cv-template/cv.mapper.ts
··· 1 + import type { 2 + CV as PrismaCV, 3 + CVTemplate as PrismaCVTemplate, 4 + } from "@prisma/client"; 5 + import { cvTemplateMapper } from "./cv-template.mapper"; 6 + import { CV } from "./graphql/cv.type"; 7 + 8 + export type PrismaCVWithTemplate = PrismaCV & { 9 + template: PrismaCVTemplate; 10 + }; 11 + 12 + export const cvMapper = { 13 + toDomain: (cv: PrismaCVWithTemplate): CV => { 14 + return new CV({ 15 + id: cv.id, 16 + title: cv.title, 17 + introduction: cv.introduction ?? null, 18 + template: cvTemplateMapper.toDomain(cv.template), 19 + createdAt: cv.createdAt, 20 + updatedAt: cv.updatedAt, 21 + }); 22 + }, 23 + 24 + mapToDomain: (cvs: PrismaCVWithTemplate[]): CV[] => { 25 + return cvs.map((cv) => cvMapper.toDomain(cv)); 26 + }, 27 + };
+97
apps/server/src/modules/cv-template/cv.service.ts
··· 1 + import { Injectable } from "@nestjs/common"; 2 + import { notFound } from "@/modules/base/not-found.util"; 3 + import { PrismaService } from "@/modules/database/prisma.service"; 4 + import { cvMapper } from "./cv.mapper"; 5 + 6 + type CVFilters = { 7 + userId: string; 8 + }; 9 + 10 + @Injectable() 11 + export class CVService { 12 + constructor(private readonly prisma: PrismaService) {} 13 + 14 + async findMany(filters: CVFilters) { 15 + const cvs = await this.prisma["cV"].findMany({ 16 + where: { userId: filters.userId }, 17 + orderBy: { updatedAt: "desc" }, 18 + include: { 19 + template: true, 20 + }, 21 + }); 22 + 23 + return cvMapper.mapToDomain(cvs); 24 + } 25 + 26 + async count(filters: CVFilters) { 27 + return this.prisma["cV"].count({ 28 + where: { userId: filters.userId }, 29 + }); 30 + } 31 + 32 + async findById(id: string) { 33 + const cv = await this.prisma["cV"].findUnique({ 34 + where: { id }, 35 + include: { 36 + template: true, 37 + }, 38 + }); 39 + 40 + return cv ? cvMapper.toDomain(cv) : null; 41 + } 42 + 43 + async findByIdOrFail(id: string) { 44 + const cv = await this.findById(id); 45 + return cv ?? notFound("CV", "id", id); 46 + } 47 + 48 + async create( 49 + userId: string, 50 + data: { 51 + templateId: string; 52 + title: string; 53 + }, 54 + ) { 55 + const cv = await this.prisma["cV"].create({ 56 + data: { 57 + userId, 58 + templateId: data.templateId, 59 + title: data.title, 60 + }, 61 + include: { 62 + template: true, 63 + }, 64 + }); 65 + 66 + return cvMapper.toDomain(cv); 67 + } 68 + 69 + async update( 70 + id: string, 71 + data: { 72 + title?: string; 73 + }, 74 + ) { 75 + const updateData: Record<string, unknown> = {}; 76 + 77 + if (data.title !== undefined) { 78 + updateData["title"] = data.title; 79 + } 80 + 81 + const cv = await this.prisma["cV"].update({ 82 + where: { id }, 83 + data: updateData, 84 + include: { 85 + template: true, 86 + }, 87 + }); 88 + 89 + return cvMapper.toDomain(cv); 90 + } 91 + 92 + async delete(id: string): Promise<void> { 93 + await this.prisma["cV"].delete({ 94 + where: { id }, 95 + }); 96 + } 97 + }
+16
apps/server/src/modules/cv-template/graphql/cv-args.type.ts
··· 1 + import { ArgsType, Field, Int } from "@nestjs/graphql"; 2 + 3 + @ArgsType() 4 + export class CVArgs { 5 + @Field(() => Int, { nullable: true }) 6 + first?: number | null; 7 + 8 + @Field(() => String, { nullable: true }) 9 + after?: string | null; 10 + 11 + @Field(() => Int, { nullable: true }) 12 + last?: number | null; 13 + 14 + @Field(() => String, { nullable: true }) 15 + before?: string | null; 16 + }
+35
apps/server/src/modules/cv-template/graphql/cv-connection.type.ts
··· 1 + import { Field, Int, ObjectType } from "@nestjs/graphql"; 2 + import { PageInfo, PaginationResult } from "@/modules/base/pagination.types"; 3 + import { CV } from "./cv.type"; 4 + import { CVEdge } from "./cv-edge.type"; 5 + 6 + @ObjectType() 7 + export class CVConnection { 8 + @Field(() => [CVEdge]) 9 + edges: CVEdge[]; 10 + 11 + @Field(() => PageInfo) 12 + pageInfo: PageInfo; 13 + 14 + @Field(() => Int) 15 + totalCount: number; 16 + 17 + constructor(edges: CVEdge[], pageInfo: PageInfo, totalCount: number) { 18 + this.edges = edges; 19 + this.pageInfo = pageInfo; 20 + this.totalCount = totalCount; 21 + } 22 + 23 + /** 24 + * Static factory method to create a connection from pagination result 25 + */ 26 + static fromPaginationResult(result: PaginationResult<CV>): CVConnection { 27 + const edges = result.edges.map((edge) => 28 + CVEdge.fromPaginationEdge({ 29 + cursor: edge.cursor, 30 + node: edge.node, 31 + }), 32 + ); 33 + return new CVConnection(edges, result.pageInfo, result.totalCount); 34 + } 35 + }
+13
apps/server/src/modules/cv-template/graphql/cv-edge.type.ts
··· 1 + import { Field, ObjectType } from "@nestjs/graphql"; 2 + import { GraphQLString } from "graphql"; 3 + import { BaseEdge } from "@/modules/base/connection.types"; 4 + import { CV } from "./cv.type"; 5 + 6 + @ObjectType() 7 + export class CVEdge extends BaseEdge<CV> { 8 + @Field(() => GraphQLString) 9 + declare cursor: string; 10 + 11 + @Field(() => CV) 12 + declare node: CV; 13 + }
+16
apps/server/src/modules/cv-template/graphql/cv-input.type.ts
··· 1 + import { Field, InputType } from "@nestjs/graphql"; 2 + 3 + @InputType() 4 + export class CreateCVInput { 5 + @Field(() => String) 6 + templateId!: string; 7 + 8 + @Field(() => String) 9 + title!: string; 10 + } 11 + 12 + @InputType() 13 + export class UpdateCVInput { 14 + @Field(() => String, { nullable: true }) 15 + title?: string; 16 + }
+16
apps/server/src/modules/cv-template/graphql/cv-template-args.type.ts
··· 1 + import { ArgsType, Field, Int } from "@nestjs/graphql"; 2 + 3 + @ArgsType() 4 + export class CVTemplateArgs { 5 + @Field(() => Int, { nullable: true }) 6 + first?: number | null; 7 + 8 + @Field(() => String, { nullable: true }) 9 + after?: string | null; 10 + 11 + @Field(() => Int, { nullable: true }) 12 + last?: number | null; 13 + 14 + @Field(() => String, { nullable: true }) 15 + before?: string | null; 16 + }
+37
apps/server/src/modules/cv-template/graphql/cv-template-connection.type.ts
··· 1 + import { Field, Int, ObjectType } from "@nestjs/graphql"; 2 + import { PageInfo, PaginationResult } from "@/modules/base/pagination.types"; 3 + import { CVTemplate } from "./cv-template.type"; 4 + import { CVTemplateEdge } from "./cv-template-edge.type"; 5 + 6 + @ObjectType() 7 + export class CVTemplateConnection { 8 + @Field(() => [CVTemplateEdge]) 9 + edges: CVTemplateEdge[]; 10 + 11 + @Field(() => PageInfo) 12 + pageInfo: PageInfo; 13 + 14 + @Field(() => Int) 15 + totalCount: number; 16 + 17 + constructor(edges: CVTemplateEdge[], pageInfo: PageInfo, totalCount: number) { 18 + this.edges = edges; 19 + this.pageInfo = pageInfo; 20 + this.totalCount = totalCount; 21 + } 22 + 23 + /** 24 + * Static factory method to create a connection from pagination result 25 + */ 26 + static fromPaginationResult( 27 + result: PaginationResult<CVTemplate>, 28 + ): CVTemplateConnection { 29 + const edges = result.edges.map((edge) => 30 + CVTemplateEdge.fromPaginationEdge({ 31 + cursor: edge.cursor, 32 + node: edge.node, 33 + }), 34 + ); 35 + return new CVTemplateConnection(edges, result.pageInfo, result.totalCount); 36 + } 37 + }
+13
apps/server/src/modules/cv-template/graphql/cv-template-edge.type.ts
··· 1 + import { Field, ObjectType } from "@nestjs/graphql"; 2 + import { GraphQLString } from "graphql"; 3 + import { BaseEdge } from "@/modules/base/connection.types"; 4 + import { CVTemplate } from "./cv-template.type"; 5 + 6 + @ObjectType() 7 + export class CVTemplateEdge extends BaseEdge<CVTemplate> { 8 + @Field(() => GraphQLString) 9 + declare cursor: string; 10 + 11 + @Field(() => CVTemplate) 12 + declare node: CVTemplate; 13 + }
+105
apps/server/src/modules/cv-template/graphql/cv-template.resolver.ts
··· 1 + import { UseGuards } from "@nestjs/common"; 2 + import { Args, Context, Mutation, Query, Resolver } from "@nestjs/graphql"; 3 + import { JwtAuthGuard } from "@/modules/auth/jwt-auth.guard"; 4 + import { PaginationService } from "@/modules/base/pagination.service"; 5 + import { CVService } from "../cv.service"; 6 + import { CVTemplateService } from "../cv-template.service"; 7 + import { CV } from "./cv.type"; 8 + import { CVArgs } from "./cv-args.type"; 9 + import { CVConnection } from "./cv-connection.type"; 10 + import { CreateCVInput, UpdateCVInput } from "./cv-input.type"; 11 + import { CVTemplate } from "./cv-template.type"; 12 + import { CVTemplateArgs } from "./cv-template-args.type"; 13 + import { CVTemplateConnection } from "./cv-template-connection.type"; 14 + 15 + @Resolver(() => CVTemplate) 16 + export class CVTemplateResolver { 17 + constructor( 18 + private readonly cvTemplateService: CVTemplateService, 19 + private readonly paginationService: PaginationService, 20 + ) {} 21 + 22 + @Query(() => CVTemplateConnection) 23 + async cvTemplates( 24 + @Args() args: CVTemplateArgs = {}, 25 + ): Promise<CVTemplateConnection> { 26 + const options = this.paginationService.parsePaginationArgs(args); 27 + 28 + const [items, totalCount] = await Promise.all([ 29 + this.cvTemplateService.findMany({}), 30 + this.cvTemplateService.count({}), 31 + ]); 32 + 33 + const result = this.paginationService.buildPaginationResult( 34 + items, 35 + totalCount, 36 + options, 37 + ); 38 + 39 + return CVTemplateConnection.fromPaginationResult(result); 40 + } 41 + 42 + @Query(() => CVTemplate, { nullable: true }) 43 + async cvTemplate(@Args("id") id: string) { 44 + return this.cvTemplateService.findById(id); 45 + } 46 + } 47 + 48 + @Resolver(() => CV) 49 + export class CVResolver { 50 + constructor( 51 + private readonly cvService: CVService, 52 + private readonly paginationService: PaginationService, 53 + ) {} 54 + 55 + @Query(() => CVConnection) 56 + @UseGuards(JwtAuthGuard) 57 + async myCVs( 58 + @Args() args: CVArgs = {}, 59 + @Context() context: { req: { user: { id: string } } }, 60 + ): Promise<CVConnection> { 61 + const userId = context.req.user.id; 62 + const options = this.paginationService.parsePaginationArgs(args); 63 + 64 + const [items, totalCount] = await Promise.all([ 65 + this.cvService.findMany({ userId }), 66 + this.cvService.count({ userId }), 67 + ]); 68 + 69 + const result = this.paginationService.buildPaginationResult( 70 + items, 71 + totalCount, 72 + options, 73 + ); 74 + 75 + return CVConnection.fromPaginationResult(result); 76 + } 77 + 78 + @Query(() => CV, { nullable: true }) 79 + @UseGuards(JwtAuthGuard) 80 + async cv(@Args("id") id: string) { 81 + return this.cvService.findById(id); 82 + } 83 + 84 + @Mutation(() => CV) 85 + @UseGuards(JwtAuthGuard) 86 + async createCV( 87 + @Args("input") input: CreateCVInput, 88 + @Context() context: { req: { user: { id: string } } }, 89 + ) { 90 + const userId = context.req.user.id; 91 + return this.cvService.create(userId, input); 92 + } 93 + 94 + @Mutation(() => CV) 95 + @UseGuards(JwtAuthGuard) 96 + async updateCV(@Args("id") id: string, @Args("input") input: UpdateCVInput) { 97 + return this.cvService.update(id, input); 98 + } 99 + 100 + @Mutation(() => Boolean) 101 + @UseGuards(JwtAuthGuard) 102 + async deleteCV(@Args("id") id: string) { 103 + return this.cvService.delete(id); 104 + } 105 + }
+33
apps/server/src/modules/cv-template/graphql/cv-template.type.ts
··· 1 + import { Field, ID, ObjectType } from "@nestjs/graphql"; 2 + 3 + @ObjectType() 4 + export class CVTemplate { 5 + @Field(() => ID) 6 + id: string; 7 + 8 + @Field(() => Date) 9 + createdAt: Date; 10 + 11 + @Field(() => Date) 12 + updatedAt: Date; 13 + 14 + @Field(() => String) 15 + name: string; 16 + 17 + @Field(() => String, { nullable: true }) 18 + description?: string | null; 19 + 20 + constructor(data: { 21 + id: string; 22 + name: string; 23 + description?: string | null; 24 + createdAt: Date; 25 + updatedAt: Date; 26 + }) { 27 + this.id = data.id; 28 + this.createdAt = data.createdAt; 29 + this.updatedAt = data.updatedAt; 30 + this.name = data.name; 31 + this.description = data.description ?? null; 32 + } 33 + }
+39
apps/server/src/modules/cv-template/graphql/cv.type.ts
··· 1 + import { Field, ID, ObjectType } from "@nestjs/graphql"; 2 + import { CVTemplate } from "./cv-template.type"; 3 + 4 + @ObjectType() 5 + export class CV { 6 + @Field(() => ID) 7 + id: string; 8 + 9 + @Field(() => Date) 10 + createdAt: Date; 11 + 12 + @Field(() => Date) 13 + updatedAt: Date; 14 + 15 + @Field(() => String) 16 + title: string; 17 + 18 + @Field(() => String, { nullable: true }) 19 + introduction: string | null; 20 + 21 + @Field(() => CVTemplate) 22 + template: CVTemplate; 23 + 24 + constructor(data: { 25 + id: string; 26 + title: string; 27 + introduction?: string | null; 28 + template: CVTemplate; 29 + createdAt: Date; 30 + updatedAt: Date; 31 + }) { 32 + this.id = data.id; 33 + this.createdAt = data.createdAt; 34 + this.updatedAt = data.updatedAt; 35 + this.title = data.title; 36 + this.introduction = data.introduction ?? null; 37 + this.template = data.template; 38 + } 39 + }
+35
apps/server/src/modules/cv-template/graphql/user-field.resolver.ts
··· 1 + import { UseGuards } from "@nestjs/common"; 2 + import { Args, Parent, ResolveField, Resolver } from "@nestjs/graphql"; 3 + import { JwtAuthGuard } from "@/modules/auth/jwt-auth.guard"; 4 + import { PaginationService } from "@/modules/base/pagination.service"; 5 + import { User } from "@/modules/user/user.type"; 6 + import { CVService } from "../cv.service"; 7 + import { CVArgs } from "./cv-args.type"; 8 + import { CVConnection } from "./cv-connection.type"; 9 + 10 + @Resolver(() => User) 11 + @UseGuards(JwtAuthGuard) 12 + export class CVUserFieldResolver { 13 + constructor( 14 + private readonly cvService: CVService, 15 + private readonly paginationService: PaginationService, 16 + ) {} 17 + 18 + @ResolveField(() => CVConnection, { nullable: true }) 19 + async cvs( 20 + @Parent() user: User, 21 + @Args() args: CVArgs = {}, 22 + ): Promise<CVConnection> { 23 + const options = this.paginationService.parsePaginationArgs(args); 24 + const [items, totalCount] = await Promise.all([ 25 + this.cvService.findMany({ userId: user.id }), 26 + this.cvService.count({ userId: user.id }), 27 + ]); 28 + const result = this.paginationService.buildPaginationResult( 29 + items, 30 + totalCount, 31 + options, 32 + ); 33 + return CVConnection.fromPaginationResult(result); 34 + } 35 + }