diff --git a/.docker/.manifests/apps__docs__package.json b/.docker/.manifests/apps__docs__package.json
index 8514c44..405ddd0 100644
--- a/.docker/.manifests/apps__docs__package.json
+++ b/.docker/.manifests/apps__docs__package.json
@@ -39,6 +39,7 @@
"postcss": "^8.4.47",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.3",
+ "unified": "^11.0.5",
"vite": "^5.4.3"
}
}
diff --git a/.docker/.manifests/apps__server__package.json b/.docker/.manifests/apps__server__package.json
deleted file mode 100644
index 5e7f493..0000000
--- a/.docker/.manifests/apps__server__package.json
+++ /dev/null
@@ -1,119 +0,0 @@
-{
- "name": "@cv/server",
- "version": "0.0.0",
- "private": true,
- "type": "commonjs",
- "scripts": {
- "build": "tsup",
- "postbuild": "sh scripts/post-build.sh",
- "start": "node dist/main.js",
- "dev": "nodemon --watch src -e ts --exec \"ts-node -r tsconfig-paths/register src/main.ts\"",
- "lint": "biome check .",
- "lint:fix": "biome check --write .",
- "typecheck": "tsc -p tsconfig.build.json --noEmit",
- "test": "jest --config ./test/jest-unit.json",
- "test:unit": "jest --config ./test/jest-unit.json",
- "test:e2e": "jest --config ./test/jest-e2e.json",
- "e2e:coverage:c8:start": "npm run prisma:deploy && npm run build && npx c8 -r text -r lcov node dist/main.js",
- "e2e:coverage:c8:report": "npx c8 report -r text -r lcov",
- "e2e:coverage:c8": "E2E_EXTERNAL_URL=http://localhost:3000 npm run test:e2e && npm run e2e:coverage:c8:report",
- "e2e:coverage:c8:stop": "pkill -f 'node dist/main.js' || true",
- "e2e:coverage:all": "node src/scripts/e2e-coverage-all.cjs",
- "prepare": "prisma generate || true",
- "prisma:generate": "prisma generate",
- "prisma:migrate": "prisma migrate dev",
- "prisma:deploy": "prisma migrate deploy",
- "prisma:studio": "prisma studio",
- "seed": "ts-node -r tsconfig-paths/register src/scripts/seed.ts",
- "seed:test": "ts-node -r tsconfig-paths/register src/scripts/seed-test.ts",
- "test:integration": "vitest run --config vitest.config.ts",
- "test:integration:watch": "vitest --config vitest.config.ts",
- "test:integration:ui": "vitest --ui --config vitest.config.ts"
- },
- "dependencies": {
- "@apollo/server": "^4.11.0",
- "@cv/ai-parser": "*",
- "@cv/ai-provider": "*",
- "@cv/auth": "*",
- "@cv/cv-renderer": "*",
- "@cv/file-storage": "*",
- "@cv/file-upload": "*",
- "@cv/system": "*",
- "@cv/utils": "*",
- "@nestjs/apollo": "^12.2.2",
- "@nestjs/common": "^10.4.7",
- "@nestjs/config": "^3.2.0",
- "@nestjs/core": "^10.4.7",
- "@nestjs/event-emitter": "^3.0.1",
- "@nestjs/graphql": "^12.2.2",
- "@nestjs/jwt": "^10.2.0",
- "@nestjs/mapped-types": "^2.1.0",
- "@nestjs/passport": "^10.0.3",
- "@nestjs/platform-express": "^10.4.7",
- "@prisma/adapter-pg": "^7.1.0",
- "@prisma/client": "^7.1.0",
- "@riotbyte-com/nest-service-locator": "0.3.0-rc.4",
- "@riotbyte-com/project-q-core": "1.0.19-rc.3",
- "@riotbyte-com/project-q-nestjs": "1.0.19-rc.3",
- "@riotbyte-com/project-q-prisma": "1.0.19-rc.3",
- "@types/cookie-parser": "^1.4.10",
- "@types/handlebars": "^4.0.40",
- "@types/multer": "^2.0.0",
- "apollo-server-express": "^3.13.0",
- "bcryptjs": "^2.4.3",
- "canvas": "^3.1.0",
- "class-transformer": "^0.5.1",
- "class-validator": "^0.14.0",
- "cookie-parser": "^1.4.7",
- "dataloader": "^2.2.3",
- "express": "^4.21.2",
- "graphql": "^16.12.0",
- "graphql-scalars": "^1.23.0",
- "graphql-type-json": "^0.3.2",
- "handlebars": "^4.7.8",
- "mammoth": "^1.11.0",
- "multer": "^2.0.2",
- "nest-commander": "^3.16.0",
- "nestjs-zod": "^5.1.1",
- "passport": "^0.7.0",
- "passport-jwt": "^4.0.1",
- "passport-local": "^1.0.0",
- "pdf-parse": "^1.1.4",
- "pdf2json": "^3.1.4",
- "pdfjs-dist": "^3.11.174",
- "pg": "^8.16.3",
- "reflect-metadata": "^0.2.2",
- "resend": "^6.5.2",
- "rxjs": "^7.8.1",
- "tesseract.js": "^5.1.1",
- "zod": "^4.3.6"
- },
- "devDependencies": {
- "@biomejs/biome": "^2.2.6",
- "@cv/biome-config": "*",
- "@cv/tsconfig": "*",
- "@faker-js/faker": "^9.9.0",
- "@nestjs/testing": "^10.4.7",
- "@swc/core": "^1.15.13",
- "@types/bcryptjs": "^2.4.6",
- "@types/express": "^5.0.0",
- "@types/jest": "^29.5.12",
- "@types/node": "^22.7.5",
- "@types/passport-jwt": "^4.0.1",
- "@types/passport-local": "^1.0.38",
- "@types/pdf-parse": "^1.1.5",
- "@types/pg": "^8.15.6",
- "@vitest/ui": "^4.0.16",
- "jest": "^29.7.0",
- "nodemon": "^3.1.7",
- "prisma": "^7.1.0",
- "supertest": "^6.3.4",
- "ts-jest": "^29.1.2",
- "ts-node": "^10.9.2",
- "tsconfig-paths": "^4.2.0",
- "tslib": "^2.8.0",
- "tsup": "^8.5.1",
- "typescript": "^5.6.3",
- "vitest": "^4.0.16"
- }
-}
diff --git a/.docker/.manifests/apps__worker__package.json b/.docker/.manifests/apps__worker__package.json
index 063c5cd..342b109 100644
--- a/.docker/.manifests/apps__worker__package.json
+++ b/.docker/.manifests/apps__worker__package.json
@@ -12,11 +12,12 @@
"lint:fix": "biome check --write ."
},
"dependencies": {
+ "@cv/core": "workspace:*",
"@cv/file-storage": "*",
- "@cv/system": "*",
- "@nestjs/common": "^10.4.7",
- "@nestjs/config": "^3.2.0",
- "@nestjs/core": "^10.4.7",
+ "@cv/handlers": "workspace:*",
+ "@nestjs/common": "^11.1.18",
+ "@nestjs/config": "^4.0.4",
+ "@nestjs/core": "^11.1.18",
"@nestjs/event-emitter": "^3.0.1",
"@riotbyte-com/nest-service-locator": "0.3.0-rc.4",
"@riotbyte-com/project-q-core": "1.0.19-rc.3",
@@ -31,7 +32,9 @@
"zod": "^4.3.6"
},
"devDependencies": {
+ "@aws-sdk/client-s3": "^3.800.0",
"@cv/tsconfig": "*",
+ "@types/pg": "^8.15.0",
"@swc/core": "^1.15.13",
"@types/node": "^22.7.5",
"nodemon": "^3.1.7",
diff --git a/.docker/.manifests/packages__ai-parser__package.json b/.docker/.manifests/packages__ai-parser__package.json
index d7ef9fe..47a5ae8 100644
--- a/.docker/.manifests/packages__ai-parser__package.json
+++ b/.docker/.manifests/packages__ai-parser__package.json
@@ -6,9 +6,9 @@
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
- "test": "jest",
- "test:watch": "jest --watch",
- "test:coverage": "jest --coverage"
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:coverage": "vitest run --coverage"
},
"dependencies": {
"@cv/ai-provider": "*",
@@ -20,10 +20,8 @@
},
"devDependencies": {
"@cv/tsconfig": "*",
- "@types/jest": "^29.5.0",
"@types/node": "^22.7.5",
- "jest": "^29.7.0",
- "ts-jest": "^29.1.1",
- "typescript": "^5.3.3"
+ "typescript": "^5.3.3",
+ "vitest": "^4.0.0"
}
}
diff --git a/.docker/.manifests/packages__ai-provider__package.json b/.docker/.manifests/packages__ai-provider__package.json
index 4854bb0..d4bb466 100644
--- a/.docker/.manifests/packages__ai-provider__package.json
+++ b/.docker/.manifests/packages__ai-provider__package.json
@@ -6,10 +6,10 @@
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
- "test": "jest",
- "test:watch": "jest --watch",
- "test:integration": "jest --testPathPattern='.integration.test.ts$' --runInBand",
- "test:all": "jest --testPathIgnorePatterns=[]"
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:integration": "vitest run --config vitest.integration.config.ts",
+ "test:all": "vitest run --config vitest.integration.config.ts"
},
"dependencies": {
"@nestjs/common": "^11.1.3",
@@ -22,10 +22,8 @@
},
"devDependencies": {
"@cv/tsconfig": "*",
- "@types/jest": "^29.5.12",
"@types/node": "^22.7.5",
- "jest": "^29.7.0",
- "ts-jest": "^29.1.2",
- "typescript": "^5.3.3"
+ "typescript": "^5.3.3",
+ "vitest": "^4.0.0"
}
}
diff --git a/.docker/.manifests/packages__auth__package.json b/.docker/.manifests/packages__auth__package.json
index ad1aaaa..61faa66 100644
--- a/.docker/.manifests/packages__auth__package.json
+++ b/.docker/.manifests/packages__auth__package.json
@@ -4,52 +4,26 @@
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
- "exports": {
- ".": {
- "require": "./src/index.ts",
- "import": "./src/index.ts",
- "types": "./src/index.ts"
- }
- },
- "files": [
- "src/"
- ],
"scripts": {
- "lint": "biome check .",
- "lint:fix": "biome check --write .",
- "typecheck": "tsc -b"
+ "typecheck": "tsc --noEmit"
},
"dependencies": {
- "@cv/system": "*",
- "@nestjs/common": "^10.4.7",
- "@nestjs/config": "^3.2.0",
- "@nestjs/core": "^10.4.7",
- "@nestjs/event-emitter": "^3.0.1",
- "@nestjs/graphql": "^12.2.2",
- "@nestjs/jwt": "^10.2.0",
- "bcryptjs": "^2.4.3",
- "graphql": "^16.12.0",
- "reflect-metadata": "^0.2.2",
- "zod": "^4.3.6"
+ "@cv/core": "workspace:*",
+ "@nestjs/common": "^11.1.18",
+ "@nestjs/config": "^4.0.4",
+ "@nestjs/core": "^11.1.18",
+ "@nestjs/graphql": "^13.2.5",
+ "@nestjs/jwt": "^11.0.2",
+ "@nestjs/passport": "^11.0.5",
+ "express": "^5.1.0",
+ "passport": "^0.7.0",
+ "passport-jwt": "^4.0.1",
+ "reflect-metadata": "^0.2.2"
},
"devDependencies": {
- "@biomejs/biome": "^2.2.6",
- "@cv/biome-config": "*",
"@cv/tsconfig": "*",
- "@types/bcryptjs": "^2.4.0",
"@types/express": "^5.0.0",
- "@types/express-serve-static-core": "^5.0.0",
- "typescript": "^5.6.3"
- },
- "peerDependencies": {
- "@cv/system": "*",
- "@nestjs/common": "^10.4.7",
- "@nestjs/config": "^3.2.0",
- "@nestjs/core": "^10.4.7",
- "@nestjs/event-emitter": "^3.0.1",
- "@nestjs/graphql": "^12.2.2",
- "@nestjs/jwt": "^10.2.0",
- "bcryptjs": "^2.4.3",
- "graphql": "^16.12.0"
+ "@types/passport-jwt": "^4.0.1",
+ "typescript": "^5.3.3"
}
}
diff --git a/.docker/.manifests/packages__file-storage__package.json b/.docker/.manifests/packages__file-storage__package.json
index e95932c..aa3786a 100644
--- a/.docker/.manifests/packages__file-storage__package.json
+++ b/.docker/.manifests/packages__file-storage__package.json
@@ -21,9 +21,9 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.800.0",
- "@nestjs/common": "^10.4.7",
- "@nestjs/config": "^3.2.0",
- "@nestjs/core": "^10.4.7"
+ "@nestjs/common": "^11.1.18",
+ "@nestjs/config": "^4.0.4",
+ "@nestjs/core": "^11.1.18"
},
"devDependencies": {
"@biomejs/biome": "^2.2.6",
@@ -32,8 +32,8 @@
"typescript": "^5.6.3"
},
"peerDependencies": {
- "@nestjs/common": "^10.4.7",
- "@nestjs/config": "^3.2.0",
- "@nestjs/core": "^10.4.7"
+ "@nestjs/common": "^11.1.18",
+ "@nestjs/config": "^4.0.4",
+ "@nestjs/core": "^11.1.18"
}
}
diff --git a/.docker/.manifests/packages__file-upload__package.json b/.docker/.manifests/packages__file-upload__package.json
index d709aaa..1e92687 100644
--- a/.docker/.manifests/packages__file-upload__package.json
+++ b/.docker/.manifests/packages__file-upload__package.json
@@ -6,7 +6,8 @@
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
- "test": "jest"
+ "test": "vitest run",
+ "test:watch": "vitest"
},
"dependencies": {
"@nestjs/common": "^11.1.3",
@@ -23,11 +24,9 @@
},
"devDependencies": {
"@cv/tsconfig": "*",
- "@types/jest": "^29.5.0",
"@types/node": "^22.7.5",
"@types/pdf-parse": "^1.1.5",
- "jest": "^29.7.0",
- "ts-jest": "^29.1.1",
- "typescript": "^5.3.3"
+ "typescript": "^5.3.3",
+ "vitest": "^4.0.0"
}
}
diff --git a/.docker/.manifests/packages__system__package.json b/.docker/.manifests/packages__system__package.json
deleted file mode 100644
index 0870ae2..0000000
--- a/.docker/.manifests/packages__system__package.json
+++ /dev/null
@@ -1,57 +0,0 @@
-{
- "name": "@cv/system",
- "version": "0.0.0",
- "private": true,
- "main": "./src/index.ts",
- "types": "./src/index.ts",
- "exports": {
- ".": {
- "require": "./src/index.ts",
- "import": "./src/index.ts",
- "types": "./src/index.ts"
- }
- },
- "files": [
- "src/"
- ],
- "scripts": {
- "lint": "biome check .",
- "lint:fix": "biome check --write .",
- "typecheck": "tsc -b"
- },
- "dependencies": {
- "@nestjs/common": "^10.4.7",
- "@nestjs/config": "^3.2.0",
- "@nestjs/core": "^10.4.7",
- "@nestjs/event-emitter": "^3.0.1",
- "@nestjs/graphql": "^12.2.2",
- "@prisma/adapter-pg": "^7.1.0",
- "@prisma/client": "^7.1.0",
- "dataloader": "^2.2.3",
- "graphql": "^16.12.0",
- "handlebars": "^4.7.8",
- "pg": "^8.16.3",
- "reflect-metadata": "^0.2.2",
- "resend": "^6.5.2"
- },
- "devDependencies": {
- "@biomejs/biome": "^2.2.6",
- "@cv/biome-config": "*",
- "@cv/tsconfig": "*",
- "@types/pg": "^8.15.0",
- "typescript": "^5.6.3"
- },
- "peerDependencies": {
- "@nestjs/common": "^10.4.7",
- "@nestjs/config": "^3.2.0",
- "@nestjs/core": "^10.4.7",
- "@nestjs/event-emitter": "^3.0.1",
- "@nestjs/graphql": "^12.2.2",
- "@prisma/adapter-pg": "^7.1.0",
- "@prisma/client": "^7.1.0",
- "graphql": "^16.12.0",
- "handlebars": "^4.7.8",
- "pg": "^8.16.3",
- "resend": "^6.5.2"
- }
-}
diff --git a/.docker/server.Dockerfile b/.docker/api.Dockerfile
similarity index 89%
rename from .docker/server.Dockerfile
rename to .docker/api.Dockerfile
index a292fbb..2d29a66 100644
--- a/.docker/server.Dockerfile
+++ b/.docker/api.Dockerfile
@@ -29,10 +29,10 @@ RUN pnpm install --frozen-lockfile
# Layer 4: source code (changes frequently)
COPY packages/ ./packages/
-COPY apps/server/ ./apps/server/
+COPY apps/api/ ./apps/api/
# Generate Prisma client
-WORKDIR /app/apps/server
+WORKDIR /app/packages/core
RUN pnpm exec prisma generate
EXPOSE 3000
@@ -63,9 +63,9 @@ RUN echo "//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}" >> .npmrc
RUN pnpm install --frozen-lockfile
COPY packages/ ./packages/
-COPY apps/server/ ./apps/server/
+COPY apps/api/ ./apps/api/
-WORKDIR /app/apps/server
+WORKDIR /app/packages/core
RUN pnpm exec prisma generate
RUN pnpm build
@@ -94,12 +94,12 @@ RUN echo "//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}" >> .npmrc
RUN pnpm install --frozen-lockfile
# Copy Prisma schema + config, generate client in prod node_modules context
-COPY apps/server/prisma/ ./apps/server/prisma/
-COPY apps/server/prisma.config.ts ./apps/server/prisma.config.ts
-RUN cd apps/server && pnpm exec prisma generate
+COPY packages/core/prisma/ ./packages/core/prisma/
+COPY packages/core/prisma.config.ts ./packages/core/prisma.config.ts
+RUN cd packages/core && pnpm exec prisma generate
# Copy bundled output (single file, @cv/* packages inlined)
-COPY --from=builder /app/apps/server/dist/ ./apps/server/dist/
+COPY --from=builder /app/apps/api/dist/ ./apps/api/dist/
# Remove auth token from final image
RUN sed -i '/_authToken/d' .npmrc
@@ -107,5 +107,5 @@ RUN sed -i '/_authToken/d' .npmrc
ENV NODE_ENV=production
EXPOSE 3000
-WORKDIR /app/apps/server
+WORKDIR /app/apps/api
CMD ["node", "dist/main.js"]
diff --git a/.docker/worker.Dockerfile b/.docker/worker.Dockerfile
index 35a6b2b..5afa9e8 100644
--- a/.docker/worker.Dockerfile
+++ b/.docker/worker.Dockerfile
@@ -23,13 +23,13 @@ RUN pnpm install --frozen-lockfile
# Layer 4: source code
COPY packages/tsconfig/ ./packages/tsconfig/
-COPY packages/system/ ./packages/system/
+COPY packages/core/ ./packages/core/
COPY packages/file-storage/ ./packages/file-storage/
-COPY apps/server/prisma/ ./apps/server/prisma/
+COPY packages/core/prisma/ ./packages/core/prisma/
COPY apps/worker/ ./apps/worker/
-# Generate Prisma client (@cv/system depends on @prisma/client)
-RUN cd apps/server && pnpm exec prisma generate
+# Generate Prisma client (@cv/core depends on @prisma/client)
+RUN cd packages/core && pnpm exec prisma generate
# Install Chromium + system deps via Playwright
RUN cd apps/worker && pnpm exec playwright install --with-deps chromium
@@ -57,12 +57,12 @@ RUN echo "//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}" >> .npmrc
RUN pnpm install --frozen-lockfile
COPY packages/tsconfig/ ./packages/tsconfig/
-COPY packages/system/ ./packages/system/
+COPY packages/core/ ./packages/core/
COPY packages/file-storage/ ./packages/file-storage/
-COPY apps/server/prisma/ ./apps/server/prisma/
+COPY packages/core/prisma/ ./packages/core/prisma/
COPY apps/worker/ ./apps/worker/
-RUN cd apps/server && pnpm exec prisma generate
+RUN cd packages/core && pnpm exec prisma generate
WORKDIR /app/apps/worker
RUN pnpm build
diff --git a/.gitignore b/.gitignore
index 4938ed3..46a6dd2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -78,6 +78,9 @@ apps/server/test/assets/*
# Coverage reports (outside coverage/)
**/coverage-unit/
+# Nx daemon cache
+**/.nx/
+
# Worktrees
.worktree/
.worktrees/
diff --git a/apps/server/eng.traineddata b/apps/api/eng.traineddata
similarity index 100%
rename from apps/server/eng.traineddata
rename to apps/api/eng.traineddata
diff --git a/apps/server/package.json b/apps/api/package.json
similarity index 76%
rename from apps/server/package.json
rename to apps/api/package.json
index 5e7f493..b98295d 100644
--- a/apps/server/package.json
+++ b/apps/api/package.json
@@ -1,5 +1,5 @@
{
- "name": "@cv/server",
+ "name": "@cv/api",
"version": "0.0.0",
"private": true,
"type": "commonjs",
@@ -11,19 +11,18 @@
"lint": "biome check .",
"lint:fix": "biome check --write .",
"typecheck": "tsc -p tsconfig.build.json --noEmit",
- "test": "jest --config ./test/jest-unit.json",
- "test:unit": "jest --config ./test/jest-unit.json",
- "test:e2e": "jest --config ./test/jest-e2e.json",
+ "test": "vitest run",
+ "test:unit": "vitest run",
"e2e:coverage:c8:start": "npm run prisma:deploy && npm run build && npx c8 -r text -r lcov node dist/main.js",
"e2e:coverage:c8:report": "npx c8 report -r text -r lcov",
"e2e:coverage:c8": "E2E_EXTERNAL_URL=http://localhost:3000 npm run test:e2e && npm run e2e:coverage:c8:report",
"e2e:coverage:c8:stop": "pkill -f 'node dist/main.js' || true",
"e2e:coverage:all": "node src/scripts/e2e-coverage-all.cjs",
- "prepare": "prisma generate || true",
- "prisma:generate": "prisma generate",
- "prisma:migrate": "prisma migrate dev",
- "prisma:deploy": "prisma migrate deploy",
- "prisma:studio": "prisma studio",
+ "prepare": "cd ../../packages/core && prisma generate || true",
+ "prisma:generate": "cd ../../packages/core && prisma generate",
+ "prisma:migrate": "cd ../../packages/core && prisma migrate dev",
+ "prisma:deploy": "cd ../../packages/core && prisma migrate deploy",
+ "prisma:studio": "cd ../../packages/core && prisma studio",
"seed": "ts-node -r tsconfig-paths/register src/scripts/seed.ts",
"seed:test": "ts-node -r tsconfig-paths/register src/scripts/seed-test.ts",
"test:integration": "vitest run --config vitest.config.ts",
@@ -31,25 +30,26 @@
"test:integration:ui": "vitest --ui --config vitest.config.ts"
},
"dependencies": {
- "@apollo/server": "^4.11.0",
+ "@apollo/server": "^5.0.0",
+ "@as-integrations/express5": "^1.1.2",
"@cv/ai-parser": "*",
"@cv/ai-provider": "*",
- "@cv/auth": "*",
+ "@cv/auth": "workspace:*",
+ "@cv/core": "workspace:*",
"@cv/cv-renderer": "*",
"@cv/file-storage": "*",
"@cv/file-upload": "*",
- "@cv/system": "*",
"@cv/utils": "*",
- "@nestjs/apollo": "^12.2.2",
- "@nestjs/common": "^10.4.7",
- "@nestjs/config": "^3.2.0",
- "@nestjs/core": "^10.4.7",
+ "@nestjs/apollo": "^13.2.5",
+ "@nestjs/common": "^11.1.18",
+ "@nestjs/config": "^4.0.4",
+ "@nestjs/core": "^11.1.18",
"@nestjs/event-emitter": "^3.0.1",
- "@nestjs/graphql": "^12.2.2",
- "@nestjs/jwt": "^10.2.0",
+ "@nestjs/graphql": "^13.2.5",
+ "@nestjs/jwt": "^11.0.2",
"@nestjs/mapped-types": "^2.1.0",
- "@nestjs/passport": "^10.0.3",
- "@nestjs/platform-express": "^10.4.7",
+ "@nestjs/passport": "^11.0.5",
+ "@nestjs/platform-express": "^11.1.18",
"@prisma/adapter-pg": "^7.1.0",
"@prisma/client": "^7.1.0",
"@riotbyte-com/nest-service-locator": "0.3.0-rc.4",
@@ -59,14 +59,13 @@
"@types/cookie-parser": "^1.4.10",
"@types/handlebars": "^4.0.40",
"@types/multer": "^2.0.0",
- "apollo-server-express": "^3.13.0",
"bcryptjs": "^2.4.3",
"canvas": "^3.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.7",
"dataloader": "^2.2.3",
- "express": "^4.21.2",
+ "express": "^5.1.0",
"graphql": "^16.12.0",
"graphql-scalars": "^1.23.0",
"graphql-type-json": "^0.3.2",
@@ -89,26 +88,25 @@
"zod": "^4.3.6"
},
"devDependencies": {
+ "@aws-sdk/client-s3": "^3.800.0",
"@biomejs/biome": "^2.2.6",
"@cv/biome-config": "*",
"@cv/tsconfig": "*",
"@faker-js/faker": "^9.9.0",
- "@nestjs/testing": "^10.4.7",
+ "@nestjs/testing": "^11.1.18",
"@swc/core": "^1.15.13",
"@types/bcryptjs": "^2.4.6",
"@types/express": "^5.0.0",
- "@types/jest": "^29.5.12",
+ "@types/express-serve-static-core": "^5.0.0",
"@types/node": "^22.7.5",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/pdf-parse": "^1.1.5",
"@types/pg": "^8.15.6",
"@vitest/ui": "^4.0.16",
- "jest": "^29.7.0",
"nodemon": "^3.1.7",
"prisma": "^7.1.0",
"supertest": "^6.3.4",
- "ts-jest": "^29.1.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tslib": "^2.8.0",
diff --git a/apps/server/scripts/post-build.sh b/apps/api/scripts/post-build.sh
similarity index 100%
rename from apps/server/scripts/post-build.sh
rename to apps/api/scripts/post-build.sh
diff --git a/apps/server/src/config/config.module.ts b/apps/api/src/config/config.module.ts
similarity index 100%
rename from apps/server/src/config/config.module.ts
rename to apps/api/src/config/config.module.ts
diff --git a/apps/server/src/config/cors.config.ts b/apps/api/src/config/cors.config.ts
similarity index 100%
rename from apps/server/src/config/cors.config.ts
rename to apps/api/src/config/cors.config.ts
diff --git a/apps/server/src/config/cors.configuration.ts b/apps/api/src/config/cors.configuration.ts
similarity index 91%
rename from apps/server/src/config/cors.configuration.ts
rename to apps/api/src/config/cors.configuration.ts
index ef4de6d..740cf89 100644
--- a/apps/server/src/config/cors.configuration.ts
+++ b/apps/api/src/config/cors.configuration.ts
@@ -7,7 +7,7 @@ export const configureCors = (app: INestApplication): void => {
const isDevelopment = corsConfig.isDevelopment();
app.enableCors({
- origin: (origin, callback) => {
+ origin: (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
if (!origin) {
return callback(null, true);
}
diff --git a/apps/server/src/config/env.validation.ts b/apps/api/src/config/env.validation.ts
similarity index 100%
rename from apps/server/src/config/env.validation.ts
rename to apps/api/src/config/env.validation.ts
diff --git a/apps/server/src/config/exception-to-http.pipe.ts b/apps/api/src/config/exception-to-http.pipe.ts
similarity index 98%
rename from apps/server/src/config/exception-to-http.pipe.ts
rename to apps/api/src/config/exception-to-http.pipe.ts
index 1cc8360..af709ef 100644
--- a/apps/server/src/config/exception-to-http.pipe.ts
+++ b/apps/api/src/config/exception-to-http.pipe.ts
@@ -2,7 +2,7 @@ import {
AuthenticationError,
AuthorizationError,
EntityNotFoundError,
-} from "@cv/auth";
+} from "@cv/core";
import {
ArgumentsHost,
Catch,
diff --git a/apps/server/src/config/file-storage.config.ts b/apps/api/src/config/file-storage.config.ts
similarity index 100%
rename from apps/server/src/config/file-storage.config.ts
rename to apps/api/src/config/file-storage.config.ts
diff --git a/apps/server/src/config/jwt.config.ts b/apps/api/src/config/jwt.config.ts
similarity index 100%
rename from apps/server/src/config/jwt.config.ts
rename to apps/api/src/config/jwt.config.ts
diff --git a/apps/server/src/domain/base.entity.ts b/apps/api/src/domain/base.entity.ts
similarity index 100%
rename from apps/server/src/domain/base.entity.ts
rename to apps/api/src/domain/base.entity.ts
diff --git a/apps/server/src/domain/errors/app-error.ts b/apps/api/src/domain/errors/app-error.ts
similarity index 100%
rename from apps/server/src/domain/errors/app-error.ts
rename to apps/api/src/domain/errors/app-error.ts
diff --git a/apps/server/src/domain/errors/authentication.error.ts b/apps/api/src/domain/errors/authentication.error.ts
similarity index 100%
rename from apps/server/src/domain/errors/authentication.error.ts
rename to apps/api/src/domain/errors/authentication.error.ts
diff --git a/apps/server/src/domain/errors/authorization.error.ts b/apps/api/src/domain/errors/authorization.error.ts
similarity index 100%
rename from apps/server/src/domain/errors/authorization.error.ts
rename to apps/api/src/domain/errors/authorization.error.ts
diff --git a/apps/server/src/domain/errors/conflict.error.ts b/apps/api/src/domain/errors/conflict.error.ts
similarity index 100%
rename from apps/server/src/domain/errors/conflict.error.ts
rename to apps/api/src/domain/errors/conflict.error.ts
diff --git a/apps/server/src/domain/errors/error-codes.ts b/apps/api/src/domain/errors/error-codes.ts
similarity index 100%
rename from apps/server/src/domain/errors/error-codes.ts
rename to apps/api/src/domain/errors/error-codes.ts
diff --git a/apps/server/src/domain/errors/not-found.error.ts b/apps/api/src/domain/errors/not-found.error.ts
similarity index 100%
rename from apps/server/src/domain/errors/not-found.error.ts
rename to apps/api/src/domain/errors/not-found.error.ts
diff --git a/apps/server/src/main.ts b/apps/api/src/main.ts
similarity index 100%
rename from apps/server/src/main.ts
rename to apps/api/src/main.ts
diff --git a/apps/server/src/modules/admin/admin.module.ts b/apps/api/src/modules/admin/admin.module.ts
similarity index 97%
rename from apps/server/src/modules/admin/admin.module.ts
rename to apps/api/src/modules/admin/admin.module.ts
index 7949696..5eef4b6 100644
--- a/apps/server/src/modules/admin/admin.module.ts
+++ b/apps/api/src/modules/admin/admin.module.ts
@@ -1,5 +1,5 @@
import { AIModule } from "@cv/ai-provider";
-import { BaseModule } from "@cv/system";
+import { BaseModule } from "@cv/core";
import { Module } from "@nestjs/common";
import { ApplicationStatusModule } from "@/modules/application/application-status/application-status.module";
import { EducationModule } from "@/modules/education/education.module";
diff --git a/apps/server/src/modules/admin/admin.resolver.ts b/apps/api/src/modules/admin/admin.resolver.ts
similarity index 99%
rename from apps/server/src/modules/admin/admin.resolver.ts
rename to apps/api/src/modules/admin/admin.resolver.ts
index 592a22e..939d70a 100644
--- a/apps/server/src/modules/admin/admin.resolver.ts
+++ b/apps/api/src/modules/admin/admin.resolver.ts
@@ -5,7 +5,7 @@ import {
} from "@cv/ai-provider";
import { AdminGuard, JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
import { Inject, Optional, UseGuards } from "@nestjs/common";
-import { PageInfo } from "@cv/system";
+import { PageInfo } from "@cv/core";
import { Args, Int, Query, Resolver } from "@nestjs/graphql";
import {
AiCallLogEntryType,
diff --git a/apps/server/src/modules/admin/admin.type.ts b/apps/api/src/modules/admin/admin.type.ts
similarity index 100%
rename from apps/server/src/modules/admin/admin.type.ts
rename to apps/api/src/modules/admin/admin.type.ts
diff --git a/apps/server/src/modules/admin/ai-call-log-persistence.service.ts b/apps/api/src/modules/admin/ai-call-log-persistence.service.ts
similarity index 100%
rename from apps/server/src/modules/admin/ai-call-log-persistence.service.ts
rename to apps/api/src/modules/admin/ai-call-log-persistence.service.ts
diff --git a/apps/server/src/modules/admin/ai-call-log.module.ts b/apps/api/src/modules/admin/ai-call-log.module.ts
similarity index 100%
rename from apps/server/src/modules/admin/ai-call-log.module.ts
rename to apps/api/src/modules/admin/ai-call-log.module.ts
diff --git a/apps/server/src/modules/admin/ai-call-log.service.ts b/apps/api/src/modules/admin/ai-call-log.service.ts
similarity index 100%
rename from apps/server/src/modules/admin/ai-call-log.service.ts
rename to apps/api/src/modules/admin/ai-call-log.service.ts
diff --git a/apps/server/src/modules/admin/graphql/admin-lookup.resolver.ts b/apps/api/src/modules/admin/graphql/admin-lookup.resolver.ts
similarity index 98%
rename from apps/server/src/modules/admin/graphql/admin-lookup.resolver.ts
rename to apps/api/src/modules/admin/graphql/admin-lookup.resolver.ts
index c1b435d..726d19b 100644
--- a/apps/server/src/modules/admin/graphql/admin-lookup.resolver.ts
+++ b/apps/api/src/modules/admin/graphql/admin-lookup.resolver.ts
@@ -1,6 +1,6 @@
import { AdminGuard, JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
-import type { NamedEntity } from "@cv/system";
-import { ClockService, UuidFactoryService } from "@cv/system";
+import type { NamedEntity } from "@cv/core";
+import { ClockService, UuidFactoryService } from "@cv/core";
import { UseGuards } from "@nestjs/common";
import { Args, Mutation, Query, Resolver } from "@nestjs/graphql";
import { ApplicationStatusService } from "@/modules/application/application-status/application-status.service";
diff --git a/apps/server/src/modules/admin/graphql/admin-lookup.type.ts b/apps/api/src/modules/admin/graphql/admin-lookup.type.ts
similarity index 100%
rename from apps/server/src/modules/admin/graphql/admin-lookup.type.ts
rename to apps/api/src/modules/admin/graphql/admin-lookup.type.ts
diff --git a/apps/server/src/modules/admin/logging-ai-provider.ts b/apps/api/src/modules/admin/logging-ai-provider.ts
similarity index 100%
rename from apps/server/src/modules/admin/logging-ai-provider.ts
rename to apps/api/src/modules/admin/logging-ai-provider.ts
diff --git a/apps/server/src/modules/admin/queue-monitor.service.ts b/apps/api/src/modules/admin/queue-monitor.service.ts
similarity index 100%
rename from apps/server/src/modules/admin/queue-monitor.service.ts
rename to apps/api/src/modules/admin/queue-monitor.service.ts
diff --git a/apps/server/src/modules/app.module.ts b/apps/api/src/modules/app.module.ts
similarity index 96%
rename from apps/server/src/modules/app.module.ts
rename to apps/api/src/modules/app.module.ts
index 8e6f1e2..3731954 100644
--- a/apps/server/src/modules/app.module.ts
+++ b/apps/api/src/modules/app.module.ts
@@ -1,11 +1,12 @@
-import { AuthModule, AuthorizationModule, UserModule } from "@cv/auth";
+import { AuthorizationModule, EventsModule, UserModule } from "@cv/core";
+import { AuthModule } from "@cv/auth";
import { FileStorageModule } from "@cv/file-storage";
import {
BaseModule,
DatabaseModule,
ResendModule,
TemplateModule,
-} from "@cv/system";
+} from "@cv/core";
import { ApolloDriver, type ApolloDriverConfig } from "@nestjs/apollo";
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
@@ -70,6 +71,7 @@ import { VacancyModule } from "./vacancies/vacancy.module";
FileStorageModule.forRootAsync({
useClass: FileStorageConfig,
}),
+ EventsModule,
AiCallLogModule,
AppConfigModule,
BaseModule,
diff --git a/apps/server/src/modules/app/app.module.ts b/apps/api/src/modules/app/app.module.ts
similarity index 100%
rename from apps/server/src/modules/app/app.module.ts
rename to apps/api/src/modules/app/app.module.ts
diff --git a/apps/server/src/modules/app/app.resolver.ts b/apps/api/src/modules/app/app.resolver.ts
similarity index 100%
rename from apps/server/src/modules/app/app.resolver.ts
rename to apps/api/src/modules/app/app.resolver.ts
diff --git a/apps/server/src/modules/app/app.service.ts b/apps/api/src/modules/app/app.service.ts
similarity index 100%
rename from apps/server/src/modules/app/app.service.ts
rename to apps/api/src/modules/app/app.service.ts
diff --git a/apps/server/src/modules/app/health.controller.ts b/apps/api/src/modules/app/health.controller.ts
similarity index 100%
rename from apps/server/src/modules/app/health.controller.ts
rename to apps/api/src/modules/app/health.controller.ts
diff --git a/apps/server/src/modules/app/health.type.ts b/apps/api/src/modules/app/health.type.ts
similarity index 100%
rename from apps/server/src/modules/app/health.type.ts
rename to apps/api/src/modules/app/health.type.ts
diff --git a/apps/server/src/modules/application/application-status.dataloader.ts b/apps/api/src/modules/application/application-status.dataloader.ts
similarity index 97%
rename from apps/server/src/modules/application/application-status.dataloader.ts
rename to apps/api/src/modules/application/application-status.dataloader.ts
index 386d5bc..02bc545 100644
--- a/apps/server/src/modules/application/application-status.dataloader.ts
+++ b/apps/api/src/modules/application/application-status.dataloader.ts
@@ -1,4 +1,4 @@
-import { PrismaService } from "@cv/system";
+import { PrismaService } from "@cv/core";
import { Injectable, Scope } from "@nestjs/common";
import DataLoader from "dataloader";
import type { ApplicationStatusRelation } from "./application.entity";
diff --git a/apps/server/src/modules/application/application-status/application-status.dto.ts b/apps/api/src/modules/application/application-status/application-status.dto.ts
similarity index 100%
rename from apps/server/src/modules/application/application-status/application-status.dto.ts
rename to apps/api/src/modules/application/application-status/application-status.dto.ts
diff --git a/apps/server/src/modules/application/application-status/application-status.entity.ts b/apps/api/src/modules/application/application-status/application-status.entity.ts
similarity index 56%
rename from apps/server/src/modules/application/application-status/application-status.entity.ts
rename to apps/api/src/modules/application/application-status/application-status.entity.ts
index 438fd13..c5d1d2f 100644
--- a/apps/server/src/modules/application/application-status/application-status.entity.ts
+++ b/apps/api/src/modules/application/application-status/application-status.entity.ts
@@ -1,3 +1,3 @@
-import { NamedEntity } from "@cv/system";
+import { NamedEntity } from "@cv/core";
export class ApplicationStatus extends NamedEntity {}
diff --git a/apps/server/src/modules/application/application-status/application-status.factory.ts b/apps/api/src/modules/application/application-status/application-status.factory.ts
similarity index 90%
rename from apps/server/src/modules/application/application-status/application-status.factory.ts
rename to apps/api/src/modules/application/application-status/application-status.factory.ts
index dd46c2f..312f7e4 100644
--- a/apps/server/src/modules/application/application-status/application-status.factory.ts
+++ b/apps/api/src/modules/application/application-status/application-status.factory.ts
@@ -1,4 +1,4 @@
-import { ClockService, Factory, UuidFactoryService } from "@cv/system";
+import { ClockService, Factory, UuidFactoryService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import type { CreateApplicationStatusDto } from "./application-status.dto";
import { ApplicationStatus } from "./application-status.entity";
diff --git a/apps/server/src/modules/application/application-status/application-status.mapper.ts b/apps/api/src/modules/application/application-status/application-status.mapper.ts
similarity index 94%
rename from apps/server/src/modules/application/application-status/application-status.mapper.ts
rename to apps/api/src/modules/application/application-status/application-status.mapper.ts
index 4f16071..5e37c01 100644
--- a/apps/server/src/modules/application/application-status/application-status.mapper.ts
+++ b/apps/api/src/modules/application/application-status/application-status.mapper.ts
@@ -1,4 +1,4 @@
-import { createNamedEntityMapper } from "@cv/system";
+import { createNamedEntityMapper } from "@cv/core";
import { Injectable } from "@nestjs/common";
import type { ApplicationStatus as PrismaApplicationStatus } from "@prisma/client";
import { ApplicationStatus } from "./application-status.entity";
diff --git a/apps/server/src/modules/application/application-status/application-status.module.ts b/apps/api/src/modules/application/application-status/application-status.module.ts
similarity index 90%
rename from apps/server/src/modules/application/application-status/application-status.module.ts
rename to apps/api/src/modules/application/application-status/application-status.module.ts
index 57c336b..c7e2f1d 100644
--- a/apps/server/src/modules/application/application-status/application-status.module.ts
+++ b/apps/api/src/modules/application/application-status/application-status.module.ts
@@ -1,4 +1,4 @@
-import { BaseModule, DatabaseModule } from "@cv/system";
+import { BaseModule, DatabaseModule } from "@cv/core";
import { Module } from "@nestjs/common";
import { ApplicationStatusFactory } from "./application-status.factory";
import { ApplicationStatusMapper } from "./application-status.mapper";
diff --git a/apps/server/src/modules/application/application-status/application-status.service.ts b/apps/api/src/modules/application/application-status/application-status.service.ts
similarity index 88%
rename from apps/server/src/modules/application/application-status/application-status.service.ts
rename to apps/api/src/modules/application/application-status/application-status.service.ts
index 23495ab..b11a5d7 100644
--- a/apps/server/src/modules/application/application-status/application-status.service.ts
+++ b/apps/api/src/modules/application/application-status/application-status.service.ts
@@ -1,4 +1,4 @@
-import { NamedEntityService, PrismaService } from "@cv/system";
+import { NamedEntityService, PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { ApplicationStatus } from "./application-status.entity";
import { ApplicationStatusMapper } from "./application-status.mapper";
diff --git a/apps/server/src/modules/application/application.entity.ts b/apps/api/src/modules/application/application.entity.ts
similarity index 96%
rename from apps/server/src/modules/application/application.entity.ts
rename to apps/api/src/modules/application/application.entity.ts
index 3cbc95e..c1614af 100644
--- a/apps/server/src/modules/application/application.entity.ts
+++ b/apps/api/src/modules/application/application.entity.ts
@@ -1,4 +1,4 @@
-import { BaseEntity } from "@cv/system";
+import { BaseEntity } from "@cv/core";
import { CV } from "@/modules/cv-template/graphql/cv.type";
import { Vacancy } from "@/modules/vacancies/vacancy.entity";
diff --git a/apps/server/src/modules/application/application.error.ts b/apps/api/src/modules/application/application.error.ts
similarity index 79%
rename from apps/server/src/modules/application/application.error.ts
rename to apps/api/src/modules/application/application.error.ts
index bef4c66..c8de26f 100644
--- a/apps/server/src/modules/application/application.error.ts
+++ b/apps/api/src/modules/application/application.error.ts
@@ -1,4 +1,4 @@
-import { DomainError } from "@cv/system";
+import { DomainError } from "@cv/core";
export class DuplicateApplicationError extends DomainError {
constructor() {
diff --git a/apps/server/src/modules/application/application.mapper.ts b/apps/api/src/modules/application/application.mapper.ts
similarity index 100%
rename from apps/server/src/modules/application/application.mapper.ts
rename to apps/api/src/modules/application/application.mapper.ts
diff --git a/apps/server/src/modules/application/application.module.ts b/apps/api/src/modules/application/application.module.ts
similarity index 89%
rename from apps/server/src/modules/application/application.module.ts
rename to apps/api/src/modules/application/application.module.ts
index d612041..581a533 100644
--- a/apps/server/src/modules/application/application.module.ts
+++ b/apps/api/src/modules/application/application.module.ts
@@ -1,5 +1,6 @@
-import { AuthModule, AuthorizationModule } from "@cv/auth";
-import { DatabaseModule } from "@cv/system";
+import { AuthorizationModule } from "@cv/core";
+import { AuthModule } from "@cv/auth";
+import { DatabaseModule } from "@cv/core";
import { Module } from "@nestjs/common";
import { AuthenticationModule } from "@/modules/authentication/authentication.module";
import { CVTemplateModule } from "@/modules/cv-template/cv-template.module";
diff --git a/apps/server/src/modules/application/application.policy.ts b/apps/api/src/modules/application/application.policy.ts
similarity index 77%
rename from apps/server/src/modules/application/application.policy.ts
rename to apps/api/src/modules/application/application.policy.ts
index 278e667..8501d92 100644
--- a/apps/server/src/modules/application/application.policy.ts
+++ b/apps/api/src/modules/application/application.policy.ts
@@ -1,4 +1,4 @@
-import { Policy, UserOwnedResourcePolicy } from "@cv/auth";
+import { Policy, UserOwnedResourcePolicy } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { Application } from "./application.entity";
diff --git a/apps/server/src/modules/application/application.service.ts b/apps/api/src/modules/application/application.service.ts
similarity index 97%
rename from apps/server/src/modules/application/application.service.ts
rename to apps/api/src/modules/application/application.service.ts
index 20f57f2..3cd4ada 100644
--- a/apps/server/src/modules/application/application.service.ts
+++ b/apps/api/src/modules/application/application.service.ts
@@ -1,5 +1,5 @@
-import { notFound } from "@cv/auth";
-import { type EntityService, PrismaService } from "@cv/system";
+import { notFound } from "@cv/core";
+import { type EntityService, PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { Application } from "./application.entity";
diff --git a/apps/server/src/modules/application/graphql/application.input.ts b/apps/api/src/modules/application/graphql/application.input.ts
similarity index 100%
rename from apps/server/src/modules/application/graphql/application.input.ts
rename to apps/api/src/modules/application/graphql/application.input.ts
diff --git a/apps/server/src/modules/application/graphql/application.resolver.ts b/apps/api/src/modules/application/graphql/application.resolver.ts
similarity index 96%
rename from apps/server/src/modules/application/graphql/application.resolver.ts
rename to apps/api/src/modules/application/graphql/application.resolver.ts
index 3f19454..e73fa9a 100644
--- a/apps/server/src/modules/application/graphql/application.resolver.ts
+++ b/apps/api/src/modules/application/graphql/application.resolver.ts
@@ -1,10 +1,7 @@
-import type { User as DomainUser } from "@cv/auth";
-import {
- AuthorizationService,
- JwtAuthGuard,
- VerifiedScopeGuard,
-} from "@cv/auth";
-import { PrismaService } from "@cv/system";
+import type { User as DomainUser } from "@cv/core";
+import { AuthorizationService } from "@cv/core";
+import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
+import { PrismaService } from "@cv/core";
import { UseGuards } from "@nestjs/common";
import {
Args,
diff --git a/apps/server/src/modules/application/graphql/application.type.ts b/apps/api/src/modules/application/graphql/application.type.ts
similarity index 100%
rename from apps/server/src/modules/application/graphql/application.type.ts
rename to apps/api/src/modules/application/graphql/application.type.ts
diff --git a/apps/server/src/modules/application/graphql/user-field.resolver.ts b/apps/api/src/modules/application/graphql/user-field.resolver.ts
similarity index 96%
rename from apps/server/src/modules/application/graphql/user-field.resolver.ts
rename to apps/api/src/modules/application/graphql/user-field.resolver.ts
index 3226726..8bb9c0c 100644
--- a/apps/server/src/modules/application/graphql/user-field.resolver.ts
+++ b/apps/api/src/modules/application/graphql/user-field.resolver.ts
@@ -1,5 +1,5 @@
import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
-import { PageInfo } from "@cv/system";
+import { PageInfo } from "@cv/core";
import { UseGuards } from "@nestjs/common";
import { Parent, ResolveField, Resolver } from "@nestjs/graphql";
import { User } from "@/modules/user/user.type";
diff --git a/apps/server/src/modules/authentication/authentication.module.ts b/apps/api/src/modules/authentication/authentication.module.ts
similarity index 87%
rename from apps/server/src/modules/authentication/authentication.module.ts
rename to apps/api/src/modules/authentication/authentication.module.ts
index a76a020..f86c4bb 100644
--- a/apps/server/src/modules/authentication/authentication.module.ts
+++ b/apps/api/src/modules/authentication/authentication.module.ts
@@ -4,10 +4,9 @@ import {
DatabaseModule,
ResendModule,
TemplateModule,
-} from "@cv/system";
+} from "@cv/core";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
-import { EventEmitterModule } from "@nestjs/event-emitter";
import { AuthenticationService } from "./authentication.service";
import { PasswordProviderModule } from "./providers/password/password-provider.module";
import { AuthenticationController } from "./rest/authentication.controller";
@@ -17,7 +16,6 @@ import { TokenResolver } from "./token/token.resolver";
imports: [
ConfigModule,
AuthModule,
- EventEmitterModule.forRoot(),
BaseModule,
DatabaseModule,
ResendModule,
diff --git a/apps/server/src/modules/authentication/authentication.service.ts b/apps/api/src/modules/authentication/authentication.service.ts
similarity index 85%
rename from apps/server/src/modules/authentication/authentication.service.ts
rename to apps/api/src/modules/authentication/authentication.service.ts
index 6fa452a..09cb270 100644
--- a/apps/server/src/modules/authentication/authentication.service.ts
+++ b/apps/api/src/modules/authentication/authentication.service.ts
@@ -1,5 +1,5 @@
-import type { User } from "@cv/auth";
-import { UserService } from "@cv/auth";
+import type { User } from "@cv/core";
+import { UserService } from "@cv/core";
import { Injectable } from "@nestjs/common";
export interface RefreshTokenDto {
refresh_token: string;
@@ -14,8 +14,8 @@ type InternalRefreshTokenResponse = RefreshTokenResponse & {
refresh_token: string;
};
-import type { RequestMetadata } from "@cv/auth";
-import { TokenService } from "@cv/auth";
+import type { RequestMetadata } from "@cv/core";
+import { TokenService } from "@cv/core";
@Injectable()
export class AuthenticationService {
diff --git a/apps/server/src/modules/authentication/graphql/authentication.type.ts b/apps/api/src/modules/authentication/graphql/authentication.type.ts
similarity index 100%
rename from apps/server/src/modules/authentication/graphql/authentication.type.ts
rename to apps/api/src/modules/authentication/graphql/authentication.type.ts
diff --git a/apps/server/src/modules/authentication/graphql/graphql-context.type.ts b/apps/api/src/modules/authentication/graphql/graphql-context.type.ts
similarity index 100%
rename from apps/server/src/modules/authentication/graphql/graphql-context.type.ts
rename to apps/api/src/modules/authentication/graphql/graphql-context.type.ts
diff --git a/apps/server/src/modules/authentication/providers/password/graphql/password-authentication.resolver.ts b/apps/api/src/modules/authentication/providers/password/graphql/password-authentication.resolver.ts
similarity index 95%
rename from apps/server/src/modules/authentication/providers/password/graphql/password-authentication.resolver.ts
rename to apps/api/src/modules/authentication/providers/password/graphql/password-authentication.resolver.ts
index e92a57e..b87889c 100644
--- a/apps/server/src/modules/authentication/providers/password/graphql/password-authentication.resolver.ts
+++ b/apps/api/src/modules/authentication/providers/password/graphql/password-authentication.resolver.ts
@@ -1,12 +1,6 @@
-import {
- AuthCookieService,
- AuthorizationService,
- type User as DomainUser,
- JwtAuthGuard,
- PasswordAuthenticationService,
- RequestMetadata,
- VerifiedScopeGuard,
-} from "@cv/auth";
+import { AuthCookieService, AuthorizationService, type User as DomainUser, PasswordAuthenticationService } from "@cv/core";
+import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
+import { RequestMetadata } from "@cv/core";
import { UseGuards } from "@nestjs/common";
import { Args, Context, Mutation, Resolver } from "@nestjs/graphql";
import { CurrentUser } from "@/modules/current-user/current-user.decorator";
diff --git a/apps/server/src/modules/authentication/providers/password/password-provider.module.ts b/apps/api/src/modules/authentication/providers/password/password-provider.module.ts
similarity index 88%
rename from apps/server/src/modules/authentication/providers/password/password-provider.module.ts
rename to apps/api/src/modules/authentication/providers/password/password-provider.module.ts
index 26d295b..95462e3 100644
--- a/apps/server/src/modules/authentication/providers/password/password-provider.module.ts
+++ b/apps/api/src/modules/authentication/providers/password/password-provider.module.ts
@@ -6,17 +6,15 @@ import {
RegistrationAttemptEmailListener,
TemplatedEmailService,
VerificationEmailListener,
-} from "@cv/auth";
-import { ResendModule, TemplateModule } from "@cv/system";
+} from "@cv/core";
+import { ResendModule, TemplateModule } from "@cv/core";
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
-import { EventEmitterModule } from "@nestjs/event-emitter";
import { PasswordAuthenticationResolver } from "./graphql/password-authentication.resolver";
@Module({
imports: [
ConfigModule,
- EventEmitterModule.forRoot(),
AuthPasswordProviderModule,
ResendModule,
TemplateModule,
diff --git a/apps/server/src/modules/authentication/request/request-with-cookies.type.ts b/apps/api/src/modules/authentication/request/request-with-cookies.type.ts
similarity index 100%
rename from apps/server/src/modules/authentication/request/request-with-cookies.type.ts
rename to apps/api/src/modules/authentication/request/request-with-cookies.type.ts
diff --git a/apps/server/src/modules/authentication/rest/authentication.controller.ts b/apps/api/src/modules/authentication/rest/authentication.controller.ts
similarity index 93%
rename from apps/server/src/modules/authentication/rest/authentication.controller.ts
rename to apps/api/src/modules/authentication/rest/authentication.controller.ts
index 7cfb483..e5dd6b1 100644
--- a/apps/server/src/modules/authentication/rest/authentication.controller.ts
+++ b/apps/api/src/modules/authentication/rest/authentication.controller.ts
@@ -1,4 +1,5 @@
-import { AuthCookieService, RequestMetadata } from "@cv/auth";
+import { AuthCookieService } from "@cv/core";
+import { RequestMetadata } from "@cv/core";
import { Controller, Post, Res } from "@nestjs/common";
import type { Response } from "express";
import { AuthenticationService } from "../authentication.service";
diff --git a/apps/server/src/modules/authentication/token/active-session.type.ts b/apps/api/src/modules/authentication/token/active-session.type.ts
similarity index 100%
rename from apps/server/src/modules/authentication/token/active-session.type.ts
rename to apps/api/src/modules/authentication/token/active-session.type.ts
diff --git a/apps/server/src/modules/authentication/token/refresh-token-cookie.decorator.ts b/apps/api/src/modules/authentication/token/refresh-token-cookie.decorator.ts
similarity index 94%
rename from apps/server/src/modules/authentication/token/refresh-token-cookie.decorator.ts
rename to apps/api/src/modules/authentication/token/refresh-token-cookie.decorator.ts
index 0b5a126..09876bc 100644
--- a/apps/server/src/modules/authentication/token/refresh-token-cookie.decorator.ts
+++ b/apps/api/src/modules/authentication/token/refresh-token-cookie.decorator.ts
@@ -1,4 +1,4 @@
-import { createGqlParamDecorator, unauthorized } from "@cv/system";
+import { createGqlParamDecorator, unauthorized } from "@cv/core";
import type { ExecutionContext } from "@nestjs/common";
import { GqlExecutionContext } from "@nestjs/graphql";
import { z } from "zod/v4";
diff --git a/apps/server/src/modules/authentication/token/refresh-token-response.dto.ts b/apps/api/src/modules/authentication/token/refresh-token-response.dto.ts
similarity index 100%
rename from apps/server/src/modules/authentication/token/refresh-token-response.dto.ts
rename to apps/api/src/modules/authentication/token/refresh-token-response.dto.ts
diff --git a/apps/server/src/modules/authentication/token/token-expiration.type.ts b/apps/api/src/modules/authentication/token/token-expiration.type.ts
similarity index 100%
rename from apps/server/src/modules/authentication/token/token-expiration.type.ts
rename to apps/api/src/modules/authentication/token/token-expiration.type.ts
diff --git a/apps/server/src/modules/authentication/token/token.module.ts b/apps/api/src/modules/authentication/token/token.module.ts
similarity index 70%
rename from apps/server/src/modules/authentication/token/token.module.ts
rename to apps/api/src/modules/authentication/token/token.module.ts
index 51dbd2f..ebe8d6d 100644
--- a/apps/server/src/modules/authentication/token/token.module.ts
+++ b/apps/api/src/modules/authentication/token/token.module.ts
@@ -1,4 +1,4 @@
-import { TokenModule as AuthTokenModule } from "@cv/auth";
+import { TokenModule as AuthTokenModule } from "@cv/core";
import { Module } from "@nestjs/common";
@Module({
diff --git a/apps/server/src/modules/authentication/token/token.resolver.ts b/apps/api/src/modules/authentication/token/token.resolver.ts
similarity index 93%
rename from apps/server/src/modules/authentication/token/token.resolver.ts
rename to apps/api/src/modules/authentication/token/token.resolver.ts
index c4f4fa3..d560436 100644
--- a/apps/server/src/modules/authentication/token/token.resolver.ts
+++ b/apps/api/src/modules/authentication/token/token.resolver.ts
@@ -1,12 +1,5 @@
-import {
- AuthCookieService,
- AuthorizationService,
- type User as DomainUser,
- JwtAuthGuard,
- RefreshToken,
- RefreshTokenService,
- VerifiedScopeGuard,
-} from "@cv/auth";
+import { AuthCookieService, AuthorizationService, type User as DomainUser, RefreshToken, RefreshTokenService } from "@cv/core";
+import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
import { UseGuards } from "@nestjs/common";
import { Args, Context, Mutation, Query, Resolver } from "@nestjs/graphql";
import type { Response } from "express";
diff --git a/apps/api/src/modules/base/base-graphql-type.ts b/apps/api/src/modules/base/base-graphql-type.ts
new file mode 100644
index 0000000..6a39e2a
--- /dev/null
+++ b/apps/api/src/modules/base/base-graphql-type.ts
@@ -0,0 +1,19 @@
+import { Field, ID, ObjectType } from "@nestjs/graphql";
+
+@ObjectType({ isAbstract: true })
+export abstract class BaseGraphQLType {
+ @Field(() => ID)
+ id: string;
+
+ @Field()
+ createdAt: Date;
+
+ @Field()
+ updatedAt: Date;
+
+ constructor(id: string, createdAt: Date, updatedAt: Date) {
+ this.id = id;
+ this.createdAt = createdAt;
+ this.updatedAt = updatedAt;
+ }
+}
diff --git a/apps/server/src/modules/base/base.entity.ts b/apps/api/src/modules/base/base.entity.ts
similarity index 100%
rename from apps/server/src/modules/base/base.entity.ts
rename to apps/api/src/modules/base/base.entity.ts
diff --git a/apps/server/src/modules/base/base.module.ts b/apps/api/src/modules/base/base.module.ts
similarity index 88%
rename from apps/server/src/modules/base/base.module.ts
rename to apps/api/src/modules/base/base.module.ts
index f4faf8b..834a391 100644
--- a/apps/server/src/modules/base/base.module.ts
+++ b/apps/api/src/modules/base/base.module.ts
@@ -1,4 +1,4 @@
-import { ClockService, UuidFactoryService } from "@cv/system";
+import { ClockService, UuidFactoryService } from "@cv/core";
import { Module } from "@nestjs/common";
import { CursorService } from "./cursor.service";
import { ErrorCode } from "./error-code.enum";
diff --git a/apps/server/src/modules/base/connection.factory.ts b/apps/api/src/modules/base/connection.factory.ts
similarity index 95%
rename from apps/server/src/modules/base/connection.factory.ts
rename to apps/api/src/modules/base/connection.factory.ts
index 6ff638f..79a101c 100644
--- a/apps/server/src/modules/base/connection.factory.ts
+++ b/apps/api/src/modules/base/connection.factory.ts
@@ -1,4 +1,4 @@
-import { PageInfo, type PaginationResult } from "@cv/system";
+import { PageInfo, type PaginationResult } from "@cv/core";
import { raise } from "@cv/utils";
import type { Type } from "@nestjs/common";
import { Field, Int, ObjectType } from "@nestjs/graphql";
diff --git a/apps/server/src/modules/base/connection.types.ts b/apps/api/src/modules/base/connection.types.ts
similarity index 100%
rename from apps/server/src/modules/base/connection.types.ts
rename to apps/api/src/modules/base/connection.types.ts
diff --git a/apps/server/src/modules/base/cursor.service.ts b/apps/api/src/modules/base/cursor.service.ts
similarity index 100%
rename from apps/server/src/modules/base/cursor.service.ts
rename to apps/api/src/modules/base/cursor.service.ts
diff --git a/apps/server/src/modules/base/error-code.enum.ts b/apps/api/src/modules/base/error-code.enum.ts
similarity index 100%
rename from apps/server/src/modules/base/error-code.enum.ts
rename to apps/api/src/modules/base/error-code.enum.ts
diff --git a/apps/server/src/modules/base/event.interface.ts b/apps/api/src/modules/base/event.interface.ts
similarity index 100%
rename from apps/server/src/modules/base/event.interface.ts
rename to apps/api/src/modules/base/event.interface.ts
diff --git a/apps/server/src/modules/base/graphql-exception.filter.ts b/apps/api/src/modules/base/graphql-exception.filter.ts
similarity index 98%
rename from apps/server/src/modules/base/graphql-exception.filter.ts
rename to apps/api/src/modules/base/graphql-exception.filter.ts
index eb2d1aa..89c8146 100644
--- a/apps/server/src/modules/base/graphql-exception.filter.ts
+++ b/apps/api/src/modules/base/graphql-exception.filter.ts
@@ -2,8 +2,8 @@ import {
AuthenticationError,
AuthorizationError,
EntityNotFoundError,
-} from "@cv/auth";
-import { DomainError } from "@cv/system";
+} from "@cv/core";
+import { DomainError } from "@cv/core";
import {
ArgumentsHost,
Catch,
diff --git a/apps/server/src/modules/base/mapper.interface.ts b/apps/api/src/modules/base/mapper.interface.ts
similarity index 100%
rename from apps/server/src/modules/base/mapper.interface.ts
rename to apps/api/src/modules/base/mapper.interface.ts
diff --git a/apps/server/src/modules/base/named-graphql-type.factory.ts b/apps/api/src/modules/base/named-graphql-type.factory.ts
similarity index 82%
rename from apps/server/src/modules/base/named-graphql-type.factory.ts
rename to apps/api/src/modules/base/named-graphql-type.factory.ts
index 767b27c..782fe5d 100644
--- a/apps/server/src/modules/base/named-graphql-type.factory.ts
+++ b/apps/api/src/modules/base/named-graphql-type.factory.ts
@@ -1,6 +1,7 @@
-import type { NamedEntity } from "@cv/system";
+import type { NamedEntity } from "@cv/core";
import type { Type } from "@nestjs/common";
-import { Field, ID, ObjectType } from "@nestjs/graphql";
+import { Field, ObjectType } from "@nestjs/graphql";
+import { BaseGraphQLType } from "./base-graphql-type";
import { createConnection } from "./connection.factory";
/**
@@ -17,22 +18,13 @@ export function createNamedGraphQLType(
_DomainClass: abstract new (...args: any[]) => TDomain,
) {
@ObjectType(name)
- class NamedGraphQLType {
- @Field(() => ID)
- id: string;
-
+ class NamedGraphQLType extends BaseGraphQLType {
@Field()
name: string;
@Field({ nullable: true })
description?: string;
- @Field()
- createdAt: Date;
-
- @Field()
- updatedAt: Date;
-
constructor(
id: string,
name: string,
@@ -40,10 +32,8 @@ export function createNamedGraphQLType(
updatedAt: Date,
description?: string,
) {
- this.id = id;
+ super(id, createdAt, updatedAt);
this.name = name;
- this.createdAt = createdAt;
- this.updatedAt = updatedAt;
if (description !== undefined) {
this.description = description;
}
diff --git a/apps/server/src/modules/base/not-found.util.ts b/apps/api/src/modules/base/not-found.util.ts
similarity index 90%
rename from apps/server/src/modules/base/not-found.util.ts
rename to apps/api/src/modules/base/not-found.util.ts
index 2a56223..342a735 100644
--- a/apps/server/src/modules/base/not-found.util.ts
+++ b/apps/api/src/modules/base/not-found.util.ts
@@ -1,4 +1,4 @@
-import { EntityNotFoundError } from "@cv/auth";
+import { EntityNotFoundError } from "@cv/core";
/**
* Utility function to throw an EntityNotFoundError with a consistent message
diff --git a/apps/server/src/modules/base/pagination.service.ts b/apps/api/src/modules/base/pagination.service.ts
similarity index 100%
rename from apps/server/src/modules/base/pagination.service.ts
rename to apps/api/src/modules/base/pagination.service.ts
diff --git a/apps/server/src/modules/base/pagination.types.ts b/apps/api/src/modules/base/pagination.types.ts
similarity index 100%
rename from apps/server/src/modules/base/pagination.types.ts
rename to apps/api/src/modules/base/pagination.types.ts
diff --git a/apps/server/src/modules/base/raise.util.ts b/apps/api/src/modules/base/raise.util.ts
similarity index 100%
rename from apps/server/src/modules/base/raise.util.ts
rename to apps/api/src/modules/base/raise.util.ts
diff --git a/apps/server/src/modules/base/unauthorized.util.ts b/apps/api/src/modules/base/unauthorized.util.ts
similarity index 100%
rename from apps/server/src/modules/base/unauthorized.util.ts
rename to apps/api/src/modules/base/unauthorized.util.ts
diff --git a/apps/server/src/modules/current-user/current-refresh-token-id.decorator.ts b/apps/api/src/modules/current-user/current-refresh-token-id.decorator.ts
similarity index 85%
rename from apps/server/src/modules/current-user/current-refresh-token-id.decorator.ts
rename to apps/api/src/modules/current-user/current-refresh-token-id.decorator.ts
index e0aa65e..d30ae29 100644
--- a/apps/server/src/modules/current-user/current-refresh-token-id.decorator.ts
+++ b/apps/api/src/modules/current-user/current-refresh-token-id.decorator.ts
@@ -1,4 +1,4 @@
-import { createGqlParamDecorator } from "@cv/system";
+import { createGqlParamDecorator } from "@cv/core";
import { z } from "zod/v4";
const jwtRequestSchema = z.looseObject({
diff --git a/apps/server/src/modules/current-user/current-user.decorator.ts b/apps/api/src/modules/current-user/current-user.decorator.ts
similarity index 84%
rename from apps/server/src/modules/current-user/current-user.decorator.ts
rename to apps/api/src/modules/current-user/current-user.decorator.ts
index 743af1b..3584612 100644
--- a/apps/server/src/modules/current-user/current-user.decorator.ts
+++ b/apps/api/src/modules/current-user/current-user.decorator.ts
@@ -1,4 +1,4 @@
-import { createGqlParamDecorator, raise } from "@cv/system";
+import { createGqlParamDecorator, raise } from "@cv/core";
import { z } from "zod/v4";
const requestSchema = z.looseObject({
diff --git a/apps/server/src/modules/current-user/current-user.module.ts b/apps/api/src/modules/current-user/current-user.module.ts
similarity index 89%
rename from apps/server/src/modules/current-user/current-user.module.ts
rename to apps/api/src/modules/current-user/current-user.module.ts
index 06f0fb2..a83586b 100644
--- a/apps/server/src/modules/current-user/current-user.module.ts
+++ b/apps/api/src/modules/current-user/current-user.module.ts
@@ -1,4 +1,4 @@
-import { UserModule } from "@cv/auth";
+import { UserModule } from "@cv/core";
import { forwardRef, Module } from "@nestjs/common";
import { AuthenticationModule } from "@/modules/authentication/authentication.module";
import { MeResolver } from "./me.resolver";
diff --git a/apps/server/src/modules/current-user/index.ts b/apps/api/src/modules/current-user/index.ts
similarity index 100%
rename from apps/server/src/modules/current-user/index.ts
rename to apps/api/src/modules/current-user/index.ts
diff --git a/apps/server/src/modules/current-user/me.resolver.ts b/apps/api/src/modules/current-user/me.resolver.ts
similarity index 78%
rename from apps/server/src/modules/current-user/me.resolver.ts
rename to apps/api/src/modules/current-user/me.resolver.ts
index 750112b..bbc4c8e 100644
--- a/apps/server/src/modules/current-user/me.resolver.ts
+++ b/apps/api/src/modules/current-user/me.resolver.ts
@@ -1,5 +1,6 @@
-import type { User as DomainUser } from "@cv/auth";
-import { JwtAuthGuard, UserService, VerifiedScopeGuard } from "@cv/auth";
+import type { User as DomainUser } from "@cv/core";
+import { UserService } from "@cv/core";
+import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
import { UseGuards } from "@nestjs/common";
import { Query, Resolver } from "@nestjs/graphql";
import { User } from "@/modules/user/user.type";
diff --git a/apps/api/src/modules/cv-parser/__tests__/cv-parser.service.spec.ts b/apps/api/src/modules/cv-parser/__tests__/cv-parser.service.spec.ts
new file mode 100644
index 0000000..0779251
--- /dev/null
+++ b/apps/api/src/modules/cv-parser/__tests__/cv-parser.service.spec.ts
@@ -0,0 +1,348 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { User } from "@cv/core";
+import type { Mocked } from "vitest";
+import type { AIProviderResolverService } from "../ai-provider-resolver.service";
+import { CVParserService } from "../cv-parser.service";
+import type { EntityResolverService } from "../entity-resolver.service";
+import {
+ mockJaneDoeParsedCV,
+ mockJohnSmithParsedCV,
+} from "./fixtures/mock-ai-responses";
+
+const mockParseCVText = vi.fn();
+
+vi.mock("@cv/ai-parser", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ CVParserService: class MockCVParser {
+ parseCVText = mockParseCVText;
+ },
+ };
+});
+
+describe("CVParserService", () => {
+ let service: CVParserService;
+ let mockTextExtractorRegistry: {
+ extract: Mock;
+ };
+ let mockEntityResolver: Mocked;
+
+ const mockUser = new User(
+ "user-1",
+ "Test User",
+ new Date(),
+ new Date(),
+ null,
+ );
+
+ const fixturesPath = join(__dirname, "fixtures");
+ const sampleTxtCV = readFileSync(
+ join(fixturesPath, "sample-cv.txt"),
+ "utf-8",
+ );
+ const sampleMdCV = readFileSync(join(fixturesPath, "sample-cv.md"), "utf-8");
+
+ beforeEach(() => {
+ mockParseCVText.mockReset();
+
+ mockTextExtractorRegistry = {
+ extract: vi.fn(),
+ };
+
+ mockEntityResolver = {
+ resolveCompany: vi.fn(),
+ resolveRole: vi.fn(),
+ resolveLevel: vi.fn(),
+ resolveSkill: vi.fn(),
+ resolveSkills: vi.fn(),
+ resolveInstitution: vi.fn(),
+ } as unknown as Mocked;
+
+ const mockProviderResolver = {
+ resolveForUser: vi.fn().mockResolvedValue({
+ generateText: vi.fn(),
+ }),
+ } as unknown as AIProviderResolverService;
+
+ service = new CVParserService(
+ mockTextExtractorRegistry as any,
+ mockEntityResolver,
+ mockProviderResolver,
+ );
+ });
+
+ describe("parseStory", () => {
+ it("parses a career narrative from plain text", async () => {
+ mockParseCVText.mockResolvedValue(mockJohnSmithParsedCV);
+
+ const result = await service.parseStory(mockUser, sampleTxtCV);
+
+ expect(mockParseCVText).toHaveBeenCalledWith(sampleTxtCV);
+ expect(result).toEqual(mockJohnSmithParsedCV);
+ expect(result.personalInfo?.name).toBe("John Smith");
+ expect(result.jobExperiences).toHaveLength(3);
+ expect(result.education).toHaveLength(1);
+ });
+
+ it("parses a career narrative from markdown", async () => {
+ mockParseCVText.mockResolvedValue(mockJaneDoeParsedCV);
+
+ const result = await service.parseStory(mockUser, sampleMdCV);
+
+ expect(mockParseCVText).toHaveBeenCalledWith(sampleMdCV);
+ expect(result).toEqual(mockJaneDoeParsedCV);
+ expect(result.personalInfo?.name).toBe("Jane Doe");
+ expect(result.jobExperiences).toHaveLength(3);
+ expect(result.education).toHaveLength(2);
+ });
+
+ it("throws error when story text is empty", async () => {
+ await expect(service.parseStory(mockUser, "")).rejects.toThrow(
+ "Story text cannot be empty",
+ );
+
+ expect(mockParseCVText).not.toHaveBeenCalled();
+ });
+
+ it("throws error when story text is only whitespace", async () => {
+ await expect(service.parseStory(mockUser, " \n\t ")).rejects.toThrow(
+ "Story text cannot be empty",
+ );
+
+ expect(mockParseCVText).not.toHaveBeenCalled();
+ });
+
+ it("throws error when story text exceeds maximum length", async () => {
+ const longText = "a".repeat(50001);
+
+ await expect(service.parseStory(mockUser, longText)).rejects.toThrow(
+ "Story text is too long (max 50,000 characters)",
+ );
+
+ expect(mockParseCVText).not.toHaveBeenCalled();
+ });
+
+ it("accepts story text at maximum length boundary", async () => {
+ const maxLengthText = "a".repeat(50000);
+ mockParseCVText.mockResolvedValue({
+ jobExperiences: [],
+ education: [],
+ skills: [],
+ });
+
+ await service.parseStory(mockUser, maxLengthText);
+
+ expect(mockParseCVText).toHaveBeenCalledWith(maxLengthText);
+ });
+
+ it("extracts job experiences with correct structure", async () => {
+ mockParseCVText.mockResolvedValue(mockJohnSmithParsedCV);
+
+ const result = await service.parseStory(mockUser, sampleTxtCV);
+
+ const firstJob = result.jobExperiences[0];
+ expect(firstJob.companyName).toBe("Acme Corporation");
+ expect(firstJob.roleName).toBe("Senior Software Engineer");
+ expect(firstJob.levelName).toBe("Senior");
+ expect(firstJob.startDate).toBe("2020-01-01");
+ expect(firstJob.endDate).toBeNull();
+ expect(firstJob.skills).toContain("TypeScript");
+ expect(firstJob.skills).toContain("Kubernetes");
+ });
+
+ it("extracts education with correct structure", async () => {
+ mockParseCVText.mockResolvedValue(mockJohnSmithParsedCV);
+
+ const result = await service.parseStory(mockUser, sampleTxtCV);
+
+ const education = result.education[0];
+ expect(education.institutionName).toBe("Stanford University");
+ expect(education.degree).toBe("Bachelor of Science");
+ expect(education.fieldOfStudy).toBe("Computer Science");
+ expect(education.startDate).toBe("2010-09-01");
+ expect(education.endDate).toBe("2014-06-30");
+ });
+
+ it("extracts overall skills list", async () => {
+ mockParseCVText.mockResolvedValue(mockJohnSmithParsedCV);
+
+ const result = await service.parseStory(mockUser, sampleTxtCV);
+
+ expect(result.skills).toContain("TypeScript");
+ expect(result.skills).toContain("Docker");
+ expect(result.skills).toContain("AWS");
+ expect(result.skills.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe("parseFile", () => {
+ const mockBuffer = Buffer.from("mock file content");
+ const mockMimeType = "text/plain";
+ const mockFileName = "resume.txt";
+
+ it("parses a valid text file", async () => {
+ mockTextExtractorRegistry.extract.mockResolvedValue({
+ success: true,
+ text: sampleTxtCV,
+ });
+ mockParseCVText.mockResolvedValue(mockJohnSmithParsedCV);
+
+ const result = await service.parseFile(
+ mockUser,
+ mockBuffer,
+ mockMimeType,
+ mockFileName,
+ );
+
+ expect(mockTextExtractorRegistry.extract).toHaveBeenCalledWith(
+ mockBuffer,
+ mockMimeType,
+ );
+ expect(mockParseCVText).toHaveBeenCalledWith(sampleTxtCV);
+ expect(result).toEqual(mockJohnSmithParsedCV);
+ });
+
+ it("throws error when text extraction fails", async () => {
+ mockTextExtractorRegistry.extract.mockResolvedValue({
+ success: false,
+ error: "Unsupported file format",
+ });
+
+ await expect(
+ service.parseFile(mockUser, mockBuffer, mockMimeType, mockFileName),
+ ).rejects.toThrow("Text extraction failed: Unsupported file format");
+ });
+
+ it("throws error when extracted text is empty", async () => {
+ mockTextExtractorRegistry.extract.mockResolvedValue({
+ success: true,
+ text: "",
+ });
+
+ await expect(
+ service.parseFile(mockUser, mockBuffer, mockMimeType, mockFileName),
+ ).rejects.toThrow("Could not extract any text from the file");
+ });
+
+ it("throws error when extracted text is only whitespace", async () => {
+ mockTextExtractorRegistry.extract.mockResolvedValue({
+ success: true,
+ text: " \n\t ",
+ });
+
+ await expect(
+ service.parseFile(mockUser, mockBuffer, mockMimeType, mockFileName),
+ ).rejects.toThrow("Could not extract any text from the file");
+ });
+ });
+
+ describe("parseStoryWithResolution", () => {
+ beforeEach(() => {
+ mockParseCVText.mockResolvedValue(mockJohnSmithParsedCV);
+
+ mockEntityResolver.resolveCompany.mockImplementation(async (name) => ({
+ id: name === "Acme Corporation" ? "company-1" : null,
+ name,
+ }));
+
+ mockEntityResolver.resolveRole.mockImplementation(async (name) => ({
+ id: name === "Senior Software Engineer" ? "role-1" : null,
+ name,
+ }));
+
+ mockEntityResolver.resolveLevel.mockImplementation(async (name) => ({
+ id: name === "Senior" ? "level-1" : null,
+ name: name ?? "Mid-level",
+ }));
+
+ mockEntityResolver.resolveInstitution.mockImplementation(
+ async (name) => ({
+ id: name === "Stanford University" ? "institution-1" : null,
+ name,
+ }),
+ );
+
+ mockEntityResolver.resolveSkills.mockImplementation(async (names) =>
+ names.map((name) => ({
+ id: ["TypeScript", "JavaScript"].includes(name)
+ ? `skill-${name}`
+ : null,
+ name,
+ })),
+ );
+ });
+
+ it("resolves entities for parsed CV data", async () => {
+ const result = await service.parseStoryWithResolution(
+ mockUser,
+ sampleTxtCV,
+ );
+
+ expect(result.jobExperiences).toHaveLength(3);
+ expect(result.education).toHaveLength(1);
+
+ const firstJob = result.jobExperiences[0];
+ expect(firstJob.company.id).toBe("company-1");
+ expect(firstJob.company.name).toBe("Acme Corporation");
+ expect(firstJob.role.id).toBe("role-1");
+ expect(firstJob.level.id).toBe("level-1");
+ });
+
+ it("returns null IDs for unmatched entities", async () => {
+ const result = await service.parseStoryWithResolution(
+ mockUser,
+ sampleTxtCV,
+ );
+
+ const secondJob = result.jobExperiences[1];
+ expect(secondJob.company.id).toBeNull();
+ expect(secondJob.company.name).toBe("TechStart Inc");
+ });
+
+ it("converts date strings to Date objects", async () => {
+ const result = await service.parseStoryWithResolution(
+ mockUser,
+ sampleTxtCV,
+ );
+
+ const firstJob = result.jobExperiences[0];
+ expect(firstJob.startDate).toBeInstanceOf(Date);
+ expect(firstJob.endDate).toBeNull();
+
+ const lastJob = result.jobExperiences[2];
+ expect(lastJob.endDate).toBeInstanceOf(Date);
+ });
+
+ it("resolves education institutions", async () => {
+ const result = await service.parseStoryWithResolution(
+ mockUser,
+ sampleTxtCV,
+ );
+
+ const education = result.education[0];
+ expect(education.institution.id).toBe("institution-1");
+ expect(education.institution.name).toBe("Stanford University");
+ expect(education.degree).toBe("Bachelor of Science");
+ });
+
+ it("resolves skills for both jobs and education", async () => {
+ const result = await service.parseStoryWithResolution(
+ mockUser,
+ sampleTxtCV,
+ );
+
+ const firstJob = result.jobExperiences[0];
+ const typescriptSkill = firstJob.skills.find(
+ (s) => s.name === "TypeScript",
+ );
+ expect(typescriptSkill?.id).toBe("skill-TypeScript");
+
+ const kubernetesSkill = firstJob.skills.find(
+ (s) => s.name === "Kubernetes",
+ );
+ expect(kubernetesSkill?.id).toBeNull();
+ });
+ });
+});
diff --git a/apps/api/src/modules/cv-parser/__tests__/entity-resolver.service.spec.ts b/apps/api/src/modules/cv-parser/__tests__/entity-resolver.service.spec.ts
new file mode 100644
index 0000000..19aa6ce
--- /dev/null
+++ b/apps/api/src/modules/cv-parser/__tests__/entity-resolver.service.spec.ts
@@ -0,0 +1,324 @@
+import type { Mock, Mocked } from "vitest";
+import { EntityResolverService } from "../entity-resolver.service";
+
+type MockPrisma = {
+ company: { findFirst: Mock };
+ role: { findFirst: Mock };
+ level: { findFirst: Mock };
+ skill: { findFirst: Mock };
+ institution: { findFirst: Mock };
+};
+
+describe("EntityResolverService", () => {
+ let service: EntityResolverService;
+ let mockPrisma: MockPrisma;
+
+ beforeEach(() => {
+ mockPrisma = {
+ company: { findFirst: vi.fn() },
+ role: { findFirst: vi.fn() },
+ level: { findFirst: vi.fn() },
+ skill: { findFirst: vi.fn() },
+ institution: { findFirst: vi.fn() },
+ };
+
+ service = new EntityResolverService(mockPrisma as any);
+ });
+
+ describe("resolveCompany", () => {
+ it("returns matched company with ID when found", async () => {
+ const mockCompany = { id: "company-123", name: "Acme Corporation" };
+ mockPrisma.company.findFirst.mockResolvedValue(mockCompany);
+
+ const result = await service.resolveCompany("Acme Corporation");
+
+ expect(mockPrisma.company.findFirst).toHaveBeenCalledWith({
+ where: { name: { equals: "Acme Corporation", mode: "insensitive" } },
+ });
+ expect(result).toEqual({ id: "company-123", name: "Acme Corporation" });
+ });
+
+ it("returns draft entity with null ID when not found", async () => {
+ mockPrisma.company.findFirst.mockResolvedValue(null);
+
+ const result = await service.resolveCompany("Unknown Company");
+
+ expect(result).toEqual({ id: null, name: "Unknown Company" });
+ });
+
+ it("performs case-insensitive matching", async () => {
+ const mockCompany = { id: "company-123", name: "ACME Corporation" };
+ mockPrisma.company.findFirst.mockResolvedValue(mockCompany);
+
+ const result = await service.resolveCompany("acme corporation");
+
+ expect(mockPrisma.company.findFirst).toHaveBeenCalledWith({
+ where: { name: { equals: "acme corporation", mode: "insensitive" } },
+ });
+ expect(result.id).toBe("company-123");
+ expect(result.name).toBe("ACME Corporation");
+ });
+ });
+
+ describe("resolveRole", () => {
+ it("returns matched role with ID when found", async () => {
+ const mockRole = { id: "role-456", name: "Senior Software Engineer" };
+ mockPrisma.role.findFirst.mockResolvedValue(mockRole);
+
+ const result = await service.resolveRole("Senior Software Engineer");
+
+ expect(mockPrisma.role.findFirst).toHaveBeenCalledWith({
+ where: {
+ name: { equals: "Senior Software Engineer", mode: "insensitive" },
+ },
+ });
+ expect(result).toEqual({
+ id: "role-456",
+ name: "Senior Software Engineer",
+ });
+ });
+
+ it("returns draft entity with null ID when not found", async () => {
+ mockPrisma.role.findFirst.mockResolvedValue(null);
+
+ const result = await service.resolveRole("Chief Happiness Officer");
+
+ expect(result).toEqual({ id: null, name: "Chief Happiness Officer" });
+ });
+ });
+
+ describe("resolveLevel", () => {
+ it("returns matched level with ID when found", async () => {
+ const mockLevel = { id: "level-789", name: "Senior" };
+ mockPrisma.level.findFirst.mockResolvedValue(mockLevel);
+
+ const result = await service.resolveLevel("Senior");
+
+ expect(mockPrisma.level.findFirst).toHaveBeenCalledWith({
+ where: { name: { equals: "Senior", mode: "insensitive" } },
+ });
+ expect(result).toEqual({ id: "level-789", name: "Senior" });
+ });
+
+ it("defaults to Mid-level when no name provided", async () => {
+ const mockLevel = { id: "level-mid", name: "Mid-level" };
+ mockPrisma.level.findFirst.mockResolvedValue(mockLevel);
+
+ const result = await service.resolveLevel(undefined);
+
+ expect(mockPrisma.level.findFirst).toHaveBeenCalledWith({
+ where: { name: { equals: "Mid-level", mode: "insensitive" } },
+ });
+ expect(result).toEqual({ id: "level-mid", name: "Mid-level" });
+ });
+
+ it("defaults to Mid-level when empty string provided", async () => {
+ mockPrisma.level.findFirst.mockResolvedValue(null);
+
+ const result = await service.resolveLevel("");
+
+ expect(mockPrisma.level.findFirst).toHaveBeenCalledWith({
+ where: { name: { equals: "Mid-level", mode: "insensitive" } },
+ });
+ expect(result).toEqual({ id: null, name: "Mid-level" });
+ });
+
+ it("returns draft entity with null ID when level not found", async () => {
+ mockPrisma.level.findFirst.mockResolvedValue(null);
+
+ const result = await service.resolveLevel("Principal");
+
+ expect(result).toEqual({ id: null, name: "Principal" });
+ });
+ });
+
+ describe("resolveSkill", () => {
+ it("returns matched skill with ID when found", async () => {
+ const mockSkill = { id: "skill-ts", name: "TypeScript" };
+ mockPrisma.skill.findFirst.mockResolvedValue(mockSkill);
+
+ const result = await service.resolveSkill("TypeScript");
+
+ expect(mockPrisma.skill.findFirst).toHaveBeenCalledWith({
+ where: { name: { equals: "TypeScript", mode: "insensitive" } },
+ });
+ expect(result).toEqual({ id: "skill-ts", name: "TypeScript" });
+ });
+
+ it("returns draft entity with null ID when not found", async () => {
+ mockPrisma.skill.findFirst.mockResolvedValue(null);
+
+ const result = await service.resolveSkill("ObscureFramework");
+
+ expect(result).toEqual({ id: null, name: "ObscureFramework" });
+ });
+
+ it("trims whitespace from skill name", async () => {
+ const mockSkill = { id: "skill-ts", name: "TypeScript" };
+ mockPrisma.skill.findFirst.mockResolvedValue(mockSkill);
+
+ const result = await service.resolveSkill(" TypeScript ");
+
+ expect(mockPrisma.skill.findFirst).toHaveBeenCalledWith({
+ where: { name: { equals: "TypeScript", mode: "insensitive" } },
+ });
+ expect(result).toEqual({ id: "skill-ts", name: "TypeScript" });
+ });
+
+ it("returns empty draft entity for empty/whitespace-only input", async () => {
+ const result = await service.resolveSkill(" ");
+
+ expect(mockPrisma.skill.findFirst).not.toHaveBeenCalled();
+ expect(result).toEqual({ id: null, name: "" });
+ });
+ });
+
+ describe("resolveInstitution", () => {
+ it("returns matched institution with ID when found", async () => {
+ const mockInstitution = {
+ id: "inst-stanford",
+ name: "Stanford University",
+ };
+ mockPrisma.institution.findFirst.mockResolvedValue(mockInstitution);
+
+ const result = await service.resolveInstitution("Stanford University");
+
+ expect(mockPrisma.institution.findFirst).toHaveBeenCalledWith({
+ where: { name: { equals: "Stanford University", mode: "insensitive" } },
+ });
+ expect(result).toEqual({
+ id: "inst-stanford",
+ name: "Stanford University",
+ });
+ });
+
+ it("returns draft entity with null ID when not found", async () => {
+ mockPrisma.institution.findFirst.mockResolvedValue(null);
+
+ const result = await service.resolveInstitution("Unknown University");
+
+ expect(result).toEqual({ id: null, name: "Unknown University" });
+ });
+ });
+
+ describe("resolveSkills", () => {
+ it("resolves multiple skills in parallel", async () => {
+ mockPrisma.skill.findFirst
+ .mockResolvedValueOnce({ id: "skill-ts", name: "TypeScript" })
+ .mockResolvedValueOnce({ id: "skill-js", name: "JavaScript" })
+ .mockResolvedValueOnce(null);
+
+ const result = await service.resolveSkills([
+ "TypeScript",
+ "JavaScript",
+ "Rust",
+ ]);
+
+ expect(mockPrisma.skill.findFirst).toHaveBeenCalledTimes(3);
+ expect(result).toHaveLength(3);
+ expect(result[0]).toEqual({ id: "skill-ts", name: "TypeScript" });
+ expect(result[1]).toEqual({ id: "skill-js", name: "JavaScript" });
+ expect(result[2]).toEqual({ id: null, name: "Rust" });
+ });
+
+ it("deduplicates skill names before resolving", async () => {
+ mockPrisma.skill.findFirst.mockResolvedValue({
+ id: "skill-ts",
+ name: "TypeScript",
+ });
+
+ const result = await service.resolveSkills([
+ "TypeScript",
+ "TypeScript",
+ "TypeScript",
+ ]);
+
+ expect(mockPrisma.skill.findFirst).toHaveBeenCalledTimes(1);
+ expect(result).toHaveLength(1);
+ });
+
+ it("filters out empty and whitespace-only skill names", async () => {
+ mockPrisma.skill.findFirst.mockResolvedValue({
+ id: "skill-ts",
+ name: "TypeScript",
+ });
+
+ const result = await service.resolveSkills([
+ "TypeScript",
+ "",
+ " ",
+ null as unknown as string,
+ undefined as unknown as string,
+ ]);
+
+ expect(mockPrisma.skill.findFirst).toHaveBeenCalledTimes(1);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual({ id: "skill-ts", name: "TypeScript" });
+ });
+
+ it("returns empty array for empty input", async () => {
+ const result = await service.resolveSkills([]);
+
+ expect(mockPrisma.skill.findFirst).not.toHaveBeenCalled();
+ expect(result).toEqual([]);
+ });
+
+ it("handles mixed matching and non-matching skills", async () => {
+ mockPrisma.skill.findFirst
+ .mockResolvedValueOnce({ id: "skill-1", name: "React" })
+ .mockResolvedValueOnce(null)
+ .mockResolvedValueOnce({ id: "skill-2", name: "Node.js" })
+ .mockResolvedValueOnce(null);
+
+ const result = await service.resolveSkills([
+ "React",
+ "SomeObscureLib",
+ "Node.js",
+ "AnotherRareSkill",
+ ]);
+
+ expect(result).toHaveLength(4);
+ expect(result[0].id).toBe("skill-1");
+ expect(result[1].id).toBeNull();
+ expect(result[2].id).toBe("skill-2");
+ expect(result[3].id).toBeNull();
+ });
+ });
+
+ describe("fuzzy matching scenarios", () => {
+ it("matches company names case-insensitively", async () => {
+ const mockCompany = { id: "c-1", name: "Google LLC" };
+ mockPrisma.company.findFirst.mockResolvedValue(mockCompany);
+
+ const variations = [
+ "google llc",
+ "GOOGLE LLC",
+ "Google LLC",
+ "GoOgLe LlC",
+ ];
+
+ for (const variation of variations) {
+ const result = await service.resolveCompany(variation);
+ expect(result.id).toBe("c-1");
+ }
+ });
+
+ it("handles abbreviated vs full company names", async () => {
+ mockPrisma.company.findFirst.mockResolvedValue(null);
+
+ const result = await service.resolveCompany("IBM");
+
+ expect(result.id).toBeNull();
+ expect(result.name).toBe("IBM");
+ });
+
+ it("preserves original name from database when matched", async () => {
+ const mockSkill = { id: "skill-1", name: "Node.js" };
+ mockPrisma.skill.findFirst.mockResolvedValue(mockSkill);
+
+ const result = await service.resolveSkill("nodejs");
+
+ expect(result.name).toBe("Node.js");
+ });
+ });
+});
diff --git a/apps/server/src/modules/cv-parser/__tests__/fixtures/mock-ai-responses.ts b/apps/api/src/modules/cv-parser/__tests__/fixtures/mock-ai-responses.ts
similarity index 100%
rename from apps/server/src/modules/cv-parser/__tests__/fixtures/mock-ai-responses.ts
rename to apps/api/src/modules/cv-parser/__tests__/fixtures/mock-ai-responses.ts
diff --git a/apps/server/src/modules/cv-parser/__tests__/fixtures/sample-cv.md b/apps/api/src/modules/cv-parser/__tests__/fixtures/sample-cv.md
similarity index 100%
rename from apps/server/src/modules/cv-parser/__tests__/fixtures/sample-cv.md
rename to apps/api/src/modules/cv-parser/__tests__/fixtures/sample-cv.md
diff --git a/apps/server/src/modules/cv-parser/__tests__/fixtures/sample-cv.txt b/apps/api/src/modules/cv-parser/__tests__/fixtures/sample-cv.txt
similarity index 100%
rename from apps/server/src/modules/cv-parser/__tests__/fixtures/sample-cv.txt
rename to apps/api/src/modules/cv-parser/__tests__/fixtures/sample-cv.txt
diff --git a/apps/server/src/modules/cv-parser/ai-provider-resolver.service.ts b/apps/api/src/modules/cv-parser/ai-provider-resolver.service.ts
similarity index 98%
rename from apps/server/src/modules/cv-parser/ai-provider-resolver.service.ts
rename to apps/api/src/modules/cv-parser/ai-provider-resolver.service.ts
index b5ef6f3..e00c769 100644
--- a/apps/server/src/modules/cv-parser/ai-provider-resolver.service.ts
+++ b/apps/api/src/modules/cv-parser/ai-provider-resolver.service.ts
@@ -5,7 +5,7 @@ import {
AnthropicProvider,
OpenAIProvider,
} from "@cv/ai-provider";
-import type { User } from "@cv/auth";
+import type { User } from "@cv/core";
import {
BadRequestException,
ForbiddenException,
diff --git a/apps/server/src/modules/cv-parser/cv-parser.module.ts b/apps/api/src/modules/cv-parser/cv-parser.module.ts
similarity index 98%
rename from apps/server/src/modules/cv-parser/cv-parser.module.ts
rename to apps/api/src/modules/cv-parser/cv-parser.module.ts
index 69b6512..f93506a 100644
--- a/apps/server/src/modules/cv-parser/cv-parser.module.ts
+++ b/apps/api/src/modules/cv-parser/cv-parser.module.ts
@@ -4,7 +4,7 @@ import { join } from "node:path";
import { CVParserModule as CVParserCoreModule } from "@cv/ai-parser";
import { AIModule } from "@cv/ai-provider";
import { FileExtractionModule } from "@cv/file-upload";
-import { DatabaseModule } from "@cv/system";
+import { DatabaseModule } from "@cv/core";
import { Module } from "@nestjs/common";
import { MulterModule } from "@nestjs/platform-express";
import { diskStorage } from "multer";
diff --git a/apps/server/src/modules/cv-parser/cv-parser.resolver.ts b/apps/api/src/modules/cv-parser/cv-parser.resolver.ts
similarity index 95%
rename from apps/server/src/modules/cv-parser/cv-parser.resolver.ts
rename to apps/api/src/modules/cv-parser/cv-parser.resolver.ts
index e0ccbdb..cc3c975 100644
--- a/apps/server/src/modules/cv-parser/cv-parser.resolver.ts
+++ b/apps/api/src/modules/cv-parser/cv-parser.resolver.ts
@@ -1,4 +1,4 @@
-import type { User as DomainUser } from "@cv/auth";
+import type { User as DomainUser } from "@cv/core";
import { JwtAuthGuard } from "@cv/auth";
import { UseGuards } from "@nestjs/common";
import { Args, Mutation, Resolver } from "@nestjs/graphql";
diff --git a/apps/server/src/modules/cv-parser/cv-parser.service.ts b/apps/api/src/modules/cv-parser/cv-parser.service.ts
similarity index 99%
rename from apps/server/src/modules/cv-parser/cv-parser.service.ts
rename to apps/api/src/modules/cv-parser/cv-parser.service.ts
index ad4a4aa..c5ee4f7 100644
--- a/apps/server/src/modules/cv-parser/cv-parser.service.ts
+++ b/apps/api/src/modules/cv-parser/cv-parser.service.ts
@@ -1,5 +1,5 @@
import { CVParserService as CVParser, type ParsedCVData } from "@cv/ai-parser";
-import type { User } from "@cv/auth";
+import type { User } from "@cv/core";
import {
TEXT_EXTRACTOR_REGISTRY,
type TextExtractorRegistry,
diff --git a/apps/server/src/modules/cv-parser/entity-resolver.service.ts b/apps/api/src/modules/cv-parser/entity-resolver.service.ts
similarity index 98%
rename from apps/server/src/modules/cv-parser/entity-resolver.service.ts
rename to apps/api/src/modules/cv-parser/entity-resolver.service.ts
index d0e3d08..88a1a94 100644
--- a/apps/server/src/modules/cv-parser/entity-resolver.service.ts
+++ b/apps/api/src/modules/cv-parser/entity-resolver.service.ts
@@ -1,4 +1,4 @@
-import { PrismaService } from "@cv/system";
+import { PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
/**
diff --git a/apps/server/src/modules/cv-parser/file-upload.controller.ts b/apps/api/src/modules/cv-parser/file-upload.controller.ts
similarity index 98%
rename from apps/server/src/modules/cv-parser/file-upload.controller.ts
rename to apps/api/src/modules/cv-parser/file-upload.controller.ts
index 3e81430..8ceff7f 100644
--- a/apps/server/src/modules/cv-parser/file-upload.controller.ts
+++ b/apps/api/src/modules/cv-parser/file-upload.controller.ts
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { readFile, unlink } from "node:fs/promises";
-import type { User as DomainUser } from "@cv/auth";
+import type { User as DomainUser } from "@cv/core";
import { JwtAuthGuard } from "@cv/auth";
import { validateFile } from "@cv/file-upload";
import {
diff --git a/apps/server/src/modules/cv-parser/graphql/upload-file.input.ts b/apps/api/src/modules/cv-parser/graphql/upload-file.input.ts
similarity index 100%
rename from apps/server/src/modules/cv-parser/graphql/upload-file.input.ts
rename to apps/api/src/modules/cv-parser/graphql/upload-file.input.ts
diff --git a/apps/server/src/modules/cv-parser/graphql/upload-file.resolver.ts b/apps/api/src/modules/cv-parser/graphql/upload-file.resolver.ts
similarity index 97%
rename from apps/server/src/modules/cv-parser/graphql/upload-file.resolver.ts
rename to apps/api/src/modules/cv-parser/graphql/upload-file.resolver.ts
index 9c0b95a..5d735e1 100644
--- a/apps/server/src/modules/cv-parser/graphql/upload-file.resolver.ts
+++ b/apps/api/src/modules/cv-parser/graphql/upload-file.resolver.ts
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
-import type { User as DomainUser } from "@cv/auth";
+import type { User as DomainUser } from "@cv/core";
import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
import { validateFile } from "@cv/file-upload";
import { BadRequestException, UseGuards } from "@nestjs/common";
diff --git a/apps/server/src/modules/cv-parser/onboarding/import.step.ts b/apps/api/src/modules/cv-parser/onboarding/import.step.ts
similarity index 92%
rename from apps/server/src/modules/cv-parser/onboarding/import.step.ts
rename to apps/api/src/modules/cv-parser/onboarding/import.step.ts
index f6e7e6c..1db3fdf 100644
--- a/apps/server/src/modules/cv-parser/onboarding/import.step.ts
+++ b/apps/api/src/modules/cv-parser/onboarding/import.step.ts
@@ -1,5 +1,5 @@
-import type { User } from "@cv/auth";
-import { PrismaService } from "@cv/system";
+import type { User } from "@cv/core";
+import { PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { OnboardingStep } from "@/modules/onboarding/onboarding-step.decorator";
import {
diff --git a/apps/server/src/modules/cv-parser/types/draft.types.ts b/apps/api/src/modules/cv-parser/types/draft.types.ts
similarity index 100%
rename from apps/server/src/modules/cv-parser/types/draft.types.ts
rename to apps/api/src/modules/cv-parser/types/draft.types.ts
diff --git a/apps/server/src/modules/cv-template/cv-data-assembler.service.ts b/apps/api/src/modules/cv-template/cv-data-assembler.service.ts
similarity index 96%
rename from apps/server/src/modules/cv-template/cv-data-assembler.service.ts
rename to apps/api/src/modules/cv-template/cv-data-assembler.service.ts
index d30448a..877cdf2 100644
--- a/apps/server/src/modules/cv-template/cv-data-assembler.service.ts
+++ b/apps/api/src/modules/cv-template/cv-data-assembler.service.ts
@@ -1,4 +1,4 @@
-import { PrismaService } from "@cv/system";
+import { PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { ProfileService } from "@/modules/profile/profile.service";
@@ -8,8 +8,7 @@ export interface CVRenderContext {
name: string;
headline: string | null;
phone: string | null;
- city: string | null;
- country: string | null;
+ locationId: string | null;
website: string | null;
linkedInUrl: string | null;
summary: string | null;
@@ -131,8 +130,7 @@ export class CVDataAssemblerService {
name: profile.fullName ?? profile.name,
headline: profile.headline ?? null,
phone: profile.phone ?? null,
- city: profile.city ?? null,
- country: profile.country ?? null,
+ locationId: profile.locationId ?? null,
website: profile.website ?? null,
linkedInUrl: profile.linkedInUrl ?? null,
summary: profile.summary ?? null,
diff --git a/apps/server/src/modules/cv-template/cv-renderer.service.ts b/apps/api/src/modules/cv-template/cv-renderer.service.ts
similarity index 100%
rename from apps/server/src/modules/cv-template/cv-renderer.service.ts
rename to apps/api/src/modules/cv-template/cv-renderer.service.ts
diff --git a/apps/server/src/modules/cv-template/cv-template.mapper.ts b/apps/api/src/modules/cv-template/cv-template.mapper.ts
similarity index 100%
rename from apps/server/src/modules/cv-template/cv-template.mapper.ts
rename to apps/api/src/modules/cv-template/cv-template.mapper.ts
diff --git a/apps/server/src/modules/cv-template/cv-template.module.ts b/apps/api/src/modules/cv-template/cv-template.module.ts
similarity index 93%
rename from apps/server/src/modules/cv-template/cv-template.module.ts
rename to apps/api/src/modules/cv-template/cv-template.module.ts
index 2906bee..c183c18 100644
--- a/apps/server/src/modules/cv-template/cv-template.module.ts
+++ b/apps/api/src/modules/cv-template/cv-template.module.ts
@@ -1,5 +1,5 @@
-import { AuthorizationModule } from "@cv/auth";
-import { BaseModule, DatabaseModule } from "@cv/system";
+import { AuthorizationModule } from "@cv/core";
+import { BaseModule, DatabaseModule } from "@cv/core";
import { Module, forwardRef } from "@nestjs/common";
import { AuthenticationModule } from "@/modules/authentication/authentication.module";
import { ProfileModule } from "@/modules/profile/profile.module";
diff --git a/apps/server/src/modules/cv-template/cv-template.policy.ts b/apps/api/src/modules/cv-template/cv-template.policy.ts
similarity index 78%
rename from apps/server/src/modules/cv-template/cv-template.policy.ts
rename to apps/api/src/modules/cv-template/cv-template.policy.ts
index 704b93d..22eb7dc 100644
--- a/apps/server/src/modules/cv-template/cv-template.policy.ts
+++ b/apps/api/src/modules/cv-template/cv-template.policy.ts
@@ -1,4 +1,4 @@
-import { Policy, PublicResourcePolicy } from "@cv/auth";
+import { Policy, PublicResourcePolicy } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { CVTemplate } from "./graphql/cv-template.type";
diff --git a/apps/server/src/modules/cv-template/cv-template.service.ts b/apps/api/src/modules/cv-template/cv-template.service.ts
similarity index 91%
rename from apps/server/src/modules/cv-template/cv-template.service.ts
rename to apps/api/src/modules/cv-template/cv-template.service.ts
index 08edcc3..b0f6133 100644
--- a/apps/server/src/modules/cv-template/cv-template.service.ts
+++ b/apps/api/src/modules/cv-template/cv-template.service.ts
@@ -1,5 +1,5 @@
-import { notFound } from "@cv/auth";
-import { PrismaService } from "@cv/system";
+import { notFound } from "@cv/core";
+import { PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { cvTemplateMapper } from "./cv-template.mapper";
diff --git a/apps/server/src/modules/cv-template/cv.dataloader.ts b/apps/api/src/modules/cv-template/cv.dataloader.ts
similarity index 91%
rename from apps/server/src/modules/cv-template/cv.dataloader.ts
rename to apps/api/src/modules/cv-template/cv.dataloader.ts
index 0e01da3..9c9fa0f 100644
--- a/apps/server/src/modules/cv-template/cv.dataloader.ts
+++ b/apps/api/src/modules/cv-template/cv.dataloader.ts
@@ -1,4 +1,4 @@
-import { BaseDataLoaderService } from "@cv/system";
+import { BaseDataLoaderService } from "@cv/core";
import { Injectable, Scope } from "@nestjs/common";
import { CVService } from "./cv.service";
import { CV } from "./graphql/cv.type";
diff --git a/apps/server/src/modules/cv-template/cv.mapper.ts b/apps/api/src/modules/cv-template/cv.mapper.ts
similarity index 100%
rename from apps/server/src/modules/cv-template/cv.mapper.ts
rename to apps/api/src/modules/cv-template/cv.mapper.ts
diff --git a/apps/server/src/modules/cv-template/cv.policy.ts b/apps/api/src/modules/cv-template/cv.policy.ts
similarity index 69%
rename from apps/server/src/modules/cv-template/cv.policy.ts
rename to apps/api/src/modules/cv-template/cv.policy.ts
index 099e0b4..329c9e2 100644
--- a/apps/server/src/modules/cv-template/cv.policy.ts
+++ b/apps/api/src/modules/cv-template/cv.policy.ts
@@ -1,5 +1,5 @@
-import { Policy, ProfileOwnedResourcePolicy } from "@cv/auth";
-import { PrismaService } from "@cv/system";
+import { Policy, ProfileOwnedResourcePolicy } from "@cv/core";
+import { PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { CV } from "./graphql/cv.type";
diff --git a/apps/server/src/modules/cv-template/cv.service.ts b/apps/api/src/modules/cv-template/cv.service.ts
similarity index 96%
rename from apps/server/src/modules/cv-template/cv.service.ts
rename to apps/api/src/modules/cv-template/cv.service.ts
index 070bcad..e5233e1 100644
--- a/apps/server/src/modules/cv-template/cv.service.ts
+++ b/apps/api/src/modules/cv-template/cv.service.ts
@@ -1,6 +1,6 @@
-import { notFound } from "@cv/auth";
-import type { PaginationOptions, PaginationResult } from "@cv/system";
-import { type EntityService, PaginationService, PrismaService } from "@cv/system";
+import { notFound } from "@cv/core";
+import type { PaginationOptions, PaginationResult } from "@cv/core";
+import { type EntityService, PaginationService, PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import type { Prisma } from "@prisma/client";
import { cvMapper } from "./cv.mapper";
diff --git a/apps/server/src/modules/cv-template/graphql/cv-args.type.ts b/apps/api/src/modules/cv-template/graphql/cv-args.type.ts
similarity index 100%
rename from apps/server/src/modules/cv-template/graphql/cv-args.type.ts
rename to apps/api/src/modules/cv-template/graphql/cv-args.type.ts
diff --git a/apps/server/src/modules/cv-template/graphql/cv-input.type.ts b/apps/api/src/modules/cv-template/graphql/cv-input.type.ts
similarity index 100%
rename from apps/server/src/modules/cv-template/graphql/cv-input.type.ts
rename to apps/api/src/modules/cv-template/graphql/cv-input.type.ts
diff --git a/apps/server/src/modules/cv-template/graphql/cv-template-args.type.ts b/apps/api/src/modules/cv-template/graphql/cv-template-args.type.ts
similarity index 100%
rename from apps/server/src/modules/cv-template/graphql/cv-template-args.type.ts
rename to apps/api/src/modules/cv-template/graphql/cv-template-args.type.ts
diff --git a/apps/server/src/modules/cv-template/graphql/cv-template.resolver.ts b/apps/api/src/modules/cv-template/graphql/cv-template.resolver.ts
similarity index 95%
rename from apps/server/src/modules/cv-template/graphql/cv-template.resolver.ts
rename to apps/api/src/modules/cv-template/graphql/cv-template.resolver.ts
index 1465487..6f46958 100644
--- a/apps/server/src/modules/cv-template/graphql/cv-template.resolver.ts
+++ b/apps/api/src/modules/cv-template/graphql/cv-template.resolver.ts
@@ -1,10 +1,7 @@
-import type { User as DomainUser } from "@cv/auth";
-import {
- AuthorizationService,
- JwtAuthGuard,
- VerifiedScopeGuard,
-} from "@cv/auth";
-import { PaginationService, UuidFactoryService } from "@cv/system";
+import type { User as DomainUser } from "@cv/core";
+import { AuthorizationService } from "@cv/core";
+import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
+import { PaginationService, UuidFactoryService } from "@cv/core";
import { UseGuards } from "@nestjs/common";
import { Args, Mutation, Query, Resolver } from "@nestjs/graphql";
import type { MessageBus } from "@riotbyte-com/project-q-core";
diff --git a/apps/server/src/modules/cv-template/graphql/cv-template.type.ts b/apps/api/src/modules/cv-template/graphql/cv-template.type.ts
similarity index 97%
rename from apps/server/src/modules/cv-template/graphql/cv-template.type.ts
rename to apps/api/src/modules/cv-template/graphql/cv-template.type.ts
index 215c6bd..f23440a 100644
--- a/apps/server/src/modules/cv-template/graphql/cv-template.type.ts
+++ b/apps/api/src/modules/cv-template/graphql/cv-template.type.ts
@@ -1,4 +1,4 @@
-import { BaseEntity } from "@cv/system";
+import { BaseEntity } from "@cv/core";
import { Field, ID, ObjectType } from "@nestjs/graphql";
import { createConnection } from "@/modules/base/connection.factory";
diff --git a/apps/server/src/modules/cv-template/graphql/cv.type.ts b/apps/api/src/modules/cv-template/graphql/cv.type.ts
similarity index 96%
rename from apps/server/src/modules/cv-template/graphql/cv.type.ts
rename to apps/api/src/modules/cv-template/graphql/cv.type.ts
index 20354e4..ae47578 100644
--- a/apps/server/src/modules/cv-template/graphql/cv.type.ts
+++ b/apps/api/src/modules/cv-template/graphql/cv.type.ts
@@ -1,4 +1,4 @@
-import { BaseEntity } from "@cv/system";
+import { BaseEntity } from "@cv/core";
import { Field, ID, ObjectType } from "@nestjs/graphql";
import { createConnection } from "@/modules/base/connection.factory";
import { CVTemplate } from "./cv-template.type";
diff --git a/apps/server/src/modules/cv-template/graphql/user-field.resolver.ts b/apps/api/src/modules/cv-template/graphql/user-field.resolver.ts
similarity index 95%
rename from apps/server/src/modules/cv-template/graphql/user-field.resolver.ts
rename to apps/api/src/modules/cv-template/graphql/user-field.resolver.ts
index 832e970..9526a6e 100644
--- a/apps/server/src/modules/cv-template/graphql/user-field.resolver.ts
+++ b/apps/api/src/modules/cv-template/graphql/user-field.resolver.ts
@@ -1,5 +1,5 @@
import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
-import { PaginationService, PrismaService } from "@cv/system";
+import { PaginationService, PrismaService } from "@cv/core";
import { UseGuards } from "@nestjs/common";
import { Args, Parent, ResolveField, Resolver } from "@nestjs/graphql";
import { User } from "@/modules/user/user.type";
diff --git a/apps/server/src/modules/cv-template/pdf-download.controller.ts b/apps/api/src/modules/cv-template/pdf-download.controller.ts
similarity index 90%
rename from apps/server/src/modules/cv-template/pdf-download.controller.ts
rename to apps/api/src/modules/cv-template/pdf-download.controller.ts
index 3860fca..c734ae8 100644
--- a/apps/server/src/modules/cv-template/pdf-download.controller.ts
+++ b/apps/api/src/modules/cv-template/pdf-download.controller.ts
@@ -1,5 +1,6 @@
-import type { User as DomainUser } from "@cv/auth";
-import { AuthorizationService, JwtAuthGuard } from "@cv/auth";
+import type { User as DomainUser } from "@cv/core";
+import { AuthorizationService } from "@cv/core";
+import { JwtAuthGuard } from "@cv/auth";
import { FILE_STORAGE, type FileStorage } from "@cv/file-storage";
import {
Controller,
diff --git a/apps/server/src/modules/cv-template/seed/cv-template.seed.ts b/apps/api/src/modules/cv-template/seed/cv-template.seed.ts
similarity index 97%
rename from apps/server/src/modules/cv-template/seed/cv-template.seed.ts
rename to apps/api/src/modules/cv-template/seed/cv-template.seed.ts
index d7b0248..c58f66b 100644
--- a/apps/server/src/modules/cv-template/seed/cv-template.seed.ts
+++ b/apps/api/src/modules/cv-template/seed/cv-template.seed.ts
@@ -1,6 +1,6 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
-import { PrismaService } from "@cv/system";
+import { PrismaService } from "@cv/core";
import { Injectable, Logger } from "@nestjs/common";
import { Seeder } from "@/modules/database/seed/seed.service";
import { Seeder as SeederDecorator } from "@/modules/database/seed/seeder.decorator";
diff --git a/apps/server/src/modules/cv-template/seed/templates/classic-executive.css b/apps/api/src/modules/cv-template/seed/templates/classic-executive.css
similarity index 100%
rename from apps/server/src/modules/cv-template/seed/templates/classic-executive.css
rename to apps/api/src/modules/cv-template/seed/templates/classic-executive.css
diff --git a/apps/server/src/modules/cv-template/seed/templates/classic-executive.hbs b/apps/api/src/modules/cv-template/seed/templates/classic-executive.hbs
similarity index 100%
rename from apps/server/src/modules/cv-template/seed/templates/classic-executive.hbs
rename to apps/api/src/modules/cv-template/seed/templates/classic-executive.hbs
diff --git a/apps/server/src/modules/cv-template/seed/templates/creative-portfolio.css b/apps/api/src/modules/cv-template/seed/templates/creative-portfolio.css
similarity index 100%
rename from apps/server/src/modules/cv-template/seed/templates/creative-portfolio.css
rename to apps/api/src/modules/cv-template/seed/templates/creative-portfolio.css
diff --git a/apps/server/src/modules/cv-template/seed/templates/creative-portfolio.hbs b/apps/api/src/modules/cv-template/seed/templates/creative-portfolio.hbs
similarity index 100%
rename from apps/server/src/modules/cv-template/seed/templates/creative-portfolio.hbs
rename to apps/api/src/modules/cv-template/seed/templates/creative-portfolio.hbs
diff --git a/apps/server/src/modules/cv-template/seed/templates/modern-professional.css b/apps/api/src/modules/cv-template/seed/templates/modern-professional.css
similarity index 100%
rename from apps/server/src/modules/cv-template/seed/templates/modern-professional.css
rename to apps/api/src/modules/cv-template/seed/templates/modern-professional.css
diff --git a/apps/server/src/modules/cv-template/seed/templates/modern-professional.hbs b/apps/api/src/modules/cv-template/seed/templates/modern-professional.hbs
similarity index 100%
rename from apps/server/src/modules/cv-template/seed/templates/modern-professional.hbs
rename to apps/api/src/modules/cv-template/seed/templates/modern-professional.hbs
diff --git a/apps/server/src/modules/data-import/data-import-source.interface.ts b/apps/api/src/modules/data-import/data-import-source.interface.ts
similarity index 87%
rename from apps/server/src/modules/data-import/data-import-source.interface.ts
rename to apps/api/src/modules/data-import/data-import-source.interface.ts
index 4fe87e3..831a716 100644
--- a/apps/server/src/modules/data-import/data-import-source.interface.ts
+++ b/apps/api/src/modules/data-import/data-import-source.interface.ts
@@ -1,5 +1,5 @@
import type { ParsedCVData } from "@cv/ai-parser";
-import type { User } from "@cv/auth";
+import type { User } from "@cv/core";
export interface DataImportSource {
readonly name: string;
diff --git a/apps/server/src/modules/data-import/data-import.module.ts b/apps/api/src/modules/data-import/data-import.module.ts
similarity index 78%
rename from apps/server/src/modules/data-import/data-import.module.ts
rename to apps/api/src/modules/data-import/data-import.module.ts
index a2210e8..2097d01 100644
--- a/apps/server/src/modules/data-import/data-import.module.ts
+++ b/apps/api/src/modules/data-import/data-import.module.ts
@@ -1,7 +1,6 @@
-import { AuthorizationModule } from "@cv/auth";
-import { DatabaseModule } from "@cv/system";
+import { AuthorizationModule } from "@cv/core";
+import { DatabaseModule } from "@cv/core";
import { Module } from "@nestjs/common";
-import { EventEmitterModule } from "@nestjs/event-emitter";
import { EntityResolverService } from "@/modules/cv-parser/entity-resolver.service";
import { ProfileModule } from "@/modules/profile/profile.module";
import { UserFileResolver } from "./graphql/user-file.resolver";
@@ -13,7 +12,7 @@ import { UserFileMapper } from "./user-file.mapper";
import { UserFilePolicy } from "./user-file.policy";
@Module({
- imports: [DatabaseModule, AuthorizationModule, EventEmitterModule.forRoot(), ProfileModule],
+ imports: [DatabaseModule, AuthorizationModule, ProfileModule],
providers: [
ImportService,
EntityResolverService,
diff --git a/apps/api/src/modules/data-import/events/user-file-created.event.ts b/apps/api/src/modules/data-import/events/user-file-created.event.ts
new file mode 100644
index 0000000..b3ea596
--- /dev/null
+++ b/apps/api/src/modules/data-import/events/user-file-created.event.ts
@@ -0,0 +1,12 @@
+import { DomainEvent } from "@cv/core";
+import type { User } from "@cv/core";
+import type { DataImportSource } from "../data-import-source.interface";
+
+interface UserFileCreatedPayload {
+ userFileId: string;
+ user: User;
+ source: DataImportSource;
+ params: Record;
+}
+
+export class UserFileCreatedEvent extends DomainEvent {}
diff --git a/apps/server/src/modules/data-import/graphql/user-file.resolver.ts b/apps/api/src/modules/data-import/graphql/user-file.resolver.ts
similarity index 90%
rename from apps/server/src/modules/data-import/graphql/user-file.resolver.ts
rename to apps/api/src/modules/data-import/graphql/user-file.resolver.ts
index 476fc3e..147bf79 100644
--- a/apps/server/src/modules/data-import/graphql/user-file.resolver.ts
+++ b/apps/api/src/modules/data-import/graphql/user-file.resolver.ts
@@ -1,9 +1,6 @@
-import type { User as DomainUser } from "@cv/auth";
-import {
- AuthorizationService,
- JwtAuthGuard,
- VerifiedScopeGuard,
-} from "@cv/auth";
+import type { User as DomainUser } from "@cv/core";
+import { AuthorizationService } from "@cv/core";
+import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
import { UseGuards } from "@nestjs/common";
import { Args, Mutation, Query, Resolver } from "@nestjs/graphql";
import { CurrentUser } from "@/modules/current-user/current-user.decorator";
diff --git a/apps/server/src/modules/data-import/graphql/user-file.type.ts b/apps/api/src/modules/data-import/graphql/user-file.type.ts
similarity index 100%
rename from apps/server/src/modules/data-import/graphql/user-file.type.ts
rename to apps/api/src/modules/data-import/graphql/user-file.type.ts
diff --git a/apps/server/src/modules/data-import/import-job.entity.ts b/apps/api/src/modules/data-import/import-job.entity.ts
similarity index 91%
rename from apps/server/src/modules/data-import/import-job.entity.ts
rename to apps/api/src/modules/data-import/import-job.entity.ts
index 48d1bed..680b547 100644
--- a/apps/server/src/modules/data-import/import-job.entity.ts
+++ b/apps/api/src/modules/data-import/import-job.entity.ts
@@ -1,4 +1,4 @@
-import { BaseEntity } from "@cv/system";
+import { BaseEntity } from "@cv/core";
export class ImportJob extends BaseEntity {
constructor(
diff --git a/apps/server/src/modules/data-import/import-job.mapper.ts b/apps/api/src/modules/data-import/import-job.mapper.ts
similarity index 95%
rename from apps/server/src/modules/data-import/import-job.mapper.ts
rename to apps/api/src/modules/data-import/import-job.mapper.ts
index 7b34657..79bd512 100644
--- a/apps/server/src/modules/data-import/import-job.mapper.ts
+++ b/apps/api/src/modules/data-import/import-job.mapper.ts
@@ -1,4 +1,4 @@
-import type { BaseMapper } from "@cv/system";
+import type { BaseMapper } from "@cv/core";
import { Injectable } from "@nestjs/common";
import type { Prisma } from "@prisma/client";
diff --git a/apps/server/src/modules/data-import/import-job.policy.ts b/apps/api/src/modules/data-import/import-job.policy.ts
similarity index 88%
rename from apps/server/src/modules/data-import/import-job.policy.ts
rename to apps/api/src/modules/data-import/import-job.policy.ts
index 9ff7041..1fc7f68 100644
--- a/apps/server/src/modules/data-import/import-job.policy.ts
+++ b/apps/api/src/modules/data-import/import-job.policy.ts
@@ -1,5 +1,5 @@
-import type { IPolicy, User } from "@cv/auth";
-import { Policy } from "@cv/auth";
+import type { IPolicy, User } from "@cv/core";
+import { Policy } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { ImportJob } from "./import-job.entity";
diff --git a/apps/server/src/modules/data-import/import.service.ts b/apps/api/src/modules/data-import/import.service.ts
similarity index 88%
rename from apps/server/src/modules/data-import/import.service.ts
rename to apps/api/src/modules/data-import/import.service.ts
index 4a3673f..f5baefb 100644
--- a/apps/server/src/modules/data-import/import.service.ts
+++ b/apps/api/src/modules/data-import/import.service.ts
@@ -1,7 +1,7 @@
-import type { User } from "@cv/auth";
-import { PrismaService } from "@cv/system";
+import type { User } from "@cv/core";
+import { PrismaService } from "@cv/core";
+import { EventService } from "@cv/core";
import { Injectable } from "@nestjs/common";
-import { EventEmitter2 } from "@nestjs/event-emitter";
import type { DataImportSource } from "./data-import-source.interface";
import { UserFileCreatedEvent } from "./events/user-file-created.event";
import { UserFile } from "./user-file.entity";
@@ -12,7 +12,7 @@ export class ImportService {
constructor(
private readonly prisma: PrismaService,
private readonly userFileMapper: UserFileMapper,
- private readonly eventEmitter: EventEmitter2,
+ private readonly eventService: EventService,
) {}
/**
@@ -56,9 +56,8 @@ export class ImportService {
},
});
- this.eventEmitter.emit(
- UserFileCreatedEvent.event,
- new UserFileCreatedEvent(record.id, user, source, params),
+ this.eventService.emit(
+ new UserFileCreatedEvent({ userFileId: record.id, user, source, params }),
);
return this.userFileMapper.toDomain(record) as UserFile;
diff --git a/apps/server/src/modules/data-import/listeners/import-job.listener.ts b/apps/api/src/modules/data-import/listeners/import-job.listener.ts
similarity index 96%
rename from apps/server/src/modules/data-import/listeners/import-job.listener.ts
rename to apps/api/src/modules/data-import/listeners/import-job.listener.ts
index 49ff425..6bb4b66 100644
--- a/apps/server/src/modules/data-import/listeners/import-job.listener.ts
+++ b/apps/api/src/modules/data-import/listeners/import-job.listener.ts
@@ -1,5 +1,5 @@
import type { ParsedCVData } from "@cv/ai-parser";
-import { PrismaService } from "@cv/system";
+import { PrismaService } from "@cv/core";
import { Injectable, Logger } from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter";
import {
@@ -35,12 +35,12 @@ export class ImportJobListener {
private readonly profileService: ProfileService,
) {}
- @OnEvent(UserFileCreatedEvent.event, { async: true })
+ @OnEvent(UserFileCreatedEvent.eventName, { async: true })
async handleUserFileCreated(event: UserFileCreatedEvent): Promise {
const job = await this.prisma.importJob.create({
data: {
- userFileId: event.userFileId,
- source: event.source.name,
+ userFileId: event.payload.userFileId,
+ source: event.payload.source.name,
status: "pending",
statusMessage: "Queued",
},
@@ -53,7 +53,7 @@ export class ImportJobListener {
jobId: string,
event: UserFileCreatedEvent,
): Promise {
- const { userFileId, user, source, params } = event;
+ const { userFileId, user, source, params } = event.payload;
const tag = `job=${jobId} source=${source.name}`;
try {
diff --git a/apps/server/src/modules/data-import/sources/file-import-source.ts b/apps/api/src/modules/data-import/sources/file-import-source.ts
similarity index 94%
rename from apps/server/src/modules/data-import/sources/file-import-source.ts
rename to apps/api/src/modules/data-import/sources/file-import-source.ts
index d009395..fa6f575 100644
--- a/apps/server/src/modules/data-import/sources/file-import-source.ts
+++ b/apps/api/src/modules/data-import/sources/file-import-source.ts
@@ -3,8 +3,8 @@ import {
type ExistingUserContext,
type ParsedCVData,
} from "@cv/ai-parser";
-import type { User } from "@cv/auth";
-import { PrismaService } from "@cv/system";
+import type { User } from "@cv/core";
+import { PrismaService } from "@cv/core";
import { TextExtractorRegistry, TEXT_EXTRACTOR_REGISTRY } from "@cv/file-upload";
import { Inject, Injectable, Logger } from "@nestjs/common";
import { EducationService } from "@/modules/education/education.service";
@@ -17,7 +17,7 @@ import type { DataImportSource } from "../data-import-source.interface";
* Build ExistingUserContext from domain models for AI prompt enrichment.
*/
const buildExistingUserContext = (
- profile: { fullName?: string | null; headline?: string | null; city?: string | null; country?: string | null } | null,
+ profile: { fullName?: string | null; headline?: string | null } | null,
jobs: Array<{ company: { name: string }; role: { name: string }; startDate: Date; endDate?: Date }>,
educations: Array<{ institution: { name: string }; degree: string; startDate: Date; endDate: Date | null }>,
): ExistingUserContext | undefined => {
@@ -25,8 +25,6 @@ const buildExistingUserContext = (
if (profile?.fullName) context.name = profile.fullName;
if (profile?.headline) context.headline = profile.headline;
- if (profile?.city) context.city = profile.city;
- if (profile?.country) context.country = profile.country;
if (jobs.length > 0) {
context.jobs = jobs.map((j) => {
diff --git a/apps/server/src/modules/data-import/user-file.entity.ts b/apps/api/src/modules/data-import/user-file.entity.ts
similarity index 93%
rename from apps/server/src/modules/data-import/user-file.entity.ts
rename to apps/api/src/modules/data-import/user-file.entity.ts
index bdeea5a..859d0f1 100644
--- a/apps/server/src/modules/data-import/user-file.entity.ts
+++ b/apps/api/src/modules/data-import/user-file.entity.ts
@@ -1,4 +1,4 @@
-import { BaseEntity } from "@cv/system";
+import { BaseEntity } from "@cv/core";
export class UserFile extends BaseEntity {
constructor(
diff --git a/apps/server/src/modules/data-import/user-file.mapper.ts b/apps/api/src/modules/data-import/user-file.mapper.ts
similarity index 95%
rename from apps/server/src/modules/data-import/user-file.mapper.ts
rename to apps/api/src/modules/data-import/user-file.mapper.ts
index 3e494e6..1542dcd 100644
--- a/apps/server/src/modules/data-import/user-file.mapper.ts
+++ b/apps/api/src/modules/data-import/user-file.mapper.ts
@@ -1,4 +1,4 @@
-import type { BaseMapper } from "@cv/system";
+import type { BaseMapper } from "@cv/core";
import { Injectable } from "@nestjs/common";
import type { Prisma } from "@prisma/client";
diff --git a/apps/server/src/modules/data-import/user-file.policy.ts b/apps/api/src/modules/data-import/user-file.policy.ts
similarity index 71%
rename from apps/server/src/modules/data-import/user-file.policy.ts
rename to apps/api/src/modules/data-import/user-file.policy.ts
index bb0a727..f5c55f2 100644
--- a/apps/server/src/modules/data-import/user-file.policy.ts
+++ b/apps/api/src/modules/data-import/user-file.policy.ts
@@ -1,5 +1,5 @@
-import { Policy, ProfileOwnedResourcePolicy } from "@cv/auth";
-import { PrismaService } from "@cv/system";
+import { Policy, ProfileOwnedResourcePolicy } from "@cv/core";
+import { PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { UserFile } from "./user-file.entity";
diff --git a/apps/server/src/modules/database/database.module.ts b/apps/api/src/modules/database/database.module.ts
similarity index 100%
rename from apps/server/src/modules/database/database.module.ts
rename to apps/api/src/modules/database/database.module.ts
diff --git a/apps/server/src/modules/database/prisma.service.ts b/apps/api/src/modules/database/prisma.service.ts
similarity index 100%
rename from apps/server/src/modules/database/prisma.service.ts
rename to apps/api/src/modules/database/prisma.service.ts
diff --git a/apps/server/src/modules/database/seed/seed.module.ts b/apps/api/src/modules/database/seed/seed.module.ts
similarity index 93%
rename from apps/server/src/modules/database/seed/seed.module.ts
rename to apps/api/src/modules/database/seed/seed.module.ts
index cf4e05e..444c812 100644
--- a/apps/server/src/modules/database/seed/seed.module.ts
+++ b/apps/api/src/modules/database/seed/seed.module.ts
@@ -1,5 +1,5 @@
import { AuthModule } from "@cv/auth";
-import { DatabaseModule } from "@cv/system";
+import { DatabaseModule } from "@cv/core";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { DiscoveryModule } from "@nestjs/core";
diff --git a/apps/server/src/modules/database/seed/seed.service.ts b/apps/api/src/modules/database/seed/seed.service.ts
similarity index 98%
rename from apps/server/src/modules/database/seed/seed.service.ts
rename to apps/api/src/modules/database/seed/seed.service.ts
index acaf9d1..61bddba 100644
--- a/apps/server/src/modules/database/seed/seed.service.ts
+++ b/apps/api/src/modules/database/seed/seed.service.ts
@@ -1,4 +1,4 @@
-import { PrismaService } from "@cv/system";
+import { PrismaService } from "@cv/core";
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { DiscoveryService } from "@nestjs/core";
import type { SeederConfig } from "./seeder.decorator";
diff --git a/apps/server/src/modules/database/seed/seeder.decorator.ts b/apps/api/src/modules/database/seed/seeder.decorator.ts
similarity index 100%
rename from apps/server/src/modules/database/seed/seeder.decorator.ts
rename to apps/api/src/modules/database/seed/seeder.decorator.ts
diff --git a/apps/server/src/modules/database/seed/topological-sort.spec.ts b/apps/api/src/modules/database/seed/topological-sort.spec.ts
similarity index 100%
rename from apps/server/src/modules/database/seed/topological-sort.spec.ts
rename to apps/api/src/modules/database/seed/topological-sort.spec.ts
diff --git a/apps/server/src/modules/database/seed/topological-sort.ts b/apps/api/src/modules/database/seed/topological-sort.ts
similarity index 100%
rename from apps/server/src/modules/database/seed/topological-sort.ts
rename to apps/api/src/modules/database/seed/topological-sort.ts
diff --git a/apps/server/src/modules/education/education.entity.ts b/apps/api/src/modules/education/education.entity.ts
similarity index 94%
rename from apps/server/src/modules/education/education.entity.ts
rename to apps/api/src/modules/education/education.entity.ts
index dc60d62..176fe3a 100644
--- a/apps/server/src/modules/education/education.entity.ts
+++ b/apps/api/src/modules/education/education.entity.ts
@@ -1,4 +1,4 @@
-import { BaseEntity } from "@cv/system";
+import { BaseEntity } from "@cv/core";
import type { Skill } from "@/modules/job-experience/skill/skill.entity";
import { Institution } from "./institution.entity";
diff --git a/apps/server/src/modules/education/education.mapper.ts b/apps/api/src/modules/education/education.mapper.ts
similarity index 97%
rename from apps/server/src/modules/education/education.mapper.ts
rename to apps/api/src/modules/education/education.mapper.ts
index 995d9ec..37955f2 100644
--- a/apps/server/src/modules/education/education.mapper.ts
+++ b/apps/api/src/modules/education/education.mapper.ts
@@ -1,4 +1,4 @@
-import type { BaseMapper } from "@cv/system";
+import type { BaseMapper } from "@cv/core";
import { Injectable } from "@nestjs/common";
import type { Prisma } from "@prisma/client";
import { SkillMapper } from "@/modules/job-experience/skill/skill.mapper";
diff --git a/apps/server/src/modules/education/education.module.ts b/apps/api/src/modules/education/education.module.ts
similarity index 93%
rename from apps/server/src/modules/education/education.module.ts
rename to apps/api/src/modules/education/education.module.ts
index 805040d..64c385c 100644
--- a/apps/server/src/modules/education/education.module.ts
+++ b/apps/api/src/modules/education/education.module.ts
@@ -1,5 +1,5 @@
-import { AuthorizationModule } from "@cv/auth";
-import { BaseModule, DatabaseModule } from "@cv/system";
+import { AuthorizationModule } from "@cv/core";
+import { BaseModule, DatabaseModule } from "@cv/core";
import { Module } from "@nestjs/common";
import { AuthenticationModule } from "@/modules/authentication/authentication.module";
import { SkillModule } from "@/modules/job-experience/skill/skill.module";
diff --git a/apps/server/src/modules/education/education.policy.ts b/apps/api/src/modules/education/education.policy.ts
similarity index 71%
rename from apps/server/src/modules/education/education.policy.ts
rename to apps/api/src/modules/education/education.policy.ts
index 55607c1..7410da0 100644
--- a/apps/server/src/modules/education/education.policy.ts
+++ b/apps/api/src/modules/education/education.policy.ts
@@ -1,5 +1,5 @@
-import { Policy, ProfileOwnedResourcePolicy } from "@cv/auth";
-import { PrismaService } from "@cv/system";
+import { Policy, ProfileOwnedResourcePolicy } from "@cv/core";
+import { PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { Education } from "./education.entity";
diff --git a/apps/server/src/modules/education/education.service.ts b/apps/api/src/modules/education/education.service.ts
similarity index 96%
rename from apps/server/src/modules/education/education.service.ts
rename to apps/api/src/modules/education/education.service.ts
index 1fb1018..20ac05d 100644
--- a/apps/server/src/modules/education/education.service.ts
+++ b/apps/api/src/modules/education/education.service.ts
@@ -1,6 +1,6 @@
-import { notFound } from "@cv/auth";
-import type { PaginationOptions, PaginationResult } from "@cv/system";
-import { PaginationService, PrismaService } from "@cv/system";
+import { notFound } from "@cv/core";
+import type { PaginationOptions, PaginationResult } from "@cv/core";
+import { PaginationService, PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import type { Prisma } from "@prisma/client";
import { Education } from "./education.entity";
diff --git a/apps/server/src/modules/education/graphql/education.resolver.ts b/apps/api/src/modules/education/graphql/education.resolver.ts
similarity index 96%
rename from apps/server/src/modules/education/graphql/education.resolver.ts
rename to apps/api/src/modules/education/graphql/education.resolver.ts
index fc3c926..bf02125 100644
--- a/apps/server/src/modules/education/graphql/education.resolver.ts
+++ b/apps/api/src/modules/education/graphql/education.resolver.ts
@@ -1,9 +1,6 @@
-import type { User as DomainUser } from "@cv/auth";
-import {
- AuthorizationService,
- JwtAuthGuard,
- VerifiedScopeGuard,
-} from "@cv/auth";
+import type { User as DomainUser } from "@cv/core";
+import { AuthorizationService } from "@cv/core";
+import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
import { UseGuards } from "@nestjs/common";
import { Args, Mutation, Resolver } from "@nestjs/graphql";
import { CurrentUser } from "@/modules/current-user/current-user.decorator";
diff --git a/apps/server/src/modules/education/graphql/education.type.ts b/apps/api/src/modules/education/graphql/education.type.ts
similarity index 100%
rename from apps/server/src/modules/education/graphql/education.type.ts
rename to apps/api/src/modules/education/graphql/education.type.ts
diff --git a/apps/server/src/modules/education/graphql/institution.resolver.ts b/apps/api/src/modules/education/graphql/institution.resolver.ts
similarity index 90%
rename from apps/server/src/modules/education/graphql/institution.resolver.ts
rename to apps/api/src/modules/education/graphql/institution.resolver.ts
index 044cc02..3cbae7a 100644
--- a/apps/server/src/modules/education/graphql/institution.resolver.ts
+++ b/apps/api/src/modules/education/graphql/institution.resolver.ts
@@ -1,9 +1,6 @@
-import type { User as DomainUser } from "@cv/auth";
-import {
- AuthorizationService,
- JwtAuthGuard,
- VerifiedScopeGuard,
-} from "@cv/auth";
+import type { User as DomainUser } from "@cv/core";
+import { AuthorizationService } from "@cv/core";
+import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
import { UseGuards } from "@nestjs/common";
import { Args, Mutation, Query, Resolver } from "@nestjs/graphql";
import { CurrentUser } from "@/modules/current-user/current-user.decorator";
diff --git a/apps/server/src/modules/education/graphql/institution.type.ts b/apps/api/src/modules/education/graphql/institution.type.ts
similarity index 100%
rename from apps/server/src/modules/education/graphql/institution.type.ts
rename to apps/api/src/modules/education/graphql/institution.type.ts
diff --git a/apps/server/src/modules/education/graphql/user-field.resolver.ts b/apps/api/src/modules/education/graphql/user-field.resolver.ts
similarity index 99%
rename from apps/server/src/modules/education/graphql/user-field.resolver.ts
rename to apps/api/src/modules/education/graphql/user-field.resolver.ts
index 8797fc4..b6c7838 100644
--- a/apps/server/src/modules/education/graphql/user-field.resolver.ts
+++ b/apps/api/src/modules/education/graphql/user-field.resolver.ts
@@ -1,5 +1,5 @@
import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
-import { PaginationArgs, PaginationService, PrismaService } from "@cv/system";
+import { PaginationArgs, PaginationService, PrismaService } from "@cv/core";
import { UseGuards } from "@nestjs/common";
import { Args, Parent, ResolveField, Resolver } from "@nestjs/graphql";
import { User } from "@/modules/user/user.type";
diff --git a/apps/server/src/modules/education/institution.entity.ts b/apps/api/src/modules/education/institution.entity.ts
similarity index 53%
rename from apps/server/src/modules/education/institution.entity.ts
rename to apps/api/src/modules/education/institution.entity.ts
index 1f0f42e..e3bbcc8 100644
--- a/apps/server/src/modules/education/institution.entity.ts
+++ b/apps/api/src/modules/education/institution.entity.ts
@@ -1,3 +1,3 @@
-import { NamedEntity } from "@cv/system";
+import { NamedEntity } from "@cv/core";
export class Institution extends NamedEntity {}
diff --git a/apps/server/src/modules/education/institution.factory.ts b/apps/api/src/modules/education/institution.factory.ts
similarity index 89%
rename from apps/server/src/modules/education/institution.factory.ts
rename to apps/api/src/modules/education/institution.factory.ts
index 8809e54..c5ebacf 100644
--- a/apps/server/src/modules/education/institution.factory.ts
+++ b/apps/api/src/modules/education/institution.factory.ts
@@ -1,4 +1,4 @@
-import { ClockService, Factory, UuidFactoryService } from "@cv/system";
+import { ClockService, Factory, UuidFactoryService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { Institution } from "./institution.entity";
diff --git a/apps/server/src/modules/education/institution.mapper.ts b/apps/api/src/modules/education/institution.mapper.ts
similarity index 93%
rename from apps/server/src/modules/education/institution.mapper.ts
rename to apps/api/src/modules/education/institution.mapper.ts
index 69913fe..4030272 100644
--- a/apps/server/src/modules/education/institution.mapper.ts
+++ b/apps/api/src/modules/education/institution.mapper.ts
@@ -1,4 +1,4 @@
-import { createNamedEntityMapper } from "@cv/system";
+import { createNamedEntityMapper } from "@cv/core";
import { Injectable } from "@nestjs/common";
import type { Institution as PrismaInstitution } from "@prisma/client";
import { Institution } from "./institution.entity";
diff --git a/apps/server/src/modules/education/institution.policy.ts b/apps/api/src/modules/education/institution.policy.ts
similarity index 82%
rename from apps/server/src/modules/education/institution.policy.ts
rename to apps/api/src/modules/education/institution.policy.ts
index 273d6c5..da54d87 100644
--- a/apps/server/src/modules/education/institution.policy.ts
+++ b/apps/api/src/modules/education/institution.policy.ts
@@ -1,4 +1,4 @@
-import { Policy, PublicResourcePolicy } from "@cv/auth";
+import { Policy, PublicResourcePolicy } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { Institution } from "./institution.entity";
diff --git a/apps/server/src/modules/education/institution.service.ts b/apps/api/src/modules/education/institution.service.ts
similarity index 86%
rename from apps/server/src/modules/education/institution.service.ts
rename to apps/api/src/modules/education/institution.service.ts
index b48c5fa..6db279a 100644
--- a/apps/server/src/modules/education/institution.service.ts
+++ b/apps/api/src/modules/education/institution.service.ts
@@ -1,4 +1,4 @@
-import { NamedEntityService, PrismaService } from "@cv/system";
+import { NamedEntityService, PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { Institution } from "./institution.entity";
import { InstitutionMapper } from "./institution.mapper";
diff --git a/apps/server/src/modules/education/onboarding/education.step.ts b/apps/api/src/modules/education/onboarding/education.step.ts
similarity index 90%
rename from apps/server/src/modules/education/onboarding/education.step.ts
rename to apps/api/src/modules/education/onboarding/education.step.ts
index a41f12e..db341ac 100644
--- a/apps/server/src/modules/education/onboarding/education.step.ts
+++ b/apps/api/src/modules/education/onboarding/education.step.ts
@@ -1,5 +1,5 @@
-import type { User } from "@cv/auth";
-import { PrismaService } from "@cv/system";
+import type { User } from "@cv/core";
+import { PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { OnboardingStep } from "@/modules/onboarding/onboarding-step.decorator";
import {
diff --git a/apps/server/src/modules/job-experience/company/company.dataloader.ts b/apps/api/src/modules/job-experience/company/company.dataloader.ts
similarity index 92%
rename from apps/server/src/modules/job-experience/company/company.dataloader.ts
rename to apps/api/src/modules/job-experience/company/company.dataloader.ts
index a5ad5c5..4b224fa 100644
--- a/apps/server/src/modules/job-experience/company/company.dataloader.ts
+++ b/apps/api/src/modules/job-experience/company/company.dataloader.ts
@@ -1,4 +1,4 @@
-import { BaseDataLoaderService } from "@cv/system";
+import { BaseDataLoaderService } from "@cv/core";
import { Injectable, Scope } from "@nestjs/common";
import { Company } from "./company.entity";
import { CompanyService } from "./company.service";
diff --git a/apps/server/src/modules/job-experience/company/company.dto.ts b/apps/api/src/modules/job-experience/company/company.dto.ts
similarity index 100%
rename from apps/server/src/modules/job-experience/company/company.dto.ts
rename to apps/api/src/modules/job-experience/company/company.dto.ts
diff --git a/apps/server/src/modules/job-experience/company/company.entity.ts b/apps/api/src/modules/job-experience/company/company.entity.ts
similarity index 86%
rename from apps/server/src/modules/job-experience/company/company.entity.ts
rename to apps/api/src/modules/job-experience/company/company.entity.ts
index 52f5799..2301260 100644
--- a/apps/server/src/modules/job-experience/company/company.entity.ts
+++ b/apps/api/src/modules/job-experience/company/company.entity.ts
@@ -1,4 +1,4 @@
-import { NamedEntity } from "@cv/system";
+import { NamedEntity } from "@cv/core";
export class Company extends NamedEntity {
constructor(
diff --git a/apps/server/src/modules/job-experience/company/company.factory.ts b/apps/api/src/modules/job-experience/company/company.factory.ts
similarity index 89%
rename from apps/server/src/modules/job-experience/company/company.factory.ts
rename to apps/api/src/modules/job-experience/company/company.factory.ts
index 8aac3b3..26dce40 100644
--- a/apps/server/src/modules/job-experience/company/company.factory.ts
+++ b/apps/api/src/modules/job-experience/company/company.factory.ts
@@ -1,4 +1,4 @@
-import { ClockService, Factory, UuidFactoryService } from "@cv/system";
+import { ClockService, Factory, UuidFactoryService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import type { CreateCompanyDto } from "./company.dto";
import { Company } from "./company.entity";
diff --git a/apps/server/src/modules/job-experience/company/company.mapper.ts b/apps/api/src/modules/job-experience/company/company.mapper.ts
similarity index 100%
rename from apps/server/src/modules/job-experience/company/company.mapper.ts
rename to apps/api/src/modules/job-experience/company/company.mapper.ts
diff --git a/apps/server/src/modules/job-experience/company/company.module.ts b/apps/api/src/modules/job-experience/company/company.module.ts
similarity index 89%
rename from apps/server/src/modules/job-experience/company/company.module.ts
rename to apps/api/src/modules/job-experience/company/company.module.ts
index 30da4ab..3ef0282 100644
--- a/apps/server/src/modules/job-experience/company/company.module.ts
+++ b/apps/api/src/modules/job-experience/company/company.module.ts
@@ -1,5 +1,5 @@
-import { AuthorizationModule } from "@cv/auth";
-import { BaseModule, DatabaseModule } from "@cv/system";
+import { AuthorizationModule } from "@cv/core";
+import { BaseModule, DatabaseModule } from "@cv/core";
import { Module } from "@nestjs/common";
import { AuthenticationModule } from "@/modules/authentication/authentication.module";
import { CompanyService } from "@/modules/job-experience/company/company.service";
diff --git a/apps/server/src/modules/job-experience/company/company.policy.ts b/apps/api/src/modules/job-experience/company/company.policy.ts
similarity index 80%
rename from apps/server/src/modules/job-experience/company/company.policy.ts
rename to apps/api/src/modules/job-experience/company/company.policy.ts
index 4520273..1b8cdc1 100644
--- a/apps/server/src/modules/job-experience/company/company.policy.ts
+++ b/apps/api/src/modules/job-experience/company/company.policy.ts
@@ -1,4 +1,4 @@
-import { Policy, PublicResourcePolicy } from "@cv/auth";
+import { Policy, PublicResourcePolicy } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { Company } from "./company.entity";
diff --git a/apps/server/src/modules/job-experience/company/company.service.ts b/apps/api/src/modules/job-experience/company/company.service.ts
similarity index 90%
rename from apps/server/src/modules/job-experience/company/company.service.ts
rename to apps/api/src/modules/job-experience/company/company.service.ts
index 3828c89..7bbbdd4 100644
--- a/apps/server/src/modules/job-experience/company/company.service.ts
+++ b/apps/api/src/modules/job-experience/company/company.service.ts
@@ -1,4 +1,4 @@
-import { NamedEntityService, PrismaService } from "@cv/system";
+import { NamedEntityService, PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { Company } from "@/modules/job-experience/company/company.entity";
import { CompanyMapper } from "./company.mapper";
diff --git a/apps/server/src/modules/job-experience/company/graphql/company-connection-args.type.ts b/apps/api/src/modules/job-experience/company/graphql/company-connection-args.type.ts
similarity index 83%
rename from apps/server/src/modules/job-experience/company/graphql/company-connection-args.type.ts
rename to apps/api/src/modules/job-experience/company/graphql/company-connection-args.type.ts
index b133c34..db8ebbe 100644
--- a/apps/server/src/modules/job-experience/company/graphql/company-connection-args.type.ts
+++ b/apps/api/src/modules/job-experience/company/graphql/company-connection-args.type.ts
@@ -1,4 +1,4 @@
-import { BasePaginationArgs } from "@cv/system";
+import { BasePaginationArgs } from "@cv/core";
import { ArgsType, Field } from "@nestjs/graphql";
import { GraphQLString } from "graphql";
diff --git a/apps/server/src/modules/job-experience/company/graphql/company.resolver.ts b/apps/api/src/modules/job-experience/company/graphql/company.resolver.ts
similarity index 95%
rename from apps/server/src/modules/job-experience/company/graphql/company.resolver.ts
rename to apps/api/src/modules/job-experience/company/graphql/company.resolver.ts
index 50a8d74..ceb729b 100644
--- a/apps/server/src/modules/job-experience/company/graphql/company.resolver.ts
+++ b/apps/api/src/modules/job-experience/company/graphql/company.resolver.ts
@@ -1,10 +1,7 @@
-import type { User as DomainUser } from "@cv/auth";
-import {
- AuthorizationService,
- JwtAuthGuard,
- VerifiedScopeGuard,
-} from "@cv/auth";
-import { PaginationService } from "@cv/system";
+import type { User as DomainUser } from "@cv/core";
+import { AuthorizationService } from "@cv/core";
+import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
+import { PaginationService } from "@cv/core";
import { UseGuards } from "@nestjs/common";
import { Args, Mutation, Query, Resolver } from "@nestjs/graphql";
import { CurrentUser } from "@/modules/current-user/current-user.decorator";
diff --git a/apps/server/src/modules/job-experience/company/graphql/company.type.ts b/apps/api/src/modules/job-experience/company/graphql/company.type.ts
similarity index 100%
rename from apps/server/src/modules/job-experience/company/graphql/company.type.ts
rename to apps/api/src/modules/job-experience/company/graphql/company.type.ts
diff --git a/apps/server/src/modules/job-experience/employment/employment.module.ts b/apps/api/src/modules/job-experience/employment/employment.module.ts
similarity index 92%
rename from apps/server/src/modules/job-experience/employment/employment.module.ts
rename to apps/api/src/modules/job-experience/employment/employment.module.ts
index 0a5cd8e..98d07fe 100644
--- a/apps/server/src/modules/job-experience/employment/employment.module.ts
+++ b/apps/api/src/modules/job-experience/employment/employment.module.ts
@@ -1,5 +1,5 @@
-import { AuthorizationModule } from "@cv/auth";
-import { BaseModule, DatabaseModule } from "@cv/system";
+import { AuthorizationModule } from "@cv/core";
+import { BaseModule, DatabaseModule } from "@cv/core";
import { Module } from "@nestjs/common";
import { AuthenticationModule } from "@/modules/authentication/authentication.module";
import { CompanyModule } from "@/modules/job-experience/company/company.module";
diff --git a/apps/server/src/modules/job-experience/employment/graphql/employment.resolver.ts b/apps/api/src/modules/job-experience/employment/graphql/employment.resolver.ts
similarity index 97%
rename from apps/server/src/modules/job-experience/employment/graphql/employment.resolver.ts
rename to apps/api/src/modules/job-experience/employment/graphql/employment.resolver.ts
index 094fef7..ce70aeb 100644
--- a/apps/server/src/modules/job-experience/employment/graphql/employment.resolver.ts
+++ b/apps/api/src/modules/job-experience/employment/graphql/employment.resolver.ts
@@ -1,9 +1,6 @@
-import type { User as DomainUser } from "@cv/auth";
-import {
- AuthorizationService,
- JwtAuthGuard,
- VerifiedScopeGuard,
-} from "@cv/auth";
+import type { User as DomainUser } from "@cv/core";
+import { AuthorizationService } from "@cv/core";
+import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
import { UseGuards } from "@nestjs/common";
import { Args, Mutation, Resolver } from "@nestjs/graphql";
import { CurrentUser } from "@/modules/current-user/current-user.decorator";
diff --git a/apps/server/src/modules/job-experience/employment/graphql/user-field.resolver.ts b/apps/api/src/modules/job-experience/employment/graphql/user-field.resolver.ts
similarity index 99%
rename from apps/server/src/modules/job-experience/employment/graphql/user-field.resolver.ts
rename to apps/api/src/modules/job-experience/employment/graphql/user-field.resolver.ts
index 6d2313f..2ef5d0d 100644
--- a/apps/server/src/modules/job-experience/employment/graphql/user-field.resolver.ts
+++ b/apps/api/src/modules/job-experience/employment/graphql/user-field.resolver.ts
@@ -1,5 +1,5 @@
import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
-import { PaginationArgs, PaginationService, PrismaService } from "@cv/system";
+import { PaginationArgs, PaginationService, PrismaService } from "@cv/core";
import { UseGuards } from "@nestjs/common";
import { Args, Parent, ResolveField, Resolver } from "@nestjs/graphql";
import { UserJobExperienceService } from "@/modules/job-experience/employment/user-job-experience.service";
diff --git a/apps/server/src/modules/job-experience/employment/graphql/user-job-experience.type.ts b/apps/api/src/modules/job-experience/employment/graphql/user-job-experience.type.ts
similarity index 100%
rename from apps/server/src/modules/job-experience/employment/graphql/user-job-experience.type.ts
rename to apps/api/src/modules/job-experience/employment/graphql/user-job-experience.type.ts
diff --git a/apps/server/src/modules/job-experience/employment/onboarding/career-history.step.ts b/apps/api/src/modules/job-experience/employment/onboarding/career-history.step.ts
similarity index 90%
rename from apps/server/src/modules/job-experience/employment/onboarding/career-history.step.ts
rename to apps/api/src/modules/job-experience/employment/onboarding/career-history.step.ts
index 526b7c8..d4caeeb 100644
--- a/apps/server/src/modules/job-experience/employment/onboarding/career-history.step.ts
+++ b/apps/api/src/modules/job-experience/employment/onboarding/career-history.step.ts
@@ -1,5 +1,5 @@
-import type { User } from "@cv/auth";
-import { PrismaService } from "@cv/system";
+import type { User } from "@cv/core";
+import { PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { OnboardingStep } from "@/modules/onboarding/onboarding-step.decorator";
import {
diff --git a/apps/server/src/modules/job-experience/employment/user-job-experience.dto.ts b/apps/api/src/modules/job-experience/employment/user-job-experience.dto.ts
similarity index 100%
rename from apps/server/src/modules/job-experience/employment/user-job-experience.dto.ts
rename to apps/api/src/modules/job-experience/employment/user-job-experience.dto.ts
diff --git a/apps/server/src/modules/job-experience/employment/user-job-experience.entity.ts b/apps/api/src/modules/job-experience/employment/user-job-experience.entity.ts
similarity index 96%
rename from apps/server/src/modules/job-experience/employment/user-job-experience.entity.ts
rename to apps/api/src/modules/job-experience/employment/user-job-experience.entity.ts
index fb2523f..6d9bab3 100644
--- a/apps/server/src/modules/job-experience/employment/user-job-experience.entity.ts
+++ b/apps/api/src/modules/job-experience/employment/user-job-experience.entity.ts
@@ -1,4 +1,4 @@
-import { BaseEntity } from "@cv/system";
+import { BaseEntity } from "@cv/core";
import type { Company } from "@/modules/job-experience/company/company.entity";
import type { Level } from "@/modules/job-experience/level/level.entity";
import type { Role } from "@/modules/job-experience/role/role.entity";
diff --git a/apps/server/src/modules/job-experience/employment/user-job-experience.mapper.ts b/apps/api/src/modules/job-experience/employment/user-job-experience.mapper.ts
similarity index 98%
rename from apps/server/src/modules/job-experience/employment/user-job-experience.mapper.ts
rename to apps/api/src/modules/job-experience/employment/user-job-experience.mapper.ts
index 219b7e4..e43ecda 100644
--- a/apps/server/src/modules/job-experience/employment/user-job-experience.mapper.ts
+++ b/apps/api/src/modules/job-experience/employment/user-job-experience.mapper.ts
@@ -1,4 +1,4 @@
-import type { BaseMapper } from "@cv/system";
+import type { BaseMapper } from "@cv/core";
import { Injectable } from "@nestjs/common";
import type {
Company as PrismaCompany,
diff --git a/apps/server/src/modules/job-experience/employment/user-job-experience.policy.ts b/apps/api/src/modules/job-experience/employment/user-job-experience.policy.ts
similarity index 74%
rename from apps/server/src/modules/job-experience/employment/user-job-experience.policy.ts
rename to apps/api/src/modules/job-experience/employment/user-job-experience.policy.ts
index f4e2fa2..7a037a6 100644
--- a/apps/server/src/modules/job-experience/employment/user-job-experience.policy.ts
+++ b/apps/api/src/modules/job-experience/employment/user-job-experience.policy.ts
@@ -1,5 +1,5 @@
-import { Policy, ProfileOwnedResourcePolicy } from "@cv/auth";
-import { PrismaService } from "@cv/system";
+import { Policy, ProfileOwnedResourcePolicy } from "@cv/core";
+import { PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { UserJobExperience } from "./user-job-experience.entity";
diff --git a/apps/server/src/modules/job-experience/employment/user-job-experience.service.ts b/apps/api/src/modules/job-experience/employment/user-job-experience.service.ts
similarity index 97%
rename from apps/server/src/modules/job-experience/employment/user-job-experience.service.ts
rename to apps/api/src/modules/job-experience/employment/user-job-experience.service.ts
index 677b65f..d47263c 100644
--- a/apps/server/src/modules/job-experience/employment/user-job-experience.service.ts
+++ b/apps/api/src/modules/job-experience/employment/user-job-experience.service.ts
@@ -1,6 +1,6 @@
-import { notFound } from "@cv/auth";
-import type { PaginationOptions, PaginationResult } from "@cv/system";
-import { PaginationService, PrismaService } from "@cv/system";
+import { notFound } from "@cv/core";
+import type { PaginationOptions, PaginationResult } from "@cv/core";
+import { PaginationService, PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import type { Prisma } from "@prisma/client";
import type {
diff --git a/apps/server/src/modules/job-experience/level/graphql/level-connection-args.type.ts b/apps/api/src/modules/job-experience/level/graphql/level-connection-args.type.ts
similarity index 83%
rename from apps/server/src/modules/job-experience/level/graphql/level-connection-args.type.ts
rename to apps/api/src/modules/job-experience/level/graphql/level-connection-args.type.ts
index 606bc87..7573fef 100644
--- a/apps/server/src/modules/job-experience/level/graphql/level-connection-args.type.ts
+++ b/apps/api/src/modules/job-experience/level/graphql/level-connection-args.type.ts
@@ -1,4 +1,4 @@
-import { BasePaginationArgs } from "@cv/system";
+import { BasePaginationArgs } from "@cv/core";
import { ArgsType, Field } from "@nestjs/graphql";
import { GraphQLString } from "graphql";
diff --git a/apps/server/src/modules/job-experience/level/graphql/level.resolver.ts b/apps/api/src/modules/job-experience/level/graphql/level.resolver.ts
similarity index 94%
rename from apps/server/src/modules/job-experience/level/graphql/level.resolver.ts
rename to apps/api/src/modules/job-experience/level/graphql/level.resolver.ts
index d3f1f69..ab88a97 100644
--- a/apps/server/src/modules/job-experience/level/graphql/level.resolver.ts
+++ b/apps/api/src/modules/job-experience/level/graphql/level.resolver.ts
@@ -1,10 +1,7 @@
-import type { User as DomainUser } from "@cv/auth";
-import {
- AuthorizationService,
- JwtAuthGuard,
- VerifiedScopeGuard,
-} from "@cv/auth";
-import { PaginationService } from "@cv/system";
+import type { User as DomainUser } from "@cv/core";
+import { AuthorizationService } from "@cv/core";
+import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
+import { PaginationService } from "@cv/core";
import { UseGuards } from "@nestjs/common";
import { Args, Mutation, Query, Resolver } from "@nestjs/graphql";
import { CurrentUser } from "@/modules/current-user/current-user.decorator";
diff --git a/apps/server/src/modules/job-experience/level/graphql/level.type.ts b/apps/api/src/modules/job-experience/level/graphql/level.type.ts
similarity index 100%
rename from apps/server/src/modules/job-experience/level/graphql/level.type.ts
rename to apps/api/src/modules/job-experience/level/graphql/level.type.ts
diff --git a/apps/server/src/modules/job-experience/level/level.dataloader.ts b/apps/api/src/modules/job-experience/level/level.dataloader.ts
similarity index 92%
rename from apps/server/src/modules/job-experience/level/level.dataloader.ts
rename to apps/api/src/modules/job-experience/level/level.dataloader.ts
index 46bcd8e..96842fd 100644
--- a/apps/server/src/modules/job-experience/level/level.dataloader.ts
+++ b/apps/api/src/modules/job-experience/level/level.dataloader.ts
@@ -1,4 +1,4 @@
-import { BaseDataLoaderService } from "@cv/system";
+import { BaseDataLoaderService } from "@cv/core";
import { Injectable, Scope } from "@nestjs/common";
import { Level } from "./level.entity";
import { LevelService } from "./level.service";
diff --git a/apps/server/src/modules/job-experience/level/level.dto.ts b/apps/api/src/modules/job-experience/level/level.dto.ts
similarity index 100%
rename from apps/server/src/modules/job-experience/level/level.dto.ts
rename to apps/api/src/modules/job-experience/level/level.dto.ts
diff --git a/apps/server/src/modules/job-experience/level/level.entity.ts b/apps/api/src/modules/job-experience/level/level.entity.ts
similarity index 50%
rename from apps/server/src/modules/job-experience/level/level.entity.ts
rename to apps/api/src/modules/job-experience/level/level.entity.ts
index 331b4b4..11eeeb8 100644
--- a/apps/server/src/modules/job-experience/level/level.entity.ts
+++ b/apps/api/src/modules/job-experience/level/level.entity.ts
@@ -1,3 +1,3 @@
-import { NamedEntity } from "@cv/system";
+import { NamedEntity } from "@cv/core";
export class Level extends NamedEntity {}
diff --git a/apps/server/src/modules/job-experience/level/level.factory.ts b/apps/api/src/modules/job-experience/level/level.factory.ts
similarity index 88%
rename from apps/server/src/modules/job-experience/level/level.factory.ts
rename to apps/api/src/modules/job-experience/level/level.factory.ts
index b21500f..b740a7d 100644
--- a/apps/server/src/modules/job-experience/level/level.factory.ts
+++ b/apps/api/src/modules/job-experience/level/level.factory.ts
@@ -1,4 +1,4 @@
-import { ClockService, Factory, UuidFactoryService } from "@cv/system";
+import { ClockService, Factory, UuidFactoryService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import type { CreateLevelDto } from "./level.dto";
import { Level } from "./level.entity";
diff --git a/apps/server/src/modules/job-experience/level/level.mapper.ts b/apps/api/src/modules/job-experience/level/level.mapper.ts
similarity index 93%
rename from apps/server/src/modules/job-experience/level/level.mapper.ts
rename to apps/api/src/modules/job-experience/level/level.mapper.ts
index 7d4bb25..6b2ae15 100644
--- a/apps/server/src/modules/job-experience/level/level.mapper.ts
+++ b/apps/api/src/modules/job-experience/level/level.mapper.ts
@@ -1,4 +1,4 @@
-import { createNamedEntityMapper } from "@cv/system";
+import { createNamedEntityMapper } from "@cv/core";
import { Injectable } from "@nestjs/common";
import type { Level as PrismaLevel } from "@prisma/client";
import { Level } from "@/modules/job-experience/level/level.entity";
diff --git a/apps/server/src/modules/job-experience/level/level.module.ts b/apps/api/src/modules/job-experience/level/level.module.ts
similarity index 88%
rename from apps/server/src/modules/job-experience/level/level.module.ts
rename to apps/api/src/modules/job-experience/level/level.module.ts
index 071a9ba..596f047 100644
--- a/apps/server/src/modules/job-experience/level/level.module.ts
+++ b/apps/api/src/modules/job-experience/level/level.module.ts
@@ -1,5 +1,5 @@
-import { AuthorizationModule } from "@cv/auth";
-import { BaseModule, DatabaseModule } from "@cv/system";
+import { AuthorizationModule } from "@cv/core";
+import { BaseModule, DatabaseModule } from "@cv/core";
import { Module } from "@nestjs/common";
import { AuthenticationModule } from "@/modules/authentication/authentication.module";
import { LevelService } from "@/modules/job-experience/level/level.service";
diff --git a/apps/server/src/modules/job-experience/level/level.policy.ts b/apps/api/src/modules/job-experience/level/level.policy.ts
similarity index 80%
rename from apps/server/src/modules/job-experience/level/level.policy.ts
rename to apps/api/src/modules/job-experience/level/level.policy.ts
index 4101686..c956ac9 100644
--- a/apps/server/src/modules/job-experience/level/level.policy.ts
+++ b/apps/api/src/modules/job-experience/level/level.policy.ts
@@ -1,4 +1,4 @@
-import { Policy, PublicResourcePolicy } from "@cv/auth";
+import { Policy, PublicResourcePolicy } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { Level } from "./level.entity";
diff --git a/apps/server/src/modules/job-experience/level/level.service.ts b/apps/api/src/modules/job-experience/level/level.service.ts
similarity index 85%
rename from apps/server/src/modules/job-experience/level/level.service.ts
rename to apps/api/src/modules/job-experience/level/level.service.ts
index 56dd311..2010294 100644
--- a/apps/server/src/modules/job-experience/level/level.service.ts
+++ b/apps/api/src/modules/job-experience/level/level.service.ts
@@ -1,4 +1,4 @@
-import { NamedEntityService, PrismaService } from "@cv/system";
+import { NamedEntityService, PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { Level } from "@/modules/job-experience/level/level.entity";
import { LevelMapper } from "./level.mapper";
diff --git a/apps/server/src/modules/job-experience/role/graphql/role-connection-args.type.ts b/apps/api/src/modules/job-experience/role/graphql/role-connection-args.type.ts
similarity index 83%
rename from apps/server/src/modules/job-experience/role/graphql/role-connection-args.type.ts
rename to apps/api/src/modules/job-experience/role/graphql/role-connection-args.type.ts
index fee2439..479f7ec 100644
--- a/apps/server/src/modules/job-experience/role/graphql/role-connection-args.type.ts
+++ b/apps/api/src/modules/job-experience/role/graphql/role-connection-args.type.ts
@@ -1,4 +1,4 @@
-import { BasePaginationArgs } from "@cv/system";
+import { BasePaginationArgs } from "@cv/core";
import { ArgsType, Field } from "@nestjs/graphql";
import { GraphQLString } from "graphql";
diff --git a/apps/server/src/modules/job-experience/role/graphql/role.resolver.ts b/apps/api/src/modules/job-experience/role/graphql/role.resolver.ts
similarity index 94%
rename from apps/server/src/modules/job-experience/role/graphql/role.resolver.ts
rename to apps/api/src/modules/job-experience/role/graphql/role.resolver.ts
index 4eeeacb..09255c1 100644
--- a/apps/server/src/modules/job-experience/role/graphql/role.resolver.ts
+++ b/apps/api/src/modules/job-experience/role/graphql/role.resolver.ts
@@ -1,10 +1,7 @@
-import type { User as DomainUser } from "@cv/auth";
-import {
- AuthorizationService,
- JwtAuthGuard,
- VerifiedScopeGuard,
-} from "@cv/auth";
-import { PaginationService } from "@cv/system";
+import type { User as DomainUser } from "@cv/core";
+import { AuthorizationService } from "@cv/core";
+import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
+import { PaginationService } from "@cv/core";
import { UseGuards } from "@nestjs/common";
import { Args, Mutation, Query, Resolver } from "@nestjs/graphql";
import { CurrentUser } from "@/modules/current-user/current-user.decorator";
diff --git a/apps/server/src/modules/job-experience/role/graphql/role.type.ts b/apps/api/src/modules/job-experience/role/graphql/role.type.ts
similarity index 100%
rename from apps/server/src/modules/job-experience/role/graphql/role.type.ts
rename to apps/api/src/modules/job-experience/role/graphql/role.type.ts
diff --git a/apps/server/src/modules/job-experience/role/role.dataloader.ts b/apps/api/src/modules/job-experience/role/role.dataloader.ts
similarity index 92%
rename from apps/server/src/modules/job-experience/role/role.dataloader.ts
rename to apps/api/src/modules/job-experience/role/role.dataloader.ts
index 57c9a4b..666d538 100644
--- a/apps/server/src/modules/job-experience/role/role.dataloader.ts
+++ b/apps/api/src/modules/job-experience/role/role.dataloader.ts
@@ -1,4 +1,4 @@
-import { BaseDataLoaderService } from "@cv/system";
+import { BaseDataLoaderService } from "@cv/core";
import { Injectable, Scope } from "@nestjs/common";
import { Role } from "./role.entity";
import { RoleService } from "./role.service";
diff --git a/apps/server/src/modules/job-experience/role/role.dto.ts b/apps/api/src/modules/job-experience/role/role.dto.ts
similarity index 100%
rename from apps/server/src/modules/job-experience/role/role.dto.ts
rename to apps/api/src/modules/job-experience/role/role.dto.ts
diff --git a/apps/server/src/modules/job-experience/role/role.entity.ts b/apps/api/src/modules/job-experience/role/role.entity.ts
similarity index 50%
rename from apps/server/src/modules/job-experience/role/role.entity.ts
rename to apps/api/src/modules/job-experience/role/role.entity.ts
index 51ff3eb..ef61b70 100644
--- a/apps/server/src/modules/job-experience/role/role.entity.ts
+++ b/apps/api/src/modules/job-experience/role/role.entity.ts
@@ -1,3 +1,3 @@
-import { NamedEntity } from "@cv/system";
+import { NamedEntity } from "@cv/core";
export class Role extends NamedEntity {}
diff --git a/apps/server/src/modules/job-experience/role/role.factory.ts b/apps/api/src/modules/job-experience/role/role.factory.ts
similarity index 88%
rename from apps/server/src/modules/job-experience/role/role.factory.ts
rename to apps/api/src/modules/job-experience/role/role.factory.ts
index a1a95f9..b9a9310 100644
--- a/apps/server/src/modules/job-experience/role/role.factory.ts
+++ b/apps/api/src/modules/job-experience/role/role.factory.ts
@@ -1,4 +1,4 @@
-import { ClockService, Factory, UuidFactoryService } from "@cv/system";
+import { ClockService, Factory, UuidFactoryService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import type { CreateRoleDto } from "./role.dto";
import { Role } from "./role.entity";
diff --git a/apps/server/src/modules/job-experience/role/role.mapper.ts b/apps/api/src/modules/job-experience/role/role.mapper.ts
similarity index 93%
rename from apps/server/src/modules/job-experience/role/role.mapper.ts
rename to apps/api/src/modules/job-experience/role/role.mapper.ts
index 6d53b80..f5eff4e 100644
--- a/apps/server/src/modules/job-experience/role/role.mapper.ts
+++ b/apps/api/src/modules/job-experience/role/role.mapper.ts
@@ -1,4 +1,4 @@
-import { createNamedEntityMapper } from "@cv/system";
+import { createNamedEntityMapper } from "@cv/core";
import { Injectable } from "@nestjs/common";
import type { Role as PrismaRole } from "@prisma/client";
import { Role } from "@/modules/job-experience/role/role.entity";
diff --git a/apps/server/src/modules/job-experience/role/role.module.ts b/apps/api/src/modules/job-experience/role/role.module.ts
similarity index 88%
rename from apps/server/src/modules/job-experience/role/role.module.ts
rename to apps/api/src/modules/job-experience/role/role.module.ts
index 75d825d..ecf55d5 100644
--- a/apps/server/src/modules/job-experience/role/role.module.ts
+++ b/apps/api/src/modules/job-experience/role/role.module.ts
@@ -1,5 +1,5 @@
-import { AuthorizationModule } from "@cv/auth";
-import { BaseModule, DatabaseModule } from "@cv/system";
+import { AuthorizationModule } from "@cv/core";
+import { BaseModule, DatabaseModule } from "@cv/core";
import { Module } from "@nestjs/common";
import { AuthenticationModule } from "@/modules/authentication/authentication.module";
import { RoleService } from "@/modules/job-experience/role/role.service";
diff --git a/apps/server/src/modules/job-experience/role/role.policy.ts b/apps/api/src/modules/job-experience/role/role.policy.ts
similarity index 79%
rename from apps/server/src/modules/job-experience/role/role.policy.ts
rename to apps/api/src/modules/job-experience/role/role.policy.ts
index ebd04a6..f761074 100644
--- a/apps/server/src/modules/job-experience/role/role.policy.ts
+++ b/apps/api/src/modules/job-experience/role/role.policy.ts
@@ -1,4 +1,4 @@
-import { Policy, PublicResourcePolicy } from "@cv/auth";
+import { Policy, PublicResourcePolicy } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { Role } from "./role.entity";
diff --git a/apps/server/src/modules/job-experience/role/role.service.ts b/apps/api/src/modules/job-experience/role/role.service.ts
similarity index 84%
rename from apps/server/src/modules/job-experience/role/role.service.ts
rename to apps/api/src/modules/job-experience/role/role.service.ts
index e16f8da..db20ee5 100644
--- a/apps/server/src/modules/job-experience/role/role.service.ts
+++ b/apps/api/src/modules/job-experience/role/role.service.ts
@@ -1,4 +1,4 @@
-import { NamedEntityService, PrismaService } from "@cv/system";
+import { NamedEntityService, PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { Role } from "@/modules/job-experience/role/role.entity";
import { RoleMapper } from "./role.mapper";
diff --git a/apps/server/src/modules/job-experience/seed/job-experience.seed.ts b/apps/api/src/modules/job-experience/seed/job-experience.seed.ts
similarity index 98%
rename from apps/server/src/modules/job-experience/seed/job-experience.seed.ts
rename to apps/api/src/modules/job-experience/seed/job-experience.seed.ts
index cae99d3..839cc75 100644
--- a/apps/server/src/modules/job-experience/seed/job-experience.seed.ts
+++ b/apps/api/src/modules/job-experience/seed/job-experience.seed.ts
@@ -1,4 +1,4 @@
-import { PrismaService } from "@cv/system";
+import { PrismaService } from "@cv/core";
import { faker } from "@faker-js/faker";
import { Injectable, Logger } from "@nestjs/common";
import { Seeder } from "@/modules/database/seed/seed.service";
diff --git a/apps/server/src/modules/job-experience/seed/reference-data.seed.ts b/apps/api/src/modules/job-experience/seed/reference-data.seed.ts
similarity index 80%
rename from apps/server/src/modules/job-experience/seed/reference-data.seed.ts
rename to apps/api/src/modules/job-experience/seed/reference-data.seed.ts
index dc7983d..f6e568a 100644
--- a/apps/server/src/modules/job-experience/seed/reference-data.seed.ts
+++ b/apps/api/src/modules/job-experience/seed/reference-data.seed.ts
@@ -1,4 +1,4 @@
-import { PrismaService } from "@cv/system";
+import { PrismaService } from "@cv/core";
import { faker } from "@faker-js/faker";
import { Injectable, Logger } from "@nestjs/common";
import { Seeder } from "@/modules/database/seed/seed.service";
@@ -20,6 +20,8 @@ export class ReferenceDataSeedService implements Seeder {
this.ensureLevels(prisma),
this.ensureJobTypes(prisma),
this.ensureApplicationStatuses(prisma),
+ this.ensureRateTypes(prisma),
+ this.ensureContractTypes(prisma),
]);
}
@@ -141,6 +143,36 @@ export class ReferenceDataSeedService implements Seeder {
);
}
+ async ensureRateTypes(
+ prisma: PrismaService = this.prisma,
+ ): Promise> {
+ const names = ["HOUR", "AGREEMENT", "FIXED", "DAY"];
+
+ return Promise.all(
+ names.map(async (name) => {
+ const existing = await prisma.rateType.findFirst({ where: { name } });
+ if (existing) return { id: existing.id };
+ const rt = await prisma.rateType.create({ data: { name } });
+ return { id: rt.id };
+ }),
+ );
+ }
+
+ async ensureContractTypes(
+ prisma: PrismaService = this.prisma,
+ ): Promise> {
+ const names = ["FREELANCE", "FIXED_TERM", "PERMANENT", "INTERNSHIP"];
+
+ return Promise.all(
+ names.map(async (name) => {
+ const existing = await prisma.contractType.findFirst({ where: { name } });
+ if (existing) return { id: existing.id };
+ const ct = await prisma.contractType.create({ data: { name } });
+ return { id: ct.id };
+ }),
+ );
+ }
+
async ensureApplicationStatuses(
prisma: PrismaService = this.prisma,
): Promise> {
diff --git a/apps/server/src/modules/job-experience/skill/graphql/skill-connection-args.type.ts b/apps/api/src/modules/job-experience/skill/graphql/skill-connection-args.type.ts
similarity index 69%
rename from apps/server/src/modules/job-experience/skill/graphql/skill-connection-args.type.ts
rename to apps/api/src/modules/job-experience/skill/graphql/skill-connection-args.type.ts
index 21a93c5..d9030c9 100644
--- a/apps/server/src/modules/job-experience/skill/graphql/skill-connection-args.type.ts
+++ b/apps/api/src/modules/job-experience/skill/graphql/skill-connection-args.type.ts
@@ -1,4 +1,4 @@
-import { SearchablePaginationArgs } from "@cv/system";
+import { SearchablePaginationArgs } from "@cv/core";
import { ArgsType } from "@nestjs/graphql";
@ArgsType()
diff --git a/apps/server/src/modules/job-experience/skill/graphql/skill.resolver.ts b/apps/api/src/modules/job-experience/skill/graphql/skill.resolver.ts
similarity index 94%
rename from apps/server/src/modules/job-experience/skill/graphql/skill.resolver.ts
rename to apps/api/src/modules/job-experience/skill/graphql/skill.resolver.ts
index 2750619..08678d6 100644
--- a/apps/server/src/modules/job-experience/skill/graphql/skill.resolver.ts
+++ b/apps/api/src/modules/job-experience/skill/graphql/skill.resolver.ts
@@ -1,10 +1,7 @@
-import type { User as DomainUser } from "@cv/auth";
-import {
- AuthorizationService,
- JwtAuthGuard,
- VerifiedScopeGuard,
-} from "@cv/auth";
-import { PaginationService } from "@cv/system";
+import type { User as DomainUser } from "@cv/core";
+import { AuthorizationService } from "@cv/core";
+import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
+import { PaginationService } from "@cv/core";
import { UseGuards } from "@nestjs/common";
import { Args, Mutation, Query, Resolver } from "@nestjs/graphql";
import { CurrentUser } from "@/modules/current-user/current-user.decorator";
diff --git a/apps/server/src/modules/job-experience/skill/graphql/skill.type.ts b/apps/api/src/modules/job-experience/skill/graphql/skill.type.ts
similarity index 100%
rename from apps/server/src/modules/job-experience/skill/graphql/skill.type.ts
rename to apps/api/src/modules/job-experience/skill/graphql/skill.type.ts
diff --git a/apps/server/src/modules/job-experience/skill/skill.dto.ts b/apps/api/src/modules/job-experience/skill/skill.dto.ts
similarity index 100%
rename from apps/server/src/modules/job-experience/skill/skill.dto.ts
rename to apps/api/src/modules/job-experience/skill/skill.dto.ts
diff --git a/apps/server/src/modules/job-experience/skill/skill.entity.ts b/apps/api/src/modules/job-experience/skill/skill.entity.ts
similarity index 50%
rename from apps/server/src/modules/job-experience/skill/skill.entity.ts
rename to apps/api/src/modules/job-experience/skill/skill.entity.ts
index 47c7f29..e53d8f9 100644
--- a/apps/server/src/modules/job-experience/skill/skill.entity.ts
+++ b/apps/api/src/modules/job-experience/skill/skill.entity.ts
@@ -1,3 +1,3 @@
-import { NamedEntity } from "@cv/system";
+import { NamedEntity } from "@cv/core";
export class Skill extends NamedEntity {}
diff --git a/apps/server/src/modules/job-experience/skill/skill.factory.ts b/apps/api/src/modules/job-experience/skill/skill.factory.ts
similarity index 88%
rename from apps/server/src/modules/job-experience/skill/skill.factory.ts
rename to apps/api/src/modules/job-experience/skill/skill.factory.ts
index 7efb996..8015db6 100644
--- a/apps/server/src/modules/job-experience/skill/skill.factory.ts
+++ b/apps/api/src/modules/job-experience/skill/skill.factory.ts
@@ -1,4 +1,4 @@
-import { ClockService, Factory, UuidFactoryService } from "@cv/system";
+import { ClockService, Factory, UuidFactoryService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import type { CreateSkillDto } from "./skill.dto";
import { Skill } from "./skill.entity";
diff --git a/apps/server/src/modules/job-experience/skill/skill.mapper.ts b/apps/api/src/modules/job-experience/skill/skill.mapper.ts
similarity index 93%
rename from apps/server/src/modules/job-experience/skill/skill.mapper.ts
rename to apps/api/src/modules/job-experience/skill/skill.mapper.ts
index 47f3463..c2c2428 100644
--- a/apps/server/src/modules/job-experience/skill/skill.mapper.ts
+++ b/apps/api/src/modules/job-experience/skill/skill.mapper.ts
@@ -1,4 +1,4 @@
-import { createNamedEntityMapper } from "@cv/system";
+import { createNamedEntityMapper } from "@cv/core";
import { Injectable } from "@nestjs/common";
import type { Skill as PrismaSkill } from "@prisma/client";
import { Skill } from "@/modules/job-experience/skill/skill.entity";
diff --git a/apps/server/src/modules/job-experience/skill/skill.module.ts b/apps/api/src/modules/job-experience/skill/skill.module.ts
similarity index 89%
rename from apps/server/src/modules/job-experience/skill/skill.module.ts
rename to apps/api/src/modules/job-experience/skill/skill.module.ts
index b23ff33..c6783f4 100644
--- a/apps/server/src/modules/job-experience/skill/skill.module.ts
+++ b/apps/api/src/modules/job-experience/skill/skill.module.ts
@@ -1,5 +1,5 @@
-import { AuthorizationModule } from "@cv/auth";
-import { BaseModule, DatabaseModule } from "@cv/system";
+import { AuthorizationModule } from "@cv/core";
+import { BaseModule, DatabaseModule } from "@cv/core";
import { Module } from "@nestjs/common";
import { AuthenticationModule } from "@/modules/authentication/authentication.module";
import { SkillService } from "@/modules/job-experience/skill/skill.service";
diff --git a/apps/server/src/modules/job-experience/skill/skill.policy.ts b/apps/api/src/modules/job-experience/skill/skill.policy.ts
similarity index 80%
rename from apps/server/src/modules/job-experience/skill/skill.policy.ts
rename to apps/api/src/modules/job-experience/skill/skill.policy.ts
index b9f77f0..8892702 100644
--- a/apps/server/src/modules/job-experience/skill/skill.policy.ts
+++ b/apps/api/src/modules/job-experience/skill/skill.policy.ts
@@ -1,4 +1,4 @@
-import { Policy, PublicResourcePolicy } from "@cv/auth";
+import { Policy, PublicResourcePolicy } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { Skill } from "./skill.entity";
diff --git a/apps/server/src/modules/job-experience/skill/skill.service.ts b/apps/api/src/modules/job-experience/skill/skill.service.ts
similarity index 97%
rename from apps/server/src/modules/job-experience/skill/skill.service.ts
rename to apps/api/src/modules/job-experience/skill/skill.service.ts
index 5918cb9..a48c69a 100644
--- a/apps/server/src/modules/job-experience/skill/skill.service.ts
+++ b/apps/api/src/modules/job-experience/skill/skill.service.ts
@@ -2,7 +2,7 @@ import {
type NamedEntityFilters,
NamedEntityService,
PrismaService,
-} from "@cv/system";
+} from "@cv/core";
import { Injectable } from "@nestjs/common";
import { Skill } from "@/modules/job-experience/skill/skill.entity";
import { SkillMapper } from "./skill.mapper";
diff --git a/apps/server/src/modules/messenger/logger.provider.ts b/apps/api/src/modules/messenger/logger.provider.ts
similarity index 100%
rename from apps/server/src/modules/messenger/logger.provider.ts
rename to apps/api/src/modules/messenger/logger.provider.ts
diff --git a/apps/server/src/modules/messenger/messages/render-pdf.message.ts b/apps/api/src/modules/messenger/messages/render-pdf.message.ts
similarity index 100%
rename from apps/server/src/modules/messenger/messages/render-pdf.message.ts
rename to apps/api/src/modules/messenger/messages/render-pdf.message.ts
diff --git a/apps/server/src/modules/messenger/messenger.module.ts b/apps/api/src/modules/messenger/messenger.module.ts
similarity index 100%
rename from apps/server/src/modules/messenger/messenger.module.ts
rename to apps/api/src/modules/messenger/messenger.module.ts
diff --git a/apps/server/src/modules/messenger/prisma-transport.module.ts b/apps/api/src/modules/messenger/prisma-transport.module.ts
similarity index 85%
rename from apps/server/src/modules/messenger/prisma-transport.module.ts
rename to apps/api/src/modules/messenger/prisma-transport.module.ts
index 24aa4b2..61f7c48 100644
--- a/apps/server/src/modules/messenger/prisma-transport.module.ts
+++ b/apps/api/src/modules/messenger/prisma-transport.module.ts
@@ -1,5 +1,5 @@
import { Module } from "@nestjs/common";
-import { DatabaseModule, PrismaService } from "@cv/system";
+import { DatabaseModule, PrismaService } from "@cv/core";
import { PrismaClientToken, PrismaTransportFactory } from "@riotbyte-com/project-q-prisma";
@Module({
diff --git a/apps/server/src/modules/onboarding/graphql/onboarding.resolver.ts b/apps/api/src/modules/onboarding/graphql/onboarding.resolver.ts
similarity index 94%
rename from apps/server/src/modules/onboarding/graphql/onboarding.resolver.ts
rename to apps/api/src/modules/onboarding/graphql/onboarding.resolver.ts
index 3723d44..61330cc 100644
--- a/apps/server/src/modules/onboarding/graphql/onboarding.resolver.ts
+++ b/apps/api/src/modules/onboarding/graphql/onboarding.resolver.ts
@@ -1,4 +1,4 @@
-import type { User as DomainUser } from "@cv/auth";
+import type { User as DomainUser } from "@cv/core";
import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
import { UseGuards } from "@nestjs/common";
import { Mutation, Query, Resolver } from "@nestjs/graphql";
diff --git a/apps/server/src/modules/onboarding/graphql/onboarding.type.ts b/apps/api/src/modules/onboarding/graphql/onboarding.type.ts
similarity index 100%
rename from apps/server/src/modules/onboarding/graphql/onboarding.type.ts
rename to apps/api/src/modules/onboarding/graphql/onboarding.type.ts
diff --git a/apps/server/src/modules/onboarding/onboarding-step.decorator.ts b/apps/api/src/modules/onboarding/onboarding-step.decorator.ts
similarity index 100%
rename from apps/server/src/modules/onboarding/onboarding-step.decorator.ts
rename to apps/api/src/modules/onboarding/onboarding-step.decorator.ts
diff --git a/apps/server/src/modules/onboarding/onboarding-step.interface.ts b/apps/api/src/modules/onboarding/onboarding-step.interface.ts
similarity index 88%
rename from apps/server/src/modules/onboarding/onboarding-step.interface.ts
rename to apps/api/src/modules/onboarding/onboarding-step.interface.ts
index d79388a..7969535 100644
--- a/apps/server/src/modules/onboarding/onboarding-step.interface.ts
+++ b/apps/api/src/modules/onboarding/onboarding-step.interface.ts
@@ -1,4 +1,4 @@
-import type { User } from "@cv/auth";
+import type { User } from "@cv/core";
export enum OnboardingStepStatus {
NOT_STARTED = "NOT_STARTED",
diff --git a/apps/server/src/modules/onboarding/onboarding.module.ts b/apps/api/src/modules/onboarding/onboarding.module.ts
similarity index 89%
rename from apps/server/src/modules/onboarding/onboarding.module.ts
rename to apps/api/src/modules/onboarding/onboarding.module.ts
index b6d52d3..4afdd3a 100644
--- a/apps/server/src/modules/onboarding/onboarding.module.ts
+++ b/apps/api/src/modules/onboarding/onboarding.module.ts
@@ -1,4 +1,4 @@
-import { DatabaseModule } from "@cv/system";
+import { DatabaseModule } from "@cv/core";
import { Module } from "@nestjs/common";
import { DiscoveryModule } from "@nestjs/core";
import { OnboardingResolver } from "./graphql/onboarding.resolver";
diff --git a/apps/server/src/modules/onboarding/onboarding.service.ts b/apps/api/src/modules/onboarding/onboarding.service.ts
similarity index 97%
rename from apps/server/src/modules/onboarding/onboarding.service.ts
rename to apps/api/src/modules/onboarding/onboarding.service.ts
index ca814f9..2f0cfd9 100644
--- a/apps/server/src/modules/onboarding/onboarding.service.ts
+++ b/apps/api/src/modules/onboarding/onboarding.service.ts
@@ -1,5 +1,5 @@
-import type { User } from "@cv/auth";
-import { PrismaService } from "@cv/system";
+import type { User } from "@cv/core";
+import { PrismaService } from "@cv/core";
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { DiscoveryService } from "@nestjs/core";
import { ONBOARDING_STEP_METADATA_KEY } from "./onboarding-step.decorator";
diff --git a/apps/server/src/modules/organization/graphql/membership-connection-args.type.ts b/apps/api/src/modules/organization/graphql/membership-connection-args.type.ts
similarity index 82%
rename from apps/server/src/modules/organization/graphql/membership-connection-args.type.ts
rename to apps/api/src/modules/organization/graphql/membership-connection-args.type.ts
index 300f5a0..17a9e57 100644
--- a/apps/server/src/modules/organization/graphql/membership-connection-args.type.ts
+++ b/apps/api/src/modules/organization/graphql/membership-connection-args.type.ts
@@ -1,4 +1,4 @@
-import { SortablePaginationArgs } from "@cv/system";
+import { SortablePaginationArgs } from "@cv/core";
import { ArgsType, Field } from "@nestjs/graphql";
@ArgsType()
diff --git a/apps/server/src/modules/organization/graphql/membership.type.ts b/apps/api/src/modules/organization/graphql/membership.type.ts
similarity index 98%
rename from apps/server/src/modules/organization/graphql/membership.type.ts
rename to apps/api/src/modules/organization/graphql/membership.type.ts
index 6eed774..41ec9e7 100644
--- a/apps/server/src/modules/organization/graphql/membership.type.ts
+++ b/apps/api/src/modules/organization/graphql/membership.type.ts
@@ -1,4 +1,4 @@
-import { UserRole } from "@cv/auth";
+import { UserRole } from "@cv/core";
import { Field, ObjectType } from "@nestjs/graphql";
import { createConnection } from "@/modules/base/connection.factory";
import { OrganizationRole } from "@/modules/organization/organization-role.entity";
diff --git a/apps/server/src/modules/organization/graphql/organization.resolver.ts b/apps/api/src/modules/organization/graphql/organization.resolver.ts
similarity index 96%
rename from apps/server/src/modules/organization/graphql/organization.resolver.ts
rename to apps/api/src/modules/organization/graphql/organization.resolver.ts
index 852d08f..e66b053 100644
--- a/apps/server/src/modules/organization/graphql/organization.resolver.ts
+++ b/apps/api/src/modules/organization/graphql/organization.resolver.ts
@@ -1,11 +1,7 @@
-import type { User as DomainUser } from "@cv/auth";
-import {
- AuthorizationService,
- JwtAuthGuard,
- UserService,
- VerifiedScopeGuard,
-} from "@cv/auth";
-import { PaginationService } from "@cv/system";
+import type { User as DomainUser } from "@cv/core";
+import { AuthorizationService, UserService } from "@cv/core";
+import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
+import { PaginationService } from "@cv/core";
import { UseGuards } from "@nestjs/common";
import {
Args,
diff --git a/apps/server/src/modules/organization/graphql/user-field.resolver.ts b/apps/api/src/modules/organization/graphql/user-field.resolver.ts
similarity index 100%
rename from apps/server/src/modules/organization/graphql/user-field.resolver.ts
rename to apps/api/src/modules/organization/graphql/user-field.resolver.ts
diff --git a/apps/server/src/modules/organization/organization-role.entity.ts b/apps/api/src/modules/organization/organization-role.entity.ts
similarity index 96%
rename from apps/server/src/modules/organization/organization-role.entity.ts
rename to apps/api/src/modules/organization/organization-role.entity.ts
index fc523e0..44930b9 100644
--- a/apps/server/src/modules/organization/organization-role.entity.ts
+++ b/apps/api/src/modules/organization/organization-role.entity.ts
@@ -1,4 +1,4 @@
-import { BaseEntity } from "@cv/system";
+import { BaseEntity } from "@cv/core";
import { Field, ID, ObjectType } from "@nestjs/graphql";
@ObjectType()
diff --git a/apps/server/src/modules/organization/organization-role.mapper.ts b/apps/api/src/modules/organization/organization-role.mapper.ts
similarity index 95%
rename from apps/server/src/modules/organization/organization-role.mapper.ts
rename to apps/api/src/modules/organization/organization-role.mapper.ts
index 6a3130d..77b1e09 100644
--- a/apps/server/src/modules/organization/organization-role.mapper.ts
+++ b/apps/api/src/modules/organization/organization-role.mapper.ts
@@ -1,4 +1,4 @@
-import type { BaseMapper } from "@cv/system";
+import type { BaseMapper } from "@cv/core";
import { Injectable } from "@nestjs/common";
import type { OrganizationRole as PrismaOrganizationRole } from "@prisma/client";
import { OrganizationRole } from "./organization-role.entity";
diff --git a/apps/server/src/modules/organization/organization-role.policy.ts b/apps/api/src/modules/organization/organization-role.policy.ts
similarity index 80%
rename from apps/server/src/modules/organization/organization-role.policy.ts
rename to apps/api/src/modules/organization/organization-role.policy.ts
index 3e8f726..200480c 100644
--- a/apps/server/src/modules/organization/organization-role.policy.ts
+++ b/apps/api/src/modules/organization/organization-role.policy.ts
@@ -1,4 +1,4 @@
-import { Policy, PublicResourcePolicy } from "@cv/auth";
+import { Policy, PublicResourcePolicy } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { OrganizationRole } from "./organization-role.entity";
diff --git a/apps/server/src/modules/organization/organization-role.service.ts b/apps/api/src/modules/organization/organization-role.service.ts
similarity index 96%
rename from apps/server/src/modules/organization/organization-role.service.ts
rename to apps/api/src/modules/organization/organization-role.service.ts
index 2d45e94..769f4f6 100644
--- a/apps/server/src/modules/organization/organization-role.service.ts
+++ b/apps/api/src/modules/organization/organization-role.service.ts
@@ -1,5 +1,5 @@
-import { notFound } from "@cv/auth";
-import { PrismaService } from "@cv/system";
+import { notFound } from "@cv/core";
+import { PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { OrganizationRole } from "./organization-role.entity";
import { OrganizationRoleMapper } from "./organization-role.mapper";
diff --git a/apps/server/src/modules/organization/organization.dto.ts b/apps/api/src/modules/organization/organization.dto.ts
similarity index 100%
rename from apps/server/src/modules/organization/organization.dto.ts
rename to apps/api/src/modules/organization/organization.dto.ts
diff --git a/apps/server/src/modules/organization/organization.entity.ts b/apps/api/src/modules/organization/organization.entity.ts
similarity index 95%
rename from apps/server/src/modules/organization/organization.entity.ts
rename to apps/api/src/modules/organization/organization.entity.ts
index 927a1f1..bcbbfb0 100644
--- a/apps/server/src/modules/organization/organization.entity.ts
+++ b/apps/api/src/modules/organization/organization.entity.ts
@@ -1,4 +1,4 @@
-import { BaseEntity } from "@cv/system";
+import { BaseEntity } from "@cv/core";
import { Field, ID, ObjectType } from "@nestjs/graphql";
@ObjectType()
diff --git a/apps/server/src/modules/organization/organization.factory.ts b/apps/api/src/modules/organization/organization.factory.ts
similarity index 89%
rename from apps/server/src/modules/organization/organization.factory.ts
rename to apps/api/src/modules/organization/organization.factory.ts
index a1b03f4..7a4cc02 100644
--- a/apps/server/src/modules/organization/organization.factory.ts
+++ b/apps/api/src/modules/organization/organization.factory.ts
@@ -1,4 +1,4 @@
-import { ClockService, Factory, UuidFactoryService } from "@cv/system";
+import { ClockService, Factory, UuidFactoryService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import type { CreateOrganizationDto } from "./organization.dto";
import { Organization } from "./organization.entity";
diff --git a/apps/server/src/modules/organization/organization.mapper.ts b/apps/api/src/modules/organization/organization.mapper.ts
similarity index 95%
rename from apps/server/src/modules/organization/organization.mapper.ts
rename to apps/api/src/modules/organization/organization.mapper.ts
index d0b22b0..7f02093 100644
--- a/apps/server/src/modules/organization/organization.mapper.ts
+++ b/apps/api/src/modules/organization/organization.mapper.ts
@@ -1,4 +1,4 @@
-import type { BaseMapper } from "@cv/system";
+import type { BaseMapper } from "@cv/core";
import { Injectable } from "@nestjs/common";
import type { Organization as PrismaOrganization } from "@prisma/client";
import { Organization } from "./organization.entity";
diff --git a/apps/server/src/modules/organization/organization.module.ts b/apps/api/src/modules/organization/organization.module.ts
similarity index 93%
rename from apps/server/src/modules/organization/organization.module.ts
rename to apps/api/src/modules/organization/organization.module.ts
index d99afd3..02352c0 100644
--- a/apps/server/src/modules/organization/organization.module.ts
+++ b/apps/api/src/modules/organization/organization.module.ts
@@ -1,5 +1,5 @@
-import { AuthorizationModule } from "@cv/auth";
-import { BaseModule, DatabaseModule } from "@cv/system";
+import { AuthorizationModule } from "@cv/core";
+import { BaseModule, DatabaseModule } from "@cv/core";
import { Module } from "@nestjs/common";
import { AuthenticationModule } from "@/modules/authentication/authentication.module";
import { OrganizationResolver } from "./graphql/organization.resolver";
diff --git a/apps/server/src/modules/organization/organization.policy.ts b/apps/api/src/modules/organization/organization.policy.ts
similarity index 82%
rename from apps/server/src/modules/organization/organization.policy.ts
rename to apps/api/src/modules/organization/organization.policy.ts
index cdb1a85..e686bff 100644
--- a/apps/server/src/modules/organization/organization.policy.ts
+++ b/apps/api/src/modules/organization/organization.policy.ts
@@ -1,4 +1,4 @@
-import { Policy, PublicResourcePolicy } from "@cv/auth";
+import { Policy, PublicResourcePolicy } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { Organization } from "./organization.entity";
diff --git a/apps/server/src/modules/organization/organization.service.ts b/apps/api/src/modules/organization/organization.service.ts
similarity index 97%
rename from apps/server/src/modules/organization/organization.service.ts
rename to apps/api/src/modules/organization/organization.service.ts
index 1825c3f..968d9a0 100644
--- a/apps/server/src/modules/organization/organization.service.ts
+++ b/apps/api/src/modules/organization/organization.service.ts
@@ -1,5 +1,5 @@
-import { notFound, User } from "@cv/auth";
-import { type EntityService, PrismaService } from "@cv/system";
+import { notFound, User } from "@cv/core";
+import { type EntityService, PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import type { Prisma } from "@prisma/client";
import { Organization } from "./organization.entity";
diff --git a/apps/server/src/modules/organization/seed/membership.seed.ts b/apps/api/src/modules/organization/seed/membership.seed.ts
similarity index 98%
rename from apps/server/src/modules/organization/seed/membership.seed.ts
rename to apps/api/src/modules/organization/seed/membership.seed.ts
index 335fad1..39dc260 100644
--- a/apps/server/src/modules/organization/seed/membership.seed.ts
+++ b/apps/api/src/modules/organization/seed/membership.seed.ts
@@ -1,4 +1,4 @@
-import { PrismaService } from "@cv/system";
+import { PrismaService } from "@cv/core";
import { faker } from "@faker-js/faker";
import { Injectable, Logger } from "@nestjs/common";
import { Seeder } from "@/modules/database/seed/seed.service";
diff --git a/apps/server/src/modules/organization/seed/organization.seed.ts b/apps/api/src/modules/organization/seed/organization.seed.ts
similarity index 97%
rename from apps/server/src/modules/organization/seed/organization.seed.ts
rename to apps/api/src/modules/organization/seed/organization.seed.ts
index b3843e0..9e2f65e 100644
--- a/apps/server/src/modules/organization/seed/organization.seed.ts
+++ b/apps/api/src/modules/organization/seed/organization.seed.ts
@@ -1,4 +1,4 @@
-import { PrismaService } from "@cv/system";
+import { PrismaService } from "@cv/core";
import { faker } from "@faker-js/faker";
import { Injectable, Logger } from "@nestjs/common";
import { Seeder } from "@/modules/database/seed/seed.service";
diff --git a/apps/server/src/modules/profile/graphql/profile-field.resolver.ts b/apps/api/src/modules/profile/graphql/profile-field.resolver.ts
similarity index 97%
rename from apps/server/src/modules/profile/graphql/profile-field.resolver.ts
rename to apps/api/src/modules/profile/graphql/profile-field.resolver.ts
index 3eccaad..8ff070c 100644
--- a/apps/server/src/modules/profile/graphql/profile-field.resolver.ts
+++ b/apps/api/src/modules/profile/graphql/profile-field.resolver.ts
@@ -1,5 +1,5 @@
import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
-import { PaginationArgs, PaginationService } from "@cv/system";
+import { PaginationArgs, PaginationService } from "@cv/core";
import { UseGuards } from "@nestjs/common";
import { Args, Parent, ResolveField, Resolver } from "@nestjs/graphql";
import { CVService } from "@/modules/cv-template/cv.service";
diff --git a/apps/server/src/modules/profile/graphql/profile.resolver.ts b/apps/api/src/modules/profile/graphql/profile.resolver.ts
similarity index 97%
rename from apps/server/src/modules/profile/graphql/profile.resolver.ts
rename to apps/api/src/modules/profile/graphql/profile.resolver.ts
index 6b8ccb3..f916287 100644
--- a/apps/server/src/modules/profile/graphql/profile.resolver.ts
+++ b/apps/api/src/modules/profile/graphql/profile.resolver.ts
@@ -1,4 +1,4 @@
-import type { User as DomainUser } from "@cv/auth";
+import type { User as DomainUser } from "@cv/core";
import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
import { UseGuards } from "@nestjs/common";
import { Args, ID, Mutation, Query, Resolver } from "@nestjs/graphql";
diff --git a/apps/server/src/modules/profile/graphql/profile.type.ts b/apps/api/src/modules/profile/graphql/profile.type.ts
similarity index 87%
rename from apps/server/src/modules/profile/graphql/profile.type.ts
rename to apps/api/src/modules/profile/graphql/profile.type.ts
index c36500e..6b7650b 100644
--- a/apps/server/src/modules/profile/graphql/profile.type.ts
+++ b/apps/api/src/modules/profile/graphql/profile.type.ts
@@ -23,11 +23,7 @@ export class ProfileType {
@Field(() => String, { nullable: true })
postalCode!: string | null;
- @Field(() => String, { nullable: true })
- city!: string | null;
-
- @Field(() => String, { nullable: true })
- country!: string | null;
+ locationId!: string | null;
@Field(() => String, { nullable: true })
website!: string | null;
@@ -66,10 +62,7 @@ export class CreateProfileInput {
postalCode?: string;
@Field(() => String, { nullable: true })
- city?: string;
-
- @Field(() => String, { nullable: true })
- country?: string;
+ locationId?: string;
@Field(() => String, { nullable: true })
website?: string;
@@ -102,10 +95,7 @@ export class UpdateProfileInput {
postalCode?: string;
@Field(() => String, { nullable: true })
- city?: string;
-
- @Field(() => String, { nullable: true })
- country?: string;
+ locationId?: string;
@Field(() => String, { nullable: true })
website?: string;
diff --git a/apps/server/src/modules/profile/onboarding/profile.step.ts b/apps/api/src/modules/profile/onboarding/profile.step.ts
similarity index 81%
rename from apps/server/src/modules/profile/onboarding/profile.step.ts
rename to apps/api/src/modules/profile/onboarding/profile.step.ts
index 611b064..31a32c5 100644
--- a/apps/server/src/modules/profile/onboarding/profile.step.ts
+++ b/apps/api/src/modules/profile/onboarding/profile.step.ts
@@ -1,5 +1,5 @@
-import type { User } from "@cv/auth";
-import { PrismaService } from "@cv/system";
+import type { User } from "@cv/core";
+import { PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { OnboardingStep } from "@/modules/onboarding/onboarding-step.decorator";
import {
@@ -19,12 +19,12 @@ export class ProfileOnboardingStep implements OnboardingStepDefinition {
async computeStatus(user: User): Promise {
const profile = await this.prisma.profile.findFirst({
where: { userId: user.id },
- select: { headline: true, city: true },
+ select: { headline: true, locationId: true },
});
if (!profile) return OnboardingStepStatus.NOT_STARTED;
- return profile.headline && profile.city
+ return profile.headline && profile.locationId
? OnboardingStepStatus.COMPLETE
: OnboardingStepStatus.IN_PROGRESS;
}
diff --git a/apps/server/src/modules/profile/profile.entity.ts b/apps/api/src/modules/profile/profile.entity.ts
similarity index 72%
rename from apps/server/src/modules/profile/profile.entity.ts
rename to apps/api/src/modules/profile/profile.entity.ts
index 12e0b24..d5f7724 100644
--- a/apps/server/src/modules/profile/profile.entity.ts
+++ b/apps/api/src/modules/profile/profile.entity.ts
@@ -1,10 +1,11 @@
-import { BaseEntity } from "@cv/system";
+import { BaseEntity } from "@cv/core";
export class ProfileEntity extends BaseEntity {
constructor(
id: string,
public readonly userId: string,
public readonly name: string,
+ public readonly locationId: string | undefined,
createdAt: Date,
updatedAt: Date,
) {
diff --git a/apps/server/src/modules/profile/profile.module.ts b/apps/api/src/modules/profile/profile.module.ts
similarity index 94%
rename from apps/server/src/modules/profile/profile.module.ts
rename to apps/api/src/modules/profile/profile.module.ts
index e2915fa..da43346 100644
--- a/apps/server/src/modules/profile/profile.module.ts
+++ b/apps/api/src/modules/profile/profile.module.ts
@@ -1,5 +1,5 @@
import { AuthModule } from "@cv/auth";
-import { BaseModule, DatabaseModule } from "@cv/system";
+import { BaseModule, DatabaseModule } from "@cv/core";
import { Module, forwardRef } from "@nestjs/common";
import { CVTemplateModule } from "@/modules/cv-template/cv-template.module";
import { EducationModule } from "@/modules/education/education.module";
diff --git a/apps/server/src/modules/profile/profile.policy.ts b/apps/api/src/modules/profile/profile.policy.ts
similarity index 77%
rename from apps/server/src/modules/profile/profile.policy.ts
rename to apps/api/src/modules/profile/profile.policy.ts
index 3d49bca..2670a40 100644
--- a/apps/server/src/modules/profile/profile.policy.ts
+++ b/apps/api/src/modules/profile/profile.policy.ts
@@ -1,4 +1,4 @@
-import { Policy, UserOwnedResourcePolicy } from "@cv/auth";
+import { Policy, UserOwnedResourcePolicy } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { ProfileEntity } from "./profile.entity";
diff --git a/apps/server/src/modules/profile/profile.service.ts b/apps/api/src/modules/profile/profile.service.ts
similarity index 90%
rename from apps/server/src/modules/profile/profile.service.ts
rename to apps/api/src/modules/profile/profile.service.ts
index 86c7e3f..bca9fb2 100644
--- a/apps/server/src/modules/profile/profile.service.ts
+++ b/apps/api/src/modules/profile/profile.service.ts
@@ -1,6 +1,6 @@
-import type { User } from "@cv/auth";
-import { TokenEncryptionService } from "@cv/auth";
-import { PrismaService } from "@cv/system";
+import type { User } from "@cv/core";
+import { TokenEncryptionService } from "@cv/core";
+import { PrismaService } from "@cv/core";
import { Injectable, NotFoundException } from "@nestjs/common";
interface CreateProfileInput {
@@ -10,8 +10,7 @@ interface CreateProfileInput {
phone?: string;
address?: string;
postalCode?: string;
- city?: string;
- country?: string;
+ locationId?: string;
website?: string;
linkedInUrl?: string;
summary?: string;
@@ -24,8 +23,7 @@ interface UpdateProfileInput {
phone?: string;
address?: string;
postalCode?: string;
- city?: string;
- country?: string;
+ locationId?: string;
website?: string;
linkedInUrl?: string;
summary?: string;
@@ -40,8 +38,7 @@ export interface DecryptedProfile {
phone: string | null;
address: string | null;
postalCode: string | null;
- city: string | null;
- country: string | null;
+ locationId: string | null;
website: string | null;
linkedInUrl: string | null;
summary: string | null;
@@ -144,10 +141,10 @@ export class ProfileService {
async hasKeyFields(userId: string): Promise {
const profile = await this.prisma.profile.findFirst({
where: { userId },
- select: { headline: true, city: true },
+ select: { headline: true, locationId: true },
});
- return profile !== null && profile.headline !== null && profile.city !== null;
+ return profile !== null && profile.headline !== null && profile.locationId !== null;
}
private decrypt(profile: {
@@ -159,8 +156,7 @@ export class ProfileService {
encryptedPhone: string | null;
encryptedAddress: string | null;
encryptedPostalCode: string | null;
- city: string | null;
- country: string | null;
+ locationId: string | null;
website: string | null;
linkedInUrl: string | null;
summary: string | null;
@@ -182,8 +178,7 @@ export class ProfileService {
postalCode: profile.encryptedPostalCode
? this.encryption.decrypt(profile.encryptedPostalCode)
: null,
- city: profile.city,
- country: profile.country,
+ locationId: profile.locationId,
website: profile.website,
linkedInUrl: profile.linkedInUrl,
summary: profile.summary,
@@ -199,8 +194,8 @@ export class ProfileService {
if (data.fullName !== undefined) result["fullName"] = data.fullName || null;
if (data.headline !== undefined) result["headline"] = data.headline || null;
- if (data.city !== undefined) result["city"] = data.city || null;
- if (data.country !== undefined) result["country"] = data.country || null;
+ if (data.locationId !== undefined)
+ result["locationId"] = data.locationId || null;
if (data.website !== undefined) result["website"] = data.website || null;
if (data.linkedInUrl !== undefined)
result["linkedInUrl"] = data.linkedInUrl || null;
diff --git a/apps/server/src/modules/user-settings/onboarding/ai-preference.step.ts b/apps/api/src/modules/user-settings/onboarding/ai-preference.step.ts
similarity index 90%
rename from apps/server/src/modules/user-settings/onboarding/ai-preference.step.ts
rename to apps/api/src/modules/user-settings/onboarding/ai-preference.step.ts
index acc48d7..fe60d5c 100644
--- a/apps/server/src/modules/user-settings/onboarding/ai-preference.step.ts
+++ b/apps/api/src/modules/user-settings/onboarding/ai-preference.step.ts
@@ -1,5 +1,5 @@
-import type { User } from "@cv/auth";
-import { PrismaService } from "@cv/system";
+import type { User } from "@cv/core";
+import { PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { OnboardingStep } from "@/modules/onboarding/onboarding-step.decorator";
import {
diff --git a/apps/server/src/modules/user-settings/user-ai-settings.module.ts b/apps/api/src/modules/user-settings/user-ai-settings.module.ts
similarity index 93%
rename from apps/server/src/modules/user-settings/user-ai-settings.module.ts
rename to apps/api/src/modules/user-settings/user-ai-settings.module.ts
index d44da77..97a6467 100644
--- a/apps/server/src/modules/user-settings/user-ai-settings.module.ts
+++ b/apps/api/src/modules/user-settings/user-ai-settings.module.ts
@@ -1,5 +1,5 @@
import { AIModule, type AIProviderType } from "@cv/ai-provider";
-import { DatabaseModule } from "@cv/system";
+import { DatabaseModule } from "@cv/core";
import { Module } from "@nestjs/common";
import { AiPreferenceOnboardingStep } from "./onboarding/ai-preference.step";
import { UserAiSettingsResolver } from "./user-ai-settings.resolver";
diff --git a/apps/server/src/modules/user-settings/user-ai-settings.resolver.ts b/apps/api/src/modules/user-settings/user-ai-settings.resolver.ts
similarity index 99%
rename from apps/server/src/modules/user-settings/user-ai-settings.resolver.ts
rename to apps/api/src/modules/user-settings/user-ai-settings.resolver.ts
index 72139a4..63460a8 100644
--- a/apps/server/src/modules/user-settings/user-ai-settings.resolver.ts
+++ b/apps/api/src/modules/user-settings/user-ai-settings.resolver.ts
@@ -1,6 +1,6 @@
import type { AIProvider } from "@cv/ai-provider";
import { AI_PROVIDER, registeredProviderTypes } from "@cv/ai-provider";
-import type { User as DomainUser } from "@cv/auth";
+import type { User as DomainUser } from "@cv/core";
import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
import { Inject, Optional, UseGuards } from "@nestjs/common";
import { Args, Mutation, Query, Resolver } from "@nestjs/graphql";
diff --git a/apps/server/src/modules/user-settings/user-ai-settings.service.ts b/apps/api/src/modules/user-settings/user-ai-settings.service.ts
similarity index 97%
rename from apps/server/src/modules/user-settings/user-ai-settings.service.ts
rename to apps/api/src/modules/user-settings/user-ai-settings.service.ts
index 13eaf20..1e0decb 100644
--- a/apps/server/src/modules/user-settings/user-ai-settings.service.ts
+++ b/apps/api/src/modules/user-settings/user-ai-settings.service.ts
@@ -1,6 +1,6 @@
-import type { User } from "@cv/auth";
-import { TokenEncryptionService } from "@cv/auth";
-import { PrismaService } from "@cv/system";
+import type { User } from "@cv/core";
+import { TokenEncryptionService } from "@cv/core";
+import { PrismaService } from "@cv/core";
import {
BadRequestException,
Injectable,
diff --git a/apps/server/src/modules/user-settings/user-ai-settings.type.ts b/apps/api/src/modules/user-settings/user-ai-settings.type.ts
similarity index 100%
rename from apps/server/src/modules/user-settings/user-ai-settings.type.ts
rename to apps/api/src/modules/user-settings/user-ai-settings.type.ts
diff --git a/apps/server/src/modules/user/seed/user.seed.ts b/apps/api/src/modules/user/seed/user.seed.ts
similarity index 97%
rename from apps/server/src/modules/user/seed/user.seed.ts
rename to apps/api/src/modules/user/seed/user.seed.ts
index 5b46afa..e609900 100644
--- a/apps/server/src/modules/user/seed/user.seed.ts
+++ b/apps/api/src/modules/user/seed/user.seed.ts
@@ -1,5 +1,5 @@
-import { CredentialsService, UserService } from "@cv/auth";
-import { PrismaService } from "@cv/system";
+import { CredentialsService, UserService } from "@cv/core";
+import { PrismaService } from "@cv/core";
import { Injectable, Logger } from "@nestjs/common";
import { Seeder } from "@/modules/database/seed/seed.service";
import { Seeder as SeederDecorator } from "@/modules/database/seed/seeder.decorator";
diff --git a/apps/server/src/modules/user/user.type.ts b/apps/api/src/modules/user/user.type.ts
similarity index 96%
rename from apps/server/src/modules/user/user.type.ts
rename to apps/api/src/modules/user/user.type.ts
index 4595e93..da6a416 100644
--- a/apps/server/src/modules/user/user.type.ts
+++ b/apps/api/src/modules/user/user.type.ts
@@ -1,5 +1,5 @@
-import type { User as DomainUser } from "@cv/auth";
-import { UserRole } from "@cv/auth";
+import type { User as DomainUser } from "@cv/core";
+import { UserRole } from "@cv/core";
import { Field, ID, ObjectType, registerEnumType } from "@nestjs/graphql";
registerEnumType(UserRole, { name: "UserRole" });
diff --git a/apps/api/src/modules/vacancies/contract-type/contract-type.dataloader.ts b/apps/api/src/modules/vacancies/contract-type/contract-type.dataloader.ts
new file mode 100644
index 0000000..77cbf40
--- /dev/null
+++ b/apps/api/src/modules/vacancies/contract-type/contract-type.dataloader.ts
@@ -0,0 +1,15 @@
+import { BaseDataLoaderService } from "@cv/core";
+import { Injectable, Scope } from "@nestjs/common";
+import { ContractType } from "./contract-type.entity";
+import { ContractTypeService } from "./contract-type.service";
+
+@Injectable({ scope: Scope.REQUEST })
+export class ContractTypeDataLoaderService extends BaseDataLoaderService {
+ constructor(service: ContractTypeService) {
+ super(async (ids: readonly string[]) => {
+ const items = await service.findMany({ id: [...ids] });
+ const map = new Map(items.map((item) => [item.id, item]));
+ return ids.map((id) => map.get(id) ?? null);
+ });
+ }
+}
diff --git a/apps/api/src/modules/vacancies/contract-type/contract-type.entity.ts b/apps/api/src/modules/vacancies/contract-type/contract-type.entity.ts
new file mode 100644
index 0000000..eec964c
--- /dev/null
+++ b/apps/api/src/modules/vacancies/contract-type/contract-type.entity.ts
@@ -0,0 +1,3 @@
+import { NamedEntity } from "@cv/core";
+
+export class ContractType extends NamedEntity {}
diff --git a/apps/api/src/modules/vacancies/contract-type/contract-type.mapper.ts b/apps/api/src/modules/vacancies/contract-type/contract-type.mapper.ts
new file mode 100644
index 0000000..74c1a23
--- /dev/null
+++ b/apps/api/src/modules/vacancies/contract-type/contract-type.mapper.ts
@@ -0,0 +1,20 @@
+import { createNamedEntityMapper } from "@cv/core";
+import { Injectable } from "@nestjs/common";
+import type { ContractType as PrismaContractType } from "@prisma/client";
+import { ContractType } from "./contract-type.entity";
+
+@Injectable()
+export class ContractTypeMapper {
+ private mapper = createNamedEntityMapper(ContractType);
+
+ toDomain(prisma: null): null;
+ toDomain(prisma: PrismaContractType): ContractType;
+ toDomain(prisma: PrismaContractType | null): ContractType | null;
+ toDomain(prisma: PrismaContractType | null): ContractType | null {
+ return this.mapper.toDomain(prisma);
+ }
+
+ mapToDomain(prisma: PrismaContractType[]): ContractType[] {
+ return this.mapper.mapToDomain(prisma);
+ }
+}
diff --git a/apps/api/src/modules/vacancies/contract-type/contract-type.module.ts b/apps/api/src/modules/vacancies/contract-type/contract-type.module.ts
new file mode 100644
index 0000000..70c411d
--- /dev/null
+++ b/apps/api/src/modules/vacancies/contract-type/contract-type.module.ts
@@ -0,0 +1,12 @@
+import { DatabaseModule } from "@cv/core";
+import { Module } from "@nestjs/common";
+import { ContractTypeDataLoaderService } from "./contract-type.dataloader";
+import { ContractTypeMapper } from "./contract-type.mapper";
+import { ContractTypeService } from "./contract-type.service";
+
+@Module({
+ imports: [DatabaseModule],
+ providers: [ContractTypeService, ContractTypeMapper, ContractTypeDataLoaderService],
+ exports: [ContractTypeService, ContractTypeDataLoaderService],
+})
+export class ContractTypeModule {}
diff --git a/apps/api/src/modules/vacancies/contract-type/contract-type.service.ts b/apps/api/src/modules/vacancies/contract-type/contract-type.service.ts
new file mode 100644
index 0000000..c6ae232
--- /dev/null
+++ b/apps/api/src/modules/vacancies/contract-type/contract-type.service.ts
@@ -0,0 +1,11 @@
+import { NamedEntityService, PrismaService } from "@cv/core";
+import { Injectable } from "@nestjs/common";
+import { ContractType } from "./contract-type.entity";
+import { ContractTypeMapper } from "./contract-type.mapper";
+
+@Injectable()
+export class ContractTypeService extends NamedEntityService {
+ constructor(prisma: PrismaService, mapper: ContractTypeMapper) {
+ super(prisma, mapper, prisma.contractType, "ContractType");
+ }
+}
diff --git a/apps/api/src/modules/vacancies/contract-type/graphql/contract-type.type.ts b/apps/api/src/modules/vacancies/contract-type/graphql/contract-type.type.ts
new file mode 100644
index 0000000..2f10eff
--- /dev/null
+++ b/apps/api/src/modules/vacancies/contract-type/graphql/contract-type.type.ts
@@ -0,0 +1,13 @@
+import { createNamedGraphQLType } from "@/modules/base/named-graphql-type.factory";
+import { ContractType as ContractTypeDomain } from "../contract-type.entity";
+
+const { Type, Connection, Edge } = createNamedGraphQLType("ContractType", ContractTypeDomain);
+
+export const ContractType = Type;
+export type ContractType = InstanceType;
+
+export const ContractTypeConnection = Connection;
+export type ContractTypeConnection = InstanceType;
+
+export const ContractTypeEdge = Edge;
+export type ContractTypeEdge = InstanceType;
diff --git a/apps/server/src/modules/vacancies/graphql/user-field.resolver.ts b/apps/api/src/modules/vacancies/graphql/user-field.resolver.ts
similarity index 95%
rename from apps/server/src/modules/vacancies/graphql/user-field.resolver.ts
rename to apps/api/src/modules/vacancies/graphql/user-field.resolver.ts
index a7a8625..07e5aa9 100644
--- a/apps/server/src/modules/vacancies/graphql/user-field.resolver.ts
+++ b/apps/api/src/modules/vacancies/graphql/user-field.resolver.ts
@@ -1,5 +1,5 @@
import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
-import { PaginationArgs, PaginationService } from "@cv/system";
+import { PaginationArgs, PaginationService } from "@cv/core";
import { UseGuards } from "@nestjs/common";
import { Args, Parent, ResolveField, Resolver } from "@nestjs/graphql";
import { User } from "@/modules/user/user.type";
diff --git a/apps/server/src/modules/vacancies/graphql/vacancy-filter.input.ts b/apps/api/src/modules/vacancies/graphql/vacancy-filter.input.ts
similarity index 100%
rename from apps/server/src/modules/vacancies/graphql/vacancy-filter.input.ts
rename to apps/api/src/modules/vacancies/graphql/vacancy-filter.input.ts
diff --git a/apps/server/src/modules/vacancies/graphql/vacancy.resolver.ts b/apps/api/src/modules/vacancies/graphql/vacancy.resolver.ts
similarity index 68%
rename from apps/server/src/modules/vacancies/graphql/vacancy.resolver.ts
rename to apps/api/src/modules/vacancies/graphql/vacancy.resolver.ts
index 03b0621..34da282 100644
--- a/apps/server/src/modules/vacancies/graphql/vacancy.resolver.ts
+++ b/apps/api/src/modules/vacancies/graphql/vacancy.resolver.ts
@@ -1,9 +1,6 @@
-import type { User as DomainUser } from "@cv/auth";
-import {
- AuthorizationService,
- JwtAuthGuard,
- VerifiedScopeGuard,
-} from "@cv/auth";
+import type { User as DomainUser } from "@cv/core";
+import { AuthorizationService } from "@cv/core";
+import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth";
import { UseGuards } from "@nestjs/common";
import {
Args,
@@ -12,6 +9,7 @@ import {
ResolveField,
Resolver,
} from "@nestjs/graphql";
+import { compact } from "@cv/utils";
import { CurrentUser } from "@/modules/current-user/current-user.decorator";
import { CompanyDataLoaderService } from "@/modules/job-experience/company/company.dataloader";
import { Company } from "@/modules/job-experience/company/graphql/company.type";
@@ -21,6 +19,12 @@ import { Role } from "@/modules/job-experience/role/graphql/role.type";
import { RoleDataLoaderService } from "@/modules/job-experience/role/role.dataloader";
import { Skill } from "@/modules/job-experience/skill/graphql/skill.type";
import { SkillService } from "@/modules/job-experience/skill/skill.service";
+import { ContractTypeDataLoaderService } from "@/modules/vacancies/contract-type/contract-type.dataloader";
+import { ContractType } from "@/modules/vacancies/contract-type/graphql/contract-type.type";
+import { LocationDataLoaderService } from "@/modules/vacancies/location/location.dataloader";
+import { Location } from "@/modules/vacancies/location/graphql/location.type";
+import { RateTypeDataLoaderService } from "@/modules/vacancies/rate-type/rate-type.dataloader";
+import { RateType } from "@/modules/vacancies/rate-type/graphql/rate-type.type";
import { Vacancy as VacancyEntity } from "@/modules/vacancies/vacancy.entity";
import { VacancyFactory } from "@/modules/vacancies/vacancy.factory";
import { VacancyService } from "@/modules/vacancies/vacancy.service";
@@ -37,6 +41,9 @@ export class VacancyResolver {
private readonly levelDataLoader: LevelDataLoaderService,
private readonly skillService: SkillService,
private readonly authorizationService: AuthorizationService,
+ private readonly rateTypeDataLoader: RateTypeDataLoaderService,
+ private readonly contractTypeDataLoader: ContractTypeDataLoaderService,
+ private readonly locationDataLoader: LocationDataLoaderService,
) {}
@Mutation(() => Vacancy)
@@ -49,7 +56,7 @@ export class VacancyResolver {
@Args("jobTypeId", { nullable: true }) jobTypeId?: string,
@Args("description", { nullable: true }) description?: string,
@Args("requirements", { nullable: true }) requirements?: string,
- @Args("location", { nullable: true }) location?: string,
+ @Args("locationId", { nullable: true }) locationId?: string,
@Args("minSalary", { nullable: true }) minSalary?: number,
@Args("maxSalary", { nullable: true }) maxSalary?: number,
@Args("applicationUrl", { nullable: true }) applicationUrl?: string,
@@ -79,16 +86,18 @@ export class VacancyResolver {
company,
role,
level,
- ...(jobTypeId !== undefined && { jobTypeId }),
- ...(description !== undefined && { description }),
- ...(requirements !== undefined && { requirements }),
- ...(location !== undefined && { location }),
- ...(minSalary !== undefined && { minSalary }),
- ...(maxSalary !== undefined && { maxSalary }),
- ...(applicationUrl !== undefined && { applicationUrl }),
- ...(deadline !== undefined && { deadline }),
- ...(isActive !== undefined && { isActive }),
- ...(isPublic !== undefined && { isPublic }),
+ ...compact({
+ jobTypeId,
+ description,
+ requirements,
+ locationId,
+ minSalary,
+ maxSalary,
+ applicationUrl,
+ deadline,
+ isActive,
+ isPublic,
+ }),
};
const entity = this.vacancyFactory.create({
@@ -142,4 +151,25 @@ export class VacancyResolver {
const skills = await this.skillService.findMany({ vacancyId: id });
return skills.map((skill) => Skill.fromDomain(skill));
}
+
+ @ResolveField(() => RateType, { nullable: true })
+ async rateType(@Parent() vacancy: Vacancy): Promise {
+ if (!vacancy.rateTypeId) return null;
+ const rateType = await this.rateTypeDataLoader.load(vacancy.rateTypeId);
+ return rateType ? RateType.fromDomain(rateType) : null;
+ }
+
+ @ResolveField(() => ContractType, { nullable: true })
+ async contractType(@Parent() vacancy: Vacancy): Promise {
+ if (!vacancy.contractTypeId) return null;
+ const contractType = await this.contractTypeDataLoader.load(vacancy.contractTypeId);
+ return contractType ? ContractType.fromDomain(contractType) : null;
+ }
+
+ @ResolveField(() => Location, { nullable: true })
+ async location(@Parent() vacancy: Vacancy): Promise {
+ if (!vacancy.locationId) return null;
+ const location = await this.locationDataLoader.load(vacancy.locationId);
+ return location ? Location.fromDomain(location) : null;
+ }
}
diff --git a/apps/api/src/modules/vacancies/graphql/vacancy.type.ts b/apps/api/src/modules/vacancies/graphql/vacancy.type.ts
new file mode 100644
index 0000000..e2a883c
--- /dev/null
+++ b/apps/api/src/modules/vacancies/graphql/vacancy.type.ts
@@ -0,0 +1,80 @@
+import { Field, ObjectType } from "@nestjs/graphql";
+import { BaseGraphQLType } from "@/modules/base/base-graphql-type";
+import { createConnection } from "@/modules/base/connection.factory";
+import type { Vacancy as DomainVacancy } from "@/modules/vacancies/vacancy.entity";
+import { Vacancy as VacancyEntity } from "@/modules/vacancies/vacancy.entity";
+
+@ObjectType()
+export class Vacancy extends BaseGraphQLType {
+ @Field()
+ title: string;
+
+ @Field()
+ ownerId: string;
+
+ @Field(() => String, { nullable: true })
+ description: string | null;
+
+ @Field(() => String, { nullable: true })
+ requirements: string | null;
+
+ @Field(() => Number, { nullable: true })
+ minSalary: number | null;
+
+ @Field(() => Number, { nullable: true })
+ maxSalary: number | null;
+
+ @Field(() => String, { nullable: true })
+ applicationUrl: string | null;
+
+ @Field(() => Date, { nullable: true })
+ deadline: Date | null;
+
+ @Field()
+ isActive: boolean;
+
+ @Field()
+ isPublic: boolean;
+
+ // Internal FK IDs — not exposed in GraphQL, used by field resolvers
+ companyId: string;
+ roleId: string;
+ levelId: string | null;
+ jobTypeId: string | null;
+ rateTypeId: string | null;
+ contractTypeId: string | null;
+ locationId: string | null;
+
+ constructor(domain: DomainVacancy) {
+ super(domain.id, domain.createdAt, domain.updatedAt);
+ this.title = domain.title;
+ this.ownerId = domain.ownerId;
+ this.companyId = domain.company.id;
+ this.roleId = domain.role.id;
+ this.levelId = domain.level?.id ?? null;
+ this.jobTypeId = domain.jobTypeId ?? null;
+ this.rateTypeId = domain.rateTypeId ?? null;
+ this.contractTypeId = domain.contractTypeId ?? null;
+ this.locationId = domain.locationId ?? null;
+ this.description = domain.description ?? null;
+ this.requirements = domain.requirements ?? null;
+ this.minSalary = domain.minSalary ?? null;
+ this.maxSalary = domain.maxSalary ?? null;
+ this.applicationUrl = domain.applicationUrl ?? null;
+ this.deadline = domain.deadline ?? null;
+ this.isActive = domain.isActive;
+ this.isPublic = domain.isPublic;
+ }
+
+ static fromDomain(domain: DomainVacancy): Vacancy {
+ return new Vacancy(domain);
+ }
+}
+
+export const { Connection: VacancyConnection, Edge: VacancyEdge } =
+ createConnection(Vacancy, (domain) =>
+ Vacancy.fromDomain(domain),
+ );
+
+export type VacancyConnection = InstanceType;
+export type VacancyEdge = InstanceType;
diff --git a/apps/server/src/modules/vacancies/job-type/job-type.dto.ts b/apps/api/src/modules/vacancies/job-type/job-type.dto.ts
similarity index 100%
rename from apps/server/src/modules/vacancies/job-type/job-type.dto.ts
rename to apps/api/src/modules/vacancies/job-type/job-type.dto.ts
diff --git a/apps/server/src/modules/vacancies/job-type/job-type.entity.ts b/apps/api/src/modules/vacancies/job-type/job-type.entity.ts
similarity index 51%
rename from apps/server/src/modules/vacancies/job-type/job-type.entity.ts
rename to apps/api/src/modules/vacancies/job-type/job-type.entity.ts
index c7e535e..90d5d9a 100644
--- a/apps/server/src/modules/vacancies/job-type/job-type.entity.ts
+++ b/apps/api/src/modules/vacancies/job-type/job-type.entity.ts
@@ -1,3 +1,3 @@
-import { NamedEntity } from "@cv/system";
+import { NamedEntity } from "@cv/core";
export class JobType extends NamedEntity {}
diff --git a/apps/server/src/modules/vacancies/job-type/job-type.factory.ts b/apps/api/src/modules/vacancies/job-type/job-type.factory.ts
similarity index 88%
rename from apps/server/src/modules/vacancies/job-type/job-type.factory.ts
rename to apps/api/src/modules/vacancies/job-type/job-type.factory.ts
index 1e826da..6fdf187 100644
--- a/apps/server/src/modules/vacancies/job-type/job-type.factory.ts
+++ b/apps/api/src/modules/vacancies/job-type/job-type.factory.ts
@@ -1,4 +1,4 @@
-import { ClockService, Factory, UuidFactoryService } from "@cv/system";
+import { ClockService, Factory, UuidFactoryService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import type { CreateJobTypeDto } from "./job-type.dto";
import { JobType } from "./job-type.entity";
diff --git a/apps/server/src/modules/vacancies/job-type/job-type.mapper.ts b/apps/api/src/modules/vacancies/job-type/job-type.mapper.ts
similarity index 92%
rename from apps/server/src/modules/vacancies/job-type/job-type.mapper.ts
rename to apps/api/src/modules/vacancies/job-type/job-type.mapper.ts
index 1ca61c0..e0316f4 100644
--- a/apps/server/src/modules/vacancies/job-type/job-type.mapper.ts
+++ b/apps/api/src/modules/vacancies/job-type/job-type.mapper.ts
@@ -1,4 +1,4 @@
-import { createNamedEntityMapper } from "@cv/system";
+import { createNamedEntityMapper } from "@cv/core";
import { Injectable } from "@nestjs/common";
import type { JobType as PrismaJobType } from "@prisma/client";
import { JobType } from "./job-type.entity";
diff --git a/apps/server/src/modules/vacancies/job-type/job-type.module.ts b/apps/api/src/modules/vacancies/job-type/job-type.module.ts
similarity index 87%
rename from apps/server/src/modules/vacancies/job-type/job-type.module.ts
rename to apps/api/src/modules/vacancies/job-type/job-type.module.ts
index acee13a..9f5ca4d 100644
--- a/apps/server/src/modules/vacancies/job-type/job-type.module.ts
+++ b/apps/api/src/modules/vacancies/job-type/job-type.module.ts
@@ -1,4 +1,4 @@
-import { BaseModule, DatabaseModule } from "@cv/system";
+import { BaseModule, DatabaseModule } from "@cv/core";
import { Module } from "@nestjs/common";
import { JobTypeFactory } from "./job-type.factory";
import { JobTypeMapper } from "./job-type.mapper";
diff --git a/apps/server/src/modules/vacancies/job-type/job-type.service.ts b/apps/api/src/modules/vacancies/job-type/job-type.service.ts
similarity index 84%
rename from apps/server/src/modules/vacancies/job-type/job-type.service.ts
rename to apps/api/src/modules/vacancies/job-type/job-type.service.ts
index 6aeed1a..316f60c 100644
--- a/apps/server/src/modules/vacancies/job-type/job-type.service.ts
+++ b/apps/api/src/modules/vacancies/job-type/job-type.service.ts
@@ -1,4 +1,4 @@
-import { NamedEntityService, PrismaService } from "@cv/system";
+import { NamedEntityService, PrismaService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { JobType } from "./job-type.entity";
import { JobTypeMapper } from "./job-type.mapper";
diff --git a/apps/api/src/modules/vacancies/location/graphql/location.type.ts b/apps/api/src/modules/vacancies/location/graphql/location.type.ts
new file mode 100644
index 0000000..25d14a9
--- /dev/null
+++ b/apps/api/src/modules/vacancies/location/graphql/location.type.ts
@@ -0,0 +1,35 @@
+import { Field, ID, ObjectType, registerEnumType } from "@nestjs/graphql";
+import { BaseGraphQLType } from "@/modules/base/base-graphql-type";
+import {
+ LocationType,
+ type Location as LocationDomain,
+} from "../location.entity";
+
+registerEnumType(LocationType, { name: "LocationType" });
+
+@ObjectType()
+export class Location extends BaseGraphQLType {
+ @Field()
+ name: string;
+
+ @Field(() => LocationType)
+ type: LocationType;
+
+ @Field(() => String, { nullable: true })
+ code?: string | undefined;
+
+ @Field(() => ID, { nullable: true })
+ parentId?: string | undefined;
+
+ constructor(domain: LocationDomain) {
+ super(domain.id, domain.createdAt, domain.updatedAt);
+ this.name = domain.name;
+ this.type = domain.type;
+ this.code = domain.code;
+ this.parentId = domain.parentId;
+ }
+
+ static fromDomain(domain: LocationDomain): Location {
+ return new Location(domain);
+ }
+}
diff --git a/apps/api/src/modules/vacancies/location/location.dataloader.ts b/apps/api/src/modules/vacancies/location/location.dataloader.ts
new file mode 100644
index 0000000..6a4bd60
--- /dev/null
+++ b/apps/api/src/modules/vacancies/location/location.dataloader.ts
@@ -0,0 +1,15 @@
+import { BaseDataLoaderService } from "@cv/core";
+import { Injectable, Scope } from "@nestjs/common";
+import { Location } from "./location.entity";
+import { LocationService } from "./location.service";
+
+@Injectable({ scope: Scope.REQUEST })
+export class LocationDataLoaderService extends BaseDataLoaderService {
+ constructor(service: LocationService) {
+ super(async (ids: readonly string[]) => {
+ const items = await service.findMany({ id: [...ids] });
+ const map = new Map(items.map((item) => [item.id, item]));
+ return ids.map((id) => map.get(id) ?? null);
+ });
+ }
+}
diff --git a/apps/api/src/modules/vacancies/location/location.entity.ts b/apps/api/src/modules/vacancies/location/location.entity.ts
new file mode 100644
index 0000000..8dc395d
--- /dev/null
+++ b/apps/api/src/modules/vacancies/location/location.entity.ts
@@ -0,0 +1,33 @@
+import { BaseEntity } from "@cv/core";
+
+export const LocationType = {
+ COUNTRY: "COUNTRY",
+ PROVINCE: "PROVINCE",
+ CITY: "CITY",
+ DISTRICT: "DISTRICT",
+} as const;
+
+export type LocationType = (typeof LocationType)[keyof typeof LocationType];
+
+export class Location extends BaseEntity {
+ name: string;
+ type: LocationType;
+ code?: string | undefined;
+ parentId?: string | undefined;
+
+ constructor(
+ id: string,
+ name: string,
+ type: LocationType,
+ createdAt: Date,
+ updatedAt: Date,
+ code?: string,
+ parentId?: string,
+ ) {
+ super(id, createdAt, updatedAt);
+ this.name = name;
+ this.type = type;
+ this.code = code;
+ this.parentId = parentId;
+ }
+}
diff --git a/apps/api/src/modules/vacancies/location/location.mapper.ts b/apps/api/src/modules/vacancies/location/location.mapper.ts
new file mode 100644
index 0000000..2c93931
--- /dev/null
+++ b/apps/api/src/modules/vacancies/location/location.mapper.ts
@@ -0,0 +1,26 @@
+import { Injectable } from "@nestjs/common";
+import type { Location as PrismaLocation } from "@prisma/client";
+import { Location } from "./location.entity";
+
+@Injectable()
+export class LocationMapper {
+ toDomain(prisma: null): null;
+ toDomain(prisma: PrismaLocation): Location;
+ toDomain(prisma: PrismaLocation | null): Location | null;
+ toDomain(prisma: PrismaLocation | null): Location | null {
+ if (!prisma) return null;
+ return new Location(
+ prisma.id,
+ prisma.name,
+ prisma.type,
+ prisma.createdAt,
+ prisma.updatedAt,
+ prisma.code ?? undefined,
+ prisma.parentId ?? undefined,
+ );
+ }
+
+ mapToDomain(items: PrismaLocation[]): Location[] {
+ return items.map((item) => this.toDomain(item)).filter((item): item is Location => item !== null);
+ }
+}
diff --git a/apps/api/src/modules/vacancies/location/location.module.ts b/apps/api/src/modules/vacancies/location/location.module.ts
new file mode 100644
index 0000000..498e21d
--- /dev/null
+++ b/apps/api/src/modules/vacancies/location/location.module.ts
@@ -0,0 +1,12 @@
+import { DatabaseModule } from "@cv/core";
+import { Module } from "@nestjs/common";
+import { LocationDataLoaderService } from "./location.dataloader";
+import { LocationMapper } from "./location.mapper";
+import { LocationService } from "./location.service";
+
+@Module({
+ imports: [DatabaseModule],
+ providers: [LocationService, LocationMapper, LocationDataLoaderService],
+ exports: [LocationService, LocationDataLoaderService],
+})
+export class LocationModule {}
diff --git a/apps/api/src/modules/vacancies/location/location.service.ts b/apps/api/src/modules/vacancies/location/location.service.ts
new file mode 100644
index 0000000..6179308
--- /dev/null
+++ b/apps/api/src/modules/vacancies/location/location.service.ts
@@ -0,0 +1,23 @@
+import { PrismaService } from "@cv/core";
+import { Injectable } from "@nestjs/common";
+import { Location } from "./location.entity";
+import { LocationMapper } from "./location.mapper";
+
+@Injectable()
+export class LocationService {
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly mapper: LocationMapper,
+ ) {}
+
+ async findById(id: string): Promise {
+ const location = await this.prisma.location.findUnique({ where: { id } });
+ return this.mapper.toDomain(location);
+ }
+
+ async findMany(filter?: { id?: string[] }): Promise {
+ const where = filter?.id ? { id: { in: filter.id } } : {};
+ const items = await this.prisma.location.findMany({ where });
+ return this.mapper.mapToDomain(items);
+ }
+}
diff --git a/apps/api/src/modules/vacancies/rate-type/graphql/rate-type.type.ts b/apps/api/src/modules/vacancies/rate-type/graphql/rate-type.type.ts
new file mode 100644
index 0000000..015cfb5
--- /dev/null
+++ b/apps/api/src/modules/vacancies/rate-type/graphql/rate-type.type.ts
@@ -0,0 +1,13 @@
+import { createNamedGraphQLType } from "@/modules/base/named-graphql-type.factory";
+import { RateType as RateTypeDomain } from "../rate-type.entity";
+
+const { Type, Connection, Edge } = createNamedGraphQLType("RateType", RateTypeDomain);
+
+export const RateType = Type;
+export type RateType = InstanceType;
+
+export const RateTypeConnection = Connection;
+export type RateTypeConnection = InstanceType;
+
+export const RateTypeEdge = Edge;
+export type RateTypeEdge = InstanceType;
diff --git a/apps/api/src/modules/vacancies/rate-type/rate-type.dataloader.ts b/apps/api/src/modules/vacancies/rate-type/rate-type.dataloader.ts
new file mode 100644
index 0000000..15b7622
--- /dev/null
+++ b/apps/api/src/modules/vacancies/rate-type/rate-type.dataloader.ts
@@ -0,0 +1,15 @@
+import { BaseDataLoaderService } from "@cv/core";
+import { Injectable, Scope } from "@nestjs/common";
+import { RateType } from "./rate-type.entity";
+import { RateTypeService } from "./rate-type.service";
+
+@Injectable({ scope: Scope.REQUEST })
+export class RateTypeDataLoaderService extends BaseDataLoaderService {
+ constructor(service: RateTypeService) {
+ super(async (ids: readonly string[]) => {
+ const items = await service.findMany({ id: [...ids] });
+ const map = new Map(items.map((item) => [item.id, item]));
+ return ids.map((id) => map.get(id) ?? null);
+ });
+ }
+}
diff --git a/apps/api/src/modules/vacancies/rate-type/rate-type.entity.ts b/apps/api/src/modules/vacancies/rate-type/rate-type.entity.ts
new file mode 100644
index 0000000..9b5ae55
--- /dev/null
+++ b/apps/api/src/modules/vacancies/rate-type/rate-type.entity.ts
@@ -0,0 +1,3 @@
+import { NamedEntity } from "@cv/core";
+
+export class RateType extends NamedEntity {}
diff --git a/apps/api/src/modules/vacancies/rate-type/rate-type.mapper.ts b/apps/api/src/modules/vacancies/rate-type/rate-type.mapper.ts
new file mode 100644
index 0000000..3a990a0
--- /dev/null
+++ b/apps/api/src/modules/vacancies/rate-type/rate-type.mapper.ts
@@ -0,0 +1,20 @@
+import { createNamedEntityMapper } from "@cv/core";
+import { Injectable } from "@nestjs/common";
+import type { RateType as PrismaRateType } from "@prisma/client";
+import { RateType } from "./rate-type.entity";
+
+@Injectable()
+export class RateTypeMapper {
+ private mapper = createNamedEntityMapper(RateType);
+
+ toDomain(prisma: null): null;
+ toDomain(prisma: PrismaRateType): RateType;
+ toDomain(prisma: PrismaRateType | null): RateType | null;
+ toDomain(prisma: PrismaRateType | null): RateType | null {
+ return this.mapper.toDomain(prisma);
+ }
+
+ mapToDomain(prisma: PrismaRateType[]): RateType[] {
+ return this.mapper.mapToDomain(prisma);
+ }
+}
diff --git a/apps/api/src/modules/vacancies/rate-type/rate-type.module.ts b/apps/api/src/modules/vacancies/rate-type/rate-type.module.ts
new file mode 100644
index 0000000..6fd4e07
--- /dev/null
+++ b/apps/api/src/modules/vacancies/rate-type/rate-type.module.ts
@@ -0,0 +1,12 @@
+import { DatabaseModule } from "@cv/core";
+import { Module } from "@nestjs/common";
+import { RateTypeDataLoaderService } from "./rate-type.dataloader";
+import { RateTypeMapper } from "./rate-type.mapper";
+import { RateTypeService } from "./rate-type.service";
+
+@Module({
+ imports: [DatabaseModule],
+ providers: [RateTypeService, RateTypeMapper, RateTypeDataLoaderService],
+ exports: [RateTypeService, RateTypeDataLoaderService],
+})
+export class RateTypeModule {}
diff --git a/apps/api/src/modules/vacancies/rate-type/rate-type.service.ts b/apps/api/src/modules/vacancies/rate-type/rate-type.service.ts
new file mode 100644
index 0000000..e657c2c
--- /dev/null
+++ b/apps/api/src/modules/vacancies/rate-type/rate-type.service.ts
@@ -0,0 +1,11 @@
+import { NamedEntityService, PrismaService } from "@cv/core";
+import { Injectable } from "@nestjs/common";
+import { RateType } from "./rate-type.entity";
+import { RateTypeMapper } from "./rate-type.mapper";
+
+@Injectable()
+export class RateTypeService extends NamedEntityService {
+ constructor(prisma: PrismaService, mapper: RateTypeMapper) {
+ super(prisma, mapper, prisma.rateType, "RateType");
+ }
+}
diff --git a/apps/server/src/modules/vacancies/seed/vacancy.seed.ts b/apps/api/src/modules/vacancies/seed/vacancy.seed.ts
similarity index 97%
rename from apps/server/src/modules/vacancies/seed/vacancy.seed.ts
rename to apps/api/src/modules/vacancies/seed/vacancy.seed.ts
index fca875c..1b476eb 100644
--- a/apps/server/src/modules/vacancies/seed/vacancy.seed.ts
+++ b/apps/api/src/modules/vacancies/seed/vacancy.seed.ts
@@ -1,4 +1,4 @@
-import { PrismaService } from "@cv/system";
+import { PrismaService } from "@cv/core";
import { faker } from "@faker-js/faker";
import { Injectable, Logger } from "@nestjs/common";
import { Seeder } from "@/modules/database/seed/seed.service";
@@ -66,7 +66,6 @@ export class VacancySeedService implements Seeder {
jobTypeId: jobType.id,
description: faker.lorem.paragraphs(3),
requirements: faker.lorem.paragraphs(2),
- location: faker.location.city(),
minSalary,
maxSalary,
applicationUrl: faker.internet.url(),
diff --git a/apps/server/src/modules/vacancies/vacancy.dataloader.ts b/apps/api/src/modules/vacancies/vacancy.dataloader.ts
similarity index 92%
rename from apps/server/src/modules/vacancies/vacancy.dataloader.ts
rename to apps/api/src/modules/vacancies/vacancy.dataloader.ts
index a92a44c..43d7c00 100644
--- a/apps/server/src/modules/vacancies/vacancy.dataloader.ts
+++ b/apps/api/src/modules/vacancies/vacancy.dataloader.ts
@@ -1,4 +1,4 @@
-import { BaseDataLoaderService } from "@cv/system";
+import { BaseDataLoaderService } from "@cv/core";
import { Injectable, Scope } from "@nestjs/common";
import { Vacancy } from "./vacancy.entity";
import { VacancyService } from "./vacancy.service";
diff --git a/apps/server/src/modules/vacancies/vacancy.entity.ts b/apps/api/src/modules/vacancies/vacancy.entity.ts
similarity index 73%
rename from apps/server/src/modules/vacancies/vacancy.entity.ts
rename to apps/api/src/modules/vacancies/vacancy.entity.ts
index 1f31de7..1bc90b3 100644
--- a/apps/server/src/modules/vacancies/vacancy.entity.ts
+++ b/apps/api/src/modules/vacancies/vacancy.entity.ts
@@ -1,4 +1,4 @@
-import { BaseEntity } from "@cv/system";
+import { BaseEntity } from "@cv/core";
import { Company } from "@/modules/job-experience/company/company.entity";
import { Level } from "@/modules/job-experience/level/level.entity";
import { Role } from "@/modules/job-experience/role/role.entity";
@@ -11,15 +11,27 @@ export class Vacancy extends BaseEntity {
level: Level | null;
jobTypeId?: string;
description?: string;
- requirements?: string;
- location?: string;
- minSalary?: number;
- maxSalary?: number;
- applicationUrl?: string;
- deadline?: Date;
+ requirements?: string | undefined;
+ minSalary?: number | undefined;
+ maxSalary?: number | undefined;
+ applicationUrl?: string | undefined;
+ deadline?: Date | undefined;
+ hoursFrom?: number | undefined;
+ hoursTo?: number | undefined;
isActive: boolean;
isPublic: boolean;
+ // Entity FKs
+ rateTypeId?: string | undefined;
+ contractTypeId?: string | undefined;
+ locationId?: string | undefined;
+
+ // External import fields
+ externalId?: string | undefined;
+ source?: string | undefined;
+ externalApplyUrl?: string | undefined;
+ publishedAt?: Date | undefined;
+
constructor(
id: string,
title: string,
@@ -32,7 +44,7 @@ export class Vacancy extends BaseEntity {
jobTypeId?: string,
description?: string,
requirements?: string,
- location?: string,
+ _location?: string,
minSalary?: number,
maxSalary?: number,
applicationUrl?: string,
@@ -58,9 +70,6 @@ export class Vacancy extends BaseEntity {
if (requirements !== undefined) {
this.requirements = requirements;
}
- if (location !== undefined) {
- this.location = location;
- }
if (minSalary !== undefined) {
this.minSalary = minSalary;
}
diff --git a/apps/server/src/modules/vacancies/vacancy.factory.ts b/apps/api/src/modules/vacancies/vacancy.factory.ts
similarity index 92%
rename from apps/server/src/modules/vacancies/vacancy.factory.ts
rename to apps/api/src/modules/vacancies/vacancy.factory.ts
index 8c32002..8e876d7 100644
--- a/apps/server/src/modules/vacancies/vacancy.factory.ts
+++ b/apps/api/src/modules/vacancies/vacancy.factory.ts
@@ -1,4 +1,4 @@
-import { ClockService, Factory, UuidFactoryService } from "@cv/system";
+import { ClockService, Factory, UuidFactoryService } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { Company } from "@/modules/job-experience/company/company.entity";
import { Level } from "@/modules/job-experience/level/level.entity";
@@ -14,7 +14,7 @@ export interface CreateVacancyDto {
jobTypeId?: string;
description?: string;
requirements?: string;
- location?: string;
+ locationId?: string;
minSalary?: number;
maxSalary?: number;
applicationUrl?: string;
@@ -44,7 +44,7 @@ export class VacancyFactory implements Factory {
createVacancyDto.jobTypeId,
createVacancyDto.description,
createVacancyDto.requirements,
- createVacancyDto.location,
+ undefined,
createVacancyDto.minSalary,
createVacancyDto.maxSalary,
createVacancyDto.applicationUrl,
diff --git a/apps/server/src/modules/vacancies/vacancy.mapper.ts b/apps/api/src/modules/vacancies/vacancy.mapper.ts
similarity index 75%
rename from apps/server/src/modules/vacancies/vacancy.mapper.ts
rename to apps/api/src/modules/vacancies/vacancy.mapper.ts
index 1a6a17d..f1841b0 100644
--- a/apps/server/src/modules/vacancies/vacancy.mapper.ts
+++ b/apps/api/src/modules/vacancies/vacancy.mapper.ts
@@ -42,7 +42,7 @@ export class VacancyMapper {
? this.levelMapper.toDomain(prismaVacancy.level)
: null;
- return new Vacancy(
+ const vacancy = new Vacancy(
prismaVacancy.id,
prismaVacancy.title,
prismaVacancy.ownerId,
@@ -54,7 +54,7 @@ export class VacancyMapper {
prismaVacancy.jobTypeId ?? undefined,
prismaVacancy.description ?? undefined,
prismaVacancy.requirements ?? undefined,
- prismaVacancy.location ?? undefined,
+ undefined,
prismaVacancy.minSalary ?? undefined,
prismaVacancy.maxSalary ?? undefined,
prismaVacancy.applicationUrl ?? undefined,
@@ -62,6 +62,18 @@ export class VacancyMapper {
prismaVacancy.isActive,
prismaVacancy.isPublic,
);
+
+ vacancy.hoursFrom = prismaVacancy.hoursFrom ?? undefined;
+ vacancy.hoursTo = prismaVacancy.hoursTo ?? undefined;
+ vacancy.rateTypeId = prismaVacancy.rateTypeId ?? undefined;
+ vacancy.contractTypeId = prismaVacancy.contractTypeId ?? undefined;
+ vacancy.locationId = prismaVacancy.locationId ?? undefined;
+ vacancy.externalId = prismaVacancy.externalId ?? undefined;
+ vacancy.source = prismaVacancy.source ?? undefined;
+ vacancy.externalApplyUrl = prismaVacancy.externalApplyUrl ?? undefined;
+ vacancy.publishedAt = prismaVacancy.publishedAt ?? undefined;
+
+ return vacancy;
}
mapToDomain(prismaVacancies: PrismaVacancyWithRelations[]): Vacancy[] {
diff --git a/apps/server/src/modules/vacancies/vacancy.module.ts b/apps/api/src/modules/vacancies/vacancy.module.ts
similarity index 79%
rename from apps/server/src/modules/vacancies/vacancy.module.ts
rename to apps/api/src/modules/vacancies/vacancy.module.ts
index 5e6f45e..e8620ca 100644
--- a/apps/server/src/modules/vacancies/vacancy.module.ts
+++ b/apps/api/src/modules/vacancies/vacancy.module.ts
@@ -1,13 +1,16 @@
-import { AuthorizationModule } from "@cv/auth";
-import { BaseModule, DatabaseModule } from "@cv/system";
+import { AuthorizationModule } from "@cv/core";
+import { BaseModule, DatabaseModule } from "@cv/core";
import { Module } from "@nestjs/common";
import { AuthenticationModule } from "@/modules/authentication/authentication.module";
import { CompanyModule } from "@/modules/job-experience/company/company.module";
import { LevelModule } from "@/modules/job-experience/level/level.module";
import { RoleModule } from "@/modules/job-experience/role/role.module";
import { SkillModule } from "@/modules/job-experience/skill/skill.module";
+import { ContractTypeModule } from "./contract-type/contract-type.module";
import { VacancyUserFieldResolver } from "./graphql/user-field.resolver";
import { VacancyResolver } from "./graphql/vacancy.resolver";
+import { LocationModule } from "./location/location.module";
+import { RateTypeModule } from "./rate-type/rate-type.module";
import { VacancySeedService } from "./seed/vacancy.seed";
import { VacancyDataLoaderService } from "./vacancy.dataloader";
import { VacancyFactory } from "./vacancy.factory";
@@ -23,6 +26,9 @@ import { VacancyService } from "./vacancy.service";
RoleModule,
LevelModule,
SkillModule,
+ RateTypeModule,
+ ContractTypeModule,
+ LocationModule,
AuthenticationModule,
AuthorizationModule,
],
diff --git a/apps/server/src/modules/vacancies/vacancy.policy.ts b/apps/api/src/modules/vacancies/vacancy.policy.ts
similarity index 77%
rename from apps/server/src/modules/vacancies/vacancy.policy.ts
rename to apps/api/src/modules/vacancies/vacancy.policy.ts
index aee1cf1..39e240c 100644
--- a/apps/server/src/modules/vacancies/vacancy.policy.ts
+++ b/apps/api/src/modules/vacancies/vacancy.policy.ts
@@ -1,5 +1,5 @@
-import type { User } from "@cv/auth";
-import { OwnerOwnedResourcePolicy, Policy } from "@cv/auth";
+import type { User } from "@cv/core";
+import { OwnerOwnedResourcePolicy, Policy } from "@cv/core";
import { Injectable } from "@nestjs/common";
import { Vacancy } from "./vacancy.entity";
diff --git a/apps/server/src/modules/vacancies/vacancy.service.ts b/apps/api/src/modules/vacancies/vacancy.service.ts
similarity index 88%
rename from apps/server/src/modules/vacancies/vacancy.service.ts
rename to apps/api/src/modules/vacancies/vacancy.service.ts
index 73e4739..913a225 100644
--- a/apps/server/src/modules/vacancies/vacancy.service.ts
+++ b/apps/api/src/modules/vacancies/vacancy.service.ts
@@ -1,5 +1,5 @@
-import { notFound } from "@cv/auth";
-import { type EntityService, PrismaService, raise } from "@cv/system";
+import { notFound } from "@cv/core";
+import { type EntityService, PrismaService, raise } from "@cv/core";
import { ensureArray } from "@cv/utils";
import { Injectable } from "@nestjs/common";
import type { Prisma } from "@prisma/client";
@@ -67,7 +67,7 @@ export class VacancyService implements EntityService {
{
requirements: searchFilter,
},
- { location: searchFilter },
+ { location: { name: searchFilter } },
];
if (where.OR) {
@@ -85,7 +85,7 @@ export class VacancyService implements EntityService {
// Location filter
if (filter.locations && filter.locations.length > 0) {
- where.location = { in: filter.locations };
+ where.locationId = { in: filter.locations };
}
// Level filter
@@ -148,22 +148,7 @@ export class VacancyService implements EntityService {
return vacancy ?? notFound("Vacancy", "id", id);
}
- private buildPrismaData(entity: Vacancy): {
- title: string;
- companyId: string;
- roleId: string;
- levelId: string | null;
- jobTypeId: string | null;
- description: string | null;
- requirements: string | null;
- location: string | null;
- minSalary: number | null;
- maxSalary: number | null;
- applicationUrl: string | null;
- deadline: Date | null;
- isActive: boolean;
- isPublic: boolean;
- } {
+ private buildPrismaData(entity: Vacancy) {
return {
title: entity.title,
companyId: entity.company.id,
@@ -172,7 +157,7 @@ export class VacancyService implements EntityService {
jobTypeId: entity.jobTypeId ?? null,
description: entity.description ?? null,
requirements: entity.requirements ?? null,
- location: entity.location ?? null,
+ locationId: entity.locationId ?? null,
minSalary: entity.minSalary ?? null,
maxSalary: entity.maxSalary ?? null,
applicationUrl: entity.applicationUrl ?? null,
diff --git a/apps/server/src/scripts/e2e-coverage-all.cjs b/apps/api/src/scripts/e2e-coverage-all.cjs
similarity index 100%
rename from apps/server/src/scripts/e2e-coverage-all.cjs
rename to apps/api/src/scripts/e2e-coverage-all.cjs
diff --git a/apps/server/src/scripts/seed-test.ts b/apps/api/src/scripts/seed-test.ts
similarity index 100%
rename from apps/server/src/scripts/seed-test.ts
rename to apps/api/src/scripts/seed-test.ts
diff --git a/apps/server/src/scripts/seed.ts b/apps/api/src/scripts/seed.ts
similarity index 100%
rename from apps/server/src/scripts/seed.ts
rename to apps/api/src/scripts/seed.ts
diff --git a/apps/server/test/app-integration.e2e-spec.ts b/apps/api/test/app-integration.e2e-spec.ts
similarity index 100%
rename from apps/server/test/app-integration.e2e-spec.ts
rename to apps/api/test/app-integration.e2e-spec.ts
diff --git a/apps/server/test/assets/.gitkeep b/apps/api/test/assets/.gitkeep
similarity index 100%
rename from apps/server/test/assets/.gitkeep
rename to apps/api/test/assets/.gitkeep
diff --git a/apps/server/test/auth-integration.e2e-spec.ts b/apps/api/test/auth-integration.e2e-spec.ts
similarity index 100%
rename from apps/server/test/auth-integration.e2e-spec.ts
rename to apps/api/test/auth-integration.e2e-spec.ts
diff --git a/apps/server/test/auth-refresh-token.e2e-spec.ts b/apps/api/test/auth-refresh-token.e2e-spec.ts
similarity index 100%
rename from apps/server/test/auth-refresh-token.e2e-spec.ts
rename to apps/api/test/auth-refresh-token.e2e-spec.ts
diff --git a/apps/server/test/auth.e2e-spec.ts b/apps/api/test/auth.e2e-spec.ts
similarity index 100%
rename from apps/server/test/auth.e2e-spec.ts
rename to apps/api/test/auth.e2e-spec.ts
diff --git a/apps/server/test/cv-upload-integration.e2e-spec.ts b/apps/api/test/cv-upload-integration.e2e-spec.ts
similarity index 100%
rename from apps/server/test/cv-upload-integration.e2e-spec.ts
rename to apps/api/test/cv-upload-integration.e2e-spec.ts
diff --git a/apps/server/test/cv-upload.e2e-spec.ts b/apps/api/test/cv-upload.e2e-spec.ts
similarity index 100%
rename from apps/server/test/cv-upload.e2e-spec.ts
rename to apps/api/test/cv-upload.e2e-spec.ts
diff --git a/apps/server/test/cv-upload.integration.ts b/apps/api/test/cv-upload.integration.ts
similarity index 100%
rename from apps/server/test/cv-upload.integration.ts
rename to apps/api/test/cv-upload.integration.ts
diff --git a/apps/server/test/graphql-optimization.e2e-spec.ts b/apps/api/test/graphql-optimization.e2e-spec.ts
similarity index 100%
rename from apps/server/test/graphql-optimization.e2e-spec.ts
rename to apps/api/test/graphql-optimization.e2e-spec.ts
diff --git a/apps/server/test/job-experience-integration.e2e-spec.ts b/apps/api/test/job-experience-integration.e2e-spec.ts
similarity index 100%
rename from apps/server/test/job-experience-integration.e2e-spec.ts
rename to apps/api/test/job-experience-integration.e2e-spec.ts
diff --git a/apps/server/test/pagination-integration.e2e-spec.ts b/apps/api/test/pagination-integration.e2e-spec.ts
similarity index 100%
rename from apps/server/test/pagination-integration.e2e-spec.ts
rename to apps/api/test/pagination-integration.e2e-spec.ts
diff --git a/apps/server/test/pdf-extraction.integration.ts b/apps/api/test/pdf-extraction.integration.ts
similarity index 100%
rename from apps/server/test/pdf-extraction.integration.ts
rename to apps/api/test/pdf-extraction.integration.ts
diff --git a/apps/server/test/queries/all-companies-query.graphql b/apps/api/test/queries/all-companies-query.graphql
similarity index 100%
rename from apps/server/test/queries/all-companies-query.graphql
rename to apps/api/test/queries/all-companies-query.graphql
diff --git a/apps/server/test/queries/all-levels-query.graphql b/apps/api/test/queries/all-levels-query.graphql
similarity index 100%
rename from apps/server/test/queries/all-levels-query.graphql
rename to apps/api/test/queries/all-levels-query.graphql
diff --git a/apps/server/test/queries/all-roles-query.graphql b/apps/api/test/queries/all-roles-query.graphql
similarity index 100%
rename from apps/server/test/queries/all-roles-query.graphql
rename to apps/api/test/queries/all-roles-query.graphql
diff --git a/apps/server/test/queries/all-skills-query.graphql b/apps/api/test/queries/all-skills-query.graphql
similarity index 100%
rename from apps/server/test/queries/all-skills-query.graphql
rename to apps/api/test/queries/all-skills-query.graphql
diff --git a/apps/server/test/queries/basic-me-query.graphql b/apps/api/test/queries/basic-me-query.graphql
similarity index 100%
rename from apps/server/test/queries/basic-me-query.graphql
rename to apps/api/test/queries/basic-me-query.graphql
diff --git a/apps/server/test/queries/companies-pagination-query.graphql b/apps/api/test/queries/companies-pagination-query.graphql
similarity index 100%
rename from apps/server/test/queries/companies-pagination-query.graphql
rename to apps/api/test/queries/companies-pagination-query.graphql
diff --git a/apps/server/test/queries/complex-me-query.graphql b/apps/api/test/queries/complex-me-query.graphql
similarity index 100%
rename from apps/server/test/queries/complex-me-query.graphql
rename to apps/api/test/queries/complex-me-query.graphql
diff --git a/apps/server/test/queries/create-company-mutation.graphql b/apps/api/test/queries/create-company-mutation.graphql
similarity index 100%
rename from apps/server/test/queries/create-company-mutation.graphql
rename to apps/api/test/queries/create-company-mutation.graphql
diff --git a/apps/server/test/queries/create-job-experience-mutation.graphql b/apps/api/test/queries/create-job-experience-mutation.graphql
similarity index 100%
rename from apps/server/test/queries/create-job-experience-mutation.graphql
rename to apps/api/test/queries/create-job-experience-mutation.graphql
diff --git a/apps/server/test/queries/delete-job-experience-mutation.graphql b/apps/api/test/queries/delete-job-experience-mutation.graphql
similarity index 100%
rename from apps/server/test/queries/delete-job-experience-mutation.graphql
rename to apps/api/test/queries/delete-job-experience-mutation.graphql
diff --git a/apps/server/test/queries/health-query.graphql b/apps/api/test/queries/health-query.graphql
similarity index 100%
rename from apps/server/test/queries/health-query.graphql
rename to apps/api/test/queries/health-query.graphql
diff --git a/apps/server/test/queries/hello-query.graphql b/apps/api/test/queries/hello-query.graphql
similarity index 100%
rename from apps/server/test/queries/hello-query.graphql
rename to apps/api/test/queries/hello-query.graphql
diff --git a/apps/server/test/queries/introspection-query.graphql b/apps/api/test/queries/introspection-query.graphql
similarity index 100%
rename from apps/server/test/queries/introspection-query.graphql
rename to apps/api/test/queries/introspection-query.graphql
diff --git a/apps/server/test/queries/levels-pagination-query.graphql b/apps/api/test/queries/levels-pagination-query.graphql
similarity index 100%
rename from apps/server/test/queries/levels-pagination-query.graphql
rename to apps/api/test/queries/levels-pagination-query.graphql
diff --git a/apps/server/test/queries/load-queries.ts b/apps/api/test/queries/load-queries.ts
similarity index 100%
rename from apps/server/test/queries/load-queries.ts
rename to apps/api/test/queries/load-queries.ts
diff --git a/apps/server/test/queries/login-mutation.graphql b/apps/api/test/queries/login-mutation.graphql
similarity index 100%
rename from apps/server/test/queries/login-mutation.graphql
rename to apps/api/test/queries/login-mutation.graphql
diff --git a/apps/server/test/queries/me-with-organizations-partial-query.graphql b/apps/api/test/queries/me-with-organizations-partial-query.graphql
similarity index 100%
rename from apps/server/test/queries/me-with-organizations-partial-query.graphql
rename to apps/api/test/queries/me-with-organizations-partial-query.graphql
diff --git a/apps/server/test/queries/me-with-organizations-query.graphql b/apps/api/test/queries/me-with-organizations-query.graphql
similarity index 100%
rename from apps/server/test/queries/me-with-organizations-query.graphql
rename to apps/api/test/queries/me-with-organizations-query.graphql
diff --git a/apps/server/test/queries/my-employment-history-minimal.graphql b/apps/api/test/queries/my-employment-history-minimal.graphql
similarity index 100%
rename from apps/server/test/queries/my-employment-history-minimal.graphql
rename to apps/api/test/queries/my-employment-history-minimal.graphql
diff --git a/apps/server/test/queries/my-employment-history-query.graphql b/apps/api/test/queries/my-employment-history-query.graphql
similarity index 100%
rename from apps/server/test/queries/my-employment-history-query.graphql
rename to apps/api/test/queries/my-employment-history-query.graphql
diff --git a/apps/server/test/queries/my-employment-history-simple-query.graphql b/apps/api/test/queries/my-employment-history-simple-query.graphql
similarity index 100%
rename from apps/server/test/queries/my-employment-history-simple-query.graphql
rename to apps/api/test/queries/my-employment-history-simple-query.graphql
diff --git a/apps/server/test/queries/register-mutation.graphql b/apps/api/test/queries/register-mutation.graphql
similarity index 100%
rename from apps/server/test/queries/register-mutation.graphql
rename to apps/api/test/queries/register-mutation.graphql
diff --git a/apps/server/test/queries/roles-pagination-query.graphql b/apps/api/test/queries/roles-pagination-query.graphql
similarity index 100%
rename from apps/server/test/queries/roles-pagination-query.graphql
rename to apps/api/test/queries/roles-pagination-query.graphql
diff --git a/apps/server/test/queries/skills-pagination-query.graphql b/apps/api/test/queries/skills-pagination-query.graphql
similarity index 100%
rename from apps/server/test/queries/skills-pagination-query.graphql
rename to apps/api/test/queries/skills-pagination-query.graphql
diff --git a/apps/server/test/queries/update-job-experience-mutation.graphql b/apps/api/test/queries/update-job-experience-mutation.graphql
similarity index 100%
rename from apps/server/test/queries/update-job-experience-mutation.graphql
rename to apps/api/test/queries/update-job-experience-mutation.graphql
diff --git a/apps/server/test/test-utils.ts b/apps/api/test/test-utils.ts
similarity index 100%
rename from apps/server/test/test-utils.ts
rename to apps/api/test/test-utils.ts
diff --git a/apps/server/tsconfig.build.json b/apps/api/tsconfig.build.json
similarity index 100%
rename from apps/server/tsconfig.build.json
rename to apps/api/tsconfig.build.json
diff --git a/apps/server/tsconfig.json b/apps/api/tsconfig.json
similarity index 79%
rename from apps/server/tsconfig.json
rename to apps/api/tsconfig.json
index 5756b0e..6cd529d 100644
--- a/apps/server/tsconfig.json
+++ b/apps/api/tsconfig.json
@@ -6,11 +6,12 @@
"types": ["node"],
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
- "preserveSymlinks": true,
+ "resolveJsonModule": true,
+ "preserveSymlinks": false,
"paths": {
"@/*": ["./src/*"],
"@cv/auth": ["../../packages/auth/src"],
- "@cv/system": ["../../packages/system/src"],
+ "@cv/core": ["../../packages/core/src"],
"@cv/utils": ["../../packages/utils"]
},
"noUncheckedIndexedAccess": true,
@@ -24,6 +25,6 @@
"strictPropertyInitialization": true,
"noImplicitAny": true
},
- "include": ["src/**/*.ts", "src/**/*.d.ts"],
+ "include": ["src/**/*.ts", "src/**/*.d.ts", "../../packages/tsconfig/graphql.d.ts"],
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}
diff --git a/apps/server/tsup.config.ts b/apps/api/tsup.config.ts
similarity index 74%
rename from apps/server/tsup.config.ts
rename to apps/api/tsup.config.ts
index cd92581..079220b 100644
--- a/apps/server/tsup.config.ts
+++ b/apps/api/tsup.config.ts
@@ -1,7 +1,19 @@
+import { readFileSync } from "node:fs";
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/main.ts", "src/scripts/seed.ts"],
+ esbuildPlugins: [
+ {
+ name: "graphql-loader",
+ setup(build) {
+ build.onLoad({ filter: /\.graphql$/ }, (args) => ({
+ contents: `export default ${JSON.stringify(readFileSync(args.path, "utf8"))}`,
+ loader: "js",
+ }));
+ },
+ },
+ ],
outDir: "dist",
format: ["cjs"],
target: "node22",
@@ -22,7 +34,6 @@ export default defineConfig({
"graphql",
/^graphql-/,
"@apollo/server",
- "apollo-server-express",
"express",
"cookie-parser",
"multer",
diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts
new file mode 100644
index 0000000..a4c94c0
--- /dev/null
+++ b/apps/api/vitest.config.ts
@@ -0,0 +1,25 @@
+import { resolve } from "node:path";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ "@/": resolve(__dirname, "src") + "/",
+ },
+ },
+ test: {
+ globals: true,
+ environment: "node",
+ testTimeout: 60000,
+ hookTimeout: 60000,
+ include: [
+ "src/**/*.{test,spec}.{ts,tsx}",
+ ],
+ exclude: [
+ "**/node_modules/**",
+ "**/dist/**",
+ "**/build/**",
+ "test/**/*.integration.{ts,tsx}",
+ ],
+ },
+});
diff --git a/apps/client/src/features/admin/components/AdminLookupTable.tsx b/apps/client/src/features/admin/components/AdminLookupTable.tsx
index 3be6091..c073391 100644
--- a/apps/client/src/features/admin/components/AdminLookupTable.tsx
+++ b/apps/client/src/features/admin/components/AdminLookupTable.tsx
@@ -41,7 +41,7 @@ export const AdminLookupTable = ({
const [editDescription, setEditDescription] = useState("");
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
- const queryKey = ["AdminLookupEntities", { entityType, searchTerm }];
+ const _queryKey = ["AdminLookupEntities", { entityType, searchTerm }];
const { data, isLoading } = useAdminLookupEntitiesQuery(
{
@@ -204,9 +204,7 @@ export const AdminLookupTable = ({
>
) : (
<>
-
- {entity.name}
-
+ {entity.name}
{entity.description ?? "-"}
diff --git a/apps/client/src/features/admin/components/AiCallLogTable.tsx b/apps/client/src/features/admin/components/AiCallLogTable.tsx
index ee991da..962883a 100644
--- a/apps/client/src/features/admin/components/AiCallLogTable.tsx
+++ b/apps/client/src/features/admin/components/AiCallLogTable.tsx
@@ -1,5 +1,5 @@
-import { useState } from "react";
import { Badge, Card, StatusBadge, StatusDot } from "@cv/ui";
+import { useState } from "react";
import type { AiCallLogQuery } from "@/generated/graphql";
type CallLogEntry = AiCallLogQuery["aiCallLog"][number];
@@ -88,7 +88,7 @@ const CallLogRow = ({
{entry.error}
) : (
- entry.finishReason ?? entry.status
+ (entry.finishReason ?? entry.status)
)}
@@ -247,9 +247,7 @@ export const AiCallLogTable = ({ entries }: AiCallLogTableProps) => {
Time
Provider
Model
-
- Duration
-
+ Duration
Tokens
Status
diff --git a/apps/client/src/features/admin/components/QueueMessagesTable.tsx b/apps/client/src/features/admin/components/QueueMessagesTable.tsx
index c09a585..8b734b9 100644
--- a/apps/client/src/features/admin/components/QueueMessagesTable.tsx
+++ b/apps/client/src/features/admin/components/QueueMessagesTable.tsx
@@ -1,9 +1,8 @@
-import { useState } from "react";
import { Badge, Card, StatusDot } from "@cv/ui";
+import { useState } from "react";
import type { QueueMessagesQuery } from "@/generated/graphql";
type MessageConnection = QueueMessagesQuery["queueMessages"];
-type Message = MessageConnection["edges"][number]["node"];
type StatusFilter = "all" | "pending" | "scheduled" | "processing";
@@ -114,14 +113,10 @@ export const QueueMessagesTable = ({ connection }: QueueMessagesTableProps) => {
{msg.messageName ?? "-"}
-
- {msg.queueName}
-
+ {msg.queueName}
-
+
{msg.status}
diff --git a/apps/client/src/features/applications/components/ApplicationsOverview.tsx b/apps/client/src/features/applications/components/ApplicationsOverview.tsx
index 59e4765..157ebfd 100644
--- a/apps/client/src/features/applications/components/ApplicationsOverview.tsx
+++ b/apps/client/src/features/applications/components/ApplicationsOverview.tsx
@@ -111,9 +111,9 @@ export const ApplicationsOverview = () => {
{application.vacancy?.company?.name} •{" "}
{application.vacancy?.role?.name}
- {application.vacancy.location && (
+ {application.vacancy.location?.name && (
- 📍 {application.vacancy.location}
+ 📍 {application.vacancy.location.name}
)}
diff --git a/apps/client/src/features/applications/components/MatchIndicator.tsx b/apps/client/src/features/applications/components/MatchIndicator.tsx
index 6f6cf47..ab14f08 100644
--- a/apps/client/src/features/applications/components/MatchIndicator.tsx
+++ b/apps/client/src/features/applications/components/MatchIndicator.tsx
@@ -46,7 +46,8 @@ export const MatchIndicator = ({
// Extract skills from user's job experience
const experiences =
- jobExperienceData?.profile?.experience?.edges?.map((edge) => edge.node) || [];
+ jobExperienceData?.profile?.experience?.edges?.map((edge) => edge.node) ||
+ [];
const userSkills = experiences.flatMap(
(job) =>
job.skills?.map((skill: { id: string; name: string }) => ({
diff --git a/apps/client/src/features/applications/components/VacancyCard.tsx b/apps/client/src/features/applications/components/VacancyCard.tsx
index e866248..ad43772 100644
--- a/apps/client/src/features/applications/components/VacancyCard.tsx
+++ b/apps/client/src/features/applications/components/VacancyCard.tsx
@@ -17,7 +17,7 @@ interface VacancyCardProps {
level?: {
name: string;
} | null;
- location?: string | null;
+ location?: { name: string } | null;
minSalary?: number | null;
maxSalary?: number | null;
applicationUrl?: string | null;
@@ -62,10 +62,10 @@ export const VacancyCard = ({
)}
{role?.name && • {role.name} }
{level?.name && • {level.name} }
- {location && (
+ {location?.name && (
- {location}
+ {location.name}
)}
{(() => {
diff --git a/apps/client/src/features/applications/components/VacancyFilterPanel/useFilterOptions.ts b/apps/client/src/features/applications/components/VacancyFilterPanel/useFilterOptions.ts
index c84202b..67eccd7 100644
--- a/apps/client/src/features/applications/components/VacancyFilterPanel/useFilterOptions.ts
+++ b/apps/client/src/features/applications/components/VacancyFilterPanel/useFilterOptions.ts
@@ -66,8 +66,8 @@ export const useFilterOptions = () => {
const locations = Array.from(
new Set(
vacancies
- .map((v) => v.location)
- .filter((location): location is string => location !== null),
+ .map((v) => v.location?.name)
+ .filter((name): name is string => name != null),
),
);
diff --git a/apps/client/src/features/applications/components/VacancySelector.tsx b/apps/client/src/features/applications/components/VacancySelector.tsx
index eee6751..de58ccf 100644
--- a/apps/client/src/features/applications/components/VacancySelector.tsx
+++ b/apps/client/src/features/applications/components/VacancySelector.tsx
@@ -19,7 +19,7 @@ interface Vacancy {
level?: {
name: string;
} | null;
- location?: string | null;
+ location?: { name: string } | null;
minSalary?: number | null;
maxSalary?: number | null;
applicationUrl?: string | null;
diff --git a/apps/client/src/features/applications/mutations/create-application.graphql b/apps/client/src/features/applications/mutations/create-application.graphql
index 437c65d..bc84d40 100644
--- a/apps/client/src/features/applications/mutations/create-application.graphql
+++ b/apps/client/src/features/applications/mutations/create-application.graphql
@@ -20,7 +20,11 @@ mutation CreateApplication($input: CreateApplicationInput!) {
id
title
description
- location
+ location {
+ id
+ name
+ type
+ }
minSalary
maxSalary
company {
diff --git a/apps/client/src/features/applications/queries/my-applications.graphql b/apps/client/src/features/applications/queries/my-applications.graphql
index 5370773..1491278 100644
--- a/apps/client/src/features/applications/queries/my-applications.graphql
+++ b/apps/client/src/features/applications/queries/my-applications.graphql
@@ -23,7 +23,11 @@ query MyApplications {
id
title
description
- location
+ location {
+ id
+ name
+ type
+ }
minSalary
maxSalary
company {
diff --git a/apps/client/src/features/onboarding/components/FileUploadStep.tsx b/apps/client/src/features/onboarding/components/FileUploadStep.tsx
index 0660b51..3d6c290 100644
--- a/apps/client/src/features/onboarding/components/FileUploadStep.tsx
+++ b/apps/client/src/features/onboarding/components/FileUploadStep.tsx
@@ -84,7 +84,11 @@ export const FileUploadStep = ({ profileId, onComplete, onBack }: Props) => {
uploadMutation.mutate(file, {
onSuccess: (result) => {
- const { id, profileId: newProfileId, isDuplicate } = result.uploadFile;
+ const {
+ id,
+ profileId: newProfileId,
+ isDuplicate,
+ } = result.uploadFile;
if (newProfileId) {
setActiveProfileId(newProfileId);
diff --git a/apps/client/src/features/onboarding/components/ReviewStep.tsx b/apps/client/src/features/onboarding/components/ReviewStep.tsx
index f21bb52..c6986d8 100644
--- a/apps/client/src/features/onboarding/components/ReviewStep.tsx
+++ b/apps/client/src/features/onboarding/components/ReviewStep.tsx
@@ -37,7 +37,9 @@ const collectUnresolved = (
const map = new Map();
entities
.filter((e) => !e.id && e.name)
- .forEach((e) => map.set(e.name.toLowerCase(), e));
+ .forEach((e) => {
+ map.set(e.name.toLowerCase(), e);
+ });
return map;
};
@@ -104,10 +106,14 @@ export const ReviewStep = ({
const allJobs = parsedData.jobExperiences;
const allEdu = parsedData.education;
- const unresolvedCompanies = collectUnresolved(allJobs.map((j) => j.company));
+ const unresolvedCompanies = collectUnresolved(
+ allJobs.map((j) => j.company),
+ );
const unresolvedRoles = collectUnresolved(allJobs.map((j) => j.role));
const unresolvedLevels = collectUnresolved(allJobs.map((j) => j.level));
- const unresolvedInstitutions = collectUnresolved(allEdu.map((e) => e.institution));
+ const unresolvedInstitutions = collectUnresolved(
+ allEdu.map((e) => e.institution),
+ );
const unresolvedSkills = collectUnresolved([
...allJobs.flatMap((j) => j.skills),
...allEdu.flatMap((e) => e.skills),
@@ -132,11 +138,19 @@ export const ReviewStep = ({
let created = 0;
if (totalEntities > 0) {
- setProgress({ phase: "Creating entities", current: 0, total: totalEntities });
+ setProgress({
+ phase: "Creating entities",
+ current: 0,
+ total: totalEntities,
+ });
const bump = () => {
created++;
- setProgress({ phase: "Creating entities", current: created, total: totalEntities });
+ setProgress({
+ phase: "Creating entities",
+ current: created,
+ total: totalEntities,
+ });
};
const entityTasks: (() => Promise)[] = [];
@@ -144,35 +158,45 @@ export const ReviewStep = ({
unresolvedCompanies.forEach((entity) => {
entityTasks.push(async () => {
const result = await createCompany({ name: entity.name });
- idMap.get("company")!.set(entity.name.toLowerCase(), result.createCompany.id);
+ idMap
+ .get("company")
+ ?.set(entity.name.toLowerCase(), result.createCompany.id);
});
});
unresolvedRoles.forEach((entity) => {
entityTasks.push(async () => {
const result = await createRole({ name: entity.name });
- idMap.get("role")!.set(entity.name.toLowerCase(), result.createRole.id);
+ idMap
+ .get("role")
+ ?.set(entity.name.toLowerCase(), result.createRole.id);
});
});
unresolvedLevels.forEach((entity) => {
entityTasks.push(async () => {
const result = await createLevel({ name: entity.name });
- idMap.get("level")!.set(entity.name.toLowerCase(), result.createLevel.id);
+ idMap
+ .get("level")
+ ?.set(entity.name.toLowerCase(), result.createLevel.id);
});
});
unresolvedInstitutions.forEach((entity) => {
entityTasks.push(async () => {
const result = await createInstitution({ name: entity.name });
- idMap.get("institution")!.set(entity.name.toLowerCase(), result.createInstitution.id);
+ idMap
+ .get("institution")
+ ?.set(entity.name.toLowerCase(), result.createInstitution.id);
});
});
unresolvedSkills.forEach((entity) => {
entityTasks.push(async () => {
const result = await createSkill({ name: entity.name });
- idMap.get("skill")!.set(entity.name.toLowerCase(), result.createSkill.id);
+ idMap
+ .get("skill")
+ ?.set(entity.name.toLowerCase(), result.createSkill.id);
});
});
@@ -183,9 +207,13 @@ export const ReviewStep = ({
if (entityErrors.length > 0) {
errors.push(
- ...entityErrors.slice(0, 3).map((r) =>
- r.reason instanceof Error ? r.reason.message : "Entity creation failed",
- ),
+ ...entityErrors
+ .slice(0, 3)
+ .map((r) =>
+ r.reason instanceof Error
+ ? r.reason.message
+ : "Entity creation failed",
+ ),
);
}
}
@@ -201,7 +229,11 @@ export const ReviewStep = ({
const recordBump = () => {
savedRecords++;
- setProgress({ phase: "Saving records", current: savedRecords, total: totalRecords });
+ setProgress({
+ phase: "Saving records",
+ current: savedRecords,
+ total: totalRecords,
+ });
};
const jobTasks = allJobs.map((job) => async () => {
@@ -212,7 +244,7 @@ export const ReviewStep = ({
.map((s) => resolveId("skill", s))
.filter((id): id is string => id !== null);
- if (!companyId || !roleId || !levelId) {
+ if (!(companyId && roleId && levelId)) {
throw new Error(`Missing entity IDs for job at ${job.company.name}`);
}
@@ -262,9 +294,13 @@ export const ReviewStep = ({
if (recordErrors.length > 0) {
errors.push(
- ...recordErrors.slice(0, 3).map((r) =>
- r.reason instanceof Error ? r.reason.message : "Record creation failed",
- ),
+ ...recordErrors
+ .slice(0, 3)
+ .map((r) =>
+ r.reason instanceof Error
+ ? r.reason.message
+ : "Record creation failed",
+ ),
);
}
@@ -274,7 +310,9 @@ export const ReviewStep = ({
queryClient.invalidateQueries({ queryKey: ["MeEducation"] }),
]);
- const succeeded = recordResults.filter((r) => r.status === "fulfilled").length;
+ const succeeded = recordResults.filter(
+ (r) => r.status === "fulfilled",
+ ).length;
if (errors.length > 0) {
showError(
@@ -434,7 +472,9 @@ export const ReviewStep = ({
diff --git a/apps/client/src/features/profile/components/ProfileSwitcher.tsx b/apps/client/src/features/profile/components/ProfileSwitcher.tsx
index a92e57d..19fa0f5 100644
--- a/apps/client/src/features/profile/components/ProfileSwitcher.tsx
+++ b/apps/client/src/features/profile/components/ProfileSwitcher.tsx
@@ -22,7 +22,7 @@ export const ProfileSwitcher = () => {
};
return (
-
+
setIsOpen((prev) => !prev)}
@@ -37,8 +37,13 @@ export const ProfileSwitcher = () => {
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
+ aria-hidden="true"
>
-
+
diff --git a/apps/client/src/features/user-settings/components/AiPreferenceStep.tsx b/apps/client/src/features/user-settings/components/AiPreferenceStep.tsx
index 0229632..e311237 100644
--- a/apps/client/src/features/user-settings/components/AiPreferenceStep.tsx
+++ b/apps/client/src/features/user-settings/components/AiPreferenceStep.tsx
@@ -61,7 +61,10 @@ export const AiPreferenceStep = ({ onComplete }: Props) => {
Add your first AI provider
-
setByokProviderAdded(true)} />
+ setByokProviderAdded(true)}
+ />
)}
diff --git a/apps/client/src/features/user-settings/components/AiProviderList.tsx b/apps/client/src/features/user-settings/components/AiProviderList.tsx
index b180572..148bbcf 100644
--- a/apps/client/src/features/user-settings/components/AiProviderList.tsx
+++ b/apps/client/src/features/user-settings/components/AiProviderList.tsx
@@ -74,9 +74,7 @@ export const AiProviderList = ({
"Provider removed. Switched back to platform AI since no BYOK keys remain.",
);
} else if (removal?.wasActive) {
- showSuccess(
- "Provider removed. Another key has been set as active.",
- );
+ showSuccess("Provider removed. Another key has been set as active.");
} else {
showSuccess("Provider removed");
}
diff --git a/apps/client/src/features/user-settings/components/ImportedFilesSection.tsx b/apps/client/src/features/user-settings/components/ImportedFilesSection.tsx
index ab6e2e0..66a6ff5 100644
--- a/apps/client/src/features/user-settings/components/ImportedFilesSection.tsx
+++ b/apps/client/src/features/user-settings/components/ImportedFilesSection.tsx
@@ -67,15 +67,16 @@ export const ImportedFilesSection = () => {
}
if (files.length === 0) {
- return (
-
No imported files yet.
- );
+ return
No imported files yet.
;
}
return (
{files.map((file) => {
- const status = STATUS_STYLES[file.status] ?? { dot: "bg-ctp-overlay1", text: "Unknown" };
+ const status = STATUS_STYLES[file.status] ?? {
+ dot: "bg-ctp-overlay1",
+ text: "Unknown",
+ };
const sourceLabel = SOURCE_LABELS[file.source] ?? file.source;
return (
@@ -84,15 +85,19 @@ export const ImportedFilesSection = () => {
className="flex items-center justify-between rounded-lg border border-ctp-surface0 bg-ctp-surface0/30 px-4 py-3"
>
-
+
{file.fileName}
- {sourceLabel} · {formatFileSize(file.sizeBytes)} ·{" "}
- {formatDate(file.createdAt)} ·{" "}
-
+ {sourceLabel} · {formatFileSize(file.sizeBytes)}{" "}
+ · {formatDate(file.createdAt)} ·{" "}
+
{status.text}
diff --git a/apps/client/src/features/vacancies/queries/create-vacancy.graphql b/apps/client/src/features/vacancies/queries/create-vacancy.graphql
index 77e5549..b81d73b 100644
--- a/apps/client/src/features/vacancies/queries/create-vacancy.graphql
+++ b/apps/client/src/features/vacancies/queries/create-vacancy.graphql
@@ -6,7 +6,7 @@ mutation CreateVacancy(
$jobTypeId: String
$description: String
$requirements: String
- $location: String
+ $locationId: String
$minSalary: Float
$maxSalary: Float
$applicationUrl: String
@@ -22,7 +22,7 @@ mutation CreateVacancy(
jobTypeId: $jobTypeId
description: $description
requirements: $requirements
- location: $location
+ locationId: $locationId
minSalary: $minSalary
maxSalary: $maxSalary
applicationUrl: $applicationUrl
@@ -33,13 +33,8 @@ mutation CreateVacancy(
id
title
ownerId
- companyId
- roleId
- levelId
- jobTypeId
description
requirements
- location
minSalary
maxSalary
applicationUrl
@@ -48,5 +43,22 @@ mutation CreateVacancy(
isPublic
createdAt
updatedAt
+ company {
+ id
+ name
+ }
+ role {
+ id
+ name
+ }
+ level {
+ id
+ name
+ }
+ location {
+ id
+ name
+ type
+ }
}
}
diff --git a/apps/client/src/features/vacancies/queries/my-vacancies.graphql b/apps/client/src/features/vacancies/queries/my-vacancies.graphql
index 5a801dc..ad5b2f8 100644
--- a/apps/client/src/features/vacancies/queries/my-vacancies.graphql
+++ b/apps/client/src/features/vacancies/queries/my-vacancies.graphql
@@ -6,13 +6,8 @@ query MyVacancies($filter: VacancyFilterInput) {
id
title
ownerId
- companyId
- roleId
- levelId
- jobTypeId
description
requirements
- location
minSalary
maxSalary
applicationUrl
@@ -33,6 +28,19 @@ query MyVacancies($filter: VacancyFilterInput) {
id
name
}
+ location {
+ id
+ name
+ type
+ }
+ contractType {
+ id
+ name
+ }
+ rateType {
+ id
+ name
+ }
skills {
id
name
diff --git a/apps/client/src/features/vacancies/queries/vacancy-filter-options.graphql b/apps/client/src/features/vacancies/queries/vacancy-filter-options.graphql
index 2f52c78..668a8e0 100644
--- a/apps/client/src/features/vacancies/queries/vacancy-filter-options.graphql
+++ b/apps/client/src/features/vacancies/queries/vacancy-filter-options.graphql
@@ -12,7 +12,11 @@ query VacancyFilterOptions {
id
name
}
- location
+ location {
+ id
+ name
+ type
+ }
}
}
}
diff --git a/apps/client/src/pages/AdminPage.tsx b/apps/client/src/pages/AdminPage.tsx
index c01802f..d0ac9f0 100644
--- a/apps/client/src/pages/AdminPage.tsx
+++ b/apps/client/src/pages/AdminPage.tsx
@@ -15,7 +15,9 @@ const ENTITY_TABS = [
] as const;
export default function AdminPage() {
- const [activeTab, setActiveTab] = useState<(typeof ENTITY_TABS)[number]["type"]>(ENTITY_TABS[0].type);
+ const [activeTab, setActiveTab] = useState<
+ (typeof ENTITY_TABS)[number]["type"]
+ >(ENTITY_TABS[0].type);
const activeEntry = ENTITY_TABS.find((t) => t.type === activeTab);
diff --git a/apps/client/src/pages/CVViewPage.tsx b/apps/client/src/pages/CVViewPage.tsx
index 6513766..2e32970 100644
--- a/apps/client/src/pages/CVViewPage.tsx
+++ b/apps/client/src/pages/CVViewPage.tsx
@@ -150,10 +150,16 @@ export const CVViewPage = () => {
Print
-
+
Edit CV
-
+
Back to List
@@ -178,7 +184,8 @@ export const CVViewPage = () => {
/>
) : (
- No template content available. The selected template may not have a body defined.
+ No template content available. The selected template may not have
+ a body defined.
)}
diff --git a/apps/client/src/pages/DashboardPage.tsx b/apps/client/src/pages/DashboardPage.tsx
index b4aeae4..6d99213 100644
--- a/apps/client/src/pages/DashboardPage.tsx
+++ b/apps/client/src/pages/DashboardPage.tsx
@@ -40,8 +40,7 @@ export default function DashboardPage() {
}
const user = userData?.me;
- const allCvs =
- cvsData?.profile?.cvs?.edges?.map((e) => e.node) ?? [];
+ const allCvs = cvsData?.profile?.cvs?.edges?.map((e) => e.node) ?? [];
const totalCVs = cvsData?.profile?.cvs?.totalCount ?? 0;
const totalTemplates = templatesData?.cvTemplates?.totalCount || 0;
diff --git a/apps/client/src/pages/EducationPage.tsx b/apps/client/src/pages/EducationPage.tsx
index 4a7237e..c043d53 100644
--- a/apps/client/src/pages/EducationPage.tsx
+++ b/apps/client/src/pages/EducationPage.tsx
@@ -7,7 +7,12 @@ import { useMeEducationQuery } from "@/generated/graphql";
export default function EducationPage() {
const profileId = useActiveProfileId();
- const { data, isPending: loading, error, refetch } = useMeEducationQuery({ profileId });
+ const {
+ data,
+ isPending: loading,
+ error,
+ refetch,
+ } = useMeEducationQuery({ profileId });
const { showError } = useToast();
const navigate = useNavigate();
diff --git a/apps/client/src/pages/ProfilePage.tsx b/apps/client/src/pages/ProfilePage.tsx
index 9845c38..3d2e913 100644
--- a/apps/client/src/pages/ProfilePage.tsx
+++ b/apps/client/src/pages/ProfilePage.tsx
@@ -26,8 +26,6 @@ export default function ProfilePage() {
phone: activeProfile.phone ?? "",
address: activeProfile.address ?? "",
postalCode: activeProfile.postalCode ?? "",
- city: activeProfile.city ?? "",
- country: activeProfile.country ?? "",
website: activeProfile.website ?? "",
linkedInUrl: activeProfile.linkedInUrl ?? "",
summary: activeProfile.summary ?? "",
diff --git a/apps/client/src/pages/ProfilesPage.tsx b/apps/client/src/pages/ProfilesPage.tsx
index c80295d..703a4ef 100644
--- a/apps/client/src/pages/ProfilesPage.tsx
+++ b/apps/client/src/pages/ProfilesPage.tsx
@@ -16,8 +16,7 @@ const formatDate = (dateStr: string): string =>
});
export default function ProfilesPage() {
- const { profiles, activeProfile, setActiveProfileId, refetch } =
- useProfile();
+ const { profiles, activeProfile, setActiveProfileId, refetch } = useProfile();
const { showSuccess, showError } = useToast();
const { showConfirmation } = useConfirmationModal();
const queryClient = useQueryClient();
@@ -38,9 +37,7 @@ export default function ProfilesPage() {
showSuccess("Profile created");
navigate("/profile");
} catch (err) {
- showError(
- extractGraphQLErrorMessage(err) || "Failed to create profile",
- );
+ showError(extractGraphQLErrorMessage(err) || "Failed to create profile");
}
};
@@ -66,9 +63,7 @@ export default function ProfilesPage() {
await queryClient.invalidateQueries();
showSuccess("Profile deleted");
} catch (err) {
- showError(
- extractGraphQLErrorMessage(err) || "Failed to delete profile",
- );
+ showError(extractGraphQLErrorMessage(err) || "Failed to delete profile");
}
};
@@ -116,12 +111,6 @@ export default function ProfilesPage() {
)}
- {(profile.city || profile.country) && (
-
- {[profile.city, profile.country].filter(Boolean).join(", ")}
-
- )}
-
Created {formatDate(profile.createdAt)}
@@ -150,7 +139,9 @@ export default function ProfilesPage() {
size="sm"
variant="ghost"
onClick={() => handleDelete(profile.id, profile.name)}
- disabled={profiles.length <= 1 || deleteProfileMutation.isPending}
+ disabled={
+ profiles.length <= 1 || deleteProfileMutation.isPending
+ }
>
Delete
diff --git a/apps/client/src/pages/SettingsPage.tsx b/apps/client/src/pages/SettingsPage.tsx
index d5830a8..dc9b0ee 100644
--- a/apps/client/src/pages/SettingsPage.tsx
+++ b/apps/client/src/pages/SettingsPage.tsx
@@ -138,9 +138,7 @@ export default function SettingsPage() {
{user?.email}
-
- User ID
-
+
User ID
{user?.id}
diff --git a/apps/client/src/pages/SystemStatusPage.tsx b/apps/client/src/pages/SystemStatusPage.tsx
index 39c7e18..51c3e6f 100644
--- a/apps/client/src/pages/SystemStatusPage.tsx
+++ b/apps/client/src/pages/SystemStatusPage.tsx
@@ -1,4 +1,8 @@
import { Badge, Card, PageHeader, Placeholder, StatusDot } from "@cv/ui";
+import { AiCallLogTable } from "@/features/admin/components/AiCallLogTable";
+import { QueueMessagesTable } from "@/features/admin/components/QueueMessagesTable";
+import { QueueStatsCard } from "@/features/admin/components/QueueStatsCard";
+import { WorkerHealthCard } from "@/features/admin/components/WorkerHealthCard";
import {
useAiCallLogQuery,
useQueueMessagesQuery,
@@ -6,10 +10,6 @@ import {
useSystemStatusQuery,
useWorkerHealthQuery,
} from "@/generated/graphql";
-import { AiCallLogTable } from "@/features/admin/components/AiCallLogTable";
-import { QueueMessagesTable } from "@/features/admin/components/QueueMessagesTable";
-import { QueueStatsCard } from "@/features/admin/components/QueueStatsCard";
-import { WorkerHealthCard } from "@/features/admin/components/WorkerHealthCard";
interface LlamaCppDetails {
kind: "llama-cpp";
@@ -124,7 +124,9 @@ const ProviderCard = ({
{details?.kind === "api" && (
)}
- {details?.baseUrl && }
+ {details?.baseUrl && (
+
+ )}
);
@@ -298,9 +300,7 @@ export default function SystemStatusPage() {
{details?.kind === "llama-cpp" &&
details.slots &&
- Array.isArray(details.slots) && (
-
- )}
+ Array.isArray(details.slots) && }
{queueStatsData?.queueStats && (
diff --git a/apps/client/src/pages/VacanciesPage.tsx b/apps/client/src/pages/VacanciesPage.tsx
index 9d1c09e..80898dc 100644
--- a/apps/client/src/pages/VacanciesPage.tsx
+++ b/apps/client/src/pages/VacanciesPage.tsx
@@ -87,7 +87,7 @@ export default function VacanciesPage() {
},
description: v.description,
requirements: v.requirements,
- location: v.location,
+ location: v.location?.name,
minSalary: v.minSalary,
maxSalary: v.maxSalary,
jobType: null,
diff --git a/apps/docs/package.json b/apps/docs/package.json
index 8514c44..405ddd0 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -39,6 +39,7 @@
"postcss": "^8.4.47",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.3",
+ "unified": "^11.0.5",
"vite": "^5.4.3"
}
}
diff --git a/apps/docs/src/config/env-interpolation-remark-plugin.ts b/apps/docs/src/config/env-interpolation-remark-plugin.ts
index 5fe29b7..2e90303 100644
--- a/apps/docs/src/config/env-interpolation-remark-plugin.ts
+++ b/apps/docs/src/config/env-interpolation-remark-plugin.ts
@@ -3,9 +3,14 @@ import type { Plugin } from "unified";
import { ENV_VARS } from "./env.config";
const visitTextNodes = (node: Node, fn: (text: Text) => void): void => {
- if (node.type === "text") return fn(node as Text);
+ if (node.type === "text") {
+ fn(node as Text);
+ return;
+ }
if ("children" in node)
- (node as Root).children.forEach((child) => visitTextNodes(child, fn));
+ (node as Root).children.forEach((child) => {
+ visitTextNodes(child, fn);
+ });
};
/**
diff --git a/apps/server/jest.config.ts b/apps/server/jest.config.ts
deleted file mode 100644
index da145f2..0000000
--- a/apps/server/jest.config.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import type { Config } from "jest";
-
-const config: Config = {
- preset: "ts-jest",
- testEnvironment: "node",
- rootDir: ".",
- moduleFileExtensions: ["ts", "tsx", "js", "json"],
- testMatch: [
- "/src/**/*.spec.ts",
- "/src/**/*.test.ts",
- "/src/**/*.spec.tsx",
- "/src/**/*.test.tsx",
- ],
- transform: {
- "^.+\\.(t|j)sx?$": [
- "ts-jest",
- {
- diagnostics: false,
- tsconfig: {
- sourceMap: true,
- inlineSourceMap: true,
- },
- },
- ],
- },
- moduleNameMapper: {
- "^@/(.*)$": "/src/$1",
- },
- collectCoverage: true,
- collectCoverageFrom: [
- "/src/**/*.{ts,tsx}",
- "!/src/main.ts",
- "!/src/**/index.ts",
- "!/src/**/__mocks__/**",
- ],
- coverageDirectory: "/coverage-unit",
- coverageReporters: ["text", "lcov"],
- coverageProvider: "babel",
-};
-
-export default config;
diff --git a/apps/server/prisma/models/vacancy.prisma b/apps/server/prisma/models/vacancy.prisma
deleted file mode 100644
index dc23395..0000000
--- a/apps/server/prisma/models/vacancy.prisma
+++ /dev/null
@@ -1,80 +0,0 @@
-model JobType {
- id String @id @default(cuid())
- name String @unique
- description String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
-
- // Vacancies with this job type
- vacancies Vacancy[]
-
- @@map("job_types")
-}
-
-model ApplicationStatus {
- id String @id @default(cuid())
- name String @unique
- description String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
-
- // Applications with this status
- applications Application[]
-
- @@map("application_statuses")
-}
-
-model Vacancy {
- id String @id @default(cuid())
- ownerId String
- title String
- companyId String
- roleId String
- levelId String?
- jobTypeId String?
- description String?
- requirements String?
- location String?
- minSalary Int?
- maxSalary Int?
- applicationUrl String?
- deadline DateTime?
- isActive Boolean @default(true)
- isPublic Boolean @default(false)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
-
- // Relations
- owner User @relation("VacancyOwner", fields: [ownerId], references: [id], onDelete: Cascade)
- company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
- role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
- level Level? @relation(fields: [levelId], references: [id], onDelete: Cascade)
- jobType JobType? @relation(fields: [jobTypeId], references: [id], onDelete: Cascade)
- skills Skill[]
- applications Application[]
-
- @@map("vacancies")
-}
-
-model Application {
- id String @id @default(cuid())
- userId String
- profileId String
- vacancyId String
- cvId String?
- coverLetter String?
- statusId String
- appliedAt DateTime @default(now())
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
-
- // Relations
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
- vacancy Vacancy @relation(fields: [vacancyId], references: [id], onDelete: Cascade)
- cv CV? @relation(fields: [cvId], references: [id], onDelete: SetNull)
- status ApplicationStatus @relation(fields: [statusId], references: [id], onDelete: Restrict)
-
- @@unique([userId, vacancyId])
- @@map("applications")
-}
diff --git a/apps/server/src/modules/data-import/events/user-file-created.event.ts b/apps/server/src/modules/data-import/events/user-file-created.event.ts
deleted file mode 100644
index 57fdbb6..0000000
--- a/apps/server/src/modules/data-import/events/user-file-created.event.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import type { User } from "@cv/auth";
-import type { DataImportSource } from "../data-import-source.interface";
-
-export class UserFileCreatedEvent {
- static readonly event = "user-file.created";
-
- constructor(
- public readonly userFileId: string,
- public readonly user: User,
- public readonly source: DataImportSource,
- public readonly params: Record,
- ) {}
-}
diff --git a/apps/server/src/modules/vacancies/graphql/vacancy.type.ts b/apps/server/src/modules/vacancies/graphql/vacancy.type.ts
deleted file mode 100644
index d419715..0000000
--- a/apps/server/src/modules/vacancies/graphql/vacancy.type.ts
+++ /dev/null
@@ -1,144 +0,0 @@
-import { Field, ID, ObjectType } from "@nestjs/graphql";
-import { createConnection } from "@/modules/base/connection.factory";
-import { Company } from "@/modules/job-experience/company/graphql/company.type";
-import { Level } from "@/modules/job-experience/level/graphql/level.type";
-import { Role } from "@/modules/job-experience/role/graphql/role.type";
-import type { Vacancy as DomainVacancy } from "@/modules/vacancies/vacancy.entity";
-import { Vacancy as VacancyEntity } from "@/modules/vacancies/vacancy.entity";
-
-@ObjectType()
-export class Vacancy {
- @Field(() => ID)
- id: string;
-
- @Field(() => String)
- title: string;
-
- @Field(() => String)
- ownerId: string;
-
- @Field(() => String)
- companyId: string;
-
- @Field(() => String)
- roleId: string;
-
- @Field(() => String, { nullable: true })
- levelId: string | null;
-
- @Field(() => Company)
- company?: Company;
-
- @Field(() => Role)
- role?: Role;
-
- @Field(() => Level, { nullable: true })
- level?: Level | null;
-
- @Field(() => String, { nullable: true })
- jobTypeId: string | null;
-
- @Field(() => String, { nullable: true })
- description: string | null;
-
- @Field(() => String, { nullable: true })
- requirements: string | null;
-
- @Field(() => String, { nullable: true })
- location: string | null;
-
- @Field(() => Number, { nullable: true })
- minSalary: number | null;
-
- @Field(() => Number, { nullable: true })
- maxSalary: number | null;
-
- @Field(() => String, { nullable: true })
- applicationUrl: string | null;
-
- @Field(() => Date, { nullable: true })
- deadline: Date | null;
-
- @Field(() => Boolean)
- isActive: boolean;
-
- @Field(() => Boolean)
- isPublic: boolean;
-
- @Field(() => Date)
- createdAt: Date;
-
- @Field(() => Date)
- updatedAt: Date;
-
- constructor(data: {
- id: string;
- title: string;
- ownerId: string;
- companyId: string;
- roleId: string;
- levelId?: string | null;
- jobTypeId?: string | null;
- description?: string | null;
- requirements?: string | null;
- location?: string | null;
- minSalary?: number | null;
- maxSalary?: number | null;
- applicationUrl?: string | null;
- deadline?: Date | null;
- isActive: boolean;
- isPublic: boolean;
- createdAt: Date;
- updatedAt: Date;
- }) {
- this.id = data.id;
- this.title = data.title;
- this.ownerId = data.ownerId;
- this.companyId = data.companyId;
- this.roleId = data.roleId;
- this.levelId = data.levelId ?? null;
- this.jobTypeId = data.jobTypeId ?? null;
- this.description = data.description ?? null;
- this.requirements = data.requirements ?? null;
- this.location = data.location ?? null;
- this.minSalary = data.minSalary ?? null;
- this.maxSalary = data.maxSalary ?? null;
- this.applicationUrl = data.applicationUrl ?? null;
- this.deadline = data.deadline ?? null;
- this.isActive = data.isActive;
- this.isPublic = data.isPublic;
- this.createdAt = data.createdAt;
- this.updatedAt = data.updatedAt;
- }
-
- static fromDomain(domainVacancy: DomainVacancy): Vacancy {
- return new Vacancy({
- id: domainVacancy.id,
- title: domainVacancy.title,
- ownerId: domainVacancy.ownerId,
- companyId: domainVacancy.company.id,
- roleId: domainVacancy.role.id,
- levelId: domainVacancy.level?.id ?? null,
- jobTypeId: domainVacancy.jobTypeId ?? null,
- description: domainVacancy.description ?? null,
- requirements: domainVacancy.requirements ?? null,
- location: domainVacancy.location ?? null,
- minSalary: domainVacancy.minSalary ?? null,
- maxSalary: domainVacancy.maxSalary ?? null,
- applicationUrl: domainVacancy.applicationUrl ?? null,
- deadline: domainVacancy.deadline ?? null,
- isActive: domainVacancy.isActive,
- isPublic: domainVacancy.isPublic,
- createdAt: domainVacancy.createdAt,
- updatedAt: domainVacancy.updatedAt,
- });
- }
-}
-
-export const { Connection: VacancyConnection, Edge: VacancyEdge } =
- createConnection(Vacancy, (domain) =>
- Vacancy.fromDomain(domain),
- );
-
-export type VacancyConnection = InstanceType;
-export type VacancyEdge = InstanceType;
diff --git a/apps/server/test/coverage-unit/lcov-report/base.css b/apps/server/test/coverage-unit/lcov-report/base.css
deleted file mode 100644
index f418035..0000000
--- a/apps/server/test/coverage-unit/lcov-report/base.css
+++ /dev/null
@@ -1,224 +0,0 @@
-body, html {
- margin:0; padding: 0;
- height: 100%;
-}
-body {
- font-family: Helvetica Neue, Helvetica, Arial;
- font-size: 14px;
- color:#333;
-}
-.small { font-size: 12px; }
-*, *:after, *:before {
- -webkit-box-sizing:border-box;
- -moz-box-sizing:border-box;
- box-sizing:border-box;
- }
-h1 { font-size: 20px; margin: 0;}
-h2 { font-size: 14px; }
-pre {
- font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace;
- margin: 0;
- padding: 0;
- -moz-tab-size: 2;
- -o-tab-size: 2;
- tab-size: 2;
-}
-a { color:#0074D9; text-decoration:none; }
-a:hover { text-decoration:underline; }
-.strong { font-weight: bold; }
-.space-top1 { padding: 10px 0 0 0; }
-.pad2y { padding: 20px 0; }
-.pad1y { padding: 10px 0; }
-.pad2x { padding: 0 20px; }
-.pad2 { padding: 20px; }
-.pad1 { padding: 10px; }
-.space-left2 { padding-left:55px; }
-.space-right2 { padding-right:20px; }
-.center { text-align:center; }
-.clearfix { display:block; }
-.clearfix:after {
- content:'';
- display:block;
- height:0;
- clear:both;
- visibility:hidden;
- }
-.fl { float: left; }
-@media only screen and (max-width:640px) {
- .col3 { width:100%; max-width:100%; }
- .hide-mobile { display:none!important; }
-}
-
-.quiet {
- color: #7f7f7f;
- color: rgba(0,0,0,0.5);
-}
-.quiet a { opacity: 0.7; }
-
-.fraction {
- font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
- font-size: 10px;
- color: #555;
- background: #E8E8E8;
- padding: 4px 5px;
- border-radius: 3px;
- vertical-align: middle;
-}
-
-div.path a:link, div.path a:visited { color: #333; }
-table.coverage {
- border-collapse: collapse;
- margin: 10px 0 0 0;
- padding: 0;
-}
-
-table.coverage td {
- margin: 0;
- padding: 0;
- vertical-align: top;
-}
-table.coverage td.line-count {
- text-align: right;
- padding: 0 5px 0 20px;
-}
-table.coverage td.line-coverage {
- text-align: right;
- padding-right: 10px;
- min-width:20px;
-}
-
-table.coverage td span.cline-any {
- display: inline-block;
- padding: 0 5px;
- width: 100%;
-}
-.missing-if-branch {
- display: inline-block;
- margin-right: 5px;
- border-radius: 3px;
- position: relative;
- padding: 0 4px;
- background: #333;
- color: yellow;
-}
-
-.skip-if-branch {
- display: none;
- margin-right: 10px;
- position: relative;
- padding: 0 4px;
- background: #ccc;
- color: white;
-}
-.missing-if-branch .typ, .skip-if-branch .typ {
- color: inherit !important;
-}
-.coverage-summary {
- border-collapse: collapse;
- width: 100%;
-}
-.coverage-summary tr { border-bottom: 1px solid #bbb; }
-.keyline-all { border: 1px solid #ddd; }
-.coverage-summary td, .coverage-summary th { padding: 10px; }
-.coverage-summary tbody { border: 1px solid #bbb; }
-.coverage-summary td { border-right: 1px solid #bbb; }
-.coverage-summary td:last-child { border-right: none; }
-.coverage-summary th {
- text-align: left;
- font-weight: normal;
- white-space: nowrap;
-}
-.coverage-summary th.file { border-right: none !important; }
-.coverage-summary th.pct { }
-.coverage-summary th.pic,
-.coverage-summary th.abs,
-.coverage-summary td.pct,
-.coverage-summary td.abs { text-align: right; }
-.coverage-summary td.file { white-space: nowrap; }
-.coverage-summary td.pic { min-width: 120px !important; }
-.coverage-summary tfoot td { }
-
-.coverage-summary .sorter {
- height: 10px;
- width: 7px;
- display: inline-block;
- margin-left: 0.5em;
- background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent;
-}
-.coverage-summary .sorted .sorter {
- background-position: 0 -20px;
-}
-.coverage-summary .sorted-desc .sorter {
- background-position: 0 -10px;
-}
-.status-line { height: 10px; }
-/* yellow */
-.cbranch-no { background: yellow !important; color: #111; }
-/* dark red */
-.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 }
-.low .chart { border:1px solid #C21F39 }
-.highlighted,
-.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{
- background: #C21F39 !important;
-}
-/* medium red */
-.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE }
-/* light red */
-.low, .cline-no { background:#FCE1E5 }
-/* light green */
-.high, .cline-yes { background:rgb(230,245,208) }
-/* medium green */
-.cstat-yes { background:rgb(161,215,106) }
-/* dark green */
-.status-line.high, .high .cover-fill { background:rgb(77,146,33) }
-.high .chart { border:1px solid rgb(77,146,33) }
-/* dark yellow (gold) */
-.status-line.medium, .medium .cover-fill { background: #f9cd0b; }
-.medium .chart { border:1px solid #f9cd0b; }
-/* light yellow */
-.medium { background: #fff4c2; }
-
-.cstat-skip { background: #ddd; color: #111; }
-.fstat-skip { background: #ddd; color: #111 !important; }
-.cbranch-skip { background: #ddd !important; color: #111; }
-
-span.cline-neutral { background: #eaeaea; }
-
-.coverage-summary td.empty {
- opacity: .5;
- padding-top: 4px;
- padding-bottom: 4px;
- line-height: 1;
- color: #888;
-}
-
-.cover-fill, .cover-empty {
- display:inline-block;
- height: 12px;
-}
-.chart {
- line-height: 0;
-}
-.cover-empty {
- background: white;
-}
-.cover-full {
- border-right: none !important;
-}
-pre.prettyprint {
- border: none !important;
- padding: 0 !important;
- margin: 0 !important;
-}
-.com { color: #999 !important; }
-.ignore-none { color: #999; font-weight: normal; }
-
-.wrapper {
- min-height: 100%;
- height: auto !important;
- height: 100%;
- margin: 0 auto -48px;
-}
-.footer, .push {
- height: 48px;
-}
diff --git a/apps/server/test/coverage-unit/lcov-report/favicon.png b/apps/server/test/coverage-unit/lcov-report/favicon.png
deleted file mode 100644
index c1525b811a167671e9de1fa78aab9f5c0b61cef7..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 445
zcmeAS@N?(olHy`uVBq!ia0y~yU=RRd4kiW$h6xih%orFLTRdGHLn;`zo@efna1>#C
zP+yQ1a@(xwR@vcOg|93^@4RTb&rJ?ebaY4}0YgS)l{}^%au-
z_;}9Zak48mJg9F`WyPkDf8Sd#=J(R3x$KX>%X`|@oLYbHga7uVXX@u){?wjyMnO+1
zTuHI+XIsFTuRkt*ZISfi{Q1E1(+AH@-!uMvG(H%&t*H1$^$z2LHOBP|-?Q^PxVHDn
zE8Sgx8|3U1=T6uDT=Zi0;l^IynQwJtgb(g2vAt1la&Mp7X_vDT_Q)j8Yy0|>yZ&F7
z>gzqTLa*+5sbwwg_2_Z>x$}aP)2jaN%AU;Neb~!aH0t4llQ(#3f3m3>T?$;c<;CUD
z*(?#4ax%qTk1L*g*`0EZDPVC|MvOnx2~Y9;F|DGdfE4vw1A9SNBas(w7%RP$}py8DW05-uwO!uq6UDXGquW&3{VYqqD7g#7*w
zi_^dSQSIK!D!Gi|=%q_%GNyQ3
-
-
-
- Code coverage report for All files
-
-
-
-
-
-
-
-
-
-
-
-
All files
-
-
-
- Unknown%
- Statements
- 0/0
-
-
-
-
- Unknown%
- Branches
- 0/0
-
-
-
-
- Unknown%
- Functions
- 0/0
-
-
-
-
- Unknown%
- Lines
- 0/0
-
-
-
-
-
- Press n or j to go to the next uncovered block, b , p or k for the previous block.
-
-
-
- Filter:
-
-
-
-
-
-
-
-
-
- File
-
- Statements
-
- Branches
-
- Functions
-
- Lines
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/apps/server/test/coverage-unit/lcov-report/prettify.css b/apps/server/test/coverage-unit/lcov-report/prettify.css
deleted file mode 100644
index b317a7c..0000000
--- a/apps/server/test/coverage-unit/lcov-report/prettify.css
+++ /dev/null
@@ -1 +0,0 @@
-.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee}
diff --git a/apps/server/test/coverage-unit/lcov-report/sort-arrow-sprite.png b/apps/server/test/coverage-unit/lcov-report/sort-arrow-sprite.png
deleted file mode 100644
index 6ed68316eb3f65dec9063332d2f69bf3093bbfab..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 138
zcmeAS@N?(olHy`uVBq!ia0y~yU|?rpV36iuV_;y|;5OBmfq@~=)5S5Qf-$*3|3hQ4
zf`Qw!#U2L?e0W@$Vi_Zt#MxdR>HO#=WF|J7#c{m=56hexM(h6^Z(XQmpqg+jN#nrN
tssE?!n)YuJ%U&k~fdV%9gAMEqevg;zSU<7i3IhWJgQu&X%Q~loCIA|(EZhJ9
diff --git a/apps/server/test/coverage-unit/lcov.info b/apps/server/test/coverage-unit/lcov.info
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/server/test/jest-e2e.json b/apps/server/test/jest-e2e.json
deleted file mode 100644
index 0e9d611..0000000
--- a/apps/server/test/jest-e2e.json
+++ /dev/null
@@ -1,36 +0,0 @@
-{
- "preset": "ts-jest",
- "moduleFileExtensions": ["js", "json", "ts"],
- "rootDir": ".",
- "testEnvironment": "node",
- "testRegex": ".e2e-spec.ts$",
- "transform": {
- "^.+\\.(t|j)s$": [
- "ts-jest",
- {
- "diagnostics": false,
- "tsconfig": {
- "sourceMap": true,
- "inlineSourceMap": true
- }
- }
- ]
- },
- "moduleNameMapper": {
- "^@/(.*)$": "/../src/$1"
- },
- "transformIgnorePatterns": ["node_modules/(?!(@faker-js|@cv)/)"],
- "collectCoverage": true,
- "collectCoverageFrom": [
- "/../src/**/*.{ts,tsx}",
- "!/../src/main.ts"
- ],
- "coverageDirectory": "/coverage",
- "coverageReporters": ["text", "lcov"],
- "coverageProvider": "babel",
- "coveragePathIgnorePatterns": [
- "/node_modules/",
- "/../src/.*/index\\.ts$",
- "/../src/.*/__mocks__/"
- ]
-}
diff --git a/apps/server/test/jest-unit.json b/apps/server/test/jest-unit.json
deleted file mode 100644
index 8c5d726..0000000
--- a/apps/server/test/jest-unit.json
+++ /dev/null
@@ -1,33 +0,0 @@
-{
- "preset": "ts-jest",
- "moduleFileExtensions": ["js", "json", "ts"],
- "rootDir": ".",
- "testEnvironment": "node",
- "testRegex": ".*([-.])?spec\\.ts$",
- "transform": {
- "^.+\\.(t|j)s$": [
- "ts-jest",
- {
- "diagnostics": false,
- "tsconfig": {
- "sourceMap": true,
- "inlineSourceMap": true
- }
- }
- ]
- },
- "moduleNameMapper": {
- "^@/(.*)$": "/../src/$1"
- },
- "transformIgnorePatterns": ["node_modules/(?!(@faker-js|@cv)/)"],
- "collectCoverage": true,
- "collectCoverageFrom": [
- "/../src/**/*.{ts,tsx}",
- "!/../src/main.ts",
- "!/../src/**/index.ts",
- "!/../src/**/__mocks__/**"
- ],
- "coverageDirectory": "/coverage-unit",
- "coverageReporters": ["text", "lcov"],
- "coverageProvider": "babel"
-}
diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts
deleted file mode 100644
index f1c90ca..0000000
--- a/apps/server/vitest.config.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { defineConfig } from "vitest/config";
-
-export default defineConfig({
- test: {
- globals: true,
- environment: "node",
- testTimeout: 60000, // 60 seconds default
- hookTimeout: 60000,
- include: ["**/*.{test,spec,integration}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
- exclude: [
- "**/node_modules/**",
- "**/dist/**",
- "**/build/**",
- "**/__tests__/**/*.spec.ts",
- ], // Exclude Jest tests
- },
-});
diff --git a/apps/worker/package.json b/apps/worker/package.json
index 063c5cd..342b109 100644
--- a/apps/worker/package.json
+++ b/apps/worker/package.json
@@ -12,11 +12,12 @@
"lint:fix": "biome check --write ."
},
"dependencies": {
+ "@cv/core": "workspace:*",
"@cv/file-storage": "*",
- "@cv/system": "*",
- "@nestjs/common": "^10.4.7",
- "@nestjs/config": "^3.2.0",
- "@nestjs/core": "^10.4.7",
+ "@cv/handlers": "workspace:*",
+ "@nestjs/common": "^11.1.18",
+ "@nestjs/config": "^4.0.4",
+ "@nestjs/core": "^11.1.18",
"@nestjs/event-emitter": "^3.0.1",
"@riotbyte-com/nest-service-locator": "0.3.0-rc.4",
"@riotbyte-com/project-q-core": "1.0.19-rc.3",
@@ -31,7 +32,9 @@
"zod": "^4.3.6"
},
"devDependencies": {
+ "@aws-sdk/client-s3": "^3.800.0",
"@cv/tsconfig": "*",
+ "@types/pg": "^8.15.0",
"@swc/core": "^1.15.13",
"@types/node": "^22.7.5",
"nodemon": "^3.1.7",
diff --git a/apps/worker/src/config.ts b/apps/worker/src/config.ts
index 05000da..c88374c 100644
--- a/apps/worker/src/config.ts
+++ b/apps/worker/src/config.ts
@@ -20,11 +20,15 @@ export const config = {
heartbeatFilePath:
process.env["HEARTBEAT_FILE_PATH"] ?? "/tmp/worker-heartbeat",
heartbeatDbIntervalMs: Number(process.env["HEARTBEAT_DB_INTERVAL_MS"] ?? "0"),
- chromiumArgs: (process.env["CHROMIUM_ARGS"] ?? "--no-sandbox,--disable-dev-shm-usage")
+ chromiumArgs: (
+ process.env["CHROMIUM_ARGS"] ?? "--no-sandbox,--disable-dev-shm-usage"
+ )
.split(",")
.map((s) => s.trim())
.filter(Boolean),
- fileStorageDriver: (process.env["FILE_STORAGE_DRIVER"] ?? "disk") as "disk" | "r2",
+ fileStorageDriver: (process.env["FILE_STORAGE_DRIVER"] ?? "disk") as
+ | "disk"
+ | "r2",
r2Endpoint: process.env["R2_ENDPOINT"] ?? "",
r2AccessKeyId: process.env["R2_ACCESS_KEY_ID"] ?? "",
r2SecretAccessKey: process.env["R2_SECRET_ACCESS_KEY"] ?? "",
diff --git a/apps/worker/src/heartbeat/heartbeat.listener.ts b/apps/worker/src/heartbeat/heartbeat.listener.ts
index d8d669c..85d13aa 100644
--- a/apps/worker/src/heartbeat/heartbeat.listener.ts
+++ b/apps/worker/src/heartbeat/heartbeat.listener.ts
@@ -17,9 +17,14 @@ export class HeartbeatListener implements OnModuleInit {
) {}
onModuleInit(): void {
- this.emitter.on(WorkerStartedEvent.name, async (event: WorkerStartedEvent) => {
- await Promise.all(this.strategies.map((s) => s.onStart(event.worker.id)));
- });
+ this.emitter.on(
+ WorkerStartedEvent.name,
+ async (event: WorkerStartedEvent) => {
+ await Promise.all(
+ this.strategies.map((s) => s.onStart(event.worker.id)),
+ );
+ },
+ );
this.emitter.on(WorkerRunningEvent.name, async () => {
await Promise.all(this.strategies.map((s) => s.onRunning()));
diff --git a/apps/worker/src/worker.module.ts b/apps/worker/src/worker.module.ts
index 8d9845c..c47a7d7 100644
--- a/apps/worker/src/worker.module.ts
+++ b/apps/worker/src/worker.module.ts
@@ -1,16 +1,25 @@
+import { DatabaseModule, PrismaService } from "@cv/core";
+import { FileStorageModule } from "@cv/file-storage";
+import {
+ HandlersModule,
+ HtmlToPdfService,
+ RenderPdfHandler,
+} from "@cv/handlers";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { EventEmitterModule } from "@nestjs/event-emitter";
-import { DatabaseModule, PrismaService } from "@cv/system";
-import { FileStorageModule } from "@cv/file-storage";
-import { LoggerProvider, MessengerModule } from "@riotbyte-com/project-q-nestjs";
-import { PrismaClientToken, PrismaTransportFactory } from "@riotbyte-com/project-q-prisma";
+import {
+ LoggerProvider,
+ MessengerModule,
+} from "@riotbyte-com/project-q-nestjs";
+import {
+ PrismaClientToken,
+ PrismaTransportFactory,
+} from "@riotbyte-com/project-q-prisma";
import type { WorkerConfig } from "./config";
import { config } from "./config";
-import { RenderPdfHandler } from "./handlers/render-pdf.handler";
import { HeartbeatModule } from "./heartbeat/heartbeat.module";
import { NestProjectQLogger } from "./logger.provider";
-import { HtmlToPdfService } from "./pdf/html-to-pdf.service";
export const WORKER_CONFIG = Symbol("WORKER_CONFIG");
@@ -22,6 +31,7 @@ export const WORKER_CONFIG = Symbol("WORKER_CONFIG");
}),
DatabaseModule,
EventEmitterModule.forRoot(),
+ HandlersModule,
HeartbeatModule.register(config),
FileStorageModule.forRoot(
config.fileStorageDriver === "r2"
@@ -46,8 +56,6 @@ export const WORKER_CONFIG = Symbol("WORKER_CONFIG");
{ provide: PrismaClientToken, useExisting: PrismaService },
{ provide: LoggerProvider, useClass: NestProjectQLogger },
PrismaTransportFactory,
- RenderPdfHandler,
- HtmlToPdfService,
],
})
export class WorkerModule {}
diff --git a/apps/worker/tsconfig.json b/apps/worker/tsconfig.json
index 33d1d3b..1c092e3 100644
--- a/apps/worker/tsconfig.json
+++ b/apps/worker/tsconfig.json
@@ -2,7 +2,7 @@
"extends": "../../packages/tsconfig/tsconfig.node.json",
"compilerOptions": {
"outDir": "dist",
- "preserveSymlinks": true,
+ "preserveSymlinks": false,
"types": ["node"]
},
"include": ["src/**/*.ts"],
diff --git a/ci/deploy.sh b/ci/deploy.sh
index ae0739e..3de98e7 100755
--- a/ci/deploy.sh
+++ b/ci/deploy.sh
@@ -38,14 +38,14 @@ echo "==> Building images"
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" build
echo "==> Running database migrations"
-docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm server \
- sh -c "cd /app/apps/server && npx prisma migrate deploy"
+docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm api \
+ sh -c "cd /app/packages/core && npx prisma migrate deploy"
echo "==> Starting services"
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d
echo "==> Seeding database (idempotent)"
-docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" exec server \
+docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" exec api \
node dist/scripts/seed.js
echo "==> Cleaning up old images"
diff --git a/docker-compose.yml b/docker-compose.yml
index 8d6d803..37be049 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -15,10 +15,10 @@ services:
timeout: 5s
retries: 3
- server:
+ api:
build:
context: .
- dockerfile: .docker/server.Dockerfile
+ dockerfile: .docker/api.Dockerfile
target: development
additional_contexts:
project-q: ${PROJECT_Q_PATH:?Set PROJECT_Q_PATH to your local project-q checkout}
@@ -44,16 +44,16 @@ services:
ports:
- "${SERVER_PORT:-3000}:3000"
volumes:
- - ./apps/server/src:/app/apps/server/src
- - ./apps/server/prisma:/app/apps/server/prisma
+ - ./apps/api/src:/app/apps/api/src
+ - ./apps/api/prisma:/app/apps/api/prisma
- ./packages/ai-parser/src:/app/packages/ai-parser/src
- ./packages/ai-provider/src:/app/packages/ai-provider/src
- - ./packages/auth/src:/app/packages/auth/src
+ - ./packages/core/src:/app/packages/core/src
- ./packages/cv-renderer/src:/app/packages/cv-renderer/src
- ./packages/file-upload/src:/app/packages/file-upload/src
- - ./packages/system/src:/app/packages/system/src
+ - ./packages/core/src:/app/packages/core/src
- worker-output:/app/pdf-output:ro
- command: sh -c "cd /app/apps/server && pnpm prisma generate && pnpm prisma:deploy && pnpm dev"
+ command: sh -c "cd /app/apps/api && pnpm prisma generate && pnpm prisma:deploy && pnpm dev"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 15s
@@ -68,11 +68,11 @@ services:
target: development
environment:
VITE_SERVER_URL: ${VITE_SERVER_URL:-http://localhost:3000}
- VITE_PROXY_TARGET: http://server:3000
+ VITE_PROXY_TARGET: http://api:3000
VITE_DOCS_URL: ${VITE_DOCS_URL:-http://localhost:3001}
- GRAPHQL_SCHEMA_URL: ${GRAPHQL_SCHEMA_URL:-http://server:3000/graphql}
+ GRAPHQL_SCHEMA_URL: ${GRAPHQL_SCHEMA_URL:-http://api:3000/graphql}
depends_on:
- server:
+ api:
condition: service_healthy
ports:
- "${CLIENT_PORT:-5173}:5173"
@@ -80,7 +80,7 @@ services:
- ./apps/client/src:/app/apps/client/src
- ./packages/routing/src:/app/packages/routing/src
- ./packages/ui/src:/app/packages/ui/src
- - ./packages/system/src:/app/packages/system/src
+ - ./packages/core/src:/app/packages/core/src
command: sh -c "cd /app/apps/client && (pnpm codegen || true) && pnpm dev"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5173"]
@@ -133,7 +133,7 @@ services:
- ./apps/docs/content:/app/apps/docs/content
- ./packages/routing/src:/app/packages/routing/src
- ./packages/ui/src:/app/packages/ui/src
- - ./packages/system/src:/app/packages/system/src
+ - ./packages/core/src:/app/packages/core/src
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001"]
interval: 15s
diff --git a/packages/ai-parser/jest.config.ts b/packages/ai-parser/jest.config.ts
deleted file mode 100644
index 7535977..0000000
--- a/packages/ai-parser/jest.config.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import type { Config } from "jest";
-
-const config: Config = {
- preset: "ts-jest",
- testEnvironment: "node",
- rootDir: ".",
- moduleFileExtensions: ["ts", "tsx", "js", "json"],
- testMatch: ["/src/**/*.spec.ts", "/src/**/*.test.ts"],
- transform: {
- "^.+\\.(t|j)sx?$": [
- "ts-jest",
- {
- diagnostics: false,
- tsconfig: {
- sourceMap: true,
- inlineSourceMap: true,
- },
- },
- ],
- },
- collectCoverage: true,
- collectCoverageFrom: [
- "/src/**/*.ts",
- "!/src/**/index.ts",
- "!/src/**/__tests__/**",
- ],
- coverageDirectory: "/coverage",
- coverageReporters: ["text", "lcov"],
-};
-
-export default config;
diff --git a/packages/ai-parser/package.json b/packages/ai-parser/package.json
index d7ef9fe..47a5ae8 100644
--- a/packages/ai-parser/package.json
+++ b/packages/ai-parser/package.json
@@ -6,9 +6,9 @@
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
- "test": "jest",
- "test:watch": "jest --watch",
- "test:coverage": "jest --coverage"
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:coverage": "vitest run --coverage"
},
"dependencies": {
"@cv/ai-provider": "*",
@@ -20,10 +20,8 @@
},
"devDependencies": {
"@cv/tsconfig": "*",
- "@types/jest": "^29.5.0",
"@types/node": "^22.7.5",
- "jest": "^29.7.0",
- "ts-jest": "^29.1.1",
- "typescript": "^5.3.3"
+ "typescript": "^5.3.3",
+ "vitest": "^4.0.0"
}
}
diff --git a/packages/ai-parser/src/__tests__/ai-parser.service.spec.ts b/packages/ai-parser/src/__tests__/ai-parser.service.spec.ts
index 6d25829..e05ddaf 100644
--- a/packages/ai-parser/src/__tests__/ai-parser.service.spec.ts
+++ b/packages/ai-parser/src/__tests__/ai-parser.service.spec.ts
@@ -1,10 +1,11 @@
import type { AIProvider, AICompletionResponse } from "@cv/ai-provider";
+import type { Mocked } from "vitest";
import { CVParserService } from "../ai-parser.service";
import type { ParsedCVData } from "../schemas";
describe("CVParserService", () => {
let service: CVParserService;
- let mockProvider: jest.Mocked;
+ let mockProvider: Mocked;
const validParsedCVData: ParsedCVData = {
personalInfo: {
@@ -45,8 +46,8 @@ describe("CVParserService", () => {
beforeEach(() => {
mockProvider = {
name: "mock-provider",
- complete: jest.fn(),
- isHealthy: jest.fn().mockResolvedValue(true),
+ complete: vi.fn(),
+ isHealthy: vi.fn().mockResolvedValue(true),
};
service = new CVParserService(mockProvider);
diff --git a/packages/ai-parser/src/ai-parser.service.test.ts b/packages/ai-parser/src/ai-parser.service.test.ts
index d4015bf..fdb0581 100644
--- a/packages/ai-parser/src/ai-parser.service.test.ts
+++ b/packages/ai-parser/src/ai-parser.service.test.ts
@@ -1,4 +1,3 @@
-import { describe, it, expect, jest } from '@jest/globals';
import type { AIProvider, AICompletionResponse } from '@cv/ai-provider';
import { CVParserService } from './ai-parser.service';
@@ -7,12 +6,12 @@ describe('CVParserService Unit Tests', () => {
mockResponse: string
): AIProvider => ({
name: 'mock',
- complete: jest.fn(async () => ({
+ complete: vi.fn(async () => ({
content: mockResponse,
model: 'mock-model',
finishReason: 'stop',
} as AICompletionResponse)),
- isHealthy: jest.fn(async () => true),
+ isHealthy: vi.fn(async () => true),
});
describe('JSON Extraction', () => {
diff --git a/packages/ai-parser/vitest.config.ts b/packages/ai-parser/vitest.config.ts
new file mode 100644
index 0000000..5751952
--- /dev/null
+++ b/packages/ai-parser/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ root: "./src",
+ globals: true,
+ },
+});
diff --git a/packages/ai-provider/jest.config.ts b/packages/ai-provider/jest.config.ts
deleted file mode 100644
index 4ee37ae..0000000
--- a/packages/ai-provider/jest.config.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import type { Config } from 'jest';
-
-const config: Config = {
- displayName: 'ai-provider',
- preset: 'ts-jest',
- testEnvironment: 'node',
- roots: ['/src'],
- testMatch: ['**/*.test.ts'],
-
- // Skip integration tests by default (they're slow)
- testPathIgnorePatterns: ['/node_modules/', '.integration.test.ts$'],
-
- collectCoverageFrom: [
- 'src/**/*.ts',
- '!src/**/*.test.ts',
- '!src/**/*.integration.test.ts',
- '!src/**/*.d.ts',
- ],
-
- moduleNameMapper: {
- '^@cv/(.*)$': '/../$1/src',
- },
-};
-
-export default config;
diff --git a/packages/ai-provider/package.json b/packages/ai-provider/package.json
index 4854bb0..d4bb466 100644
--- a/packages/ai-provider/package.json
+++ b/packages/ai-provider/package.json
@@ -6,10 +6,10 @@
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
- "test": "jest",
- "test:watch": "jest --watch",
- "test:integration": "jest --testPathPattern='.integration.test.ts$' --runInBand",
- "test:all": "jest --testPathIgnorePatterns=[]"
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:integration": "vitest run --config vitest.integration.config.ts",
+ "test:all": "vitest run --config vitest.integration.config.ts"
},
"dependencies": {
"@nestjs/common": "^11.1.3",
@@ -22,10 +22,8 @@
},
"devDependencies": {
"@cv/tsconfig": "*",
- "@types/jest": "^29.5.12",
"@types/node": "^22.7.5",
- "jest": "^29.7.0",
- "ts-jest": "^29.1.2",
- "typescript": "^5.3.3"
+ "typescript": "^5.3.3",
+ "vitest": "^4.0.0"
}
}
diff --git a/packages/ai-provider/src/providers/llama-cpp.provider.integration.test.ts b/packages/ai-provider/src/providers/llama-cpp.provider.integration.test.ts
index f7e07ad..393ef03 100644
--- a/packages/ai-provider/src/providers/llama-cpp.provider.integration.test.ts
+++ b/packages/ai-provider/src/providers/llama-cpp.provider.integration.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect, beforeAll } from '@jest/globals';
+import { describe, it, expect, beforeAll } from 'vitest';
import { LlamaCppProvider } from './llama-cpp.provider';
describe('LlamaCppProvider Integration Tests', () => {
diff --git a/packages/ai-provider/vitest.config.ts b/packages/ai-provider/vitest.config.ts
new file mode 100644
index 0000000..db95ea5
--- /dev/null
+++ b/packages/ai-provider/vitest.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ root: "./src",
+ globals: true,
+ exclude: ["**/*.integration.test.ts"],
+ passWithNoTests: true,
+ },
+});
diff --git a/packages/ai-provider/vitest.integration.config.ts b/packages/ai-provider/vitest.integration.config.ts
new file mode 100644
index 0000000..89820f2
--- /dev/null
+++ b/packages/ai-provider/vitest.integration.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ root: "./src",
+ globals: true,
+ include: ["**/*.integration.test.ts"],
+ },
+});
diff --git a/packages/auth/README.md b/packages/auth/README.md
deleted file mode 100644
index c4aff8b..0000000
--- a/packages/auth/README.md
+++ /dev/null
@@ -1,84 +0,0 @@
-# @cv/auth
-
-Authentication and authorization package for the CV Generator monorepo.
-
-## Overview
-
-This package provides reusable authentication and authorization infrastructure that can be used across multiple applications in the monorepo. It includes user management, token handling, guards, policies, and identity provider system.
-
-## Package Structure
-
-```
-src/
-├── auth.module.ts # Main module (global)
-├── user/ # User domain entities and services
-│ ├── user.entity.ts
-│ ├── user.service.ts
-│ ├── user.mapper.ts
-│ ├── credentials.entity.ts
-│ ├── credentials.service.ts
-│ └── credentials.mapper.ts
-├── token/ # Token management
-│ ├── token.service.ts
-│ ├── refresh-token.service.ts
-│ ├── auth-cookie.service.ts
-│ └── token-expiry.config.ts
-├── guards/ # Authentication guards
-│ ├── jwt-auth.guard.ts
-│ └── verified-scope.guard.ts
-├── authorization/ # Authorization system
-│ ├── authorization.service.ts
-│ ├── policy-registry.service.ts
-│ ├── policy.interface.ts
-│ └── *-resource.policy.ts
-├── providers/ # Identity providers
-│ └── password/
-│ ├── password-identity-provider.ts
-│ ├── password-authentication.service.ts
-│ └── credentials/ # Email templates and listeners
-├── config/ # Configuration services
-├── cookie/ # Cookie utilities
-├── metadata/ # Device and location services
-├── request/ # Request decorators
-├── jwt/ # JWT utilities
-└── errors/ # Authentication/authorization errors
-```
-
-## Usage
-
-### Installation
-
-This package is part of the monorepo and is automatically available to other packages via npm workspaces.
-
-### Importing
-
-```typescript
-import { AuthModule, JwtAuthGuard, UserService } from "@cv/auth";
-```
-
-### Global Module
-
-`AuthModule` is marked as `@Global()`, which means once imported in the root module, all its exports are available throughout the application without needing to import it in every module.
-
-### Key Exports
-
-- **Modules**: `AuthModule`, `UserModule`, `TokenModule`, `AuthorizationModule`, `PasswordProviderModule`
-- **Services**: `UserService`, `TokenService`, `AuthorizationService`, `PasswordAuthenticationService`
-- **Guards**: `JwtAuthGuard`, `VerifiedScopeGuard`
-- **Entities**: `User`, `Credentials`, `RefreshToken`
-- **Utilities**: `RequestMetadata`, `CookieService`, `AuthCookieService`
-
-## Dependencies
-
-- **Peer Dependencies**: `@cv/system`, `@nestjs/common`, `@nestjs/core`, `@nestjs/jwt`, `@nestjs/config`
-- **Internal Dependencies**: Only `@cv/system` (infrastructure layer)
-
-## Architecture
-
-This package follows the three-layer architecture:
-- **Domain Layer**: Entities (`User`, `Credentials`, `RefreshToken`)
-- **Service Layer**: Business logic services
-- **Guard/Policy Layer**: NestJS guards and authorization policies
-
-All entities are domain entities that contain business logic and rules, not Prisma models.
-
diff --git a/packages/auth/package.json b/packages/auth/package.json
index ad1aaaa..61faa66 100644
--- a/packages/auth/package.json
+++ b/packages/auth/package.json
@@ -4,52 +4,26 @@
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
- "exports": {
- ".": {
- "require": "./src/index.ts",
- "import": "./src/index.ts",
- "types": "./src/index.ts"
- }
- },
- "files": [
- "src/"
- ],
"scripts": {
- "lint": "biome check .",
- "lint:fix": "biome check --write .",
- "typecheck": "tsc -b"
+ "typecheck": "tsc --noEmit"
},
"dependencies": {
- "@cv/system": "*",
- "@nestjs/common": "^10.4.7",
- "@nestjs/config": "^3.2.0",
- "@nestjs/core": "^10.4.7",
- "@nestjs/event-emitter": "^3.0.1",
- "@nestjs/graphql": "^12.2.2",
- "@nestjs/jwt": "^10.2.0",
- "bcryptjs": "^2.4.3",
- "graphql": "^16.12.0",
- "reflect-metadata": "^0.2.2",
- "zod": "^4.3.6"
+ "@cv/core": "workspace:*",
+ "@nestjs/common": "^11.1.18",
+ "@nestjs/config": "^4.0.4",
+ "@nestjs/core": "^11.1.18",
+ "@nestjs/graphql": "^13.2.5",
+ "@nestjs/jwt": "^11.0.2",
+ "@nestjs/passport": "^11.0.5",
+ "express": "^5.1.0",
+ "passport": "^0.7.0",
+ "passport-jwt": "^4.0.1",
+ "reflect-metadata": "^0.2.2"
},
"devDependencies": {
- "@biomejs/biome": "^2.2.6",
- "@cv/biome-config": "*",
"@cv/tsconfig": "*",
- "@types/bcryptjs": "^2.4.0",
"@types/express": "^5.0.0",
- "@types/express-serve-static-core": "^5.0.0",
- "typescript": "^5.6.3"
- },
- "peerDependencies": {
- "@cv/system": "*",
- "@nestjs/common": "^10.4.7",
- "@nestjs/config": "^3.2.0",
- "@nestjs/core": "^10.4.7",
- "@nestjs/event-emitter": "^3.0.1",
- "@nestjs/graphql": "^12.2.2",
- "@nestjs/jwt": "^10.2.0",
- "bcryptjs": "^2.4.3",
- "graphql": "^16.12.0"
+ "@types/passport-jwt": "^4.0.1",
+ "typescript": "^5.3.3"
}
}
diff --git a/packages/auth/src/auth.module.ts b/packages/auth/src/auth.module.ts
index 11261c7..46d43a4 100644
--- a/packages/auth/src/auth.module.ts
+++ b/packages/auth/src/auth.module.ts
@@ -1,11 +1,13 @@
import { Global, Module } from "@nestjs/common";
import { DiscoveryModule } from "@nestjs/core";
-import { AuthorizationModule } from "./authorization/authorization.module";
+import {
+ AuthorizationModule,
+ IdentityProviderRegistry,
+ TokenModule,
+ UserModule,
+} from "@cv/core";
import { JwtAuthGuard } from "./guards/jwt-auth.guard";
import { VerifiedScopeGuard } from "./guards/verified-scope.guard";
-import { IdentityProviderRegistry } from "./identity-provider-registry.service";
-import { TokenModule } from "./token/token.module";
-import { UserModule } from "./user/user.module";
@Global()
@Module({
diff --git a/packages/auth/src/guards/admin.guard.ts b/packages/auth/src/guards/admin.guard.ts
index 498674a..5fd4dd4 100644
--- a/packages/auth/src/guards/admin.guard.ts
+++ b/packages/auth/src/guards/admin.guard.ts
@@ -5,7 +5,7 @@ import {
Injectable,
} from "@nestjs/common";
import { GqlExecutionContext } from "@nestjs/graphql";
-import type { User } from "../user/user.entity";
+import type { User } from "@cv/core";
/**
* Guard that restricts access to admin users.
diff --git a/packages/auth/src/guards/jwt-auth.guard.ts b/packages/auth/src/guards/jwt-auth.guard.ts
index 886f94b..f9105c5 100644
--- a/packages/auth/src/guards/jwt-auth.guard.ts
+++ b/packages/auth/src/guards/jwt-auth.guard.ts
@@ -5,12 +5,12 @@ import {
} from "@nestjs/common";
import { GqlExecutionContext } from "@nestjs/graphql";
import { JwtService } from "@nestjs/jwt";
-import { EntityNotFoundError } from "../errors";
import {
+ EntityNotFoundError,
InvalidTokenError,
NoTokenError,
-} from "../errors/authentication.error";
-import { UserService } from "../user/user.service";
+ UserService,
+} from "@cv/core";
@Injectable()
export class JwtAuthGuard implements CanActivate {
diff --git a/packages/auth/src/guards/verified-scope.guard.ts b/packages/auth/src/guards/verified-scope.guard.ts
index 187b7e3..e8473d4 100644
--- a/packages/auth/src/guards/verified-scope.guard.ts
+++ b/packages/auth/src/guards/verified-scope.guard.ts
@@ -6,7 +6,7 @@ import {
} from "@nestjs/common";
import { GqlExecutionContext } from "@nestjs/graphql";
import { JwtService } from "@nestjs/jwt";
-import { JwtScope } from "../jwt/jwt-scope.enum";
+import { JwtScope } from "@cv/core";
@Injectable()
export class VerifiedScopeGuard implements CanActivate {
diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts
index 86b1ffd..c9fb708 100644
--- a/packages/auth/src/index.ts
+++ b/packages/auth/src/index.ts
@@ -1,19 +1,2 @@
export * from "./auth.module";
-export * from "./authorization";
-export * from "./config";
-export * from "./cookie";
-export * from "./errors";
export * from "./guards";
-export {
- IDENTITY_PROVIDER_KEY,
- IdentityProvider as IdentityProviderDecorator,
- type IdentityProviderMeta,
-} from "./identity-provider.decorator";
-export type { IdentityProvider } from "./identity-provider.interface";
-export * from "./identity-provider-registry.service";
-export * from "./jwt";
-export * from "./metadata";
-export * from "./providers";
-export * from "./request";
-export * from "./token";
-export * from "./user";
diff --git a/packages/auth/src/providers/password/credentials/events/index.ts b/packages/auth/src/providers/password/credentials/events/index.ts
deleted file mode 100644
index 93331b9..0000000
--- a/packages/auth/src/providers/password/credentials/events/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export * from "./password-reset-requested.event";
-export * from "./registration-attempt-on-existing-email.event";
-export * from "./verification-email-requested.event";
diff --git a/packages/auth/src/providers/password/credentials/events/password-reset-requested.event.ts b/packages/auth/src/providers/password/credentials/events/password-reset-requested.event.ts
deleted file mode 100644
index 6c86556..0000000
--- a/packages/auth/src/providers/password/credentials/events/password-reset-requested.event.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import type { Event } from "@cv/system";
-
-export const PASSWORD_RESET_REQUESTED = Symbol("password-reset-requested");
-
-export class PasswordResetRequestedEvent implements Event {
- readonly eventName = PASSWORD_RESET_REQUESTED;
-
- constructor(
- public readonly email: string,
- public readonly resetToken: string,
- ) {}
-}
diff --git a/packages/auth/src/providers/password/credentials/events/registration-attempt-on-existing-email.event.ts b/packages/auth/src/providers/password/credentials/events/registration-attempt-on-existing-email.event.ts
deleted file mode 100644
index cf2b8e4..0000000
--- a/packages/auth/src/providers/password/credentials/events/registration-attempt-on-existing-email.event.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import type { Event } from "@cv/system";
-
-export const REGISTRATION_ATTEMPT_ON_EXISTING_EMAIL = Symbol(
- "registration-attempt-on-existing-email",
-);
-
-export class RegistrationAttemptOnExistingEmailEvent implements Event {
- readonly eventName = REGISTRATION_ATTEMPT_ON_EXISTING_EMAIL;
-
- constructor(public readonly email: string) {}
-}
diff --git a/packages/auth/src/providers/password/credentials/events/verification-email-requested.event.ts b/packages/auth/src/providers/password/credentials/events/verification-email-requested.event.ts
deleted file mode 100644
index 049107e..0000000
--- a/packages/auth/src/providers/password/credentials/events/verification-email-requested.event.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import type { Event } from "@cv/system";
-
-export const VERIFICATION_EMAIL_REQUESTED = Symbol(
- "verification-email-requested",
-);
-
-export class VerificationEmailRequestedEvent implements Event {
- readonly eventName = VERIFICATION_EMAIL_REQUESTED;
-
- constructor(
- public readonly userId: string,
- public readonly email: string,
- public readonly verificationToken: string,
- ) {}
-}
diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json
index 56296f7..93e6448 100644
--- a/packages/auth/tsconfig.json
+++ b/packages/auth/tsconfig.json
@@ -3,13 +3,11 @@
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
- "baseUrl": ".",
- "composite": true,
- "paths": {
- "@/*": ["./src/*"]
- },
- "skipLibCheck": true
+ "resolveJsonModule": true,
+ "preserveSymlinks": false,
+ "experimentalDecorators": true,
+ "emitDecoratorMetadata": true
},
"include": ["src/**/*"],
- "references": [{ "path": "../system" }, { "path": "../utils" }]
+ "exclude": ["src/**/*.spec.ts", "src/**/__tests__/**"]
}
diff --git a/packages/core/package.json b/packages/core/package.json
new file mode 100644
index 0000000..2b6a7c0
--- /dev/null
+++ b/packages/core/package.json
@@ -0,0 +1,70 @@
+{
+ "name": "@cv/core",
+ "version": "0.0.0",
+ "private": true,
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "scripts": {
+ "typecheck": "tsc --noEmit",
+ "build": "tsup"
+ },
+ "dependencies": {
+ "@cv/ai-parser": "workspace:*",
+ "@cv/ai-provider": "workspace:*",
+ "@cv/cv-renderer": "workspace:*",
+ "@cv/file-storage": "workspace:*",
+ "@cv/file-upload": "workspace:*",
+ "@cv/utils": "workspace:*",
+ "@nestjs/common": "^11.1.18",
+ "@nestjs/config": "^4.0.4",
+ "@nestjs/core": "^11.1.18",
+ "@nestjs/event-emitter": "^3.0.1",
+ "@nestjs/graphql": "^13.2.5",
+ "@nestjs/jwt": "^11.0.2",
+ "@nestjs/passport": "^11.0.5",
+ "@nestjs/platform-express": "^11.1.18",
+ "@prisma/adapter-pg": "^7.1.0",
+ "@prisma/client": "^7.1.0",
+ "@riotbyte-com/nest-service-locator": "0.3.0-rc.4",
+ "@riotbyte-com/project-q-core": "1.0.19-rc.3",
+ "@riotbyte-com/project-q-nestjs": "1.0.19-rc.3",
+ "@riotbyte-com/project-q-prisma": "1.0.19-rc.3",
+ "bcryptjs": "^2.4.3",
+ "canvas": "^3.1.0",
+ "dataloader": "^2.2.3",
+ "express": "^5.1.0",
+ "graphql": "^16.12.0",
+ "handlebars": "^4.7.8",
+ "mammoth": "^1.11.0",
+ "multer": "^2.0.2",
+ "nest-commander": "^3.16.0",
+ "nestjs-zod": "^5.1.1",
+ "neverthrow": "^8.2.0",
+ "passport": "^0.7.0",
+ "passport-jwt": "^4.0.1",
+ "passport-local": "^1.0.0",
+ "pdf-parse": "^1.1.4",
+ "pg": "^8.16.3",
+ "reflect-metadata": "^0.2.2",
+ "resend": "^6.5.2",
+ "rxjs": "^7.8.1",
+ "turndown": "^7.2.0",
+ "zod": "^4.3.6"
+ },
+ "devDependencies": {
+ "@cv/tsconfig": "*",
+ "@faker-js/faker": "^9.9.0",
+ "@types/bcryptjs": "^2.4.6",
+ "@types/express": "^5.0.0",
+ "@types/express-serve-static-core": "^5.0.0",
+ "@types/multer": "^2.0.0",
+ "@types/node": "^22.7.5",
+ "@types/passport-jwt": "^4.0.1",
+ "@types/passport-local": "^1.0.38",
+ "@types/pdf-parse": "^1.1.5",
+ "@types/pg": "^8.15.6",
+ "@types/turndown": "^5.0.5",
+ "tsup": "^8.5.1",
+ "typescript": "^5.3.3"
+ }
+}
diff --git a/apps/server/prisma.config.ts b/packages/core/prisma.config.ts
similarity index 100%
rename from apps/server/prisma.config.ts
rename to packages/core/prisma.config.ts
diff --git a/apps/server/prisma/migrations/20251017094729_init/migration.sql b/packages/core/prisma/migrations/20251017094729_init/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251017094729_init/migration.sql
rename to packages/core/prisma/migrations/20251017094729_init/migration.sql
diff --git a/apps/server/prisma/migrations/20251017115341_add_job_entities/migration.sql b/packages/core/prisma/migrations/20251017115341_add_job_entities/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251017115341_add_job_entities/migration.sql
rename to packages/core/prisma/migrations/20251017115341_add_job_entities/migration.sql
diff --git a/apps/server/prisma/migrations/20251018092856_add_organizations/migration.sql b/packages/core/prisma/migrations/20251018092856_add_organizations/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251018092856_add_organizations/migration.sql
rename to packages/core/prisma/migrations/20251018092856_add_organizations/migration.sql
diff --git a/apps/server/prisma/migrations/20251019210751_add_organization_roles/migration.sql b/packages/core/prisma/migrations/20251019210751_add_organization_roles/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251019210751_add_organization_roles/migration.sql
rename to packages/core/prisma/migrations/20251019210751_add_organization_roles/migration.sql
diff --git a/apps/server/prisma/migrations/20251019212422_add_role_colors/migration.sql b/packages/core/prisma/migrations/20251019212422_add_role_colors/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251019212422_add_role_colors/migration.sql
rename to packages/core/prisma/migrations/20251019212422_add_role_colors/migration.sql
diff --git a/apps/server/prisma/migrations/20251019214831_add_vacancies/migration.sql b/packages/core/prisma/migrations/20251019214831_add_vacancies/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251019214831_add_vacancies/migration.sql
rename to packages/core/prisma/migrations/20251019214831_add_vacancies/migration.sql
diff --git a/apps/server/prisma/migrations/20251025120015_add_cv_template_system/migration.sql b/packages/core/prisma/migrations/20251025120015_add_cv_template_system/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251025120015_add_cv_template_system/migration.sql
rename to packages/core/prisma/migrations/20251025120015_add_cv_template_system/migration.sql
diff --git a/apps/server/prisma/migrations/20251026154204_simplify_cv_template_schema/migration.sql b/packages/core/prisma/migrations/20251026154204_simplify_cv_template_schema/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251026154204_simplify_cv_template_schema/migration.sql
rename to packages/core/prisma/migrations/20251026154204_simplify_cv_template_schema/migration.sql
diff --git a/apps/server/prisma/migrations/20251026160424_rename_user_organizations_to_memberships/migration.sql b/packages/core/prisma/migrations/20251026160424_rename_user_organizations_to_memberships/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251026160424_rename_user_organizations_to_memberships/migration.sql
rename to packages/core/prisma/migrations/20251026160424_rename_user_organizations_to_memberships/migration.sql
diff --git a/apps/server/prisma/migrations/20251026203242_add_cv_fields/migration.sql b/packages/core/prisma/migrations/20251026203242_add_cv_fields/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251026203242_add_cv_fields/migration.sql
rename to packages/core/prisma/migrations/20251026203242_add_cv_fields/migration.sql
diff --git a/apps/server/prisma/migrations/20251026203330_refactor_cv_and_vacancy_entities/migration.sql b/packages/core/prisma/migrations/20251026203330_refactor_cv_and_vacancy_entities/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251026203330_refactor_cv_and_vacancy_entities/migration.sql
rename to packages/core/prisma/migrations/20251026203330_refactor_cv_and_vacancy_entities/migration.sql
diff --git a/apps/server/prisma/migrations/20251026203431_add_job_type_entity/migration.sql b/packages/core/prisma/migrations/20251026203431_add_job_type_entity/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251026203431_add_job_type_entity/migration.sql
rename to packages/core/prisma/migrations/20251026203431_add_job_type_entity/migration.sql
diff --git a/apps/server/prisma/migrations/20251026203550_add_vacancy_owner_and_public/migration.sql b/packages/core/prisma/migrations/20251026203550_add_vacancy_owner_and_public/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251026203550_add_vacancy_owner_and_public/migration.sql
rename to packages/core/prisma/migrations/20251026203550_add_vacancy_owner_and_public/migration.sql
diff --git a/apps/server/prisma/migrations/20251026204100_add_application_and_education_entities/migration.sql b/packages/core/prisma/migrations/20251026204100_add_application_and_education_entities/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251026204100_add_application_and_education_entities/migration.sql
rename to packages/core/prisma/migrations/20251026204100_add_application_and_education_entities/migration.sql
diff --git a/apps/server/prisma/migrations/20251026204243_rename_education_to_institution/migration.sql b/packages/core/prisma/migrations/20251026204243_rename_education_to_institution/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251026204243_rename_education_to_institution/migration.sql
rename to packages/core/prisma/migrations/20251026204243_rename_education_to_institution/migration.sql
diff --git a/apps/server/prisma/migrations/20251026204357_remove_userid_from_vacancy/migration.sql b/packages/core/prisma/migrations/20251026204357_remove_userid_from_vacancy/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251026204357_remove_userid_from_vacancy/migration.sql
rename to packages/core/prisma/migrations/20251026204357_remove_userid_from_vacancy/migration.sql
diff --git a/apps/server/prisma/migrations/20251026204432_add_application_status_enum/migration.sql b/packages/core/prisma/migrations/20251026204432_add_application_status_enum/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251026204432_add_application_status_enum/migration.sql
rename to packages/core/prisma/migrations/20251026204432_add_application_status_enum/migration.sql
diff --git a/apps/server/prisma/migrations/20251026204502_remove_education_from_cv/migration.sql b/packages/core/prisma/migrations/20251026204502_remove_education_from_cv/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251026204502_remove_education_from_cv/migration.sql
rename to packages/core/prisma/migrations/20251026204502_remove_education_from_cv/migration.sql
diff --git a/apps/server/prisma/migrations/20251026204631_add_application_status_entity/migration.sql b/packages/core/prisma/migrations/20251026204631_add_application_status_entity/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251026204631_add_application_status_entity/migration.sql
rename to packages/core/prisma/migrations/20251026204631_add_application_status_entity/migration.sql
diff --git a/apps/server/prisma/migrations/20251029130211_add_min_max_salary/migration.sql b/packages/core/prisma/migrations/20251029130211_add_min_max_salary/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251029130211_add_min_max_salary/migration.sql
rename to packages/core/prisma/migrations/20251029130211_add_min_max_salary/migration.sql
diff --git a/apps/server/prisma/migrations/20251029135936_migrate_salary_to_min_max/migration.sql b/packages/core/prisma/migrations/20251029135936_migrate_salary_to_min_max/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251029135936_migrate_salary_to_min_max/migration.sql
rename to packages/core/prisma/migrations/20251029135936_migrate_salary_to_min_max/migration.sql
diff --git a/apps/server/prisma/migrations/20251103203729_add_skills_to_education/migration.sql b/packages/core/prisma/migrations/20251103203729_add_skills_to_education/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251103203729_add_skills_to_education/migration.sql
rename to packages/core/prisma/migrations/20251103203729_add_skills_to_education/migration.sql
diff --git a/apps/server/prisma/migrations/20251207163643_add_credentials_table/migration.sql b/packages/core/prisma/migrations/20251207163643_add_credentials_table/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251207163643_add_credentials_table/migration.sql
rename to packages/core/prisma/migrations/20251207163643_add_credentials_table/migration.sql
diff --git a/apps/server/prisma/migrations/20251207201916_add_email_verification_and_password_reset_fields/migration.sql b/packages/core/prisma/migrations/20251207201916_add_email_verification_and_password_reset_fields/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251207201916_add_email_verification_and_password_reset_fields/migration.sql
rename to packages/core/prisma/migrations/20251207201916_add_email_verification_and_password_reset_fields/migration.sql
diff --git a/apps/server/prisma/migrations/20251207201931_add_unique_constraints_to_tokens/migration.sql b/packages/core/prisma/migrations/20251207201931_add_unique_constraints_to_tokens/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251207201931_add_unique_constraints_to_tokens/migration.sql
rename to packages/core/prisma/migrations/20251207201931_add_unique_constraints_to_tokens/migration.sql
diff --git a/apps/server/prisma/migrations/20251207215106_remove_email_verified_boolean/migration.sql b/packages/core/prisma/migrations/20251207215106_remove_email_verified_boolean/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251207215106_remove_email_verified_boolean/migration.sql
rename to packages/core/prisma/migrations/20251207215106_remove_email_verified_boolean/migration.sql
diff --git a/apps/server/prisma/migrations/20251209174757_add_refresh_tokens/migration.sql b/packages/core/prisma/migrations/20251209174757_add_refresh_tokens/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251209174757_add_refresh_tokens/migration.sql
rename to packages/core/prisma/migrations/20251209174757_add_refresh_tokens/migration.sql
diff --git a/apps/server/prisma/migrations/20251209175507_add_encrypted_refresh_token/migration.sql b/packages/core/prisma/migrations/20251209175507_add_encrypted_refresh_token/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251209175507_add_encrypted_refresh_token/migration.sql
rename to packages/core/prisma/migrations/20251209175507_add_encrypted_refresh_token/migration.sql
diff --git a/apps/server/prisma/migrations/20251209180341_add_device_location_to_refresh_tokens/migration.sql b/packages/core/prisma/migrations/20251209180341_add_device_location_to_refresh_tokens/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20251209180341_add_device_location_to_refresh_tokens/migration.sql
rename to packages/core/prisma/migrations/20251209180341_add_device_location_to_refresh_tokens/migration.sql
diff --git a/apps/server/prisma/migrations/20260207151745_add_user_ai_settings/migration.sql b/packages/core/prisma/migrations/20260207151745_add_user_ai_settings/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20260207151745_add_user_ai_settings/migration.sql
rename to packages/core/prisma/migrations/20260207151745_add_user_ai_settings/migration.sql
diff --git a/apps/server/prisma/migrations/20260207221010_add_user_profile/migration.sql b/packages/core/prisma/migrations/20260207221010_add_user_profile/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20260207221010_add_user_profile/migration.sql
rename to packages/core/prisma/migrations/20260207221010_add_user_profile/migration.sql
diff --git a/apps/server/prisma/migrations/20260209211505_add_data_import_models/migration.sql b/packages/core/prisma/migrations/20260209211505_add_data_import_models/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20260209211505_add_data_import_models/migration.sql
rename to packages/core/prisma/migrations/20260209211505_add_data_import_models/migration.sql
diff --git a/apps/server/prisma/migrations/20260210004726_add_cv_template_content_fields/migration.sql b/packages/core/prisma/migrations/20260210004726_add_cv_template_content_fields/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20260210004726_add_cv_template_content_fields/migration.sql
rename to packages/core/prisma/migrations/20260210004726_add_cv_template_content_fields/migration.sql
diff --git a/apps/server/prisma/migrations/20260210120000_add_ai_call_log/migration.sql b/packages/core/prisma/migrations/20260210120000_add_ai_call_log/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20260210120000_add_ai_call_log/migration.sql
rename to packages/core/prisma/migrations/20260210120000_add_ai_call_log/migration.sql
diff --git a/apps/server/prisma/migrations/20260210214526_add_user_profile_full_name/migration.sql b/packages/core/prisma/migrations/20260210214526_add_user_profile_full_name/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20260210214526_add_user_profile_full_name/migration.sql
rename to packages/core/prisma/migrations/20260210214526_add_user_profile_full_name/migration.sql
diff --git a/apps/server/prisma/migrations/20260216120000_multi_profile/migration.sql b/packages/core/prisma/migrations/20260216120000_multi_profile/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20260216120000_multi_profile/migration.sql
rename to packages/core/prisma/migrations/20260216120000_multi_profile/migration.sql
diff --git a/apps/server/prisma/migrations/20260217120000_add_file_hash/migration.sql b/packages/core/prisma/migrations/20260217120000_add_file_hash/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20260217120000_add_file_hash/migration.sql
rename to packages/core/prisma/migrations/20260217120000_add_file_hash/migration.sql
diff --git a/apps/server/prisma/migrations/20260218120000_add_user_role/migration.sql b/packages/core/prisma/migrations/20260218120000_add_user_role/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20260218120000_add_user_role/migration.sql
rename to packages/core/prisma/migrations/20260218120000_add_user_role/migration.sql
diff --git a/apps/server/prisma/migrations/20260222120000_add_queue_schema/migration.sql b/packages/core/prisma/migrations/20260222120000_add_queue_schema/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20260222120000_add_queue_schema/migration.sql
rename to packages/core/prisma/migrations/20260222120000_add_queue_schema/migration.sql
diff --git a/apps/server/prisma/migrations/20260222130000_add_worker_heartbeats/migration.sql b/packages/core/prisma/migrations/20260222130000_add_worker_heartbeats/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20260222130000_add_worker_heartbeats/migration.sql
rename to packages/core/prisma/migrations/20260222130000_add_worker_heartbeats/migration.sql
diff --git a/apps/server/prisma/migrations/20260225193547_replace_queue_with_prisma_transport/migration.sql b/packages/core/prisma/migrations/20260225193547_replace_queue_with_prisma_transport/migration.sql
similarity index 100%
rename from apps/server/prisma/migrations/20260225193547_replace_queue_with_prisma_transport/migration.sql
rename to packages/core/prisma/migrations/20260225193547_replace_queue_with_prisma_transport/migration.sql
diff --git a/packages/core/prisma/migrations/20260412120000_add_external_import_fields_to_vacancy/migration.sql b/packages/core/prisma/migrations/20260412120000_add_external_import_fields_to_vacancy/migration.sql
new file mode 100644
index 0000000..f83ae78
--- /dev/null
+++ b/packages/core/prisma/migrations/20260412120000_add_external_import_fields_to_vacancy/migration.sql
@@ -0,0 +1,14 @@
+-- AlterTable
+ALTER TABLE "vacancies" ADD COLUMN "externalId" TEXT,
+ADD COLUMN "source" TEXT,
+ADD COLUMN "externalApplyUrl" TEXT,
+ADD COLUMN "descriptionHtml" TEXT,
+ADD COLUMN "hoursFrom" INTEGER,
+ADD COLUMN "hoursTo" INTEGER,
+ADD COLUMN "province" TEXT,
+ADD COLUMN "rateType" TEXT,
+ADD COLUMN "contractType" TEXT,
+ADD COLUMN "publishedAt" TIMESTAMP(3);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "vacancies_source_externalId_key" ON "vacancies"("source", "externalId");
diff --git a/packages/core/prisma/migrations/20260412130000_extract_vacancy_string_fields_to_entities/migration.sql b/packages/core/prisma/migrations/20260412130000_extract_vacancy_string_fields_to_entities/migration.sql
new file mode 100644
index 0000000..c6e0028
--- /dev/null
+++ b/packages/core/prisma/migrations/20260412130000_extract_vacancy_string_fields_to_entities/migration.sql
@@ -0,0 +1,77 @@
+-- CreateEnum
+CREATE TYPE "VacancySource" AS ENUM ('MANUAL');
+
+-- CreateEnum
+CREATE TYPE "LocationType" AS ENUM ('COUNTRY', 'PROVINCE', 'CITY', 'DISTRICT');
+
+-- CreateTable
+CREATE TABLE "locations" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "type" "LocationType" NOT NULL,
+ "code" TEXT,
+ "parentId" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "locations_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "rate_types" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "description" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "rate_types_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "contract_types" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "description" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "contract_types_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "locations_type_code_key" ON "locations"("type", "code");
+CREATE UNIQUE INDEX "rate_types_name_key" ON "rate_types"("name");
+CREATE UNIQUE INDEX "contract_types_name_key" ON "contract_types"("name");
+
+-- AddForeignKey (location self-referential)
+ALTER TABLE "locations" ADD CONSTRAINT "locations_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "locations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AlterTable: add new FK columns, convert source to enum, drop old string columns
+ALTER TABLE "vacancies"
+ ADD COLUMN "rateTypeId" TEXT,
+ ADD COLUMN "contractTypeId" TEXT,
+ ADD COLUMN "locationId" TEXT;
+
+-- Convert source from String to VacancySource enum
+-- Drop the unique constraint that references the old source column
+DROP INDEX IF EXISTS "vacancies_source_externalId_key";
+
+-- Alter the column type
+ALTER TABLE "vacancies"
+ ALTER COLUMN "source" TYPE "VacancySource" USING "source"::"VacancySource";
+
+-- Re-add the unique constraint
+CREATE UNIQUE INDEX "vacancies_source_externalId_key" ON "vacancies"("source", "externalId");
+
+-- Drop old string columns (data was denormalized)
+ALTER TABLE "vacancies"
+ DROP COLUMN IF EXISTS "location",
+ DROP COLUMN IF EXISTS "province",
+ DROP COLUMN IF EXISTS "rateType",
+ DROP COLUMN IF EXISTS "contractType";
+
+-- AddForeignKey
+ALTER TABLE "vacancies" ADD CONSTRAINT "vacancies_rateTypeId_fkey" FOREIGN KEY ("rateTypeId") REFERENCES "rate_types"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ALTER TABLE "vacancies" ADD CONSTRAINT "vacancies_contractTypeId_fkey" FOREIGN KEY ("contractTypeId") REFERENCES "contract_types"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ALTER TABLE "vacancies" ADD CONSTRAINT "vacancies_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/packages/core/prisma/migrations/20260412140000_drop_description_html_from_vacancy/migration.sql b/packages/core/prisma/migrations/20260412140000_drop_description_html_from_vacancy/migration.sql
new file mode 100644
index 0000000..98e6eb5
--- /dev/null
+++ b/packages/core/prisma/migrations/20260412140000_drop_description_html_from_vacancy/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable: drop descriptionHtml — description is now always markdown
+ALTER TABLE "vacancies" DROP COLUMN IF EXISTS "descriptionHtml";
diff --git a/packages/core/prisma/migrations/20260412150000_profile_location_fk/migration.sql b/packages/core/prisma/migrations/20260412150000_profile_location_fk/migration.sql
new file mode 100644
index 0000000..24b3331
--- /dev/null
+++ b/packages/core/prisma/migrations/20260412150000_profile_location_fk/migration.sql
@@ -0,0 +1,11 @@
+-- AlterTable
+ALTER TABLE "profiles" ADD COLUMN "locationId" TEXT;
+
+-- DropColumn
+ALTER TABLE "profiles" DROP COLUMN "city";
+
+-- DropColumn
+ALTER TABLE "profiles" DROP COLUMN "country";
+
+-- AddForeignKey
+ALTER TABLE "profiles" ADD CONSTRAINT "profiles_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/apps/server/prisma/migrations/migration_lock.toml b/packages/core/prisma/migrations/migration_lock.toml
similarity index 100%
rename from apps/server/prisma/migrations/migration_lock.toml
rename to packages/core/prisma/migrations/migration_lock.toml
diff --git a/apps/server/prisma/models/ai-call-log.prisma b/packages/core/prisma/models/ai-call-log.prisma
similarity index 100%
rename from apps/server/prisma/models/ai-call-log.prisma
rename to packages/core/prisma/models/ai-call-log.prisma
diff --git a/apps/server/prisma/models/cv.prisma b/packages/core/prisma/models/cv.prisma
similarity index 100%
rename from apps/server/prisma/models/cv.prisma
rename to packages/core/prisma/models/cv.prisma
diff --git a/apps/server/prisma/models/data-import.prisma b/packages/core/prisma/models/data-import.prisma
similarity index 100%
rename from apps/server/prisma/models/data-import.prisma
rename to packages/core/prisma/models/data-import.prisma
diff --git a/apps/server/prisma/models/job-experience.prisma b/packages/core/prisma/models/job-experience.prisma
similarity index 100%
rename from apps/server/prisma/models/job-experience.prisma
rename to packages/core/prisma/models/job-experience.prisma
diff --git a/apps/server/prisma/models/organization.prisma b/packages/core/prisma/models/organization.prisma
similarity index 100%
rename from apps/server/prisma/models/organization.prisma
rename to packages/core/prisma/models/organization.prisma
diff --git a/apps/server/prisma/models/profile.prisma b/packages/core/prisma/models/profile.prisma
similarity index 84%
rename from apps/server/prisma/models/profile.prisma
rename to packages/core/prisma/models/profile.prisma
index 512ca95..cdb4cf0 100644
--- a/apps/server/prisma/models/profile.prisma
+++ b/packages/core/prisma/models/profile.prisma
@@ -7,8 +7,7 @@ model Profile {
encryptedPhone String?
encryptedAddress String?
encryptedPostalCode String?
- city String?
- country String?
+ locationId String?
website String?
linkedInUrl String?
summary String?
@@ -16,6 +15,7 @@ model Profile {
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
educations Education[]
jobExperiences UserJobExperience[]
cvs CV[]
diff --git a/apps/server/prisma/models/queue.prisma b/packages/core/prisma/models/queue.prisma
similarity index 100%
rename from apps/server/prisma/models/queue.prisma
rename to packages/core/prisma/models/queue.prisma
diff --git a/apps/server/prisma/models/refresh-token.prisma b/packages/core/prisma/models/refresh-token.prisma
similarity index 100%
rename from apps/server/prisma/models/refresh-token.prisma
rename to packages/core/prisma/models/refresh-token.prisma
diff --git a/apps/server/prisma/models/user-ai-settings.prisma b/packages/core/prisma/models/user-ai-settings.prisma
similarity index 100%
rename from apps/server/prisma/models/user-ai-settings.prisma
rename to packages/core/prisma/models/user-ai-settings.prisma
diff --git a/apps/server/prisma/models/user.prisma b/packages/core/prisma/models/user.prisma
similarity index 100%
rename from apps/server/prisma/models/user.prisma
rename to packages/core/prisma/models/user.prisma
diff --git a/packages/core/prisma/models/vacancy.prisma b/packages/core/prisma/models/vacancy.prisma
new file mode 100644
index 0000000..2893966
--- /dev/null
+++ b/packages/core/prisma/models/vacancy.prisma
@@ -0,0 +1,145 @@
+enum VacancySource {
+ MANUAL
+}
+
+enum LocationType {
+ COUNTRY
+ PROVINCE
+ CITY
+ DISTRICT
+}
+
+model Location {
+ id String @id @default(cuid())
+ name String
+ type LocationType
+ code String?
+ parentId String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ parent Location? @relation("LocationHierarchy", fields: [parentId], references: [id])
+ children Location[] @relation("LocationHierarchy")
+ vacancies Vacancy[]
+ profiles Profile[]
+
+ @@unique([type, code])
+ @@map("locations")
+}
+
+model RateType {
+ id String @id @default(cuid())
+ name String @unique
+ description String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ vacancies Vacancy[]
+
+ @@map("rate_types")
+}
+
+model ContractType {
+ id String @id @default(cuid())
+ name String @unique
+ description String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ vacancies Vacancy[]
+
+ @@map("contract_types")
+}
+
+model JobType {
+ id String @id @default(cuid())
+ name String @unique
+ description String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ vacancies Vacancy[]
+
+ @@map("job_types")
+}
+
+model ApplicationStatus {
+ id String @id @default(cuid())
+ name String @unique
+ description String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ applications Application[]
+
+ @@map("application_statuses")
+}
+
+model Vacancy {
+ id String @id @default(cuid())
+ ownerId String
+ title String
+ companyId String
+ roleId String
+ levelId String?
+ jobTypeId String?
+ rateTypeId String?
+ contractTypeId String?
+ locationId String?
+ description String?
+ requirements String?
+ minSalary Int?
+ maxSalary Int?
+ applicationUrl String?
+ deadline DateTime?
+ hoursFrom Int?
+ hoursTo Int?
+ isActive Boolean @default(true)
+ isPublic Boolean @default(false)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ // External import fields
+ externalId String?
+ source VacancySource?
+ externalApplyUrl String?
+ publishedAt DateTime?
+
+ // Relations
+ owner User @relation("VacancyOwner", fields: [ownerId], references: [id], onDelete: Cascade)
+ company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
+ role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
+ level Level? @relation(fields: [levelId], references: [id], onDelete: Cascade)
+ jobType JobType? @relation(fields: [jobTypeId], references: [id], onDelete: Cascade)
+ rateType RateType? @relation(fields: [rateTypeId], references: [id], onDelete: SetNull)
+ contractType ContractType? @relation(fields: [contractTypeId], references: [id], onDelete: SetNull)
+ location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
+ skills Skill[]
+ applications Application[]
+
+ @@unique([source, externalId])
+ @@map("vacancies")
+}
+
+model Application {
+ id String @id @default(cuid())
+ userId String
+ profileId String
+ vacancyId String
+ cvId String?
+ coverLetter String?
+ statusId String
+ appliedAt DateTime @default(now())
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ // Relations
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
+ vacancy Vacancy @relation(fields: [vacancyId], references: [id], onDelete: Cascade)
+ cv CV? @relation(fields: [cvId], references: [id], onDelete: SetNull)
+ status ApplicationStatus @relation(fields: [statusId], references: [id], onDelete: Restrict)
+
+ @@unique([userId, vacancyId])
+ @@map("applications")
+}
diff --git a/apps/server/prisma/schema.prisma b/packages/core/prisma/schema.prisma
similarity index 100%
rename from apps/server/prisma/schema.prisma
rename to packages/core/prisma/schema.prisma
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
new file mode 100644
index 0000000..8126836
--- /dev/null
+++ b/packages/core/src/index.ts
@@ -0,0 +1,5 @@
+export * from "./shared";
+export * from "./modules/database";
+export * from "./modules/mail";
+export * from "./modules/auth";
+export * from "./modules/events";
diff --git a/packages/core/src/modules/admin/admin.module.ts b/packages/core/src/modules/admin/admin.module.ts
new file mode 100644
index 0000000..45c6231
--- /dev/null
+++ b/packages/core/src/modules/admin/admin.module.ts
@@ -0,0 +1,31 @@
+import { AIModule } from "@cv/ai-provider";
+import { BaseModule } from "../../shared";
+import { Module } from "@nestjs/common";
+import { ApplicationStatusModule } from "../application/application-status/application-status.module";
+import { EducationModule } from "../education/education.module";
+import { CompanyModule } from "../job-experience/company/company.module";
+import { LevelModule } from "../job-experience/level/level.module";
+import { RoleModule } from "../job-experience/role/role.module";
+import { SkillModule } from "../job-experience/skill/skill.module";
+import { OrganizationModule } from "../organization/organization.module";
+import { JobTypeModule } from "../vacancies/job-type/job-type.module";
+import { DatabaseModule } from "../database/database.module";
+import { QueueMonitorService } from "./queue-monitor.service";
+
+@Module({
+ imports: [
+ AIModule.forConfig(),
+ BaseModule,
+ DatabaseModule,
+ SkillModule,
+ CompanyModule,
+ RoleModule,
+ LevelModule,
+ EducationModule,
+ JobTypeModule,
+ ApplicationStatusModule,
+ OrganizationModule,
+ ],
+ providers: [QueueMonitorService],
+})
+export class AdminModule {}
diff --git a/packages/core/src/modules/admin/ai-call-log-persistence.service.ts b/packages/core/src/modules/admin/ai-call-log-persistence.service.ts
new file mode 100644
index 0000000..95ca51a
--- /dev/null
+++ b/packages/core/src/modules/admin/ai-call-log-persistence.service.ts
@@ -0,0 +1,54 @@
+import { Injectable, Logger } from "@nestjs/common";
+import { PrismaService } from "../database/prisma.service";
+import type { AiCallLogEntry } from "./ai-call-log.service";
+
+interface QueryOptions {
+ limit?: number;
+ status?: string;
+ providerName?: string;
+}
+
+/**
+ * Persists AI call log entries to the database.
+ * All writes are fire-and-forget to avoid impacting AI call latency.
+ */
+@Injectable()
+export class AiCallLogPersistenceService {
+ private readonly logger = new Logger(AiCallLogPersistenceService.name);
+
+ constructor(private readonly prisma: PrismaService) {}
+
+ persist(entry: AiCallLogEntry): void {
+ this.prisma.aiCallLog
+ .create({
+ data: {
+ id: entry.id,
+ userId: entry.userId ?? null,
+ providerName: entry.providerName,
+ model: entry.model ?? null,
+ source: entry.source ?? null,
+ durationMs: entry.durationMs,
+ promptTokens: entry.promptTokens ?? null,
+ completionTokens: entry.completionTokens ?? null,
+ finishReason: entry.finishReason ?? null,
+ status: entry.status,
+ error: entry.error ?? null,
+ createdAt: new Date(entry.timestamp),
+ },
+ })
+ .catch((err) => {
+ this.logger.error("Failed to persist AI call log entry", err);
+ });
+ }
+
+ async query(options: QueryOptions = {}) {
+ return this.prisma.aiCallLog.findMany({
+ where: {
+ ...(options.status ? { status: options.status } : {}),
+ ...(options.providerName ? { providerName: options.providerName } : {}),
+ },
+ orderBy: { createdAt: "desc" },
+ take: options.limit ?? 100,
+ });
+ }
+}
diff --git a/packages/core/src/modules/admin/ai-call-log.module.ts b/packages/core/src/modules/admin/ai-call-log.module.ts
new file mode 100644
index 0000000..5e4b370
--- /dev/null
+++ b/packages/core/src/modules/admin/ai-call-log.module.ts
@@ -0,0 +1,12 @@
+import { Global, Module } from "@nestjs/common";
+import { DatabaseModule } from "../database/database.module";
+import { AiCallLogPersistenceService } from "./ai-call-log-persistence.service";
+import { AiCallLogService } from "./ai-call-log.service";
+
+@Global()
+@Module({
+ imports: [DatabaseModule],
+ providers: [AiCallLogPersistenceService, AiCallLogService],
+ exports: [AiCallLogService],
+})
+export class AiCallLogModule {}
diff --git a/packages/core/src/modules/admin/ai-call-log.service.ts b/packages/core/src/modules/admin/ai-call-log.service.ts
new file mode 100644
index 0000000..810214e
--- /dev/null
+++ b/packages/core/src/modules/admin/ai-call-log.service.ts
@@ -0,0 +1,54 @@
+import { Injectable, Optional } from "@nestjs/common";
+import { AiCallLogPersistenceService } from "./ai-call-log-persistence.service";
+
+export interface AiCallLogEntry {
+ id: string;
+ timestamp: string;
+ providerName: string;
+ durationMs: number;
+ promptTokens?: number | undefined;
+ completionTokens?: number | undefined;
+ model?: string | undefined;
+ finishReason?: string | undefined;
+ status: "success" | "error";
+ error?: string | undefined;
+ userId?: string | undefined;
+ source?: string | undefined;
+}
+
+const MAX_ENTRIES = 100;
+
+/**
+ * In-memory ring buffer for AI provider call history.
+ * Retains the last 100 entries; resets on server restart.
+ * Optionally persists to DB via AiCallLogPersistenceService.
+ */
+@Injectable()
+export class AiCallLogService {
+ private readonly entries: AiCallLogEntry[] = [];
+
+ constructor(
+ @Optional()
+ private readonly persistenceService?: AiCallLogPersistenceService,
+ ) {}
+
+ record(entry: AiCallLogEntry): void {
+ this.entries.unshift(entry);
+ if (this.entries.length > MAX_ENTRIES) {
+ this.entries.length = MAX_ENTRIES;
+ }
+ this.persistenceService?.persist(entry);
+ }
+
+ getEntries(limit?: number): AiCallLogEntry[] {
+ return limit ? this.entries.slice(0, limit) : [...this.entries];
+ }
+
+ async queryHistory(options?: {
+ limit?: number;
+ status?: string;
+ providerName?: string;
+ }) {
+ return this.persistenceService?.query(options) ?? [];
+ }
+}
diff --git a/packages/core/src/modules/admin/logging-ai-provider.ts b/packages/core/src/modules/admin/logging-ai-provider.ts
new file mode 100644
index 0000000..b204911
--- /dev/null
+++ b/packages/core/src/modules/admin/logging-ai-provider.ts
@@ -0,0 +1,83 @@
+import { randomUUID } from "node:crypto";
+import type {
+ AICompletionRequest,
+ AICompletionResponse,
+ AIProvider,
+ AIProviderStatus,
+} from "@cv/ai-provider";
+import { AiCallLogService } from "./ai-call-log.service";
+
+interface LoggingOptions {
+ userId?: string;
+ source?: string;
+}
+
+/**
+ * Thin wrapper around an AIProvider that records call timing and
+ * response metadata to the in-memory AiCallLogService.
+ */
+export class LoggingAIProvider implements AIProvider {
+ readonly name: string;
+
+ constructor(
+ private readonly inner: AIProvider,
+ private readonly logService: AiCallLogService,
+ private readonly options: LoggingOptions = {},
+ ) {
+ this.name = inner.name;
+ }
+
+ async complete(request: AICompletionRequest): Promise {
+ const start = performance.now();
+
+ try {
+ const response = await this.inner.complete(request);
+ const durationMs = Math.round(performance.now() - start);
+
+ this.logService.record({
+ id: randomUUID(),
+ timestamp: new Date().toISOString(),
+ providerName: this.name,
+ durationMs,
+ promptTokens: response.promptTokens,
+ completionTokens: response.completionTokens,
+ model: response.model,
+ finishReason: response.finishReason,
+ status: "success",
+ userId: this.options.userId,
+ source: this.options.source,
+ });
+
+ return response;
+ } catch (err) {
+ const durationMs = Math.round(performance.now() - start);
+
+ this.logService.record({
+ id: randomUUID(),
+ timestamp: new Date().toISOString(),
+ providerName: this.name,
+ durationMs,
+ status: "error",
+ error: err instanceof Error ? err.message : String(err),
+ userId: this.options.userId,
+ source: this.options.source,
+ });
+
+ throw err;
+ }
+ }
+
+ isHealthy(): Promise {
+ return this.inner.isHealthy();
+ }
+
+ getStatus(): Promise {
+ return (
+ this.inner.getStatus?.() ??
+ Promise.resolve({
+ healthy: false,
+ providerName: this.name,
+ })
+ );
+ }
+}
diff --git a/packages/core/src/modules/admin/queue-monitor.service.ts b/packages/core/src/modules/admin/queue-monitor.service.ts
new file mode 100644
index 0000000..39e107d
--- /dev/null
+++ b/packages/core/src/modules/admin/queue-monitor.service.ts
@@ -0,0 +1,124 @@
+import { Injectable } from "@nestjs/common";
+import { PrismaService } from "../database/prisma.service";
+
+interface QueueStatsRow {
+ pending: bigint;
+ scheduled: bigint;
+ processing: bigint;
+ oldest_pending_seconds: number | null;
+}
+
+interface QueueMessageRow {
+ id: string;
+ queue_name: string;
+ message_name: string | null;
+ created_at: Date;
+ available_at: Date;
+ delivered_at: Date | null;
+}
+
+interface WorkerHeartbeatRow {
+ worker_id: string;
+ started_at: Date;
+ last_seen_at: Date;
+}
+
+export interface QueueStatsResult {
+ pending: number;
+ scheduled: number;
+ processing: number;
+ oldestPendingSeconds: number | null;
+}
+
+export interface QueueMessageResult {
+ id: string;
+ queueName: string;
+ messageName: string | null;
+ status: "pending" | "scheduled" | "processing";
+ createdAt: Date;
+}
+
+export interface WorkerHealthResult {
+ workerId: string;
+ status: "healthy" | "stale" | "dead";
+ startedAt: Date;
+ lastSeenAt: Date;
+}
+
+const HEALTHY_THRESHOLD_SECONDS = 60;
+const STALE_THRESHOLD_SECONDS = 300;
+
+const deriveWorkerStatus = (
+ lastSeenAt: Date,
+): "healthy" | "stale" | "dead" => {
+ const ageSeconds = (Date.now() - lastSeenAt.getTime()) / 1000;
+ if (ageSeconds <= HEALTHY_THRESHOLD_SECONDS) return "healthy";
+ if (ageSeconds <= STALE_THRESHOLD_SECONDS) return "stale";
+ return "dead";
+};
+
+const deriveMessageStatus = (
+ row: QueueMessageRow,
+): "pending" | "scheduled" | "processing" => {
+ if (row.delivered_at) return "processing";
+ return row.available_at > new Date() ? "scheduled" : "pending";
+};
+
+/**
+ * Read-only monitoring service for the project-q queue tables.
+ * Uses raw SQL since queue tables live in the `queue` schema (not managed by Prisma).
+ */
+@Injectable()
+export class QueueMonitorService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ async getStats(): Promise {
+ const rows = await this.prisma.$queryRaw`
+ SELECT
+ COUNT(*) FILTER (WHERE delivered_at IS NULL AND available_at <= now()) AS pending,
+ COUNT(*) FILTER (WHERE delivered_at IS NULL AND available_at > now()) AS scheduled,
+ COUNT(*) FILTER (WHERE delivered_at IS NOT NULL) AS processing,
+ EXTRACT(EPOCH FROM now() - MIN(available_at) FILTER (WHERE delivered_at IS NULL AND available_at <= now())) AS oldest_pending_seconds
+ FROM queue.messages
+ `;
+
+ const row = rows[0];
+ return {
+ pending: Number(row?.pending ?? 0),
+ scheduled: Number(row?.scheduled ?? 0),
+ processing: Number(row?.processing ?? 0),
+ oldestPendingSeconds: row?.oldest_pending_seconds ?? null,
+ };
+ }
+
+ async getMessages(limit = 50): Promise {
+ const clampedLimit = Math.max(1, Math.min(limit, 500));
+ const rows = await this.prisma.$queryRaw`
+ SELECT id, queue_name, body->'message'->>'name' AS message_name,
+ created_at, available_at, delivered_at
+ FROM queue.messages ORDER BY created_at DESC LIMIT ${clampedLimit}
+ `;
+
+ return rows.map((row) => ({
+ id: String(row.id),
+ queueName: row.queue_name,
+ messageName: row.message_name,
+ status: deriveMessageStatus(row),
+ createdAt: row.created_at,
+ }));
+ }
+
+ async getWorkers(): Promise {
+ const rows = await this.prisma.$queryRaw`
+ SELECT worker_id, started_at, last_seen_at
+ FROM queue.worker_heartbeats ORDER BY started_at DESC
+ `;
+
+ return rows.map((row) => ({
+ workerId: row.worker_id,
+ status: deriveWorkerStatus(row.last_seen_at),
+ startedAt: row.started_at,
+ lastSeenAt: row.last_seen_at,
+ }));
+ }
+}
diff --git a/packages/core/src/modules/app/app.module.ts b/packages/core/src/modules/app/app.module.ts
new file mode 100644
index 0000000..7715df5
--- /dev/null
+++ b/packages/core/src/modules/app/app.module.ts
@@ -0,0 +1,10 @@
+import { Module } from "@nestjs/common";
+import { AppService } from "./app.service";
+import { HealthController } from "./health.controller";
+
+@Module({
+ controllers: [HealthController],
+ providers: [AppService],
+ exports: [AppService],
+})
+export class AppModule {}
diff --git a/packages/core/src/modules/app/app.service.ts b/packages/core/src/modules/app/app.service.ts
new file mode 100644
index 0000000..fc11559
--- /dev/null
+++ b/packages/core/src/modules/app/app.service.ts
@@ -0,0 +1,23 @@
+import { Injectable } from "@nestjs/common";
+
+export interface HealthResponse {
+ status: string;
+ timestamp: string;
+ timezone: string;
+ uptime: number;
+}
+
+@Injectable()
+export class AppService {
+ private readonly startTime = Date.now();
+
+ getHealth(): HealthResponse {
+ const now = new Date();
+ return {
+ status: "ok",
+ timestamp: now.toISOString(),
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ uptime: Date.now() - this.startTime,
+ };
+ }
+}
diff --git a/packages/core/src/modules/app/health.controller.ts b/packages/core/src/modules/app/health.controller.ts
new file mode 100644
index 0000000..d29dbcd
--- /dev/null
+++ b/packages/core/src/modules/app/health.controller.ts
@@ -0,0 +1,12 @@
+import { Controller, Get } from "@nestjs/common";
+import { AppService } from "./app.service";
+
+@Controller("health")
+export class HealthController {
+ constructor(private readonly appService: AppService) {}
+
+ @Get()
+ check() {
+ return this.appService.getHealth();
+ }
+}
diff --git a/packages/core/src/modules/application/application-status/application-status.dto.ts b/packages/core/src/modules/application/application-status/application-status.dto.ts
new file mode 100644
index 0000000..7e12d1f
--- /dev/null
+++ b/packages/core/src/modules/application/application-status/application-status.dto.ts
@@ -0,0 +1,9 @@
+export interface CreateApplicationStatusDto {
+ name: string;
+ description?: string;
+}
+
+export interface UpdateApplicationStatusDto {
+ name?: string;
+ description?: string;
+}
diff --git a/packages/core/src/modules/application/application-status/application-status.entity.ts b/packages/core/src/modules/application/application-status/application-status.entity.ts
new file mode 100644
index 0000000..d5b5a81
--- /dev/null
+++ b/packages/core/src/modules/application/application-status/application-status.entity.ts
@@ -0,0 +1,3 @@
+import { NamedEntity } from "../../../shared";
+
+export class ApplicationStatus extends NamedEntity {}
diff --git a/packages/core/src/modules/application/application-status/application-status.factory.ts b/packages/core/src/modules/application/application-status/application-status.factory.ts
new file mode 100644
index 0000000..43a474c
--- /dev/null
+++ b/packages/core/src/modules/application/application-status/application-status.factory.ts
@@ -0,0 +1,25 @@
+import { ClockService, Factory, UuidFactoryService } from "../../../shared";
+import { Injectable } from "@nestjs/common";
+import type { CreateApplicationStatusDto } from "./application-status.dto";
+import { ApplicationStatus } from "./application-status.entity";
+
+@Injectable()
+export class ApplicationStatusFactory
+ implements Factory
+{
+ constructor(
+ private readonly uuidFactory: UuidFactoryService,
+ private readonly clock: ClockService,
+ ) {}
+
+ create(dto: CreateApplicationStatusDto): ApplicationStatus {
+ const now = this.clock.now();
+ return new ApplicationStatus(
+ this.uuidFactory.generate(),
+ dto.name,
+ now,
+ now,
+ dto.description,
+ );
+ }
+}
diff --git a/packages/core/src/modules/application/application-status/application-status.mapper.ts b/packages/core/src/modules/application/application-status/application-status.mapper.ts
new file mode 100644
index 0000000..49d0aa6
--- /dev/null
+++ b/packages/core/src/modules/application/application-status/application-status.mapper.ts
@@ -0,0 +1,29 @@
+import { createNamedEntityMapper } from "../../../shared";
+import { Injectable } from "@nestjs/common";
+import type { ApplicationStatus as PrismaApplicationStatus } from "@prisma/client";
+import { ApplicationStatus } from "./application-status.entity";
+
+@Injectable()
+export class ApplicationStatusMapper {
+ private mapper = createNamedEntityMapper<
+ PrismaApplicationStatus,
+ ApplicationStatus
+ >(ApplicationStatus);
+
+ toDomain(prismaEntity: null): null;
+ toDomain(prismaEntity: PrismaApplicationStatus): ApplicationStatus;
+ toDomain(
+ prismaEntity: PrismaApplicationStatus | null,
+ ): ApplicationStatus | null;
+ toDomain(
+ prismaEntity: PrismaApplicationStatus | null,
+ ): ApplicationStatus | null {
+ return this.mapper.toDomain(prismaEntity);
+ }
+
+ mapToDomain(
+ prismaEntities: PrismaApplicationStatus[],
+ ): ApplicationStatus[] {
+ return this.mapper.mapToDomain(prismaEntities);
+ }
+}
diff --git a/packages/core/src/modules/application/application-status/application-status.module.ts b/packages/core/src/modules/application/application-status/application-status.module.ts
new file mode 100644
index 0000000..6988736
--- /dev/null
+++ b/packages/core/src/modules/application/application-status/application-status.module.ts
@@ -0,0 +1,21 @@
+import { BaseModule } from "../../../shared";
+import { DatabaseModule } from "../../database";
+import { Module } from "@nestjs/common";
+import { ApplicationStatusFactory } from "./application-status.factory";
+import { ApplicationStatusMapper } from "./application-status.mapper";
+import { ApplicationStatusService } from "./application-status.service";
+
+@Module({
+ imports: [DatabaseModule, BaseModule],
+ providers: [
+ ApplicationStatusService,
+ ApplicationStatusMapper,
+ ApplicationStatusFactory,
+ ],
+ exports: [
+ ApplicationStatusService,
+ ApplicationStatusMapper,
+ ApplicationStatusFactory,
+ ],
+})
+export class ApplicationStatusModule {}
diff --git a/packages/core/src/modules/application/application-status/application-status.service.ts b/packages/core/src/modules/application/application-status/application-status.service.ts
new file mode 100644
index 0000000..2783096
--- /dev/null
+++ b/packages/core/src/modules/application/application-status/application-status.service.ts
@@ -0,0 +1,20 @@
+import { NamedEntityService } from "../../../shared";
+import { PrismaService } from "../../database";
+import { Injectable } from "@nestjs/common";
+import { ApplicationStatus } from "./application-status.entity";
+import { ApplicationStatusMapper } from "./application-status.mapper";
+
+@Injectable()
+export class ApplicationStatusService extends NamedEntityService {
+ constructor(
+ prisma: PrismaService,
+ applicationStatusMapper: ApplicationStatusMapper,
+ ) {
+ super(
+ prisma,
+ applicationStatusMapper,
+ prisma.applicationStatus,
+ "ApplicationStatus",
+ );
+ }
+}
diff --git a/packages/core/src/modules/application/application.entity.ts b/packages/core/src/modules/application/application.entity.ts
new file mode 100644
index 0000000..63d844a
--- /dev/null
+++ b/packages/core/src/modules/application/application.entity.ts
@@ -0,0 +1,49 @@
+import { BaseEntity } from "../../shared";
+import { CV } from "../cv-template/cv.entity";
+import { Vacancy } from "../vacancies/vacancy.entity";
+
+export interface ApplicationStatusRelation {
+ id: string;
+ name: string;
+ description: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export class Application extends BaseEntity {
+ userId: string;
+ profileId: string;
+ vacancy: Vacancy;
+ cv: CV | null;
+ coverLetter?: string;
+ statusId: string;
+ status: ApplicationStatusRelation;
+ appliedAt: Date;
+
+ constructor(
+ id: string,
+ userId: string,
+ profileId: string,
+ vacancy: Vacancy,
+ statusId: string,
+ status: ApplicationStatusRelation,
+ appliedAt: Date,
+ createdAt: Date,
+ updatedAt: Date,
+ cv: CV | null = null,
+ coverLetter?: string,
+ ) {
+ super(id, createdAt, updatedAt);
+ this.userId = userId;
+ this.profileId = profileId;
+ this.vacancy = vacancy;
+ this.statusId = statusId;
+ this.status = status;
+ this.appliedAt = appliedAt;
+ this.cv = cv;
+
+ if (coverLetter !== undefined) {
+ this.coverLetter = coverLetter;
+ }
+ }
+}
diff --git a/packages/core/src/modules/application/application.error.ts b/packages/core/src/modules/application/application.error.ts
new file mode 100644
index 0000000..5c11754
--- /dev/null
+++ b/packages/core/src/modules/application/application.error.ts
@@ -0,0 +1,7 @@
+import { DomainError } from "../../shared";
+
+export class DuplicateApplicationError extends DomainError {
+ constructor() {
+ super("APPLICATION_DUPLICATE", "User has already applied to this vacancy");
+ }
+}
diff --git a/packages/core/src/modules/application/application.mapper.ts b/packages/core/src/modules/application/application.mapper.ts
new file mode 100644
index 0000000..8a710e6
--- /dev/null
+++ b/packages/core/src/modules/application/application.mapper.ts
@@ -0,0 +1,83 @@
+import { Injectable } from "@nestjs/common";
+import type { Prisma } from "@prisma/client";
+import { cvMapper } from "../cv-template/cv.mapper";
+import { VacancyMapper } from "../vacancies/vacancy.mapper";
+import {
+ Application,
+ type ApplicationStatusRelation,
+} from "./application.entity";
+
+type PrismaApplicationWithRelations = Prisma.ApplicationGetPayload<{
+ include: {
+ user: true;
+ status: true;
+ vacancy: {
+ include: {
+ company: true;
+ role: true;
+ level: true;
+ jobType: true;
+ skills: true;
+ };
+ };
+ cv: {
+ include: {
+ template: true;
+ };
+ };
+ };
+}>;
+
+@Injectable()
+export class ApplicationMapper {
+ constructor(private readonly vacancyMapper: VacancyMapper) {}
+
+ toDomain(
+ prismaApplication: PrismaApplicationWithRelations | null,
+ ): Application | null {
+ if (!prismaApplication) {
+ return null;
+ }
+
+ const status: ApplicationStatusRelation = {
+ id: prismaApplication.status.id,
+ name: prismaApplication.status.name,
+ description: prismaApplication.status.description,
+ createdAt: prismaApplication.status.createdAt,
+ updatedAt: prismaApplication.status.updatedAt,
+ };
+
+ const vacancy = this.vacancyMapper.toDomain(prismaApplication.vacancy);
+ if (!vacancy) {
+ throw new Error("Vacancy is required for Application");
+ }
+
+ const cv = prismaApplication.cv
+ ? cvMapper.toDomain(prismaApplication.cv)
+ : null;
+
+ return new Application(
+ prismaApplication.id,
+ prismaApplication.userId,
+ prismaApplication.profileId,
+ vacancy,
+ prismaApplication.statusId,
+ status,
+ prismaApplication.appliedAt,
+ prismaApplication.createdAt,
+ prismaApplication.updatedAt,
+ cv,
+ prismaApplication.coverLetter ?? undefined,
+ );
+ }
+
+ mapToDomainWithRelations(
+ prismaApplications: PrismaApplicationWithRelations[],
+ ): Application[] {
+ return prismaApplications
+ .map((application) => this.toDomain(application))
+ .filter(
+ (application): application is Application => application !== null,
+ );
+ }
+}
diff --git a/packages/core/src/modules/application/application.module.ts b/packages/core/src/modules/application/application.module.ts
new file mode 100644
index 0000000..ee31e2c
--- /dev/null
+++ b/packages/core/src/modules/application/application.module.ts
@@ -0,0 +1,26 @@
+import { AuthorizationModule } from "../auth";
+import { DatabaseModule } from "../database";
+import { Module } from "@nestjs/common";
+import { AuthenticationModule } from "../authentication/authentication.module";
+import { CVTemplateModule } from "../cv-template/cv-template.module";
+import { VacancyModule } from "../vacancies/vacancy.module";
+import { ApplicationMapper } from "./application.mapper";
+import { ApplicationPolicy } from "./application.policy";
+import { ApplicationService } from "./application.service";
+
+@Module({
+ imports: [
+ DatabaseModule,
+ AuthenticationModule,
+ AuthorizationModule,
+ VacancyModule,
+ CVTemplateModule,
+ ],
+ providers: [
+ ApplicationService,
+ ApplicationMapper,
+ ApplicationPolicy,
+ ],
+ exports: [ApplicationService, ApplicationMapper],
+})
+export class ApplicationModule {}
diff --git a/packages/core/src/modules/application/application.policy.ts b/packages/core/src/modules/application/application.policy.ts
new file mode 100644
index 0000000..9d9773b
--- /dev/null
+++ b/packages/core/src/modules/application/application.policy.ts
@@ -0,0 +1,7 @@
+import { Policy, UserOwnedResourcePolicy } from "../auth";
+import { Injectable } from "@nestjs/common";
+import { Application } from "./application.entity";
+
+@Injectable()
+@Policy(Application)
+export class ApplicationPolicy extends UserOwnedResourcePolicy {}
diff --git a/packages/core/src/modules/application/application.service.ts b/packages/core/src/modules/application/application.service.ts
new file mode 100644
index 0000000..13502f5
--- /dev/null
+++ b/packages/core/src/modules/application/application.service.ts
@@ -0,0 +1,143 @@
+import { notFound } from "../auth";
+import { type EntityService } from "../../shared";
+import { PrismaService } from "../database";
+import { Injectable } from "@nestjs/common";
+import { Prisma } from "@prisma/client";
+import { Application } from "./application.entity";
+import { DuplicateApplicationError } from "./application.error";
+import { ApplicationMapper } from "./application.mapper";
+
+type ApplicationFilters = {
+ userId?: string;
+ id?: string | string[];
+};
+
+type ApplicationWhereInput = {
+ userId?: string;
+ id?: { in: string[] } | string;
+};
+
+@Injectable()
+export class ApplicationService
+ implements EntityService
+{
+ readonly applicationInclude = {
+ user: true,
+ vacancy: {
+ include: {
+ company: true,
+ role: true,
+ level: true,
+ jobType: true,
+ skills: true,
+ },
+ },
+ status: true,
+ cv: {
+ include: {
+ template: true,
+ },
+ },
+ } satisfies Prisma.ApplicationInclude;
+
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly applicationMapper: ApplicationMapper,
+ ) {}
+
+ async findById(id: string): Promise {
+ const application = await this.prisma.application.findUnique({
+ where: { id },
+ include: this.applicationInclude,
+ });
+
+ return application ? this.applicationMapper.toDomain(application) : null;
+ }
+
+ async findByIdOrFail(id: string): Promise {
+ const application = await this.findById(id);
+ return application ?? notFound("Application", "id", id);
+ }
+
+ private buildWhere(filters: ApplicationFilters = {}): ApplicationWhereInput {
+ const where: ApplicationWhereInput = {};
+ if (filters.userId) {
+ where.userId = filters.userId;
+ }
+ if (filters.id !== undefined) {
+ where.id = Array.isArray(filters.id) ? { in: filters.id } : filters.id;
+ }
+ return where;
+ }
+
+ async findMany(filters: ApplicationFilters = {}): Promise {
+ const applications = await this.prisma.application.findMany({
+ where: this.buildWhere(filters),
+ include: this.applicationInclude,
+ orderBy: { appliedAt: "desc" },
+ });
+
+ return this.applicationMapper.mapToDomainWithRelations(applications);
+ }
+
+ async count(filters: ApplicationFilters = {}): Promise {
+ return this.prisma.application.count({ where: this.buildWhere(filters) });
+ }
+
+ private buildPrismaData(entity: Application): {
+ statusId: string;
+ cvId: string | null;
+ coverLetter: string | null;
+ } {
+ return {
+ statusId: entity.statusId,
+ cvId: entity.cv?.id ?? null,
+ coverLetter: entity.coverLetter ?? null,
+ };
+ }
+
+ async save(entity: Application): Promise {
+ const data = this.buildPrismaData(entity);
+
+ try {
+ const application = await this.prisma.application.upsert({
+ where: { id: entity.id },
+ create: {
+ id: entity.id,
+ userId: entity.userId,
+ profileId: entity.profileId,
+ vacancyId: entity.vacancy.id,
+ ...data,
+ },
+ update: data,
+ include: this.applicationInclude,
+ });
+
+ const domain = this.applicationMapper.toDomain(application);
+ if (!domain) {
+ throw new Error("Failed to map application to domain");
+ }
+ return domain;
+ } catch (error: unknown) {
+ if (
+ error instanceof Prisma.PrismaClientKnownRequestError &&
+ error.code === "P2002" &&
+ error.meta &&
+ typeof error.meta === "object" &&
+ "target" in error.meta &&
+ Array.isArray(error.meta["target"]) &&
+ error.meta["target"].includes("userId") &&
+ error.meta["target"].includes("vacancyId")
+ ) {
+ throw new DuplicateApplicationError();
+ }
+ throw error;
+ }
+ }
+
+ async destroy(entity: Application): Promise {
+ await this.prisma.application.delete({
+ where: { id: entity.id },
+ });
+ }
+}
diff --git a/packages/auth/src/authorization/authorization.module.ts b/packages/core/src/modules/auth/authorization/authorization.module.ts
similarity index 100%
rename from packages/auth/src/authorization/authorization.module.ts
rename to packages/core/src/modules/auth/authorization/authorization.module.ts
diff --git a/packages/auth/src/authorization/authorization.service.ts b/packages/core/src/modules/auth/authorization/authorization.service.ts
similarity index 97%
rename from packages/auth/src/authorization/authorization.service.ts
rename to packages/core/src/modules/auth/authorization/authorization.service.ts
index 7a5f79c..6f54ac3 100644
--- a/packages/auth/src/authorization/authorization.service.ts
+++ b/packages/core/src/modules/auth/authorization/authorization.service.ts
@@ -1,4 +1,4 @@
-import type { BaseEntity } from "@cv/system";
+import type { BaseEntity } from "../../../shared";
import { Injectable, type Type } from "@nestjs/common";
import {
CannotCreateError,
diff --git a/packages/auth/src/authorization/index.ts b/packages/core/src/modules/auth/authorization/index.ts
similarity index 84%
rename from packages/auth/src/authorization/index.ts
rename to packages/core/src/modules/auth/authorization/index.ts
index cba2193..503f7af 100644
--- a/packages/auth/src/authorization/index.ts
+++ b/packages/core/src/modules/auth/authorization/index.ts
@@ -1,7 +1,7 @@
export * from "./authorization.module";
export * from "./authorization.service";
export * from "./owner-owned-resource.policy";
-export { Policy } from "./policy.decorator";
+export { Policy, POLICY_RESOURCE_KEY } from "./policy.decorator";
export type { Policy as IPolicy } from "./policy.interface";
export * from "./policy-registry.service";
export * from "./profile-owned-resource.policy";
diff --git a/packages/auth/src/authorization/owner-owned-resource.policy.ts b/packages/core/src/modules/auth/authorization/owner-owned-resource.policy.ts
similarity index 100%
rename from packages/auth/src/authorization/owner-owned-resource.policy.ts
rename to packages/core/src/modules/auth/authorization/owner-owned-resource.policy.ts
diff --git a/packages/auth/src/authorization/policy-registry.service.ts b/packages/core/src/modules/auth/authorization/policy-registry.service.ts
similarity index 97%
rename from packages/auth/src/authorization/policy-registry.service.ts
rename to packages/core/src/modules/auth/authorization/policy-registry.service.ts
index e1fd270..88d510e 100644
--- a/packages/auth/src/authorization/policy-registry.service.ts
+++ b/packages/core/src/modules/auth/authorization/policy-registry.service.ts
@@ -1,4 +1,4 @@
-import { raise } from "@cv/system";
+import { raise } from "../../../shared";
import { Injectable, type OnModuleInit, type Type } from "@nestjs/common";
import { DiscoveryService, Reflector } from "@nestjs/core";
import { POLICY_RESOURCE_KEY } from "./policy.decorator";
diff --git a/packages/auth/src/authorization/policy.decorator.ts b/packages/core/src/modules/auth/authorization/policy.decorator.ts
similarity index 100%
rename from packages/auth/src/authorization/policy.decorator.ts
rename to packages/core/src/modules/auth/authorization/policy.decorator.ts
diff --git a/packages/auth/src/authorization/policy.interface.ts b/packages/core/src/modules/auth/authorization/policy.interface.ts
similarity index 100%
rename from packages/auth/src/authorization/policy.interface.ts
rename to packages/core/src/modules/auth/authorization/policy.interface.ts
diff --git a/packages/auth/src/authorization/profile-owned-resource.policy.ts b/packages/core/src/modules/auth/authorization/profile-owned-resource.policy.ts
similarity index 95%
rename from packages/auth/src/authorization/profile-owned-resource.policy.ts
rename to packages/core/src/modules/auth/authorization/profile-owned-resource.policy.ts
index e49c39a..b8cc982 100644
--- a/packages/auth/src/authorization/profile-owned-resource.policy.ts
+++ b/packages/core/src/modules/auth/authorization/profile-owned-resource.policy.ts
@@ -1,4 +1,4 @@
-import { PrismaService } from "@cv/system";
+import { PrismaService } from "../../database";
import type { User } from "../user/user.entity";
import type { Policy } from "./policy.interface";
diff --git a/packages/auth/src/authorization/public-resource.policy.ts b/packages/core/src/modules/auth/authorization/public-resource.policy.ts
similarity index 100%
rename from packages/auth/src/authorization/public-resource.policy.ts
rename to packages/core/src/modules/auth/authorization/public-resource.policy.ts
diff --git a/packages/auth/src/authorization/user-owned-resource.policy.ts b/packages/core/src/modules/auth/authorization/user-owned-resource.policy.ts
similarity index 100%
rename from packages/auth/src/authorization/user-owned-resource.policy.ts
rename to packages/core/src/modules/auth/authorization/user-owned-resource.policy.ts
diff --git a/packages/auth/src/config/index.ts b/packages/core/src/modules/auth/config/index.ts
similarity index 100%
rename from packages/auth/src/config/index.ts
rename to packages/core/src/modules/auth/config/index.ts
diff --git a/packages/auth/src/config/jwt.config.ts b/packages/core/src/modules/auth/config/jwt.config.ts
similarity index 69%
rename from packages/auth/src/config/jwt.config.ts
rename to packages/core/src/modules/auth/config/jwt.config.ts
index 58a77d9..1cdc05e 100644
--- a/packages/auth/src/config/jwt.config.ts
+++ b/packages/core/src/modules/auth/config/jwt.config.ts
@@ -13,6 +13,14 @@ export class JwtConfigService {
return this.configService.getOrThrow("JWT_REFRESH_TOKEN_EXPIRY");
}
+ getAccessTokenExpirySeconds(): number {
+ return this.parseToSeconds(this.getAccessTokenExpiry());
+ }
+
+ getRefreshTokenExpirySeconds(): number {
+ return this.parseToSeconds(this.getRefreshTokenExpiry());
+ }
+
getSecret(): string {
return this.configService.getOrThrow("JWT_SECRET");
}
@@ -50,4 +58,21 @@ export class JwtConfigService {
calculateAccessTokenExpiryDate(): Date {
return this.calculateExpiryDate(this.getAccessTokenExpiry());
}
+
+ private parseToSeconds(expiryString: string): number {
+ const match = expiryString.match(/^(\d+)([smhd])$/);
+ if (!(match?.[1] && match[2])) {
+ return 900;
+ }
+
+ const value = Number.parseInt(match[1], 10);
+ const multipliers: Record = {
+ s: 1,
+ m: 60,
+ h: 3600,
+ d: 86400,
+ };
+
+ return value * (multipliers[match[2]] ?? 60);
+ }
}
diff --git a/packages/auth/src/cookie/cookie.service.ts b/packages/core/src/modules/auth/cookie/cookie.service.ts
similarity index 100%
rename from packages/auth/src/cookie/cookie.service.ts
rename to packages/core/src/modules/auth/cookie/cookie.service.ts
diff --git a/packages/auth/src/cookie/index.ts b/packages/core/src/modules/auth/cookie/index.ts
similarity index 100%
rename from packages/auth/src/cookie/index.ts
rename to packages/core/src/modules/auth/cookie/index.ts
diff --git a/packages/auth/src/errors/authentication.error.ts b/packages/core/src/modules/auth/errors/authentication.error.ts
similarity index 98%
rename from packages/auth/src/errors/authentication.error.ts
rename to packages/core/src/modules/auth/errors/authentication.error.ts
index 41056ee..18d704c 100644
--- a/packages/auth/src/errors/authentication.error.ts
+++ b/packages/core/src/modules/auth/errors/authentication.error.ts
@@ -1,4 +1,4 @@
-import { DomainError } from "@cv/system";
+import { DomainError } from "../../../shared";
export abstract class AuthenticationError extends DomainError {}
diff --git a/packages/auth/src/errors/authorization.error.ts b/packages/core/src/modules/auth/errors/authorization.error.ts
similarity index 95%
rename from packages/auth/src/errors/authorization.error.ts
rename to packages/core/src/modules/auth/errors/authorization.error.ts
index 6f46a56..f1e2f3e 100644
--- a/packages/auth/src/errors/authorization.error.ts
+++ b/packages/core/src/modules/auth/errors/authorization.error.ts
@@ -1,4 +1,4 @@
-import { DomainError } from "@cv/system";
+import { DomainError } from "../../../shared";
export abstract class AuthorizationError extends DomainError {}
diff --git a/packages/auth/src/errors/index.ts b/packages/core/src/modules/auth/errors/index.ts
similarity index 100%
rename from packages/auth/src/errors/index.ts
rename to packages/core/src/modules/auth/errors/index.ts
diff --git a/packages/auth/src/errors/not-found.util.ts b/packages/core/src/modules/auth/errors/not-found.util.ts
similarity index 90%
rename from packages/auth/src/errors/not-found.util.ts
rename to packages/core/src/modules/auth/errors/not-found.util.ts
index 9c7ceca..2a08d6c 100644
--- a/packages/auth/src/errors/not-found.util.ts
+++ b/packages/core/src/modules/auth/errors/not-found.util.ts
@@ -1,4 +1,4 @@
-import { DomainError } from "@cv/system";
+import { DomainError } from "../../../shared";
export class EntityNotFoundError extends DomainError {
constructor(entityName: string, property: string, value: string) {
diff --git a/packages/auth/src/identity-provider-registry.service.ts b/packages/core/src/modules/auth/identity-provider-registry.service.ts
similarity index 99%
rename from packages/auth/src/identity-provider-registry.service.ts
rename to packages/core/src/modules/auth/identity-provider-registry.service.ts
index e435aaa..ee2479e 100644
--- a/packages/auth/src/identity-provider-registry.service.ts
+++ b/packages/core/src/modules/auth/identity-provider-registry.service.ts
@@ -1,4 +1,4 @@
-import { raise } from "@cv/system";
+import { raise } from "../../shared";
import { Injectable, type OnModuleInit } from "@nestjs/common";
import { DiscoveryService, Reflector } from "@nestjs/core";
import { z } from "zod/v4";
diff --git a/packages/auth/src/identity-provider.decorator.ts b/packages/core/src/modules/auth/identity-provider.decorator.ts
similarity index 100%
rename from packages/auth/src/identity-provider.decorator.ts
rename to packages/core/src/modules/auth/identity-provider.decorator.ts
diff --git a/packages/auth/src/identity-provider.interface.ts b/packages/core/src/modules/auth/identity-provider.interface.ts
similarity index 100%
rename from packages/auth/src/identity-provider.interface.ts
rename to packages/core/src/modules/auth/identity-provider.interface.ts
diff --git a/packages/core/src/modules/auth/index.ts b/packages/core/src/modules/auth/index.ts
new file mode 100644
index 0000000..844533d
--- /dev/null
+++ b/packages/core/src/modules/auth/index.ts
@@ -0,0 +1,17 @@
+export * from "./authorization";
+export * from "./config";
+export * from "./cookie";
+export * from "./errors";
+export {
+ IDENTITY_PROVIDER_KEY,
+ IdentityProvider as IdentityProviderDecorator,
+ type IdentityProviderMeta,
+} from "./identity-provider.decorator";
+export type { IdentityProvider } from "./identity-provider.interface";
+export * from "./identity-provider-registry.service";
+export * from "./jwt";
+export * from "./metadata";
+export * from "./providers";
+export * from "./request";
+export * from "./token";
+export * from "./user";
diff --git a/packages/auth/src/jwt/index.ts b/packages/core/src/modules/auth/jwt/index.ts
similarity index 100%
rename from packages/auth/src/jwt/index.ts
rename to packages/core/src/modules/auth/jwt/index.ts
diff --git a/packages/auth/src/jwt/jwt-scope.enum.ts b/packages/core/src/modules/auth/jwt/jwt-scope.enum.ts
similarity index 100%
rename from packages/auth/src/jwt/jwt-scope.enum.ts
rename to packages/core/src/modules/auth/jwt/jwt-scope.enum.ts
diff --git a/packages/auth/src/metadata/device-identification.service.ts b/packages/core/src/modules/auth/metadata/device-identification.service.ts
similarity index 100%
rename from packages/auth/src/metadata/device-identification.service.ts
rename to packages/core/src/modules/auth/metadata/device-identification.service.ts
diff --git a/packages/auth/src/metadata/index.ts b/packages/core/src/modules/auth/metadata/index.ts
similarity index 100%
rename from packages/auth/src/metadata/index.ts
rename to packages/core/src/modules/auth/metadata/index.ts
diff --git a/packages/auth/src/metadata/location.service.ts b/packages/core/src/modules/auth/metadata/location.service.ts
similarity index 100%
rename from packages/auth/src/metadata/location.service.ts
rename to packages/core/src/modules/auth/metadata/location.service.ts
diff --git a/packages/auth/src/providers/index.ts b/packages/core/src/modules/auth/providers/index.ts
similarity index 100%
rename from packages/auth/src/providers/index.ts
rename to packages/core/src/modules/auth/providers/index.ts
diff --git a/packages/auth/src/providers/password/credentials/email.config.ts b/packages/core/src/modules/auth/providers/password/credentials/email.config.ts
similarity index 100%
rename from packages/auth/src/providers/password/credentials/email.config.ts
rename to packages/core/src/modules/auth/providers/password/credentials/email.config.ts
diff --git a/packages/auth/src/providers/password/credentials/emails/templates/password-reset-requested-html.hbs b/packages/core/src/modules/auth/providers/password/credentials/emails/templates/password-reset-requested-html.hbs
similarity index 100%
rename from packages/auth/src/providers/password/credentials/emails/templates/password-reset-requested-html.hbs
rename to packages/core/src/modules/auth/providers/password/credentials/emails/templates/password-reset-requested-html.hbs
diff --git a/packages/auth/src/providers/password/credentials/emails/templates/password-reset-requested-subject.hbs b/packages/core/src/modules/auth/providers/password/credentials/emails/templates/password-reset-requested-subject.hbs
similarity index 100%
rename from packages/auth/src/providers/password/credentials/emails/templates/password-reset-requested-subject.hbs
rename to packages/core/src/modules/auth/providers/password/credentials/emails/templates/password-reset-requested-subject.hbs
diff --git a/packages/auth/src/providers/password/credentials/emails/templates/password-reset-requested-text.hbs b/packages/core/src/modules/auth/providers/password/credentials/emails/templates/password-reset-requested-text.hbs
similarity index 100%
rename from packages/auth/src/providers/password/credentials/emails/templates/password-reset-requested-text.hbs
rename to packages/core/src/modules/auth/providers/password/credentials/emails/templates/password-reset-requested-text.hbs
diff --git a/packages/auth/src/providers/password/credentials/emails/templates/registration-attempt-on-existing-email-html.hbs b/packages/core/src/modules/auth/providers/password/credentials/emails/templates/registration-attempt-on-existing-email-html.hbs
similarity index 100%
rename from packages/auth/src/providers/password/credentials/emails/templates/registration-attempt-on-existing-email-html.hbs
rename to packages/core/src/modules/auth/providers/password/credentials/emails/templates/registration-attempt-on-existing-email-html.hbs
diff --git a/packages/auth/src/providers/password/credentials/emails/templates/registration-attempt-on-existing-email-subject.hbs b/packages/core/src/modules/auth/providers/password/credentials/emails/templates/registration-attempt-on-existing-email-subject.hbs
similarity index 100%
rename from packages/auth/src/providers/password/credentials/emails/templates/registration-attempt-on-existing-email-subject.hbs
rename to packages/core/src/modules/auth/providers/password/credentials/emails/templates/registration-attempt-on-existing-email-subject.hbs
diff --git a/packages/auth/src/providers/password/credentials/emails/templates/registration-attempt-on-existing-email-text.hbs b/packages/core/src/modules/auth/providers/password/credentials/emails/templates/registration-attempt-on-existing-email-text.hbs
similarity index 100%
rename from packages/auth/src/providers/password/credentials/emails/templates/registration-attempt-on-existing-email-text.hbs
rename to packages/core/src/modules/auth/providers/password/credentials/emails/templates/registration-attempt-on-existing-email-text.hbs
diff --git a/packages/auth/src/providers/password/credentials/emails/templates/verification-email-requested-html.hbs b/packages/core/src/modules/auth/providers/password/credentials/emails/templates/verification-email-requested-html.hbs
similarity index 100%
rename from packages/auth/src/providers/password/credentials/emails/templates/verification-email-requested-html.hbs
rename to packages/core/src/modules/auth/providers/password/credentials/emails/templates/verification-email-requested-html.hbs
diff --git a/packages/auth/src/providers/password/credentials/emails/templates/verification-email-requested-subject.hbs b/packages/core/src/modules/auth/providers/password/credentials/emails/templates/verification-email-requested-subject.hbs
similarity index 100%
rename from packages/auth/src/providers/password/credentials/emails/templates/verification-email-requested-subject.hbs
rename to packages/core/src/modules/auth/providers/password/credentials/emails/templates/verification-email-requested-subject.hbs
diff --git a/packages/auth/src/providers/password/credentials/emails/templates/verification-email-requested-text.hbs b/packages/core/src/modules/auth/providers/password/credentials/emails/templates/verification-email-requested-text.hbs
similarity index 100%
rename from packages/auth/src/providers/password/credentials/emails/templates/verification-email-requested-text.hbs
rename to packages/core/src/modules/auth/providers/password/credentials/emails/templates/verification-email-requested-text.hbs
diff --git a/packages/core/src/modules/auth/providers/password/credentials/events/index.ts b/packages/core/src/modules/auth/providers/password/credentials/events/index.ts
new file mode 100644
index 0000000..86b2ae0
--- /dev/null
+++ b/packages/core/src/modules/auth/providers/password/credentials/events/index.ts
@@ -0,0 +1,3 @@
+export { PasswordResetRequestedEvent } from "./password-reset-requested.event";
+export { RegistrationAttemptOnExistingEmailEvent } from "./registration-attempt-on-existing-email.event";
+export { VerificationEmailRequestedEvent } from "./verification-email-requested.event";
diff --git a/packages/core/src/modules/auth/providers/password/credentials/events/password-reset-requested.event.ts b/packages/core/src/modules/auth/providers/password/credentials/events/password-reset-requested.event.ts
new file mode 100644
index 0000000..24d58a2
--- /dev/null
+++ b/packages/core/src/modules/auth/providers/password/credentials/events/password-reset-requested.event.ts
@@ -0,0 +1,8 @@
+import { DomainEvent } from "../../../../../events";
+
+interface PasswordResetRequestedPayload {
+ email: string;
+ resetToken: string;
+}
+
+export class PasswordResetRequestedEvent extends DomainEvent {}
diff --git a/packages/core/src/modules/auth/providers/password/credentials/events/registration-attempt-on-existing-email.event.ts b/packages/core/src/modules/auth/providers/password/credentials/events/registration-attempt-on-existing-email.event.ts
new file mode 100644
index 0000000..0d29e37
--- /dev/null
+++ b/packages/core/src/modules/auth/providers/password/credentials/events/registration-attempt-on-existing-email.event.ts
@@ -0,0 +1,7 @@
+import { DomainEvent } from "../../../../../events";
+
+interface RegistrationAttemptOnExistingEmailPayload {
+ email: string;
+}
+
+export class RegistrationAttemptOnExistingEmailEvent extends DomainEvent {}
diff --git a/packages/core/src/modules/auth/providers/password/credentials/events/verification-email-requested.event.ts b/packages/core/src/modules/auth/providers/password/credentials/events/verification-email-requested.event.ts
new file mode 100644
index 0000000..182b0c8
--- /dev/null
+++ b/packages/core/src/modules/auth/providers/password/credentials/events/verification-email-requested.event.ts
@@ -0,0 +1,9 @@
+import { DomainEvent } from "../../../../../events";
+
+interface VerificationEmailRequestedPayload {
+ userId: string;
+ email: string;
+ verificationToken: string;
+}
+
+export class VerificationEmailRequestedEvent extends DomainEvent {}
diff --git a/packages/auth/src/providers/password/credentials/index.ts b/packages/core/src/modules/auth/providers/password/credentials/index.ts
similarity index 100%
rename from packages/auth/src/providers/password/credentials/index.ts
rename to packages/core/src/modules/auth/providers/password/credentials/index.ts
diff --git a/packages/auth/src/providers/password/credentials/listeners/index.ts b/packages/core/src/modules/auth/providers/password/credentials/listeners/index.ts
similarity index 100%
rename from packages/auth/src/providers/password/credentials/listeners/index.ts
rename to packages/core/src/modules/auth/providers/password/credentials/listeners/index.ts
diff --git a/packages/auth/src/providers/password/credentials/listeners/password-reset-email.listener.ts b/packages/core/src/modules/auth/providers/password/credentials/listeners/password-reset-email.listener.ts
similarity index 65%
rename from packages/auth/src/providers/password/credentials/listeners/password-reset-email.listener.ts
rename to packages/core/src/modules/auth/providers/password/credentials/listeners/password-reset-email.listener.ts
index 0c29306..9585d27 100644
--- a/packages/auth/src/providers/password/credentials/listeners/password-reset-email.listener.ts
+++ b/packages/core/src/modules/auth/providers/password/credentials/listeners/password-reset-email.listener.ts
@@ -1,20 +1,17 @@
import { Injectable } from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter";
-import {
- PASSWORD_RESET_REQUESTED,
- type PasswordResetRequestedEvent,
-} from "../events/password-reset-requested.event";
+import { PasswordResetRequestedEvent } from "../events/password-reset-requested.event";
import { TemplatedEmailService } from "../templated-email.service";
@Injectable()
export class PasswordResetEmailListener {
constructor(private readonly templatedEmailService: TemplatedEmailService) {}
- @OnEvent(PASSWORD_RESET_REQUESTED)
- async handlePasswordResetRequested({
- email,
- resetToken,
- }: PasswordResetRequestedEvent): Promise {
+ @OnEvent(PasswordResetRequestedEvent.eventName)
+ async handlePasswordResetRequested(
+ event: PasswordResetRequestedEvent,
+ ): Promise {
+ const { email, resetToken } = event.payload;
await this.templatedEmailService.send({
to: email,
data: {
diff --git a/packages/auth/src/providers/password/credentials/listeners/registration-attempt-email.listener.ts b/packages/core/src/modules/auth/providers/password/credentials/listeners/registration-attempt-email.listener.ts
similarity index 63%
rename from packages/auth/src/providers/password/credentials/listeners/registration-attempt-email.listener.ts
rename to packages/core/src/modules/auth/providers/password/credentials/listeners/registration-attempt-email.listener.ts
index df66c94..35b5a92 100644
--- a/packages/auth/src/providers/password/credentials/listeners/registration-attempt-email.listener.ts
+++ b/packages/core/src/modules/auth/providers/password/credentials/listeners/registration-attempt-email.listener.ts
@@ -1,19 +1,17 @@
import { Injectable } from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter";
-import {
- REGISTRATION_ATTEMPT_ON_EXISTING_EMAIL,
- type RegistrationAttemptOnExistingEmailEvent,
-} from "../events/registration-attempt-on-existing-email.event";
+import { RegistrationAttemptOnExistingEmailEvent } from "../events/registration-attempt-on-existing-email.event";
import { TemplatedEmailService } from "../templated-email.service";
@Injectable()
export class RegistrationAttemptEmailListener {
constructor(private readonly templatedEmailService: TemplatedEmailService) {}
- @OnEvent(REGISTRATION_ATTEMPT_ON_EXISTING_EMAIL)
- async handleRegistrationAttemptOnExistingEmail({
- email,
- }: RegistrationAttemptOnExistingEmailEvent): Promise {
+ @OnEvent(RegistrationAttemptOnExistingEmailEvent.eventName)
+ async handleRegistrationAttemptOnExistingEmail(
+ event: RegistrationAttemptOnExistingEmailEvent,
+ ): Promise {
+ const { email } = event.payload;
await this.templatedEmailService.send({
to: email,
data: {
diff --git a/packages/auth/src/providers/password/credentials/listeners/verification-email.listener.ts b/packages/core/src/modules/auth/providers/password/credentials/listeners/verification-email.listener.ts
similarity index 65%
rename from packages/auth/src/providers/password/credentials/listeners/verification-email.listener.ts
rename to packages/core/src/modules/auth/providers/password/credentials/listeners/verification-email.listener.ts
index 8946e71..dbea37b 100644
--- a/packages/auth/src/providers/password/credentials/listeners/verification-email.listener.ts
+++ b/packages/core/src/modules/auth/providers/password/credentials/listeners/verification-email.listener.ts
@@ -1,20 +1,17 @@
import { Injectable } from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter";
-import {
- VERIFICATION_EMAIL_REQUESTED,
- type VerificationEmailRequestedEvent,
-} from "../events/verification-email-requested.event";
+import { VerificationEmailRequestedEvent } from "../events/verification-email-requested.event";
import { TemplatedEmailService } from "../templated-email.service";
@Injectable()
export class VerificationEmailListener {
constructor(private readonly templatedEmailService: TemplatedEmailService) {}
- @OnEvent(VERIFICATION_EMAIL_REQUESTED)
- async handleVerificationEmailRequested({
- email,
- verificationToken,
- }: VerificationEmailRequestedEvent): Promise {
+ @OnEvent(VerificationEmailRequestedEvent.eventName)
+ async handleVerificationEmailRequested(
+ event: VerificationEmailRequestedEvent,
+ ): Promise {
+ const { email, verificationToken } = event.payload;
await this.templatedEmailService.send({
to: email,
data: {
diff --git a/packages/auth/src/providers/password/credentials/templated-email.service.ts b/packages/core/src/modules/auth/providers/password/credentials/templated-email.service.ts
similarity index 98%
rename from packages/auth/src/providers/password/credentials/templated-email.service.ts
rename to packages/core/src/modules/auth/providers/password/credentials/templated-email.service.ts
index 78ea78a..625d056 100644
--- a/packages/auth/src/providers/password/credentials/templated-email.service.ts
+++ b/packages/core/src/modules/auth/providers/password/credentials/templated-email.service.ts
@@ -3,7 +3,7 @@ import {
type HandlebarsTemplateService,
MAIL_SERVICE_TOKEN,
type MailService,
-} from "@cv/system";
+} from "../../../../mail";
import { Inject, Injectable } from "@nestjs/common";
import { EMAIL_CONFIG_TOKEN, type EmailConfig } from "./email.config";
diff --git a/packages/auth/src/providers/password/index.ts b/packages/core/src/modules/auth/providers/password/index.ts
similarity index 100%
rename from packages/auth/src/providers/password/index.ts
rename to packages/core/src/modules/auth/providers/password/index.ts
diff --git a/packages/auth/src/providers/password/password-authentication.service.ts b/packages/core/src/modules/auth/providers/password/password-authentication.service.ts
similarity index 87%
rename from packages/auth/src/providers/password/password-authentication.service.ts
rename to packages/core/src/modules/auth/providers/password/password-authentication.service.ts
index 011d506..9f17080 100644
--- a/packages/auth/src/providers/password/password-authentication.service.ts
+++ b/packages/core/src/modules/auth/providers/password/password-authentication.service.ts
@@ -1,6 +1,6 @@
import { Injectable } from "@nestjs/common";
-import { EventEmitter2 } from "@nestjs/event-emitter";
import * as bcrypt from "bcryptjs";
+import { EventService } from "../../../events";
import {
CurrentPasswordIncorrectError,
EmailAlreadyVerifiedError,
@@ -17,18 +17,9 @@ import { CredentialsService } from "../../user/credentials.service";
import { generateSecureToken } from "../../user/credentials-token.util";
import type { User } from "../../user/user.entity";
import { UserService } from "../../user/user.service";
-import {
- PASSWORD_RESET_REQUESTED,
- PasswordResetRequestedEvent,
-} from "./credentials/events/password-reset-requested.event";
-import {
- REGISTRATION_ATTEMPT_ON_EXISTING_EMAIL,
- RegistrationAttemptOnExistingEmailEvent,
-} from "./credentials/events/registration-attempt-on-existing-email.event";
-import {
- VERIFICATION_EMAIL_REQUESTED,
- VerificationEmailRequestedEvent,
-} from "./credentials/events/verification-email-requested.event";
+import { PasswordResetRequestedEvent } from "./credentials/events/password-reset-requested.event";
+import { RegistrationAttemptOnExistingEmailEvent } from "./credentials/events/registration-attempt-on-existing-email.event";
+import { VerificationEmailRequestedEvent } from "./credentials/events/verification-email-requested.event";
import { PASSWORD_PROVIDER_NAME } from "./password-identity-provider";
export interface LoginDto {
@@ -66,7 +57,7 @@ export class PasswordAuthenticationService {
constructor(
private userService: UserService,
private credentialsService: CredentialsService,
- private readonly eventEmitter: EventEmitter2,
+ private readonly eventService: EventService,
private tokenExpiryConfig: TokenExpiryConfigService,
private tokenService: TokenService,
private identityProviderRegistry: IdentityProviderRegistry,
@@ -79,9 +70,8 @@ export class PasswordAuthenticationService {
const emailExists = await this.credentialsService.exists(email);
if (emailExists) {
- this.eventEmitter.emit(
- REGISTRATION_ATTEMPT_ON_EXISTING_EMAIL,
- new RegistrationAttemptOnExistingEmailEvent(email),
+ this.eventService.emit(
+ new RegistrationAttemptOnExistingEmailEvent({ email }),
);
const existingUser =
@@ -114,9 +104,12 @@ export class PasswordAuthenticationService {
verificationToken,
expiresAt,
);
- this.eventEmitter.emit(
- VERIFICATION_EMAIL_REQUESTED,
- new VerificationEmailRequestedEvent(user.id, email, verificationToken),
+ this.eventService.emit(
+ new VerificationEmailRequestedEvent({
+ userId: user.id,
+ email,
+ verificationToken,
+ }),
);
const userWithCredentials = await this.userService.findByIdOrFail(user.id);
@@ -219,13 +212,12 @@ export class PasswordAuthenticationService {
verificationToken,
expiresAt,
);
- this.eventEmitter.emit(
- VERIFICATION_EMAIL_REQUESTED,
- new VerificationEmailRequestedEvent(
- credentials.userId,
+ this.eventService.emit(
+ new VerificationEmailRequestedEvent({
+ userId: credentials.userId,
email,
verificationToken,
- ),
+ }),
);
}
@@ -272,9 +264,8 @@ export class PasswordAuthenticationService {
resetToken,
expiresAt,
);
- this.eventEmitter.emit(
- PASSWORD_RESET_REQUESTED,
- new PasswordResetRequestedEvent(email, resetToken),
+ this.eventService.emit(
+ new PasswordResetRequestedEvent({ email, resetToken }),
);
}
diff --git a/packages/auth/src/providers/password/password-identity-provider.ts b/packages/core/src/modules/auth/providers/password/password-identity-provider.ts
similarity index 97%
rename from packages/auth/src/providers/password/password-identity-provider.ts
rename to packages/core/src/modules/auth/providers/password/password-identity-provider.ts
index 7eada6f..40f09c4 100644
--- a/packages/auth/src/providers/password/password-identity-provider.ts
+++ b/packages/core/src/modules/auth/providers/password/password-identity-provider.ts
@@ -1,4 +1,4 @@
-import { IdentityProviderDecorator } from "@cv/auth";
+import { IdentityProviderDecorator } from "../..";
import { Injectable } from "@nestjs/common";
import * as bcrypt from "bcryptjs";
import { InvalidCredentialsError } from "../../errors/authentication.error";
diff --git a/packages/auth/src/providers/password/password-provider.module.ts b/packages/core/src/modules/auth/providers/password/password-provider.module.ts
similarity index 95%
rename from packages/auth/src/providers/password/password-provider.module.ts
rename to packages/core/src/modules/auth/providers/password/password-provider.module.ts
index dfa6a01..517f25a 100644
--- a/packages/auth/src/providers/password/password-provider.module.ts
+++ b/packages/core/src/modules/auth/providers/password/password-provider.module.ts
@@ -3,10 +3,9 @@ import {
ResendModule,
TemplateModule,
TemplateRegistryService,
-} from "@cv/system";
+} from "../../../mail";
import { Module, type OnModuleInit } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
-import { EventEmitterModule } from "@nestjs/event-emitter";
import { AuthorizationModule } from "../../authorization/authorization.module";
import { TokenModule } from "../../token/token.module";
import { UserModule } from "../../user/user.module";
@@ -21,7 +20,6 @@ import { PasswordIdentityProvider } from "./password-identity-provider";
@Module({
imports: [
ConfigModule,
- EventEmitterModule.forRoot(),
ResendModule,
TemplateModule,
UserModule,
diff --git a/packages/auth/src/request/index.ts b/packages/core/src/modules/auth/request/index.ts
similarity index 100%
rename from packages/auth/src/request/index.ts
rename to packages/core/src/modules/auth/request/index.ts
diff --git a/packages/auth/src/request/request-metadata.decorator.ts b/packages/core/src/modules/auth/request/request-metadata.decorator.ts
similarity index 100%
rename from packages/auth/src/request/request-metadata.decorator.ts
rename to packages/core/src/modules/auth/request/request-metadata.decorator.ts
diff --git a/packages/auth/src/token/auth-cookie.service.ts b/packages/core/src/modules/auth/token/auth-cookie.service.ts
similarity index 100%
rename from packages/auth/src/token/auth-cookie.service.ts
rename to packages/core/src/modules/auth/token/auth-cookie.service.ts
diff --git a/packages/auth/src/token/index.ts b/packages/core/src/modules/auth/token/index.ts
similarity index 100%
rename from packages/auth/src/token/index.ts
rename to packages/core/src/modules/auth/token/index.ts
diff --git a/packages/auth/src/token/refresh-token.entity.ts b/packages/core/src/modules/auth/token/refresh-token.entity.ts
similarity index 94%
rename from packages/auth/src/token/refresh-token.entity.ts
rename to packages/core/src/modules/auth/token/refresh-token.entity.ts
index ce886a1..bca11e2 100644
--- a/packages/auth/src/token/refresh-token.entity.ts
+++ b/packages/core/src/modules/auth/token/refresh-token.entity.ts
@@ -1,4 +1,4 @@
-import { BaseEntity } from "@cv/system";
+import { BaseEntity } from "../../../shared";
export class RefreshToken extends BaseEntity {
constructor(
diff --git a/packages/auth/src/token/refresh-token.mapper.ts b/packages/core/src/modules/auth/token/refresh-token.mapper.ts
similarity index 100%
rename from packages/auth/src/token/refresh-token.mapper.ts
rename to packages/core/src/modules/auth/token/refresh-token.mapper.ts
diff --git a/packages/auth/src/token/refresh-token.policy.ts b/packages/core/src/modules/auth/token/refresh-token.policy.ts
similarity index 100%
rename from packages/auth/src/token/refresh-token.policy.ts
rename to packages/core/src/modules/auth/token/refresh-token.policy.ts
diff --git a/packages/auth/src/token/refresh-token.service.ts b/packages/core/src/modules/auth/token/refresh-token.service.ts
similarity index 99%
rename from packages/auth/src/token/refresh-token.service.ts
rename to packages/core/src/modules/auth/token/refresh-token.service.ts
index 895febd..af0a9fd 100644
--- a/packages/auth/src/token/refresh-token.service.ts
+++ b/packages/core/src/modules/auth/token/refresh-token.service.ts
@@ -1,4 +1,4 @@
-import { PrismaService } from "@cv/system";
+import { PrismaService } from "../../database";
import { Injectable } from "@nestjs/common";
import { InvalidRefreshTokenError, notFound } from "../errors";
import { DeviceIdentificationService } from "../metadata/device-identification.service";
diff --git a/packages/auth/src/token/token-expiry.config.ts b/packages/core/src/modules/auth/token/token-expiry.config.ts
similarity index 100%
rename from packages/auth/src/token/token-expiry.config.ts
rename to packages/core/src/modules/auth/token/token-expiry.config.ts
diff --git a/packages/auth/src/token/token.module.ts b/packages/core/src/modules/auth/token/token.module.ts
similarity index 96%
rename from packages/auth/src/token/token.module.ts
rename to packages/core/src/modules/auth/token/token.module.ts
index 89e1e83..3d610cb 100644
--- a/packages/auth/src/token/token.module.ts
+++ b/packages/core/src/modules/auth/token/token.module.ts
@@ -1,4 +1,4 @@
-import { DatabaseModule } from "@cv/system";
+import { DatabaseModule } from "../../database";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { JwtConfigService } from "../config/jwt.config";
diff --git a/packages/auth/src/token/token.service.ts b/packages/core/src/modules/auth/token/token.service.ts
similarity index 92%
rename from packages/auth/src/token/token.service.ts
rename to packages/core/src/modules/auth/token/token.service.ts
index 8301c7e..dd3a54b 100644
--- a/packages/auth/src/token/token.service.ts
+++ b/packages/core/src/modules/auth/token/token.service.ts
@@ -36,12 +36,13 @@ export class TokenService {
const tokenScope =
scope ?? (isVerified ? JwtScope.VERIFIED : JwtScope.UNVERIFIED);
- const accessTokenExpiry = this.jwtConfig.getAccessTokenExpiry();
+ const accessTokenExpirySeconds = this.jwtConfig.getAccessTokenExpirySeconds();
const refreshTokenExpiry = this.jwtConfig.getRefreshTokenExpiry();
+ const refreshTokenExpirySeconds = this.jwtConfig.getRefreshTokenExpirySeconds();
const refresh_token = this.jwtService.sign(
{ sub: user.id, type: "refresh" },
- { expiresIn: refreshTokenExpiry },
+ { expiresIn: refreshTokenExpirySeconds },
);
const expires_at = this.jwtConfig.calculateAccessTokenExpiryDate();
@@ -64,7 +65,7 @@ export class TokenService {
};
const access_token = this.jwtService.sign(payload, {
- expiresIn: accessTokenExpiry,
+ expiresIn: accessTokenExpirySeconds,
});
return { access_token, refresh_token, expires_at };
diff --git a/packages/auth/src/user/credentials-token.util.ts b/packages/core/src/modules/auth/user/credentials-token.util.ts
similarity index 100%
rename from packages/auth/src/user/credentials-token.util.ts
rename to packages/core/src/modules/auth/user/credentials-token.util.ts
diff --git a/packages/auth/src/user/credentials.entity.ts b/packages/core/src/modules/auth/user/credentials.entity.ts
similarity index 92%
rename from packages/auth/src/user/credentials.entity.ts
rename to packages/core/src/modules/auth/user/credentials.entity.ts
index 033b1dc..e1b0be9 100644
--- a/packages/auth/src/user/credentials.entity.ts
+++ b/packages/core/src/modules/auth/user/credentials.entity.ts
@@ -1,4 +1,4 @@
-import { BaseEntity } from "@cv/system";
+import { BaseEntity } from "../../../shared";
export class Credentials extends BaseEntity {
constructor(
diff --git a/packages/auth/src/user/credentials.mapper.ts b/packages/core/src/modules/auth/user/credentials.mapper.ts
similarity index 96%
rename from packages/auth/src/user/credentials.mapper.ts
rename to packages/core/src/modules/auth/user/credentials.mapper.ts
index 1ebac24..d6beb5d 100644
--- a/packages/auth/src/user/credentials.mapper.ts
+++ b/packages/core/src/modules/auth/user/credentials.mapper.ts
@@ -1,4 +1,4 @@
-import type { BaseMapper } from "@cv/system";
+import type { BaseMapper } from "../../../shared";
import { Injectable } from "@nestjs/common";
import type { Credentials as PrismaCredentials } from "@prisma/client";
import { Credentials } from "./credentials.entity";
diff --git a/packages/auth/src/user/credentials.service.ts b/packages/core/src/modules/auth/user/credentials.service.ts
similarity index 99%
rename from packages/auth/src/user/credentials.service.ts
rename to packages/core/src/modules/auth/user/credentials.service.ts
index 8daef51..98527d6 100644
--- a/packages/auth/src/user/credentials.service.ts
+++ b/packages/core/src/modules/auth/user/credentials.service.ts
@@ -1,4 +1,4 @@
-import { PrismaService } from "@cv/system";
+import { PrismaService } from "../../database";
import { Injectable } from "@nestjs/common";
import { notFound } from "../errors";
import {
diff --git a/packages/auth/src/user/index.ts b/packages/core/src/modules/auth/user/index.ts
similarity index 100%
rename from packages/auth/src/user/index.ts
rename to packages/core/src/modules/auth/user/index.ts
diff --git a/packages/auth/src/user/token-encryption.service.ts b/packages/core/src/modules/auth/user/token-encryption.service.ts
similarity index 100%
rename from packages/auth/src/user/token-encryption.service.ts
rename to packages/core/src/modules/auth/user/token-encryption.service.ts
diff --git a/packages/auth/src/user/user.entity.ts b/packages/core/src/modules/auth/user/user.entity.ts
similarity index 91%
rename from packages/auth/src/user/user.entity.ts
rename to packages/core/src/modules/auth/user/user.entity.ts
index 8b4a60d..668d9f0 100644
--- a/packages/auth/src/user/user.entity.ts
+++ b/packages/core/src/modules/auth/user/user.entity.ts
@@ -1,4 +1,4 @@
-import { BaseEntity } from "@cv/system";
+import { BaseEntity } from "../../../shared";
import type { Credentials } from "./credentials.entity";
export enum UserRole {
diff --git a/packages/auth/src/user/user.mapper.ts b/packages/core/src/modules/auth/user/user.mapper.ts
similarity index 95%
rename from packages/auth/src/user/user.mapper.ts
rename to packages/core/src/modules/auth/user/user.mapper.ts
index 3615dd2..c9011f7 100644
--- a/packages/auth/src/user/user.mapper.ts
+++ b/packages/core/src/modules/auth/user/user.mapper.ts
@@ -1,4 +1,4 @@
-import type { BaseMapper } from "@cv/system";
+import type { BaseMapper } from "../../../shared";
import { Injectable } from "@nestjs/common";
import type { Prisma } from "@prisma/client";
import { CredentialsMapper } from "./credentials.mapper";
diff --git a/packages/auth/src/user/user.module.ts b/packages/core/src/modules/auth/user/user.module.ts
similarity index 93%
rename from packages/auth/src/user/user.module.ts
rename to packages/core/src/modules/auth/user/user.module.ts
index 225d87f..892db8b 100644
--- a/packages/auth/src/user/user.module.ts
+++ b/packages/core/src/modules/auth/user/user.module.ts
@@ -1,4 +1,4 @@
-import { DatabaseModule } from "@cv/system";
+import { DatabaseModule } from "../../database";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { CredentialsMapper } from "./credentials.mapper";
diff --git a/packages/auth/src/user/user.service.ts b/packages/core/src/modules/auth/user/user.service.ts
similarity index 95%
rename from packages/auth/src/user/user.service.ts
rename to packages/core/src/modules/auth/user/user.service.ts
index 72ec106..1e90c4d 100644
--- a/packages/auth/src/user/user.service.ts
+++ b/packages/core/src/modules/auth/user/user.service.ts
@@ -1,4 +1,4 @@
-import { PrismaService } from "@cv/system";
+import { PrismaService } from "../../database";
import { Injectable } from "@nestjs/common";
import { notFound } from "../errors";
import type { User } from "./user.entity";
diff --git a/packages/core/src/modules/authentication/authentication.module.ts b/packages/core/src/modules/authentication/authentication.module.ts
new file mode 100644
index 0000000..afd3923
--- /dev/null
+++ b/packages/core/src/modules/authentication/authentication.module.ts
@@ -0,0 +1,27 @@
+import { AuthorizationModule } from "../auth/authorization/authorization.module";
+import { TokenModule } from "../auth/token/token.module";
+import { UserModule } from "../auth/user/user.module";
+import { BaseModule } from "../../shared";
+import { DatabaseModule } from "../database";
+import { ResendModule, TemplateModule } from "../mail";
+import { Module } from "@nestjs/common";
+import { ConfigModule } from "@nestjs/config";
+import { AuthenticationService } from "./authentication.service";
+import { PasswordProviderModule } from "./providers/password/password-provider.module";
+
+@Module({
+ imports: [
+ ConfigModule,
+ UserModule,
+ TokenModule,
+ AuthorizationModule,
+ BaseModule,
+ DatabaseModule,
+ ResendModule,
+ TemplateModule,
+ PasswordProviderModule,
+ ],
+ providers: [AuthenticationService],
+ exports: [AuthenticationService],
+})
+export class AuthenticationModule {}
diff --git a/packages/core/src/modules/authentication/authentication.service.ts b/packages/core/src/modules/authentication/authentication.service.ts
new file mode 100644
index 0000000..85d8d28
--- /dev/null
+++ b/packages/core/src/modules/authentication/authentication.service.ts
@@ -0,0 +1,50 @@
+import type { User } from "../auth";
+import { UserService } from "../auth";
+import { Injectable } from "@nestjs/common";
+export interface RefreshTokenDto {
+ refresh_token: string;
+}
+
+export interface RefreshTokenResponse {
+ access_token: string;
+ expires_at: Date;
+}
+
+type InternalRefreshTokenResponse = RefreshTokenResponse & {
+ refresh_token: string;
+};
+
+import type { RequestMetadata } from "../auth/request/request-metadata.decorator";
+import { TokenService } from "../auth";
+
+@Injectable()
+export class AuthenticationService {
+ constructor(
+ private userService: UserService,
+ private tokenService: TokenService,
+ ) {}
+
+ async refreshToken(
+ { refresh_token }: RefreshTokenDto,
+ requestMetadata?: RequestMetadata,
+ ): Promise {
+ const tokens = await this.tokenService.refreshTokenPair(
+ refresh_token,
+ requestMetadata,
+ );
+
+ return {
+ access_token: tokens.access_token,
+ expires_at: tokens.expires_at,
+ refresh_token: tokens.refresh_token,
+ };
+ }
+
+ async validateUser(userId: string): Promise {
+ try {
+ return await this.userService.findByIdOrFail(userId);
+ } catch {
+ return null;
+ }
+ }
+}
diff --git a/packages/core/src/modules/authentication/providers/password/password-provider.module.ts b/packages/core/src/modules/authentication/providers/password/password-provider.module.ts
new file mode 100644
index 0000000..0d4e625
--- /dev/null
+++ b/packages/core/src/modules/authentication/providers/password/password-provider.module.ts
@@ -0,0 +1,44 @@
+import {
+ PasswordProviderModule as AuthPasswordProviderModule,
+ EMAIL_CONFIG_TOKEN,
+ EmailConfig,
+ PasswordResetEmailListener,
+ RegistrationAttemptEmailListener,
+ TemplatedEmailService,
+ VerificationEmailListener,
+} from "../../../auth";
+import { ResendModule, TemplateModule } from "../../../mail";
+import { Module } from "@nestjs/common";
+import { ConfigModule, ConfigService } from "@nestjs/config";
+
+@Module({
+ imports: [
+ ConfigModule,
+ AuthPasswordProviderModule,
+ ResendModule,
+ TemplateModule,
+ ],
+ providers: [
+ {
+ provide: EMAIL_CONFIG_TOKEN,
+ useFactory: (configService: ConfigService): EmailConfig => {
+ const clientUrl =
+ configService.get("CLIENT_URL") ??
+ configService.get("CLIENT_ORIGIN") ??
+ "http://localhost:5173";
+ const fromEmail =
+ configService.get("EMAIL_FROM_ADDRESS") ??
+ "noreply@example.com";
+ const fromName = configService.get("EMAIL_FROM_NAME");
+ return new EmailConfig(clientUrl, fromEmail, fromName);
+ },
+ inject: [ConfigService],
+ },
+ TemplatedEmailService,
+ VerificationEmailListener,
+ PasswordResetEmailListener,
+ RegistrationAttemptEmailListener,
+ ],
+ exports: [],
+})
+export class PasswordProviderModule {}
diff --git a/packages/core/src/modules/authentication/request/request-with-cookies.type.ts b/packages/core/src/modules/authentication/request/request-with-cookies.type.ts
new file mode 100644
index 0000000..a10aade
--- /dev/null
+++ b/packages/core/src/modules/authentication/request/request-with-cookies.type.ts
@@ -0,0 +1,5 @@
+import type { Request } from "express";
+
+export type RequestWithCookies> = Request & {
+ cookies?: TCookies;
+};
diff --git a/packages/core/src/modules/authentication/token/token.module.ts b/packages/core/src/modules/authentication/token/token.module.ts
new file mode 100644
index 0000000..847b985
--- /dev/null
+++ b/packages/core/src/modules/authentication/token/token.module.ts
@@ -0,0 +1,8 @@
+import { TokenModule as AuthTokenModule } from "../../auth";
+import { Module } from "@nestjs/common";
+
+@Module({
+ imports: [AuthTokenModule],
+ exports: [AuthTokenModule],
+})
+export class TokenModule {}
diff --git a/packages/core/src/modules/current-user/current-refresh-token-id.decorator.ts b/packages/core/src/modules/current-user/current-refresh-token-id.decorator.ts
new file mode 100644
index 0000000..a6194bb
--- /dev/null
+++ b/packages/core/src/modules/current-user/current-refresh-token-id.decorator.ts
@@ -0,0 +1,14 @@
+import { createGqlParamDecorator } from "../../shared";
+import { z } from "zod/v4";
+
+const jwtRequestSchema = z.looseObject({
+ jwtPayload: z.looseObject({
+ refreshTokenId: z.string(),
+ }),
+});
+
+export const CurrentRefreshTokenId = createGqlParamDecorator({
+ schema: jwtRequestSchema,
+ lens: ({ jwtPayload: jwt }) => jwt.refreshTokenId,
+ fallback: undefined,
+});
diff --git a/packages/core/src/modules/current-user/current-user.decorator.ts b/packages/core/src/modules/current-user/current-user.decorator.ts
new file mode 100644
index 0000000..72c7a67
--- /dev/null
+++ b/packages/core/src/modules/current-user/current-user.decorator.ts
@@ -0,0 +1,16 @@
+import { createGqlParamDecorator, raise } from "../../shared";
+import { z } from "zod/v4";
+
+const requestSchema = z.looseObject({
+ user: z.looseObject({
+ id: z.string(),
+ name: z.string(),
+ role: z.string(),
+ }),
+});
+
+export const CurrentUser = createGqlParamDecorator({
+ schema: requestSchema,
+ lens: ({ user }) => user,
+ fallback: () => raise("User not found in request context"),
+});
diff --git a/packages/core/src/modules/current-user/current-user.module.ts b/packages/core/src/modules/current-user/current-user.module.ts
new file mode 100644
index 0000000..cede94d
--- /dev/null
+++ b/packages/core/src/modules/current-user/current-user.module.ts
@@ -0,0 +1,10 @@
+import { UserModule } from "../auth";
+import { forwardRef, Module } from "@nestjs/common";
+import { AuthenticationModule } from "../authentication/authentication.module";
+
+@Module({
+ imports: [UserModule, forwardRef(() => AuthenticationModule)],
+ providers: [],
+ exports: [],
+})
+export class CurrentUserModule {}
diff --git a/packages/core/src/modules/current-user/index.ts b/packages/core/src/modules/current-user/index.ts
new file mode 100644
index 0000000..ba0a537
--- /dev/null
+++ b/packages/core/src/modules/current-user/index.ts
@@ -0,0 +1,2 @@
+export * from "./current-user.decorator";
+export * from "./current-user.module";
diff --git a/apps/server/src/modules/cv-parser/__tests__/cv-parser.service.spec.ts b/packages/core/src/modules/cv-parser/__tests__/cv-parser.service.spec.ts
similarity index 100%
rename from apps/server/src/modules/cv-parser/__tests__/cv-parser.service.spec.ts
rename to packages/core/src/modules/cv-parser/__tests__/cv-parser.service.spec.ts
diff --git a/apps/server/src/modules/cv-parser/__tests__/entity-resolver.service.spec.ts b/packages/core/src/modules/cv-parser/__tests__/entity-resolver.service.spec.ts
similarity index 99%
rename from apps/server/src/modules/cv-parser/__tests__/entity-resolver.service.spec.ts
rename to packages/core/src/modules/cv-parser/__tests__/entity-resolver.service.spec.ts
index a9a9704..54ae2e5 100644
--- a/apps/server/src/modules/cv-parser/__tests__/entity-resolver.service.spec.ts
+++ b/packages/core/src/modules/cv-parser/__tests__/entity-resolver.service.spec.ts
@@ -1,4 +1,4 @@
-import { PrismaService } from "@cv/system";
+import { PrismaService } from "../../database";
import { Test, type TestingModule } from "@nestjs/testing";
import { EntityResolverService } from "../entity-resolver.service";
diff --git a/packages/core/src/modules/cv-parser/__tests__/fixtures/mock-ai-responses.ts b/packages/core/src/modules/cv-parser/__tests__/fixtures/mock-ai-responses.ts
new file mode 100644
index 0000000..80f7ba3
--- /dev/null
+++ b/packages/core/src/modules/cv-parser/__tests__/fixtures/mock-ai-responses.ts
@@ -0,0 +1,184 @@
+import type { ParsedCVData } from "@cv/ai-parser";
+
+/**
+ * Mock AI response for the plain text CV (John Smith)
+ */
+export const mockJohnSmithParsedCV: ParsedCVData = {
+ personalInfo: {
+ name: "John Smith",
+ headline: undefined,
+ introduction:
+ "Experienced software engineer with 8+ years of experience building scalable web applications and distributed systems.",
+ city: undefined,
+ country: undefined,
+ phone: undefined,
+ website: undefined,
+ linkedInUrl: undefined,
+ },
+ jobExperiences: [
+ {
+ companyName: "Acme Corporation",
+ roleName: "Senior Software Engineer",
+ levelName: "Senior",
+ startDate: "2020-01-01",
+ endDate: null,
+ description:
+ "Led development of microservices architecture serving 10M+ users. Mentored team of 5 junior developers.",
+ skills: ["TypeScript", "Node.js", "Kubernetes", "PostgreSQL", "Redis"],
+ },
+ {
+ companyName: "TechStart Inc",
+ roleName: "Software Engineer",
+ levelName: "Mid-level",
+ startDate: "2016-03-01",
+ endDate: "2019-12-31",
+ description:
+ "Built REST APIs and GraphQL endpoints for e-commerce platform. Optimized database queries.",
+ skills: ["JavaScript", "Python", "MySQL", "Docker", "AWS"],
+ },
+ {
+ companyName: "WebDev Agency",
+ roleName: "Junior Developer",
+ levelName: "Junior",
+ startDate: "2014-06-01",
+ endDate: "2016-02-28",
+ description: "Developed responsive websites for various clients.",
+ skills: ["PHP", "JavaScript", "HTML", "CSS"],
+ },
+ ],
+ education: [
+ {
+ institutionName: "Stanford University",
+ degree: "Bachelor of Science",
+ fieldOfStudy: "Computer Science",
+ startDate: "2010-09-01",
+ endDate: "2014-06-30",
+ description: "GPA: 3.8/4.0, Dean's List",
+ skills: ["Algorithms", "Data Structures", "C++"],
+ },
+ ],
+ skills: [
+ "TypeScript",
+ "JavaScript",
+ "Python",
+ "Node.js",
+ "React",
+ "GraphQL",
+ "PostgreSQL",
+ "MongoDB",
+ "Redis",
+ "Docker",
+ "Kubernetes",
+ "AWS",
+ "Git",
+ ],
+};
+
+/**
+ * Mock AI response for the markdown CV (Jane Doe)
+ */
+export const mockJaneDoeParsedCV: ParsedCVData = {
+ personalInfo: {
+ name: "Jane Doe",
+ headline: undefined,
+ introduction:
+ "Passionate full-stack developer with 6 years of experience in building modern web applications.",
+ city: undefined,
+ country: undefined,
+ phone: undefined,
+ website: undefined,
+ linkedInUrl: undefined,
+ },
+ jobExperiences: [
+ {
+ companyName: "Global Tech Solutions",
+ roleName: "Lead Developer",
+ levelName: "Lead",
+ startDate: "2021-04-01",
+ endDate: null,
+ description:
+ "Architected and led development of customer portal used by 50K+ enterprises.",
+ skills: [
+ "React",
+ "TypeScript",
+ "NestJS",
+ "PostgreSQL",
+ "RabbitMQ",
+ "AWS",
+ ],
+ },
+ {
+ companyName: "StartupXYZ",
+ roleName: "Software Engineer",
+ levelName: "Mid-level",
+ startDate: "2018-08-01",
+ endDate: "2021-03-31",
+ description: "Built real-time collaboration features using WebSockets.",
+ skills: ["React", "React Native", "Node.js", "MongoDB", "Firebase"],
+ },
+ {
+ companyName: "Digital Creations Ltd",
+ roleName: "Junior Software Developer",
+ levelName: "Junior",
+ startDate: "2017-01-01",
+ endDate: "2018-07-31",
+ description: "Created RESTful APIs for content management system.",
+ skills: ["Express.js", "JavaScript", "MySQL", "Jest"],
+ },
+ ],
+ education: [
+ {
+ institutionName: "Columbia University",
+ degree: "Master of Science",
+ fieldOfStudy: "Software Engineering",
+ startDate: "2015-09-01",
+ endDate: "2017-05-31",
+ description: "Focus: Distributed Systems and Cloud Computing",
+ skills: [],
+ },
+ {
+ institutionName: "NYU",
+ degree: "Bachelor of Arts",
+ fieldOfStudy: "Computer Science",
+ startDate: "2011-09-01",
+ endDate: "2015-05-31",
+ description: "Minor: Mathematics, Graduated Magna Cum Laude",
+ skills: [],
+ },
+ ],
+ skills: [
+ "TypeScript",
+ "JavaScript",
+ "Python",
+ "Go",
+ "React",
+ "React Native",
+ "Next.js",
+ "Vue.js",
+ "Node.js",
+ "NestJS",
+ "Express",
+ "GraphQL",
+ "PostgreSQL",
+ "MongoDB",
+ "Redis",
+ "AWS",
+ "Docker",
+ "Kubernetes",
+ "Terraform",
+ ],
+};
+
+/**
+ * Mock AI response wrapped in markdown code block (common LLM output format)
+ */
+export const createMockAIJsonResponse = (data: ParsedCVData): string =>
+ `\`\`\`json
+${JSON.stringify(data, null, 2)}
+\`\`\``;
+
+/**
+ * Mock AI response as raw JSON (alternative LLM output format)
+ */
+export const createMockAIRawJsonResponse = (data: ParsedCVData): string =>
+ JSON.stringify(data);
diff --git a/packages/core/src/modules/cv-parser/__tests__/fixtures/sample-cv.md b/packages/core/src/modules/cv-parser/__tests__/fixtures/sample-cv.md
new file mode 100644
index 0000000..1dc52d2
--- /dev/null
+++ b/packages/core/src/modules/cv-parser/__tests__/fixtures/sample-cv.md
@@ -0,0 +1,62 @@
+# Jane Doe
+**Full Stack Developer** | New York, NY | jane.doe@email.com
+
+---
+
+## Summary
+Passionate full-stack developer with 6 years of experience in building modern web applications. Strong background in React, Node.js, and cloud technologies. Committed to writing maintainable, well-tested code.
+
+---
+
+## Experience
+
+### Lead Developer
+**Global Tech Solutions** | April 2021 - Present
+
+- Architected and led development of customer portal used by 50K+ enterprises
+- Established coding standards and code review processes for team of 8 engineers
+- Migrated legacy monolith to event-driven microservices architecture
+
+**Technologies:** React, TypeScript, NestJS, PostgreSQL, RabbitMQ, AWS
+
+### Software Engineer
+**StartupXYZ** | August 2018 - March 2021
+
+- Built real-time collaboration features using WebSockets
+- Developed mobile-responsive UI components with React Native
+- Integrated third-party payment and authentication services
+
+**Technologies:** React, React Native, Node.js, MongoDB, Firebase
+
+### Junior Software Developer
+**Digital Creations Ltd** | January 2017 - July 2018
+
+- Created RESTful APIs for content management system
+- Wrote unit and integration tests achieving 85% code coverage
+- Participated in agile ceremonies and sprint planning
+
+**Technologies:** Express.js, JavaScript, MySQL, Jest
+
+---
+
+## Education
+
+### Master of Science in Software Engineering
+**Columbia University** | September 2015 - May 2017
+- Focus: Distributed Systems and Cloud Computing
+- Thesis: "Scalable Event-Driven Architectures for Real-Time Applications"
+
+### Bachelor of Arts in Computer Science
+**NYU** | September 2011 - May 2015
+- Minor: Mathematics
+- Graduated Magna Cum Laude
+
+---
+
+## Skills
+
+**Languages:** TypeScript, JavaScript, Python, Go
+**Frontend:** React, React Native, Next.js, Vue.js
+**Backend:** Node.js, NestJS, Express, GraphQL
+**Databases:** PostgreSQL, MongoDB, Redis
+**Cloud & DevOps:** AWS, Docker, Kubernetes, Terraform, CI/CD
diff --git a/packages/core/src/modules/cv-parser/__tests__/fixtures/sample-cv.txt b/packages/core/src/modules/cv-parser/__tests__/fixtures/sample-cv.txt
new file mode 100644
index 0000000..646fbda
--- /dev/null
+++ b/packages/core/src/modules/cv-parser/__tests__/fixtures/sample-cv.txt
@@ -0,0 +1,42 @@
+John Smith
+Software Engineer | San Francisco, CA
+
+PROFESSIONAL SUMMARY
+Experienced software engineer with 8+ years of experience building scalable web applications and distributed systems. Passionate about clean code, testing, and mentoring junior developers.
+
+WORK EXPERIENCE
+
+Senior Software Engineer
+Acme Corporation
+January 2020 - Present
+- Led development of microservices architecture serving 10M+ users
+- Mentored team of 5 junior developers
+- Implemented CI/CD pipelines reducing deployment time by 60%
+Skills: TypeScript, Node.js, Kubernetes, PostgreSQL, Redis
+
+Software Engineer
+TechStart Inc
+March 2016 - December 2019
+- Built REST APIs and GraphQL endpoints for e-commerce platform
+- Optimized database queries improving response times by 40%
+- Collaborated with product team on feature specifications
+Skills: JavaScript, Python, MySQL, Docker, AWS
+
+Junior Developer
+WebDev Agency
+June 2014 - February 2016
+- Developed responsive websites for various clients
+- Maintained legacy PHP applications
+Skills: PHP, JavaScript, HTML, CSS
+
+EDUCATION
+
+Bachelor of Science in Computer Science
+Stanford University
+September 2010 - June 2014
+- GPA: 3.8/4.0
+- Dean's List
+Skills: Algorithms, Data Structures, C++
+
+SKILLS
+TypeScript, JavaScript, Python, Node.js, React, GraphQL, PostgreSQL, MongoDB, Redis, Docker, Kubernetes, AWS, Git
diff --git a/packages/core/src/modules/cv-parser/ai-provider-resolver.service.ts b/packages/core/src/modules/cv-parser/ai-provider-resolver.service.ts
new file mode 100644
index 0000000..ba7b2ce
--- /dev/null
+++ b/packages/core/src/modules/cv-parser/ai-provider-resolver.service.ts
@@ -0,0 +1,116 @@
+import { randomUUID } from "node:crypto";
+import {
+ AI_PROVIDER,
+ type AIProvider,
+ AnthropicProvider,
+ OpenAIProvider,
+} from "@cv/ai-provider";
+import type { User } from "../auth";
+import {
+ BadRequestException,
+ ForbiddenException,
+ Inject,
+ Injectable,
+ Optional,
+} from "@nestjs/common";
+import { AiCallLogService } from "../admin/ai-call-log.service";
+import { LoggingAIProvider } from "../admin/logging-ai-provider";
+import { UserAiSettingsService } from "../user-settings/user-ai-settings.service";
+
+const DEFAULT_BASE_URLS: Record = {
+ anthropic: "https://api.anthropic.com",
+ openai: "https://api.openai.com",
+};
+
+/**
+ * Resolves the correct AIProvider for a given user based on their AI preference.
+ */
+@Injectable()
+export class AIProviderResolverService {
+ constructor(
+ @Inject(AI_PROVIDER)
+ @Optional()
+ private readonly globalProvider: AIProvider | undefined,
+ private readonly userAiSettingsService: UserAiSettingsService,
+ private readonly aiCallLogService: AiCallLogService,
+ ) {}
+
+ async resolveForUser(user: User): Promise {
+ try {
+ return await this.resolveProvider(user);
+ } catch (err) {
+ this.aiCallLogService.record({
+ id: randomUUID(),
+ timestamp: new Date().toISOString(),
+ providerName: "resolution",
+ durationMs: 0,
+ status: "error",
+ error: err instanceof Error ? err.message : String(err),
+ userId: user.id,
+ source: "resolution",
+ });
+ throw err;
+ }
+ }
+
+ private async resolveProvider(user: User): Promise {
+ const settings = await this.userAiSettingsService.getOrCreateSettings(user);
+
+ if (settings.aiPreference === "NO_AI") {
+ throw new ForbiddenException(
+ "AI is disabled. Enable it in profile settings.",
+ );
+ }
+
+ if (settings.aiPreference === "PLATFORM") {
+ if (!this.globalProvider) {
+ throw new BadRequestException(
+ "Platform AI is not available. Configure your own provider in profile settings.",
+ );
+ }
+ return new LoggingAIProvider(this.globalProvider, this.aiCallLogService, {
+ userId: user.id,
+ source: "platform",
+ });
+ }
+
+ const resolved =
+ await this.userAiSettingsService.resolveActiveProvider(user);
+
+ return this.instantiateProvider(resolved, user.id);
+ }
+
+ private instantiateProvider(
+ config: {
+ providerType: string;
+ decryptedApiKey: string;
+ model: string | null;
+ baseUrl: string | null;
+ },
+ userId: string,
+ ): AIProvider {
+ const baseUrl =
+ config.baseUrl || DEFAULT_BASE_URLS[config.providerType] || "";
+
+ const providerConfig = {
+ baseUrl,
+ apiKey: config.decryptedApiKey,
+ ...(config.model != null && { model: config.model }),
+ };
+
+ const provider = (() => {
+ if (config.providerType === "anthropic")
+ return new AnthropicProvider(providerConfig);
+ if (config.providerType === "openai")
+ return new OpenAIProvider(providerConfig);
+ throw new BadRequestException(
+ `Unsupported provider type: ${config.providerType}`,
+ );
+ })();
+
+ return new LoggingAIProvider(provider, this.aiCallLogService, {
+ userId,
+ source: "byok",
+ });
+ }
+}
diff --git a/packages/core/src/modules/cv-parser/cv-parser.module.ts b/packages/core/src/modules/cv-parser/cv-parser.module.ts
new file mode 100644
index 0000000..e41009e
--- /dev/null
+++ b/packages/core/src/modules/cv-parser/cv-parser.module.ts
@@ -0,0 +1,81 @@
+import { randomBytes } from "node:crypto";
+import { mkdirSync } from "node:fs";
+import { join } from "node:path";
+import { CVParserModule as CVParserCoreModule } from "@cv/ai-parser";
+import { AIModule } from "@cv/ai-provider";
+import { FileExtractionModule } from "@cv/file-upload";
+import { DatabaseModule } from "../database";
+import { Module } from "@nestjs/common";
+import { MulterModule } from "@nestjs/platform-express";
+import { diskStorage } from "multer";
+import { DataImportModule } from "../data-import/data-import.module";
+import { FileImportSource } from "../data-import/sources/file-import-source";
+import { EducationModule } from "../education/education.module";
+import { EmploymentModule } from "../job-experience/employment/employment.module";
+import { ProfileModule } from "../profile/profile.module";
+import { UserAiSettingsModule } from "../user-settings/user-ai-settings.module";
+import { AIProviderResolverService } from "./ai-provider-resolver.service";
+import { CVParserService } from "./cv-parser.service";
+import { EntityResolverService } from "./entity-resolver.service";
+import { FileUploadController } from "./file-upload.controller";
+import { ImportOnboardingStep } from "./onboarding/import.step";
+
+@Module({
+ imports: [
+ CVParserCoreModule.forConfig(),
+ AIModule.forConfig(),
+ FileExtractionModule.forRoot(),
+ DatabaseModule,
+ UserAiSettingsModule,
+ ProfileModule,
+ EmploymentModule,
+ EducationModule,
+ DataImportModule,
+ MulterModule.register({
+ storage: diskStorage({
+ destination: (_req, _file, cb) => {
+ const tmpDir = join(process.cwd(), "tmp", "cv-uploads");
+ mkdirSync(tmpDir, { recursive: true });
+ cb(null, tmpDir);
+ },
+ filename: (_req, file, cb) => {
+ const uniqueSuffix = `${Date.now()}-${randomBytes(8).toString("hex")}`;
+ const ext = file.originalname.split(".").pop() ?? "";
+ cb(null, `${uniqueSuffix}.${ext}`);
+ },
+ }),
+ limits: {
+ fileSize: 10 * 1024 * 1024, // 10MB
+ },
+ fileFilter: (_req, file, cb) => {
+ const allowedMimes = [
+ "application/pdf",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "text/plain",
+ "text/markdown",
+ ];
+
+ if (allowedMimes.includes(file.mimetype)) {
+ cb(null, true);
+ } else {
+ cb(
+ new Error(
+ `File type ${file.mimetype} not allowed. Supported: PDF, DOCX, TXT, MD`,
+ ),
+ false,
+ );
+ }
+ },
+ }),
+ ],
+ providers: [
+ EntityResolverService,
+ AIProviderResolverService,
+ CVParserService,
+ ImportOnboardingStep,
+ FileImportSource,
+ ],
+ controllers: [FileUploadController],
+ exports: [CVParserService],
+})
+export class CVParserModule {}
diff --git a/packages/core/src/modules/cv-parser/cv-parser.service.ts b/packages/core/src/modules/cv-parser/cv-parser.service.ts
new file mode 100644
index 0000000..cf687a5
--- /dev/null
+++ b/packages/core/src/modules/cv-parser/cv-parser.service.ts
@@ -0,0 +1,178 @@
+import { CVParserService as CVParser, type ParsedCVData } from "@cv/ai-parser";
+import type { User } from "../auth";
+import {
+ TEXT_EXTRACTOR_REGISTRY,
+ type TextExtractorRegistry,
+ validateFile,
+} from "@cv/file-upload";
+import { Inject, Injectable } from "@nestjs/common";
+import { AIProviderResolverService } from "./ai-provider-resolver.service";
+import {
+ EntityResolverService,
+ type ResolvedEducation,
+ type ResolvedJobExperience,
+} from "./entity-resolver.service";
+
+/**
+ * Parsed CV data with resolved entities
+ */
+export interface ParsedCVDataWithResolution {
+ jobExperiences: ResolvedJobExperience[];
+ education: ResolvedEducation[];
+}
+
+@Injectable()
+export class CVParserService {
+ constructor(
+ @Inject(TEXT_EXTRACTOR_REGISTRY)
+ private readonly textExtractorRegistry: TextExtractorRegistry,
+ private readonly entityResolver: EntityResolverService,
+ private readonly providerResolver: AIProviderResolverService,
+ ) {}
+
+ /** Create a per-request CVParser using the user's resolved provider */
+ private async createParser(user: User): Promise {
+ const provider = await this.providerResolver.resolveForUser(user);
+ return new CVParser(provider);
+ }
+
+ async parseFile(
+ user: User,
+ buffer: Buffer,
+ mimeType: string,
+ originalName: string,
+ ): Promise {
+ const validation = validateFile({
+ buffer,
+ mimeType,
+ originalName,
+ sizeBytes: buffer.length,
+ });
+
+ if (!validation.valid) {
+ throw new Error(`File validation failed: ${validation.error}`);
+ }
+
+ const extraction = await this.textExtractorRegistry.extract(
+ buffer,
+ mimeType,
+ );
+
+ if (!extraction.success) {
+ throw new Error(`Text extraction failed: ${extraction.error}`);
+ }
+
+ if (!extraction.text || extraction.text.trim().length === 0) {
+ throw new Error("Could not extract any text from the file");
+ }
+
+ const parser = await this.createParser(user);
+ return parser.parseCVText(extraction.text);
+ }
+
+ async parseStory(user: User, storyText: string): Promise {
+ if (!storyText || storyText.trim().length === 0) {
+ throw new Error("Story text cannot be empty");
+ }
+
+ if (storyText.trim().length > 50000) {
+ throw new Error("Story text is too long (max 50,000 characters)");
+ }
+
+ const parser = await this.createParser(user);
+ return parser.parseCVText(storyText);
+ }
+
+ /**
+ * Parse story text and resolve entities to existing database records
+ * Returns draft data with entity IDs where matches were found
+ */
+ async parseStoryWithResolution(
+ user: User,
+ storyText: string,
+ ): Promise {
+ const parsed = await this.parseStory(user, storyText);
+ return this.resolveEntities(parsed);
+ }
+
+ /**
+ * Parse file and resolve entities to existing database records
+ */
+ async parseFileWithResolution(
+ user: User,
+ buffer: Buffer,
+ mimeType: string,
+ originalName: string,
+ ): Promise {
+ const parsed = await this.parseFile(user, buffer, mimeType, originalName);
+ return this.resolveEntities(parsed);
+ }
+
+ /**
+ * Resolve entity names to existing database records
+ */
+ private async resolveEntities(
+ parsed: ParsedCVData,
+ ): Promise {
+ const [jobExperiences, education] = await Promise.all([
+ this.resolveJobExperiences(parsed.jobExperiences),
+ this.resolveEducation(parsed.education),
+ ]);
+
+ return { jobExperiences, education };
+ }
+
+ /**
+ * Resolve job experience entities
+ */
+ private async resolveJobExperiences(
+ jobs: ParsedCVData["jobExperiences"],
+ ): Promise {
+ return Promise.all(
+ jobs.map(async (job) => {
+ const [company, role, level, skills] = await Promise.all([
+ this.entityResolver.resolveCompany(job.companyName),
+ this.entityResolver.resolveRole(job.roleName),
+ this.entityResolver.resolveLevel(job.levelName),
+ this.entityResolver.resolveSkills(job.skills ?? []),
+ ]);
+
+ return {
+ company,
+ role,
+ level,
+ skills,
+ startDate: new Date(job.startDate),
+ endDate: job.endDate ? new Date(job.endDate) : null,
+ description: job.description ?? null,
+ };
+ }),
+ );
+ }
+
+ /**
+ * Resolve education entities
+ */
+ private async resolveEducation(
+ education: ParsedCVData["education"],
+ ): Promise {
+ return Promise.all(
+ education.map(async (edu) => {
+ const [institution, skills] = await Promise.all([
+ this.entityResolver.resolveInstitution(edu.institutionName),
+ this.entityResolver.resolveSkills(edu.skills ?? []),
+ ]);
+
+ return {
+ institution,
+ degree: edu.degree,
+ fieldOfStudy: edu.fieldOfStudy ?? null,
+ skills,
+ startDate: new Date(edu.startDate),
+ endDate: edu.endDate ? new Date(edu.endDate) : null,
+ description: edu.description ?? null,
+ };
+ }),
+ );
+ }
+}
diff --git a/packages/core/src/modules/cv-parser/entity-resolver.service.ts b/packages/core/src/modules/cv-parser/entity-resolver.service.ts
new file mode 100644
index 0000000..a8a4539
--- /dev/null
+++ b/packages/core/src/modules/cv-parser/entity-resolver.service.ts
@@ -0,0 +1,120 @@
+import { PrismaService } from "../database";
+import { Injectable } from "@nestjs/common";
+
+/**
+ * Draft entity representation - ID is null if no match found
+ */
+export interface DraftEntity {
+ id: string | null;
+ name: string;
+}
+
+/**
+ * Resolved job experience with draft entities
+ */
+export interface ResolvedJobExperience {
+ company: DraftEntity;
+ role: DraftEntity;
+ level: DraftEntity;
+ skills: DraftEntity[];
+ startDate: Date;
+ endDate: Date | null;
+ description: string | null;
+}
+
+/**
+ * Resolved education with draft entities
+ */
+export interface ResolvedEducation {
+ institution: DraftEntity;
+ degree: string;
+ fieldOfStudy: string | null;
+ skills: DraftEntity[];
+ startDate: Date;
+ endDate: Date | null;
+ description: string | null;
+}
+
+/**
+ * Service for resolving entity names to existing database entities
+ * Returns stubs with null IDs for unmatched names
+ */
+@Injectable()
+export class EntityResolverService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ /**
+ * Resolve a company name to an existing entity
+ */
+ async resolveCompany(name: string): Promise {
+ const match = await this.prisma.company.findFirst({
+ where: { name: { equals: name, mode: "insensitive" } },
+ });
+
+ return match ? { id: match.id, name: match.name } : { id: null, name };
+ }
+
+ /**
+ * Resolve a role name to an existing entity
+ */
+ async resolveRole(name: string): Promise {
+ const match = await this.prisma.role.findFirst({
+ where: { name: { equals: name, mode: "insensitive" } },
+ });
+
+ return match ? { id: match.id, name: match.name } : { id: null, name };
+ }
+
+ /**
+ * Resolve a level name to an existing entity
+ * Falls back to "Mid-level" if not provided
+ */
+ async resolveLevel(name?: string): Promise {
+ const searchName = name || "Mid-level";
+ const match = await this.prisma.level.findFirst({
+ where: { name: { equals: searchName, mode: "insensitive" } },
+ });
+
+ return match
+ ? { id: match.id, name: match.name }
+ : { id: null, name: searchName };
+ }
+
+ /**
+ * Resolve a skill name to an existing entity
+ */
+ async resolveSkill(name: string): Promise {
+ const trimmed = name.trim();
+ if (!trimmed) {
+ return { id: null, name: "" };
+ }
+
+ const match = await this.prisma.skill.findFirst({
+ where: { name: { equals: trimmed, mode: "insensitive" } },
+ });
+
+ return match
+ ? { id: match.id, name: match.name }
+ : { id: null, name: trimmed };
+ }
+
+ /**
+ * Resolve an institution name to an existing entity
+ */
+ async resolveInstitution(name: string): Promise {
+ const match = await this.prisma.institution.findFirst({
+ where: { name: { equals: name, mode: "insensitive" } },
+ });
+
+ return match ? { id: match.id, name: match.name } : { id: null, name };
+ }
+
+ /**
+ * Resolve multiple skills in parallel
+ */
+ async resolveSkills(names: string[]): Promise {
+ const uniqueNames = [...new Set(names.filter((s) => s?.trim()))];
+
+ return Promise.all(uniqueNames.map((name) => this.resolveSkill(name)));
+ }
+}
diff --git a/packages/core/src/modules/cv-parser/file-upload.controller.ts b/packages/core/src/modules/cv-parser/file-upload.controller.ts
new file mode 100644
index 0000000..a5e84f0
--- /dev/null
+++ b/packages/core/src/modules/cv-parser/file-upload.controller.ts
@@ -0,0 +1,97 @@
+import { createHash } from "node:crypto";
+import { readFile, unlink } from "node:fs/promises";
+import type { User as DomainUser } from "../auth";
+import { validateFile } from "@cv/file-upload";
+import {
+ BadRequestException,
+ Body,
+ Controller,
+ Logger,
+ Post,
+ Req,
+ UploadedFile,
+ UseInterceptors,
+} from "@nestjs/common";
+import { FileInterceptor } from "@nestjs/platform-express";
+import { FileImportSource } from "../data-import/sources/file-import-source";
+import { ImportService } from "../data-import/import.service";
+
+@Controller("api/cv-parser")
+export class FileUploadController {
+ private readonly logger = new Logger(FileUploadController.name);
+
+ constructor(
+ private readonly importService: ImportService,
+ private readonly fileImportSource: FileImportSource,
+ ) {}
+
+ @Post("upload")
+ @UseInterceptors(FileInterceptor("file"))
+ async upload(
+ @Req() req: { user: DomainUser },
+ @Body("profileId") profileId: string,
+ @UploadedFile() file?: Express.Multer.File,
+ ) {
+ if (!file) {
+ throw new BadRequestException("No file provided");
+ }
+
+ if (!profileId) {
+ throw new BadRequestException("profileId is required");
+ }
+
+ const filePath = file.path;
+
+ try {
+ const buffer = file.buffer ?? (await readFile(filePath));
+
+ const validation = validateFile({
+ buffer,
+ mimeType: file.mimetype,
+ originalName: file.originalname,
+ sizeBytes: file.size,
+ });
+
+ if (!validation.valid) {
+ throw new Error(`File validation failed: ${validation.error}`);
+ }
+
+ const fingerprint = createHash("sha256").update(buffer).digest("hex");
+
+ const userFile = await this.importService.createImport(
+ req.user,
+ profileId,
+ this.fileImportSource,
+ {
+ fileName: file.originalname,
+ mimeType: file.mimetype,
+ sizeBytes: file.size,
+ fingerprint,
+ },
+ { buffer, mimeType: file.mimetype },
+ );
+
+ return {
+ userFileId: userFile.id,
+ status: userFile.status,
+ fileName: userFile.fileName,
+ sizeBytes: userFile.sizeBytes,
+ };
+ } catch (error) {
+ this.logger.error(`File upload failed: ${error instanceof Error ? error.message : error}`, error instanceof Error ? error.stack : undefined);
+
+ const message =
+ error instanceof Error ? error.message : "Failed to process file";
+
+ throw new BadRequestException(message);
+ } finally {
+ if (filePath) {
+ try {
+ await unlink(filePath);
+ } catch (err) {
+ this.logger.warn(`Failed to clean up temporary file ${filePath}: ${err instanceof Error ? err.message : err}`);
+ }
+ }
+ }
+ }
+}
diff --git a/packages/core/src/modules/cv-parser/onboarding/import.step.ts b/packages/core/src/modules/cv-parser/onboarding/import.step.ts
new file mode 100644
index 0000000..051c1ba
--- /dev/null
+++ b/packages/core/src/modules/cv-parser/onboarding/import.step.ts
@@ -0,0 +1,33 @@
+import type { User } from "../../auth";
+import { PrismaService } from "../../database";
+import { Injectable } from "@nestjs/common";
+import { OnboardingStep } from "../../onboarding/onboarding-step.decorator";
+import {
+ type OnboardingStepDefinition,
+ OnboardingStepStatus,
+} from "../../onboarding/onboarding-step.interface";
+
+@Injectable()
+@OnboardingStep()
+export class ImportOnboardingStep implements OnboardingStepDefinition {
+ readonly name = "import";
+ readonly order = 1;
+ readonly dependsOn = ["ai-preference"];
+
+ constructor(private readonly prisma: PrismaService) {}
+
+ async computeStatus(user: User): Promise {
+ const [jobCount, eduCount] = await Promise.all([
+ this.prisma.userJobExperience.count({
+ where: { profile: { userId: user.id } },
+ }),
+ this.prisma.education.count({
+ where: { profile: { userId: user.id } },
+ }),
+ ]);
+
+ return jobCount > 0 || eduCount > 0
+ ? OnboardingStepStatus.COMPLETE
+ : OnboardingStepStatus.NOT_STARTED;
+ }
+}
diff --git a/packages/core/src/modules/cv-parser/types/draft.types.ts b/packages/core/src/modules/cv-parser/types/draft.types.ts
new file mode 100644
index 0000000..ac2d177
--- /dev/null
+++ b/packages/core/src/modules/cv-parser/types/draft.types.ts
@@ -0,0 +1,162 @@
+import { Field, ID, ObjectType } from "@nestjs/graphql";
+import type {
+ DraftEntity,
+ ResolvedEducation,
+ ResolvedJobExperience,
+} from "../entity-resolver.service";
+
+/**
+ * Draft entity - represents an entity that may or may not exist in the database
+ * If id is null, the entity needs to be created on save
+ */
+@ObjectType()
+export class DraftEntityType {
+ @Field(() => ID, { nullable: true })
+ id: string | null;
+
+ @Field()
+ name: string;
+
+ constructor(id: string | null, name: string) {
+ this.id = id;
+ this.name = name;
+ }
+
+ static fromDraft(draft: DraftEntity): DraftEntityType {
+ return new DraftEntityType(draft.id, draft.name);
+ }
+}
+
+/**
+ * Draft job experience - parsed from CV with resolved entities
+ */
+@ObjectType()
+export class DraftJobExperienceType {
+ @Field(() => DraftEntityType)
+ company: DraftEntityType;
+
+ @Field(() => DraftEntityType)
+ role: DraftEntityType;
+
+ @Field(() => DraftEntityType)
+ level: DraftEntityType;
+
+ @Field(() => [DraftEntityType])
+ skills: DraftEntityType[];
+
+ @Field()
+ startDate: Date;
+
+ @Field(() => Date, { nullable: true })
+ endDate: Date | null;
+
+ @Field(() => String, { nullable: true })
+ description: string | null;
+
+ constructor(
+ company: DraftEntityType,
+ role: DraftEntityType,
+ level: DraftEntityType,
+ skills: DraftEntityType[],
+ startDate: Date,
+ endDate: Date | null,
+ description: string | null,
+ ) {
+ this.company = company;
+ this.role = role;
+ this.level = level;
+ this.skills = skills;
+ this.startDate = startDate;
+ this.endDate = endDate;
+ this.description = description;
+ }
+
+ static fromResolved(resolved: ResolvedJobExperience): DraftJobExperienceType {
+ return new DraftJobExperienceType(
+ DraftEntityType.fromDraft(resolved.company),
+ DraftEntityType.fromDraft(resolved.role),
+ DraftEntityType.fromDraft(resolved.level),
+ resolved.skills.map(DraftEntityType.fromDraft),
+ resolved.startDate,
+ resolved.endDate,
+ resolved.description,
+ );
+ }
+}
+
+/**
+ * Draft education - parsed from CV with resolved entities
+ */
+@ObjectType()
+export class DraftEducationType {
+ @Field(() => DraftEntityType)
+ institution: DraftEntityType;
+
+ @Field()
+ degree: string;
+
+ @Field(() => String, { nullable: true })
+ fieldOfStudy: string | null;
+
+ @Field(() => [DraftEntityType])
+ skills: DraftEntityType[];
+
+ @Field()
+ startDate: Date;
+
+ @Field(() => Date, { nullable: true })
+ endDate: Date | null;
+
+ @Field(() => String, { nullable: true })
+ description: string | null;
+
+ constructor(
+ institution: DraftEntityType,
+ degree: string,
+ fieldOfStudy: string | null,
+ skills: DraftEntityType[],
+ startDate: Date,
+ endDate: Date | null,
+ description: string | null,
+ ) {
+ this.institution = institution;
+ this.degree = degree;
+ this.fieldOfStudy = fieldOfStudy;
+ this.skills = skills;
+ this.startDate = startDate;
+ this.endDate = endDate;
+ this.description = description;
+ }
+
+ static fromResolved(resolved: ResolvedEducation): DraftEducationType {
+ return new DraftEducationType(
+ DraftEntityType.fromDraft(resolved.institution),
+ resolved.degree,
+ resolved.fieldOfStudy,
+ resolved.skills.map(DraftEntityType.fromDraft),
+ resolved.startDate,
+ resolved.endDate,
+ resolved.description,
+ );
+ }
+}
+
+/**
+ * Parsed CV data with resolved entities
+ */
+@ObjectType()
+export class ParsedCVDataWithResolutionType {
+ @Field(() => [DraftJobExperienceType])
+ jobExperiences: DraftJobExperienceType[];
+
+ @Field(() => [DraftEducationType])
+ education: DraftEducationType[];
+
+ constructor(
+ jobExperiences: DraftJobExperienceType[],
+ education: DraftEducationType[],
+ ) {
+ this.jobExperiences = jobExperiences;
+ this.education = education;
+ }
+}
diff --git a/packages/core/src/modules/cv-template/cv-data-assembler.service.ts b/packages/core/src/modules/cv-template/cv-data-assembler.service.ts
new file mode 100644
index 0000000..bb4ae4b
--- /dev/null
+++ b/packages/core/src/modules/cv-template/cv-data-assembler.service.ts
@@ -0,0 +1,145 @@
+import { PrismaService } from "../database";
+import { Injectable } from "@nestjs/common";
+import { ProfileService } from "../profile/profile.service";
+
+export interface CVRenderContext {
+ cv: { title: string; introduction: string | null };
+ profile: {
+ name: string;
+ headline: string | null;
+ phone: string | null;
+ locationId: string | null;
+ website: string | null;
+ linkedInUrl: string | null;
+ summary: string | null;
+ email: string | null;
+ };
+ experience: Array<{
+ company: string;
+ role: string;
+ level: string | null;
+ startDate: string;
+ endDate: string | null;
+ duration: string;
+ description: string | null;
+ skills: string[];
+ }>;
+ education: Array<{
+ institution: string;
+ degree: string;
+ fieldOfStudy: string | null;
+ startDate: string;
+ endDate: string | null;
+ duration: string;
+ description: string | null;
+ skills: string[];
+ }>;
+ allSkills: string[];
+ generatedAt: string;
+}
+
+const MONTHS = [
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
+] as const;
+
+const formatDate = (date: Date): string =>
+ `${MONTHS[date.getMonth()]} ${date.getFullYear()}`;
+
+const computeDuration = (start: Date, end: Date | null): string => {
+ const endDate = end ?? new Date();
+ const totalMonths =
+ (endDate.getFullYear() - start.getFullYear()) * 12 +
+ (endDate.getMonth() - start.getMonth());
+
+ const years = Math.floor(totalMonths / 12);
+ const months = totalMonths % 12;
+
+ const parts: string[] = [];
+ if (years > 0) parts.push(`${years}y`);
+ if (months > 0) parts.push(`${months}m`);
+
+ return parts.length === 0 ? "<1m" : parts.join(" ");
+};
+
+@Injectable()
+export class CVDataAssemblerService {
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly profileService: ProfileService,
+ ) {}
+
+ async assemble(cvId: string): Promise {
+ const cv = await this.prisma.cV.findUniqueOrThrow({ where: { id: cvId } });
+
+ const [profile, experiences, educations] = await Promise.all([
+ this.profileService.findByIdOrFail(cv.profileId),
+ this.prisma.userJobExperience.findMany({
+ where: { profileId: cv.profileId },
+ orderBy: { startDate: "desc" },
+ include: { company: true, role: true, level: true, skills: true },
+ }),
+ this.prisma.education.findMany({
+ where: { profileId: cv.profileId },
+ orderBy: { startDate: "desc" },
+ include: { institution: true, skills: true },
+ }),
+ ]);
+
+ const profileRecord = await this.prisma.profile.findUniqueOrThrow({
+ where: { id: cv.profileId },
+ select: { userId: true },
+ });
+
+ const credential = await this.prisma.credentials.findFirst({
+ where: { userId: profileRecord.userId },
+ select: { email: true },
+ });
+
+ const experienceItems = experiences.map((exp) => ({
+ company: exp.company.name,
+ role: exp.role.name,
+ level: exp.level?.name ?? null,
+ startDate: formatDate(exp.startDate),
+ endDate: exp.endDate ? formatDate(exp.endDate) : null,
+ duration: computeDuration(exp.startDate, exp.endDate ?? null),
+ description: exp.description ?? null,
+ skills: exp.skills.map((s) => s.name),
+ }));
+
+ const educationItems = educations.map((edu) => ({
+ institution: edu.institution.name,
+ degree: edu.degree,
+ fieldOfStudy: edu.fieldOfStudy ?? null,
+ startDate: formatDate(edu.startDate),
+ endDate: edu.endDate ? formatDate(edu.endDate) : null,
+ duration: computeDuration(edu.startDate, edu.endDate ?? null),
+ description: edu.description ?? null,
+ skills: edu.skills.map((s) => s.name),
+ }));
+
+ const allSkillNames = [
+ ...experienceItems.flatMap((e) => e.skills),
+ ...educationItems.flatMap((e) => e.skills),
+ ];
+ const allSkills = [...new Set(allSkillNames)].sort();
+
+ return {
+ cv: { title: cv.title, introduction: cv.introduction ?? null },
+ profile: {
+ name: profile.fullName ?? profile.name,
+ headline: profile.headline ?? null,
+ phone: profile.phone ?? null,
+ locationId: profile.locationId ?? null,
+ website: profile.website ?? null,
+ linkedInUrl: profile.linkedInUrl ?? null,
+ summary: profile.summary ?? null,
+ email: credential?.email ?? null,
+ },
+ experience: experienceItems,
+ education: educationItems,
+ allSkills,
+ generatedAt: new Date().toISOString(),
+ };
+ }
+}
diff --git a/packages/core/src/modules/cv-template/cv-renderer.service.ts b/packages/core/src/modules/cv-template/cv-renderer.service.ts
new file mode 100644
index 0000000..cf97581
--- /dev/null
+++ b/packages/core/src/modules/cv-template/cv-renderer.service.ts
@@ -0,0 +1,38 @@
+import {
+ type TemplateEngine,
+ HandlebarsEngine,
+ wrapInDocument,
+} from "@cv/cv-renderer";
+import { Injectable } from "@nestjs/common";
+import { CVDataAssemblerService } from "./cv-data-assembler.service";
+import { CVTemplateService } from "./cv-template.service";
+
+const engines: Record = {
+ handlebars: new HandlebarsEngine(),
+};
+
+@Injectable()
+export class CVRendererService {
+ constructor(
+ private readonly cvTemplateService: CVTemplateService,
+ private readonly dataAssembler: CVDataAssemblerService,
+ ) {}
+
+ async render(cvId: string, templateId: string): Promise {
+ const template = await this.cvTemplateService.findByIdOrFail(templateId);
+
+ if (!template.body) {
+ throw new Error(`Template "${template.name}" has no body content`);
+ }
+
+ const engine = engines[template.engine];
+ if (!engine) {
+ throw new Error(`Unknown template engine: ${template.engine}`);
+ }
+
+ const context = await this.dataAssembler.assemble(cvId);
+ const rendered = engine.render(template.body, context);
+
+ return wrapInDocument(rendered, template.css ?? "");
+ }
+}
diff --git a/packages/core/src/modules/cv-template/cv-template.entity.ts b/packages/core/src/modules/cv-template/cv-template.entity.ts
new file mode 100644
index 0000000..00008a3
--- /dev/null
+++ b/packages/core/src/modules/cv-template/cv-template.entity.ts
@@ -0,0 +1,35 @@
+import { BaseEntity } from "../../shared";
+
+export class CVTemplate extends BaseEntity {
+ name: string;
+ description?: string | null;
+ body?: string | null;
+ css?: string | null;
+ engine: string;
+
+ constructor(
+ id: string,
+ name: string,
+ createdAt: Date,
+ updatedAt: Date,
+ description?: string | null,
+ body?: string | null,
+ css?: string | null,
+ engine: string = "handlebars",
+ ) {
+ super(id, createdAt, updatedAt);
+ this.name = name;
+ this.description = description ?? null;
+ this.body = body ?? null;
+ this.css = css ?? null;
+ this.engine = engine;
+ }
+}
+
+export class RenderedCV {
+ html: string;
+
+ constructor(html: string) {
+ this.html = html;
+ }
+}
diff --git a/packages/core/src/modules/cv-template/cv-template.mapper.ts b/packages/core/src/modules/cv-template/cv-template.mapper.ts
new file mode 100644
index 0000000..632e6ea
--- /dev/null
+++ b/packages/core/src/modules/cv-template/cv-template.mapper.ts
@@ -0,0 +1,19 @@
+import type { CVTemplate as PrismaCVTemplate } from "@prisma/client";
+import { CVTemplate } from "./cv-template.entity";
+
+export const cvTemplateMapper = {
+ toDomain: (template: PrismaCVTemplate): CVTemplate =>
+ new CVTemplate(
+ template.id,
+ template.name,
+ template.createdAt,
+ template.updatedAt,
+ template.description,
+ template.body,
+ template.css,
+ template.engine,
+ ),
+
+ mapToDomain: (templates: PrismaCVTemplate[]): CVTemplate[] =>
+ templates.map((template) => cvTemplateMapper.toDomain(template)),
+};
diff --git a/packages/core/src/modules/cv-template/cv-template.module.ts b/packages/core/src/modules/cv-template/cv-template.module.ts
new file mode 100644
index 0000000..4105276
--- /dev/null
+++ b/packages/core/src/modules/cv-template/cv-template.module.ts
@@ -0,0 +1,36 @@
+import { AuthorizationModule } from "../auth";
+import { BaseModule } from "../../shared";
+import { DatabaseModule } from "../database";
+import { Module, forwardRef } from "@nestjs/common";
+import { AuthenticationModule } from "../authentication/authentication.module";
+import { ProfileModule } from "../profile/profile.module";
+import { CVDataAssemblerService } from "./cv-data-assembler.service";
+import { CVRendererService } from "./cv-renderer.service";
+import { CVPolicy } from "./cv.policy";
+import { CVService } from "./cv.service";
+import { CVTemplatePolicy } from "./cv-template.policy";
+import { CVTemplateService } from "./cv-template.service";
+import { PdfDownloadController } from "./pdf-download.controller";
+import { CVTemplateSeedService } from "./seed/cv-template.seed";
+
+@Module({
+ imports: [
+ DatabaseModule,
+ BaseModule,
+ AuthenticationModule,
+ AuthorizationModule,
+ forwardRef(() => ProfileModule),
+ ],
+ controllers: [PdfDownloadController],
+ providers: [
+ CVTemplateService,
+ CVService,
+ CVPolicy,
+ CVTemplatePolicy,
+ CVTemplateSeedService,
+ CVDataAssemblerService,
+ CVRendererService,
+ ],
+ exports: [CVTemplateService, CVService],
+})
+export class CVTemplateModule {}
diff --git a/packages/core/src/modules/cv-template/cv-template.policy.ts b/packages/core/src/modules/cv-template/cv-template.policy.ts
new file mode 100644
index 0000000..0b4b392
--- /dev/null
+++ b/packages/core/src/modules/cv-template/cv-template.policy.ts
@@ -0,0 +1,7 @@
+import { Policy, PublicResourcePolicy } from "../auth";
+import { Injectable } from "@nestjs/common";
+import { CVTemplate } from "./cv-template.entity";
+
+@Injectable()
+@Policy(CVTemplate)
+export class CVTemplatePolicy extends PublicResourcePolicy {}
diff --git a/packages/core/src/modules/cv-template/cv-template.service.ts b/packages/core/src/modules/cv-template/cv-template.service.ts
new file mode 100644
index 0000000..428f160
--- /dev/null
+++ b/packages/core/src/modules/cv-template/cv-template.service.ts
@@ -0,0 +1,36 @@
+import { notFound } from "../auth";
+import { PrismaService } from "../database";
+import { Injectable } from "@nestjs/common";
+import { cvTemplateMapper } from "./cv-template.mapper";
+
+type CVTemplateFilters = Record;
+
+@Injectable()
+export class CVTemplateService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ async findMany(filters: CVTemplateFilters = {}) {
+ const templates = await this.prisma.cVTemplate.findMany({
+ orderBy: { createdAt: "desc" },
+ });
+
+ return cvTemplateMapper.mapToDomain(templates);
+ }
+
+ async count(filters: CVTemplateFilters = {}) {
+ return this.prisma.cVTemplate.count();
+ }
+
+ async findById(id: string) {
+ const template = await this.prisma.cVTemplate.findUnique({
+ where: { id },
+ });
+
+ return template ? cvTemplateMapper.toDomain(template) : null;
+ }
+
+ async findByIdOrFail(id: string) {
+ const template = await this.findById(id);
+ return template ?? notFound("CVTemplate", "id", id);
+ }
+}
diff --git a/packages/core/src/modules/cv-template/cv.entity.ts b/packages/core/src/modules/cv-template/cv.entity.ts
new file mode 100644
index 0000000..79ad5cc
--- /dev/null
+++ b/packages/core/src/modules/cv-template/cv.entity.ts
@@ -0,0 +1,25 @@
+import { BaseEntity } from "../../shared";
+import type { CVTemplate } from "./cv-template.entity";
+
+export class CV extends BaseEntity {
+ profileId: string;
+ title: string;
+ introduction: string | null;
+ template: CVTemplate;
+
+ constructor(
+ id: string,
+ profileId: string,
+ title: string,
+ createdAt: Date,
+ updatedAt: Date,
+ template: CVTemplate,
+ introduction?: string | null,
+ ) {
+ super(id, createdAt, updatedAt);
+ this.profileId = profileId;
+ this.title = title;
+ this.introduction = introduction ?? null;
+ this.template = template;
+ }
+}
diff --git a/packages/core/src/modules/cv-template/cv.mapper.ts b/packages/core/src/modules/cv-template/cv.mapper.ts
new file mode 100644
index 0000000..ffd3529
--- /dev/null
+++ b/packages/core/src/modules/cv-template/cv.mapper.ts
@@ -0,0 +1,28 @@
+import type {
+ CV as PrismaCV,
+ CVTemplate as PrismaCVTemplate,
+} from "@prisma/client";
+import { cvTemplateMapper } from "./cv-template.mapper";
+import { CV } from "./cv.entity";
+
+export type PrismaCVWithTemplate = PrismaCV & {
+ template: PrismaCVTemplate;
+};
+
+export const cvMapper = {
+ toDomain: (cv: PrismaCVWithTemplate): CV => {
+ return new CV(
+ cv.id,
+ cv.profileId,
+ cv.title,
+ cv.createdAt,
+ cv.updatedAt,
+ cvTemplateMapper.toDomain(cv.template),
+ cv.introduction ?? null,
+ );
+ },
+
+ mapToDomain: (cvs: PrismaCVWithTemplate[]): CV[] => {
+ return cvs.map((cv) => cvMapper.toDomain(cv));
+ },
+};
diff --git a/packages/core/src/modules/cv-template/cv.policy.ts b/packages/core/src/modules/cv-template/cv.policy.ts
new file mode 100644
index 0000000..55349d1
--- /dev/null
+++ b/packages/core/src/modules/cv-template/cv.policy.ts
@@ -0,0 +1,12 @@
+import { Policy, ProfileOwnedResourcePolicy } from "../auth";
+import { PrismaService } from "../database";
+import { Injectable } from "@nestjs/common";
+import { CV } from "./cv.entity";
+
+@Injectable()
+@Policy(CV)
+export class CVPolicy extends ProfileOwnedResourcePolicy {
+ constructor(prisma: PrismaService) {
+ super(prisma);
+ }
+}
diff --git a/packages/core/src/modules/cv-template/cv.service.ts b/packages/core/src/modules/cv-template/cv.service.ts
new file mode 100644
index 0000000..d1a8b20
--- /dev/null
+++ b/packages/core/src/modules/cv-template/cv.service.ts
@@ -0,0 +1,134 @@
+import { notFound } from "../auth";
+import type { PaginationOptions, PaginationResult } from "../../shared";
+import { type EntityService, PaginationService } from "../../shared";
+import { PrismaService } from "../database";
+import { Injectable } from "@nestjs/common";
+import type { Prisma } from "@prisma/client";
+import { cvMapper } from "./cv.mapper";
+import { CV } from "./cv.entity";
+
+type CVFilters = {
+ profileId?: string | string[];
+ id?: string | string[];
+};
+
+type CVWhereInput = {
+ profileId?: { in: string[] } | string;
+ id?: { in: string[] } | string;
+};
+
+@Injectable()
+export class CVService implements EntityService {
+ private readonly cvInclude = {
+ template: true,
+ } satisfies Prisma.CVInclude;
+
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly paginationService: PaginationService,
+ ) {}
+
+ async findById(id: string): Promise {
+ const cv = await this.prisma.cV.findUnique({
+ where: { id },
+ include: this.cvInclude,
+ });
+
+ return cv ? cvMapper.toDomain(cv) : null;
+ }
+
+ async findByIdOrFail(id: string): Promise {
+ const cv = await this.findById(id);
+ return cv ?? notFound("CV", "id", id);
+ }
+
+ private buildWhere(filters: CVFilters): CVWhereInput {
+ const where: CVWhereInput = {};
+
+ if (filters.profileId !== undefined) {
+ where.profileId = Array.isArray(filters.profileId)
+ ? { in: filters.profileId }
+ : filters.profileId;
+ }
+
+ if (filters.id !== undefined) {
+ where.id = Array.isArray(filters.id) ? { in: filters.id } : filters.id;
+ }
+
+ return where;
+ }
+
+ async findMany(filters: CVFilters): Promise {
+ const cvs = await this.prisma.cV.findMany({
+ where: this.buildWhere(filters),
+ orderBy: { updatedAt: "desc" },
+ include: this.cvInclude,
+ });
+
+ return cvMapper.mapToDomain(cvs);
+ }
+
+ async findManyForProfile(
+ profileId: string,
+ options: PaginationOptions = {},
+ ): Promise> {
+ const where = { profileId };
+ const orderBy = { updatedAt: "desc" as const };
+ const queryOptions = this.paginationService.buildQueryOptions(
+ where,
+ orderBy,
+ options,
+ );
+
+ const [items, totalCount] = await Promise.all([
+ this.prisma.cV.findMany({
+ ...queryOptions,
+ include: this.cvInclude,
+ }),
+ this.prisma.cV.count({ where }),
+ ]);
+
+ return this.paginationService.buildPaginationResult(
+ cvMapper.mapToDomain(items),
+ totalCount,
+ options,
+ );
+ }
+
+ async count(filters: CVFilters): Promise {
+ return this.prisma.cV.count({ where: this.buildWhere(filters) });
+ }
+
+ async save(entity: CV): Promise {
+ const shared = {
+ title: entity.title,
+ introduction: entity.introduction,
+ };
+
+ const cv = await this.prisma.cV.upsert({
+ where: { id: entity.id },
+ create: {
+ id: entity.id,
+ ...shared,
+ profile: { connect: { id: entity.profileId } },
+ template: { connect: { id: entity.template.id } },
+ },
+ update: {
+ ...shared,
+ template: { connect: { id: entity.template.id } },
+ },
+ include: this.cvInclude,
+ });
+ const domain = cvMapper.toDomain(cv);
+ if (!domain) {
+ throw new Error("Failed to map CV to domain");
+ }
+ return domain;
+ }
+
+ async destroy(entity: CV): Promise {
+ await this.prisma.cV.delete({
+ where: { id: entity.id },
+ });
+ }
+}
diff --git a/packages/core/src/modules/cv-template/pdf-download.controller.ts b/packages/core/src/modules/cv-template/pdf-download.controller.ts
new file mode 100644
index 0000000..5ca2397
--- /dev/null
+++ b/packages/core/src/modules/cv-template/pdf-download.controller.ts
@@ -0,0 +1,50 @@
+import type { User as DomainUser } from "../auth";
+import { AuthorizationService } from "../auth";
+import { FILE_STORAGE, type FileStorage } from "@cv/file-storage";
+import {
+ Controller,
+ Get,
+ Inject,
+ NotFoundException,
+ Param,
+ Req,
+ Res,
+} from "@nestjs/common";
+import type { Response } from "express";
+import { CVService } from "./cv.service";
+import { CV } from "./cv.entity";
+
+@Controller("api/cv")
+export class PdfDownloadController {
+ constructor(
+ private readonly cvService: CVService,
+ private readonly authorizationService: AuthorizationService,
+ @Inject(FILE_STORAGE) private readonly storage: FileStorage,
+ ) {}
+
+ @Get(":id/pdf")
+ async downloadPdf(
+ @Req() req: { user: DomainUser },
+ @Param("id") id: string,
+ @Res() res: Response,
+ ): Promise {
+ const cv = await this.cvService.findByIdOrFail(id);
+ await this.authorizationService.canView(req.user, cv, CV);
+
+ const key = `${id}.pdf`;
+
+ if (!(await this.storage.exists(key))) {
+ throw new NotFoundException("PDF not yet available");
+ }
+
+ const pdf = await this.storage.read(key);
+ const safeTitle = cv.title.replace(/[^a-zA-Z0-9_\- ]/g, "");
+
+ res.set({
+ "Content-Type": "application/pdf",
+ "Content-Disposition": `attachment; filename="${safeTitle}.pdf"`,
+ "Content-Length": pdf.length.toString(),
+ });
+ res.send(pdf);
+ }
+}
diff --git a/packages/core/src/modules/cv-template/seed/cv-template.seed.ts b/packages/core/src/modules/cv-template/seed/cv-template.seed.ts
new file mode 100644
index 0000000..0a46c83
--- /dev/null
+++ b/packages/core/src/modules/cv-template/seed/cv-template.seed.ts
@@ -0,0 +1,76 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { PrismaService } from "../../database";
+import { Injectable, Logger } from "@nestjs/common";
+import { Seeder } from "../../database/seed/seed.service";
+import { Seeder as SeederDecorator } from "../../database/seed/seeder.decorator";
+
+const TEMPLATES_DIR = join(__dirname, "templates");
+
+const readTemplate = (name: string) => ({
+ body: readFileSync(join(TEMPLATES_DIR, `${name}.hbs`), "utf-8"),
+ css: readFileSync(join(TEMPLATES_DIR, `${name}.css`), "utf-8"),
+});
+
+interface TemplateData {
+ name: string;
+ description: string;
+ body: string;
+ css: string;
+ engine: string;
+}
+
+const templates: TemplateData[] = [
+ {
+ name: "Modern Professional",
+ description: "A clean, modern template perfect for tech professionals",
+ ...readTemplate("modern-professional"),
+ engine: "handlebars",
+ },
+ {
+ name: "Classic Executive",
+ description: "A traditional template suitable for executive positions",
+ ...readTemplate("classic-executive"),
+ engine: "handlebars",
+ },
+ {
+ name: "Creative Portfolio",
+ description: "A creative template for designers and artists",
+ ...readTemplate("creative-portfolio"),
+ engine: "handlebars",
+ },
+];
+
+@Injectable()
+@SeederDecorator("CV Templates")
+export class CVTemplateSeedService implements Seeder {
+ private readonly logger = new Logger(CVTemplateSeedService.name);
+
+ constructor(readonly _prisma: PrismaService) {}
+
+ async seed(prisma: PrismaService): Promise {
+ this.logger.log("Seeding CV templates...");
+
+ await Promise.all(
+ templates.map(async (template) => {
+ const existing = await prisma["cVTemplate"].findFirst({
+ where: { name: template.name },
+ });
+
+ if (existing) {
+ await prisma["cVTemplate"].update({
+ where: { id: existing.id },
+ data: {
+ description: template.description,
+ body: template.body,
+ css: template.css,
+ engine: template.engine,
+ },
+ });
+ } else {
+ await prisma["cVTemplate"].create({ data: template });
+ }
+ }),
+ );
+ }
+}
diff --git a/packages/core/src/modules/cv-template/seed/templates/classic-executive.css b/packages/core/src/modules/cv-template/seed/templates/classic-executive.css
new file mode 100644
index 0000000..318f298
--- /dev/null
+++ b/packages/core/src/modules/cv-template/seed/templates/classic-executive.css
@@ -0,0 +1,24 @@
+body {
+ font-family: Georgia, 'Times New Roman', serif;
+ color: #222;
+ line-height: 1.5;
+ font-size: 14px;
+ padding: 2rem;
+}
+.cv { max-width: 780px; margin: 0 auto; }
+.header { text-align: center; border-bottom: 2px solid #222; padding-bottom: 1rem; margin-bottom: 1.5rem; }
+.header h1 { font-size: 2rem; font-weight: 400; letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 0.25rem; }
+.headline { font-style: italic; color: #555; font-size: 1rem; margin-bottom: 0.5rem; }
+.contact-row { display: flex; justify-content: center; flex-wrap: wrap; gap: 1.2rem; font-size: 0.85rem; color: #555; }
+.section { margin-bottom: 1.5rem; }
+.section h2 { font-size: 1.1rem; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 1px solid #999; padding-bottom: 0.2rem; margin-bottom: 0.75rem; color: #333; }
+.entry { margin-bottom: 1rem; }
+.entry-header { display: flex; justify-content: space-between; align-items: baseline; }
+.org { color: #555; font-size: 0.9rem; margin-bottom: 0.25rem; }
+.dates { font-size: 0.85rem; color: #555; white-space: nowrap; }
+.description { margin-top: 0.3rem; color: #333; white-space: pre-line; }
+.skill-line { font-size: 0.9rem; color: #444; margin-top: 0.3rem; }
+.intro { font-style: italic; color: #444; }
+@media print {
+ body { padding: 0; font-size: 11pt; }
+}
diff --git a/packages/core/src/modules/cv-template/seed/templates/classic-executive.hbs b/packages/core/src/modules/cv-template/seed/templates/classic-executive.hbs
new file mode 100644
index 0000000..0b5e510
--- /dev/null
+++ b/packages/core/src/modules/cv-template/seed/templates/classic-executive.hbs
@@ -0,0 +1,66 @@
+
+
+
+ {{#if profile.summary}}
+
+ Professional Summary
+ {{profile.summary}}
+
+ {{/if}}
+
+ {{#if cv.introduction}}
+
+ {{/if}}
+
+ {{#if (hasItems experience)}}
+
+ Professional Experience
+ {{#each experience}}
+
+
+
{{this.company}}{{#if this.level}}, {{this.level}}{{/if}}
+ {{#if this.description}}
{{this.description}}
{{/if}}
+ {{#if (hasItems this.skills)}}
Key skills: {{join this.skills ", "}}
{{/if}}
+
+ {{/each}}
+
+ {{/if}}
+
+ {{#if (hasItems education)}}
+
+ Education
+ {{#each education}}
+
+
+
{{this.institution}}
+ {{#if this.description}}
{{this.description}}
{{/if}}
+
+ {{/each}}
+
+ {{/if}}
+
+ {{#if (hasItems allSkills)}}
+
+ Core Competencies
+ {{join allSkills " • "}}
+
+ {{/if}}
+
diff --git a/packages/core/src/modules/cv-template/seed/templates/creative-portfolio.css b/packages/core/src/modules/cv-template/seed/templates/creative-portfolio.css
new file mode 100644
index 0000000..1890c23
--- /dev/null
+++ b/packages/core/src/modules/cv-template/seed/templates/creative-portfolio.css
@@ -0,0 +1,35 @@
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ color: #2d3748;
+ line-height: 1.6;
+ font-size: 14px;
+ margin: 0;
+ padding: 0;
+}
+.cv { display: flex; min-height: 100vh; }
+.sidebar { width: 280px; background: #1a1a2e; color: #e2e8f0; padding: 2rem 1.5rem; flex-shrink: 0; }
+.sidebar-header h1 { font-size: 1.5rem; font-weight: 700; color: #fff; margin-bottom: 0.25rem; }
+.headline { color: #a78bfa; font-size: 0.95rem; }
+.sidebar-section { margin-top: 1.5rem; }
+.sidebar-section h3 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.1em; color: #a78bfa; margin-bottom: 0.5rem; }
+.sidebar-section p { font-size: 0.85rem; margin-bottom: 0.25rem; }
+.sidebar-section a { color: #c4b5fd; text-decoration: none; }
+.skill-tags { display: flex; flex-wrap: wrap; gap: 0.3rem; }
+.tag { background: rgba(167,139,250,0.2); color: #c4b5fd; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; }
+.main { flex: 1; padding: 2rem; }
+.section { margin-bottom: 1.5rem; }
+.section h2 { font-size: 1.15rem; color: #1a1a2e; border-bottom: 2px solid #a78bfa; padding-bottom: 0.2rem; margin-bottom: 0.75rem; }
+.entry { margin-bottom: 1rem; }
+.entry-header { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; }
+.entry-header h3 { font-size: 1rem; font-weight: 600; }
+.org { color: #718096; font-size: 0.9rem; }
+.dates { font-size: 0.85rem; color: #718096; white-space: nowrap; }
+.description { margin-top: 0.3rem; color: #4a5568; white-space: pre-line; }
+.intro { color: #718096; font-style: italic; }
+.inline-skills { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.4rem; }
+.inline-tag { background: #ede9fe; color: #6d28d9; padding: 0.1rem 0.5rem; border-radius: 4px; font-size: 0.75rem; }
+@media print {
+ .cv { display: flex; }
+ .sidebar { background: #1a1a2e !important; color: #e2e8f0 !important; }
+ .tag { border: 1px solid #a78bfa; }
+}
diff --git a/packages/core/src/modules/cv-template/seed/templates/creative-portfolio.hbs b/packages/core/src/modules/cv-template/seed/templates/creative-portfolio.hbs
new file mode 100644
index 0000000..ea876f5
--- /dev/null
+++ b/packages/core/src/modules/cv-template/seed/templates/creative-portfolio.hbs
@@ -0,0 +1,72 @@
+
+
+
+
+ {{#if profile.summary}}
+
+ Profile
+ {{profile.summary}}
+
+ {{/if}}
+
+ {{#if cv.introduction}}
+
+ {{/if}}
+
+ {{#if (hasItems experience)}}
+
+ Experience
+ {{#each experience}}
+
+
+
{{this.company}}{{#if this.level}} · {{this.level}}{{/if}}
+ {{#if this.description}}
{{this.description}}
{{/if}}
+ {{#if (hasItems this.skills)}}
{{#each this.skills}}{{this}} {{/each}}
{{/if}}
+
+ {{/each}}
+
+ {{/if}}
+
+ {{#if (hasItems education)}}
+
+ Education
+ {{#each education}}
+
+
+
{{this.institution}}{{#if this.fieldOfStudy}} · {{this.fieldOfStudy}}{{/if}}
+ {{#if this.description}}
{{this.description}}
{{/if}}
+
+ {{/each}}
+
+ {{/if}}
+
+
diff --git a/packages/core/src/modules/cv-template/seed/templates/modern-professional.css b/packages/core/src/modules/cv-template/seed/templates/modern-professional.css
new file mode 100644
index 0000000..0840be5
--- /dev/null
+++ b/packages/core/src/modules/cv-template/seed/templates/modern-professional.css
@@ -0,0 +1,29 @@
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ color: #1a1a2e;
+ line-height: 1.6;
+ font-size: 14px;
+ padding: 2rem;
+}
+.cv { max-width: 800px; margin: 0 auto; }
+.header { border-bottom: 2px solid #2563eb; padding-bottom: 1rem; margin-bottom: 1.5rem; }
+.header h1 { font-size: 2rem; font-weight: 700; margin-bottom: 0.25rem; }
+.headline { color: #2563eb; font-size: 1.1rem; margin-bottom: 0.5rem; }
+.contact-row { display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.85rem; color: #555; }
+.contact-row a { color: #2563eb; text-decoration: none; }
+.section { margin-bottom: 1.5rem; }
+.section h2 { font-size: 1.2rem; text-transform: uppercase; letter-spacing: 0.05em; color: #2563eb; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.25rem; margin-bottom: 0.75rem; }
+.entry { margin-bottom: 1rem; }
+.entry-header { display: flex; justify-content: space-between; align-items: flex-start; }
+.entry-header h3 { font-size: 1rem; font-weight: 600; }
+.company { color: #555; font-size: 0.9rem; }
+.dates { text-align: right; font-size: 0.85rem; color: #555; white-space: nowrap; }
+.duration { display: block; font-size: 0.8rem; color: #888; }
+.description { margin-top: 0.4rem; color: #333; white-space: pre-line; }
+.skills { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.5rem; }
+.tag { background: #eff6ff; color: #2563eb; padding: 0.15rem 0.6rem; border-radius: 9999px; font-size: 0.8rem; }
+@media print {
+ body { padding: 0; font-size: 11pt; }
+ .header { border-color: #2563eb; }
+ .tag { border: 1px solid #2563eb; background: transparent; }
+}
diff --git a/packages/core/src/modules/cv-template/seed/templates/modern-professional.hbs b/packages/core/src/modules/cv-template/seed/templates/modern-professional.hbs
new file mode 100644
index 0000000..e95297f
--- /dev/null
+++ b/packages/core/src/modules/cv-template/seed/templates/modern-professional.hbs
@@ -0,0 +1,78 @@
+
+
+
+ {{#if profile.summary}}
+
+ Profile
+ {{profile.summary}}
+
+ {{/if}}
+
+ {{#if cv.introduction}}
+
+ About This CV
+ {{cv.introduction}}
+
+ {{/if}}
+
+ {{#if (hasItems experience)}}
+
+ Experience
+ {{#each experience}}
+
+
+ {{#if this.description}}
{{this.description}}
{{/if}}
+ {{#if (hasItems this.skills)}}
{{#each this.skills}}{{this}} {{/each}}
{{/if}}
+
+ {{/each}}
+
+ {{/if}}
+
+ {{#if (hasItems education)}}
+
+ Education
+ {{#each education}}
+
+
+ {{#if this.description}}
{{this.description}}
{{/if}}
+ {{#if (hasItems this.skills)}}
{{#each this.skills}}{{this}} {{/each}}
{{/if}}
+
+ {{/each}}
+
+ {{/if}}
+
+ {{#if (hasItems allSkills)}}
+
+ Skills
+ {{#each allSkills}}{{this}} {{/each}}
+
+ {{/if}}
+
diff --git a/packages/core/src/modules/data-import/data-import-source.interface.ts b/packages/core/src/modules/data-import/data-import-source.interface.ts
new file mode 100644
index 0000000..a7c4442
--- /dev/null
+++ b/packages/core/src/modules/data-import/data-import-source.interface.ts
@@ -0,0 +1,11 @@
+import type { ParsedCVData } from "@cv/ai-parser";
+import type { User } from "../auth";
+
+export interface DataImportSource {
+ readonly name: string;
+ execute(
+ user: User,
+ params: Record,
+ onStatus: (message: string) => Promise,
+ ): Promise;
+}
diff --git a/packages/core/src/modules/data-import/data-import.module.ts b/packages/core/src/modules/data-import/data-import.module.ts
new file mode 100644
index 0000000..afbae43
--- /dev/null
+++ b/packages/core/src/modules/data-import/data-import.module.ts
@@ -0,0 +1,26 @@
+import { AuthorizationModule } from "../auth";
+import { DatabaseModule } from "../database";
+import { Module } from "@nestjs/common";
+import { EntityResolverService } from "../cv-parser/entity-resolver.service";
+import { ProfileModule } from "../profile/profile.module";
+import { ImportService } from "./import.service";
+import { ImportJobMapper } from "./import-job.mapper";
+import { ImportJobPolicy } from "./import-job.policy";
+import { ImportJobListener } from "./listeners/import-job.listener";
+import { UserFileMapper } from "./user-file.mapper";
+import { UserFilePolicy } from "./user-file.policy";
+
+@Module({
+ imports: [DatabaseModule, AuthorizationModule, ProfileModule],
+ providers: [
+ ImportService,
+ EntityResolverService,
+ UserFileMapper,
+ UserFilePolicy,
+ ImportJobMapper,
+ ImportJobPolicy,
+ ImportJobListener,
+ ],
+ exports: [ImportService, UserFileMapper, ImportJobMapper],
+})
+export class DataImportModule {}
diff --git a/packages/core/src/modules/data-import/events/user-file-created.event.ts b/packages/core/src/modules/data-import/events/user-file-created.event.ts
new file mode 100644
index 0000000..68195f4
--- /dev/null
+++ b/packages/core/src/modules/data-import/events/user-file-created.event.ts
@@ -0,0 +1,12 @@
+import { DomainEvent } from "../../events";
+import type { User } from "../../auth";
+import type { DataImportSource } from "../data-import-source.interface";
+
+interface UserFileCreatedPayload {
+ userFileId: string;
+ user: User;
+ source: DataImportSource;
+ params: Record;
+}
+
+export class UserFileCreatedEvent extends DomainEvent {}
diff --git a/packages/core/src/modules/data-import/import-job.entity.ts b/packages/core/src/modules/data-import/import-job.entity.ts
new file mode 100644
index 0000000..de6b1bf
--- /dev/null
+++ b/packages/core/src/modules/data-import/import-job.entity.ts
@@ -0,0 +1,18 @@
+import { BaseEntity } from "../../shared";
+
+export class ImportJob extends BaseEntity {
+ constructor(
+ id: string,
+ public readonly userFileId: string,
+ public readonly source: string,
+ public readonly status: string,
+ public readonly statusMessage: string,
+ public readonly error: string | null,
+ public readonly startedAt: Date | null,
+ public readonly completedAt: Date | null,
+ createdAt: Date,
+ updatedAt: Date,
+ ) {
+ super(id, createdAt, updatedAt);
+ }
+}
diff --git a/packages/core/src/modules/data-import/import-job.mapper.ts b/packages/core/src/modules/data-import/import-job.mapper.ts
new file mode 100644
index 0000000..d07c68a
--- /dev/null
+++ b/packages/core/src/modules/data-import/import-job.mapper.ts
@@ -0,0 +1,36 @@
+import type { BaseMapper } from "../../shared";
+import { Injectable } from "@nestjs/common";
+import type { Prisma } from "@prisma/client";
+
+type PrismaImportJob = Prisma.ImportJobGetPayload;
+
+import { ImportJob } from "./import-job.entity";
+
+@Injectable()
+export class ImportJobMapper implements BaseMapper {
+ toDomain(prisma: null): null;
+ toDomain(prisma: PrismaImportJob): ImportJob;
+ toDomain(prisma: PrismaImportJob | null): ImportJob | null;
+ toDomain(prisma: PrismaImportJob | null): ImportJob | null {
+ if (!prisma) return null;
+
+ return new ImportJob(
+ prisma.id,
+ prisma.userFileId,
+ prisma.source,
+ prisma.status,
+ prisma.statusMessage,
+ prisma.error,
+ prisma.startedAt,
+ prisma.completedAt,
+ prisma.createdAt,
+ prisma.updatedAt,
+ );
+ }
+
+ mapToDomain(items: PrismaImportJob[]): ImportJob[] {
+ return items
+ .map((item) => this.toDomain(item))
+ .filter((item): item is ImportJob => item !== null);
+ }
+}
diff --git a/packages/core/src/modules/data-import/import-job.policy.ts b/packages/core/src/modules/data-import/import-job.policy.ts
new file mode 100644
index 0000000..f586a2c
--- /dev/null
+++ b/packages/core/src/modules/data-import/import-job.policy.ts
@@ -0,0 +1,28 @@
+import type { IPolicy, User } from "../auth";
+import { Policy } from "../auth";
+import { Injectable } from "@nestjs/common";
+import { ImportJob } from "./import-job.entity";
+
+/**
+ * ImportJob is an internal/admin-only entity.
+ * All user-facing operations go through UserFile instead.
+ */
+@Injectable()
+@Policy(ImportJob)
+export class ImportJobPolicy implements IPolicy {
+ view(_user: User, _resource: ImportJob): boolean {
+ return false;
+ }
+
+ create(_user: User, _resource?: Partial): boolean {
+ return false;
+ }
+
+ update(_user: User, _resource: ImportJob): boolean {
+ return false;
+ }
+
+ delete(_user: User, _resource: ImportJob): boolean {
+ return false;
+ }
+}
diff --git a/packages/core/src/modules/data-import/import.service.ts b/packages/core/src/modules/data-import/import.service.ts
new file mode 100644
index 0000000..480b0f7
--- /dev/null
+++ b/packages/core/src/modules/data-import/import.service.ts
@@ -0,0 +1,85 @@
+import type { User } from "../auth";
+import { PrismaService } from "../database";
+import { Injectable } from "@nestjs/common";
+import { EventService } from "../events";
+import type { DataImportSource } from "./data-import-source.interface";
+import { UserFileCreatedEvent } from "./events/user-file-created.event";
+import { UserFile } from "./user-file.entity";
+import { UserFileMapper } from "./user-file.mapper";
+
+@Injectable()
+export class ImportService {
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly userFileMapper: UserFileMapper,
+ private readonly eventService: EventService,
+ ) {}
+
+ /**
+ * Find an existing completed UserFile with the same fingerprint for this user.
+ */
+ async findDuplicateForUser(
+ userId: string,
+ fingerprint: string,
+ ): Promise {
+ const record = await this.prisma.userFile.findFirst({
+ where: {
+ fingerprint,
+ profile: { userId },
+ status: "completed",
+ },
+ orderBy: { createdAt: "desc" },
+ });
+ return this.userFileMapper.toDomain(record);
+ }
+
+ /**
+ * Create a UserFile and emit an event for async processing.
+ */
+ async createImport(
+ user: User,
+ profileId: string,
+ source: DataImportSource,
+ file: { fileName: string; mimeType: string; sizeBytes: number; fingerprint?: string },
+ params: Record,
+ ): Promise {
+ const record = await this.prisma.userFile.create({
+ data: {
+ profile: { connect: { id: profileId } },
+ fingerprint: file.fingerprint ?? null,
+ fileName: file.fileName,
+ mimeType: file.mimeType,
+ sizeBytes: file.sizeBytes,
+ source: source.name,
+ status: "pending",
+ statusMessage: "Queued for processing",
+ },
+ });
+
+ this.eventService.emit(
+ new UserFileCreatedEvent({ userFileId: record.id, user, source, params }),
+ );
+
+ return this.userFileMapper.toDomain(record) as UserFile;
+ }
+
+ /**
+ * Delete a UserFile and its cascading ImportJobs.
+ */
+ async deleteUserFile(id: string): Promise {
+ await this.prisma.userFile.delete({ where: { id } });
+ }
+
+ async findUserFileById(id: string): Promise {
+ const record = await this.prisma.userFile.findUnique({ where: { id } });
+ return this.userFileMapper.toDomain(record);
+ }
+
+ async findUserFilesForUser(userId: string): Promise {
+ const records = await this.prisma.userFile.findMany({
+ where: { profile: { userId } },
+ orderBy: { createdAt: "desc" },
+ });
+ return this.userFileMapper.mapToDomain(records);
+ }
+}
diff --git a/packages/core/src/modules/data-import/listeners/import-job.listener.ts b/packages/core/src/modules/data-import/listeners/import-job.listener.ts
new file mode 100644
index 0000000..4e0c3fb
--- /dev/null
+++ b/packages/core/src/modules/data-import/listeners/import-job.listener.ts
@@ -0,0 +1,268 @@
+import type { ParsedCVData } from "@cv/ai-parser";
+import { PrismaService } from "../../database";
+import { Injectable, Logger } from "@nestjs/common";
+import { OnEvent } from "@nestjs/event-emitter";
+import {
+ EntityResolverService,
+ type ResolvedEducation,
+ type ResolvedJobExperience,
+} from "../../cv-parser/entity-resolver.service";
+import { ProfileService } from "../../profile/profile.service";
+import { UserFileCreatedEvent } from "../events/user-file-created.event";
+
+interface ResolvedResult {
+ personalInfo?: {
+ name?: string | undefined;
+ headline?: string | undefined;
+ introduction?: string | undefined;
+ city?: string | undefined;
+ country?: string | undefined;
+ phone?: string | undefined;
+ website?: string | undefined;
+ linkedInUrl?: string | undefined;
+ };
+ jobExperiences: ResolvedJobExperience[];
+ education: ResolvedEducation[];
+}
+
+@Injectable()
+export class ImportJobListener {
+ private readonly logger = new Logger(ImportJobListener.name);
+
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly entityResolver: EntityResolverService,
+ private readonly profileService: ProfileService,
+ ) {}
+
+ @OnEvent(UserFileCreatedEvent.eventName, { async: true })
+ async handleUserFileCreated(event: UserFileCreatedEvent): Promise {
+ const job = await this.prisma.importJob.create({
+ data: {
+ userFileId: event.payload.userFileId,
+ source: event.payload.source.name,
+ status: "pending",
+ statusMessage: "Queued",
+ },
+ });
+
+ await this.processJob(job.id, event);
+ }
+
+ private async processJob(
+ jobId: string,
+ event: UserFileCreatedEvent,
+ ): Promise {
+ const { userFileId, user, source, params } = event.payload;
+ const tag = `job=${jobId} source=${source.name}`;
+
+ try {
+ await this.startJob(jobId, userFileId);
+
+ const userFile = await this.prisma.userFile.findUniqueOrThrow({
+ where: { id: userFileId },
+ select: { profileId: true },
+ });
+
+ const onStatus = async (message: string) => {
+ this.logger.log(`${tag} ${message}`);
+ await this.updateStatusMessage(jobId, userFileId, message);
+ };
+
+ const parsed: ParsedCVData = await source.execute(user, params, onStatus);
+
+ this.logger.log(
+ `${tag} Parsed: ${parsed.jobExperiences.length} job(s), ` +
+ `${parsed.education.length} education(s), ${parsed.skills.length} skill(s)`,
+ );
+ this.logger.debug(`${tag} Parsed result: ${JSON.stringify(parsed, null, 2)}`);
+
+ await this.updateStatusMessage(jobId, userFileId, "Resolving entities");
+ const resolved = await this.resolveEntities(parsed);
+
+ if (parsed.personalInfo) {
+ resolved.personalInfo = parsed.personalInfo;
+ }
+
+ this.logger.log(`${tag} Entity resolution complete`);
+
+ await this.populateProfile(userFile.profileId, parsed, tag);
+ await this.completeJob(jobId, userFileId, resolved);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Processing failed";
+ this.logger.error(`${tag} Import failed: ${message}`, err instanceof Error ? err.stack : undefined);
+ await this.failJob(jobId, userFileId, message);
+ }
+ }
+
+ private async resolveEntities(parsed: ParsedCVData): Promise {
+ const [jobExperiences, education] = await Promise.all([
+ Promise.all(
+ parsed.jobExperiences.map(async (job) => {
+ const [company, role, level, skills] = await Promise.all([
+ this.entityResolver.resolveCompany(job.companyName),
+ this.entityResolver.resolveRole(job.roleName),
+ this.entityResolver.resolveLevel(job.levelName),
+ this.entityResolver.resolveSkills(job.skills ?? []),
+ ]);
+
+ return {
+ company,
+ role,
+ level,
+ skills,
+ startDate: new Date(job.startDate),
+ endDate: job.endDate ? new Date(job.endDate) : null,
+ description: job.description ?? null,
+ };
+ }),
+ ),
+ Promise.all(
+ parsed.education.map(async (edu) => {
+ const [institution, skills] = await Promise.all([
+ this.entityResolver.resolveInstitution(edu.institutionName),
+ this.entityResolver.resolveSkills(edu.skills ?? []),
+ ]);
+
+ return {
+ institution,
+ degree: edu.degree,
+ fieldOfStudy: edu.fieldOfStudy ?? null,
+ skills,
+ startDate: new Date(edu.startDate),
+ endDate: edu.endDate ? new Date(edu.endDate) : null,
+ description: edu.description ?? null,
+ };
+ }),
+ ),
+ ]);
+
+ return { jobExperiences, education };
+ }
+
+ /**
+ * Auto-populate empty profile fields from imported personalInfo.
+ * Only fills fields that are currently empty -- never overwrites existing data.
+ */
+ private async populateProfile(
+ profileId: string,
+ parsed: ParsedCVData,
+ tag: string,
+ ): Promise {
+ const info = parsed.personalInfo;
+ if (!info) return;
+
+ try {
+ const existing = await this.profileService.findByIdOrFail(profileId);
+ const updates: Record = {};
+
+ const fieldMap: Record = {
+ fullName: info.name,
+ headline: info.headline,
+ summary: info.introduction,
+ city: info.city,
+ country: info.country,
+ phone: info.phone,
+ website: info.website,
+ linkedInUrl: info.linkedInUrl,
+ };
+
+ for (const [field, value] of Object.entries(fieldMap)) {
+ if (value && !existing[field as keyof typeof existing]) {
+ updates[field] = value;
+ }
+ }
+
+ if (Object.keys(updates).length === 0) {
+ this.logger.debug(`${tag} Profile already has data, skipping auto-populate`);
+ return;
+ }
+
+ await this.profileService.updateProfile(profileId, updates);
+ this.logger.log(`${tag} Auto-populated profile: ${Object.keys(updates).join(", ")}`);
+ } catch (err) {
+ this.logger.warn(`${tag} Failed to auto-populate profile (non-fatal): ${err instanceof Error ? err.message : err}`);
+ }
+ }
+
+ private async startJob(jobId: string, userFileId: string): Promise {
+ await Promise.all([
+ this.prisma.importJob.update({
+ where: { id: jobId },
+ data: {
+ status: "processing",
+ statusMessage: "Starting import",
+ startedAt: new Date(),
+ },
+ }),
+ this.prisma.userFile.update({
+ where: { id: userFileId },
+ data: { status: "processing", statusMessage: "Starting import" },
+ }),
+ ]);
+ }
+
+ private async updateStatusMessage(
+ jobId: string,
+ userFileId: string,
+ message: string,
+ ): Promise {
+ await Promise.all([
+ this.prisma.importJob.update({
+ where: { id: jobId },
+ data: { statusMessage: message },
+ }),
+ this.prisma.userFile.update({
+ where: { id: userFileId },
+ data: { statusMessage: message },
+ }),
+ ]);
+ }
+
+ private async completeJob(
+ jobId: string,
+ userFileId: string,
+ result: ResolvedResult,
+ ): Promise {
+ await Promise.all([
+ this.prisma.importJob.update({
+ where: { id: jobId },
+ data: {
+ status: "completed",
+ statusMessage: "Import completed",
+ completedAt: new Date(),
+ },
+ }),
+ this.prisma.userFile.update({
+ where: { id: userFileId },
+ data: {
+ status: "completed",
+ statusMessage: "Import completed",
+ resultJson: JSON.stringify(result),
+ },
+ }),
+ ]);
+ }
+
+ private async failJob(
+ jobId: string,
+ userFileId: string,
+ error: string,
+ ): Promise {
+ await Promise.all([
+ this.prisma.importJob.update({
+ where: { id: jobId },
+ data: {
+ status: "failed",
+ statusMessage: "Import failed",
+ error,
+ completedAt: new Date(),
+ },
+ }),
+ this.prisma.userFile.update({
+ where: { id: userFileId },
+ data: { status: "failed", statusMessage: "Import failed", error },
+ }),
+ ]);
+ }
+}
diff --git a/packages/core/src/modules/data-import/sources/file-import-source.ts b/packages/core/src/modules/data-import/sources/file-import-source.ts
new file mode 100644
index 0000000..7ad9276
--- /dev/null
+++ b/packages/core/src/modules/data-import/sources/file-import-source.ts
@@ -0,0 +1,150 @@
+import {
+ CVParserService as CVParser,
+ type ExistingUserContext,
+ type ParsedCVData,
+} from "@cv/ai-parser";
+import type { User } from "../../auth";
+import { PrismaService } from "../../database";
+import { TextExtractorRegistry, TEXT_EXTRACTOR_REGISTRY } from "@cv/file-upload";
+import { Inject, Injectable, Logger } from "@nestjs/common";
+import { EducationService } from "../../education/education.service";
+import { AIProviderResolverService } from "../../cv-parser/ai-provider-resolver.service";
+import { UserJobExperienceService } from "../../job-experience/employment/user-job-experience.service";
+import { ProfileService } from "../../profile/profile.service";
+import type { DataImportSource } from "../data-import-source.interface";
+
+/**
+ * Build ExistingUserContext from domain models for AI prompt enrichment.
+ */
+const buildExistingUserContext = (
+ profile: { fullName?: string | null; headline?: string | null } | null,
+ jobs: Array<{ company: { name: string }; role: { name: string }; startDate: Date; endDate?: Date }>,
+ educations: Array<{ institution: { name: string }; degree: string; startDate: Date; endDate: Date | null }>,
+): ExistingUserContext | undefined => {
+ const context: ExistingUserContext = {};
+
+ if (profile?.fullName) context.name = profile.fullName;
+ if (profile?.headline) context.headline = profile.headline;
+
+ if (jobs.length > 0) {
+ context.jobs = jobs.map((j) => {
+ const entry: { company: string; role: string; startDate: string; endDate?: string } = {
+ company: j.company.name,
+ role: j.role.name,
+ startDate: j.startDate.toISOString().slice(0, 10),
+ };
+ if (j.endDate) entry.endDate = j.endDate.toISOString().slice(0, 10);
+ return entry;
+ });
+ }
+
+ if (educations.length > 0) {
+ context.education = educations.map((e) => {
+ const entry: { institution: string; degree: string; startDate: string; endDate?: string } = {
+ institution: e.institution.name,
+ degree: e.degree,
+ startDate: e.startDate.toISOString().slice(0, 10),
+ };
+ if (e.endDate) entry.endDate = e.endDate.toISOString().slice(0, 10);
+ return entry;
+ });
+ }
+
+ const hasData = Object.keys(context).length > 0;
+ return hasData ? context : undefined;
+};
+
+/**
+ * Wraps the existing file extraction + AI parsing pipeline
+ * as a DataImportSource.
+ */
+@Injectable()
+export class FileImportSource implements DataImportSource {
+ readonly name = "file-upload";
+ private readonly logger = new Logger(FileImportSource.name);
+
+ constructor(
+ @Inject(TEXT_EXTRACTOR_REGISTRY)
+ private readonly textExtractorRegistry: TextExtractorRegistry,
+ private readonly providerResolver: AIProviderResolverService,
+ private readonly profileService: ProfileService,
+ private readonly jobExperienceService: UserJobExperienceService,
+ private readonly educationService: EducationService,
+ private readonly prisma: PrismaService,
+ ) {}
+
+ async execute(
+ user: User,
+ params: Record,
+ onStatus: (message: string) => Promise,
+ ): Promise {
+ const buffer = params["buffer"] as Buffer;
+ const mimeType = params["mimeType"] as string;
+
+ await onStatus("Extracting text from file");
+
+ const extraction = await this.textExtractorRegistry.extract(
+ buffer,
+ mimeType,
+ );
+
+ if (!extraction.success) {
+ throw new Error(`Text extraction failed: ${extraction.error}`);
+ }
+
+ if (!extraction.text || extraction.text.trim().length === 0) {
+ throw new Error("Could not extract any text from the file");
+ }
+
+ this.logger.log(`Extracted ${extraction.text.length} chars from ${mimeType}`);
+ this.logger.debug(`Extracted text preview: ${extraction.text.slice(0, 500)}`);
+
+ await onStatus("Gathering existing profile data");
+
+ const existingContext = await this.gatherUserContext(user);
+
+ await onStatus("Analyzing with AI");
+
+ const provider = await this.providerResolver.resolveForUser(user);
+ this.logger.log(`AI provider: ${provider.constructor.name}`);
+
+ const parser = new CVParser(provider);
+ const result = await parser.parseCVText(extraction.text, existingContext);
+
+ return result;
+ }
+
+ private async gatherUserContext(user: User): Promise {
+ try {
+ const firstProfile = await this.prisma.profile.findFirst({
+ where: { userId: user.id },
+ select: { id: true },
+ });
+
+ if (!firstProfile) return undefined;
+
+ const [profile, jobs, educationResult] = await Promise.all([
+ this.profileService.findByIdOrFail(firstProfile.id),
+ this.jobExperienceService.findForProfile(firstProfile.id),
+ this.educationService.findManyForProfile(firstProfile.id),
+ ]);
+
+ const context = buildExistingUserContext(
+ profile,
+ jobs,
+ educationResult.edges.map((e) => e.node),
+ );
+
+ if (context) {
+ this.logger.debug(`User context: ${JSON.stringify(context)}`);
+ }
+
+ return context;
+ } catch (err) {
+ this.logger.warn(
+ `Failed to gather user context (non-fatal): ${err instanceof Error ? err.message : err}`,
+ );
+ return undefined;
+ }
+ }
+}
diff --git a/packages/core/src/modules/data-import/user-file.entity.ts b/packages/core/src/modules/data-import/user-file.entity.ts
new file mode 100644
index 0000000..4e353cd
--- /dev/null
+++ b/packages/core/src/modules/data-import/user-file.entity.ts
@@ -0,0 +1,21 @@
+import { BaseEntity } from "../../shared";
+
+export class UserFile extends BaseEntity {
+ constructor(
+ id: string,
+ public readonly profileId: string,
+ public readonly fingerprint: string | null,
+ public readonly fileName: string,
+ public readonly mimeType: string,
+ public readonly sizeBytes: number,
+ public readonly source: string,
+ public readonly status: string,
+ public readonly statusMessage: string,
+ public readonly resultJson: string | null,
+ public readonly error: string | null,
+ createdAt: Date,
+ updatedAt: Date,
+ ) {
+ super(id, createdAt, updatedAt);
+ }
+}
diff --git a/packages/core/src/modules/data-import/user-file.mapper.ts b/packages/core/src/modules/data-import/user-file.mapper.ts
new file mode 100644
index 0000000..935eda9
--- /dev/null
+++ b/packages/core/src/modules/data-import/user-file.mapper.ts
@@ -0,0 +1,39 @@
+import type { BaseMapper } from "../../shared";
+import { Injectable } from "@nestjs/common";
+import type { Prisma } from "@prisma/client";
+
+type PrismaUserFile = Prisma.UserFileGetPayload;
+
+import { UserFile } from "./user-file.entity";
+
+@Injectable()
+export class UserFileMapper implements BaseMapper {
+ toDomain(prisma: null): null;
+ toDomain(prisma: PrismaUserFile): UserFile;
+ toDomain(prisma: PrismaUserFile | null): UserFile | null;
+ toDomain(prisma: PrismaUserFile | null): UserFile | null {
+ if (!prisma) return null;
+
+ return new UserFile(
+ prisma.id,
+ prisma.profileId,
+ prisma.fingerprint,
+ prisma.fileName,
+ prisma.mimeType,
+ prisma.sizeBytes,
+ prisma.source,
+ prisma.status,
+ prisma.statusMessage,
+ prisma.resultJson,
+ prisma.error,
+ prisma.createdAt,
+ prisma.updatedAt,
+ );
+ }
+
+ mapToDomain(items: PrismaUserFile[]): UserFile[] {
+ return items
+ .map((item) => this.toDomain(item))
+ .filter((item): item is UserFile => item !== null);
+ }
+}
diff --git a/packages/core/src/modules/data-import/user-file.policy.ts b/packages/core/src/modules/data-import/user-file.policy.ts
new file mode 100644
index 0000000..10efd0f
--- /dev/null
+++ b/packages/core/src/modules/data-import/user-file.policy.ts
@@ -0,0 +1,12 @@
+import { Policy, ProfileOwnedResourcePolicy } from "../auth";
+import { PrismaService } from "../database";
+import { Injectable } from "@nestjs/common";
+import { UserFile } from "./user-file.entity";
+
+@Injectable()
+@Policy(UserFile)
+export class UserFilePolicy extends ProfileOwnedResourcePolicy {
+ constructor(prisma: PrismaService) {
+ super(prisma);
+ }
+}
diff --git a/packages/system/src/database/database.module.ts b/packages/core/src/modules/database/database.module.ts
similarity index 100%
rename from packages/system/src/database/database.module.ts
rename to packages/core/src/modules/database/database.module.ts
diff --git a/packages/system/src/database/index.ts b/packages/core/src/modules/database/index.ts
similarity index 100%
rename from packages/system/src/database/index.ts
rename to packages/core/src/modules/database/index.ts
diff --git a/packages/system/src/database/prisma.service.ts b/packages/core/src/modules/database/prisma.service.ts
similarity index 100%
rename from packages/system/src/database/prisma.service.ts
rename to packages/core/src/modules/database/prisma.service.ts
diff --git a/packages/core/src/modules/database/seed/seed.module.ts b/packages/core/src/modules/database/seed/seed.module.ts
new file mode 100644
index 0000000..23f662a
--- /dev/null
+++ b/packages/core/src/modules/database/seed/seed.module.ts
@@ -0,0 +1,24 @@
+import { DatabaseModule } from "..";
+import { Module } from "@nestjs/common";
+import { ConfigModule } from "@nestjs/config";
+import { DiscoveryModule } from "@nestjs/core";
+import { AuthorizationModule } from "../../auth/authorization/authorization.module";
+import { TokenModule } from "../../auth/token/token.module";
+import { UserModule } from "../../auth/user/user.module";
+import { CVTemplateSeedService } from "../../cv-template/seed/cv-template.seed";
+import { UserSeedService } from "../../user/seed/user.seed";
+import { SeedService } from "./seed.service";
+
+@Module({
+ imports: [
+ ConfigModule.forRoot(),
+ DatabaseModule,
+ DiscoveryModule,
+ UserModule,
+ TokenModule,
+ AuthorizationModule,
+ ],
+ providers: [SeedService, UserSeedService, CVTemplateSeedService],
+ exports: [SeedService],
+})
+export class SeedModule {}
diff --git a/packages/core/src/modules/database/seed/seed.service.ts b/packages/core/src/modules/database/seed/seed.service.ts
new file mode 100644
index 0000000..abcf5b2
--- /dev/null
+++ b/packages/core/src/modules/database/seed/seed.service.ts
@@ -0,0 +1,104 @@
+import { PrismaService } from "..";
+import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
+import { DiscoveryService } from "@nestjs/core";
+import type { SeederConfig } from "./seeder.decorator";
+import { SEEDER_METADATA_KEY } from "./seeder.decorator";
+import { topologicalSort } from "./topological-sort";
+
+export interface Seeder {
+ seed(prisma: PrismaService): Promise;
+}
+
+const parseMetadata = (
+ metadata: unknown,
+ fallbackName: string,
+): { name: string; dependsOn: string[] } => {
+ if (typeof metadata === "string") return { name: metadata, dependsOn: [] };
+ if (typeof metadata === "object" && metadata !== null) {
+ const config = metadata as SeederConfig;
+ return {
+ name: config.name ?? fallbackName,
+ dependsOn: config.dependsOn ?? [],
+ };
+ }
+ return { name: fallbackName, dependsOn: [] };
+};
+
+@Injectable()
+export class SeedService implements OnModuleInit {
+ private readonly logger = new Logger(SeedService.name);
+ private readonly seeders: Array<{
+ name: string;
+ dependsOn: string[];
+ seeder: Seeder;
+ }> = [];
+
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly discoveryService: DiscoveryService,
+ ) {}
+
+ async onModuleInit(): Promise {
+ const providers = this.discoveryService.getProviders();
+
+ for (const wrapper of providers) {
+ if (!wrapper.isDependencyTreeStatic() || !wrapper.instance) continue;
+
+ const instance = wrapper.instance;
+ const seederMetadata = Reflect.getMetadata(
+ SEEDER_METADATA_KEY,
+ instance.constructor,
+ );
+
+ if (!seederMetadata || typeof instance.seed !== "function") continue;
+
+ const { name, dependsOn } = parseMetadata(
+ seederMetadata,
+ instance.constructor.name,
+ );
+ this.seeders.push({ name, dependsOn, seeder: instance });
+ this.logger.log(`Registered seeder: ${name}`);
+ }
+ }
+
+ async seed(): Promise {
+ this.logger.log("Starting database seeding...");
+
+ const result = topologicalSort(
+ this.seeders.map((s) => ({
+ name: s.name,
+ dependsOn: s.dependsOn,
+ value: s.seeder,
+ })),
+ );
+
+ if ("cycle" in result) {
+ throw new Error(
+ `Circular seeder dependencies detected: ${result.cycle.join(" -> ")}`,
+ );
+ }
+
+ this.logger.log(
+ `Execution order: ${result.sorted.map((s) => s.name).join(" -> ")}`,
+ );
+
+ const failures: string[] = [];
+
+ for (const { name, value: seeder } of result.sorted) {
+ try {
+ this.logger.log(`Seeding: ${name}...`);
+ await seeder.seed(this.prisma);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ this.logger.error(`Seeder "${name}" failed: ${message}`);
+ failures.push(name);
+ }
+ }
+
+ if (failures.length > 0) {
+ throw new Error(`Seeders failed: ${failures.join(", ")}`);
+ }
+
+ this.logger.log("Database seeding completed!");
+ }
+}
diff --git a/packages/core/src/modules/database/seed/seeder.decorator.ts b/packages/core/src/modules/database/seed/seeder.decorator.ts
new file mode 100644
index 0000000..ae72ef9
--- /dev/null
+++ b/packages/core/src/modules/database/seed/seeder.decorator.ts
@@ -0,0 +1,11 @@
+import { SetMetadata } from "@nestjs/common";
+
+export const SEEDER_METADATA_KEY = "seeder";
+
+export interface SeederConfig {
+ name?: string;
+ dependsOn?: string[];
+}
+
+export const Seeder = (config?: string | SeederConfig) =>
+ SetMetadata(SEEDER_METADATA_KEY, config ?? true);
diff --git a/packages/core/src/modules/database/seed/topological-sort.spec.ts b/packages/core/src/modules/database/seed/topological-sort.spec.ts
new file mode 100644
index 0000000..dafdef8
--- /dev/null
+++ b/packages/core/src/modules/database/seed/topological-sort.spec.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it } from "vitest";
+import { topologicalSort } from "./topological-sort";
+
+describe("topologicalSort", () => {
+ it("returns empty array for empty input", () => {
+ const result = topologicalSort([]);
+ expect(result).toEqual({ sorted: [] });
+ });
+
+ it("returns nodes with no dependencies in stable order", () => {
+ const result = topologicalSort([
+ { name: "A", dependsOn: [], value: 1 },
+ { name: "B", dependsOn: [], value: 2 },
+ { name: "C", dependsOn: [], value: 3 },
+ ]);
+
+ expect(result).toEqual({
+ sorted: [
+ { name: "A", value: 1 },
+ { name: "B", value: 2 },
+ { name: "C", value: 3 },
+ ],
+ });
+ });
+
+ it("sorts a linear dependency chain", () => {
+ const result = topologicalSort([
+ { name: "C", dependsOn: ["B"], value: 3 },
+ { name: "A", dependsOn: [], value: 1 },
+ { name: "B", dependsOn: ["A"], value: 2 },
+ ]);
+
+ expect("sorted" in result).toBe(true);
+ if (!("sorted" in result)) return;
+
+ const names = result.sorted.map((n) => n.name);
+ expect(names.indexOf("A")).toBeLessThan(names.indexOf("B"));
+ expect(names.indexOf("B")).toBeLessThan(names.indexOf("C"));
+ });
+
+ it("sorts branching dependencies", () => {
+ const result = topologicalSort([
+ { name: "D", dependsOn: ["B", "C"], value: 4 },
+ { name: "B", dependsOn: ["A"], value: 2 },
+ { name: "C", dependsOn: ["A"], value: 3 },
+ { name: "A", dependsOn: [], value: 1 },
+ ]);
+
+ expect("sorted" in result).toBe(true);
+ if (!("sorted" in result)) return;
+
+ const names = result.sorted.map((n) => n.name);
+ expect(names.indexOf("A")).toBeLessThan(names.indexOf("B"));
+ expect(names.indexOf("A")).toBeLessThan(names.indexOf("C"));
+ expect(names.indexOf("B")).toBeLessThan(names.indexOf("D"));
+ expect(names.indexOf("C")).toBeLessThan(names.indexOf("D"));
+ });
+
+ it("detects circular dependencies", () => {
+ const result = topologicalSort([
+ { name: "A", dependsOn: ["B"], value: 1 },
+ { name: "B", dependsOn: ["A"], value: 2 },
+ ]);
+
+ expect("cycle" in result).toBe(true);
+ if (!("cycle" in result)) return;
+
+ expect(result.cycle).toContain("A");
+ expect(result.cycle).toContain("B");
+ });
+
+ it("throws on unknown dependency names", () => {
+ expect(() =>
+ topologicalSort([
+ { name: "A", dependsOn: ["NonExistent"], value: 1 },
+ ]),
+ ).toThrow("Unknown seeder dependencies: NonExistent");
+ });
+
+ it("preserves values through sorting", () => {
+ const obj = { data: "test" };
+ const result = topologicalSort([
+ { name: "A", dependsOn: [], value: obj },
+ ]);
+
+ expect("sorted" in result).toBe(true);
+ if (!("sorted" in result)) return;
+
+ expect(result.sorted[0].value).toBe(obj);
+ });
+});
diff --git a/packages/core/src/modules/database/seed/topological-sort.ts b/packages/core/src/modules/database/seed/topological-sort.ts
new file mode 100644
index 0000000..448b181
--- /dev/null
+++ b/packages/core/src/modules/database/seed/topological-sort.ts
@@ -0,0 +1,61 @@
+/**
+ * Topologically sorts nodes using Kahn's algorithm.
+ * Returns sorted order or the cycle that prevents resolution.
+ */
+export const topologicalSort = (
+ nodes: Array<{ name: string; dependsOn: string[]; value: T }>,
+): { sorted: Array<{ name: string; value: T }> } | { cycle: string[] } => {
+ const nameSet = new Set(nodes.map((n) => n.name));
+
+ const unknownDeps = nodes.flatMap((n) =>
+ n.dependsOn.filter((dep) => !nameSet.has(dep)),
+ );
+ if (unknownDeps.length > 0) {
+ throw new Error(
+ `Unknown seeder dependencies: ${[...new Set(unknownDeps)].join(", ")}`,
+ );
+ }
+
+ const inDegree = new Map();
+ const adjacency = new Map();
+ const valueMap = new Map();
+
+ nodes.forEach((n) => {
+ inDegree.set(n.name, 0);
+ adjacency.set(n.name, []);
+ valueMap.set(n.name, n.value);
+ });
+
+ nodes.forEach((n) => {
+ n.dependsOn.forEach((dep) => {
+ adjacency.get(dep)!.push(n.name);
+ inDegree.set(n.name, inDegree.get(n.name)! + 1);
+ });
+ });
+
+ const queue = nodes
+ .filter((n) => inDegree.get(n.name) === 0)
+ .map((n) => n.name);
+
+ const sorted: Array<{ name: string; value: T }> = [];
+
+ while (queue.length > 0) {
+ const current = queue.shift()!;
+ sorted.push({ name: current, value: valueMap.get(current)! });
+
+ for (const neighbor of adjacency.get(current)!) {
+ const newDegree = inDegree.get(neighbor)! - 1;
+ inDegree.set(neighbor, newDegree);
+ if (newDegree === 0) queue.push(neighbor);
+ }
+ }
+
+ if (sorted.length !== nodes.length) {
+ const cycle = nodes
+ .filter((n) => inDegree.get(n.name)! > 0)
+ .map((n) => n.name);
+ return { cycle };
+ }
+
+ return { sorted };
+};
diff --git a/packages/core/src/modules/education/education.entity.ts b/packages/core/src/modules/education/education.entity.ts
new file mode 100644
index 0000000..e478690
--- /dev/null
+++ b/packages/core/src/modules/education/education.entity.ts
@@ -0,0 +1,24 @@
+import { BaseEntity } from "../../shared";
+import type { Skill } from "../job-experience/skill/skill.entity";
+import { Institution } from "./institution.entity";
+
+export class Education extends BaseEntity {
+ constructor(
+ id: string,
+ public readonly profileId: string,
+ public readonly institution: Institution,
+ public readonly degree: string,
+ public readonly fieldOfStudy: string | null,
+ public readonly startDate: Date,
+ public readonly endDate: Date | null,
+ public readonly description: string | null,
+ createdAt: Date,
+ updatedAt: Date,
+ public readonly skills?: Skill[],
+ ) {
+ super(id, createdAt, updatedAt);
+ if (skills !== undefined) {
+ this.skills = skills;
+ }
+ }
+}
diff --git a/packages/core/src/modules/education/education.mapper.ts b/packages/core/src/modules/education/education.mapper.ts
new file mode 100644
index 0000000..e467faf
--- /dev/null
+++ b/packages/core/src/modules/education/education.mapper.ts
@@ -0,0 +1,64 @@
+import type { BaseMapper } from "../../shared";
+import { Injectable } from "@nestjs/common";
+import type { Prisma } from "@prisma/client";
+import { SkillMapper } from "../job-experience/skill/skill.mapper";
+import { Education } from "./education.entity";
+import { InstitutionMapper } from "./institution.mapper";
+
+type PrismaEducation = Prisma.EducationGetPayload<{
+ include: {
+ institution: true;
+ profile: true;
+ skills: true;
+ };
+}>;
+
+@Injectable()
+export class EducationMapper implements BaseMapper {
+ constructor(
+ private readonly institutionMapper: InstitutionMapper,
+ private readonly skillMapper: SkillMapper,
+ ) {}
+
+ /**
+ * Maps a Prisma Education entity to a domain Education entity
+ * Uses overloads to return the correct type based on input
+ */
+ toDomain(prismaEducation: null): null;
+ toDomain(prismaEducation: PrismaEducation): Education;
+ toDomain(prismaEducation: PrismaEducation | null): Education | null;
+ toDomain(prismaEducation: PrismaEducation | null): Education | null {
+ if (!prismaEducation) {
+ return null;
+ }
+
+ const institution = this.institutionMapper.toDomain(
+ prismaEducation.institution,
+ );
+ if (!institution) {
+ throw new Error("Institution is required for Education");
+ }
+
+ return new Education(
+ prismaEducation.id,
+ prismaEducation.profileId,
+ institution,
+ prismaEducation.degree,
+ prismaEducation.fieldOfStudy,
+ prismaEducation.startDate,
+ prismaEducation.endDate,
+ prismaEducation.description,
+ prismaEducation.createdAt,
+ prismaEducation.updatedAt,
+ prismaEducation.skills
+ ? this.skillMapper.mapToDomain(prismaEducation.skills)
+ : undefined,
+ );
+ }
+
+ mapToDomain(prismaEducations: PrismaEducation[]): Education[] {
+ return prismaEducations
+ .map((education) => this.toDomain(education))
+ .filter((education): education is Education => education !== null);
+ }
+}
diff --git a/packages/core/src/modules/education/education.module.ts b/packages/core/src/modules/education/education.module.ts
new file mode 100644
index 0000000..8104255
--- /dev/null
+++ b/packages/core/src/modules/education/education.module.ts
@@ -0,0 +1,36 @@
+import { AuthorizationModule } from "../auth";
+import { BaseModule } from "../../shared";
+import { DatabaseModule } from "../database";
+import { Module } from "@nestjs/common";
+import { AuthenticationModule } from "../authentication/authentication.module";
+import { SkillModule } from "../job-experience/skill/skill.module";
+import { EducationMapper } from "./education.mapper";
+import { EducationPolicy } from "./education.policy";
+import { EducationService } from "./education.service";
+import { InstitutionFactory } from "./institution.factory";
+import { InstitutionMapper } from "./institution.mapper";
+import { InstitutionPolicy } from "./institution.policy";
+import { InstitutionService } from "./institution.service";
+import { EducationOnboardingStep } from "./onboarding/education.step";
+
+@Module({
+ imports: [
+ DatabaseModule,
+ BaseModule,
+ AuthenticationModule,
+ AuthorizationModule,
+ SkillModule,
+ ],
+ providers: [
+ EducationService,
+ EducationMapper,
+ EducationPolicy,
+ InstitutionService,
+ InstitutionMapper,
+ InstitutionFactory,
+ InstitutionPolicy,
+ EducationOnboardingStep,
+ ],
+ exports: [EducationService, EducationMapper, InstitutionService],
+})
+export class EducationModule {}
diff --git a/packages/core/src/modules/education/education.policy.ts b/packages/core/src/modules/education/education.policy.ts
new file mode 100644
index 0000000..6c54f80
--- /dev/null
+++ b/packages/core/src/modules/education/education.policy.ts
@@ -0,0 +1,12 @@
+import { Policy, ProfileOwnedResourcePolicy } from "../auth";
+import { PrismaService } from "../database";
+import { Injectable } from "@nestjs/common";
+import { Education } from "./education.entity";
+
+@Injectable()
+@Policy(Education)
+export class EducationPolicy extends ProfileOwnedResourcePolicy {
+ constructor(prisma: PrismaService) {
+ super(prisma);
+ }
+}
diff --git a/packages/core/src/modules/education/education.service.ts b/packages/core/src/modules/education/education.service.ts
new file mode 100644
index 0000000..a932bce
--- /dev/null
+++ b/packages/core/src/modules/education/education.service.ts
@@ -0,0 +1,171 @@
+import { notFound } from "../auth";
+import type { PaginationOptions, PaginationResult } from "../../shared";
+import { PaginationService } from "../../shared";
+import { PrismaService } from "../database";
+import { Injectable } from "@nestjs/common";
+import type { Prisma } from "@prisma/client";
+import { Education } from "./education.entity";
+import { EducationMapper } from "./education.mapper";
+
+@Injectable()
+export class EducationService {
+ private readonly educationInclude = {
+ institution: true,
+ profile: true,
+ skills: true,
+ } satisfies Prisma.EducationInclude;
+
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly educationMapper: EducationMapper,
+ private readonly paginationService: PaginationService,
+ ) {}
+
+ async findMany(
+ profileId?: string,
+ options: PaginationOptions = {},
+ ): Promise> {
+ const where: Prisma.EducationWhereInput = profileId ? { profileId } : {};
+ const queryOptions = this.paginationService.buildQueryOptions(
+ where,
+ { startDate: "desc" },
+ options,
+ );
+
+ const [items, totalCount] = await Promise.all([
+ this.prisma.education.findMany({
+ ...queryOptions,
+ include: this.educationInclude,
+ }),
+ this.count(profileId),
+ ]);
+
+ const domainEducations = this.educationMapper.mapToDomain(items);
+ return this.paginationService.buildPaginationResult(
+ domainEducations,
+ totalCount,
+ options,
+ );
+ }
+
+ async findManyForProfile(
+ profileId: string,
+ options: PaginationOptions = {},
+ ): Promise> {
+ return this.findMany(profileId, options);
+ }
+
+ async findById(id: string): Promise {
+ const education = await this.prisma.education.findUnique({
+ where: { id },
+ include: this.educationInclude,
+ });
+ return this.educationMapper.toDomain(education);
+ }
+
+ async count(profileId?: string): Promise {
+ const where: Prisma.EducationWhereInput = profileId ? { profileId } : {};
+ return this.prisma.education.count({
+ where,
+ });
+ }
+
+ async create(
+ profileId: string,
+ data: {
+ institutionId: string;
+ degree: string;
+ fieldOfStudy?: string | null;
+ startDate: Date;
+ endDate?: Date | null;
+ description?: string | null;
+ skillIds?: string[];
+ },
+ ): Promise {
+ const createData: Prisma.EducationCreateInput = {
+ profile: { connect: { id: profileId } },
+ institution: { connect: { id: data.institutionId } },
+ degree: data.degree,
+ fieldOfStudy: data.fieldOfStudy ?? null,
+ startDate: data.startDate,
+ endDate: data.endDate ?? null,
+ description: data.description ?? null,
+ };
+
+ if (data.skillIds && data.skillIds.length > 0) {
+ createData.skills = {
+ connect: data.skillIds.map((id) => ({ id })),
+ };
+ }
+
+ const education = await this.prisma.education.create({
+ data: createData,
+ include: this.educationInclude,
+ });
+ const domainEducation = this.educationMapper.toDomain(education);
+ if (!domainEducation) {
+ throw new Error("Failed to create education");
+ }
+ return domainEducation;
+ }
+
+ async findByIdOrFail(id: string): Promise {
+ const education = await this.findById(id);
+ return education ?? notFound("Education", "id", id);
+ }
+
+ async update(
+ id: string,
+ data: {
+ institutionId?: string;
+ degree?: string;
+ fieldOfStudy?: string | null;
+ startDate?: Date;
+ endDate?: Date | null;
+ description?: string | null;
+ skillIds?: string[];
+ },
+ ): Promise {
+ const updateData: Prisma.EducationUpdateInput = {};
+
+ if (data.institutionId !== undefined) {
+ updateData.institution = { connect: { id: data.institutionId } };
+ }
+ if (data.degree !== undefined) {
+ updateData.degree = data.degree;
+ }
+ if (data.fieldOfStudy !== undefined) {
+ updateData.fieldOfStudy = data.fieldOfStudy;
+ }
+ if (data.startDate !== undefined) {
+ updateData.startDate = data.startDate;
+ }
+ if (data.endDate !== undefined) {
+ updateData.endDate = data.endDate;
+ }
+ if (data.description !== undefined) {
+ updateData.description = data.description;
+ }
+ if (data.skillIds !== undefined) {
+ updateData.skills = { set: data.skillIds.map((sid) => ({ id: sid })) };
+ }
+
+ const updated = await this.prisma.education.update({
+ where: { id },
+ data: updateData,
+ include: this.educationInclude,
+ });
+
+ const domainEducation = this.educationMapper.toDomain(updated);
+ if (!domainEducation) {
+ throw new Error("Failed to update education");
+ }
+ return domainEducation;
+ }
+
+ async delete(id: string): Promise {
+ await this.prisma.education.delete({
+ where: { id },
+ });
+ }
+}
diff --git a/packages/core/src/modules/education/institution.entity.ts b/packages/core/src/modules/education/institution.entity.ts
new file mode 100644
index 0000000..1f4323a
--- /dev/null
+++ b/packages/core/src/modules/education/institution.entity.ts
@@ -0,0 +1,3 @@
+import { NamedEntity } from "../../shared";
+
+export class Institution extends NamedEntity {}
diff --git a/packages/core/src/modules/education/institution.factory.ts b/packages/core/src/modules/education/institution.factory.ts
new file mode 100644
index 0000000..242a152
--- /dev/null
+++ b/packages/core/src/modules/education/institution.factory.ts
@@ -0,0 +1,29 @@
+import { ClockService, Factory, UuidFactoryService } from "../../shared";
+import { Injectable } from "@nestjs/common";
+import { Institution } from "./institution.entity";
+
+type CreateInstitutionDto = {
+ name: string;
+ description?: string | null;
+};
+
+@Injectable()
+export class InstitutionFactory
+ implements Factory
+{
+ constructor(
+ private readonly uuidFactory: UuidFactoryService,
+ private readonly clock: ClockService,
+ ) {}
+
+ create(dto: CreateInstitutionDto): Institution {
+ const now = this.clock.now();
+ return new Institution(
+ this.uuidFactory.generate(),
+ dto.name,
+ now,
+ now,
+ dto.description ?? undefined,
+ );
+ }
+}
diff --git a/packages/core/src/modules/education/institution.mapper.ts b/packages/core/src/modules/education/institution.mapper.ts
new file mode 100644
index 0000000..4e2be02
--- /dev/null
+++ b/packages/core/src/modules/education/institution.mapper.ts
@@ -0,0 +1,22 @@
+import { createNamedEntityMapper } from "../../shared";
+import { Injectable } from "@nestjs/common";
+import type { Institution as PrismaInstitution } from "@prisma/client";
+import { Institution } from "./institution.entity";
+
+@Injectable()
+export class InstitutionMapper {
+ private mapper = createNamedEntityMapper(
+ Institution,
+ );
+
+ toDomain(prismaInstitution: null): null;
+ toDomain(prismaInstitution: PrismaInstitution): Institution;
+ toDomain(prismaInstitution: PrismaInstitution | null): Institution | null;
+ toDomain(prismaInstitution: PrismaInstitution | null): Institution | null {
+ return this.mapper.toDomain(prismaInstitution);
+ }
+
+ mapToDomain(prismaInstitutions: PrismaInstitution[]): Institution[] {
+ return this.mapper.mapToDomain(prismaInstitutions);
+ }
+}
diff --git a/packages/core/src/modules/education/institution.policy.ts b/packages/core/src/modules/education/institution.policy.ts
new file mode 100644
index 0000000..b9872aa
--- /dev/null
+++ b/packages/core/src/modules/education/institution.policy.ts
@@ -0,0 +1,11 @@
+import { Policy, PublicResourcePolicy } from "../auth";
+import { Injectable } from "@nestjs/common";
+import { Institution } from "./institution.entity";
+
+@Injectable()
+@Policy(Institution)
+export class InstitutionPolicy extends PublicResourcePolicy {
+ override create(): boolean {
+ return true;
+ }
+}
diff --git a/packages/core/src/modules/education/institution.service.ts b/packages/core/src/modules/education/institution.service.ts
new file mode 100644
index 0000000..5405ab7
--- /dev/null
+++ b/packages/core/src/modules/education/institution.service.ts
@@ -0,0 +1,12 @@
+import { NamedEntityService } from "../../shared";
+import { PrismaService } from "../database";
+import { Injectable } from "@nestjs/common";
+import { Institution } from "./institution.entity";
+import { InstitutionMapper } from "./institution.mapper";
+
+@Injectable()
+export class InstitutionService extends NamedEntityService {
+ constructor(prisma: PrismaService, institutionMapper: InstitutionMapper) {
+ super(prisma, institutionMapper, prisma.institution, "Institution");
+ }
+}
diff --git a/packages/core/src/modules/education/onboarding/education.step.ts b/packages/core/src/modules/education/onboarding/education.step.ts
new file mode 100644
index 0000000..291f229
--- /dev/null
+++ b/packages/core/src/modules/education/onboarding/education.step.ts
@@ -0,0 +1,28 @@
+import type { User } from "../../auth";
+import { PrismaService } from "../../database";
+import { Injectable } from "@nestjs/common";
+import { OnboardingStep } from "../../onboarding/onboarding-step.decorator";
+import {
+ type OnboardingStepDefinition,
+ OnboardingStepStatus,
+} from "../../onboarding/onboarding-step.interface";
+
+@Injectable()
+@OnboardingStep()
+export class EducationOnboardingStep implements OnboardingStepDefinition {
+ readonly name = "education";
+ readonly order = 4;
+ readonly dependsOn: string[] = [];
+
+ constructor(private readonly prisma: PrismaService) {}
+
+ async computeStatus(user: User): Promise {
+ const count = await this.prisma.education.count({
+ where: { profile: { userId: user.id } },
+ });
+
+ return count > 0
+ ? OnboardingStepStatus.COMPLETE
+ : OnboardingStepStatus.NOT_STARTED;
+ }
+}
diff --git a/packages/core/src/modules/events/domain-event.ts b/packages/core/src/modules/events/domain-event.ts
new file mode 100644
index 0000000..063b1cc
--- /dev/null
+++ b/packages/core/src/modules/events/domain-event.ts
@@ -0,0 +1,31 @@
+/**
+ * Base class for all domain events.
+ * Provides typed payloads and a consistent event name derived from the class name.
+ *
+ * @example
+ * ```ts
+ * class VacancyImported extends DomainEvent<{ vacancyId: string; source: string }> {}
+ *
+ * // Emit:
+ * eventService.emit(new VacancyImported({ vacancyId: "123", source: "manual" }));
+ *
+ * // Listen:
+ * @OnEvent(VacancyImported.eventName)
+ * async handle(event: VacancyImported) { ... }
+ * ```
+ */
+export abstract class DomainEvent {
+ static get eventName(): string {
+ return this.name;
+ }
+
+ readonly eventName: string;
+ readonly payload: TPayload;
+ readonly occurredAt: Date;
+
+ constructor(...args: TPayload extends void ? [] : [payload: TPayload]) {
+ this.eventName = (this.constructor as typeof DomainEvent).eventName;
+ this.payload = args[0] as TPayload;
+ this.occurredAt = new Date();
+ }
+}
diff --git a/packages/core/src/modules/events/event.service.ts b/packages/core/src/modules/events/event.service.ts
new file mode 100644
index 0000000..6bd04ae
--- /dev/null
+++ b/packages/core/src/modules/events/event.service.ts
@@ -0,0 +1,20 @@
+import { Injectable } from "@nestjs/common";
+import { EventEmitter2 } from "@nestjs/event-emitter";
+import type { DomainEvent } from "./domain-event";
+
+/**
+ * Typed wrapper around NestJS EventEmitter2.
+ * Use this instead of injecting EventEmitter2 directly for type safety.
+ */
+@Injectable()
+export class EventService {
+ constructor(private readonly emitter: EventEmitter2) {}
+
+ emit(event: DomainEvent): void {
+ this.emitter.emit(event.eventName, event);
+ }
+
+ async emitAsync(event: DomainEvent): Promise {
+ await this.emitter.emitAsync(event.eventName, event);
+ }
+}
diff --git a/packages/core/src/modules/events/events.module.ts b/packages/core/src/modules/events/events.module.ts
new file mode 100644
index 0000000..803a556
--- /dev/null
+++ b/packages/core/src/modules/events/events.module.ts
@@ -0,0 +1,11 @@
+import { Global, Module } from "@nestjs/common";
+import { EventEmitterModule } from "@nestjs/event-emitter";
+import { EventService } from "./event.service";
+
+@Global()
+@Module({
+ imports: [EventEmitterModule.forRoot()],
+ providers: [EventService],
+ exports: [EventService],
+})
+export class EventsModule {}
diff --git a/packages/core/src/modules/events/index.ts b/packages/core/src/modules/events/index.ts
new file mode 100644
index 0000000..0cfd546
--- /dev/null
+++ b/packages/core/src/modules/events/index.ts
@@ -0,0 +1,3 @@
+export { DomainEvent } from "./domain-event";
+export { EventService } from "./event.service";
+export { EventsModule } from "./events.module";
diff --git a/packages/core/src/modules/job-experience/company/company.dataloader.ts b/packages/core/src/modules/job-experience/company/company.dataloader.ts
new file mode 100644
index 0000000..d249a63
--- /dev/null
+++ b/packages/core/src/modules/job-experience/company/company.dataloader.ts
@@ -0,0 +1,23 @@
+import { BaseDataLoaderService } from "../../../shared";
+import { Injectable, Scope } from "@nestjs/common";
+import { Company } from "./company.entity";
+import { CompanyService } from "./company.service";
+
+@Injectable({ scope: Scope.REQUEST })
+export class CompanyDataLoaderService extends BaseDataLoaderService<
+ string,
+ Company
+> {
+ constructor(readonly companyService: CompanyService) {
+ super(async (ids: readonly string[]) => {
+ const companies = await companyService.findMany({ id: [...ids] });
+
+ const companyMap = new Map();
+ for (const company of companies) {
+ companyMap.set(company.id, company);
+ }
+
+ return ids.map((id) => companyMap.get(id) ?? null);
+ });
+ }
+}
diff --git a/packages/core/src/modules/job-experience/company/company.dto.ts b/packages/core/src/modules/job-experience/company/company.dto.ts
new file mode 100644
index 0000000..e89771a
--- /dev/null
+++ b/packages/core/src/modules/job-experience/company/company.dto.ts
@@ -0,0 +1,11 @@
+export interface CreateCompanyDto {
+ name: string;
+ description?: string;
+ website?: string;
+}
+
+export interface UpdateCompanyDto {
+ name?: string;
+ description?: string;
+ website?: string;
+}
diff --git a/packages/core/src/modules/job-experience/company/company.entity.ts b/packages/core/src/modules/job-experience/company/company.entity.ts
new file mode 100644
index 0000000..88e203c
--- /dev/null
+++ b/packages/core/src/modules/job-experience/company/company.entity.ts
@@ -0,0 +1,14 @@
+import { NamedEntity } from "../../../shared";
+
+export class Company extends NamedEntity {
+ constructor(
+ id: string,
+ name: string,
+ createdAt: Date,
+ updatedAt: Date,
+ description?: string,
+ public website?: string,
+ ) {
+ super(id, name, createdAt, updatedAt, description);
+ }
+}
diff --git a/packages/core/src/modules/job-experience/company/company.factory.ts b/packages/core/src/modules/job-experience/company/company.factory.ts
new file mode 100644
index 0000000..939c439
--- /dev/null
+++ b/packages/core/src/modules/job-experience/company/company.factory.ts
@@ -0,0 +1,24 @@
+import { ClockService, Factory, UuidFactoryService } from "../../../shared";
+import { Injectable } from "@nestjs/common";
+import type { CreateCompanyDto } from "./company.dto";
+import { Company } from "./company.entity";
+
+@Injectable()
+export class CompanyFactory implements Factory {
+ constructor(
+ private readonly uuidFactory: UuidFactoryService,
+ private readonly clock: ClockService,
+ ) {}
+
+ create(dto: CreateCompanyDto): Company {
+ const now = this.clock.now();
+ return new Company(
+ this.uuidFactory.generate(),
+ dto.name,
+ now,
+ now,
+ dto.description,
+ dto.website,
+ );
+ }
+}
diff --git a/packages/core/src/modules/job-experience/company/company.mapper.ts b/packages/core/src/modules/job-experience/company/company.mapper.ts
new file mode 100644
index 0000000..c5bcb56
--- /dev/null
+++ b/packages/core/src/modules/job-experience/company/company.mapper.ts
@@ -0,0 +1,27 @@
+import { Injectable } from "@nestjs/common";
+import type { Company as PrismaCompany } from "@prisma/client";
+import { Company } from "./company.entity";
+
+@Injectable()
+export class CompanyMapper {
+ toDomain(prismaCompany: null): null;
+ toDomain(prismaCompany: PrismaCompany): Company;
+ toDomain(prismaCompany: PrismaCompany | null): Company | null;
+ toDomain(prismaCompany: PrismaCompany | null): Company | null {
+ if (prismaCompany === null) {
+ return null;
+ }
+ return new Company(
+ prismaCompany.id,
+ prismaCompany.name,
+ prismaCompany.createdAt,
+ prismaCompany.updatedAt,
+ prismaCompany.description ?? undefined,
+ prismaCompany.website ?? undefined,
+ );
+ }
+
+ mapToDomain(prismaCompanies: PrismaCompany[]): Company[] {
+ return prismaCompanies.map((company) => this.toDomain(company));
+ }
+}
diff --git a/packages/core/src/modules/job-experience/company/company.module.ts b/packages/core/src/modules/job-experience/company/company.module.ts
new file mode 100644
index 0000000..adad9a3
--- /dev/null
+++ b/packages/core/src/modules/job-experience/company/company.module.ts
@@ -0,0 +1,28 @@
+import { AuthorizationModule } from "../../auth";
+import { BaseModule } from "../../../shared";
+import { DatabaseModule } from "../../database";
+import { Module } from "@nestjs/common";
+import { AuthenticationModule } from "../../authentication/authentication.module";
+import { CompanyService } from "./company.service";
+import { CompanyDataLoaderService } from "./company.dataloader";
+import { CompanyFactory } from "./company.factory";
+import { CompanyMapper } from "./company.mapper";
+import { CompanyPolicy } from "./company.policy";
+
+@Module({
+ imports: [
+ DatabaseModule,
+ BaseModule,
+ AuthenticationModule,
+ AuthorizationModule,
+ ],
+ providers: [
+ CompanyService,
+ CompanyMapper,
+ CompanyFactory,
+ CompanyPolicy,
+ CompanyDataLoaderService,
+ ],
+ exports: [CompanyService, CompanyMapper, CompanyDataLoaderService],
+})
+export class CompanyModule {}
diff --git a/packages/core/src/modules/job-experience/company/company.policy.ts b/packages/core/src/modules/job-experience/company/company.policy.ts
new file mode 100644
index 0000000..65df040
--- /dev/null
+++ b/packages/core/src/modules/job-experience/company/company.policy.ts
@@ -0,0 +1,11 @@
+import { Policy, PublicResourcePolicy } from "../../auth";
+import { Injectable } from "@nestjs/common";
+import { Company } from "./company.entity";
+
+@Injectable()
+@Policy(Company)
+export class CompanyPolicy extends PublicResourcePolicy {
+ override create(): boolean {
+ return true;
+ }
+}
diff --git a/packages/core/src/modules/job-experience/company/company.service.ts b/packages/core/src/modules/job-experience/company/company.service.ts
new file mode 100644
index 0000000..82edc90
--- /dev/null
+++ b/packages/core/src/modules/job-experience/company/company.service.ts
@@ -0,0 +1,23 @@
+import { NamedEntityService } from "../../../shared";
+import { PrismaService } from "../../database";
+import { Injectable } from "@nestjs/common";
+import { Company } from "./company.entity";
+import { CompanyMapper } from "./company.mapper";
+
+@Injectable()
+export class CompanyService extends NamedEntityService {
+ constructor(prisma: PrismaService, companyMapper: CompanyMapper) {
+ super(prisma, companyMapper, prisma.company, "Company");
+ }
+
+ protected override buildPrismaData(entity: Company): {
+ name: string;
+ description: string | null;
+ website: string | null;
+ } {
+ return {
+ ...super.buildPrismaData(entity),
+ website: entity.website ?? null,
+ };
+ }
+}
diff --git a/packages/core/src/modules/job-experience/employment/employment.module.ts b/packages/core/src/modules/job-experience/employment/employment.module.ts
new file mode 100644
index 0000000..dc5cc07
--- /dev/null
+++ b/packages/core/src/modules/job-experience/employment/employment.module.ts
@@ -0,0 +1,34 @@
+import { AuthorizationModule } from "../../auth";
+import { BaseModule } from "../../../shared";
+import { DatabaseModule } from "../../database";
+import { Module } from "@nestjs/common";
+import { AuthenticationModule } from "../../authentication/authentication.module";
+import { CompanyModule } from "../company/company.module";
+import { LevelModule } from "../level/level.module";
+import { RoleModule } from "../role/role.module";
+import { SkillModule } from "../skill/skill.module";
+import { CareerHistoryOnboardingStep } from "./onboarding/career-history.step";
+import { UserJobExperienceMapper } from "./user-job-experience.mapper";
+import { UserJobExperiencePolicy } from "./user-job-experience.policy";
+import { UserJobExperienceService } from "./user-job-experience.service";
+
+@Module({
+ imports: [
+ DatabaseModule,
+ BaseModule,
+ AuthenticationModule,
+ AuthorizationModule,
+ CompanyModule,
+ RoleModule,
+ LevelModule,
+ SkillModule,
+ ],
+ providers: [
+ UserJobExperienceService,
+ UserJobExperienceMapper,
+ UserJobExperiencePolicy,
+ CareerHistoryOnboardingStep,
+ ],
+ exports: [UserJobExperienceService, UserJobExperienceMapper],
+})
+export class EmploymentModule {}
diff --git a/packages/core/src/modules/job-experience/employment/onboarding/career-history.step.ts b/packages/core/src/modules/job-experience/employment/onboarding/career-history.step.ts
new file mode 100644
index 0000000..27d7913
--- /dev/null
+++ b/packages/core/src/modules/job-experience/employment/onboarding/career-history.step.ts
@@ -0,0 +1,28 @@
+import type { User } from "../../../auth";
+import { PrismaService } from "../../../database";
+import { Injectable } from "@nestjs/common";
+import { OnboardingStep } from "../../../onboarding/onboarding-step.decorator";
+import {
+ type OnboardingStepDefinition,
+ OnboardingStepStatus,
+} from "../../../onboarding/onboarding-step.interface";
+
+@Injectable()
+@OnboardingStep()
+export class CareerHistoryOnboardingStep implements OnboardingStepDefinition {
+ readonly name = "career-history";
+ readonly order = 3;
+ readonly dependsOn: string[] = [];
+
+ constructor(private readonly prisma: PrismaService) {}
+
+ async computeStatus(user: User): Promise {
+ const count = await this.prisma.userJobExperience.count({
+ where: { profile: { userId: user.id } },
+ });
+
+ return count > 0
+ ? OnboardingStepStatus.COMPLETE
+ : OnboardingStepStatus.NOT_STARTED;
+ }
+}
diff --git a/packages/core/src/modules/job-experience/employment/user-job-experience.dto.ts b/packages/core/src/modules/job-experience/employment/user-job-experience.dto.ts
new file mode 100644
index 0000000..bae690d
--- /dev/null
+++ b/packages/core/src/modules/job-experience/employment/user-job-experience.dto.ts
@@ -0,0 +1,25 @@
+import type { Company } from "../company/company.entity";
+import type { Level } from "../level/level.entity";
+import type { Role } from "../role/role.entity";
+import type { Skill } from "../skill/skill.entity";
+
+export interface CreateUserJobExperienceDto {
+ profileId: string;
+ company: Company;
+ role: Role;
+ level: Level;
+ startDate: Date;
+ endDate?: Date;
+ description?: string;
+ skills?: Skill[];
+}
+
+export interface UpdateUserJobExperienceDto {
+ company?: Company;
+ role?: Role;
+ level?: Level;
+ startDate?: Date;
+ endDate?: Date;
+ description?: string;
+ skills?: Skill[];
+}
diff --git a/packages/core/src/modules/job-experience/employment/user-job-experience.entity.ts b/packages/core/src/modules/job-experience/employment/user-job-experience.entity.ts
new file mode 100644
index 0000000..c46ecf1
--- /dev/null
+++ b/packages/core/src/modules/job-experience/employment/user-job-experience.entity.ts
@@ -0,0 +1,48 @@
+import { BaseEntity } from "../../../shared";
+import type { Company } from "../company/company.entity";
+import type { Level } from "../level/level.entity";
+import type { Role } from "../role/role.entity";
+import type { Skill } from "../skill/skill.entity";
+
+export class UserJobExperience extends BaseEntity {
+ profileId: string;
+ startDate: Date;
+ endDate?: Date;
+ description?: string;
+
+ // Relations
+ company: Company;
+ role: Role;
+ level: Level;
+ skills?: Skill[];
+
+ constructor(
+ id: string,
+ profileId: string,
+ startDate: Date,
+ createdAt: Date,
+ updatedAt: Date,
+ company: Company,
+ role: Role,
+ level: Level,
+ endDate?: Date,
+ description?: string,
+ skills?: Skill[],
+ ) {
+ super(id, createdAt, updatedAt);
+ this.profileId = profileId;
+ this.startDate = startDate;
+ this.company = company;
+ this.role = role;
+ this.level = level;
+ if (endDate !== undefined) {
+ this.endDate = endDate;
+ }
+ if (description !== undefined) {
+ this.description = description;
+ }
+ if (skills !== undefined) {
+ this.skills = skills;
+ }
+ }
+}
diff --git a/packages/core/src/modules/job-experience/employment/user-job-experience.mapper.ts b/packages/core/src/modules/job-experience/employment/user-job-experience.mapper.ts
new file mode 100644
index 0000000..a0c55ba
--- /dev/null
+++ b/packages/core/src/modules/job-experience/employment/user-job-experience.mapper.ts
@@ -0,0 +1,84 @@
+import type { BaseMapper } from "../../../shared";
+import { Injectable } from "@nestjs/common";
+import type {
+ Company as PrismaCompany,
+ Level as PrismaLevel,
+ Profile as PrismaProfile,
+ Role as PrismaRole,
+ Skill as PrismaSkill,
+ UserJobExperience as PrismaUserJobExperience,
+} from "@prisma/client";
+import { CompanyMapper } from "../company/company.mapper";
+import { LevelMapper } from "../level/level.mapper";
+import { RoleMapper } from "../role/role.mapper";
+import { SkillMapper } from "../skill/skill.mapper";
+import { UserJobExperience } from "./user-job-experience.entity";
+
+/**
+ * Extended Prisma entity type that includes relations
+ */
+type PrismaUserJobExperienceWithRelations = PrismaUserJobExperience & {
+ company: PrismaCompany;
+ role: PrismaRole;
+ level: PrismaLevel;
+ skills?: PrismaSkill[];
+ profile?: PrismaProfile | null;
+};
+
+/**
+ * Mapper service for converting between Prisma UserJobExperience entities and domain UserJobExperience entities
+ */
+@Injectable()
+export class UserJobExperienceMapper
+ implements BaseMapper
+{
+ constructor(
+ private companyMapper: CompanyMapper,
+ private roleMapper: RoleMapper,
+ private levelMapper: LevelMapper,
+ private skillMapper: SkillMapper,
+ ) {}
+ /**
+ * Maps a Prisma UserJobExperience entity to a domain UserJobExperience entity
+ * Uses overloads to return the correct type based on input
+ */
+ toDomain(prismaExperience: null): null;
+ toDomain(
+ prismaExperience: PrismaUserJobExperienceWithRelations,
+ ): UserJobExperience;
+ toDomain(
+ prismaExperience: PrismaUserJobExperienceWithRelations | null,
+ ): UserJobExperience | null;
+ toDomain(
+ prismaExperience: PrismaUserJobExperienceWithRelations | null,
+ ): UserJobExperience | null {
+ if (prismaExperience === null) {
+ return null;
+ }
+
+ return new UserJobExperience(
+ prismaExperience.id,
+ prismaExperience.profileId,
+ prismaExperience.startDate,
+ prismaExperience.createdAt,
+ prismaExperience.updatedAt,
+ this.companyMapper.toDomain(prismaExperience.company),
+ this.roleMapper.toDomain(prismaExperience.role),
+ this.levelMapper.toDomain(prismaExperience.level),
+ prismaExperience.endDate ?? undefined,
+ prismaExperience.description ?? undefined,
+ prismaExperience.skills
+ ? this.skillMapper.mapToDomain(prismaExperience.skills)
+ : undefined,
+ );
+ }
+
+ /**
+ * Maps an array of Prisma UserJobExperience entities to domain UserJobExperience entities
+ */
+ mapToDomain(
+ prismaExperiences: PrismaUserJobExperienceWithRelations[],
+ ): UserJobExperience[] {
+ return prismaExperiences.map((experience) => this.toDomain(experience));
+ }
+}
diff --git a/packages/core/src/modules/job-experience/employment/user-job-experience.policy.ts b/packages/core/src/modules/job-experience/employment/user-job-experience.policy.ts
new file mode 100644
index 0000000..5c24c24
--- /dev/null
+++ b/packages/core/src/modules/job-experience/employment/user-job-experience.policy.ts
@@ -0,0 +1,12 @@
+import { Policy, ProfileOwnedResourcePolicy } from "../../auth";
+import { PrismaService } from "../../database";
+import { Injectable } from "@nestjs/common";
+import { UserJobExperience } from "./user-job-experience.entity";
+
+@Injectable()
+@Policy(UserJobExperience)
+export class UserJobExperiencePolicy extends ProfileOwnedResourcePolicy {
+ constructor(prisma: PrismaService) {
+ super(prisma);
+ }
+}
diff --git a/packages/core/src/modules/job-experience/employment/user-job-experience.service.ts b/packages/core/src/modules/job-experience/employment/user-job-experience.service.ts
new file mode 100644
index 0000000..a53e681
--- /dev/null
+++ b/packages/core/src/modules/job-experience/employment/user-job-experience.service.ts
@@ -0,0 +1,224 @@
+import { notFound } from "../../auth";
+import type { PaginationOptions, PaginationResult } from "../../../shared";
+import { PaginationService } from "../../../shared";
+import { PrismaService } from "../../database";
+import { Injectable } from "@nestjs/common";
+import type { Prisma } from "@prisma/client";
+import type {
+ CreateUserJobExperienceDto,
+ UpdateUserJobExperienceDto,
+} from "./user-job-experience.dto";
+import { UserJobExperience } from "./user-job-experience.entity";
+import { UserJobExperienceMapper } from "./user-job-experience.mapper";
+
+type PrismaUserJobExperienceCreateData = {
+ profile: { connect: { id: string } };
+ company: { connect: { id: string } };
+ role: { connect: { id: string } };
+ level: { connect: { id: string } };
+ startDate: Date;
+ endDate?: Date;
+ description?: string;
+ skills?: {
+ connect: Array<{ id: string }>;
+ };
+};
+
+type PrismaUserJobExperienceUpdateData = {
+ company?: { connect: { id: string } };
+ role?: { connect: { id: string } };
+ level?: { connect: { id: string } };
+ startDate?: Date;
+ endDate?: Date;
+ description?: string;
+ skills?: {
+ set: Array<{ id: string }>;
+ };
+};
+
+@Injectable()
+export class UserJobExperienceService {
+ private readonly userJobExperienceInclude = {
+ profile: true,
+ company: true,
+ role: true,
+ level: true,
+ skills: true,
+ } satisfies Prisma.UserJobExperienceInclude;
+
+ constructor(
+ private prisma: PrismaService,
+ private userJobExperienceMapper: UserJobExperienceMapper,
+ private paginationService: PaginationService,
+ ) {}
+
+ async create(
+ createUserJobExperienceDto: CreateUserJobExperienceDto,
+ ): Promise {
+ const createData: PrismaUserJobExperienceCreateData = {
+ profile: { connect: { id: createUserJobExperienceDto.profileId } },
+ company: { connect: { id: createUserJobExperienceDto.company.id } },
+ role: { connect: { id: createUserJobExperienceDto.role.id } },
+ level: { connect: { id: createUserJobExperienceDto.level.id } },
+ startDate: createUserJobExperienceDto.startDate,
+ };
+
+ if (createUserJobExperienceDto.endDate !== undefined) {
+ createData.endDate = createUserJobExperienceDto.endDate;
+ }
+ if (createUserJobExperienceDto.description !== undefined) {
+ createData.description = createUserJobExperienceDto.description;
+ }
+ if (createUserJobExperienceDto.skills !== undefined) {
+ createData.skills = {
+ connect: createUserJobExperienceDto.skills.map((skill) => ({
+ id: skill.id,
+ })),
+ };
+ }
+
+ const prismaExperience = await this.prisma.userJobExperience.create({
+ data: createData,
+ include: this.userJobExperienceInclude,
+ });
+ return this.userJobExperienceMapper.toDomain(prismaExperience);
+ }
+
+ async findMany(
+ profileId?: string,
+ options: PaginationOptions = {},
+ ): Promise> {
+ const where: Prisma.UserJobExperienceWhereInput = profileId
+ ? { profileId }
+ : {};
+ const queryOptions = this.paginationService.buildQueryOptions(
+ where,
+ { startDate: "desc" },
+ options,
+ );
+
+ const [items, totalCount] = await Promise.all([
+ this.prisma.userJobExperience.findMany({
+ ...queryOptions,
+ include: this.userJobExperienceInclude,
+ }),
+ this.prisma.userJobExperience.count({ where }),
+ ]);
+
+ const domainExperiences = this.userJobExperienceMapper.mapToDomain(items);
+ return this.paginationService.buildPaginationResult(
+ domainExperiences,
+ totalCount,
+ options,
+ );
+ }
+
+ async findForProfile(profileId: string): Promise {
+ const result = await this.findMany(profileId);
+ return result.edges.map((edge) => edge.node);
+ }
+
+ async findManyForProfile(
+ profileId: string,
+ options: PaginationOptions = {},
+ ): Promise> {
+ return this.findMany(profileId, options);
+ }
+
+ async findById(id: string): Promise {
+ const prismaExperience = await this.prisma.userJobExperience.findUnique({
+ where: { id },
+ include: this.userJobExperienceInclude,
+ });
+ return this.userJobExperienceMapper.toDomain(prismaExperience);
+ }
+
+ async findByIdOrFail(id: string): Promise {
+ const experience = await this.findById(id);
+ return experience ?? notFound("Job experience", "id", id);
+ }
+
+ async update(
+ id: string,
+ updateUserJobExperienceDto: UpdateUserJobExperienceDto,
+ ): Promise {
+ const updateData: PrismaUserJobExperienceUpdateData = {};
+
+ if (updateUserJobExperienceDto.company !== undefined) {
+ updateData.company = {
+ connect: { id: updateUserJobExperienceDto.company.id },
+ };
+ }
+ if (updateUserJobExperienceDto.role !== undefined) {
+ updateData.role = {
+ connect: { id: updateUserJobExperienceDto.role.id },
+ };
+ }
+ if (updateUserJobExperienceDto.level !== undefined) {
+ updateData.level = {
+ connect: { id: updateUserJobExperienceDto.level.id },
+ };
+ }
+ if (updateUserJobExperienceDto.startDate !== undefined) {
+ updateData.startDate = updateUserJobExperienceDto.startDate;
+ }
+ if (updateUserJobExperienceDto.endDate !== undefined) {
+ updateData.endDate = updateUserJobExperienceDto.endDate;
+ }
+ if (updateUserJobExperienceDto.description !== undefined) {
+ updateData.description = updateUserJobExperienceDto.description;
+ }
+ if (updateUserJobExperienceDto.skills !== undefined) {
+ updateData.skills = {
+ set: updateUserJobExperienceDto.skills.map((skill) => ({
+ id: skill.id,
+ })),
+ };
+ }
+
+ const prismaExperience = await this.prisma.userJobExperience.update({
+ where: { id },
+ data: updateData,
+ include: this.userJobExperienceInclude,
+ });
+ return this.userJobExperienceMapper.toDomain(prismaExperience);
+ }
+
+ async delete(id: string): Promise {
+ await this.prisma.userJobExperience.delete({
+ where: { id },
+ });
+ }
+
+ async addSkills(
+ experienceId: string,
+ skillIds: string[],
+ ): Promise {
+ const prismaExperience = await this.prisma.userJobExperience.update({
+ where: { id: experienceId },
+ data: {
+ skills: {
+ connect: skillIds.map((id) => ({ id })),
+ },
+ },
+ include: this.userJobExperienceInclude,
+ });
+ return this.userJobExperienceMapper.toDomain(prismaExperience);
+ }
+
+ async removeSkills(
+ experienceId: string,
+ skillIds: string[],
+ ): Promise {
+ const prismaExperience = await this.prisma.userJobExperience.update({
+ where: { id: experienceId },
+ data: {
+ skills: {
+ disconnect: skillIds.map((id) => ({ id })),
+ },
+ },
+ include: this.userJobExperienceInclude,
+ });
+ return this.userJobExperienceMapper.toDomain(prismaExperience);
+ }
+}
diff --git a/packages/core/src/modules/job-experience/level/level.dataloader.ts b/packages/core/src/modules/job-experience/level/level.dataloader.ts
new file mode 100644
index 0000000..9bc7c10
--- /dev/null
+++ b/packages/core/src/modules/job-experience/level/level.dataloader.ts
@@ -0,0 +1,23 @@
+import { BaseDataLoaderService } from "../../../shared";
+import { Injectable, Scope } from "@nestjs/common";
+import { Level } from "./level.entity";
+import { LevelService } from "./level.service";
+
+@Injectable({ scope: Scope.REQUEST })
+export class LevelDataLoaderService extends BaseDataLoaderService<
+ string,
+ Level
+> {
+ constructor(readonly levelService: LevelService) {
+ super(async (ids: readonly string[]) => {
+ const levels = await levelService.findMany({ id: [...ids] });
+
+ const levelMap = new Map();
+ for (const level of levels) {
+ levelMap.set(level.id, level);
+ }
+
+ return ids.map((id) => levelMap.get(id) ?? null);
+ });
+ }
+}
diff --git a/packages/core/src/modules/job-experience/level/level.dto.ts b/packages/core/src/modules/job-experience/level/level.dto.ts
new file mode 100644
index 0000000..59d1f57
--- /dev/null
+++ b/packages/core/src/modules/job-experience/level/level.dto.ts
@@ -0,0 +1,9 @@
+export interface CreateLevelDto {
+ name: string;
+ description?: string;
+}
+
+export interface UpdateLevelDto {
+ name?: string;
+ description?: string;
+}
diff --git a/packages/core/src/modules/job-experience/level/level.entity.ts b/packages/core/src/modules/job-experience/level/level.entity.ts
new file mode 100644
index 0000000..71def10
--- /dev/null
+++ b/packages/core/src/modules/job-experience/level/level.entity.ts
@@ -0,0 +1,3 @@
+import { NamedEntity } from "../../../shared";
+
+export class Level extends NamedEntity {}
diff --git a/packages/core/src/modules/job-experience/level/level.factory.ts b/packages/core/src/modules/job-experience/level/level.factory.ts
new file mode 100644
index 0000000..a0139c5
--- /dev/null
+++ b/packages/core/src/modules/job-experience/level/level.factory.ts
@@ -0,0 +1,23 @@
+import { ClockService, Factory, UuidFactoryService } from "../../../shared";
+import { Injectable } from "@nestjs/common";
+import type { CreateLevelDto } from "./level.dto";
+import { Level } from "./level.entity";
+
+@Injectable()
+export class LevelFactory implements Factory {
+ constructor(
+ private readonly uuidFactory: UuidFactoryService,
+ private readonly clock: ClockService,
+ ) {}
+
+ create(dto: CreateLevelDto): Level {
+ const now = this.clock.now();
+ return new Level(
+ this.uuidFactory.generate(),
+ dto.name,
+ now,
+ now,
+ dto.description,
+ );
+ }
+}
diff --git a/packages/core/src/modules/job-experience/level/level.mapper.ts b/packages/core/src/modules/job-experience/level/level.mapper.ts
new file mode 100644
index 0000000..e258928
--- /dev/null
+++ b/packages/core/src/modules/job-experience/level/level.mapper.ts
@@ -0,0 +1,23 @@
+import { createNamedEntityMapper } from "../../../shared";
+import { Injectable } from "@nestjs/common";
+import type { Level as PrismaLevel } from "@prisma/client";
+import { Level } from "./level.entity";
+
+/**
+ * Mapper service for converting between Prisma Level entities and domain Level entities
+ */
+@Injectable()
+export class LevelMapper {
+ private mapper = createNamedEntityMapper(Level);
+
+ toDomain(prismaLevel: null): null;
+ toDomain(prismaLevel: PrismaLevel): Level;
+ toDomain(prismaLevel: PrismaLevel | null): Level | null;
+ toDomain(prismaLevel: PrismaLevel | null): Level | null {
+ return this.mapper.toDomain(prismaLevel);
+ }
+
+ mapToDomain(prismaLevels: PrismaLevel[]): Level[] {
+ return this.mapper.mapToDomain(prismaLevels);
+ }
+}
diff --git a/packages/core/src/modules/job-experience/level/level.module.ts b/packages/core/src/modules/job-experience/level/level.module.ts
new file mode 100644
index 0000000..1ab6d7b
--- /dev/null
+++ b/packages/core/src/modules/job-experience/level/level.module.ts
@@ -0,0 +1,28 @@
+import { AuthorizationModule } from "../../auth";
+import { BaseModule } from "../../../shared";
+import { DatabaseModule } from "../../database";
+import { Module } from "@nestjs/common";
+import { AuthenticationModule } from "../../authentication/authentication.module";
+import { LevelService } from "./level.service";
+import { LevelDataLoaderService } from "./level.dataloader";
+import { LevelFactory } from "./level.factory";
+import { LevelMapper } from "./level.mapper";
+import { LevelPolicy } from "./level.policy";
+
+@Module({
+ imports: [
+ DatabaseModule,
+ BaseModule,
+ AuthenticationModule,
+ AuthorizationModule,
+ ],
+ providers: [
+ LevelService,
+ LevelMapper,
+ LevelFactory,
+ LevelPolicy,
+ LevelDataLoaderService,
+ ],
+ exports: [LevelService, LevelMapper, LevelDataLoaderService],
+})
+export class LevelModule {}
diff --git a/packages/core/src/modules/job-experience/level/level.policy.ts b/packages/core/src/modules/job-experience/level/level.policy.ts
new file mode 100644
index 0000000..c51561e
--- /dev/null
+++ b/packages/core/src/modules/job-experience/level/level.policy.ts
@@ -0,0 +1,11 @@
+import { Policy, PublicResourcePolicy } from "../../auth";
+import { Injectable } from "@nestjs/common";
+import { Level } from "./level.entity";
+
+@Injectable()
+@Policy(Level)
+export class LevelPolicy extends PublicResourcePolicy {
+ override create(): boolean {
+ return true;
+ }
+}
diff --git a/packages/core/src/modules/job-experience/level/level.service.ts b/packages/core/src/modules/job-experience/level/level.service.ts
new file mode 100644
index 0000000..f5053dd
--- /dev/null
+++ b/packages/core/src/modules/job-experience/level/level.service.ts
@@ -0,0 +1,12 @@
+import { NamedEntityService } from "../../../shared";
+import { PrismaService } from "../../database";
+import { Injectable } from "@nestjs/common";
+import { Level } from "./level.entity";
+import { LevelMapper } from "./level.mapper";
+
+@Injectable()
+export class LevelService extends NamedEntityService {
+ constructor(prisma: PrismaService, levelMapper: LevelMapper) {
+ super(prisma, levelMapper, prisma.level, "Level");
+ }
+}
diff --git a/packages/core/src/modules/job-experience/role/role.dataloader.ts b/packages/core/src/modules/job-experience/role/role.dataloader.ts
new file mode 100644
index 0000000..47d8746
--- /dev/null
+++ b/packages/core/src/modules/job-experience/role/role.dataloader.ts
@@ -0,0 +1,20 @@
+import { BaseDataLoaderService } from "../../../shared";
+import { Injectable, Scope } from "@nestjs/common";
+import { Role } from "./role.entity";
+import { RoleService } from "./role.service";
+
+@Injectable({ scope: Scope.REQUEST })
+export class RoleDataLoaderService extends BaseDataLoaderService {
+ constructor(readonly roleService: RoleService) {
+ super(async (ids: readonly string[]) => {
+ const roles = await roleService.findMany({ id: [...ids] });
+
+ const roleMap = new Map();
+ for (const role of roles) {
+ roleMap.set(role.id, role);
+ }
+
+ return ids.map((id) => roleMap.get(id) ?? null);
+ });
+ }
+}
diff --git a/packages/core/src/modules/job-experience/role/role.dto.ts b/packages/core/src/modules/job-experience/role/role.dto.ts
new file mode 100644
index 0000000..b0f040f
--- /dev/null
+++ b/packages/core/src/modules/job-experience/role/role.dto.ts
@@ -0,0 +1,9 @@
+export interface CreateRoleDto {
+ name: string;
+ description?: string;
+}
+
+export interface UpdateRoleDto {
+ name?: string;
+ description?: string;
+}
diff --git a/packages/core/src/modules/job-experience/role/role.entity.ts b/packages/core/src/modules/job-experience/role/role.entity.ts
new file mode 100644
index 0000000..72ff32e
--- /dev/null
+++ b/packages/core/src/modules/job-experience/role/role.entity.ts
@@ -0,0 +1,3 @@
+import { NamedEntity } from "../../../shared";
+
+export class Role extends NamedEntity {}
diff --git a/packages/core/src/modules/job-experience/role/role.factory.ts b/packages/core/src/modules/job-experience/role/role.factory.ts
new file mode 100644
index 0000000..40072b2
--- /dev/null
+++ b/packages/core/src/modules/job-experience/role/role.factory.ts
@@ -0,0 +1,23 @@
+import { ClockService, Factory, UuidFactoryService } from "../../../shared";
+import { Injectable } from "@nestjs/common";
+import type { CreateRoleDto } from "./role.dto";
+import { Role } from "./role.entity";
+
+@Injectable()
+export class RoleFactory implements Factory {
+ constructor(
+ private readonly uuidFactory: UuidFactoryService,
+ private readonly clock: ClockService,
+ ) {}
+
+ create(dto: CreateRoleDto): Role {
+ const now = this.clock.now();
+ return new Role(
+ this.uuidFactory.generate(),
+ dto.name,
+ now,
+ now,
+ dto.description,
+ );
+ }
+}
diff --git a/packages/core/src/modules/job-experience/role/role.mapper.ts b/packages/core/src/modules/job-experience/role/role.mapper.ts
new file mode 100644
index 0000000..2eae1a3
--- /dev/null
+++ b/packages/core/src/modules/job-experience/role/role.mapper.ts
@@ -0,0 +1,23 @@
+import { createNamedEntityMapper } from "../../../shared";
+import { Injectable } from "@nestjs/common";
+import type { Role as PrismaRole } from "@prisma/client";
+import { Role } from "./role.entity";
+
+/**
+ * Mapper service for converting between Prisma Role entities and domain Role entities
+ */
+@Injectable()
+export class RoleMapper {
+ private mapper = createNamedEntityMapper(Role);
+
+ toDomain(prismaRole: null): null;
+ toDomain(prismaRole: PrismaRole): Role;
+ toDomain(prismaRole: PrismaRole | null): Role | null;
+ toDomain(prismaRole: PrismaRole | null): Role | null {
+ return this.mapper.toDomain(prismaRole);
+ }
+
+ mapToDomain(prismaRoles: PrismaRole[]): Role[] {
+ return this.mapper.mapToDomain(prismaRoles);
+ }
+}
diff --git a/packages/core/src/modules/job-experience/role/role.module.ts b/packages/core/src/modules/job-experience/role/role.module.ts
new file mode 100644
index 0000000..55182e3
--- /dev/null
+++ b/packages/core/src/modules/job-experience/role/role.module.ts
@@ -0,0 +1,28 @@
+import { AuthorizationModule } from "../../auth";
+import { BaseModule } from "../../../shared";
+import { DatabaseModule } from "../../database";
+import { Module } from "@nestjs/common";
+import { AuthenticationModule } from "../../authentication/authentication.module";
+import { RoleService } from "./role.service";
+import { RoleDataLoaderService } from "./role.dataloader";
+import { RoleFactory } from "./role.factory";
+import { RoleMapper } from "./role.mapper";
+import { RolePolicy } from "./role.policy";
+
+@Module({
+ imports: [
+ DatabaseModule,
+ BaseModule,
+ AuthenticationModule,
+ AuthorizationModule,
+ ],
+ providers: [
+ RoleService,
+ RoleMapper,
+ RoleFactory,
+ RolePolicy,
+ RoleDataLoaderService,
+ ],
+ exports: [RoleService, RoleMapper, RoleDataLoaderService],
+})
+export class RoleModule {}
diff --git a/packages/core/src/modules/job-experience/role/role.policy.ts b/packages/core/src/modules/job-experience/role/role.policy.ts
new file mode 100644
index 0000000..44a914a
--- /dev/null
+++ b/packages/core/src/modules/job-experience/role/role.policy.ts
@@ -0,0 +1,11 @@
+import { Policy, PublicResourcePolicy } from "../../auth";
+import { Injectable } from "@nestjs/common";
+import { Role } from "./role.entity";
+
+@Injectable()
+@Policy(Role)
+export class RolePolicy extends PublicResourcePolicy {
+ override create(): boolean {
+ return true;
+ }
+}
diff --git a/packages/core/src/modules/job-experience/role/role.service.ts b/packages/core/src/modules/job-experience/role/role.service.ts
new file mode 100644
index 0000000..673c30c
--- /dev/null
+++ b/packages/core/src/modules/job-experience/role/role.service.ts
@@ -0,0 +1,12 @@
+import { NamedEntityService } from "../../../shared";
+import { PrismaService } from "../../database";
+import { Injectable } from "@nestjs/common";
+import { Role } from "./role.entity";
+import { RoleMapper } from "./role.mapper";
+
+@Injectable()
+export class RoleService extends NamedEntityService {
+ constructor(prisma: PrismaService, roleMapper: RoleMapper) {
+ super(prisma, roleMapper, prisma.role, "Role");
+ }
+}
diff --git a/packages/core/src/modules/job-experience/seed/job-experience.seed.ts b/packages/core/src/modules/job-experience/seed/job-experience.seed.ts
new file mode 100644
index 0000000..2e2a0c3
--- /dev/null
+++ b/packages/core/src/modules/job-experience/seed/job-experience.seed.ts
@@ -0,0 +1,94 @@
+import { PrismaService } from "../../database";
+import { faker } from "@faker-js/faker";
+import { Injectable, Logger } from "@nestjs/common";
+import { Seeder } from "../../database/seed/seed.service";
+import { Seeder as SeederDecorator } from "../../database/seed/seeder.decorator";
+import { ReferenceDataSeedService } from "./reference-data.seed";
+
+@Injectable()
+@SeederDecorator({ name: "Job Experiences", dependsOn: ["Users", "Reference Data"] })
+export class JobExperienceSeedService implements Seeder {
+ private readonly logger = new Logger(JobExperienceSeedService.name);
+
+ constructor(
+ readonly _prisma: PrismaService,
+ private readonly refs: ReferenceDataSeedService,
+ ) {}
+
+ async seed(prisma: PrismaService): Promise {
+ this.logger.log("Seeding job experiences...");
+
+ // First ensure reference data exists
+ const [skills, companies, roles, levels] = await Promise.all([
+ this.refs.ensureSkills(prisma),
+ this.refs.ensureCompanies(prisma),
+ this.refs.ensureRoles(prisma),
+ this.refs.ensureLevels(prisma),
+ ]);
+
+ // Get test user by email from credentials
+ const credentials = await prisma["credentials"].findUnique({
+ where: { email: "test@test.test" },
+ include: { user: true },
+ });
+ const testUser = credentials?.user ?? null;
+
+ if (!testUser) {
+ this.logger.warn("Test user not found, skipping job experience seeding");
+ return;
+ }
+
+ // Get or create profile for the test user
+ const existingProfile = await prisma.profile.findFirst({
+ where: { userId: testUser.id },
+ });
+ const profile = existingProfile ?? await prisma.profile.create({
+ data: { userId: testUser.id, name: testUser.name },
+ });
+
+ // Clear existing job experiences for this profile only
+ this.logger.log("Clearing existing job experiences for test user...");
+ await prisma.userJobExperience.deleteMany({
+ where: { profileId: profile.id },
+ });
+
+ // Create job experiences for the test user
+ const numExperiences = faker.number.int({ min: 2, max: 5 });
+
+ await Promise.all(
+ Array.from({ length: numExperiences }, async () => {
+ const startDate = faker.date.past({ years: 8 });
+ const endDate = faker.datatype.boolean()
+ ? faker.date.between({ from: startDate, to: new Date() })
+ : null;
+
+ const company = faker.helpers.arrayElement(companies);
+ const role = faker.helpers.arrayElement(roles);
+ const level = faker.helpers.arrayElement(levels);
+
+ // Select 3-8 random skills for this job experience
+ const selectedSkills = faker.helpers.arrayElements(skills, {
+ min: 3,
+ max: 8,
+ });
+
+ return prisma.userJobExperience.create({
+ data: {
+ profileId: profile.id,
+ companyId: company.id,
+ roleId: role.id,
+ levelId: level.id,
+ startDate,
+ endDate,
+ description: faker.lorem.paragraph(),
+ skills: {
+ connect: selectedSkills.map((skill) => ({ id: skill.id })),
+ },
+ },
+ });
+ }),
+ );
+
+ this.logger.log(`Created ${numExperiences} job experiences for test user`);
+ }
+}
diff --git a/packages/core/src/modules/job-experience/seed/reference-data.seed.ts b/packages/core/src/modules/job-experience/seed/reference-data.seed.ts
new file mode 100644
index 0000000..6cb0e53
--- /dev/null
+++ b/packages/core/src/modules/job-experience/seed/reference-data.seed.ts
@@ -0,0 +1,196 @@
+import { PrismaService } from "../../database";
+import { faker } from "@faker-js/faker";
+import { Injectable, Logger } from "@nestjs/common";
+import { Seeder } from "../../database/seed/seed.service";
+import { Seeder as SeederDecorator } from "../../database/seed/seeder.decorator";
+
+@Injectable()
+@SeederDecorator("Reference Data")
+export class ReferenceDataSeedService implements Seeder {
+ private readonly logger = new Logger(ReferenceDataSeedService.name);
+
+ constructor(private readonly prisma: PrismaService) {}
+
+ async seed(prisma: PrismaService): Promise {
+ this.logger.log("Seeding reference data...");
+ await Promise.all([
+ this.ensureSkills(prisma),
+ this.ensureCompanies(prisma),
+ this.ensureRoles(prisma),
+ this.ensureLevels(prisma),
+ this.ensureJobTypes(prisma),
+ this.ensureApplicationStatuses(prisma),
+ this.ensureRateTypes(prisma),
+ this.ensureContractTypes(prisma),
+ ]);
+ }
+
+ async ensureSkills(
+ prisma: PrismaService = this.prisma,
+ ): Promise> {
+ const skills = ["React", "TypeScript", "Node.js", "GraphQL", "PostgreSQL"];
+
+ return Promise.all(
+ skills.map(async (name) => {
+ const existing = await prisma["skill"].findFirst({
+ where: { name },
+ });
+ if (existing) {
+ return { id: existing.id };
+ }
+ const s = await prisma["skill"].create({
+ data: { name, description: faker.lorem.sentence() },
+ });
+ return { id: s.id };
+ }),
+ );
+ }
+
+ async ensureCompanies(
+ prisma: PrismaService = this.prisma,
+ ): Promise> {
+ const names = ["Google", "Microsoft", "Amazon", "Netflix"];
+
+ return Promise.all(
+ names.map(async (name) => {
+ const existing = await prisma["company"].findFirst({
+ where: { name },
+ });
+ if (existing) {
+ return { id: existing.id };
+ }
+ const c = await prisma["company"].create({
+ data: {
+ name,
+ description: faker.company.catchPhrase(),
+ website: faker.internet.url(),
+ },
+ });
+ return { id: c.id };
+ }),
+ );
+ }
+
+ async ensureRoles(
+ prisma: PrismaService = this.prisma,
+ ): Promise> {
+ const names = [
+ "Software Engineer",
+ "Senior Software Engineer",
+ "Engineering Manager",
+ ];
+
+ return Promise.all(
+ names.map(async (name) => {
+ const existing = await prisma["role"].findFirst({ where: { name } });
+ if (existing) {
+ return { id: existing.id };
+ }
+ const r = await prisma["role"].create({
+ data: { name, description: faker.lorem.sentence() },
+ });
+ return { id: r.id };
+ }),
+ );
+ }
+
+ async ensureLevels(
+ prisma: PrismaService = this.prisma,
+ ): Promise> {
+ const names = ["Junior", "Mid-level", "Senior"];
+
+ return Promise.all(
+ names.map(async (name) => {
+ const existing = await prisma["level"].findFirst({
+ where: { name },
+ });
+ if (existing) {
+ return { id: existing.id };
+ }
+ const l = await prisma["level"].create({
+ data: { name, description: faker.lorem.sentence() },
+ });
+ return { id: l.id };
+ }),
+ );
+ }
+
+ async ensureJobTypes(
+ prisma: PrismaService = this.prisma,
+ ): Promise> {
+ const names = [
+ "Full-time",
+ "Part-time",
+ "Freelance",
+ "Contract",
+ "Internship",
+ "Temporary",
+ ];
+
+ return Promise.all(
+ names.map(async (name) => {
+ const existing = await prisma["jobType"].findFirst({
+ where: { name },
+ });
+ if (existing) {
+ return { id: existing.id };
+ }
+ const jt = await prisma["jobType"].create({
+ data: { name, description: faker.lorem.sentence() },
+ });
+ return { id: jt.id };
+ }),
+ );
+ }
+
+ async ensureRateTypes(
+ prisma: PrismaService = this.prisma,
+ ): Promise> {
+ const names = ["HOUR", "AGREEMENT", "FIXED", "DAY"];
+
+ return Promise.all(
+ names.map(async (name) => {
+ const existing = await prisma.rateType.findFirst({ where: { name } });
+ if (existing) return { id: existing.id };
+ const rt = await prisma.rateType.create({ data: { name } });
+ return { id: rt.id };
+ }),
+ );
+ }
+
+ async ensureContractTypes(
+ prisma: PrismaService = this.prisma,
+ ): Promise> {
+ const names = ["FREELANCE", "FIXED_TERM", "PERMANENT", "INTERNSHIP"];
+
+ return Promise.all(
+ names.map(async (name) => {
+ const existing = await prisma.contractType.findFirst({ where: { name } });
+ if (existing) return { id: existing.id };
+ const ct = await prisma.contractType.create({ data: { name } });
+ return { id: ct.id };
+ }),
+ );
+ }
+
+ async ensureApplicationStatuses(
+ prisma: PrismaService = this.prisma,
+ ): Promise> {
+ const names = ["Pending", "Accepted", "Rejected", "Withdrawn"];
+
+ return Promise.all(
+ names.map(async (name) => {
+ const existing = await prisma["applicationStatus"].findFirst({
+ where: { name },
+ });
+ if (existing) {
+ return { id: existing.id };
+ }
+ const status = await prisma["applicationStatus"].create({
+ data: { name, description: faker.lorem.sentence() },
+ });
+ return { id: status.id };
+ }),
+ );
+ }
+}
diff --git a/packages/core/src/modules/job-experience/skill/skill.dto.ts b/packages/core/src/modules/job-experience/skill/skill.dto.ts
new file mode 100644
index 0000000..74b357d
--- /dev/null
+++ b/packages/core/src/modules/job-experience/skill/skill.dto.ts
@@ -0,0 +1,9 @@
+export interface CreateSkillDto {
+ name: string;
+ description?: string;
+}
+
+export interface UpdateSkillDto {
+ name?: string;
+ description?: string;
+}
diff --git a/packages/core/src/modules/job-experience/skill/skill.entity.ts b/packages/core/src/modules/job-experience/skill/skill.entity.ts
new file mode 100644
index 0000000..1d72f80
--- /dev/null
+++ b/packages/core/src/modules/job-experience/skill/skill.entity.ts
@@ -0,0 +1,3 @@
+import { NamedEntity } from "../../../shared";
+
+export class Skill extends NamedEntity {}
diff --git a/packages/core/src/modules/job-experience/skill/skill.factory.ts b/packages/core/src/modules/job-experience/skill/skill.factory.ts
new file mode 100644
index 0000000..8118a24
--- /dev/null
+++ b/packages/core/src/modules/job-experience/skill/skill.factory.ts
@@ -0,0 +1,23 @@
+import { ClockService, Factory, UuidFactoryService } from "../../../shared";
+import { Injectable } from "@nestjs/common";
+import type { CreateSkillDto } from "./skill.dto";
+import { Skill } from "./skill.entity";
+
+@Injectable()
+export class SkillFactory implements Factory {
+ constructor(
+ private readonly uuidFactory: UuidFactoryService,
+ private readonly clock: ClockService,
+ ) {}
+
+ create(dto: CreateSkillDto): Skill {
+ const now = this.clock.now();
+ return new Skill(
+ this.uuidFactory.generate(),
+ dto.name,
+ now,
+ now,
+ dto.description,
+ );
+ }
+}
diff --git a/packages/core/src/modules/job-experience/skill/skill.mapper.ts b/packages/core/src/modules/job-experience/skill/skill.mapper.ts
new file mode 100644
index 0000000..3adaa5b
--- /dev/null
+++ b/packages/core/src/modules/job-experience/skill/skill.mapper.ts
@@ -0,0 +1,23 @@
+import { createNamedEntityMapper } from "../../../shared";
+import { Injectable } from "@nestjs/common";
+import type { Skill as PrismaSkill } from "@prisma/client";
+import { Skill } from "./skill.entity";
+
+/**
+ * Mapper service for converting between Prisma Skill entities and domain Skill entities
+ */
+@Injectable()
+export class SkillMapper {
+ private mapper = createNamedEntityMapper(Skill);
+
+ toDomain(prismaSkill: null): null;
+ toDomain(prismaSkill: PrismaSkill): Skill;
+ toDomain(prismaSkill: PrismaSkill | null): Skill | null;
+ toDomain(prismaSkill: PrismaSkill | null): Skill | null {
+ return this.mapper.toDomain(prismaSkill);
+ }
+
+ mapToDomain(prismaSkills: PrismaSkill[]): Skill[] {
+ return this.mapper.mapToDomain(prismaSkills);
+ }
+}
diff --git a/packages/core/src/modules/job-experience/skill/skill.module.ts b/packages/core/src/modules/job-experience/skill/skill.module.ts
new file mode 100644
index 0000000..143d002
--- /dev/null
+++ b/packages/core/src/modules/job-experience/skill/skill.module.ts
@@ -0,0 +1,30 @@
+import { AuthorizationModule } from "../../auth";
+import { BaseModule } from "../../../shared";
+import { DatabaseModule } from "../../database";
+import { Module } from "@nestjs/common";
+import { AuthenticationModule } from "../../authentication/authentication.module";
+import { SkillService } from "./skill.service";
+import { JobExperienceSeedService } from "../seed/job-experience.seed";
+import { ReferenceDataSeedService } from "../seed/reference-data.seed";
+import { SkillFactory } from "./skill.factory";
+import { SkillMapper } from "./skill.mapper";
+import { SkillPolicy } from "./skill.policy";
+
+@Module({
+ imports: [
+ DatabaseModule,
+ BaseModule,
+ AuthenticationModule,
+ AuthorizationModule,
+ ],
+ providers: [
+ SkillService,
+ SkillMapper,
+ SkillFactory,
+ SkillPolicy,
+ ReferenceDataSeedService,
+ JobExperienceSeedService,
+ ],
+ exports: [SkillService, SkillMapper],
+})
+export class SkillModule {}
diff --git a/packages/core/src/modules/job-experience/skill/skill.policy.ts b/packages/core/src/modules/job-experience/skill/skill.policy.ts
new file mode 100644
index 0000000..c6f9ff5
--- /dev/null
+++ b/packages/core/src/modules/job-experience/skill/skill.policy.ts
@@ -0,0 +1,11 @@
+import { Policy, PublicResourcePolicy } from "../../auth";
+import { Injectable } from "@nestjs/common";
+import { Skill } from "./skill.entity";
+
+@Injectable()
+@Policy(Skill)
+export class SkillPolicy extends PublicResourcePolicy {
+ override create(): boolean {
+ return true;
+ }
+}
diff --git a/packages/core/src/modules/job-experience/skill/skill.service.ts b/packages/core/src/modules/job-experience/skill/skill.service.ts
new file mode 100644
index 0000000..7973c5e
--- /dev/null
+++ b/packages/core/src/modules/job-experience/skill/skill.service.ts
@@ -0,0 +1,40 @@
+import { type NamedEntityFilters, NamedEntityService } from "../../../shared";
+import { PrismaService } from "../../database";
+import { Injectable } from "@nestjs/common";
+import { Skill } from "./skill.entity";
+import { SkillMapper } from "./skill.mapper";
+
+type SkillFilters = NamedEntityFilters & {
+ vacancyId?: string | undefined;
+};
+
+type SkillWhereInput = {
+ name?: { contains: string; mode: "insensitive" };
+ vacancies?: { some: { id: string } };
+ id?: { in: string[] } | string;
+};
+
+@Injectable()
+export class SkillService extends NamedEntityService<
+ Skill,
+ SkillFilters,
+ SkillWhereInput
+> {
+ constructor(prisma: PrismaService, skillMapper: SkillMapper) {
+ super(prisma, skillMapper, prisma.skill, "Skill");
+ }
+
+ protected override buildWhere(filters: SkillFilters = {}): SkillWhereInput {
+ const where = super.buildWhere(filters) as SkillWhereInput;
+
+ if (filters.vacancyId) {
+ where.vacancies = {
+ some: {
+ id: filters.vacancyId,
+ },
+ };
+ }
+
+ return where;
+ }
+}
diff --git a/packages/system/src/mail/index.ts b/packages/core/src/modules/mail/index.ts
similarity index 100%
rename from packages/system/src/mail/index.ts
rename to packages/core/src/modules/mail/index.ts
diff --git a/packages/system/src/mail/mail.module.ts b/packages/core/src/modules/mail/mail.module.ts
similarity index 100%
rename from packages/system/src/mail/mail.module.ts
rename to packages/core/src/modules/mail/mail.module.ts
diff --git a/packages/system/src/mail/mail.service.interface.ts b/packages/core/src/modules/mail/mail.service.interface.ts
similarity index 100%
rename from packages/system/src/mail/mail.service.interface.ts
rename to packages/core/src/modules/mail/mail.service.interface.ts
diff --git a/packages/system/src/mail/providers/console-mail.service.ts b/packages/core/src/modules/mail/providers/console-mail.service.ts
similarity index 100%
rename from packages/system/src/mail/providers/console-mail.service.ts
rename to packages/core/src/modules/mail/providers/console-mail.service.ts
diff --git a/packages/system/src/mail/providers/example-mail.service.ts b/packages/core/src/modules/mail/providers/example-mail.service.ts
similarity index 100%
rename from packages/system/src/mail/providers/example-mail.service.ts
rename to packages/core/src/modules/mail/providers/example-mail.service.ts
diff --git a/packages/system/src/mail/resend/index.ts b/packages/core/src/modules/mail/resend/index.ts
similarity index 100%
rename from packages/system/src/mail/resend/index.ts
rename to packages/core/src/modules/mail/resend/index.ts
diff --git a/packages/system/src/mail/resend/resend-mail.service.ts b/packages/core/src/modules/mail/resend/resend-mail.service.ts
similarity index 100%
rename from packages/system/src/mail/resend/resend-mail.service.ts
rename to packages/core/src/modules/mail/resend/resend-mail.service.ts
diff --git a/packages/system/src/mail/resend/resend.module.ts b/packages/core/src/modules/mail/resend/resend.module.ts
similarity index 100%
rename from packages/system/src/mail/resend/resend.module.ts
rename to packages/core/src/modules/mail/resend/resend.module.ts
diff --git a/packages/system/src/mail/template/handlebars-template.service.ts b/packages/core/src/modules/mail/template/handlebars-template.service.ts
similarity index 100%
rename from packages/system/src/mail/template/handlebars-template.service.ts
rename to packages/core/src/modules/mail/template/handlebars-template.service.ts
diff --git a/packages/system/src/mail/template/index.ts b/packages/core/src/modules/mail/template/index.ts
similarity index 100%
rename from packages/system/src/mail/template/index.ts
rename to packages/core/src/modules/mail/template/index.ts
diff --git a/packages/system/src/mail/template/template-registry.service.ts b/packages/core/src/modules/mail/template/template-registry.service.ts
similarity index 100%
rename from packages/system/src/mail/template/template-registry.service.ts
rename to packages/core/src/modules/mail/template/template-registry.service.ts
diff --git a/packages/system/src/mail/template/template.module.ts b/packages/core/src/modules/mail/template/template.module.ts
similarity index 100%
rename from packages/system/src/mail/template/template.module.ts
rename to packages/core/src/modules/mail/template/template.module.ts
diff --git a/packages/core/src/modules/messenger/logger.provider.ts b/packages/core/src/modules/messenger/logger.provider.ts
new file mode 100644
index 0000000..ad00320
--- /dev/null
+++ b/packages/core/src/modules/messenger/logger.provider.ts
@@ -0,0 +1,18 @@
+import { Logger as NestLogger } from "@nestjs/common";
+import type { Logger } from "@riotbyte-com/project-q-core";
+
+export class NestProjectQLogger implements Logger {
+ private readonly logger = new NestLogger("ProjectQ");
+
+ info(message: string): void {
+ this.logger.log(message);
+ }
+
+ error(message: string): void {
+ this.logger.error(message);
+ }
+
+ debug(message: string): void {
+ this.logger.debug(message);
+ }
+}
diff --git a/packages/core/src/modules/messenger/messages/render-pdf.message.ts b/packages/core/src/modules/messenger/messages/render-pdf.message.ts
new file mode 100644
index 0000000..e39cf5d
--- /dev/null
+++ b/packages/core/src/modules/messenger/messages/render-pdf.message.ts
@@ -0,0 +1,11 @@
+import { defineZodMessage } from "@riotbyte-com/project-q-core";
+import { z } from "zod/v4";
+
+export const RenderPdfMessage = defineZodMessage(
+ "render-pdf",
+ z.object({
+ cvId: z.string(),
+ html: z.string(),
+ requestedBy: z.string(),
+ }),
+);
diff --git a/packages/core/src/modules/messenger/messenger.module.ts b/packages/core/src/modules/messenger/messenger.module.ts
new file mode 100644
index 0000000..11851a7
--- /dev/null
+++ b/packages/core/src/modules/messenger/messenger.module.ts
@@ -0,0 +1,23 @@
+import { Module } from "@nestjs/common";
+import { LoggerProvider, MessengerModule } from "@riotbyte-com/project-q-nestjs";
+import { NestProjectQLogger } from "./logger.provider";
+import { PrismaTransportModule } from "./prisma-transport.module";
+
+@Module({
+ imports: [
+ PrismaTransportModule,
+ MessengerModule.forRoot({
+ transports: {
+ async: { dsn: "prisma://?queue=default", retry: true },
+ },
+ routing: new Map([["render-pdf", "async"]]),
+ }),
+ ],
+ providers: [
+ {
+ provide: LoggerProvider,
+ useClass: NestProjectQLogger,
+ },
+ ],
+})
+export class ProjectQMessengerModule {}
diff --git a/packages/core/src/modules/messenger/prisma-transport.module.ts b/packages/core/src/modules/messenger/prisma-transport.module.ts
new file mode 100644
index 0000000..76629a4
--- /dev/null
+++ b/packages/core/src/modules/messenger/prisma-transport.module.ts
@@ -0,0 +1,13 @@
+import { Module } from "@nestjs/common";
+import { DatabaseModule, PrismaService } from "../database";
+import { PrismaClientToken, PrismaTransportFactory } from "@riotbyte-com/project-q-prisma";
+
+@Module({
+ imports: [DatabaseModule],
+ providers: [
+ PrismaTransportFactory,
+ { provide: PrismaClientToken, useExisting: PrismaService },
+ ],
+ exports: [PrismaTransportFactory],
+})
+export class PrismaTransportModule {}
diff --git a/packages/core/src/modules/onboarding/onboarding-step.decorator.ts b/packages/core/src/modules/onboarding/onboarding-step.decorator.ts
new file mode 100644
index 0000000..1edc9e7
--- /dev/null
+++ b/packages/core/src/modules/onboarding/onboarding-step.decorator.ts
@@ -0,0 +1,6 @@
+import { SetMetadata } from "@nestjs/common";
+
+export const ONBOARDING_STEP_METADATA_KEY = "onboarding_step";
+
+export const OnboardingStep = () =>
+ SetMetadata(ONBOARDING_STEP_METADATA_KEY, true);
diff --git a/packages/core/src/modules/onboarding/onboarding-step.interface.ts b/packages/core/src/modules/onboarding/onboarding-step.interface.ts
new file mode 100644
index 0000000..c74a285
--- /dev/null
+++ b/packages/core/src/modules/onboarding/onboarding-step.interface.ts
@@ -0,0 +1,14 @@
+import type { User } from "../auth";
+
+export enum OnboardingStepStatus {
+ NOT_STARTED = "NOT_STARTED",
+ IN_PROGRESS = "IN_PROGRESS",
+ COMPLETE = "COMPLETE",
+}
+
+export interface OnboardingStepDefinition {
+ name: string;
+ order: number;
+ dependsOn: string[];
+ computeStatus(user: User): Promise;
+}
diff --git a/packages/core/src/modules/onboarding/onboarding.module.ts b/packages/core/src/modules/onboarding/onboarding.module.ts
new file mode 100644
index 0000000..daefd3d
--- /dev/null
+++ b/packages/core/src/modules/onboarding/onboarding.module.ts
@@ -0,0 +1,11 @@
+import { DatabaseModule } from "../database";
+import { Module } from "@nestjs/common";
+import { DiscoveryModule } from "@nestjs/core";
+import { OnboardingService } from "./onboarding.service";
+
+@Module({
+ imports: [DiscoveryModule, DatabaseModule],
+ providers: [OnboardingService],
+ exports: [OnboardingService],
+})
+export class OnboardingModule {}
diff --git a/packages/core/src/modules/onboarding/onboarding.service.ts b/packages/core/src/modules/onboarding/onboarding.service.ts
new file mode 100644
index 0000000..4ebd52d
--- /dev/null
+++ b/packages/core/src/modules/onboarding/onboarding.service.ts
@@ -0,0 +1,101 @@
+import type { User } from "../auth";
+import { PrismaService } from "../database";
+import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
+import { DiscoveryService } from "@nestjs/core";
+import { ONBOARDING_STEP_METADATA_KEY } from "./onboarding-step.decorator";
+import type {
+ OnboardingStepDefinition,
+ OnboardingStepStatus,
+} from "./onboarding-step.interface";
+
+export interface OnboardingStepResult {
+ name: string;
+ status: OnboardingStepStatus;
+ dependsOn: string[];
+ blockedBy: string[];
+}
+
+@Injectable()
+export class OnboardingService implements OnModuleInit {
+ private readonly logger = new Logger(OnboardingService.name);
+ private steps: OnboardingStepDefinition[] = [];
+
+ constructor(
+ private readonly discoveryService: DiscoveryService,
+ private readonly prisma: PrismaService,
+ ) {}
+
+ async onModuleInit(): Promise {
+ const providers = this.discoveryService.getProviders();
+
+ for (const wrapper of providers) {
+ if (!(wrapper.isDependencyTreeStatic() && wrapper.instance)) continue;
+
+ const metadata = Reflect.getMetadata(
+ ONBOARDING_STEP_METADATA_KEY,
+ wrapper.instance.constructor,
+ );
+
+ if (!metadata) continue;
+
+ const instance = wrapper.instance as OnboardingStepDefinition;
+
+ if (
+ typeof instance.name !== "string" ||
+ typeof instance.order !== "number" ||
+ !Array.isArray(instance.dependsOn) ||
+ typeof instance.computeStatus !== "function"
+ ) {
+ this.logger.warn(
+ `Provider ${wrapper.instance.constructor.name} has @OnboardingStep() but doesn't implement OnboardingStepDefinition`,
+ );
+ continue;
+ }
+
+ this.steps.push(instance);
+ this.logger.log(`Registered onboarding step: ${instance.name}`);
+ }
+
+ this.steps.sort((a, b) => a.order - b.order);
+ }
+
+ async resetOnboarding(user: User): Promise {
+ await this.prisma.$transaction([
+ this.prisma.education.deleteMany({
+ where: { profile: { userId: user.id } },
+ }),
+ this.prisma.userJobExperience.deleteMany({
+ where: { profile: { userId: user.id } },
+ }),
+ this.prisma.importJob.deleteMany({
+ where: { userFile: { profile: { userId: user.id } } },
+ }),
+ this.prisma.userFile.deleteMany({
+ where: { profile: { userId: user.id } },
+ }),
+ this.prisma.profile.deleteMany({ where: { userId: user.id } }),
+ this.prisma.userAiProvider.deleteMany({ where: { userId: user.id } }),
+ this.prisma.userAiSettings.deleteMany({ where: { userId: user.id } }),
+ ]);
+
+ this.logger.log(`Onboarding reset for user ${user.id}`);
+ return true;
+ }
+
+ async getStatus(user: User): Promise {
+ const statuses = await Promise.all(
+ this.steps.map(async (step) => ({
+ name: step.name,
+ status: await step.computeStatus(user),
+ dependsOn: step.dependsOn,
+ })),
+ );
+
+ const statusMap = new Map(statuses.map((s) => [s.name, s.status]));
+
+ return statuses.map((s) => ({
+ ...s,
+ blockedBy: s.dependsOn.filter((dep) => statusMap.get(dep) !== "COMPLETE"),
+ }));
+ }
+}
diff --git a/packages/core/src/modules/organization/organization-role.entity.ts b/packages/core/src/modules/organization/organization-role.entity.ts
new file mode 100644
index 0000000..104d663
--- /dev/null
+++ b/packages/core/src/modules/organization/organization-role.entity.ts
@@ -0,0 +1,55 @@
+import { BaseEntity } from "../../shared";
+import { Field, ID, ObjectType } from "@nestjs/graphql";
+
+@ObjectType()
+export class OrganizationRole extends BaseEntity {
+ @Field(() => ID)
+ declare id: string;
+
+ @Field(() => String)
+ declare name: string;
+
+ @Field(() => String, { nullable: true })
+ declare description: string | null;
+
+ @Field(() => String, { nullable: true })
+ declare color: string | null;
+
+ @Field(() => Date)
+ declare createdAt: Date;
+
+ @Field(() => Date)
+ declare updatedAt: Date;
+
+ constructor(
+ id: string,
+ name: string,
+ createdAt: Date,
+ updatedAt: Date,
+ description: string | null = null,
+ color: string | null = null,
+ ) {
+ super(id, createdAt, updatedAt);
+ this.name = name;
+ this.description = description;
+ this.color = color;
+ }
+
+ static fromDomain(domainRole: {
+ id: string;
+ name: string;
+ description?: string | null;
+ color?: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+ }): OrganizationRole {
+ return new OrganizationRole(
+ domainRole.id,
+ domainRole.name,
+ domainRole.createdAt,
+ domainRole.updatedAt,
+ domainRole.description,
+ domainRole.color,
+ );
+ }
+}
diff --git a/packages/core/src/modules/organization/organization-role.mapper.ts b/packages/core/src/modules/organization/organization-role.mapper.ts
new file mode 100644
index 0000000..2569ad4
--- /dev/null
+++ b/packages/core/src/modules/organization/organization-role.mapper.ts
@@ -0,0 +1,30 @@
+import type { BaseMapper } from "../../shared";
+import { Injectable } from "@nestjs/common";
+import type { OrganizationRole as PrismaOrganizationRole } from "@prisma/client";
+import { OrganizationRole } from "./organization-role.entity";
+
+@Injectable()
+export class OrganizationRoleMapper
+ implements BaseMapper
+{
+ toDomain(prismaRole: null): null;
+ toDomain(prismaRole: PrismaOrganizationRole): OrganizationRole;
+ toDomain(prismaRole: PrismaOrganizationRole | null): OrganizationRole | null;
+ toDomain(prismaRole: PrismaOrganizationRole | null): OrganizationRole | null {
+ if (prismaRole === null) {
+ return null;
+ }
+ return new OrganizationRole(
+ prismaRole.id,
+ prismaRole.name,
+ prismaRole.createdAt,
+ prismaRole.updatedAt,
+ prismaRole.description,
+ prismaRole.color,
+ );
+ }
+
+ mapToDomain(prismaRoles: PrismaOrganizationRole[]): OrganizationRole[] {
+ return prismaRoles.map((r) => this.toDomain(r));
+ }
+}
diff --git a/packages/core/src/modules/organization/organization-role.policy.ts b/packages/core/src/modules/organization/organization-role.policy.ts
new file mode 100644
index 0000000..5bf6bce
--- /dev/null
+++ b/packages/core/src/modules/organization/organization-role.policy.ts
@@ -0,0 +1,7 @@
+import { Policy, PublicResourcePolicy } from "../auth";
+import { Injectable } from "@nestjs/common";
+import { OrganizationRole } from "./organization-role.entity";
+
+@Injectable()
+@Policy(OrganizationRole)
+export class OrganizationRolePolicy extends PublicResourcePolicy {}
diff --git a/packages/core/src/modules/organization/organization-role.service.ts b/packages/core/src/modules/organization/organization-role.service.ts
new file mode 100644
index 0000000..729726e
--- /dev/null
+++ b/packages/core/src/modules/organization/organization-role.service.ts
@@ -0,0 +1,77 @@
+import { notFound } from "../auth";
+import { PrismaService } from "../database";
+import { Injectable } from "@nestjs/common";
+import { OrganizationRole } from "./organization-role.entity";
+import { OrganizationRoleMapper } from "./organization-role.mapper";
+
+@Injectable()
+export class OrganizationRoleService {
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly organizationRoleMapper: OrganizationRoleMapper,
+ ) {}
+
+ async findById(id: string): Promise {
+ const role = await this.prisma.organizationRole.findUnique({
+ where: { id },
+ });
+
+ return this.organizationRoleMapper.toDomain(role);
+ }
+
+ async findByIdOrFail(id: string): Promise {
+ const role = await this.findById(id);
+ return role ?? notFound("Organization role", "id", id);
+ }
+
+ async findAll(): Promise {
+ const roles = await this.prisma.organizationRole.findMany({
+ orderBy: { name: "asc" },
+ });
+ return this.organizationRoleMapper.mapToDomain(roles);
+ }
+
+ async create(data: {
+ name: string;
+ description?: string;
+ color?: string;
+ }): Promise {
+ const role = await this.prisma.organizationRole.create({
+ data: {
+ name: data.name,
+ description: data.description ?? null,
+ color: data.color ?? "#6366f1",
+ },
+ });
+
+ return this.organizationRoleMapper.toDomain(role);
+ }
+
+ async update(
+ id: string,
+ data: {
+ name?: string;
+ description?: string;
+ color?: string;
+ },
+ ): Promise {
+ const role = await this.prisma.organizationRole.update({
+ where: { id },
+ data: {
+ ...(data.name !== undefined && { name: data.name }),
+ ...(data.description !== undefined && {
+ description: data.description,
+ }),
+ ...(data.color !== undefined && { color: data.color }),
+ },
+ });
+
+ return this.organizationRoleMapper.toDomain(role);
+ }
+
+ async delete(id: string): Promise {
+ await this.prisma.organizationRole.delete({
+ where: { id },
+ });
+ }
+}
diff --git a/packages/core/src/modules/organization/organization.dto.ts b/packages/core/src/modules/organization/organization.dto.ts
new file mode 100644
index 0000000..da5837d
--- /dev/null
+++ b/packages/core/src/modules/organization/organization.dto.ts
@@ -0,0 +1,9 @@
+export interface CreateOrganizationDto {
+ name: string;
+ description?: string;
+}
+
+export interface UpdateOrganizationDto {
+ name?: string;
+ description?: string;
+}
diff --git a/packages/core/src/modules/organization/organization.entity.ts b/packages/core/src/modules/organization/organization.entity.ts
new file mode 100644
index 0000000..11bf242
--- /dev/null
+++ b/packages/core/src/modules/organization/organization.entity.ts
@@ -0,0 +1,48 @@
+import { BaseEntity } from "../../shared";
+import { Field, ID, ObjectType } from "@nestjs/graphql";
+
+@ObjectType()
+export class Organization extends BaseEntity {
+ @Field(() => ID)
+ declare id: string;
+
+ @Field(() => String)
+ declare name: string;
+
+ @Field(() => String, { nullable: true })
+ declare description: string | null;
+
+ @Field(() => Date)
+ declare createdAt: Date;
+
+ @Field(() => Date)
+ declare updatedAt: Date;
+
+ constructor(
+ id: string,
+ name: string,
+ createdAt: Date,
+ updatedAt: Date,
+ description: string | null = null,
+ ) {
+ super(id, createdAt, updatedAt);
+ this.name = name;
+ this.description = description;
+ }
+
+ static fromDomain(domainOrg: {
+ id: string;
+ name: string;
+ description?: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+ }): Organization {
+ return new Organization(
+ domainOrg.id,
+ domainOrg.name,
+ domainOrg.createdAt,
+ domainOrg.updatedAt,
+ domainOrg.description,
+ );
+ }
+}
diff --git a/packages/core/src/modules/organization/organization.factory.ts b/packages/core/src/modules/organization/organization.factory.ts
new file mode 100644
index 0000000..309042d
--- /dev/null
+++ b/packages/core/src/modules/organization/organization.factory.ts
@@ -0,0 +1,25 @@
+import { ClockService, Factory, UuidFactoryService } from "../../shared";
+import { Injectable } from "@nestjs/common";
+import type { CreateOrganizationDto } from "./organization.dto";
+import { Organization } from "./organization.entity";
+
+@Injectable()
+export class OrganizationFactory
+ implements Factory
+{
+ constructor(
+ private readonly uuidFactory: UuidFactoryService,
+ private readonly clock: ClockService,
+ ) {}
+
+ create(dto: CreateOrganizationDto): Organization {
+ const now = this.clock.now();
+ return new Organization(
+ this.uuidFactory.generate(),
+ dto.name,
+ now,
+ now,
+ dto.description ?? null,
+ );
+ }
+}
diff --git a/packages/core/src/modules/organization/organization.mapper.ts b/packages/core/src/modules/organization/organization.mapper.ts
new file mode 100644
index 0000000..7313681
--- /dev/null
+++ b/packages/core/src/modules/organization/organization.mapper.ts
@@ -0,0 +1,29 @@
+import type { BaseMapper } from "../../shared";
+import { Injectable } from "@nestjs/common";
+import type { Organization as PrismaOrganization } from "@prisma/client";
+import { Organization } from "./organization.entity";
+
+@Injectable()
+export class OrganizationMapper
+ implements BaseMapper
+{
+ toDomain(prismaOrganization: null): null;
+ toDomain(prismaOrganization: PrismaOrganization): Organization;
+ toDomain(prismaOrganization: PrismaOrganization | null): Organization | null;
+ toDomain(prismaOrganization: PrismaOrganization | null): Organization | null {
+ if (prismaOrganization === null) {
+ return null;
+ }
+ return new Organization(
+ prismaOrganization.id,
+ prismaOrganization.name,
+ prismaOrganization.createdAt,
+ prismaOrganization.updatedAt,
+ prismaOrganization.description,
+ );
+ }
+
+ mapToDomain(prismaOrganizations: PrismaOrganization[]): Organization[] {
+ return prismaOrganizations.map((o) => this.toDomain(o));
+ }
+}
diff --git a/packages/core/src/modules/organization/organization.module.ts b/packages/core/src/modules/organization/organization.module.ts
new file mode 100644
index 0000000..997dd57
--- /dev/null
+++ b/packages/core/src/modules/organization/organization.module.ts
@@ -0,0 +1,41 @@
+import { AuthorizationModule } from "../auth";
+import { BaseModule } from "../../shared";
+import { DatabaseModule } from "../database";
+import { Module } from "@nestjs/common";
+import { AuthenticationModule } from "../authentication/authentication.module";
+import { OrganizationFactory } from "./organization.factory";
+import { OrganizationMapper } from "./organization.mapper";
+import { OrganizationPolicy } from "./organization.policy";
+import { OrganizationService } from "./organization.service";
+import { OrganizationRoleMapper } from "./organization-role.mapper";
+import { OrganizationRolePolicy } from "./organization-role.policy";
+import { OrganizationRoleService } from "./organization-role.service";
+import { MembershipSeedService } from "./seed/membership.seed";
+import { OrganizationSeedService } from "./seed/organization.seed";
+
+@Module({
+ imports: [
+ DatabaseModule,
+ BaseModule,
+ AuthenticationModule,
+ AuthorizationModule,
+ ],
+ providers: [
+ OrganizationService,
+ OrganizationRoleService,
+ OrganizationFactory,
+ OrganizationPolicy,
+ OrganizationRolePolicy,
+ OrganizationMapper,
+ OrganizationRoleMapper,
+ OrganizationSeedService,
+ MembershipSeedService,
+ ],
+ exports: [
+ OrganizationService,
+ OrganizationRoleService,
+ OrganizationMapper,
+ OrganizationRoleMapper,
+ ],
+})
+export class OrganizationModule {}
diff --git a/packages/core/src/modules/organization/organization.policy.ts b/packages/core/src/modules/organization/organization.policy.ts
new file mode 100644
index 0000000..b073294
--- /dev/null
+++ b/packages/core/src/modules/organization/organization.policy.ts
@@ -0,0 +1,11 @@
+import { Policy, PublicResourcePolicy } from "../auth";
+import { Injectable } from "@nestjs/common";
+import { Organization } from "./organization.entity";
+
+@Injectable()
+@Policy(Organization)
+export class OrganizationPolicy extends PublicResourcePolicy {
+ override create(): boolean {
+ return true;
+ }
+}
diff --git a/packages/core/src/modules/organization/organization.service.ts b/packages/core/src/modules/organization/organization.service.ts
new file mode 100644
index 0000000..67b1979
--- /dev/null
+++ b/packages/core/src/modules/organization/organization.service.ts
@@ -0,0 +1,206 @@
+import { notFound, User } from "../auth";
+import { type EntityService } from "../../shared";
+import { PrismaService } from "../database";
+import { Injectable } from "@nestjs/common";
+import type { Prisma } from "@prisma/client";
+import { Organization } from "./organization.entity";
+import { OrganizationMapper } from "./organization.mapper";
+
+export type Membership = {
+ id: string;
+ organizationId: string;
+ userId: string;
+ organizationRoleId: string;
+ createdAt: Date;
+ updatedAt: Date;
+ user: {
+ id: string;
+ name: string;
+ createdAt: Date;
+ credentials?: {
+ email: string;
+ } | null;
+ };
+ role: {
+ id: string;
+ name: string;
+ description: string | null;
+ color: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+ };
+};
+
+export type MembershipSortField = "createdAt" | "userName" | "roleName";
+export type MembershipSortOrder = "asc" | "desc";
+
+type OrganizationFilters = Record;
+
+@Injectable()
+export class OrganizationService
+ implements EntityService
+{
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly organizationMapper: OrganizationMapper,
+ ) {}
+
+ async findById(id: string): Promise {
+ const organization = await this.prisma.organization.findUnique({
+ where: { id },
+ });
+
+ return this.organizationMapper.toDomain(organization);
+ }
+
+ async findMembershipsByOrganization(
+ organizationId: string,
+ sortBy: MembershipSortField = "createdAt",
+ sortOrder: MembershipSortOrder = "asc",
+ searchTerm?: string,
+ ): Promise {
+ const whereClause: Prisma.MembershipWhereInput = { organizationId };
+
+ // Add search functionality for user names
+ if (searchTerm?.trim()) {
+ whereClause.user = {
+ name: {
+ contains: searchTerm.trim(),
+ mode: "insensitive",
+ },
+ };
+ }
+
+ const memberships = await this.prisma.membership.findMany({
+ where: whereClause,
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ createdAt: true,
+ credentials: {
+ select: {
+ email: true,
+ },
+ },
+ },
+ },
+ role: {
+ select: {
+ id: true,
+ name: true,
+ description: true,
+ color: true,
+ createdAt: true,
+ updatedAt: true,
+ },
+ },
+ },
+ orderBy:
+ sortBy === "userName"
+ ? { user: { name: sortOrder } }
+ : sortBy === "roleName"
+ ? { role: { name: sortOrder } }
+ : { createdAt: sortOrder },
+ });
+
+ return memberships as Membership[];
+ }
+
+ async countMembershipsByOrganization(
+ organizationId: string,
+ searchTerm?: string,
+ ): Promise {
+ const whereClause: Prisma.MembershipWhereInput = { organizationId };
+
+ // Add search functionality for user names
+ if (searchTerm?.trim()) {
+ whereClause.user = {
+ name: {
+ contains: searchTerm.trim(),
+ mode: "insensitive",
+ },
+ };
+ }
+
+ return this.prisma.membership.count({
+ where: whereClause,
+ });
+ }
+
+ async findByIdOrFail(id: string): Promise {
+ return (await this.findById(id)) ?? notFound("Organization", "id", id);
+ }
+
+ async findMany(_filters: OrganizationFilters = {}): Promise {
+ const organizations = await this.prisma.organization.findMany({
+ orderBy: { name: "asc" },
+ });
+ return this.organizationMapper.mapToDomain(organizations);
+ }
+
+ async count(_filters: OrganizationFilters = {}): Promise {
+ return this.prisma.organization.count();
+ }
+
+ private buildPrismaData({ name, description }: Organization): {
+ name: string;
+ description: string | null;
+ } {
+ return {
+ name,
+ description: description ?? null,
+ };
+ }
+
+ async save(entity: Organization): Promise {
+ const { id } = entity;
+ const data = this.buildPrismaData(entity);
+ const organization = await this.prisma.organization.upsert({
+ where: { id },
+ create: { id, ...data },
+ update: data,
+ });
+ return this.organizationMapper.toDomain(organization);
+ }
+
+ async destroy(entity: Organization): Promise {
+ await this.prisma.organization.delete({
+ where: { id: entity.id },
+ });
+ }
+
+ async addUserToOrganization(
+ { id: organizationId }: Organization,
+ { id: userId }: User,
+ roleId?: string,
+ ): Promise {
+ // Default to member role if no role specified
+ const defaultRoleId = roleId || "member_role_id";
+
+ await this.prisma.membership.create({
+ data: {
+ organizationId,
+ userId,
+ organizationRoleId: defaultRoleId,
+ },
+ });
+
+ return true;
+ }
+
+ async removeUserFromOrganization(
+ { id: organizationId }: Organization,
+ { id: userId }: User,
+ ): Promise {
+ await this.prisma.membership.deleteMany({
+ where: {
+ organizationId,
+ userId,
+ },
+ });
+
+ return true;
+ }
+}
diff --git a/packages/core/src/modules/organization/seed/membership.seed.ts b/packages/core/src/modules/organization/seed/membership.seed.ts
new file mode 100644
index 0000000..648f15d
--- /dev/null
+++ b/packages/core/src/modules/organization/seed/membership.seed.ts
@@ -0,0 +1,134 @@
+import { PrismaService } from "../../database";
+import { faker } from "@faker-js/faker";
+import { Injectable, Logger } from "@nestjs/common";
+import { Seeder } from "../../database/seed/seed.service";
+import { Seeder as SeederDecorator } from "../../database/seed/seeder.decorator";
+
+@Injectable()
+@SeederDecorator({ name: "Memberships", dependsOn: ["Users", "Organizations"] })
+export class MembershipSeedService implements Seeder {
+ private readonly logger = new Logger(MembershipSeedService.name);
+
+ constructor(readonly _prisma: PrismaService) {}
+
+ private async ensureMemberRole(
+ prisma: PrismaService,
+ ): Promise<{ id: string }> {
+ const existing = await prisma["organizationRole"].findFirst({
+ where: { name: "Member" },
+ });
+
+ if (existing) return { id: existing.id };
+
+ return prisma["organizationRole"].create({
+ data: { name: "Member", description: "Default member role" },
+ });
+ }
+
+ private async createMembershipIfMissing(
+ prisma: PrismaService,
+ userId: string,
+ organizationId: string,
+ roleId: string,
+ ): Promise {
+ const existing = await prisma["membership"].findFirst({
+ where: { userId, organizationId },
+ });
+
+ if (!existing) {
+ await prisma["membership"].create({
+ data: { userId, organizationId, organizationRoleId: roleId },
+ });
+ }
+ }
+
+ async seed(prisma: PrismaService): Promise {
+ this.logger.log("Seeding memberships...");
+
+ const memberRole = await this.ensureMemberRole(prisma);
+
+ const [allUsers, allOrganizations] = await Promise.all([
+ prisma["user"].findMany(),
+ prisma["organization"].findMany(),
+ ]);
+
+ if (allUsers.length === 0 || allOrganizations.length === 0) {
+ this.logger.warn(
+ "No users or organizations found, skipping membership seeding",
+ );
+ return;
+ }
+
+ await this.ensureTestUserMemberships(
+ prisma,
+ allOrganizations,
+ memberRole.id,
+ );
+
+ const allCredentials = await prisma["credentials"].findMany({
+ include: { user: true },
+ });
+ const testUserCredentials = allCredentials.find(
+ (c: { email: string }) => c.email === "test@test.test",
+ );
+ const testUserIds = testUserCredentials ? [testUserCredentials.userId] : [];
+
+ await Promise.all(
+ allUsers.map(async (user: { id: string }) => {
+ if (testUserIds.includes(user.id)) return;
+
+ const numOrganizations = faker.number.int({ min: 1, max: 2 });
+ const selectedOrganizations = faker.helpers.arrayElements(
+ allOrganizations,
+ { min: 1, max: numOrganizations },
+ );
+
+ await Promise.all(
+ selectedOrganizations.map((org: { id: string }) =>
+ this.createMembershipIfMissing(
+ prisma,
+ user.id,
+ org.id,
+ memberRole.id,
+ ),
+ ),
+ );
+ }),
+ );
+
+ this.logger.log("Assigned users to organizations");
+ }
+
+ private async ensureTestUserMemberships(
+ prisma: PrismaService,
+ allOrganizations: Array<{ id: string }>,
+ roleId: string,
+ ): Promise {
+ const testCredentials = await prisma["credentials"].findUnique({
+ where: { email: "test@test.test" },
+ include: { user: true },
+ });
+
+ if (!testCredentials) {
+ this.logger.warn(
+ "Test user not found, skipping test user membership assignment",
+ );
+ return;
+ }
+
+ const testUserId = testCredentials.userId;
+ const organizationsForTestUser = allOrganizations.slice(0, 5);
+
+ await Promise.all(
+ organizationsForTestUser.map(async (org) => {
+ this.logger.log(`Adding test@test.test to organization ${org.id}`);
+ await this.createMembershipIfMissing(
+ prisma,
+ testUserId,
+ org.id,
+ roleId,
+ );
+ }),
+ );
+ }
+}
diff --git a/packages/core/src/modules/organization/seed/organization.seed.ts b/packages/core/src/modules/organization/seed/organization.seed.ts
new file mode 100644
index 0000000..b682126
--- /dev/null
+++ b/packages/core/src/modules/organization/seed/organization.seed.ts
@@ -0,0 +1,57 @@
+import { PrismaService } from "../../database";
+import { faker } from "@faker-js/faker";
+import { Injectable, Logger } from "@nestjs/common";
+import { Seeder } from "../../database/seed/seed.service";
+import { Seeder as SeederDecorator } from "../../database/seed/seeder.decorator";
+
+@Injectable()
+@SeederDecorator("Organizations")
+export class OrganizationSeedService implements Seeder {
+ private readonly logger = new Logger(OrganizationSeedService.name);
+
+ constructor(private readonly prisma: PrismaService) {}
+
+ async seed(prisma: PrismaService): Promise {
+ this.logger.log("Seeding organizations...");
+ await this.ensureOrganizations(prisma);
+ }
+
+ async ensureOrganizations(
+ prisma: PrismaService = this.prisma,
+ ): Promise> {
+ const names = [
+ "TechCorp Solutions",
+ "Innovation Labs",
+ "Digital Dynamics",
+ "Future Systems",
+ "Alpha Technologies",
+ "Beta Solutions",
+ "Gamma Industries",
+ "Delta Enterprises",
+ "Epsilon Corp",
+ "Zeta Systems",
+ "Eta Technologies",
+ "Theta Labs",
+ "Iota Innovations",
+ "Kappa Dynamics",
+ "Lambda Solutions",
+ ];
+
+ const created = await Promise.all(
+ names.map(async (name) => {
+ const existing = await prisma["organization"].findFirst({
+ where: { name },
+ });
+ if (existing) {
+ return { id: existing.id };
+ }
+ const org = await prisma["organization"].create({
+ data: { name, description: faker.company.catchPhrase() },
+ });
+ return { id: org.id };
+ }),
+ );
+
+ return created;
+ }
+}
diff --git a/packages/core/src/modules/profile/onboarding/profile.step.ts b/packages/core/src/modules/profile/onboarding/profile.step.ts
new file mode 100644
index 0000000..72449b6
--- /dev/null
+++ b/packages/core/src/modules/profile/onboarding/profile.step.ts
@@ -0,0 +1,31 @@
+import type { User } from "../../auth";
+import { PrismaService } from "../../database";
+import { Injectable } from "@nestjs/common";
+import { OnboardingStep } from "../../onboarding/onboarding-step.decorator";
+import {
+ type OnboardingStepDefinition,
+ OnboardingStepStatus,
+} from "../../onboarding/onboarding-step.interface";
+
+@Injectable()
+@OnboardingStep()
+export class ProfileOnboardingStep implements OnboardingStepDefinition {
+ readonly name = "personal-profile";
+ readonly order = 2;
+ readonly dependsOn: string[] = [];
+
+ constructor(private readonly prisma: PrismaService) {}
+
+ async computeStatus(user: User): Promise {
+ const profile = await this.prisma.profile.findFirst({
+ where: { userId: user.id },
+ select: { headline: true, locationId: true },
+ });
+
+ if (!profile) return OnboardingStepStatus.NOT_STARTED;
+
+ return profile.headline && profile.locationId
+ ? OnboardingStepStatus.COMPLETE
+ : OnboardingStepStatus.IN_PROGRESS;
+ }
+}
diff --git a/packages/core/src/modules/profile/profile.entity.ts b/packages/core/src/modules/profile/profile.entity.ts
new file mode 100644
index 0000000..0d6dd39
--- /dev/null
+++ b/packages/core/src/modules/profile/profile.entity.ts
@@ -0,0 +1,14 @@
+import { BaseEntity } from "../../shared";
+
+export class ProfileEntity extends BaseEntity {
+ constructor(
+ id: string,
+ public readonly userId: string,
+ public readonly name: string,
+ public readonly locationId: string | undefined,
+ createdAt: Date,
+ updatedAt: Date,
+ ) {
+ super(id, createdAt, updatedAt);
+ }
+}
diff --git a/packages/core/src/modules/profile/profile.module.ts b/packages/core/src/modules/profile/profile.module.ts
new file mode 100644
index 0000000..b94f151
--- /dev/null
+++ b/packages/core/src/modules/profile/profile.module.ts
@@ -0,0 +1,28 @@
+import { AuthorizationModule } from "../auth/authorization/authorization.module";
+import { BaseModule } from "../../shared";
+import { DatabaseModule } from "../database";
+import { Module, forwardRef } from "@nestjs/common";
+import { CVTemplateModule } from "../cv-template/cv-template.module";
+import { EducationModule } from "../education/education.module";
+import { EmploymentModule } from "../job-experience/employment/employment.module";
+import { ProfileOnboardingStep } from "./onboarding/profile.step";
+import { ProfilePolicy } from "./profile.policy";
+import { ProfileService } from "./profile.service";
+
+@Module({
+ imports: [
+ DatabaseModule,
+ BaseModule,
+ AuthorizationModule,
+ forwardRef(() => EducationModule),
+ forwardRef(() => EmploymentModule),
+ forwardRef(() => CVTemplateModule),
+ ],
+ providers: [
+ ProfileService,
+ ProfilePolicy,
+ ProfileOnboardingStep,
+ ],
+ exports: [ProfileService],
+})
+export class ProfileModule {}
diff --git a/packages/core/src/modules/profile/profile.policy.ts b/packages/core/src/modules/profile/profile.policy.ts
new file mode 100644
index 0000000..294281a
--- /dev/null
+++ b/packages/core/src/modules/profile/profile.policy.ts
@@ -0,0 +1,7 @@
+import { Policy, UserOwnedResourcePolicy } from "../auth";
+import { Injectable } from "@nestjs/common";
+import { ProfileEntity } from "./profile.entity";
+
+@Injectable()
+@Policy(ProfileEntity)
+export class ProfilePolicy extends UserOwnedResourcePolicy {}
diff --git a/packages/core/src/modules/profile/profile.service.ts b/packages/core/src/modules/profile/profile.service.ts
new file mode 100644
index 0000000..c5b6c5e
--- /dev/null
+++ b/packages/core/src/modules/profile/profile.service.ts
@@ -0,0 +1,219 @@
+import type { User } from "../auth";
+import { TokenEncryptionService } from "../auth";
+import { PrismaService } from "../database";
+import { Injectable, NotFoundException } from "@nestjs/common";
+
+interface CreateProfileInput {
+ name: string;
+ fullName?: string;
+ headline?: string;
+ phone?: string;
+ address?: string;
+ postalCode?: string;
+ locationId?: string;
+ website?: string;
+ linkedInUrl?: string;
+ summary?: string;
+}
+
+interface UpdateProfileInput {
+ name?: string;
+ fullName?: string;
+ headline?: string;
+ phone?: string;
+ address?: string;
+ postalCode?: string;
+ locationId?: string;
+ website?: string;
+ linkedInUrl?: string;
+ summary?: string;
+}
+
+export interface DecryptedProfile {
+ id: string;
+ userId: string;
+ name: string;
+ fullName: string | null;
+ headline: string | null;
+ phone: string | null;
+ address: string | null;
+ postalCode: string | null;
+ locationId: string | null;
+ website: string | null;
+ linkedInUrl: string | null;
+ summary: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+@Injectable()
+export class ProfileService {
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly encryption: TokenEncryptionService,
+ ) {}
+
+ async createProfile(
+ userId: string,
+ data: CreateProfileInput,
+ ): Promise {
+ const encrypted = this.encryptFields(data);
+
+ const profile = await this.prisma.profile.create({
+ data: {
+ userId,
+ name: data.name,
+ ...encrypted,
+ },
+ });
+
+ return this.decrypt(profile);
+ }
+
+ async getProfilesForUser(userId: string): Promise {
+ const profiles = await this.prisma.profile.findMany({
+ where: { userId },
+ orderBy: { createdAt: "asc" },
+ });
+
+ return profiles.map((p) => this.decrypt(p));
+ }
+
+ async findByIdOrFail(profileId: string): Promise {
+ const profile = await this.prisma.profile.findUnique({
+ where: { id: profileId },
+ });
+
+ if (!profile) throw new NotFoundException("Profile not found");
+
+ return this.decrypt(profile);
+ }
+
+ async findByIdAndUserOrFail(
+ profileId: string,
+ userId: string,
+ ): Promise