Assets Backend Documentation
Backend architecture, data model, services, and operational behavior for the asset management module.
Assets Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Schema | packages/db/src/schema/school/assets.ts | Every table, column, index, and CHECK, read in full |
| Vocabulary | packages/db/src/schema/school/asset-contract.ts | Every closed enum and the notification matrix, read in full |
| Module wiring | apps/api/src/modules/assets/**/*.module.ts | Composition, controllers, providers |
| Controllers | All 13 apps/api/src/modules/assets/**/*.controller.ts | Route surface, guards, permissions |
| Notifier | apps/api/src/modules/assets/notify/asset-notifier.service.ts | Event-to-notification mapping |
| People integration | apps/api/src/modules/people/shared/people-deletion.service.ts, people-code.service.ts | The deletion guard and code allocator |
| Permissions | packages/db/src/authorization/permission-catalog.ts (671-688, 861-868) | 9 asset permission modules, all SUPERADMIN_ONLY except MyAssets |
| Error codes | apps/api/src/common/types/error-codes.ts (2067-2102, 2110) | Every ASSET_* code and STAFF_HAS_ACTIVE_PAY_OR_ASSETS |
2. Backend Scope and Boundaries
Owns
- The asset vocabulary: types, categories, locations (
catalog). - The asset register itself — creation, edits, activation, custody, placement, condition, maintenance, disposal, attachments (
register). - The accounting application's inbound sync and conflict resolution (
inbound). - The staff self-service "my assets" read (
portal). - Dashboards and CSV export (
reports). - Global settings and the notification matrix (
settings). - Notifications (
notify).
Does Not Own
- Authentication and RBAC — consumes
JwtAuthGuard,RoleGuard,@Permissions()fromcommon/authorization. - The accounting application's own API surface, credentials, and webhook delivery — owned by
integration, which reads the asset change feed and writes only the accounting-owned fields through the inbound path. - Room and department vocabularies —
assetsreferencesroomsanddepartmentsby foreign key but does not define them. - Staff identity — references
staffby foreign key (custodian_staff_id) but does not own the staff row.
There is no separate BullMQ queue for assets: unlike salary, the asset module runs no scheduled sweep. Every write is synchronous within its own request.
3. Module Composition
apps/api/src/modules/assets/
catalog/ AssetCatalogModule — types, categories, locations, staff/department/room lookups
register/ AssetRegisterModule — assets, assignments, maintenance, disposal, attachments
inbound/ AssetInboundModule — accounting sync events and conflict resolution
portal/ AssetPortalModule — staff self-service "my assets" (mounted under /api/mobile)
reports/ AssetReportsModule — dashboard, CSV export
settings/ AssetSettingsModule — global settings, notification matrix
notify/ AssetNotifierService, consumed by register/inbound writers
shared/ Cross-cutting DTOs and helpers used by more than one submodule4. Data Model
Every table below is read in full from packages/db/src/schema/school/assets.ts, cross-checked against packages/db/src/migrations/0046_salary_asset_management_integrity.sql. Money columns are bigint paisa. Every table carries a serial id PK plus a uuid public_id (default uuidv7()) unless noted, and a shared auditColumns() mixin (version int default 1, created_by_user_id/updated_by_user_id FK users.id SET NULL, created_at/updated_at) unless noted otherwise.
asset_types
| Column | Type | Null | Default | Meaning |
|---|---|---|---|---|
code | text | no | — | ^[A-Z][A-Z0-9_]{1,29}$, unique |
name | text | no | — | uniqueIndex(lower(name)), 1-80 trimmed |
description | text | yes | — | ≤500 |
status | text | no | active | ASSET_CATALOG_STATUSES |
sort_order | int | no | 0 | ≥0 |
asset_categories
| Column | Type | Null | Meaning |
|---|---|---|---|
type_id | int | no | FK asset_types.id, RESTRICT; unique(id, type_id) is the composite target assets pins to |
code | text | no | ^[A-Z][A-Z0-9_]{1,29}$, unique |
name | text | no | uniqueIndex(lower(name)), 1-80 trimmed |
description | text | yes | ≤500 |
status | text | no, default active | ASSET_CATALOG_STATUSES |
accounting_code | text | yes | 1-64 trimmed — the optional mapping to the accounting application's chart of accounts |
asset_locations
name (uniqueIndex(lower(name)), 1-120 trimmed), location_type (ASSET_LOCATION_TYPES: office/classroom/laboratory/library/store/hall/ground/other), description (≤500), is_active (bool, default true). Named places that are not rooms — a room comes from the classes module's rooms table instead.
asset_settings
Singleton (uniqueIndex(singleton) WHERE singleton, CHECK singleton = true). accounting_client_id FK integrationClients.id, SET NULL — the one accounting client allowed to report asset accounting state; version, updated_by_user_id, updated_at.
assets
The register itself (packages/db/src/schema/school/assets.ts:267).
| Column | Type | Null | Default | Meaning |
|---|---|---|---|---|
asset_code | text | no | — | ^AST-[0-9]{4}-[0-9]{6}$, unique; allocated by PeopleCodeService's asset scope |
name | text | no | — | 1-150 trimmed |
type_id, category_id | int | no | — | Both required; composite FK (category_id, type_id) → asset_categories(id, type_id), RESTRICT — a category cannot be reassigned to a different type while an asset references it, and an asset cannot be filed under a category belonging to a different type than its own type_id |
description | text | yes | — | ≤2000 |
serial_number | text | yes | — | 1-120 trimmed; uniqueIndex(lower(serial_number)) WHERE serial_number IS NOT NULL AND deleted_at IS NULL — case-insensitively unique among live rows only, so a soft-deleted asset's serial is free to reuse |
model, manufacturer, vendor | text | yes | — | ≤120 / ≤120 / ≤150 |
purchase_date | date | yes | — | Gregorian |
purchase_date_bs_{year,month,day} | smallint | yes | — | All four purchase-date columns null together or all set together (assets_purchase_bs_coherent); BS bounds 2000-2090/1-12/1-32 when set |
purchase_reference | text | yes | — | ≤128 |
purchase_cost_paisa | bigint | yes | — | 0-999,999,999,999 |
warranty_expires_on | date | yes | — | — |
warranty_notes | text | yes | — | ≤2000 |
quantity | int | no | 1 | 1-100000; forced to 1 when serial_number is set (assets_serial_single_quantity) |
status | text | no | draft | ASSET_STATUSES |
condition | text | no | new | ASSET_CONDITIONS |
custodian_staff_id | uuid | yes | — | FK staff.id, RESTRICT |
custodian_department_id | int | yes | — | FK departments.id, RESTRICT |
room_id | int | yes | — | FK rooms.id, RESTRICT; mutually exclusive with location_id (assets_place_exclusive) |
location_id | int | yes | — | FK asset_locations.id, RESTRICT |
assigned_at | timestamptz | yes | — | Set when custody/placement is taken |
notes | text | yes | — | ≤2000 |
accounting_status | text | no | unrecorded | ASSET_ACCOUNTING_STATUSES — owned by the accounting application |
accounting_reference | text | yes | — | ≤128 |
accounting_book_value_paisa | bigint | yes | — | 0-999,999,999,999 |
accounting_updated_at | timestamptz | yes | — | Last inbound-applied timestamp |
accounting_divergence_ack | jsonb | yes | — | Records an admin's acknowledgement of a resolved divergence |
disposed_on, disposal_method, disposal_reason, disposal_proceeds_paisa, disposal_authorized_by_user_id, disposal_reference | — | yes | All except proceeds/reference mandatory together once status='disposed' (assets_disposed_recorded); disposed_on may be set only once the status is disposed/archived (assets_disposal_only_when_disposed) | |
deleted_at | timestamptz | yes | — | Soft delete; only legal while status='draft' (assets_deleted_only_draft) |
Indexes: unique(asset_code); uniqueIndex(lower(serial_number)) WHERE ... (above); index(status, id) WHERE deleted_at IS NULL; index(category_id, type_id); index(type_id); index(custodian_staff_id), index(custodian_department_id), index(room_id), index(location_id), index(disposal_authorized_by_user_id), index(created_by_user_id), index(updated_by_user_id); trigram GIN indexes gin_trgm_ops on name and asset_code for substring search.
CHECKs beyond the ones named above: asset_code/name format and length; status/condition/accounting_status known; disposal_method known when set; serial_number format when set; quantity 1-100000; every money column bounded 0-999,999,999,999; every text field's length; and the status/custody/placement implications, each a separate named CHECK: assets_available_unheld (available ⇒ no custodian, no department, no room — a store location is still allowed), assets_in_use_placed (in_use ⇒ no custodian/department and a room or location set), assets_assigned_held (assigned ⇒ a custodian staff member or department set), assets_retired_unheld (retired ⇒ nothing held), assets_gone_nowhere (disposed/archived ⇒ nothing held and nowhere placed). damaged, lost, and under_maintenance are deliberately absent from this list — those statuses keep whatever custody/placement the asset already had.
Deferred constraint trigger assets_maintenance_check (0046_salary_asset_management_integrity.sql:583-608, fires on either assets or asset_maintenance at commit): status = 'under_maintenance' if and only if exactly one asset_maintenance row for that asset is status = 'open' — the two tables' claims about "is this asset under maintenance" can never disagree at commit, regardless of which one the service wrote first. A maintenance record is never deleted (asset_maintenance_no_delete, 0047_salary_asset_guards.sql): it is closed or cancelled, so the asset can never be left under maintenance with no open record, and the events that narrate it keep their target.
asset_events
Append-only timeline, covered by asset_events_append_only (the same shared salary_append_only-style trigger, 0046_salary_asset_management_integrity.sql:38-73 — despite the name it also guards this table): no UPDATE or DELETE, ever.
| Column | Type | Null | Meaning |
|---|---|---|---|
asset_id | int | no | FK RESTRICT |
event_type | text | no | ASSET_EVENT_TYPES (18 values: created … republished) |
source | text | no | ASSET_EVENT_SOURCES (user/accounting/system) |
from_status, to_status | text | yes | Each independently a known ASSET_STATUSES value or null |
from_condition, to_condition | text | yes | Each independently a known ASSET_CONDITIONS value or null |
from_custodian_staff_id, from_custodian_department_id, from_room_id, from_location_id | — | yes | No foreign key on any of the eight from/to reference columns — history must outlive a deleted room, location, staff row, or department |
to_custodian_staff_id, to_custodian_department_id, to_room_id, to_location_id | — | yes | Same |
from_holder_label, to_holder_label | text | yes | ≤300 each — the human-readable name captured at the time, since the id above carries no FK to join back to a live name |
reason, notes | text | yes | ≤2000 each |
reference | text | yes | ≤128 |
occurred_on | date | no | — |
actor_user_id | uuid | yes | FK RESTRICT |
integration_client_id | int | yes | FK RESTRICT — set when source='accounting' |
maintenance_id | int | yes | The asset_maintenance row this event narrates; FK fk_asset_events_maintenance RESTRICT |
index(asset_id, created_at, id) is the timeline read order; index(actor_user_id), index(integration_client_id), index(maintenance_id).
asset_maintenance
| Column | Type | Null | Default | Meaning |
|---|---|---|---|---|
asset_id | int | no | — | FK RESTRICT |
status | text | no | open | ASSET_MAINTENANCE_STATUSES (open/completed/cancelled) |
opened_on, closed_on | date | — | — | closed_on set iff status <> 'open'; closed_on ≥ opened_on |
description | text | no | — | 3-2000 trimmed |
notes | text | yes | — | ≤2000 |
vendor | text | yes | — | ≤150 |
cost_paisa | bigint | yes | — | 0-999,999,999,999 |
reference | text | yes | — | ≤128 |
status_before | text | no | — | The asset's ASSET_STATUSES value at the moment this ticket opened — what return-to-service restores |
condition_after | text | yes | — | ASSET_CONDITIONS, set on close |
opened_by_user_id | uuid | no | — | FK RESTRICT |
closed_by_user_id | uuid | yes | — | FK RESTRICT; paired with closed_on |
uniqueIndex(asset_id) WHERE status='open' — at most one open ticket per asset, enforced by Postgres (ASSET_MAINTENANCE_OPEN_EXISTS is the service's translation of the resulting 23505). index(asset_id, id), index(opened_by_user_id), index(closed_by_user_id).
asset_attachments
asset_id FK RESTRICT; storage_key (LIKE 'uploads/asset-attachment/%', ≤512, unique); file_name (1-255); content_type (CHECK IN image/jpeg/image/png/image/webp/application/pdf — no other MIME type is accepted at the database, not just the upload interceptor); size_bytes (1-10,485,760, i.e. 1 byte to 10 MiB); uploaded_by_user_id FK RESTRICT; deleted_at/deleted_by_user_id (soft delete, paired). index(asset_id, id) WHERE deleted_at IS NULL is what a listing scans; there is no schema-level cap on attachment count per asset — the per-asset limit (ASSET_ATTACHMENT_LIMIT) is enforced by the service, not a CHECK, because a CHECK cannot count sibling rows.
asset_inbound_events
| Column | Type | Null | Meaning |
|---|---|---|---|
integration_client_id | int | no | FK RESTRICT |
external_event_id | text | no | 1-128, unique(integration_client_id, external_event_id) |
payload_hash | text | no | ^[0-9a-f]{64}$ |
asset_id | int | no | FK RESTRICT |
state | text | no | ASSET_INBOUND_STATES (applied/stale/held_conflict) |
incoming, previous | jsonb | no | The reported payload and the asset's state immediately before it, for the resolution screen to diff |
external_updated_at | timestamptz | no | The accounting application's own event timestamp — what a stale comparison is made against |
resolution | text | yes | ASSET_CONFLICT_RESOLUTIONS (accepted/dismissed); set only when state='held_conflict' (asset_inbound_events_resolution_pair) |
resolved_at, resolved_by_user_id, resolution_note | — | yes | Paired with resolution; note ≤2000 |
A stale event (an incoming external_updated_at older than what is already recorded) is rejected and never stored as a row here at all — only accepted (applied) or held (held_conflict) events persist, so this table's row count is never inflated by noise. index(asset_id), index(state, received_at, id).
asset_notification_settings
One row per event key (eventKey PK, ASSET_NOTIFICATION_KEYS — 8 values), is_enabled, audiences/channels (checked subsets of ASSET_NOTIFICATION_AUDIENCES — custodian/admin — and the four channel literals), enabled ⇒ both non-empty.
5. Services
AssetsService(register) — CRUD, activation (draft → available), the read paths behindAssets_READ. On create and update it derivespurchase_date_bs_{year,month,day}frompurchaseDatewithgregorianToBs(a nullpurchaseDateclears all three), and a date outside BS 2000-2090 is refused with 400CALENDAR_BS_DATE_OUT_OF_RANGE.AssetAssignmentsService— assign/install/transfer/return/uninstall; the one place that enforces the custody/placement CHECK invariants at the application layer before they ever reach Postgres, so a violation surfaces as a translatedASSET_STATUS_TRANSITION_INVALID/ASSET_CUSTODY_INVALID/ASSET_PLACE_INVALIDrather than a raw constraint error.AssetMaintenanceService— condition changes, maintenance open/close/cancel, mark-damaged/mark-lost/return-to-service.AssetDisposalService— retire/dispose/archive, requiring the disposal quartet together.AssetInboundService— accepts accounting payloads, applies or holds them as conflicts, exposes resolution.AssetPortalService— resolves the caller's ownstaffIdand reads their held assets; the sole control on the no-permission portal route.AssetNotifierService—notify(tx, input)in the same transaction as the write that triggered it, looks up the matrix, writes notification rows.AssetCodeAllocator(viaPeopleCodeService) — issues the nextAST-<year>-<6 digits>code;ASSET_CODE_RANGE_EXHAUSTEDonce a year's six-digit range is spent.
6. Runtime Flows
Register → activate → assign: POST /assets creates a draft row → POST /:id/activate moves it to available → POST /:id/assign moves it to assigned with a custodian, appending asset_events rows at each step and firing asset_assigned on the last one.
Accounting sync: the accounting application posts to /api/asset-sync (documented in integration's API doc) → an asset_inbound_events row is written → if the incoming timestamp is newer than what is recorded, the asset's accounting fields are updated and accounting_updated notifies; if a non-accounting-owned field would need to move to match, or the school's own record disagrees, a held_conflict row is created and asset_accounting_conflict alerts an admin, who resolves it through POST /api/asset-sync/events/:id/resolve.
Deletion guard: PeopleDeletionService.assertStaffHoldsNothing takes the same per-staff advisory lock salary uses (SALARY_STAFF_LOCK_NAMESPACE), then checks (among the salary conditions) EXISTS (SELECT 1 FROM assets WHERE custodian_staff_id = ...); any row held throws STAFF_HAS_ACTIVE_PAY_OR_ASSETS naming assetsInCustody in details.
7. Security
Every asset permission module except MyAssets is on SUPERADMIN_ONLY_MODULES, asserted by permissions-catalog.spec.ts — no default role grant, a school must name a role deliberately. The staff portal route carries no @Permissions() and instead resolves the caller's own staffId; it is listed in NO_PERMISSION_ADMIN_HANDLERS in role.guard.ts and mirrored in route-permissions.spec.ts.
8. Observability
Every mutating asset route is covered by the global ActivityAuditInterceptor (module Assets/AssetCatalog/etc., action from the handler's @Permissions()), so every register change, custody move, disposal, and conflict resolution is in the audit log without a dedicated recordActivity call.
9. Testing
Integration specs live under apps/api/src/modules/assets/__tests__/ (register lifecycle, portal, notices/notifications). They connect to the real test database per the repo's testing rules — no mocked ORM, Redis, or queue.