Angel Asensio - Solved#18
Conversation
Implement candidate models and migration, POST /api/candidates with multipart validation and PDF/DOCX uploads, and React dashboard plus form. Default API port 3011 to avoid clashes with other local LTI projects on 3010. Root GET / returns HTML clarifying API vs frontend URL. Add detailed manual test guide and SDD-style tickets under docs/tickets. Stop tracking backend/.env; add .env.example and ignore **/.env. Made-with: Cursor
📝 WalkthroughWalkthroughThis pull request implements the "Add Candidate to ATS" feature with full-stack changes: database schema and migrations for candidate records with education/experience/resume relations, backend Express API with file upload handling and validation, environment configuration updates, and frontend React components for candidate creation workflow with multi-step routing. Changes
Sequence Diagram(s)sequenceDiagram
participant Browser as Browser/Frontend
participant ReactApp as React App
participant API as Express API
participant FileSystem as File System
participant Database as PostgreSQL
Browser->>ReactApp: User submits form with resume file
ReactApp->>ReactApp: Validate fields, check file extension
ReactApp->>API: POST /api/candidates (FormData)
API->>API: Run Multer upload middleware
API->>FileSystem: Store resume with UUID filename
API->>API: Parse & validate payload (Zod)
alt Validation Fails
API-->>ReactApp: 400 VALIDATION_ERROR
ReactApp-->>Browser: Display field errors
else Email Duplicate
API->>Database: Check email uniqueness
Database-->>API: Email exists
API-->>ReactApp: 409 DUPLICATE_EMAIL
ReactApp-->>Browser: Show email error
else Success
API->>Database: Start transaction
Database->>Database: CREATE Candidate
Database->>Database: CREATE CandidateEducation (if any)
Database->>Database: CREATE CandidateWorkExperience (if any)
Database->>Database: CREATE CandidateResume
Database->>Database: Commit transaction
API->>Database: Fetch full candidate with relations
Database-->>API: Return candidate data
API-->>ReactApp: 201 Created (candidate JSON)
ReactApp->>ReactApp: Update app state to success view
ReactApp-->>Browser: Display success screen
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~55 minutes Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 11
🧹 Nitpick comments (5)
backend/src/tests/app.test.ts (1)
9-10: Make the frontend URL assertion less brittle.Asserting a fixed
localhost:3000can break when local frontend port changes. Prefer checking presence of “frontend” plus a generic localhost URL pattern.Example resilient assertion tweak
expect(response.text).toContain('API'); - expect(response.text).toContain('localhost:3000'); + expect(response.text.toLowerCase()).toContain('frontend'); + expect(response.text).toMatch(/localhost:\d{2,5}/);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/src/tests/app.test.ts` around lines 9 - 10, The assertion is brittle because it expects a fixed "localhost:3000"; update the test that uses response (in backend/src/tests/app.test.ts) to instead assert for the presence of "frontend" and a generic localhost URL pattern; replace the expect(response.text).toContain('localhost:3000') with a more resilient check that verifies response.text contains "frontend" and/or uses expect(...).toMatch(/localhost:\d+/) (or a combined regex) so any local port passes.backend/src/tests/candidates.test.ts (1)
18-105: Good test coverage for core scenarios.The tests cover three important paths:
- Validation failure (400) with invalid email
- Successful creation (201) with education data
- Duplicate email conflict (409) via Prisma P2002
Consider adding tests for:
- File upload (resume) scenarios
- Missing required fields validation
- File size/type rejection (413, 400)
These can be added in a follow-up if not blocking.
,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/src/tests/candidates.test.ts` around lines 18 - 105, Add tests to cover resume file upload and missing/invalid file handling: extend backend/src/tests/candidates.test.ts by adding cases that use request(app).post('/api/candidates') with .attach('resume', ...) to assert successful upload (201 and resume present in response) and rejection scenarios (oversized file -> expect 413, wrong mime type -> expect 400). Also add tests for missing required fields (e.g., omit firstName or email) expecting 400 VALIDATION_ERROR and ensure mockedCreate (the mock for candidateService.createCandidateWithRelations) is not called; reuse mockedCreate and Prisma.PrismaClientKnownRequestError patterns from existing tests to simulate backend behavior.backend/prisma/migrations/20260409140000_add_candidate_ats/migration.sql (1)
65-87: Appropriate indexes and constraints.
- Unique indexes on
User.emailandCandidate.emailenforce uniqueness and enable efficient lookups- Unique constraint on
CandidateResume.candidateIdenforces 1:1 relationship- CASCADE delete/update maintains referential integrity
Consider adding indexes on
CandidateEducation.candidateIdandCandidateWorkExperience.candidateIdif you expect frequent queries loading a candidate with their related records. Postgres doesn't auto-index foreign keys. This is optional for small datasets.,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/prisma/migrations/20260409140000_add_candidate_ats/migration.sql` around lines 65 - 87, Add non-unique indexes on the foreign key columns to speed joins when loading a candidate's related rows: create indexes on CandidateEducation("candidateId") and CandidateWorkExperience("candidateId") in the migration (referencing the tables CandidateEducation and CandidateWorkExperience and the column candidateId) so the DB can efficiently query by candidateId; add appropriately named indexes (e.g., CandidateEducation_candidateId_idx and CandidateWorkExperience_candidateId_idx) alongside the existing CREATE INDEX statements in migration.sql.frontend/src/App.tsx (1)
8-26: Clean state-driven view routing with type-safe discriminated union.The
Viewtype with discriminated union ensuresview.candidateis only accessible whenview.name === 'success', providing compile-time safety. The callback wiring for navigation between views is straightforward.The conditional rendering pattern works but could be slightly cleaner:
,
♻️ Optional: Cleaner conditional rendering
- {view.name === 'dashboard' ? ( - <Dashboard onAddCandidate={() => setView({ name: 'form' })} /> - ) : null} - {view.name === 'form' ? ( - <AddCandidate - onCancel={() => setView({ name: 'dashboard' })} - onSuccess={(candidate) => setView({ name: 'success', candidate })} - /> - ) : null} - {view.name === 'success' ? ( - <SuccessScreen candidate={view.candidate} onBack={() => setView({ name: 'dashboard' })} /> - ) : null} + {view.name === 'dashboard' && ( + <Dashboard onAddCandidate={() => setView({ name: 'form' })} /> + )} + {view.name === 'form' && ( + <AddCandidate + onCancel={() => setView({ name: 'dashboard' })} + onSuccess={(candidate) => setView({ name: 'success', candidate })} + /> + )} + {view.name === 'success' && ( + <SuccessScreen candidate={view.candidate} onBack={() => setView({ name: 'dashboard' })} /> + )}Using
&&short-circuit is more idiomatic for conditional rendering in React.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/src/App.tsx` around lines 8 - 26, Replace the ternary-with-null conditional renders in the App component with idiomatic short-circuit rendering (use &&) to simplify the JSX: for each conditional currently using patterns like {view.name === 'dashboard' ? (<Dashboard ... />) : null}, change to {view.name === 'dashboard' && <Dashboard onAddCandidate={() => setView({ name: 'form' })} />}, and similarly for AddCandidate and SuccessScreen while keeping the same props and the discriminated union View, setView, and candidate handling intact.frontend/src/pages/AddCandidate.tsx (1)
57-64: Static arrays can be module-level constants.The
useMemohooks with empty dependency arrays wrap static string arrays that never change. Moving them outside the component as constants eliminates unnecessary hook overhead.♻️ Proposed refactor
+const SUGGESTED_INSTITUTIONS = ['Universidad Politécnica de Madrid', 'Universidad Complutense', 'Universidad de Barcelona', 'ESADE', 'IE']; +const SUGGESTED_COMPANIES = ['Acme Corp', 'Globex', 'Initech', 'Umbrella', 'Stark Industries']; + export function AddCandidate({ onCancel, onSuccess }: Props) { const formId = useId(); // ... state declarations ... - - const suggestedInstitutions = useMemo( - () => ['Universidad Politécnica de Madrid', 'Universidad Complutense', 'Universidad de Barcelona', 'ESADE', 'IE'], - [], - ); - const suggestedCompanies = useMemo( - () => ['Acme Corp', 'Globex', 'Initech', 'Umbrella', 'Stark Industries'], - [], - );Then use
SUGGESTED_INSTITUTIONSandSUGGESTED_COMPANIESdirectly in the JSX.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/src/pages/AddCandidate.tsx` around lines 57 - 64, The arrays wrapped by useMemo (suggestedInstitutions and suggestedCompanies) are static and should be moved to module-level constants to remove unnecessary hooks; create top-level constants (e.g., SUGGESTED_INSTITUTIONS and SUGGESTED_COMPANIES) with the respective string arrays and replace usages of suggestedInstitutions and suggestedCompanies in AddCandidate.tsx with those constants, removing the useMemo declarations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/.env.example`:
- Around line 3-7: Current .env.example defines both
DB_PASSWORD/DB_USER/DB_NAME/DB_PORT and DATABASE_URL which creates two sources
of truth (Prisma uses DATABASE_URL); remove the DB_* entries from .env.example
(or alternatively remove DATABASE_URL and document switching Prisma to use DB_*)
so there is a single canonical connection string—update the example to keep only
DATABASE_URL (and a short comment instructing maintainers to update
DATABASE_URL) and ensure Prisma and any startup scripts reference DATABASE_URL
consistently.
In `@backend/src/middleware/candidateUpload.ts`:
- Around line 47-52: Validate MAX_UPLOAD_MB at startup by parsing
process.env.MAX_UPLOAD_MB into a Number and ensuring it is finite and > 0 before
using it to compute the Multer fileSize limit; update the logic around the maxMb
variable used by candidateUploadMiddleware to fall back to the default (10) when
the parsed value is NaN, non-finite, or <= 0, and log or throw a clear error at
module init if you prefer strict failure instead of silent fallback so negative
or invalid values cannot produce an invalid limits.fileSize for multer/busboy.
In `@backend/src/routes/candidates.ts`:
- Around line 69-79: When parseEducationJson or parseExperienceJson throws and
you return the ApiError 400 path, ensure the uploaded file is removed first to
avoid orphaned PII: in the catch blocks around parseEducationJson and
parseExperienceJson (and the analogous 92-97 block) call a cleanup that removes
req.file.path if req.file exists before calling next(new ApiError(...)) and
returning; reference candidateUploadMiddleware which writes the file, and use
the same file path cleanup logic (delete/unlink) used elsewhere in the codebase
so the file is always deleted on these pre-database validation failures.
In `@backend/src/validators/candidatePayload.ts`:
- Around line 7-8: The startDate and endDate fields in the candidate payload
schema currently use z.string().trim().optional().nullable() and accept
arbitrary text; replace those with string validators that enforce YYYY, YYYY-MM
or YYYY-MM-DD formats using a regex (e.g. /^\d{4}(-\d{2}(-\d{2})?)?$/) while
preserving optional()/nullable() semantics, update both occurrences of startDate
and endDate in the validator (including any nested employment/education entries)
to use z.string().trim().regex(...).refine or .regex(..., { message: 'invalid
date format' }) so invalid values fail safeParse() and tests reflect the new
error message.
In `@docs/tickets/add-candidate/TICKET-02-backend-api.md`:
- Around line 15-17: Actualiza la especificación del endpoint de creación de
candidato para convertir la elección actual (single-request multipart/form-data)
en un contrato claro: documenta que POST /candidates acepta Content-Type:
multipart/form-data con campos JSON planos (e.g., name, email, education[],
experience[]) y un campo de archivo resume (campo: "resume") que es opcional;
valida tipos MIME aceptados (application/pdf,
application/vnd.openxmlformats-officedocument.wordprocessingml.document), tamaño
máximo (p. ej. 5MB), y esquema de campos requeridos/formatos (email regex,
fechas ISO, etc.); especifica códigos de respuesta y cuerpos de error coherentes
(400 con {error, details}, 422 para validación, 201 con {id, ...} al crear), y
referencia las implementaciones actuales middleware candidateUpload
(candidateUpload.ts) y la ruta POST handler en routes/candidates.ts para que
tests y frontend esperen ese formato exacto.
- Around line 95-99: Update the docs to show both distinct 400 response shapes
emitted by the candidate creation route: one for INVALID_JSON when parsing
malformed `education`/`experience` JSON (the parse block that returns
`INVALID_JSON`) and one for VALIDATION_ERROR that returns a `message` plus the
flattened validation errors (the validation path that uses
`parsed.error.flatten()`). Add example payloads for each variant (INVALID_JSON
with an `error` and `field`/`reason` info, and VALIDATION_ERROR with `message`
and a `details` object/array matching the flattened error shape) and clearly
label which runtime condition produces each response.
In `@frontend/package.json`:
- Around line 44-46: The package.json currently lists ts-jest and
jest-environment-jsdom at v29 but keeps `@types/jest`@^27.x in dependencies and
lacks an explicit jest devDependency; update package.json so that "jest" is
added to devDependencies (matching v29.x), upgrade or add "@types/jest" to a
v29-compatible release (or remove it entirely if you rely on Jest's built-in
types), ensure "ts-jest" and "jest-environment-jsdom" remain on v29, and remove
any "@types/jest" entry from dependencies (move to devDependencies if kept) so
types and runtime versions match.
In `@frontend/src/api/candidates.ts`:
- Around line 1-2: getBaseUrl currently falls back to 'http://localhost:3011'
unconditionally; change it so the localhost fallback is only used in development
and production uses a same-origin relative URL. Update the getBaseUrl function
to: if REACT_APP_API_URL is set, return it with trailing slash removed; else if
process.env.NODE_ENV === 'development' return 'http://localhost:3011'; otherwise
return a relative base (e.g., '' or '/') so production builds never hardcode
localhost. Ensure you reference getBaseUrl and REACT_APP_API_URL (and NODE_ENV)
when making this change.
In `@frontend/src/App.css`:
- Around line 76-85: Add a missing disabled style for the secondary button by
creating a .ats-btn-secondary:disabled rule that mirrors the primary button's
disabled appearance—set muted color, transparent background, and a subdued
border-color and cursor: not-allowed so the "Volver al panel" button (which uses
disabled={submitting} in AddCandidate.tsx) looks and behaves consistently;
update the CSS near the existing .ats-btn-secondary and its :hover rule to
include this :disabled selector referencing .ats-btn-secondary:disabled.
In `@frontend/src/pages/AddCandidate.tsx`:
- Around line 315-327: The degree input (and similarly the job title input and
resume error span) is missing an id and aria-describedby so error spans aren't
announced; update the degree input in the education row render (where
value={row.degree} and setEducation(...) is used) to include a unique id like
`edu_deg_${i}` and set aria-describedby to the corresponding error span id
(e.g., `edu_deg_${i}_error`) only when fieldErrors[`edu_deg_${i}`] exists, and
ensure the error span has that id and role="status"; apply the same pattern to
the job title input (matching its fieldErrors key, e.g., `job_title_${i}`) and
to the resume input/error span (matching the resume fieldErrors key) so screen
readers will announce the error messages.
In `@prompts.md`:
- Line 51: Fix typos in the prompt text on line containing "Puedes crear una
carpeta dentro del proyecto ccon el nomvre que mejor consideres y definir 3
tickets de trabajo para cda uno de las tareas tecnicas?" by changing "ccon el
nomvre" to "con el nombre" and "cda" to "cada", and optionally correct
"nomvre"→"nombre" and "tecnicas"→"técnicas" to improve clarity; update the
sentence in prompts.md so it reads correctly in Spanish while preserving the
original intent.
---
Nitpick comments:
In `@backend/prisma/migrations/20260409140000_add_candidate_ats/migration.sql`:
- Around line 65-87: Add non-unique indexes on the foreign key columns to speed
joins when loading a candidate's related rows: create indexes on
CandidateEducation("candidateId") and CandidateWorkExperience("candidateId") in
the migration (referencing the tables CandidateEducation and
CandidateWorkExperience and the column candidateId) so the DB can efficiently
query by candidateId; add appropriately named indexes (e.g.,
CandidateEducation_candidateId_idx and CandidateWorkExperience_candidateId_idx)
alongside the existing CREATE INDEX statements in migration.sql.
In `@backend/src/tests/app.test.ts`:
- Around line 9-10: The assertion is brittle because it expects a fixed
"localhost:3000"; update the test that uses response (in
backend/src/tests/app.test.ts) to instead assert for the presence of "frontend"
and a generic localhost URL pattern; replace the
expect(response.text).toContain('localhost:3000') with a more resilient check
that verifies response.text contains "frontend" and/or uses
expect(...).toMatch(/localhost:\d+/) (or a combined regex) so any local port
passes.
In `@backend/src/tests/candidates.test.ts`:
- Around line 18-105: Add tests to cover resume file upload and missing/invalid
file handling: extend backend/src/tests/candidates.test.ts by adding cases that
use request(app).post('/api/candidates') with .attach('resume', ...) to assert
successful upload (201 and resume present in response) and rejection scenarios
(oversized file -> expect 413, wrong mime type -> expect 400). Also add tests
for missing required fields (e.g., omit firstName or email) expecting 400
VALIDATION_ERROR and ensure mockedCreate (the mock for
candidateService.createCandidateWithRelations) is not called; reuse mockedCreate
and Prisma.PrismaClientKnownRequestError patterns from existing tests to
simulate backend behavior.
In `@frontend/src/App.tsx`:
- Around line 8-26: Replace the ternary-with-null conditional renders in the App
component with idiomatic short-circuit rendering (use &&) to simplify the JSX:
for each conditional currently using patterns like {view.name === 'dashboard' ?
(<Dashboard ... />) : null}, change to {view.name === 'dashboard' && <Dashboard
onAddCandidate={() => setView({ name: 'form' })} />}, and similarly for
AddCandidate and SuccessScreen while keeping the same props and the
discriminated union View, setView, and candidate handling intact.
In `@frontend/src/pages/AddCandidate.tsx`:
- Around line 57-64: The arrays wrapped by useMemo (suggestedInstitutions and
suggestedCompanies) are static and should be moved to module-level constants to
remove unnecessary hooks; create top-level constants (e.g.,
SUGGESTED_INSTITUTIONS and SUGGESTED_COMPANIES) with the respective string
arrays and replace usages of suggestedInstitutions and suggestedCompanies in
AddCandidate.tsx with those constants, removing the useMemo declarations.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: fba4e366-eff9-4270-9b72-6f8f809acc75
⛔ Files ignored due to path filters (2)
backend/package-lock.jsonis excluded by!**/package-lock.jsonfrontend/package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (37)
.gitignoreREADME.mdbackend/.envbackend/.env.examplebackend/package.jsonbackend/prisma/migrations/20260409140000_add_candidate_ats/migration.sqlbackend/prisma/migrations/migration_lock.tomlbackend/prisma/schema.prismabackend/src/app.tsbackend/src/errors/ApiError.tsbackend/src/index.tsbackend/src/middleware/candidateUpload.tsbackend/src/prisma.tsbackend/src/routes/candidates.tsbackend/src/services/candidateService.tsbackend/src/tests/app.test.jsbackend/src/tests/app.test.tsbackend/src/tests/candidates.test.tsbackend/src/validators/candidatePayload.tsdocker-compose.ymldocs/GUIA_PRUEBA_ADD_CANDIDATE.mddocs/tickets/add-candidate/README.mddocs/tickets/add-candidate/TICKET-01-base-de-datos.mddocs/tickets/add-candidate/TICKET-02-backend-api.mddocs/tickets/add-candidate/TICKET-03-frontend-ui.mdfrontend/README.mdfrontend/jest.config.jsfrontend/package.jsonfrontend/src/App.cssfrontend/src/App.tsxfrontend/src/api/candidates.tsfrontend/src/pages/AddCandidate.tsxfrontend/src/pages/Dashboard.tsxfrontend/src/pages/SuccessScreen.tsxfrontend/src/tests/App.test.tsxfrontend/src/tests/styleMock.jsprompts.md
💤 Files with no reviewable changes (2)
- backend/.env
- backend/src/tests/app.test.js
| DB_PASSWORD=password | ||
| DB_USER=postgres | ||
| DB_NAME=mydatabase | ||
| DB_PORT=5432 | ||
| DATABASE_URL="postgresql://postgres:password@localhost:5432/mydatabase" |
There was a problem hiding this comment.
Avoid config drift between DB_* keys and DATABASE_URL.
Right now there are two sources of truth, but Prisma reads DATABASE_URL. If someone edits DB_* only, the app still points to old credentials.
Suggested simplification
PORT=3011
-DB_PASSWORD=password
-DB_USER=postgres
-DB_NAME=mydatabase
-DB_PORT=5432
-DATABASE_URL="postgresql://postgres:password@localhost:5432/mydatabase"
+DATABASE_URL=postgresql://postgres:password@localhost:5432/mydatabase🧰 Tools
🪛 dotenv-linter (4.0.0)
[warning] 3-3: [UnorderedKey] The DB_PASSWORD key should go before the PORT key
(UnorderedKey)
[warning] 4-4: [UnorderedKey] The DB_USER key should go before the PORT key
(UnorderedKey)
[warning] 5-5: [UnorderedKey] The DB_NAME key should go before the DB_PASSWORD key
(UnorderedKey)
[warning] 6-6: [UnorderedKey] The DB_PORT key should go before the DB_USER key
(UnorderedKey)
[warning] 7-7: [QuoteCharacter] The value has quote characters (', ")
(QuoteCharacter)
[warning] 7-7: [UnorderedKey] The DATABASE_URL key should go before the DB_NAME key
(UnorderedKey)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/.env.example` around lines 3 - 7, Current .env.example defines both
DB_PASSWORD/DB_USER/DB_NAME/DB_PORT and DATABASE_URL which creates two sources
of truth (Prisma uses DATABASE_URL); remove the DB_* entries from .env.example
(or alternatively remove DATABASE_URL and document switching Prisma to use DB_*)
so there is a single canonical connection string—update the example to keep only
DATABASE_URL (and a short comment instructing maintainers to update
DATABASE_URL) and ensure Prisma and any startup scripts reference DATABASE_URL
consistently.
| const maxMb = Number(process.env.MAX_UPLOAD_MB) || 10; | ||
|
|
||
| export const candidateUploadMiddleware = multer({ | ||
| storage, | ||
| fileFilter, | ||
| limits: { fileSize: maxMb * 1024 * 1024 }, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find backend -name "candidateUpload.ts" -type fRepository: LIDR-academy/Ai4devs-ticket-LTI-2026-03-senior
Length of output: 131
🏁 Script executed:
git ls-files backend/src/middleware/ | head -20Repository: LIDR-academy/Ai4devs-ticket-LTI-2026-03-senior
Length of output: 131
🏁 Script executed:
wc -l backend/src/middleware/candidateUpload.tsRepository: LIDR-academy/Ai4devs-ticket-LTI-2026-03-senior
Length of output: 134
🏁 Script executed:
cat -n backend/src/middleware/candidateUpload.ts | sed -n '40,60p'Repository: LIDR-academy/Ai4devs-ticket-LTI-2026-03-senior
Length of output: 512
🏁 Script executed:
grep -r "MAX_UPLOAD_MB" backend --include="*.ts" --include="*.js"Repository: LIDR-academy/Ai4devs-ticket-LTI-2026-03-senior
Length of output: 186
🏁 Script executed:
find backend -name "*.test.ts" -o -name "*.spec.ts" | xargs grep -l "candidateUpload\|MAX_UPLOAD" 2>/dev/nullRepository: LIDR-academy/Ai4devs-ticket-LTI-2026-03-senior
Length of output: 74
🏁 Script executed:
grep -r "process.env\." backend/src --include="*.ts" -A 2 | head -40Repository: LIDR-academy/Ai4devs-ticket-LTI-2026-03-senior
Length of output: 895
🏁 Script executed:
cat -n backend/src/middleware/candidateUpload.tsRepository: LIDR-academy/Ai4devs-ticket-LTI-2026-03-senior
Length of output: 1907
🏁 Script executed:
grep -r "fileSize\|upload\|resume" backend/src --include="*.ts" -B 2 -A 2 | head -50Repository: LIDR-academy/Ai4devs-ticket-LTI-2026-03-senior
Length of output: 3087
🏁 Script executed:
grep -r "fileSize\|multer" backend/src --include="*.ts" -B 3 -A 3 | grep -A 10 -B 10 "error\|Error\|catch"Repository: LIDR-academy/Ai4devs-ticket-LTI-2026-03-senior
Length of output: 1171
🌐 Web query:
Multer fileSize negative limit behavior documentation
💡 Result:
Multer's official documentation does not explicitly document the behavior of negative values for the limits.fileSize option. The limits object is passed directly to the underlying busboy parser without any validation or normalization for negative values in current released versions (up to v2.1.x as of 2026). Busboy, which handles the actual multipart parsing, expects fileSize to be a non-negative integer (default: Infinity). Negative values are likely treated as 0 or invalid: - Setting fileSize: 0 rejects all files immediately with a LIMIT_FILE_SIZE MulterError, as busboy emits the 'limit' event on the first byte (per busboy issue #297, where even fileSize: 1 rejects exactly 1-byte files). - Negative values (e.g., -1) are probably coerced to 0 by JavaScript Number handling or cause busboy to reject files similarly, since limits must be integers and negative limits make no sense for size constraints. A pending PR (#1335, opened 2025) proposes adding validation to throw a MulterError for invalid (negative/non-integer) limits before parsing starts, but it is not merged as of 2026-04-14. To disallow all file uploads, explicitly set limits: { files: 0 } or use multer.none. Avoid negative fileSize values, as they are unsupported and lead to undefined behavior equivalent to rejecting all files. Example error handling: app.use((err, req, res, next) => { if (err.code === 'LIMIT_FILE_SIZE') { res.status(400).send('File too large'); } });
Citations:
- 1: https://www.npmjs.com/package/multer
- 2: https://github.com/expressjs/multer/
- 3: feat: Multer limit option validation and error messages expressjs/multer#1335
- 4: https://github.com/mscdex/busboy
- 5: multer throws "File too large" error if file size of exact "limits.fileSize" bytes provided. expressjs/multer#1348
- 6: In v1
fileSizelimit is 1 byte off mscdex/busboy#297
Validate MAX_UPLOAD_MB as a positive number at startup.
Line 47 uses loose parsing: Number(process.env.MAX_UPLOAD_MB) || 10. Negative values like -1 pass through (since -1 is truthy) and set an invalid limit. According to Multer/busboy behavior, negative fileSize limits cause all uploads to be rejected. Validate this env var as a finite positive number before creating the middleware.
🔧 Suggested fix
-const maxMb = Number(process.env.MAX_UPLOAD_MB) || 10;
+const configuredMaxMb = process.env.MAX_UPLOAD_MB;
+const maxMb = configuredMaxMb == null ? 10 : Number(configuredMaxMb);
+
+if (!Number.isFinite(maxMb) || maxMb <= 0) {
+ throw new Error('MAX_UPLOAD_MB must be a positive number');
+}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/src/middleware/candidateUpload.ts` around lines 47 - 52, Validate
MAX_UPLOAD_MB at startup by parsing process.env.MAX_UPLOAD_MB into a Number and
ensuring it is finite and > 0 before using it to compute the Multer fileSize
limit; update the logic around the maxMb variable used by
candidateUploadMiddleware to fall back to the default (10) when the parsed value
is NaN, non-finite, or <= 0, and log or throw a clear error at module init if
you prefer strict failure instead of silent fallback so negative or invalid
values cannot produce an invalid limits.fileSize for multer/busboy.
| try { | ||
| parsedEducation = parseEducationJson(req.body?.education); | ||
| } catch { | ||
| next(new ApiError(400, 'INVALID_JSON', 'education no es JSON válido')); | ||
| return; | ||
| } | ||
| try { | ||
| parsedExperience = parseExperienceJson(req.body?.experience); | ||
| } catch { | ||
| next(new ApiError(400, 'INVALID_JSON', 'experience no es JSON válido')); | ||
| return; |
There was a problem hiding this comment.
Delete rejected resumes on the pre-database 400 paths.
By the time Line 70 runs, candidateUploadMiddleware has already written the file to disk (backend/src/middleware/candidateUpload.ts:27-35). The early 400s at Line 72, Line 78, and Line 94 return without removing req.file.path, so invalid submissions leave orphaned CVs on disk and retain applicant PII.
🧹 Suggested fix
+async function cleanupUploadedFile(file?: Express.Multer.File) {
+ if (!file?.path) return;
+ try {
+ await fs.promises.unlink(file.path);
+ } catch {
+ /* ignore */
+ }
+}
+
router.post('/', (req: Request, res: Response, next: NextFunction) => {
void (async () => {
const file = req.file;
@@
try {
parsedEducation = parseEducationJson(req.body?.education);
} catch {
+ await cleanupUploadedFile(file);
next(new ApiError(400, 'INVALID_JSON', 'education no es JSON válido'));
return;
}
try {
parsedExperience = parseExperienceJson(req.body?.experience);
} catch {
+ await cleanupUploadedFile(file);
next(new ApiError(400, 'INVALID_JSON', 'experience no es JSON válido'));
return;
}
@@
if (!parsed.success) {
+ await cleanupUploadedFile(file);
next(
new ApiError(400, 'VALIDATION_ERROR', 'Datos de entrada no válidos', parsed.error.flatten()),
);
return;
}
@@
} catch (e) {
- if (file?.path) {
- try {
- fs.unlinkSync(file.path);
- } catch {
- /* ignore */
- }
- }
+ await cleanupUploadedFile(file);
if (isPrismaUniqueViolation(e)) {
next(new ApiError(409, 'DUPLICATE_EMAIL', 'Ya existe un candidato con este correo'));
return;Also applies to: 92-97
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/src/routes/candidates.ts` around lines 69 - 79, When
parseEducationJson or parseExperienceJson throws and you return the ApiError 400
path, ensure the uploaded file is removed first to avoid orphaned PII: in the
catch blocks around parseEducationJson and parseExperienceJson (and the
analogous 92-97 block) call a cleanup that removes req.file.path if req.file
exists before calling next(new ApiError(...)) and returning; reference
candidateUploadMiddleware which writes the file, and use the same file path
cleanup logic (delete/unlink) used elsewhere in the codebase so the file is
always deleted on these pre-database validation failures.
| startDate: z.string().trim().optional().nullable(), | ||
| endDate: z.string().trim().optional().nullable(), |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n backend/src/validators/candidatePayload.tsRepository: LIDR-academy/Ai4devs-ticket-LTI-2026-03-senior
Length of output: 1877
🏁 Script executed:
cat -n backend/src/services/candidateService.ts | head -60Repository: LIDR-academy/Ai4devs-ticket-LTI-2026-03-senior
Length of output: 2252
🏁 Script executed:
find . -name "schema.prisma" -type fRepository: LIDR-academy/Ai4devs-ticket-LTI-2026-03-senior
Length of output: 120
🏁 Script executed:
cat -n backend/prisma/schema.prismaRepository: LIDR-academy/Ai4devs-ticket-LTI-2026-03-senior
Length of output: 2572
🏁 Script executed:
find backend -name "*.ts" -o -name "*.tsx" | head -20Repository: LIDR-academy/Ai4devs-ticket-LTI-2026-03-senior
Length of output: 408
🏁 Script executed:
find . -type f \( -name "*.tsx" -o -name "*.ts" -o -name "*.jsx" -o -name "*.js" \) | grep -E "(form|candidate|date)" | head -20Repository: LIDR-academy/Ai4devs-ticket-LTI-2026-03-senior
Length of output: 366
🏁 Script executed:
cat -n frontend/src/pages/AddCandidate.tsxRepository: LIDR-academy/Ai4devs-ticket-LTI-2026-03-senior
Length of output: 22351
🏁 Script executed:
cat -n backend/src/tests/candidates.test.tsRepository: LIDR-academy/Ai4devs-ticket-LTI-2026-03-senior
Length of output: 3990
Validate date fields instead of accepting arbitrary text.
Lines 7-8 and 14-15 currently accept any trimmed string without format validation, so arbitrary values like "later" pass safeParse() and persist unchanged to the database. Add format validation for these date fields.
🔧 Suggested fix
+const optionalDateString = z
+ .string()
+ .trim()
+ .refine((value) => value === '' || /^\d{4}(-\d{2})?(-\d{2})?$/.test(value), 'Fecha no válida')
+ .transform((value) => (value === '' ? null : value))
+ .optional()
+ .nullable();
+
const educationItemSchema = z.object({
institution: z.string().trim().min(1, 'institution requerido'),
degree: z.string().trim().min(1, 'degree requerido'),
field: z.string().trim().optional().nullable(),
- startDate: z.string().trim().optional().nullable(),
- endDate: z.string().trim().optional().nullable(),
+ startDate: optionalDateString,
+ endDate: optionalDateString,
});
@@
const experienceItemSchema = z.object({
company: z.string().trim().min(1, 'company requerido'),
title: z.string().trim().min(1, 'title requerido'),
- startDate: z.string().trim().optional().nullable(),
- endDate: z.string().trim().optional().nullable(),
+ startDate: optionalDateString,
+ endDate: optionalDateString,
description: z.string().trim().optional().nullable(),
});The regex allows YYYY, YYYY-MM, or YYYY-MM-DD formats, matching the frontend placeholder examples ("2018 o 2018-09") and test expectations.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| startDate: z.string().trim().optional().nullable(), | |
| endDate: z.string().trim().optional().nullable(), | |
| const optionalDateString = z | |
| .string() | |
| .trim() | |
| .refine((value) => value === '' || /^\d{4}(-\d{2})?(-\d{2})?$/.test(value), 'Fecha no válida') | |
| .transform((value) => (value === '' ? null : value)) | |
| .optional() | |
| .nullable(); | |
| const educationItemSchema = z.object({ | |
| institution: z.string().trim().min(1, 'institution requerido'), | |
| degree: z.string().trim().min(1, 'degree requerido'), | |
| field: z.string().trim().optional().nullable(), | |
| startDate: optionalDateString, | |
| endDate: optionalDateString, | |
| }); | |
| const experienceItemSchema = z.object({ | |
| company: z.string().trim().min(1, 'company requerido'), | |
| title: z.string().trim().min(1, 'title requerido'), | |
| startDate: optionalDateString, | |
| endDate: optionalDateString, | |
| description: z.string().trim().optional().nullable(), | |
| }); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/src/validators/candidatePayload.ts` around lines 7 - 8, The startDate
and endDate fields in the candidate payload schema currently use
z.string().trim().optional().nullable() and accept arbitrary text; replace those
with string validators that enforce YYYY, YYYY-MM or YYYY-MM-DD formats using a
regex (e.g. /^\d{4}(-\d{2}(-\d{2})?)?$/) while preserving optional()/nullable()
semantics, update both occurrences of startDate and endDate in the validator
(including any nested employment/education entries) to use
z.string().trim().regex(...).refine or .regex(..., { message: 'invalid date
format' }) so invalid values fail safeParse() and tests reflect the new error
message.
| El backend actual (`backend/src/index.ts`) expone una ruta de prueba y manejo genérico de errores. Se necesita una **API REST** (o RPC documentado) que permita al reclutador **crear un candidato** con sus datos, educación, experiencia y **subida opcional u obligatoria de CV** en **PDF o DOCX**, con **validación**, **respuestas coherentes** y **mensajes de error** comprensibles para el cliente. | ||
|
|
||
| **Objetivo entregable:** endpoint(s) funcionando contra PostgreSQL vía Prisma, subida de fichero persistente, validación servidor, tests automatizados mínimos y notas de seguridad. |
There was a problem hiding this comment.
Turn the chosen upload flow into a concrete contract.
This ticket still leaves both "multipart vs two-step" and "optional vs required CV" open-ended. The implementation has already chosen single-request multipart/form-data, and resume is optional (backend/src/routes/candidates.ts:53-129, backend/src/middleware/candidateUpload.ts:49-53). Keeping the contract ambiguous here is enough for frontend and tests to drift.
📝 Suggested doc update
-El backend actual (`backend/src/index.ts`) expone una ruta de prueba y manejo genérico de errores. Se necesita una **API REST** (o RPC documentado) que permita al reclutador **crear un candidato** con sus datos, educación, experiencia y **subida opcional u obligatoria de CV** en **PDF o DOCX**, con **validación**, **respuestas coherentes** y **mensajes de error** comprensibles para el cliente.
+El backend actual (`backend/src/index.ts`) expone una ruta de prueba y manejo genérico de errores. Se necesita una **API REST** que permita al reclutador **crear un candidato** con sus datos, educación, experiencia y **subida opcional de CV** en **PDF o DOCX**, con **validación**, **respuestas coherentes** y **mensajes de error** comprensibles para el cliente.
@@
-- Aceptación **multipart/form-data** si el CV se envía en la misma petición (recomendado para UX del ticket 03), **o** flujo en dos pasos (crear candidato + `POST /api/candidates/:id/resume`); **elegir una** y documentarla en §4.
+- Aceptación de **multipart/form-data** en `POST /api/candidates`, adjuntando `resume` en la misma petición cuando exista.
@@
-| `resume` | file | opcional/obligatorio según producto |
+| `resume` | file | opcional |Also applies to: 25-30, 65-77
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/tickets/add-candidate/TICKET-02-backend-api.md` around lines 15 - 17,
Actualiza la especificación del endpoint de creación de candidato para convertir
la elección actual (single-request multipart/form-data) en un contrato claro:
documenta que POST /candidates acepta Content-Type: multipart/form-data con
campos JSON planos (e.g., name, email, education[], experience[]) y un campo de
archivo resume (campo: "resume") que es opcional; valida tipos MIME aceptados
(application/pdf,
application/vnd.openxmlformats-officedocument.wordprocessingml.document), tamaño
máximo (p. ej. 5MB), y esquema de campos requeridos/formatos (email regex,
fechas ISO, etc.); especifica códigos de respuesta y cuerpos de error coherentes
(400 con {error, details}, 422 para validación, 201 con {id, ...} al crear), y
referencia las implementaciones actuales middleware candidateUpload
(candidateUpload.ts) y la ruta POST handler en routes/candidates.ts para que
tests y frontend esperen ese formato exacto.
| "devDependencies": { | ||
| "jest-environment-jsdom": "^29.7.0", | ||
| "ts-jest": "^29.1.2" |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Declared frontend test stack:"
jq '{
test_script: .scripts.test,
dependencies: ((.dependencies // {}) | with_entries(select(.key|test("(^react-scripts$|^@types/jest$)")))),
devDependencies: ((.devDependencies // {}) | with_entries(select(.key|test("(^jest$|^ts-jest$|^jest-environment-jsdom$|^@types/jest$)"))))
}' frontend/package.json
if [ -f frontend/package-lock.json ]; then
echo "Resolved versions from lockfile:"
jq -r '{
jest: .packages["node_modules/jest"].version,
ts_jest: .packages["node_modules/ts-jest"].version,
jest_environment_jsdom: .packages["node_modules/jest-environment-jsdom"].version
}' frontend/package-lock.json
fiRepository: LIDR-academy/Ai4devs-ticket-LTI-2026-03-senior
Length of output: 466
Update Jest and its type definitions to v29 for proper type support.
The package declares ts-jest@^29.1.2 and jest-environment-jsdom@^29.7.0, but carries @types/jest@^27.5.2 in dependencies. Lockfile resolves Jest to 29.7.0, creating a type definition mismatch that may hide TypeScript errors in tests. Additionally, jest should be explicitly declared in devDependencies to avoid accidental removal.
Suggested fix
"devDependencies": {
+ "jest": "^29.7.0",
+ "@types/jest": "^29.5.12",
"jest-environment-jsdom": "^29.7.0",
"ts-jest": "^29.1.2"
}Also remove @types/jest from dependencies if present.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "devDependencies": { | |
| "jest-environment-jsdom": "^29.7.0", | |
| "ts-jest": "^29.1.2" | |
| "devDependencies": { | |
| "jest": "^29.7.0", | |
| "@types/jest": "^29.5.12", | |
| "jest-environment-jsdom": "^29.7.0", | |
| "ts-jest": "^29.1.2" |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/package.json` around lines 44 - 46, The package.json currently lists
ts-jest and jest-environment-jsdom at v29 but keeps `@types/jest`@^27.x in
dependencies and lacks an explicit jest devDependency; update package.json so
that "jest" is added to devDependencies (matching v29.x), upgrade or add
"@types/jest" to a v29-compatible release (or remove it entirely if you rely on
Jest's built-in types), ensure "ts-jest" and "jest-environment-jsdom" remain on
v29, and remove any "@types/jest" entry from dependencies (move to
devDependencies if kept) so types and runtime versions match.
| const getBaseUrl = (): string => | ||
| process.env.REACT_APP_API_URL?.replace(/\/$/, '') || 'http://localhost:3011'; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's see the actual code in the candidates.ts file
cat -n frontend/src/api/candidates.ts | head -20Repository: LIDR-academy/Ai4devs-ticket-LTI-2026-03-senior
Length of output: 705
🏁 Script executed:
# Check if there's any deployment/environment configuration
find . -type f \( -name ".env*" -o -name "*.config.*" -o -name "package.json" \) | grep -E "\.(env|config|package)" | head -20Repository: LIDR-academy/Ai4devs-ticket-LTI-2026-03-senior
Length of output: 163
🏁 Script executed:
# Search for how getBaseUrl is used in the codebase
rg "getBaseUrl" -nRepository: LIDR-academy/Ai4devs-ticket-LTI-2026-03-senior
Length of output: 251
🏁 Script executed:
# Check for REACT_APP_API_URL references to understand how it's configured
rg "REACT_APP_API_URL" -nRepository: LIDR-academy/Ai4devs-ticket-LTI-2026-03-senior
Length of output: 1886
Don't default browser API calls to localhost outside development.
Line 2 falls back to http://localhost:3011 unconditionally. If REACT_APP_API_URL is not set during the production build, the bundle will contain undefined and callers will POST to the user's own machine instead of the hosted API. Restrict that fallback to development, or use a same-origin relative URL in production.
🔧 Suggested fix
-const getBaseUrl = (): string =>
- process.env.REACT_APP_API_URL?.replace(/\/$/, '') || 'http://localhost:3011';
+const getBaseUrl = (): string => {
+ const configured = process.env.REACT_APP_API_URL?.replace(/\/+$/, '');
+ if (configured) return configured;
+ return process.env.NODE_ENV === 'development' ? 'http://localhost:3011' : '';
+};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const getBaseUrl = (): string => | |
| process.env.REACT_APP_API_URL?.replace(/\/$/, '') || 'http://localhost:3011'; | |
| const getBaseUrl = (): string => { | |
| const configured = process.env.REACT_APP_API_URL?.replace(/\/+$/, ''); | |
| if (configured) return configured; | |
| return process.env.NODE_ENV === 'development' ? 'http://localhost:3011' : ''; | |
| }; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/api/candidates.ts` around lines 1 - 2, getBaseUrl currently
falls back to 'http://localhost:3011' unconditionally; change it so the
localhost fallback is only used in development and production uses a same-origin
relative URL. Update the getBaseUrl function to: if REACT_APP_API_URL is set,
return it with trailing slash removed; else if process.env.NODE_ENV ===
'development' return 'http://localhost:3011'; otherwise return a relative base
(e.g., '' or '/') so production builds never hardcode localhost. Ensure you
reference getBaseUrl and REACT_APP_API_URL (and NODE_ENV) when making this
change.
| .ats-btn-secondary { | ||
| background: transparent; | ||
| color: #e2e8f0; | ||
| border-color: rgba(148, 163, 184, 0.45); | ||
| } | ||
|
|
||
| .ats-btn-secondary:hover:not(:disabled) { | ||
| border-color: #cbd5e1; | ||
| color: #fff; | ||
| } |
There was a problem hiding this comment.
Missing :disabled style for secondary button.
The primary button has explicit disabled styling (lines 71-74), but .ats-btn-secondary:disabled is missing. In AddCandidate.tsx, the "Volver al panel" button uses disabled={submitting}, so it needs consistent disabled styling.
🎨 Proposed fix
.ats-btn-secondary:hover:not(:disabled) {
border-color: `#cbd5e1`;
color: `#fff`;
}
+
+.ats-btn-secondary:disabled {
+ opacity: 0.65;
+ cursor: not-allowed;
+}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| .ats-btn-secondary { | |
| background: transparent; | |
| color: #e2e8f0; | |
| border-color: rgba(148, 163, 184, 0.45); | |
| } | |
| .ats-btn-secondary:hover:not(:disabled) { | |
| border-color: #cbd5e1; | |
| color: #fff; | |
| } | |
| .ats-btn-secondary { | |
| background: transparent; | |
| color: `#e2e8f0`; | |
| border-color: rgba(148, 163, 184, 0.45); | |
| } | |
| .ats-btn-secondary:hover:not(:disabled) { | |
| border-color: `#cbd5e1`; | |
| color: `#fff`; | |
| } | |
| .ats-btn-secondary:disabled { | |
| opacity: 0.65; | |
| cursor: not-allowed; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/App.css` around lines 76 - 85, Add a missing disabled style for
the secondary button by creating a .ats-btn-secondary:disabled rule that mirrors
the primary button's disabled appearance—set muted color, transparent
background, and a subdued border-color and cursor: not-allowed so the "Volver al
panel" button (which uses disabled={submitting} in AddCandidate.tsx) looks and
behaves consistently; update the CSS near the existing .ats-btn-secondary and
its :hover rule to include this :disabled selector referencing
.ats-btn-secondary:disabled.
| <input | ||
| value={row.degree} | ||
| onChange={(ev) => { | ||
| const v = ev.target.value; | ||
| setEducation((rows) => rows.map((r, j) => (j === i ? { ...r, degree: v } : r))); | ||
| }} | ||
| aria-invalid={!!fieldErrors[`edu_deg_${i}`]} | ||
| /> | ||
| {fieldErrors[`edu_deg_${i}`] ? ( | ||
| <span className="ats-field-error" role="status"> | ||
| {fieldErrors[`edu_deg_${i}`]} | ||
| </span> | ||
| ) : null} |
There was a problem hiding this comment.
Missing id and aria-describedby on degree/title inputs.
The institution and company inputs have proper id attributes for focus targeting and accessibility, but the degree input (and similarly, title input at line 405) lacks an id and aria-describedby linkage. This means the error message won't be announced by screen readers.
🔧 Proposed fix for degree input
<label className="ats-field">
Título / grado
<input
+ id={`${formId}-edu_deg_${i}`}
value={row.degree}
onChange={(ev) => {
const v = ev.target.value;
setEducation((rows) => rows.map((r, j) => (j === i ? { ...r, degree: v } : r)));
}}
aria-invalid={!!fieldErrors[`edu_deg_${i}`]}
+ aria-describedby={fieldErrors[`edu_deg_${i}`] ? `${formId}-edu_deg_${i}-err` : undefined}
/>
{fieldErrors[`edu_deg_${i}`] ? (
- <span className="ats-field-error" role="status">
+ <span id={`${formId}-edu_deg_${i}-err`} className="ats-field-error" role="status">
{fieldErrors[`edu_deg_${i}`]}
</span>
) : null}
</label>Apply the same pattern to the title input (lines 405-418) and resume error span (lines 476-479).
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <input | |
| value={row.degree} | |
| onChange={(ev) => { | |
| const v = ev.target.value; | |
| setEducation((rows) => rows.map((r, j) => (j === i ? { ...r, degree: v } : r))); | |
| }} | |
| aria-invalid={!!fieldErrors[`edu_deg_${i}`]} | |
| /> | |
| {fieldErrors[`edu_deg_${i}`] ? ( | |
| <span className="ats-field-error" role="status"> | |
| {fieldErrors[`edu_deg_${i}`]} | |
| </span> | |
| ) : null} | |
| <input | |
| id={`${formId}-edu_deg_${i}`} | |
| value={row.degree} | |
| onChange={(ev) => { | |
| const v = ev.target.value; | |
| setEducation((rows) => rows.map((r, j) => (j === i ? { ...r, degree: v } : r))); | |
| }} | |
| aria-invalid={!!fieldErrors[`edu_deg_${i}`]} | |
| aria-describedby={fieldErrors[`edu_deg_${i}`] ? `${formId}-edu_deg_${i}-err` : undefined} | |
| /> | |
| {fieldErrors[`edu_deg_${i}`] ? ( | |
| <span id={`${formId}-edu_deg_${i}-err`} className="ats-field-error" role="status"> | |
| {fieldErrors[`edu_deg_${i}`]} | |
| </span> | |
| ) : null} |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/pages/AddCandidate.tsx` around lines 315 - 327, The degree input
(and similarly the job title input and resume error span) is missing an id and
aria-describedby so error spans aren't announced; update the degree input in the
education row render (where value={row.degree} and setEducation(...) is used) to
include a unique id like `edu_deg_${i}` and set aria-describedby to the
corresponding error span id (e.g., `edu_deg_${i}_error`) only when
fieldErrors[`edu_deg_${i}`] exists, and ensure the error span has that id and
role="status"; apply the same pattern to the job title input (matching its
fieldErrors key, e.g., `job_title_${i}`) and to the resume input/error span
(matching the resume fieldErrors key) so screen readers will announce the error
messages.
|
|
||
| Te recomendamos encarecidamente definir primero los 3 tickets de trabajo a fondo, y usarlos como input para el asistente de código. | ||
|
|
||
| Puedes crear una carpeta dentro del proyecto ccon el nomvre que mejor consideres y definir 3 tickets de trabajo para cda uno de las tareas tecnicas? tipo SDD, todo bien detallado |
There was a problem hiding this comment.
Minor typos in prompt documentation.
Line 51 contains typos: "ccon el nomvre" → "con el nombre", "cda" → "cada". Since this is an internal record of prompts used, this is low priority but worth correcting for clarity.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@prompts.md` at line 51, Fix typos in the prompt text on line containing
"Puedes crear una carpeta dentro del proyecto ccon el nomvre que mejor
consideres y definir 3 tickets de trabajo para cda uno de las tareas tecnicas?"
by changing "ccon el nomvre" to "con el nombre" and "cda" to "cada", and
optionally correct "nomvre"→"nombre" and "tecnicas"→"técnicas" to improve
clarity; update the sentence in prompts.md so it reads correctly in Spanish
while preserving the original intent.
Implement candidate models and migration, POST /api/candidates with multipart validation and PDF/DOCX uploads, and React dashboard plus form. Default API port 3011 to avoid clashes with other local LTI projects on 3010. Root GET / returns HTML clarifying API vs frontend URL.
Add detailed manual test guide and SDD-style tickets under docs/tickets. Stop tracking backend/.env; add .env.example and ignore **/.env.
Made-with: Cursor
Summary by CodeRabbit
Release Notes
New Features
Tests
Documentation