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
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/data-transfer/data-transfer.module.ts, data-transfer-worker.module.ts | Imports, providers, exports, why the workers are a separate module. |
| Controller | apps/api/src/modules/data-transfer/data-transfer.controller.ts | Route ownership, guards, permissions. |
| Services | data-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.ts | Business logic, transactions, idempotency, error mapping. |
| Workers | workers/people-queue.processor.ts, workers/people-import.processor.ts, workers/people-export.processor.ts, workers/people-invite.processor.ts | Queue registration, handler dispatch, dead-letter recording. |
| Pure helpers | apps/api/src/utils/data-transfer/data-transfer.types.ts, templates.ts, parse-people-file.ts, build-export-file.ts | Template columns, parsing rules, formula-injection guarding. |
| DTOs | apps/api/src/modules/data-transfer/dto/data-transfer.dto.ts | Request/response shapes and validation. |
| Schema | packages/db/src/schema/async-request/async-requests.ts, packages/db/src/schema/jobs/job-failures.ts, packages/db/src/schema/outbox/outbox-events.ts | Columns, indexes, constraints. |
| Migration | packages/db/src/migrations/0004_job_failures_public_id.sql | How job_failures.public_id was added to a table that already has rows in every environment. |
| Queue contract | packages/jobs/src/index.ts (QueueName.PEOPLE, PeopleJob, payload types), packages/jobs/src/build-job-id.ts | Job names, payload shapes, id rules. |
| Outbox | apps/api/src/modules/outbox/shared/outbox.service.ts, apps/api/src/modules/outbox/workers/outbox-dispatcher.processor.ts | Transactional-outbox contract and the real BullMQ jobId scheme. |
| Uploads | apps/api/src/common/utils/multer.util.ts, packages/storage/src/upload-allowlist.ts | Upload size cap, extension allowlist. |
| Codes | apps/api/src/modules/people/shared/people-code.service.ts | Admission/employee number allocation. |
| Permissions | packages/db/src/authorization/permission-catalog.ts | DataImport, DataExport modules and their actions. |
| Errors | apps/api/src/common/types/error-codes.ts | The 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_requests—data-transfer.service.ts,shared/data-transfer-request.service.ts. - Writing validated rows into
students,guardians,staffand their linkedusersrows, 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 callsqueue.add()directly for the commit or export flows. - File storage mechanics (disk vs. remote driver, signed-URL support) — owned by
@skoolsewa/storage'sStorageManager. - Sending the actual invitation email and issuing its token — owned by
AuthEmailServiceandVerificationTokenService(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 anaccount_invitevalid forACCOUNT_INVITE_TTL_MS(7 days), redeemed atPOST /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
| Concern | Source of Truth | Notes |
|---|---|---|
| Whether an import/export has run, and its report | async_requests row | The 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 write | The stored uploaded file, re-read at commit time | Never 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 contain | PeopleExportBuildPayload.includeGatedColumns, decided at request time | The 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 running | job_failures | Distinct 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 database | public_id (uuid), never the serial id | id 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 valid | job_failures.replayed_at | Written 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 ownership | async_requests.actor_id, checked against the caller, with a superadmin bypass | data-transfer.service.ts:433-443. |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
DataTransferModule | Leaf (HTTP) | apps/api/src/modules/data-transfer/data-transfer.module.ts | DataTransferController | DataTransferService, DataTransferRequestService, DeadLetterService, PeopleImportWriter, PeopleExportReader | Same five providers | The request surface: templates, upload, commit, export request/status/download, dead-letter list/replay. |
DataTransferWorkerModule | Leaf (worker) | apps/api/src/modules/data-transfer/data-transfer-worker.module.ts | None | PeopleQueueProcessor, PeopleImportProcessor, PeopleExportProcessor, PeopleInviteProcessor | None | The 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| File | Purpose | Key Exports | Notes |
|---|---|---|---|
data-transfer.controller.ts | Every HTTP route for import, export, templates, dead letters. | DataTransferController | Mounted at /data, not per-entity, so one bulk operation is one surface (data-transfer.controller.ts:46-53). |
data-transfer.service.ts | Upload dry-run, commit, export request/status/download, file staging. | DataTransferService, STORAGE_KEY_RESULT_FIELD | STORAGE_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.ts | The async_requests row: create, create-or-get by file hash, find, conditional transition. | DataTransferRequestService, TransferRecord, TransferStatus | fingerprint() is sha256(bytes), static, used as the idempotency key. |
shared/dead-letter.service.ts | job_failures list, replay under a fresh job id (releasing it again if the enqueue fails), record on terminal failure. | DeadLetterService | REPLAYABLE_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.ts | Turns parsed rows into users + scope-specific rows, one transaction per row. | PeopleImportWriter, ImportOutcome | Also 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.ts | Role-scoped SELECT per scope, capped at 20,000 rows. | PeopleExportReader, ExportColumn | Adds/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.ts | The one @Processor(QueueName.PEOPLE); routes by job.name; records terminal failures. | PeopleQueueProcessor | handlers is Record<PeopleJob, …> — a compile error if a PeopleJob member has no handler. |
workers/people-import.processor.ts | Applies IMPORT_COMMIT. | PeopleImportProcessor | First 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.ts | Applies EXPORT_BUILD. | PeopleExportProcessor | Same duplicate-delivery guard, keyed on status !== "pending". |
workers/people-invite.processor.ts | Applies SEND_INVITE_BATCH. | PeopleInviteProcessor | Enqueued both directly (POST /data/invites) and automatically from a completed import — see §9. |
utils/data-transfer/templates.ts | The three template column lists and the workbook builder. | getTemplate, buildTemplateWorkbook | Carries 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.ts | CSV/XLSX buffer to ParseResult. | parsePeopleFile | Row numbers are the operator's own spreadsheet line numbers. |
utils/data-transfer/build-export-file.ts | Rows to a CSV/XLSX buffer. | buildExportFile | Escapes 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.
| Column | Type | Nullable | Default | Index/Constraint | Notes |
|---|---|---|---|---|---|
id | serial | No | generated | PK | Internal only; never returned. |
request_id | varchar(100) | No | — | UNIQUE | The public id every route uses (a UUIDv7, generated in DataTransferRequestService.createOrGet/create). |
actor_id | uuid | No | — | indexed (async_requests_actor_id_idx) | The uploader/requester; checked in assertOwnRequest. |
scope | varchar(50) | No | — | part of async_requests_idempotency_idx | import: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_key | varchar(200) | No | — | part of async_requests_idempotency_idx | For imports: sha256(file bytes). For exports: the request's own request_id (self-idempotent — never deduplicated). |
status | varchar(20) | No | 'pending' | indexed (async_requests_status_idx, with created_at) | pending | committing | completed | failed. Exports never pass through committing — see §7. |
result | jsonb | Yes | — | — | Holds different shapes for import vs. export; see below. |
error | text | Yes | — | — | Set on failed, truncated to 2000 characters before storage. |
created_at | timestamptz | No | now() | — | |
completed_at | timestamptz | Yes | — | — | Set 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/Constraint | Columns | Type | Query/Invariant Supported | Tradeoff |
|---|---|---|---|---|
async_requests_idempotency_idx | scope, idempotency_key | unique btree | The 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_idx | actor_id | btree | "my requests" style lookups (not currently exposed by a list route, but available). | Extra write on every insert. |
async_requests_status_idx | status, created_at | btree | Operational queries filtering by status. | Extra write on every status transition. |
async_requests_request_id_idx | request_id | btree | Every 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.
| Column | Type | Nullable | Default | Index/Constraint | Notes |
|---|---|---|---|---|---|
id | serial | No | generated | PK | Internal only — used for indexes and joins, never returned by an API response. |
public_id | uuid | No | uuid7() (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_name | varchar(50) | No | — | part of job_failures_queue_failed_at_idx | Global across every queue in the system, not only people — the service filters this at read/replay time. |
job_name | varchar(100) | No | — | — | The PeopleJob value, e.g. people.import_commit. |
job_id | varchar(200) | Yes | — | — | BullMQ's own id, for finding the job in Bull Board. |
payload_ref | jsonb (Record<string,string>) | Yes | — | — | A 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_id | uuid | Yes | — | indexed (job_failures_actor_id_idx) | Who asked for the work, when known. |
attempts | integer | No | — | CHECK > 0 (job_failures_attempts_positive) | Attempts made when this row was written; always the final attempt. |
last_error | text | Yes | — | — | Truncated to 4000 characters before insert (dead-letter.service.ts:221). |
replayed_at | timestamptz | Yes | — | CHECK paired with replay_job_id (job_failures_replay_is_complete) | Set atomically with replay_job_id by a conditional UPDATE. |
replayed_by | uuid | Yes | — | — | The operator who replayed it. |
replay_job_id | varchar(200) | Yes | — | paired via job_failures_replay_is_complete | The fresh BullMQ job id the replay was enqueued under. |
failed_at | timestamptz | No | now() | part of job_failures_queue_failed_at_idx |
Constraints and index rationale
| Index/Constraint | Columns | Type | Query/Invariant Supported | Tradeoff |
|---|---|---|---|---|
job_failures_public_id_unique | public_id | UNIQUE btree | The 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_positive | attempts | CHECK > 0 | A 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_complete | replayed_at, replay_job_id | CHECK (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_idx | queue_name, failed_at | btree | The list screen's default sort (newest first, per queue). | Write cost per insert. |
job_failures_actor_id_idx | actor_id | btree | Finding every failure caused by one operator's requests. | Write cost per insert. |
job_failures_unreplayed_idx | failed_at | partial btree, WHERE replayed_at IS NULL | The 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:
| Column | Value data-transfer writes |
|---|---|
aggregate_type | "people_import" or "people_export" |
aggregate_id | the async_requests.request_id |
event_type | "people.import_committed" or "people.export_requested" |
target_queue | QueueName.PEOPLE |
job_name | PeopleJob.IMPORT_COMMIT or PeopleJob.EXPORT_BUILD |
payload | the PeopleImportCommitPayload / PeopleExportBuildPayload shape |
dedupe_key | import-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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
buildTemplate(scope, format) | Controller (GET /data/templates/:scope) | Nothing (pure, delegates to buildTemplateWorkbook) | Nothing | None | None |
uploadForDryRun(actor, file, dto) | Controller (POST /data/imports) | Uploaded file bytes | async_requests (insert-or-get, then an update to attach the storage key) | Stores the file via StorageManager.handleUpload | DATA_TRANSFER_FILE_REQUIRED, IMPORT_FILE_TOO_LARGE, DATA_TRANSFER_FILE_UNREADABLE, DATA_TRANSFER_REQUEST_CONFLICT |
getImport(actor, requestId) | Controller (GET /data/imports/:requestId) | async_requests | None | None | DATA_TRANSFER_REQUEST_NOT_FOUND, DATA_TRANSFER_NOT_YOURS |
commitImport(actor, requestId) | Controller (POST /data/imports/:requestId/commit) | async_requests | async_requests status → committing, outbox_events insert — same transaction | Nothing until the worker runs | DATA_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 500 | Nothing | async_requests insert (scope invite:batch), outbox_events insert — same transaction | None until the worker runs | VALIDATION_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 transaction | None until the worker runs | None thrown directly |
getExport(actor, requestId) | Controller (GET /data/exports/:requestId) | async_requests | None | None | DATA_TRANSFER_REQUEST_NOT_FOUND, DATA_TRANSFER_NOT_YOURS |
readExportFile(actor, requestId) | Controller (GET /data/exports/:requestId/download) | async_requests, StorageManager.getFile | None | Reads bytes off storage | DATA_TRANSFER_REQUEST_NOT_FOUND, DATA_TRANSFER_NOT_YOURS, DATA_TRANSFER_EXPORT_NOT_READY |
storeGeneratedFile(buffer, filename, mimetype, category) | PeopleExportProcessor | Nothing | Stages a temp file, uploads via StorageManager | Deletes the local temp copy only when the upload produced a remote key (data-transfer.service.ts:551-553) | DATA_TRANSFER_FILE_UNREADABLE |
readStoredFile(storageKey) | PeopleImportProcessor | StorageManager.getFile | None | None | Whatever StorageManager throws |
Explain:
- Input normalization. None of the upload path is normalized before parsing —
parsePeopleFiledoes all cell-level trimming; the service only checks size and readability. - Validation order in
commitImport: existence → ownership → status (pendingonly) → 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, andrequestInviteseach wrap theirasync_requeststransition/insert and theiroutbox.enqueuecall in onethis.db.transaction(...)block — that pairing is the whole point of the outbox pattern (see §9).requestInvitesis called both from the controller and fromPeopleImportProcessor, so this atomicity applies identically whether the batch was asked for directly or chained off a completed import. - Idempotency.
uploadForDryRunis idempotent on file content viaDataTransferRequestService.createOrGet;commitImport's transition is idempotent via thefrom: "pending"guard (a second click finds the status alreadycommittingand is refused with a 409, not silently re-run).requestInvitesis not idempotent — it de-duplicates ids within one call, but two calls with the sameuserIdsqueue two independent batches, sinceDataTransferRequestService.creategives every invite request its own self-uniqueidempotencyKey. - 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.errorinuploadForDryRunwhen the uploaded file cannot be read from disk.
6.2 DataTransferRequestService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
fingerprint(bytes) (static) | DataTransferService.uploadForDryRun | Nothing | Nothing | None | None |
createOrGet(input) | uploadForDryRun | async_requests (on conflict) | async_requests insert, ON CONFLICT DO NOTHING on (scope, idempotency_key) | None | DATA_TRANSFER_REQUEST_CONFLICT (only if the conflicting row vanished between the insert and the re-read — another transaction rolled back concurrently) |
create(input) | requestExport | Nothing | async_requests insert, idempotencyKey = requestId (self-unique, so exports are never deduplicated on content) | None | None |
findByRequestId(requestId, executor?) | Every read path, and both processors | async_requests | None | None | DATA_TRANSFER_REQUEST_NOT_FOUND |
transition(requestId, from, to, patch, executor?) | commitImport, both processors, fail() helpers | async_requests (via the WHERE status IN (from) clause) | async_requests (status, optionally result/error/completed_at) | None | Never throws; returns boolean — false means nothing moved |
Explain:
- Why
transitionreturns a boolean instead of throwing. Thefromguard is a conditionalUPDATE ... 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 incommitImport). Making this a return value rather than an exception is what lets one primitive serve both call sites correctly. - Transaction boundaries.
transitionandfindByRequestIdboth accept an optionalexecutorso they can run inside a caller's transaction —commitImportpasses itstxin; the read-only paths use the default (this.db).
6.3 DeadLetterService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
findAll(query) | Controller (GET /data/job-failures) | job_failures | None | None | PAGINATION_LIMIT_INVALID (pagination: false) |
replay(publicId, actorId, attempts) | Controller (POST /data/job-failures/:publicId/replay) | job_failures | job_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 only | None | job_failures (replayed_at/replayed_by/replay_job_id reset to null) — matched on both public_id and the failed call's own replay_job_id | None | Logs and swallows its own failure; never throws |
record(input, executor?) | PeopleQueueProcessor.onFailed | None | job_failures insert | None | Never throws to the caller in practice (the caller wraps it in a try/catch) |
Explain:
- Rows are addressed by
public_id, never the serialid.idstays 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.
findAllrefusespagination: falseoutright withPAGINATION_LIMIT_INVALID(dead-letter.service.ts:71-76) — nothing prunesjob_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 unrecognisedqueueNamequery parameter and falls back to the one permitted queue rather than trusting the caller (dead-letter.service.ts:296-305).job_failuresis 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 onlyDataImport_READ/DataImport_UPDATEsee 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'sadd()treats a repeated existingjobIdas 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 NULLruns beforequeue.add. The alternative order can leave a job running against a row that still reads unreplayed if theUPDATEthen 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 sincereplayed_atis 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'stry/catcharoundqueue.addcallsreleaseClaimon failure, resettingreplayed_at/replayed_by/replay_job_idback tonulland returning503 JOB_FAILURE_REPLAY_ENQUEUE_FAILEDinstead of a false success.releaseClaimmatches on the failed call's ownreplay_job_idas 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). recordis best-effort from the caller's side. It is called from inside atry/catchinPeopleQueueProcessor.onFailed, because afailedhandler 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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
apply(scope, rows, actorId, grantLogin?) | PeopleImportProcessor | code_counters (via PeopleCodeService.allocate), department/designation lookups, ethnicity/mother-tongue name lookups, guardian-by-phone lookup | users, and one of students/guardians/staff, plus student_guardian for a student row with guardian info — one transaction per row | None 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 oncode_countersfor 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 toundefinedbefore it reaches an insert (people-import-writer.service.ts:107-122). Passing''straight through made every sparsely-filled row fail with an unmapped Postgres22P02on 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 forPermanent Province/District/Municipality/Ward/Tole, five more for theCurrentequivalents. 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 onlower(btrim(name)), refusing the row withIMPORT_UNKNOWN_PLACErather than guessing. Because two different provinces can each have a district of the same name,Permanent Districtis looked up withinPermanent Province, not by name alone — the same reasoningPermanent Municipalityapplies againstPermanent 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 bare23514from the corresponding CHECK — which still exists as the authority and is mapped again byaddressConstraintIssuein 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/Toleare only filled when they genuinely differ from the permanent address — leaving the wholeCurrentgroup 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 NameandGuardian Phoneare blank, the row fails withIMPORT_STUDENT_REQUIRES_GUARDIANnaming theGuardian Namecolumn — a second write path enforcing the same "a pupil must have at least one guardian" rule the people module's API enforces withSTUDENT_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.
findOrCreateGuardianonly 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 thrownImportRowError, 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
basicSalaryorallowancesis present, the other defaults to"0"rather than stayingnull— thestaff_salary_pair_coherentcheck (owned by the people/staff schema) requires both or neither, and defaulting to an explicit zero is a real value, unlike a silentNULLthat would also nulltotal_salary(people-import-writer.service.ts:331-340). - Error translation never leaks a raw database error to the report.
toIssue()triesImportRowErrorfirst, thenPersonWriterService.translate()(the shared typed-error translator), and only falls back to a genericIMPORT_ROW_WRITE_FAILEDmessage — 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, aterrorlevel. - Ethnicity and mother tongue arrive as names and refuse rather than guess.
resolveClassificationmatches the spreadsheet cell againstethnicities/motherTonguesonlower(btrim(name))— the same normalisation the tables' own unique index uses — and throwsIMPORT_UNKNOWN_ETHNICITY/IMPORT_UNKNOWN_MOTHER_TONGUEfor a name that does not match, rather than writingnull(people-import-writer.service.ts:454-479). Both columns areON 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_loginon every row this run creates follows the upload's ownsendInviteschoice, never a per-row value.apply'sgrantLoginparameter (defaultfalse) is threaded into everybuildInsertcall — 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).imeisIdon 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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
read(scope, filters, gates) | PeopleExportProcessor | students+users (+ student_guardian/guardians for the primary guardian), or guardians+users (+ a student_guardian count subquery), or staff+users+departments+designations, depending on scope | None | None | None thrown directly |
Explain:
- Gated columns are added or removed as whole columns, never nulled. A caller without
StudentMedical_READ/StaffSalary_READgets 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(fromStaffSalary_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 onlyStaff_READhas 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 SQLLIMIT, not surfaced as a separate error — an export simply truncates at that many rows. An unboundedSELECTmaterialised 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_primaryonstudent_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 andusers), consistently across all three scopes. - Filters are a fixed, typed allowlist per scope, never an arbitrary passthrough of the caller's filter object —
recordStatus/genderfor students,kindfor guardians,employmentStatus/departmentIdfor 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
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | DataTransferController.uploadImport | Receives 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. |
| 2 | DataTransferService.uploadForDryRun | Re-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. |
| 3 | parsePeopleFile | Validates headers and every cell against the scope's template. | Returns issues rather than throwing; a missing required header instead returns rows: [] with missingHeaders populated. |
| 4 | DataTransferRequestService.createOrGet | Inserts 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. |
| 5 | DataTransferService.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
| Branch | Condition | Behavior | Result |
|---|---|---|---|
Nothing writes to students/guardians/staff | Always, on this route | The upload is always a dry run. | The response says so (dryRun: true), and no person row is ever created here. |
| Same file uploaded twice | Identical sha256 under the same scope | The 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 scope | Same bytes, scope=guardians vs. scope=staff | The idempotency key is (scope, hash), so this is a distinct request. | A second, independent dry run. |
| Required headers missing | e.g. no "Admission Date" column | Parsing 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 headers | Parsing 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 file | A header the template does not recognise | Reported, not fatal. | Listed in unknownHeaders; the operator's own working columns do not block the import. |
7.2 Commit an import
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | commitImport | Existence, ownership, status === "pending". | DATA_TRANSFER_REQUEST_NOT_FOUND, DATA_TRANSFER_NOT_YOURS, DATA_TRANSFER_NOT_COMMITTABLE (already committing/completed/failed). |
| 2 | commitImport | Re-checks the stored report: no missing headers, at least one valid row. | IMPORT_MISSING_HEADERS, DATA_TRANSFER_HAS_BLOCKING_ISSUES. |
| 3 | commitImport, inside db.transaction | Conditional transition pending -> committing, then outbox.enqueue — one 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. |
| 4 | PeopleImportProcessor.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. |
| 5 | PeopleImportProcessor.process | Re-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. |
| 6 | PeopleImportWriter.apply | Writes 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. |
| 7 | transition(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. |
| 8 | PeopleImportProcessor.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
| Branch | Condition | Behavior | Result |
|---|---|---|---|
| Double-click commit | Two requests to commit the same requestId in quick succession | The 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-delivery | At-least-once delivery redelivers IMPORT_COMMIT | The 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 commit | Operational/manual deletion | readStoredFile 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 commit | The 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 invalid | Always, on commit | Never attempted at write time at all. | Prevents a confusing second, vaguer failure message for the same row. |
| An unrecognised ethnicity or mother-tongue name | A cell that does not match any name in the school's own list, case/whitespace-insensitively | The 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 fails | requestInvites throws for one chunk after the import is already completed | The 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 holds | Over 500 rows created with sendInvites: true | queueInvites 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
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | requestExport | Decides 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. |
| 2 | requestExport, inside db.transaction | Creates the async_requests row and the outbox row together. | If the transaction fails, neither exists — no orphaned request with nothing scheduled. |
| 3 | PeopleExportProcessor.process | Proceeds only if status === "pending". | Duplicate delivery: logged no-op. |
| 4 | PeopleExportReader.read | Runs the scope-specific, role-scoped SELECT, capped at 20,000 rows. | No error path — a too-large result set is silently truncated. |
| 5 | buildExportFile + storeGeneratedFile | Builds 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. |
| 6 | transition(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. |
| 7 | getExport | Computes downloadUrl only when status === "completed" and a storageKey is present. | Otherwise downloadUrl: null — the caller is expected to keep polling. |
| 8 | readExportFile | Re-checks ownership and status independently of the status call. | DATA_TRANSFER_EXPORT_NOT_READY if not completed, or already failed. |
Branches and Edge Cases
| Branch | Condition | Behavior | Result |
|---|---|---|---|
| Download URL is a path on this API, not a signed storage URL | Always | downloadUrl 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 permission | e.g. no StaffSalary_READ | The 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 twice | Two POST /data/exports calls with the same scope/filters | Each 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 finishes | status still pending | readExportFile 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 processor | Request 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
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | PeopleQueueProcessor.onFailed (@OnWorkerEvent("failed")) | Fires on every attempt. | — |
| 2 | onFailed | Compares 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. |
| 3 | onFailed, final attempt only | Calls 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. |
| 4 | referenceOf(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
| Branch | Condition | Behavior | Result |
|---|---|---|---|
| Failure belongs to a non-people queue | queue_name not in REPLAYABLE_QUEUES | Refused with 409, not 404 | JOB_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 once | Concurrent POST .../replay | The 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 succeeds | Redis unreachable, or the target queue is unresolvable | The 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 id | Never happens | N/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 reference | Always | queue.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.
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | DataTransferController.sendInvites | Gated on Users_UPDATE, not an import/export permission — granting sign-in access is an identity change reachable without a spreadsheet. | Missing permission -> 403. |
| 2 | requestInvites | De-duplicates userIds, order-preserving; rejects an empty or oversized batch before writing anything. | VALIDATION_FAILED (empty), INVITE_BATCH_TOO_LARGE (over 500). |
| 3 | requestInvites, inside db.transaction | Inserts the async_requests row and the outbox row together. | If the transaction fails, neither exists. |
| 4 | PeopleInviteProcessor.process | Proceeds 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. |
| 5 | PeopleInviteProcessor.process | Mints 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. |
| 6 | transition(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 Pattern | Builder | Value | TTL | Invalidation | Caller |
|---|---|---|---|---|---|
| N/A | — | — | — | — | — |
9. BullMQ, Schedulers, and Async Work
| Queue | Job | Producer | Processor | Payload | Retry/Backoff | Idempotency |
|---|---|---|---|---|---|---|
QueueName.PEOPLE ("people") | PeopleJob.IMPORT_COMMIT ("people.import_commit") | DataTransferService.commitImport, via OutboxService.enqueue | PeopleImportProcessor (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.PEOPLE | PeopleJob.EXPORT_BUILD ("people.export_build") | DataTransferService.requestExport, via OutboxService.enqueue | PeopleExportProcessor | PeopleExportBuildPayload { correlationId, requestId, scope, actorId, filters, includeGatedColumns } | Same as above. | Conditional status transition (pending only). |
QueueName.PEOPLE | PeopleJob.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 person | PeopleInviteProcessor | PeopleSendInviteBatchPayload { 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
@ProcessoronQueueName.PEOPLE, routing by job name — not three.@nestjs/bullmqbuilds one BullMQWorkerper class decorated with@Processor;WorkerOptions.nameis a monitoring label, not a filter. Three decorated processors on this queue would be threeWorkerinstances racing for every job on it, and a handler that does not checkjob.namewill happily run against the wrong payload shape.PeopleQueueProcessor.handlersis typedRecord<PeopleJob, (job: Job) => Promise<unknown>>, so adding a member toPeopleJobwithout 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
jobIdthe dispatcher actually uses isoutbox_<outbox row id>(outbox-dispatcher.processor.ts:54,121), not thededupeKeystring this module supplies (import-commit-${requestId}/export-build-${record.requestId}). ThededupeKeyonly makes theoutbox_eventsinsert 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_BATCHhas two producers and one handler.requestInvitesis the single call bothDataTransferController.sendInvitesandPeopleImportProcessormake — 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 tojob_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
DataTransferControllersits 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), andUsers_UPDATE(send invitations).DataImport_DELETE/_RESTOREandDataExport_UPDATE/_DELETE/_RESTOREexist 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
Userspermission, not aDataImport/DataExportone.POST /data/invitesrequiresUsers_UPDATEbecause 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 serialid. 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_idis a uuid, generated per row, and the serialidnever appears in a response. - The failure queue refuses to be read unpaginated.
pagination=falseonGET /data/job-failuresis rejected withPAGINATION_LIMIT_INVALID— the table only ever grows, and nothing prunes it. - Ownership, not just authentication.
assertOwnRequestadditionally requiresrecord.actorId === actor.id, withactor.activeRole?.isSuperadminas 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.
spreadsheetMulterOptionsrejects a non-.csv/.xlsxextension and anything over 10 MB before the controller method runs;DataTransferService.uploadForDryRunindependently 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 insiderequestExport, 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_refnever carries a full job payload — see §5.2 and §16.5. This is the load-bearing control that keepsDataImport_READfrom 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
RoleGuardfor 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.processthrows for anyjob.namenot in itshandlersmap, deliberately, so BullMQ records the failure rather than the job silently completing.
13. Error Handling
| Error Code | HTTP Status | Thrown By | Condition | Client Action |
|---|---|---|---|---|
DATA_TRANSFER_REQUEST_NOT_FOUND | 404 | DataTransferRequestService.findByRequestId | No async_requests row for the given requestId. | Check the id; it may have been mistyped or the request never existed. |
DATA_TRANSFER_REQUEST_CONFLICT | 409 | DataTransferRequestService.createOrGet | The idempotency-key insert conflicted and the conflicting row could not be re-read (concurrent rollback). | Retry the upload. |
DATA_TRANSFER_NOT_COMMITTABLE | 409 | DataTransferService.commitImport | Status 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_ISSUES | 409 | DataTransferService.commitImport | validRows === 0 — every row in the file failed validation. | Fix the reported issues and re-upload. |
DATA_TRANSFER_NOT_YOURS | 403 | DataTransferService.assertOwnRequest | Caller is neither the original actor nor a superadmin. | Ask the original uploader, or escalate to a superadmin. |
DATA_TRANSFER_FILE_REQUIRED | 400 | DataTransferService.uploadForDryRun | No file present on the multipart request. | Attach a file. |
DATA_TRANSFER_FILE_UNREADABLE | 400 | DataTransferService.uploadForDryRun, storeImportFile, storeGeneratedFile | The 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_READY | 409 | DataTransferService.readExportFile | Status 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) | parsePeopleFile | A required column's cell is blank. | Fill in the cell and re-upload. |
IMPORT_INVALID_ENUM_VALUE | (row-level) | parsePeopleFile | A 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) | parsePeopleFile | A date column's value is not a valid YYYY-MM-DD date. | Correct the date format. |
IMPORT_INVALID_NUMBER | (row-level) | parsePeopleFile | A numeric column's value is not a non-negative number. | Correct the value. |
IMPORT_FILE_TOO_LARGE | 400 (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_HEADERS | 409 | DataTransferService.commitImport | The 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.toIssue | A 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.resolveDepartment | The 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.resolveDepartment | The named designation does not exist under the given department. | Correct the designation name. |
IMPORT_UNKNOWN_ETHNICITY | (row-level) | PeopleImportWriter.resolveClassification | The 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.resolveClassification | The named mother tongue does not match any entry in the school's list. | Add it to the list first, or correct the spelling. |
VALIDATION_FAILED | 400 | DataTransferService.requestInvites | userIds is empty after de-duplication. | Choose at least one person to invite. |
INVITE_BATCH_TOO_LARGE | 400 | DataTransferService.requestInvites | More than 500 userIds (after de-duplication) on POST /data/invites. | Split the batch across multiple requests. |
JOB_FAILURE_ALREADY_REPLAYED | 409 | DeadLetterService.replay | replayed_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_FOUND | 404 | DeadLetterService.replay | No job_failures row for the given public_id. | Check the id. |
JOB_FAILURE_QUEUE_NOT_PERMITTED | 409 | DeadLetterService.replay | The 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_FAILED | 503 | DeadLetterService.replay | The 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_INVALID | 400 | DeadLetterService.findAll | pagination: false was requested against job_failures, a table that only ever grows. | Page through the results instead. |
14. Observability
| Signal | Location | Purpose |
|---|---|---|
| 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.apply | One line per import run: [import] {scope}: {created} created, {issues.length} failed, requested by {actorId}. |
| Log (error) | PeopleImportWriter.toIssue | Every 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.process | Same duplicate-delivery log; a completion summary with row count and format. |
| Log (info/error) | PeopleInviteProcessor.process | Per-batch summary: sent / no-address / not-invitable / failed counts. |
| Log (warn/error) | PeopleQueueProcessor.onFailed | Warn on a non-final failed attempt; error with stack on the final attempt. |
| Log (info) | DeadLetterService.replay | One 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. |
| Audit | None dedicated | Ownership 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 Type | Files | Coverage |
|---|---|---|
| Unit | apps/api/src/utils/data-transfer/parse-people-file.spec.ts | Header matching, row numbering, required/enum/date/numeric validation, BOM and zip-signature handling. |
| Unit | apps/api/src/utils/data-transfer/templates.spec.ts | Template 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. |
| Unit | apps/api/src/utils/data-transfer/build-export-file.spec.ts | Formula-injection escaping, date formatting, header/column ordering. |
| Integration (real Postgres) | apps/api/src/modules/data-transfer/shared/dead-letter.service.spec.ts | 8 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.ts | 8 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. |
| Manual | pnpm 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
| Unit | Type | Owns | Depends On | Called By | Calls | State Touched | Failure Modes |
|---|---|---|---|---|---|---|---|
DataTransferController | Controller | Every /data/* route | DataTransferService, DeadLetterService | HTTP clients | Both services above | None directly | Guard/permission rejection, DTO validation error |
DataTransferService | Service | Template building, upload dry-run, commit, export request/status/download, file staging | DataTransferRequestService, OutboxService, StorageManager, PeoplePermissionsService, DATABASE | Controller, both processors (readStoredFile, storeGeneratedFile) | Storage, DB, outbox | async_requests, outbox_events (via outbox), storage files | See §13 |
DataTransferRequestService | Service | async_requests lifecycle | DATABASE | DataTransferService, both processors, PeopleInviteProcessor | DB only | async_requests | NotFoundException, ConflictException |
DeadLetterService | Service | job_failures list/replay/record | DATABASE, ModuleRef (dynamic queue lookup) | Controller, PeopleQueueProcessor.onFailed | DB, BullMQ Queue.add | job_failures | NotFoundException, ConflictException, BadRequestException (pagination=false), ServiceUnavailableException (enqueue failure, with the claim released) |
PeopleImportWriter | Service (write path) | Row-to-person writes | DATABASE, PeopleCodeService, PersonWriterService | PeopleImportProcessor | Per-row transactions | users, students/guardians/staff, student_guardian | Per-row RowIssue, never propagated as an exception |
PeopleExportReader | Service (read path) | Role-scoped export SELECTs | DATABASE | PeopleExportProcessor | Read-only queries | None | None thrown |
PeopleQueueProcessor | Processor (@Processor) | The single Worker on QueueName.PEOPLE; job-name routing; dead-letter recording | The three sibling processors, DeadLetterService | BullMQ | Delegates to handlers; on final failure, DeadLetterService.record | job_failures (indirect) | Throws on an unhandled job name |
PeopleImportProcessor | Processor (plain provider, not @Processor) | IMPORT_COMMIT handling | DataTransferRequestService, DataTransferService, PeopleImportWriter | PeopleQueueProcessor | Re-read, re-parse, write, transition, and (best-effort, after completion) DataTransferService.requestInvites | async_requests | Re-throws after marking failed, so BullMQ retries; a failure to queue invitations afterward is only logged |
PeopleExportProcessor | Processor (plain provider) | EXPORT_BUILD handling | DataTransferRequestService, DataTransferService, PeopleExportReader | PeopleQueueProcessor | Read, build, store, transition | async_requests | Same re-throw pattern |
PeopleInviteProcessor | Processor (plain provider) | SEND_INVITE_BATCH handling | DATABASE, DataTransferRequestService, VerificationTokenService, AuthEmailService | PeopleQueueProcessor, fed by DataTransferService.requestInvites (called from the controller or from PeopleImportProcessor) | Per-person try/catch, paced 500ms apart | async_requests, sends emails via AuthEmailService | Throws 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 |
parsePeopleFile | Pure function | CSV/XLSX to ParseResult | getTemplate | DataTransferService, PeopleImportProcessor | None (pure) | None | None thrown; returns issues |
buildTemplateWorkbook / getTemplate | Pure functions | The three template column lists | None | DataTransferService.buildTemplate, parsePeopleFile, buildExportFile | None | None | None |
buildExportFile | Pure function | Rows to CSV/XLSX buffer | getTemplate (fallback columns) | PeopleExportProcessor | None | None | None |
Rules:
- Every file under
apps/api/src/modules/data-transfer/andapps/api/src/utils/data-transfer/is represented above. PersonWriterServiceandPeopleCodeServiceare 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/VerificationTokenServiceare 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
| Step | Code Location | What Happens | Why It Happens | Failure/Edge Case |
|---|---|---|---|---|
| 1 | data-transfer.controller.ts:139-152 | Route 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. |
| 2 | data-transfer.service.ts:210-211 | findByRequestId + assertOwnRequest. | Ownership is checked fresh on every mutating call, never cached from an earlier read. | 404/403. |
| 3 | data-transfer.service.ts:213-221 | Status 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. |
| 4 | data-transfer.service.ts:223-236 | Re-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. |
| 5 | data-transfer.service.ts:240-247 | Opens 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. |
| 6 | data-transfer.service.ts:255-272 | outbox.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. |
| 7 | data-transfer.service.ts:275 | Re-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-53 | Worker 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. |
| 9 | people-import.processor.ts:64-79 | Re-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). |
| 10 | people-import-writer.service.ts:80-99 | One 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. |
| 11 | people-import.processor.ts:81-91 | transition(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.resultorjob_failures.payload_refat the database level — both are plainjsonb, 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/allowancesare staff-module columns this module writes to viaPeopleImportWriter, not columns it owns. - No timezone-sensitive interpretation beyond what
timestamptzalready 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
| Invariant | Enforced By | Why It Exists | Failure Error | Tests |
|---|---|---|---|---|
| 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
| Decision | Context | Chosen Option | Alternatives | Why Chosen | Tradeoffs | Revisit Trigger |
|---|---|---|---|---|---|---|
| Upload is always a dry run; commit is a separate call | Bulk operations are irreversible at scale | Two explicit HTTP calls, two distinct permissions (DataImport_CREATE vs. DataImport_UPDATE) | A single call that both validates and writes | An import of 2,000 pupils has no undo; a confirmation step is the only real safeguard against a mis-clicked file picker | Doubles the number of round trips; requires the file to be stored between the two calls | None — this is a deliberate, permanent safety boundary |
Idempotency key is sha256(file) under scope, globally unique | Duplicate uploads happen | Global unique index, not per-actor | Per-actor uniqueness | Two administrators uploading the same file is the ordinary failure mode, and nothing downstream (generated admission numbers) would catch a double import otherwise | A 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 report | None identified |
Commit goes through the transactional outbox, not a direct queue.add | Async work must survive a crash between the write and the enqueue | Outbox pattern, at-least-once delivery, idempotent handlers | Direct enqueue inside the request handler | A direct enqueue and a DB write cannot be made atomic; either can succeed while the other fails, with no error surfaced anywhere | Delivery is only at-least-once — every consumer must handle redelivery itself, which is more code than a naive single enqueue | None — this is the repo-wide standard (see the outbox module's own docs) |
One @Processor on the queue, routing by job name | BullMQ builds one Worker per decorated class | A single PeopleQueueProcessor with a Record<PeopleJob, handler> map | Three separate @Processor classes, one per job type | Three 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 queue | Adding 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 error | None — validated by the type system itself |
Dead-letter storage is payload_ref only, never the payload | A failed job's payload may contain secrets | Allowlisted reference fields (requestId, scope, correlationId, replayOf, a bounded sample of user ids) | Storing the full job.data | A password-reset invitation payload carries a live, single-use token; storing that behind a read permission is account takeover | A 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 way | A future PeopleJob whose payload cannot be reconstructed from requestId alone |
| Export download is a path on this API, never a signed storage URL | Exports carry sensitive personal/financial data | /api/data/exports/:requestId/download, behind the same guard chain | A time-limited signed URL from StorageManager | A 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 signing | Every 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 matters | None — 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 transaction | sendInvites is checked at upload time but the created people (and their ids) are not known until the commit worker finishes writing rows | PeopleImportProcessor.queueInvites, called after transition(... "completed" ...), outside the try/catch that would fail the import | Enqueue the invite batch inside the same transaction that marks the import completed | The 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-running | A 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 screens | None — 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 code | Granting sign-in access is an identity change, reachable with no spreadsheet involved | A dedicated POST /data/invites route under Users_UPDATE | Reuse DataImport_UPDATE, since invitations are conceptually adjacent to importing | An import permission would let anyone who can import files also grant login access to arbitrary existing people — a materially different capability | None 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 id | The old numeric-id route made the table's size and failure rate visible to anyone holding the read permission | A dedicated uuid column, added nullable/backfilled/tightened via 0004_job_failures_public_id.sql | Keep the serial id as the public identifier | A sequential id is a working enumeration: /job-failures/1..n answers "how much has broken here, and when" to anyone with DataImport_READ | The migration had to backfill existing rows with gen_random_uuid() (v4, not the application's v7) rather than leave them without one | None — permanent |
| Replay claims the row, then enqueues, and releases the claim if the enqueue fails | Claim-then-enqueue is safe against two operators racing, but a claim can outlive an enqueue that never reached Redis | The 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_id | Enqueue first, then claim | Enqueue-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 claim | None — validated by dead-letter.service.spec.ts's release/re-replay test |
The failure queue refuses pagination=false | job_failures only ever grows; nothing prunes it | findAll throws PAGINATION_LIMIT_INVALID rather than serving an unbounded read | Allow it, like the reference-table lists elsewhere in the codebase do | A 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 response | An operator who genuinely wants every row must page through them | None — 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 export | Both are integer foreign keys in the database; nobody filling in a paper-to-spreadsheet transcription has the id of "Tamang" to hand | Match by name, case/whitespace-insensitively, on the way in; emit by name on the way out | Accept/emit the integer id | A 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 template | An unrecognised name fails the row rather than importing blank — the safer failure mode, but a stricter one than silently accepting any string | None — 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 file | A 2,000-row import must be partially recoverable | One transaction per row | One all-or-nothing transaction for the whole file | An all-or-nothing rollback on row 1,999 gives the operator nothing, and holds code_counters locked for the whole run, blocking concurrent manual admissions | A 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/no | None — this is the deliberate design |
| Row numbers in every reported issue are the operator's own spreadsheet line, header included | Reports must be actionable without translation | dataIndex + 2 throughout parsePeopleFile | Reporting a 0- or 1-based index into the parsed row array | An operator fixing "row 4" in their own spreadsheet must land on the same row the system means | None meaningful | None |
16.7 Operational Runbook
| Operation | How to Inspect | Healthy State | Failure Signal | Recovery |
|---|---|---|---|---|
| Import/export request status | SELECT * FROM async_requests WHERE request_id = '...' | Progresses pending -> committing (import only) -> completed, or lands on failed with error populated | Stuck at committing/pending far longer than the file size would justify | Check 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 health | BullMQ / Bull Board on QueueName.PEOPLE | Jobs completing at concurrency 2; queue depth not growing unboundedly | Jobs piling up, or repeatedly failing and retrying | Check job_failures for the specific job_name; check whether DataTransferWorkerModule is actually composed into the running worker process |
| Dead letters | GET /data/job-failures (or SELECT * FROM job_failures WHERE queue_name = 'people' ORDER BY failed_at DESC) | Empty, or only old/already-replayed rows | New unreplayed rows accumulating | Inspect 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_FAILED | Re-run SELECT * FROM job_failures WHERE public_id = '...' | replayed_at/replayed_by/replay_job_id are back to null — the claim was released | The row still reads unreplayed immediately after an operator clicked Replay | This 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 id | The worker should have picked it up within one dispatcher cycle | No matching BullMQ job at all | Check 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 export | StorageManager / underlying driver for the storageKey in async_requests.result.storageKey | File readable, GET /data/exports/:requestId/download returns bytes | DATA_TRANSFER_FILE_UNREADABLE on download despite status: completed | The 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
| Risk | Area | Impact | Current Mitigation | Remaining Gap |
|---|---|---|---|---|
| Two administrators intentionally re-uploading an identical, correct file | Import idempotency | The second upload silently returns the first (possibly stale) report rather than a fresh one | alreadyExisted: true is surfaced in the response message | No 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 Error | Error handling consistency | If 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 code | DataTransferService.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 path | Any future producer of SEND_INVITE_BATCH that bypasses requestInvites would hit the uncoded path |
| Export row cap (20,000) is a silent truncation | Data completeness | An operator exporting a school with more than 20,000 matching records gets an incomplete file with no indication it was cut off | rowCount is reported on the completed request, so the true count is visible if checked | Nothing 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 workers | Test coverage | Regressions 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 elsewhere | dead-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 covered | Transaction 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.statushas 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
- API doc:
/docs/developer/data-transfer/api - Features and flows doc:
/docs/developer/data-transfer/feature - TDD: not present for this module