Skoolsewa - School Management System Docs
Developer ResourcesID Cards

ID Cards Features and Flows

Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for ID Cards.

ID Cards Features and Flows

1. Documentation Evidence

Source TypeFiles or DocsWhat Was Extracted
Backend docid-cards/backend.mdx, apps/api/src/modules/id-cards/**Services, transactions, side effects, state transitions
APIid-cards/api.mdx, controllersRoute surface, actors, permissions
Pure rulesapps/api/src/utils/id-cards/id-card-rules.util.ts, id-card-design.tsValidity math, verdict logic, design schema
Schemapackages/db/src/schema/school/id-cards.ts, migrations 0033/0034Constraints that ARE the business rules (duplicate guard, append-only history)

2. Feature Summary

FieldValue
ModuleIdCards
SubmoduleTemplates, Assets, Settings, Generation, Cards, Verification, Workers
Primary user valueDesign a card once, generate it correctly for every student/teacher/staff member, and give front-office and gate staff a fast, permission-scoped way to verify who is holding it.
ActorsAdmin (template designer, front-office operator, gate/security operator), worker/system (generation runner, sweeps), guest (public verification only)
Main entry points/api/id-card-templates*, /api/id-card-generation*, /api/id-cards*, /api/id-card-scan, /api/id-card-verification/*, the two crons, the BullMQ processor
Main outputsCard and template records, rendered card images (print/download), append-only card history events, six notification kinds, printed cards carrying a QR code
Related docsAPI, Backend

3. Actor Matrix

ActorCan DoCannot DoAuth RequirementNotes
GuestView the public verification page and photo for a card whose token they possessList cards, search by name, see guardian contacts, see anything without a valid tokenNoneRate-limited 60/min/IP; every response carries no-store/noindex/no-referrer headers.
Admin — template designerDesign, publish, activate/deactivate, archive, assign, and default templates; upload/delete assetsGenerate a card without IdCards_CREATE; publish an invalid designJWT + IdCardTemplates_*A published version is immutable — editing the draft afterward never touches an already-generated card.
Admin — front officeGenerate cards (single/bulk), issue, print/reprint, replace, cancel, view dashboards and historyReport an incident without IdCardIncidents_UPDATE; see guardian contacts without Guardians_READJWT + IdCards_*, IdCardIssue_UPDATE, IdCardPrint_UPDATE, IdCardReplace_CREATEEvery reader also needs a people-read code (Students_READ/Staff_READ) — IdCards_READ alone shows nobody.
Admin — gate/security operatorScan a card by token or number and see enough to verify identitySee guardian contacts without Guardians_READ; get full detail from a card-number-only lookupJWT + IdCardVerify_READRate-limited 120/min/account. The single most sensitive read in the module.
Admin — superadminEverything above, plus configure public verification—JWT + IdCardPublicVerification_UPDATEThe only role that can turn the public page on or change what it shows.
Worker/systemProcess bulk generation jobs; run the two sweeps—Internal (BullMQ)Idempotent per lease token and per bucketed cron job id.

4. Capability Matrix

CapabilitySurfaceActorRoute/TriggerState ReadState WrittenLinked API Section
Design a card layoutAdminDesignerPATCH /id-card-templates/:publicIdDraft designdraft_design, draft_hashAPI §8.1-adjacent
Publish an immutable versionAdminDesignerPOST .../publishDraft designNew id_card_template_versions rowAPI §4
Scope a template to a grade/section/departmentAdminDesignerPOST /id-card-template-assignmentsExisting assignmentsNew assignment rowAPI §4
Upload a logo/seal/signatureAdminDesignerPOST /id-card-assets—New asset row, storage bytesAPI §4
Review generation candidates and readinessAdminFront officeGET /id-card-generation/candidatesStudents/staff, existing cardsnoneAPI §4
Generate one cardAdminFront officePOST /id-card-generation/cardsTemplate, settings, personid_cards, id_card_eventsAPI §4
Submit a bulk generation jobAdminFront officePOST /id-card-generation-jobs—Job + item rows, outbox eventAPI §8.1
Cancel/retry a jobAdminFront officePOST .../cancel, .../retryJob row (locked)status, item statusesAPI §8.4, §8.5
Mark a card printed / reprintAdminFront officePOST /id-cards/print/*Card rowprinted_at, print_countAPI §4
Issue a cardAdminFront officePOST /id-cards/issueCard row, holder's prior active cardstatus = active, closes prior cardAPI §8.7
Report an incidentAdminFront officePOST /id-cards/:publicId/incidentsCard rowstatus = lost/stolen/damaged, closed_atAPI §4
Generate a replacementAdminFront officePOST /id-cards/:publicId/replacementsPredecessor cardNew id_cards row, predecessor replacedAPI §4
Cancel a cardAdminFront officePOST /id-cards/:publicId/cancelCard rowstatus = cancelledAPI §4
Scan a cardAdminGate operatorPOST /id-card-scanCard, holder, guardians (gated)id_card_events (verified)API §8.8
Verify publiclyGuestAnyone with a tokenGET /id-card-verification/:tokenCard, settings' public-show flagsid_card_events (verified)API §8.9
Recover a stuck generation jobWorkerSystem5-minute cronJobs with expired leaserecovery_count, re-enqueueBackend §7.4
Expire, notify, deactivate, pruneWorkerSystemHourly cronActive cards, leavers, old verified eventsstatus, expiry_notified_at, event deletionBackend §7.4

5. User-Facing Flows

5.1 Designing and Publishing a Template

Summary

A designer creates a draft template for a card type, lays out front and back sides in millimetres, references uploaded assets and dynamic field keys (photo, name, roll number, guardian name, etc.), validates it, and publishes an immutable version before it can be used for generation.

Preconditions

  • Caller holds IdCardTemplates_CREATE/_UPDATE.
  • For a student template bound to a session, the session must exist.
  • Any asset referenced in the design must already be uploaded.

Main Flow

StepActor/SystemActionResultSource
1DesignerCreates a draft with a card type and orientationNew draft templateIdCardTemplatesService.create
2DesignerEdits draftDesign (elements, positions, field keys)Design validated structurallyid-card-design.ts
3DesignerCalls validation to check for errors before publishingValidation reportGET .../validation
4DesignerPublishesImmutable id_card_template_versions row; template flips to active/inactiveIdCardTemplatesLifecycleService.publish
5DesignerOptionally sets the template as default for its card typeis_default = true, unset on any prior defaultsetDefault()

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Invalid designAn element overlaps the safe margin, or references a missing field keyReject publishID_CARD_TEMPLATE_INVALID_DESIGN
Design too largeSerialized JSON exceeds the size capReject publishID_CARD_TEMPLATE_DESIGN_TOO_LARGE
Name collisionAnother live template shares the name (case-insensitive)Reject create/renameID_CARD_TEMPLATE_NAME_TAKEN
Default on non-activeAttempted to default a draft/inactive/session-bound templateRejectID_CARD_TEMPLATE_DEFAULT_NOT_ALLOWED
Archive in useA card still references one of this template's versionsReject archiveID_CARD_TEMPLATE_IN_USE
Duplicate actionPublishing twice with no draft changeEach publish creates a NEW version regardless — versions are never deduplicated by contentNew version_no each time

5.2 Single Card Generation

Summary

Front office generates one person's card immediately — used for a walk-in replacement need or a single late enrolment, where waiting for a bulk batch is unnecessary.

Preconditions

  • Caller holds IdCards_CREATE and a people-read code for the target's type.
  • Readiness review passes (required photo, roll number, etc., per settings).
  • Person holds no active or pending card already (unless this call is itself a replacement, which is a separate route).

Main Flow

StepActor/SystemActionResultSource
1Front officeSelects a person and (optionally) a templateCandidate validatedIdCardGenerationController.candidates/.people
2BackendRuns readiness reviewPass/fail with reasonsIdCardReadinessService
3BackendResolves the template (explicit, or via assignment rules / default)Active, matching-type template resolvedIdCardTemplateResolverService
4BackendComputes validity window (session-based for students, months-based for staff) or accepts an overridevalidFrom/validUntil setstudentValidity()/staffValidity()/checkValidityOverride()
5BackendGenerates the card number from the sequence + settings prefix/paddingcard_number assignedid_card_number_seq
6BackendWrites the card and its generated eventNew id_cards rowIdCardGenerateService

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Active card existsPerson already holds an active cardRejectID_CARD_ACTIVE_CARD_EXISTS
Pending card existsPerson already holds generated/printedRejectID_CARD_PENDING_CARD_EXISTS
Readiness failureRequired photo/roll number missingRejectID_CARD_PERSON_NOT_READY
Session endedStudent's session end date has passedRejectID_CARD_SESSION_ENDED
Bad overrideAdministrator-supplied validity fails checkValidityOverride (incomplete, inverted, already ended, too long, beyond session grace)RejectID_CARD_VALIDITY_INVALID
Duplicate actionSame request retried after a transient failureNot idempotent at this route — the caller must check state before retryingN/A

5.3 Bulk Generation Job

Summary

Front office selects up to 2000 people (a whole grade, a whole department) and submits one job. The job runs asynchronously; each person's outcome is tracked independently so one failure never blocks the batch.

Preconditions

  • Caller holds IdCards_CREATE.
  • Caller has fewer than the per-account cap of open jobs.
  • clientRequestId is a fresh UUID (or a deliberate replay of a known one).

Main Flow

StepActor/SystemActionResultSource
1Front officeSelects people, template, existing-card policyJob payload assembledClient
2BackendTakes a per-user advisory lock, checks the open-job capPass or ID_CARD_JOB_LIMIT_REACHEDIdCardGenerationJobsService.create
3BackendWrites the job + one item per person + an outbox event, in one transactionqueued jobSchema §5.3
4Outbox dispatcherRelays the job onto BullMQPROCESS_GENERATION_JOB enqueuedOutbox invariant
5WorkerClaims a lease, sets running, processes each pending item in its own transactionItem outcomes recorded incrementallyIdCardGenerationRunnerService
6WorkerSets the final job status from the item outcomescompleted / completed_with_errors / failedsame
7BackendNotifies the requesterIn-app/email notificationIdCardNotifierService

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Open-job capCaller has too many open jobsReject at submitID_CARD_JOB_LIMIT_REACHED
Concurrent submit, same userTwo submits race the capSerialized by an advisory lock, so the cap cannot be racedSecond submit sees the lock, then a fresh (correct) count
Duplicate submitSame clientRequestId resubmittedReturns the original job, no new rowID_CARD_JOB_DUPLICATE_REQUEST semantics (idempotent, not an error to the caller)
Person already has a card, policy skipEncountered mid-runItem marked skipped with an outcome code; job continuesNon-fatal to the batch
Person already has a card, policy replaceEncountered mid-runItem generates a replacement card, closing the old oneSame as §5.5
Worker crash mid-jobLease expires before the worker finishesThe 5-minute recovery sweep reclaims the job and resumes itBackend §7.4
Cancel mid-runCaller cancels while runningJob moves to cancelling, then cancelled once the worker observes it between itemsAPI §8.4
Retry after partial failureCaller retries a completed_with_errors jobFailed/skipped items reset to pending and re-enqueued; successfully generated items are untouchedAPI §8.5
Row-scope mismatchA caller with a restricted people scope tries to read/act on another user's job404, indistinguishable from "does not exist"Backend §6.3

5.4 Issuing a Card

Summary

Front office hands a physical card to its holder. This is the moment the card becomes the holder's live, active credential.

Preconditions

  • Card is generated or printed (or, if settings allow issuing without printing, generated alone suffices).
  • The card's validFrom is not in the future.

Main Flow

StepActor/SystemActionResultSource
1Front officeConfirms the physical handoverIssue request sentClient
2BackendChecks the card is in an issuable state and its validity window has begunPass or rejectIdCardIssueActionService
3BackendIf the holder has a prior active card, closes it in the same transactionPrior card's status changes away from activeDatabase's non-deferrable unique index requires this ordering
4BackendSets the new card active, records issued_atCard activesame
5BackendWrites an issued event and notifiesHistory + notificationid_card_events, IdCardNotifierService

Branches and Edge Cases

BranchConditionBehaviorError/Result
Not yet validvalidFrom is in the futureRejectID_CARD_NOT_YET_VALID
Wrong statusCard is not generated/printedRejectID_CARD_INVALID_TRANSITION
Print requiredallow_issue_without_print is off and the card was never printedRejectID_CARD_INVALID_TRANSITION

5.5 Incident and Replacement

Summary

A card is lost, stolen, or damaged; front office records the incident (which is irreversible — the card can never return to active) and generates a replacement, which supersedes the old card and carries its own history link back to the predecessor.

Main Flow

StepActor/SystemActionResultSource
1Front officeReports the incident with a reasonCard status → lost/stolen/damaged, closed_at setIdCardActionsController.reportIncident
2Front officeRequests a replacementNew card generated, replaces_card_id setIdCardReplacementActionService
3BackendPredecessor's status becomes replaced if not already terminal from the incidentHistory updatedSame transaction

Branches and Edge Cases

BranchConditionBehaviorError/Result
Non-incident sourceAttempted to replace a card that is not in a replaceable stateRejectID_CARD_REPLACEMENT_SOURCE_INVALID
Already has a live replacementA non-terminal replacement already exists for this predecessorRejectID_CARD_REPLACEMENT_EXISTS
Incident reason on wrong routelost/stolen/damaged supplied to the generic replacement route instead of the incident routeRejectID_CARD_REPLACEMENT_REASON_INVALID
Reason other with no noteRejectID_CARD_REPLACEMENT_NOTE_REQUIRED
Missing permissionCaller lacks IdCardReplace_CREATERejectID_CARD_REPLACE_PERMISSION_REQUIRED

5.6 Scanning and Public Verification

Summary

A gate operator scans a printed card's QR (or types its number) to confirm the holder's identity; anyone who scans the QR without staff credentials reaches a minimal public page showing only what the school has chosen to expose.

Preconditions

  • Guest flow: qrVerificationEnabled and publicVerificationEnabled are both on.
  • Staff flow: caller holds IdCardVerify_READ.

Main Flow

StepActor/SystemActionResultSource
1Operator/GuestSubmits the token (scan) or code (staff)Card resolvedControllers
2BackendComputes the live verdict from status + dates + holder's current standingvalid/expired/deactivated/etc.cardVerdict() + a live holder-status check
3BackendApplies disclosure rules (guardian contacts, full vs. basic detail, public field visibility)Filtered responseIdCardScanService/IdCardVerificationPublicService
4BackendRecords a verified eventHistory updatedid_card_events

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Deactivated holderHolder left/deleted, sweep not yet runVerdict deactivated immediatelyLive check, not the stored status
Card-number lookupStaff scan by number instead of tokenBasic info only, no guardians, regardless of permissionsFull detail is token-only
Guardian gateCaller lacks Guardians_READGuardian contacts omitted from scan responseDetail still returned otherwise
Public disabledpublicVerificationEnabled is off409 ID_CARD_VERIFICATION_DISABLED
Invalid tokenMalformed or unknown token404 ID_CARD_NOT_FOUNDNever distinguishes "malformed" from "unknown" to a guest
Malformed :publicId/token shape on admin routesNon-UUID path param400 at the parameter pipe, before any queryConsistent across the module

6. Admin Flows

6.1 Create Template Assignment Rule

Route: POST /api/id-card-template-assignments, permission IdCardTemplates_UPDATE.

6.2 Archive a Template

Route: POST /api/id-card-templates/:publicId/archive, permission IdCardTemplates_DELETE.

6.3 Cancel a Generation Job (row-scoped)

Route: POST /api/id-card-generation-jobs/:publicId/cancel, permission IdCards_CREATE.

7. Lifecycle and State Transitions

7.1 Card Lifecycle

EntityFromEvent/ActionToGuard ConditionSide Effects
id_cards(none)GenerategeneratedNo active/pending card exists for the person; readiness passesid_card_events (generated), notification if configured
id_cardsgeneratedMark printedprinted—printed_at, print_count += 1, event printed
id_cardsprinted (or generated if allowed)IssueactivevalidFrom not in the future; prior active card (if any) closes firstEvent issued, notification
id_cardsactiveReport incidentlost/stolen/damaged—Event marked_*, closed_at, high-priority notification
id_cardsactiveSweep detects expiryexpiredtoday > validUntilEvent expired, notification once (expiry_notified_at)
id_cardsactiveSweep detects holder leftdeactivatedauto_deactivate_on_leave onEvent deactivated
id_cardsgenerated/printed/activeCancelcancelled—Event cancelled
id_cardsany live stateReplacement generatedreplacedA successor card is createdEvent replaced on this row; new row created

Diagram:

Every non-[*] terminal state in this diagram is reached only once per card — lost/stolen/damaged never return to active, matching the schema's design: incidents are irreversible by construction, not by convention.

7.2 Generation Job Lifecycle

EntityFromEvent/ActionToGuard ConditionSide Effects
id_card_generation_jobs(none)SubmitqueuedUnder the open-job capItems created, outbox event
id_card_generation_jobsqueuedWorker claims leaserunningLease acquiredstarted_at, lease_token set
id_card_generation_jobsrunningAll items processed cleanlycompleted—finished_at
id_card_generation_jobsrunningSome items failed/skippedcompleted_with_errors—finished_at
id_card_generation_jobsrunningUnrecoverable errorfailed—finished_at, failure_code
id_card_generation_jobsqueuedCancelcancelled—Items → skipped
id_card_generation_jobsrunningCancel requestedcancelling → cancelledWorker observes between items—
id_card_generation_jobsfailed/completed_with_errorsRetryrunningNot cancelled/cancellingFailed items → pending, re-enqueued

Diagram: see the state diagram in the backend doc §7.4 — it is the same machine.

7.3 Template Lifecycle

9. Data and Side Effects by Flow

FlowDB WritesCache EffectsJobsRealtimeAnalyticsNotifications
Publish templateid_card_template_versions, id_card_templatesnonenonenonenonenone
Generate single cardid_cards, id_card_eventsnonenonenonenonenone unless "ready for pickup" configured
Submit bulk jobid_card_generation_jobs, id_card_generation_job_items, outbox_eventsnonePROCESS_GENERATION_JOBnonenoneon completion, to requester
Issue cardid_cards (this + prior card), id_card_eventsnonenonenonenoneid_card.issued
Report incidentid_cards, id_card_eventsnonenonenonenoneid_card.incident_reported (high priority)
Generate replacementid_cards (new + predecessor), id_card_eventsnonenonenonenoneid_card.replacement_generated
Scan/verifyid_card_events (verified)nonenonenonenonenone
Lifecycle sweepid_cards.status, expiry_notified_at, id_card_events (delete, pruned rows)noneLIFECYCLE_SWEEPnonenoneid_card.expiring, id_card.expired
Recovery sweepid_card_generation_jobs.recovery_count, re-enqueuenoneRECOVERY_SWEEPnonenonenone

10. Error and Recovery Flows

ScenarioTriggerUser/System ExperienceRecoverySource
Rate limited (scan)More than 120 scans/min from one account429Wait for the window to resetID_CARD_SCAN throttle
Rate limited (public verify)More than 60 requests/min from one IP429Wait for the window to resetPUBLIC_CARD_VERIFY throttle
Rate limited (job submit)More than 20 submissions/hour from one account429Wait, or reduce submission frequencyID_CARD_JOB_SUBMIT throttle
Queue failureBullMQ/Redis unavailable when a job's outbox event is ready to relayJob stays queued; the outbox dispatcher retries on its own scheduleAutomatic, once Redis recoversOutbox invariant
Worker crash mid-jobLease expires before completionJob appears stuck running for up to 5 minutesThe recovery sweep reclaims and resumes itIdCardRecoverySweepService
Version conflictConcurrent edits to a template/settings/job/card409 with the current row's versionRe-fetch, reapply, resubmitbump_row_version() triggers
Duplicate submitSame clientRequestId sent twiceOriginal job returned, not an errorNone needed — idempotent by designid_card_generation_jobs_client_request_unique

11. Diagrams Required Per Module

Covered above: actor/capability matrix (§3-4), high-level module flow (backend doc §3, §7), sequence diagrams per major flow (§5.1-5.6, §6), state machines for cards/jobs/templates (§7), data side-effect table (§9), error branch diagrams (§6.3, §5.6).

12. Mandatory Feature and Flow Deep-Dive Pack

12.1 Feature Inventory With Minor Behaviors

FeatureMinor BehaviorActorTriggerUser/System ResultBackend Side EffectSource
Template publishPublishing with no draft change still creates a new versionDesignerPOST .../publishNew version number issuedNew id_card_template_versions rowIdCardTemplatesLifecycleService
Template defaultAt most one default per card type, and only among active, session-agnostic templatesDesignerPOST .../set-defaultPrior default cleared automaticallyPartial unique index enforces it in the DBschema §5.3
Template nameName uniqueness is case-insensitive and only among LIVE (non-deleted) templatesDesignerCreate/renameAn archived template's old name can be reusedid_card_templates_name_live_uniqueschema
Asset deletionRefused while any design still references the assetDesignerDELETE /id-card-assets/:publicId409 ID_CARD_ASSET_IN_USENo row changeAPI §4
Bulk job idempotencyResubmitting the same clientRequestId is a no-op, not an error surfaced to the callerFront officeNetwork retry after a timeoutOriginal job's state returnedUnique index on (requestedByUserId, clientRequestId)schema
Job cap concurrencyTwo simultaneous submits from one user cannot both pass the cap checkFront officeDouble-click submitOne succeeds, one sees the true post-first-submit countPer-user pg_advisory_xact_lock taken before the countIdCardGenerationJobsService.create
Job row scopeA caller with an unrestricted people scope for a card type can act on ANY job of that type, not only their ownFront officeList/detail/cancel/retryBroader visibility for unrestricted scopesIdCardScopeServicebackend §6.3
Job item scopeItems are filtered by people scope even when the parent job is visibleFront officeGET .../itemsFewer items than totalCount for a scoped callerSame servicebackend §6.3
Issue closes prior cardIssuing a new card for someone who still holds an active one closes the old one FIRST, same transactionFront officePOST /id-cards/issueNo moment where two cards are simultaneously activeNon-deferrable unique index orderingschema §5.3
Guardian omissionDetail response OMITS the guardian key rather than nulling it, for a caller without Guardians_READFront officeGET /id-cards/:publicIdResponse shape itself reveals nothingIdCardDetailService.detailbackend §6.4
Preview empty guardianThe generation preview resolves guardian.* to empty strings instead of omitting the keyFront officeGET /id-card-generation/preview-dataDesign placeholders render blank rather than breakingIdCardPreviewServicebackend §6.4
Card-number scan limitsA card-number lookup on the scan endpoint never returns guardian data, regardless of permissionGate operatorPOST /id-card-scan with a number, not a tokenBasic info onlyIdCardScanServiceAPI §8.8
Live deactivation on scanA scan/verify shows deactivated even if the hourly sweep has not yet recorded it on the rowGate operator / guestAny scan/verify of a leaver's cardVerdict is correct immediatelyLive holder-status check in scan/verify servicesin-flight behavior
Not-yet-valid issueA card cannot be issued before its own validFromFront officePOST /id-cards/issue409 ID_CARD_NOT_YET_VALIDIdCardIssueActionServicein-flight behavior
Readiness excludes card fieldsFields filled only at generation (card.*) are never flagged as empty in the pre-generation readiness reviewFront officeGET /id-card-generation/candidatesNo false-positive "missing field" warningsIdCardReadinessServicein-flight behavior
UUID path validationEvery :publicId/token-shaped param is validated before any query runsAny callerAny route with a path param400 on malformed input, not a 404 or 500Global parameter pipein-flight behavior
Event ordering under one transactionid_card_events.created_at uses clock_timestamp(), not now()SystemAny flow writing two events in one transaction (e.g. issue + close)Events order correctly by actual occurrenceschema id_card_eventsschema
Retention prune scopeThe lifecycle sweep may delete ONLY verified events, only under a session flag set for that transactionSystemHourly sweepCard history (transitions, incidents) is never prunableid_card_events_append_only triggerschema §5.3
Sequence never reusedA card number burned by a failed generation attempt is never reissuedSystemAny failed generation after nextval()No two cards ever share a number, even across failuresid_card_number_seqmigration 0034

12.2 Business Process Diagram Pack

12.3 Business Rules and Policy Traceability

RuleBusiness ReasonActor ImpactEnforced InAPI ImpactBackend ImpactTests
At most one active card per personA person cannot hold two simultaneously valid credentialsFront office sees ID_CARD_ACTIVE_CARD_EXISTS instead of two live cardsPartial unique index id_cards_one_active_student/_staffGeneration route rejects with 409Cannot be raced by concurrent generatorsschema constraint, exercised by any generation integration test
At most one pending card per personA second generated/printed card for the same person before the first resolves would be confusing at pickupID_CARD_PENDING_CARD_EXISTSPartial unique index409Same guarantee, database-levelschema constraint
Templates are immutable once publishedA card must render identically for as long as it existsEditing a draft after publish never changes issued cardsAppend-only trigger on id_card_template_versionsPublish always creates a new version, never edits oneGuard trigger raises on direct UPDATE/DELETEschema constraint
Incidents are irreversibleA lost/stolen/damaged card must never re-enter circulation as if nothing happenedFront office must generate a REPLACEMENT, not "un-report"id_card_status enum has no edge back to active from those statesNo "undo incident" route existsTransition service refuses itstate diagram §7.1
Card cannot be issued before its validity startsA card's printed validity window is a promise about when it is usableID_CARD_NOT_YET_VALIDIdCardIssueActionService409in-flight behavior—
Guardian data requires Guardians_READA card carries a child's guardian contact details, which is more sensitive than the card itselfFront-office staff without the extra grant see cards but not who to callIdCardDetailService, IdCardScanServiceResponse shape omits/blanks the fieldSame—
Card-number lookup is never full-detailA printed card number is far more guessable than a 192-bit QR tokenGate staff must actually scan the QR for full detailIdCardScanServiceResponse shape differs by lookup methodSame—
Bulk jobs cap at 2000 peopleBounds worker transaction count and lease duration for a single jobFront office splits very large schools into multiple jobsid_card_generation_jobs_total_range CHECK400 ID_CARD_SELECTION_TOO_LARGESchema-enforced upper boundschema constraint
Job submission is per-account rate-limited (20/hour)Each job can carry 2000 people; unlimited submission could overwhelm the workerFront office paces bulk submissionsID_CARD_JOB_SUBMIT throttle429Redis-backed IP/user throttle—
Public verification is superadmin-gatedDeciding what the open internet learns about a child is the highest-stakes settings surface in the moduleOnly superadmin can turn it on or change its shown fieldsIdCardPublicVerification permission module, SUPERADMIN_ONLY_MODULESPATCH .../public-verification requires the codeSamepermission catalog
Verify URL is frozen per cardA later change to the school's verify domain must never break already-printed QR codesFront office can safely change settings without reprinting existing cardsid_cards.verify_url set once at generationDetail/render always uses the card's own frozen URLSame—

12.4 Tradeoffs and Product Rationale

Product DecisionUser BenefitEngineering BenefitAlternativeTradeoffRisk
Async bulk generation via BullMQ instead of a synchronous batch endpointFront office is never blocked waiting on a 2000-person requestRequest handler stays fast; failures are per-item, not all-or-nothingSynchronous loop in the request handlerA caller must poll for job completion instead of getting an instant resultWorker outage delays generation, mitigated by the recovery sweep
Guardian data omitted, not nulled, in unauthorized readsA caller without the grant cannot infer that guardian data exists at allSimpler response contract than per-field maskingReturn guardian: nullA client naively checking if (guardian) behaves identically either way, so the distinction matters mainly for introspection/debuggingLow
Card-number scan is deliberately weaker than token scanLimits what a guessed/leaked card number can exposeOne endpoint serves two trust levels without duplicating logicTwo separate scan endpointsSlightly less discoverable API shape (one endpoint, two response shapes)Low
Public verification default-off, superadmin-gatedA school can choose never to expose anything about a child publiclyOne clear owner for the riskiest configuration surfaceGrant IdCardSettings_UPDATE holders the same powerSlower to enable per-school if an operator lacks superadminMitigated — this is intentional friction
Incidents are a one-way transitionPrevents "un-reporting" a theft to quietly reactivate a card that may be compromisedSimpler state machine, no reverse edges to validateAllow reverting an incidentAn honest mis-click (reported lost by mistake) requires a NEW card, not a fixLow — administrators are trained on this
Sequence-based numbering, never MAX()+1A card's identity is never recycled, even after a failed attemptImmune to race conditions between concurrent generatorsCompute the next number from existing rowsNumbering has gaps after failures — cosmetic onlyNone functionally

12.5 Flow Edge-Case Matrix

FlowEdge CaseTriggerExpected BehaviorUser/System FeedbackSource
Bulk generationEmpty selectionpersonPublicIds: []Rejected before any DB write400 validation errorDTO validation
Bulk generationFirst use for a schoolNo prior jobs existBehaves identically to any later jobNormal queued response—
Bulk generationLast item in the batch failsWorker processes items sequentiallyJob still finalizes with completed_with_errorsItem-level detail available via .../itemsIdCardGenerationRunnerService
Bulk generationDuplicate actionSame clientRequestId retriedNo second job createdOriginal job returnedunique index
Bulk generationConcurrent cancel + worker claimRace between an admin's cancel and the worker's lease claimSerialized on the job row's FOR UPDATE lockOne of the two wins deterministically; no split-brain stateIdCardTransitionService
Card issuanceExpired validity window at issue timevalidUntil already passed for an administrator-overridden cardIssuable if validFrom <= today <= validUntil... but a card with validUntil in the past should not have been generated in the first place; the check only guards validFromID_CARD_NOT_YET_VALID only guards the lower boundin-flight behavior
Card issuancePermission mismatchCaller lacks IdCardIssue_UPDATE403 before any row is touchedRoleGuard—
Card issuanceGuest limitationN/A — no guest-issuable flow exists———
ScanMissing dependencyCard references a template version whose asset was later deletedRender may show a placeholder for the missing asset; the card record itself is unaffectedRenderer degrades gracefullyIdCardRenderService
ScanCache stale/missN/A — module has no cache layerEvery read is live—backend §8
ScanUnsupported filter/sortN/A — scan takes no query filters———
Public verifyRate-limit failureMore than 60/min from one IP429, no card data leakedThrottle guardPUBLIC_CARD_VERIFY
Template publishConcurrent edits by two designersTwo PATCH calls race on the same draftSecond writer's version mismatches, gets 409ID_CARD_TEMPLATE_VERSION_CONFLICTbump_row_version trigger
Template assignmentSection without gradeScope specifies a section but no gradeRejected — sections are shared across grades and would be ambiguous400 ID_CARD_ASSIGNMENT_SCOPE_INVALIDschema check

12.6 Flow-to-Data Trace

FlowReadsWritesCacheJobs/EventsResponse Fields
Bulk generation submitstudents/staff, existing cards, settingsid_card_generation_jobs, id_card_generation_job_items, outbox_eventsnonePROCESS_GENERATION_JOBpublicId, status, totalCount, createdAt
Card issuecard row, holder's prior active card, settingsid_cards (this + prior), id_card_eventsnonenotificationstatus, issuedAt
Scancard, holder, guardians (gated)id_card_events (verified)nonenoneverdict, personName, guardians (gated)

12.7 Experience Quality Checklist

  • The doc explains what the actor is trying to accomplish (design once, generate correctly, verify quickly).
  • The doc explains backend behavior the actor does not see (advisory locks, lease fencing, append-only guards, live deactivation checks).
  • The doc covers minor flows and branches (idempotent resubmission, row scoping, guardian omission vs. blanking).
  • The doc includes admin, worker, and guest flows.
  • The doc explains business logic, tradeoffs, and rationale (§12.3, §12.4).
  • The doc maps every flow to API routes and backend side effects.
  • The doc includes diagrams appropriate to each flow type.
  • The doc covers edge cases and failure recovery (§12.5, §10).

13. Completion Checklist

  • Every feature, minor action, and submodule capability is listed (§4, §12.1).
  • Every actor has allowed and forbidden behavior (§3).
  • Every major and minor flow includes steps, branches, and diagrams (§5, §6).
  • Every lifecycle has a transition table and state diagram (§7).
  • Every flow links to the API and backend docs.
  • In-flight behaviors (row scoping, guardian disclosure, ID_CARD_NOT_YET_VALID, live deactivation, readiness exclusions, UUID validation, token redaction) are documented as the system's current behavior.

See Also