Exact endpoints per screen. Wire contract per 00-shared/07: base
/api/v1, Bearer JWT, success {success:true,message:"OK",data,meta?, timestamp,requestId}, error envelope with codes. Only shapes in code are
used. (planned)/(forward-looking) marked. Sources: bulk.controller.ts,
bulk-import.service.ts, import-adapter.interface.ts,
students-import.adapter.ts, permissions.constants.ts,
event-queue-map.ts, queue.constants.ts.
POST /api/v1/bulk/import/:entity (bulk.controller.ts:35-48)
Guard
JwtAuthGuard (bulk.controller.ts:30); RBAC permission guard (planned) — user.import exists (permissions.constants.ts:10) but is not enforced on this route
Content
multipart/form-data, field file (FileInterceptor('file'):38); required — missing → 400 VALIDATION_ERRORCSV file is required (multipart field "file"). (:43-46)
Entity param
free string; resolved by adapterFor (bulk-import.service.ts:17-20); students only (students-import.adapter.ts:15); anything else → 404 RESOURCE_NOT_FOUNDNo import adapter for entity "X". (bulk-import.service.ts:19); other entities (planned) (bulk-import.service.ts:15-16, IMPLEMENTATION_PLAN.md:172)
csv-parse/sync{columns:true, skip_empty_lines:true, trim:true} (bulk-import.service.ts:26-30); unparsable → 400 VALIDATION_ERRORMalformed CSV: could not parse file. (:31-33); zero data rows → 400 VALIDATION_ERRORCSV must include a header row and data. (:34-35)
Execution
synchronous per-row validate → create loop (:45-63); rowNumber = physical line (header = 1) (:46); one bad row never aborts the batch (bulk-import.service.spec.ts:53-65)
per row: dup checks (admissionNumber via studentRepo.findByAdmissionNumber:49-54; email via usersService.findByEmail:55-56); ref resolution (academicYear by name :113-117, grade by name-or-code :118-121, section by name :122-125, class by grade+section+year :131-142) → validate errors; else usersService.create (:66-71) + studentService.create (:76-84)
Side effects
UserCreated event → in-app queue user-created-notification (users.service.ts:64-76; event-queue-map.ts:10); StudentCreated event → in-app queue student-enrolled (student.service.ts:79-91; event-queue-map.ts:28); student auto-enrolled into class enrollment, status ACTIVE, admissionDate now (student.service.ts:63-78)
Race fallback
if usersService.create throws (duplicate), the adapter reuses the existing user id (students-import.adapter.ts:72-75) — row still imports as a student
Async/polling
none exists — result returned in the same request; no bulk queue in queue.constants.ts:1-17; async workers with BullMQ progress + rollback (planned) (IMPLEMENTATION_PLAN.md:172); students-specific POST /api/v1/students/bulk-import(planned) (IMPLEMENTATION_PLAN.md:195)
GET /api/v1/bulk/export/:entity (bulk.controller.ts:50-60)
Guard
JwtAuthGuard (:30); RBAC (planned)
Response
text/csv (:52); Content-Disposition: attachment; filename="<entity>.csv" (:55-58); body = csv-stringify of adapter rows with header (bulk-import.service.ts:67-71)
Students rows
admissionNumber, rollNumber, status, admissionDate (ISO string, '' when unset), sorted by admissionNumber asc (students-import.adapter.ts:87-98)
Empty
header-only CSV (stringify([],{header:true})) — still 200
UsersService.create per imported row (users.service.ts:64-76)
in-app / user-created-notification (:10)
StudentCreated
StudentService.create per imported row (student.service.ts:79-91)
in-app / student-enrolled (:28)
Contrast: the users module's inline import (users.service.ts:264-272) emits
no events; the bulk path emits both per row. Imported users get in-app
notifications; there is no email for them (email worker handles only
UserRegistered / PasswordResetRequested — users package 01 §4).