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
- Identity & Access
- Master Data
- Assets
- Audits
- Transfers
- Custody
- Maintenance
- Notifications
- Documents
- Reporting
- Settings
- 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 consolidatedInitialCreatemigration 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:51enforces with aConflictException. To re-assign, cancel the existing one first.
Removed:
DueDate. (Squashed into the consolidatedInitialCreatemigration 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 (PendingReview→InReview→Approved/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.csdoc 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:
- Refuses if any line has no
ReceivedAt. - For each line with
ReceivedStatus = Ok: writes the new location/org to the asset and createsAssetLocationHistory/AssetOrganizationHistoryrows withSource = 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 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:
- Look up the user → respect
PreferredLanguagefor primary/secondary template choice. - Auto-inject
user.fullName,user.email,user.code,user.userName, andlinkinto the merge-fields dict (caller values win viaTryAdd). - Render subject + body via the regex
\{\{\s*([\w\.]+)\s*\}\}. Pre-replace andto plain space (admin Quill editor injects these — without normalization the merge regex misses placeholders). - For InApp: strip HTML tags + decode entities → plain text → INSERT
Notification. - For Email (if user.Email exists and channel enabled): keep HTML → INSERT
NotificationDeliverywithStatus = 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 idlesRedirectAllEnabled+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 |