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
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/id-cards/**/*.module.ts (10 modules), apps/api/src/app.module.ts | Composition, ADMIN_MODULES membership |
| Controllers | 10 controllers under apps/api/src/modules/id-cards/**/*.controller.ts | Route paths, verbs, guards, permissions |
| Services | 35 services under apps/api/src/modules/id-cards/**/*.service.ts | Business logic, transactions, side effects |
| Pure rules | apps/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 |
| Schema | packages/db/src/schema/school/id-cards.ts (1026 lines) | 9 tables, 8 enums, every constraint and index |
| Migrations | packages/db/src/migrations/0033_id_card_module.sql, 0034_id_card_constraints_and_seeds.sql | Table/index creation, the card-number sequence, version triggers, seed row, append-only guards |
| Jobs | packages/jobs/src/index.ts (QueueName.ID_CARDS, IdCardJob) | Queue contract and payload shapes |
| Error codes | apps/api/src/common/types/error-codes.ts — ID_CARD_* block | Every code's throw site |
| Permissions | packages/db/src/authorization/permission-catalog.ts | 10 IdCard* modules |
| Rate limits | apps/api/src/common/guards/ip-throttler.config.ts | PUBLIC_CARD_VERIFY, ID_CARD_SCAN, ID_CARD_JOB_SUBMIT |
| Notifications | apps/api/src/modules/notification/templates/id-card-templates.ts | 6 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_ASSETupload 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 ownsid_card_assetsmetadata 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) —
NotificationServiceowns 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 —
SchoolClockServicesupplies the school's current date; the module's pure rule functions never read a clock themselves.
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| Card lifecycle | id_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 design | id_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 person | Partial 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 token | version (bigint, bumped by bump_row_version() triggers) on id_card_settings, id_card_templates, id_card_generation_jobs, id_cards | A 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 progress | id_card_generation_jobs.status + id_card_generation_job_items | The 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. |
| Numbering | id_card_number_seq (a bare bigint sequence) + id_card_settings prefix/padding columns | A 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 token | id_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.
| Module | Type | Path | Controllers | Providers/Exports | Responsibility |
|---|---|---|---|---|---|
IdCardTemplatesAdminModule | Leaf | templates/id-card-templates-admin.module.ts | IdCardTemplatesController | IdCardTemplatesService, query/lifecycle/mapper services | /api/id-card-templates*. |
IdCardTemplateAssignmentsAdminModule | Leaf | templates/id-card-template-assignments-admin.module.ts | IdCardTemplateAssignmentsController | IdCardTemplateAssignmentsService | /api/id-card-template-assignments*. |
IdCardAssetsAdminModule | Leaf | assets/id-card-assets-admin.module.ts | IdCardAssetsController | IdCardAssetsService | /api/id-card-assets*. |
IdCardSettingsAdminModule | Leaf | settings/id-card-settings-admin.module.ts | IdCardSettingsController | IdCardSettingsService | /api/id-card-settings*. |
IdCardGenerationAdminModule | Leaf | generation/id-card-generation-admin.module.ts | IdCardGenerationController, IdCardGenerationJobsController | candidates/preview/generate/job services | /api/id-card-generation*, /api/id-card-generation-jobs*. |
IdCardsAdminModule | Leaf | cards/id-cards-admin.module.ts | IdCardsController, IdCardActionsController | dashboard/detail/list/lifecycle/print/render/replacement services | /api/id-cards*. |
IdCardScanAdminModule | Leaf | verification/id-card-scan-admin.module.ts | IdCardScanController | IdCardScanService | /api/id-card-scan. |
IdCardVerificationPublicModule | Leaf | verification/id-card-verification-public.module.ts | IdCardVerificationPublicController | IdCardVerificationPublicService | /api/id-card-verification* (public). |
IdCardsSharedModule | Leaf (shared) | shared/id-cards-shared.module.ts | None | factory, notifier, person-reader, readiness, render, scope, settings-reader, template-resolver, transition services | Cross-cutting services every other submodule imports. |
IdCardsWorkerModule | Leaf | workers/id-cards-worker.module.ts | None | IdCardGenerationRunnerService, IdCardLifecycleSweepService, IdCardRecoverySweepService, IdCardsQueueProcessor, IdCardsScheduler | The 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
| File | Purpose | Key Exports | Notes |
|---|---|---|---|
utils/id-cards/id-card-rules.util.ts | Pure lifecycle rules, no I/O | cardVerdict(), 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.ts | The mm-based JSON design schema (schemaVersion 1) and its structural validator | Design types, field-key catalogue, validation entry point | Front/back sides, elements with mm positions, dynamic field keys, safe-margin checks. |
shared/id-card-transition.service.ts | Row-locking status transitions for cards and generation jobs | IdCardTransitionService | FOR 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.ts | Pre-generation readiness review | IdCardReadinessService | Flags 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.ts | Row-scoping generation-job reads and actions to their requester | IdCardScopeService | Backs the row-scoped job list/detail/items/cancel/retry behavior described in §7.5. |
shared/id-card-render.service.ts | Renders a card (front/back) from a template version's design and a snapshot | IdCardRenderService | One renderer for designer preview, print, and download — no generated image file is ever persisted. |
shared/id-card-notifier.service.ts | Dispatches the six ID-card notification kinds | IdCardNotifierService | Calls the shared NotificationService; resolves guardian recipients for student cards. |
workers/id-card-generation-runner.service.ts | Processes one bulk generation job | IdCardGenerationRunnerService (508 lines) | Lease-fenced; one transaction per item; writes job-item outcomes as it goes. |
workers/id-card-recovery-sweep.service.ts | Reclaims jobs whose lease expired without the worker finishing | IdCardRecoverySweepService (265 lines) | Runs every 5 minutes via IdCardsScheduler. |
workers/id-card-lifecycle-sweep.service.ts | Expiry, expiring-soon notices, leave deactivation, event prune | IdCardLifecycleSweepService (262 lines) | Runs hourly via IdCardsScheduler. |
5. Data Model
5.1 Schema Source
packages/db/src/schema/school/id-cards.tsOne 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
| Enum | Values | Used By |
|---|---|---|
id_card_type | student, teacher, staff | id_card_templates.card_type, id_cards.card_type, job/assignment card_type |
id_card_template_status | draft, active, inactive, archived | id_card_templates.status |
id_card_status | generated, printed, active, expired, lost, stolen, damaged, replaced, cancelled, deactivated | id_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_type | generated, printed, reprinted, issued, marked_lost, marked_stolen, marked_damaged, replaced, cancelled, expired, deactivated, downloaded, verified, expiry_notified | id_card_events.event_type |
id_card_replacement_reason | lost, damaged, stolen, name_change, photo_update, class_section_change, roll_number_change, designation_change, academic_year_renewal, template_change, other | id_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_status | queued, running, cancelling, completed, completed_with_errors, failed, cancelled | id_card_generation_jobs.status |
id_card_job_item_status | pending, generated, skipped, failed | id_card_generation_job_items.status |
id_card_existing_policy | skip, replace | id_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.
| Column | Type | Nullable | Default | Constraint | Notes |
|---|---|---|---|---|---|
id | smallint | No | 1 | PK, CHECK id = 1 | Singleton. |
student_prefix, teacher_prefix, staff_prefix | text | No | STU/TCH/STF | CHECK ~ '^[A-Z0-9]{1,8}$' | Card-number prefixes. |
number_padding | smallint | No | 6 | CHECK BETWEEN 4 AND 10 | Zero-padding width for the sequence value. |
staff_validity_months | smallint | No | 12 | CHECK BETWEEN 1 AND 120 | Staff/teacher cards run this many months from generation. |
expiring_soon_days | smallint | No | 30 | CHECK BETWEEN 1 AND 180 | Window the lifecycle sweep uses for the "expiring soon" notice. |
require_photo, require_roll_number, require_school_logo | boolean | No | true/false/false | — | Readiness-review requirements. |
allow_issue_without_print | boolean | No | false | — | When false, a card must be marked printed before it can be issued. |
default_existing_policy | id_card_existing_policy | No | skip | — | Default for bulk generation when a person already has a live card. |
auto_deactivate_on_leave | boolean | No | true | — | Whether the lifecycle sweep deactivates a card when its holder leaves. |
qr_verification_enabled | boolean | No | true | — | Whether cards carry a verify URL/QR at all. |
public_verification_enabled | boolean | No | false | — | Whether /api/id-card-verification/:token answers publicly. Written only through PATCH .../public-verification, gated by IdCardPublicVerification_UPDATE (superadmin-only module). |
public_show_name | text | No | initials | CHECK 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_class | boolean | No | false/true/true/false | — | Field-by-field visibility on the public page. |
verify_base_url | text | Yes | NULL | CHECK 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_size | text | No | A4 | CHECK IN ('A4','Letter') | |
print_sides | text | No | both | CHECK IN ('front','back','both') | |
print_duplex_flip | text | No | long | CHECK IN ('long','short') | |
print_cards_per_page | smallint | No | 8 | CHECK BETWEEN 1 AND 12 | |
print_margin_mm | smallint | No | 10 | CHECK BETWEEN 5 AND 30 | Printers cannot print their outer 3-5mm; a card placed there clips. |
print_gap_mm | smallint | No | 4 | CHECK BETWEEN 0 AND 20 | |
safe_margin_mm | smallint | No | 3 | CHECK BETWEEN 0 AND 10 | Minimum clearance a design element must keep from the card edge. |
scan_event_retention_days | smallint | No | 180 | CHECK BETWEEN 30 AND 3650 | How long verified events survive the lifecycle sweep's prune. |
notify_ready_for_pickup, notify_issued, notify_incident, notify_replaced, notify_expiring, notify_expired | boolean | No | all true | — | Per-event notification toggles. |
updated_by_user_id | uuid | Yes | — | FK → users.id, SET NULL | |
version | bigint (number mode) | No | 0 | bumped by id_card_settings_bump_row_version trigger | Optimistic concurrency. |
created_at, updated_at | timestamptz | No | now() |
id_card_assets
| Column | Type | Nullable | Notes |
|---|---|---|---|
id | serial | No | Internal PK. |
public_id | uuid | No, unique | uuidv7() default. |
storage_key | text | No, unique | Points into @skoolsewa/storage under the private ID_CARD_ASSET type. |
original_name | text | No | CHECK length BETWEEN 1 AND 255. |
mime_type | text | No | CHECK IN ('image/png','image/jpeg','image/webp'). |
byte_size | integer | No | CHECK > 0 AND <= 5242880 (5 MiB). |
width, height | integer | Yes | Pixel dimensions, when known. |
uploaded_by_user_id | uuid | Yes | FK → users.id, SET NULL. |
created_at | timestamptz | No | |
deleted_at | timestamptz | Yes | Soft delete. |
A design references an asset by public_id, never by storage key — the storage key never reaches the wire.
id_card_templates
| Column | Type | Nullable | Notes |
|---|---|---|---|
id | serial | No | Internal PK. |
public_id | uuid | No, unique | |
name | text | No | CHECK 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). |
description | text | Yes | CHECK length <= 1000. |
card_type | id_card_type | No | Immutable after creation — the design's field catalogue depends on it. |
academic_session_id | integer | Yes | FK → academic_sessions.id, restrict. CHECK only settable when card_type = 'student'. |
status | id_card_template_status | No | draft default. |
orientation | text | No | CHECK IN ('portrait','landscape'). |
draft_design | jsonb | No | Editable working copy; only a PUBLISHED version is ever used for generation. |
draft_hash | text | No | CHECK ~ '^[0-9a-f]{64}$' (sha256 hex). |
published_version_no | integer | Yes | FK (composite, with id) → id_card_template_versions(template_id, version_no), restrict. |
is_default | boolean | No | At 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_id | uuid | Yes | FK → users.id, SET NULL. |
last_used_at | timestamptz | Yes | Set when a card is generated from the template. |
deleted_at | timestamptz | Yes | Soft delete (archive path). |
version | bigint | No | Bumped by trigger. |
created_at, updated_at | timestamptz | No |
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)
| Column | Type | Nullable | Notes |
|---|---|---|---|
id | serial | No | |
public_id | uuid | No, unique | |
template_id | integer | No | FK → id_card_templates.id, restrict. |
version_no | integer | No | CHECK >= 1; unique per (template_id, version_no) — the composite FK target for templates and cards. |
design | jsonb | No | Frozen design document; what a card is generated from. |
content_hash | text | No | CHECK ~ '^[0-9a-f]{64}$'. |
orientation | text | No | CHECK IN ('portrait','landscape'). |
note | text | Yes | CHECK length <= 500. |
published_by_user_id | uuid | Yes | FK → users.id, SET NULL. |
created_at | timestamptz | No |
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.
| Column | Type | Nullable | Notes |
|---|---|---|---|
id | serial | No | |
public_id | uuid | No, unique | |
template_id | integer | No | Composite FK (with card_type) → id_card_templates(id, card_type), cascade — proves the rule's card type matches its template's. |
card_type | id_card_type | No | |
academic_session_id, grade_id, section_id | integer | Yes | Student-only scope columns (restrict on delete). CHECK: non-student rows must leave all three NULL. |
department_id, designation_id | integer | Yes | Staff-only scope columns (restrict). CHECK: student rows must leave both NULL. |
priority | smallint | No | CHECK BETWEEN 0 AND 1000. Resolution scores the most specific match. |
created_by_user_id | uuid | Yes | FK → users.id, SET NULL. |
created_at | timestamptz | No |
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.
| Column | Type | Nullable | Notes |
|---|---|---|---|
id | uuid | No | uuidv7() PK. |
public_id | uuid | No, unique | |
card_type | id_card_type | No | |
academic_session_id | integer | Yes | Required exactly when card_type = 'student' (CHECK). |
template_id | integer | Yes | FK → id_card_templates.id, restrict. |
existing_policy | id_card_existing_policy | No | skip or replace. |
replacement_reason | id_card_replacement_reason | Yes | Required exactly when existing_policy = 'replace'; incident reasons (lost/stolen/damaged) refused here. |
replacement_note | text | Yes | Required when reason is other; CHECK length <= 500. |
valid_from, valid_until | date | Yes | Both or neither; until >= from. |
status | id_card_job_status | No | queued default. |
total_count | integer | No | CHECK BETWEEN 1 AND 2000. |
requested_by_user_id | uuid | Yes | FK → users.id, SET NULL. |
client_request_id | uuid | No | Unique per (requested_by_user_id, client_request_id) — the idempotency key for a resubmitted request. |
attempt | integer | No | CHECK >= 1. |
recovery_count | smallint | No | CHECK BETWEEN 0 AND 10. |
lease_token | uuid | Yes | Paired with lease_expires_at (CHECK both-or-neither). Only the holder may write status. |
lease_expires_at, last_heartbeat_at | timestamptz | Yes | |
started_at, finished_at | timestamptz | Yes | finished_at set exactly for terminal statuses (CHECK). |
failure_code | text | Yes | |
version | bigint | No | Bumped by trigger. |
created_at, updated_at | timestamptz | No |
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
| Column | Type | Nullable | Notes |
|---|---|---|---|
id | uuid | No | uuidv7() PK. |
public_id | uuid | No, unique | |
card_number | text | No, unique | PREFIX-000041 shape from the sequence + settings. |
card_type | id_card_type | No | |
student_id | uuid | Yes | FK → students.id, restrict. Exactly one of student_id/staff_id set, matching card_type (CHECK). |
staff_id | uuid | Yes | FK → staff.id, restrict. |
academic_session_id | integer | Yes | FK, restrict. Required exactly for card_type = 'student'. |
enrollment_id | uuid | Yes | FK → 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_id | integer | Yes | FK, SET NULL. Student-only (CHECK: staff cards leave these NULL). |
department_id, designation_id | integer | Yes | FK, SET NULL. Staff-only. |
person_name | text | No | CHECK length BETWEEN 1 AND 300. Frozen at generation. |
person_code | text | Yes | |
roll_number | smallint | Yes | Student-only. |
template_id, template_version_no | integer | No | Composite FK → id_card_template_versions(template_id, version_no), restrict — the exact version the card was rendered from. |
snapshot | jsonb | No | The person, placement, and school exactly as they were at generation. |
photo_key | text | Yes | |
status | id_card_status | No | generated default. |
valid_from, valid_until | date | No | CHECK until >= from. |
verification_token | text | No, unique | CHECK ~ '^[A-Za-z0-9_-]{32}$' — 192 random bits, base64url. Never returned outside a print or download render. |
verify_url | text | Yes | Frozen at generation from the settings' verify_base_url; CHECK matches .../verify/card/<token> shape. |
replaces_card_id | uuid | Yes | Self-FK, restrict. CHECK not self-referencing; paired with replacement_reason (both or neither). |
replacement_reason, replacement_note | — | Yes | Same shape rules as the job table. |
generation_job_id | uuid | Yes | FK → id_card_generation_jobs.id, SET NULL. |
generated_by_user_id, printed_by_user_id, issued_by_user_id, closed_by_user_id | uuid | Yes | FK → users.id, SET NULL. |
generated_at | timestamptz | No | default now(). |
printed_at, issued_at, closed_at | timestamptz | Yes | Each paired with its own CHECK (e.g. printed_at required when status = 'printed'). |
print_count | integer | No | default 0; CHECK (printed_at IS NULL) = (print_count = 0). |
closed_reason | text | Yes | CHECK length <= 1000. |
expiry_notified_at | timestamptz | Yes | Set by the lifecycle sweep once, to avoid re-notifying. |
version | bigint | No | Bumped by trigger. |
created_at, updated_at | timestamptz | No |
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.
| Column | Type | Nullable | Notes |
|---|---|---|---|
id | bigserial | No | |
card_id | uuid | No | FK → id_cards.id, restrict. |
event_type | id_card_event_type | No | |
from_status, to_status | id_card_status | Yes | |
actor_user_id | uuid | Yes | FK → users.id, SET NULL. |
reason | text | Yes | CHECK length <= 1000. |
metadata | jsonb | No | default '{}'. |
created_at | timestamptz | No | default 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.
| Column | Type | Nullable | Notes |
|---|---|---|---|
id | bigserial | No | |
job_id | uuid | No | FK → id_card_generation_jobs.id, cascade — items are owned by their job. |
student_id, staff_id | uuid | Yes | Exactly one set (CHECK), FK restrict. |
status | id_card_job_item_status | No | pending default. |
card_id | uuid | Yes | FK → id_cards.id, restrict. Set exactly when status = 'generated' (CHECK). |
outcome_code | text | Yes | Set exactly when status IN ('skipped','failed'). |
warnings | text[] | No | default '{}'. |
attempts | smallint | No | CHECK BETWEEN 0 AND 100. |
processed_at | timestamptz | Yes | NULL 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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
create() | IdCardTemplatesController.create | id_card_templates (name uniqueness) | id_card_templates | none | ID_CARD_TEMPLATE_NAME_TAKEN |
update() | .update | template row (FOR UPDATE via version check) | id_card_templates.draft_design, draft_hash | none | ID_CARD_TEMPLATE_NOT_FOUND, ID_CARD_TEMPLATE_VERSION_CONFLICT, ID_CARD_TEMPLATE_VALIDATION_FAILED |
publish() | .publish | draft design, current version count | id_card_template_versions (insert), id_card_templates.published_version_no, status | none | ID_CARD_TEMPLATE_INVALID_DESIGN, ID_CARD_TEMPLATE_DESIGN_TOO_LARGE |
activate()/deactivate() | .activate/.deactivate | template row | status | none | ID_CARD_TEMPLATE_NEVER_PUBLISHED, ID_CARD_TEMPLATE_INVALID_TRANSITION |
setDefault()/unsetDefault() | .setDefault/.unsetDefault | template row, existing default for card_type | is_default | none | ID_CARD_TEMPLATE_DEFAULT_NOT_ALLOWED (not active, or session-bound) |
archive() | .archive | usage check (any card referencing this template's versions) | deleted_at, status | none | ID_CARD_TEMPLATE_IN_USE |
duplicate() | .duplicate | source template | new id_card_templates row (draft) | none | ID_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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
candidates() | GET /id-card-generation/candidates | students/staff filtered by scope, existing id_cards | none | none | none (read-only) |
previewData() | GET /id-card-generation/preview-data | one person's snapshot fields, Guardians_READ check | none | none | ID_CARD_PERSON_NOT_FOUND |
generateCards() (single, synchronous) | POST /id-card-generation/cards | template version, settings, existing active/pending cards | id_cards (insert), id_card_events (generated) | notification enqueue via outbox | ID_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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
create() | POST /id-card-generation-jobs | pg_advisory_xact_lock per requester, open-job count | id_card_generation_jobs, id_card_generation_job_items, outbox event | enqueues PROCESS_GENERATION_JOB via outbox dispatch | ID_CARD_JOB_LIMIT_REACHED, ID_CARD_JOB_DUPLICATE_REQUEST (idempotent replay of client_request_id) |
cancel() | POST .../cancel | job row FOR UPDATE | status = 'cancelled' (if queued) or 'cancelling' (if running), matching pending items skipped | none | ID_CARD_JOB_INVALID_TRANSITION |
retry() | POST .../retry | job row FOR UPDATE | resets failed items to pending, re-enqueues | enqueues PROCESS_GENERATION_JOB | ID_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-jobs | id_card_generation_jobs, row-scoped | none | none | none |
detail()/items() | GET .../:publicId, .../items | job row + items, row-scoped, items additionally filtered by people scope | none | none | ID_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
| Method | Called By | Reads | Writes | Notes |
|---|---|---|---|---|
detail() | GET /id-cards/:publicId | one card row + snapshot | none | Guardians_READ-gated omission — see below |
list() | GET /id-cards | id_cards, filtered/paginated | none | Applies the caller's people scope. |
dashboard() | GET /id-cards/dashboard | aggregate counts by status/type | none |
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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
issue() | POST /id-cards/issue | card row, settings (allow_issue_without_print) | status = 'active', issued_at, closes any prior active card for the same person | id_card_events (issued), notification | ID_CARD_INVALID_TRANSITION, ID_CARD_NOT_YET_VALID |
markPrinted()/reprint() | POST /id-cards/print/* | card row | printed_at, print_count += 1 | id_card_events (printed/reprinted) | ID_CARD_INVALID_TRANSITION |
render() | POST /id-cards/render | template version + snapshot | none | none | ID_CARD_RENDER_PERMISSION_REQUIRED |
reportIncident() | POST /id-cards/:publicId/incidents | card row | status → lost/stolen/damaged, closed_at, closed_reason | id_card_events, notification (high priority for incidents) | ID_CARD_INVALID_TRANSITION |
cancel() | POST /id-cards/:publicId/cancel | card row | status = 'cancelled', closed_at | id_card_events | ID_CARD_INVALID_TRANSITION |
createReplacement() | POST /id-cards/:publicId/replacements | predecessor card, id_cards_one_live_replacement | new id_cards row with replaces_card_id | id_card_events (replaced on predecessor), notification | ID_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
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | IdCardTemplatesController.update | Validates draft design shape | ID_CARD_TEMPLATE_INVALID_DESIGN |
| 2 | IdCardTemplatesLifecycleService.publish | Re-validates full design, checks size | ID_CARD_TEMPLATE_DESIGN_TOO_LARGE |
| 3 | same | Inserts an immutable version row | Guard trigger refuses any later mutation of that row |
| 4 | same | Flips status to active/inactive per caller intent | ID_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
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | IdCardGenerationJobsService.create | Advisory lock, then open-job cap check | ID_CARD_JOB_LIMIT_REACHED |
| 2 | same | Writes job + one item per selected person | ID_CARD_JOB_DUPLICATE_REQUEST on a replayed client_request_id |
| 3 | IdCardGenerationRunnerService.run | Acquires the lease (lease_token, lease_expires_at) | A dead lease is later reclaimed by the recovery sweep, never by a second concurrent worker pass |
| 4 | same | One transaction per item — readiness check, duplicate-card check, card insert | A failing item is marked failed/skipped with an outcome_code; the job continues to the next item |
| 5 | same | Job status set from item outcomes | completed (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
| Queue | Job | Producer | Processor | Payload | Retry/Backoff | Idempotency |
|---|---|---|---|---|---|---|
QueueName.ID_CARDS ("id-cards") | IdCardJob.PROCESS_GENERATION_JOB | IdCardGenerationJobsService.create/.retry, via outbox | IdCardsQueueProcessor → IdCardGenerationRunnerService | IdCardGenerationJobPayload (job id, requestId) | BullMQ's own attempt/backoff plus the job row's own attempt counter and the recovery sweep | The lease token; only its holder may advance the job's status |
QueueName.ID_CARDS | IdCardJob.RECOVERY_SWEEP | IdCardsScheduler (cron, every 5 minutes, buildJobId(["id-card-recovery-sweep", bucket])) | IdCardsQueueProcessor → IdCardRecoverySweepService | IdCardSweepPayload ({v:1, requestId}) | Cron re-fires next period regardless | Bucketed job id dedupes a tick firing twice in the same 5-minute window |
QueueName.ID_CARDS | IdCardJob.LIFECYCLE_SWEEP | IdCardsScheduler (cron, hourly, buildJobId(["id-card-lifecycle-sweep", bucket])) | IdCardsQueueProcessor → IdCardLifecycleSweepService | IdCardSweepPayload | Cron re-fires next period regardless | Bucketed 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) +RoleGuardon every admin route;@Permissions()codes as listed in the API doc's permission table.IdCardVerificationPublicControllercarries neither — its two routes are@Public(). - Permissions: 10
IdCard*modules inpermission-catalog.ts, none granted to the seededstaff,teacher,student, orguardianroles by default — a card carries a child's photo, date of birth, and guardian contacts, so every reader is an explicit administrative grant.IdCardPublicVerificationis 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 holdingIdCards_READalone cannot see anyone until it also holds a people-read code. - Rate limits:
PUBLIC_CARD_VERIFY(60/min per IP, plainipstrategy — deliberately notip+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
:publicIdroute param across the module's controllers is UUID-validated by the parameter pipe — a malformed value returns400before 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:
IdCardVerificationPublicControllersetsCache-Control: no-store,X-Robots-Tag: noindex, andReferrer-Policy: no-referreron 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 aRefererheader. - Photo/asset access:
id_card_assetsare private-typed uploads, sniffed by magic bytes at upload time, served only through the authenticatedid-card-assets/:publicId/filerelay — never a public storage URL. - Fail-closed decisions: guardian-block omission (not nulling) on detail reads without
Guardians_READ;404(not403) for a generation job outside the caller's scope, to avoid an id-enumeration oracle; incident reports are irreversible (a card can never return toactiveonce markedlost/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 Code | HTTP Status | Condition | Client Action |
|---|---|---|---|
ID_CARD_NOT_FOUND | 404 | publicId does not resolve to a card | Re-check the id; likely a stale list. |
ID_CARD_VERSION_CONFLICT | 409 | Stale version on a card write | Re-fetch and retry. |
ID_CARD_INVALID_TRANSITION | 409 | Requested status change is not reachable from the current status | Re-read the card's current status. |
ID_CARD_ACTIVE_CARD_EXISTS | 409 | Person already holds an active card | Issue a replacement instead of a fresh generation. |
ID_CARD_ACTIVE_CARD_IN_NEWER_SESSION | 409 | An active card exists tied to a later academic session | Resolve the session mismatch before generating. |
ID_CARD_PENDING_CARD_EXISTS | 409 | Person already holds a generated/printed card | Complete or cancel the pending card first. |
ID_CARD_PERSON_NOT_FOUND | 404 | Target student/staff row does not exist or is out of scope | Re-check the person id. |
ID_CARD_PERSON_NOT_READY | 422 | Readiness review found a blocking gap (e.g. missing required photo) | Fix the person's record, then retry. |
ID_CARD_SESSION_REQUIRED | 400 | Student card request missing an academic session | Supply academicSessionId. |
ID_CARD_SESSION_NOT_FOUND | 404 | Supplied session does not exist | Re-check the session id. |
ID_CARD_SESSION_ENDED | 409 | Session's end date has already passed | Generation for a finished session is refused. |
ID_CARD_VALIDITY_INVALID | 400 | Administrator-supplied validity window fails checkValidityOverride | Fix the date range (see design rules in the feature doc). |
ID_CARD_NOT_YET_VALID | 409 | Attempted to issue() a card whose validFrom is in the future | Wait until the card's validity window begins. |
ID_CARD_SELECTION_TOO_LARGE | 400 | Candidate selection exceeds the job cap (2000) | Split into multiple jobs. |
ID_CARD_REPLACEMENT_SOURCE_INVALID | 409 | Predecessor card is not eligible for replacement | Check the predecessor's status. |
ID_CARD_REPLACEMENT_EXISTS | 409 | Predecessor already has a live (non-terminal) replacement | Only one live replacement chain per card. |
ID_CARD_REPLACEMENT_REASON_INVALID | 400 | Reason is an incident reason on the wrong route, or otherwise invalid | Use the incident route for lost/stolen/damaged. |
ID_CARD_REPLACEMENT_NOTE_REQUIRED | 400 | Reason is other with no note | Supply a note. |
ID_CARD_REPLACE_PERMISSION_REQUIRED | 403 | Caller lacks IdCardReplace_CREATE | Request the permission. |
ID_CARD_RENDER_PERMISSION_REQUIRED | 403 | Caller lacks read access to render a card image | Request IdCards_READ. |
ID_CARD_VERIFY_URL_NOT_CONFIGURED | 409 | verify_base_url unset while QR verification is enabled | Configure it in settings. |
ID_CARD_VERIFY_URL_INVALID | 400 | Supplied verify_base_url fails the origin-shape check | Supply a bare origin, no path/query. |
ID_CARD_VERIFICATION_DISABLED | 409 | qr_verification_enabled/public_verification_enabled is off | Enable the relevant setting. |
ID_CARD_SCAN_CODE_INVALID | 400 | Scan code matches neither a token nor a card number | Re-scan or re-key. |
ID_CARD_SETTINGS_VERSION_CONFLICT | 409 | Stale version on a settings write | Re-fetch and retry. |
ID_CARD_SETTINGS_INVALID | 400 | Settings payload fails validation | Fix the field named in the response. |
ID_CARD_TEMPLATE_NOT_FOUND | 404 | publicId does not resolve to a template | Re-check the id. |
ID_CARD_TEMPLATE_VERSION_CONFLICT | 409 | Stale version on a template write | Re-fetch and retry. |
ID_CARD_TEMPLATE_NAME_TAKEN | 409 | Another live template has the same name (case-insensitive) | Choose a different name. |
ID_CARD_TEMPLATE_INVALID_DESIGN | 400 | Design fails structural validation | Fix the flagged element/field. |
ID_CARD_TEMPLATE_DESIGN_TOO_LARGE | 413 | Serialized design exceeds the size cap | Reduce element count/asset references. |
ID_CARD_TEMPLATE_VALIDATION_FAILED | 400 | Generic design-validator failure | See the response's field-level errors. |
ID_CARD_TEMPLATE_INVALID_TRANSITION | 409 | Requested status change not reachable | Re-read the template's current status. |
ID_CARD_TEMPLATE_NOT_ACTIVE | 409 | Template used for generation is not active | Activate the template first. |
ID_CARD_TEMPLATE_TYPE_MISMATCH | 400 | Template's card_type does not match the request | Pick a matching template. |
ID_CARD_TEMPLATE_SESSION_MISMATCH | 409 | Session-bound template used outside its session | Pick a session-agnostic or matching template. |
ID_CARD_TEMPLATE_IN_USE | 409 | Attempted to archive a template with cards still referencing it | Cannot archive a template in use. |
ID_CARD_TEMPLATE_NEVER_PUBLISHED | 409 | Attempted to activate a template with no published version | Publish first. |
ID_CARD_TEMPLATE_VERSION_NOT_FOUND | 404 | Requested version number does not exist | Re-check the version number. |
ID_CARD_TEMPLATE_DEFAULT_NOT_ALLOWED | 409 | Attempted to default a non-active or session-bound template | Only an active, session-agnostic template may be default. |
ID_CARD_ASSIGNMENT_NOT_FOUND | 404 | Assignment rule does not exist | Re-check the id. |
ID_CARD_ASSIGNMENT_DUPLICATE | 409 | Another rule already covers the exact same scope | Edit the existing rule instead. |
ID_CARD_ASSIGNMENT_SCOPE_INVALID | 400 | Scope combination violates the schema's shape rules (e.g. section without grade) | Fix the scope fields. |
ID_CARD_ASSET_NOT_FOUND | 404 | Asset does not exist | Re-check the id. |
ID_CARD_ASSET_IN_USE | 409 | Attempted to delete an asset a design still references | Remove the reference first. |
ID_CARD_JOB_NOT_FOUND | 404 | Job does not exist, or exists outside the caller's scope | Re-check the id. |
ID_CARD_JOB_INVALID_TRANSITION | 409 | Cancel/retry requested from a status that does not allow it | Re-read the job's current status. |
ID_CARD_JOB_NOTHING_TO_RETRY | 409 | No failed/skipped items to retry | Nothing to do. |
ID_CARD_JOB_LIMIT_REACHED | 429 | Caller already has too many open jobs | Wait for an existing job to finish. |
ID_CARD_JOB_DUPLICATE_REQUEST | — | client_request_id reused; the original result is returned instead of erroring | Idempotent replay, not a client error. |
13. Observability
| Signal | Location | Purpose |
|---|---|---|
Logger (structured, module: "IdCards") | IdCardsScheduler, IdCardsQueueProcessor, sweep and runner services | Enqueue/process start/success/failure with jobName/jobId/requestId. |
Audit log (MongoDB, via ActivityAuditInterceptor) | Every mutating admin route | Actor, module (IdCards*), action, target id, outcome — automatic, derived from @Permissions(). |
JobFailureRecorderService | IdCardsQueueProcessor | Records a queue job failure to the shared failure/dead-letter table for operator visibility. |
| Error log redaction | redact-url.util.ts applied to verification-token URLs | A logged error never leaks a live scan token. |
14. Testing and Validation
| Test Type | Files | Coverage |
|---|---|---|
| Unit | apps/api/src/utils/id-cards/*.spec.ts | Pure design-validator and rules-util functions (verdict, validity windows, card-number formatting) against real inputs, no I/O. |
| Integration | apps/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. |
| Structure | apps/api/test/structure/structure.baseline.json | Route/guard fingerprint — run pnpm --filter @skoolsewa/api test:structure after any route change. |
| Swagger coverage | apps/api/test/structure/swagger-coverage.spec.ts | Every 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:docsSee Also
- API doc:
/docs/developer/id-cards/api - Features and flows doc:
/docs/developer/id-cards/feature