Data Transfer Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for Data Transfer.
Data Transfer Features and Flows
1. Documentation Evidence
| Source Type | Files or Docs | What Was Extracted |
|---|---|---|
| Backend doc | data-transfer/backend.mdx, and directly data-transfer.service.ts, shared/*.ts, workers/*.ts | Service behavior, transaction boundaries, state transitions, queue behavior. |
| API | data-transfer/api.mdx, and directly data-transfer.controller.ts, dto/data-transfer.dto.ts | Route surface, actors, permissions, response shapes. |
| Schema | packages/db/src/schema/async-request/async-requests.ts, packages/db/src/schema/jobs/job-failures.ts | Status values, idempotency scope, dead-letter fields. |
| Templates | apps/api/src/utils/data-transfer/templates.ts | Every column, its requiredness, and its allowed values, per scope. |
| Parser | apps/api/src/utils/data-transfer/parse-people-file.ts | Exact validation rules and row-numbering convention. |
2. Feature Summary
| Field | Value |
|---|---|
| Module | data-transfer |
| Submodule | N/A (import, export, and dead letters are one cohesive surface) |
| Primary user value | An office operator turns a spreadsheet of students, guardians, or staff into real records — safely, with every problem reported against the exact line they typed it on — and turns the reverse direction, a live roll, into a spreadsheet they can hand to someone else. |
| Actors | Logged-in admin/operator (every route requires a JWT and a permission — there is no guest or public surface), worker/system (BullMQ), superadmin (ownership bypass). |
| Main entry points | /data/templates/:scope, /data/imports, /data/imports/:requestId, /data/imports/:requestId/commit, /data/exports, /data/exports/:requestId, /data/exports/:requestId/download, /data/invites, /data/job-failures, /data/job-failures/:publicId/replay; the QueueName.PEOPLE BullMQ worker. |
| Main outputs | A validation report (dry run), a set of newly created users/students/guardians/staff rows (commit), a downloadable CSV/XLSX file (export), a replayed BullMQ job (dead-letter recovery). |
| Related docs | API, Backend |
3. Actor Matrix
| Actor | Can Do | Cannot Do | Auth Requirement | Notes |
|---|---|---|---|---|
| Guest / unauthenticated | Nothing | Everything — no route in this module is public or guest-accessible | None granted | JwtAuthGuard + RoleGuard sit on the controller itself. |
Operator holding DataImport_*/DataExport_* | Download a template; upload and dry-run an import; view their own import's report; commit their own validated import; request an export; view and download their own export; view and replay people-queue dead letters | Read or act on another operator's import/export request; replay a dead letter from any other queue; send account invitations (that needs Users_UPDATE, not an import/export permission) | JWT + specific permission per route (DataImport_READ/_CREATE/_UPDATE, DataExport_CREATE/_READ) | Ownership is enforced separately from the permission — holding the permission is necessary but not sufficient to read someone else's request. |
Operator holding Users_UPDATE | Send account-activation invitations to a batch of people, whether or not any of them arrived via an import | Import or export anything — Users_UPDATE carries no import/export permission | JWT + Users_UPDATE | Deliberately not one of the DataImport_*/DataExport_* codes: granting somebody sign-in access is an identity change, and it is reachable without ever touching a spreadsheet. |
| Superadmin | Everything an operator can, plus read/act on any operator's import or export request | Still limited to the people queue for dead-letter replay — no bypass on REPLAYABLE_QUEUES | JWT, activeRole.isSuperadmin | The only bypass in assertOwnRequest; used to recover a request when the original uploader is unavailable. |
| Worker / system (BullMQ) | Apply a committed import; build a requested export; send an invitation batch, whether it was requested directly or chained automatically off a completed import; record a terminal failure | Choose which columns an export contains (that decision is made for it at request time); retry indefinitely (bounded by the queue's configured attempts) | Internal — no HTTP identity; acts on behalf of the actorId carried in the job payload | Every handler re-reads async_requests rather than trusting its payload, because delivery is at-least-once. |
4. Capability Matrix
| Capability | Surface | Actor | Route/Trigger | State Read | State Written | Linked API Section |
|---|---|---|---|---|---|---|
| Download an import template | Admin | Operator (DataImport_READ) | GET /data/templates/:scope | None | None | API §8.1 |
| Upload and dry-run validate a file | Admin | Operator (DataImport_CREATE) | POST /data/imports | None | async_requests (insert-or-get) | API §8.2 |
| View an import's report/status | Admin | Operator (owner) or superadmin (DataImport_READ) | GET /data/imports/:requestId | async_requests | None | API §8.3 |
| Commit (apply) a validated import | Admin | Operator (owner, DataImport_UPDATE) | POST /data/imports/:requestId/commit | async_requests | async_requests status, outbox_events | API §8.4 |
| Apply the import (async) | Worker | System | PeopleJob.IMPORT_COMMIT | async_requests, stored file | users, students/guardians/staff, student_guardian, async_requests outcome | Backend §7.2 |
| Request an export | Admin | Operator (DataExport_CREATE) | POST /data/exports | Caller's active-role permissions | async_requests, outbox_events | API §8.5 |
| Build the export (async) | Worker | System | PeopleJob.EXPORT_BUILD | students/guardians/staff+users (role-scoped) | File in storage, async_requests result | Backend §7.3 |
| View an export's status/download link | Admin | Operator (owner) or superadmin (DataExport_READ) | GET /data/exports/:requestId | async_requests | None | API §8.6 |
| Download a built export | Admin | Operator (owner) or superadmin (DataExport_READ) | GET /data/exports/:requestId/download | async_requests, storage | None | API §8.7 |
| Send account invitations | Admin | Operator (Users_UPDATE) | POST /data/invites | None | async_requests, outbox_events | API §8.10 |
| List processing dead letters | Admin | Operator (DataImport_READ) | GET /data/job-failures | job_failures (pinned to people queue) | None | API §8.8 |
| Replay a dead letter | Admin | Operator (DataImport_UPDATE) | POST /data/job-failures/:publicId/replay | job_failures | job_failures (claim, released again if the enqueue fails), BullMQ (fresh job) | API §8.9 |
| Send an invitation batch (async) | Worker | System | PeopleJob.SEND_INVITE_BATCH | users, async_requests | Emails sent, async_requests outcome | Triggered by POST /data/invites, and chained automatically after a committed import whose upload asked for invitations |
| Record a terminal job failure | Worker | System | Any PeopleJob failing its final attempt | BullMQ job metadata | job_failures | Backend §7.4 |
5. User-Facing Flows
5.1 Upload a spreadsheet and get a validation report
Summary
An operator picks a file (students, guardians, or staff), uploads it, and immediately gets back — synchronously, in the same HTTP response — a full report of what the file contains, which rows have problems, and a preview of the first rows that would be created. Nothing is written to the database at this point, no matter how clean the file is.
Preconditions
- The operator is signed in and holds
DataImport_CREATE. - The file is
.csvor.xlsx, at most 10 MB, at most 5,000 data rows. - The operator ideally started from the matching template (§5.4), though any file with matching headers works.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Operator | Selects scope (students/guardians/staff) and a file, optionally toggling "send invites," and uploads. | Multipart request sent. | data-transfer.controller.ts:106-125 |
| 2 | Backend | Checks size/extension (multer), then re-checks size and reads the bytes. | Rejects outright if too large, wrong type, or unreadable. | data-transfer.service.ts:103-128 |
| 3 | Backend | Parses the file against the scope's template: header matching, per-cell validation. | A full ImportReport: total/valid row counts, per-row issues, unknown/missing headers, a 10-row preview. | parse-people-file.ts |
| 4 | Backend | Fingerprints the file (sha256) and inserts-or-finds the async_requests row keyed on (scope, hash). | A new report, or the identical report from an earlier identical upload. | data-transfer.service.ts:153-181 |
| 5 | Backend | Stores the file (only for a genuinely new upload). | The bytes are available for the later commit call. | data-transfer.service.ts:169-180 |
| 6 | Backend | Returns the report. | Operator sees exactly what will happen if they commit. | data-transfer.controller.ts:117-125 |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Validation failure at upload | Missing/oversized/wrong-type file | Rejected before parsing. | 400 DATA_TRANSFER_FILE_REQUIRED/IMPORT_FILE_TOO_LARGE |
| Missing required headers | e.g. no "Admission Date" column | Parsing stops early; nothing per-row is validated. | missingHeaders populated, rows: [] — this file cannot later be committed |
| Duplicate upload | Byte-identical file, same scope, uploaded before (by anyone) | No new report generated. | alreadyExisted: true, existing report returned, distinct message |
| Guest branch | N/A — no guest access exists on this route | — | 401/403 from the guard chain |
| Unknown extra column | A header the template does not expect | Reported, not fatal. | Listed under unknownHeaders |
5.2 Commit (apply) a validated import
Summary
Having reviewed the dry-run report, the operator explicitly asks for the file to be applied. This is the only action in the whole module that creates people. It runs asynchronously — the commit call returns immediately with a "queued" status, and the actual writes happen in a background worker.
Preconditions
- The upload's dry run reported at least one valid row and no missing headers.
- The request is still
pending(not already committing, completed, or failed). - The operator is the original uploader, or a superadmin.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Operator | Calls commit on the request id from the dry run. | Ownership and status re-checked. | data-transfer.controller.ts:143-152 |
| 2 | Backend | Flips status pending -> committing and schedules the work — one transaction. | Either both happen or neither does. | data-transfer.service.ts:240-273 |
| 3 | Backend | Returns the now-committing request. | Operator sees the import is in progress. | data-transfer.service.ts:275 |
| 4 | Worker | Re-reads the stored file, re-parses it, writes each valid row in its own transaction. Only people this run actually creates get sign-in access (can_login), and only when the operator checked "send invites" on the upload — otherwise every created row is login-disabled regardless of what any individual cell says. | People are created; each row's outcome is independently recorded; the ids of the users rows created (not counting a guardian created only as a side effect of a student row) are collected. | people-import.processor.ts, people-import-writer.service.ts |
| 5 | Worker | Marks the request completed, attaching created/failed counts and any write-time issues. | Operator's next poll shows the final outcome. | people-import.processor.ts:92-98 |
| 6 | Worker | If the upload asked for invitations and at least one person was created, queues an invitation batch for exactly those people — after the import is already marked completed, and outside the block that would otherwise fail it. | A second, independent request (its own requestId) starts sending account-activation emails; a failure to queue it is logged and does not reopen or fail the import. | people-import.processor.ts:104-115 |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Double-click commit | Two commit calls for the same request | Only one status transition can win. | The second gets 409 DATA_TRANSFER_NOT_COMMITTABLE |
| Not the owner | Someone other than the uploader (non-superadmin) tries to commit | Refused outright, before any status check. | 403 DATA_TRANSFER_NOT_YOURS |
| Everything in the file was invalid | validRows === 0 | Nothing to commit. | 409 DATA_TRANSFER_HAS_BLOCKING_ISSUES |
| Duplicate job delivery | The outbox redelivers IMPORT_COMMIT | Worker finds status no longer committing; does nothing. | Silent no-op, logged |
| A row fails at write time despite passing dry-run validation | e.g. a race with another process creating a conflicting email | That one row is reported failed in the outcome; every other row still commits. | Row-level IMPORT_ROW_WRITE_FAILED (or a mapped code) inside outcome.issues |
| A row names an ethnicity or mother tongue the school has not listed | Typo, or a classification never added | That row fails outright rather than importing with the column left blank — a blanked value is indistinguishable from one deliberately left empty. | Row-level IMPORT_UNKNOWN_ETHNICITY / IMPORT_UNKNOWN_MOTHER_TONGUE |
| File went missing from storage before commit ran | Manual/operational deletion | The whole commit fails. | Request marked failed; error message names the cause |
| More people were created than one invitation batch holds | An import accepts up to 5,000 rows; an invitation batch holds at most 500 | The created people are split into chunks of 500 and each chunk becomes its own invitation request with its own id. | Everybody the import created is invited, across as many batches as it takes |
| Invitations were requested but queueing one chunk fails | The invite request/outbox insert throws (e.g. a database hiccup) after the import already committed | The import stays completed; the people in that chunk are counted and the failure is logged, naming how many were not invited and across how many batches. Every other chunk still goes. | No error returned to the operator on this call — the people in the failed chunk simply have no invitation queued yet, and can be invited from the people screens |
5.3 Request, monitor, and download an export
Summary
An operator asks for a spreadsheet of students, guardians, or staff — optionally filtered the same way the screen they exported from was filtered. The file is built in the background; the operator polls (or is notified) until it is ready, then downloads it through the same authenticated API, never a raw storage link.
Preconditions
- The operator holds
DataExport_CREATEto request, andDataExport_READto view status/download. - Salary and medical columns appear only if the requester's own active role already grants
StaffSalary_READ/StudentMedical_READ— there is no way to request them without holding the permission.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Operator | Requests an export: scope, format, optional filters. | A request is created; gated columns are decided right now, from the operator's own permissions. | data-transfer.service.ts:280-323 |
| 2 | Worker | Reads the matching rows (role-scoped, capped at 20,000), builds the file, stores it. | async_requests marked completed with a storage key and row count. | people-export.processor.ts |
| 3 | Operator | Polls status. | downloadUrl appears only once the file is actually ready. | data-transfer.service.ts:325-350 |
| 4 | Operator | Downloads. | Ownership re-checked; bytes streamed through this API. | data-transfer.service.ts:360-389 |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Download attempted early | Status still pending | Refused. | 409 DATA_TRANSFER_EXPORT_NOT_READY ("still being built") |
| Export build failed | Storage error, or any thrown error during build | Marked failed. | 409 DATA_TRANSFER_EXPORT_NOT_READY ("failed to build. Request it again.") |
| Caller lacks a gated permission | No StaffSalary_READ/StudentMedical_READ | Those columns are absent from the file entirely — not blank. | No error; the file simply has fewer columns |
| More matching rows than the cap | Over 20,000 matching records | File truncates at 20,000; rowCount on the request reflects what was actually written. | No explicit "truncated" flag in the response — must be inferred |
| Someone else's download link is used | Forwarded URL, no DataExport_READ or not the owner | Refused independently of the status call. | 403 DATA_TRANSFER_NOT_YOURS / permission 403 |
| Requesting the same export twice | Two identical POST /data/exports calls | Each is its own independent build — never deduplicated. | Two separate requestIds, two separate files, both reflecting current data at the time each ran |
5.4 Download a template
Summary
Before uploading anything, an operator downloads a blank starting spreadsheet for the scope they intend to import — every expected column, with a one-row example and, where relevant, a hint about the allowed values.
Preconditions
- The operator holds
DataImport_READ.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Operator | Requests a template for a scope, optionally choosing CSV over the default XLSX. | A file streams back immediately. | data-transfer.controller.ts:72-97 |
| 2 | Backend | Builds the header row and one example row from the scope's fixed column list. | Same file for everyone; nothing is stored or linked. | templates.ts:333-365 |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Unknown scope in the URL | A scope not in ["students","guardians","staff"] | Silently falls back to students. | No error — the URL param is not validated against the enum before this fallback |
| CSV requested on a school whose staff have Devanagari names | Format = csv | A UTF-8 BOM is prepended so Excel on Windows renders the names correctly. | File opens correctly instead of as mojibake |
5.5 Place imported pupils in classes
The student template carries three more columns — Grade, Section and Shift — so a school
migrating its roll can put pupils in classes in the same upload that creates them, instead of placing
several hundred of them one at a time afterwards.
Resolved by name, not by id. Nobody types a uuid into a spreadsheet, and the export already emits
names for the same reason. None of the three carries an allowed list, Shift included: allowed
is also the parser's gate, and a value it does not recognise drops the whole row before the writer
sees it. Losing a pupil over the word "Morning Shift" is the outcome the other two columns avoid, so
all three are validated in the writer instead — the way Guardian Relationship already is.
What each combination does
| Grade | Section | Shift | Outcome |
|---|---|---|---|
| blank | blank | blank | No enrolment. The ordinary case, and what every existing file does. |
| blank | blank | set | Row issue — a shift is not a class. |
| set | blank | any | Row issue naming the missing section. |
| blank | set | any | Row issue naming the missing grade. |
| set | set | blank | One shift runs it → resolved silently. Both → row issue. |
| set | set | set | Exact match → resolved. No such class → row issue. |
The enrolment date is max(today, session start date) — never the pupil's admission date. In a
roll migration that date is an earlier year, and an enrolment outside the session is refused, so using
it would fail every row of exactly the import this exists for. Bare "today" fails from the other
side: nothing ties is_current to the calendar, so a school setting up next year and importing before
term starts would be refused too.
Everything the dry run can predict, it predicts. Unknown names, half-stated classes, ambiguous
shifts and admission dates later than the enrolment date are all reported before anything is
written, and they count against validRows — so a file whose every class cell is wrong is refused at
commit rather than importing nobody. Only capacity is left to write time, because only capacity is
genuinely unknowable until then.
A pupil whose class cannot be resolved is not created. The enrolment is written inside that row's own transaction, so an unplaceable pupil rolls back with it rather than arriving classless and unnoticed. That is why the dry run matters: the operator sees every such row before committing.
Capacity is enforced and there is no override column. An import cannot ask a question, and a column would apply an override to as many rows as the operator dragged it down. A full class is a row issue.
Two whole-file refusals, because both are one fact about the upload rather than five thousand row failures: no academic year is marked current, and the current one has already ended.
Permissions
Placing pupils needs Students_CREATE — the permission on POST /students, which is the operation a
class-bearing row performs: create a pupil and enrol them in one transaction. Uploading and committing
still need only the DataImport_* codes.
The gate is evaluated at commit, against the committer (a superadmin may commit somebody else's
upload, and the committer is who the enrolments are recorded against), and re-derived again in the
worker from the actor's live permissions. Captured-and-still-true, both required: a payload flag
alone would let a role revoked in between go unnoticed, and would let the dead-letter replay route —
which needs only DataImport_UPDATE — carry an entitlement decided for somebody else.
A file with no class columns is unaffected by any of this and imports exactly as before.
What the report never contains
Import issues carry a registered error code and the operator's own input. They never carry a resolved
class name or an occupancy count. The capacity refusal raised internally says "Class 5 A (morning) is
full — 37 of 40 places are taken"; the uploader, who holds no Classes_READ, sees "That class is
already full." on the Grade column. The refusal is sanitised where it is raised, not where it is
rendered.
Export
The same three columns are emitted on a student export, gated on Classes_READ — a caller without it
gets a file with no such columns, not empty ones, matching how the salary columns behave. A pupil
with no class exports three empty cells.
6. Admin Flows
6.1 List and review processing dead letters
Route: GET /data/job-failures — DataImport_READ. Read-only; no mutation, no cache invalidation, no audit log beyond the row itself. Supports queueName (ignored/overridden to people if anything else is supplied) and unreplayedOnly filters, plus the shared pagination DTO.
6.2 Replay a dead-lettered job
Route: POST /data/job-failures/:publicId/replay — DataImport_UPDATE. Addressed by the row's public_id (a uuid), never its serial primary key — a sequential id on a route holding a read permission would let anyone walk the table and learn how much of the platform has failed and when.
The claim is written before the enqueue, not after — the alternative order can leave a job running against a row that still reads unreplayed if the follow-up update then fails, and the next operator replays it a second time. The cost of claiming first is that a claim can outlive an enqueue that never reached Redis, and since replayed_at is exactly what the already-replayed refusal reads, a row left in that state could never be replayed again through this route. The release step is what makes the ordering safe: it is conditional on this call's own replay_job_id, so it can only ever undo its own claim, never a claim a second operator made in the meantime.
There is no create, reorder, activate/deactivate, soft delete, or restore flow anywhere in this module — async_requests and job_failures rows are append-only records of events that happened, not entities an admin curates.
6.3 Send account invitations
Route: POST /data/invites — Users_UPDATE, not one of the DataImport_*/DataExport_* codes. Granting sign-in access to a person is an identity change, and this route is reachable 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.
The operator supplies a list of userIds. Duplicates are removed, order preserved, so the same person listed twice yields one invitation rather than two competing single-use tokens (the second would invalidate the first, and the recipient's working link would not be the one they are most likely to click). The request row and its outbox event are written in one transaction, and the batch itself is queued and paced by the same worker an import's automatic invitations use — see §5.2 for that path.
7. Lifecycle and State Transitions
async_requests.status (import)
| From | Event/Action | To | Guard Condition | Side Effects |
|---|---|---|---|---|
| (new row) | Upload validated (new file) | pending | File parsed; idempotency key did not already exist | File stored |
pending | Commit requested | committing | Caller owns the request; status is currently pending; report has valid rows and no missing headers | Outbox row inserted, same transaction |
committing | Worker applies rows successfully | completed | Worker finds status still committing (guards redelivery) | completedAt set; outcome attached |
committing | Worker cannot proceed (storage missing, or a thrown error) | failed | Same redelivery guard | completedAt set; error set |
async_requests.status (export)
| From | Event/Action | To | Guard Condition | Side Effects |
|---|---|---|---|---|
| (new row) | Export requested | pending | — | Outbox row inserted, same transaction |
pending | Worker builds the file successfully | completed | Worker finds status still pending | completedAt set; storageKey, rowCount attached |
pending | Worker fails (read error, storage error) | failed | Same guard | completedAt set; error set |
Note: an export never passes through committing — that status exists only for the import side of the lifecycle.
job_failures (per row — not a state machine, a one-way claim)
| From | Event/Action | To | Guard Condition | Side Effects |
|---|---|---|---|---|
| (unreplayed) | Job fails on its final BullMQ attempt | row created | attemptsMade >= maxAttempts | Row inserted with replayed_at: null, a fresh public_id |
| unreplayed | Operator replays it, enqueue succeeds | replayed | replayed_at IS NULL at claim time | replayed_at, replayed_by, replay_job_id set; a fresh BullMQ job enqueued |
| unreplayed (briefly claimed) | Operator replays it, but the enqueue itself fails | unreplayed again | Claim's own replay_job_id matches | replayed_at, replayed_by, replay_job_id reset to null; 503 JOB_FAILURE_REPLAY_ENQUEUE_FAILED returned; row is replayable again |
Every row addresses only by public_id from the API's side — the serial id never leaves the database. There is no "un-replay" once an enqueue actually succeeds — a row in that state stays replayed permanently.
9. Data and Side Effects by Flow
| Flow | DB Writes | Cache Effects | Jobs | Realtime | Analytics | Notifications |
|---|---|---|---|---|---|---|
| Upload (dry run) | async_requests insert-or-get | None | None | None | None | None |
| Commit | async_requests status, outbox_events insert | None | Enqueues IMPORT_COMMIT (via outbox) | None | None | None |
| Import applied (worker) | users, students/guardians/staff, student_guardian, async_requests outcome | None | Chains an invitation-batch request (via the outbox) when the upload asked for invitations and rows were created | None | None | None directly — see the invitation rows below |
| Request export | async_requests insert, outbox_events insert (one transaction) | None | Enqueues EXPORT_BUILD (via outbox) | None | None | None |
| Export built (worker) | async_requests result | None | None | None | None | None |
| Download template/export | None | None | None | None | None | None |
| Send invitations | async_requests insert, outbox_events insert (one transaction) | None | Enqueues SEND_INVITE_BATCH (via outbox) | None | None | None |
| Send invite batch (worker) | users (read), async_requests outcome | None | None | None | None | Password-reset-style invitation emails, per person |
| List dead letters | None (read) | None | None | None | None | None |
| Replay dead letter | job_failures claim (released again if the enqueue fails) | None | queue.add a fresh job on the original queue | None | None | None |
| Job fails terminally (worker) | job_failures insert | None | None | None | None | None |
10. Error and Recovery Flows
| Scenario | Trigger | User/System Experience | Recovery | Source |
|---|---|---|---|---|
| Upload rejected for size/type | File too large or wrong extension | 400 before any parsing happens | Fix the file and re-upload | spreadsheetMulterOptions, data-transfer.service.ts:109-116 |
| File has blocking validation issues | Missing headers, or every row invalid | Dry-run report shows the problem; commit is refused if attempted anyway | Fix the file per the report, re-upload | parse-people-file.ts, data-transfer.service.ts:223-236 |
| Commit race lost | Two commit attempts on one request | Loser gets 409 | Poll the request; someone else's commit is already running | data-transfer.service.ts:240-253 |
| Queue/Redis failure at enqueue time | Outbox dispatcher cannot reach Redis | The async_requests row and the outbox row still both exist (committed together); dispatch itself retries independently | Handled entirely by the outbox module's own retry/dead-letter path — outside this module | packages/db/src/schema/outbox/outbox-events.ts |
| Worker crashes mid-import | Process restart between rows | Rows already written stay written (each was its own committed transaction); the request is left committing until either a redelivery resumes it or it is manually investigated | Operational — see the runbook in the backend doc | people-import-writer.service.ts:80-99 |
| Job fails all its attempts | Transient error (e.g. storage timeout) persists past retries | Recorded in job_failures; the underlying async_requests row is separately marked failed by the processor's own catch block | Operator reviews the dead letter, then either replays it or fixes the root cause and re-requests | people-queue.processor.ts:99-137 |
| Dead letter belongs to a different queue | An operator without knowledge of the queue boundary tries to replay a backup/restore failure from this screen | Refused with a specific message, not a generic error | Use the tooling for that queue instead | dead-letter.service.ts:132-140 |
| Replay's enqueue cannot reach the queue | Redis unreachable, or the target queue is unresolvable, at the moment queue.add runs | The claim already written is released back to unreplayed; the operator sees a 503, not a false success | Try the replay again once the queue is reachable | dead-letter.service.ts:194-219 |
| The failure queue is asked for an unpaginated read | pagination=false on GET /data/job-failures | Refused outright — nothing prunes this table, so an unbounded read asks the process to buffer every row and stack trace at once | Page through the results instead | dead-letter.service.ts:71-76 |
| Invitation batch too large | More than 500 userIds on POST /data/invites | Refused before anything is queued | Split the batch — an import does this for itself, in chunks of 500 | data-transfer.service.ts (requestInvites) |
| Export download requested for someone else's request | A forwarded or guessed download URL | Refused, independent of any earlier status check | Ask the original requester, or a superadmin | data-transfer.service.ts:360-365 |
11. Diagrams Required Per Module
- Actor capability diagram: §3 / §4 tables serve this role; every capability in this module maps to exactly one actor pairing (operator + worker), so a separate diagram would repeat the table.
- High-level module flow diagram: see §12.2.
- Sequence diagram for each major flow: §5.1-§5.4, §6.1-§6.2.
- State machine diagram for every lifecycle: §7.
- Data side-effect diagram for write flows: §12.6.
- Error branch diagram: §6.2 covers the richest branch set; the rest are tabulated in §10.
12. Mandatory Feature and Flow Deep-Dive Pack
12.1 Feature Inventory With Minor Behaviors
| Feature | Minor Behavior | Actor | Trigger | User/System Result | Backend Side Effect | Source |
|---|---|---|---|---|---|---|
| Template download | Unknown scope in the URL silently defaults to students | Operator | GET /data/templates/:bad-scope | Gets the students template, not an error | None | data-transfer.controller.ts:80-82 |
| Template download | CSV gets a UTF-8 BOM; XLSX does not need one | Operator | ?format=csv | Devanagari example names render correctly in Excel on Windows | None | templates.ts:359-364 |
| Upload | Blank cells are trimmed and become undefined, not "" | System | Any upload | Optional fields left blank in the spreadsheet do not fail an enum/date write | None visible to the operator | people-import-writer.service.ts:119-122 |
| Upload | A trailing all-blank row (common after saving in Excel) is skipped silently | System | Any upload | Not counted as a row at all, not reported as an error | None | parse-people-file.ts:206-209 |
| Upload | Duplicate upload of the identical file returns the earlier report with a distinguishing message | Operator | Re-upload of an identical file | alreadyExisted: true; UI can tell the operator this is not new | None — no second async_requests row | data-transfer.controller.ts:117-124 |
| Upload | Unknown extra columns are reported but do not block anything | Operator | A file with the operator's own working columns alongside the template's | Listed in unknownHeaders, import proceeds | None | parse-people-file.ts:158-160 |
| Commit | Rows the dry run already flagged invalid are never attempted again at write time | System | Commit of a file with some invalid rows | Only one message per problem row, not two | None | people-import.processor.ts:68-75 |
| Commit | Admission/employee numbers allocated once, up front, for the whole file | System | Any student/staff import | A failed row leaves a gap in the number sequence rather than shifting later rows' numbers | code_counters locked briefly, once, not once per row | people-import-writer.service.ts:73-78 |
| Commit | Two children sharing a phone number are attached to one guardian record; two different phone numbers create two guardians even if names look similar | System | Student rows with guardian info | Fewer duplicate guardian records for a family | Guardian lookup by phone, not name | people-import-writer.service.ts:200-239 |
| Commit | An unknown department/designation name refuses the row rather than silently creating it | System | Staff import with a typo'd department | The row fails with a specific, correctable message | No new departments/designations row created | people-import-writer.service.ts:359-421 |
| Commit | A staff row with only one of basic salary / allowances gets the other defaulted to zero, not left blank | System | Partial salary data | Row succeeds instead of failing a pairing check | total_salary computed rather than NULL | people-import-writer.service.ts:331-340 |
| Commit | Ethnicity and mother tongue on the spreadsheet are names, matched case- and whitespace-insensitively against the school's own lists | System | A row naming "Rai " with trailing whitespace or mixed case | Matches the same classification as an exact-cased entry | None if matched; the row fails with IMPORT_UNKNOWN_ETHNICITY/IMPORT_UNKNOWN_MOTHER_TONGUE if it does not | people-import-writer.service.ts:454-479 |
| Commit | A student row also carries an IEMIS ID column | System | A file with a value in "IEMIS ID" | Stored on the student row as text, so a leading zero survives | None | people-import-writer.service.ts:202 |
| Commit | Imported people can only sign in when the upload asked for invitations | System | Any import | Every row's can_login follows the upload's own sendInvites choice, never a per-row value | None | people-import-writer.service.ts:84-90 |
| Export | Gated columns (salary, medical) are omitted as whole columns for a caller without the permission — never blanked | System | Export request without StaffSalary_READ/StudentMedical_READ | The file has fewer headers, not empty cells under sensitive headers | None | people-export-reader.service.ts:23-28 |
| Export | Bank and statutory identifiers (bank name, account number, branch, PAN, citizenship, SSF, CIT numbers) ride the salary gate, not the staff one | System | Export request without StaffSalary_READ | Those columns are absent from the file exactly like the salary figures — a colleague holding only Staff_READ has no more business with where wages are paid than with how much they are | None | people-export-reader.service.ts:367-386 |
| Export | Ethnicity and mother tongue are emitted as names, round-trippable straight back into the import template | System | Any export | The file can be corrected and re-imported without translating ids | None | people-export-reader.service.ts |
| Export | Row cap of 20,000 truncates silently | System | A very large matching set | File is complete only up to the cap; rowCount reflects the truncated count | None | people-export-reader.service.ts:66 |
| Export | Cells starting with =, +, -, @, tab, or CR are quote-prefixed | System | Any export containing free-text with those leading characters | The cell displays literally instead of executing as a formula when opened | None | build-export-file.ts:15,28-30 |
| Export | Download URL only appears once the file is actually stored | Operator | Polling before the build finishes | downloadUrl: null until ready — no broken link is ever handed out | None | data-transfer.service.ts:332-349 |
| Export | Two identical export requests never dedupe | Operator | Requesting the same scope/filters twice | Two independent files, both reflecting data at their own build time | Two async_requests rows, each written in the same transaction as its own outbox event | shared/data-transfer-request.service.ts:127-152 |
| Invites | The same person listed twice in one batch is invited once | Operator | Duplicate userIds on POST /data/invites | De-duplicated before anything is queued, order preserved | One invitation, not two competing tokens | data-transfer.service.ts:318-322 |
| Invites | An import that asked for invitations queues them itself | System | A committed import with sendInvites: true and at least one created person | One or more further async_requests rows (invite:batch) appear without any operator action — one per chunk of 500 created people | An outbox event enqueuing SEND_INVITE_BATCH per chunk | people-import.processor.ts (queueInvites) |
| Invites | An invitation link is good for a week | The invited person | Any invitation, however it was requested | The link stays usable for 7 days, then stops | An account_invite verification record with a 7-day expiry rather than the 15-minute OTP default | people-invite.processor.ts |
| Invites | An invitation carries no one-time code | The invited person | Any invitation | The email offers a link and nothing else | The code is minted but stripped before the email is built — the OTP branch of the reset endpoint matches password resets alone, so a code an invitation cannot be redeemed with would only lead the recipient somewhere that refuses them | people-invite.processor.ts |
| Dead letters | The queue filter cannot be widened by the query string | Operator | Passing ?queueName=backup | Silently ignored; results are still scoped to people | None | dead-letter.service.ts:296-305 |
| Dead letters | Replay always uses a new job id | System | Any replay | The old failed job stays failed in BullMQ's own records; the new attempt is a distinct job | None | dead-letter.service.ts:160-164 |
| Dead letters | A concurrent double-replay fails softly for the loser | Two operators | Both click Replay at once | One succeeds, one gets 409 already replayed | Only one extra job enqueued | dead-letter.service.ts:178-192 |
| Dead letters | A replay whose enqueue fails releases its own claim | System | Redis unreachable at enqueue time | The row is left unreplayed rather than permanently stuck, and the operator sees a 503 instead of a false success | Row's replayed_at/replayed_by/replay_job_id reset to null | dead-letter.service.ts:208-219 |
| Dead letters | The failure queue cannot be read unpaginated | Operator | pagination=false on GET /data/job-failures | Refused, since nothing ever prunes this table | 400 PAGINATION_LIMIT_INVALID | dead-letter.service.ts:71-76 |
Rules followed: restore/retry/skip/duplicate-action/cache-miss/permission-failure behaviors above are called out individually rather than grouped, per the format's own instruction.
12.2 Business Process Diagram Pack
User journey map — an operator running an import end to end
Service blueprint — the commit flow across actor / API / service / DB / async
12.3 Business Rules and Policy Traceability
| Rule | Business Reason | Actor Impact | Enforced In | API Impact | Backend Impact | Tests |
|---|---|---|---|---|---|---|
| Upload never writes a person | Bulk writes are irreversible at scale; a mis-click must not create thousands of wrong records | Operator gets a report, never a fait accompli | DataTransferService.uploadForDryRun (structurally — no write path exists) | dryRun: true always on this response | No path from upload to PeopleImportWriter | None dedicated |
| Import idempotency is content-based and global | Two people uploading the same file is the normal way a roll doubles | A second identical upload is a no-op, not a new import | async_requests_idempotency_idx, createOrGet | alreadyExisted flag distinguishes it | Global unique index, not scoped by actor | None dedicated |
| A request can only be acted on by its owner (or a superadmin) | Reports and exports carry real personal data | Others are refused even read access | assertOwnRequest | 403 DATA_TRANSFER_NOT_YOURS | Checked fresh on every mutating and reading call | None dedicated |
| Export column visibility follows the requester's active role, fixed at request time | A worker cannot legitimately choose a role on the operator's behalf | Two requests by the same person under different active roles can produce different files | requestExport's permissions.can calls, carried on the payload | No client control over which columns appear beyond holding the permission | Worker trusts the payload's includeGatedColumns completely | None dedicated |
| Downloads never leave this API | A signed storage URL is a bearer credential that bypasses the permission system | Every download re-runs the guard chain | readExportFile, controller routes | downloadUrl is always a same-API path | No signed-URL code path exists in this module | None dedicated |
Dead-letter replay is scoped to the people queue only | job_failures is shared, global infrastructure | An operator cannot accidentally (or deliberately) re-run another domain's failed job from this screen | DeadLetterService.REPLAYABLE_QUEUES | 409 JOB_FAILURE_QUEUE_NOT_PERMITTED for anything else | Hardcoded array, not query-string driven | None dedicated |
| A replay always gets a fresh job id | BullMQ silently no-ops add() on a repeated id | An operator who replays sees an actual new attempt, not a false "success" | buildJobId in DeadLetterService.replay | The replay response's replayJobId is always new | Structural | None dedicated |
| Admission/employee numbers are allocated atomically, once per batch | Concurrent office staff must never receive the same number | Numbers in the report match numbers in the database exactly | PeopleCodeService.allocate | The preview/outcome shows the real allocated numbers | Single upsert-and-increment statement | None dedicated in this module |
| A staff row's salary fields are all-or-nothing | The people-module staff_salary_pair_coherent check requires both or neither | An operator supplying only one salary figure does not get a rejected row | PeopleImportWriter.writeStaff | N/A | Defaults the missing half to "0" | None dedicated |
| An unrecognised ethnicity or mother tongue name fails the row, never blanks it | 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 | The operator sees exactly which row and which name did not match | PeopleImportWriter.resolveClassification | Row-level IMPORT_UNKNOWN_ETHNICITY/IMPORT_UNKNOWN_MOTHER_TONGUE | Matches on lower(btrim(name)), the same normalisation the unique index uses | people-import-writer.service.spec.ts |
| Imported people get sign-in access only when the upload asked for invitations | An import is frequently a migration of records for people who must never be emailed; an invitation to an account that cannot sign in is silently dropped | Every row's can_login follows the upload's own choice, not any per-row data | PeopleImportWriter.apply's grantLogin parameter | N/A | Structural — no per-row override exists | people-import-writer.service.spec.ts |
| A committed import chains its own invitation batches | The operator already said "send invites" once; a second manual step would be work the system already has everything it needs to do. Chunking is what makes that hold at any size: an import accepts ten times what one batch does, so without it a large roll would create everybody and invite nobody while reporting success | Invitations start sending without further action, as separate, independently-visible requests | PeopleImportProcessor.process, after transition(... "completed" ...) | N/A — happens after the HTTP response for the commit call has long since returned | A queueing failure is logged, not surfaced, and does not reopen the import | None dedicated |
Sending invitations requires Users_UPDATE, not an import or export permission | Granting sign-in access is an identity change, and this route needs no spreadsheet at all | An operator holding only DataImport_* cannot invite people through this route | @Permissions("Users_UPDATE") on POST /data/invites | 403 from RoleGuard for anyone lacking it | Structural | None dedicated |
| A dead-letter row's claim is released if its enqueue fails | A claim that outlives a failed enqueue would be unrecoverable — replayed_at is exactly what the already-replayed refusal reads | The operator gets a 503 and can retry the same replay immediately | DeadLetterService.releaseClaim, matched on the claim's own replay_job_id | 503 JOB_FAILURE_REPLAY_ENQUEUE_FAILED | Conditional UPDATE so a slow failing replay cannot clear a different operator's successful claim | dead-letter.service.spec.ts |
| The failure queue can never be read unpaginated | Nothing prunes job_failures — a replayed row is still the record of why something never arrived | An operator cannot accidentally request every failure and stack trace the platform has ever recorded in one response | DeadLetterService.findAll | 400 PAGINATION_LIMIT_INVALID | Structural | dead-letter.service.spec.ts |
| An export request's row and its outbox event commit together | A crash between the two would leave a request stuck pending forever, with no reaper watching for it | An export the operator requested either fully exists or does not exist at all — never half | DataTransferRequestService.create's optional executor, used inside requestExport's transaction | N/A | Structural | None dedicated |
12.4 Tradeoffs and Product Rationale
| Product Decision | User Benefit | Engineering Benefit | Alternative | Tradeoff | Risk |
|---|---|---|---|---|---|
| Two-step upload-then-commit | An operator cannot accidentally create thousands of wrong records with one click | Isolates parsing/validation (fast, synchronous) from writing (slower, async, needs a worker) | One-step upload-and-write | An extra round trip and a second explicit permission requirement | None material — this is the module's core safety property |
| Content-hash idempotency, globally scoped | Protects against the most common real-world duplicate scenario (two staff uploading the same file) | One index, one ON CONFLICT, no per-actor bookkeeping | Per-actor idempotency | A deliberate identical re-upload by a different operator is indistinguishable from an accident, and returns the old report | Low — matches the stated business intent exactly |
| Async commit and export via the outbox, not synchronous | The HTTP request returns fast even for a 5,000-row file | Consistent with every other async flow in the codebase; survives a crash between the DB write and the enqueue | Synchronous processing inside the HTTP request | The operator must poll (or be otherwise notified) rather than getting an immediate final answer | None material — a 5,000-row synchronous write would risk request timeouts |
| Export is a same-API download, never a signed URL | Nobody can bypass the permission check by forwarding a link | Works uniformly regardless of which storage driver is configured (the local driver cannot sign URLs at all) | Signed object-storage URL | Every download re-authorizes, which is marginally more expensive per byte served | None — this is a deliberate, permanent security boundary |
| An import's invitation batch is queued after the commit is marked completed, never inside the same transaction | The import's own success is never held hostage by a mail-queueing hiccup | Keeps the outbox pattern's atomicity property intact for the commit itself — its completion is one commit, and inviting the people it created is a separate, independently retryable request | Enqueue the invite batch inside the same transaction that marks the import completed | A queueing failure leaves a completed import with no invitations queued rather than a half-finished commit; the operator can invite the same people later from the people screens | Low — the failure is logged, and the import's own outcome is unaffected either way |
| Export row cap (20,000), silent truncation | Keeps a worker from being killed by an out-of-memory export | Simple, no separate pagination/streaming machinery for exports | Cursor-based streaming export with no cap | A very large school could get an incomplete file with no explicit "truncated" flag, discoverable only by checking rowCount | Low today (few schools approach this size), but present |
| One transaction per import row | A 2,000-row import surviving row 1,999 failing gives the operator 1,998 real records instead of zero | Avoids holding code_counters locked for the whole run | One transaction for the whole file | The import is no longer atomic as a unit — "did the import succeed" is answered by the report, not a single yes/no | None material — matches the stated design intent exactly |
12.5 Flow Edge-Case Matrix
| Flow | Edge Case | Trigger | Expected Behavior | User/System Feedback | Source |
|---|---|---|---|---|---|
| Upload | Empty state | A file with headers but zero data rows | Parses to totalRows: 0; nothing to commit | DATA_TRANSFER_HAS_BLOCKING_ISSUES if commit is attempted | parse-people-file.ts |
| Upload | First use | An operator's very first upload for a scope | No special-casing; behaves identically to any other upload | Standard report | — |
| Upload | Last item | The final row of a file | Numbered like every other row (dataIndex + 2) | No special-casing | parse-people-file.ts:198 |
| Upload | Duplicate action | Identical file uploaded twice | Second call is a no-op returning the first's report | alreadyExisted: true | data-transfer.service.ts:117-124 |
| Commit | Concurrent action | Two commit calls racing | Exactly one wins the status transition | 409 for the loser | data-transfer.service.ts:240-253 |
| Commit | Expired state | Attempting to commit a request that is already completed/failed | Refused | 409 DATA_TRANSFER_NOT_COMMITTABLE with a status-specific message | data-transfer.service.ts:213-221 |
| Commit / any route | Permission mismatch | Caller lacks the required permission code | Refused before the controller body runs | 403 from RoleGuard | Route decorators |
| Commit / any route | Guest limitation | N/A | No route accepts an unauthenticated caller | 401 | Controller-level guards |
| Commit | Missing dependency | The stored file has been deleted from storage | Commit is accepted, but the worker fails | Request marked failed with a storage-error message | people-import.processor.ts:55-62 |
| Export | Cache stale/miss | N/A — this module has no cache layer | — | — | — |
| Export | Queue failure | The people queue's worker is down | Request stays pending indefinitely | No user-facing error — an operational condition, visible via the runbook | Backend doc §16.7 |
| Export | Unsupported filter or sort option | An unrecognised filter key on requestExport, or the ListJobFailuresQueryDto's sort/order/search fields (inherited from QueryDto but not actually used by DeadLetterService.findAll) | Ignored silently rather than erroring | The response simply does not reflect the unsupported option | people-export-reader.service.ts filter allowlists; dead-letter.service.ts:63-113 hardcodes its own sort |
| Invites | Duplicate action | The identical userIds batch submitted twice | Two independent requests, two independent batches — never deduplicated, unlike an import upload | Two invitation emails per person, one from each batch | data-transfer.service.ts:337-367 |
| Invites | Empty state | userIds empty after de-duplication | Refused before anything is written | 400 VALIDATION_FAILED | data-transfer.service.ts:324-329 |
| Invites | Missing dependency | A listed person was deleted, banned, or had sign-in switched off between the request and the worker running | Excluded from the send; counted separately in the outcome | notInvitable count in the completed request's outcome, not an error | people-invite.processor.ts:70-88,126-128 |
| Dead letters | Empty result | No failures for the people queue | Empty list, valid pagination metadata | 200, empty data array | dead-letter.service.ts:63-113 |
| Dead letters | Concurrent action, enqueue side | A replay's claim succeeds but the enqueue to BullMQ then fails | The claim is released rather than left stranded | 503 JOB_FAILURE_REPLAY_ENQUEUE_FAILED; the row is replayable again immediately | dead-letter.service.ts:194-219 |
| Dead letters | Unsupported filter or sort option, pagination form | pagination=false | Refused outright rather than silently ignored, unlike the other unsupported options on this endpoint | 400 PAGINATION_LIMIT_INVALID | dead-letter.service.ts:71-76 |
12.6 Flow-to-Data Trace
| Flow | Reads | Writes | Cache | Jobs/Events | Response Fields |
|---|---|---|---|---|---|
| Upload | Uploaded file bytes | async_requests, storage (new upload only) | None | None | dryRun, requestId, status, report.* |
| Commit | async_requests | async_requests, outbox_events | None | IMPORT_COMMIT enqueued | status: committing, later outcome.* |
| Import applied (worker) | async_requests, stored file | users, scope table, student_guardian, async_requests | None | Chains a SEND_INVITE_BATCH request (via the outbox) when sendInvites was set and rows were created | (not an HTTP response — visible via the next poll) |
| Request export | Caller's role permissions | async_requests, outbox_events (one transaction) | None | EXPORT_BUILD enqueued | requestId, status: pending, downloadUrl: null |
| Export built (worker) | Scope table + users (role-scoped) | Storage, async_requests | None | None | (visible via the next poll) |
| Download | async_requests, storage | None | None | None | File bytes |
| Send invitations | Submitted userIds | async_requests, outbox_events (one transaction) | None | SEND_INVITE_BATCH enqueued | requestId, status: pending |
| Invitation batch built (worker) | users (re-read, filtered to eligible people) | async_requests outcome | None | None | (not an HTTP response — visible via the next poll); sends emails |
| List/replay dead letters | job_failures | job_failures (replay only; released again if the enqueue fails) | None | Fresh job enqueued (replay only) | JobFailureDto[] / single JobFailureDto |
12.7 Experience Quality Checklist
- The doc explains what the actor is trying to accomplish (turn a spreadsheet into records, or records into a spreadsheet, safely).
- The doc explains what the backend does that the actor does not see (per-row transactions, content-hash idempotency, role-gated column decisions made at request time, the outbox pairing).
- The doc covers every minor flow and branch (§12.1, §12.5).
- The doc includes user, admin, worker, and system flows where applicable.
- The doc explains business logic, tradeoffs, and rationale (§12.3, §12.4), including how an import's own invitation batch is chained.
- The doc maps every flow to API routes and backend side effects (§12.6).
- The doc includes diagrams appropriate to each flow type.
- The doc covers all edge cases and failure recovery (§10, §12.5).
13. Completion Checklist
- Every feature, minor action, and submodule capability is listed.
- Every actor has allowed and forbidden behavior.
- Every major and minor flow includes steps, branches, and diagrams.
- Every lifecycle has a transition table and state diagram.
- Every flow links to the API and backend docs.
- TDD dependencies are called out where they shape behavior — none exist for this module; none of the reviewed source references a separate TDD document.
See Also
- API doc:
/docs/developer/data-transfer/api - Backend doc:
/docs/developer/data-transfer/backend - TDD: not present for this module
Classes API Reference
Complete API contracts for grades, sections, rooms, classes, and student class enrolments, including routes, auth, DTOs, responses, errors, and examples.
Data Transfer Backend Documentation
Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for Data Transfer.