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 onboarding checklist with self-registering step discovery

+328
+33
apps/server/src/modules/cv-parser/onboarding/import.step.ts
··· 1 + import type { User } from "@cv/auth"; 2 + import { PrismaService } from "@cv/system"; 3 + import { Injectable } from "@nestjs/common"; 4 + import { OnboardingStep } from "@/modules/onboarding/onboarding-step.decorator"; 5 + import { 6 + type OnboardingStepDefinition, 7 + OnboardingStepStatus, 8 + } from "@/modules/onboarding/onboarding-step.interface"; 9 + 10 + @Injectable() 11 + @OnboardingStep() 12 + export class ImportOnboardingStep implements OnboardingStepDefinition { 13 + readonly name = "import"; 14 + readonly order = 1; 15 + readonly dependsOn = ["ai-preference"]; 16 + 17 + constructor(private readonly prisma: PrismaService) {} 18 + 19 + async computeStatus(user: User): Promise<OnboardingStepStatus> { 20 + const [jobCount, eduCount] = await Promise.all([ 21 + this.prisma.userJobExperience.count({ 22 + where: { profile: { userId: user.id } }, 23 + }), 24 + this.prisma.education.count({ 25 + where: { profile: { userId: user.id } }, 26 + }), 27 + ]); 28 + 29 + return jobCount > 0 || eduCount > 0 30 + ? OnboardingStepStatus.COMPLETE 31 + : OnboardingStepStatus.NOT_STARTED; 32 + } 33 + }
+28
apps/server/src/modules/education/onboarding/education.step.ts
··· 1 + import type { User } from "@cv/auth"; 2 + import { PrismaService } from "@cv/system"; 3 + import { Injectable } from "@nestjs/common"; 4 + import { OnboardingStep } from "@/modules/onboarding/onboarding-step.decorator"; 5 + import { 6 + type OnboardingStepDefinition, 7 + OnboardingStepStatus, 8 + } from "@/modules/onboarding/onboarding-step.interface"; 9 + 10 + @Injectable() 11 + @OnboardingStep() 12 + export class EducationOnboardingStep implements OnboardingStepDefinition { 13 + readonly name = "education"; 14 + readonly order = 4; 15 + readonly dependsOn: string[] = []; 16 + 17 + constructor(private readonly prisma: PrismaService) {} 18 + 19 + async computeStatus(user: User): Promise<OnboardingStepStatus> { 20 + const count = await this.prisma.education.count({ 21 + where: { profile: { userId: user.id } }, 22 + }); 23 + 24 + return count > 0 25 + ? OnboardingStepStatus.COMPLETE 26 + : OnboardingStepStatus.NOT_STARTED; 27 + } 28 + }
+28
apps/server/src/modules/job-experience/employment/onboarding/career-history.step.ts
··· 1 + import type { User } from "@cv/auth"; 2 + import { PrismaService } from "@cv/system"; 3 + import { Injectable } from "@nestjs/common"; 4 + import { OnboardingStep } from "@/modules/onboarding/onboarding-step.decorator"; 5 + import { 6 + type OnboardingStepDefinition, 7 + OnboardingStepStatus, 8 + } from "@/modules/onboarding/onboarding-step.interface"; 9 + 10 + @Injectable() 11 + @OnboardingStep() 12 + export class CareerHistoryOnboardingStep implements OnboardingStepDefinition { 13 + readonly name = "career-history"; 14 + readonly order = 3; 15 + readonly dependsOn: string[] = []; 16 + 17 + constructor(private readonly prisma: PrismaService) {} 18 + 19 + async computeStatus(user: User): Promise<OnboardingStepStatus> { 20 + const count = await this.prisma.userJobExperience.count({ 21 + where: { profile: { userId: user.id } }, 22 + }); 23 + 24 + return count > 0 25 + ? OnboardingStepStatus.COMPLETE 26 + : OnboardingStepStatus.NOT_STARTED; 27 + } 28 + }
+25
apps/server/src/modules/onboarding/graphql/onboarding.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 { Mutation, Query, Resolver } from "@nestjs/graphql"; 5 + import { CurrentUser } from "@/modules/current-user/current-user.decorator"; 6 + import { OnboardingService } from "../onboarding.service"; 7 + import { OnboardingStepResult } from "./onboarding.type"; 8 + 9 + @Resolver() 10 + @UseGuards(JwtAuthGuard, VerifiedScopeGuard) 11 + export class OnboardingResolver { 12 + constructor(private readonly onboardingService: OnboardingService) {} 13 + 14 + @Query(() => [OnboardingStepResult]) 15 + async onboardingStatus( 16 + @CurrentUser() user: DomainUser, 17 + ): Promise<OnboardingStepResult[]> { 18 + return this.onboardingService.getStatus(user); 19 + } 20 + 21 + @Mutation(() => Boolean) 22 + async resetOnboarding(@CurrentUser() user: DomainUser): Promise<boolean> { 23 + return this.onboardingService.resetOnboarding(user); 24 + } 25 + }
+22
apps/server/src/modules/onboarding/graphql/onboarding.type.ts
··· 1 + import { Field, ObjectType, registerEnumType } from "@nestjs/graphql"; 2 + import { OnboardingStepStatus } from "../onboarding-step.interface"; 3 + 4 + registerEnumType(OnboardingStepStatus, { 5 + name: "OnboardingStepStatus", 6 + description: "Status of an onboarding step", 7 + }); 8 + 9 + @ObjectType() 10 + export class OnboardingStepResult { 11 + @Field() 12 + name!: string; 13 + 14 + @Field(() => OnboardingStepStatus) 15 + status!: OnboardingStepStatus; 16 + 17 + @Field(() => [String]) 18 + dependsOn!: string[]; 19 + 20 + @Field(() => [String]) 21 + blockedBy!: string[]; 22 + }
+6
apps/server/src/modules/onboarding/onboarding-step.decorator.ts
··· 1 + import { SetMetadata } from "@nestjs/common"; 2 + 3 + export const ONBOARDING_STEP_METADATA_KEY = "onboarding_step"; 4 + 5 + export const OnboardingStep = () => 6 + SetMetadata(ONBOARDING_STEP_METADATA_KEY, true);
+14
apps/server/src/modules/onboarding/onboarding-step.interface.ts
··· 1 + import type { User } from "@cv/auth"; 2 + 3 + export enum OnboardingStepStatus { 4 + NOT_STARTED = "NOT_STARTED", 5 + IN_PROGRESS = "IN_PROGRESS", 6 + COMPLETE = "COMPLETE", 7 + } 8 + 9 + export interface OnboardingStepDefinition { 10 + name: string; 11 + order: number; 12 + dependsOn: string[]; 13 + computeStatus(user: User): Promise<OnboardingStepStatus>; 14 + }
+12
apps/server/src/modules/onboarding/onboarding.module.ts
··· 1 + import { DatabaseModule } from "@cv/system"; 2 + import { Module } from "@nestjs/common"; 3 + import { DiscoveryModule } from "@nestjs/core"; 4 + import { OnboardingResolver } from "./graphql/onboarding.resolver"; 5 + import { OnboardingService } from "./onboarding.service"; 6 + 7 + @Module({ 8 + imports: [DiscoveryModule, DatabaseModule], 9 + providers: [OnboardingService, OnboardingResolver], 10 + exports: [OnboardingService], 11 + }) 12 + export class OnboardingModule {}
+101
apps/server/src/modules/onboarding/onboarding.service.ts
··· 1 + import type { User } from "@cv/auth"; 2 + import { PrismaService } from "@cv/system"; 3 + import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; 4 + import { DiscoveryService } from "@nestjs/core"; 5 + import { ONBOARDING_STEP_METADATA_KEY } from "./onboarding-step.decorator"; 6 + import type { 7 + OnboardingStepDefinition, 8 + OnboardingStepStatus, 9 + } from "./onboarding-step.interface"; 10 + 11 + export interface OnboardingStepResult { 12 + name: string; 13 + status: OnboardingStepStatus; 14 + dependsOn: string[]; 15 + blockedBy: string[]; 16 + } 17 + 18 + @Injectable() 19 + export class OnboardingService implements OnModuleInit { 20 + private readonly logger = new Logger(OnboardingService.name); 21 + private steps: OnboardingStepDefinition[] = []; 22 + 23 + constructor( 24 + private readonly discoveryService: DiscoveryService, 25 + private readonly prisma: PrismaService, 26 + ) {} 27 + 28 + async onModuleInit(): Promise<void> { 29 + const providers = this.discoveryService.getProviders(); 30 + 31 + for (const wrapper of providers) { 32 + if (!(wrapper.isDependencyTreeStatic() && wrapper.instance)) continue; 33 + 34 + const metadata = Reflect.getMetadata( 35 + ONBOARDING_STEP_METADATA_KEY, 36 + wrapper.instance.constructor, 37 + ); 38 + 39 + if (!metadata) continue; 40 + 41 + const instance = wrapper.instance as OnboardingStepDefinition; 42 + 43 + if ( 44 + typeof instance.name !== "string" || 45 + typeof instance.order !== "number" || 46 + !Array.isArray(instance.dependsOn) || 47 + typeof instance.computeStatus !== "function" 48 + ) { 49 + this.logger.warn( 50 + `Provider ${wrapper.instance.constructor.name} has @OnboardingStep() but doesn't implement OnboardingStepDefinition`, 51 + ); 52 + continue; 53 + } 54 + 55 + this.steps.push(instance); 56 + this.logger.log(`Registered onboarding step: ${instance.name}`); 57 + } 58 + 59 + this.steps.sort((a, b) => a.order - b.order); 60 + } 61 + 62 + async resetOnboarding(user: User): Promise<boolean> { 63 + await this.prisma.$transaction([ 64 + this.prisma.education.deleteMany({ 65 + where: { profile: { userId: user.id } }, 66 + }), 67 + this.prisma.userJobExperience.deleteMany({ 68 + where: { profile: { userId: user.id } }, 69 + }), 70 + this.prisma.importJob.deleteMany({ 71 + where: { userFile: { profile: { userId: user.id } } }, 72 + }), 73 + this.prisma.userFile.deleteMany({ 74 + where: { profile: { userId: user.id } }, 75 + }), 76 + this.prisma.profile.deleteMany({ where: { userId: user.id } }), 77 + this.prisma.userAiProvider.deleteMany({ where: { userId: user.id } }), 78 + this.prisma.userAiSettings.deleteMany({ where: { userId: user.id } }), 79 + ]); 80 + 81 + this.logger.log(`Onboarding reset for user ${user.id}`); 82 + return true; 83 + } 84 + 85 + async getStatus(user: User): Promise<OnboardingStepResult[]> { 86 + const statuses = await Promise.all( 87 + this.steps.map(async (step) => ({ 88 + name: step.name, 89 + status: await step.computeStatus(user), 90 + dependsOn: step.dependsOn, 91 + })), 92 + ); 93 + 94 + const statusMap = new Map(statuses.map((s) => [s.name, s.status])); 95 + 96 + return statuses.map((s) => ({ 97 + ...s, 98 + blockedBy: s.dependsOn.filter((dep) => statusMap.get(dep) !== "COMPLETE"), 99 + })); 100 + } 101 + }
+31
apps/server/src/modules/profile/onboarding/profile.step.ts
··· 1 + import type { User } from "@cv/auth"; 2 + import { PrismaService } from "@cv/system"; 3 + import { Injectable } from "@nestjs/common"; 4 + import { OnboardingStep } from "@/modules/onboarding/onboarding-step.decorator"; 5 + import { 6 + type OnboardingStepDefinition, 7 + OnboardingStepStatus, 8 + } from "@/modules/onboarding/onboarding-step.interface"; 9 + 10 + @Injectable() 11 + @OnboardingStep() 12 + export class ProfileOnboardingStep implements OnboardingStepDefinition { 13 + readonly name = "personal-profile"; 14 + readonly order = 2; 15 + readonly dependsOn: string[] = []; 16 + 17 + constructor(private readonly prisma: PrismaService) {} 18 + 19 + async computeStatus(user: User): Promise<OnboardingStepStatus> { 20 + const profile = await this.prisma.profile.findFirst({ 21 + where: { userId: user.id }, 22 + select: { headline: true, city: true }, 23 + }); 24 + 25 + if (!profile) return OnboardingStepStatus.NOT_STARTED; 26 + 27 + return profile.headline && profile.city 28 + ? OnboardingStepStatus.COMPLETE 29 + : OnboardingStepStatus.IN_PROGRESS; 30 + } 31 + }
+28
apps/server/src/modules/user-settings/onboarding/ai-preference.step.ts
··· 1 + import type { User } from "@cv/auth"; 2 + import { PrismaService } from "@cv/system"; 3 + import { Injectable } from "@nestjs/common"; 4 + import { OnboardingStep } from "@/modules/onboarding/onboarding-step.decorator"; 5 + import { 6 + type OnboardingStepDefinition, 7 + OnboardingStepStatus, 8 + } from "@/modules/onboarding/onboarding-step.interface"; 9 + 10 + @Injectable() 11 + @OnboardingStep() 12 + export class AiPreferenceOnboardingStep implements OnboardingStepDefinition { 13 + readonly name = "ai-preference"; 14 + readonly order = 0; 15 + readonly dependsOn: string[] = []; 16 + 17 + constructor(private readonly prisma: PrismaService) {} 18 + 19 + async computeStatus(user: User): Promise<OnboardingStepStatus> { 20 + const settings = await this.prisma.userAiSettings.findUnique({ 21 + where: { userId: user.id }, 22 + }); 23 + 24 + return settings 25 + ? OnboardingStepStatus.COMPLETE 26 + : OnboardingStepStatus.NOT_STARTED; 27 + } 28 + }