Skoolsewa - Ecommerce Docs
Developer ResourcesData transfer

Data Transfer Backend Documentation

Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for Data Transfer.

Data Transfer Backend Documentation

1. Documentation Evidence

AreaFiles InspectedVerified Details
Module wiringapps/api/src/modules/data-transfer/data-transfer.module.ts, data-transfer-worker.module.tsImports, providers, exports, why the workers are a separate module.
Controllerapps/api/src/modules/data-transfer/data-transfer.controller.tsRoute ownership, guards, permissions.
Servicesdata-transfer.service.ts, shared/data-transfer-request.service.ts, shared/dead-letter.service.ts, shared/people-import-writer.service.ts, shared/people-export-reader.service.tsBusiness logic, transactions, idempotency, error mapping.
Workersworkers/people-queue.processor.ts, workers/people-import.processor.ts, workers/people-export.processor.ts, workers/people-invite.processor.tsQueue registration, handler dispatch, dead-letter recording.
Pure helpersapps/api/src/utils/data-transfer/data-transfer.types.ts, templates.ts, parse-people-file.ts, build-export-file.tsTemplate columns, parsing rules, formula-injection guarding.
DTOsapps/api/src/modules/data-transfer/dto/data-transfer.dto.tsRequest/response shapes and validation.
Schemapackages/db/src/schema/async-request/async-requests.ts, packages/db/src/schema/jobs/job-failures.ts, packages/db/src/schema/outbox/outbox-events.tsColumns, indexes, constraints.
Migrationpackages/db/src/migrations/0004_job_failures_public_id.sqlHow job_failures.public_id was added to a table that already has rows in every environment.
Queue contractpackages/jobs/src/index.ts (QueueName.PEOPLE, PeopleJob, payload types), packages/jobs/src/build-job-id.tsJob names, payload shapes, id rules.
Outboxapps/api/src/modules/outbox/shared/outbox.service.ts, apps/api/src/modules/outbox/workers/outbox-dispatcher.processor.tsTransactional-outbox contract and the real BullMQ jobId scheme.
Uploadsapps/api/src/common/utils/multer.util.ts, packages/storage/src/upload-allowlist.tsUpload size cap, extension allowlist.
Codesapps/api/src/modules/people/shared/people-code.service.tsAdmission/employee number allocation.
Permissionspackages/db/src/authorization/permission-catalog.tsDataImport, DataExport modules and their actions.
Errorsapps/api/src/common/types/error-codes.tsThe bulk-transfer error code block.

2. Backend Scope and Boundaries

Owns

  • Import templates for the three people scopes (students, guardians, staff) — apps/api/src/utils/data-transfer/templates.ts.
  • Parsing an uploaded spreadsheet into validated rows and reporting problems against the operator's own line numbers — parse-people-file.ts.
  • The dry-run / commit lifecycle of an import request, backed by async_requestsdata-transfer.service.ts, shared/data-transfer-request.service.ts.
  • Writing validated rows into students, guardians, staff and their linked users rows, one transaction per row — shared/people-import-writer.service.ts.
  • Building an export file (CSV/XLSX) from a role-scoped column set — shared/people-export-reader.service.ts, apps/api/src/utils/data-transfer/build-export-file.ts.
  • Storing and re-serving import and export files through StorageManager, never through a signed object-storage URL.
  • The single BullMQ worker on QueueName.PEOPLE, and the queue-level dead-letter record for anything that fails after being enqueued — workers/people-queue.processor.ts, shared/dead-letter.service.ts.
  • Sending account-invitation emails to a bounded, paced batch of people, whether that batch was requested directly (POST /data/invites) or chained automatically off a completed import — workers/people-invite.processor.ts.

Does Not Own

  • Person, student, guardian and staff row shape and validation itself — owned by the people module (PersonWriterService, PeopleCodeService), which this module calls rather than duplicates (apps/api/src/modules/data-transfer/data-transfer.module.ts:22-24).
  • Scheduling guarantees — owned by OutboxService (apps/api/src/modules/outbox/shared/outbox.service.ts). This module never calls queue.add() directly for the commit or export flows.
  • File storage mechanics (disk vs. remote driver, signed-URL support) — owned by @skoolsewa/storage's StorageManager.
  • Sending the actual invitation email and issuing its token — owned by AuthEmailService and VerificationTokenService (apps/api/src/modules/auth/services/*); the invite processor only decides who is eligible, states the purpose and lifetime, and paces the calls. The token it asks for is an account_invite valid for ACCOUNT_INVITE_TTL_MS (7 days), redeemed at POST /api/auth/password/reset.
  • Permission grants themselves — owned by the authorization module; this module only declares which permission codes (DataImport_*, DataExport_*) its routes require.

Source of Truth

ConcernSource of TruthNotes
Whether an import/export has run, and its reportasync_requests rowThe BullMQ job id is not durable — completed jobs are age/count-trimmed — so the row, not the queue, answers "did this actually happen" (shared/data-transfer-request.service.ts:35-41).
What an import will writeThe stored uploaded file, re-read at commit timeNever the in-memory dry-run report, and never the job payload — both can be stale relative to the operator's approval (data-transfer.service.ts:164-181, workers/people-import.processor.ts:26-31).
Which export columns a file may containPeopleExportBuildPayload.includeGatedColumns, decided at request timeThe worker has no session and no active role, so it trusts what the request handler already decided (data-transfer.service.ts:284-296).
Whether a job failed while runningjob_failuresDistinct from outbox_events.status = 'dead', which records a job that could not be enqueued (packages/db/src/schema/jobs/job-failures.ts:18-33).
How a job_failures row is addressed from outside the databasepublic_id (uuid), never the serial idid stays the primary key for indexes and joins but never leaves the database — a sequential id on a replay route would let anyone holding the read permission enumerate how much of the platform has failed and when (job-failures.ts:56-69).
Whether a dead-letter claim is still validjob_failures.replayed_atWritten by the claim UPDATE before the enqueue runs, and reset back to null by releaseClaim if the enqueue then fails — so a claim can never outlive a job that was never actually scheduled (dead-letter.service.ts:166-219).
Request ownershipasync_requests.actor_id, checked against the caller, with a superadmin bypassdata-transfer.service.ts:433-443.

3. Module Composition

ModuleTypePathControllersProvidersExportsResponsibility
DataTransferModuleLeaf (HTTP)apps/api/src/modules/data-transfer/data-transfer.module.tsDataTransferControllerDataTransferService, DataTransferRequestService, DeadLetterService, PeopleImportWriter, PeopleExportReaderSame five providersThe request surface: templates, upload, commit, export request/status/download, dead-letter list/replay.
DataTransferWorkerModuleLeaf (worker)apps/api/src/modules/data-transfer/data-transfer-worker.module.tsNonePeopleQueueProcessor, PeopleImportProcessor, PeopleExportProcessor, PeopleInviteProcessorNoneThe BullMQ side. Imports DataTransferModule for the shared services and AuthModule for the invite processor's email dependencies.

Deliberately two modules, not one (data-transfer.module.ts:14-25, data-transfer-worker.module.ts:9-21): importing a module that declares an @Processor starts a BullMQ Worker in whatever process imports it. If the controller module also declared the processors, every short-lived process that imports it to serve one HTTP route — including a one-off script — would start a worker and could claim a job it is about to exit in the middle of. DataTransferWorkerModule is composed only where the app actually intends to run workers.

4. File and Directory Map

apps/api/src/modules/data-transfer/
  data-transfer.module.ts          # HTTP-facing module: controller + shared services
  data-transfer-worker.module.ts   # Worker-facing module: the one @Processor and its handlers
  data-transfer.controller.ts      # Every route in this doc
  data-transfer.service.ts         # Templates, upload/dry-run, commit, export request/status/download
  dto/
    data-transfer.dto.ts           # Every request/response DTO
    index.ts
  shared/
    data-transfer-request.service.ts  # async_requests CRUD + status transitions
    dead-letter.service.ts            # job_failures list/replay/record
    people-import-writer.service.ts   # Row -> users/students/guardians/staff writes
    people-export-reader.service.ts   # Role-scoped SELECTs for export
  workers/
    people-queue.processor.ts     # The one @Processor(QueueName.PEOPLE)
    people-import.processor.ts    # Handles PeopleJob.IMPORT_COMMIT
    people-export.processor.ts    # Handles PeopleJob.EXPORT_BUILD
    people-invite.processor.ts    # Handles PeopleJob.SEND_INVITE_BATCH

apps/api/src/utils/data-transfer/
  data-transfer.types.ts   # Pure shared types: DataScope, TemplateColumn, ParseResult, RowIssue, ImportReport
  templates.ts             # The three column lists + buildTemplateWorkbook
  parse-people-file.ts     # CSV/XLSX -> ParseResult
  build-export-file.ts     # rows -> CSV/XLSX buffer, with formula-injection guarding
  index.ts
FilePurposeKey ExportsNotes
data-transfer.controller.tsEvery HTTP route for import, export, templates, dead letters.DataTransferControllerMounted at /data, not per-entity, so one bulk operation is one surface (data-transfer.controller.ts:46-53).
data-transfer.service.tsUpload dry-run, commit, export request/status/download, file staging.DataTransferService, STORAGE_KEY_RESULT_FIELDSTORAGE_KEY_RESULT_FIELD is the literal key both the service and the export processor use inside async_requests.result for the storage key.
shared/data-transfer-request.service.tsThe async_requests row: create, create-or-get by file hash, find, conditional transition.DataTransferRequestService, TransferRecord, TransferStatusfingerprint() is sha256(bytes), static, used as the idempotency key.
shared/dead-letter.service.tsjob_failures list, replay under a fresh job id (releasing it again if the enqueue fails), record on terminal failure.DeadLetterServiceREPLAYABLE_QUEUES is hardcoded to [QueueName.PEOPLE] — the only queue this screen may touch; rows are addressed by public_id, never the serial id.
shared/people-import-writer.service.tsTurns parsed rows into users + scope-specific rows, one transaction per row.PeopleImportWriter, ImportOutcomeAlso resolves guardian matching, department/designation lookup, and ethnicity/mother-tongue name lookup; collects the ids of every users row it creates.
shared/people-export-reader.service.tsRole-scoped SELECT per scope, capped at 20,000 rows.PeopleExportReader, ExportColumnAdds/removes whole columns, never nulls a gated cell; emits ethnicity/mother tongue as names, round-trippable back into the import template.
workers/people-queue.processor.tsThe one @Processor(QueueName.PEOPLE); routes by job.name; records terminal failures.PeopleQueueProcessorhandlers is Record<PeopleJob, …> — a compile error if a PeopleJob member has no handler.
workers/people-import.processor.tsApplies IMPORT_COMMIT.PeopleImportProcessorFirst act is a conditional status check, not a write; after marking the request completed, chains an invitation batch if the upload asked for one.
workers/people-export.processor.tsApplies EXPORT_BUILD.PeopleExportProcessorSame duplicate-delivery guard, keyed on status !== "pending".
workers/people-invite.processor.tsApplies SEND_INVITE_BATCH.PeopleInviteProcessorEnqueued both directly (POST /data/invites) and automatically from a completed import — see §9.
utils/data-transfer/templates.tsThe three template column lists and the workbook builder.getTemplate, buildTemplateWorkbookCarries Ethnicity and Mother Tongue (as names) on all three scopes, and an IEMIS ID column on students only; deliberately excludes bank account, PAN/citizenship number, and student medical columns from any template.
utils/data-transfer/parse-people-file.tsCSV/XLSX buffer to ParseResult.parsePeopleFileRow numbers are the operator's own spreadsheet line numbers.
utils/data-transfer/build-export-file.tsRows to a CSV/XLSX buffer.buildExportFileEscapes formula-injection prefixes; formats dates as YYYY-MM-DD.

5. Data Model

5.1 Schema Source

packages/db/src/schema/async-request/
  async-requests.ts   # asyncRequests table (import + export requests, both scopes)
packages/db/src/schema/jobs/
  job-failures.ts      # jobFailures table (the processing dead-letter queue)
packages/db/src/schema/outbox/
  outbox-events.ts     # outboxEvents table (the enqueue ledger the commit and export-request flows write to)

5.2 Tables and Collections

async_requests

The durable record behind every import and export. One row per upload (import) or per requested export.

ColumnTypeNullableDefaultIndex/ConstraintNotes
idserialNogeneratedPKInternal only; never returned.
request_idvarchar(100)NoUNIQUEThe public id every route uses (a UUIDv7, generated in DataTransferRequestService.createOrGet/create).
actor_iduuidNoindexed (async_requests_actor_id_idx)The uploader/requester; checked in assertOwnRequest.
scopevarchar(50)Nopart of async_requests_idempotency_idximport:students|import:guardians|import:staff|export:students|export:guardians|export:staff — the import:/export: prefix is a string convention enforced only by this module's code, not a schema enum.
idempotency_keyvarchar(200)Nopart of async_requests_idempotency_idxFor imports: sha256(file bytes). For exports: the request's own request_id (self-idempotent — never deduplicated).
statusvarchar(20)No'pending'indexed (async_requests_status_idx, with created_at)pending | committing | completed | failed. Exports never pass through committing — see §7.
resultjsonbYesHolds different shapes for import vs. export; see below.
errortextYesSet on failed, truncated to 2000 characters before storage.
created_attimestamptzNonow()
completed_attimestamptzYesSet only on a terminal transition (completed/failed) via patch.completed.

result shape for an import request (built incrementally):

{
  "report": { "scope": "students", "totalRows": 120, "validRows": 118, "issues": [...], "unknownHeaders": [...], "missingHeaders": [...], "preview": [...] },
  "sendInvites": false,
  "storageKey": "imports/students/....dat",
  "outcome": { "created": 115, "failed": 3, "issues": [...], "createdUserIds": ["018f...", "018f..."] }
}

report and sendInvites are written by the dry-run upload; storageKey is added immediately after (only for a genuinely new upload, not a re-served duplicate — data-transfer.service.ts:165-181); outcome is added only after a successful commit, by the import processor. sendInvites is read back at commit time, not from the job payload, to decide can_login on every row the commit creates; outcome.createdUserIds is what the processor hands to requestInvites when it chains an invitation batch immediately afterward.

result shape for an invitation-batch request (scope invite:batch, whether created by POST /data/invites or chained off an import):

{ "requested": 3, "outcome": { "sent": 2, "skippedNoEmail": 0, "notInvitable": 1, "failed": [] } }

requested is written at creation time; outcome is added once PeopleInviteProcessor finishes the batch.

result shape for an export request:

{ "format": "xlsx", "storageKey": "exports/staff/....dat", "rowCount": 342 }

format is set at request time; storageKey and rowCount are added by the export processor once the file is built.

Indexes and constraints — rationale

Index/ConstraintColumnsTypeQuery/Invariant SupportedTradeoff
async_requests_idempotency_idxscope, idempotency_keyunique btreeThe upload dedupe: same file (by content hash) under the same scope cannot create two request rows. Global, not per-actor — deliberately, since two administrators uploading the same spreadsheet is the ordinary way a roll gets doubled, and admission/employee numbers are generated rather than supplied so nothing downstream would otherwise catch it (async-requests.ts:30-42).A second upload of the identical file by anyone, at any time, returns the first request's report rather than creating a new one.
async_requests_actor_id_idxactor_idbtree"my requests" style lookups (not currently exposed by a list route, but available).Extra write on every insert.
async_requests_status_idxstatus, created_atbtreeOperational queries filtering by status.Extra write on every status transition.
async_requests_request_id_idxrequest_idbtreeEvery GET/POST .../:requestId route resolves through this.Redundant with the UNIQUE constraint's own index in practice, but named separately in the schema.

job_failures

The processing dead-letter queue — what failed while a job was running, as opposed to outbox_events.status = 'dead', which records a job that could not be enqueued at all. See §9 for the full distinction.

ColumnTypeNullableDefaultIndex/ConstraintNotes
idserialNogeneratedPKInternal only — used for indexes and joins, never returned by an API response.
public_iduuidNouuid7() (application-generated)UNIQUE (job_failures_public_id_unique)The id the replay route actually speaks. Added by a migration that backfilled existing rows with gen_random_uuid() (v4) rather than a v7 — nothing sorts on this column, and inventing v7 values retroactively would fabricate a creation order failed_at already records correctly.
queue_namevarchar(50)Nopart of job_failures_queue_failed_at_idxGlobal across every queue in the system, not only people — the service filters this at read/replay time.
job_namevarchar(100)NoThe PeopleJob value, e.g. people.import_commit.
job_idvarchar(200)YesBullMQ's own id, for finding the job in Bull Board.
payload_refjsonb (Record<string,string>)YesA reference, never the payload. A queued notification embeds a live single-use password-reset token in its body; storing that behind DataImport_READ would turn the failures screen into account takeover (job-failures.ts:35-48).
actor_iduuidYesindexed (job_failures_actor_id_idx)Who asked for the work, when known.
attemptsintegerNoCHECK > 0 (job_failures_attempts_positive)Attempts made when this row was written; always the final attempt.
last_errortextYesTruncated to 4000 characters before insert (dead-letter.service.ts:221).
replayed_attimestamptzYesCHECK paired with replay_job_id (job_failures_replay_is_complete)Set atomically with replay_job_id by a conditional UPDATE.
replayed_byuuidYesThe operator who replayed it.
replay_job_idvarchar(200)Yespaired via job_failures_replay_is_completeThe fresh BullMQ job id the replay was enqueued under.
failed_attimestamptzNonow()part of job_failures_queue_failed_at_idx

Constraints and index rationale

Index/ConstraintColumnsTypeQuery/Invariant SupportedTradeoff
job_failures_public_id_uniquepublic_idUNIQUE btreeThe replay route's identity lookup — a duplicate would make replay act on an arbitrary one of two rows.Extra unique index maintained on every insert.
job_failures_attempts_positiveattemptsCHECK > 0A worker reporting zero attempts would corrupt the "how many tries did this get" answer; also protects the insert itself — a violation here throws inside a failed handler, where the throw would otherwise be swallowed and the failure disappear entirely.None — attempts is defensively clamped to Math.max(1, …) before insert anyway (dead-letter.service.ts:291).
job_failures_replay_is_completereplayed_at, replay_job_idCHECK (both null or both set)A row can never show "replayed" with no job id to look up, or a replay job id with no timestamp.None.
job_failures_queue_failed_at_idxqueue_name, failed_atbtreeThe list screen's default sort (newest first, per queue).Write cost per insert.
job_failures_actor_id_idxactor_idbtreeFinding every failure caused by one operator's requests.Write cost per insert.
job_failures_unreplayed_idxfailed_atpartial btree, WHERE replayed_at IS NULLThe unreplayedOnly list filter — small and cheap because replayed rows drop out of the index.None meaningful; a partial index shrinks as rows are replayed.

outbox_events (shared infrastructure, not owned by this module)

Not owned by data-transfer, but load-bearing for it: commitImport and requestExport both write here inside the same transaction as their async_requests status change. See §9 and the outbox module's own documentation for the full table. The columns this module's rows actually populate:

ColumnValue data-transfer writes
aggregate_type"people_import" or "people_export"
aggregate_idthe async_requests.request_id
event_type"people.import_committed" or "people.export_requested"
target_queueQueueName.PEOPLE
job_namePeopleJob.IMPORT_COMMIT or PeopleJob.EXPORT_BUILD
payloadthe PeopleImportCommitPayload / PeopleExportBuildPayload shape
dedupe_keyimport-commit-${requestId} or export-build-${record.requestId}

5.3 Relationship Diagram

There is no foreign key between async_requests and either outbox_events or job_failures — the link is by value (aggregate_id / payload_ref.requestId equal to async_requests.request_id), because outbox_events and job_failures are shared infrastructure tables written by every module in the system, not tables this module can add a constraint to.

6. Services and Responsibilities

6.1 DataTransferService

MethodCalled ByReadsWritesSide EffectsErrors
buildTemplate(scope, format)Controller (GET /data/templates/:scope)Nothing (pure, delegates to buildTemplateWorkbook)NothingNoneNone
uploadForDryRun(actor, file, dto)Controller (POST /data/imports)Uploaded file bytesasync_requests (insert-or-get, then an update to attach the storage key)Stores the file via StorageManager.handleUploadDATA_TRANSFER_FILE_REQUIRED, IMPORT_FILE_TOO_LARGE, DATA_TRANSFER_FILE_UNREADABLE, DATA_TRANSFER_REQUEST_CONFLICT
getImport(actor, requestId)Controller (GET /data/imports/:requestId)async_requestsNoneNoneDATA_TRANSFER_REQUEST_NOT_FOUND, DATA_TRANSFER_NOT_YOURS
commitImport(actor, requestId)Controller (POST /data/imports/:requestId/commit)async_requestsasync_requests status → committing, outbox_events insert — same transactionNothing until the worker runsDATA_TRANSFER_REQUEST_NOT_FOUND, DATA_TRANSFER_NOT_YOURS, DATA_TRANSFER_NOT_COMMITTABLE, IMPORT_MISSING_HEADERS, DATA_TRANSFER_HAS_BLOCKING_ISSUES
requestInvites(actorId, userIds)Controller (POST /data/invites); PeopleImportProcessor after a successful commit, once per chunk of 500Nothingasync_requests insert (scope invite:batch), outbox_events insert — same transactionNone until the worker runsVALIDATION_FAILED (empty batch after de-duplication), INVITE_BATCH_TOO_LARGE (over 500)
requestExport(actor, dto)Controller (POST /data/exports)PeoplePermissionsService.can (twice)async_requests insert, outbox_events insert — same transactionNone until the worker runsNone thrown directly
getExport(actor, requestId)Controller (GET /data/exports/:requestId)async_requestsNoneNoneDATA_TRANSFER_REQUEST_NOT_FOUND, DATA_TRANSFER_NOT_YOURS
readExportFile(actor, requestId)Controller (GET /data/exports/:requestId/download)async_requests, StorageManager.getFileNoneReads bytes off storageDATA_TRANSFER_REQUEST_NOT_FOUND, DATA_TRANSFER_NOT_YOURS, DATA_TRANSFER_EXPORT_NOT_READY
storeGeneratedFile(buffer, filename, mimetype, category)PeopleExportProcessorNothingStages a temp file, uploads via StorageManagerDeletes the local temp copy only when the upload produced a remote key (data-transfer.service.ts:551-553)DATA_TRANSFER_FILE_UNREADABLE
readStoredFile(storageKey)PeopleImportProcessorStorageManager.getFileNoneNoneWhatever StorageManager throws

Explain:

  • Input normalization. None of the upload path is normalized before parsing — parsePeopleFile does all cell-level trimming; the service only checks size and readability.
  • Validation order in commitImport: existence → ownership → status (pending only) → missing headers → zero valid rows → the transactional status flip. Each check throws before the next runs, so a caller always gets the first real reason, not a generic conflict.
  • Transaction boundaries. commitImport, requestExport, and requestInvites each wrap their async_requests transition/insert and their outbox.enqueue call in one this.db.transaction(...) block — that pairing is the whole point of the outbox pattern (see §9). requestInvites is called both from the controller and from PeopleImportProcessor, so this atomicity applies identically whether the batch was asked for directly or chained off a completed import.
  • Idempotency. uploadForDryRun is idempotent on file content via DataTransferRequestService.createOrGet; commitImport's transition is idempotent via the from: "pending" guard (a second click finds the status already committing and is refused with a 409, not silently re-run). requestInvites is not idempotent — it de-duplicates ids within one call, but two calls with the same userIds queue two independent batches, since DataTransferRequestService.create gives every invite request its own self-unique idempotencyKey.
  • Fail-open or fail-closed. Ownership (assertOwnRequest) is fail-closed: anyone but the uploader or a superadmin is refused, even to just read the report, because a report carries a preview of real personal data (data-transfer.service.ts:425-432).
  • Logger usage. One Logger.error in uploadForDryRun when the uploaded file cannot be read from disk.

6.2 DataTransferRequestService

MethodCalled ByReadsWritesSide EffectsErrors
fingerprint(bytes) (static)DataTransferService.uploadForDryRunNothingNothingNoneNone
createOrGet(input)uploadForDryRunasync_requests (on conflict)async_requests insert, ON CONFLICT DO NOTHING on (scope, idempotency_key)NoneDATA_TRANSFER_REQUEST_CONFLICT (only if the conflicting row vanished between the insert and the re-read — another transaction rolled back concurrently)
create(input)requestExportNothingasync_requests insert, idempotencyKey = requestId (self-unique, so exports are never deduplicated on content)NoneNone
findByRequestId(requestId, executor?)Every read path, and both processorsasync_requestsNoneNoneDATA_TRANSFER_REQUEST_NOT_FOUND
transition(requestId, from, to, patch, executor?)commitImport, both processors, fail() helpersasync_requests (via the WHERE status IN (from) clause)async_requests (status, optionally result/error/completed_at)NoneNever throws; returns booleanfalse means nothing moved

Explain:

  • Why transition returns a boolean instead of throwing. The from guard is a conditional UPDATE ... WHERE status IN (...). Zero rows updated means somebody else already moved the record — a second outbox delivery, or a concurrent operator — and the caller decides what that means (a duplicate-delivery no-op in the processors; a 409 in commitImport). Making this a return value rather than an exception is what lets one primitive serve both call sites correctly.
  • Transaction boundaries. transition and findByRequestId both accept an optional executor so they can run inside a caller's transaction — commitImport passes its tx in; the read-only paths use the default (this.db).

6.3 DeadLetterService

MethodCalled ByReadsWritesSide EffectsErrors
findAll(query)Controller (GET /data/job-failures)job_failuresNoneNonePAGINATION_LIMIT_INVALID (pagination: false)
replay(publicId, actorId, attempts)Controller (POST /data/job-failures/:publicId/replay)job_failuresjob_failures (replayed_at, replayed_by, replay_job_id — conditional UPDATE; released again to null if the enqueue below fails)queue.add(...) on the row's own queue_name, resolved dynamically via ModuleRef.get(getQueueToken(...))JOB_FAILURE_NOT_FOUND, JOB_FAILURE_QUEUE_NOT_PERMITTED, JOB_FAILURE_ALREADY_REPLAYED (thrown twice — once from a cheap pre-check, once from the claim losing the race), JOB_FAILURE_REPLAY_ENQUEUE_FAILED (503, when the claim succeeds but queue.add throws)
releaseClaim(publicId, replayJobId) (private)replay, from its catch block onlyNonejob_failures (replayed_at/replayed_by/replay_job_id reset to null) — matched on both public_id and the failed call's own replay_job_idNoneLogs and swallows its own failure; never throws
record(input, executor?)PeopleQueueProcessor.onFailedNonejob_failures insertNoneNever throws to the caller in practice (the caller wraps it in a try/catch)

Explain:

  • Rows are addressed by public_id, never the serial id. id stays the primary key for indexes and any future retention job, but it never leaves the database — a sequential id on a route gated only by a read permission would let anyone holding it enumerate how much of the platform has failed and when, and the count itself is exactly the information the queue filter below already exists to withhold.
  • The failure queue can never be read unpaginated. findAll refuses pagination: false outright with PAGINATION_LIMIT_INVALID (dead-letter.service.ts:71-76) — nothing prunes job_failures, so an unbounded read asks the process to buffer every row and every stack trace it has ever recorded at once, unlike the reference tables elsewhere in the codebase that do permit an unpaginated read.
  • Queue scoping is done here, in code, not from the query string. REPLAYABLE_QUEUES = [QueueName.PEOPLE] is a private static array; queueFilter() ignores an unrecognised queueName query parameter and falls back to the one permitted queue rather than trusting the caller (dead-letter.service.ts:296-305). job_failures is global across every BullMQ queue in the system (backup, restore, catalog, orders-maintenance, outbox, people, …), so an unfiltered list or replay would let a clerk holding only DataImport_READ/DataImport_UPDATE see and re-run failures from every other domain.
  • Replay uses a fresh job id, never the original. buildJobId(["replay", String(row.id), String(Date.now())]). BullMQ's add() treats a repeated existing jobId as a no-op that returns the existing (already-failed) job — replaying under the original id would report success having enqueued nothing (dead-letter.service.ts:160-164).
  • Claim-then-enqueue ordering, not enqueue-then-claim. The conditional UPDATE ... WHERE replayed_at IS NULL runs before queue.add. The alternative order can leave a job running against a row that still reads unreplayed if the UPDATE then fails — and the next operator replays it again. This order's cost is that a claim can outlive an enqueue that never reached Redis, and since replayed_at is exactly what the already-replayed refusal reads, a row left in that state could never be replayed again through this route (dead-letter.service.ts:166-192).
  • The enqueue is wrapped, and the claim is released if it fails. replay's try/catch around queue.add calls releaseClaim on failure, resetting replayed_at/replayed_by/replay_job_id back to null and returning 503 JOB_FAILURE_REPLAY_ENQUEUE_FAILED instead of a false success. releaseClaim matches on the failed call's own replay_job_id as well as the row id — without that predicate, a slow failing replay could clear a claim a second operator had already made successfully in the meantime, making their still-running job replayable a second time (dead-letter.service.ts:194-219,239-259).
  • record is best-effort from the caller's side. It is called from inside a try/catch in PeopleQueueProcessor.onFailed, because a failed handler that throws has its exception swallowed by BullMQ — a failure to record the failure must never replace the original error in the log.

6.4 PeopleImportWriter

MethodCalled ByReadsWritesSide EffectsErrors
apply(scope, rows, actorId, grantLogin?)PeopleImportProcessorcode_counters (via PeopleCodeService.allocate), department/designation lookups, ethnicity/mother-tongue name lookups, guardian-by-phone lookupusers, and one of students/guardians/staff, plus student_guardian for a student row with guardian info — one transaction per rowNone beyond the writes; collects the users.id of every row it creates (ImportOutcome.createdUserIds, excluding a guardian created only as a side effect)Per-row: caught and translated into a RowIssue, never thrown to the caller

Explain:

  • One transaction per row, not one for the file (people-import-writer.service.ts:30-46). An all-or-nothing transaction over 2,000 rows that fails on row 1,999 gives the operator nothing to show for the run and no way to tell which row was at fault beyond a raw constraint name — and it holds a lock on code_counters for the whole run, blocking every concurrent manual admission. Per-row transactions mean a bad row costs exactly one row; the report names it, and the operator re-uploads just the corrected lines.
  • Admission/employee numbers are allocated once, up front, for the whole batch via PeopleCodeService.allocate(scope, rows.length) (people-import-writer.service.ts:73-78), then consumed positionally by array index. A row that fails leaves its allocated number unused rather than shifting every later row's number — a gap in the sequence is harmless; renumbering later rows would break the correspondence between "row 47 in the report" and "STU-2026-0051 in the database."
  • Blank cells become undefined, not "". PeopleImportWriter.cell() trims and converts an empty string to undefined before it reaches an insert (people-import-writer.service.ts:107-122). Passing '' straight through made every sparsely-filled row fail with an unmapped Postgres 22P02 on an enum or date column, because Postgres rejects '' for those types outright — the report then said only "Failed query" for every row.
  • The address is ten spreadsheet columns, resolved by name, scoped to the parent already resolved on that row (shared/people-import-address.util.ts, resolveAddress) — five for Permanent Province/District/Municipality/Ward/Tole, five more for the Current equivalents. Province, district and municipality are integer foreign keys in the database, exactly like ethnicity and mother tongue, so an operator filling a spreadsheet from a paper form has no id to hand; the resolver matches on lower(btrim(name)), refusing the row with IMPORT_UNKNOWN_PLACE rather than guessing. Because two different provinces can each have a district of the same name, Permanent District is looked up within Permanent Province, not by name alone — the same reasoning Permanent Municipality applies against Permanent District. The fill order is checked here, before the database ever sees the row, so a district given without its province fails with a message naming the missing column rather than a bare 23514 from the corresponding CHECK — which still exists as the authority and is mapped again by addressConstraintIssue in case a geography row is renamed or retired between resolution and insert. There is no house-number column on the template at all: it is the finest-grained, least standardised part of the address, and import leaves it unset, settable afterward through the person forms. Current Province/District/Municipality/Ward/Tole are only filled when they genuinely differ from the permanent address — leaving the whole Current group blank means "not recorded," never "same as permanent," because there is no such flag on the sheet or in the database.
  • A student import row with no guardian information is refused outright, thrown inside the row's own transaction so the pupil it would otherwise orphan is rolled back with it. If both Guardian Name and Guardian Phone are blank, the row fails with IMPORT_STUDENT_REQUIRES_GUARDIAN naming the Guardian Name column — a second write path enforcing the same "a pupil must have at least one guardian" rule the people module's API enforces with STUDENT_REQUIRES_ONE_GUARDIAN, kept in step deliberately after an earlier version of this feature let the two drift.
  • Guardian matching is by phone, not by name (people-import-writer.service.ts:200-212). Two rows both reading "Ram Rai" in a roll of several hundred are commonly two different people; a shared phone number in this context is a household. Matching on name would create duplicate guardian rows for every sibling pair; matching on phone lets siblings correctly share one guardian record. With no phone number at all, a new guardian is always created — a duplicate is easier to undo than a wrong merge.
  • Two-or-more guardians already sharing one phone number is treated as ambiguous, not resolved. findOrCreateGuardian only reuses a match when the phone lookup returns exactly one row; two matches falls through to creating a third guardian rather than guessing (people-import-writer.service.ts:234-239).
  • Department/designation resolution refuses rather than guesses (people-import-writer.service.ts:359-421). An unknown department name is a thrown ImportRowError, not a silently created department — a typo would otherwise permanently pollute the school's own department list and split every later report across the misspelling and the correct name.
  • Salary is all-or-nothing at zero, not partial-null. If either basicSalary or allowances is present, the other defaults to "0" rather than staying null — the staff_salary_pair_coherent check (owned by the people/staff schema) requires both or neither, and defaulting to an explicit zero is a real value, unlike a silent NULL that would also null total_salary (people-import-writer.service.ts:331-340).
  • Error translation never leaks a raw database error to the report. toIssue() tries ImportRowError first, then PersonWriterService.translate() (the shared typed-error translator), and only falls back to a generic IMPORT_ROW_WRITE_FAILED message — drizzle's own error message is the full parameterised SQL plus every bound value, which would otherwise put a pupil's personal data and the schema itself into a report an operator downloads (people-import-writer.service.ts:548-586). The raw error still reaches the log, at error level.
  • Ethnicity and mother tongue arrive as names and refuse rather than guess. resolveClassification matches the spreadsheet cell against ethnicities/motherTongues on lower(btrim(name)) — the same normalisation the tables' own unique index uses — and throws IMPORT_UNKNOWN_ETHNICITY/IMPORT_UNKNOWN_MOTHER_TONGUE for a name that does not match, rather than writing null (people-import-writer.service.ts:454-479). Both columns are ON DELETE SET NULL, so a silently blanked value would be indistinguishable from one the operator left blank on purpose, and the school's own ethnicity return would simply come up short by however many rows were misspelled.
  • can_login on every row this run creates follows the upload's own sendInvites choice, never a per-row value. apply's grantLogin parameter (default false) is threaded into every buildInsert call — an import is frequently a migration of records for people (last year's leavers, a roll typed from paper) who must never be emailed at all, so the default is no account; ticking "send invites" on the upload is what flips it for every row this run creates (people-import-writer.service.ts:84-90,184-188).
  • imeisId on a student row is stored as text, straight from the "IEMIS ID" column. Kept as text, like every other identifier-shaped column in this module, so a leading zero in the ministry's own identifier is not silently dropped (people-import-writer.service.ts:202).

6.5 PeopleExportReader

MethodCalled ByReadsWritesSide EffectsErrors
read(scope, filters, gates)PeopleExportProcessorstudents+users (+ student_guardian/guardians for the primary guardian), or guardians+users (+ a student_guardian count subquery), or staff+users+departments+designations, depending on scopeNoneNoneNone thrown directly

Explain:

  • Gated columns are added or removed as whole columns, never nulled. A caller without StudentMedical_READ/StaffSalary_READ gets a file with those headers absent entirely (people-export-reader.service.ts:23-28). An empty cell under a present header reads as "this person has no recorded value," which is a different and wrong statement — and a spreadsheet with the header present is one an operator will fill in and re-import, creating data nobody actually reviewed.
  • Bank and statutory identifiers ride the salary gate, not the staff one. includeSalary (from StaffSalary_READ) additionally adds bank name, account number, branch, PAN number, citizenship number, SSF number, and CIT number to the staff export (people-export-reader.service.ts:367-386) — a colleague holding only Staff_READ has no more legitimate business with where somebody's wages are paid than with how much they are paid, so those columns are absent for them exactly as the salary figures are.
  • Ethnicity and mother tongue are exported as names, on all three scopes. Each is a correlated scalar subquery against ethnicities/motherTongues (people-export-reader.service.ts:159-164, mirrored for guardians and staff) rather than the stored integer id — an export that emitted the id would produce a file the same system cannot re-import, and an office correcting a typo by round-tripping a spreadsheet through export-then-import is the ordinary case this is built for.
  • The student export also carries the IEMIS ID column, read straight off students.imeisId (people-export-reader.service.ts:170,231) — present regardless of any gate, since it is not sensitive in the way medical or salary data is.
  • The row cap is 20,000 (MAX_ROWS), applied as a plain SQL LIMIT, not surfaced as a separate error — an export simply truncates at that many rows. An unbounded SELECT materialised into a workbook in memory is an out-of-memory kill of the worker process, which BullMQ then retries and kills again.
  • The student export flattens exactly one guardian per pupil — the one marked is_primary on student_guardian — via three correlated scalar subqueries (name, phone, relationship). A child with three guardians does not become three rows; the export's grain is "one row per pupil," and changing that silently would change what the file means to whoever opens it.
  • Soft-deleted rows are excluded everywhere (isNull(*.deletedAt) on both the entity table and users), consistently across all three scopes.
  • Filters are a fixed, typed allowlist per scope, never an arbitrary passthrough of the caller's filter object — recordStatus/gender for students, kind for guardians, employmentStatus/departmentId for staff. An unrecognised or wrongly-typed filter key is silently ignored rather than applied.

7. Runtime Flows

7.1 Upload and dry-run validate an import

StepCode PathBehaviorFailure Case
1DataTransferController.uploadImportReceives the multipart upload behind spreadsheetMulterOptions (10 MB cap, .csv/.xlsx extension filter).Multer rejects an oversized or wrong-extension file before the controller runs at all.
2DataTransferService.uploadForDryRunRe-checks size (MAX_UPLOAD_BYTES, also 10 MB) and reads the bytes.DATA_TRANSFER_FILE_REQUIRED (no file), IMPORT_FILE_TOO_LARGE, DATA_TRANSFER_FILE_UNREADABLE.
3parsePeopleFileValidates headers and every cell against the scope's template.Returns issues rather than throwing; a missing required header instead returns rows: [] with missingHeaders populated.
4DataTransferRequestService.createOrGetInserts keyed on sha256(bytes) under scope, or finds the existing row on conflict.DATA_TRANSFER_REQUEST_CONFLICT only on the narrow race where the conflicting row disappears between statements.
5DataTransferService.storeImportFile (only for a genuinely new upload)Uploads the file via StorageManager, optimize: false.DATA_TRANSFER_FILE_UNREADABLE if the storage driver returns neither a remote key nor a relative path.

Branches and Edge Cases

BranchConditionBehaviorResult
Nothing writes to students/guardians/staffAlways, on this routeThe upload is always a dry run.The response says so (dryRun: true), and no person row is ever created here.
Same file uploaded twiceIdentical sha256 under the same scopeThe insert conflicts; the existing row is returned.alreadyExisted: true, message "This file has already been uploaded; showing the existing report," not a new report.
Same content, different scopeSame bytes, scope=guardians vs. scope=staffThe idempotency key is (scope, hash), so this is a distinct request.A second, independent dry run.
Required headers missinge.g. no "Admission Date" columnParsing short-circuits before per-row validation.missingHeaders populated, rows: []; the file cannot later be committed (§7.2).
File exceeds MAX_ROWS (5,000 data rows)Row count check after parsing headersParsing stops with a single synthetic issue at row 0.The report is a single "exceeds the limit" issue, not 5,000+ per-row issues.
Unknown extra column in the fileA header the template does not recogniseReported, not fatal.Listed in unknownHeaders; the operator's own working columns do not block the import.

7.2 Commit an import

StepCode PathBehaviorFailure Case
1commitImportExistence, ownership, status === "pending".DATA_TRANSFER_REQUEST_NOT_FOUND, DATA_TRANSFER_NOT_YOURS, DATA_TRANSFER_NOT_COMMITTABLE (already committing/completed/failed).
2commitImportRe-checks the stored report: no missing headers, at least one valid row.IMPORT_MISSING_HEADERS, DATA_TRANSFER_HAS_BLOCKING_ISSUES.
3commitImport, inside db.transactionConditional transition pending -> committing, then outbox.enqueueone transaction.If the transition returns false (lost a race to a concurrent commit), throws DATA_TRANSFER_NOT_COMMITTABLE and the transaction rolls back — the outbox row is never inserted.
4PeopleImportProcessor.process (worker, later)Re-reads the record; proceeds only if status === "committing".A duplicate outbox delivery finds a status of completed/failed already and logs a no-op.
5PeopleImportProcessor.processRe-reads the stored file (not the in-memory dry-run report) and re-parses it.readStoredFile throws whatever StorageManager.getFile throws if the file has gone missing; caught, and the request is marked failed.
6PeopleImportWriter.applyWrites only the rows the dry run did not flag as invalid. Every row's can_login follows the upload's own sendInvites choice (grantLogin), never a per-row value.Per-row failures are appended to the same issues list the dry run produced, so the final report holds every reason a row is missing in one place.
7transition(committing -> completed, outcome)Records created/failed counts, the merged issue list, and createdUserIds.If the write throws (e.g. the transition itself fails), the error is caught by fail(), the request is marked failed, and the error is re-thrown so BullMQ retries and, on the final attempt, records a job_failures row.
8PeopleImportProcessor.queueInvites (only if sendInvites was requested and createdUserIds is non-empty)Splits the created ids into chunks of MAX_INVITE_BATCH (500) and calls DataTransferService.requestInvites once per chunk, each becoming its own request with its own id — after the import is marked completed and outside the try/catch that would fail it.A chunk that cannot be queued is counted, and the failure log names how many people were not invited across how many batches. Logged, not thrown — the people already exist either way, and turning a mail-queueing hiccup into a failed, retried import would leave the import stuck reapplying rows it already wrote.

Branches and Edge Cases

BranchConditionBehaviorResult
Double-click commitTwo requests to commit the same requestId in quick successionThe transactional status flip is the lock: only one UPDATE ... WHERE status = 'pending' can succeed.The loser gets DATA_TRANSFER_NOT_COMMITTABLE (409); no double enqueue.
Outbox dispatch crashes mid-deliveryAt-least-once delivery redelivers IMPORT_COMMITThe worker's first act is the conditional status re-check.Second delivery: status !== "committing", logged, returns — no re-application.
File deleted from storage between upload and commitOperational/manual deletionreadStoredFile throws.Caught; request marked failed with the storage error message; the throw propagates so BullMQ retries and eventually dead-letters it.
A row valid at dry-run time now violates a uniqueness rule (e.g. a duplicate email created by another process in between)Race between dry run and commitThe per-row transaction fails; toIssue translates the error.That one row is reported failed in outcome.issues; every other row still commits.
A row the dry run already flagged invalidAlways, on commitNever attempted at write time at all.Prevents a confusing second, vaguer failure message for the same row.
An unrecognised ethnicity or mother-tongue nameA cell that does not match any name in the school's own list, case/whitespace-insensitivelyThe row fails at write time, alongside every other row-level failure.IMPORT_UNKNOWN_ETHNICITY / IMPORT_UNKNOWN_MOTHER_TONGUE in outcome.issues.
sendInvites was requested but queueing a chunk failsrequestInvites throws for one chunk after the import is already completedThe import's own status and outcome are unaffected, and every other chunk is still queued; the failed one is counted and logged.No error surfaced to the operator on this call; the people in that chunk can still be invited from the people screens.
An import creates more people than one invitation batch holdsOver 500 rows created with sendInvites: truequeueInvites chunks at 500. The two limits genuinely disagree — an import accepts MAX_ROWS (5,000) while requestInvites refuses more than MAX_INVITE_BATCH (500) — and chunking is what reconciles them.One invite:batch request per chunk, each independently visible and independently retryable.

7.3 Request, poll, and download an export

StepCode PathBehaviorFailure Case
1requestExportDecides includeGatedColumns now, from the caller's active-role permissions — never re-derived later.Nothing thrown; an actor without either gated permission simply gets an empty includeGatedColumns array.
2requestExport, inside db.transactionCreates the async_requests row and the outbox row together.If the transaction fails, neither exists — no orphaned request with nothing scheduled.
3PeopleExportProcessor.processProceeds only if status === "pending".Duplicate delivery: logged no-op.
4PeopleExportReader.readRuns the scope-specific, role-scoped SELECT, capped at 20,000 rows.No error path — a too-large result set is silently truncated.
5buildExportFile + storeGeneratedFileBuilds the buffer, stages it, uploads it, and — only when the upload produced a remote key — deletes the local staging copy.DATA_TRANSFER_FILE_UNREADABLE if storage returns no usable key.
6transition(pending -> completed, storageKey, rowCount)Attaches the result.On any prior failure, transition(pending -> failed, error) runs instead, and the original error is re-thrown for BullMQ's retry/dead-letter path.
7getExportComputes downloadUrl only when status === "completed" and a storageKey is present.Otherwise downloadUrl: null — the caller is expected to keep polling.
8readExportFileRe-checks ownership and status independently of the status call.DATA_TRANSFER_EXPORT_NOT_READY if not completed, or already failed.

Branches and Edge Cases

BranchConditionBehaviorResult
Download URL is a path on this API, not a signed storage URLAlwaysdownloadUrl is /api/data/exports/:requestId/download.Every download re-enters RoleGuard/Permissions("DataExport_READ"); a forwarded link cannot bypass the permission check. Also avoids the local storage driver's getSignedUrl throwing "not supported" for a perfectly built export.
Caller lacks the gated permissione.g. no StaffSalary_READThe relevant columns are entirely absent from the built file, not present-and-blank.A downstream re-import of that file cannot accidentally wipe salary data, because there is no salary header to map to.
Requesting the identical export twiceTwo POST /data/exports calls with the same scope/filtersEach creates its own async_requests row (idempotencyKey = requestId, self-unique).Two independent builds — deliberate, since a second request an hour later is a request for current data, not a request to be handed back a stale file.
Download attempted before the build finishesstatus still pendingreadExportFile throws.DATA_TRANSFER_EXPORT_NOT_READY, message distinguishes "still being built" from "failed to build."
Export build fails (e.g. storage error)Any thrown error inside the processorRequest marked failed; error re-thrown.On the final BullMQ attempt, a job_failures row is recorded (see §7.4).

7.4 A job fails while running

StepCode PathBehaviorFailure Case
1PeopleQueueProcessor.onFailed (@OnWorkerEvent("failed"))Fires on every attempt.
2onFailedCompares job.attemptsMade to job.opts.attempts.If attempts were ever unregistered for this queue, BullMQ's bare default of 1 would make every transient error read as terminal — not the case here, since QueueName.PEOPLE carries env-configured defaultJobOptions.
3onFailed, final attempt onlyCalls DeadLetterService.record inside a try/catch.A throw inside a BullMQ failed handler is swallowed by BullMQ itself, so the catch here is the only place this failure can be logged if the insert itself fails.
4referenceOf(job)Extracts only requestId, scope, correlationId, replayOf, and (for invite batches) a count plus the first five user ids — never the full payload.

7.5 Replay a dead-lettered job

Branches and Edge Cases

BranchConditionBehaviorResult
Failure belongs to a non-people queuequeue_name not in REPLAYABLE_QUEUESRefused with 409, not 404JOB_FAILURE_QUEUE_NOT_PERMITTED — a 404 would falsely imply the row does not exist, when another screen may legitimately show it.
Two operators replay the same row at onceConcurrent POST .../replayThe conditional UPDATE admits exactly one.The loser gets JOB_FAILURE_ALREADY_REPLAYED; only one fresh job is enqueued.
The enqueue itself fails after the claim succeedsRedis unreachable, or the target queue is unresolvableThe claim is released — replayed_at/replayed_by/replay_job_id reset to null, matched on the failed call's own replay_job_id so a concurrent successful claim is never clobbered.503 JOB_FAILURE_REPLAY_ENQUEUE_FAILED; the row is replayable again immediately.
Replay is enqueued under the original job idNever happensN/A — the id is always buildJobId(["replay", id, timestamp]).Guards against BullMQ's add() silently no-op'ing on a repeated id and reporting a false success.
Replayed payload is only the referenceAlwaysqueue.add sends {...(row.payloadRef ?? {}), correlationId: replayJobId, replayOf: row.id}not the original full payload, since it was never stored.The replayed job must be reconstructable from requestId/scope alone — which both PeopleImportProcessor and PeopleExportProcessor already do, by re-reading async_requests.

7.6 Send an invitation batch

Whether the batch was requested through this route or chained automatically by PeopleImportProcessor after a commit, it is the same requestInvites call, the same async_requests row shape, and the same worker — there is exactly one code path for sending an invitation batch, with two producers.

StepCode PathBehaviorFailure Case
1DataTransferController.sendInvitesGated on Users_UPDATE, not an import/export permission — granting sign-in access is an identity change reachable without a spreadsheet.Missing permission -> 403.
2requestInvitesDe-duplicates userIds, order-preserving; rejects an empty or oversized batch before writing anything.VALIDATION_FAILED (empty), INVITE_BATCH_TOO_LARGE (over 500).
3requestInvites, inside db.transactionInserts the async_requests row and the outbox row together.If the transaction fails, neither exists.
4PeopleInviteProcessor.processProceeds only if status === "pending"; re-reads users rather than trusting the payload, since someone in the batch may have been deleted, banned, or had sign-in switched off since the request was queued.Duplicate delivery: logged no-op. Its own MAX_BATCH (500) check is defensive — the API already refuses an oversized batch before this job is ever created.
5PeopleInviteProcessor.processMints each eligible person an account_invite verification record valid for 7 days (ACCOUNT_INVITE_TTL_MS, rather than the 15-minute OTP default) and sends their own email, independently, paced ~500ms apart. The one-time code is minted by the token factory but stripped before the email is built: consumeByOtp matches password_reset alone, so an invitation is redeemable by link only and printing a code would lead the recipient somewhere that always refuses them.One person's failure is recorded in outcome.failed and does not stop the rest of the batch.
6transition(pending -> completed, outcome)Records sent/no-address/not-invitable/failed counts.Terminal — there is no retry-in-place for a partially-failed batch; re-inviting the same people is a new POST /data/invites call.

8. Caching

This module does not use Redis for read caching. async_requests is read directly from PostgreSQL on every status poll — an export status endpoint is expected to be polled at low frequency by a human waiting for a file, so a cache layer here would add invalidation complexity (bust on every worker write) for a query pattern that does not need it.

Cache Key PatternBuilderValueTTLInvalidationCaller
N/A

9. BullMQ, Schedulers, and Async Work

QueueJobProducerProcessorPayloadRetry/BackoffIdempotency
QueueName.PEOPLE ("people")PeopleJob.IMPORT_COMMIT ("people.import_commit")DataTransferService.commitImport, via OutboxService.enqueuePeopleImportProcessor (dispatched from PeopleQueueProcessor)PeopleImportCommitPayload { correlationId, requestId, scope, actorId }Env-configured defaultJobOptions for the queue; BullMQ's own retry/backoff applies.Conditional status transition (committing only) makes redelivery a no-op.
QueueName.PEOPLEPeopleJob.EXPORT_BUILD ("people.export_build")DataTransferService.requestExport, via OutboxService.enqueuePeopleExportProcessorPeopleExportBuildPayload { correlationId, requestId, scope, actorId, filters, includeGatedColumns }Same as above.Conditional status transition (pending only).
QueueName.PEOPLEPeopleJob.SEND_INVITE_BATCH ("people.send_invite_batch")Two producers, both through DataTransferService.requestInvites, via OutboxService.enqueue: DataTransferController.sendInvites (POST /data/invites), and PeopleImportProcessor.process after a successful commit whose upload set sendInvites: true and created at least one personPeopleInviteProcessorPeopleSendInviteBatchPayload { correlationId, requestId, userIds, actorId }Env-configured defaultJobOptions for the queue.Conditional status transition (pending only), plus per-person independent try/catch inside the batch.

Explain:

  • One @Processor on QueueName.PEOPLE, routing by job name — not three. @nestjs/bullmq builds one BullMQ Worker per class decorated with @Processor; WorkerOptions.name is a monitoring label, not a filter. Three decorated processors on this queue would be three Worker instances racing for every job on it, and a handler that does not check job.name will happily run against the wrong payload shape. PeopleQueueProcessor.handlers is typed Record<PeopleJob, (job: Job) => Promise<unknown>>, so adding a member to PeopleJob without adding its handler is a compile error, not a job that silently completes having done nothing (people-queue.processor.ts:11-38).
  • Concurrency is 2. An import holds a worker slot for the length of a whole file; concurrency of 1 would make an export queue invisibly behind a 2,000-row import with the operator having no way to see why. Higher than 2 is not free either — every import row transaction takes a lock on code_counters, and running many imports at once turns that counter into the contention point (people-queue.processor.ts:31-37).
  • The BullMQ jobId the dispatcher actually uses is outbox_<outbox row id> (outbox-dispatcher.processor.ts:54,121), not the dedupeKey string this module supplies (import-commit-${requestId} / export-build-${record.requestId}). The dedupeKey only makes the outbox_events insert idempotent under (aggregate_type, aggregate_id, job_name, dedupe_key); it plays no role in BullMQ's own deduplication.
  • Delivery is at-least-once, never exactly-once. Every processor's first act is therefore a conditional status read (committing-only for import, pending-only for export/invite) rather than an unconditional write.
  • SEND_INVITE_BATCH has two producers and one handler. requestInvites is the single call both DataTransferController.sendInvites and PeopleImportProcessor make — so an invitation batch behaves identically regardless of whether an operator asked for it directly or an import chained it, and there is exactly one place that decides how the batch is queued.
  • A dead letter is recorded only from PeopleQueueProcessor.onFailed, on the final attempt. Nothing else in this module writes to job_failures.

10. Realtime and Events

None. This module emits no Socket.IO events and has no realtime consumer.

11. Security, Auth, and Abuse Controls

  • Guards. Every route on DataTransferController sits behind @UseGuards(JwtAuthGuard, RoleGuard) at the controller level (data-transfer.controller.ts:56) — there is no public or guest-accessible route in this module.
  • Permissions. DataImport_READ (templates, get import, list job failures), DataImport_CREATE (upload), DataImport_UPDATE (commit, replay), DataExport_CREATE (request export), DataExport_READ (get export status, download), and Users_UPDATE (send invitations). DataImport_DELETE/_RESTORE and DataExport_UPDATE/_DELETE/_RESTORE exist in the permission catalog (every module gets all five actions by construction — permission-catalog.ts:117-123) but are not used by any route here; there is no delete or restore endpoint for a transfer request.
  • Invitations are deliberately gated on a Users permission, not a DataImport/DataExport one. POST /data/invites requires Users_UPDATE because granting sign-in access to a person is an identity change, and this route reaches that outcome without ever touching a spreadsheet — gating it on an import permission would let anyone who can import files also grant login access to arbitrary existing people, which is a different capability than importing.
  • Dead-letter rows are addressed by public_id, never the serial id. A sequential id on a route gated only by a read permission is a working enumeration of every job the platform has ever failed; job_failures.public_id is a uuid, generated per row, and the serial id never appears in a response.
  • The failure queue refuses to be read unpaginated. pagination=false on GET /data/job-failures is rejected with PAGINATION_LIMIT_INVALID — the table only ever grows, and nothing prunes it.
  • Ownership, not just authentication. assertOwnRequest additionally requires record.actorId === actor.id, with actor.activeRole?.isSuperadmin as the only bypass — a superadmin can see a stuck request when the clerk who started it is unavailable; everyone else is refused even read access, because a report/export can carry a live preview of pupils' or staff's personal data.
  • File-type and size enforcement is layered, not single-point. spreadsheetMulterOptions rejects a non-.csv/.xlsx extension and anything over 10 MB before the controller method runs; DataTransferService.uploadForDryRun independently re-checks the same 10 MB cap on the bytes it actually reads. parsePeopleFile's own zip-signature sniff (0x50 0x4B) decides CSV-vs-XLSX decoding from the bytes themselves, not from the client-supplied MIME type, which Excel, LibreOffice and Windows report inconsistently for CSV.
  • Row count is bounded on both directions. Imports refuse anything over 5,000 data rows (DataTransferService.MAX_ROWS); exports are capped at 20,000 rows read (PeopleExportReader.MAX_ROWS). Both exist to keep a single request from exhausting the process's memory.
  • Column-level authorization is enforced by omission, decided once, at the edge. Gated export columns (StaffSalary_READ, StudentMedical_READ) are resolved from the requester's active role inside requestExport, carried on the outbox payload, and never re-derived inside the worker — a worker has no session and no active role, so re-deriving would mean choosing a role on the operator's behalf, and the safe-looking choice (the union of every role they hold) is exactly the privilege escalation the active-role model exists to prevent.
  • Dead-letter payload redaction. job_failures.payload_ref never carries a full job payload — see §5.2 and §16.5. This is the load-bearing control that keeps DataImport_READ from becoming an account-takeover primitive against queued password-reset invitations.
  • Downloads never leave this API's own auth boundary. Both the template download and the export download stream bytes through this controller behind the same guard chain as every other route, rather than minting a signed object-storage URL — a signed URL is a bearer credential in a query string that would bypass RoleGuard for anyone it is forwarded to.
  • CSV/XLSX formula-injection guarding on export. Any exported cell whose text begins with =, +, -, @, a tab, or a carriage return is prefixed with a leading ' before being written (build-export-file.ts:15,28-30) — a free-text field influenced by an outsider (a rejection reason, a note) could otherwise carry =HYPERLINK(...), which Excel executes the moment the file opens.
  • Fail-closed on unknown queue job names. PeopleQueueProcessor.process throws for any job.name not in its handlers map, deliberately, so BullMQ records the failure rather than the job silently completing.

13. Error Handling

Error CodeHTTP StatusThrown ByConditionClient Action
DATA_TRANSFER_REQUEST_NOT_FOUND404DataTransferRequestService.findByRequestIdNo async_requests row for the given requestId.Check the id; it may have been mistyped or the request never existed.
DATA_TRANSFER_REQUEST_CONFLICT409DataTransferRequestService.createOrGetThe idempotency-key insert conflicted and the conflicting row could not be re-read (concurrent rollback).Retry the upload.
DATA_TRANSFER_NOT_COMMITTABLE409DataTransferService.commitImportStatus is not pending (already committing, completed, or failed), including a lost race on the transition itself.Poll the request's current status instead of retrying the commit.
DATA_TRANSFER_HAS_BLOCKING_ISSUES409DataTransferService.commitImportvalidRows === 0 — every row in the file failed validation.Fix the reported issues and re-upload.
DATA_TRANSFER_NOT_YOURS403DataTransferService.assertOwnRequestCaller is neither the original actor nor a superadmin.Ask the original uploader, or escalate to a superadmin.
DATA_TRANSFER_FILE_REQUIRED400DataTransferService.uploadForDryRunNo file present on the multipart request.Attach a file.
DATA_TRANSFER_FILE_UNREADABLE400DataTransferService.uploadForDryRun, storeImportFile, storeGeneratedFileThe uploaded bytes could not be read from disk, or storage returned neither a remote key nor a relative path.Re-upload; if persistent, a storage/infra issue.
DATA_TRANSFER_EXPORT_NOT_READY409DataTransferService.readExportFileStatus is not completed, or no storageKey is present.Poll GET /data/exports/:requestId until status: "completed".
IMPORT_REQUIRED_FIELD_MISSING(row-level, in the report body, 200)parsePeopleFileA required column's cell is blank.Fill in the cell and re-upload.
IMPORT_INVALID_ENUM_VALUE(row-level)parsePeopleFileA cell's value is not one of the column's allowed values.Use one of the values shown in the template's guidance row.
IMPORT_INVALID_DATE(row-level)parsePeopleFileA date column's value is not a valid YYYY-MM-DD date.Correct the date format.
IMPORT_INVALID_NUMBER(row-level)parsePeopleFileA numeric column's value is not a non-negative number.Correct the value.
IMPORT_FILE_TOO_LARGE400 (upload) or row-level (parse)DataTransferService.uploadForDryRun (byte size), parsePeopleFile (row count over maxRows)Same code, two distinct guards — byte size at upload, row count at parse.Split the file into smaller batches.
IMPORT_MISSING_HEADERS409DataTransferService.commitImportThe stored report still shows missing required headers.Re-upload a file with the correct headers; this should not normally be reachable since the dry-run already reports it.
IMPORT_ROW_WRITE_FAILED(row-level, inside outcome.issues)PeopleImportWriter.toIssueA row failed at write time with an error the translator could not map.Check the row does not duplicate an existing record; contact support if unclear.
IMPORT_UNKNOWN_DEPARTMENT(row-level)PeopleImportWriter.resolveDepartmentThe named department does not exist, or a designation was given with no department.Create the department first, or correct the spelling.
IMPORT_UNKNOWN_DESIGNATION(row-level)PeopleImportWriter.resolveDepartmentThe named designation does not exist under the given department.Correct the designation name.
IMPORT_UNKNOWN_ETHNICITY(row-level)PeopleImportWriter.resolveClassificationThe named ethnicity does not match any entry in the school's list, case/whitespace-insensitively.Add it to the list first, or correct the spelling.
IMPORT_UNKNOWN_MOTHER_TONGUE(row-level)PeopleImportWriter.resolveClassificationThe named mother tongue does not match any entry in the school's list.Add it to the list first, or correct the spelling.
VALIDATION_FAILED400DataTransferService.requestInvitesuserIds is empty after de-duplication.Choose at least one person to invite.
INVITE_BATCH_TOO_LARGE400DataTransferService.requestInvitesMore than 500 userIds (after de-duplication) on POST /data/invites.Split the batch across multiple requests.
JOB_FAILURE_ALREADY_REPLAYED409DeadLetterService.replayreplayed_at is already set (checked twice: an early read, and again when the claim UPDATE affects zero rows).Refresh the failures list; someone already replayed it.
JOB_FAILURE_NOT_FOUND404DeadLetterService.replayNo job_failures row for the given public_id.Check the id.
JOB_FAILURE_QUEUE_NOT_PERMITTED409DeadLetterService.replayThe failure's queue_name is not in REPLAYABLE_QUEUES (i.e. not people).This screen cannot replay it; use the owning module's own tooling.
JOB_FAILURE_REPLAY_ENQUEUE_FAILED503DeadLetterService.replayThe claim UPDATE succeeded but queue.add then threw (Redis unreachable, or the target queue unresolvable) — the claim is released back to unreplayed before this is thrown.Try the same replay again; the row is claimable once more.
PAGINATION_LIMIT_INVALID400DeadLetterService.findAllpagination: false was requested against job_failures, a table that only ever grows.Page through the results instead.

14. Observability

SignalLocationPurpose
Log (info)DataTransferService — none beyond the error case below
Log (error)DataTransferService.uploadForDryRun"Could not read the uploaded spreadsheet" with the underlying error.
Log (info)PeopleImportWriter.applyOne line per import run: [import] {scope}: {created} created, {issues.length} failed, requested by {actorId}.
Log (error)PeopleImportWriter.toIssueEvery unmapped row-write failure, with the row number and stack — the detail deliberately withheld from the operator-facing report.
Log (info)PeopleImportProcessor.process"duplicate delivery, nothing to do" on a redundant IMPORT_COMMIT; a completion summary on success.
Log (info)PeopleExportProcessor.processSame duplicate-delivery log; a completion summary with row count and format.
Log (info/error)PeopleInviteProcessor.processPer-batch summary: sent / no-address / not-invitable / failed counts.
Log (warn/error)PeopleQueueProcessor.onFailedWarn on a non-final failed attempt; error with stack on the final attempt.
Log (info)DeadLetterService.replayOne line per replay: original failure id, queue/job name, new job id, actor.
Log (error)DeadLetterService.record caller (onFailed)If the job_failures insert itself throws, this is the only remaining record of the original failure.
AuditNone dedicatedOwnership and replay actor are captured on the rows themselves (async_requests.actor_id, job_failures.replayed_by) rather than in a separate audit log.

15. Testing and Validation

Test TypeFilesCoverage
Unitapps/api/src/utils/data-transfer/parse-people-file.spec.tsHeader matching, row numbering, required/enum/date/numeric validation, BOM and zip-signature handling.
Unitapps/api/src/utils/data-transfer/templates.spec.tsTemplate columns and workbook generation (CSV BOM, XLSX structure), including the required/optional split, the enum allowed lists, the Joining Date/salary columns on staff, and the IEMIS ID column on students.
Unitapps/api/src/utils/data-transfer/build-export-file.spec.tsFormula-injection escaping, date formatting, header/column ordering.
Integration (real Postgres)apps/api/src/modules/data-transfer/shared/dead-letter.service.spec.ts8 tests: the pagination=false refusal, replay under a fresh job id, refusing a second replay, releasing the claim when the queue rejects the enqueue (and that a released row can be replayed again), refusing to replay a non-people queue, a 404 on an unknown publicId, and the queue filter hiding rows from other queues. Only BullMQ is doubled (via a fake ModuleRef); every row the assertions read is real.
Integration (real Postgres)apps/api/src/modules/data-transfer/shared/people-import-writer.service.spec.ts8 tests: resolving ethnicity/mother tongue by name (including case/whitespace differences), failing a row for an unrecognised name with the correct error code, leaving both columns null when blank, one bad row costing exactly one row, storing the IEMIS ID, and that can_login follows the upload's sendInvites choice rather than any per-row value.
Unit(none found)No dedicated spec exists for DataTransferController, DataTransferService, DataTransferRequestService, or any of the four workers.
E2E(none found)No *.e2e-spec.ts under apps/api/test exercises /data/* routes.
Manualpnpm turbo run build:docs (this doc's own build gate)Not a functional test — verifies the documentation site builds, nothing about the module's runtime behavior.

16. Mandatory Backend Deep-Dive Pack

16.1 Submodule Coverage Matrix

UnitTypeOwnsDepends OnCalled ByCallsState TouchedFailure Modes
DataTransferControllerControllerEvery /data/* routeDataTransferService, DeadLetterServiceHTTP clientsBoth services aboveNone directlyGuard/permission rejection, DTO validation error
DataTransferServiceServiceTemplate building, upload dry-run, commit, export request/status/download, file stagingDataTransferRequestService, OutboxService, StorageManager, PeoplePermissionsService, DATABASEController, both processors (readStoredFile, storeGeneratedFile)Storage, DB, outboxasync_requests, outbox_events (via outbox), storage filesSee §13
DataTransferRequestServiceServiceasync_requests lifecycleDATABASEDataTransferService, both processors, PeopleInviteProcessorDB onlyasync_requestsNotFoundException, ConflictException
DeadLetterServiceServicejob_failures list/replay/recordDATABASE, ModuleRef (dynamic queue lookup)Controller, PeopleQueueProcessor.onFailedDB, BullMQ Queue.addjob_failuresNotFoundException, ConflictException, BadRequestException (pagination=false), ServiceUnavailableException (enqueue failure, with the claim released)
PeopleImportWriterService (write path)Row-to-person writesDATABASE, PeopleCodeService, PersonWriterServicePeopleImportProcessorPer-row transactionsusers, students/guardians/staff, student_guardianPer-row RowIssue, never propagated as an exception
PeopleExportReaderService (read path)Role-scoped export SELECTsDATABASEPeopleExportProcessorRead-only queriesNoneNone thrown
PeopleQueueProcessorProcessor (@Processor)The single Worker on QueueName.PEOPLE; job-name routing; dead-letter recordingThe three sibling processors, DeadLetterServiceBullMQDelegates to handlers; on final failure, DeadLetterService.recordjob_failures (indirect)Throws on an unhandled job name
PeopleImportProcessorProcessor (plain provider, not @Processor)IMPORT_COMMIT handlingDataTransferRequestService, DataTransferService, PeopleImportWriterPeopleQueueProcessorRe-read, re-parse, write, transition, and (best-effort, after completion) DataTransferService.requestInvitesasync_requestsRe-throws after marking failed, so BullMQ retries; a failure to queue invitations afterward is only logged
PeopleExportProcessorProcessor (plain provider)EXPORT_BUILD handlingDataTransferRequestService, DataTransferService, PeopleExportReaderPeopleQueueProcessorRead, build, store, transitionasync_requestsSame re-throw pattern
PeopleInviteProcessorProcessor (plain provider)SEND_INVITE_BATCH handlingDATABASE, DataTransferRequestService, VerificationTokenService, AuthEmailServicePeopleQueueProcessor, fed by DataTransferService.requestInvites (called from the controller or from PeopleImportProcessor)Per-person try/catch, paced 500ms apartasync_requests, sends emails via AuthEmailServiceThrows a plain (untyped) Error for an over-MAX_BATCH request — defensive only, since the API already refuses an oversized batch before this job exists; individual send failures are recorded, not thrown
parsePeopleFilePure functionCSV/XLSX to ParseResultgetTemplateDataTransferService, PeopleImportProcessorNone (pure)NoneNone thrown; returns issues
buildTemplateWorkbook / getTemplatePure functionsThe three template column listsNoneDataTransferService.buildTemplate, parsePeopleFile, buildExportFileNoneNoneNone
buildExportFilePure functionRows to CSV/XLSX buffergetTemplate (fallback columns)PeopleExportProcessorNoneNoneNone

Rules:

  • Every file under apps/api/src/modules/data-transfer/ and apps/api/src/utils/data-transfer/ is represented above.
  • PersonWriterService and PeopleCodeService are imported from the people module rather than reimplemented, because person-row shape and admission/employee-number allocation are people-module invariants this module must not duplicate or drift from.
  • AuthEmailService/VerificationTokenService are imported from the auth module for the same reason — invitation delivery and token issuance are auth invariants.

16.2 UML and Architecture Diagram Pack

16.3 Code Flow Narrative

DataTransferService.commitImport — the highest-stakes method in this module

StepCode LocationWhat HappensWhy It HappensFailure/Edge Case
1data-transfer.controller.ts:139-152Route entry; DataImport_UPDATE required, CurrentUser supplies the actor.Applying a validated file is a mutation, gated separately from reading (DataImport_READ) or uploading (DataImport_CREATE).Missing permission -> 403 from RoleGuard before the controller body runs.
2data-transfer.service.ts:210-211findByRequestId + assertOwnRequest.Ownership is checked fresh on every mutating call, never cached from an earlier read.404/403.
3data-transfer.service.ts:213-221Status must be exactly pending.A request already committing/completed/failed has nothing left to validate against.409 DATA_TRANSFER_NOT_COMMITTABLE, with a status-specific message.
4data-transfer.service.ts:223-236Re-derives the stored ImportReport and re-checks missingHeaders/validRows.The dry-run report could in principle be stale (it never is in the current flow, but the check is defensive and cheap).409 IMPORT_MISSING_HEADERS / 409 DATA_TRANSFER_HAS_BLOCKING_ISSUES.
5data-transfer.service.ts:240-247Opens db.transaction; calls transition(requestId, "pending", "committing", {}, tx).The conditional UPDATE inside the transaction is both the state change and the concurrency lock.If it returns false, throws inside the transaction — everything rolls back, including nothing having been enqueued.
6data-transfer.service.ts:255-272outbox.enqueue(tx, {...}) with dedupeKey: "import-commit-" + requestId.Same transaction as step 5 — the status flip and the scheduling decision commit together or not at all.An outbox insert conflict here (extremely unlikely, since the status guard already prevents a second commit) would roll back the whole transaction too.
7data-transfer.service.ts:275Re-reads the now-committing record and maps it to ImportRequestDto.The response reflects the committed state, not a locally-mutated copy.N/A.
8(async, later) people-import.processor.ts:44-53Worker re-reads the record; proceeds only if status === "committing".Guards against at-least-once redelivery re-running the whole import.Logged no-op on a duplicate delivery.
9people-import.processor.ts:64-79Re-reads the stored file, re-parses it, filters to rows the dry run did not already reject.The payload never carries rows — only a request id — so this is the only source of truth for what to write.Missing storage key -> fail(), no throw (nothing to retry against).
10people-import-writer.service.ts:80-99One transaction per row; codes pre-allocated once for the whole batch.Isolates a bad row's failure to that row; avoids holding code_counters locked for the whole run.Each row's own error becomes a RowIssue; the loop continues.
11people-import.processor.ts:81-91transition(committing -> completed, outcome).Terminal state, completedAt set.If this itself throws, caught by the outer catch, fail() marks failed, and the original error is re-thrown for BullMQ.

16.4 Data Layer Deep Dive

Covered in full in §5.2 (field tables, nullability, constraints, business meaning) and the index-rationale tables embedded there. Summary of what is not repeated here:

  • No JSON Schema is enforced on async_requests.result or job_failures.payload_ref at the database level — both are plain jsonb, and the shape discipline (never store the payload, only a reference; specific keys per import/export) is enforced entirely in application code (data-transfer.service.ts, dead-letter.service.ts, people-queue.processor.ts).
  • No money units in this module's own tables — basicSalary/allowances are staff-module columns this module writes to via PeopleImportWriter, not columns it owns.
  • No timezone-sensitive interpretation beyond what timestamptz already gives; dates parsed from spreadsheets (admissionDate, dateOfBirth, joiningDate) are validated as calendar dates (YYYY-MM-DD) with no time-of-day component.
  • No migration history is referenced in the reviewed files beyond the schema definitions themselves.
  • No seed-data dependency — this module's tables start empty and are populated entirely by runtime use.

16.5 Business Logic and Invariant Catalog

InvariantEnforced ByWhy It ExistsFailure ErrorTests
An upload never writes a person row.DataTransferService.uploadForDryRun calling only parsePeopleFile + createOrGet, never PeopleImportWriter.A mis-clicked file picker must not create thousands of pupils with generated admission numbers indistinguishable from real ones.N/A (structural — there is no code path from upload to a write).None dedicated.
Import idempotency is per-(scope, sha256(file)), globally, not per-actor.async_requests_idempotency_idx (unique) + DataTransferRequestService.createOrGet's ON CONFLICT DO NOTHING.Two administrators uploading the identical spreadsheet is the ordinary way a roll gets doubled; nothing else would catch it since admission/employee numbers are generated.DATA_TRANSFER_REQUEST_CONFLICT (only on the narrow concurrent-rollback race; the common case is a silent return of the existing row).parse-people-file.spec.ts covers parsing; no test exercises the dedupe path itself.
A commit can only run from pending, exactly once.The conditional transition(requestId, "pending", "committing", ...) inside commitImport's transaction.Prevents a double-click, or a second concurrent commit, from enqueueing the import twice.DATA_TRANSFER_NOT_COMMITTABLE.None dedicated.
The commit's status flip and its outbox enqueue are atomic.db.transaction wrapping both the transition and the outbox.enqueue call.Prevents a request stuck at committing with nothing scheduled, and prevents a job running against a transaction that rolled back.N/A — enforced structurally by transaction scope.None dedicated.
An export request's row and its outbox event are written atomically.requestExport's db.transaction wrapping DataTransferRequestService.create and outbox.enqueue.The same reason as the commit: a crash between the two would leave a request stuck pending forever, with no reaper watching for it.N/A — structural.None dedicated.
An invitation request's row and its outbox event are written atomically, whether requested directly or chained from an import.requestInvites's db.transaction, called identically from the controller and from PeopleImportProcessor.Same reason again — one code path, one atomicity guarantee, regardless of which producer calls it.N/A — structural.None dedicated.
A worker never applies a request twice.Each processor's own status !== expected early return.Outbox delivery is at-least-once.N/A — silent no-op, logged.None dedicated.
An import chains its own invitation batch, never inside the commit's own transaction.PeopleImportProcessor.process calls requestInvites after transition(... "completed" ...), outside the try/catch that would fail the import.The people already exist either way; a queueing failure must not reopen or retry an import that already succeeded.N/A — a queueing failure is logged, not thrown.None dedicated.
Imported people get sign-in access only when the upload asked for invitations.PeopleImportWriter.apply's grantLogin parameter, threaded into every row's canLogin.An import is frequently a migration of records for people (last year's leavers, a roll typed from paper) who must never be emailed.N/A — structural; no per-row override exists.people-import-writer.service.spec.ts.
An unrecognised ethnicity or mother-tongue name fails the row rather than blanking it.PeopleImportWriter.resolveClassification, matched on lower(btrim(name)).A silently blanked classification is indistinguishable from a deliberately empty one, and the school's official return would be short by however many rows were misspelled.IMPORT_UNKNOWN_ETHNICITY / IMPORT_UNKNOWN_MOTHER_TONGUE.people-import-writer.service.spec.ts.
An invitation batch de-duplicates its ids before queueing, order preserved.requestInvites's [...new Set(userIds)].The same person twice is two invitation emails and two live tokens, the second invalidating the first — the recipient's working link would not be the one they are most likely to click.N/A — structural.None dedicated.
Export column visibility is decided once, at request time, from the requester's role.requestExport's two permissions.can(...) calls, carried on includeGatedColumns in the job payload.A worker has no session/active role; re-deriving would mean choosing a role on the operator's behalf.N/A — structural.None dedicated.
A gated column is omitted entirely, never nulled.PeopleExportReader's conditional column spread (...(includeMedical ? {...} : {})).An empty cell under a present header falsely reads as "no recorded value."N/A.build-export-file.spec.ts covers the writer, not the reader's gating.
Export downloads are re-authorized independently of the status poll.readExportFile repeats assertOwnRequest.The download URL is a guessable, shareable path; ownership must be re-checked on the bytes themselves, not trusted from an earlier call.DATA_TRANSFER_NOT_YOURS.None dedicated.
A replay always uses a fresh BullMQ job id.buildJobId(["replay", id, timestamp]) in DeadLetterService.replay.BullMQ's add() on a repeated id is a silent no-op returning the existing (failed) job.N/A — structural.dead-letter.service.spec.ts.
A dead-letter row can be replayed at most once.The conditional UPDATE ... WHERE replayed_at IS NULL, backed by job_failures_replay_is_complete.Two operators looking at the same failures screen must not both enqueue the same fix.JOB_FAILURE_ALREADY_REPLAYED.dead-letter.service.spec.ts.
A claim is released if its enqueue fails.DeadLetterService.releaseClaim, matched on both public_id and the failed call's own replay_job_id.A claim that outlives a failed enqueue would be unrecoverable, since replayed_at is exactly what the already-replayed refusal reads.JOB_FAILURE_REPLAY_ENQUEUE_FAILED.dead-letter.service.spec.ts.
A dead-letter row is addressed by public_id, never the serial id.ParseUUIDPipe on the route param, DeadLetterService querying public_id throughout.The serial id is a working enumeration of every job the platform has ever failed, to anyone holding the read permission.JOB_FAILURE_NOT_FOUND on an unrecognised publicId.dead-letter.service.spec.ts.
The failure queue cannot be read unpaginated.DeadLetterService.findAll's early pagination === false guard.Nothing prunes job_failures — a replayed row is still the record of why something never arrived, so the table only grows.PAGINATION_LIMIT_INVALID.dead-letter.service.spec.ts.
The dead-letter screen and replay only ever touch the people queue.DeadLetterService.REPLAYABLE_QUEUES hardcoded, ignored query-string override.job_failures is global across every queue; an unfiltered replay would let DataImport_UPDATE re-run a backup/restore job.JOB_FAILURE_QUEUE_NOT_PERMITTED.dead-letter.service.spec.ts.
job_failures.payload_ref never contains a full job payload.PeopleQueueProcessor.referenceOf allowlists specific keys only.A queued invite/notification payload can carry a live single-use token; storing it behind DataImport_READ would be account takeover.N/A — structural.None dedicated.
An import batch's admission/employee numbers never collide across concurrent imports.PeopleCodeService.allocate's single atomic upsert-and-increment.Two office staff admitting at the same moment reading max()+1 would both compute the same next number.Postgres unique-constraint violation, translated by PersonWriterService.translate into a mapped row issue if it somehow still occurred.None dedicated in this module.
The invite batch size is capped, at the request boundary.DataTransferService.requestInvites's MAX_INVITE_BATCH (500), duplicated deliberately in PeopleInviteProcessor.MAX_BATCH — the worker's own copy protects the worker from an unbounded hold, throwing a plain Error rather than this typed code, since the API already refuses an oversized batch before the job exists.A worker held for an unbounded time is a worker not doing anything else — the queue runs at concurrency 2.INVITE_BATCH_TOO_LARGE at the API boundary; an untyped Error (surfacing in job_failures.last_error) if the worker's own defensive check is ever the one to fire.None dedicated.

16.6 Tradeoffs, Alternatives, and ADR Notes

DecisionContextChosen OptionAlternativesWhy ChosenTradeoffsRevisit Trigger
Upload is always a dry run; commit is a separate callBulk operations are irreversible at scaleTwo explicit HTTP calls, two distinct permissions (DataImport_CREATE vs. DataImport_UPDATE)A single call that both validates and writesAn import of 2,000 pupils has no undo; a confirmation step is the only real safeguard against a mis-clicked file pickerDoubles the number of round trips; requires the file to be stored between the two callsNone — this is a deliberate, permanent safety boundary
Idempotency key is sha256(file) under scope, globally uniqueDuplicate uploads happenGlobal unique index, not per-actorPer-actor uniquenessTwo administrators uploading the same file is the ordinary failure mode, and nothing downstream (generated admission numbers) would catch a double import otherwiseA legitimate re-upload of an intentionally identical file (e.g. a corrected re-send with no content change) is impossible to distinguish from an accidental duplicate — it simply returns the earlier reportNone identified
Commit goes through the transactional outbox, not a direct queue.addAsync work must survive a crash between the write and the enqueueOutbox pattern, at-least-once delivery, idempotent handlersDirect enqueue inside the request handlerA direct enqueue and a DB write cannot be made atomic; either can succeed while the other fails, with no error surfaced anywhereDelivery is only at-least-once — every consumer must handle redelivery itself, which is more code than a naive single enqueueNone — this is the repo-wide standard (see the outbox module's own docs)
One @Processor on the queue, routing by job nameBullMQ builds one Worker per decorated classA single PeopleQueueProcessor with a Record<PeopleJob, handler> mapThree separate @Processor classes, one per job typeThree decorated classes on one queue would be three Workers racing for every job; this repo has already paid for that exact mistake on the outbox queueAdding a new PeopleJob member requires remembering to add a handler — mitigated by making the map's type exhaustive, so a missing handler is a compile errorNone — validated by the type system itself
Dead-letter storage is payload_ref only, never the payloadA failed job's payload may contain secretsAllowlisted reference fields (requestId, scope, correlationId, replayOf, a bounded sample of user ids)Storing the full job.dataA password-reset invitation payload carries a live, single-use token; storing that behind a read permission is account takeoverA replay reconstructs from the reference plus a re-read of async_requests, which works for every job type currently on this queue but would need extending for a future job whose full state is not re-derivable this wayA future PeopleJob whose payload cannot be reconstructed from requestId alone
Export download is a path on this API, never a signed storage URLExports carry sensitive personal/financial data/api/data/exports/:requestId/download, behind the same guard chainA time-limited signed URL from StorageManagerA signed URL is a bearer credential in a query string, bypassing RoleGuard for anyone it is forwarded to; it also throws under the local storage driver, which does not support signingEvery download re-runs the full auth/ownership check, which is strictly more work than validating a signature, but the module has no route where that cost mattersNone — this is a deliberate, permanent security boundary
An import's own invitation batch is queued after the commit is marked completed, not inside the commit's own transactionsendInvites is checked at upload time but the created people (and their ids) are not known until the commit worker finishes writing rowsPeopleImportProcessor.queueInvites, called after transition(... "completed" ...), outside the try/catch that would fail the importEnqueue the invite batch inside the same transaction that marks the import completedThe people already exist by the time invitations could be queued; coupling the two would mean a queueing failure could reopen or retry a commit that had already fully succeeded, re-running writes that do not need re-runningA completed import can, rarely, have no invitations queued if requestInvites itself throws — logged, not surfaced, and recoverable by inviting the same people later from the people screensNone — this is the deliberate boundary between "the import happened" and "the emails went out"
Sending invitations is gated on Users_UPDATE, not a DataImport/DataExport codeGranting sign-in access is an identity change, reachable with no spreadsheet involvedA dedicated POST /data/invites route under Users_UPDATEReuse DataImport_UPDATE, since invitations are conceptually adjacent to importingAn import permission would let anyone who can import files also grant login access to arbitrary existing people — a materially different capabilityNone material — this cleanly separates "can bulk-import records" from "can grant account access"None
Dead-letter rows are addressed by public_id, never the serial idThe old numeric-id route made the table's size and failure rate visible to anyone holding the read permissionA dedicated uuid column, added nullable/backfilled/tightened via 0004_job_failures_public_id.sqlKeep the serial id as the public identifierA sequential id is a working enumeration: /job-failures/1..n answers "how much has broken here, and when" to anyone with DataImport_READThe migration had to backfill existing rows with gen_random_uuid() (v4, not the application's v7) rather than leave them without oneNone — permanent
Replay claims the row, then enqueues, and releases the claim if the enqueue failsClaim-then-enqueue is safe against two operators racing, but a claim can outlive an enqueue that never reached RedisThe enqueue is wrapped, and a failure resets replayed_at/replayed_by/replay_job_id back to null, matched on the claim's own replay_job_idEnqueue first, then claimEnqueue-then-claim can leave a job running against a row that still reads unreplayed if the claim update then fails, letting the next operator replay it again — this repo chose the ordering whose worst case is visible (a 503) over the one whose worst case is silent (a duplicate run)The release step adds a second write path to the row, and matching on replay_job_id (not just the row id) is load-bearing — get that predicate wrong and a slow failing replay could clear a different operator's successful claimNone — validated by dead-letter.service.spec.ts's release/re-replay test
The failure queue refuses pagination=falsejob_failures only ever grows; nothing prunes itfindAll throws PAGINATION_LIMIT_INVALID rather than serving an unbounded readAllow it, like the reference-table lists elsewhere in the codebase doA queue that has had a bad week could otherwise ask the process to buffer every row and every stack trace at once in a single responseAn operator who genuinely wants every row must page through themNone — this is a deliberate exception to the reference-table default
Ethnicity and mother tongue travel as names, not ids, in both the import template and every exportBoth are integer foreign keys in the database; nobody filling in a paper-to-spreadsheet transcription has the id of "Tamang" to handMatch by name, case/whitespace-insensitively, on the way in; emit by name on the way outAccept/emit the integer idA column of ids is a column nobody filling in an office spreadsheet can check by eye, and an id-based export could not be corrected and round-tripped back through the import templateAn unrecognised name fails the row rather than importing blank — the safer failure mode, but a stricter one than silently accepting any stringNone — matches how every other name-matched lookup in this module already behaves (departments, designations)
Per-row transactions for import writes, not one transaction for the fileA 2,000-row import must be partially recoverableOne transaction per rowOne all-or-nothing transaction for the whole fileAn all-or-nothing rollback on row 1,999 gives the operator nothing, and holds code_counters locked for the whole run, blocking concurrent manual admissionsA partially-applied import is now the normal outcome of a bad row, not an edge case — the report must be trusted as the source of what actually happened, since "did the import succeed" is no longer a single yes/noNone — this is the deliberate design
Row numbers in every reported issue are the operator's own spreadsheet line, header includedReports must be actionable without translationdataIndex + 2 throughout parsePeopleFileReporting a 0- or 1-based index into the parsed row arrayAn operator fixing "row 4" in their own spreadsheet must land on the same row the system meansNone meaningfulNone

16.7 Operational Runbook

OperationHow to InspectHealthy StateFailure SignalRecovery
Import/export request statusSELECT * FROM async_requests WHERE request_id = '...'Progresses pending -> committing (import only) -> completed, or lands on failed with error populatedStuck at committing/pending far longer than the file size would justifyCheck the outbox row for the same aggregate_id; if it is dead, the outbox itself failed to enqueue (see the outbox module's runbook) — if it is dispatched, check job_failures for a terminal worker-side failure
Worker healthBullMQ / Bull Board on QueueName.PEOPLEJobs completing at concurrency 2; queue depth not growing unboundedlyJobs piling up, or repeatedly failing and retryingCheck job_failures for the specific job_name; check whether DataTransferWorkerModule is actually composed into the running worker process
Dead lettersGET /data/job-failures (or SELECT * FROM job_failures WHERE queue_name = 'people' ORDER BY failed_at DESC)Empty, or only old/already-replayed rowsNew unreplayed rows accumulatingInspect last_error; if transient (timeout, storage hiccup), replay via POST /data/job-failures/:publicId/replay; if a data problem (bad row), the underlying async_requests report already names the row — fix the source file and re-upload/re-request instead of replaying
A replay returns 503 JOB_FAILURE_REPLAY_ENQUEUE_FAILEDRe-run SELECT * FROM job_failures WHERE public_id = '...'replayed_at/replayed_by/replay_job_id are back to null — the claim was releasedThe row still reads unreplayed immediately after an operator clicked ReplayThis means Redis/BullMQ was unreachable at that moment, not that the replay logic is broken; check queue connectivity, then replay the same row again
Stuck import (never reaches completed/failed)Compare async_requests.status = 'committing' against job_failures and the BullMQ job for that request_id's outbox_${id} job idThe worker should have picked it up within one dispatcher cycleNo matching BullMQ job at allCheck the outbox dispatcher's own health first — if the outbox row never dispatched, this module's worker was never given the job
Storage for a completed exportStorageManager / underlying driver for the storageKey in async_requests.result.storageKeyFile readable, GET /data/exports/:requestId/download returns bytesDATA_TRANSFER_FILE_UNREADABLE on download despite status: completedThe stored file was deleted or moved out from under a completed row; the export must be re-requested — there is no route that regenerates from an existing completed row

16.8 Backend Risk Register

RiskAreaImpactCurrent MitigationRemaining Gap
Two administrators intentionally re-uploading an identical, correct fileImport idempotencyThe second upload silently returns the first (possibly stale) report rather than a fresh onealreadyExisted: true is surfaced in the response messageNo way to force a fresh dry run of byte-identical content short of changing the file
PeopleInviteProcessor's own MAX_BATCH guard still throws an untyped ErrorError handling consistencyIf it were ever reached, a batch-size violation there would surface as an uncoded message in job_failures.last_error rather than a client-parseable codeDataTransferService.requestInvites already refuses an oversized batch with INVITE_BATCH_TOO_LARGE before this job is ever created, so the worker's own check is a defensive backstop, not the normal pathAny future producer of SEND_INVITE_BATCH that bypasses requestInvites would hit the uncoded path
Export row cap (20,000) is a silent truncationData completenessAn operator exporting a school with more than 20,000 matching records gets an incomplete file with no indication it was cut offrowCount is reported on the completed request, so the true count is visible if checkedNothing in the response body flags "truncated" explicitly; it must be inferred by comparing rowCount to an independent expectation
No dedicated tests for the controller, DataTransferService, DataTransferRequestService, or three of the four workersTest coverageRegressions in commit/idempotency/ownership logic would only be caught by the pure-function tests, the two service-level integration specs that do exist, or by manual/e2e testing elsewheredead-letter.service.spec.ts and people-import-writer.service.spec.ts cover the claim/release and ethnicity/mother-tongue/grantLogin logic against real Postgres; the pure helpers (parsing, templates, export formatting) are well coveredTransaction boundaries, status transitions, and ownership checks in DataTransferService, DataTransferRequestService, PeopleExportReader, PeopleImportProcessor, PeopleExportProcessor, and PeopleInviteProcessor have no automated regression coverage in this module

17. Zero-Omission Backend Checklist

  • Every file in the module directory is represented or explicitly marked non-runtime.
  • Every controller, service, provider, processor, scheduler, helper, mapper, DTO, enum, and schema is documented.
  • Every method with business behavior has a code-flow narrative (the highest-stakes one in full; the rest via the runtime-flow tables in §7).
  • Every table/collection/cache object/job payload has field-level detail.
  • Every index, constraint, relation, and delete behavior has rationale.
  • Every lifecycle/status transition has a state diagram and transition table (see §7.1-7.3 sequence diagrams and their step tables; async_requests.status has no separate state-machine diagram beyond the transition tables since it is a linear, non-branching progression per flow).
  • Every read/write/action/job flow has sequence and activity diagrams.
  • Every business invariant is cataloged.
  • Every cache key (none exist), queue job, realtime event (none exist), and external call is documented.
  • Every architectural tradeoff is documented with alternatives and revisit triggers.
  • Every operational failure mode has a runbook entry.

18. Backend Completion Checklist

  • Module boundaries are documented.
  • Every controller, service, DTO, schema file, job, cache key, and event is covered.
  • Every database table/collection has a field table and relationship diagram.
  • Every runtime flow has a diagram and branch notes.
  • API, feature/flows, and TDD docs are linked.
  • No claim is made without a source file or documented source reference.

See Also