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 multi-profile data model and migrate user->profile ownership

+768 -38
+24
apps/server/prisma/migrations/20260207221010_add_user_profile/migration.sql
··· 1 + -- CreateTable 2 + CREATE TABLE "user_profiles" ( 3 + "id" TEXT NOT NULL, 4 + "userId" TEXT NOT NULL, 5 + "headline" TEXT, 6 + "encryptedPhone" TEXT, 7 + "encryptedAddress" TEXT, 8 + "encryptedPostalCode" TEXT, 9 + "city" TEXT, 10 + "country" TEXT, 11 + "website" TEXT, 12 + "linkedInUrl" TEXT, 13 + "summary" TEXT, 14 + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 15 + "updatedAt" TIMESTAMP(3) NOT NULL, 16 + 17 + CONSTRAINT "user_profiles_pkey" PRIMARY KEY ("id") 18 + ); 19 + 20 + -- CreateIndex 21 + CREATE UNIQUE INDEX "user_profiles_userId_key" ON "user_profiles"("userId"); 22 + 23 + -- AddForeignKey 24 + ALTER TABLE "user_profiles" ADD CONSTRAINT "user_profiles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+2
apps/server/prisma/migrations/20260210214526_add_user_profile_full_name/migration.sql
··· 1 + -- AlterTable 2 + ALTER TABLE "user_profiles" ADD COLUMN "fullName" TEXT;
+110
apps/server/prisma/migrations/20260216120000_multi_profile/migration.sql
··· 1 + -- CreateTable: profiles (absorbs user_profiles) 2 + CREATE TABLE "profiles" ( 3 + "id" TEXT NOT NULL, 4 + "userId" TEXT NOT NULL, 5 + "name" TEXT NOT NULL, 6 + "fullName" TEXT, 7 + "headline" TEXT, 8 + "encryptedPhone" TEXT, 9 + "encryptedAddress" TEXT, 10 + "encryptedPostalCode" TEXT, 11 + "city" TEXT, 12 + "country" TEXT, 13 + "website" TEXT, 14 + "linkedInUrl" TEXT, 15 + "summary" TEXT, 16 + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 17 + "updatedAt" TIMESTAMP(3) NOT NULL, 18 + 19 + CONSTRAINT "profiles_pkey" PRIMARY KEY ("id") 20 + ); 21 + 22 + -- Migrate existing user_profiles into profiles 23 + INSERT INTO "profiles" ("id", "userId", "name", "fullName", "headline", "encryptedPhone", "encryptedAddress", "encryptedPostalCode", "city", "country", "website", "linkedInUrl", "summary", "createdAt", "updatedAt") 24 + SELECT "id", "userId", 'Default', "fullName", "headline", "encryptedPhone", "encryptedAddress", "encryptedPostalCode", "city", "country", "website", "linkedInUrl", "summary", "createdAt", "updatedAt" 25 + FROM "user_profiles"; 26 + 27 + -- Create profiles for users who don't have one yet 28 + INSERT INTO "profiles" ("id", "userId", "name", "createdAt", "updatedAt") 29 + SELECT gen_random_uuid()::text, u."id", 'Default', NOW(), NOW() 30 + FROM "users" u 31 + WHERE NOT EXISTS (SELECT 1 FROM "profiles" p WHERE p."userId" = u."id"); 32 + 33 + -- Add profileId column to educations (nullable initially) 34 + ALTER TABLE "educations" ADD COLUMN "profileId" TEXT; 35 + 36 + -- Populate profileId from userId 37 + UPDATE "educations" e 38 + SET "profileId" = (SELECT p."id" FROM "profiles" p WHERE p."userId" = e."userId" LIMIT 1); 39 + 40 + -- Make profileId NOT NULL and add FK 41 + ALTER TABLE "educations" ALTER COLUMN "profileId" SET NOT NULL; 42 + ALTER TABLE "educations" ADD CONSTRAINT "educations_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; 43 + 44 + -- Drop old userId FK and column from educations 45 + ALTER TABLE "educations" DROP CONSTRAINT "educations_userId_fkey"; 46 + ALTER TABLE "educations" DROP COLUMN "userId"; 47 + 48 + -- Add profileId column to user_job_experiences (nullable initially) 49 + ALTER TABLE "user_job_experiences" ADD COLUMN "profileId" TEXT; 50 + 51 + -- Populate profileId from userId 52 + UPDATE "user_job_experiences" uje 53 + SET "profileId" = (SELECT p."id" FROM "profiles" p WHERE p."userId" = uje."userId" LIMIT 1); 54 + 55 + -- Make profileId NOT NULL and add FK 56 + ALTER TABLE "user_job_experiences" ALTER COLUMN "profileId" SET NOT NULL; 57 + ALTER TABLE "user_job_experiences" ADD CONSTRAINT "user_job_experiences_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; 58 + 59 + -- Drop old userId FK and column from user_job_experiences 60 + ALTER TABLE "user_job_experiences" DROP CONSTRAINT "user_job_experiences_userId_fkey"; 61 + ALTER TABLE "user_job_experiences" DROP COLUMN "userId"; 62 + 63 + -- Add profileId column to cvs (nullable initially) 64 + ALTER TABLE "cvs" ADD COLUMN "profileId" TEXT; 65 + 66 + -- Populate profileId from userId 67 + UPDATE "cvs" c 68 + SET "profileId" = (SELECT p."id" FROM "profiles" p WHERE p."userId" = c."userId" LIMIT 1); 69 + 70 + -- Make profileId NOT NULL and add FK 71 + ALTER TABLE "cvs" ALTER COLUMN "profileId" SET NOT NULL; 72 + ALTER TABLE "cvs" ADD CONSTRAINT "cvs_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; 73 + 74 + -- Drop old userId FK and column from cvs 75 + ALTER TABLE "cvs" DROP CONSTRAINT "cvs_userId_fkey"; 76 + ALTER TABLE "cvs" DROP COLUMN "userId"; 77 + 78 + -- Add profileId column to user_files (nullable initially) 79 + ALTER TABLE "user_files" ADD COLUMN "profileId" TEXT; 80 + 81 + -- Populate profileId from userId 82 + UPDATE "user_files" uf 83 + SET "profileId" = (SELECT p."id" FROM "profiles" p WHERE p."userId" = uf."userId" LIMIT 1); 84 + 85 + -- Make profileId NOT NULL and add FK 86 + ALTER TABLE "user_files" ALTER COLUMN "profileId" SET NOT NULL; 87 + ALTER TABLE "user_files" ADD CONSTRAINT "user_files_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; 88 + 89 + -- Drop old userId FK and column from user_files 90 + ALTER TABLE "user_files" DROP CONSTRAINT "user_files_userId_fkey"; 91 + ALTER TABLE "user_files" DROP COLUMN "userId"; 92 + 93 + -- Add profileId column to applications (nullable initially) 94 + ALTER TABLE "applications" ADD COLUMN "profileId" TEXT; 95 + 96 + -- Populate profileId from userId 97 + UPDATE "applications" a 98 + SET "profileId" = (SELECT p."id" FROM "profiles" p WHERE p."userId" = a."userId" LIMIT 1); 99 + 100 + -- Make profileId NOT NULL and add FK 101 + ALTER TABLE "applications" ALTER COLUMN "profileId" SET NOT NULL; 102 + ALTER TABLE "applications" ADD CONSTRAINT "applications_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; 103 + 104 + -- Note: applications keeps userId (for @@unique([userId, vacancyId]) constraint) 105 + 106 + -- Add FK from profiles to users 107 + ALTER TABLE "profiles" ADD CONSTRAINT "profiles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 108 + 109 + -- Drop old user_profiles table 110 + DROP TABLE "user_profiles";
+5
apps/server/prisma/migrations/20260218120000_add_user_role/migration.sql
··· 1 + -- CreateEnum 2 + CREATE TYPE "UserRole" AS ENUM ('USER', 'ADMIN'); 3 + 4 + -- AlterTable 5 + ALTER TABLE "users" ADD COLUMN "role" "UserRole" NOT NULL DEFAULT 'USER';
+7 -4
apps/server/prisma/models/cv.prisma
··· 2 2 id String @id @default(cuid()) 3 3 name String 4 4 description String? 5 + body String? @db.Text 6 + css String? @db.Text 7 + engine String @default("handlebars") 5 8 createdAt DateTime @default(now()) 6 9 updatedAt DateTime @updatedAt 7 - 10 + 8 11 // CVs using this template 9 12 cvs CV[] 10 13 ··· 13 16 14 17 model CV { 15 18 id String @id @default(cuid()) 16 - userId String 19 + profileId String 17 20 templateId String 18 21 title String 19 22 introduction String? 20 23 createdAt DateTime @default(now()) 21 24 updatedAt DateTime @updatedAt 22 - 25 + 23 26 // Relations 24 - user User @relation(fields: [userId], references: [id], onDelete: Cascade) 27 + profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade) 25 28 template CVTemplate @relation(fields: [templateId], references: [id], onDelete: Restrict) 26 29 applications Application[] 27 30
+13 -13
apps/server/prisma/models/job-experience.prisma
··· 4 4 description String? 5 5 createdAt DateTime @default(now()) 6 6 updatedAt DateTime @updatedAt 7 - 7 + 8 8 // Job experiences that use this skill 9 9 jobExperiences UserJobExperience[] 10 - 10 + 11 11 // Vacancies requiring this skill 12 12 vacancies Vacancy[] 13 - 13 + 14 14 // Education entries that use this skill 15 15 educations Education[] 16 16 ··· 24 24 website String? 25 25 createdAt DateTime @default(now()) 26 26 updatedAt DateTime @updatedAt 27 - 27 + 28 28 // Job experiences at this company 29 29 jobExperiences UserJobExperience[] 30 - 30 + 31 31 // Vacancies at this company 32 32 vacancies Vacancy[] 33 33 ··· 40 40 description String? 41 41 createdAt DateTime @default(now()) 42 42 updatedAt DateTime @updatedAt 43 - 43 + 44 44 // Job experiences with this role 45 45 jobExperiences UserJobExperience[] 46 - 46 + 47 47 // Vacancies with this role 48 48 vacancies Vacancy[] 49 49 ··· 56 56 description String? 57 57 createdAt DateTime @default(now()) 58 58 updatedAt DateTime @updatedAt 59 - 59 + 60 60 // Job experiences at this level 61 61 jobExperiences UserJobExperience[] 62 - 62 + 63 63 // Vacancies at this level 64 64 vacancies Vacancy[] 65 65 ··· 68 68 69 69 model UserJobExperience { 70 70 id String @id @default(cuid()) 71 - userId String 71 + profileId String 72 72 companyId String 73 73 roleId String 74 74 levelId String ··· 77 77 description String? 78 78 createdAt DateTime @default(now()) 79 79 updatedAt DateTime @updatedAt 80 - 80 + 81 81 // Relations 82 - user User @relation(fields: [userId], references: [id], onDelete: Cascade) 82 + profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade) 83 83 company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) 84 84 role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) 85 85 level Level @relation(fields: [levelId], references: [id], onDelete: Cascade) 86 - 86 + 87 87 // Skills used in this job experience 88 88 skills Skill[] 89 89
+26
apps/server/prisma/models/profile.prisma
··· 1 + model Profile { 2 + id String @id @default(cuid()) 3 + userId String 4 + name String 5 + fullName String? 6 + headline String? 7 + encryptedPhone String? 8 + encryptedAddress String? 9 + encryptedPostalCode String? 10 + city String? 11 + country String? 12 + website String? 13 + linkedInUrl String? 14 + summary String? 15 + createdAt DateTime @default(now()) 16 + updatedAt DateTime @updatedAt 17 + 18 + user User @relation(fields: [userId], references: [id], onDelete: Cascade) 19 + educations Education[] 20 + jobExperiences UserJobExperience[] 21 + cvs CV[] 22 + applications Application[] 23 + userFiles UserFile[] 24 + 25 + @@map("profiles") 26 + }
+23 -16
apps/server/prisma/models/user.prisma
··· 1 + enum UserRole { 2 + USER 3 + ADMIN 4 + } 5 + 1 6 model User { 2 7 id String @id @default(cuid()) 3 8 name String 9 + role UserRole @default(USER) 4 10 createdAt DateTime @default(now()) 5 11 updatedAt DateTime @updatedAt 6 - 12 + 7 13 // Credentials (one-to-one relationship) 8 14 credentials Credentials? 9 15 10 - // Job experiences 11 - jobExperiences UserJobExperience[] 12 - 13 16 // Organizations 14 17 memberships Membership[] 15 18 16 19 // Vacancies owned by user 17 20 ownedVacancies Vacancy[] @relation("VacancyOwner") 18 21 19 - // CVs 20 - cvs CV[] 21 - 22 - // Applications 22 + // Applications (kept user-scoped for unique constraint) 23 23 applications Application[] 24 - 25 - // Education history 26 - educationHistory Education[] 27 24 28 25 // Refresh tokens 29 26 refreshTokens RefreshToken[] 30 27 28 + // Profiles (replaces 1:1 UserProfile) 29 + profiles Profile[] 30 + 31 + // AI settings 32 + aiSettings UserAiSettings? 33 + aiProviders UserAiProvider[] 34 + 35 + // AI call logs 36 + aiCallLogs AiCallLog[] 37 + 31 38 @@map("users") 32 39 } 33 40 ··· 43 50 passwordResetTokenExpiresAt DateTime? 44 51 createdAt DateTime @default(now()) 45 52 updatedAt DateTime @updatedAt 46 - 53 + 47 54 // Relation to User 48 55 user User @relation(fields: [userId], references: [id], onDelete: Cascade) 49 56 ··· 56 63 description String? 57 64 createdAt DateTime @default(now()) 58 65 updatedAt DateTime @updatedAt 59 - 66 + 60 67 // Education entries at this institution 61 68 educationEntries Education[] 62 69 ··· 65 72 66 73 model Education { 67 74 id String @id @default(cuid()) 68 - userId String 75 + profileId String 69 76 institutionId String 70 77 degree String 71 78 fieldOfStudy String? ··· 74 81 description String? 75 82 createdAt DateTime @default(now()) 76 83 updatedAt DateTime @updatedAt 77 - 84 + 78 85 // Relations 79 - user User @relation(fields: [userId], references: [id], onDelete: Cascade) 86 + profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade) 80 87 institution Institution @relation(fields: [institutionId], references: [id], onDelete: Cascade) 81 88 skills Skill[] 82 89
+4 -2
apps/server/prisma/models/vacancy.prisma
··· 4 4 description String? 5 5 createdAt DateTime @default(now()) 6 6 updatedAt DateTime @updatedAt 7 - 7 + 8 8 // Vacancies with this job type 9 9 vacancies Vacancy[] 10 10 ··· 17 17 description String? 18 18 createdAt DateTime @default(now()) 19 19 updatedAt DateTime @updatedAt 20 - 20 + 21 21 // Applications with this status 22 22 applications Application[] 23 23 ··· 59 59 model Application { 60 60 id String @id @default(cuid()) 61 61 userId String 62 + profileId String 62 63 vacancyId String 63 64 cvId String? 64 65 coverLetter String? ··· 69 70 70 71 // Relations 71 72 user User @relation(fields: [userId], references: [id], onDelete: Cascade) 73 + profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade) 72 74 vacancy Vacancy @relation(fields: [vacancyId], references: [id], onDelete: Cascade) 73 75 cv CV? @relation(fields: [cvId], references: [id], onDelete: SetNull) 74 76 status ApplicationStatus @relation(fields: [statusId], references: [id], onDelete: Restrict)
+6 -2
apps/server/prisma/schema.prisma
··· 1 1 // This is your Prisma schema file, 2 2 // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 3 4 + // output: pnpm hoists .prisma/client to the workspace root but prisma generate 5 + // writes to the pnpm store by default. Explicit output ensures types land where 6 + // TypeScript actually resolves them (relative to this prisma/ directory). 4 7 generator client { 5 - provider = "prisma-client-js" 8 + provider = "prisma-client-js" 9 + output = "../../../node_modules/.prisma/client" 6 10 enableTracing = false 7 - binaryTargets = ["native", "darwin-arm64", "linux-musl-arm64-openssl-3.0.x"] 11 + binaryTargets = ["native", "darwin-arm64", "debian-openssl-3.0.x"] 8 12 } 9 13 10 14 datasource db {
+61
apps/server/src/modules/profile/graphql/profile-field.resolver.ts
··· 1 + import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth"; 2 + import { PaginationArgs, PaginationService } from "@cv/system"; 3 + import { UseGuards } from "@nestjs/common"; 4 + import { Args, Parent, ResolveField, Resolver } from "@nestjs/graphql"; 5 + import { CVService } from "@/modules/cv-template/cv.service"; 6 + import { CVConnection } from "@/modules/cv-template/graphql/cv.type"; 7 + import { EducationService } from "@/modules/education/education.service"; 8 + import { EducationConnection } from "@/modules/education/graphql/education.type"; 9 + import { UserJobExperienceService } from "@/modules/job-experience/employment/user-job-experience.service"; 10 + import { UserJobExperienceConnection } from "@/modules/job-experience/employment/graphql/user-job-experience.type"; 11 + import { ProfileType } from "./profile.type"; 12 + 13 + @Resolver(() => ProfileType) 14 + @UseGuards(JwtAuthGuard, VerifiedScopeGuard) 15 + export class ProfileFieldResolver { 16 + constructor( 17 + private readonly educationService: EducationService, 18 + private readonly userJobExperienceService: UserJobExperienceService, 19 + private readonly cvService: CVService, 20 + private readonly paginationService: PaginationService, 21 + ) {} 22 + 23 + @ResolveField(() => EducationConnection, { nullable: true }) 24 + async educationHistory( 25 + @Parent() profile: ProfileType, 26 + @Args() args: PaginationArgs = {}, 27 + ): Promise<EducationConnection> { 28 + const options = this.paginationService.parsePaginationArgs(args); 29 + const result = await this.educationService.findManyForProfile( 30 + profile.id, 31 + options, 32 + ); 33 + return EducationConnection.fromPaginationResult(result); 34 + } 35 + 36 + @ResolveField(() => UserJobExperienceConnection, { nullable: true }) 37 + async experience( 38 + @Parent() profile: ProfileType, 39 + @Args() args: PaginationArgs = {}, 40 + ): Promise<UserJobExperienceConnection> { 41 + const options = this.paginationService.parsePaginationArgs(args); 42 + const result = await this.userJobExperienceService.findManyForProfile( 43 + profile.id, 44 + options, 45 + ); 46 + return UserJobExperienceConnection.fromPaginationResult(result); 47 + } 48 + 49 + @ResolveField(() => CVConnection, { nullable: true }) 50 + async cvs( 51 + @Parent() profile: ProfileType, 52 + @Args() args: PaginationArgs = {}, 53 + ): Promise<CVConnection> { 54 + const options = this.paginationService.parsePaginationArgs(args); 55 + const result = await this.cvService.findManyForProfile( 56 + profile.id, 57 + options, 58 + ); 59 + return CVConnection.fromPaginationResult(result); 60 + } 61 + }
+59
apps/server/src/modules/profile/graphql/profile.resolver.ts
··· 1 + import type { User as DomainUser } from "@cv/auth"; 2 + import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth"; 3 + import { UseGuards } from "@nestjs/common"; 4 + import { Args, ID, Mutation, Query, Resolver } from "@nestjs/graphql"; 5 + import { CurrentUser } from "@/modules/current-user/current-user.decorator"; 6 + import { ProfileService } from "../profile.service"; 7 + import { 8 + CreateProfileInput, 9 + ProfileType, 10 + UpdateProfileInput, 11 + } from "./profile.type"; 12 + 13 + @Resolver(() => ProfileType) 14 + @UseGuards(JwtAuthGuard, VerifiedScopeGuard) 15 + export class ProfileResolver { 16 + constructor(private readonly profileService: ProfileService) {} 17 + 18 + @Query(() => [ProfileType]) 19 + async myProfiles( 20 + @CurrentUser() user: DomainUser, 21 + ): Promise<ProfileType[]> { 22 + return this.profileService.getProfilesForUser(user.id); 23 + } 24 + 25 + @Query(() => ProfileType, { nullable: true }) 26 + async profile( 27 + @CurrentUser() user: DomainUser, 28 + @Args("id", { type: () => ID }) id: string, 29 + ): Promise<ProfileType | null> { 30 + return this.profileService.findByIdAndUserOrFail(id, user.id); 31 + } 32 + 33 + @Mutation(() => ProfileType) 34 + async createProfile( 35 + @CurrentUser() user: DomainUser, 36 + @Args("input") input: CreateProfileInput, 37 + ): Promise<ProfileType> { 38 + return this.profileService.createProfile(user.id, input); 39 + } 40 + 41 + @Mutation(() => ProfileType) 42 + async updateProfile( 43 + @CurrentUser() user: DomainUser, 44 + @Args("id", { type: () => ID }) id: string, 45 + @Args("input") input: UpdateProfileInput, 46 + ): Promise<ProfileType> { 47 + await this.profileService.findByIdAndUserOrFail(id, user.id); 48 + return this.profileService.updateProfile(id, input); 49 + } 50 + 51 + @Mutation(() => Boolean) 52 + async deleteProfile( 53 + @CurrentUser() user: DomainUser, 54 + @Args("id", { type: () => ID }) id: string, 55 + ): Promise<boolean> { 56 + await this.profileService.findByIdAndUserOrFail(id, user.id); 57 + return this.profileService.deleteProfile(id); 58 + } 59 + }
+118
apps/server/src/modules/profile/graphql/profile.type.ts
··· 1 + import { Field, ID, InputType, ObjectType } from "@nestjs/graphql"; 2 + 3 + @ObjectType() 4 + export class ProfileType { 5 + @Field(() => ID) 6 + id!: string; 7 + 8 + @Field() 9 + name!: string; 10 + 11 + @Field(() => String, { nullable: true }) 12 + fullName!: string | null; 13 + 14 + @Field(() => String, { nullable: true }) 15 + headline!: string | null; 16 + 17 + @Field(() => String, { nullable: true }) 18 + phone!: string | null; 19 + 20 + @Field(() => String, { nullable: true }) 21 + address!: string | null; 22 + 23 + @Field(() => String, { nullable: true }) 24 + postalCode!: string | null; 25 + 26 + @Field(() => String, { nullable: true }) 27 + city!: string | null; 28 + 29 + @Field(() => String, { nullable: true }) 30 + country!: string | null; 31 + 32 + @Field(() => String, { nullable: true }) 33 + website!: string | null; 34 + 35 + @Field(() => String, { nullable: true }) 36 + linkedInUrl!: string | null; 37 + 38 + @Field(() => String, { nullable: true }) 39 + summary!: string | null; 40 + 41 + @Field() 42 + createdAt!: Date; 43 + 44 + @Field() 45 + updatedAt!: Date; 46 + } 47 + 48 + @InputType() 49 + export class CreateProfileInput { 50 + @Field() 51 + name!: string; 52 + 53 + @Field(() => String, { nullable: true }) 54 + fullName?: string; 55 + 56 + @Field(() => String, { nullable: true }) 57 + headline?: string; 58 + 59 + @Field(() => String, { nullable: true }) 60 + phone?: string; 61 + 62 + @Field(() => String, { nullable: true }) 63 + address?: string; 64 + 65 + @Field(() => String, { nullable: true }) 66 + postalCode?: string; 67 + 68 + @Field(() => String, { nullable: true }) 69 + city?: string; 70 + 71 + @Field(() => String, { nullable: true }) 72 + country?: string; 73 + 74 + @Field(() => String, { nullable: true }) 75 + website?: string; 76 + 77 + @Field(() => String, { nullable: true }) 78 + linkedInUrl?: string; 79 + 80 + @Field(() => String, { nullable: true }) 81 + summary?: string; 82 + } 83 + 84 + @InputType() 85 + export class UpdateProfileInput { 86 + @Field(() => String, { nullable: true }) 87 + name?: string; 88 + 89 + @Field(() => String, { nullable: true }) 90 + fullName?: string; 91 + 92 + @Field(() => String, { nullable: true }) 93 + headline?: string; 94 + 95 + @Field(() => String, { nullable: true }) 96 + phone?: string; 97 + 98 + @Field(() => String, { nullable: true }) 99 + address?: string; 100 + 101 + @Field(() => String, { nullable: true }) 102 + postalCode?: string; 103 + 104 + @Field(() => String, { nullable: true }) 105 + city?: string; 106 + 107 + @Field(() => String, { nullable: true }) 108 + country?: string; 109 + 110 + @Field(() => String, { nullable: true }) 111 + website?: string; 112 + 113 + @Field(() => String, { nullable: true }) 114 + linkedInUrl?: string; 115 + 116 + @Field(() => String, { nullable: true }) 117 + summary?: string; 118 + }
+13
apps/server/src/modules/profile/profile.entity.ts
··· 1 + import { BaseEntity } from "@cv/system"; 2 + 3 + export class ProfileEntity extends BaseEntity { 4 + constructor( 5 + id: string, 6 + public readonly userId: string, 7 + public readonly name: string, 8 + createdAt: Date, 9 + updatedAt: Date, 10 + ) { 11 + super(id, createdAt, updatedAt); 12 + } 13 + }
+31
apps/server/src/modules/profile/profile.module.ts
··· 1 + import { AuthModule } from "@cv/auth"; 2 + import { BaseModule, DatabaseModule } from "@cv/system"; 3 + import { Module, forwardRef } from "@nestjs/common"; 4 + import { CVTemplateModule } from "@/modules/cv-template/cv-template.module"; 5 + import { EducationModule } from "@/modules/education/education.module"; 6 + import { EmploymentModule } from "@/modules/job-experience/employment/employment.module"; 7 + import { ProfileFieldResolver } from "./graphql/profile-field.resolver"; 8 + import { ProfileResolver } from "./graphql/profile.resolver"; 9 + import { ProfileOnboardingStep } from "./onboarding/profile.step"; 10 + import { ProfilePolicy } from "./profile.policy"; 11 + import { ProfileService } from "./profile.service"; 12 + 13 + @Module({ 14 + imports: [ 15 + DatabaseModule, 16 + BaseModule, 17 + AuthModule, 18 + forwardRef(() => EducationModule), 19 + forwardRef(() => EmploymentModule), 20 + forwardRef(() => CVTemplateModule), 21 + ], 22 + providers: [ 23 + ProfileService, 24 + ProfileResolver, 25 + ProfileFieldResolver, 26 + ProfilePolicy, 27 + ProfileOnboardingStep, 28 + ], 29 + exports: [ProfileService], 30 + }) 31 + export class ProfileModule {}
+7
apps/server/src/modules/profile/profile.policy.ts
··· 1 + import { Policy, UserOwnedResourcePolicy } from "@cv/auth"; 2 + import { Injectable } from "@nestjs/common"; 3 + import { ProfileEntity } from "./profile.entity"; 4 + 5 + @Injectable() 6 + @Policy(ProfileEntity) 7 + export class ProfilePolicy extends UserOwnedResourcePolicy<ProfileEntity> {}
+224
apps/server/src/modules/profile/profile.service.ts
··· 1 + import type { User } from "@cv/auth"; 2 + import { TokenEncryptionService } from "@cv/auth"; 3 + import { PrismaService } from "@cv/system"; 4 + import { Injectable, NotFoundException } from "@nestjs/common"; 5 + 6 + interface CreateProfileInput { 7 + name: string; 8 + fullName?: string; 9 + headline?: string; 10 + phone?: string; 11 + address?: string; 12 + postalCode?: string; 13 + city?: string; 14 + country?: string; 15 + website?: string; 16 + linkedInUrl?: string; 17 + summary?: string; 18 + } 19 + 20 + interface UpdateProfileInput { 21 + name?: string; 22 + fullName?: string; 23 + headline?: string; 24 + phone?: string; 25 + address?: string; 26 + postalCode?: string; 27 + city?: string; 28 + country?: string; 29 + website?: string; 30 + linkedInUrl?: string; 31 + summary?: string; 32 + } 33 + 34 + export interface DecryptedProfile { 35 + id: string; 36 + userId: string; 37 + name: string; 38 + fullName: string | null; 39 + headline: string | null; 40 + phone: string | null; 41 + address: string | null; 42 + postalCode: string | null; 43 + city: string | null; 44 + country: string | null; 45 + website: string | null; 46 + linkedInUrl: string | null; 47 + summary: string | null; 48 + createdAt: Date; 49 + updatedAt: Date; 50 + } 51 + 52 + @Injectable() 53 + export class ProfileService { 54 + constructor( 55 + private readonly prisma: PrismaService, 56 + private readonly encryption: TokenEncryptionService, 57 + ) {} 58 + 59 + async createProfile( 60 + userId: string, 61 + data: CreateProfileInput, 62 + ): Promise<DecryptedProfile> { 63 + const encrypted = this.encryptFields(data); 64 + 65 + const profile = await this.prisma.profile.create({ 66 + data: { 67 + userId, 68 + name: data.name, 69 + ...encrypted, 70 + }, 71 + }); 72 + 73 + return this.decrypt(profile); 74 + } 75 + 76 + async getProfilesForUser(userId: string): Promise<DecryptedProfile[]> { 77 + const profiles = await this.prisma.profile.findMany({ 78 + where: { userId }, 79 + orderBy: { createdAt: "asc" }, 80 + }); 81 + 82 + return profiles.map((p) => this.decrypt(p)); 83 + } 84 + 85 + async findByIdOrFail(profileId: string): Promise<DecryptedProfile> { 86 + const profile = await this.prisma.profile.findUnique({ 87 + where: { id: profileId }, 88 + }); 89 + 90 + if (!profile) throw new NotFoundException("Profile not found"); 91 + 92 + return this.decrypt(profile); 93 + } 94 + 95 + async findByIdAndUserOrFail( 96 + profileId: string, 97 + userId: string, 98 + ): Promise<DecryptedProfile> { 99 + const profile = await this.prisma.profile.findFirst({ 100 + where: { id: profileId, userId }, 101 + }); 102 + 103 + if (!profile) throw new NotFoundException("Profile not found"); 104 + 105 + return this.decrypt(profile); 106 + } 107 + 108 + async updateProfile( 109 + profileId: string, 110 + data: UpdateProfileInput, 111 + ): Promise<DecryptedProfile> { 112 + const encrypted = this.encryptFields(data); 113 + 114 + if (data.name !== undefined) { 115 + encrypted["name"] = data.name; 116 + } 117 + 118 + const profile = await this.prisma.profile.update({ 119 + where: { id: profileId }, 120 + data: encrypted, 121 + }); 122 + 123 + return this.decrypt(profile); 124 + } 125 + 126 + async deleteProfile(profileId: string): Promise<boolean> { 127 + await this.prisma.profile.delete({ 128 + where: { id: profileId }, 129 + }); 130 + return true; 131 + } 132 + 133 + async getOrCreateDefaultProfile(userId: string): Promise<DecryptedProfile> { 134 + const existing = await this.prisma.profile.findFirst({ 135 + where: { userId }, 136 + orderBy: { createdAt: "asc" }, 137 + }); 138 + 139 + if (existing) return this.decrypt(existing); 140 + 141 + return this.createProfile(userId, { name: "Default" }); 142 + } 143 + 144 + async hasKeyFields(userId: string): Promise<boolean> { 145 + const profile = await this.prisma.profile.findFirst({ 146 + where: { userId }, 147 + select: { headline: true, city: true }, 148 + }); 149 + 150 + return profile !== null && profile.headline !== null && profile.city !== null; 151 + } 152 + 153 + private decrypt(profile: { 154 + id: string; 155 + userId: string; 156 + name: string; 157 + fullName: string | null; 158 + headline: string | null; 159 + encryptedPhone: string | null; 160 + encryptedAddress: string | null; 161 + encryptedPostalCode: string | null; 162 + city: string | null; 163 + country: string | null; 164 + website: string | null; 165 + linkedInUrl: string | null; 166 + summary: string | null; 167 + createdAt: Date; 168 + updatedAt: Date; 169 + }): DecryptedProfile { 170 + return { 171 + id: profile.id, 172 + userId: profile.userId, 173 + name: profile.name, 174 + fullName: profile.fullName, 175 + headline: profile.headline, 176 + phone: profile.encryptedPhone 177 + ? this.encryption.decrypt(profile.encryptedPhone) 178 + : null, 179 + address: profile.encryptedAddress 180 + ? this.encryption.decrypt(profile.encryptedAddress) 181 + : null, 182 + postalCode: profile.encryptedPostalCode 183 + ? this.encryption.decrypt(profile.encryptedPostalCode) 184 + : null, 185 + city: profile.city, 186 + country: profile.country, 187 + website: profile.website, 188 + linkedInUrl: profile.linkedInUrl, 189 + summary: profile.summary, 190 + createdAt: profile.createdAt, 191 + updatedAt: profile.updatedAt, 192 + }; 193 + } 194 + 195 + private encryptFields( 196 + data: Partial<CreateProfileInput>, 197 + ): Record<string, unknown> { 198 + const result: Record<string, unknown> = {}; 199 + 200 + if (data.fullName !== undefined) result["fullName"] = data.fullName || null; 201 + if (data.headline !== undefined) result["headline"] = data.headline || null; 202 + if (data.city !== undefined) result["city"] = data.city || null; 203 + if (data.country !== undefined) result["country"] = data.country || null; 204 + if (data.website !== undefined) result["website"] = data.website || null; 205 + if (data.linkedInUrl !== undefined) 206 + result["linkedInUrl"] = data.linkedInUrl || null; 207 + if (data.summary !== undefined) result["summary"] = data.summary || null; 208 + 209 + if (data.phone !== undefined) 210 + result["encryptedPhone"] = data.phone 211 + ? this.encryption.encrypt(data.phone) 212 + : null; 213 + if (data.address !== undefined) 214 + result["encryptedAddress"] = data.address 215 + ? this.encryption.encrypt(data.address) 216 + : null; 217 + if (data.postalCode !== undefined) 218 + result["encryptedPostalCode"] = data.postalCode 219 + ? this.encryption.encrypt(data.postalCode) 220 + : null; 221 + 222 + return result; 223 + } 224 + }
+25
apps/server/src/modules/user/seed/user.seed.ts
··· 42 42 43 43 const user = await this.userService.findByIdOrFail(credentials.userId); 44 44 45 + await this._prisma["user"].update({ 46 + where: { id: user.id }, 47 + data: { role: "ADMIN" }, 48 + }); 49 + 50 + await this.ensureDefaultProfile(user.id, "Test User"); 51 + 45 52 return { 46 53 id: user.id, 47 54 email: credentials.email, ··· 93 100 94 101 const user = await this.userService.findByIdOrFail(credentials.userId); 95 102 103 + await this.ensureDefaultProfile(user.id, userData.name); 104 + 96 105 return { 97 106 id: user.id, 98 107 email: credentials.email, ··· 102 111 ); 103 112 104 113 return createdUsers; 114 + } 115 + 116 + private async ensureDefaultProfile( 117 + userId: string, 118 + name: string, 119 + ): Promise<void> { 120 + const existing = await this._prisma["profile"].findFirst({ 121 + where: { userId }, 122 + }); 123 + 124 + if (existing) return; 125 + 126 + this.logger.log(`Creating default profile for ${name}`); 127 + await this._prisma["profile"].create({ 128 + data: { userId, name: "Default" }, 129 + }); 105 130 } 106 131 }
+10 -1
apps/server/src/modules/user/user.type.ts
··· 1 1 import type { User as DomainUser } from "@cv/auth"; 2 - import { Field, ID, ObjectType } from "@nestjs/graphql"; 2 + import { UserRole } from "@cv/auth"; 3 + import { Field, ID, ObjectType, registerEnumType } from "@nestjs/graphql"; 4 + 5 + registerEnumType(UserRole, { name: "UserRole" }); 3 6 import { ApplicationConnection } from "@/modules/application/graphql/application.type"; 4 7 import { CVConnection } from "@/modules/cv-template/graphql/cv.type"; 5 8 import { EducationConnection } from "@/modules/education/graphql/education.type"; ··· 15 18 @Field() 16 19 name: string; 17 20 21 + @Field(() => UserRole) 22 + role: UserRole; 23 + 18 24 @Field(() => String, { nullable: true }) 19 25 email: string | null; 20 26 ··· 45 51 constructor( 46 52 id: string, 47 53 name: string, 54 + role: UserRole, 48 55 createdAt: Date, 49 56 email?: string | null, 50 57 emailVerifiedAt?: Date | null, ··· 52 59 ) { 53 60 this.id = id; 54 61 this.name = name; 62 + this.role = role; 55 63 this.email = email ?? null; 56 64 this.emailVerifiedAt = emailVerifiedAt ?? null; 57 65 this.createdAt = createdAt; ··· 65 73 return new User( 66 74 domainUser.id, 67 75 domainUser.name, 76 + domainUser.role as UserRole, 68 77 domainUser.createdAt, 69 78 domainUser.credentials?.email ?? null, 70 79 domainUser.credentials?.emailVerifiedAt ?? null,