Skoolsewa - School Management System Docs
Developer ResourcesID Cards

ID Cards Backend Documentation

Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for ID Cards.

ID Cards Backend Documentation

1. Documentation Evidence

AreaFiles InspectedVerified Details
Module wiringapps/api/src/modules/id-cards/**/*.module.ts (10 modules), apps/api/src/app.module.tsComposition, ADMIN_MODULES membership
Controllers10 controllers under apps/api/src/modules/id-cards/**/*.controller.tsRoute paths, verbs, guards, permissions
Services35 services under apps/api/src/modules/id-cards/**/*.service.tsBusiness logic, transactions, side effects
Pure rulesapps/api/src/utils/id-cards/id-card-design.ts (1178 lines), apps/api/src/utils/id-cards/id-card-rules.util.ts (271 lines)Design schema validation, validity dates, verdict, numbering
Schemapackages/db/src/schema/school/id-cards.ts (1026 lines)9 tables, 8 enums, every constraint and index
Migrationspackages/db/src/migrations/0033_id_card_module.sql, 0034_id_card_constraints_and_seeds.sqlTable/index creation, the card-number sequence, version triggers, seed row, append-only guards
Jobspackages/jobs/src/index.ts (QueueName.ID_CARDS, IdCardJob)Queue contract and payload shapes
Error codesapps/api/src/common/types/error-codes.ts — ID_CARD_* blockEvery code's throw site
Permissionspackages/db/src/authorization/permission-catalog.ts10 IdCard* modules
Rate limitsapps/api/src/common/guards/ip-throttler.config.tsPUBLIC_CARD_VERIFY, ID_CARD_SCAN, ID_CARD_JOB_SUBMIT
Notificationsapps/api/src/modules/notification/templates/id-card-templates.ts6 notification kinds

2. Backend Scope and Boundaries

Owns

  • The school's ID-card templates: an mm-based JSON design (front/back, elements, dynamic field keys), a draft/active/inactive/archived lifecycle, immutable published versions, per-scope assignment rules, and one default template per card type.
  • Card-image assets (logos, seals, signatures) uploaded as the private ID_CARD_ASSET upload type, sniffed by magic bytes, served only through the authenticated relay.
  • Candidate selection and readiness review for generation — which students/staff are eligible and what would block generation before it runs.
  • Card generation itself: a single synchronous card, or a bulk request processed asynchronously through the outbox → BullMQ → a lease-fenced worker.
  • The full card lifecycle: generated → printed → active → expired/lost/stolen/damaged/replaced/cancelled/deactivated, including incidents, replacements, print marking, downloads, and an append-only per-card event history.
  • QR/token verification: an authenticated staff scan (IdCardVerify_READ) and a public, unauthenticated verification page reachable from a printed card's QR code.
  • Module settings: numbering (prefix + sequence + padding), validity defaults, generation requirements, printing defaults, notification switches, and a separately-gated public-verification configuration.
  • Two scheduled sweeps: a 5-minute recovery sweep for generation jobs whose worker died mid-lease, and an hourly lifecycle sweep (expiry, expiring-soon notices, leave-triggered deactivation, event-history pruning).
  • Six notification kinds delivered through the shared NotificationService.

Does Not Own

  • Student, staff, and guardian records (people/ module) — this module reads them and never writes them, except through row-scoped, permission-gated queries.
  • Classes, sections, academic sessions (school/ schema, academic-sessions) — read-only references for placement and validity.
  • File storage bytes (@skoolsewa/storage) — the module owns id_card_assets metadata and calls the storage manager directly.
  • The outbox and BullMQ infrastructure (OutboxService, @skoolsewa/jobs) — the module is a producer/consumer, not the owner.
  • Notification delivery mechanics (channel senders) — NotificationService owns that; this module supplies templates, recipients, and event triggers.
  • Authentication and permission evaluation (JwtAuthGuard, RoleGuard) — the module supplies @Permissions() codes and, for scan/verify, its own disclosure rules.
  • Timezone/"today" computation — SchoolClockService supplies the school's current date; the module's pure rule functions never read a clock themselves.

Source of Truth

ConcernSource of TruthNotes
Card lifecycleid_cards.status (stored)The live verdict a caller sees (valid/expired/not_yet_valid/etc.) is DERIVED from status + validFrom/validUntil against the school's "today" — see cardVerdict() in id-card-rules.util.ts. A stored active card can be verdict expired in the window before the hourly sweep records it.
Card designid_card_template_versions.design (immutable, append-only)A card's snapshot freezes the design and data at generation time; editing a template's draft_design afterward never changes a card already issued from a published version.
One active/pending card per personPartial unique indexes on id_cards (id_cards_one_active_student, ..._one_pending_student, and the staff equivalents)Enforced in the database because bulk generation, single generation, and replacement all race for the same person; only the index sees every writer.
Concurrency tokenversion (bigint, bumped by bump_row_version() triggers) on id_card_settings, id_card_templates, id_card_generation_jobs, id_cardsA stale token is refused with ID_CARD_VERSION_CONFLICT, ID_CARD_TEMPLATE_VERSION_CONFLICT, ID_CARD_SETTINGS_VERSION_CONFLICT, or a job's own transition check.
Generation job progressid_card_generation_jobs.status + id_card_generation_job_itemsThe job row is the aggregate state; each item row is one person's outcome. Only the job's lease_token holder may write its status.
Numberingid_card_number_seq (a bare bigint sequence) + id_card_settings prefix/padding columnsA sequence, never MAX()+1 — a number burned by a failed generation is never reused, because identity must never repeat, including for a cancelled card.
Verification tokenid_cards.verification_token (32-char base64url, 192 random bits)Generated once at card creation, never rotated; a scan or public verify resolves the card by this token, never by card_number in the public path.

3. Module Composition

There is no aggregate IdCardsModule. Eight admin-facing leaf modules and one worker module are composed directly by app.module.ts, mirroring the rule that ADMIN_MODULES must name a module that declares its own controllers.

ModuleTypePathControllersProviders/ExportsResponsibility
IdCardTemplatesAdminModuleLeaftemplates/id-card-templates-admin.module.tsIdCardTemplatesControllerIdCardTemplatesService, query/lifecycle/mapper services/api/id-card-templates*.
IdCardTemplateAssignmentsAdminModuleLeaftemplates/id-card-template-assignments-admin.module.tsIdCardTemplateAssignmentsControllerIdCardTemplateAssignmentsService/api/id-card-template-assignments*.
IdCardAssetsAdminModuleLeafassets/id-card-assets-admin.module.tsIdCardAssetsControllerIdCardAssetsService/api/id-card-assets*.
IdCardSettingsAdminModuleLeafsettings/id-card-settings-admin.module.tsIdCardSettingsControllerIdCardSettingsService/api/id-card-settings*.
IdCardGenerationAdminModuleLeafgeneration/id-card-generation-admin.module.tsIdCardGenerationController, IdCardGenerationJobsControllercandidates/preview/generate/job services/api/id-card-generation*, /api/id-card-generation-jobs*.
IdCardsAdminModuleLeafcards/id-cards-admin.module.tsIdCardsController, IdCardActionsControllerdashboard/detail/list/lifecycle/print/render/replacement services/api/id-cards*.
IdCardScanAdminModuleLeafverification/id-card-scan-admin.module.tsIdCardScanControllerIdCardScanService/api/id-card-scan.
IdCardVerificationPublicModuleLeafverification/id-card-verification-public.module.tsIdCardVerificationPublicControllerIdCardVerificationPublicService/api/id-card-verification* (public).
IdCardsSharedModuleLeaf (shared)shared/id-cards-shared.module.tsNonefactory, notifier, person-reader, readiness, render, scope, settings-reader, template-resolver, transition servicesCross-cutting services every other submodule imports.
IdCardsWorkerModuleLeafworkers/id-cards-worker.module.tsNoneIdCardGenerationRunnerService, IdCardLifecycleSweepService, IdCardRecoverySweepService, IdCardsQueueProcessor, IdCardsSchedulerThe one processor on QueueName.ID_CARDS plus the two crons. Composed only outside API_RUNTIME_PROFILE=fast, matching the rule that importing a @Processor-bearing module starts a worker.

4. File and Directory Map

apps/api/src/modules/id-cards/
  templates/
    admin/           id-card-templates.controller.ts, id-card-template-assignments.controller.ts
    core/             templates/assignments/lifecycle/query/mapper services, dto/
    id-card-templates-admin.module.ts
    id-card-template-assignments-admin.module.ts
  assets/
    admin/            id-card-assets.controller.ts
    core/             id-card-assets.service.ts, dto/
    id-card-assets-admin.module.ts
  settings/
    admin/            id-card-settings.controller.ts
    core/             id-card-settings.service.ts, dto/
    id-card-settings-admin.module.ts
  generation/
    admin/            id-card-generation.controller.ts, id-card-generation-jobs.controller.ts
    core/             candidates/generate/job-mapper/jobs-read/jobs/people-picker/preview services, dto/
    id-card-generation-admin.module.ts
  cards/
    admin/            id-cards.controller.ts, id-card-actions.controller.ts
    core/             dashboard/detail/issue-action/lifecycle-action/list/mapper/print-action/render-action/
                      replacement-action/replacements-read services, dto/
    id-cards-admin.module.ts
  verification/
    admin/            id-card-scan.controller.ts
    public/           id-card-verification-public.controller.ts
    core/             id-card-scan.service.ts, id-card-verification-public.service.ts, dto/
    id-card-scan-admin.module.ts
    id-card-verification-public.module.ts
  shared/
    id-card-card-sources.types.ts, id-card-factory.service.ts, id-card-left-holder.util.ts,
    id-card-notifier.service.ts, id-card-person-reader.service.ts, id-card-readiness.service.ts,
    id-card-render.service.ts, id-card-scope.service.ts, id-card-settings-reader.service.ts,
    id-card-snapshot.ts, id-card-template-resolver.service.ts, id-card-transition.service.ts,
    id-cards-shared.module.ts
  workers/
    id-card-generation-runner.service.ts, id-card-lifecycle-sweep.service.ts,
    id-card-recovery-sweep.service.ts, id-cards-queue.processor.ts, id-cards.scheduler.ts,
    id-cards-worker.module.ts

apps/api/src/utils/id-cards/
  id-card-design.ts        (1178 lines — the mm-based design schema and its validator)
  id-card-rules.util.ts    (271 lines — verdict, validity windows, card-number formatting)

Key Files

FilePurposeKey ExportsNotes
utils/id-cards/id-card-rules.util.tsPure lifecycle rules, no I/OcardVerdict(), studentValidity(), staffValidity(), checkValidityOverride(), isLiveStatus()Takes "today" as an ISO string supplied by the caller from SchoolClockService; never reads a clock itself.
utils/id-cards/id-card-design.tsThe mm-based JSON design schema (schemaVersion 1) and its structural validatorDesign types, field-key catalogue, validation entry pointFront/back sides, elements with mm positions, dynamic field keys, safe-margin checks.
shared/id-card-transition.service.tsRow-locking status transitions for cards and generation jobsIdCardTransitionServiceFOR UPDATE reads decide the allowed transition from the locked row; the lock, not a repeated status predicate, is the concurrency control.
shared/id-card-readiness.service.tsPre-generation readiness reviewIdCardReadinessServiceFlags missing photo/roll number/etc. per settings; never flags card-assigned fields (card.*, filled at generation time) as TEMPLATE_FIELD_EMPTY.
shared/id-card-scope.service.tsRow-scoping generation-job reads and actions to their requesterIdCardScopeServiceBacks the row-scoped job list/detail/items/cancel/retry behavior described in §7.5.
shared/id-card-render.service.tsRenders a card (front/back) from a template version's design and a snapshotIdCardRenderServiceOne renderer for designer preview, print, and download — no generated image file is ever persisted.
shared/id-card-notifier.service.tsDispatches the six ID-card notification kindsIdCardNotifierServiceCalls the shared NotificationService; resolves guardian recipients for student cards.
workers/id-card-generation-runner.service.tsProcesses one bulk generation jobIdCardGenerationRunnerService (508 lines)Lease-fenced; one transaction per item; writes job-item outcomes as it goes.
workers/id-card-recovery-sweep.service.tsReclaims jobs whose lease expired without the worker finishingIdCardRecoverySweepService (265 lines)Runs every 5 minutes via IdCardsScheduler.
workers/id-card-lifecycle-sweep.service.tsExpiry, expiring-soon notices, leave deactivation, event pruneIdCardLifecycleSweepService (262 lines)Runs hourly via IdCardsScheduler.

5. Data Model

5.1 Schema Source

packages/db/src/schema/school/id-cards.ts

One file, 8 enums and 9 tables. Migrations: 0033_id_card_module.sql (tables, indexes), 0034_id_card_constraints_and_seeds.sql (the card-number sequence, bump_row_version triggers, the seeded settings row, and the two append-only guard triggers).

5.2 Enums

EnumValuesUsed By
id_card_typestudent, teacher, staffid_card_templates.card_type, id_cards.card_type, job/assignment card_type
id_card_template_statusdraft, active, inactive, archivedid_card_templates.status
id_card_statusgenerated, printed, active, expired, lost, stolen, damaged, replaced, cancelled, deactivatedid_cards.status. One lifecycle covers both "print status" and "card status" — two columns would admit contradictory pairs such as issued-and-ready-to-print. Live states: generated, printed, active; every other value is terminal.
id_card_event_typegenerated, printed, reprinted, issued, marked_lost, marked_stolen, marked_damaged, replaced, cancelled, expired, deactivated, downloaded, verified, expiry_notifiedid_card_events.event_type
id_card_replacement_reasonlost, damaged, stolen, name_change, photo_update, class_section_change, roll_number_change, designation_change, academic_year_renewal, template_change, otherid_cards.replacement_reason, id_card_generation_jobs.replacement_reason. Incident reasons (lost/stolen/damaged) are refused on the bulk-generation job — they go through the incident route only.
id_card_job_statusqueued, running, cancelling, completed, completed_with_errors, failed, cancelledid_card_generation_jobs.status
id_card_job_item_statuspending, generated, skipped, failedid_card_generation_job_items.status
id_card_existing_policyskip, replaceid_card_generation_jobs.existing_policy, id_card_settings.default_existing_policy

5.3 Tables

id_card_settings

Singleton row (id = 1, CHECK id = 1), seeded by migration 0034.

ColumnTypeNullableDefaultConstraintNotes
idsmallintNo1PK, CHECK id = 1Singleton.
student_prefix, teacher_prefix, staff_prefixtextNoSTU/TCH/STFCHECK ~ '^[A-Z0-9]{1,8}$'Card-number prefixes.
number_paddingsmallintNo6CHECK BETWEEN 4 AND 10Zero-padding width for the sequence value.
staff_validity_monthssmallintNo12CHECK BETWEEN 1 AND 120Staff/teacher cards run this many months from generation.
expiring_soon_dayssmallintNo30CHECK BETWEEN 1 AND 180Window the lifecycle sweep uses for the "expiring soon" notice.
require_photo, require_roll_number, require_school_logobooleanNotrue/false/false—Readiness-review requirements.
allow_issue_without_printbooleanNofalse—When false, a card must be marked printed before it can be issued.
default_existing_policyid_card_existing_policyNoskip—Default for bulk generation when a person already has a live card.
auto_deactivate_on_leavebooleanNotrue—Whether the lifecycle sweep deactivates a card when its holder leaves.
qr_verification_enabledbooleanNotrue—Whether cards carry a verify URL/QR at all.
public_verification_enabledbooleanNofalse—Whether /api/id-card-verification/:token answers publicly. Written only through PATCH .../public-verification, gated by IdCardPublicVerification_UPDATE (superadmin-only module).
public_show_nametextNoinitialsCHECK IN ('full','initials','hidden')What the public page shows for the holder's name.
public_show_photo, public_show_card_number, public_show_session, public_show_classbooleanNofalse/true/true/false—Field-by-field visibility on the public page.
verify_base_urltextYesNULLCHECK origin shape (https?://host, no path/query/fragment)Frozen into each card's verify_url at generation; a later change never rewrites an already-printed QR.
print_page_sizetextNoA4CHECK IN ('A4','Letter')
print_sidestextNobothCHECK IN ('front','back','both')
print_duplex_fliptextNolongCHECK IN ('long','short')
print_cards_per_pagesmallintNo8CHECK BETWEEN 1 AND 12
print_margin_mmsmallintNo10CHECK BETWEEN 5 AND 30Printers cannot print their outer 3-5mm; a card placed there clips.
print_gap_mmsmallintNo4CHECK BETWEEN 0 AND 20
safe_margin_mmsmallintNo3CHECK BETWEEN 0 AND 10Minimum clearance a design element must keep from the card edge.
scan_event_retention_dayssmallintNo180CHECK BETWEEN 30 AND 3650How long verified events survive the lifecycle sweep's prune.
notify_ready_for_pickup, notify_issued, notify_incident, notify_replaced, notify_expiring, notify_expiredbooleanNoall true—Per-event notification toggles.
updated_by_user_iduuidYes—FK → users.id, SET NULL
versionbigint (number mode)No0bumped by id_card_settings_bump_row_version triggerOptimistic concurrency.
created_at, updated_attimestamptzNonow()

id_card_assets

ColumnTypeNullableNotes
idserialNoInternal PK.
public_iduuidNo, uniqueuuidv7() default.
storage_keytextNo, uniquePoints into @skoolsewa/storage under the private ID_CARD_ASSET type.
original_nametextNoCHECK length BETWEEN 1 AND 255.
mime_typetextNoCHECK IN ('image/png','image/jpeg','image/webp').
byte_sizeintegerNoCHECK > 0 AND <= 5242880 (5 MiB).
width, heightintegerYesPixel dimensions, when known.
uploaded_by_user_iduuidYesFK → users.id, SET NULL.
created_attimestamptzNo
deleted_attimestamptzYesSoft delete.

A design references an asset by public_id, never by storage key — the storage key never reaches the wire.

id_card_templates

ColumnTypeNullableNotes
idserialNoInternal PK.
public_iduuidNo, unique
nametextNoCHECK length BETWEEN 1 AND 120 AND name = btrim(name); unique per lowercase name among live rows (id_card_templates_name_live_unique, partial on deleted_at IS NULL).
descriptiontextYesCHECK length <= 1000.
card_typeid_card_typeNoImmutable after creation — the design's field catalogue depends on it.
academic_session_idintegerYesFK → academic_sessions.id, restrict. CHECK only settable when card_type = 'student'.
statusid_card_template_statusNodraft default.
orientationtextNoCHECK IN ('portrait','landscape').
draft_designjsonbNoEditable working copy; only a PUBLISHED version is ever used for generation.
draft_hashtextNoCHECK ~ '^[0-9a-f]{64}$' (sha256 hex).
published_version_nointegerYesFK (composite, with id) → id_card_template_versions(template_id, version_no), restrict.
is_defaultbooleanNoAt most one default per card_type (id_card_templates_one_default_per_type, partial unique index); must be active and session-agnostic; a deleted template can never be default.
created_by_user_id, updated_by_user_iduuidYesFK → users.id, SET NULL.
last_used_attimestamptzYesSet when a card is generated from the template.
deleted_attimestamptzYesSoft delete (archive path).
versionbigintNoBumped by trigger.
created_at, updated_attimestamptzNo

Key checks: draft status implies no published version; active/inactive require one; a session-bound template is never a default.

id_card_template_versions (append-only)

ColumnTypeNullableNotes
idserialNo
public_iduuidNo, unique
template_idintegerNoFK → id_card_templates.id, restrict.
version_nointegerNoCHECK >= 1; unique per (template_id, version_no) — the composite FK target for templates and cards.
designjsonbNoFrozen design document; what a card is generated from.
content_hashtextNoCHECK ~ '^[0-9a-f]{64}$'.
orientationtextNoCHECK IN ('portrait','landscape').
notetextYesCHECK length <= 500.
published_by_user_iduuidYesFK → users.id, SET NULL.
created_attimestamptzNo

Append-only guard: a BEFORE UPDATE OR DELETE trigger raises id_card_template_versions is append-only on any write, with one exception — the published_by_user_id SET NULL cascade fired at pg_trigger_depth() > 1 when a user row is deleted. A direct UPDATE at depth 1 is refused even if it would set the same column to the same value. A version is what a card was generated from and must render the same card for as long as the card exists.

id_card_template_assignments

"Grade 1-5 use the junior card." One row per exact scope.

ColumnTypeNullableNotes
idserialNo
public_iduuidNo, unique
template_idintegerNoComposite FK (with card_type) → id_card_templates(id, card_type), cascade — proves the rule's card type matches its template's.
card_typeid_card_typeNo
academic_session_id, grade_id, section_idintegerYesStudent-only scope columns (restrict on delete). CHECK: non-student rows must leave all three NULL.
department_id, designation_idintegerYesStaff-only scope columns (restrict). CHECK: student rows must leave both NULL.
prioritysmallintNoCHECK BETWEEN 0 AND 1000. Resolution scores the most specific match.
created_by_user_iduuidYesFK → users.id, SET NULL.
created_attimestamptzNo

id_card_template_assignments_scope_unique is a unique index over (card_type, coalesce(session,0), coalesce(grade,0), coalesce(section,0), coalesce(department,0), coalesce(designation,0)) — coalesce lets "any session" (NULL) collide correctly with another "any session" row rather than being distinct every time (Postgres treats NULL <> NULL in a unique index). id_card_template_assignments_section_needs_grade refuses a section-only scope, since sections are shared across grades.

id_card_generation_jobs

A bulk generation request, processed under a lease.

ColumnTypeNullableNotes
iduuidNouuidv7() PK.
public_iduuidNo, unique
card_typeid_card_typeNo
academic_session_idintegerYesRequired exactly when card_type = 'student' (CHECK).
template_idintegerYesFK → id_card_templates.id, restrict.
existing_policyid_card_existing_policyNoskip or replace.
replacement_reasonid_card_replacement_reasonYesRequired exactly when existing_policy = 'replace'; incident reasons (lost/stolen/damaged) refused here.
replacement_notetextYesRequired when reason is other; CHECK length <= 500.
valid_from, valid_untildateYesBoth or neither; until >= from.
statusid_card_job_statusNoqueued default.
total_countintegerNoCHECK BETWEEN 1 AND 2000.
requested_by_user_iduuidYesFK → users.id, SET NULL.
client_request_iduuidNoUnique per (requested_by_user_id, client_request_id) — the idempotency key for a resubmitted request.
attemptintegerNoCHECK >= 1.
recovery_countsmallintNoCHECK BETWEEN 0 AND 10.
lease_tokenuuidYesPaired with lease_expires_at (CHECK both-or-neither). Only the holder may write status.
lease_expires_at, last_heartbeat_attimestamptzYes
started_at, finished_attimestamptzYesfinished_at set exactly for terminal statuses (CHECK).
failure_codetextYes
versionbigintNoBumped by trigger.
created_at, updated_attimestamptzNo

Indexes: id_card_generation_jobs_open_idx (partial, on status where open), id_card_generation_jobs_requester_idx (requested_by_user_id, created_at — backs the row-scoped list).

id_cards

ColumnTypeNullableNotes
iduuidNouuidv7() PK.
public_iduuidNo, unique
card_numbertextNo, uniquePREFIX-000041 shape from the sequence + settings.
card_typeid_card_typeNo
student_iduuidYesFK → students.id, restrict. Exactly one of student_id/staff_id set, matching card_type (CHECK).
staff_iduuidYesFK → staff.id, restrict.
academic_session_idintegerYesFK, restrict. Required exactly for card_type = 'student'.
enrollment_iduuidYesFK → student_class_enrollments.id, SET NULL — removing an enrolment must never be blocked by a card; the snapshot preserves history.
grade_id, section_id, class_idintegerYesFK, SET NULL. Student-only (CHECK: staff cards leave these NULL).
department_id, designation_idintegerYesFK, SET NULL. Staff-only.
person_nametextNoCHECK length BETWEEN 1 AND 300. Frozen at generation.
person_codetextYes
roll_numbersmallintYesStudent-only.
template_id, template_version_nointegerNoComposite FK → id_card_template_versions(template_id, version_no), restrict — the exact version the card was rendered from.
snapshotjsonbNoThe person, placement, and school exactly as they were at generation.
photo_keytextYes
statusid_card_statusNogenerated default.
valid_from, valid_untildateNoCHECK until >= from.
verification_tokentextNo, uniqueCHECK ~ '^[A-Za-z0-9_-]{32}$' — 192 random bits, base64url. Never returned outside a print or download render.
verify_urltextYesFrozen at generation from the settings' verify_base_url; CHECK matches .../verify/card/<token> shape.
replaces_card_iduuidYesSelf-FK, restrict. CHECK not self-referencing; paired with replacement_reason (both or neither).
replacement_reason, replacement_note—YesSame shape rules as the job table.
generation_job_iduuidYesFK → id_card_generation_jobs.id, SET NULL.
generated_by_user_id, printed_by_user_id, issued_by_user_id, closed_by_user_iduuidYesFK → users.id, SET NULL.
generated_attimestamptzNodefault now().
printed_at, issued_at, closed_attimestamptzYesEach paired with its own CHECK (e.g. printed_at required when status = 'printed').
print_countintegerNodefault 0; CHECK (printed_at IS NULL) = (print_count = 0).
closed_reasontextYesCHECK length <= 1000.
expiry_notified_attimestamptzYesSet by the lifecycle sweep once, to avoid re-notifying.
versionbigintNoBumped by trigger.
created_at, updated_attimestamptzNo

The duplicate guard (why it is a partial unique index, not a service check): id_cards_one_active_student/_staff allows at most one active card per person; id_cards_one_pending_student/_staff allows at most one generated/printed card per person; id_cards_one_live_replacement allows at most one non-terminal replacement chained off a given predecessor. Bulk generation, single generation, and replacement can all attempt to create a card for the same person concurrently, and only the index observes every writer atomically. Issuing a successor while the person still holds an active card flips the old card out of active FIRST, in the same transaction, because a unique index is not deferrable.

Other indexes: id_cards_status_type_idx, id_cards_placement_idx (session/grade/section), id_cards_template_version_idx, id_cards_student_history_idx/_staff_history_idx (person + generated_at, for a person's card history), id_cards_active_valid_until_idx (partial, status = 'active' — feeds the expiry sweep), id_cards_person_name_idx (lower(name) text_pattern_ops, for prefix search), id_cards_generation_job_idx, id_cards_replaces_idx.

id_card_events (append-only)

Per-card history: every transition, print, download, and scan.

ColumnTypeNullableNotes
idbigserialNo
card_iduuidNoFK → id_cards.id, restrict.
event_typeid_card_event_typeNo
from_status, to_statusid_card_statusYes
actor_user_iduuidYesFK → users.id, SET NULL.
reasontextYesCHECK length <= 1000.
metadatajsonbNodefault '{}'.
created_attimestamptzNodefault clock_timestamp(), not now() — several events written in one transaction (e.g. an issue that also closes the predecessor) must order by when they actually happened, and now() is fixed at transaction start.

Append-only guard: BEFORE UPDATE OR DELETE trigger raises id_card_events is append-only, with two exceptions: the actor_user_id SET NULL cascade at pg_trigger_depth() > 1, and a DELETE of verified rows when the transaction has run SET LOCAL id_cards.allow_event_prune = 'on'. SET LOCAL ends with the transaction, so the permission cannot leak onto a pooled connection and outlive the prune that requested it — only the lifecycle sweep's retention prune sets it.

Indexes: id_card_events_card_idx (card_id, created_at), id_card_events_type_idx, id_card_events_actor_card_idx.

id_card_generation_job_items

One row per person in a bulk generation job.

ColumnTypeNullableNotes
idbigserialNo
job_iduuidNoFK → id_card_generation_jobs.id, cascade — items are owned by their job.
student_id, staff_iduuidYesExactly one set (CHECK), FK restrict.
statusid_card_job_item_statusNopending default.
card_iduuidYesFK → id_cards.id, restrict. Set exactly when status = 'generated' (CHECK).
outcome_codetextYesSet exactly when status IN ('skipped','failed').
warningstext[]Nodefault '{}'.
attemptssmallintNoCHECK BETWEEN 0 AND 100.
processed_attimestamptzYesNULL exactly when status = 'pending'.

Uniqueness: (job_id, student_id) and (job_id, staff_id), each partial on the respective column being non-null — a person appears at most once per job. id_card_generation_job_items_job_status_idx (job_id, status, id) backs both the caller's row-scoped item list and the worker's next-pending scan.

5.4 Relationship Diagram

6. Services and Responsibilities

6.1 IdCardTemplatesService / IdCardTemplatesLifecycleService / IdCardTemplatesQueryService

MethodCalled ByReadsWritesSide EffectsErrors
create()IdCardTemplatesController.createid_card_templates (name uniqueness)id_card_templatesnoneID_CARD_TEMPLATE_NAME_TAKEN
update().updatetemplate row (FOR UPDATE via version check)id_card_templates.draft_design, draft_hashnoneID_CARD_TEMPLATE_NOT_FOUND, ID_CARD_TEMPLATE_VERSION_CONFLICT, ID_CARD_TEMPLATE_VALIDATION_FAILED
publish().publishdraft design, current version countid_card_template_versions (insert), id_card_templates.published_version_no, statusnoneID_CARD_TEMPLATE_INVALID_DESIGN, ID_CARD_TEMPLATE_DESIGN_TOO_LARGE
activate()/deactivate().activate/.deactivatetemplate rowstatusnoneID_CARD_TEMPLATE_NEVER_PUBLISHED, ID_CARD_TEMPLATE_INVALID_TRANSITION
setDefault()/unsetDefault().setDefault/.unsetDefaulttemplate row, existing default for card_typeis_defaultnoneID_CARD_TEMPLATE_DEFAULT_NOT_ALLOWED (not active, or session-bound)
archive().archiveusage check (any card referencing this template's versions)deleted_at, statusnoneID_CARD_TEMPLATE_IN_USE
duplicate().duplicatesource templatenew id_card_templates row (draft)noneID_CARD_TEMPLATE_NAME_TAKEN

Explain: template creation and update validate the design against id-card-design.ts's schema before persisting draft_design; publish is the only path that writes an immutable id_card_template_versions row, and it re-validates the full design (not just the diff) because a draft accumulated edits since the last publish. card_type is fixed at creation because the field catalogue for elements (photo, roll number, guardian name, etc.) is type-specific.

6.2 IdCardGenerateService / IdCardCandidatesService / IdCardPeoplePickerService / IdCardPreviewService

MethodCalled ByReadsWritesSide EffectsErrors
candidates()GET /id-card-generation/candidatesstudents/staff filtered by scope, existing id_cardsnonenonenone (read-only)
previewData()GET /id-card-generation/preview-dataone person's snapshot fields, Guardians_READ checknonenoneID_CARD_PERSON_NOT_FOUND
generateCards() (single, synchronous)POST /id-card-generation/cardstemplate version, settings, existing active/pending cardsid_cards (insert), id_card_events (generated)notification enqueue via outboxID_CARD_ACTIVE_CARD_EXISTS, ID_CARD_PENDING_CARD_EXISTS, ID_CARD_PERSON_NOT_READY, ID_CARD_VALIDITY_INVALID, ID_CARD_SELECTION_TOO_LARGE

generateCards runs the readiness review (IdCardReadinessService) before writing; a failing readiness check for any selected person aborts that person's row without failing the whole batch when called through the bulk path (see §7.3), but refuses outright for the single-card path.

6.3 IdCardGenerationJobsService / IdCardGenerationJobsReadService

MethodCalled ByReadsWritesSide EffectsErrors
create()POST /id-card-generation-jobspg_advisory_xact_lock per requester, open-job countid_card_generation_jobs, id_card_generation_job_items, outbox eventenqueues PROCESS_GENERATION_JOB via outbox dispatchID_CARD_JOB_LIMIT_REACHED, ID_CARD_JOB_DUPLICATE_REQUEST (idempotent replay of client_request_id)
cancel()POST .../canceljob row FOR UPDATEstatus = 'cancelled' (if queued) or 'cancelling' (if running), matching pending items skippednoneID_CARD_JOB_INVALID_TRANSITION
retry()POST .../retryjob row FOR UPDATEresets failed items to pending, re-enqueuesenqueues PROCESS_GENERATION_JOBID_CARD_JOB_NOTHING_TO_RETRY, ID_CARD_JOB_INVALID_TRANSITION (refused for cancelled/cancelling explicitly, not folded into "nothing to retry")
list()GET /id-card-generation-jobsid_card_generation_jobs, row-scopednonenonenone
detail()/items()GET .../:publicId, .../itemsjob row + items, row-scoped, items additionally filtered by people scopenonenoneID_CARD_JOB_NOT_FOUND (404 when the job exists but is out of the caller's scope)

create()'s per-user pg_advisory_xact_lock(hashtext('id_card_jobs:<userId>')) is taken BEFORE the open-job count is read — a second concurrent submit from the SAME user blocks on the lock rather than racing the count, which would otherwise let two submits both read "0 open jobs" and both pass the cap.

Row scoping (in-flight behavior). list(), detail(), items(), cancel(), and retry() are scoped to the caller: a caller sees and acts on only the jobs they requested, unless their people-scope for the job's card_type is unrestricted ({kind:"all"}), in which case they may act on any job. A job outside the caller's scope answers 404, not 403 — an id space over generation jobs must not become an enumeration oracle. items() additionally filters the returned item rows by the caller's own people scope (e.g., a scoped teacher sees only items for students within their own class scope), even when the parent job itself is visible.

cancel() and retry() lock the job row with FOR UPDATE before deciding the transition, so a concurrent cancel-and-retry pair, or the worker racing to claim the job, serializes on that lock rather than on a repeated status predicate. retry() explicitly refuses cancelled and cancelling jobs rather than reporting "nothing to retry" — a cancelled job's items were deliberately skipped, and retrying one is a status error, not an empty-retry no-op.

6.4 IdCardDetailService / IdCardListService / IdCardDashboardService

MethodCalled ByReadsWritesNotes
detail()GET /id-cards/:publicIdone card row + snapshotnoneGuardians_READ-gated omission — see below
list()GET /id-cardsid_cards, filtered/paginatednoneApplies the caller's people scope.
dashboard()GET /id-cards/dashboardaggregate counts by status/typenone

Guardian disclosure (in-flight behavior). IdCardDetailService.detail() computes canSeeGuardian = isSuperadmin || held.includes("Guardians_READ") and, when false, strips the guardian block from the returned snapshot entirely rather than nulling its fields — a caller without the permission cannot infer even that guardian data exists on the snapshot. IdCardPreviewService's preview-data path applies the same check but resolves guardian.* fields to empty strings instead, because preview renders directly into the design's field placeholders and an omitted key would leave a broken placeholder on screen.

6.5 IdCardIssueActionService / IdCardLifecycleActionService / IdCardPrintActionService / IdCardReplacementActionService

MethodCalled ByReadsWritesSide EffectsErrors
issue()POST /id-cards/issuecard row, settings (allow_issue_without_print)status = 'active', issued_at, closes any prior active card for the same personid_card_events (issued), notificationID_CARD_INVALID_TRANSITION, ID_CARD_NOT_YET_VALID
markPrinted()/reprint()POST /id-cards/print/*card rowprinted_at, print_count += 1id_card_events (printed/reprinted)ID_CARD_INVALID_TRANSITION
render()POST /id-cards/rendertemplate version + snapshotnonenoneID_CARD_RENDER_PERMISSION_REQUIRED
reportIncident()POST /id-cards/:publicId/incidentscard rowstatus → lost/stolen/damaged, closed_at, closed_reasonid_card_events, notification (high priority for incidents)ID_CARD_INVALID_TRANSITION
cancel()POST /id-cards/:publicId/cancelcard rowstatus = 'cancelled', closed_atid_card_eventsID_CARD_INVALID_TRANSITION
createReplacement()POST /id-cards/:publicId/replacementspredecessor card, id_cards_one_live_replacementnew id_cards row with replaces_card_idid_card_events (replaced on predecessor), notificationID_CARD_REPLACEMENT_SOURCE_INVALID, ID_CARD_REPLACEMENT_EXISTS, ID_CARD_REPLACEMENT_REASON_INVALID, ID_CARD_REPLACEMENT_NOTE_REQUIRED, ID_CARD_REPLACE_PERMISSION_REQUIRED

ID_CARD_NOT_YET_VALID (in-flight behavior). issue() refuses to issue a card whose validFrom is after the school's current date (per SchoolClockService) — a card cannot be handed to its holder before its own validity window begins. Thrown from IdCardIssueActionService.

7. Runtime Flows

7.1 Template Design and Publish

StepCode PathBehaviorFailure Case
1IdCardTemplatesController.updateValidates draft design shapeID_CARD_TEMPLATE_INVALID_DESIGN
2IdCardTemplatesLifecycleService.publishRe-validates full design, checks sizeID_CARD_TEMPLATE_DESIGN_TOO_LARGE
3sameInserts an immutable version rowGuard trigger refuses any later mutation of that row
4sameFlips status to active/inactive per caller intentID_CARD_TEMPLATE_INVALID_TRANSITION

7.2 Single Card Generation

Branches: person already holds an active card (ID_CARD_ACTIVE_CARD_EXISTS), a pending card (ID_CARD_PENDING_CARD_EXISTS), readiness fails (ID_CARD_PERSON_NOT_READY), the session has ended for a student card (ID_CARD_SESSION_ENDED), or an administrator-supplied validity window is invalid (ID_CARD_VALIDITY_INVALID).

7.3 Bulk Generation Job

StepCode PathBehaviorFailure Case
1IdCardGenerationJobsService.createAdvisory lock, then open-job cap checkID_CARD_JOB_LIMIT_REACHED
2sameWrites job + one item per selected personID_CARD_JOB_DUPLICATE_REQUEST on a replayed client_request_id
3IdCardGenerationRunnerService.runAcquires the lease (lease_token, lease_expires_at)A dead lease is later reclaimed by the recovery sweep, never by a second concurrent worker pass
4sameOne transaction per item — readiness check, duplicate-card check, card insertA failing item is marked failed/skipped with an outcome_code; the job continues to the next item
5sameJob status set from item outcomescompleted (all generated), completed_with_errors (mixed), failed (unrecoverable)

The outbox write and the job/item rows commit in the same transaction as create(), per the repo-wide outbox invariant — a direct queue.add() in the request handler is never used here.

7.4 Recovery and Lifecycle Sweeps

IdCardRecoverySweepService (every 5 minutes) finds jobs whose lease_expires_at has passed while status = 'running', increments recovery_count (capped at 10), and re-enqueues them so a crashed worker's job resumes rather than hanging forever. IdCardLifecycleSweepService (hourly) sets status = 'expired' for active cards past valid_until, sends the "expiring soon" notification once per card (via expiry_notified_at), deactivates cards for people who have left when auto_deactivate_on_leave is enabled, and prunes verified events past scan_event_retention_days under SET LOCAL id_cards.allow_event_prune = 'on'.

7.5 Authenticated Scan and Public Verification

Disclosure rule (in-flight behavior). A token-based scan (IdCardVerify_READ) returns the full card, plus guardian contacts when the caller also holds Guardians_READ. A card-number lookup through the same endpoint returns basic info only — a card number is guessable in a way a 192-bit token is not, so the full-detail path is reserved for a caller who actually scanned the QR. Deactivation on scan/verify (in-flight behavior). Both the authenticated scan and the public verification page return a deactivated verdict when the holder has left the school or been soft-deleted, even before the hourly lifecycle sweep has recorded the deactivation on the card row itself — the check re-reads person status live rather than trusting the card's own stored status.

8. Caching

The module keeps no Redis-backed cache of its own. All reads go directly to PostgreSQL; the only "cache" behavior is the frozen snapshot/verify_url fields on id_cards, which exist precisely so a historical card never re-reads live data.

9. BullMQ, Schedulers, and Async Work

QueueJobProducerProcessorPayloadRetry/BackoffIdempotency
QueueName.ID_CARDS ("id-cards")IdCardJob.PROCESS_GENERATION_JOBIdCardGenerationJobsService.create/.retry, via outboxIdCardsQueueProcessor → IdCardGenerationRunnerServiceIdCardGenerationJobPayload (job id, requestId)BullMQ's own attempt/backoff plus the job row's own attempt counter and the recovery sweepThe lease token; only its holder may advance the job's status
QueueName.ID_CARDSIdCardJob.RECOVERY_SWEEPIdCardsScheduler (cron, every 5 minutes, buildJobId(["id-card-recovery-sweep", bucket]))IdCardsQueueProcessor → IdCardRecoverySweepServiceIdCardSweepPayload ({v:1, requestId})Cron re-fires next period regardlessBucketed job id dedupes a tick firing twice in the same 5-minute window
QueueName.ID_CARDSIdCardJob.LIFECYCLE_SWEEPIdCardsScheduler (cron, hourly, buildJobId(["id-card-lifecycle-sweep", bucket]))IdCardsQueueProcessor → IdCardLifecycleSweepServiceIdCardSweepPayloadCron re-fires next period regardlessBucketed job id, same as above

IdCardsQueueProcessor's handler map is a Record<IdCardJob, handler> — a job name with no handler is a compile error, matching the repo-wide rule that a queue dispatcher's handler map must be exhaustive. Concurrency is fixed at 1: generation holds a lease per job and the sweeps are whole-school passes, so nothing here benefits from parallel execution within this worker. Every job failure is recorded through JobFailureRecorderService (the shared dead-letter/failure-queue mechanism).

10. Realtime and Events

None. The ID-cards module emits no realtime/Socket.IO events; the only cross-cutting event surface it uses is the shared notification system (§ below) and the outbox for BullMQ.

11. Security, Auth, and Abuse Controls

  • Guards: JwtAuthGuard (global) + RoleGuard on every admin route; @Permissions() codes as listed in the API doc's permission table. IdCardVerificationPublicController carries neither — its two routes are @Public().
  • Permissions: 10 IdCard* modules in permission-catalog.ts, none granted to the seeded staff, teacher, student, or guardian roles by default — a card carries a child's photo, date of birth, and guardian contacts, so every reader is an explicit administrative grant. IdCardPublicVerification is additionally listed in the module's own restricted set (superadmin-only), because it controls what the open internet learns about a child.
  • Row scoping: every route that returns a person applies the caller's people scope (Students_READ/Staff_READ-derived), so a role holding IdCards_READ alone cannot see anyone until it also holds a people-read code.
  • Rate limits: PUBLIC_CARD_VERIFY (60/min per IP, plain ip strategy — deliberately not ip+device, since both signals in that composite are caller-controlled and a scraper rotating either would get a fresh bucket), ID_CARD_SCAN (120/min per account), ID_CARD_JOB_SUBMIT (20/hour per account, bounding concurrent load given a job can carry up to 2000 people).
  • Path validation: every :publicId route param across the module's controllers is UUID-validated by the parameter pipe — a malformed value returns 400 before any query runs.
  • Sensitive data redaction: verification-token URLs are redacted from error logs (utils/http/redact-url.util.ts), so a stack trace or log line never leaks a live token.
  • Response privacy headers: IdCardVerificationPublicController sets Cache-Control: no-store, X-Robots-Tag: noindex, and Referrer-Policy: no-referrer on both its routes — a search engine must never index a child's card, an intermediate cache must never retain one, and a followed link must never carry the token onward in a Referer header.
  • Photo/asset access: id_card_assets are private-typed uploads, sniffed by magic bytes at upload time, served only through the authenticated id-card-assets/:publicId/file relay — never a public storage URL.
  • Fail-closed decisions: guardian-block omission (not nulling) on detail reads without Guardians_READ; 404 (not 403) for a generation job outside the caller's scope, to avoid an id-enumeration oracle; incident reports are irreversible (a card can never return to active once marked lost/stolen/damaged).

12. Error Handling

The ID_CARD_* and related codes, grouped by area. Every throw carries the code, never a bare HTTP status.

Error CodeHTTP StatusConditionClient Action
ID_CARD_NOT_FOUND404publicId does not resolve to a cardRe-check the id; likely a stale list.
ID_CARD_VERSION_CONFLICT409Stale version on a card writeRe-fetch and retry.
ID_CARD_INVALID_TRANSITION409Requested status change is not reachable from the current statusRe-read the card's current status.
ID_CARD_ACTIVE_CARD_EXISTS409Person already holds an active cardIssue a replacement instead of a fresh generation.
ID_CARD_ACTIVE_CARD_IN_NEWER_SESSION409An active card exists tied to a later academic sessionResolve the session mismatch before generating.
ID_CARD_PENDING_CARD_EXISTS409Person already holds a generated/printed cardComplete or cancel the pending card first.
ID_CARD_PERSON_NOT_FOUND404Target student/staff row does not exist or is out of scopeRe-check the person id.
ID_CARD_PERSON_NOT_READY422Readiness review found a blocking gap (e.g. missing required photo)Fix the person's record, then retry.
ID_CARD_SESSION_REQUIRED400Student card request missing an academic sessionSupply academicSessionId.
ID_CARD_SESSION_NOT_FOUND404Supplied session does not existRe-check the session id.
ID_CARD_SESSION_ENDED409Session's end date has already passedGeneration for a finished session is refused.
ID_CARD_VALIDITY_INVALID400Administrator-supplied validity window fails checkValidityOverrideFix the date range (see design rules in the feature doc).
ID_CARD_NOT_YET_VALID409Attempted to issue() a card whose validFrom is in the futureWait until the card's validity window begins.
ID_CARD_SELECTION_TOO_LARGE400Candidate selection exceeds the job cap (2000)Split into multiple jobs.
ID_CARD_REPLACEMENT_SOURCE_INVALID409Predecessor card is not eligible for replacementCheck the predecessor's status.
ID_CARD_REPLACEMENT_EXISTS409Predecessor already has a live (non-terminal) replacementOnly one live replacement chain per card.
ID_CARD_REPLACEMENT_REASON_INVALID400Reason is an incident reason on the wrong route, or otherwise invalidUse the incident route for lost/stolen/damaged.
ID_CARD_REPLACEMENT_NOTE_REQUIRED400Reason is other with no noteSupply a note.
ID_CARD_REPLACE_PERMISSION_REQUIRED403Caller lacks IdCardReplace_CREATERequest the permission.
ID_CARD_RENDER_PERMISSION_REQUIRED403Caller lacks read access to render a card imageRequest IdCards_READ.
ID_CARD_VERIFY_URL_NOT_CONFIGURED409verify_base_url unset while QR verification is enabledConfigure it in settings.
ID_CARD_VERIFY_URL_INVALID400Supplied verify_base_url fails the origin-shape checkSupply a bare origin, no path/query.
ID_CARD_VERIFICATION_DISABLED409qr_verification_enabled/public_verification_enabled is offEnable the relevant setting.
ID_CARD_SCAN_CODE_INVALID400Scan code matches neither a token nor a card numberRe-scan or re-key.
ID_CARD_SETTINGS_VERSION_CONFLICT409Stale version on a settings writeRe-fetch and retry.
ID_CARD_SETTINGS_INVALID400Settings payload fails validationFix the field named in the response.
ID_CARD_TEMPLATE_NOT_FOUND404publicId does not resolve to a templateRe-check the id.
ID_CARD_TEMPLATE_VERSION_CONFLICT409Stale version on a template writeRe-fetch and retry.
ID_CARD_TEMPLATE_NAME_TAKEN409Another live template has the same name (case-insensitive)Choose a different name.
ID_CARD_TEMPLATE_INVALID_DESIGN400Design fails structural validationFix the flagged element/field.
ID_CARD_TEMPLATE_DESIGN_TOO_LARGE413Serialized design exceeds the size capReduce element count/asset references.
ID_CARD_TEMPLATE_VALIDATION_FAILED400Generic design-validator failureSee the response's field-level errors.
ID_CARD_TEMPLATE_INVALID_TRANSITION409Requested status change not reachableRe-read the template's current status.
ID_CARD_TEMPLATE_NOT_ACTIVE409Template used for generation is not activeActivate the template first.
ID_CARD_TEMPLATE_TYPE_MISMATCH400Template's card_type does not match the requestPick a matching template.
ID_CARD_TEMPLATE_SESSION_MISMATCH409Session-bound template used outside its sessionPick a session-agnostic or matching template.
ID_CARD_TEMPLATE_IN_USE409Attempted to archive a template with cards still referencing itCannot archive a template in use.
ID_CARD_TEMPLATE_NEVER_PUBLISHED409Attempted to activate a template with no published versionPublish first.
ID_CARD_TEMPLATE_VERSION_NOT_FOUND404Requested version number does not existRe-check the version number.
ID_CARD_TEMPLATE_DEFAULT_NOT_ALLOWED409Attempted to default a non-active or session-bound templateOnly an active, session-agnostic template may be default.
ID_CARD_ASSIGNMENT_NOT_FOUND404Assignment rule does not existRe-check the id.
ID_CARD_ASSIGNMENT_DUPLICATE409Another rule already covers the exact same scopeEdit the existing rule instead.
ID_CARD_ASSIGNMENT_SCOPE_INVALID400Scope combination violates the schema's shape rules (e.g. section without grade)Fix the scope fields.
ID_CARD_ASSET_NOT_FOUND404Asset does not existRe-check the id.
ID_CARD_ASSET_IN_USE409Attempted to delete an asset a design still referencesRemove the reference first.
ID_CARD_JOB_NOT_FOUND404Job does not exist, or exists outside the caller's scopeRe-check the id.
ID_CARD_JOB_INVALID_TRANSITION409Cancel/retry requested from a status that does not allow itRe-read the job's current status.
ID_CARD_JOB_NOTHING_TO_RETRY409No failed/skipped items to retryNothing to do.
ID_CARD_JOB_LIMIT_REACHED429Caller already has too many open jobsWait for an existing job to finish.
ID_CARD_JOB_DUPLICATE_REQUEST—client_request_id reused; the original result is returned instead of erroringIdempotent replay, not a client error.

13. Observability

SignalLocationPurpose
Logger (structured, module: "IdCards")IdCardsScheduler, IdCardsQueueProcessor, sweep and runner servicesEnqueue/process start/success/failure with jobName/jobId/requestId.
Audit log (MongoDB, via ActivityAuditInterceptor)Every mutating admin routeActor, module (IdCards*), action, target id, outcome — automatic, derived from @Permissions().
JobFailureRecorderServiceIdCardsQueueProcessorRecords a queue job failure to the shared failure/dead-letter table for operator visibility.
Error log redactionredact-url.util.ts applied to verification-token URLsA logged error never leaks a live scan token.

14. Testing and Validation

Test TypeFilesCoverage
Unitapps/api/src/utils/id-cards/*.spec.tsPure design-validator and rules-util functions (verdict, validity windows, card-number formatting) against real inputs, no I/O.
Integrationapps/api/src/modules/id-cards/**/*.int-spec.ts (where present)Services against the real test database, per the repo-wide "never mock the database" rule.
Structureapps/api/test/structure/structure.baseline.jsonRoute/guard fingerprint — run pnpm --filter @skoolsewa/api test:structure after any route change.
Swagger coverageapps/api/test/structure/swagger-coverage.spec.tsEvery ID-cards route appears in the admin OpenAPI document with exactly one tag.

Validation commands:

find apps packages -name '*.tsbuildinfo' -not -path '*/node_modules/*' -delete && pnpm check-types
pnpm test
pnpm --filter @skoolsewa/api test:structure
pnpm check:ci
pnpm turbo run build:docs

See Also