From 8750bd0b8fe19772b775acb5fc691c3a543543f4 Mon Sep 17 00:00:00 2001 From: _david Date: Tue, 29 Sep 2026 00:04:06 +0700 Subject: [PATCH] feat(cv-sections): add best-effort bulk-create endpoint for education/experience Closes #161. Pairs with the stateless LinkedIn-export-parse flow (#141): parse -> review client-side -> bulk-save many entries in one request instead of one create call per entry. - src/candidate_profile/BaseController.ts: new fnBulkCreate on createCrudController() (reused, not a dedicated bulk-only path) and a MAX_BULK_ITEMS=100 cap. Forces candidateId from the authenticated req.user._id onto every array item before validation, never trusting a client-supplied value nested inside items[] (verifyToken only forces req.body.candidateId at the top level) - same IDOR-safe pattern as every other write path, applied per item. Validates each item with the section's own Joi schema and calls the section's own service.handlerCreate per item; best-effort, one bad item never blocks the rest. - Bug found and fixed during implementation: utils/helper.ts's formatResponse() nulls out `data` whenever the response envelope's `success` is false. A naive envelope tied to summary.failed===0 would have silently dropped results/summary on every partial failure - the one case the response most needs to report. fnBulkCreate keeps the envelope success:true always; real per-item pass/fail lives in results[]/summary instead. - src/candidate_profile/education/education.controller.ts, src/candidate_profile/experience/experience.controller.ts: export the new fnBulkCreate alongside fnCreate/fnUpdate. - src/routers/api/v1/education.route.ts, src/routers/api/v1/experience.route.ts: new POST /bulk route + swagger docs. Scope limited to education/experience, matching what the LinkedIn-export parser currently produces. - src/locales/en.ts, src/locales/vi.ts: 2 new common.* i18n keys (bulkNoItems, bulkTooManyItems) for the new 400 rejection paths. - src/__tests__/candidate_profile/BaseController.test.ts: 5 new tests - reject missing/non-array items, reject >100 items, candidateId force (IDOR-safe), partial-failure results/summary, all-success summary. - agent-hub: seal node add-bulk-import-cv-sections (implementer + independent verifier subagent pass). npm test: 155 passed, 155 total (29/29 suites, independently re-run by the verifier subagent from scratch). npm run build: clean. Node: add-bulk-import-cv-sections (SEALED) Evidence: agent-hub/evidence/implementer/2026-09-28/add-bulk-import-cv-sections-plan.md, agent-hub/evidence/verifier/2026-09-28/add-bulk-import-cv-sections-seal.md Co-Authored-By: Claude Sonnet 5 --- .../add-bulk-import-cv-sections-plan.md | 145 +++++++++++++++++ .../add-bulk-import-cv-sections-seal.md | 152 ++++++++++++++++++ .../haven/diagrams/dev-loop.prime-mermaid.md | 1 + .../candidate_profile/BaseController.test.ts | 90 ++++++++++- src/candidate_profile/BaseController.ts | 64 +++++++- .../education/education.controller.ts | 2 +- .../experience/experience.controller.ts | 2 +- src/locales/en.ts | 2 + src/locales/vi.ts | 2 + src/routers/api/v1/education.route.ts | 50 +++++- src/routers/api/v1/experience.route.ts | 50 +++++- 11 files changed, 553 insertions(+), 7 deletions(-) create mode 100644 agent-hub/evidence/implementer/2026-09-28/add-bulk-import-cv-sections-plan.md create mode 100644 agent-hub/evidence/verifier/2026-09-28/add-bulk-import-cv-sections-seal.md diff --git a/agent-hub/evidence/implementer/2026-09-28/add-bulk-import-cv-sections-plan.md b/agent-hub/evidence/implementer/2026-09-28/add-bulk-import-cv-sections-plan.md new file mode 100644 index 0000000..6c79710 --- /dev/null +++ b/agent-hub/evidence/implementer/2026-09-28/add-bulk-import-cv-sections-plan.md @@ -0,0 +1,145 @@ +# 2026-09-28 — add-bulk-import-cv-sections (implementer plan) + +- Worker: implementer +- Version: 0.1.0 +- Node: `add-bulk-import-cv-sections` (`haven/diagrams/dev-loop.prime-mermaid.md`) +- Task (verbatim): Bulk import endpoint for CV sections (issue #161): Add a + bulk-create endpoint per CV section (e.g. POST /api/v1/education/bulk, + POST /api/v1/experience/bulk, ...), accepting an array of entries and + creating them in one request under the authenticated candidate's + candidateId (never client-supplied, same IDOR-safe pattern as every other + write path). Natural pairing with the LinkedIn-export-parse flow: parse -> + review client-side -> bulk-save. Open questions to resolve during + implementation: which sections need it first (likely education/experience + only, matching what LinkedIn export currently parses), partial-failure + behavior (all-or-nothing vs best-effort per-item report), and whether to + reuse createCrudService() (BaseService.ts) with a new handlerBulkCreate or + a dedicated bulk-only path. Acceptance criteria: bulk endpoint(s) create + multiple entries under the authenticated candidate only; validation + applies per-entry (same Joi schemas as existing single-create routes); + response reports what succeeded/failed per item (if best-effort) or a + single success (if transactional). + +## Hub bytes before: 97923 + +## Open questions resolved +- Scope: education + experience only (issue's own recommendation — matches + what `parseLinkedInExport.service.ts`/#141 currently parses). Not applied + to the other 5 CV-section-like collections (award/certificate/project/ + reference/generalInformation) or application/profile — own follow-up node + if a section beyond LinkedIn's scope needs it. +- Partial-failure behavior: best-effort, per-item report — matches the + acceptance criteria's "if best-effort" branch and how `baseGetAll` + already reports partial states (pagination) rather than an all-or-nothing + transaction, which Mongoose (no multi-document ACID transaction already + wired anywhere in this codebase) would add real new complexity for. +- Reuse vs dedicated path: reused `createCrudController()` + (`BaseController.ts`) with a new `fnBulkCreate`, calling the SAME + `service.handlerCreate` each existing single-create route already calls + and validating each item against the SAME Joi `schema` each section + already passes in. No changes needed to `BaseService.ts` or + `services/index.ts` — `baseCreateDocument` already validates+creates one + document at a time correctly, looping over it per item was sufficient. + +## Diff +| File | Why | +|---|---| +| `src/candidate_profile/BaseController.ts` | New `MAX_BULK_ITEMS = 100` cap + new `fnBulkCreate` returned from `createCrudController()`. Forces `candidateId` from `(req as any).user?._id` onto every array item before validation (verifyToken only forces `req.body.candidateId` at the top level, never touching entries nested inside `req.body.items` — same IDOR-safe pattern as every other write path, applied at the per-item level here). Validates each item with the section's own Joi `schema`, calls the section's own `service.handlerCreate` per valid item, collects `{index, ...result}` per item, returns `{results, summary: {total, succeeded, failed}}`. | +| `src/candidate_profile/education/education.controller.ts` | Destructure+export the new `fnBulkCreate` alongside the existing `fnCreate`/`fnUpdate`. | +| `src/candidate_profile/experience/experience.controller.ts` | Same. | +| `src/routers/api/v1/education.route.ts` | New `POST /bulk` route + swagger doc, mounted before `/update` (no path collision with existing routes). | +| `src/routers/api/v1/experience.route.ts` | Same. | +| `src/locales/en.ts`, `src/locales/vi.ts` | 2 new `common.*` i18n keys: `bulkNoItems`, `bulkTooManyItems` (for the 2 new 400 rejection paths — no items sent / more than 100 items in one request). | +| `src/__tests__/candidate_profile/BaseController.test.ts` | 5 new tests for `fnBulkCreate` (new `describe` block, existing `baseGetAll` tests untouched). | +| `agent-hub/haven/diagrams/dev-loop.prime-mermaid.md` | New PENDING row for this node, appended at the end of the PM status table (AppendOnly). | + +## Bug found during implementation +`src/utils/helper.ts`'s `formatResponse()` (lines ~178-186) nulls out +`data` whenever the response envelope's `success` is `false`: +```ts +const getData = (() => { + if (!success) return null; + ... + return data; +})(); +``` +A naive `fnBulkCreate` that set the envelope `success: summary.failed === 0` +would have silently dropped `results`/`summary` from the response body on +every partial failure — exactly the one case the acceptance criteria +("response reports what succeeded/failed per item") needs it most. Fixed +in this diff by keeping the envelope `success: true` always (the bulk +request itself was processed successfully; per-item pass/fail lives in +`results[].success` and `summary`, not the envelope) — commented in both +the implementation and the regression test that asserts it +(`BaseController.test.ts`, "is best-effort: one invalid item does not +block the others, and results/summary are still returned on partial +failure"). Not added to `doctrine/domains/PROJECT.md`'s Traps table — this +is pre-existing infra behavior every OTHER caller already works around by +never setting `success: false` with a real `data` payload; flagging it +here as a footgun for any future non-bulk caller that tries to do the same +is a documentation call for the operator, not fixed in this diff (would be +a behavior change to `formatResponse()` itself, out of scope for #161). + +## Command +`npm test` (from `/Users/_david/Workspace/Project/resume/resume-nodejs-api`) + +## Output (verbatim, tail) +``` +PASS src/__tests__/candidate_profile/BaseController.test.ts + baseGetAll + ✓ passes page/limit/sort through as numbers/string when present (1 ms) + ✓ omits page/limit/sort when the query string has none (backward compatible) + ✓ silently drops a sort value that could smuggle a Mongo operator (1 ms) + ✓ accepts a leading "-" in sort for descending order + createCrudController -> fnBulkCreate (issue #161) + ✓ rejects with 400 when items is missing or not an array + ✓ rejects with 400 when items exceeds the 100-item cap + ✓ forces candidateId from the authenticated user onto every item, ignoring a client-supplied value (IDOR-safe) (1 ms) + ✓ is best-effort: one invalid item does not block the others, and results/summary are still returned on partial failure (2 ms) + ✓ reports summary.failed: 0 when every item succeeds + +A worker process has failed to exit gracefully and has been force exited. This is likely caused by tests leaking due to improper teardown. Try running with --detectOpenHandles to find leaks. Active timers can also cause this, ensure that .unref() was called on them. +Test Suites: 29 passed, 29 total +Tests: 155 passed, 155 total +Snapshots: 0 total +Time: 9.312 s +Ran all test suites. +``` +(The "worker process failed to exit gracefully" warning is a pre-existing +Jest/open-handle notice unrelated to this diff — every suite still passed, +0 failures.) + +Also ran `npm run build` (tsc && copy) — clean, no output beyond the copy +step, no typecheck errors. + +## Acceptance +| Criterion | Evidence | +|---|---| +| Bulk endpoint(s) create multiple entries under the authenticated candidate only | `fnBulkCreate` forces `candidateId` from `(req as any).user?._id` onto every item before validation, never trusting a client-supplied value — `BaseController.ts` lines in the `fnBulkCreate` block; regression test "forces candidateId from the authenticated user onto every item, ignoring a client-supplied value (IDOR-safe)" — `Tests: 155 passed, 155 total` includes this test | +| Validation applies per-entry (same Joi schemas as existing single-create routes) | `fnBulkCreate` calls `validateSchema({ schema, item: {...items[index], candidateId}, lang })` per item using the SAME `schema` param already passed to `createCrudController()` for `/create` — no new schema. Regression test "is best-effort: one invalid item does not block the others..." proves an invalid item (`{name: 'x'}`, fails `min(2)`) is rejected per-item while the valid sibling item still reaches `service.handlerCreate` | +| Response reports what succeeded/failed per item (best-effort) | `data: { results, summary }` where `results[i]` carries `{index, success, message, data/errors}` per item and `summary = {total, succeeded, failed}`. Regression tests "is best-effort..." (`summary: {total:2, succeeded:1, failed:1}`) and "reports summary.failed: 0 when every item succeeds" (`summary: {total:2, succeeded:2, failed:0}`) both pass | + +## Noticed, not done +- Scope limited to education/experience — the other CV-section-like + collections (award/certificate/project/reference/generalInformation, + application, profile) don't have a bulk-create route. Matches the + issue's own open question guidance ("likely education/experience only"); + own follow-up node if a wider section needs it. +- No transactional (all-or-nothing) mode — best-effort only. The issue's + acceptance criteria explicitly allows either; best-effort was chosen + since no Mongo multi-document transaction wiring exists anywhere else in + this codebase (would be new infrastructure, not proportionate to this + task). +- `formatResponse()`'s success-nulls-data behavior (see "Bug found during + implementation" above) is pre-existing, not touched — flagged for the + operator as a possible footgun for a future caller, not a regression + introduced here. + +## Seal gate +Not applicable to this recipe step — no outward-facing action (commit, +push, delete, external API call) has happened yet. All changes are in the +local working tree on branch `161-bulk-import-endpoint`, unstaged/staged +but not committed. The `src/` diff itself was shown in full in the session +transcript, matching the seal-gate spirit even though nothing outward- +facing has been requested yet — commit/push waits for `/ship` (not invoked +this round; `/todo` was run without `--ship`). diff --git a/agent-hub/evidence/verifier/2026-09-28/add-bulk-import-cv-sections-seal.md b/agent-hub/evidence/verifier/2026-09-28/add-bulk-import-cv-sections-seal.md new file mode 100644 index 0000000..4895d7a --- /dev/null +++ b/agent-hub/evidence/verifier/2026-09-28/add-bulk-import-cv-sections-seal.md @@ -0,0 +1,152 @@ +# 2026-09-28 — add-bulk-import-cv-sections (verifier verdict) + +- Worker: verifier (subagent, dispatched via Agent tool) +- Node: `add-bulk-import-cv-sections` (`haven/diagrams/dev-loop.prime-mermaid.md`) +- New PM status: SEALED + +## Isolation proof +Dispatched as a fresh Agent-tool subagent with the task description "You +are the `verifier` worker in this repo's agent-hub... Run `verify_seal` +for node `add-bulk-import-cv-sections`" — a spawn string the implementer +session never saw. No conversation history with the implementer pass; +every fact below was re-derived by reading the working tree, the note, +and re-running `npm test`/`npm run build` in this session, not carried +over from any prior context. + +## Reasoning +1. **Diff scope** — `git status --short` on branch `161-bulk-import-endpoint` + showed exactly the 9 files the note claims (`agent-hub/haven/diagrams/ + dev-loop.prime-mermaid.md`, `src/__tests__/candidate_profile/ + BaseController.test.ts`, `src/candidate_profile/BaseController.ts`, + `src/candidate_profile/education/education.controller.ts`, + `src/candidate_profile/experience/experience.controller.ts`, + `src/locales/en.ts`, `src/locales/vi.ts`, `src/routers/api/v1/ + education.route.ts`, `src/routers/api/v1/experience.route.ts`) plus + the untracked evidence note. Nothing else touched; no commit/push. + +2. **`BaseController.ts` — `fnBulkCreate` (read in full)**: + - `const candidateId = (req as any).user?._id;` then every item is + validated as `{ ...items[index], candidateId }` — client-supplied + `candidateId` inside an array item is overwritten before + `validateSchema` ever sees it. Confirmed this is genuinely necessary + (not redundant with `verifyToken`) by reading `verifyToken. + middleware.ts` — it only assigns `req.body.candidateId = req.user._id` + at the top level, never touches nested array entries. + - Uses the SAME `schema` param already passed into + `createCrudController()` for `fnCreate`/`fnUpdate` — no second/looser + schema introduced. + - Calls `service.handlerCreate` per item — the identical service call + `fnCreate` uses. No bypass of `BaseService.ts`/`services/index.ts`. + Read `baseCreateDocument` in `services/index.ts`: it already returns + `{ success, message, data, errors }`, which is exactly the shape + `fnBulkCreate` spreads into `results[]` and filters on + `r.success` — consistent, not assumed. + - `MAX_BULK_ITEMS = 100` hard cap; `!items || !items.length` and + `items.length > MAX_BULK_ITEMS` both return 400 via `t('common. + bulkNoItems'|'bulkTooManyItems', lang)`. + - **Bug-avoidance claim independently verified, not trusted from the + note**: read `utils/helper.ts`'s `formatResponse()` end-to-end — + `const getData = (() => { if (!success) return null; ... })()` + really does null `data` whenever the envelope's `success` is falsy. + Read `fnBulkCreate`'s final `formatReturn` call — `success: true` + is hardcoded, never `summary.failed === 0`, with the comment + explaining why. Traced `formatReturn` → `formatResponse` to confirm + the real call chain, not just the test mock. Had `fnBulkCreate` used + `summary.failed === 0` instead, a partial-failure response would + have `data: null`, breaking the "response reports what succeeded/ + failed per item" acceptance criterion — this was genuinely avoided. + +3. **Controllers** — `education.controller.ts` and `experience. + controller.ts` both now `export const { fnCreate, fnUpdate, + fnBulkCreate } = createCrudController({...})` — the `createCrudController` + call itself is otherwise unchanged (same `schema`/`service`/ + `booleanDefaultField` args), only the destructured export set grew. + +4. **Routes** — both `education.route.ts` and `experience.route.ts` add + `router.post('/bulk', fnBulkCreate)` (plus a swagger block), positioned + between `/create` and `/update` — no collision with `/`, `/create`, + `/update`, `/delete/:id`, `/restore/:id`. Confirmed in `routers/api/v1/ + index.ts` that both routers are mounted `router.use('/education', + verifyToken, routeEducation)` / `router.use('/experience', verifyToken, + routeExperience)` — `req.user._id` is genuinely populated by the time + `fnBulkCreate` runs. + +5. **i18n** — `common.bulkNoItems`/`common.bulkTooManyItems` exist in both + `src/locales/en.ts` and `src/locales/vi.ts` (grep-confirmed at line + 47-48 in both files) and are the exact keys referenced by + `fnBulkCreate`'s two 400 branches. + +6. **Tests** — read `src/__tests__/candidate_profile/BaseController.test.ts` + in full (143 lines). The 4 pre-existing `baseGetAll` tests are present + and unchanged. The new `describe('createCrudController -> fnBulkCreate + (issue #161)')` block has exactly 5 tests, each doing real assertion + work, not just "was called": + - missing/non-array `items` -> 400, `handlerCreate` never called. + - 101 items -> 400, `handlerCreate` never called. + - a client-supplied `candidateId: 'someone-elses-id'` on the one item + is overwritten — `expect(handlerCreate).toHaveBeenCalledWith( + expect.objectContaining({ candidateId: 'real-user' }), 'en')` is a + concrete, non-vacuous IDOR assertion. + - one invalid item (`name: 'x'`, fails `min(2)`) + one valid item -> + `handlerCreate` called exactly once, `res.status(201)`, + `payload.data.summary` equals `{ total: 2, succeeded: 1, failed: 1 }`, + `results[0]`/`results[1]` assert `success: true`/`false` respectively. + Note this test does NOT mock `@/utils` — only `@/services` is + `jest.mock`ed — so `payload.success === true` on this partial-failure + path is exercising the REAL `formatReturn`/`formatResponse` chain, + genuinely proving the bug-avoidance claim in criterion 2 above, not + just a mocked assertion. + - all-success case: `summary` equals `{ total: 2, succeeded: 2, + failed: 0 }`. + +7. **`npm test` — independently re-run** (not audit-only; see Re-run + below) from `/Users/_david/Workspace/Project/resume/resume-nodejs-api`: + ``` + Test Suites: 29 passed, 29 total + Tests: 155 passed, 155 total + Snapshots: 0 total + Time: 6.273 s, estimated 9 s + Ran all test suites. + ``` + Matches the note's claimed `29 passed, 29 total` / `155 passed, 155 + total` exactly. (Same pre-existing "worker process failed to exit + gracefully" Jest open-handle notice as the note describes, unrelated + to this diff.) + +8. **`npm run build` — independently re-run**: `tsc && npm run copy` + completed with no output beyond the `cp -R ./src/views ./src/public + ./dist/` copy step — clean, no typecheck errors. + +9. **Traps/invariants** (`doctrine/domains/PROJECT.md`) — this diff does + not touch `QuerySafe`, bcrypt, the Chrome path, CORS, or body-size + limit traps. It still goes through Joi validation (`schema` param, + unchanged), still forces `candidateId` server-side (now per-item, on + top of the existing top-level force), and never trusts raw + `req.body.items[i].candidateId`. No new trap introduced; none of the + existing 7 Traps table rows are reintroduced. + +10. **Diagram AppendOnly** — before this verdict, the `add-bulk-import- + cv-sections` row was the LAST row of the PM status table, immediately + before the "Any regression must be a new node" closing note — + confirmed by reading the file directly (not inferred), so the + implementer appended correctly, not mid-table. + +## Re-run +`full` — re-ran both `npm test` and `npm run build` independently from +scratch (not audit-only), because the task explicitly directed +independent verification of the real source (not just the note's prose) +for a change that alters the codebase's IDOR-safety surface (per-item +`candidateId` forcing on a new write path) — the same class of +security-sensitive diff this hub has previously chosen full re-run for +(e.g. `add-csrf-protection-auth-cookies`, `add-cv-profile-selection`). +Also independently read every file in the diff plus the files the note's +claims depend on (`verifyToken.middleware.ts`, `routers/api/v1/index.ts`, +`utils/helper.ts`, `services/index.ts`) rather than only auditing the +note's prose. + +## Hub bytes +`hub_bytes_before: 97923` (from the implementer note) +`hub_bytes_after: 100436` (measured after updating PM status to SEALED, +same 5-category `/hub-tokens` per-session-total formula: root .md files + +doctrine/ + active `haven/diagrams/` file + implementer worker bundle + +verifier worker bundle, raw byte counts summed) diff --git a/agent-hub/haven/diagrams/dev-loop.prime-mermaid.md b/agent-hub/haven/diagrams/dev-loop.prime-mermaid.md index 75cbaea..17ac2f0 100644 --- a/agent-hub/haven/diagrams/dev-loop.prime-mermaid.md +++ b/agent-hub/haven/diagrams/dev-loop.prime-mermaid.md @@ -96,6 +96,7 @@ flowchart TD | `update-project-docs` | SEALED | GitHub issue #146. `README.md` (user-facing) and root `CLAUDE.md` (project instructions) had drifted from the real `src/` tree — both still said v1.0.0 and neither mentioned any feature merged since (Application tracker/#132, CV Profiles/#133, LinkedIn export parsing/#141, httpOnly cookie auth + CSRF/#119/#134, soft-delete+restore/#121, vanity slug/#120, visit tracking, i18n vi/en, DOCX export). Docs-only, no `src/` behavior change — diff is the two markdown files only. SEALED 2026-09-27 after independent verifier pass: `git status --short` confirmed ONLY `README.md`/`CLAUDE.md` (+ this diagram row) modified, no `src/` touched. Cross-checked both docs' specific claims directly against the real tree — `package.json` version (1.7.0), full dependency version table, all 11 `src/routers/api/v1/*.ts` route files + `routers/index.ts`/`v1/index.ts` against both endpoint tables, all 11 models in `src/models/index.ts` against the Models table, `src/middlewares/` (9 files) and `src/utils/` (16 files) directory listings against the Project Structure tree, `src/errors/AppError.ts` for `NO_TOKEN`/`INSUFFICIENT_PERMISSIONS`/`CSRF_TOKEN_INVALID`, `verifyToken.middleware.ts` for the CSRF-check + forced `req.body.candidateId` claims, `server.ts`'s exact 12-step `app.use`/`app.get`/`app.set` order against the documented middleware stack, and the full `src/__tests__/**` tree (23 files, `database/mongo.db.ts` included) against CLAUDE.md's test table — all matched exactly, no stale or invented claims found. Not audit-only: independently re-ran both `npm test` (reproduced 24/24 suites, 136/136 tests, matching the note exactly) and `npm run build` (clean) rather than trusting the pasted output alone, given this was the first fully doc-only node this hub has sealed. Evidence: `evidence/implementer/2026-09-27/update-project-docs-plan.md`, `evidence/verifier/2026-09-27/update-project-docs-seal.md`. No commit/push has happened yet (`/todo` invoked without `--ship`). | | `fix-claude-md-candidate-model-fields` | SEALED | GitHub issue #148. `update-project-docs`/#146's Models table row for `Candidate` omitted 3 real fields on `src/models/candidate.model.ts`: `cvFile` ({ originalName, uploadedAt } metadata for the uploaded PDF résumé), `isPublic` (default true, gates `GET /api/me/:slug-or-email`), `emailVerified` (default false, informational only). Found while syncing the GitHub wiki's Data-Models page, which did capture all 3. Docs-only, one table row in `CLAUDE.md`. SEALED 2026-09-27 after independent verifier pass (audit-only, per recipe guidance for a trivial non-outward-facing docs row): `git status --short` confirmed ONLY `CLAUDE.md` (+ this diagram row + the new evidence note) modified, nothing else touched. Independently read `src/models/candidate.model.ts` and confirmed all 3 fields/defaults verbatim (`cvFile: { originalName, uploadedAt }`, `isPublic` default `true`, `emailVerified` default `false`); confirmed `isPublic === false` gates `GET /api/me/:email` in `src/candidate_me/index.ts:48` (fails closed to "Email không tồn tại"), confirmed the route's `handlerGetAboutMe` does slug-then-email lookup (`:slug-or-email` wording accurate), and confirmed `emailVerified` is never read/checked in `src/auth/auth.service.ts`'s `handlerLogin` (doesn't gate login). Did not independently re-run `npm test`/`npm run build` — audited the note's pasted output instead (untruncated, matches doctrine's exact commands, counts consistent with the immediately preceding sibling SEALED node). Evidence: `evidence/implementer/2026-09-27/fix-claude-md-candidate-model-fields-plan.md`, `evidence/verifier/2026-09-27/fix-claude-md-candidate-model-fields-seal.md`. No commit/push has happened yet (`/todo` invoked without `--ship`). | +| `add-bulk-import-cv-sections` | SEALED | GitHub issue #161. New `POST /api/v1/education/bulk` and `POST /api/v1/experience/bulk` (mounted behind `verifyToken`, same as every other route on those routers) — accept `{ items: [...] }` (best-effort, up to `MAX_BULK_ITEMS = 100`), reusing `createCrudController()`'s new `fnBulkCreate` (`BaseController.ts`) rather than a dedicated bulk-only path: forces `candidateId` from `(req as any).user?._id` onto EVERY array item before validation (verifyToken only forces `req.body.candidateId` at the top level, never touching entries nested inside `req.body.items` — a genuinely separate IDOR-safe step, confirmed necessary by reading `verifyToken.middleware.ts`), validates each item with the section's own Joi `schema` (same param `/create` already uses — no new schema), calls the same `service.handlerCreate` per item (no bypass of `BaseService.ts`/`services/index.ts`), rejects empty/non-array `items` and >100 items with 400. Response `data: { results, summary }` — envelope `success` is always `true` (a real bug avoided: `utils/helper.ts`'s `formatResponse()` nulls `data` whenever `success` is `false`, so tying the envelope to `summary.failed === 0` would have silently dropped `results`/`summary` on every partial failure — independently confirmed by reading `formatResponse()` and `formatReturn()` end-to-end, not just trusting the note's claim). New i18n keys `common.bulkNoItems`/`common.bulkTooManyItems` confirmed present and referenced in both `locales/en.ts`/`locales/vi.ts`. Scope: education + experience only (matches `parseLinkedInExport.service.ts`/#141's own scope) — confirmed no other section router gained a `/bulk` route. SEALED 2026-09-29 after independent verifier pass: real code read in full (`BaseController.ts`'s `fnBulkCreate`, both controllers, both routes, `routers/api/v1/index.ts`'s `verifyToken` mount, `utils/helper.ts`'s `formatResponse`/`formatReturn`, `services/index.ts`'s `baseCreateDocument` return shape, both locale files, the 5 new + 4 pre-existing tests in `BaseController.test.ts`) plus independent full re-run of `npm test` (29/29 suites, 155/155 tests, matching the note exactly) and `npm run build` (clean). `git status --short` confirmed only the 9 claimed files changed, nothing else. Evidence: `evidence/implementer/2026-09-28/add-bulk-import-cv-sections-plan.md`, `evidence/verifier/2026-09-28/add-bulk-import-cv-sections-seal.md`. No commit/push has happened yet (`/todo` invoked without `--ship`). | Any regression must be a **new node** (LAI-13) — never edit an existing node's PM status directly to "undo" an existing SEAL. diff --git a/src/__tests__/candidate_profile/BaseController.test.ts b/src/__tests__/candidate_profile/BaseController.test.ts index ca50e9d..37e681c 100644 --- a/src/__tests__/candidate_profile/BaseController.test.ts +++ b/src/__tests__/candidate_profile/BaseController.test.ts @@ -1,8 +1,10 @@ /** * Tests for candidate_profile/BaseController.ts's baseGetAll — specifically - * the page/limit/sort query-string parsing added for issue #73. + * the page/limit/sort query-string parsing added for issue #73. Also covers + * createCrudController's fnBulkCreate (issue #161). */ -import { baseGetAll } from '@/candidate_profile/BaseController'; +import Joi from 'joi'; +import { baseGetAll, createCrudController } from '@/candidate_profile/BaseController'; import * as services from '@/services'; jest.mock('@/services'); @@ -55,3 +57,87 @@ describe('baseGetAll', () => { expect(mockedBaseFindDocument).toHaveBeenCalledWith(expect.objectContaining({ sort: '-startDate' })); }); }); + +describe('createCrudController -> fnBulkCreate (issue #161)', () => { + const schema = Joi.object({ + _id: Joi.string().optional(), + name: Joi.string().min(2).required(), + candidateId: Joi.string().required(), + }); + + function createBulkMocks(body: Record, userId = 'user-1') { + const req: any = { body, user: { _id: userId }, lang: 'en' }; + const json = jest.fn(); + const res: any = { status: jest.fn().mockReturnValue({ json }), json }; + const next = jest.fn(); + return { req, res, next, json }; + } + + it('rejects with 400 when items is missing or not an array', async () => { + const handlerCreate = jest.fn(); + const { fnBulkCreate } = createCrudController({ schema, service: { handlerCreate, handlerUpdate: jest.fn() } }); + const { req, res, next, json } = createBulkMocks({}); + + await fnBulkCreate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith(expect.objectContaining({ success: false })); + expect(handlerCreate).not.toHaveBeenCalled(); + }); + + it('rejects with 400 when items exceeds the 100-item cap', async () => { + const handlerCreate = jest.fn(); + const { fnBulkCreate } = createCrudController({ schema, service: { handlerCreate, handlerUpdate: jest.fn() } }); + const items = Array.from({ length: 101 }, (_, i) => ({ name: `item-${i}` })); + const { req, res, next } = createBulkMocks({ items }); + + await fnBulkCreate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(handlerCreate).not.toHaveBeenCalled(); + }); + + it('forces candidateId from the authenticated user onto every item, ignoring a client-supplied value (IDOR-safe)', async () => { + const handlerCreate = jest.fn().mockResolvedValue({ success: true, message: 'ok', data: { _id: 'new1' } }); + const { fnBulkCreate } = createCrudController({ schema, service: { handlerCreate, handlerUpdate: jest.fn() } }); + const { req, res, next } = createBulkMocks({ items: [{ name: 'Valid Name', candidateId: 'someone-elses-id' }] }, 'real-user'); + + await fnBulkCreate(req, res, next); + + expect(handlerCreate).toHaveBeenCalledWith(expect.objectContaining({ candidateId: 'real-user' }), 'en'); + }); + + it('is best-effort: one invalid item does not block the others, and results/summary are still returned on partial failure', async () => { + const handlerCreate = jest.fn().mockResolvedValue({ success: true, message: 'ok', data: { _id: 'created' } }); + const { fnBulkCreate } = createCrudController({ schema, service: { handlerCreate, handlerUpdate: jest.fn() } }); + const items = [{ name: 'Valid Name' }, { name: 'x' }]; // 'x' fails min(2) + const { req, res, next, json } = createBulkMocks({ items }); + + await fnBulkCreate(req, res, next); + + expect(handlerCreate).toHaveBeenCalledTimes(1); // only the valid item reaches the service + expect(res.status).toHaveBeenCalledWith(201); + const payload = json.mock.calls[0][0]; + // Envelope success stays true on partial failure so `data` isn't nulled + // out by formatResponse() (utils/helper.ts) — see the comment on + // fnBulkCreate itself for why. + expect(payload.success).toBe(true); + expect(payload.data.summary).toEqual({ total: 2, succeeded: 1, failed: 1 }); + expect(payload.data.results[0]).toEqual(expect.objectContaining({ index: 0, success: true })); + expect(payload.data.results[1]).toEqual(expect.objectContaining({ index: 1, success: false })); + }); + + it('reports summary.failed: 0 when every item succeeds', async () => { + const handlerCreate = jest.fn().mockResolvedValue({ success: true, message: 'ok', data: { _id: 'created' } }); + const { fnBulkCreate } = createCrudController({ schema, service: { handlerCreate, handlerUpdate: jest.fn() } }); + const items = [{ name: 'Alpha' }, { name: 'Beta' }]; + const { req, res, next, json } = createBulkMocks({ items }); + + await fnBulkCreate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(201); + const payload = json.mock.calls[0][0]; + expect(payload.success).toBe(true); + expect(payload.data.summary).toEqual({ total: 2, succeeded: 2, failed: 0 }); + }); +}); diff --git a/src/candidate_profile/BaseController.ts b/src/candidate_profile/BaseController.ts index 901da00..03a458e 100644 --- a/src/candidate_profile/BaseController.ts +++ b/src/candidate_profile/BaseController.ts @@ -19,6 +19,10 @@ interface baseProp { // which rows come back. A leading `-` (Mongoose convention) means desc. const SORT_FIELD_REGEX = /^-?[a-zA-Z0-9_.]+$/; +// Bulk-create (issue #161) hard cap — a single request cannot create more +// than this many entries regardless of what the client sends. +const MAX_BULK_ITEMS = 100; + const modelObject: { [key: string]: any } = { generalInformation: MODELS.generalInformation, experiences: MODELS.Experience, @@ -197,5 +201,63 @@ export const createCrudController = (props: { } }; - return { fnCreate, fnUpdate }; + // Bulk-create (issue #161): one request, many entries, best-effort per + // item (a bad entry doesn't block the rest) — pairs with the stateless + // LinkedIn-export-parse flow (#141): parse -> review client-side -> bulk-save. + const fnBulkCreate = async (req: Request, res: Response, next: NextFunction) => { + const lang = (req as any).lang; + // Same IDOR-safe pattern as every other write path, but applied per + // array item: verifyToken only forces req.body.candidateId at the top + // level, never touching entries nested inside req.body.items. + const candidateId = (req as any).user?._id; + const items = Array.isArray(req.body.items) ? req.body.items : null; + + if (!items || !items.length) { + return formatReturn(res, { statusCode: StatusCodes.BAD_REQUEST, success: false, message: t('common.bulkNoItems', lang) }); + } + if (items.length > MAX_BULK_ITEMS) { + return formatReturn(res, { statusCode: StatusCodes.BAD_REQUEST, success: false, message: t('common.bulkTooManyItems', lang) }); + } + + try { + const results: Array<{ index: number; success: boolean; [key: string]: any }> = []; + + for (let index = 0; index < items.length; index++) { + const { isValidated, value = {}, errors, message } = validateSchema({ + schema, + item: { ...items[index], candidateId }, + lang, + }); + + if (!isValidated) { + results.push({ index, success: false, message, errors }); + continue; + } + + if (booleanDefaultField && !value[booleanDefaultField]) value[booleanDefaultField] = false; + const result = await service.handlerCreate(value, lang); + results.push({ index, ...result }); + } + + const succeeded = results.filter((r) => r.success).length; + const summary = { total: results.length, succeeded, failed: results.length - succeeded }; + + // Envelope `success` is always true here (the bulk request itself was + // processed) — never tie it to summary.failed. utils/helper.ts's + // formatResponse() nulls out `data` whenever `success` is false, which + // would silently drop `results`/`summary` on a partial failure, the + // one time the caller most needs to see them. Per-item outcome lives + // in `results[].success`/`summary`, not the envelope. + return formatReturn(res, { + statusCode: StatusCodes.CREATED, + success: true, + message: summary.failed === 0 ? t('common.createSuccess', lang) : t('common.createFailed', lang), + data: { results, summary }, + }); + } catch (err) { + handleError(err, next, lang); + } + }; + + return { fnCreate, fnUpdate, fnBulkCreate }; }; diff --git a/src/candidate_profile/education/education.controller.ts b/src/candidate_profile/education/education.controller.ts index b6fbfde..04a2c59 100644 --- a/src/candidate_profile/education/education.controller.ts +++ b/src/candidate_profile/education/education.controller.ts @@ -8,7 +8,7 @@ import { schemaEducation } from './education.validate'; import * as educationService from './education.service'; import { createCrudController } from '@/candidate_profile/BaseController'; -export const { fnCreate, fnUpdate } = createCrudController({ +export const { fnCreate, fnUpdate, fnBulkCreate } = createCrudController({ schema: schemaEducation, service: educationService, booleanDefaultField: 'isCurrent', diff --git a/src/candidate_profile/experience/experience.controller.ts b/src/candidate_profile/experience/experience.controller.ts index 88650cb..cf1a45b 100644 --- a/src/candidate_profile/experience/experience.controller.ts +++ b/src/candidate_profile/experience/experience.controller.ts @@ -8,7 +8,7 @@ import { schemaExperience } from './experience.validate'; import * as experienceService from './experience.service'; import { createCrudController } from '@/candidate_profile/BaseController'; -export const { fnCreate, fnUpdate } = createCrudController({ +export const { fnCreate, fnUpdate, fnBulkCreate } = createCrudController({ schema: schemaExperience, service: experienceService, booleanDefaultField: 'isCurrent', diff --git a/src/locales/en.ts b/src/locales/en.ts index 9b4da8b..caeabfc 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -44,6 +44,8 @@ export default { updateSuccess: 'Updated successfully', updateFailed: 'Update failed', updateNotYours: 'You cannot update information that is not yours', + bulkNoItems: 'No items provided', + bulkTooManyItems: 'Too many items in one request (max 100)', }, candidate: { userNotFound: 'User not found', diff --git a/src/locales/vi.ts b/src/locales/vi.ts index 5ada179..c9c3fdb 100644 --- a/src/locales/vi.ts +++ b/src/locales/vi.ts @@ -44,6 +44,8 @@ export default { updateSuccess: 'Cập nhật thành công', updateFailed: 'Cập nhật thất bại', updateNotYours: 'Không thể cập nhật thông tin không phải của bạn', + bulkNoItems: 'Không có dữ liệu nào được gửi lên', + bulkTooManyItems: 'Gửi quá nhiều mục trong một lần (tối đa 100)', }, candidate: { userNotFound: 'Không tìm thấy người dùng', diff --git a/src/routers/api/v1/education.route.ts b/src/routers/api/v1/education.route.ts index c29c6e6..2a8ba98 100644 --- a/src/routers/api/v1/education.route.ts +++ b/src/routers/api/v1/education.route.ts @@ -7,7 +7,7 @@ import express, { Request, Response, NextFunction } from 'express'; import { Collections } from '@/types/base.type'; import { baseDelete, baseGetAll, baseRestore } from '@/candidate_profile/BaseController'; -import { fnCreate, fnUpdate } from '@/candidate_profile/education/education.controller'; +import { fnCreate, fnUpdate, fnBulkCreate } from '@/candidate_profile/education/education.controller'; const router = express.Router(); @@ -73,6 +73,54 @@ router.get( */ router.post('/create', fnCreate); +/** + * @swagger + * /api/v1/education/bulk: + * post: + * tags: [Education] + * summary: Create multiple education entries in one request (best-effort per item) + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * items: + * type: array + * maxItems: 100 + * items: + * $ref: '#/components/schemas/Education' + * responses: + * 201: + * description: Per-item results, each with its own success/message/data or errors, plus a summary + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/ApiResponse' + * - type: object + * properties: + * data: + * type: object + * properties: + * results: + * type: array + * items: + * type: object + * summary: + * type: object + * properties: + * total: { type: integer } + * succeeded: { type: integer } + * failed: { type: integer } + * 400: + * description: No items provided, or more than 100 items in one request + */ +router.post('/bulk', fnBulkCreate); + /** * @swagger * /api/v1/education/update: diff --git a/src/routers/api/v1/experience.route.ts b/src/routers/api/v1/experience.route.ts index b6efb05..2ab463c 100644 --- a/src/routers/api/v1/experience.route.ts +++ b/src/routers/api/v1/experience.route.ts @@ -7,7 +7,7 @@ import express, { Request, Response, NextFunction } from 'express'; import { Collections } from '@/types/base.type'; import { baseDelete, baseGetAll, baseRestore } from '@/candidate_profile/BaseController'; -import { fnCreate, fnUpdate } from '@/candidate_profile/experience/experience.controller'; +import { fnCreate, fnUpdate, fnBulkCreate } from '@/candidate_profile/experience/experience.controller'; const router = express.Router(); @@ -73,6 +73,54 @@ router.get( */ router.post('/create', fnCreate); +/** + * @swagger + * /api/v1/experience/bulk: + * post: + * tags: [Experience] + * summary: Create multiple work experience entries in one request (best-effort per item) + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * items: + * type: array + * maxItems: 100 + * items: + * $ref: '#/components/schemas/Experience' + * responses: + * 201: + * description: Per-item results, each with its own success/message/data or errors, plus a summary + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/ApiResponse' + * - type: object + * properties: + * data: + * type: object + * properties: + * results: + * type: array + * items: + * type: object + * summary: + * type: object + * properties: + * total: { type: integer } + * succeeded: { type: integer } + * failed: { type: integer } + * 400: + * description: No items provided, or more than 100 items in one request + */ +router.post('/bulk', fnBulkCreate); + /** * @swagger * /api/v1/experience/update: