Skoolsewa - School Management System Docs
Developer ResourcesAssets

Assets Backend Documentation

Backend architecture, data model, services, and operational behavior for the asset management module.

Assets Backend Documentation

1. Documentation Evidence

AreaFiles InspectedVerified Details
Schemapackages/db/src/schema/school/assets.tsEvery table, column, index, and CHECK, read in full
Vocabularypackages/db/src/schema/school/asset-contract.tsEvery closed enum and the notification matrix, read in full
Module wiringapps/api/src/modules/assets/**/*.module.tsComposition, controllers, providers
ControllersAll 13 apps/api/src/modules/assets/**/*.controller.tsRoute surface, guards, permissions
Notifierapps/api/src/modules/assets/notify/asset-notifier.service.tsEvent-to-notification mapping
People integrationapps/api/src/modules/people/shared/people-deletion.service.ts, people-code.service.tsThe deletion guard and code allocator
Permissionspackages/db/src/authorization/permission-catalog.ts (671-688, 861-868)9 asset permission modules, all SUPERADMIN_ONLY except MyAssets
Error codesapps/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() from common/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 — assets references rooms and departments by foreign key but does not define them.
  • Staff identity — references staff by 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 submodule

4. 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

ColumnTypeNullDefaultMeaning
codetextno—^[A-Z][A-Z0-9_]{1,29}$, unique
nametextno—uniqueIndex(lower(name)), 1-80 trimmed
descriptiontextyes—≤500
statustextnoactiveASSET_CATALOG_STATUSES
sort_orderintno0≥0

asset_categories

ColumnTypeNullMeaning
type_idintnoFK asset_types.id, RESTRICT; unique(id, type_id) is the composite target assets pins to
codetextno^[A-Z][A-Z0-9_]{1,29}$, unique
nametextnouniqueIndex(lower(name)), 1-80 trimmed
descriptiontextyes≤500
statustextno, default activeASSET_CATALOG_STATUSES
accounting_codetextyes1-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).

ColumnTypeNullDefaultMeaning
asset_codetextno—^AST-[0-9]{4}-[0-9]{6}$, unique; allocated by PeopleCodeService's asset scope
nametextno—1-150 trimmed
type_id, category_idintno—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
descriptiontextyes—≤2000
serial_numbertextyes—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, vendortextyes—≤120 / ≤120 / ≤150
purchase_datedateyes—Gregorian
purchase_date_bs_{year,month,day}smallintyes—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_referencetextyes—≤128
purchase_cost_paisabigintyes—0-999,999,999,999
warranty_expires_ondateyes——
warranty_notestextyes—≤2000
quantityintno11-100000; forced to 1 when serial_number is set (assets_serial_single_quantity)
statustextnodraftASSET_STATUSES
conditiontextnonewASSET_CONDITIONS
custodian_staff_iduuidyes—FK staff.id, RESTRICT
custodian_department_idintyes—FK departments.id, RESTRICT
room_idintyes—FK rooms.id, RESTRICT; mutually exclusive with location_id (assets_place_exclusive)
location_idintyes—FK asset_locations.id, RESTRICT
assigned_attimestamptzyes—Set when custody/placement is taken
notestextyes—≤2000
accounting_statustextnounrecordedASSET_ACCOUNTING_STATUSES — owned by the accounting application
accounting_referencetextyes—≤128
accounting_book_value_paisabigintyes—0-999,999,999,999
accounting_updated_attimestamptzyes—Last inbound-applied timestamp
accounting_divergence_ackjsonbyes—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—yesAll 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_attimestamptzyes—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.

ColumnTypeNullMeaning
asset_idintnoFK RESTRICT
event_typetextnoASSET_EVENT_TYPES (18 values: created … republished)
sourcetextnoASSET_EVENT_SOURCES (user/accounting/system)
from_status, to_statustextyesEach independently a known ASSET_STATUSES value or null
from_condition, to_conditiontextyesEach independently a known ASSET_CONDITIONS value or null
from_custodian_staff_id, from_custodian_department_id, from_room_id, from_location_id—yesNo 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—yesSame
from_holder_label, to_holder_labeltextyes≤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, notestextyes≤2000 each
referencetextyes≤128
occurred_ondateno—
actor_user_iduuidyesFK RESTRICT
integration_client_idintyesFK RESTRICT — set when source='accounting'
maintenance_idintyesThe 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

ColumnTypeNullDefaultMeaning
asset_idintno—FK RESTRICT
statustextnoopenASSET_MAINTENANCE_STATUSES (open/completed/cancelled)
opened_on, closed_ondate——closed_on set iff status <> 'open'; closed_on ≥ opened_on
descriptiontextno—3-2000 trimmed
notestextyes—≤2000
vendortextyes—≤150
cost_paisabigintyes—0-999,999,999,999
referencetextyes—≤128
status_beforetextno—The asset's ASSET_STATUSES value at the moment this ticket opened — what return-to-service restores
condition_aftertextyes—ASSET_CONDITIONS, set on close
opened_by_user_iduuidno—FK RESTRICT
closed_by_user_iduuidyes—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

ColumnTypeNullMeaning
integration_client_idintnoFK RESTRICT
external_event_idtextno1-128, unique(integration_client_id, external_event_id)
payload_hashtextno^[0-9a-f]{64}$
asset_idintnoFK RESTRICT
statetextnoASSET_INBOUND_STATES (applied/stale/held_conflict)
incoming, previousjsonbnoThe reported payload and the asset's state immediately before it, for the resolution screen to diff
external_updated_attimestamptznoThe accounting application's own event timestamp — what a stale comparison is made against
resolutiontextyesASSET_CONFLICT_RESOLUTIONS (accepted/dismissed); set only when state='held_conflict' (asset_inbound_events_resolution_pair)
resolved_at, resolved_by_user_id, resolution_note—yesPaired 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 behind Assets_READ. On create and update it derives purchase_date_bs_{year,month,day} from purchaseDate with gregorianToBs (a null purchaseDate clears all three), and a date outside BS 2000-2090 is refused with 400 CALENDAR_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 translated ASSET_STATUS_TRANSITION_INVALID/ASSET_CUSTODY_INVALID/ASSET_PLACE_INVALID rather 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 own staffId and 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 (via PeopleCodeService) — issues the next AST-<year>-<6 digits> code; ASSET_CODE_RANGE_EXHAUSTED once 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.