Implements Unit 2 "Offerings" (backend module, admin CRUD UI with drag-and-drop reordering, public GET /api/v1/offerings endpoint) and executes the feature's D-15 CI/CD cutover, switching the deploy pipeline's build/publish target from SlpModularCms.Api to SlpModularCms.Api.SlpSoftware. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FWyStNL2ZsjrS7FLd7xvvN
5.8 KiB
Business Rules — Unit: Offerings
BR-OFF-01: Featured Exclusivity Rule (US-10)
At most one non-deleted Offering may have Featured = true at any time.
%%{init: {'themeVariables': {'primaryTextColor':'#000000','textColor':'#000000','tertiaryTextColor':'#000000'}}}%%
graph TD
start{"Create or Update request<br/>has Featured = true?"}
find_current{"A different Offering<br/>is currently Featured?"}
unfeature["Set that Offering's<br/>Featured = false"]
save["Save the request's Offering<br/>with Featured = true"]
save_asis["Save the request's Offering<br/>as submitted (Featured value unchanged)"]
start -->|"No"| save_asis
start -->|"Yes"| find_current
find_current -->|"Yes"| unfeature --> save
find_current -->|"No"| save
classDef decision fill:#fbd38d,stroke:#92400e,stroke-width:2px,color:#000000,font-weight:bold;
classDef outcome fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000000,font-weight:bold;
class start,find_current decision;
class unfeature,save,save_asis outcome;
Text alternative: if a create/update request sets Featured = true, the service first checks for a different currently-featured offering and un-features it before saving; if the request does not set Featured = true, the offering is saved with whatever Featured value was submitted (allowing an admin to explicitly un-feature the current one, per US-10's third scenario).
Applies identically regardless of UI entry point (Functional Design Q5): whether the admin sets Featured via the create/edit form's toggle, or via the list-row quick-action, both call the same IOfferingsService.CreateAsync/UpdateAsync methods — this rule lives in the service layer, not in either UI path.
BR-OFF-02: Soft Delete Never Blocked (US-06/US-07)
%%{init: {'themeVariables': {'primaryTextColor':'#000000','textColor':'#000000','tertiaryTextColor':'#000000'}}}%%
graph TD
delete_req{"Delete request for Offering X"}
is_last{"X is the last remaining<br/>non-deleted Offering?"}
proceed["Set X.IsDeleted = true,<br/>X.DeletedAt = now"]
delete_req --> is_last
is_last -->|"Yes"| proceed
is_last -->|"No"| proceed
classDef decision fill:#fbd38d,stroke:#92400e,stroke-width:2px,color:#000000,font-weight:bold;
classDef outcome fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000000,font-weight:bold;
class delete_req,is_last decision;
class proceed outcome;
Text alternative: deletion is always permitted regardless of how many offerings remain — the "last remaining offering" case is drawn explicitly to show it is not a special case that blocks the operation (D-5/Q4 in requirements.md).
Admin UI adds a confirmation step (Functional Design Q4) before the delete request is even sent — a UI/UX safeguard, not a backend rule; the backend itself does not require confirmation semantics.
BR-OFF-03: Reorder Boundary Rules (US-08/US-09)
%%{init: {'themeVariables': {'primaryTextColor':'#000000','textColor':'#000000','tertiaryTextColor':'#000000'}}}%%
graph TD
move_up{"MoveUp requested<br/>for Offering X"}
is_first{"X has the lowest<br/>DisplayOrder (already first)?"}
noop_up["No-op — X is already<br/>first, nothing to swap with"]
swap_up["Swap DisplayOrder with the<br/>Offering immediately before X"]
move_up --> is_first
is_first -->|"Yes"| noop_up
is_first -->|"No"| swap_up
classDef decision fill:#fbd38d,stroke:#92400e,stroke-width:2px,color:#000000,font-weight:bold;
classDef outcome fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000000,font-weight:bold;
class move_up,is_first decision;
class noop_up,swap_up outcome;
Text alternative: MoveUp on the first item in the list is a no-op (the frontend disables the button in this state per US-09's acceptance criteria); MoveDown on the last item is symmetric. ReorderAsync (drag-and-drop, US-08) receives the complete ordered list of IDs and reassigns DisplayOrder sequentially (0, 1, 2, ...) to match — it has no boundary case, since it always resequences the entire list at once.
BR-OFF-04: Field Validation Rules (SECURITY-05, Functional Design Q3)
%%{init: {'themeVariables': {'primaryTextColor':'#000000','textColor':'#000000','tertiaryTextColor':'#000000'}}}%%
graph TD
submit{"Create/Update request submitted"}
check_required{"Title, Description, Price,<br/>PriceNote, CtaLabel all non-empty,<br/>and at least 1 Feature?"}
check_lengths{"Title ≤100, Description ≤500,<br/>Price ≤50, PriceNote ≤100, CtaLabel ≤50,<br/>each Feature ≤200, Features count ≤10?"}
reject["Reject: 400 with field-level<br/>validation errors (US-11)"]
accept["Proceed to BR-OFF-01"]
submit --> check_required
check_required -->|"No"| reject
check_required -->|"Yes"| check_lengths
check_lengths -->|"No"| reject
check_lengths -->|"Yes"| accept
classDef decision fill:#fbd38d,stroke:#92400e,stroke-width:2px,color:#000000,font-weight:bold;
classDef outcome fill:#f56565,stroke:#9b2c2c,stroke-width:2px,color:#000000,font-weight:bold;
classDef success fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000000,font-weight:bold;
class submit,check_required,check_lengths decision;
class reject outcome;
class accept success;
Text alternative: every create/update request is checked for required fields first, then for length/count bounds; either failure rejects the request with field-level errors (no partial save), matching US-11's acceptance criteria. Bounds are enforced identically on the backend (source of truth, SECURITY-05) and mirrored in the frontend form schema for immediate user feedback (Functional Design Q3 = A: Title 100, Description 500, Price 50, PriceNote 100, CtaLabel 50 characters; Features 1-10 items, each ≤200 characters).