├── VERSION ├── frontend ├── src │ ├── react-app-env.d.ts │ ├── assets │ │ └── lti-logo.png │ ├── index.css │ ├── reportWebVitals.ts │ ├── index.tsx │ ├── App.css │ ├── services │ │ ├── candidateService.js │ │ └── positionService.js │ ├── App.js │ ├── components │ │ ├── CandidateCard.js │ │ ├── RecruiterDashboard.js │ │ ├── StageColumn.js │ │ ├── FileUploader.js │ │ ├── Positions.tsx │ │ ├── PositionDetails.js │ │ └── CandidateDetails.js │ └── logo.svg ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── logo192.png │ ├── manifest.json │ └── index.html ├── cypress │ ├── screenshots │ │ └── candidates.cy.ts │ │ │ ├── Candidates API -- GET candidates -- should handle invalid limit number (failed).png │ │ │ ├── Candidates API -- GET candidates -- should handle invalid page number (failed).png │ │ │ ├── Candidates API -- GET candidates -- should handle pagination correctly (failed).png │ │ │ ├── Candidates API -- GET candidates -- should filter candidates by search term (failed).png │ │ │ ├── Candidates API -- GET candidates -- should sort candidates by specified field (failed).png │ │ │ ├── Candidates API -- GET candidates -- should return a list of candidates successfully (failed).png │ │ │ └── Candidates API -- GET candidates -- should return empty array when no candidates match filters (failed).png │ ├── tsconfig.json │ └── e2e │ │ ├── candidates.cy.ts │ │ └── positions.cy.ts ├── cypress.config.ts ├── tsconfig.json ├── package.json └── README.md ├── backend ├── .prettierrc ├── .eslintrc.js ├── prisma │ ├── migrations │ │ ├── migration_lock.toml │ │ ├── 20240528140846_ │ │ │ └── migration.sql │ │ ├── 20240528110522_ │ │ │ └── migration.sql │ │ ├── 20240528082702_ │ │ │ └── migration.sql │ │ └── 20240528085016_ │ │ │ └── migration.sql │ ├── getTopCandidates.js │ └── schema.prisma ├── .env ├── src │ ├── infrastructure │ │ └── logger.ts │ ├── routes │ │ ├── positionRoutes.ts │ │ └── candidateRoutes.ts │ ├── domain │ │ ├── repositories │ │ │ ├── index.ts │ │ │ ├── ICompanyRepository.ts │ │ │ ├── IInterviewFlowRepository.ts │ │ │ ├── IBaseRepository.ts │ │ │ ├── ICandidateRepository.ts │ │ │ ├── IPositionRepository.ts │ │ │ ├── IApplicationRepository.ts │ │ │ └── IInterviewRepository.ts │ │ └── models │ │ │ ├── Company.ts │ │ │ ├── InterviewFlow.ts │ │ │ ├── Resume.ts │ │ │ ├── InterviewType.ts │ │ │ ├── Employee.ts │ │ │ ├── Education.ts │ │ │ ├── InterviewStep.ts │ │ │ ├── WorkExperience.ts │ │ │ ├── Interview.ts │ │ │ ├── Application.ts │ │ │ ├── Position.ts │ │ │ └── Candidate.ts │ ├── lambda.ts │ ├── application │ │ ├── services │ │ │ ├── candidateService.test.ts │ │ │ ├── positionService.test.ts │ │ │ ├── fileUploadService.ts │ │ │ ├── positionService.ts │ │ │ └── candidateService.ts │ │ └── validator.ts │ ├── presentation │ │ └── controllers │ │ │ ├── candidateController.test.ts │ │ │ ├── candidateController.ts │ │ │ └── positionController.ts │ └── index.ts ├── tsconfig.json ├── jest.config.js ├── serverless.yml └── package.json ├── .cursorignore ├── .env ├── docker-compose.yml ├── package.json ├── .gitignore ├── LICENSE.md ├── .cursor └── rules │ ├── LTI.mdc │ └── memory-bank-rules.mdc ├── memory-bank ├── projectbrief.md ├── activeContext.md ├── productContext.md ├── techContext.md ├── systemPatterns.md └── progress.md └── documentation └── prompts.md /VERSION: -------------------------------------------------------------------------------- 1 | 0.0.0.001 2 | -------------------------------------------------------------------------------- /frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['plugin:prettier/recommended'], 3 | }; -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/540/AI4Devs-LTI-extended/main/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/540/AI4Devs-LTI-extended/main/frontend/public/logo192.png -------------------------------------------------------------------------------- /frontend/src/assets/lti-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/540/AI4Devs-LTI-extended/main/frontend/src/assets/lti-logo.png -------------------------------------------------------------------------------- /.cursorignore: -------------------------------------------------------------------------------- 1 | # Memory Bank - Exclude from Cursor indexing 2 | @memory-bank-rules.mdc 3 | @memory-bank/ 4 | # @backend/documentation/ 5 | # @ats-readme.md 6 | # @README.md -------------------------------------------------------------------------------- /backend/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | DB_PASSWORD=D1ymf8wyQEGthFR1E9xhCq 2 | DB_USER=LTIdbUser 3 | DB_NAME=LTIdb 4 | DB_PORT=5432 5 | DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_NAME}" 6 | 7 | -------------------------------------------------------------------------------- /backend/.env: -------------------------------------------------------------------------------- 1 | DB_PASSWORD=D1ymf8wyQEGthFR1E9xhCq 2 | DB_USER=LTIdbUser 3 | DB_NAME=LTIdb 4 | DB_PORT=5432 5 | DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_NAME}" 6 | 7 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20240528140846_/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `status` on the `Application` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "Application" DROP COLUMN "status"; 9 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.1" 2 | 3 | services: 4 | db: 5 | image: postgres 6 | restart: always 7 | environment: 8 | POSTGRES_PASSWORD: ${DB_PASSWORD} 9 | POSTGRES_USER: ${DB_USER} 10 | POSTGRES_DB: ${DB_NAME} 11 | ports: 12 | - ${DB_PORT}:5432 13 | -------------------------------------------------------------------------------- /frontend/cypress/screenshots/candidates.cy.ts/Candidates API -- GET candidates -- should handle invalid limit number (failed).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/540/AI4Devs-LTI-extended/main/frontend/cypress/screenshots/candidates.cy.ts/Candidates API -- GET candidates -- should handle invalid limit number (failed).png -------------------------------------------------------------------------------- /frontend/cypress/screenshots/candidates.cy.ts/Candidates API -- GET candidates -- should handle invalid page number (failed).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/540/AI4Devs-LTI-extended/main/frontend/cypress/screenshots/candidates.cy.ts/Candidates API -- GET candidates -- should handle invalid page number (failed).png -------------------------------------------------------------------------------- /frontend/cypress/screenshots/candidates.cy.ts/Candidates API -- GET candidates -- should handle pagination correctly (failed).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/540/AI4Devs-LTI-extended/main/frontend/cypress/screenshots/candidates.cy.ts/Candidates API -- GET candidates -- should handle pagination correctly (failed).png -------------------------------------------------------------------------------- /backend/prisma/migrations/20240528110522_/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `status` to the `Application` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "Application" ADD COLUMN "status" TEXT NOT NULL; 9 | -------------------------------------------------------------------------------- /frontend/cypress/screenshots/candidates.cy.ts/Candidates API -- GET candidates -- should filter candidates by search term (failed).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/540/AI4Devs-LTI-extended/main/frontend/cypress/screenshots/candidates.cy.ts/Candidates API -- GET candidates -- should filter candidates by search term (failed).png -------------------------------------------------------------------------------- /frontend/cypress/screenshots/candidates.cy.ts/Candidates API -- GET candidates -- should sort candidates by specified field (failed).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/540/AI4Devs-LTI-extended/main/frontend/cypress/screenshots/candidates.cy.ts/Candidates API -- GET candidates -- should sort candidates by specified field (failed).png -------------------------------------------------------------------------------- /frontend/cypress/screenshots/candidates.cy.ts/Candidates API -- GET candidates -- should return a list of candidates successfully (failed).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/540/AI4Devs-LTI-extended/main/frontend/cypress/screenshots/candidates.cy.ts/Candidates API -- GET candidates -- should return a list of candidates successfully (failed).png -------------------------------------------------------------------------------- /frontend/cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "isolatedModules": false, 5 | "types": ["cypress", "node"], 6 | "target": "es5", 7 | "lib": ["es5", "dom"], 8 | "baseUrl": ".." 9 | }, 10 | "include": [ 11 | "**/*.ts" 12 | ] 13 | } -------------------------------------------------------------------------------- /frontend/cypress/screenshots/candidates.cy.ts/Candidates API -- GET candidates -- should return empty array when no candidates match filters (failed).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/540/AI4Devs-LTI-extended/main/frontend/cypress/screenshots/candidates.cy.ts/Candidates API -- GET candidates -- should return empty array when no candidates match filters (failed).png -------------------------------------------------------------------------------- /frontend/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress' 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | baseUrl: 'http://localhost:3000', // URL de tu frontend 6 | supportFile: false, 7 | specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}', 8 | env: { 9 | API_URL: 'http://localhost:3010' // URL de tu backend API 10 | } 11 | }, 12 | }) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@types/aws-lambda": "^8.10.149", 4 | "@types/axios": "^0.9.36", 5 | "aws-lambda": "^1.0.7", 6 | "axios": "^1.9.0", 7 | "dotenv": "^16.4.5", 8 | "serverless-http": "^3.2.0" 9 | }, 10 | "prisma": { 11 | "schema": "backend/prisma/schema.prisma" 12 | }, 13 | "devDependencies": { 14 | "@types/cypress": "^0.1.6" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore in all directories 2 | **/.DS_Store 3 | #**/.env 4 | **/.env.example 5 | **/node_modules 6 | **/prompts_master.md 7 | 8 | # other dependencies 9 | **/.pnp 10 | **.pnp.js 11 | 12 | # testing 13 | **/coverage 14 | 15 | # production 16 | **/build 17 | **/dist 18 | 19 | # logs 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | # IDE 25 | .idea 26 | .vscode 27 | 28 | # .env -------------------------------------------------------------------------------- /backend/src/infrastructure/logger.ts: -------------------------------------------------------------------------------- 1 | export class Logger { 2 | static info(message: string, ...args: any[]) { 3 | console.log(`[INFO] ${message}`, ...args); 4 | } 5 | 6 | static error(message: string, ...args: any[]) { 7 | console.error(`[ERROR] ${message}`, ...args); 8 | } 9 | 10 | static warn(message: string, ...args: any[]) { 11 | console.warn(`[WARN] ${message}`, ...args); 12 | } 13 | } -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "sourceMap": true, 14 | "types": ["jest", "node"] 15 | }, 16 | "include": ["src/**/*"], 17 | "exclude": ["node_modules", "**/*.test.ts"] 18 | } -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | const root = ReactDOM.createRoot( 8 | document.getElementById('root') as HTMLElement 9 | ); 10 | root.render( 11 | 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /backend/src/routes/positionRoutes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { getCandidatesByPosition, getInterviewFlowByPosition, getAllPositions, getCandidateNamesByPosition, updatePosition, getPositionById } from '../presentation/controllers/positionController'; 3 | 4 | const router = Router(); 5 | 6 | router.get('/', getAllPositions); 7 | router.get('/:id', getPositionById); 8 | router.get('/:id/candidates', getCandidatesByPosition); 9 | router.get('/:id/candidates/names', getCandidateNamesByPosition); 10 | router.get('/:id/interviewflow', getInterviewFlowByPosition); 11 | router.put('/:id', updatePosition); 12 | 13 | export default router; 14 | -------------------------------------------------------------------------------- /backend/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | collectCoverageFrom: [ 5 | 'src/**/*.{ts,tsx}', 6 | '!src/**/*.d.ts', 7 | '!src/**/*.test.{ts,tsx}' 8 | ], 9 | coverageThreshold: { 10 | global: { 11 | branches: 90, 12 | functions: 90, 13 | lines: 90, 14 | statements: 90 15 | } 16 | }, 17 | testMatch: [ 18 | '/src/**/__tests__/**/*.{ts,tsx}', 19 | '/src/**/*.{test,spec}.{ts,tsx}' 20 | ], 21 | globals: { 22 | 'ts-jest': { 23 | useESM: false 24 | } 25 | } 26 | }; -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx", 22 | "baseUrl": ".", 23 | "paths": { 24 | "@/*": ["src/*"] 25 | }, 26 | "types": ["cypress", "node"] 27 | }, 28 | "include": [ 29 | "src", 30 | "cypress/**/*.ts" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /backend/src/domain/repositories/index.ts: -------------------------------------------------------------------------------- 1 | // Interfaces base 2 | export { IBaseRepository } from './IBaseRepository'; 3 | 4 | // Interfaces específicas de repositorios 5 | export { ICandidateRepository, CandidateSearchCriteria, PaginatedCandidates } from './ICandidateRepository'; 6 | export { IPositionRepository, PositionStatus, PositionSearchCriteria } from './IPositionRepository'; 7 | export { IApplicationRepository, CandidateInfo, ApplicationSummary } from './IApplicationRepository'; 8 | export { 9 | IInterviewRepository, 10 | InterviewSearchCriteria, 11 | InterviewStatistics, 12 | InterviewSummary 13 | } from './IInterviewRepository'; 14 | export { ICompanyRepository } from './ICompanyRepository'; 15 | export { IInterviewFlowRepository } from './IInterviewFlowRepository'; 16 | 17 | // Re-exportar tipos útiles 18 | export type RepositoryId = number; 19 | export type RepositoryEntity = any; // Será reemplazado por tipos específicos del dominio -------------------------------------------------------------------------------- /backend/serverless.yml: -------------------------------------------------------------------------------- 1 | service: lti-interviews-api 2 | 3 | provider: 4 | name: aws 5 | runtime: nodejs18.x 6 | region: us-east-1 7 | environment: 8 | DATABASE_URL: ${env:DATABASE_URL} 9 | NODE_ENV: ${env:NODE_ENV} 10 | 11 | functions: 12 | api: 13 | handler: dist/lambda.handler 14 | events: 15 | - http: 16 | path: /{proxy+} 17 | method: any 18 | cors: true 19 | environment: 20 | DATABASE_URL: ${env:DATABASE_URL} 21 | NODE_ENV: ${env:NODE_ENV} 22 | vpc: 23 | securityGroupIds: 24 | - sg-xxxxxxxxxxxxxxxxx # Reemplazar con tu Security Group ID 25 | subnetIds: 26 | - subnet-xxxxxxxxxxxxxxxxx # Reemplazar con tu Subnet ID 27 | timeout: 30 28 | memorySize: 256 29 | 30 | plugins: 31 | - serverless-offline 32 | - serverless-dotenv-plugin 33 | 34 | custom: 35 | dotenv: 36 | path: ./.env 37 | include: 38 | - DATABASE_URL 39 | - NODE_ENV -------------------------------------------------------------------------------- /frontend/src/services/candidateService.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | export const uploadCV = async (file) => { 4 | const formData = new FormData(); 5 | formData.append('file', file); 6 | 7 | try { 8 | const response = await axios.post('http://localhost:3010/upload', formData, { 9 | headers: { 10 | 'Content-Type': 'multipart/form-data', 11 | }, 12 | }); 13 | return response.data; // Devuelve la ruta del archivo y el tipo 14 | } catch (error) { 15 | throw new Error('Error al subir el archivo:', error.response.data); 16 | } 17 | }; 18 | 19 | export const sendCandidateData = async (candidateData) => { 20 | try { 21 | const response = await axios.post('http://localhost:3010/candidates', candidateData); 22 | return response.data; 23 | } catch (error) { 24 | throw new Error('Error al enviar datos del candidato:', error.response.data); 25 | } 26 | }; -------------------------------------------------------------------------------- /frontend/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import 'bootstrap/dist/css/bootstrap.min.css'; 3 | import { BrowserRouter, Routes, Route } from 'react-router-dom'; 4 | import RecruiterDashboard from './components/RecruiterDashboard'; 5 | import AddCandidate from './components/AddCandidateForm'; 6 | import Positions from './components/Positions'; 7 | import PositionDetails from './components/PositionDetails'; 8 | import EditPosition from './components/EditPosition'; 9 | 10 | const App = () => { 11 | return ( 12 | 13 | 14 | } /> 15 | } /> 16 | } /> 17 | } /> 18 | } /> 19 | 20 | 21 | ); 22 | }; 23 | 24 | export default App; -------------------------------------------------------------------------------- /backend/src/routes/candidateRoutes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { addCandidate, getCandidateById, updateCandidateStageController, getAllCandidatesController } from '../presentation/controllers/candidateController'; 3 | 4 | const router = Router(); 5 | 6 | // GET /candidates - Obtener todos los candidatos 7 | router.get('/', getAllCandidatesController); 8 | 9 | router.post('/', async (req, res) => { 10 | try { 11 | // console.log(req.body); //Just in case you want to inspect the request body 12 | const result = await addCandidate(req.body); 13 | res.status(201).send(result); 14 | } catch (error) { 15 | if (error instanceof Error) { 16 | res.status(400).send({ message: error.message }); 17 | } else { 18 | res.status(500).send({ message: "An unexpected error occurred" }); 19 | } 20 | } 21 | }); 22 | 23 | router.get('/:id', getCandidateById); 24 | 25 | router.put('/:id', updateCandidateStageController); 26 | 27 | export default router; 28 | -------------------------------------------------------------------------------- /backend/src/domain/models/Company.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | const prisma = new PrismaClient(); 4 | 5 | export class Company { 6 | id?: number; 7 | name: string; 8 | 9 | constructor(data: any) { 10 | this.id = data.id; 11 | this.name = data.name; 12 | } 13 | 14 | async save() { 15 | const companyData: any = { 16 | name: this.name, 17 | }; 18 | 19 | if (this.id) { 20 | return await prisma.company.update({ 21 | where: { id: this.id }, 22 | data: companyData, 23 | }); 24 | } else { 25 | return await prisma.company.create({ 26 | data: companyData, 27 | }); 28 | } 29 | } 30 | 31 | static async findOne(id: number): Promise { 32 | const data = await prisma.company.findUnique({ 33 | where: { id: id }, 34 | }); 35 | if (!data) return null; 36 | return new Company(data); 37 | } 38 | } 39 | 40 | -------------------------------------------------------------------------------- /backend/src/lambda.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; 2 | import serverless from 'serverless-http'; 3 | import { app } from './index'; 4 | 5 | // Crear el handler de serverless-http 6 | const serverlessHandler = serverless(app); 7 | 8 | // Handler principal de Lambda 9 | export const handler = async ( 10 | event: APIGatewayProxyEvent, 11 | context: Context 12 | ): Promise => { 13 | // Asegurarse de que el contexto de Lambda se mantenga vivo 14 | context.callbackWaitsForEmptyEventLoop = false; 15 | 16 | try { 17 | // Procesar la solicitud usando serverless-http 18 | const result = await serverlessHandler(event, context) as APIGatewayProxyResult; 19 | return result; 20 | } catch (error) { 21 | console.error('Error en el handler de Lambda:', error); 22 | return { 23 | statusCode: 500, 24 | body: JSON.stringify({ 25 | message: 'Error interno del servidor', 26 | error: error instanceof Error ? error.message : 'Unknown error' 27 | }) 28 | }; 29 | } 30 | }; -------------------------------------------------------------------------------- /frontend/src/components/CandidateCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Card } from 'react-bootstrap'; 3 | import { Draggable } from 'react-beautiful-dnd'; 4 | 5 | const CandidateCard = ({ candidate, index, onClick }) => ( 6 | 7 | {(provided) => ( 8 | onClick(candidate)} 14 | > 15 | 16 | {candidate.name} 17 |
18 | {Array.from({ length: candidate.rating }).map((_, i) => ( 19 | 🟢 20 | ))} 21 |
22 |
23 |
24 | )} 25 |
26 | ); 27 | 28 | export default CandidateCard; 29 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 LiDR AI4Devs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /backend/src/domain/models/InterviewFlow.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | const prisma = new PrismaClient(); 4 | 5 | export class InterviewFlow { 6 | id?: number; 7 | description?: string; 8 | 9 | constructor(data: any) { 10 | this.id = data.id; 11 | this.description = data.description; 12 | } 13 | 14 | async save() { 15 | const interviewFlowData: any = { 16 | description: this.description, 17 | }; 18 | 19 | if (this.id) { 20 | return await prisma.interviewFlow.update({ 21 | where: { id: this.id }, 22 | data: interviewFlowData, 23 | }); 24 | } else { 25 | return await prisma.interviewFlow.create({ 26 | data: interviewFlowData, 27 | }); 28 | } 29 | } 30 | 31 | static async findOne(id: number): Promise { 32 | const data = await prisma.interviewFlow.findUnique({ 33 | where: { id: id }, 34 | }); 35 | if (!data) return null; 36 | return new InterviewFlow(data); 37 | } 38 | } 39 | 40 | -------------------------------------------------------------------------------- /frontend/src/services/positionService.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const API_BASE_URL = 'http://localhost:3010'; 4 | 5 | export const positionService = { 6 | // Get all positions 7 | getAllPositions: async () => { 8 | try { 9 | const response = await axios.get(`${API_BASE_URL}/positions`); 10 | return response.data; 11 | } catch (error) { 12 | console.error('Error fetching positions:', error); 13 | throw error; 14 | } 15 | }, 16 | 17 | // Get position by ID 18 | getPositionById: async (id) => { 19 | try { 20 | const response = await axios.get(`${API_BASE_URL}/positions/${id}`); 21 | return response.data; 22 | } catch (error) { 23 | console.error('Error fetching position:', error); 24 | throw error; 25 | } 26 | }, 27 | 28 | // Update position 29 | updatePosition: async (id, positionData) => { 30 | try { 31 | const response = await axios.put(`${API_BASE_URL}/positions/${id}`, positionData); 32 | return response.data; 33 | } catch (error) { 34 | console.error('Error updating position:', error); 35 | throw error; 36 | } 37 | } 38 | }; -------------------------------------------------------------------------------- /backend/src/domain/models/Resume.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | const prisma = new PrismaClient(); 4 | 5 | export class Resume { 6 | id: number; 7 | candidateId: number; 8 | filePath: string; 9 | fileType: string; 10 | uploadDate: Date; 11 | 12 | constructor(data: any) { 13 | this.id = data?.id; 14 | this.candidateId = data?.candidateId; 15 | this.filePath = data?.filePath; 16 | this.fileType = data?.fileType; 17 | this.uploadDate = new Date(); 18 | } 19 | 20 | async save(): Promise { 21 | if (!this.id) { 22 | return await this.create(); 23 | } 24 | throw new Error('No se permite la actualización de un currículum existente.'); 25 | } 26 | 27 | async create(): Promise { 28 | console.log(this); 29 | 30 | const createdResume = await prisma.resume.create({ 31 | data: { 32 | candidateId: this.candidateId, 33 | filePath: this.filePath, 34 | fileType: this.fileType, 35 | uploadDate: this.uploadDate 36 | }, 37 | }); 38 | return new Resume(createdResume); 39 | } 40 | } -------------------------------------------------------------------------------- /backend/src/domain/models/InterviewType.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | const prisma = new PrismaClient(); 4 | 5 | export class InterviewType { 6 | id?: number; 7 | name: string; 8 | description?: string; 9 | 10 | constructor(data: any) { 11 | this.id = data.id; 12 | this.name = data.name; 13 | this.description = data.description; 14 | } 15 | 16 | async save() { 17 | const interviewTypeData: any = { 18 | name: this.name, 19 | description: this.description, 20 | }; 21 | 22 | if (this.id) { 23 | return await prisma.interviewType.update({ 24 | where: { id: this.id }, 25 | data: interviewTypeData, 26 | }); 27 | } else { 28 | return await prisma.interviewType.create({ 29 | data: interviewTypeData, 30 | }); 31 | } 32 | } 33 | 34 | static async findOne(id: number): Promise { 35 | const data = await prisma.interviewType.findUnique({ 36 | where: { id: id }, 37 | }); 38 | if (!data) return null; 39 | return new InterviewType(data); 40 | } 41 | } 42 | 43 | -------------------------------------------------------------------------------- /backend/src/application/services/candidateService.test.ts: -------------------------------------------------------------------------------- 1 | import { updateCandidateStage } from './candidateService'; 2 | import { PrismaClient } from '@prisma/client'; 3 | 4 | const prisma = new PrismaClient(); 5 | 6 | jest.mock('@prisma/client', () => { 7 | const mockPrisma = { 8 | application: { 9 | findFirst: jest.fn(), 10 | update: jest.fn(), 11 | }, 12 | }; 13 | return { PrismaClient: jest.fn(() => mockPrisma) }; 14 | }); 15 | 16 | describe('updateCandidateStage', () => { 17 | it('should update the candidate stage and return the updated application', async () => { 18 | const mockApplication = { 19 | id: 1, 20 | positionId: 1, 21 | candidateId: 1, 22 | currentInterviewStep: 1, 23 | applicationDate: new Date(), 24 | notes: null, 25 | }; 26 | 27 | jest.spyOn(prisma.application, 'findFirst').mockResolvedValue(mockApplication); 28 | jest.spyOn(prisma.application, 'update').mockResolvedValue({ 29 | ...mockApplication, 30 | currentInterviewStep: 2, 31 | }); 32 | 33 | const result = await updateCandidateStage(1, 1, 2); 34 | expect(result).toEqual(expect.objectContaining({ 35 | ...mockApplication, 36 | currentInterviewStep: 2, 37 | })); 38 | }); 39 | }); -------------------------------------------------------------------------------- /backend/src/presentation/controllers/candidateController.test.ts: -------------------------------------------------------------------------------- 1 | import { updateCandidateStageController } from './candidateController'; 2 | import { Request, Response } from 'express'; 3 | import { updateCandidateStage } from '../../application/services/candidateService'; 4 | 5 | jest.mock('../../application/services/candidateService'); 6 | 7 | describe('updateCandidateStageController', () => { 8 | it('should return 200 and updated candidate stage', async () => { 9 | const req = { params: { id: '1' }, body: { applicationId: 1, currentInterviewStep: 2 } } as unknown as Request; 10 | const res = { 11 | status: jest.fn().mockReturnThis(), 12 | json: jest.fn(), 13 | } as unknown as Response; 14 | 15 | (updateCandidateStage as jest.Mock).mockResolvedValue({ 16 | id: 1, 17 | applicationId: 1, 18 | candidateId: 1, 19 | currentInterviewStep: 2, 20 | }); 21 | 22 | await updateCandidateStageController(req, res); 23 | 24 | expect(res.status).toHaveBeenCalledWith(200); 25 | expect(res.json).toHaveBeenCalledWith({ 26 | message: 'Candidate stage updated successfully', 27 | data: { 28 | id: 1, 29 | applicationId: 1, 30 | candidateId: 1, 31 | currentInterviewStep: 2, 32 | }, 33 | }); 34 | }); 35 | }); -------------------------------------------------------------------------------- /backend/src/application/services/positionService.test.ts: -------------------------------------------------------------------------------- 1 | import { getCandidatesByPositionService } from './positionService'; 2 | import { PrismaClient } from '@prisma/client'; 3 | 4 | const prisma = new PrismaClient(); 5 | 6 | jest.mock('@prisma/client', () => { 7 | const mockPrisma = { 8 | application: { 9 | findMany: jest.fn(), 10 | }, 11 | }; 12 | return { PrismaClient: jest.fn(() => mockPrisma) }; 13 | }); 14 | 15 | describe('getCandidatesByPositionService', () => { 16 | it('should return candidates with their average scores', async () => { 17 | const mockApplications = [ 18 | { 19 | id: 1, 20 | positionId: 1, 21 | candidateId: 1, 22 | applicationDate: new Date(), 23 | currentInterviewStep: 1, 24 | notes: null, 25 | candidate: { firstName: 'John', lastName: 'Doe' }, 26 | interviewStep: { name: 'Technical Interview' }, 27 | interviews: [{ score: 5 }, { score: 3 }], 28 | }, 29 | ]; 30 | 31 | jest.spyOn(prisma.application, 'findMany').mockResolvedValue(mockApplications); 32 | 33 | const result = await getCandidatesByPositionService(1); 34 | expect(result).toEqual([ 35 | { 36 | fullName: 'John Doe', 37 | currentInterviewStep: 'Technical Interview', 38 | averageScore: 4, 39 | }, 40 | ]); 41 | }); 42 | }); 43 | 44 | -------------------------------------------------------------------------------- /backend/src/domain/models/Employee.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | const prisma = new PrismaClient(); 4 | 5 | export class Employee { 6 | id?: number; 7 | companyId: number; 8 | name: string; 9 | email: string; 10 | role: string; 11 | isActive: boolean; 12 | 13 | constructor(data: any) { 14 | this.id = data.id; 15 | this.companyId = data.companyId; 16 | this.name = data.name; 17 | this.email = data.email; 18 | this.role = data.role; 19 | this.isActive = data.isActive ?? true; 20 | } 21 | 22 | async save() { 23 | const employeeData: any = { 24 | companyId: this.companyId, 25 | name: this.name, 26 | email: this.email, 27 | role: this.role, 28 | isActive: this.isActive, 29 | }; 30 | 31 | if (this.id) { 32 | return await prisma.employee.update({ 33 | where: { id: this.id }, 34 | data: employeeData, 35 | }); 36 | } else { 37 | return await prisma.employee.create({ 38 | data: employeeData, 39 | }); 40 | } 41 | } 42 | 43 | static async findOne(id: number): Promise { 44 | const data = await prisma.employee.findUnique({ 45 | where: { id: id }, 46 | }); 47 | if (!data) return null; 48 | return new Employee(data); 49 | } 50 | } 51 | 52 | -------------------------------------------------------------------------------- /backend/src/domain/models/Education.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | const prisma = new PrismaClient(); 4 | 5 | export class Education { 6 | id?: number; 7 | institution: string; 8 | title: string; 9 | startDate: Date; 10 | endDate?: Date; 11 | candidateId?: number; 12 | 13 | constructor(data: any) { 14 | this.id = data.id; 15 | this.institution = data.institution; 16 | this.title = data.title; 17 | this.startDate = new Date(data.startDate); 18 | this.endDate = data.endDate ? new Date(data.endDate) : undefined; 19 | this.candidateId = data.candidateId; 20 | } 21 | 22 | async save() { 23 | const educationData: any = { 24 | institution: this.institution, 25 | title: this.title, 26 | startDate: this.startDate, 27 | endDate: this.endDate, 28 | }; 29 | 30 | if (this.candidateId !== undefined) { 31 | educationData.candidateId = this.candidateId; 32 | } 33 | 34 | if (this.id) { 35 | // Actualizar una experiencia laboral existente 36 | return await prisma.education.update({ 37 | where: { id: this.id }, 38 | data: educationData 39 | }); 40 | } else { 41 | // Crear una nueva experiencia laboral 42 | return await prisma.education.create({ 43 | data: educationData 44 | }); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /backend/src/domain/models/InterviewStep.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | const prisma = new PrismaClient(); 4 | 5 | export class InterviewStep { 6 | id?: number; 7 | interviewFlowId: number; 8 | interviewTypeId: number; 9 | name: string; 10 | orderIndex: number; 11 | 12 | constructor(data: any) { 13 | this.id = data.id; 14 | this.interviewFlowId = data.interviewFlowId; 15 | this.interviewTypeId = data.interviewTypeId; 16 | this.name = data.name; 17 | this.orderIndex = data.orderIndex; 18 | } 19 | 20 | async save() { 21 | const interviewStepData: any = { 22 | interviewFlowId: this.interviewFlowId, 23 | interviewTypeId: this.interviewTypeId, 24 | name: this.name, 25 | orderIndex: this.orderIndex, 26 | }; 27 | 28 | if (this.id) { 29 | return await prisma.interviewStep.update({ 30 | where: { id: this.id }, 31 | data: interviewStepData, 32 | }); 33 | } else { 34 | return await prisma.interviewStep.create({ 35 | data: interviewStepData, 36 | }); 37 | } 38 | } 39 | 40 | static async findOne(id: number): Promise { 41 | const data = await prisma.interviewStep.findUnique({ 42 | where: { id: id }, 43 | }); 44 | if (!data) return null; 45 | return new InterviewStep(data); 46 | } 47 | } 48 | 49 | -------------------------------------------------------------------------------- /.cursor/rules/LTI.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | # Rules and Patterns for LTI Project 7 | 8 | ## Documentation 9 | When writing technical documentation, such as the data model, unit tests, README, API specs, other MD docs, etc., ALWAYS WRITE IN ENGLISH, including comments and any explanation in the files. This applies both to creating new documentation and updating existing one, and it also applies to documentation within the code (comments, explanations of functions or fields, etc.). 10 | Before making any commit or git push, or if you're asked to document a commit, you must ALWAYS review which technical documentation should be updated. This can also happen on demand using the /updatedocs command. 11 | 12 | ### Documentation Updates 13 | When updating documentation, I will: 14 | 1. Review all recent changes in the codebase 15 | 2. Identify which documentation files need updates based on the changes. Some clear examples: 16 | - For data model changes: Update data model definition section in datamodel.md 17 | - For API changes: Update api-docs.yml 18 | - For changes in libraries, database migrations, or anything that changes the installation process, update README.md 19 | 3. Update each affected documentation file in English, maintaining consistency with existing documentation 20 | 4. Ensure all documentation is properly formatted and follows the established structure 21 | 5. Verify that all changes are accurately reflected in the documentation 22 | 6. Report which files were updated and what changes were made 23 | -------------------------------------------------------------------------------- /frontend/src/components/RecruiterDashboard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button, Card, Container, Row, Col } from 'react-bootstrap'; 3 | import { Link } from 'react-router-dom'; 4 | import logo from '../assets/lti-logo.png'; // Ruta actualizada para importar desde src/assets 5 | 6 | const RecruiterDashboard = () => { 7 | return ( 8 | 9 |
{/* Contenedor para el logo */} 10 | LTI Logo 11 |
12 |

Dashboard del Reclutador

13 | 14 | 15 | 16 |
Añadir Candidato
17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 |
Ver Posiciones
25 | 26 | 27 | 28 |
29 | 30 |
31 |
32 | ); 33 | }; 34 | 35 | export default RecruiterDashboard; -------------------------------------------------------------------------------- /backend/src/domain/models/WorkExperience.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | const prisma = new PrismaClient(); 4 | 5 | export class WorkExperience { 6 | id?: number; 7 | company: string; 8 | position: string; 9 | description?: string; 10 | startDate: Date; 11 | endDate?: Date; 12 | candidateId?: number; 13 | 14 | constructor(data: any) { 15 | this.id = data.id; 16 | this.company = data.company; 17 | this.position = data.position; 18 | this.description = data.description; 19 | this.startDate = new Date(data.startDate); 20 | this.endDate = data.endDate ? new Date(data.endDate) : undefined; 21 | this.candidateId = data.candidateId; 22 | } 23 | 24 | async save() { 25 | const workExperienceData: any = { 26 | company: this.company, 27 | position: this.position, 28 | description: this.description, 29 | startDate: this.startDate, 30 | endDate: this.endDate 31 | }; 32 | 33 | if (this.candidateId !== undefined) { 34 | workExperienceData.candidateId = this.candidateId; 35 | } 36 | 37 | if (this.id) { 38 | // Actualizar una experiencia laboral existente 39 | return await prisma.workExperience.update({ 40 | where: { id: this.id }, 41 | data: workExperienceData 42 | }); 43 | } else { 44 | // Crear una nueva experiencia laboral 45 | return await prisma.workExperience.create({ 46 | data: workExperienceData 47 | }); 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /frontend/src/components/StageColumn.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Col, Card } from 'react-bootstrap'; 3 | import { Droppable } from 'react-beautiful-dnd'; 4 | import CandidateCard from './CandidateCard'; 5 | 6 | const StageColumn = ({ stage, index, onCardClick }) => { 7 | // Ordenar candidatos por rating (score) de mayor a menor 8 | const sortedCandidates = [...stage.candidates].sort((a, b) => { 9 | // Si alguno de los ratings es undefined o null, tratarlo como 0 10 | const ratingA = a.rating ?? 0; 11 | const ratingB = b.rating ?? 0; 12 | return ratingB - ratingA; 13 | }); 14 | 15 | return ( 16 | 17 | 18 | {(provided) => ( 19 | 20 | {stage.title} 21 | 22 | {sortedCandidates.map((candidate, idx) => ( 23 | 29 | ))} 30 | {provided.placeholder} 31 | 32 | 33 | )} 34 | 35 | 36 | ); 37 | }; 38 | 39 | export default StageColumn; 40 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "dist/lambda.js", 6 | "scripts": { 7 | "start": "node dist/index.js", 8 | "dev": "ts-node-dev --respawn --transpile-only src/index.ts", 9 | "build": "tsc", 10 | "test": "jest", 11 | "prisma:init": "npx prisma init", 12 | "prisma:generate": "npx prisma generate", 13 | "start:prod": "npm run build && npm start", 14 | "build:lambda": "tsc && cd dist && zip -r ../function.zip ." 15 | }, 16 | "keywords": [], 17 | "author": "", 18 | "license": "ISC", 19 | "dependencies": { 20 | "@prisma/client": "^5.13.0", 21 | "cors": "^2.8.5", 22 | "dotenv": "^16.4.5", 23 | "express": "^4.19.2", 24 | "multer": "^1.4.5-lts.1", 25 | "serverless-http": "^3.2.0", 26 | "swagger-jsdoc": "^6.2.8", 27 | "swagger-ui-express": "^5.0.0" 28 | }, 29 | "devDependencies": { 30 | "@types/aws-lambda": "^8.10.136", 31 | "@types/cors": "^2.8.17", 32 | "@types/express": "^4.17.9", 33 | "@types/jest": "^29.5.12", 34 | "@types/multer": "^1.4.11", 35 | "@types/node": "^20.12.12", 36 | "eslint": "^9.2.0", 37 | "eslint-config-prettier": "^9.1.0", 38 | "eslint-plugin-prettier": "^5.1.3", 39 | "jest": "^29.7.0", 40 | "prettier": "^3.2.5", 41 | "prisma": "^5.13.0", 42 | "ts-jest": "^29.1.2", 43 | "ts-node": "^9.1.1", 44 | "ts-node-dev": "^1.1.6", 45 | "typescript": "^4.9.5" 46 | }, 47 | "prisma": { 48 | "seed": "npx tsx prisma/seed.ts" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.17.0", 7 | "@testing-library/react": "^13.4.0", 8 | "@testing-library/user-event": "^13.5.0", 9 | "@types/jest": "^27.5.2", 10 | "@types/node": "^16.18.97", 11 | "@types/react": "^18.3.1", 12 | "@types/react-dom": "^18.3.0", 13 | "bootstrap": "^5.3.3", 14 | "dotenv": "^16.4.5", 15 | "react": "^18.3.1", 16 | "react-beautiful-dnd": "^13.1.1", 17 | "react-bootstrap": "^2.10.2", 18 | "react-bootstrap-icons": "^1.11.4", 19 | "react-datepicker": "^6.9.0", 20 | "react-dnd": "^16.0.1", 21 | "react-dnd-html5-backend": "^16.0.1", 22 | "react-dom": "^18.3.1", 23 | "react-router-dom": "^6.23.1", 24 | "react-scripts": "5.0.1", 25 | "typescript": "^4.9.5", 26 | "web-vitals": "^2.1.4" 27 | }, 28 | "scripts": { 29 | "start": "react-scripts start", 30 | "build": "react-scripts build", 31 | "test": "jest --config jest.config.js", 32 | "eject": "react-scripts eject", 33 | "cypress:open": "cypress open", 34 | "cypress:run": "cypress run", 35 | "cypress:run:headless": "cypress run --headless" 36 | }, 37 | "eslintConfig": { 38 | "extends": [ 39 | "react-app", 40 | "react-app/jest" 41 | ] 42 | }, 43 | "browserslist": { 44 | "production": [ 45 | ">0.2%", 46 | "not dead", 47 | "not op_mini all" 48 | ], 49 | "development": [ 50 | "last 1 chrome version", 51 | "last 1 firefox version", 52 | "last 1 safari version" 53 | ] 54 | }, 55 | "devDependencies": { 56 | "cypress": "^14.4.1" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /backend/src/domain/models/Interview.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | const prisma = new PrismaClient(); 4 | 5 | export class Interview { 6 | id?: number; 7 | applicationId: number; 8 | interviewStepId: number; 9 | employeeId: number; 10 | interviewDate: Date; 11 | result?: string; 12 | score?: number; 13 | notes?: string; 14 | 15 | constructor(data: any) { 16 | this.id = data.id; 17 | this.applicationId = data.applicationId; 18 | this.interviewStepId = data.interviewStepId; 19 | this.employeeId = data.employeeId; 20 | this.interviewDate = new Date(data.interviewDate); 21 | this.result = data.result; 22 | this.score = data.score; 23 | this.notes = data.notes; 24 | } 25 | 26 | async save() { 27 | const interviewData: any = { 28 | applicationId: this.applicationId, 29 | interviewStepId: this.interviewStepId, 30 | employeeId: this.employeeId, 31 | interviewDate: this.interviewDate, 32 | result: this.result, 33 | score: this.score, 34 | notes: this.notes, 35 | }; 36 | 37 | if (this.id) { 38 | return await prisma.interview.update({ 39 | where: { id: this.id }, 40 | data: interviewData, 41 | }); 42 | } else { 43 | return await prisma.interview.create({ 44 | data: interviewData, 45 | }); 46 | } 47 | } 48 | 49 | static async findOne(id: number): Promise { 50 | const data = await prisma.interview.findUnique({ 51 | where: { id: id }, 52 | }); 53 | if (!data) return null; 54 | return new Interview(data); 55 | } 56 | } -------------------------------------------------------------------------------- /backend/src/domain/repositories/ICompanyRepository.ts: -------------------------------------------------------------------------------- 1 | import { IBaseRepository } from './IBaseRepository'; 2 | import { Company } from '../models/Company'; 3 | 4 | /** 5 | * Interface para el repositorio de compañías. 6 | * Define operaciones específicas para la gestión de compañías. 7 | */ 8 | export interface ICompanyRepository extends IBaseRepository { 9 | /** 10 | * Busca una compañía por su nombre 11 | * @param name - Nombre de la compañía 12 | * @returns La compañía encontrada o null si no existe 13 | */ 14 | findByName(name: string): Promise; 15 | 16 | /** 17 | * Verifica si un nombre de compañía ya está en uso 18 | * @param name - Nombre a verificar 19 | * @param excludeId - ID de la compañía a excluir de la verificación (para actualizaciones) 20 | * @returns true si el nombre está en uso, false en caso contrario 21 | */ 22 | isNameInUse(name: string, excludeId?: number): Promise; 23 | 24 | /** 25 | * Busca compañías con empleados activos 26 | * @returns Array de compañías que tienen empleados activos 27 | */ 28 | findWithActiveEmployees(): Promise; 29 | 30 | /** 31 | * Busca compañías con posiciones activas 32 | * @returns Array de compañías que tienen posiciones activas 33 | */ 34 | findWithActivePositions(): Promise; 35 | 36 | /** 37 | * Obtiene estadísticas de una compañía 38 | * @param id - ID de la compañía 39 | * @returns Objeto con estadísticas de la compañía 40 | */ 41 | getStatistics(id: number): Promise<{ 42 | totalEmployees: number; 43 | activeEmployees: number; 44 | totalPositions: number; 45 | activePositions: number; 46 | totalApplications: number; 47 | }>; 48 | } -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /backend/src/application/services/fileUploadService.ts: -------------------------------------------------------------------------------- 1 | import multer from 'multer'; 2 | import { Request, Response } from 'express'; 3 | 4 | const storage = multer.diskStorage({ 5 | destination: function (req, file, cb) { 6 | cb(null, '../uploads/'); 7 | }, 8 | filename: function (req, file, cb) { 9 | const uniqueSuffix = Date.now(); 10 | cb(null, uniqueSuffix + '-' + file.originalname); 11 | } 12 | }); 13 | 14 | const fileFilter = (req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => { 15 | if (file.mimetype === 'application/pdf' || file.mimetype === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') { 16 | cb(null, true); 17 | } else { 18 | cb(null, false); 19 | } 20 | }; 21 | 22 | const upload = multer({ 23 | storage: storage, 24 | limits: { 25 | fileSize: 1024 * 1024 * 10 // 10MB 26 | }, 27 | fileFilter: fileFilter 28 | }); 29 | 30 | export const uploadFile = (req: Request, res: Response) => { 31 | const uploader = upload.single('file'); 32 | uploader(req, res, function (err) { 33 | if (err instanceof multer.MulterError) { 34 | // Manejo de errores específicos de Multer 35 | return res.status(500).json({ error: err.message }); 36 | } else if (err) { 37 | // Otros errores posibles 38 | return res.status(500).json({ error: err.message }); 39 | } 40 | 41 | // Verificar si el archivo fue rechazado por el filtro de archivos 42 | if (!req.file) { 43 | return res.status(400).json({ error: 'Invalid file type, only PDF and DOCX are allowed!' }); 44 | } 45 | // Si todo está bien, proceder a responder con la ruta del archivo y el tipo de archivo 46 | res.status(200).json({ 47 | filePath: req.file.path, 48 | fileType: req.file.mimetype // Aquí se añade el tipo de archivo 49 | }); 50 | }); 51 | }; -------------------------------------------------------------------------------- /backend/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import express from 'express'; 3 | import { PrismaClient } from '@prisma/client'; 4 | import dotenv from 'dotenv'; 5 | import candidateRoutes from './routes/candidateRoutes'; 6 | import positionRoutes from './routes/positionRoutes'; 7 | import { uploadFile } from './application/services/fileUploadService'; 8 | import cors from 'cors'; 9 | 10 | // Extender la interfaz Request para incluir prisma 11 | declare global { 12 | namespace Express { 13 | interface Request { 14 | prisma: PrismaClient; 15 | } 16 | } 17 | } 18 | 19 | dotenv.config(); 20 | const prisma = new PrismaClient(); 21 | 22 | export const app = express(); 23 | export default app; 24 | 25 | // Middleware para parsear JSON. Asegúrate de que esto esté antes de tus rutas. 26 | app.use(express.json()); 27 | 28 | // Middleware para adjuntar prisma al objeto de solicitud 29 | app.use((req, res, next) => { 30 | req.prisma = prisma; 31 | next(); 32 | }); 33 | 34 | // Middleware para permitir CORS desde http://localhost:3000 35 | app.use(cors({ 36 | origin: 'http://localhost:3000', 37 | credentials: true 38 | })); 39 | 40 | // Import and use candidateRoutes 41 | app.use('/candidates', candidateRoutes); 42 | 43 | // Route for file uploads 44 | app.post('/upload', uploadFile); 45 | 46 | // Route to get candidates by position 47 | app.use('/positions', positionRoutes); 48 | 49 | app.use((req, res, next) => { 50 | console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`); 51 | next(); 52 | }); 53 | 54 | const port = 3010; 55 | 56 | app.get('/', (req, res) => { 57 | res.send('Hola LTI!'); 58 | }); 59 | 60 | app.use((err: any, req: Request, res: Response, next: NextFunction) => { 61 | console.error(err.stack); 62 | res.type('text/plain'); 63 | res.status(500).send('Something broke!'); 64 | }); 65 | 66 | app.listen(port, () => { 67 | console.log(`Server is running at http://localhost:${port}`); 68 | }); 69 | -------------------------------------------------------------------------------- /backend/src/domain/repositories/IInterviewFlowRepository.ts: -------------------------------------------------------------------------------- 1 | import { IBaseRepository } from './IBaseRepository'; 2 | import { InterviewFlow } from '../models/InterviewFlow'; 3 | 4 | /** 5 | * Interface para el repositorio de flujos de entrevista. 6 | * Define operaciones específicas para la gestión de flujos de entrevista. 7 | */ 8 | export interface IInterviewFlowRepository extends IBaseRepository { 9 | /** 10 | * Busca un flujo de entrevista con todos sus pasos incluidos 11 | * @param id - ID del flujo de entrevista 12 | * @returns El flujo con sus pasos o null si no existe 13 | */ 14 | findByIdWithSteps(id: number): Promise; 15 | 16 | /** 17 | * Busca flujos de entrevista que tienen pasos configurados 18 | * @returns Array de flujos que tienen al menos un paso 19 | */ 20 | findWithSteps(): Promise; 21 | 22 | /** 23 | * Busca flujos de entrevista utilizados por posiciones activas 24 | * @returns Array de flujos en uso por posiciones activas 25 | */ 26 | findInUseByActivePositions(): Promise; 27 | 28 | /** 29 | * Verifica si un flujo de entrevista está siendo utilizado por alguna posición 30 | * @param id - ID del flujo de entrevista 31 | * @returns true si está en uso, false en caso contrario 32 | */ 33 | isInUse(id: number): Promise; 34 | 35 | /** 36 | * Cuenta el número de posiciones que usan un flujo específico 37 | * @param id - ID del flujo de entrevista 38 | * @returns Número de posiciones que usan el flujo 39 | */ 40 | countPositionsUsing(id: number): Promise; 41 | 42 | /** 43 | * Busca flujos de entrevista por descripción (búsqueda parcial) 44 | * @param description - Término de búsqueda en la descripción 45 | * @returns Array de flujos que coinciden con la búsqueda 46 | */ 47 | findByDescription(description: string): Promise; 48 | } -------------------------------------------------------------------------------- /backend/src/domain/repositories/IBaseRepository.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Interface base para repositorios que define las operaciones comunes 3 | * que todos los repositorios deben implementar. 4 | */ 5 | export interface IBaseRepository { 6 | /** 7 | * Busca una entidad por su ID 8 | * @param id - Identificador único de la entidad 9 | * @returns La entidad encontrada o null si no existe 10 | */ 11 | findById(id: TId): Promise; 12 | 13 | /** 14 | * Busca todas las entidades que cumplan con los criterios especificados 15 | * @param criteria - Criterios de búsqueda opcionales 16 | * @returns Array de entidades que cumplen los criterios 17 | */ 18 | findMany(criteria?: any): Promise; 19 | 20 | /** 21 | * Guarda una nueva entidad en el repositorio 22 | * @param entity - Entidad a guardar 23 | * @returns La entidad guardada con su ID asignado 24 | */ 25 | save(entity: T): Promise; 26 | 27 | /** 28 | * Actualiza una entidad existente 29 | * @param id - ID de la entidad a actualizar 30 | * @param entity - Datos actualizados de la entidad 31 | * @returns La entidad actualizada 32 | */ 33 | update(id: TId, entity: Partial): Promise; 34 | 35 | /** 36 | * Elimina una entidad por su ID 37 | * @param id - ID de la entidad a eliminar 38 | * @returns true si se eliminó correctamente, false en caso contrario 39 | */ 40 | delete(id: TId): Promise; 41 | 42 | /** 43 | * Cuenta el número total de entidades que cumplen con los criterios 44 | * @param criteria - Criterios de búsqueda opcionales 45 | * @returns Número total de entidades 46 | */ 47 | count(criteria?: any): Promise; 48 | 49 | /** 50 | * Verifica si existe una entidad con el ID especificado 51 | * @param id - ID de la entidad a verificar 52 | * @returns true si existe, false en caso contrario 53 | */ 54 | exists(id: TId): Promise; 55 | } -------------------------------------------------------------------------------- /backend/prisma/getTopCandidates.js: -------------------------------------------------------------------------------- 1 | const { PrismaClient } = require('@prisma/client'); 2 | const prisma = new PrismaClient(); 3 | 4 | async function getTopCandidates(positionId) { 5 | try { 6 | const topCandidates = await prisma.application.findMany({ 7 | where: { 8 | positionId: parseInt(positionId) 9 | }, 10 | select: { 11 | candidate: { 12 | select: { 13 | id: true, 14 | firstName: true, 15 | lastName: true, 16 | email: true 17 | } 18 | }, 19 | interviews: { 20 | select: { 21 | score: true 22 | } 23 | } 24 | } 25 | }); 26 | 27 | // Calcular la puntuación media para cada candidato 28 | const candidatesWithScores = topCandidates.map(application => { 29 | const scores = application.interviews 30 | .map(interview => interview.score) 31 | .filter(score => score !== null); 32 | 33 | const averageScore = scores.length > 0 34 | ? scores.reduce((a, b) => a + b, 0) / scores.length 35 | : 0; 36 | 37 | return { 38 | ...application.candidate, 39 | averageScore: parseFloat(averageScore.toFixed(2)) 40 | }; 41 | }); 42 | 43 | // Ordenar por puntuación y obtener los 3 mejores 44 | const top3Candidates = candidatesWithScores 45 | .sort((a, b) => b.averageScore - a.averageScore) 46 | .slice(0, 3); 47 | 48 | console.log('Top 3 candidatos:'); 49 | console.table(top3Candidates); 50 | 51 | await prisma.$disconnect(); 52 | return top3Candidates; 53 | } catch (error) { 54 | console.error('Error:', error); 55 | await prisma.$disconnect(); 56 | process.exit(1); 57 | } 58 | } 59 | 60 | // Obtener el positionId desde los argumentos de la línea de comandos 61 | const positionId = process.argv[2]; 62 | 63 | if (!positionId) { 64 | console.error('Por favor, proporciona un positionId como argumento.'); 65 | process.exit(1); 66 | } 67 | 68 | getTopCandidates(positionId); -------------------------------------------------------------------------------- /backend/prisma/migrations/20240528082702_/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Candidate" ( 3 | "id" SERIAL NOT NULL, 4 | "firstName" VARCHAR(100) NOT NULL, 5 | "lastName" VARCHAR(100) NOT NULL, 6 | "email" VARCHAR(255) NOT NULL, 7 | "phone" VARCHAR(15), 8 | "address" VARCHAR(100), 9 | 10 | CONSTRAINT "Candidate_pkey" PRIMARY KEY ("id") 11 | ); 12 | 13 | -- CreateTable 14 | CREATE TABLE "Education" ( 15 | "id" SERIAL NOT NULL, 16 | "institution" VARCHAR(100) NOT NULL, 17 | "title" VARCHAR(250) NOT NULL, 18 | "startDate" TIMESTAMP(3) NOT NULL, 19 | "endDate" TIMESTAMP(3), 20 | "candidateId" INTEGER NOT NULL, 21 | 22 | CONSTRAINT "Education_pkey" PRIMARY KEY ("id") 23 | ); 24 | 25 | -- CreateTable 26 | CREATE TABLE "WorkExperience" ( 27 | "id" SERIAL NOT NULL, 28 | "company" VARCHAR(100) NOT NULL, 29 | "position" VARCHAR(100) NOT NULL, 30 | "description" VARCHAR(200), 31 | "startDate" TIMESTAMP(3) NOT NULL, 32 | "endDate" TIMESTAMP(3), 33 | "candidateId" INTEGER NOT NULL, 34 | 35 | CONSTRAINT "WorkExperience_pkey" PRIMARY KEY ("id") 36 | ); 37 | 38 | -- CreateTable 39 | CREATE TABLE "Resume" ( 40 | "id" SERIAL NOT NULL, 41 | "filePath" VARCHAR(500) NOT NULL, 42 | "fileType" VARCHAR(50) NOT NULL, 43 | "uploadDate" TIMESTAMP(3) NOT NULL, 44 | "candidateId" INTEGER NOT NULL, 45 | 46 | CONSTRAINT "Resume_pkey" PRIMARY KEY ("id") 47 | ); 48 | 49 | -- CreateIndex 50 | CREATE UNIQUE INDEX "Candidate_email_key" ON "Candidate"("email"); 51 | 52 | -- AddForeignKey 53 | ALTER TABLE "Education" ADD CONSTRAINT "Education_candidateId_fkey" FOREIGN KEY ("candidateId") REFERENCES "Candidate"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 54 | 55 | -- AddForeignKey 56 | ALTER TABLE "WorkExperience" ADD CONSTRAINT "WorkExperience_candidateId_fkey" FOREIGN KEY ("candidateId") REFERENCES "Candidate"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 57 | 58 | -- AddForeignKey 59 | ALTER TABLE "Resume" ADD CONSTRAINT "Resume_candidateId_fkey" FOREIGN KEY ("candidateId") REFERENCES "Candidate"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 60 | -------------------------------------------------------------------------------- /memory-bank/projectbrief.md: -------------------------------------------------------------------------------- 1 | # Sistema de Seguimiento de Talento LTI 2 | 3 | ## Resumen del Proyecto 4 | El Sistema de Seguimiento de Talento LTI es una aplicación full-stack diseñada para gestionar el proceso de selección de candidatos y la administración de entrevistas. La aplicación permite el registro de candidatos, sus experiencias educativas y laborales, así como el seguimiento de los procesos de entrevista en diferentes posiciones laborales. 5 | 6 | ## Objetivos Principales 7 | - Gestionar información completa de candidatos (datos personales, educación, experiencia laboral) 8 | - Administrar CVs y documentación de candidatos 9 | - Definir y gestionar flujos de entrevistas estructurados 10 | - Realizar seguimiento de aplicaciones a posiciones laborales 11 | - Registrar resultados de entrevistas y progreso de candidatos 12 | 13 | ## Requisitos Funcionales Clave 14 | 1. **Gestión de Candidatos** 15 | - Registro de información personal de candidatos 16 | - Gestión de experiencias educativas y laborales 17 | - Almacenamiento y acceso a CVs 18 | 19 | 2. **Gestión de Empresas y Posiciones** 20 | - Registro de empresas y sus empleados 21 | - Creación y gestión de posiciones laborales 22 | - Definición de requisitos y responsabilidades 23 | 24 | 3. **Procesos de Entrevista** 25 | - Definición de flujos de entrevista personalizables 26 | - Asignación de tipos de entrevista a cada paso 27 | - Seguimiento del progreso de los candidatos 28 | 29 | 4. **Aplicaciones** 30 | - Registro de aplicaciones de candidatos a posiciones 31 | - Seguimiento del estado actual de cada aplicación 32 | - Documentación de notas y observaciones 33 | 34 | ## Arquitectura Técnica 35 | El proyecto está construido con: 36 | - **Frontend**: React con TypeScript 37 | - **Backend**: Node.js (Express) con TypeScript 38 | - **Base de Datos**: PostgreSQL 39 | - **ORM**: Prisma 40 | - **Contenedorización**: Docker 41 | 42 | ## Alcance del Proyecto 43 | El sistema abarca todo el ciclo de vida del proceso de contratación, desde el registro inicial de candidatos hasta la finalización del proceso de entrevistas, proporcionando una herramienta completa para equipos de recursos humanos y reclutamiento. -------------------------------------------------------------------------------- /frontend/src/components/FileUploader.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Button, InputGroup, FormControl, Spinner } from 'react-bootstrap'; 3 | 4 | const FileUploader = ({ onChange, onUpload }) => { 5 | const [file, setFile] = useState(null); 6 | const [fileName, setFileName] = useState(''); 7 | const [fileData, setFileData] = useState(null); 8 | const [loading, setLoading] = useState(false); 9 | 10 | const handleFileChange = (event) => { 11 | setFile(event.target.files[0]); 12 | setFileName(event.target.files[0].name); 13 | onChange(event.target.files[0]); 14 | }; 15 | 16 | const handleFileUpload = async () => { 17 | if (file) { 18 | setLoading(true); 19 | const formData = new FormData(); 20 | formData.append('file', file); 21 | 22 | try { 23 | const res = await fetch('http://localhost:3010/upload', { 24 | method: 'POST', 25 | body: formData, 26 | }); 27 | 28 | if (!res.ok) { 29 | throw new Error('Error al subir archivo'); 30 | } 31 | 32 | const fileData = await res.json(); 33 | setFileData(fileData); 34 | onUpload(fileData); 35 | } catch (error) { 36 | console.error('Error al subir archivo:', error); 37 | } finally { 38 | setLoading(false); // Asegura que loading se establezca a false después de la operación 39 | } 40 | } 41 | }; 42 | 43 | return ( 44 |
45 | 46 | 52 | 59 | 60 |

Selected file: {fileName}

61 | {fileData && ( 62 |

63 | Archivo subido con éxito 64 |

65 | )} 66 |
67 | ); 68 | }; 69 | 70 | export default FileUploader; 71 | -------------------------------------------------------------------------------- /backend/src/domain/models/Application.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | import { Interview } from './Interview'; 3 | 4 | const prisma = new PrismaClient(); 5 | 6 | export class Application { 7 | id?: number; 8 | positionId: number; 9 | candidateId: number; 10 | applicationDate: Date; 11 | currentInterviewStep: number; 12 | notes?: string; 13 | interviews: Interview[]; // Added this line 14 | 15 | constructor(data: any) { 16 | this.id = data.id; 17 | this.positionId = data.positionId; 18 | this.candidateId = data.candidateId; 19 | this.applicationDate = new Date(data.applicationDate); 20 | this.currentInterviewStep = data.currentInterviewStep; 21 | this.notes = data.notes; 22 | this.interviews = data.interviews || []; // Added this line 23 | } 24 | 25 | async save() { 26 | const applicationData: any = { 27 | positionId: this.positionId, 28 | candidateId: this.candidateId, 29 | applicationDate: this.applicationDate, 30 | currentInterviewStep: this.currentInterviewStep, 31 | notes: this.notes, 32 | }; 33 | 34 | if (this.id) { 35 | return await prisma.application.update({ 36 | where: { id: this.id }, 37 | data: applicationData, 38 | }); 39 | } else { 40 | return await prisma.application.create({ 41 | data: applicationData, 42 | }); 43 | } 44 | } 45 | 46 | static async findOne(id: number): Promise { 47 | const data = await prisma.application.findUnique({ 48 | where: { id: id }, 49 | }); 50 | if (!data) return null; 51 | return new Application(data); 52 | } 53 | 54 | static async findOneByPositionCandidateId(applicationIdNumber: number, candidateId: number): Promise { 55 | const data = await prisma.application.findFirst({ 56 | where: { 57 | id: applicationIdNumber, 58 | candidateId: candidateId 59 | } 60 | }); 61 | if (!data) return null; 62 | return new Application(data); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /frontend/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/src/domain/repositories/ICandidateRepository.ts: -------------------------------------------------------------------------------- 1 | import { IBaseRepository } from './IBaseRepository'; 2 | import { Candidate } from '../models/Candidate'; 3 | 4 | /** 5 | * Criterios para búsqueda paginada de candidatos 6 | */ 7 | export interface CandidateSearchCriteria { 8 | page?: number; 9 | limit?: number; 10 | search?: string; // Búsqueda por nombre, apellido o email 11 | sort?: 'firstName' | 'lastName' | 'email'; 12 | order?: 'asc' | 'desc'; 13 | } 14 | 15 | /** 16 | * Resultado paginado de candidatos 17 | */ 18 | export interface PaginatedCandidates { 19 | data: Candidate[]; 20 | metadata: { 21 | total: number; 22 | page: number; 23 | limit: number; 24 | totalPages: number; 25 | }; 26 | } 27 | 28 | /** 29 | * Interface para el repositorio de candidatos. 30 | * Define operaciones específicas para la gestión de candidatos. 31 | */ 32 | export interface ICandidateRepository extends IBaseRepository { 33 | /** 34 | * Busca un candidato por su email 35 | * @param email - Email del candidato 36 | * @returns El candidato encontrado o null si no existe 37 | */ 38 | findByEmail(email: string): Promise; 39 | 40 | /** 41 | * Busca candidatos con paginación y filtros 42 | * @param criteria - Criterios de búsqueda y paginación 43 | * @returns Resultado paginado con candidatos y metadatos 44 | */ 45 | findWithPagination(criteria: CandidateSearchCriteria): Promise; 46 | 47 | /** 48 | * Busca un candidato con todas sus relaciones (educación, experiencia, CV, aplicaciones) 49 | * @param id - ID del candidato 50 | * @returns El candidato con todas sus relaciones o null si no existe 51 | */ 52 | findByIdWithRelations(id: number): Promise; 53 | 54 | /** 55 | * Busca candidatos por posición aplicada 56 | * @param positionId - ID de la posición 57 | * @returns Array de candidatos que aplicaron a la posición 58 | */ 59 | findByPositionId(positionId: number): Promise; 60 | 61 | /** 62 | * Verifica si un email ya está en uso por otro candidato 63 | * @param email - Email a verificar 64 | * @param excludeId - ID del candidato a excluir de la verificación (para actualizaciones) 65 | * @returns true si el email está en uso, false en caso contrario 66 | */ 67 | isEmailInUse(email: string, excludeId?: number): Promise; 68 | 69 | /** 70 | * Busca candidatos por criterios de búsqueda de texto libre 71 | * @param searchTerm - Término de búsqueda 72 | * @returns Array de candidatos que coinciden con la búsqueda 73 | */ 74 | searchByTerm(searchTerm: string): Promise; 75 | 76 | /** 77 | * Obtiene estadísticas básicas de candidatos 78 | * @returns Objeto con estadísticas (total, activos, etc.) 79 | */ 80 | getStatistics(): Promise<{ 81 | total: number; 82 | withApplications: number; 83 | withResumes: number; 84 | }>; 85 | } -------------------------------------------------------------------------------- /backend/src/domain/repositories/IPositionRepository.ts: -------------------------------------------------------------------------------- 1 | import { IBaseRepository } from './IBaseRepository'; 2 | import { Position } from '../models/Position'; 3 | 4 | /** 5 | * Estados válidos de una posición 6 | */ 7 | export type PositionStatus = 'Draft' | 'Published' | 'Closed' | 'On Hold'; 8 | 9 | /** 10 | * Criterios para búsqueda de posiciones 11 | */ 12 | export interface PositionSearchCriteria { 13 | companyId?: number; 14 | status?: PositionStatus; 15 | isVisible?: boolean; 16 | location?: string; 17 | employmentType?: string; 18 | salaryMin?: number; 19 | salaryMax?: number; 20 | } 21 | 22 | /** 23 | * Interface para el repositorio de posiciones. 24 | * Define operaciones específicas para la gestión de posiciones. 25 | */ 26 | export interface IPositionRepository extends IBaseRepository { 27 | /** 28 | * Busca todas las posiciones visibles 29 | * @returns Array de posiciones visibles 30 | */ 31 | findAllVisible(): Promise; 32 | 33 | /** 34 | * Busca posiciones por compañía 35 | * @param companyId - ID de la compañía 36 | * @returns Array de posiciones de la compañía 37 | */ 38 | findByCompanyId(companyId: number): Promise; 39 | 40 | /** 41 | * Busca una posición con su flujo de entrevistas incluido 42 | * @param id - ID de la posición 43 | * @returns La posición con su flujo de entrevistas o null si no existe 44 | */ 45 | findByIdWithInterviewFlow(id: number): Promise; 46 | 47 | /** 48 | * Busca posiciones por criterios específicos 49 | * @param criteria - Criterios de búsqueda 50 | * @returns Array de posiciones que cumplen los criterios 51 | */ 52 | findByCriteria(criteria: PositionSearchCriteria): Promise; 53 | 54 | /** 55 | * Busca posiciones activas (publicadas y visibles) 56 | * @returns Array de posiciones activas 57 | */ 58 | findActivePositions(): Promise; 59 | 60 | /** 61 | * Actualiza el estado de una posición 62 | * @param id - ID de la posición 63 | * @param status - Nuevo estado 64 | * @returns La posición actualizada 65 | */ 66 | updateStatus(id: number, status: PositionStatus): Promise; 67 | 68 | /** 69 | * Actualiza la visibilidad de una posición 70 | * @param id - ID de la posición 71 | * @param isVisible - Nueva visibilidad 72 | * @returns La posición actualizada 73 | */ 74 | updateVisibility(id: number, isVisible: boolean): Promise; 75 | 76 | /** 77 | * Verifica si una posición tiene aplicaciones activas 78 | * @param id - ID de la posición 79 | * @returns true si tiene aplicaciones, false en caso contrario 80 | */ 81 | hasActiveApplications(id: number): Promise; 82 | 83 | /** 84 | * Cuenta las aplicaciones por posición 85 | * @param id - ID de la posición 86 | * @returns Número de aplicaciones 87 | */ 88 | countApplications(id: number): Promise; 89 | 90 | /** 91 | * Obtiene estadísticas de posiciones por compañía 92 | * @param companyId - ID de la compañía 93 | * @returns Objeto con estadísticas 94 | */ 95 | getStatisticsByCompany(companyId: number): Promise<{ 96 | total: number; 97 | published: number; 98 | draft: number; 99 | closed: number; 100 | totalApplications: number; 101 | }>; 102 | } -------------------------------------------------------------------------------- /memory-bank/activeContext.md: -------------------------------------------------------------------------------- 1 | # Contexto Activo 2 | 3 | ## Estado Actual del Proyecto 4 | 5 | El Sistema de Seguimiento de Talento LTI se encuentra en fase de desarrollo. La arquitectura básica está implementada con una funcionalidad central establecida, pero se requieren mejoras y ampliaciones en varias áreas clave. 6 | 7 | ## Enfoque Actual 8 | 9 | El trabajo actual se centra en el desarrollo y mejora de las siguientes áreas: 10 | 11 | 1. **Gestión de Candidatos**: 12 | - Implementación completa del CRUD para candidatos 13 | - Mejora de la validación de datos 14 | - Optimización del manejo de CVs y documentos 15 | 16 | 2. **Flujos de Entrevista**: 17 | - Creación y configuración de flujos personalizados 18 | - Asignación de tipos de entrevista a pasos 19 | - Seguimiento del progreso de candidatos en el proceso 20 | 21 | 3. **Experiencia de Usuario**: 22 | - Mejora de la interfaz para una navegación más intuitiva 23 | - Implementación de formularios más eficientes 24 | - Desarrollo de dashboards informativos 25 | 26 | ## Decisiones Técnicas Recientes 27 | 28 | 1. **Adopción Completa de DDD**: Se ha decidido profundizar en la implementación de Domain-Driven Design para mejorar la estructura del código y la representación del modelo de negocio. 29 | 30 | 2. **Optimización de Consultas con Prisma**: Se está revisando y optimizando el uso de Prisma para consultas complejas, especialmente para aquellas que involucran múltiples entidades relacionadas. 31 | 32 | 3. **Mejora del Manejo de Estado en Frontend**: Se está evaluando la implementación de una solución más robusta para la gestión del estado de la aplicación frontend, posiblemente incorporando React Context o Redux. 33 | 34 | ## Desafíos Actuales 35 | 36 | 1. **Rendimiento en Consultas Complejas**: Las consultas que involucran múltiples relaciones (como obtener aplicaciones con sus candidatos, posiciones, entrevistas, etc.) pueden ser lentas y requieren optimización. 37 | 38 | 2. **Manejo de Archivos**: El almacenamiento y gestión de CVs y otros documentos necesita mejoras para escalabilidad y seguridad. 39 | 40 | 3. **Validación Compleja de Datos**: Se requiere una estrategia más robusta para validar datos complejos de candidatos y aplicaciones. 41 | 42 | 4. **Testing**: Necesidad de ampliar la cobertura de pruebas, especialmente para servicios de aplicación y lógica de dominio. 43 | 44 | ## Próximos Pasos 45 | 46 | 1. **Implementación de Autenticación y Autorización**: Desarrollo de un sistema de usuarios con roles y permisos. 47 | 48 | 2. **Mejora de la Interfaz de Usuario**: Rediseño de componentes clave para mejorar la experiencia del usuario. 49 | 50 | 3. **Ampliación de la API REST**: Implementación de endpoints adicionales para cubrir todas las funcionalidades necesarias. 51 | 52 | 4. **Sistema de Notificaciones**: Desarrollo de un sistema para notificar sobre cambios en el estado de las aplicaciones y entrevistas. 53 | 54 | 5. **Optimización de Rendimiento**: Revisión y optimización del rendimiento general, tanto en frontend como en backend. 55 | 56 | ## Consideraciones y Limitaciones 57 | 58 | 1. **Escalabilidad**: El diseño actual debe evolucionar para soportar un mayor volumen de datos y usuarios. 59 | 60 | 2. **Internacionalización**: Se debe considerar la preparación del sistema para soporte multilingüe en el futuro. 61 | 62 | 3. **Integración con Servicios Externos**: Se contempla la posibilidad de integración con servicios como LinkedIn, plataformas de email marketing, etc. 63 | 64 | 4. **Seguridad de Datos**: Necesidad de revisar y mejorar aspectos de seguridad, especialmente para datos sensibles de candidatos. -------------------------------------------------------------------------------- /memory-bank/productContext.md: -------------------------------------------------------------------------------- 1 | # Contexto del Producto 2 | 3 | ## Problema que Resuelve 4 | 5 | El Sistema de Seguimiento de Talento LTI aborda varios desafíos críticos en los procesos de reclutamiento y selección: 6 | 7 | 1. **Desorganización en la gestión de candidatos**: Muchas empresas utilizan sistemas dispersos (hojas de cálculo, emails, notas) para seguir a los candidatos, lo que lleva a pérdida de información y duplicidad de esfuerzos. 8 | 9 | 2. **Dificultad para gestionar flujos de entrevista complejos**: Los procesos de selección modernos pueden incluir múltiples etapas con diferentes evaluadores, lo que requiere una coordinación meticulosa. 10 | 11 | 3. **Falta de visibilidad centralizada**: Los equipos de reclutamiento necesitan una visión unificada de todos los candidatos, posiciones y procesos activos. 12 | 13 | 4. **Documentación inconsistente**: La información sobre candidatos (CVs, resultados de entrevistas, notas) suele estar fragmentada entre diferentes sistemas. 14 | 15 | ## Experiencia de Usuario Deseada 16 | 17 | El sistema busca proporcionar: 18 | 19 | 1. **Interfaz Intuitiva**: Una experiencia fluida y clara para gestionar todos los aspectos del proceso de reclutamiento. 20 | 21 | 2. **Centralización de Información**: Un único punto de acceso para toda la información relacionada con candidatos y procesos. 22 | 23 | 3. **Visibilidad y Transparencia**: Dashboards y vistas que permitan entender rápidamente el estado de los procesos de selección. 24 | 25 | 4. **Flexibilidad en los Flujos**: Capacidad para adaptar los procesos a diferentes roles y necesidades organizacionales. 26 | 27 | 5. **Acceso Eficiente a Documentos**: Almacenamiento y recuperación simplificados de CVs y otros documentos relacionados. 28 | 29 | ## Usuarios Principales 30 | 31 | El sistema está diseñado para varios tipos de usuarios: 32 | 33 | 1. **Reclutadores**: Profesionales encargados de identificar, contactar y evaluar candidatos inicialmente. 34 | 35 | 2. **Entrevistadores Técnicos**: Profesionales que evalúan las habilidades técnicas de los candidatos. 36 | 37 | 3. **Gestores de RRHH**: Responsables de supervisar todo el proceso de selección. 38 | 39 | 4. **Hiring Managers**: Líderes de equipo o departamento que toman la decisión final de contratación. 40 | 41 | 5. **Administradores del Sistema**: Encargados de configurar y mantener el sistema. 42 | 43 | ## Contexto de Uso 44 | 45 | El sistema está pensado para ser utilizado en diversos escenarios: 46 | 47 | 1. **Durante el Sourcing de Candidatos**: Para registrar información inicial de posibles candidatos. 48 | 49 | 2. **En el Proceso de Revisión de CVs**: Para almacenar y acceder a documentación de candidatos. 50 | 51 | 3. **Durante las Entrevistas**: Para registrar resultados, puntuaciones y observaciones. 52 | 53 | 4. **En Reuniones de Decisión**: Para recopilar toda la información relevante que informe la decisión de contratación. 54 | 55 | 5. **En el Análisis de Procesos**: Para extraer métricas sobre la efectividad y eficiencia de los procesos de selección. 56 | 57 | ## Valor para el Negocio 58 | 59 | La implementación del sistema proporciona: 60 | 61 | 1. **Reducción del Tiempo de Contratación**: Al optimizar y agilizar los procesos de selección. 62 | 63 | 2. **Mejora en la Calidad de las Contrataciones**: Gracias a evaluaciones más estructuradas y mejor documentadas. 64 | 65 | 3. **Mayor Colaboración**: Facilitando que múltiples stakeholders participen en el proceso. 66 | 67 | 4. **Mejores Decisiones**: Al tener toda la información centralizada y accesible. 68 | 69 | 5. **Reducción de Costos**: Minimizando el tiempo administrativo y previniendo la pérdida de candidatos valiosos debido a procesos ineficientes. -------------------------------------------------------------------------------- /backend/src/domain/models/Position.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | const prisma = new PrismaClient(); 4 | 5 | export class Position { 6 | id?: number; 7 | companyId: number; 8 | interviewFlowId: number; 9 | title: string; 10 | description: string; 11 | status: string; 12 | isVisible: boolean; 13 | location: string; 14 | jobDescription: string; 15 | requirements?: string; 16 | responsibilities?: string; 17 | salaryMin?: number; 18 | salaryMax?: number; 19 | employmentType?: string; 20 | benefits?: string; 21 | companyDescription?: string; 22 | applicationDeadline?: Date; 23 | contactInfo?: string; 24 | 25 | constructor(data: any) { 26 | this.id = data.id; 27 | this.companyId = data.companyId; 28 | this.interviewFlowId = data.interviewFlowId; 29 | this.title = data.title; 30 | this.description = data.description; 31 | this.status = data.status ?? 'Draft'; 32 | this.isVisible = data.isVisible ?? false; 33 | this.location = data.location; 34 | this.jobDescription = data.jobDescription; 35 | this.requirements = data.requirements; 36 | this.responsibilities = data.responsibilities; 37 | this.salaryMin = data.salaryMin; 38 | this.salaryMax = data.salaryMax; 39 | this.employmentType = data.employmentType; 40 | this.benefits = data.benefits; 41 | this.companyDescription = data.companyDescription; 42 | this.applicationDeadline = data.applicationDeadline ? new Date(data.applicationDeadline) : undefined; 43 | this.contactInfo = data.contactInfo; 44 | } 45 | 46 | async save() { 47 | const positionData: any = { 48 | companyId: this.companyId, 49 | interviewFlowId: this.interviewFlowId, 50 | title: this.title, 51 | description: this.description, 52 | status: this.status, 53 | isVisible: this.isVisible, 54 | location: this.location, 55 | jobDescription: this.jobDescription, 56 | requirements: this.requirements, 57 | responsibilities: this.responsibilities, 58 | salaryMin: this.salaryMin, 59 | salaryMax: this.salaryMax, 60 | employmentType: this.employmentType, 61 | benefits: this.benefits, 62 | companyDescription: this.companyDescription, 63 | applicationDeadline: this.applicationDeadline, 64 | contactInfo: this.contactInfo, 65 | }; 66 | 67 | if (this.id) { 68 | return await prisma.position.update({ 69 | where: { id: this.id }, 70 | data: positionData, 71 | }); 72 | } else { 73 | return await prisma.position.create({ 74 | data: positionData, 75 | }); 76 | } 77 | } 78 | 79 | static async findOne(id: number): Promise { 80 | const data = await prisma.position.findUnique({ 81 | where: { id: id }, 82 | }); 83 | if (!data) return null; 84 | return new Position(data); 85 | } 86 | 87 | static async findOneWithInterviewFlow(id: number): Promise { 88 | const data = await prisma.position.findUnique({ 89 | where: { id: id }, 90 | include: { 91 | interviewFlow: { 92 | include: { 93 | interviewSteps: true 94 | } 95 | } 96 | } 97 | }); 98 | if (!data) return null; 99 | return new Position(data) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /documentation/prompts.md: -------------------------------------------------------------------------------- 1 | # META PROMPT 2 | # Instrucciones 3 | Eres un experto en prompt engineering. 4 | Dado el siguiente prompt, preparalo usando las mejores practicas de estructura (rol, objetivo...) y formato para conseguir un resultado preciso y exhaustivo. Ciñete solo al objetivo pedido analizando bien lo que se pide en el prompt original 5 | 6 | # Prompt original: 7 | [dame tests unitarios para la funcionalidad de obtener los candidatos de una posición] 8 | --- 9 | 10 | # Generate README 11 | 12 | You are an expert architect experienced in ATS. 13 | 14 | Prepare a readme file in README.md in the root folder covering the following topics: 15 | - a comprehensive overview of the ATS project, including: 16 | - its purpose 17 | - folder structure with a simple diagram 18 | - technologies 19 | - architecture 20 | - setup instructions, including backend, frontend, postgresql via docker, and cypress testing suite 21 | 22 | Output is in markdown format, and properly formatted and indented. Follow exactly the same structure you can find in the example attached 23 | 24 | --- 25 | 26 | # Generate data model 27 | 28 | Eres un experto en bases de datos. Dame una documentacion del modelo de datos que explique campos, relaciones y un diagrama en formato mermaid @/prisma 29 | 30 | --- 31 | # Prompt para enriquecer historias de usuario 32 | Eres un experto en producto. 33 | 34 | A esta historia de usuario le falta detalle técnico y específico para permitir al developer ser totalmente autónomo a la hora de completarla 35 | 36 | Por favor entiende la necesidad y proporciona un historia mejorada que sea más clara, específica y concisa acorde a las mejores prácticas de producto, incluyendo descripción completa de la funcionalidad, lista exhaustiva de campos a tocar, estructura y URL de los endpoints necesarios, ficheros a modificar acorde a la arquitectura y buenas prácticas, pasos para que la tarea se asuma como completada, como actualizar la documentación que sea relevante o crear tests unitarios, y requisitos no funcionales relativos a seguridad, rendimiento, etc. Devuelvela en formato markdown 37 | 38 | [HISTORIA DE USUARIO] 39 | 40 | @README.md @ModeloDatos.md @api-spec.yaml @ManifestoBuenasPracticas.md 41 | 42 | --- 43 | # Prompt para tests unitarios 44 | 45 | # Rol 46 | Eres un experto en tests unitarios. 47 | 48 | # Objetivo 49 | Dada la historia de usuario a continuación, propón tests unitarios de la manera más exhaustiva posible. 50 | 51 | # Formato 52 | Solo quiero la descripcion y solo tests unitarios, no quiero que me devuelvas codigo aun: 53 | [HISTORIA DE USUARIO] 54 | 55 | 56 | 57 | 58 | --- 59 | # Prompt para Consultas SQL 60 | # Rol 61 | Eres un experto desarrollador SQL. 62 | 63 | # Consulta 64 | Obtener los 3 mejores candidatos para una posición dada 65 | 66 | # Objetivo 67 | Dame la query para PostgreSQL que devuelve lo indicado en [Consulta]. 68 | Aplica todas las practicas SQL para que pueda ejecutar la consulta sin errores (como las dobles comillas en campos y tablas) 69 | Basate en el modelo de datos @schema.prisma 70 | 71 | 72 | --- 73 | # Prompt para reiniciar bbdd 74 | # Rol 75 | Eres un experto desarrollador SQL. 76 | 77 | # Objetivo 78 | reinicia la base de datos con el seed y las migraciones @backend 79 | 80 | --- 81 | # Prompt para añadir flujo de entrevistas nuevo al seed 82 | 83 | # Rol 84 | Eres un experto desarrollador SQL. 85 | 86 | # Objetivo 87 | Necesito que crees un flujo de entrevistas similar al de la posicion 1 para la posicion 2, pero con un paso extra team fit interview al final. Ya hay un registro de interviewflow para este proceso con id=2. Actualiza seed.ts para que sea la nueva base de datos de inicio 88 | 89 | 90 | --- 91 | # Prompt CoT refactoring 92 | 93 | # Rol 94 | Eres un experto arquitecto de software 95 | 96 | # Objetivo 97 | Quiero refactorizar la base de codigo existente para [mejorar la aplicación de DDD] 98 | 99 | Analiza el estado actual y proponme un plan detallado paso a paso de lo que habría que hacer, revisando cuidadosamente cada paso. No crees codigo aun 100 | -------------------------------------------------------------------------------- /backend/src/domain/repositories/IApplicationRepository.ts: -------------------------------------------------------------------------------- 1 | import { IBaseRepository } from './IBaseRepository'; 2 | import { Application } from '../models/Application'; 3 | 4 | /** 5 | * Información de candidato para listados 6 | */ 7 | export interface CandidateInfo { 8 | candidateId: number; 9 | fullName: string; 10 | currentInterviewStep: string; 11 | applicationId: number; 12 | averageScore: number; 13 | } 14 | 15 | /** 16 | * Información resumida de aplicación 17 | */ 18 | export interface ApplicationSummary { 19 | id: number; 20 | positionTitle: string; 21 | candidateName: string; 22 | applicationDate: Date; 23 | currentStepName: string; 24 | averageScore: number; 25 | } 26 | 27 | /** 28 | * Interface para el repositorio de aplicaciones. 29 | * Define operaciones específicas para la gestión de aplicaciones. 30 | */ 31 | export interface IApplicationRepository extends IBaseRepository { 32 | /** 33 | * Busca aplicaciones por posición 34 | * @param positionId - ID de la posición 35 | * @returns Array de aplicaciones para la posición 36 | */ 37 | findByPositionId(positionId: number): Promise; 38 | 39 | /** 40 | * Busca aplicaciones por candidato 41 | * @param candidateId - ID del candidato 42 | * @returns Array de aplicaciones del candidato 43 | */ 44 | findByCandidateId(candidateId: number): Promise; 45 | 46 | /** 47 | * Busca una aplicación específica por posición y candidato 48 | * @param positionId - ID de la posición 49 | * @param candidateId - ID del candidato 50 | * @returns La aplicación encontrada o null si no existe 51 | */ 52 | findByPositionAndCandidate(positionId: number, candidateId: number): Promise; 53 | 54 | /** 55 | * Obtiene candidatos de una posición con información resumida 56 | * @param positionId - ID de la posición 57 | * @returns Array de información de candidatos 58 | */ 59 | getCandidatesByPosition(positionId: number): Promise; 60 | 61 | /** 62 | * Obtiene nombres de candidatos por posición 63 | * @param positionId - ID de la posición 64 | * @returns Array con ID y nombre completo de candidatos 65 | */ 66 | getCandidateNamesByPosition(positionId: number): Promise>; 70 | 71 | /** 72 | * Actualiza el paso de entrevista actual de una aplicación 73 | * @param applicationId - ID de la aplicación 74 | * @param interviewStepId - ID del nuevo paso de entrevista 75 | * @returns La aplicación actualizada 76 | */ 77 | updateInterviewStep(applicationId: number, interviewStepId: number): Promise; 78 | 79 | /** 80 | * Busca aplicaciones por paso de entrevista 81 | * @param interviewStepId - ID del paso de entrevista 82 | * @returns Array de aplicaciones en ese paso 83 | */ 84 | findByInterviewStep(interviewStepId: number): Promise; 85 | 86 | /** 87 | * Verifica si existe una aplicación para una posición y candidato específicos 88 | * @param positionId - ID de la posición 89 | * @param candidateId - ID del candidato 90 | * @returns true si existe, false en caso contrario 91 | */ 92 | existsForPositionAndCandidate(positionId: number, candidateId: number): Promise; 93 | 94 | /** 95 | * Obtiene el resumen de aplicaciones con información adicional 96 | * @param filters - Filtros opcionales 97 | * @returns Array de resúmenes de aplicaciones 98 | */ 99 | getApplicationsSummary(filters?: { 100 | positionId?: number; 101 | candidateId?: number; 102 | interviewStepId?: number; 103 | }): Promise; 104 | 105 | /** 106 | * Cuenta aplicaciones por estado de entrevista 107 | * @param positionId - ID de la posición (opcional) 108 | * @returns Objeto con conteos por paso de entrevista 109 | */ 110 | countByInterviewStep(positionId?: number): Promise>; 111 | } -------------------------------------------------------------------------------- /memory-bank/techContext.md: -------------------------------------------------------------------------------- 1 | # Contexto Tecnológico 2 | 3 | ## Stack Tecnológico 4 | 5 | ### Frontend 6 | 7 | - **Framework Principal**: React 8 | - **Lenguaje**: TypeScript 9 | - **Empaquetador**: Create React App 10 | - **Estilos**: CSS/SCSS (potencialmente con frameworks como Bootstrap o Material-UI) 11 | - **Routing**: React Router 12 | - **Manejo de Estado**: Context API o posiblemente Redux 13 | - **Comunicación con API**: Fetch API o Axios 14 | 15 | ### Backend 16 | 17 | - **Runtime**: Node.js 18 | - **Framework**: Express.js 19 | - **Lenguaje**: TypeScript 20 | - **ORM**: Prisma 21 | - **Base de Datos**: PostgreSQL 22 | - **Validación**: Posiblemente Joi o Zod 23 | - **Gestión de Archivos**: Multer para manejo de uploads 24 | 25 | ### Infraestructura 26 | 27 | - **Contenedorización**: Docker 28 | - **Orquestación**: Docker Compose 29 | - **Base de Datos**: PostgreSQL en contenedor Docker 30 | - **Almacenamiento de Archivos**: Sistema de archivos local (potencialmente migrando a solución cloud) 31 | 32 | ## Entorno de Desarrollo 33 | 34 | ### Requisitos Previos 35 | 36 | - Node.js (v14+) 37 | - npm o yarn 38 | - Docker y Docker Compose 39 | - PostgreSQL Client (opcional, para conexión directa a la BD) 40 | 41 | ### Configuración Inicial 42 | 43 | 1. **Variables de Entorno**: 44 | - `.env` contiene configuraciones de conexión a la base de datos y otras configuraciones sensibles 45 | - Ejemplo de variables principales: 46 | ``` 47 | DATABASE_URL=postgresql://usuario:contraseña@localhost:5432/nombd 48 | PORT=3010 49 | UPLOAD_DIR=uploads 50 | ``` 51 | 52 | 2. **Instalación de Dependencias**: 53 | ```bash 54 | cd frontend && npm install 55 | cd backend && npm install 56 | ``` 57 | 58 | 3. **Inicialización de Base de Datos**: 59 | ```bash 60 | docker-compose up -d 61 | cd backend && npx prisma migrate dev 62 | cd backend/prisma && ts-node seed.ts 63 | ``` 64 | 65 | ## Dependencias Principales 66 | 67 | ### Backend 68 | 69 | - **express**: Framework web para Node.js 70 | - **prisma**: ORM para acceso a base de datos 71 | - **cors**: Middleware para configurar Cross-Origin Resource Sharing 72 | - **multer**: Middleware para manejo de formularios multipart/form-data 73 | - **typescript**: Lenguaje con tipado estático 74 | - **jest**: Framework para pruebas 75 | 76 | ### Frontend 77 | 78 | - **react**: Biblioteca para construir interfaces de usuario 79 | - **react-dom**: Renderizado de React para navegadores 80 | - **react-router-dom**: Enrutamiento para aplicaciones React 81 | - **typescript**: Tipado estático para JavaScript 82 | - **axios** (potencial): Cliente HTTP para realizar peticiones a la API 83 | 84 | ## Convenciones de Código 85 | 86 | - **Linting**: ESLint para mantener estándares de código 87 | - **Formatting**: Prettier para formateo consistente 88 | - **Tipado**: TypeScript para tipos estáticos 89 | - **Naming**: 90 | - camelCase para variables, funciones y métodos 91 | - PascalCase para clases y componentes React 92 | - snake_case para algunos nombres de archivos en el backend 93 | - kebab-case para algunos nombres de archivos en el frontend 94 | 95 | ## Restricciones Técnicas 96 | 97 | 1. **Compatibilidad del Navegador**: El sistema debe funcionar en navegadores modernos (últimas 2 versiones de Chrome, Firefox, Safari, Edge) 98 | 99 | 2. **Rendimiento**: 100 | - Tiempos de carga de página < 2 segundos 101 | - Respuestas de API < 500ms para operaciones comunes 102 | 103 | 3. **Seguridad**: 104 | - Validación de entrada en cliente y servidor 105 | - Sanitización de datos para prevenir inyecciones SQL 106 | - Protección contra ataques XSS 107 | - Implementación futura de autenticación y autorización 108 | 109 | 4. **Escalabilidad**: 110 | - Diseño que permita crecer en número de usuarios y datos 111 | - Paginación para conjuntos grandes de datos 112 | - Optimización de consultas a la base de datos 113 | 114 | ## Consideraciones de Despliegue 115 | 116 | El sistema actualmente está diseñado para entornos de desarrollo, con potencial para configuración en entornos de producción mediante: 117 | 118 | 1. **CI/CD**: Integración y despliegue continuos (potencialmente con GitHub Actions) 119 | 2. **Contenedorización**: Despliegue basado en Docker 120 | 3. **Entorno Cloud**: Preparación para despliegue en servicios como AWS, Azure o GCP 121 | 4. **Monitorización**: Implementación futura de herramientas de monitorización y logging -------------------------------------------------------------------------------- /backend/src/presentation/controllers/candidateController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { addCandidate, findCandidateById, updateCandidateStage, getAllCandidates } from '../../application/services/candidateService'; 3 | 4 | /** 5 | * @route POST /candidates 6 | * @description Creates a new candidate 7 | * @access Public 8 | */ 9 | export const addCandidateController = async (req: Request, res: Response) => { 10 | try { 11 | const candidateData = req.body; 12 | const candidate = await addCandidate(candidateData); 13 | res.status(201).json({ message: 'Candidate added successfully', data: candidate }); 14 | } catch (error: unknown) { 15 | if (error instanceof Error) { 16 | res.status(400).json({ message: 'Error adding candidate', error: error.message }); 17 | } else { 18 | res.status(400).json({ message: 'Error adding candidate', error: 'Unknown error' }); 19 | } 20 | } 21 | }; 22 | 23 | /** 24 | * @route GET /candidates/:id 25 | * @description Get a candidate by their ID 26 | * @access Public 27 | */ 28 | export const getCandidateById = async (req: Request, res: Response) => { 29 | try { 30 | const id = parseInt(req.params.id); 31 | if (isNaN(id)) { 32 | return res.status(400).json({ error: 'Invalid ID format' }); 33 | } 34 | const candidate = await findCandidateById(id); 35 | if (!candidate) { 36 | return res.status(404).json({ error: 'Candidate not found' }); 37 | } 38 | res.json(candidate); 39 | } catch (error) { 40 | res.status(500).json({ error: 'Internal Server Error' }); 41 | } 42 | }; 43 | 44 | /** 45 | * @route PUT /candidates/:id/stage 46 | * @description Updates the interview stage of a candidate 47 | * @access Public 48 | */ 49 | export const updateCandidateStageController = async (req: Request, res: Response) => { 50 | try { 51 | const id = parseInt(req.params.id); 52 | const { applicationId, currentInterviewStep } = req.body; 53 | const applicationIdNumber = parseInt(applicationId); 54 | if (isNaN(applicationIdNumber)) { 55 | return res.status(400).json({ error: 'Invalid position ID format' }); 56 | } 57 | const currentInterviewStepNumber = parseInt(currentInterviewStep); 58 | if (isNaN(currentInterviewStepNumber)) { 59 | return res.status(400).json({ error: 'Invalid currentInterviewStep format' }); 60 | } 61 | const updatedCandidate = await updateCandidateStage(id, applicationIdNumber, currentInterviewStepNumber); 62 | res.status(200).json({ message: 'Candidate stage updated successfully', data: updatedCandidate }); 63 | } catch (error: unknown) { 64 | if (error instanceof Error) { 65 | if (error.message === 'Error: Application not found') { 66 | res.status(404).json({ message: 'Application not found', error: error.message }); 67 | } else { 68 | res.status(400).json({ message: 'Error updating candidate stage', error: error.message }); 69 | } 70 | } else { 71 | res.status(500).json({ message: 'Error updating candidate stage', error: 'Unknown error' }); 72 | } 73 | } 74 | }; 75 | 76 | /** 77 | * @route GET /candidates 78 | * @description Get all candidates with pagination and filters 79 | * @access Public 80 | */ 81 | export const getAllCandidatesController = async (req: Request, res: Response) => { 82 | try { 83 | const page = req.query.page ? parseInt(req.query.page as string) : undefined; 84 | const limit = req.query.limit ? parseInt(req.query.limit as string) : undefined; 85 | const search = req.query.search as string; 86 | const sort = req.query.sort as string; 87 | const order = req.query.order as 'asc' | 'desc'; 88 | 89 | const result = await getAllCandidates({ 90 | page, 91 | limit, 92 | search, 93 | sort, 94 | order 95 | }); 96 | 97 | res.status(200).json(result); 98 | } catch (error: unknown) { 99 | if (error instanceof Error) { 100 | if (error.message.includes('must be greater than')) { 101 | res.status(400).json({ error: error.message }); 102 | } else { 103 | res.status(500).json({ error: 'Internal Server Error' }); 104 | } 105 | } else { 106 | res.status(500).json({ error: 'Internal Server Error' }); 107 | } 108 | } 109 | }; 110 | 111 | export { addCandidate }; 112 | -------------------------------------------------------------------------------- /memory-bank/systemPatterns.md: -------------------------------------------------------------------------------- 1 | # Patrones del Sistema 2 | 3 | ## Arquitectura General 4 | 5 | El Sistema de Seguimiento de Talento LTI sigue una arquitectura cliente-servidor con separación clara entre frontend y backend: 6 | 7 | ```mermaid 8 | flowchart TD 9 | Client[Cliente Web - React] <--> API[API REST - Express] 10 | API <--> DB[(Base de Datos - PostgreSQL)] 11 | API <--> FileStorage[Almacenamiento de Archivos] 12 | ``` 13 | 14 | ## Arquitectura del Backend 15 | 16 | El backend sigue principios de Domain-Driven Design (DDD) con una arquitectura en capas: 17 | 18 | ```mermaid 19 | flowchart TD 20 | subgraph Backend 21 | API[Controllers/Routes] --> App[Application Services] 22 | App --> Domain[Domain Model] 23 | Domain <--> Repos[Repositories] 24 | Repos --> Infra[Infrastructure - Prisma ORM] 25 | Infra --> DB[(Database)] 26 | end 27 | ``` 28 | 29 | ### Estructura de Carpetas Backend 30 | 31 | ``` 32 | backend/ 33 | ├── src/ 34 | │ ├── application/ # Servicios de aplicación 35 | │ ├── domain/ # Modelos de dominio y lógica de negocio 36 | │ ├── infrastructure/ # Implementaciones de repositorios y servicios técnicos 37 | │ ├── presentation/ # Controladores y definición de rutas API 38 | │ └── index.ts # Punto de entrada de la aplicación 39 | ├── prisma/ 40 | │ └── schema.prisma # Esquema de la base de datos 41 | ``` 42 | 43 | ## Patrones de Diseño Clave 44 | 45 | ### 1. Domain-Driven Design (DDD) 46 | 47 | El sistema implementa principios de DDD para modelar el dominio del negocio: 48 | 49 | - **Entidades**: Objetos con identidad única (Candidate, Position, Interview) 50 | - **Value Objects**: Objetos sin identidad propia (posiblemente Education, WorkExperience) 51 | - **Agregados**: Conjuntos de entidades tratadas como unidad (Candidate es raíz de agregado) 52 | - **Repositorios**: Encapsulan acceso a datos (CandidateRepository, PositionRepository) 53 | - **Servicios de Dominio**: Encapsulan lógica de negocio compleja 54 | 55 | ### 2. Repository Pattern 56 | 57 | Los repositorios abstraen el acceso a datos, permitiendo: 58 | 59 | - Centralizar la lógica de acceso a datos 60 | - Facilitar pruebas unitarias mediante mocks 61 | - Desacoplar la lógica de negocio de la infraestructura de datos 62 | 63 | ```typescript 64 | export interface CandidateRepository { 65 | findById(id: number): Promise; 66 | findAll(): Promise; 67 | save(candidate: Candidate): Promise; 68 | delete(id: number): Promise; 69 | } 70 | ``` 71 | 72 | ### 3. Service Layer Pattern 73 | 74 | Los servicios de aplicación coordinan operaciones complejas que involucran múltiples entidades o repositorios: 75 | 76 | ```typescript 77 | export class CandidateApplicationService { 78 | constructor( 79 | private candidateRepo: CandidateRepository, 80 | private resumeRepo: ResumeRepository 81 | ) {} 82 | 83 | async createCandidateWithResume(candidateData, resumeData): Promise { 84 | // Lógica de coordinación entre múltiples repositorios 85 | } 86 | } 87 | ``` 88 | 89 | ### 4. Model-View-Controller (MVC) 90 | 91 | El backend sigue un patrón MVC modificado: 92 | 93 | - **Model**: Representado por los modelos de dominio y repositorios 94 | - **View**: No hay views tradicionales, sino respuestas JSON de la API 95 | - **Controller**: Controladores que manejan requests HTTP y delegan a los servicios 96 | 97 | ## Modelo de Datos 98 | 99 | El sistema utiliza un modelo relacional gestionado por Prisma ORM. Las principales entidades son: 100 | 101 | 1. **Candidate**: Información de candidatos 102 | 2. **Education**: Historial educativo de candidatos 103 | 3. **WorkExperience**: Experiencia laboral de candidatos 104 | 4. **Resume**: Documentos CV asociados a candidatos 105 | 5. **Company**: Empresas que ofrecen posiciones 106 | 6. **Position**: Puestos de trabajo disponibles 107 | 7. **InterviewFlow**: Definición de procesos de entrevista 108 | 8. **InterviewStep**: Pasos individuales en un proceso de entrevista 109 | 9. **Application**: Aplicaciones de candidatos a posiciones 110 | 10. **Interview**: Resultados de entrevistas específicas 111 | 112 | ## Patrones de Integración 113 | 114 | 1. **API REST**: Interfaz principal entre frontend y backend 115 | 2. **DTOs**: Objetos de transferencia de datos para las comunicaciones API 116 | 3. **Middleware**: Para autenticación, validación y manejo de errores 117 | 118 | ## Patrones de Frontend 119 | 120 | El frontend utiliza: 121 | 122 | 1. **Componentes React**: Estructura modular de la interfaz 123 | 2. **Gestión de Estado**: Posiblemente con Context API o Redux 124 | 3. **Hooks**: Para lógica reutilizable y efectos secundarios 125 | 4. **Formularios Controlados**: Para recopilación de datos de usuario 126 | 5. **Routing**: Para navegación entre diferentes vistas -------------------------------------------------------------------------------- /memory-bank/progress.md: -------------------------------------------------------------------------------- 1 | # Progreso del Proyecto 2 | 3 | ## Funcionalidades Implementadas 4 | 5 | ### Backend 6 | 7 | 1. **Modelo de Datos** 8 | - ✅ Esquema Prisma definido con todas las entidades principales 9 | - ✅ Migraciones básicas establecidas 10 | - ✅ Seeding de datos de prueba 11 | 12 | 2. **API REST** 13 | - ✅ CRUD básico para Candidatos 14 | - ✅ Gestión de CVs (upload y almacenamiento) 15 | - ✅ Endpoints para experiencia educativa y laboral 16 | - ⚠️ Endpoints para Posiciones (parcialmente implementados) 17 | - ⚠️ Endpoints para Aplicaciones (parcialmente implementados) 18 | - ❌ Endpoints para Entrevistas (pendientes) 19 | 20 | 3. **Servicios de Aplicación** 21 | - ✅ Servicios para gestión de Candidatos 22 | - ⚠️ Servicios para gestión de Posiciones (parcialmente implementados) 23 | - ❌ Servicios para gestión de Aplicaciones y Entrevistas (pendientes) 24 | 25 | 4. **Implementación DDD** 26 | - ✅ Modelos de dominio básicos 27 | - ⚠️ Repositorios (parcialmente implementados) 28 | - ⚠️ Servicios de dominio (parcialmente implementados) 29 | - ❌ Agregados completos y Value Objects (pendientes) 30 | 31 | ### Frontend 32 | 33 | 1. **Componentes UI** 34 | - ✅ Formularios para creación/edición de Candidatos 35 | - ✅ Upload de CVs 36 | - ⚠️ Visualización de listas de Candidatos (básica) 37 | - ❌ Gestión de Posiciones (pendiente) 38 | - ❌ Gestión de Aplicaciones (pendiente) 39 | - ❌ Dashboards y reportes (pendientes) 40 | 41 | 2. **Integración con API** 42 | - ✅ Servicios para comunicación con endpoints de Candidatos 43 | - ❌ Integración con resto de endpoints (pendiente) 44 | 45 | 3. **Experiencia de Usuario** 46 | - ⚠️ Navegación básica implementada 47 | - ⚠️ Formularios con validación básica 48 | - ❌ Diseño responsivo completo (pendiente) 49 | - ❌ Feedback de usuario mejorado (pendiente) 50 | 51 | ### Infraestructura 52 | 53 | 1. **Configuración Docker** 54 | - ✅ Docker Compose para base de datos 55 | - ❌ Containerización completa de la aplicación (pendiente) 56 | 57 | 2. **Configuración de Desarrollo** 58 | - ✅ Variables de entorno 59 | - ✅ Scripts básicos npm 60 | - ❌ Pipeline CI/CD (pendiente) 61 | 62 | ## Tareas Pendientes Prioritarias 63 | 64 | 1. **Backend** 65 | - Completar implementación de endpoints para todas las entidades 66 | - Mejorar validación de datos 67 | - Optimizar consultas complejas con Prisma 68 | - Implementar manejo de errores más robusto 69 | - Expandir tests unitarios y de integración 70 | 71 | 2. **Frontend** 72 | - Desarrollar interfaces para gestión de Posiciones 73 | - Implementar vistas para Aplicaciones y proceso de entrevistas 74 | - Mejorar la experiencia de usuario y diseño visual 75 | - Implementar sistema de notificaciones en UI 76 | 77 | 3. **Seguridad** 78 | - Implementar autenticación y autorización 79 | - Mejorar seguridad en manejo de archivos 80 | - Sanitización y validación robusta de datos 81 | 82 | 4. **Infraestructura** 83 | - Completar containerización de la aplicación 84 | - Preparar configuración para entorno de producción 85 | - Implementar sistema de logging y monitorización 86 | 87 | ## Problemas Conocidos 88 | 89 | 1. **Rendimiento** 90 | - Consultas que involucran múltiples relaciones son lentas 91 | - Carga inicial de la aplicación puede ser optimizada 92 | 93 | 2. **UX/UI** 94 | - Inconsistencias en el diseño de algunos componentes 95 | - Falta de feedback adecuado en operaciones asíncronas 96 | 97 | 3. **Técnicos** 98 | - Manejo subóptimo de referencias circulares en el modelo de datos 99 | - Algunas consultas Prisma requieren optimización 100 | - Gestión de estado en frontend necesita refactorización 101 | 102 | ## Métricas y KPIs 103 | 104 | 1. **Cobertura de Tests** 105 | - Actual: ~20% (estimado) 106 | - Objetivo: >70% 107 | 108 | 2. **Velocidad de Desarrollo** 109 | - Tiempo promedio para implementar feature: 1-2 semanas 110 | - Objetivo: Reducir a <1 semana por feature mediana 111 | 112 | 3. **Rendimiento** 113 | - Tiempo de respuesta API: 200-500ms (promedio) 114 | - Objetivo: <200ms para el 95% de las peticiones 115 | 116 | ## Próximas Funcionalidades Planificadas 117 | 118 | 1. **Corto Plazo (1-2 Sprints)** 119 | - Completar CRUD para todas las entidades principales 120 | - Implementar búsqueda avanzada de candidatos 121 | - Mejorar UX en formularios de candidatos 122 | 123 | 2. **Medio Plazo (2-3 Meses)** 124 | - Sistema de usuarios y permisos 125 | - Dashboards analíticos 126 | - Sistema de notificaciones 127 | - Mejoras de rendimiento 128 | 129 | 3. **Largo Plazo (6+ Meses)** 130 | - Integración con servicios externos (LinkedIn, etc.) 131 | - Análisis predictivo para contrataciones 132 | - Soporte multilingüe 133 | - Aplicación móvil complementaria -------------------------------------------------------------------------------- /backend/prisma/migrations/20240528085016_/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Company" ( 3 | "id" SERIAL NOT NULL, 4 | "name" TEXT NOT NULL, 5 | 6 | CONSTRAINT "Company_pkey" PRIMARY KEY ("id") 7 | ); 8 | 9 | -- CreateTable 10 | CREATE TABLE "Employee" ( 11 | "id" SERIAL NOT NULL, 12 | "companyId" INTEGER NOT NULL, 13 | "name" TEXT NOT NULL, 14 | "email" TEXT NOT NULL, 15 | "role" TEXT NOT NULL, 16 | "isActive" BOOLEAN NOT NULL DEFAULT true, 17 | 18 | CONSTRAINT "Employee_pkey" PRIMARY KEY ("id") 19 | ); 20 | 21 | -- CreateTable 22 | CREATE TABLE "InterviewType" ( 23 | "id" SERIAL NOT NULL, 24 | "name" TEXT NOT NULL, 25 | "description" TEXT, 26 | 27 | CONSTRAINT "InterviewType_pkey" PRIMARY KEY ("id") 28 | ); 29 | 30 | -- CreateTable 31 | CREATE TABLE "InterviewFlow" ( 32 | "id" SERIAL NOT NULL, 33 | "description" TEXT, 34 | 35 | CONSTRAINT "InterviewFlow_pkey" PRIMARY KEY ("id") 36 | ); 37 | 38 | -- CreateTable 39 | CREATE TABLE "InterviewStep" ( 40 | "id" SERIAL NOT NULL, 41 | "interviewFlowId" INTEGER NOT NULL, 42 | "interviewTypeId" INTEGER NOT NULL, 43 | "name" TEXT NOT NULL, 44 | "orderIndex" INTEGER NOT NULL, 45 | 46 | CONSTRAINT "InterviewStep_pkey" PRIMARY KEY ("id") 47 | ); 48 | 49 | -- CreateTable 50 | CREATE TABLE "Position" ( 51 | "id" SERIAL NOT NULL, 52 | "companyId" INTEGER NOT NULL, 53 | "interviewFlowId" INTEGER NOT NULL, 54 | "title" TEXT NOT NULL, 55 | "description" TEXT NOT NULL, 56 | "status" TEXT NOT NULL DEFAULT 'Draft', 57 | "isVisible" BOOLEAN NOT NULL DEFAULT false, 58 | "location" TEXT NOT NULL, 59 | "jobDescription" TEXT NOT NULL, 60 | "requirements" TEXT, 61 | "responsibilities" TEXT, 62 | "salaryMin" DOUBLE PRECISION, 63 | "salaryMax" DOUBLE PRECISION, 64 | "employmentType" TEXT, 65 | "benefits" TEXT, 66 | "companyDescription" TEXT, 67 | "applicationDeadline" TIMESTAMP(3), 68 | "contactInfo" TEXT, 69 | 70 | CONSTRAINT "Position_pkey" PRIMARY KEY ("id") 71 | ); 72 | 73 | -- CreateTable 74 | CREATE TABLE "Application" ( 75 | "id" SERIAL NOT NULL, 76 | "positionId" INTEGER NOT NULL, 77 | "candidateId" INTEGER NOT NULL, 78 | "applicationDate" TIMESTAMP(3) NOT NULL, 79 | "currentInterviewStep" INTEGER NOT NULL, 80 | "notes" TEXT, 81 | 82 | CONSTRAINT "Application_pkey" PRIMARY KEY ("id") 83 | ); 84 | 85 | -- CreateTable 86 | CREATE TABLE "Interview" ( 87 | "id" SERIAL NOT NULL, 88 | "applicationId" INTEGER NOT NULL, 89 | "interviewStepId" INTEGER NOT NULL, 90 | "employeeId" INTEGER NOT NULL, 91 | "interviewDate" TIMESTAMP(3) NOT NULL, 92 | "result" TEXT, 93 | "score" INTEGER, 94 | "notes" TEXT, 95 | 96 | CONSTRAINT "Interview_pkey" PRIMARY KEY ("id") 97 | ); 98 | 99 | -- CreateIndex 100 | CREATE UNIQUE INDEX "Company_name_key" ON "Company"("name"); 101 | 102 | -- CreateIndex 103 | CREATE UNIQUE INDEX "Employee_email_key" ON "Employee"("email"); 104 | 105 | -- AddForeignKey 106 | ALTER TABLE "Employee" ADD CONSTRAINT "Employee_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 107 | 108 | -- AddForeignKey 109 | ALTER TABLE "InterviewStep" ADD CONSTRAINT "InterviewStep_interviewFlowId_fkey" FOREIGN KEY ("interviewFlowId") REFERENCES "InterviewFlow"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 110 | 111 | -- AddForeignKey 112 | ALTER TABLE "InterviewStep" ADD CONSTRAINT "InterviewStep_interviewTypeId_fkey" FOREIGN KEY ("interviewTypeId") REFERENCES "InterviewType"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 113 | 114 | -- AddForeignKey 115 | ALTER TABLE "Position" ADD CONSTRAINT "Position_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 116 | 117 | -- AddForeignKey 118 | ALTER TABLE "Position" ADD CONSTRAINT "Position_interviewFlowId_fkey" FOREIGN KEY ("interviewFlowId") REFERENCES "InterviewFlow"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 119 | 120 | -- AddForeignKey 121 | ALTER TABLE "Application" ADD CONSTRAINT "Application_positionId_fkey" FOREIGN KEY ("positionId") REFERENCES "Position"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 122 | 123 | -- AddForeignKey 124 | ALTER TABLE "Application" ADD CONSTRAINT "Application_candidateId_fkey" FOREIGN KEY ("candidateId") REFERENCES "Candidate"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 125 | 126 | -- AddForeignKey 127 | ALTER TABLE "Application" ADD CONSTRAINT "Application_currentInterviewStep_fkey" FOREIGN KEY ("currentInterviewStep") REFERENCES "InterviewStep"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 128 | 129 | -- AddForeignKey 130 | ALTER TABLE "Interview" ADD CONSTRAINT "Interview_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 131 | 132 | -- AddForeignKey 133 | ALTER TABLE "Interview" ADD CONSTRAINT "Interview_interviewStepId_fkey" FOREIGN KEY ("interviewStepId") REFERENCES "InterviewStep"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 134 | 135 | -- AddForeignKey 136 | ALTER TABLE "Interview" ADD CONSTRAINT "Interview_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 137 | -------------------------------------------------------------------------------- /backend/src/domain/repositories/IInterviewRepository.ts: -------------------------------------------------------------------------------- 1 | import { IBaseRepository } from './IBaseRepository'; 2 | import { Interview } from '../models/Interview'; 3 | 4 | /** 5 | * Criterios para búsqueda de entrevistas 6 | */ 7 | export interface InterviewSearchCriteria { 8 | applicationId?: number; 9 | employeeId?: number; 10 | interviewStepId?: number; 11 | dateFrom?: Date; 12 | dateTo?: Date; 13 | result?: string; 14 | minScore?: number; 15 | maxScore?: number; 16 | } 17 | 18 | /** 19 | * Estadísticas de entrevistas 20 | */ 21 | export interface InterviewStatistics { 22 | total: number; 23 | completed: number; 24 | pending: number; 25 | averageScore: number; 26 | scoreDistribution: { 27 | excellent: number; // 9-10 28 | good: number; // 7-8 29 | fair: number; // 5-6 30 | poor: number; // 1-4 31 | }; 32 | } 33 | 34 | /** 35 | * Resumen de entrevista para reportes 36 | */ 37 | export interface InterviewSummary { 38 | id: number; 39 | candidateName: string; 40 | positionTitle: string; 41 | interviewStepName: string; 42 | interviewDate: Date; 43 | interviewer: string; 44 | score?: number; 45 | result?: string; 46 | } 47 | 48 | /** 49 | * Interface para el repositorio de entrevistas. 50 | * Define operaciones específicas para la gestión de entrevistas. 51 | */ 52 | export interface IInterviewRepository extends IBaseRepository { 53 | /** 54 | * Busca entrevistas por aplicación 55 | * @param applicationId - ID de la aplicación 56 | * @returns Array de entrevistas de la aplicación 57 | */ 58 | findByApplicationId(applicationId: number): Promise; 59 | 60 | /** 61 | * Busca entrevistas por entrevistador 62 | * @param employeeId - ID del empleado entrevistador 63 | * @returns Array de entrevistas realizadas por el empleado 64 | */ 65 | findByEmployeeId(employeeId: number): Promise; 66 | 67 | /** 68 | * Busca entrevistas por paso de entrevista 69 | * @param interviewStepId - ID del paso de entrevista 70 | * @returns Array de entrevistas del paso específico 71 | */ 72 | findByInterviewStepId(interviewStepId: number): Promise; 73 | 74 | /** 75 | * Busca entrevistas por criterios específicos 76 | * @param criteria - Criterios de búsqueda 77 | * @returns Array de entrevistas que cumplen los criterios 78 | */ 79 | findByCriteria(criteria: InterviewSearchCriteria): Promise; 80 | 81 | /** 82 | * Busca entrevistas programadas para un rango de fechas 83 | * @param dateFrom - Fecha de inicio 84 | * @param dateTo - Fecha de fin 85 | * @returns Array de entrevistas en el rango 86 | */ 87 | findByDateRange(dateFrom: Date, dateTo: Date): Promise; 88 | 89 | /** 90 | * Calcula el promedio de puntuación para una aplicación 91 | * @param applicationId - ID de la aplicación 92 | * @returns Promedio de puntuación o 0 si no hay entrevistas 93 | */ 94 | getAverageScoreByApplication(applicationId: number): Promise; 95 | 96 | /** 97 | * Obtiene estadísticas de entrevistas 98 | * @param filters - Filtros opcionales 99 | * @returns Estadísticas de entrevistas 100 | */ 101 | getStatistics(filters?: { 102 | employeeId?: number; 103 | dateFrom?: Date; 104 | dateTo?: Date; 105 | }): Promise; 106 | 107 | /** 108 | * Obtiene entrevistas pendientes para un entrevistador 109 | * @param employeeId - ID del empleado entrevistador 110 | * @returns Array de entrevistas pendientes 111 | */ 112 | getPendingInterviews(employeeId: number): Promise; 113 | 114 | /** 115 | * Busca la última entrevista de una aplicación 116 | * @param applicationId - ID de la aplicación 117 | * @returns La última entrevista o null si no hay entrevistas 118 | */ 119 | getLastInterviewByApplication(applicationId: number): Promise; 120 | 121 | /** 122 | * Obtiene resumen de entrevistas para reportes 123 | * @param filters - Filtros opcionales 124 | * @returns Array de resúmenes de entrevistas 125 | */ 126 | getInterviewsSummary(filters?: { 127 | positionId?: number; 128 | employeeId?: number; 129 | dateFrom?: Date; 130 | dateTo?: Date; 131 | }): Promise; 132 | 133 | /** 134 | * Cuenta entrevistas por resultado 135 | * @param filters - Filtros opcionales 136 | * @returns Objeto con conteos por resultado 137 | */ 138 | countByResult(filters?: { 139 | employeeId?: number; 140 | dateFrom?: Date; 141 | dateTo?: Date; 142 | }): Promise>; 143 | 144 | /** 145 | * Verifica si existe una entrevista para una aplicación y paso específicos 146 | * @param applicationId - ID de la aplicación 147 | * @param interviewStepId - ID del paso de entrevista 148 | * @returns true si existe, false en caso contrario 149 | */ 150 | existsForApplicationAndStep(applicationId: number, interviewStepId: number): Promise; 151 | } -------------------------------------------------------------------------------- /frontend/src/components/Positions.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, ChangeEvent } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { Card, Container, Row, Col, Form, Button } from 'react-bootstrap'; 4 | import axios from 'axios'; 5 | 6 | type Position = { 7 | id: number; 8 | title: string; 9 | contactInfo: string; 10 | applicationDeadline: string; 11 | status: 'Open' | 'Contratado' | 'Cerrado' | 'Borrador'; 12 | }; 13 | 14 | const Positions: React.FC = () => { 15 | const [positions, setPositions] = useState([]); 16 | const navigate = useNavigate(); 17 | const [searchTerm, setSearchTerm] = useState(''); 18 | 19 | useEffect(() => { 20 | const fetchPositions = async () => { 21 | try { 22 | const response = await axios.get('http://localhost:3010/positions'); 23 | const formattedPositions = response.data.map((pos: Position) => ({ 24 | ...pos, 25 | applicationDeadline: formatDate(pos.applicationDeadline) 26 | })); 27 | setPositions(formattedPositions); 28 | } catch (error) { 29 | console.error('Failed to fetch positions', error); 30 | } 31 | }; 32 | 33 | fetchPositions(); 34 | }, []); 35 | 36 | const formatDate = (dateString: string) => { 37 | const date = new Date(dateString); 38 | const day = date.getDate().toString().padStart(2, '0'); 39 | const month = (date.getMonth() + 1).toString().padStart(2, '0'); // Months are zero-indexed 40 | const year = date.getFullYear(); 41 | return `${day}/${month}/${year}`; 42 | }; 43 | 44 | const handleSearch = (e: ChangeEvent) => { 45 | setSearchTerm(e.target.value); 46 | }; 47 | 48 | const filteredPositions = positions.filter(position => 49 | position.title.toLowerCase().includes(searchTerm.toLowerCase()) 50 | ); 51 | 52 | return ( 53 | 54 | 57 |

Posiciones

58 | 59 | 60 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | {filteredPositions.map((position, index) => ( 91 | 92 | 93 | 94 | {position.title} 95 | 96 | Manager: {position.contactInfo}
97 | Deadline: {position.applicationDeadline} 98 |
99 | 100 | {position.status} 101 | 102 |
103 | 104 | 105 |
106 |
107 |
108 | 109 | ))} 110 |
111 |
112 | ); 113 | }; 114 | 115 | export default Positions; -------------------------------------------------------------------------------- /backend/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? 5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init 6 | 7 | generator client { 8 | provider = "prisma-client-js" 9 | binaryTargets = ["native", "debian-openssl-3.0.x"] 10 | } 11 | 12 | datasource db { 13 | provider = "postgresql" 14 | url = "postgresql://LTIdbUser:D1ymf8wyQEGthFR1E9xhCq@localhost:5432/LTIdb" 15 | } 16 | 17 | model Candidate { 18 | id Int @id @default(autoincrement()) 19 | firstName String @db.VarChar(100) 20 | lastName String @db.VarChar(100) 21 | email String @unique @db.VarChar(255) 22 | phone String? @db.VarChar(15) 23 | address String? @db.VarChar(100) 24 | educations Education[] 25 | workExperiences WorkExperience[] 26 | resumes Resume[] 27 | applications Application[] 28 | } 29 | 30 | model Education { 31 | id Int @id @default(autoincrement()) 32 | institution String @db.VarChar(100) 33 | title String @db.VarChar(250) 34 | startDate DateTime 35 | endDate DateTime? 36 | candidateId Int 37 | candidate Candidate @relation(fields: [candidateId], references: [id]) 38 | } 39 | 40 | model WorkExperience { 41 | id Int @id @default(autoincrement()) 42 | company String @db.VarChar(100) 43 | position String @db.VarChar(100) 44 | description String? @db.VarChar(200) 45 | startDate DateTime 46 | endDate DateTime? 47 | candidateId Int 48 | candidate Candidate @relation(fields: [candidateId], references: [id]) 49 | } 50 | 51 | model Resume { 52 | id Int @id @default(autoincrement()) 53 | filePath String @db.VarChar(500) 54 | fileType String @db.VarChar(50) 55 | uploadDate DateTime 56 | candidateId Int 57 | candidate Candidate @relation(fields: [candidateId], references: [id]) 58 | } 59 | 60 | model Company { 61 | id Int @id @default(autoincrement()) 62 | name String @unique 63 | employees Employee[] 64 | positions Position[] 65 | } 66 | 67 | model Employee { 68 | id Int @id @default(autoincrement()) 69 | companyId Int 70 | company Company @relation(fields: [companyId], references: [id]) 71 | name String 72 | email String @unique 73 | role String 74 | isActive Boolean @default(true) 75 | interviews Interview[] 76 | } 77 | 78 | model InterviewType { 79 | id Int @id @default(autoincrement()) 80 | name String 81 | description String? 82 | interviewSteps InterviewStep[] 83 | } 84 | 85 | model InterviewFlow { 86 | id Int @id @default(autoincrement()) 87 | description String? 88 | interviewSteps InterviewStep[] 89 | positions Position[] 90 | } 91 | 92 | model InterviewStep { 93 | id Int @id @default(autoincrement()) 94 | interviewFlowId Int 95 | interviewTypeId Int 96 | name String 97 | orderIndex Int 98 | interviewFlow InterviewFlow @relation(fields: [interviewFlowId], references: [id]) 99 | interviewType InterviewType @relation(fields: [interviewTypeId], references: [id]) 100 | applications Application[] 101 | interviews Interview[] 102 | } 103 | 104 | model Position { 105 | id Int @id @default(autoincrement()) 106 | companyId Int 107 | interviewFlowId Int 108 | title String 109 | description String 110 | status String @default("Draft") 111 | isVisible Boolean @default(false) 112 | location String 113 | jobDescription String 114 | requirements String? 115 | responsibilities String? 116 | salaryMin Float? 117 | salaryMax Float? 118 | employmentType String? 119 | benefits String? 120 | companyDescription String? 121 | applicationDeadline DateTime? 122 | contactInfo String? 123 | company Company @relation(fields: [companyId], references: [id]) 124 | interviewFlow InterviewFlow @relation(fields: [interviewFlowId], references: [id]) 125 | applications Application[] 126 | } 127 | 128 | model Application { 129 | id Int @id @default(autoincrement()) 130 | positionId Int 131 | candidateId Int 132 | applicationDate DateTime 133 | currentInterviewStep Int 134 | notes String? 135 | position Position @relation(fields: [positionId], references: [id]) 136 | candidate Candidate @relation(fields: [candidateId], references: [id]) 137 | interviewStep InterviewStep @relation(fields: [currentInterviewStep], references: [id]) 138 | interviews Interview[] 139 | } 140 | 141 | model Interview { 142 | id Int @id @default(autoincrement()) 143 | applicationId Int 144 | interviewStepId Int 145 | employeeId Int 146 | interviewDate DateTime 147 | result String? 148 | score Int? 149 | notes String? 150 | application Application @relation(fields: [applicationId], references: [id]) 151 | interviewStep InterviewStep @relation(fields: [interviewStepId], references: [id]) 152 | employee Employee @relation(fields: [employeeId], references: [id]) 153 | } 154 | -------------------------------------------------------------------------------- /frontend/src/components/PositionDetails.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useParams } from 'react-router-dom'; 3 | import { Container, Row, Offcanvas, Button } from 'react-bootstrap'; 4 | import { DragDropContext } from 'react-beautiful-dnd'; 5 | import StageColumn from './StageColumn'; 6 | import CandidateDetails from './CandidateDetails'; 7 | import { useNavigate } from 'react-router-dom'; 8 | 9 | const PositionsDetails = () => { 10 | const { id } = useParams(); 11 | const [stages, setStages] = useState([]); 12 | const [positionName, setPositionName] = useState(''); 13 | const [selectedCandidate, setSelectedCandidate] = useState(null); 14 | const navigate = useNavigate(); 15 | 16 | useEffect(() => { 17 | const fetchInterviewFlow = async () => { 18 | try { 19 | const response = await fetch(`http://localhost:3010/positions/${id}/interviewFlow`); 20 | const data = await response.json(); 21 | const interviewSteps = data.interviewFlow.interviewFlow.interviewSteps.map(step => ({ 22 | title: step.name, 23 | id: step.id, 24 | candidates: [] 25 | })); 26 | setStages(interviewSteps); 27 | setPositionName(data.interviewFlow.positionName); 28 | } catch (error) { 29 | console.error('Error fetching interview flow:', error); 30 | } 31 | }; 32 | 33 | const fetchCandidates = async () => { 34 | try { 35 | const response = await fetch(`http://localhost:3010/positions/${id}/candidates`); 36 | const candidates = await response.json(); 37 | setStages(prevStages => 38 | prevStages.map(stage => ({ 39 | ...stage, 40 | candidates: candidates 41 | .filter(candidate => candidate.currentInterviewStep === stage.title) 42 | .map(candidate => ({ 43 | id: candidate.candidateId.toString(), 44 | name: candidate.fullName, 45 | rating: candidate.averageScore, 46 | applicationId: candidate.applicationId 47 | })) 48 | })) 49 | ); 50 | } catch (error) { 51 | console.error('Error fetching candidates:', error); 52 | } 53 | }; 54 | 55 | fetchInterviewFlow(); 56 | fetchCandidates(); 57 | }, [id]); 58 | 59 | const updateCandidateStep = async (candidateId, applicationId, newStep) => { 60 | try { 61 | const response = await fetch(`http://localhost:3010/candidates/${candidateId}`, { 62 | method: 'PUT', 63 | headers: { 64 | 'Content-Type': 'application/json' 65 | }, 66 | body: JSON.stringify({ 67 | applicationId: Number(applicationId), 68 | currentInterviewStep: Number(newStep) 69 | }) 70 | }); 71 | 72 | if (!response.ok) { 73 | throw new Error('Error updating candidate step'); 74 | } 75 | } catch (error) { 76 | console.error('Error updating candidate step:', error); 77 | } 78 | }; 79 | 80 | const onDragEnd = (result) => { 81 | const { source, destination, draggableId } = result; 82 | 83 | if (!destination) { 84 | return; 85 | } 86 | 87 | // Convert droppableId to number since it's stored as string in StageColumn 88 | const sourceStageIndex = Number(source.droppableId); 89 | const destStageIndex = Number(destination.droppableId); 90 | 91 | const sourceStage = stages[sourceStageIndex]; 92 | const destStage = stages[destStageIndex]; 93 | 94 | // Find the candidate by draggableId instead of using index 95 | // This ensures we get the correct candidate even when the array is sorted 96 | const candidateIndex = sourceStage.candidates.findIndex( 97 | candidate => candidate.id === draggableId 98 | ); 99 | 100 | if (candidateIndex === -1) { 101 | console.error('Candidate not found in source stage'); 102 | return; 103 | } 104 | 105 | // Remove the candidate from source stage 106 | const [movedCandidate] = sourceStage.candidates.splice(candidateIndex, 1); 107 | 108 | // Insert at the destination index (ensure it's within bounds) 109 | // Note: destination.index is from the sorted view, but we insert into unsorted array 110 | // The array will be re-sorted on next render, so position is approximate 111 | const insertIndex = Math.min(destination.index, destStage.candidates.length); 112 | destStage.candidates.splice(insertIndex, 0, movedCandidate); 113 | 114 | setStages([...stages]); 115 | 116 | const destStageId = destStage.id; 117 | 118 | updateCandidateStep(movedCandidate.id, movedCandidate.applicationId, destStageId); 119 | }; 120 | 121 | const handleCardClick = (candidate) => { 122 | setSelectedCandidate(candidate); 123 | }; 124 | 125 | const closeSlide = () => { 126 | setSelectedCandidate(null); 127 | }; 128 | 129 | return ( 130 | 131 | 134 |

{positionName}

135 | 136 | 137 | {stages.map((stage, index) => ( 138 | 139 | ))} 140 | 141 | 142 | 143 |
144 | ); 145 | }; 146 | 147 | export default PositionsDetails; 148 | 149 | -------------------------------------------------------------------------------- /backend/src/application/services/positionService.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | import { Position } from '../../domain/models/Position'; 3 | import { validatePositionUpdate } from '../validator'; 4 | 5 | const prisma = new PrismaClient(); 6 | 7 | const calculateAverageScore = (interviews: any[]) => { 8 | if (interviews.length === 0) return 0; 9 | const totalScore = interviews.reduce((acc, interview) => acc + (interview.score || 0), 0); 10 | return totalScore / interviews.length; 11 | }; 12 | 13 | export const getCandidatesByPositionService = async (positionId: number) => { 14 | try { 15 | const applications = await prisma.application.findMany({ 16 | where: { positionId }, 17 | include: { 18 | candidate: true, 19 | interviews: true, 20 | interviewStep: true 21 | } 22 | }); 23 | 24 | return applications.map(app => ({ 25 | fullName: `${app.candidate.firstName} ${app.candidate.lastName}`, 26 | currentInterviewStep: app.interviewStep.name, 27 | candidateId: app.candidateId, 28 | applicationId: app.id, 29 | averageScore: calculateAverageScore(app.interviews) 30 | })); 31 | } catch (error) { 32 | console.error('Error retrieving candidates by position:', error); 33 | throw new Error('Error retrieving candidates by position'); 34 | } 35 | }; 36 | 37 | export const getInterviewFlowByPositionService = async (positionId: number) => { 38 | const positionWithInterviewFlow = await prisma.position.findUnique({ 39 | where: { id: positionId }, 40 | include: { 41 | interviewFlow: { 42 | include: { 43 | interviewSteps: true 44 | } 45 | } 46 | } 47 | }); 48 | 49 | if (!positionWithInterviewFlow) { 50 | throw new Error('Position not found'); 51 | } 52 | 53 | // Formatear la respuesta para incluir el nombre de la posición y el flujo de entrevistas 54 | return { 55 | positionName: positionWithInterviewFlow.title, 56 | interviewFlow: { 57 | id: positionWithInterviewFlow.interviewFlow.id, 58 | description: positionWithInterviewFlow.interviewFlow.description, 59 | interviewSteps: positionWithInterviewFlow.interviewFlow.interviewSteps.map(step => ({ 60 | id: step.id, 61 | interviewFlowId: step.interviewFlowId, 62 | interviewTypeId: step.interviewTypeId, 63 | name: step.name, 64 | orderIndex: step.orderIndex 65 | })) 66 | } 67 | }; 68 | }; 69 | 70 | export const getAllPositionsService = async () => { 71 | try { 72 | return await prisma.position.findMany({ 73 | where: { isVisible: true } 74 | }); 75 | } catch (error) { 76 | console.error('Error retrieving all positions:', error); 77 | throw new Error('Error retrieving all positions'); 78 | } 79 | }; 80 | 81 | export const getPositionByIdService = async (positionId: number) => { 82 | try { 83 | const position = await Position.findOne(positionId); 84 | if (!position) { 85 | throw new Error('Position not found'); 86 | } 87 | return position; 88 | } catch (error) { 89 | console.error('Error retrieving position by ID:', error); 90 | if (error instanceof Error) { 91 | throw error; 92 | } 93 | throw new Error('Error retrieving position by ID'); 94 | } 95 | }; 96 | 97 | export const getCandidateNamesByPositionService = async (positionId: number) => { 98 | try { 99 | const applications = await prisma.application.findMany({ 100 | where: { positionId }, 101 | include: { 102 | candidate: true 103 | } 104 | }); 105 | 106 | return applications.map(app => ({ 107 | candidateId: app.candidateId, 108 | fullName: `${app.candidate.firstName} ${app.candidate.lastName}` 109 | })); 110 | } catch (error) { 111 | console.error('Error retrieving candidate names by position:', error); 112 | throw new Error('Error retrieving candidate names by position'); 113 | } 114 | }; 115 | 116 | /** 117 | * Actualiza una posición existente 118 | * @param positionId - ID de la posición a actualizar 119 | * @param updateData - Datos a actualizar 120 | * @returns Posición actualizada 121 | */ 122 | export const updatePositionService = async (positionId: number, updateData: any) => { 123 | try { 124 | // Validar que la posición existe 125 | const existingPosition = await Position.findOne(positionId); 126 | if (!existingPosition) { 127 | throw new Error('Position not found'); 128 | } 129 | 130 | // Validar los datos de entrada 131 | validatePositionUpdate(updateData); 132 | 133 | // Verificar que companyId e interviewFlowId existen si se están actualizando 134 | if (updateData.companyId) { 135 | const company = await prisma.company.findUnique({ 136 | where: { id: updateData.companyId } 137 | }); 138 | if (!company) { 139 | throw new Error('Company not found'); 140 | } 141 | } 142 | 143 | if (updateData.interviewFlowId) { 144 | const interviewFlow = await prisma.interviewFlow.findUnique({ 145 | where: { id: updateData.interviewFlowId } 146 | }); 147 | if (!interviewFlow) { 148 | throw new Error('Interview flow not found'); 149 | } 150 | } 151 | 152 | // Actualizar la posición usando el modelo de dominio 153 | const updatedPosition = new Position({ 154 | ...existingPosition, 155 | ...updateData, 156 | id: positionId 157 | }); 158 | 159 | const result = await updatedPosition.save(); 160 | 161 | return result; 162 | } catch (error) { 163 | console.error('Error updating position:', error); 164 | if (error instanceof Error) { 165 | throw error; 166 | } 167 | throw new Error('Error updating position'); 168 | } 169 | }; -------------------------------------------------------------------------------- /frontend/cypress/e2e/candidates.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Candidates API', () => { 2 | const API_URL = 'http://localhost:3010'; 3 | 4 | beforeEach(() => { 5 | // Limpiar cualquier estado previo 6 | cy.window().then((win) => { 7 | win.localStorage.clear(); 8 | }); 9 | }); 10 | 11 | describe('GET /candidates', () => { 12 | it('should return a list of candidates successfully', () => { 13 | cy.request({ 14 | method: 'GET', 15 | url: `${API_URL}/candidates`, 16 | }).then((response) => { 17 | expect(response.status).to.eq(200); 18 | expect(response.body).to.be.an('object'); 19 | expect(response.body.data).to.be.an('array'); 20 | expect(response.body.metadata).to.be.an('object'); 21 | expect(response.body.metadata).to.have.property('total'); 22 | expect(response.body.metadata).to.have.property('page'); 23 | expect(response.body.metadata).to.have.property('limit'); 24 | expect(response.body.metadata).to.have.property('totalPages'); 25 | }); 26 | }); 27 | 28 | it('should handle pagination correctly', () => { 29 | cy.request({ 30 | method: 'GET', 31 | url: `${API_URL}/candidates?page=1&limit=10`, 32 | }).then((response) => { 33 | expect(response.status).to.eq(200); 34 | expect(response.body.data).to.have.length.at.most(10); 35 | expect(response.body.metadata).to.have.property('total'); 36 | expect(response.body.metadata).to.have.property('page'); 37 | expect(response.body.metadata).to.have.property('limit'); 38 | expect(response.body.metadata.page).to.eq(1); 39 | expect(response.body.metadata.limit).to.eq(10); 40 | }); 41 | }); 42 | 43 | it('should filter candidates by search term', () => { 44 | // Primero verificar que hay candidatos en la base de datos 45 | cy.request({ 46 | method: 'GET', 47 | url: `${API_URL}/candidates`, 48 | }).then((initialResponse) => { 49 | if (initialResponse.body.data.length > 0) { 50 | // Si hay candidatos, usar el primer nombre para buscar 51 | const firstCandidate = initialResponse.body.data[0]; 52 | const searchTerm = firstCandidate.firstName.substring(0, 3); 53 | 54 | cy.request({ 55 | method: 'GET', 56 | url: `${API_URL}/candidates?search=${searchTerm}`, 57 | }).then((response) => { 58 | expect(response.status).to.eq(200); 59 | expect(response.body.data).to.be.an('array'); 60 | // Verificar que los resultados contienen el término de búsqueda 61 | response.body.data.forEach((candidate: any) => { 62 | expect( 63 | candidate.firstName.toLowerCase().includes(searchTerm.toLowerCase()) || 64 | candidate.lastName.toLowerCase().includes(searchTerm.toLowerCase()) || 65 | candidate.email.toLowerCase().includes(searchTerm.toLowerCase()) 66 | ).to.be.true; 67 | }); 68 | }); 69 | } else { 70 | // Si no hay candidatos, solo verificar que la búsqueda devuelve array vacío 71 | cy.request({ 72 | method: 'GET', 73 | url: `${API_URL}/candidates?search=test`, 74 | }).then((response) => { 75 | expect(response.status).to.eq(200); 76 | expect(response.body.data).to.be.an('array').that.is.empty; 77 | }); 78 | } 79 | }); 80 | }); 81 | 82 | it('should handle invalid page number', () => { 83 | cy.request({ 84 | method: 'GET', 85 | url: `${API_URL}/candidates?page=-1`, 86 | failOnStatusCode: false, 87 | }).then((response) => { 88 | expect(response.status).to.eq(400); 89 | expect(response.body).to.have.property('error'); 90 | expect(response.body.error).to.include('must be greater than'); 91 | }); 92 | }); 93 | 94 | it('should handle invalid limit number', () => { 95 | cy.request({ 96 | method: 'GET', 97 | url: `${API_URL}/candidates?limit=0`, 98 | failOnStatusCode: false, 99 | }).then((response) => { 100 | expect(response.status).to.eq(400); 101 | expect(response.body).to.have.property('error'); 102 | expect(response.body.error).to.include('must be greater than'); 103 | }); 104 | }); 105 | 106 | it('should return empty array when no candidates match filters', () => { 107 | cy.request({ 108 | method: 'GET', 109 | url: `${API_URL}/candidates?search=nonexistentcandidate123xyz`, 110 | }).then((response) => { 111 | expect(response.status).to.eq(200); 112 | expect(response.body.data).to.be.an('array').that.is.empty; 113 | expect(response.body.metadata.total).to.eq(0); 114 | }); 115 | }); 116 | 117 | it('should sort candidates by specified field', () => { 118 | cy.request({ 119 | method: 'GET', 120 | url: `${API_URL}/candidates?sort=firstName&order=asc`, 121 | }).then((response) => { 122 | expect(response.status).to.eq(200); 123 | expect(response.body.data).to.be.an('array'); 124 | 125 | if (response.body.data.length > 1) { 126 | // Solo verificar ordenamiento si hay más de un candidato 127 | const firstNames = response.body.data.map((candidate: any) => candidate.firstName); 128 | const sortedFirstNames = [...firstNames].sort(); 129 | expect(firstNames).to.deep.equal(sortedFirstNames); 130 | } 131 | }); 132 | }); 133 | 134 | it('should sort candidates in descending order', () => { 135 | cy.request({ 136 | method: 'GET', 137 | url: `${API_URL}/candidates?sort=firstName&order=desc`, 138 | }).then((response) => { 139 | expect(response.status).to.eq(200); 140 | expect(response.body.data).to.be.an('array'); 141 | 142 | if (response.body.data.length > 1) { 143 | // Solo verificar ordenamiento si hay más de un candidato 144 | const firstNames = response.body.data.map((candidate: any) => candidate.firstName); 145 | const sortedFirstNames = [...firstNames].sort().reverse(); 146 | expect(firstNames).to.deep.equal(sortedFirstNames); 147 | } 148 | }); 149 | }); 150 | }); 151 | }); -------------------------------------------------------------------------------- /.cursor/rules/memory-bank-rules.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: false 5 | --- 6 | # Cursor's Memory Bank 7 | 8 | I am Cursor, an expert software engineer with a unique characteristic: my memory resets completely between sessions. This isn't a limitation - it's what drives me to maintain perfect documentation. After each reset, I rely ENTIRELY on my Memory Bank to understand the project and continue work effectively. I MUST read ALL memory bank files at the start of EVERY task - this is not optional. 9 | 10 | ## Memory Bank Structure 11 | 12 | The Memory Bank consists of required core files and optional context files, all in Markdown format. Files build upon each other in a clear hierarchy: 13 | 14 | ```mermaid 15 | flowchart TD 16 | PB[projectbrief.md] --> PC[productContext.md] 17 | PB --> SP[systemPatterns.md] 18 | PB --> TC[techContext.md] 19 | 20 | PC --> AC[activeContext.md] 21 | SP --> AC 22 | TC --> AC 23 | 24 | AC --> P[progress.md] 25 | ``` 26 | 27 | ### Core Files (Required) 28 | 1. `projectbrief.md` 29 | - Foundation document that shapes all other files 30 | - Created at project start if it doesn't exist 31 | - Defines core requirements and goals 32 | - Source of truth for project scope 33 | 34 | 2. `productContext.md` 35 | - Why this project exists 36 | - Problems it solves 37 | - How it should work 38 | - User experience goals 39 | 40 | 3. `activeContext.md` 41 | - Current work focus 42 | - Recent changes 43 | - Next steps 44 | - Active decisions and considerations 45 | 46 | 4. `systemPatterns.md` 47 | - System architecture 48 | - Key technical decisions 49 | - Design patterns in use 50 | - Component relationships 51 | 52 | 5. `techContext.md` 53 | - Technologies used 54 | - Development setup 55 | - Technical constraints 56 | - Dependencies 57 | 58 | 6. `progress.md` 59 | - What works 60 | - What's left to build 61 | - Current status 62 | - Known issues 63 | 64 | ### Additional Context 65 | Create additional files/folders within memory-bank/ when they help organize: 66 | - Complex feature documentation 67 | - Integration specifications 68 | - API documentation 69 | - Testing strategies 70 | - Deployment procedures 71 | 72 | ## Core Workflows 73 | 74 | ### Plan Mode 75 | ```mermaid 76 | flowchart TD 77 | Start[Start] --> ReadFiles[Read Memory Bank] 78 | ReadFiles --> CheckFiles{Files Complete?} 79 | 80 | CheckFiles -->|No| Plan[Create Plan] 81 | Plan --> Document[Document in Chat] 82 | 83 | CheckFiles -->|Yes| Verify[Verify Context] 84 | Verify --> Strategy[Develop Strategy] 85 | Strategy --> Present[Present Approach] 86 | ``` 87 | 88 | ### Act Mode 89 | ```mermaid 90 | flowchart TD 91 | Start[Start] --> Context[Check Memory Bank] 92 | Context --> Update[Update Documentation] 93 | Update --> Rules[Update .cursorrules if needed] 94 | Rules --> Execute[Execute Task] 95 | Execute --> Document[Document Changes] 96 | ``` 97 | 98 | ## Documentation Updates 99 | 100 | Memory Bank updates occur when: 101 | 1. Discovering new project patterns 102 | 2. After implementing significant changes 103 | 3. When user requests with **update memory bank** (MUST review ALL files) 104 | 4. When context needs clarification 105 | 106 | ```mermaid 107 | flowchart TD 108 | Start[Update Process] 109 | 110 | subgraph Process 111 | P1[Review ALL Files] 112 | P2[Document Current State] 113 | P3[Clarify Next Steps] 114 | P4[Update .cursorrules] 115 | 116 | P1 --> P2 --> P3 --> P4 117 | end 118 | 119 | Start --> Process 120 | ``` 121 | 122 | Note: When triggered by **update memory bank**, I MUST review every memory bank file, even if some don't require updates. Focus particularly on activeContext.md and progress.md as they track current state. 123 | 124 | ## Project Intelligence (.cursorrules) 125 | 126 | The .cursorrules file is my learning journal for each project. It captures important patterns, preferences, and project intelligence that help me work more effectively. As I work with you and the project, I'll discover and document key insights that aren't obvious from the code alone. 127 | 128 | ```mermaid 129 | flowchart TD 130 | Start{Discover New Pattern} 131 | 132 | subgraph Learn [Learning Process] 133 | D1[Identify Pattern] 134 | D2[Validate with User] 135 | D3[Document in .cursorrules] 136 | end 137 | 138 | subgraph Apply [Usage] 139 | A1[Read .cursorrules] 140 | A2[Apply Learned Patterns] 141 | A3[Improve Future Work] 142 | end 143 | 144 | Start --> Learn 145 | Learn --> Apply 146 | ``` 147 | 148 | ### What to Capture 149 | - Critical implementation paths 150 | - User preferences and workflow 151 | - Project-specific patterns 152 | - Known challenges 153 | - Evolution of project decisions 154 | - Tool usage patterns 155 | 156 | The format is flexible - focus on capturing valuable insights that help me work more effectively with you and the project. Think of .cursorrules as a living document that grows smarter as we work together. 157 | 158 | REMEMBER: After every memory reset, I begin completely fresh. The Memory Bank is my only link to previous work. It must be maintained with precision and clarity, as my effectiveness depends entirely on its accuracy. 159 | 160 | 161 | # Planning 162 | When asked to enter "Planner Mode" or using the /plan command, deeply reflect upon the changes being asked and analyze existing code to map the full scope of changes needed. Before proposing a plan and in case the task is not clear, ask 4-6 clarifying questions based on your findings. Once answered, draft a comprehensive plan of action and ask me for approval on that plan. Once approved, implement all steps in that plan. After completing each phase/step, mention what was just completed and what the next steps are + phases remaining after these steps 163 | 164 | # Documentation Updates 165 | When using the /updatedocs command, I will: 166 | 1. Review all recent API changes in the codebase 167 | 2. Identify which documentation files need updates based on the changes: 168 | - For data model changes: Update ModeloDatos.md 169 | - For API changes: Update api-spec.yaml 170 | 3. Update each affected documentation file in English, maintaining consistency with existing documentation 171 | 4. Ensure all documentation is properly formatted and follows the established structure 172 | 5. Verify that all changes are accurately reflected in the documentation 173 | 6. Report which files were updated and what changes were made 174 | 175 | -------------------------------------------------------------------------------- /backend/src/application/services/candidateService.ts: -------------------------------------------------------------------------------- 1 | import { Candidate } from '../../domain/models/Candidate'; 2 | import { validateCandidateData } from '../validator'; 3 | import { Education } from '../../domain/models/Education'; 4 | import { WorkExperience } from '../../domain/models/WorkExperience'; 5 | import { Resume } from '../../domain/models/Resume'; 6 | import { Application } from '../../domain/models/Application'; 7 | import { PrismaClient } from '@prisma/client'; 8 | 9 | const prisma = new PrismaClient(); 10 | 11 | export const addCandidate = async (candidateData: any) => { 12 | try { 13 | validateCandidateData(candidateData); // Validar los datos del candidato 14 | } catch (error: any) { 15 | throw new Error(error); 16 | } 17 | 18 | const candidate = new Candidate(candidateData); // Crear una instancia del modelo Candidate 19 | try { 20 | const savedCandidate = await candidate.save(); // Guardar el candidato en la base de datos 21 | const candidateId = savedCandidate.id; // Obtener el ID del candidato guardado 22 | 23 | // Guardar la educación del candidato 24 | if (candidateData.educations) { 25 | for (const education of candidateData.educations) { 26 | const educationModel = new Education(education); 27 | educationModel.candidateId = candidateId; 28 | await educationModel.save(); 29 | candidate.educations.push(educationModel); 30 | } 31 | } 32 | 33 | // Guardar la experiencia laboral del candidato 34 | if (candidateData.workExperiences) { 35 | for (const experience of candidateData.workExperiences) { 36 | const experienceModel = new WorkExperience(experience); 37 | experienceModel.candidateId = candidateId; 38 | await experienceModel.save(); 39 | candidate.workExperiences.push(experienceModel); 40 | } 41 | } 42 | 43 | // Guardar los archivos de CV 44 | if (candidateData.cv && Object.keys(candidateData.cv).length > 0) { 45 | const resumeModel = new Resume(candidateData.cv); 46 | resumeModel.candidateId = candidateId; 47 | await resumeModel.save(); 48 | candidate.resumes.push(resumeModel); 49 | } 50 | return savedCandidate; 51 | } catch (error: any) { 52 | if (error.code === 'P2002') { 53 | // Unique constraint failed on the fields: (`email`) 54 | throw new Error('The email already exists in the database'); 55 | } else { 56 | throw error; 57 | } 58 | } 59 | }; 60 | 61 | export const findCandidateById = async (id: number): Promise => { 62 | try { 63 | const candidate = await Candidate.findOne(id); // Cambio aquí: pasar directamente el id 64 | return candidate; 65 | } catch (error) { 66 | console.error('Error al buscar el candidato:', error); 67 | throw new Error('Error al recuperar el candidato'); 68 | } 69 | }; 70 | 71 | export const updateCandidateStage = async (id: number, applicationIdNumber: number, currentInterviewStep: number) => { 72 | try { 73 | const application = await Application.findOneByPositionCandidateId(applicationIdNumber, id); 74 | if (!application) { 75 | throw new Error('Application not found'); 76 | } 77 | 78 | // Actualizar solo la etapa de la entrevista actual de la aplicación específica 79 | application.currentInterviewStep = currentInterviewStep; 80 | 81 | // Guardar la aplicación actualizada 82 | await application.save(); 83 | 84 | return application; 85 | } catch (error: any) { 86 | throw new Error(error); 87 | } 88 | }; 89 | 90 | export const getAllCandidates = async (options: { 91 | page?: number; 92 | limit?: number; 93 | search?: string; 94 | sort?: string; 95 | order?: 'asc' | 'desc'; 96 | }) => { 97 | try { 98 | const { 99 | page = 1, 100 | limit = 10, 101 | search, 102 | sort = 'firstName', 103 | order = 'asc' 104 | } = options; 105 | 106 | // Validar parámetros 107 | if (page < 1) { 108 | throw new Error('Page number must be greater than 0'); 109 | } 110 | if (limit < 1) { 111 | throw new Error('Limit must be greater than 0'); 112 | } 113 | 114 | const skip = (page - 1) * limit; 115 | 116 | // Construir filtros de búsqueda 117 | const where: any = {}; 118 | if (search) { 119 | where.OR = [ 120 | { firstName: { contains: search, mode: 'insensitive' } }, 121 | { lastName: { contains: search, mode: 'insensitive' } }, 122 | { email: { contains: search, mode: 'insensitive' } } 123 | ]; 124 | } 125 | 126 | // Construir ordenamiento 127 | const orderBy: any = {}; 128 | if (sort === 'firstName' || sort === 'lastName' || sort === 'email') { 129 | orderBy[sort] = order; 130 | } else { 131 | orderBy.firstName = order; 132 | } 133 | 134 | // Obtener candidatos con paginación 135 | const [candidates, total] = await Promise.all([ 136 | prisma.candidate.findMany({ 137 | where, 138 | orderBy, 139 | skip, 140 | take: limit, 141 | include: { 142 | educations: true, 143 | workExperiences: true, 144 | resumes: true, 145 | applications: { 146 | include: { 147 | position: { 148 | select: { 149 | id: true, 150 | title: true 151 | } 152 | } 153 | } 154 | } 155 | } 156 | }), 157 | prisma.candidate.count({ where }) 158 | ]); 159 | 160 | return { 161 | data: candidates, 162 | metadata: { 163 | total, 164 | page, 165 | limit, 166 | totalPages: Math.ceil(total / limit) 167 | } 168 | }; 169 | } catch (error: any) { 170 | console.error('Error retrieving candidates:', error); 171 | throw new Error(error.message || 'Error retrieving candidates'); 172 | } 173 | }; -------------------------------------------------------------------------------- /backend/src/domain/models/Candidate.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient, Prisma } from '@prisma/client'; 2 | import { Education } from './Education'; 3 | import { WorkExperience } from './WorkExperience'; 4 | import { Resume } from './Resume'; 5 | import { Application } from './Application'; 6 | 7 | const prisma = new PrismaClient(); 8 | 9 | export class Candidate { 10 | id?: number; 11 | firstName: string; 12 | lastName: string; 13 | email: string; 14 | phone?: string; 15 | address?: string; 16 | educations: Education[]; 17 | workExperiences: WorkExperience[]; 18 | resumes: Resume[]; 19 | applications: Application[]; 20 | 21 | constructor(data: any) { 22 | this.id = data.id; 23 | this.firstName = data.firstName; 24 | this.lastName = data.lastName; 25 | this.email = data.email; 26 | this.phone = data.phone; 27 | this.address = data.address; 28 | this.educations = data.educations || []; 29 | this.workExperiences = data.workExperiences || []; 30 | this.resumes = data.resumes || []; 31 | this.applications = data.applications || []; 32 | } 33 | 34 | async save() { 35 | const candidateData: any = {}; 36 | 37 | // Solo añadir al objeto candidateData los campos que no son undefined 38 | if (this.firstName !== undefined) candidateData.firstName = this.firstName; 39 | if (this.lastName !== undefined) candidateData.lastName = this.lastName; 40 | if (this.email !== undefined) candidateData.email = this.email; 41 | if (this.phone !== undefined) candidateData.phone = this.phone; 42 | if (this.address !== undefined) candidateData.address = this.address; 43 | 44 | // Añadir educations si hay alguna para añadir 45 | if (this.educations.length > 0) { 46 | candidateData.educations = { 47 | create: this.educations.map(edu => ({ 48 | institution: edu.institution, 49 | title: edu.title, 50 | startDate: edu.startDate, 51 | endDate: edu.endDate 52 | })) 53 | }; 54 | } 55 | 56 | // Añadir workExperiences si hay alguna para añadir 57 | if (this.workExperiences.length > 0) { 58 | candidateData.workExperiences = { 59 | create: this.workExperiences.map(exp => ({ 60 | company: exp.company, 61 | position: exp.position, 62 | description: exp.description, 63 | startDate: exp.startDate, 64 | endDate: exp.endDate 65 | })) 66 | }; 67 | } 68 | 69 | // Añadir resumes si hay alguno para añadir 70 | if (this.resumes.length > 0) { 71 | candidateData.resumes = { 72 | create: this.resumes.map(resume => ({ 73 | filePath: resume.filePath, 74 | fileType: resume.fileType 75 | })) 76 | }; 77 | } 78 | 79 | // Añadir applications si hay alguna para añadir 80 | if (this.applications.length > 0) { 81 | candidateData.applications = { 82 | create: this.applications.map(app => ({ 83 | positionId: app.positionId, 84 | candidateId: app.candidateId, 85 | applicationDate: app.applicationDate, 86 | currentInterviewStep: app.currentInterviewStep, 87 | notes: app.notes, 88 | })) 89 | }; 90 | } 91 | 92 | if (this.id) { 93 | // Actualizar un candidato existente 94 | try { 95 | return await prisma.candidate.update({ 96 | where: { id: this.id }, 97 | data: candidateData 98 | }); 99 | } catch (error: any) { 100 | console.log(error); 101 | if (error instanceof Prisma.PrismaClientInitializationError) { 102 | // Database connection error 103 | throw new Error('No se pudo conectar con la base de datos. Por favor, asegúrese de que el servidor de base de datos esté en ejecución.'); 104 | } else if (error.code === 'P2025') { 105 | // Record not found error 106 | throw new Error('No se pudo encontrar el registro del candidato con el ID proporcionado.'); 107 | } else { 108 | throw error; 109 | } 110 | } 111 | } else { 112 | // Crear un nuevo candidato 113 | try { 114 | const result = await prisma.candidate.create({ 115 | data: candidateData 116 | }); 117 | return result; 118 | } catch (error: any) { 119 | if (error instanceof Prisma.PrismaClientInitializationError) { 120 | // Database connection error 121 | throw new Error('No se pudo conectar con la base de datos. Por favor, asegúrese de que el servidor de base de datos esté en ejecución.'); 122 | } else { 123 | throw error; 124 | } 125 | } 126 | } 127 | } 128 | 129 | static async findOne(id: number): Promise { 130 | const data = await prisma.candidate.findUnique({ 131 | where: { id: id }, 132 | include: { 133 | educations: true, 134 | workExperiences: true, 135 | resumes: true, 136 | applications: { 137 | include: { 138 | position: { 139 | select: { 140 | id: true, 141 | title: true 142 | } 143 | }, 144 | interviews: { 145 | select: { 146 | interviewDate: true, 147 | interviewStep: { 148 | select: { 149 | name: true 150 | } 151 | }, 152 | notes: true, 153 | score: true 154 | } 155 | } 156 | } 157 | } 158 | } 159 | }); 160 | if (!data) return null; 161 | return new Candidate(data); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /backend/src/presentation/controllers/positionController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { getCandidatesByPositionService, getInterviewFlowByPositionService, getAllPositionsService, getCandidateNamesByPositionService, updatePositionService, getPositionByIdService } from '../../application/services/positionService'; 3 | 4 | 5 | export const getAllPositions = async (req: Request, res: Response) => { 6 | try { 7 | const positions = await getAllPositionsService(); 8 | res.status(200).json(positions); 9 | } catch (error) { 10 | res.status(500).json({ message: 'Error retrieving positions', error: error instanceof Error ? error.message : String(error) }); 11 | } 12 | }; 13 | 14 | export const getPositionById = async (req: Request, res: Response) => { 15 | try { 16 | const positionId = parseInt(req.params.id); 17 | 18 | // Validate position ID format 19 | if (isNaN(positionId)) { 20 | return res.status(400).json({ 21 | message: 'Invalid position ID format', 22 | error: 'Position ID must be a valid number' 23 | }); 24 | } 25 | 26 | const position = await getPositionByIdService(positionId); 27 | res.status(200).json(position); 28 | } catch (error) { 29 | if (error instanceof Error) { 30 | if (error.message === 'Position not found') { 31 | res.status(404).json({ 32 | message: 'Position not found', 33 | error: error.message 34 | }); 35 | } else { 36 | res.status(500).json({ 37 | message: 'Error retrieving position', 38 | error: error.message 39 | }); 40 | } 41 | } else { 42 | res.status(500).json({ 43 | message: 'Error retrieving position', 44 | error: 'Unknown error occurred' 45 | }); 46 | } 47 | } 48 | }; 49 | 50 | export const getCandidatesByPosition = async (req: Request, res: Response) => { 51 | try { 52 | const positionId = parseInt(req.params.id); 53 | const candidates = await getCandidatesByPositionService(positionId); 54 | res.status(200).json(candidates); 55 | } catch (error) { 56 | if (error instanceof Error) { 57 | res.status(500).json({ message: 'Error retrieving candidates', error: error.message }); 58 | } else { 59 | res.status(500).json({ message: 'Error retrieving candidates', error: String(error) }); 60 | } 61 | } 62 | }; 63 | 64 | export const getInterviewFlowByPosition = async (req: Request, res: Response) => { 65 | try { 66 | const positionId = parseInt(req.params.id); 67 | const interviewFlow = await getInterviewFlowByPositionService(positionId); 68 | res.status(200).json({ interviewFlow }); 69 | } catch (error) { 70 | if (error instanceof Error) { 71 | res.status(404).json({ message: 'Position not found', error: error.message }); 72 | } else { 73 | res.status(500).json({ message: 'Server error', error: String(error) }); 74 | } 75 | } 76 | }; 77 | 78 | export const getCandidateNamesByPosition = async (req: Request, res: Response) => { 79 | try { 80 | const positionId = parseInt(req.params.id); 81 | const candidateNames = await getCandidateNamesByPositionService(positionId); 82 | res.status(200).json(candidateNames); 83 | } catch (error) { 84 | if (error instanceof Error) { 85 | res.status(500).json({ message: 'Error retrieving candidate names', error: error.message }); 86 | } else { 87 | res.status(500).json({ message: 'Error retrieving candidate names', error: String(error) }); 88 | } 89 | } 90 | }; 91 | 92 | /** 93 | * @route PUT /positions/:id 94 | * @description Actualiza una posición existente 95 | * @access Public 96 | */ 97 | export const updatePosition = async (req: Request, res: Response) => { 98 | try { 99 | const positionId = parseInt(req.params.id); 100 | 101 | // Validar que el ID es un número válido 102 | if (isNaN(positionId)) { 103 | return res.status(400).json({ 104 | message: 'Invalid position ID format', 105 | error: 'Position ID must be a valid number' 106 | }); 107 | } 108 | 109 | const updateData = req.body; 110 | 111 | // Validar que se envían datos para actualizar 112 | if (!updateData || Object.keys(updateData).length === 0) { 113 | return res.status(400).json({ 114 | message: 'No data provided for update', 115 | error: 'Request body cannot be empty' 116 | }); 117 | } 118 | 119 | const updatedPosition = await updatePositionService(positionId, updateData); 120 | 121 | res.status(200).json({ 122 | message: 'Position updated successfully', 123 | data: updatedPosition 124 | }); 125 | } catch (error) { 126 | if (error instanceof Error) { 127 | // Manejar errores específicos 128 | if (error.message === 'Position not found') { 129 | res.status(404).json({ 130 | message: 'Position not found', 131 | error: error.message 132 | }); 133 | } else if (error.message.includes('Company not found') || 134 | error.message.includes('Interview flow not found')) { 135 | res.status(400).json({ 136 | message: 'Invalid reference data', 137 | error: error.message 138 | }); 139 | } else if (error.message.includes('inválido') || 140 | error.message.includes('Invalid') || 141 | error.message.includes('exceder') || 142 | error.message.includes('mayor') || 143 | error.message.includes('obligatorio') || 144 | error.message.includes('debe ser') || 145 | error.message.includes('no puede') || 146 | error.message.includes('Estado inválido') || 147 | error.message.includes('número válido') || 148 | error.message.includes('cadena válida') || 149 | error.message.includes('valor booleano') || 150 | error.message.includes('número entero positivo')) { 151 | res.status(400).json({ 152 | message: 'Validation error', 153 | error: error.message 154 | }); 155 | } else { 156 | res.status(500).json({ 157 | message: 'Error updating position', 158 | error: error.message 159 | }); 160 | } 161 | } else { 162 | res.status(500).json({ 163 | message: 'Error updating position', 164 | error: 'Unknown error occurred' 165 | }); 166 | } 167 | } 168 | }; -------------------------------------------------------------------------------- /frontend/src/components/CandidateDetails.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Offcanvas, Form, Button } from 'react-bootstrap'; 3 | 4 | const CandidateDetails = ({ candidate, onClose }) => { 5 | const [candidateDetails, setCandidateDetails] = useState(null); 6 | const [newInterview, setNewInterview] = useState({ 7 | notes: '', 8 | score: 0 9 | }); 10 | 11 | useEffect(() => { 12 | if (candidate) { 13 | fetch(`http://localhost:3010/candidates/${candidate.id}`) 14 | .then(response => response.json()) 15 | .then(data => setCandidateDetails(data)) 16 | .catch(error => console.error('Error fetching candidate details:', error)); 17 | } 18 | }, [candidate]); 19 | 20 | const handleInputChange = (e) => { 21 | const { name, value } = e.target; 22 | setNewInterview({ 23 | ...newInterview, 24 | [name]: value 25 | }); 26 | }; 27 | 28 | const handleScoreChange = (score) => { 29 | setNewInterview({ 30 | ...newInterview, 31 | score 32 | }); 33 | }; 34 | 35 | const handleSubmit = (e) => { 36 | e.preventDefault(); 37 | fetch(`http://localhost:3010/candidates/${candidate.id}/interviews`, { 38 | method: 'POST', 39 | headers: { 40 | 'Content-Type': 'application/json' 41 | }, 42 | body: JSON.stringify(newInterview) 43 | }) 44 | .then(response => response.json()) 45 | .then(data => { 46 | // Update the candidate details with the new interview 47 | setCandidateDetails(prevDetails => ({ 48 | ...prevDetails, 49 | applications: prevDetails.applications.map(app => { 50 | if (app.id === data.applicationId) { 51 | return { 52 | ...app, 53 | interviews: [...app.interviews, data] 54 | }; 55 | } 56 | return app; 57 | }) 58 | })); 59 | // Reset the form 60 | setNewInterview({ 61 | notes: '', 62 | score: 0 63 | }); 64 | // Close the panel 65 | onClose(); 66 | }) 67 | .catch(error => console.error('Error creating interview:', error)) 68 | .finally(() => onClose()) 69 | }; 70 | 71 | return ( 72 | 73 | 74 | Detalles del Candidato 75 | 76 | 77 | {candidateDetails ? ( 78 | <> 79 |
{candidateDetails.firstName} {candidateDetails.lastName}
80 |

Email: {candidateDetails.email}

81 |

Teléfono: {candidateDetails.phone}

82 |

Dirección: {candidateDetails.address}

83 |
Educación
84 | {candidateDetails.educations.map(edu => ( 85 |
86 |

{edu.institution} - {edu.title}

87 |

{new Date(edu.startDate).toLocaleDateString()} - {new Date(edu.endDate).toLocaleDateString()}

88 |
89 | ))} 90 |
Experiencias Laborales
91 | {candidateDetails.workExperiences.map(work => ( 92 |
93 |

{work.company} - {work.position}

94 |

{work.description}

95 |

{new Date(work.startDate).toLocaleDateString()} - {new Date(work.endDate).toLocaleDateString()}

96 |
97 | ))} 98 |
Curriculums
99 | {candidateDetails.resumes.map(resume => ( 100 |
101 |

Descargar Curriculum

102 |
103 | ))} 104 |
Solicitudes
105 | {candidateDetails.applications.map(app => ( 106 |
107 |

Posición: {app.position.title}

108 |

Fecha de Solicitud: {new Date(app.applicationDate).toLocaleDateString()}

109 |
Entrevistas
110 | {app.interviews.map(interview => ( 111 |
112 |

Fecha de la Entrevista: {new Date(interview.interviewDate).toLocaleDateString()}

113 |

Etapa: {interview.interviewStep.name}

114 |

Notas: {interview.notes}

115 |

Puntuación: {interview.score}

116 |
117 | ))} 118 |
119 | ))} 120 |
Registrar Nueva Entrevista
121 |
122 | 123 | Notas 124 | 131 | 132 | 133 | Puntuación 134 |
135 | {[1, 2, 3, 4, 5].map(score => ( 136 | = score ? 'gold' : 'gray' 141 | }} 142 | onClick={() => handleScoreChange(score)} 143 | > 144 | ★ 145 | 146 | ))} 147 |
148 |
149 | 152 |
153 | 154 | ) : ( 155 |

Cargando...

156 | )} 157 |
158 |
159 | ); 160 | }; 161 | 162 | 163 | export default CandidateDetails; 164 | -------------------------------------------------------------------------------- /backend/src/application/validator.ts: -------------------------------------------------------------------------------- 1 | const NAME_REGEX = /^[a-zA-ZñÑáéíóúÁÉÍÓÚ ]+$/; 2 | const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; 3 | const PHONE_REGEX = /^(6|7|9)\d{8}$/; 4 | const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; 5 | 6 | //Length validations according to the database schema 7 | 8 | const validateName = (name: string) => { 9 | if (!name || name.length < 2 || name.length > 100 || !NAME_REGEX.test(name)) { 10 | throw new Error('Invalid name'); 11 | } 12 | }; 13 | 14 | const validateEmail = (email: string) => { 15 | if (!email || !EMAIL_REGEX.test(email)) { 16 | throw new Error('Invalid email'); 17 | } 18 | }; 19 | 20 | const validatePhone = (phone: string) => { 21 | if (phone && !PHONE_REGEX.test(phone)) { 22 | throw new Error('Invalid phone'); 23 | } 24 | }; 25 | 26 | const validateDate = (date: string) => { 27 | if (!date || !DATE_REGEX.test(date)) { 28 | throw new Error('Invalid date'); 29 | } 30 | }; 31 | 32 | const validateAddress = (address: string) => { 33 | if (address && address.length > 100) { 34 | throw new Error('Invalid address'); 35 | } 36 | }; 37 | 38 | const validateEducation = (education: any) => { 39 | if (!education.institution || education.institution.length > 100) { 40 | throw new Error('Invalid institution'); 41 | } 42 | 43 | if (!education.title || education.title.length > 100) { 44 | throw new Error('Invalid title'); 45 | } 46 | 47 | validateDate(education.startDate); 48 | 49 | if (education.endDate && !DATE_REGEX.test(education.endDate)) { 50 | throw new Error('Invalid end date'); 51 | } 52 | }; 53 | 54 | const validateExperience = (experience: any) => { 55 | if (!experience.company || experience.company.length > 100) { 56 | throw new Error('Invalid company'); 57 | } 58 | 59 | if (!experience.position || experience.position.length > 100) { 60 | throw new Error('Invalid position'); 61 | } 62 | 63 | if (experience.description && experience.description.length > 200) { 64 | throw new Error('Invalid description'); 65 | } 66 | 67 | validateDate(experience.startDate); 68 | 69 | if (experience.endDate && !DATE_REGEX.test(experience.endDate)) { 70 | throw new Error('Invalid end date'); 71 | } 72 | }; 73 | 74 | const validateCV = (cv: any) => { 75 | if (typeof cv !== 'object' || !cv.filePath || typeof cv.filePath !== 'string' || !cv.fileType || typeof cv.fileType !== 'string') { 76 | throw new Error('Invalid CV data'); 77 | } 78 | }; 79 | 80 | export const validateCandidateData = (data: any) => { 81 | if (data.id) { 82 | // If id is provided, we are editing an existing candidate, so fields are not mandatory 83 | return; 84 | } 85 | 86 | validateName(data.firstName); 87 | validateName(data.lastName); 88 | validateEmail(data.email); 89 | validatePhone(data.phone); 90 | validateAddress(data.address); 91 | 92 | if (data.educations) { 93 | // Ensure the maximum number of educations does not exceed 3 94 | if (data.educations.length > 3) { 95 | throw new Error("A candidate cannot have more than 3 educations"); 96 | } 97 | for (const education of data.educations) { 98 | validateEducation(education); 99 | } 100 | } 101 | 102 | if (data.workExperiences) { 103 | for (const experience of data.workExperiences) { 104 | validateExperience(experience); 105 | } 106 | } 107 | 108 | if (data.cv && Object.keys(data.cv).length > 0) { 109 | validateCV(data.cv); 110 | } 111 | }; 112 | 113 | export const validatePositionUpdate = (positionData: any) => { 114 | // Validar título 115 | if (positionData.title !== undefined) { 116 | if (!positionData.title || typeof positionData.title !== 'string' || positionData.title.trim().length === 0) { 117 | throw new Error('El título es obligatorio y debe ser una cadena válida'); 118 | } 119 | if (positionData.title.length > 100) { 120 | throw new Error('El título no puede exceder 100 caracteres'); 121 | } 122 | } 123 | 124 | // Validar descripción 125 | if (positionData.description !== undefined) { 126 | if (!positionData.description || typeof positionData.description !== 'string' || positionData.description.trim().length === 0) { 127 | throw new Error('La descripción es obligatoria y debe ser una cadena válida'); 128 | } 129 | } 130 | 131 | // Validar ubicación 132 | if (positionData.location !== undefined) { 133 | if (!positionData.location || typeof positionData.location !== 'string' || positionData.location.trim().length === 0) { 134 | throw new Error('La ubicación es obligatoria y debe ser una cadena válida'); 135 | } 136 | } 137 | 138 | // Validar descripción del trabajo 139 | if (positionData.jobDescription !== undefined) { 140 | if (!positionData.jobDescription || typeof positionData.jobDescription !== 'string' || positionData.jobDescription.trim().length === 0) { 141 | throw new Error('La descripción del trabajo es obligatoria y debe ser una cadena válida'); 142 | } 143 | } 144 | 145 | // Validar estado 146 | if (positionData.status !== undefined) { 147 | const validStatuses = ['Open', 'Contratado', 'Cerrado', 'Borrador']; 148 | if (!validStatuses.includes(positionData.status)) { 149 | throw new Error(`Estado inválido. Debe ser uno de: ${validStatuses.join(', ')}`); 150 | } 151 | } 152 | 153 | // Validar visibilidad 154 | if (positionData.isVisible !== undefined) { 155 | if (typeof positionData.isVisible !== 'boolean') { 156 | throw new Error('isVisible debe ser un valor booleano'); 157 | } 158 | } 159 | 160 | // Validar IDs de referencia 161 | if (positionData.companyId !== undefined) { 162 | if (!Number.isInteger(positionData.companyId) || positionData.companyId <= 0) { 163 | throw new Error('companyId debe ser un número entero positivo'); 164 | } 165 | } 166 | 167 | if (positionData.interviewFlowId !== undefined) { 168 | if (!Number.isInteger(positionData.interviewFlowId) || positionData.interviewFlowId <= 0) { 169 | throw new Error('interviewFlowId debe ser un número entero positivo'); 170 | } 171 | } 172 | 173 | // Validar salarios 174 | if (positionData.salaryMin !== undefined && positionData.salaryMin !== null) { 175 | const salaryMin = parseFloat(positionData.salaryMin); 176 | if (isNaN(salaryMin) || salaryMin < 0) { 177 | throw new Error('El salario mínimo debe ser un número válido mayor o igual a 0'); 178 | } 179 | } 180 | 181 | if (positionData.salaryMax !== undefined && positionData.salaryMax !== null) { 182 | const salaryMax = parseFloat(positionData.salaryMax); 183 | if (isNaN(salaryMax) || salaryMax < 0) { 184 | throw new Error('El salario máximo debe ser un número válido mayor o igual a 0'); 185 | } 186 | } 187 | 188 | // Validar que salario mínimo no sea mayor que el máximo 189 | if (positionData.salaryMin !== undefined && positionData.salaryMax !== undefined && 190 | positionData.salaryMin !== null && positionData.salaryMax !== null) { 191 | const salaryMin = parseFloat(positionData.salaryMin); 192 | const salaryMax = parseFloat(positionData.salaryMax); 193 | if (!isNaN(salaryMin) && !isNaN(salaryMax) && salaryMin > salaryMax) { 194 | throw new Error('El salario mínimo no puede ser mayor que el máximo'); 195 | } 196 | } 197 | 198 | // Validar fecha límite de aplicación 199 | if (positionData.applicationDeadline !== undefined && positionData.applicationDeadline !== null) { 200 | const deadline = new Date(positionData.applicationDeadline); 201 | if (isNaN(deadline.getTime())) { 202 | throw new Error('Fecha límite inválida'); 203 | } 204 | 205 | // Validar que la fecha límite no sea en el pasado 206 | const today = new Date(); 207 | today.setHours(0, 0, 0, 0); // Resetear horas para comparar solo fechas 208 | if (deadline < today) { 209 | throw new Error('La fecha límite no puede ser anterior a hoy'); 210 | } 211 | } 212 | 213 | // Validar tipo de empleo 214 | if (positionData.employmentType !== undefined && positionData.employmentType !== null) { 215 | if (typeof positionData.employmentType !== 'string' || positionData.employmentType.trim().length === 0) { 216 | throw new Error('El tipo de empleo debe ser una cadena válida'); 217 | } 218 | } 219 | 220 | // Validar campos de texto opcionales 221 | const textFields = ['requirements', 'responsibilities', 'benefits', 'companyDescription', 'contactInfo']; 222 | textFields.forEach(field => { 223 | if (positionData[field] !== undefined && positionData[field] !== null) { 224 | if (typeof positionData[field] !== 'string') { 225 | throw new Error(`${field} debe ser una cadena de texto`); 226 | } 227 | } 228 | }); 229 | }; -------------------------------------------------------------------------------- /frontend/cypress/e2e/positions.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Positions API - Update', () => { 2 | const API_URL = Cypress.env('API_URL') || 'http://localhost:3010'; 3 | let testPositionId: number; 4 | 5 | before(() => { 6 | // Obtener una posición existente para usar en los tests 7 | cy.request({ 8 | method: 'GET', 9 | url: `${API_URL}/positions` 10 | }).then((response) => { 11 | expect(response.status).to.eq(200); 12 | expect(response.body).to.be.an('array'); 13 | if (response.body.length > 0) { 14 | testPositionId = response.body[0].id; 15 | } else { 16 | throw new Error('No positions available for testing. Please ensure test data exists.'); 17 | } 18 | }); 19 | }); 20 | 21 | beforeEach(() => { 22 | // Limpiar cualquier estado previo 23 | cy.window().then((win) => { 24 | win.localStorage.clear(); 25 | }); 26 | }); 27 | 28 | describe('PUT /positions/:id', () => { 29 | it('should update a position successfully with all valid fields', () => { 30 | const updateData = { 31 | title: 'Updated Test Position', 32 | description: 'Updated description', 33 | status: 'Open', 34 | isVisible: true, 35 | location: 'Updated Location', 36 | jobDescription: 'Updated job description', 37 | requirements: 'Updated requirements', 38 | responsibilities: 'Updated responsibilities', 39 | salaryMin: 60000, 40 | salaryMax: 90000, 41 | employmentType: 'Part-time', 42 | benefits: 'Updated benefits', 43 | companyDescription: 'Updated company description', 44 | contactInfo: 'updated@example.com' 45 | }; 46 | 47 | cy.request({ 48 | method: 'PUT', 49 | url: `${API_URL}/positions/${testPositionId}`, 50 | body: updateData 51 | }).then((response) => { 52 | expect(response.status).to.eq(200); 53 | expect(response.body).to.have.property('message', 'Position updated successfully'); 54 | expect(response.body).to.have.property('data'); 55 | expect(response.body.data).to.have.property('title', updateData.title); 56 | expect(response.body.data).to.have.property('status', updateData.status); 57 | expect(response.body.data).to.have.property('isVisible', updateData.isVisible); 58 | expect(response.body.data).to.have.property('location', updateData.location); 59 | }); 60 | }); 61 | 62 | it('should return error when trying to update non-existent position', () => { 63 | const nonExistentId = 99999; 64 | const updateData = { 65 | title: 'Updated Title' 66 | }; 67 | 68 | cy.request({ 69 | method: 'PUT', 70 | url: `${API_URL}/positions/${nonExistentId}`, 71 | body: updateData, 72 | failOnStatusCode: false 73 | }).then((response) => { 74 | expect(response.status).to.eq(404); 75 | expect(response.body).to.have.property('message', 'Position not found'); 76 | expect(response.body).to.have.property('error'); 77 | }); 78 | }); 79 | 80 | it('should return error when trying to update with invalid data', () => { 81 | const invalidData = { 82 | title: '', // Campo vacío 83 | salaryMin: -1000, // Salario negativo 84 | status: 'InvalidStatus' // Estado inválido 85 | }; 86 | 87 | cy.request({ 88 | method: 'PUT', 89 | url: `${API_URL}/positions/${testPositionId}`, 90 | body: invalidData, 91 | failOnStatusCode: false 92 | }).then((response) => { 93 | expect(response.status).to.eq(400); 94 | expect(response.body).to.have.property('message', 'Validation error'); 95 | expect(response.body).to.have.property('error'); 96 | }); 97 | }); 98 | 99 | it('should validate that required fields cannot be empty', () => { 100 | const emptyFieldsData = { 101 | title: '', 102 | description: '', 103 | location: '', 104 | jobDescription: '' 105 | }; 106 | 107 | cy.request({ 108 | method: 'PUT', 109 | url: `${API_URL}/positions/${testPositionId}`, 110 | body: emptyFieldsData, 111 | failOnStatusCode: false 112 | }).then((response) => { 113 | expect(response.status).to.eq(400); 114 | expect(response.body).to.have.property('message', 'Validation error'); 115 | expect(response.body.error).to.include('obligatorio'); 116 | }); 117 | }); 118 | 119 | it('should return updated position with new data in response', () => { 120 | const updateData = { 121 | title: 'Verified Updated Position', 122 | status: 'Open', 123 | salaryMin: 55000, 124 | salaryMax: 85000 125 | }; 126 | 127 | cy.request({ 128 | method: 'PUT', 129 | url: `${API_URL}/positions/${testPositionId}`, 130 | body: updateData 131 | }).then((response) => { 132 | expect(response.status).to.eq(200); 133 | expect(response.body.data).to.have.property('title', updateData.title); 134 | expect(response.body.data).to.have.property('status', updateData.status); 135 | expect(response.body.data).to.have.property('salaryMin', updateData.salaryMin); 136 | expect(response.body.data).to.have.property('salaryMax', updateData.salaryMax); 137 | }); 138 | }); 139 | 140 | it('should return error when trying to update with invalid ID format', () => { 141 | const invalidId = 'not-a-number'; 142 | const updateData = { 143 | title: 'Updated Title' 144 | }; 145 | 146 | cy.request({ 147 | method: 'PUT', 148 | url: `${API_URL}/positions/${invalidId}`, 149 | body: updateData, 150 | failOnStatusCode: false 151 | }).then((response) => { 152 | expect(response.status).to.eq(400); 153 | expect(response.body).to.have.property('message', 'Invalid position ID format'); 154 | expect(response.body).to.have.property('error', 'Position ID must be a valid number'); 155 | }); 156 | }); 157 | 158 | it('should verify that unmodified fields maintain their original values', () => { 159 | // Primero obtenemos los datos originales de la lista de posiciones 160 | cy.request({ 161 | method: 'GET', 162 | url: `${API_URL}/positions` 163 | }).then((originalResponse) => { 164 | const originalData = originalResponse.body.find((pos: any) => pos.id === testPositionId); 165 | expect(originalData).to.exist; 166 | 167 | // Actualizamos solo algunos campos 168 | const partialUpdate = { 169 | title: 'Partially Updated Position', 170 | status: 'Open' 171 | }; 172 | 173 | cy.request({ 174 | method: 'PUT', 175 | url: `${API_URL}/positions/${testPositionId}`, 176 | body: partialUpdate 177 | }).then((updateResponse) => { 178 | expect(updateResponse.status).to.eq(200); 179 | const updatedData = updateResponse.body.data; 180 | 181 | // Verificar que los campos actualizados cambiaron 182 | expect(updatedData.title).to.eq(partialUpdate.title); 183 | expect(updatedData.status).to.eq(partialUpdate.status); 184 | 185 | // Verificar que los campos no modificados mantienen sus valores 186 | expect(updatedData.description).to.eq(originalData.description); 187 | expect(updatedData.location).to.eq(originalData.location); 188 | expect(updatedData.salaryMin).to.eq(originalData.salaryMin); 189 | expect(updatedData.salaryMax).to.eq(originalData.salaryMax); 190 | }); 191 | }); 192 | }); 193 | 194 | it('should validate salary range constraints', () => { 195 | const invalidSalaryData = { 196 | salaryMin: 100000, 197 | salaryMax: 50000 // Máximo menor que mínimo 198 | }; 199 | 200 | cy.request({ 201 | method: 'PUT', 202 | url: `${API_URL}/positions/${testPositionId}`, 203 | body: invalidSalaryData, 204 | failOnStatusCode: false 205 | }).then((response) => { 206 | expect(response.status).to.eq(400); 207 | expect(response.body).to.have.property('message', 'Validation error'); 208 | expect(response.body.error).to.include('mínimo no puede ser mayor que el máximo'); 209 | }); 210 | }); 211 | 212 | it('should return error when no data is provided for update', () => { 213 | cy.request({ 214 | method: 'PUT', 215 | url: `${API_URL}/positions/${testPositionId}`, 216 | body: {}, 217 | failOnStatusCode: false 218 | }).then((response) => { 219 | expect(response.status).to.eq(400); 220 | expect(response.body).to.have.property('message', 'No data provided for update'); 221 | expect(response.body).to.have.property('error', 'Request body cannot be empty'); 222 | }); 223 | }); 224 | 225 | it('should validate status enum values', () => { 226 | const validStatuses = ['Open', 'Contratado', 'Cerrado', 'Borrador']; 227 | 228 | validStatuses.forEach((status) => { 229 | cy.request({ 230 | method: 'PUT', 231 | url: `${API_URL}/positions/${testPositionId}`, 232 | body: { status: status } 233 | }).then((response) => { 234 | expect(response.status).to.eq(200); 235 | expect(response.body.data.status).to.eq(status); 236 | }); 237 | }); 238 | }); 239 | 240 | it('should return error for invalid company or interview flow references', () => { 241 | const invalidReferenceData = { 242 | companyId: 99999, // ID que no existe 243 | interviewFlowId: 99999 // ID que no existe 244 | }; 245 | 246 | cy.request({ 247 | method: 'PUT', 248 | url: `${API_URL}/positions/${testPositionId}`, 249 | body: invalidReferenceData, 250 | failOnStatusCode: false 251 | }).then((response) => { 252 | expect(response.status).to.eq(400); 253 | expect(response.body).to.have.property('message', 'Invalid reference data'); 254 | expect(response.body.error).to.satisfy((error: string) => 255 | error.includes('Company not found') || error.includes('Interview flow not found') 256 | ); 257 | }); 258 | }); 259 | 260 | it('should handle partial updates correctly', () => { 261 | const partialUpdates = [ 262 | { title: 'Only Title Updated' }, 263 | { status: 'Open' }, 264 | { isVisible: true }, 265 | { salaryMin: 65000 }, 266 | { location: 'New Location Only' } 267 | ]; 268 | 269 | partialUpdates.forEach((updateData, index) => { 270 | cy.request({ 271 | method: 'PUT', 272 | url: `${API_URL}/positions/${testPositionId}`, 273 | body: updateData 274 | }).then((response) => { 275 | expect(response.status).to.eq(200); 276 | expect(response.body).to.have.property('message', 'Position updated successfully'); 277 | 278 | // Verificar que el campo específico se actualizó 279 | const fieldName = Object.keys(updateData)[0]; 280 | const fieldValue = Object.values(updateData)[0]; 281 | expect(response.body.data[fieldName]).to.eq(fieldValue); 282 | }); 283 | }); 284 | }); 285 | }); 286 | }); --------------------------------------------------------------------------------