because I got bored of customising my CV for every job

feat(server): add education and institution modules

Changed files
+817
apps
server
prisma
migrations
20251026204100_add_application_and_education_entities
20251026204243_rename_education_to_institution
20251026204502_remove_education_from_cv
20251103203729_add_skills_to_education
src
+75
apps/server/prisma/migrations/20251026204100_add_application_and_education_entities/migration.sql
··· 1 + /* 2 + Warnings: 3 + 4 + - You are about to drop the column `education` on the `cvs` table. All the data in the column will be lost. 5 + 6 + */ 7 + -- AlterTable 8 + ALTER TABLE "cvs" DROP COLUMN "education"; 9 + 10 + -- CreateTable 11 + CREATE TABLE "applications" ( 12 + "id" TEXT NOT NULL, 13 + "userId" TEXT NOT NULL, 14 + "vacancyId" TEXT NOT NULL, 15 + "cvId" TEXT, 16 + "coverLetter" TEXT, 17 + "status" TEXT NOT NULL DEFAULT 'pending', 18 + "appliedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 19 + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 20 + "updatedAt" TIMESTAMP(3) NOT NULL, 21 + 22 + CONSTRAINT "applications_pkey" PRIMARY KEY ("id") 23 + ); 24 + 25 + -- CreateTable 26 + CREATE TABLE "educations" ( 27 + "id" TEXT NOT NULL, 28 + "name" TEXT NOT NULL, 29 + "description" TEXT, 30 + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 31 + "updatedAt" TIMESTAMP(3) NOT NULL, 32 + 33 + CONSTRAINT "educations_pkey" PRIMARY KEY ("id") 34 + ); 35 + 36 + -- CreateTable 37 + CREATE TABLE "user_educations" ( 38 + "id" TEXT NOT NULL, 39 + "userId" TEXT NOT NULL, 40 + "cvId" TEXT, 41 + "institutionId" TEXT NOT NULL, 42 + "degree" TEXT NOT NULL, 43 + "fieldOfStudy" TEXT, 44 + "startDate" TIMESTAMP(3) NOT NULL, 45 + "endDate" TIMESTAMP(3), 46 + "description" TEXT, 47 + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 48 + "updatedAt" TIMESTAMP(3) NOT NULL, 49 + 50 + CONSTRAINT "user_educations_pkey" PRIMARY KEY ("id") 51 + ); 52 + 53 + -- CreateIndex 54 + CREATE UNIQUE INDEX "applications_userId_vacancyId_key" ON "applications"("userId", "vacancyId"); 55 + 56 + -- CreateIndex 57 + CREATE UNIQUE INDEX "educations_name_key" ON "educations"("name"); 58 + 59 + -- AddForeignKey 60 + ALTER TABLE "applications" ADD CONSTRAINT "applications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 61 + 62 + -- AddForeignKey 63 + ALTER TABLE "applications" ADD CONSTRAINT "applications_vacancyId_fkey" FOREIGN KEY ("vacancyId") REFERENCES "vacancies"("id") ON DELETE CASCADE ON UPDATE CASCADE; 64 + 65 + -- AddForeignKey 66 + ALTER TABLE "applications" ADD CONSTRAINT "applications_cvId_fkey" FOREIGN KEY ("cvId") REFERENCES "cvs"("id") ON DELETE SET NULL ON UPDATE CASCADE; 67 + 68 + -- AddForeignKey 69 + ALTER TABLE "user_educations" ADD CONSTRAINT "user_educations_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 70 + 71 + -- AddForeignKey 72 + ALTER TABLE "user_educations" ADD CONSTRAINT "user_educations_cvId_fkey" FOREIGN KEY ("cvId") REFERENCES "cvs"("id") ON DELETE CASCADE ON UPDATE CASCADE; 73 + 74 + -- AddForeignKey 75 + ALTER TABLE "user_educations" ADD CONSTRAINT "user_educations_institutionId_fkey" FOREIGN KEY ("institutionId") REFERENCES "educations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+58
apps/server/prisma/migrations/20251026204243_rename_education_to_institution/migration.sql
··· 1 + /* 2 + Warnings: 3 + 4 + - You are about to drop the column `name` on the `educations` table. All the data in the column will be lost. 5 + - You are about to drop the `user_educations` table. If the table is not empty, all the data it contains will be lost. 6 + - Added the required column `degree` to the `educations` table without a default value. This is not possible if the table is not empty. 7 + - Added the required column `institutionId` to the `educations` table without a default value. This is not possible if the table is not empty. 8 + - Added the required column `startDate` to the `educations` table without a default value. This is not possible if the table is not empty. 9 + - Added the required column `userId` to the `educations` table without a default value. This is not possible if the table is not empty. 10 + 11 + */ 12 + -- DropForeignKey 13 + ALTER TABLE "public"."user_educations" DROP CONSTRAINT "user_educations_cvId_fkey"; 14 + 15 + -- DropForeignKey 16 + ALTER TABLE "public"."user_educations" DROP CONSTRAINT "user_educations_institutionId_fkey"; 17 + 18 + -- DropForeignKey 19 + ALTER TABLE "public"."user_educations" DROP CONSTRAINT "user_educations_userId_fkey"; 20 + 21 + -- DropIndex 22 + DROP INDEX "public"."educations_name_key"; 23 + 24 + -- AlterTable 25 + ALTER TABLE "educations" DROP COLUMN "name", 26 + ADD COLUMN "cvId" TEXT, 27 + ADD COLUMN "degree" TEXT NOT NULL, 28 + ADD COLUMN "endDate" TIMESTAMP(3), 29 + ADD COLUMN "fieldOfStudy" TEXT, 30 + ADD COLUMN "institutionId" TEXT NOT NULL, 31 + ADD COLUMN "startDate" TIMESTAMP(3) NOT NULL, 32 + ADD COLUMN "userId" TEXT NOT NULL; 33 + 34 + -- DropTable 35 + DROP TABLE "public"."user_educations"; 36 + 37 + -- CreateTable 38 + CREATE TABLE "institutions" ( 39 + "id" TEXT NOT NULL, 40 + "name" TEXT NOT NULL, 41 + "description" TEXT, 42 + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 43 + "updatedAt" TIMESTAMP(3) NOT NULL, 44 + 45 + CONSTRAINT "institutions_pkey" PRIMARY KEY ("id") 46 + ); 47 + 48 + -- CreateIndex 49 + CREATE UNIQUE INDEX "institutions_name_key" ON "institutions"("name"); 50 + 51 + -- AddForeignKey 52 + ALTER TABLE "educations" ADD CONSTRAINT "educations_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 53 + 54 + -- AddForeignKey 55 + ALTER TABLE "educations" ADD CONSTRAINT "educations_cvId_fkey" FOREIGN KEY ("cvId") REFERENCES "cvs"("id") ON DELETE CASCADE ON UPDATE CASCADE; 56 + 57 + -- AddForeignKey 58 + ALTER TABLE "educations" ADD CONSTRAINT "educations_institutionId_fkey" FOREIGN KEY ("institutionId") REFERENCES "institutions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+11
apps/server/prisma/migrations/20251026204502_remove_education_from_cv/migration.sql
··· 1 + /* 2 + Warnings: 3 + 4 + - You are about to drop the column `cvId` on the `educations` table. All the data in the column will be lost. 5 + 6 + */ 7 + -- DropForeignKey 8 + ALTER TABLE "public"."educations" DROP CONSTRAINT "educations_cvId_fkey"; 9 + 10 + -- AlterTable 11 + ALTER TABLE "educations" DROP COLUMN "cvId";
+16
apps/server/prisma/migrations/20251103203729_add_skills_to_education/migration.sql
··· 1 + -- CreateTable 2 + CREATE TABLE "_EducationToSkill" ( 3 + "A" TEXT NOT NULL, 4 + "B" TEXT NOT NULL, 5 + 6 + CONSTRAINT "_EducationToSkill_AB_pkey" PRIMARY KEY ("A","B") 7 + ); 8 + 9 + -- CreateIndex 10 + CREATE INDEX "_EducationToSkill_B_index" ON "_EducationToSkill"("B"); 11 + 12 + -- AddForeignKey 13 + ALTER TABLE "_EducationToSkill" ADD CONSTRAINT "_EducationToSkill_A_fkey" FOREIGN KEY ("A") REFERENCES "educations"("id") ON DELETE CASCADE ON UPDATE CASCADE; 14 + 15 + -- AddForeignKey 16 + ALTER TABLE "_EducationToSkill" ADD CONSTRAINT "_EducationToSkill_B_fkey" FOREIGN KEY ("B") REFERENCES "skills"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+27
apps/server/src/modules/education/education.entity.ts
··· 1 + import { BaseEntity } from "@/domain/base.entity"; 2 + import type { Skill } from "@/modules/job-experience/skill/skill.entity"; 3 + import { User } from "@/modules/user/user.entity"; 4 + import { Institution } from "./institution.entity"; 5 + 6 + export class Education extends BaseEntity { 7 + constructor( 8 + id: string, 9 + public readonly userId: string, 10 + public readonly user: User | null, 11 + public readonly institutionId: string, 12 + public readonly institution: Institution | null, 13 + public readonly degree: string, 14 + public readonly fieldOfStudy: string | null, 15 + public readonly startDate: Date, 16 + public readonly endDate: Date | null, 17 + public readonly description: string | null, 18 + createdAt: Date, 19 + updatedAt: Date, 20 + public readonly skills?: Skill[], 21 + ) { 22 + super(id, createdAt, updatedAt); 23 + if (skills !== undefined) { 24 + this.skills = skills; 25 + } 26 + } 27 + }
+61
apps/server/src/modules/education/education.mapper.ts
··· 1 + import { Injectable } from "@nestjs/common"; 2 + import type { Prisma } from "@prisma/client"; 3 + import type { BaseMapper } from "@/modules/base/mapper.interface"; 4 + import { SkillMapper } from "@/modules/job-experience/skill/skill.mapper"; 5 + import { UserMapper } from "@/modules/user/user.mapper"; 6 + import { Education } from "./education.entity"; 7 + import { InstitutionMapper } from "./institution.mapper"; 8 + 9 + type PrismaEducation = Prisma.EducationGetPayload<{ 10 + include: { 11 + institution: true; 12 + user: true; 13 + skills: true; 14 + }; 15 + }>; 16 + 17 + @Injectable() 18 + export class EducationMapper implements BaseMapper<PrismaEducation, Education> { 19 + constructor( 20 + private readonly institutionMapper: InstitutionMapper, 21 + private readonly userMapper: UserMapper, 22 + private readonly skillMapper: SkillMapper, 23 + ) {} 24 + 25 + /** 26 + * Maps a Prisma Education entity to a domain Education entity 27 + * Uses overloads to return the correct type based on input 28 + */ 29 + toDomain(prismaEducation: null): null; 30 + toDomain(prismaEducation: PrismaEducation): Education; 31 + toDomain(prismaEducation: PrismaEducation | null): Education | null; 32 + toDomain(prismaEducation: PrismaEducation | null): Education | null { 33 + if (!prismaEducation) { 34 + return null; 35 + } 36 + 37 + return new Education( 38 + prismaEducation.id, 39 + prismaEducation.userId, 40 + this.userMapper.toDomain(prismaEducation.user), 41 + prismaEducation.institutionId, 42 + this.institutionMapper.toDomain(prismaEducation.institution), 43 + prismaEducation.degree, 44 + prismaEducation.fieldOfStudy, 45 + prismaEducation.startDate, 46 + prismaEducation.endDate, 47 + prismaEducation.description, 48 + prismaEducation.createdAt, 49 + prismaEducation.updatedAt, 50 + prismaEducation.skills 51 + ? this.skillMapper.mapToDomain(prismaEducation.skills) 52 + : undefined, 53 + ); 54 + } 55 + 56 + mapToDomain(prismaEducations: PrismaEducation[]): Education[] { 57 + return prismaEducations 58 + .map((education) => this.toDomain(education)) 59 + .filter((education): education is Education => education !== null); 60 + } 61 + }
+28
apps/server/src/modules/education/education.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 { SkillModule } from "@/modules/job-experience/skill/skill.module"; 6 + import { UserModule } from "@/modules/user/user.module"; 7 + import { EducationMapper } from "./education.mapper"; 8 + import { EducationService } from "./education.service"; 9 + import { EducationResolver } from "./graphql/education.resolver"; 10 + import { InstitutionResolver } from "./graphql/institution.resolver"; 11 + import { EducationUserFieldResolver } from "./graphql/user-field.resolver"; 12 + import { InstitutionMapper } from "./institution.mapper"; 13 + import { InstitutionService } from "./institution.service"; 14 + 15 + @Module({ 16 + imports: [DatabaseModule, BaseModule, AuthModule, UserModule, SkillModule], 17 + providers: [ 18 + EducationService, 19 + EducationMapper, 20 + EducationResolver, 21 + InstitutionService, 22 + InstitutionMapper, 23 + InstitutionResolver, 24 + EducationUserFieldResolver, 25 + ], 26 + exports: [EducationService, EducationMapper], 27 + }) 28 + export class EducationModule {}
+137
apps/server/src/modules/education/education.service.ts
··· 1 + import { Injectable } from "@nestjs/common"; 2 + import type { Prisma } from "@prisma/client"; 3 + import { notFound } from "@/modules/base/not-found.util"; 4 + import { PaginationService } from "@/modules/base/pagination.service"; 5 + import type { 6 + PaginationOptions, 7 + PaginationResult, 8 + } from "@/modules/base/pagination.types"; 9 + import { PrismaService } from "@/modules/database/prisma.service"; 10 + import { Education } from "./education.entity"; 11 + import { EducationMapper } from "./education.mapper"; 12 + 13 + @Injectable() 14 + export class EducationService { 15 + constructor( 16 + private readonly prisma: PrismaService, 17 + private readonly educationMapper: EducationMapper, 18 + private readonly paginationService: PaginationService, 19 + ) {} 20 + 21 + async findMany( 22 + userId?: string, 23 + options: PaginationOptions = {}, 24 + ): Promise<PaginationResult<Education>> { 25 + const where: Prisma.EducationWhereInput = userId ? { userId } : {}; 26 + const queryOptions = this.paginationService.buildQueryOptions( 27 + where, 28 + { startDate: "desc" }, 29 + options, 30 + ); 31 + 32 + const [items, totalCount] = await Promise.all([ 33 + this.prisma["education"].findMany({ 34 + ...queryOptions, 35 + include: { 36 + institution: true, 37 + user: true, 38 + skills: true, 39 + }, 40 + }), 41 + this.count(userId), 42 + ]); 43 + 44 + const domainEducations = this.educationMapper.mapToDomain(items); 45 + return this.paginationService.buildPaginationResult( 46 + domainEducations, 47 + totalCount, 48 + options, 49 + ); 50 + } 51 + 52 + async findManyForUser( 53 + userId: string, 54 + options: PaginationOptions = {}, 55 + ): Promise<PaginationResult<Education>> { 56 + return this.findMany(userId, options); 57 + } 58 + 59 + async findById(id: string): Promise<Education | null> { 60 + const education = await this.prisma["education"].findUnique({ 61 + where: { id }, 62 + include: { 63 + institution: true, 64 + user: true, 65 + skills: true, 66 + }, 67 + }); 68 + return this.educationMapper.toDomain(education); 69 + } 70 + 71 + async count(userId?: string): Promise<number> { 72 + const where: Prisma.EducationWhereInput = userId ? { userId } : {}; 73 + return this.prisma["education"].count({ 74 + where, 75 + }); 76 + } 77 + 78 + async create( 79 + userId: string, 80 + data: { 81 + institutionId: string; 82 + degree: string; 83 + fieldOfStudy?: string | null; 84 + startDate: Date; 85 + endDate?: Date | null; 86 + description?: string | null; 87 + skillIds?: string[]; 88 + }, 89 + ): Promise<Education> { 90 + const createData: Prisma.EducationCreateInput = { 91 + user: { connect: { id: userId } }, 92 + institution: { connect: { id: data.institutionId } }, 93 + degree: data.degree, 94 + fieldOfStudy: data.fieldOfStudy ?? null, 95 + startDate: data.startDate, 96 + endDate: data.endDate ?? null, 97 + description: data.description ?? null, 98 + }; 99 + 100 + if (data.skillIds && data.skillIds.length > 0) { 101 + createData.skills = { 102 + connect: data.skillIds.map((id) => ({ id })), 103 + }; 104 + } 105 + 106 + const education = await this.prisma["education"].create({ 107 + data: createData, 108 + include: { 109 + institution: true, 110 + user: true, 111 + skills: true, 112 + }, 113 + }); 114 + const domainEducation = this.educationMapper.toDomain(education); 115 + if (!domainEducation) { 116 + throw new Error("Failed to create education"); 117 + } 118 + return domainEducation; 119 + } 120 + 121 + async findByIdOrFail(id: string): Promise<Education> { 122 + const education = await this.findById(id); 123 + return education ?? notFound("Education", "id", id); 124 + } 125 + 126 + async delete(id: string, userId: string): Promise<void> { 127 + // Verify the education belongs to the user 128 + const education = await this.findById(id); 129 + if (!education || education.userId !== userId) { 130 + throw new Error("Education not found or does not belong to user"); 131 + } 132 + 133 + await this.prisma["education"].delete({ 134 + where: { id }, 135 + }); 136 + } 137 + }
+38
apps/server/src/modules/education/graphql/education-connection.type.ts
··· 1 + import { Field, Int, ObjectType } from "@nestjs/graphql"; 2 + import { PageInfo, PaginationResult } from "@/modules/base/pagination.types"; 3 + import type { Education as EducationEntity } from "../education.entity"; 4 + import { Education } from "./education.type"; 5 + import { EducationEdge } from "./education-edge.type"; 6 + 7 + @ObjectType() 8 + export class EducationConnection { 9 + @Field(() => [EducationEdge]) 10 + edges: EducationEdge[]; 11 + 12 + @Field(() => PageInfo) 13 + pageInfo: PageInfo; 14 + 15 + @Field(() => Int) 16 + totalCount: number; 17 + 18 + constructor(edges: EducationEdge[], pageInfo: PageInfo, totalCount: number) { 19 + this.edges = edges; 20 + this.pageInfo = pageInfo; 21 + this.totalCount = totalCount; 22 + } 23 + 24 + /** 25 + * Static factory method to create a connection from pagination result 26 + */ 27 + static fromPaginationResult( 28 + result: PaginationResult<EducationEntity>, 29 + ): EducationConnection { 30 + const edges = result.edges.map((edge) => 31 + EducationEdge.fromPaginationEdge({ 32 + cursor: edge.cursor, 33 + node: Education.fromDomain(edge.node), 34 + }), 35 + ); 36 + return new EducationConnection(edges, result.pageInfo, result.totalCount); 37 + } 38 + }
+13
apps/server/src/modules/education/graphql/education-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 { Education } from "./education.type"; 5 + 6 + @ObjectType() 7 + export class EducationEdge extends BaseEdge<Education> { 8 + @Field(() => GraphQLString) 9 + declare cursor: string; 10 + 11 + @Field(() => Education) 12 + declare node: Education; 13 + }
+54
apps/server/src/modules/education/graphql/education.resolver.ts
··· 1 + import { UseGuards } from "@nestjs/common"; 2 + import { Args, Mutation, Query, Resolver } from "@nestjs/graphql"; 3 + import { CurrentUser } from "@/modules/auth/current-user.decorator"; 4 + import { JwtAuthGuard } from "@/modules/auth/jwt-auth.guard"; 5 + import { User } from "@/modules/user/user.type"; 6 + import { EducationService } from "../education.service"; 7 + import { Education } from "./education.type"; 8 + 9 + @Resolver(() => Education) 10 + @UseGuards(JwtAuthGuard) 11 + export class EducationResolver { 12 + constructor(private readonly educationService: EducationService) {} 13 + 14 + @Query(() => [Education]) 15 + async myEducationHistory(@CurrentUser() user: User): Promise<Education[]> { 16 + const result = await this.educationService.findManyForUser(user.id); 17 + return result.edges 18 + .map((edge) => edge.node) 19 + .map((education) => Education.fromDomain(education)); 20 + } 21 + 22 + @Mutation(() => Education) 23 + async createEducation( 24 + @CurrentUser() user: User, 25 + @Args("institutionId") institutionId: string, 26 + @Args("degree") degree: string, 27 + @Args("startDate") startDate: Date, 28 + @Args("fieldOfStudy", { nullable: true }) fieldOfStudy?: string, 29 + @Args("endDate", { nullable: true }) endDate?: Date, 30 + @Args("description", { nullable: true }) description?: string, 31 + @Args("skillIds", { type: () => [String], nullable: true }) 32 + skillIds?: string[], 33 + ): Promise<Education> { 34 + const education = await this.educationService.create(user.id, { 35 + institutionId, 36 + degree, 37 + fieldOfStudy: fieldOfStudy ?? null, 38 + startDate, 39 + endDate: endDate ?? null, 40 + description: description ?? null, 41 + ...(skillIds !== undefined && skillIds.length > 0 ? { skillIds } : {}), 42 + }); 43 + return Education.fromDomain(education); 44 + } 45 + 46 + @Mutation(() => Boolean) 47 + async deleteEducation( 48 + @CurrentUser() user: User, 49 + @Args("id") id: string, 50 + ): Promise<boolean> { 51 + await this.educationService.delete(id, user.id); 52 + return true; 53 + } 54 + }
+92
apps/server/src/modules/education/graphql/education.type.ts
··· 1 + import { Field, ObjectType } from "@nestjs/graphql"; 2 + import { Skill } from "@/modules/job-experience/skill/graphql/skill.type"; 3 + import { Education as EducationEntity } from "../education.entity"; 4 + import { Institution } from "./institution.type"; 5 + 6 + @ObjectType() 7 + export class Education { 8 + @Field(() => String) 9 + id: string; 10 + 11 + @Field(() => String) 12 + userId: string; 13 + 14 + @Field(() => String) 15 + institutionId: string; 16 + 17 + @Field(() => Institution, { nullable: true }) 18 + institution: Institution | null; 19 + 20 + @Field(() => String) 21 + degree: string; 22 + 23 + @Field(() => String, { nullable: true }) 24 + fieldOfStudy: string | null; 25 + 26 + @Field(() => Date) 27 + startDate: Date; 28 + 29 + @Field(() => Date, { nullable: true }) 30 + endDate: Date | null; 31 + 32 + @Field(() => String, { nullable: true }) 33 + description: string | null; 34 + 35 + @Field(() => [Skill], { nullable: true }) 36 + skills: Skill[] | null; 37 + 38 + @Field(() => Date) 39 + createdAt: Date; 40 + 41 + @Field(() => Date) 42 + updatedAt: Date; 43 + 44 + constructor(data: { 45 + id: string; 46 + userId: string; 47 + institutionId: string; 48 + institution: Institution | null; 49 + degree: string; 50 + fieldOfStudy: string | null; 51 + startDate: Date; 52 + endDate: Date | null; 53 + description: string | null; 54 + skills: Skill[] | null; 55 + createdAt: Date; 56 + updatedAt: Date; 57 + }) { 58 + this.id = data.id; 59 + this.userId = data.userId; 60 + this.institutionId = data.institutionId; 61 + this.institution = data.institution; 62 + this.degree = data.degree; 63 + this.fieldOfStudy = data.fieldOfStudy; 64 + this.startDate = data.startDate; 65 + this.endDate = data.endDate; 66 + this.description = data.description; 67 + this.skills = data.skills; 68 + this.createdAt = data.createdAt; 69 + this.updatedAt = data.updatedAt; 70 + } 71 + 72 + static fromDomain(education: EducationEntity): Education { 73 + return new Education({ 74 + id: education.id, 75 + userId: education.userId, 76 + institutionId: education.institutionId, 77 + institution: education.institution 78 + ? Institution.fromDomain(education.institution) 79 + : null, 80 + degree: education.degree, 81 + fieldOfStudy: education.fieldOfStudy, 82 + startDate: education.startDate, 83 + endDate: education.endDate, 84 + description: education.description, 85 + skills: education.skills 86 + ? education.skills.map((skill) => Skill.fromDomain(skill)) 87 + : null, 88 + createdAt: education.createdAt, 89 + updatedAt: education.updatedAt, 90 + }); 91 + } 92 + }
+31
apps/server/src/modules/education/graphql/institution.resolver.ts
··· 1 + import { UseGuards } from "@nestjs/common"; 2 + import { Args, Mutation, Query, Resolver } from "@nestjs/graphql"; 3 + import { JwtAuthGuard } from "@/modules/auth/jwt-auth.guard"; 4 + import { InstitutionService } from "../institution.service"; 5 + import { Institution } from "./institution.type"; 6 + 7 + @Resolver(() => Institution) 8 + @UseGuards(JwtAuthGuard) 9 + export class InstitutionResolver { 10 + constructor(private readonly institutionService: InstitutionService) {} 11 + 12 + @Query(() => [Institution]) 13 + async institutions(): Promise<Institution[]> { 14 + const institutions = await this.institutionService.findMany(); 15 + return institutions.map((institution) => 16 + Institution.fromDomain(institution), 17 + ); 18 + } 19 + 20 + @Mutation(() => Institution) 21 + async createInstitution( 22 + @Args("name") name: string, 23 + @Args("description", { nullable: true }) description?: string, 24 + ): Promise<Institution> { 25 + const institution = await this.institutionService.create({ 26 + name, 27 + description: description ?? null, 28 + }); 29 + return Institution.fromDomain(institution); 30 + } 31 + }
+44
apps/server/src/modules/education/graphql/institution.type.ts
··· 1 + import { Field, ObjectType } from "@nestjs/graphql"; 2 + import { Institution as InstitutionEntity } from "../institution.entity"; 3 + 4 + @ObjectType() 5 + export class Institution { 6 + @Field(() => String) 7 + id: string; 8 + 9 + @Field(() => String) 10 + name: string; 11 + 12 + @Field(() => String, { nullable: true }) 13 + description: string | null; 14 + 15 + @Field(() => Date) 16 + createdAt: Date; 17 + 18 + @Field(() => Date) 19 + updatedAt: Date; 20 + 21 + constructor(data: { 22 + id: string; 23 + name: string; 24 + description: string | null; 25 + createdAt: Date; 26 + updatedAt: Date; 27 + }) { 28 + this.id = data.id; 29 + this.name = data.name; 30 + this.description = data.description; 31 + this.createdAt = data.createdAt; 32 + this.updatedAt = data.updatedAt; 33 + } 34 + 35 + static fromDomain(institution: InstitutionEntity): Institution { 36 + return new Institution({ 37 + id: institution.id, 38 + name: institution.name, 39 + description: institution.description, 40 + createdAt: institution.createdAt, 41 + updatedAt: institution.updatedAt, 42 + }); 43 + } 44 + }
+30
apps/server/src/modules/education/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 { PaginationArgs } from "@/modules/base/pagination.types"; 6 + import { User } from "@/modules/user/user.type"; 7 + import { EducationService } from "../education.service"; 8 + import { EducationConnection } from "./education-connection.type"; 9 + 10 + @Resolver(() => User) 11 + @UseGuards(JwtAuthGuard) 12 + export class EducationUserFieldResolver { 13 + constructor( 14 + private readonly educationService: EducationService, 15 + private readonly paginationService: PaginationService, 16 + ) {} 17 + 18 + @ResolveField(() => EducationConnection, { nullable: true }) 19 + async educationHistory( 20 + @Parent() user: User, 21 + @Args() args: PaginationArgs = {}, 22 + ): Promise<EducationConnection> { 23 + const options = this.paginationService.parsePaginationArgs(args); 24 + const result = await this.educationService.findManyForUser( 25 + user.id, 26 + options, 27 + ); 28 + return EducationConnection.fromPaginationResult(result); 29 + } 30 + }
+13
apps/server/src/modules/education/institution.entity.ts
··· 1 + import { BaseEntity } from "@/domain/base.entity"; 2 + 3 + export class Institution extends BaseEntity { 4 + constructor( 5 + id: string, 6 + public readonly name: string, 7 + public readonly description: string | null, 8 + createdAt: Date, 9 + updatedAt: Date, 10 + ) { 11 + super(id, createdAt, updatedAt); 12 + } 13 + }
+40
apps/server/src/modules/education/institution.mapper.ts
··· 1 + import { Injectable } from "@nestjs/common"; 2 + import type { Prisma } from "@prisma/client"; 3 + import type { BaseMapper } from "@/modules/base/mapper.interface"; 4 + import { Institution } from "./institution.entity"; 5 + 6 + type PrismaInstitution = Prisma.InstitutionGetPayload<Record<string, never>>; 7 + 8 + @Injectable() 9 + export class InstitutionMapper 10 + implements BaseMapper<PrismaInstitution, Institution> 11 + { 12 + /** 13 + * Maps a Prisma Institution entity to a domain Institution entity 14 + * Uses overloads to return the correct type based on input 15 + */ 16 + toDomain(prismaInstitution: null): null; 17 + toDomain(prismaInstitution: PrismaInstitution): Institution; 18 + toDomain(prismaInstitution: PrismaInstitution | null): Institution | null; 19 + toDomain(prismaInstitution: PrismaInstitution | null): Institution | null { 20 + if (!prismaInstitution) { 21 + return null; 22 + } 23 + 24 + return new Institution( 25 + prismaInstitution.id, 26 + prismaInstitution.name, 27 + prismaInstitution.description, 28 + prismaInstitution.createdAt, 29 + prismaInstitution.updatedAt, 30 + ); 31 + } 32 + 33 + mapToDomain(prismaInstitutions: PrismaInstitution[]): Institution[] { 34 + return prismaInstitutions 35 + .map((institution) => this.toDomain(institution)) 36 + .filter( 37 + (institution): institution is Institution => institution !== null, 38 + ); 39 + } 40 + }
+49
apps/server/src/modules/education/institution.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 { Institution } from "./institution.entity"; 5 + import { InstitutionMapper } from "./institution.mapper"; 6 + 7 + @Injectable() 8 + export class InstitutionService { 9 + constructor( 10 + private readonly prisma: PrismaService, 11 + private readonly institutionMapper: InstitutionMapper, 12 + ) {} 13 + 14 + async findMany(): Promise<Institution[]> { 15 + const institutions = await this.prisma["institution"].findMany({ 16 + orderBy: { name: "asc" }, 17 + }); 18 + return this.institutionMapper.mapToDomain(institutions); 19 + } 20 + 21 + async findById(id: string): Promise<Institution | null> { 22 + const institution = await this.prisma["institution"].findUnique({ 23 + where: { id }, 24 + }); 25 + return this.institutionMapper.toDomain(institution); 26 + } 27 + 28 + async findByIdOrFail(id: string): Promise<Institution> { 29 + const institution = await this.findById(id); 30 + return institution ?? notFound("Institution", "id", id); 31 + } 32 + 33 + async create(data: { 34 + name: string; 35 + description?: string | null; 36 + }): Promise<Institution> { 37 + const institution = await this.prisma["institution"].create({ 38 + data: { 39 + name: data.name, 40 + description: data.description ?? null, 41 + }, 42 + }); 43 + const domainInstitution = this.institutionMapper.toDomain(institution); 44 + if (!domainInstitution) { 45 + throw new Error("Failed to create institution"); 46 + } 47 + return domainInstitution; 48 + } 49 + }