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.

refactor(server): add topological sort to seeders with dependency resolution

+310 -111
+2 -1
apps/server/src/modules/database/seed/seed.module.ts
··· 3 3 import { Module } from "@nestjs/common"; 4 4 import { ConfigModule } from "@nestjs/config"; 5 5 import { DiscoveryModule } from "@nestjs/core"; 6 + import { CVTemplateSeedService } from "@/modules/cv-template/seed/cv-template.seed"; 6 7 import { UserSeedService } from "@/modules/user/seed/user.seed"; 7 8 import { SeedService } from "./seed.service"; 8 9 ··· 13 14 DiscoveryModule, 14 15 AuthModule, 15 16 ], 16 - providers: [SeedService, UserSeedService], 17 + providers: [SeedService, UserSeedService, CVTemplateSeedService], 17 18 exports: [SeedService], 18 19 }) 19 20 export class SeedModule {}
+71 -23
apps/server/src/modules/database/seed/seed.service.ts
··· 1 1 import { PrismaService } from "@cv/system"; 2 2 import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; 3 3 import { DiscoveryService } from "@nestjs/core"; 4 + import type { SeederConfig } from "./seeder.decorator"; 4 5 import { SEEDER_METADATA_KEY } from "./seeder.decorator"; 6 + import { topologicalSort } from "./topological-sort"; 5 7 6 8 export interface Seeder { 7 9 seed(prisma: PrismaService): Promise<void>; 8 10 } 9 11 12 + const parseMetadata = ( 13 + metadata: unknown, 14 + fallbackName: string, 15 + ): { name: string; dependsOn: string[] } => { 16 + if (typeof metadata === "string") return { name: metadata, dependsOn: [] }; 17 + if (typeof metadata === "object" && metadata !== null) { 18 + const config = metadata as SeederConfig; 19 + return { 20 + name: config.name ?? fallbackName, 21 + dependsOn: config.dependsOn ?? [], 22 + }; 23 + } 24 + return { name: fallbackName, dependsOn: [] }; 25 + }; 26 + 10 27 @Injectable() 11 28 export class SeedService implements OnModuleInit { 12 29 private readonly logger = new Logger(SeedService.name); 13 - private readonly seeders: Array<{ name: string; seeder: Seeder }> = []; 30 + private readonly seeders: Array<{ 31 + name: string; 32 + dependsOn: string[]; 33 + seeder: Seeder; 34 + }> = []; 14 35 15 36 constructor( 16 37 private readonly prisma: PrismaService, ··· 18 39 ) {} 19 40 20 41 async onModuleInit(): Promise<void> { 21 - // Discover all providers that have the @Seeder decorator 22 42 const providers = this.discoveryService.getProviders(); 23 43 24 44 for (const wrapper of providers) { 25 - if (wrapper.isDependencyTreeStatic() && wrapper.instance) { 26 - const instance = wrapper.instance; 45 + if (!wrapper.isDependencyTreeStatic() || !wrapper.instance) continue; 27 46 28 - // Check if the provider has the @Seeder decorator 29 - const seederMetadata = Reflect.getMetadata( 30 - SEEDER_METADATA_KEY, 31 - instance.constructor, 32 - ); 47 + const instance = wrapper.instance; 48 + const seederMetadata = Reflect.getMetadata( 49 + SEEDER_METADATA_KEY, 50 + instance.constructor, 51 + ); 33 52 34 - if (seederMetadata && instance && typeof instance.seed === "function") { 35 - const name = 36 - typeof seederMetadata === "string" 37 - ? seederMetadata 38 - : instance.constructor.name; 39 - this.seeders.push({ name, seeder: instance }); 40 - this.logger.log(`Registered seeder: ${name}`); 41 - } 42 - } 53 + if (!seederMetadata || typeof instance.seed !== "function") continue; 54 + 55 + const { name, dependsOn } = parseMetadata( 56 + seederMetadata, 57 + instance.constructor.name, 58 + ); 59 + this.seeders.push({ name, dependsOn, seeder: instance }); 60 + this.logger.log(`Registered seeder: ${name}`); 43 61 } 44 62 } 45 63 46 64 async seed(): Promise<void> { 47 - this.logger.log("🌱 Starting database seeding..."); 65 + this.logger.log("Starting database seeding..."); 66 + 67 + const result = topologicalSort( 68 + this.seeders.map((s) => ({ 69 + name: s.name, 70 + dependsOn: s.dependsOn, 71 + value: s.seeder, 72 + })), 73 + ); 74 + 75 + if ("cycle" in result) { 76 + throw new Error( 77 + `Circular seeder dependencies detected: ${result.cycle.join(" -> ")}`, 78 + ); 79 + } 80 + 81 + this.logger.log( 82 + `Execution order: ${result.sorted.map((s) => s.name).join(" -> ")}`, 83 + ); 84 + 85 + const failures: string[] = []; 86 + 87 + for (const { name, value: seeder } of result.sorted) { 88 + try { 89 + this.logger.log(`Seeding: ${name}...`); 90 + await seeder.seed(this.prisma); 91 + } catch (error) { 92 + const message = error instanceof Error ? error.message : String(error); 93 + this.logger.error(`Seeder "${name}" failed: ${message}`); 94 + failures.push(name); 95 + } 96 + } 48 97 49 - for (const { name, seeder } of this.seeders) { 50 - this.logger.log(`Seeding: ${name}...`); 51 - await seeder.seed(this.prisma); 98 + if (failures.length > 0) { 99 + throw new Error(`Seeders failed: ${failures.join(", ")}`); 52 100 } 53 101 54 - this.logger.log("✅ Database seeding completed!"); 102 + this.logger.log("Database seeding completed!"); 55 103 } 56 104 }
+7 -2
apps/server/src/modules/database/seed/seeder.decorator.ts
··· 2 2 3 3 export const SEEDER_METADATA_KEY = "seeder"; 4 4 5 - export const Seeder = (name?: string) => 6 - SetMetadata(SEEDER_METADATA_KEY, name ?? true); 5 + export interface SeederConfig { 6 + name?: string; 7 + dependsOn?: string[]; 8 + } 9 + 10 + export const Seeder = (config?: string | SeederConfig) => 11 + SetMetadata(SEEDER_METADATA_KEY, config ?? true);
+91
apps/server/src/modules/database/seed/topological-sort.spec.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { topologicalSort } from "./topological-sort"; 3 + 4 + describe("topologicalSort", () => { 5 + it("returns empty array for empty input", () => { 6 + const result = topologicalSort([]); 7 + expect(result).toEqual({ sorted: [] }); 8 + }); 9 + 10 + it("returns nodes with no dependencies in stable order", () => { 11 + const result = topologicalSort([ 12 + { name: "A", dependsOn: [], value: 1 }, 13 + { name: "B", dependsOn: [], value: 2 }, 14 + { name: "C", dependsOn: [], value: 3 }, 15 + ]); 16 + 17 + expect(result).toEqual({ 18 + sorted: [ 19 + { name: "A", value: 1 }, 20 + { name: "B", value: 2 }, 21 + { name: "C", value: 3 }, 22 + ], 23 + }); 24 + }); 25 + 26 + it("sorts a linear dependency chain", () => { 27 + const result = topologicalSort([ 28 + { name: "C", dependsOn: ["B"], value: 3 }, 29 + { name: "A", dependsOn: [], value: 1 }, 30 + { name: "B", dependsOn: ["A"], value: 2 }, 31 + ]); 32 + 33 + expect("sorted" in result).toBe(true); 34 + if (!("sorted" in result)) return; 35 + 36 + const names = result.sorted.map((n) => n.name); 37 + expect(names.indexOf("A")).toBeLessThan(names.indexOf("B")); 38 + expect(names.indexOf("B")).toBeLessThan(names.indexOf("C")); 39 + }); 40 + 41 + it("sorts branching dependencies", () => { 42 + const result = topologicalSort([ 43 + { name: "D", dependsOn: ["B", "C"], value: 4 }, 44 + { name: "B", dependsOn: ["A"], value: 2 }, 45 + { name: "C", dependsOn: ["A"], value: 3 }, 46 + { name: "A", dependsOn: [], value: 1 }, 47 + ]); 48 + 49 + expect("sorted" in result).toBe(true); 50 + if (!("sorted" in result)) return; 51 + 52 + const names = result.sorted.map((n) => n.name); 53 + expect(names.indexOf("A")).toBeLessThan(names.indexOf("B")); 54 + expect(names.indexOf("A")).toBeLessThan(names.indexOf("C")); 55 + expect(names.indexOf("B")).toBeLessThan(names.indexOf("D")); 56 + expect(names.indexOf("C")).toBeLessThan(names.indexOf("D")); 57 + }); 58 + 59 + it("detects circular dependencies", () => { 60 + const result = topologicalSort([ 61 + { name: "A", dependsOn: ["B"], value: 1 }, 62 + { name: "B", dependsOn: ["A"], value: 2 }, 63 + ]); 64 + 65 + expect("cycle" in result).toBe(true); 66 + if (!("cycle" in result)) return; 67 + 68 + expect(result.cycle).toContain("A"); 69 + expect(result.cycle).toContain("B"); 70 + }); 71 + 72 + it("throws on unknown dependency names", () => { 73 + expect(() => 74 + topologicalSort([ 75 + { name: "A", dependsOn: ["NonExistent"], value: 1 }, 76 + ]), 77 + ).toThrow("Unknown seeder dependencies: NonExistent"); 78 + }); 79 + 80 + it("preserves values through sorting", () => { 81 + const obj = { data: "test" }; 82 + const result = topologicalSort([ 83 + { name: "A", dependsOn: [], value: obj }, 84 + ]); 85 + 86 + expect("sorted" in result).toBe(true); 87 + if (!("sorted" in result)) return; 88 + 89 + expect(result.sorted[0].value).toBe(obj); 90 + }); 91 + });
+61
apps/server/src/modules/database/seed/topological-sort.ts
··· 1 + /** 2 + * Topologically sorts nodes using Kahn's algorithm. 3 + * Returns sorted order or the cycle that prevents resolution. 4 + */ 5 + export const topologicalSort = <T>( 6 + nodes: Array<{ name: string; dependsOn: string[]; value: T }>, 7 + ): { sorted: Array<{ name: string; value: T }> } | { cycle: string[] } => { 8 + const nameSet = new Set(nodes.map((n) => n.name)); 9 + 10 + const unknownDeps = nodes.flatMap((n) => 11 + n.dependsOn.filter((dep) => !nameSet.has(dep)), 12 + ); 13 + if (unknownDeps.length > 0) { 14 + throw new Error( 15 + `Unknown seeder dependencies: ${[...new Set(unknownDeps)].join(", ")}`, 16 + ); 17 + } 18 + 19 + const inDegree = new Map<string, number>(); 20 + const adjacency = new Map<string, string[]>(); 21 + const valueMap = new Map<string, T>(); 22 + 23 + nodes.forEach((n) => { 24 + inDegree.set(n.name, 0); 25 + adjacency.set(n.name, []); 26 + valueMap.set(n.name, n.value); 27 + }); 28 + 29 + nodes.forEach((n) => { 30 + n.dependsOn.forEach((dep) => { 31 + adjacency.get(dep)!.push(n.name); 32 + inDegree.set(n.name, inDegree.get(n.name)! + 1); 33 + }); 34 + }); 35 + 36 + const queue = nodes 37 + .filter((n) => inDegree.get(n.name) === 0) 38 + .map((n) => n.name); 39 + 40 + const sorted: Array<{ name: string; value: T }> = []; 41 + 42 + while (queue.length > 0) { 43 + const current = queue.shift()!; 44 + sorted.push({ name: current, value: valueMap.get(current)! }); 45 + 46 + for (const neighbor of adjacency.get(current)!) { 47 + const newDegree = inDegree.get(neighbor)! - 1; 48 + inDegree.set(neighbor, newDegree); 49 + if (newDegree === 0) queue.push(neighbor); 50 + } 51 + } 52 + 53 + if (sorted.length !== nodes.length) { 54 + const cycle = nodes 55 + .filter((n) => inDegree.get(n.name)! > 0) 56 + .map((n) => n.name); 57 + return { cycle }; 58 + } 59 + 60 + return { sorted }; 61 + };
+12 -4
apps/server/src/modules/job-experience/seed/job-experience.seed.ts
··· 6 6 import { ReferenceDataSeedService } from "./reference-data.seed"; 7 7 8 8 @Injectable() 9 - @SeederDecorator("Job Experiences") 9 + @SeederDecorator({ name: "Job Experiences", dependsOn: ["Users", "Reference Data"] }) 10 10 export class JobExperienceSeedService implements Seeder { 11 11 private readonly logger = new Logger(JobExperienceSeedService.name); 12 12 ··· 38 38 return; 39 39 } 40 40 41 - // Clear existing job experiences for this user only 41 + // Get or create profile for the test user 42 + const existingProfile = await prisma.profile.findFirst({ 43 + where: { userId: testUser.id }, 44 + }); 45 + const profile = existingProfile ?? await prisma.profile.create({ 46 + data: { userId: testUser.id, name: testUser.name }, 47 + }); 48 + 49 + // Clear existing job experiences for this profile only 42 50 this.logger.log("Clearing existing job experiences for test user..."); 43 51 await prisma.userJobExperience.deleteMany({ 44 - where: { userId: testUser.id }, 52 + where: { profileId: profile.id }, 45 53 }); 46 54 47 55 // Create job experiences for the test user ··· 66 74 67 75 return prisma.userJobExperience.create({ 68 76 data: { 69 - userId: testUser.id, 77 + profileId: profile.id, 70 78 companyId: company.id, 71 79 roleId: role.id, 72 80 levelId: level.id,
+2
apps/server/src/modules/organization/graphql/membership.type.ts
··· 1 + import { UserRole } from "@cv/auth"; 1 2 import { Field, ObjectType } from "@nestjs/graphql"; 2 3 import { createConnection } from "@/modules/base/connection.factory"; 3 4 import { OrganizationRole } from "@/modules/organization/organization-role.entity"; ··· 72 73 const graphqlUser = new User( 73 74 user.id, 74 75 user.name, 76 + UserRole.USER, 75 77 user.createdAt, 76 78 user.credentials?.email ?? null, 77 79 );
+1 -15
apps/server/src/modules/organization/organization-role.service.ts
··· 1 1 import { notFound } from "@cv/auth"; 2 2 import { PrismaService } from "@cv/system"; 3 - import { Injectable, Logger } from "@nestjs/common"; 3 + import { Injectable } from "@nestjs/common"; 4 4 import { OrganizationRole } from "./organization-role.entity"; 5 5 import { OrganizationRoleMapper } from "./organization-role.mapper"; 6 6 7 7 @Injectable() 8 8 export class OrganizationRoleService { 9 - private readonly logger = new Logger(OrganizationRoleService.name); 10 - 11 9 constructor( 12 10 private readonly prisma: PrismaService, 13 11 private readonly organizationRoleMapper: OrganizationRoleMapper, 14 12 ) {} 15 13 16 14 async findById(id: string): Promise<OrganizationRole | null> { 17 - this.logger.log(`Finding organization role by id: ${id}`); 18 - 19 15 const role = await this.prisma.organizationRole.findUnique({ 20 16 where: { id }, 21 17 }); ··· 24 20 } 25 21 26 22 async findByIdOrFail(id: string): Promise<OrganizationRole> { 27 - this.logger.log(`Finding organization role by id or fail: ${id}`); 28 - 29 23 const role = await this.findById(id); 30 24 return role ?? notFound("Organization role", "id", id); 31 25 } 32 26 33 27 async findAll(): Promise<OrganizationRole[]> { 34 - this.logger.log("Finding all organization roles"); 35 - 36 28 const roles = await this.prisma.organizationRole.findMany({ 37 29 orderBy: { name: "asc" }, 38 30 }); ··· 44 36 description?: string; 45 37 color?: string; 46 38 }): Promise<OrganizationRole> { 47 - this.logger.log(`Creating organization role: ${data.name}`); 48 - 49 39 const role = await this.prisma.organizationRole.create({ 50 40 data: { 51 41 name: data.name, ··· 65 55 color?: string; 66 56 }, 67 57 ): Promise<OrganizationRole> { 68 - this.logger.log(`Updating organization role: ${id}`); 69 - 70 58 const role = await this.prisma.organizationRole.update({ 71 59 where: { id }, 72 60 data: { ··· 82 70 } 83 71 84 72 async delete(id: string): Promise<void> { 85 - this.logger.log(`Deleting organization role: ${id}`); 86 - 87 73 await this.prisma.organizationRole.delete({ 88 74 where: { id }, 89 75 });
+62 -65
apps/server/src/modules/organization/seed/membership.seed.ts
··· 5 5 import { Seeder as SeederDecorator } from "@/modules/database/seed/seeder.decorator"; 6 6 7 7 @Injectable() 8 - @SeederDecorator("Memberships") 8 + @SeederDecorator({ name: "Memberships", dependsOn: ["Users", "Organizations"] }) 9 9 export class MembershipSeedService implements Seeder { 10 10 private readonly logger = new Logger(MembershipSeedService.name); 11 11 12 12 constructor(readonly _prisma: PrismaService) {} 13 13 14 + private async ensureMemberRole( 15 + prisma: PrismaService, 16 + ): Promise<{ id: string }> { 17 + const existing = await prisma["organizationRole"].findFirst({ 18 + where: { name: "Member" }, 19 + }); 20 + 21 + if (existing) return { id: existing.id }; 22 + 23 + return prisma["organizationRole"].create({ 24 + data: { name: "Member", description: "Default member role" }, 25 + }); 26 + } 27 + 28 + private async createMembershipIfMissing( 29 + prisma: PrismaService, 30 + userId: string, 31 + organizationId: string, 32 + roleId: string, 33 + ): Promise<void> { 34 + const existing = await prisma["membership"].findFirst({ 35 + where: { userId, organizationId }, 36 + }); 37 + 38 + if (!existing) { 39 + await prisma["membership"].create({ 40 + data: { userId, organizationId, organizationRoleId: roleId }, 41 + }); 42 + } 43 + } 44 + 14 45 async seed(prisma: PrismaService): Promise<void> { 15 46 this.logger.log("Seeding memberships..."); 16 47 17 - // Get all users and organizations 48 + const memberRole = await this.ensureMemberRole(prisma); 49 + 18 50 const [allUsers, allOrganizations] = await Promise.all([ 19 51 prisma["user"].findMany(), 20 52 prisma["organization"].findMany(), ··· 27 59 return; 28 60 } 29 61 30 - // First, ensure test@test.test is in multiple organizations 31 - await this.ensureTestUserMemberships(prisma, allUsers, allOrganizations); 62 + await this.ensureTestUserMemberships( 63 + prisma, 64 + allOrganizations, 65 + memberRole.id, 66 + ); 32 67 33 - // Get all credentials to find test user by email 34 68 const allCredentials = await prisma["credentials"].findMany({ 35 69 include: { user: true }, 36 70 }); 37 71 const testUserCredentials = allCredentials.find( 38 - (c) => c.email === "test@test.test", 72 + (c: { email: string }) => c.email === "test@test.test", 39 73 ); 40 74 const testUserIds = testUserCredentials ? [testUserCredentials.userId] : []; 41 75 42 - // Then assign other users to organizations 43 76 await Promise.all( 44 - allUsers.map(async (user) => { 45 - // Skip test@test.test as it's already handled above 46 - if (testUserIds.includes(user.id)) { 47 - return; 48 - } 77 + allUsers.map(async (user: { id: string }) => { 78 + if (testUserIds.includes(user.id)) return; 49 79 50 - // Each user should have 1-2 organizations, usually at least one 51 80 const numOrganizations = faker.number.int({ min: 1, max: 2 }); 52 81 const selectedOrganizations = faker.helpers.arrayElements( 53 82 allOrganizations, 54 - { 55 - min: 1, 56 - max: numOrganizations, 57 - }, 83 + { min: 1, max: numOrganizations }, 58 84 ); 59 85 60 - // Create memberships for this user 61 86 await Promise.all( 62 - selectedOrganizations.map(async (org) => { 63 - // Check if user is already in this organization 64 - const existingMembership = await prisma["membership"].findFirst({ 65 - where: { 66 - userId: user.id, 67 - organizationId: org.id, 68 - }, 69 - }); 70 - 71 - if (!existingMembership) { 72 - await prisma["membership"].create({ 73 - data: { 74 - userId: user.id, 75 - organizationId: org.id, 76 - organizationRoleId: "member_role_id", 77 - }, 78 - }); 79 - } 80 - }), 87 + selectedOrganizations.map((org: { id: string }) => 88 + this.createMembershipIfMissing( 89 + prisma, 90 + user.id, 91 + org.id, 92 + memberRole.id, 93 + ), 94 + ), 81 95 ); 82 96 }), 83 97 ); 84 98 85 - this.logger.log(`Assigned users to organizations`); 99 + this.logger.log("Assigned users to organizations"); 86 100 } 87 101 88 - async ensureTestUserMemberships( 102 + private async ensureTestUserMemberships( 89 103 prisma: PrismaService, 90 - allUsers: Array<{ id: string }>, 91 104 allOrganizations: Array<{ id: string }>, 105 + roleId: string, 92 106 ): Promise<void> { 93 107 const testCredentials = await prisma["credentials"].findUnique({ 94 108 where: { email: "test@test.test" }, ··· 102 116 return; 103 117 } 104 118 105 - const testUser = { id: testCredentials.userId }; 106 - 107 - // Add test@test.test to the first 5 organizations 119 + const testUserId = testCredentials.userId; 108 120 const organizationsForTestUser = allOrganizations.slice(0, 5); 109 121 110 122 await Promise.all( 111 123 organizationsForTestUser.map(async (org) => { 112 - // Check if user is already in this organization 113 - const existingMembership = await prisma["membership"].findFirst({ 114 - where: { 115 - userId: testUser.id, 116 - organizationId: org.id, 117 - }, 118 - }); 119 - 120 - if (!existingMembership) { 121 - this.logger.log(`Adding test@test.test to organization ${org.id}`); 122 - await prisma["membership"].create({ 123 - data: { 124 - userId: testUser.id, 125 - organizationId: org.id, 126 - organizationRoleId: "member_role_id", 127 - }, 128 - }); 129 - } 124 + this.logger.log(`Adding test@test.test to organization ${org.id}`); 125 + await this.createMembershipIfMissing( 126 + prisma, 127 + testUserId, 128 + org.id, 129 + roleId, 130 + ); 130 131 }), 131 - ); 132 - 133 - this.logger.log( 134 - `Test user added to ${organizationsForTestUser.length} organizations`, 135 132 ); 136 133 } 137 134 }
+1 -1
apps/server/src/modules/vacancies/seed/vacancy.seed.ts
··· 5 5 import { Seeder as SeederDecorator } from "@/modules/database/seed/seeder.decorator"; 6 6 7 7 @Injectable() 8 - @SeederDecorator("Public Vacancies") 8 + @SeederDecorator({ name: "Public Vacancies", dependsOn: ["Users", "Reference Data"] }) 9 9 export class VacancySeedService implements Seeder { 10 10 private readonly logger = new Logger(VacancySeedService.name); 11 11