Skoolsewa - Ecommerce Docs
Developer ResourcesData transfer

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 TypeFiles or DocsWhat Was Extracted
Backend docdata-transfer/backend.mdx, and directly data-transfer.service.ts, shared/*.ts, workers/*.tsService behavior, transaction boundaries, state transitions, queue behavior.
APIdata-transfer/api.mdx, and directly data-transfer.controller.ts, dto/data-transfer.dto.tsRoute surface, actors, permissions, response shapes.
Schemapackages/db/src/schema/async-request/async-requests.ts, packages/db/src/schema/jobs/job-failures.tsStatus values, idempotency scope, dead-letter fields.
Templatesapps/api/src/utils/data-transfer/templates.tsEvery column, its requiredness, and its allowed values, per scope.
Parserapps/api/src/utils/data-transfer/parse-people-file.tsExact validation rules and row-numbering convention.

2. Feature Summary

FieldValue
Moduledata-transfer
SubmoduleN/A (import, export, and dead letters are one cohesive surface)
Primary user valueAn 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.
ActorsLogged-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 outputsA 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 docsAPI, Backend

3. Actor Matrix

ActorCan DoCannot DoAuth RequirementNotes
Guest / unauthenticatedNothingEverything — no route in this module is public or guest-accessibleNone grantedJwtAuthGuard + 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 lettersRead 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_UPDATESend account-activation invitations to a batch of people, whether or not any of them arrived via an importImport or export anything — Users_UPDATE carries no import/export permissionJWT + Users_UPDATEDeliberately 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.
SuperadminEverything an operator can, plus read/act on any operator's import or export requestStill limited to the people queue for dead-letter replay — no bypass on REPLAYABLE_QUEUESJWT, activeRole.isSuperadminThe 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 failureChoose 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 payloadEvery handler re-reads async_requests rather than trusting its payload, because delivery is at-least-once.

4. Capability Matrix

CapabilitySurfaceActorRoute/TriggerState ReadState WrittenLinked API Section
Download an import templateAdminOperator (DataImport_READ)GET /data/templates/:scopeNoneNoneAPI §8.1
Upload and dry-run validate a fileAdminOperator (DataImport_CREATE)POST /data/importsNoneasync_requests (insert-or-get)API §8.2
View an import's report/statusAdminOperator (owner) or superadmin (DataImport_READ)GET /data/imports/:requestIdasync_requestsNoneAPI §8.3
Commit (apply) a validated importAdminOperator (owner, DataImport_UPDATE)POST /data/imports/:requestId/commitasync_requestsasync_requests status, outbox_eventsAPI §8.4
Apply the import (async)WorkerSystemPeopleJob.IMPORT_COMMITasync_requests, stored fileusers, students/guardians/staff, student_guardian, async_requests outcomeBackend §7.2
Request an exportAdminOperator (DataExport_CREATE)POST /data/exportsCaller's active-role permissionsasync_requests, outbox_eventsAPI §8.5
Build the export (async)WorkerSystemPeopleJob.EXPORT_BUILDstudents/guardians/staff+users (role-scoped)File in storage, async_requests resultBackend §7.3
View an export's status/download linkAdminOperator (owner) or superadmin (DataExport_READ)GET /data/exports/:requestIdasync_requestsNoneAPI §8.6
Download a built exportAdminOperator (owner) or superadmin (DataExport_READ)GET /data/exports/:requestId/downloadasync_requests, storageNoneAPI §8.7
Send account invitationsAdminOperator (Users_UPDATE)POST /data/invitesNoneasync_requests, outbox_eventsAPI §8.10
List processing dead lettersAdminOperator (DataImport_READ)GET /data/job-failuresjob_failures (pinned to people queue)NoneAPI §8.8
Replay a dead letterAdminOperator (DataImport_UPDATE)POST /data/job-failures/:publicId/replayjob_failuresjob_failures (claim, released again if the enqueue fails), BullMQ (fresh job)API §8.9
Send an invitation batch (async)WorkerSystemPeopleJob.SEND_INVITE_BATCHusers, async_requestsEmails sent, async_requests outcomeTriggered by POST /data/invites, and chained automatically after a committed import whose upload asked for invitations
Record a terminal job failureWorkerSystemAny PeopleJob failing its final attemptBullMQ job metadatajob_failuresBackend §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 .csv or .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

StepActor/SystemActionResultSource
1OperatorSelects scope (students/guardians/staff) and a file, optionally toggling "send invites," and uploads.Multipart request sent.data-transfer.controller.ts:106-125
2BackendChecks 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
3BackendParses 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
4BackendFingerprints 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
5BackendStores the file (only for a genuinely new upload).The bytes are available for the later commit call.data-transfer.service.ts:169-180
6BackendReturns the report.Operator sees exactly what will happen if they commit.data-transfer.controller.ts:117-125

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Validation failure at uploadMissing/oversized/wrong-type fileRejected before parsing.400 DATA_TRANSFER_FILE_REQUIRED/IMPORT_FILE_TOO_LARGE
Missing required headerse.g. no "Admission Date" columnParsing stops early; nothing per-row is validated.missingHeaders populated, rows: [] — this file cannot later be committed
Duplicate uploadByte-identical file, same scope, uploaded before (by anyone)No new report generated.alreadyExisted: true, existing report returned, distinct message
Guest branchN/A — no guest access exists on this route401/403 from the guard chain
Unknown extra columnA header the template does not expectReported, 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

StepActor/SystemActionResultSource
1OperatorCalls commit on the request id from the dry run.Ownership and status re-checked.data-transfer.controller.ts:143-152
2BackendFlips status pending -> committing and schedules the work — one transaction.Either both happen or neither does.data-transfer.service.ts:240-273
3BackendReturns the now-committing request.Operator sees the import is in progress.data-transfer.service.ts:275
4WorkerRe-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
5WorkerMarks 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
6WorkerIf 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

BranchConditionBehaviorError/Result
Double-click commitTwo commit calls for the same requestOnly one status transition can win.The second gets 409 DATA_TRANSFER_NOT_COMMITTABLE
Not the ownerSomeone other than the uploader (non-superadmin) tries to commitRefused outright, before any status check.403 DATA_TRANSFER_NOT_YOURS
Everything in the file was invalidvalidRows === 0Nothing to commit.409 DATA_TRANSFER_HAS_BLOCKING_ISSUES
Duplicate job deliveryThe outbox redelivers IMPORT_COMMITWorker finds status no longer committing; does nothing.Silent no-op, logged
A row fails at write time despite passing dry-run validatione.g. a race with another process creating a conflicting emailThat 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 listedTypo, or a classification never addedThat 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 ranManual/operational deletionThe whole commit fails.Request marked failed; error message names the cause
More people were created than one invitation batch holdsAn import accepts up to 5,000 rows; an invitation batch holds at most 500The 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 failsThe invite request/outbox insert throws (e.g. a database hiccup) after the import already committedThe 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_CREATE to request, and DataExport_READ to 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

StepActor/SystemActionResultSource
1OperatorRequests 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
2WorkerReads 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
3OperatorPolls status.downloadUrl appears only once the file is actually ready.data-transfer.service.ts:325-350
4OperatorDownloads.Ownership re-checked; bytes streamed through this API.data-transfer.service.ts:360-389

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Download attempted earlyStatus still pendingRefused.409 DATA_TRANSFER_EXPORT_NOT_READY ("still being built")
Export build failedStorage error, or any thrown error during buildMarked failed.409 DATA_TRANSFER_EXPORT_NOT_READY ("failed to build. Request it again.")
Caller lacks a gated permissionNo StaffSalary_READ/StudentMedical_READThose columns are absent from the file entirely — not blank.No error; the file simply has fewer columns
More matching rows than the capOver 20,000 matching recordsFile 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 usedForwarded URL, no DataExport_READ or not the ownerRefused independently of the status call.403 DATA_TRANSFER_NOT_YOURS / permission 403
Requesting the same export twiceTwo identical POST /data/exports callsEach 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

StepActor/SystemActionResultSource
1OperatorRequests a template for a scope, optionally choosing CSV over the default XLSX.A file streams back immediately.data-transfer.controller.ts:72-97
2BackendBuilds 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

BranchConditionBehaviorError/Result
Unknown scope in the URLA 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 namesFormat = csvA 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

GradeSectionShiftOutcome
blankblankblankNo enrolment. The ordinary case, and what every existing file does.
blankblanksetRow issue — a shift is not a class.
setblankanyRow issue naming the missing section.
blanksetanyRow issue naming the missing grade.
setsetblankOne shift runs it → resolved silently. Both → row issue.
setsetsetExact 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-failuresDataImport_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/replayDataImport_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/invitesUsers_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)

FromEvent/ActionToGuard ConditionSide Effects
(new row)Upload validated (new file)pendingFile parsed; idempotency key did not already existFile stored
pendingCommit requestedcommittingCaller owns the request; status is currently pending; report has valid rows and no missing headersOutbox row inserted, same transaction
committingWorker applies rows successfullycompletedWorker finds status still committing (guards redelivery)completedAt set; outcome attached
committingWorker cannot proceed (storage missing, or a thrown error)failedSame redelivery guardcompletedAt set; error set

async_requests.status (export)

FromEvent/ActionToGuard ConditionSide Effects
(new row)Export requestedpendingOutbox row inserted, same transaction
pendingWorker builds the file successfullycompletedWorker finds status still pendingcompletedAt set; storageKey, rowCount attached
pendingWorker fails (read error, storage error)failedSame guardcompletedAt 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)

FromEvent/ActionToGuard ConditionSide Effects
(unreplayed)Job fails on its final BullMQ attemptrow createdattemptsMade >= maxAttemptsRow inserted with replayed_at: null, a fresh public_id
unreplayedOperator replays it, enqueue succeedsreplayedreplayed_at IS NULL at claim timereplayed_at, replayed_by, replay_job_id set; a fresh BullMQ job enqueued
unreplayed (briefly claimed)Operator replays it, but the enqueue itself failsunreplayed againClaim's own replay_job_id matchesreplayed_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

FlowDB WritesCache EffectsJobsRealtimeAnalyticsNotifications
Upload (dry run)async_requests insert-or-getNoneNoneNoneNoneNone
Commitasync_requests status, outbox_events insertNoneEnqueues IMPORT_COMMIT (via outbox)NoneNoneNone
Import applied (worker)users, students/guardians/staff, student_guardian, async_requests outcomeNoneChains an invitation-batch request (via the outbox) when the upload asked for invitations and rows were createdNoneNoneNone directly — see the invitation rows below
Request exportasync_requests insert, outbox_events insert (one transaction)NoneEnqueues EXPORT_BUILD (via outbox)NoneNoneNone
Export built (worker)async_requests resultNoneNoneNoneNoneNone
Download template/exportNoneNoneNoneNoneNoneNone
Send invitationsasync_requests insert, outbox_events insert (one transaction)NoneEnqueues SEND_INVITE_BATCH (via outbox)NoneNoneNone
Send invite batch (worker)users (read), async_requests outcomeNoneNoneNoneNonePassword-reset-style invitation emails, per person
List dead lettersNone (read)NoneNoneNoneNoneNone
Replay dead letterjob_failures claim (released again if the enqueue fails)Nonequeue.add a fresh job on the original queueNoneNoneNone
Job fails terminally (worker)job_failures insertNoneNoneNoneNoneNone

10. Error and Recovery Flows

ScenarioTriggerUser/System ExperienceRecoverySource
Upload rejected for size/typeFile too large or wrong extension400 before any parsing happensFix the file and re-uploadspreadsheetMulterOptions, data-transfer.service.ts:109-116
File has blocking validation issuesMissing headers, or every row invalidDry-run report shows the problem; commit is refused if attempted anywayFix the file per the report, re-uploadparse-people-file.ts, data-transfer.service.ts:223-236
Commit race lostTwo commit attempts on one requestLoser gets 409Poll the request; someone else's commit is already runningdata-transfer.service.ts:240-253
Queue/Redis failure at enqueue timeOutbox dispatcher cannot reach RedisThe async_requests row and the outbox row still both exist (committed together); dispatch itself retries independentlyHandled entirely by the outbox module's own retry/dead-letter path — outside this modulepackages/db/src/schema/outbox/outbox-events.ts
Worker crashes mid-importProcess restart between rowsRows 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 investigatedOperational — see the runbook in the backend docpeople-import-writer.service.ts:80-99
Job fails all its attemptsTransient error (e.g. storage timeout) persists past retriesRecorded in job_failures; the underlying async_requests row is separately marked failed by the processor's own catch blockOperator reviews the dead letter, then either replays it or fixes the root cause and re-requestspeople-queue.processor.ts:99-137
Dead letter belongs to a different queueAn operator without knowledge of the queue boundary tries to replay a backup/restore failure from this screenRefused with a specific message, not a generic errorUse the tooling for that queue insteaddead-letter.service.ts:132-140
Replay's enqueue cannot reach the queueRedis unreachable, or the target queue is unresolvable, at the moment queue.add runsThe claim already written is released back to unreplayed; the operator sees a 503, not a false successTry the replay again once the queue is reachabledead-letter.service.ts:194-219
The failure queue is asked for an unpaginated readpagination=false on GET /data/job-failuresRefused outright — nothing prunes this table, so an unbounded read asks the process to buffer every row and stack trace at oncePage through the results insteaddead-letter.service.ts:71-76
Invitation batch too largeMore than 500 userIds on POST /data/invitesRefused before anything is queuedSplit the batch — an import does this for itself, in chunks of 500data-transfer.service.ts (requestInvites)
Export download requested for someone else's requestA forwarded or guessed download URLRefused, independent of any earlier status checkAsk the original requester, or a superadmindata-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

FeatureMinor BehaviorActorTriggerUser/System ResultBackend Side EffectSource
Template downloadUnknown scope in the URL silently defaults to studentsOperatorGET /data/templates/:bad-scopeGets the students template, not an errorNonedata-transfer.controller.ts:80-82
Template downloadCSV gets a UTF-8 BOM; XLSX does not need oneOperator?format=csvDevanagari example names render correctly in Excel on WindowsNonetemplates.ts:359-364
UploadBlank cells are trimmed and become undefined, not ""SystemAny uploadOptional fields left blank in the spreadsheet do not fail an enum/date writeNone visible to the operatorpeople-import-writer.service.ts:119-122
UploadA trailing all-blank row (common after saving in Excel) is skipped silentlySystemAny uploadNot counted as a row at all, not reported as an errorNoneparse-people-file.ts:206-209
UploadDuplicate upload of the identical file returns the earlier report with a distinguishing messageOperatorRe-upload of an identical filealreadyExisted: true; UI can tell the operator this is not newNone — no second async_requests rowdata-transfer.controller.ts:117-124
UploadUnknown extra columns are reported but do not block anythingOperatorA file with the operator's own working columns alongside the template'sListed in unknownHeaders, import proceedsNoneparse-people-file.ts:158-160
CommitRows the dry run already flagged invalid are never attempted again at write timeSystemCommit of a file with some invalid rowsOnly one message per problem row, not twoNonepeople-import.processor.ts:68-75
CommitAdmission/employee numbers allocated once, up front, for the whole fileSystemAny student/staff importA failed row leaves a gap in the number sequence rather than shifting later rows' numberscode_counters locked briefly, once, not once per rowpeople-import-writer.service.ts:73-78
CommitTwo children sharing a phone number are attached to one guardian record; two different phone numbers create two guardians even if names look similarSystemStudent rows with guardian infoFewer duplicate guardian records for a familyGuardian lookup by phone, not namepeople-import-writer.service.ts:200-239
CommitAn unknown department/designation name refuses the row rather than silently creating itSystemStaff import with a typo'd departmentThe row fails with a specific, correctable messageNo new departments/designations row createdpeople-import-writer.service.ts:359-421
CommitA staff row with only one of basic salary / allowances gets the other defaulted to zero, not left blankSystemPartial salary dataRow succeeds instead of failing a pairing checktotal_salary computed rather than NULLpeople-import-writer.service.ts:331-340
CommitEthnicity and mother tongue on the spreadsheet are names, matched case- and whitespace-insensitively against the school's own listsSystemA row naming "Rai " with trailing whitespace or mixed caseMatches the same classification as an exact-cased entryNone if matched; the row fails with IMPORT_UNKNOWN_ETHNICITY/IMPORT_UNKNOWN_MOTHER_TONGUE if it does notpeople-import-writer.service.ts:454-479
CommitA student row also carries an IEMIS ID columnSystemA file with a value in "IEMIS ID"Stored on the student row as text, so a leading zero survivesNonepeople-import-writer.service.ts:202
CommitImported people can only sign in when the upload asked for invitationsSystemAny importEvery row's can_login follows the upload's own sendInvites choice, never a per-row valueNonepeople-import-writer.service.ts:84-90
ExportGated columns (salary, medical) are omitted as whole columns for a caller without the permission — never blankedSystemExport request without StaffSalary_READ/StudentMedical_READThe file has fewer headers, not empty cells under sensitive headersNonepeople-export-reader.service.ts:23-28
ExportBank and statutory identifiers (bank name, account number, branch, PAN, citizenship, SSF, CIT numbers) ride the salary gate, not the staff oneSystemExport request without StaffSalary_READThose 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 areNonepeople-export-reader.service.ts:367-386
ExportEthnicity and mother tongue are emitted as names, round-trippable straight back into the import templateSystemAny exportThe file can be corrected and re-imported without translating idsNonepeople-export-reader.service.ts
ExportRow cap of 20,000 truncates silentlySystemA very large matching setFile is complete only up to the cap; rowCount reflects the truncated countNonepeople-export-reader.service.ts:66
ExportCells starting with =, +, -, @, tab, or CR are quote-prefixedSystemAny export containing free-text with those leading charactersThe cell displays literally instead of executing as a formula when openedNonebuild-export-file.ts:15,28-30
ExportDownload URL only appears once the file is actually storedOperatorPolling before the build finishesdownloadUrl: null until ready — no broken link is ever handed outNonedata-transfer.service.ts:332-349
ExportTwo identical export requests never dedupeOperatorRequesting the same scope/filters twiceTwo independent files, both reflecting data at their own build timeTwo async_requests rows, each written in the same transaction as its own outbox eventshared/data-transfer-request.service.ts:127-152
InvitesThe same person listed twice in one batch is invited onceOperatorDuplicate userIds on POST /data/invitesDe-duplicated before anything is queued, order preservedOne invitation, not two competing tokensdata-transfer.service.ts:318-322
InvitesAn import that asked for invitations queues them itselfSystemA committed import with sendInvites: true and at least one created personOne or more further async_requests rows (invite:batch) appear without any operator action — one per chunk of 500 created peopleAn outbox event enqueuing SEND_INVITE_BATCH per chunkpeople-import.processor.ts (queueInvites)
InvitesAn invitation link is good for a weekThe invited personAny invitation, however it was requestedThe link stays usable for 7 days, then stopsAn account_invite verification record with a 7-day expiry rather than the 15-minute OTP defaultpeople-invite.processor.ts
InvitesAn invitation carries no one-time codeThe invited personAny invitationThe email offers a link and nothing elseThe 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 thempeople-invite.processor.ts
Dead lettersThe queue filter cannot be widened by the query stringOperatorPassing ?queueName=backupSilently ignored; results are still scoped to peopleNonedead-letter.service.ts:296-305
Dead lettersReplay always uses a new job idSystemAny replayThe old failed job stays failed in BullMQ's own records; the new attempt is a distinct jobNonedead-letter.service.ts:160-164
Dead lettersA concurrent double-replay fails softly for the loserTwo operatorsBoth click Replay at onceOne succeeds, one gets 409 already replayedOnly one extra job enqueueddead-letter.service.ts:178-192
Dead lettersA replay whose enqueue fails releases its own claimSystemRedis unreachable at enqueue timeThe row is left unreplayed rather than permanently stuck, and the operator sees a 503 instead of a false successRow's replayed_at/replayed_by/replay_job_id reset to nulldead-letter.service.ts:208-219
Dead lettersThe failure queue cannot be read unpaginatedOperatorpagination=false on GET /data/job-failuresRefused, since nothing ever prunes this table400 PAGINATION_LIMIT_INVALIDdead-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

RuleBusiness ReasonActor ImpactEnforced InAPI ImpactBackend ImpactTests
Upload never writes a personBulk writes are irreversible at scale; a mis-click must not create thousands of wrong recordsOperator gets a report, never a fait accompliDataTransferService.uploadForDryRun (structurally — no write path exists)dryRun: true always on this responseNo path from upload to PeopleImportWriterNone dedicated
Import idempotency is content-based and globalTwo people uploading the same file is the normal way a roll doublesA second identical upload is a no-op, not a new importasync_requests_idempotency_idx, createOrGetalreadyExisted flag distinguishes itGlobal unique index, not scoped by actorNone dedicated
A request can only be acted on by its owner (or a superadmin)Reports and exports carry real personal dataOthers are refused even read accessassertOwnRequest403 DATA_TRANSFER_NOT_YOURSChecked fresh on every mutating and reading callNone dedicated
Export column visibility follows the requester's active role, fixed at request timeA worker cannot legitimately choose a role on the operator's behalfTwo requests by the same person under different active roles can produce different filesrequestExport's permissions.can calls, carried on the payloadNo client control over which columns appear beyond holding the permissionWorker trusts the payload's includeGatedColumns completelyNone dedicated
Downloads never leave this APIA signed storage URL is a bearer credential that bypasses the permission systemEvery download re-runs the guard chainreadExportFile, controller routesdownloadUrl is always a same-API pathNo signed-URL code path exists in this moduleNone dedicated
Dead-letter replay is scoped to the people queue onlyjob_failures is shared, global infrastructureAn operator cannot accidentally (or deliberately) re-run another domain's failed job from this screenDeadLetterService.REPLAYABLE_QUEUES409 JOB_FAILURE_QUEUE_NOT_PERMITTED for anything elseHardcoded array, not query-string drivenNone dedicated
A replay always gets a fresh job idBullMQ silently no-ops add() on a repeated idAn operator who replays sees an actual new attempt, not a false "success"buildJobId in DeadLetterService.replayThe replay response's replayJobId is always newStructuralNone dedicated
Admission/employee numbers are allocated atomically, once per batchConcurrent office staff must never receive the same numberNumbers in the report match numbers in the database exactlyPeopleCodeService.allocateThe preview/outcome shows the real allocated numbersSingle upsert-and-increment statementNone dedicated in this module
A staff row's salary fields are all-or-nothingThe people-module staff_salary_pair_coherent check requires both or neitherAn operator supplying only one salary figure does not get a rejected rowPeopleImportWriter.writeStaffN/ADefaults the missing half to "0"None dedicated
An unrecognised ethnicity or mother tongue name fails the row, never blanks itA silently blanked classification is indistinguishable from a deliberately empty one, and the school's official return would be short by however many rows were misspelledThe operator sees exactly which row and which name did not matchPeopleImportWriter.resolveClassificationRow-level IMPORT_UNKNOWN_ETHNICITY/IMPORT_UNKNOWN_MOTHER_TONGUEMatches on lower(btrim(name)), the same normalisation the unique index usespeople-import-writer.service.spec.ts
Imported people get sign-in access only when the upload asked for invitationsAn 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 droppedEvery row's can_login follows the upload's own choice, not any per-row dataPeopleImportWriter.apply's grantLogin parameterN/AStructural — no per-row override existspeople-import-writer.service.spec.ts
A committed import chains its own invitation batchesThe 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 successInvitations start sending without further action, as separate, independently-visible requestsPeopleImportProcessor.process, after transition(... "completed" ...)N/A — happens after the HTTP response for the commit call has long since returnedA queueing failure is logged, not surfaced, and does not reopen the importNone dedicated
Sending invitations requires Users_UPDATE, not an import or export permissionGranting sign-in access is an identity change, and this route needs no spreadsheet at allAn operator holding only DataImport_* cannot invite people through this route@Permissions("Users_UPDATE") on POST /data/invites403 from RoleGuard for anyone lacking itStructuralNone dedicated
A dead-letter row's claim is released if its enqueue failsA claim that outlives a failed enqueue would be unrecoverable — replayed_at is exactly what the already-replayed refusal readsThe operator gets a 503 and can retry the same replay immediatelyDeadLetterService.releaseClaim, matched on the claim's own replay_job_id503 JOB_FAILURE_REPLAY_ENQUEUE_FAILEDConditional UPDATE so a slow failing replay cannot clear a different operator's successful claimdead-letter.service.spec.ts
The failure queue can never be read unpaginatedNothing prunes job_failures — a replayed row is still the record of why something never arrivedAn operator cannot accidentally request every failure and stack trace the platform has ever recorded in one responseDeadLetterService.findAll400 PAGINATION_LIMIT_INVALIDStructuraldead-letter.service.spec.ts
An export request's row and its outbox event commit togetherA crash between the two would leave a request stuck pending forever, with no reaper watching for itAn export the operator requested either fully exists or does not exist at all — never halfDataTransferRequestService.create's optional executor, used inside requestExport's transactionN/AStructuralNone dedicated

12.4 Tradeoffs and Product Rationale

Product DecisionUser BenefitEngineering BenefitAlternativeTradeoffRisk
Two-step upload-then-commitAn operator cannot accidentally create thousands of wrong records with one clickIsolates parsing/validation (fast, synchronous) from writing (slower, async, needs a worker)One-step upload-and-writeAn extra round trip and a second explicit permission requirementNone material — this is the module's core safety property
Content-hash idempotency, globally scopedProtects against the most common real-world duplicate scenario (two staff uploading the same file)One index, one ON CONFLICT, no per-actor bookkeepingPer-actor idempotencyA deliberate identical re-upload by a different operator is indistinguishable from an accident, and returns the old reportLow — matches the stated business intent exactly
Async commit and export via the outbox, not synchronousThe HTTP request returns fast even for a 5,000-row fileConsistent with every other async flow in the codebase; survives a crash between the DB write and the enqueueSynchronous processing inside the HTTP requestThe operator must poll (or be otherwise notified) rather than getting an immediate final answerNone material — a 5,000-row synchronous write would risk request timeouts
Export is a same-API download, never a signed URLNobody can bypass the permission check by forwarding a linkWorks uniformly regardless of which storage driver is configured (the local driver cannot sign URLs at all)Signed object-storage URLEvery download re-authorizes, which is marginally more expensive per byte servedNone — this is a deliberate, permanent security boundary
An import's invitation batch is queued after the commit is marked completed, never inside the same transactionThe import's own success is never held hostage by a mail-queueing hiccupKeeps 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 requestEnqueue the invite batch inside the same transaction that marks the import completedA 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 screensLow — the failure is logged, and the import's own outcome is unaffected either way
Export row cap (20,000), silent truncationKeeps a worker from being killed by an out-of-memory exportSimple, no separate pagination/streaming machinery for exportsCursor-based streaming export with no capA very large school could get an incomplete file with no explicit "truncated" flag, discoverable only by checking rowCountLow today (few schools approach this size), but present
One transaction per import rowA 2,000-row import surviving row 1,999 failing gives the operator 1,998 real records instead of zeroAvoids holding code_counters locked for the whole runOne transaction for the whole fileThe import is no longer atomic as a unit — "did the import succeed" is answered by the report, not a single yes/noNone material — matches the stated design intent exactly

12.5 Flow Edge-Case Matrix

FlowEdge CaseTriggerExpected BehaviorUser/System FeedbackSource
UploadEmpty stateA file with headers but zero data rowsParses to totalRows: 0; nothing to commitDATA_TRANSFER_HAS_BLOCKING_ISSUES if commit is attemptedparse-people-file.ts
UploadFirst useAn operator's very first upload for a scopeNo special-casing; behaves identically to any other uploadStandard report
UploadLast itemThe final row of a fileNumbered like every other row (dataIndex + 2)No special-casingparse-people-file.ts:198
UploadDuplicate actionIdentical file uploaded twiceSecond call is a no-op returning the first's reportalreadyExisted: truedata-transfer.service.ts:117-124
CommitConcurrent actionTwo commit calls racingExactly one wins the status transition409 for the loserdata-transfer.service.ts:240-253
CommitExpired stateAttempting to commit a request that is already completed/failedRefused409 DATA_TRANSFER_NOT_COMMITTABLE with a status-specific messagedata-transfer.service.ts:213-221
Commit / any routePermission mismatchCaller lacks the required permission codeRefused before the controller body runs403 from RoleGuardRoute decorators
Commit / any routeGuest limitationN/ANo route accepts an unauthenticated caller401Controller-level guards
CommitMissing dependencyThe stored file has been deleted from storageCommit is accepted, but the worker failsRequest marked failed with a storage-error messagepeople-import.processor.ts:55-62
ExportCache stale/missN/A — this module has no cache layer
ExportQueue failureThe people queue's worker is downRequest stays pending indefinitelyNo user-facing error — an operational condition, visible via the runbookBackend doc §16.7
ExportUnsupported filter or sort optionAn 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 erroringThe response simply does not reflect the unsupported optionpeople-export-reader.service.ts filter allowlists; dead-letter.service.ts:63-113 hardcodes its own sort
InvitesDuplicate actionThe identical userIds batch submitted twiceTwo independent requests, two independent batches — never deduplicated, unlike an import uploadTwo invitation emails per person, one from each batchdata-transfer.service.ts:337-367
InvitesEmpty stateuserIds empty after de-duplicationRefused before anything is written400 VALIDATION_FAILEDdata-transfer.service.ts:324-329
InvitesMissing dependencyA listed person was deleted, banned, or had sign-in switched off between the request and the worker runningExcluded from the send; counted separately in the outcomenotInvitable count in the completed request's outcome, not an errorpeople-invite.processor.ts:70-88,126-128
Dead lettersEmpty resultNo failures for the people queueEmpty list, valid pagination metadata200, empty data arraydead-letter.service.ts:63-113
Dead lettersConcurrent action, enqueue sideA replay's claim succeeds but the enqueue to BullMQ then failsThe claim is released rather than left stranded503 JOB_FAILURE_REPLAY_ENQUEUE_FAILED; the row is replayable again immediatelydead-letter.service.ts:194-219
Dead lettersUnsupported filter or sort option, pagination formpagination=falseRefused outright rather than silently ignored, unlike the other unsupported options on this endpoint400 PAGINATION_LIMIT_INVALIDdead-letter.service.ts:71-76

12.6 Flow-to-Data Trace

FlowReadsWritesCacheJobs/EventsResponse Fields
UploadUploaded file bytesasync_requests, storage (new upload only)NoneNonedryRun, requestId, status, report.*
Commitasync_requestsasync_requests, outbox_eventsNoneIMPORT_COMMIT enqueuedstatus: committing, later outcome.*
Import applied (worker)async_requests, stored fileusers, scope table, student_guardian, async_requestsNoneChains 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 exportCaller's role permissionsasync_requests, outbox_events (one transaction)NoneEXPORT_BUILD enqueuedrequestId, status: pending, downloadUrl: null
Export built (worker)Scope table + users (role-scoped)Storage, async_requestsNoneNone(visible via the next poll)
Downloadasync_requests, storageNoneNoneNoneFile bytes
Send invitationsSubmitted userIdsasync_requests, outbox_events (one transaction)NoneSEND_INVITE_BATCH enqueuedrequestId, status: pending
Invitation batch built (worker)users (re-read, filtered to eligible people)async_requests outcomeNoneNone(not an HTTP response — visible via the next poll); sends emails
List/replay dead lettersjob_failuresjob_failures (replay only; released again if the enqueue fails)NoneFresh 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