02 — Backend Modules

For each module: entities (with field summary), command/query handlers (with file path), validators, business rules, lifecycle diagrams, and notification events emitted. Endpoint URLs are catalogued in 07-api-reference; column-level types in 03-database.

Format: every command/query references its handler at src/AssetTracking.Application/Features/<Module>/<Feature>/<Name>.cs. Entity FKs reference the entities defined in this doc.

Index

  1. Identity & Access
  2. Master Data
  3. Assets
  4. Audits
  5. Transfers
  6. Custody
  7. Maintenance
  8. Notifications
  9. Documents
  10. Reporting
  11. Settings
  12. System

Identity & Access

Auth, users, roles, permissions, sessions, login audit. The cornerstone module — every other module's permission gates resolve through it.

Entities

Entity Purpose Key fields
User Account record. Bilingual full name. Email (unique), UserName (unique), FullNamePrimary/Secondary, PasswordHash, SecurityStamp (regenerated to invalidate caches), PhoneNumber, EmailConfirmed, LockoutEnd, AccessFailedCount, PreferredLanguage (ISO code), PreferredTheme (Light/Dark/System), AvatarUrl, UserType (Interactive/Mobile), IsActive, LastLoginAt, LastLoginIp, ManagerId (self-FK), OrganizationId, DefaultLocationId
Role Cosmetic collection of permissions NamePrimary/Secondary, DescriptionPrimary/Secondary, IsSystem (cannot be deleted), IsBuiltIn (seeded — auto-syncs new permissions on startup)
Permission Catalog row Key (unique, e.g. asset.create), Module, NamePrimary/Secondary, DescriptionPrimary/Secondary, IsDangerous (UI hint — red badge), SortOrder
UserRole M:N user↔role with time bounds UserId, RoleId, AssignedBy, AssignedAt, ValidFrom, ValidUntil?
RolePermission M:N role↔permission RoleId, PermissionId, AddedBy, AddedAt
UserPermission Per-user override UserId, PermissionId, Effect (Grant/Deny), Reason, GrantedBy, GrantedAt, ValidFrom, ValidUntil?
RefreshToken Rotating refresh-token family UserId, FamilyId (rotation chain), TokenHash (SHA-256), IssuedAt, ExpiresAt, ReplacedByTokenHash, RevokedAt, RevokedReason, DeviceId, IpAddress. Computed: IsRevoked, IsExpired, IsActive
LoginAudit Every login attempt (success or fail) UserId?, AttemptedEmail, Result (Success/InvalidPassword/LockedOut/Disabled), IpAddress, UserAgent, DeviceId, OccurredAt

Auth flow

sequenceDiagram
  participant C as Client
  participant API
  participant TS as JwtTokenService
  participant PR as PermissionResolver

  C->>API: POST /api/v1/auth/login {email, password}
  API->>API: LoginCommandHandler — verify + check IsActive + lockout
  API->>TS: GenerateTokensAsync(user)
  TS->>PR: GetEffectivePermissionsAsync
  TS->>API: { accessToken, refreshToken, expirations }
  API->>C: 200 + tokens + LoginAudit row written

  C->>API: GET /api/v1/* with Authorization: Bearer <jwt>
  API->>API: UseAuthentication → JwtBearer validates signature
  API->>API: RequirePermissionFilter → PR.GetEffectivePermissionsAsync (cached)

  Note over C: 15 min later, access token expires
  C->>API: POST /auth/refresh {refreshToken}
  API->>TS: RefreshAsync(token)
  TS->>TS: Validate hash, family, not-revoked, not-expired
  TS->>TS: ROTATE — old.ReplacedByTokenHash = hash(new); old.RevokedAt = now
  TS->>API: { newAccessToken, newRefreshToken }
  TS-->>API: REUSE DETECTED — revoke whole family, throw 422

  C->>API: POST /auth/logout
  API->>API: LogoutCommandHandler — revoke specific refresh token

Auth handlers (Application/Features/Auth/)

Command/Query Returns Handler
LoginCommand(Email, Password, DeviceId?) LoginResultDto (tokens + user) Login/LoginCommandHandler.cs
RefreshTokenCommand(RefreshToken, DeviceId?) TokenResultDto RefreshToken/RefreshTokenCommandHandler.cs
LogoutCommand(RefreshToken) Unit Logout/LogoutCommandHandler.cs
ChangePasswordCommand(CurrentPassword, NewPassword) Unit ChangePassword/ChangePasswordCommandHandler.cs
GetCurrentUserQuery CurrentUserDto GetCurrentUser/GetCurrentUserQueryHandler.cs

LoginCommandHandler writes a LoginAudit row on every attempt regardless of outcome. AccessFailedCount increments on InvalidPassword; lockout is set when count exceeds the configured threshold (currently hardcoded — see handler).

User handlers (Application/Features/Users/)

Operation Permission Notes
GetUsers (paged search) user.read Filters: search, isActive, role
GetUserById user.read Includes roles + direct grants
CreateUser user.create Hashes password via IPasswordHasher, sets SecurityStamp = Guid.NewGuid()
UpdateUser user.update Profile fields only — does NOT change roles or password
DeleteUser user.delete Soft-delete via repo.Remove
GetUserRoles user.read UserRoles with role details
GetUserDirectPermissions user.read UserPermissions (grants + denies)
GetUserEffectivePermissions user.read Calls IPermissionResolver for the user
GetUserSessions user.read (own) or user.session.read (others) Active RefreshTokens
RevokeUserSession user.session.revoke Sets RevokedAt on a single token

Role handlers (Application/Features/Roles/)

Operation Permission Notes
GetRoles (paged) role.read Excludes soft-deleted
GetRoleById role.read Includes permissions
CreateRole role.create Optional PermissionIds seeds initial RolePermissions; Ids set explicitly
UpdateRole role.update Name/description only
DeleteRole role.delete Refuses if IsSystem == true
GetRolePermissions role.read Returns the Permissions linked to the role
AssignPermissionsToRole role.update Diff-based — soft-deletes removed, revives previously-deleted, adds new with explicit Id = Guid.NewGuid(). Invalidates permission cache for every user holding the role.

Permission handlers (Application/Features/Permissions/)

Operation Permission Notes
GetPermissions permission.read Returns flat list grouped by Module field — frontend builds the per-module accordion

Permissions are catalog-only; CRUD is closed (only the seeder writes). Obsolete keys are hard-deleted via DatabaseSeeder.obsoleteKeys.

User-Role assignment (Application/Features/UserRoles/)

Operation Permission
AssignRoleToUser user-role.assign
RemoveRoleFromUser user-role.remove

Both invalidate the affected user's permission cache.

User-Permission overrides (Application/Features/UserPermissions/)

Operation Permission
GrantPermission user-permission.grant
RevokePermission user-permission.revoke

Login audit (Application/Features/LoginAudits/)

Operation Permission
GetLoginAudit (paged) login-audit.read

Master Data

Hierarchical or flat catalog tables that other entities reference dimensionally.

Hierarchical entities — Organization, Location, Classification

All three follow the same pattern: self-referencing parent FK, Level field (root=1), bilingual name+description, optional image. Shared traits:

Entity Hierarchy FK Special fields
Organization ParentOrganizationId ManagerId (FK to User)
Location ParentLocationId LocationType (Site/Building/Floor/Zone/Room/Rack/Vehicle/Outdoor), Latitude, Longitude, GeofenceRadiusMeters, Address
Classification ParentClassId DefaultUsefulLifeMonths, RequiresSerialNumber, RequiresMaintenancePlan

Hierarchy depth + labels

Configurable per HierarchyEntityType (Location/Organization/Classification) via the HierarchyConfig + HierarchyLevel settings tables. See §Settings. The HierarchyConfig row caps the maximum depth (e.g., 5 levels for Location) and provides per-level labels (e.g., level 2 = "Floor").

Code generation for hierarchical entities

IHierarchicalCodeService (Persistence) builds child codes by concatenating parent + per-level digits:

Org root:  ORG-000001
Org L2:    ORG-000001-DEPT-0001

Padding width per level comes from HierarchyLevel.CodeDigits.

Operations (parallel for all three)

Endpoints follow the same shape — substitute Organizations / Locations / Classifications:

Operation Permission Handler folder
Get*s (paged + tree) organization.read / location.read / classification.read Get*s
Get*ById same Get*ById
Get*Children same Get*Children (lazy-loaded tree leaves)
Create* *.create Create* — auto-derives Level from parent
Update* *.update Update*
Delete* *.delete Delete* — soft-delete; refuses if children exist (manual SQL Manual_SoftDeleteOrphanedAssetChildren.sql cleans orphaned references)
Export* *.export XLSX/CSV via IReportExporter
Import* *.import Bulk-import from XLSX (template generator: ImportExport/*Excel.cs)

Flat entities — Vendor, Manufacturer

No hierarchy. Bilingual name + contact info.

Entity Special fields
Vendor ContactName, ContactEmail, ContactPhone, Address, TaxId, Notes
Manufacturer Website, SupportEmail, SupportPhone, Notes

Operations: Get*s, Get*ById, Create*, Update*, Delete*, Export*, Import* — same pattern, all under Application/Features/Vendors/ and Application/Features/Manufacturers/.


Assets

The hub of the system. Every other operational entity references Asset.

Entities

Entity Purpose
Asset Current state of a physical asset
AssetDetails 1:1 extension table — kept narrow on Asset, deep details here
AssetStatus Enumeration of statuses (Active, InMaintenance, Missing, etc.) — DB-driven not C# enum, so admins can extend
AssetStatusHistory Append-only log of every status transition
AssetLocationHistory Append-only log of every location change
AssetOrganizationHistory Append-only log of every org change
AssetCustodyHistory Append-only log of every custodian change

Asset field detail

Field Type Notes
NamePrimary/Secondary string / string? Bilingual
DescriptionPrimary/Secondary string? Bilingual
ImageBaseUrl string? Public asset image
OrganizationId Guid (FK) Required
LocationId Guid (FK) Required
ClassificationId Guid (FK) Required — drives the asset's code suffix
StatusId Guid (FK) Required
CurrentCustodianId Guid? (FK to User) Optional
IsCritical bool Drives priority in reports/filters
ConditionRating int? 1-5
AcquisitionMethod enum Purchased/Leased/Donated/Transferred/Other
LastSeenAt DateTime? Updated by audit submissions
LastSeenLocationId Guid? Updated by audit submissions
Details nav 1:1 to AssetDetails

AssetDetails field detail

SerialNumber (often required by classification), ManufacturerId?, VendorId?, Model, Inches, Color, Weight, Price, CurrencyCode, PurchaseDate, PurchaseOrderNumber, WarrantyStart, WarrantyEnd, ExpectedUsefulLifeMonths, CustomFieldsJson (free-form JSON for per-classification extras).

AssetStatus field detail

Key (stable string code, e.g. "Active"), NamePrimary/Secondary, Color (hex), SortOrder, IsTerminal (e.g. Disposed is terminal — no further changes allowed).

Seeded statuses (DatabaseSeeder._assetStatuses):

Key Bilingual name Color Terminal
Active Active / نشط green no
InMaintenance In Maintenance / تحت الصيانة amber no
Missing Missing / مفقود red no
OutOfService Out of Service / خارج الخدمة gray no
Disposed Disposed / مُتلف dark yes
Reserved Reserved / محجوز blue no
OnLoan Checked Out / معار violet no

AssetStatusHistory (representative — same shape for the other 3 history tables)

Field Type Notes
AssetId Guid (FK)
OldStatusId, NewStatusId Guid? / Guid First-ever change has OldStatusId == null
ChangedAt DateTime Server time
ChangedBy Guid (FK to User)
Reason string? Free-form
Source enum Manual/Audit/Transfer/CheckOut/CheckIn/Maintenance/BulkImport/System
SourceRefId Guid? E.g., the audit-result-line that caused the change

Asset handlers (Application/Features/Assets/)

Operation Permission Notes
GetAssets (paged) asset.read Filters: search, organizationId, locationId, classificationId, statusId, isCritical, includeDescendants. Builds root→leaf chains for org/loc/class and returns them in AssetListDto.OrganizationChain/LocationChain/ClassificationChain (info-icon tooltip on the list, see 04 §Assets feature)
GetAssetById asset.read Eager-includes Details, status, custodian
GetAssetHistory asset.history.read Unified timeline merging all 4 history tables
CreateAsset asset.create Generates code: {6-digit-seq}{classificationCode} via NextAssetSequenceAsync
UpdateAsset asset.update Status/location/org/custody changes write history rows
ChangeAssetStatus asset.update Dedicated transition endpoint
DeleteAsset asset.delete Soft-delete
ImportAssets asset.import XLSX bulk-import w/ row-level errors
ExportAssets asset.export XLSX/CSV

History row creation

Every UpdateAsset-style handler that mutates StatusId/LocationId/OrganizationId/CurrentCustodianId creates a corresponding history row in the same transaction. Source reflects the trigger (Manual for direct edits, Audit for review write-back, etc.), and SourceRefId carries the originating entity's id so the history page can deep-link back.


Audits

The most complex module. Lifecycle: Plan → Scope resolve → Assignment → Mobile execution → Submission → Review → Write-back → Plan roll-up.

Entities

Entity Purpose
AuditPlan Header: name, status, priority
AuditPlanScope One scope rule (location-tree, classification, asset-list, intersection)
AuditAssignment Plan + assigned auditor (1:1 active per plan — see invariant)
AuditAssignmentAsset Snapshot of expected state per asset at assignment time
AuditResult Submission record (one per assignment)
AuditResultLine Per-asset outcome line
AuditReviewAction Reviewer's per-line decision (Approve/Reject/Modify)

AuditPlan field detail

NamePrimary/Secondary, DescriptionPrimary/Secondary, Status (Draft/Scheduled/InProgress/Completed/Cancelled), Priority (Low/Normal/High/Urgent), ResolvedAssetCount (cached, populated on create from IAuditScopeResolver).

Removed fields: StartDate, EndDate. The product decided plans don't need start/end dates separately from scheduling state. (Squashed into the consolidated InitialCreate migration on 2026-05-04.)

AuditPlanScope shapes

ScopeType enum dictates which fields are used:

ScopeType Fields used Resolves to
All (none) Every asset in the system
Organization TargetId, IncludeChildren All assets under that org (or just the org if !IncludeChildren)
Location TargetId, IncludeChildren Same shape, location tree
Classification TargetId, IncludeChildren Same shape, classification tree
AssetList AssetIdsJson (JSON array of Guids) The exact set
Combined Two or three of OrganizationId/LocationId/ClassificationId, IncludeChildren INTERSECT — assets matching all set dimensions. At least 2 must be set; resolver throws otherwise.

Resolver: Application/Services/AuditScopeResolver.cs (IAuditScopeResolver). Used by CreateAuditPlanCommandHandler (compute resolved count) and CreateAssignmentCommandHandler (build the asset list for snapshotting).

AuditAssignment field detail

Field Notes
AuditPlanId FK
AssignedUserId FK to User
Status Assigned/Downloaded/InProgress/Submitted/InReview/Completed/Cancelled
DownloadedAt, SubmittedAt Timestamps
DeviceId Captured on download for the mobile shell
PayloadVersion Snapshot version (current = 1)
DraftPayload JSON — opaque mobile-side scan progress. Persisted server-side so the auditor can resume across devices.
DraftUpdatedAt When the draft last changed

Invariant: only one active (non-cancelled) assignment per plan. CreateAssignmentCommandHandler:51 enforces with a ConflictException. To re-assign, cancel the existing one first.

Removed: DueDate. (Squashed into the consolidated InitialCreate migration on 2026-05-04.)

AuditAssignmentAsset — snapshots

Captured at assignment-create time so discrepancy detection is stable even if the asset's actual fields change before submission:

ExpectedOrganizationId   ExpectedLocationId   ExpectedClassificationId   ExpectedStatusId   ExpectedCustodianId?

These are what the mobile app shows the auditor as "should be here". Comparing the auditor's observed values to these produces the Outcome.

AuditResult + AuditResultLine field detail

AuditResult: AuditAssignmentId, SubmittedBy, SubmittedAt, ClientSubmissionId (idempotency key from mobile), DeviceInfo, Latitude/Longitude (optional GPS), ReviewStatus (PendingReviewInReviewApproved/Rejected (terminal)/UnderClarification), ReviewStartedAt, ReviewCompletedAt, ReviewedBy.

AuditResultLine: AuditResultId, AssetId? (null for purely-extra lines that didn't resolve), Outcome, IdentificationMethod (Qr/Manual), Observed*Id fields, ObservedConditionRating?, Notes, PhotosCount. Linked photos live in DocumentLink rows with OwnerType=AuditResultLine.

AuditOutcome enum

Outcome Meaning Approve write-back
Found Asset where expected none
NotFound Could not locate sets asset's status to Missing
LocationMismatch Asset found, wrong location writes ObservedLocationId to the asset
OrganizationMismatch Asset found, wrong org writes ObservedOrganizationId to the asset
Extra Scanned but not in assignment writes ObservedLocationId to the asset (same write-back as LocationMismatch — distinct outcome to flag the auditor's view of "unexpected scan")

Classification is intentionally not an outcome — assets' codes are derived from classification, so a mis-classified asset must be deleted and re-imported. See AuditOutcome.cs doc comment.

AuditReviewAction

AuditResultLineId, Action (Approve/Reject/Modify), reviewer-modified ModifiedOrganizationId/LocationId/ClassificationId (only used when Action=Modify), Notes, ActedAt, ActedBy.

A line can have multiple review actions over time (e.g., line was rejected, auditor clarifies, reviewer approves). The latest action wins for write-back.

Audit lifecycle

stateDiagram-v2
  [*] --> Draft : CreateAuditPlan
  Draft --> Scheduled : CreateAssignment (first)
  Scheduled --> InProgress : SubmitResult (first)
  InProgress --> Completed : ReviewLines (last assignment fully approved)
  Draft --> Cancelled
  Scheduled --> Cancelled
  InProgress --> Cancelled

  state Assignment {
    [*] --> Assigned : CreateAssignment
    Assigned --> Downloaded : mobile downloads template / draft
    Downloaded --> InProgress : SaveDraft
    InProgress --> Submitted : SubmitResult
    Submitted --> InReview : reviewer opens it
    InReview --> Completed : all lines reviewed
    Assigned --> Cancelled
    Downloaded --> Cancelled
    InProgress --> Cancelled
  }

Audit Plan handlers (Application/Features/AuditPlans/)

Operation Permission Notes
GetAuditPlans (paged) audit-plan.read Filters: status, priority, search
GetAuditPlanById audit-plan.read Includes Scopes
GetPlanAssignments (paged) audit-plan.read List of assignments under this plan
PreviewScope audit-plan.read Returns the asset id list for given scope rules without persisting — used by the create form to show "X assets will be in scope"
CreateAuditPlan audit-plan.create Persists scopes (each gets explicit Id = Guid.NewGuid()); stamps ResolvedAssetCount from IAuditScopeResolver
UpdateAuditPlan audit-plan.update Reconciles scope diff; recomputes ResolvedAssetCount
DeleteAuditPlan audit-plan.delete Soft-delete; refuses if any non-cancelled assignment exists

Audit Assignment handlers (Application/Features/AuditAssignments/)

Operation Permission Notes
GetMyAssignments (paged) audit-assignment.read Filtered to AssignedUserId == currentUser; ordered CreatedAt DESC
GetAssignment audit-assignment.read Single assignment + plan
GetAssignmentAssets audit-assignment.read The snapshot rows + each asset's location chain (used by mobile for hierarchical filter)
GetAssignmentResult audit-result.read The result if submitted
GetLocationHierarchy audit-assignment.read Per-level labels for the auditor's mobile-side filter UI
LookupAsset audit-assignment.read Resolve asset id (from QR scan) → name/code, scoped to the auditor's permissions
LookupAssetByCode audit-assignment.read Manual entry path: type the printed code → resolve to id
GetDraft audit-assignment.read (own) Returns saved DraftPayload JSON
SaveDraft audit-assignment.submit Debounced PUT from mobile scanner; persists JSON blob
UploadPhoto audit-assignment.submit Multipart upload; creates a Document + returns its id for the mobile app to attach
CreateAssignment audit-assignment.create Resolves scope, creates assignment + snapshots; flips plan Draft→Scheduled if first; emits audit.assigned notification

Audit Result handlers (Application/Features/AuditResults/)

Operation Permission Notes
GetResult audit-result.read Result + lines + review actions
GetLines (paged) audit-result.read For the review screen
GetPendingReviews (paged) audit-result.review Reviewer's queue
GetPhoto audit-result.read Streams a photo through IFileStorage
GenerateExcelTemplate audit-assignment.submit Build XLSX with the assignment's expected assets pre-filled — alternative to mobile scanning
SubmitResult audit-assignment.submit Idempotent via ClientSubmissionId. Validates photo doc ids exist, writes lines + DocumentLinks atomically. Flips assignment to Submitted, plan Scheduled→InProgress. Emits audit.result.review-required to all users with audit-result.review.
SubmitResultFromExcel audit-assignment.submit Parse uploaded XLSX → same submission flow
ReviewLines (single line) audit-result.review Adds an AuditReviewAction, performs write-back (UpdateAsset + history) when Action=Approve. When all lines reviewed → assignment Completed, plan roll-up. Emits audit.result.approved to submitter when all lines approved.
BulkReviewLines audit-result.review Multi-line variant

Notification events emitted

Template key Trigger Recipients Merge fields supplied
audit.assigned CreateAssignment the assigned auditor plan.name, plan.code, assignment.code, assignment.assetCount, link, user.*
audit.result.review-required SubmitResult every user with audit-result.review (excluding the submitter) result.code, assignment.code, plan.name, plan.code, submitter.name, link, user.*
audit.result.approved ReviewLines (when all lines reviewed) the submitter result.code, link, user.*

Transfers

Asset transfer workflow with multi-line approval gating.

Entities

AssetTransfer:

Field Notes
FromOrganizationId? / ToOrganizationId? At least one of the org/location pairs must change
FromLocationId? / ToLocationId?
Reason Required, free text (any length — column is nvarchar(max) to allow Quill rich-text + embedded images)
Status Draft/Submitted/Approved/InTransit/Completed/Rejected/Cancelled
RequestedBy, RequestedAt
ApprovedBy?, ApprovedAt? Set on Approve OR Reject (the "decided by" fields)
CompletedBy?, CompletedAt? Set on Complete
ExpectedCompletionDate?
RejectionReason?
Lines At least one AssetTransferLine

AssetTransferLine: AssetTransferId, AssetId, ReceivedAt?, ReceivedStatus? (Ok/Damaged/Missing), Notes?.

Lifecycle

stateDiagram-v2
  [*] --> Draft : CreateTransfer
  Draft --> Submitted : SubmitTransfer
  Submitted --> Approved : ApproveTransfer
  Approved --> InTransit : (auto on Approve, in handler)
  InTransit --> Completed : CompleteTransfer (after all lines received)
  Submitted --> Rejected : RejectTransfer
  Draft --> Cancelled : CancelTransfer
  Submitted --> Cancelled : CancelTransfer

ApproveTransferCommandHandler sets status to Approved and immediately to InTransit in the same save (implementation detail — the Approved state is barely visible; you can think of it as a single action).

Operations (Application/Features/AssetTransfers/)

Operation Permission
GetTransfers (paged) asset-transfer.read
GetTransferById asset-transfer.read
CreateTransfer (Draft, with lines) asset-transfer.create
SubmitTransfer asset-transfer.submit
ApproveTransfer asset-transfer.approve
RejectTransfer asset-transfer.reject
ReceiveTransferLine asset-transfer.receive
ReceiveAllTransferLines asset-transfer.receive
CompleteTransfer asset-transfer.complete
CancelTransfer asset-transfer.cancel
DeleteTransfer asset-transfer.delete

CompleteTransferCommandHandler:

  1. Refuses if any line has no ReceivedAt.
  2. For each line with ReceivedStatus = Ok: writes the new location/org to the asset and creates AssetLocationHistory / AssetOrganizationHistory rows with Source = Transfer, SourceRefId = transferId.

Notifications emitted

Template key Trigger Recipients Merge fields
transfer.pending SubmitTransfer users with asset-transfer.approve (excluding submitter) transfer.code, transfer.reason, link, user.*
transfer.approved ApproveTransfer requester + users with asset-transfer.receive transfer.code, transfer.reason, approver.name, link, user.*
transfer.rejected RejectTransfer requester transfer.code, transfer.reason, transfer.rejectionReason, link, user.*
transfer.completed CompleteTransfer requester transfer.code, transfer.reason, link, user.*

Custody

Per-asset check-out / check-in tracking. Independent of transfers (which move an asset's home location).

CheckOut entity

Field Notes
AssetId
CustodianId The user taking custody
CheckedOutBy The user issuing the check-out (often a manager)
CheckedOutAt, ExpectedReturnAt?
Purpose Free text
Status Active/Returned/Overdue/Lost/Cancelled
CheckedInBy?, CheckedInAt? Set on check-in
ReturnConditionRating?, ReturnNotes?
PreviousStatusId? Asset's status at check-out time, restored on check-in

Operations (Application/Features/CheckOuts/)

Operation Permission Side effects on Asset
GetCheckOuts (paged) checkout.read
GetCheckOutById checkout.read
CreateCheckOut checkout.create Sets Asset.StatusId = OnLoan, Asset.CurrentCustodianId = CustodianId. Saves PreviousStatusId. Writes status + custody history.
CheckIn checkout.check-in Restores Asset.StatusId = PreviousStatusId, clears CurrentCustodianId. Writes history.
CancelCheckOut checkout.cancel If checkout was Active → reverts asset state (same as CheckIn)

Overdue status is computed by a periodic job (or filtered ad-hoc on read) based on ExpectedReturnAt < now. Not persisted automatically.


Maintenance

Three layers: MaintenancePlan (scheduled, reusable) → MaintenanceRequest (user-filed) → WorkOrder (the actual work).

MaintenancePlan + MaintenancePlanAsset

Multi-asset plans. The single-AssetId and ClassificationId fields that earlier shipped on MaintenancePlan were dropped in favor of a many-to-many join table.

MaintenancePlan fields: NamePrimary/Secondary, Frequency (Daily/Weekly/Monthly/Quarterly/Yearly/CustomDays), CustomIntervalDays?, NextDueDate?, LastPerformedDate?, EstimatedDurationHours?, DefaultVendorId?, IsActive.

MaintenancePlanAsset: MaintenancePlanId, AssetId. Unique on (PlanId, AssetId).

MaintenanceRequest

User-filed report. Fields:

Field Notes
AssetId, ReportedBy, ReportedAt
Severity Low/Medium/High/Critical
Summary, Description
Status Open/UnderReview/PromotedToWorkOrder/Rejected/Cancelled
LinkedWorkOrderId? Set on Promote
ReviewedBy?, ReviewedAt?, RejectionReason?

WorkOrder

The unit of work — assigned to a user or vendor, scheduled, costed.

Field Notes
AssetId
OriginType Plan/Request/Manual
OriginRefId? Plan or Request id
Type Preventive/Corrective/Inspection/Calibration/Other
Priority Low/Normal/High/Critical
Summary, Description
Status Open/Assigned/InProgress/OnHold/Completed/Cancelled
AssignedToUserId? XOR with vendor
AssignedToVendorId? XOR with user
ScheduledStart/End? Plan time window
ActualStart/End? Real start/end
DowntimeHours?, LaborCost?, PartsCost?, TotalCost?, CurrencyCode?
ResolutionNotes?
PreviousStatusId? Asset's status before WO InProgress, restored on Complete

Operations

MaintenancePlans (Application/Features/MaintenancePlans/)

Operation Permission Notes
GetPlans (paged) maintenance-plan.read Filters: search, code, isActive, frequency, nextDueFrom/To. assetId filter matches plans containing that asset (via the join).
GetPlanById maintenance-plan.read Includes Assets list with code+name
GetPlanWorkOrders (paged) maintenance-plan.read History of WOs generated from this plan
CreatePlan maintenance-plan.create Validates ≥1 AssetIds; persists join rows with explicit Ids
UpdatePlan maintenance-plan.update Diffs join rows (add/remove); reconciles other fields
DeletePlan maintenance-plan.delete Soft-delete
GenerateWorkOrders maintenance-plan.generate Refuses if !IsActive, no assets, or NextDueDate > now (one cycle = one batch). Emits one WorkOrder per plan-asset. Sets each WO's Status = Assigned if DefaultVendorId is set, else Open. Rolls plan: LastPerformedDate = now; NextDueDate = AdvanceDueDate(now, Frequency, CustomIntervalDays).

MaintenanceRequests (Application/Features/MaintenanceRequests/)

Operation Permission
GetRequests (paged) maintenance-request.read
GetRequestById maintenance-request.read
CreateRequest maintenance-request.create
PromoteToWorkOrder maintenance-request.promote
RejectRequest maintenance-request.reject

WorkOrders (Application/Features/WorkOrders/)

Operation Permission Status transition / side effect
GetWorkOrders (paged) work-order.read
GetWorkOrderById work-order.read
CreateWorkOrder work-order.create Status = Open (or Assigned if vendor set)
AssignWorkOrder work-order.assign Status = Open → Assigned
StartWorkOrder work-order.update Status = Assigned → InProgress, captures Asset.StatusId into PreviousStatusId, sets Asset.StatusId = InMaintenance
CompleteWorkOrder work-order.close Status → Completed, restores Asset.StatusId = PreviousStatusId, computes TotalCost, fills ActualEnd, optional DowntimeHours
CancelWorkOrder work-order.update Status → Cancelled

work-order.update ("Update Work Orders") gates Start + Cancel. work-order.close ("Close Work Orders") gates Complete only. Both controller endpoints are wired and the frontend gates the matching buttons. (See §Frontend §work-order-detail.)

Notifications emitted

Template key Trigger Recipients Merge fields
work-order.assigned AssignWorkOrder the assignee wo.code, wo.summary, link, user.*

Notifications

Bilingual template engine + per-user preferences + in-app + email channels.

Entities

Entity Purpose
NotificationTemplate One row per (Key, Channel) — bilingual subject + body with {{ merge.fields }} placeholders
Notification An in-app message addressed to a recipient
NotificationDelivery A queued/sent email row driven by NotificationDeliveryWorker
NotificationPreference Per-user channel toggle for a given template key

NotificationTemplate field detail

Key (event id, e.g. audit.assigned), Channel (InApp/Email), SubjectPrimary?/Secondary? (subject in two languages), BodyPrimary/Secondary? (body in two languages), IsActive. Same key can have one row per channel.

Notification field detail

RecipientUserId, Title, Body (rendered + plain-text-cleaned for InApp — <p> and &nbsp; stripped), Severity (Info/Success/Warning/Error), Link? (relative path), IsRead, ReadAt?, TemplateKey, ContextJson? (the merge-fields dict, JSON-serialized).

NotificationDelivery field detail

NotificationId? (links back to the in-app row when both channels were delivered), RecipientUserId, Channel (always Email currently), Status (Queued/Sending/Sent/Failed/Bounced/Rejected), Attempts, LastAttemptAt?, NextAttemptAt?, ProviderMessageId?, ErrorMessage?, Subject?, Body, Recipient? (email address).

NotificationPreference field detail

UserId, TemplateKey, Channel, IsEnabled. Missing rows = enabled (opt-out, not opt-in).

NotificationService API

Application/Services/NotificationService.cs:

PublishAsync(string templateKey, Guid recipientUserId, IDictionary<string,object?> mergeFields,
             string? link = null, CancellationToken ct = default);

PublishToManyAsync(string templateKey, IEnumerable<Guid> recipientIds, IDictionary<string,object?> mergeFields,
                   string? link = null, CancellationToken ct = default);

PublishToPermissionAsync(string templateKey, string permissionKey, IDictionary<string,object?> mergeFields,
                         string? link = null, Guid? excludeUserId = null, CancellationToken ct = default);

Steps:

  1. Look up the user → respect PreferredLanguage for primary/secondary template choice.
  2. Auto-inject user.fullName, user.email, user.code, user.userName, and link into the merge-fields dict (caller values win via TryAdd).
  3. Render subject + body via the regex \{\{\s*([\w\.]+)\s*\}\}. Pre-replace &nbsp; and   to plain space (admin Quill editor injects these — without normalization the merge regex misses placeholders).
  4. For InApp: strip HTML tags + decode entities → plain text → INSERT Notification.
  5. For Email (if user.Email exists and channel enabled): keep HTML → INSERT NotificationDelivery with Status = Queued.

NotificationDeliveryWorker (Infrastructure, hosted service): polls NotificationDeliveries where Status = Queued AND (NextAttemptAt is null OR NextAttemptAt <= now), claims a BatchSize chunk, calls IEmailSender.SendAsync(...). Retries up to MaxAttempts with exponential backoff. Marks Sent/Failed/Bounced accordingly.

SmtpEmailSender reads its config live from EmailProviderSettings (singleton row), decrypts the password via IDataProtector, applies the RedirectAllToAddress if RedirectAllEnabled (staging escape hatch), and prefixes the subject with the original recipient address so testers can tell who each redirected mail was meant for.

Operations

Notification templates (Application/Features/NotificationTemplates/)

Operation Permission
GetTemplates (paged) notification-template.read
GetTemplateById notification-template.read
GetAvailableTemplates notification-template.read (returns the NotificationEventCatalog of supported events with merge-field documentation)
CreateTemplate notification-template.create
UpdateTemplate notification-template.update
DeleteTemplate notification-template.delete
PreviewTemplate notification-template.read (renders a sample with mocked merge fields)

My notifications (Application/Features/Notifications/)

Operation Permission
GetMyNotifications (paged) me.notification.read
GetUnreadCount me.notification.read
MarkAsRead me.notification.read
MarkAllAsRead me.notification.read
DeleteNotification me.notification.read
ClearAllNotifications me.notification.read

My preferences (Application/Features/NotificationPreferences/)

Operation Permission
GetMyPreferences me.notification-preference.update
UpdateMyPreferences me.notification-preference.update

NotificationEventCatalog

Application/Services/NotificationEventCatalog.cs is the source of truth for which template keys exist + which merge fields the system passes for each. Used by:

  • The admin "Available templates" list (so an admin doesn't invent a key the system never publishes).
  • Documentation in this section.

Every event currently in the catalog:

Key Merge fields
audit.assigned plan.name, plan.code, assignment.code, assignment.assetCount
audit.result.review-required result.code, assignment.code, plan.name, plan.code, submitter.name
audit.result.approved result.code
transfer.pending transfer.code, transfer.reason
transfer.approved transfer.code, transfer.reason, approver.name
transfer.rejected transfer.code, transfer.reason, transfer.rejectionReason
transfer.completed transfer.code, transfer.reason
work-order.assigned wo.code, wo.summary

Plus auto-injected on every event: user.fullName, user.email, user.code, user.userName, link.


Documents

Polymorphic file-attachment system.

Entities

Document: file metadata (NamePrimary, FileName, MimeType, SizeBytes, StorageKey, Sha256, UploadedBy, UploadedAt, ScanStatus (Pending/Clean/Infected/ScanFailed), Category (Invoice/Warranty/Manual/Photo/Certificate/AuditEvidence/Other)). The actual bytes live on disk via IFileStorage.

DocumentLink: DocumentId, OwnerType (Asset/AssetTransfer/WorkOrder/MaintenanceRequest/AuditResult/AuditResultLine/User/Organization/Vendor/CheckOut), OwnerId, LinkedBy, LinkedAt. A document can be linked to multiple owners; deleting a link doesn't delete the document.

Operations (Application/Features/Documents/)

Operation Permission Notes
UploadDocument document.upload Multipart; computes SHA-256, stores via IFileStorage, sets ScanStatus = Pending
DownloadDocument document.read Streams via IFileStorage
GetDocumentById document.read Metadata only
GetDocumentsForOwner document.read Filters DocumentLinks by (OwnerType, OwnerId)
LinkDocument document.link Creates a new DocumentLink
UnlinkDocument document.link Soft-deletes the link
DeleteDocument document.delete Soft-deletes the Document (links remain orphaned but invisible due to FK soft-delete cascade in the query filter)

There is no built-in virus scan integration — ScanStatus stays Pending forever unless an external job updates it. The frontend ignores the field; admins can wire a custom scanner if needed.


Reporting

Per-page filterable reports backed by hard-coded query handlers in Application/Features/BusinessReports/. Each one has a paged GET and a matching XLSX/PDF export, gated by per-report granular permission keys.

Folder Returns Used by Read permission
AssetInventory Filtered asset list /reports/asset-inventory report.asset-inventory.read
AuditHistory All audit-result lines across all results /reports/audit-history report.audit-history.read
AuditResults Result-level summary /reports/audit-results report.audit-results.read
TransferHistory Transfer rows + lines /reports/transfer-history report.transfer-history.read
MaintenanceHistory WorkOrder rows /reports/maintenance-history report.maintenance-history.read
CheckoutActivity CheckOut rows /reports/checkout-activity report.checkout-activity.read

Each has a Get*Query + Export* path that uses IReportExporter (Infrastructure) → XLSX or PDF via QuestPDF. Export endpoints additionally check report.{key}.export-excel or report.{key}.export-pdf based on the format query param.

Per feedback_permission_granularity, what was previously a single report.read was split per-report (e.g., report.audit-history.read, report.audit-history.export-excel, report.audit-history.export-pdf). The generic report.read is in DatabaseSeeder.obsoleteKeys.


Settings

System-wide configuration tables. Administered via /settings/* pages.

HierarchyConfig + HierarchyLevel

One HierarchyConfig per EntityType (Location/Organization/Classification). Each has 1–5 HierarchyLevel rows defining LevelNumber, LabelPrimary/Secondary (e.g. "Building", "Floor", "Room"), and CodeDigits (padding width for the per-level code segment).

Operation Permission
GetHierarchyConfig(entityType) hierarchy-config.read
SetHierarchyConfig(entityType, levels) hierarchy-config.update

AppSettings

Singleton (id = 11111111-1111-1111-1111-111111111111). Fields:

Field Default Notes
PrimaryLanguageCode "en" ISO 639-1; the language stored in every *Primary column
SecondaryLanguageCode "ar" ISO 639-1; the language in every *Secondary column
Operation Permission
GetAppSettings app-language.manage
UpdateAppSettings app-language.manage

Translation

Admin overrides for UI translation keys. The frontend's translations.ts is the source of truth for defaults; this table only stores edited rows. Active map = bundled defaults merged with Translations rows.

Operation Permission
GetTranslations(lang, search) (paged) translation.read
GetActiveTranslations(lang) (anonymous after login) — merged map served to the i18n service
UpdateTranslation(key, lang, value) translation.update
DeleteTranslation(key, lang) translation.delete (resets to bundled default)

EmailProviderSettings

Singleton (id = 22222222-2222-2222-2222-222222222222). Drives the SMTP email pipeline. Fields documented in the entity's XML doc (above) — key flags:

  • Enabled — master switch; when off the worker idles
  • RedirectAllEnabled + RedirectAllToAddress — staging mode (every email goes to the test inbox)
  • BatchSize, PollIntervalSeconds, MaxAttempts — worker tuning
Operation Permission
GetEmailProviderSettings email-settings.manage
UpdateEmailProviderSettings email-settings.manage
TestEmailProviderSettings email-settings.manage (sends a one-off test email to a chosen address)

System

Cross-cutting system tables not owned by any module.

AuditLogEntry

Append-only change log written by AuditLogInterceptor. Not a BaseEntity — no soft-delete, no Code/RowVersion. Fields:

Field Notes
Id Guid
OccurredAt UTC
UserId?, UserEmail? From IUserContext
EntityName nameof(T) of the entity changed
EntityId The entity's id
EntityCode? Snapshot of Code at change time (so you can find the row even if Code was changed later)
Action "Created" / "Updated" / "Deleted"
OldValues, NewValues JSON snapshots of the auditable property bag
Changes JSON diff (only changed properties)
IpAddress From the request
CorrelationId? Threads through to RequestLog

Auditable entities are flagged in AuditLogInterceptor (entity allowlist). The interceptor runs before AuditInterceptor so it sees the original Deleted state (vs the rewritten Modified after AuditInterceptor flips it for soft-delete).

Retention: 7 years (per product requirement). Cleanup is operator-scheduled, not built-in.

Operation Permission
GetAuditLog (paged, filtered by entity name / id / user / date range) audit-log.read
GetEntityNames audit-log.read (distinct values for the filter dropdown)

RequestLog

Per-request access log written by RequestLoggingMiddleware (see 01). Fields covered there.

Retention: 90 days — same operator-driven cleanup pattern.

There is no controller for RequestLog — the LoginAuditController is sometimes confused for it but only surfaces LoginAudit rows. Direct DB queries.

CodeSequence (Persistence-internal)

Not a domain entity — lives in Persistence/Entities/. Backs CodeGeneratorService.NextSequenceAsync. Fields: Id, EntityType, Period (e.g. "2026"), LastValue. Read with WITH (UPDLOCK, ROWLOCK) to serialize concurrent inserts. See 01 §Code generation.


Where to go next

If you want… See
The exact column types, indexes, FKs, constraints 03-database
Endpoint URLs and request/response shapes 07-api-reference
The Angular components driving each module's UI 05-frontend-features
Build/run/seed/deploy 06-operations