Adds 2 units and docs for unit 3. nfr-requirements plan

This commit is contained in:
2026-06-29 22:18:37 +02:00
parent 0e01ca1e1c
commit c156107cb1
126 changed files with 15204 additions and 80199 deletions
@@ -0,0 +1,191 @@
# Business Logic Model — Unit 1: master-backend
## Flow 1 — AddAsync (Add Slave CMS)
**Trigger**: `POST /api/v1/CmsInstances` (Owner only)
```mermaid
sequenceDiagram
box rgba(99,179,237,0.3) API Layer
participant Ctrl as CmsInstanceController
end
box rgba(154,230,180,0.3) Service Layer
participant Svc as CmsInstanceService
participant Repo as CmsInstanceRepository
participant Client as SlaveApiClient
end
box rgba(246,224,94,0.3) Infrastructure
participant DP as IDataProtector
participant Ctx as IHttpContextAccessor
participant Opts as MasterModuleOptions
end
box rgba(200,200,200,0.3) Persistence
participant DB as MasterDbContext
end
Ctrl->>Svc: AddAsync(request)
Note over Svc: Validate Name, Url, ApiKey not empty
Note over Svc: Validate Url starts with http or https
Svc->>DP: Protect(request.ApiKey)
DP-->>Svc: encryptedApiKey
Svc->>Repo: AddAsync(new CmsInstance)
Note over Svc,Repo: Status=Available, LastContactedAt=null
Svc->>Repo: SaveChangesAsync()
Repo->>DB: INSERT CmsInstances
Note over Svc: Determine masterUrl
Svc->>Ctx: try get base URL from HttpContext
alt HttpContext available
Ctx-->>Svc: masterUrl from request
else HttpContext unavailable
Svc->>Opts: read MasterUrl
Opts-->>Svc: configured masterUrl
end
Svc->>DP: Unprotect(encryptedApiKey)
DP-->>Svc: plainApiKey
Svc->>Client: RegisterMasterAsync(slaveUrl, plainApiKey, masterUrl)
alt Registration success
Client-->>Svc: true
Svc->>Repo: UpdateAsync (LastContactedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
else Registration failed
Client-->>Svc: false
Note over Svc: LastContactedAt stays null (owner can see)
end
Svc-->>Ctrl: CmsInstanceDto
Ctrl-->>Ctrl: return 201 Created
```
Text alternative: Controller calls service; service validates, encrypts ApiKey, persists entity, determines master URL from HttpContext or config, attempts slave registration, updates LastContactedAt on success; always returns DTO regardless of registration outcome.
---
## Flow 2 — UpdateStatusAsync (Set Slave Status)
**Trigger**: `PUT /api/v1/CmsInstances/{id}/status` (Owner only)
```mermaid
sequenceDiagram
box rgba(99,179,237,0.3) API Layer
participant Ctrl as CmsInstanceController
end
box rgba(154,230,180,0.3) Service Layer
participant Svc as CmsInstanceService
participant Repo as CmsInstanceRepository
participant Client as SlaveApiClient
end
box rgba(246,224,94,0.3) Infrastructure
participant DP as IDataProtector
end
box rgba(200,200,200,0.3) Persistence
participant DB as MasterDbContext
end
Ctrl->>Svc: UpdateStatusAsync(id, status, disableMessage)
Svc->>Repo: GetByIdAsync(id)
Repo->>DB: SELECT CmsInstances WHERE Id
DB-->>Repo: CmsInstance or null
Repo-->>Svc: entity or null
alt Entity not found
Svc-->>Ctrl: throw NotFoundException
end
Note over Svc: Validate DisableMessage required if NotAvailable
alt Validation fails
Svc-->>Ctrl: throw ValidationException
end
alt newStatus = Inactive
Svc->>Repo: UpdateAsync (Status=Inactive, DisableMessage=null)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=true)
else newStatus = Available or NotAvailable
Svc->>Repo: UpdateAsync (Status, DisableMessage)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
Svc->>DP: Unprotect(entity.ApiKey)
DP-->>Svc: plainApiKey
Svc->>Client: PushStatusAsync(slaveUrl, plainApiKey, status, disableMessage)
alt Push success
Client-->>Svc: true
Svc->>Repo: UpdateAsync (LastStatusPushedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=true)
else Push failed
Client-->>Svc: false
Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=false)
end
end
Ctrl-->>Ctrl: return 200 OK with UpdateStatusResult
```
Text alternative: Controller calls service with id and new status; service loads entity, validates, updates DB, then for non-Inactive transitions decrypts ApiKey and pushes status to slave; returns SlaveContactSuccess=false if push fails but DB is always the authority.
---
## Flow 3 — VerifyIntegrityAsync (Background Integrity Check)
**Trigger**: `IntegrityCheckBackgroundService` periodic timer (every `IntegrityCheckIntervalMinutes`)
```mermaid
sequenceDiagram
box rgba(200,200,200,0.3) Background
participant Timer as PeriodicTimer
participant BgSvc as IntegrityCheckBackgroundService
end
box rgba(154,230,180,0.3) Service Layer
participant Svc as CmsInstanceService
participant Repo as CmsInstanceRepository
participant Client as SlaveApiClient
end
box rgba(246,224,94,0.3) Infrastructure
participant DP as IDataProtector
participant Opts as MasterModuleOptions
end
box rgba(200,200,200,0.3) Persistence
participant DB as MasterDbContext
end
Timer->>BgSvc: Tick
BgSvc->>Svc: VerifyIntegrityAsync()
Svc->>Repo: GetActiveAsync()
Repo->>DB: SELECT WHERE Status != Inactive
DB-->>Repo: list of CmsInstance
Repo-->>Svc: instances
loop for each instance
Svc->>DP: Unprotect(instance.ApiKey)
DP-->>Svc: plainApiKey
Svc->>Opts: read MasterUrl
Opts-->>Svc: masterUrl
Svc->>Client: GetRegisteredMasterUrlAsync(slaveUrl, plainApiKey)
alt Slave unreachable
Client-->>Svc: throws or returns null
Svc->>Repo: UpdateAsync (LastIntegrityCheckFailedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
else Slave reachable
Client-->>Svc: registeredMasterUrl
alt URLs match
Svc->>Repo: UpdateAsync (LastContactedAt = UtcNow, LastIntegrityCheckFailedAt = null)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
else URL mismatch
Svc->>Client: RegisterMasterAsync(slaveUrl, plainApiKey, masterUrl)
alt Re-registration success
Client-->>Svc: true
Svc->>Repo: UpdateAsync (LastContactedAt = UtcNow, LastIntegrityCheckFailedAt = null)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
else Re-registration failed
Client-->>Svc: false
Svc->>Repo: UpdateAsync (LastIntegrityCheckFailedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
end
end
end
end
Svc-->>BgSvc: done
```
Text alternative: Background timer triggers integrity service; for each non-Inactive slave: decrypts key, retrieves registered master URL, clears failure flag on match, re-registers on mismatch, sets LastIntegrityCheckFailedAt when slave is unreachable or re-registration fails.
@@ -0,0 +1,148 @@
# Business Rules — Unit 1: master-backend
## BR-01 — Status Update Decision Logic
```mermaid
graph TD
Start(["UpdateStatusAsync called"])
CheckExists{"Entity exists\nfor given id?"}
NotFound["Throw NotFoundException\n404 to caller"]
CheckMsg{"newStatus = NotAvailable\nAND disableMessage\nis null or empty?"}
ValidationErr["Throw ValidationException\nDisableMessage required"]
CheckInactive{"newStatus\n= Inactive?"}
SetInactive["Status = Inactive\nDisableMessage = null\nNo HTTP push\nSlaveContactSuccess = true"]
PersistStatus["Persist Status + DisableMessage\nto MasterDbContext"]
DecryptKey["Decrypt ApiKey\nvia IDataProtector"]
PushSlave["PushStatusAsync\nto slave endpoint"]
PushOk{"HTTP push\nsucceeded?"}
UpdatePushed["LastStatusPushedAt = UtcNow\nSave"]
ReturnOk["Return UpdateStatusResult\nSuccess=true\nSlaveContactSuccess=true"]
ReturnWarn["Return UpdateStatusResult\nSuccess=true\nSlaveContactSuccess=false"]
Done(["Return result to controller"])
Start --> CheckExists
CheckExists -->|"no"| NotFound
CheckExists -->|"yes"| CheckMsg
CheckMsg -->|"yes — invalid"| ValidationErr
CheckMsg -->|"no — valid"| CheckInactive
CheckInactive -->|"yes"| SetInactive --> Done
CheckInactive -->|"no"| PersistStatus --> DecryptKey --> PushSlave --> PushOk
PushOk -->|"yes"| UpdatePushed --> ReturnOk --> Done
PushOk -->|"no"| ReturnWarn --> Done
classDef decision fill:#FFC107,stroke:#F57F17,stroke-width:2px,color:#000
classDef action fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
classDef terminal fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef error fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
class CheckExists,CheckMsg,CheckInactive,PushOk decision
class PersistStatus,DecryptKey,PushSlave,UpdatePushed,SetInactive action
class Start,Done terminal
class NotFound,ValidationErr error
```
Text alternative: Load entity (404 if missing) → validate DisableMessage required for NotAvailable → for Inactive skip push → for others persist, decrypt key, push to slave, set SlaveContactSuccess based on push result.
---
## BR-02 — Integrity Check Decision Logic
```mermaid
graph TD
Start(["VerifyIntegrityAsync\nper instance"])
GetUrl["GetRegisteredMasterUrlAsync\n(slaveUrl, plainApiKey)"]
Reachable{"Slave\nreachable?"}
SetFailed["LastIntegrityCheckFailedAt = UtcNow\nSave — continue to next"]
UrlMatch{"registeredMasterUrl\n= expected masterUrl?"}
ClearOk["LastIntegrityCheckFailedAt = null\nLastContactedAt = UtcNow\nSave"]
ReRegister["RegisterMasterAsync\n(slaveUrl, plainApiKey, masterUrl)"]
RegOk{"Re-registration\nsucceeded?"}
ClearAfterReg["LastIntegrityCheckFailedAt = null\nLastContactedAt = UtcNow\nSave"]
SetFailedReg["LastIntegrityCheckFailedAt = UtcNow\nSave"]
Next(["Next instance"])
Start --> GetUrl --> Reachable
Reachable -->|"no"| SetFailed --> Next
Reachable -->|"yes"| UrlMatch
UrlMatch -->|"match"| ClearOk --> Next
UrlMatch -->|"mismatch"| ReRegister --> RegOk
RegOk -->|"yes"| ClearAfterReg --> Next
RegOk -->|"no"| SetFailedReg --> Next
classDef decision fill:#FFC107,stroke:#F57F17,stroke-width:2px,color:#000
classDef action fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
classDef terminal fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef error fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
class Reachable,UrlMatch,RegOk decision
class GetUrl,SetFailed,ClearOk,ReRegister,ClearAfterReg,SetFailedReg action
class Start,Next terminal
```
Text alternative: For each active slave — attempt to get its registered master URL; if unreachable set failure flag; if reachable and URL matches clear flag; if mismatch re-register; clear flag on success, set flag on failure.
---
## Validation Rules
| Rule | Field | Condition | Error |
|------|-------|-----------|-------|
| BR-VAL-01 | `Name` | Must not be null or whitespace | `Name is required` |
| BR-VAL-02 | `Url` | Must not be null or whitespace | `Url is required` |
| BR-VAL-03 | `Url` | Must start with `http://` or `https://` | `Url must be a valid absolute HTTP(S) URL` |
| BR-VAL-04 | `ApiKey` | Must not be null or whitespace (on create) | `ApiKey is required` |
| BR-VAL-05 | `DisableMessage` | Required (non-empty) when `Status = NotAvailable` | `DisableMessage is required when status is NotAvailable` |
| BR-VAL-06 | `Status` | Must be a valid `CmsInstanceStatus` enum value | `Invalid status value` |
| BR-VAL-07 | `id` (update) | CmsInstance with given id must exist | `CmsInstance not found` (404) |
---
## Status Transition Rules
| From | To | DisableMessage | HTTP Push | Notes |
|------|----|---------------|-----------|-------|
| Any | `Available` | Clear to null | Yes | Slave re-enabled |
| Any | `NotAvailable` | Required, non-empty | Yes | Slave disabled with message |
| Any | `Inactive` | Clear to null | **No** | Master stops all contact |
| `Inactive` | `Available` | Clear to null | Yes | Reactivation |
| `Inactive` | `NotAvailable` | Required, non-empty | Yes | Reactivation with disable |
---
## ApiKey Encryption Rules
| Rule | Description |
|------|-------------|
| BR-ENC-01 | `ApiKey` is encrypted via `IDataProtector` before writing to `MasterDbContext` |
| BR-ENC-02 | `ApiKey` is decrypted via `IDataProtector` immediately before each HTTP call requiring it |
| BR-ENC-03 | `ApiKey` is **never** included in `CmsInstanceDto` or any other API response |
| BR-ENC-04 | `ApiKey` is accepted in `CreateCmsInstanceRequest` on creation only; no update endpoint for ApiKey |
---
## Master URL Resolution Rules
| Context | Resolution Strategy |
|---------|-------------------|
| Controller-originated calls (Add) | Derive from `HttpContext.Request` scheme + host + (optional port) via `IHttpContextAccessor` |
| Background service calls (Integrity Check) | Read `MasterModuleOptions.MasterUrl` from configuration |
| `MasterModuleOptions.MasterUrl` is null in background context | Log a warning; skip registration/integrity for that cycle |
---
## HTTP Contact Exclusion Rules
| Rule | Description |
|------|-------------|
| BR-CONTACT-01 | Instances with `Status = Inactive` are excluded from `GetActiveAsync` and never contacted via HTTP |
| BR-CONTACT-02 | Status push is skipped when transitioning any status → `Inactive` |
| BR-CONTACT-03 | Integrity check runs only against instances where `Status != Inactive` |
---
## Background Service Rules
| Rule | Description |
|------|-------------|
| BR-BG-01 | `IntegrityCheckBackgroundService` resolves `ICmsInstanceService` via `IServiceScopeFactory` per tick (not injected directly, as service is Scoped) |
| BR-BG-02 | Each tick creates and disposes its own `IServiceScope` |
| BR-BG-03 | Exceptions within a single slave's integrity check are caught, logged, and do not abort processing for remaining slaves |
| BR-BG-04 | If `MasterModuleOptions.MasterUrl` is null or empty, the background service logs a warning and skips the entire integrity check for that cycle |
@@ -0,0 +1,132 @@
# Domain Entities — Unit 1: master-backend
## Entity Overview
```mermaid
graph TD
MasterDbCtx["MasterDbContext\n(per-module EF Core DbContext)"]
CmsInst["CmsInstance\n(aggregate root)"]
Status["CmsInstanceStatus\n(enum)"]
Opts["MasterModuleOptions\n(config POCO)"]
DP["IDataProtector\n(ApiKey encryption)"]
DTO["CmsInstanceDto\n(API response shape)"]
CreateReq["CreateCmsInstanceRequest\n(API input)"]
UpdateReq["UpdateStatusRequest\n(API input)"]
UpdateRes["UpdateStatusResult\n(API response for status update)"]
MasterDbCtx -->|"owns"| CmsInst
CmsInst -->|"has"| Status
CmsInst -->|"ApiKey encrypted via"| DP
CmsInst -->|"projected to"| DTO
CreateReq -->|"creates"| CmsInst
UpdateReq -->|"mutates status of"| CmsInst
UpdateRes -->|"returned from UpdateStatusAsync"| CmsInst
Opts -->|"IntegrityCheckIntervalMinutes"| MasterDbCtx
Opts -->|"MasterUrl fallback"| MasterDbCtx
classDef entity fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef infra fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
classDef dto fill:#FFC107,stroke:#F57F17,stroke-width:1px,color:#000
classDef config fill:#CE93D8,stroke:#6A1B9A,stroke-width:1px,color:#000
class CmsInst entity
class MasterDbCtx,DP infra
class Status,DTO,CreateReq,UpdateReq,UpdateRes dto
class Opts config
```
Text alternative: MasterDbContext owns CmsInstance; CmsInstance has a Status enum and its ApiKey is encrypted via IDataProtector; request/response shapes map to and from CmsInstance.
---
## CmsInstance
**Table**: `CmsInstances` (owned by `MasterDbContext`, migrations in `SlpModularCms.Modules.Master`)
| Field | Type | Nullable | Notes |
|-------|------|----------|-------|
| `Id` | `Guid` | No | Primary key |
| `Name` | `string` | No | Friendly display name; required |
| `Url` | `string` | No | Base URL of slave CMS API; must start with `http://` or `https://` |
| `ApiKey` | `string` | No | Encrypted via ASP.NET Core Data Protection before storage; decrypted before HTTP calls |
| `Status` | `CmsInstanceStatus` | No | Default: `Available` on creation |
| `DisableMessage` | `string?` | Yes | Required when `Status = NotAvailable`; null otherwise |
| `LastContactedAt` | `DateTimeOffset?` | Yes | Null = never successfully contacted; set on successful registration or integrity check |
| `LastStatusPushedAt` | `DateTimeOffset?` | Yes | Null = status never successfully pushed; set after successful `PushStatusAsync` |
| `LastIntegrityCheckFailedAt` | `DateTimeOffset?` | Yes | Null = no pending failure; set when integrity check cannot reach slave or re-registration fails; cleared on next successful contact |
---
## CmsInstanceStatus
```csharp
public enum CmsInstanceStatus
{
Available = 0,
NotAvailable = 1,
Inactive = 2,
}
```
| Value | Meaning | Master Contacts Slave? |
|-------|---------|----------------------|
| `Available` | Slave is enabled; normal operation | Yes (status push + integrity checks) |
| `NotAvailable` | Slave is disabled; `DisableMessage` served to end-users | Yes (status push + integrity checks) |
| `Inactive` | Soft-removed; greyed out in UI | **No** — all HTTP contact is halted |
---
## MasterModuleOptions
**Config section**: `"MasterModule"` in `appsettings.json`
| Property | Type | Default | Side | Notes |
|----------|------|---------|------|-------|
| `IntegrityCheckIntervalMinutes` | `int` | `60` | Master | Interval for `IntegrityCheckBackgroundService` |
| `MasterUrl` | `string?` | `null` | Master | Fallback public URL of this master CMS; used by background service when `HttpContext` is unavailable |
| `CacheMinutes` | `int` | `60` | Slave | Slave pull cache interval (used by Unit 2) |
| `ApiKey` | `string?` | `null` | Slave | Slave API key for validating incoming master requests (used by Unit 2) |
---
## CmsInstanceDto (API Response)
**Rule**: `ApiKey` is **never** included (NFR-MASTER-03).
| Property | Type | Notes |
|----------|------|-------|
| `Id` | `Guid` | |
| `Name` | `string` | |
| `Url` | `string` | |
| `Status` | `string` | Serialized as string (`"Available"` / `"NotAvailable"` / `"Inactive"`) |
| `DisableMessage` | `string?` | |
| `LastContactedAt` | `DateTimeOffset?` | |
| `LastStatusPushedAt` | `DateTimeOffset?` | |
| `LastIntegrityCheckFailedAt` | `DateTimeOffset?` | Visible in UI so owner knows which slaves have pending check failures |
---
## CreateCmsInstanceRequest (API Input)
| Property | Type | Validation |
|----------|------|-----------|
| `Name` | `string` | Required, non-empty |
| `Url` | `string` | Required; must start with `http://` or `https://` |
| `ApiKey` | `string` | Required, non-empty |
---
## UpdateStatusRequest (API Input)
| Property | Type | Validation |
|----------|------|-----------|
| `Status` | `CmsInstanceStatus` | Required; must be valid enum value |
| `DisableMessage` | `string?` | Required and non-empty when `Status = NotAvailable`; ignored otherwise |
---
## UpdateStatusResult (Service Return / API Response)
| Property | Type | Notes |
|----------|------|-------|
| `Success` | `bool` | Always `true` when status persisted to DB (DB is the authority) |
| `SlaveContactSuccess` | `bool` | `true` if HTTP push to slave succeeded; `false` if push failed (slave unreachable); not applicable for `Inactive` transitions (returns `true`) |