Adds the Offerings module and retargets the CI/CD pipeline to Api.SlpSoftware
Continuous Integration / config (pull_request) Successful in 12s
Continuous Integration / changes (pull_request) Successful in 22s
Continuous Integration / backend-build (pull_request) Successful in 5m53s
Continuous Integration / vulnerability-scan (pull_request) Successful in 5m46s
Continuous Integration / frontend-prepare (pull_request) Successful in 1m54s
Continuous Integration / backend-test (pull_request) Successful in 7m37s
Continuous Integration / frontend-build (pull_request) Successful in 2m14s
Continuous Integration / frontend-test (pull_request) Successful in 4m59s
Continuous Integration / frontend-lint (pull_request) Successful in 2m2s
Continuous Integration / publish-production (pull_request) Skipped
Continuous Integration / deploy-production (pull_request) Skipped
Continuous Integration / publish-test (pull_request) Successful in 7m34s
Continuous Integration / deploy-test (pull_request) Skipped

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
This commit is contained in:
2026-08-02 16:23:09 +02:00
co-authored by Claude Sonnet 5
parent b6e9c07c06
commit cfb06b28b6
79 changed files with 4069 additions and 94 deletions
@@ -0,0 +1,37 @@
# Code Generation Summary — Unit: Offerings
Implements all 12 user stories (US-01US-12) and FR-4/5/6/7/8, per `offerings-code-generation-plan.md`.
## Backend — `SlpModularCms.Modules.Offerings`
- **Domain**: `Offering` entity (13 fields per `domain-entities.md`), `OfferingsDbContext` — table `OfferingsOfferings`, `Features` stored as a JSON column (`List<string>` with an EF Core `ValueComparer`), soft-delete enforced via a global `HasQueryFilter`.
- **Repository**: `IOfferingRepository`/`OfferingRepository` — CRUD plus `GetMaxDisplayOrderAsync`, `GetFeaturedAsync`, `GetPreviousAsync`/`GetNextAsync` (adjacent-swap lookups), and a `BeginTransactionAsync` wrapper.
- **Service**: `IOfferingsService`/`OfferingsService` — BR-OFF-01 (featured exclusivity), BR-OFF-02 (soft delete, never blocked), BR-OFF-03 (reorder + adjacent-swap boundaries), BR-OFF-04 (validation, enforced at the model-binding layer). Multi-row operations (featured swap, reorder, adjacent swap) run inside an explicit EF Core transaction per NFR Design Pattern 1. `LastModifiedByUserId` set on every create/update/delete (NFR-OFF-03); one `LogInformation` structured log entry per mutation.
- **Models**: `OfferingDto` (public), `OfferingAdminDto` (admin, adds `DisplayOrder`), `CreateOfferingRequest`/`UpdateOfferingRequest` (DataAnnotations + a custom `FeaturesValidationAttribute` for the 1-10-items/≤200-chars rule), `ReorderOfferingsRequest`.
- **Controller**: `OfferingsController` — route/auth table exactly per `component-methods.md` (`GET /api/v1/offerings` public + `[EnableRateLimiting("offerings-public")]`; admin CRUD/reorder/move under `AdminOnly`).
- **Module**: `OfferingsModule` (`IModule`) — registers the DbContext (Pomelo MySQL, `NonLockingMySQLHistoryRepository`), repository, service; migrates on startup.
- **Rate limiting**: new `offerings-public` `FixedWindowLimiter` policy added to `SlpModularCms.Core.Hosting.ServiceCollectionExtensions.AddCmsRateLimiting`, config-driven via `RateLimiting:OfferingsPublic` (added to `Api.SlpSoftware/appsettings.json`).
- **Migration**: `InitialCreate` (EF Core, generated against `Api.SlpSoftware` as startup project).
- **Wiring**: `SlpModularCms.Modules.Offerings`/`.Tests` added to the solution (Application/Modules and Tests/Modules folders per `CLAUDE.md`); `<ProjectReference>` added to `Api.SlpSoftware.csproj`.
- **Tests**: 38 tests — `OfferingRepositoryTests` (EF Core InMemory), `OfferingsServiceTests` (NSubstitute), `OfferingsControllerTests`. All four business rules covered, plus the soft-delete query filter.
## Frontend — `frontend/src/features/offerings/`
- **Dependency**: `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities` added (Functional Design Q1 = A).
- **Schema**: `schemas/offering.ts` (zod, mirrors BR-OFF-04 exactly).
- **Services** (API-calling hooks): `useOfferings`, `useOffering` (derived from the admin-list cache), `useCreateOffering`, `useUpdateOffering`, `useDeleteOffering`, `useReorderOfferings`, `useMoveOffering`.
- **Hooks** (feature-local): `useOfferingsDnd` — dnd-kit sensor setup, drag-end reordering, optimistic local state, delegates persistence to `useReorderOfferings`.
- **Components**: `OfferingsList` (`DndContext`/`SortableContext`), `OfferingRow` (drag handle, featured toggle, move up/down, edit/delete actions, all `data-testid`s per `frontend-components.md`), `DeleteOfferingDialog`, `OfferingForm` (react-hook-form + zod, dynamic features list managed via `setValue` rather than `useFieldArray` since `features` is a plain `string[]`).
- **Pages**: `OfferingsListPage`, `OfferingFormPage` (shared by create/edit, per Functional Design Q2).
- **Routing/nav/i18n**: three routes under `authenticatedRoute` (`/offerings`, `/offerings/new`, `/offerings/$id/edit`), each behind `RoleGuard(Owner, Administrator)` + `ModuleGuard(requiredModule="Offerings")`; `Sidebar.tsx` nav entry; full `nav.offerings`/`offerings.*` key sets added to both `en`/`nl` locale files.
- **Tests**: schema tests, an `OfferingsListPage` integration suite (title/list/empty-state/delete-confirm/delete-cancel/move-button-boundaries/auth-redirect), an `OfferingFormPage` suite (create/validation-error/edit-prefill) — all via MSW-mocked handlers in `features/offerings/mocks/handlers.ts`, following the `features/cms` test-style precedent.
## Verification
- Backend: full solution build succeeded; full test suite green, 414/414 (38 new in `Modules.Offerings.Tests`, no regressions elsewhere).
- Frontend: `pnpm build` (tsc + vite) succeeded; `pnpm lint` clean (one `react-hooks/set-state-in-effect` violation in `useOfferingsDnd` found and fixed — switched to the "adjust state during render" pattern); `pnpm test` green, 254/254 across 41 files (43 new).
## Deviations From the Plan Worth Noting
- `useFieldArray` was planned implicitly for the dynamic features list but doesn't type-check cleanly against a plain `string[]` field — used `useWatch` + `setValue` instead, a standard react-hook-form alternative for primitive arrays.
- `Modules.Offerings.csproj` does not reference `Microsoft.EntityFrameworkCore.Design` — confirmed by inspecting `Modules.Master.csproj` that this package belongs on the *startup* project (`Api.SlpSoftware`, which already has it), not on every project with a `DbContext`.
@@ -0,0 +1,139 @@
# Business Logic Model — Unit: Offerings
## Flow 1: Create/Update an Offering (with Featured Exclusivity)
```mermaid
sequenceDiagram
box rgba(246,224,94,0.4) Admin
participant Admin as CMS Administrator
end
box rgba(99,179,237,0.4) Frontend
participant Form as OfferingFormPage
end
box rgba(159,122,234,0.4) Backend
participant Ctrl as OfferingsController
participant Svc as OfferingsService
participant Repo as OfferingRepository
end
Admin->>Form: Fills form, toggles Featured, submits
Form->>Form: Validate against zod schema (BR-OFF-04, client-side mirror)
Form->>Ctrl: POST or PUT with offering data
Ctrl->>Svc: CreateAsync or UpdateAsync
Svc->>Svc: Re-validate (BR-OFF-04, server-side, authoritative)
alt Featured set to true
Svc->>Repo: GetFeaturedAsync
Repo-->>Svc: currently-featured Offering or none
Svc->>Repo: UpdateAsync (unfeature previous, if any)
end
Svc->>Repo: AddAsync or UpdateAsync (this offering)
Repo-->>Svc: saved Offering
Svc-->>Ctrl: OfferingAdminDto
Ctrl-->>Form: 200/201 with the saved offering
Form-->>Admin: Redirect to offerings list
```
Text alternative: the admin fills the create/edit page (a full page, not a modal — Functional Design Q2), client-side validation runs first for immediate feedback, then the server re-validates authoritatively; if `Featured` is being set to true, the service un-features any previously-featured offering in the same operation before saving (yellow = admin actor, blue = frontend page, purple = backend layers).
## Flow 2: Delete an Offering (with Confirmation)
```mermaid
sequenceDiagram
box rgba(246,224,94,0.4) Admin
participant Admin as CMS Administrator
end
box rgba(99,179,237,0.4) Frontend
participant List as OfferingsListPage
participant Dialog as DeleteOfferingDialog
end
box rgba(159,122,234,0.4) Backend
participant Ctrl as OfferingsController
participant Svc as OfferingsService
end
Admin->>List: Clicks delete on a row
List->>Dialog: Open confirmation dialog (Functional Design Q4)
Admin->>Dialog: Confirms
Dialog->>Ctrl: DELETE request
Ctrl->>Svc: DeleteAsync (always allowed, BR-OFF-02)
Svc-->>Ctrl: success
Ctrl-->>List: 204, remove row from list
```
Text alternative: deletion always shows a confirmation dialog first (a frontend-only safeguard); once confirmed, the delete request is unconditionally accepted by the backend, including for the last remaining offering.
## Flow 3: Reorder via Drag-and-Drop (US-08)
```mermaid
sequenceDiagram
box rgba(246,224,94,0.4) Admin
participant Admin as CMS Administrator
end
box rgba(99,179,237,0.4) Frontend
participant List as OfferingsListPage
end
box rgba(159,122,234,0.4) Backend
participant Ctrl as OfferingsController
participant Svc as OfferingsService
end
Admin->>List: Drags a row to a new position (dnd-kit)
List->>List: Reorders local row state optimistically
List->>Ctrl: PUT reorder with the full ordered id list
Ctrl->>Svc: ReorderAsync(orderedIds)
Svc->>Svc: Reassign DisplayOrder sequentially to match
Svc-->>Ctrl: success
Ctrl-->>List: 204 (or revert local state on failure)
```
Text alternative: dragging a row reorders the frontend's local list immediately (perceived responsiveness), then sends the complete new order to the backend, which reassigns every offering's `DisplayOrder` to match in one operation.
## Flow 4: Reorder via Up/Down Buttons (US-09)
```mermaid
sequenceDiagram
box rgba(246,224,94,0.4) Admin
participant Admin as CMS Administrator
end
box rgba(99,179,237,0.4) Frontend
participant List as OfferingsListPage
end
box rgba(159,122,234,0.4) Backend
participant Ctrl as OfferingsController
participant Svc as OfferingsService
end
Admin->>List: Clicks "move up" on a row
List->>Ctrl: POST move-up for that offering id
Ctrl->>Svc: MoveUpAsync(id)
Svc->>Svc: Swap DisplayOrder with the<br/>preceding offering (BR-OFF-03)
Svc-->>Ctrl: success
Ctrl-->>List: 204, list refetches or reorders locally
```
Text alternative: an explicit per-row button swaps the offering's position with its immediate neighbor — the accessible alternative to drag-and-drop, symmetric for "move down".
## Flow 5: Featured Toggle from the List Row (Functional Design Q5)
```mermaid
sequenceDiagram
box rgba(246,224,94,0.4) Admin
participant Admin as CMS Administrator
end
box rgba(99,179,237,0.4) Frontend
participant List as OfferingsListPage
end
box rgba(159,122,234,0.4) Backend
participant Ctrl as OfferingsController
participant Svc as OfferingsService
end
Admin->>List: Clicks the "featured" star icon on a row
List->>Ctrl: PUT update using the row's already-loaded data,<br/>with Featured flipped
Ctrl->>Svc: UpdateAsync
Note over Svc: Same BR-OFF-01 exclusivity logic as the form path
Svc-->>Ctrl: success
Ctrl-->>List: 200, list reflects the new featured offering
```
Text alternative: the quick list-row star action is not a separate backend capability — it calls the exact same update endpoint as the full edit form, just pre-filled from data the list already has in memory, so BR-OFF-01's exclusivity rule applies identically no matter which UI path the admin used (resolves Functional Design Q5: both the form toggle and the list-row action work, backed by one shared mechanism).
@@ -0,0 +1,105 @@
# 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.
```mermaid
%%{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)
```mermaid
%%{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)
```mermaid
%%{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)
```mermaid
%%{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).
@@ -0,0 +1,66 @@
# Domain Entities — Unit: Offerings
## Entity Relationships
```mermaid
%%{init: {'themeVariables': {'primaryTextColor':'#000000','textColor':'#000000','tertiaryTextColor':'#000000'}}}%%
graph TD
offering["Offering<br/>(entity)"]
public_dto["OfferingDto<br/>(public contract, FR-6)"]
admin_dto["OfferingAdminDto<br/>(admin view, FR-7)"]
offering -->|"projected to (excludes IsDeleted/DeletedAt)"| public_dto
offering -->|"projected to (adds DisplayOrder)"| admin_dto
classDef entity fill:#bee3f8,stroke:#0d47a1,stroke-width:2px,color:#000000,font-weight:bold;
classDef dto fill:#fed7aa,stroke:#e65100,stroke-width:2px,color:#000000,font-weight:bold;
class offering entity;
class public_dto,admin_dto dto;
```
Text alternative: the single `Offering` entity projects into two different DTO shapes — the public contract (no internal/administrative fields) and the admin view (adds `DisplayOrder` for the list UI) — blue for the stateful entity, orange for the two value-object projections.
## Entity Definitions
### Offering
| Field | Type | Required | Description |
|---|---|---|---|
| `Id` | `Guid` | Yes | System-generated (Application Design Q4 = A). Public contract exposes this as a `string`. |
| `Title` | `string` | Yes | Max 100 characters (Functional Design Q3 = A). |
| `Description` | `string` | Yes | Max 500 characters. |
| `Price` | `string` | Yes | Max 50 characters. Pre-formatted display string, not numeric (FR-5) — e.g. `"€ 300"` or `"Op maat"`. |
| `PriceNote` | `string` | Yes | Max 100 characters. |
| `Features` | `List<string>` | Yes | Min 1, max 10 items; each item max 200 characters. Ordered — array order is display order. |
| `CtaLabel` | `string` | Yes | Max 50 characters. |
| `Featured` | `bool` | No (default `false`) | At most one non-deleted `Offering` may have this `true` at any time (US-10, enforced in `IOfferingsService`, not at the database/constraint level — see business-rules.md). |
| `DisplayOrder` | `int` | Yes | Determines public array order and admin list order. Unique among non-deleted offerings; not necessarily contiguous after deletions (soft delete does not renumber). |
| `IsDeleted` | `bool` | Yes (default `false`) | Soft-delete flag (Application Design Q5 = B). Never exposed on either DTO. |
| `DeletedAt` | `DateTimeOffset?` | No | Set when `IsDeleted` becomes `true`. Never exposed on either DTO. |
| `CreatedAt` | `DateTimeOffset` | Yes | Set once on creation. Part of the SECURITY-13 audit trail ("when"). |
| `UpdatedAt` | `DateTimeOffset` | Yes | Set on every create/update. Same rationale as `CreatedAt`. |
| `LastModifiedByUserId` | `Guid` | Yes | The authenticated admin's user id, set on every create/update/delete (NFR Requirements Q3 = A). Closes the "who" half of the SECURITY-13 open item alongside `CreatedAt`/`UpdatedAt`'s "when" — together a minimal audit trail without building a full audit-log table. |
### OfferingDto (public contract, FR-6)
| Field | Type | Maps From |
|---|---|---|
| `id` | `string` | `Offering.Id.ToString()` |
| `title` | `string` | `Offering.Title` |
| `description` | `string` | `Offering.Description` |
| `price` | `string` | `Offering.Price` |
| `priceNote` | `string` | `Offering.PriceNote` |
| `features` | `string[]` | `Offering.Features` |
| `ctaLabel` | `string` | `Offering.CtaLabel` |
| `featured` | `bool` | `Offering.Featured` |
### OfferingAdminDto (admin view, FR-7)
Same fields as `OfferingDto`, plus:
| Field | Type | Maps From |
|---|---|---|
| `displayOrder` | `int` | `Offering.DisplayOrder` |
`IsDeleted`/`DeletedAt`/`CreatedAt`/`UpdatedAt`/`LastModifiedByUserId` are intentionally not exposed on either DTO — soft-deleted rows are never returned to any caller (repository-level filtering, per services.md), and the audit fields exist for potential future audit tooling, not for display in this unit's admin UI.
@@ -0,0 +1,124 @@
# Frontend Components — Unit: Offerings
New feature folder `frontend/src/features/offerings/`, following the existing `frontend/src/features/cms/` structural pattern (pages/components/services/schemas) — the closest existing precedent for an admin list+CRUD screen in this codebase (Functional Design Q2 investigation).
**New dependency** (Functional Design Q1 = A): `@dnd-kit/core` + `@dnd-kit/sortable`, added to `frontend/package.json`.
## Component Hierarchy
```mermaid
%%{init: {'themeVariables': {'primaryTextColor':'#000000','textColor':'#000000','tertiaryTextColor':'#000000'}}}%%
graph TD
router["Admin Router"]
list_page["OfferingsListPage"]
form_page["OfferingFormPage<br/>(create and edit)"]
list["OfferingsList<br/>(dnd-kit SortableContext)"]
row["OfferingRow<br/>(dnd-kit useSortable)"]
delete_dialog["DeleteOfferingDialog"]
form["OfferingForm<br/>(shared by create/edit)"]
dnd_hook["useOfferingsDnd<br/>(hooks/)"]
router -->|"/admin/offerings"| list_page
router -->|"/admin/offerings/new"| form_page
router -->|"/admin/offerings/:id/edit"| form_page
list_page --> list
list --> row
list --> dnd_hook
list_page --> delete_dialog
form_page --> form
classDef root fill:#c6f6d5,stroke:#2e7d32,stroke-width:2px,color:#000000,font-weight:bold;
classDef page fill:#bee3f8,stroke:#0d47a1,stroke-width:2px,color:#000000,font-weight:bold;
classDef component fill:#e9d8fd,stroke:#4a148c,stroke-width:2px,color:#000000,font-weight:bold;
classDef hook fill:#fed7aa,stroke:#c05621,stroke-width:2px,color:#000000,font-weight:bold;
class router root;
class list_page,form_page page;
class list,row,delete_dialog,form component;
class dnd_hook hook;
```
Text alternative: the admin router has three Offerings routes — a list page, and a shared form page used for both create and edit (Functional Design Q2: separate pages, not modals). The list page composes a sortable list component (dnd-kit) made of individual rows, a delete-confirmation dialog, and the `useOfferingsDnd` hook that owns the drag-and-drop orchestration; the form page composes a single shared form component (green = router root, blue = pages, purple = components, orange = the feature-local hook).
## `OfferingsListPage` (`pages/OfferingsListPage.tsx`)
**Route**: `/admin/offerings`
**Responsibilities**: Fetches the admin offering list, renders `OfferingsList`, hosts `DeleteOfferingDialog`, links to `/admin/offerings/new` and per-row `/admin/offerings/:id/edit`.
**State**: `offeringPendingDelete: OfferingAdminDto | null` (controls whether `DeleteOfferingDialog` is open).
**API calls**: `useOfferings()` (`GET /api/v1/offerings/admin`).
## `OfferingsList` (`components/OfferingsList.tsx`)
**Props**: `offerings: OfferingAdminDto[]`, `onDeleteRequested: (offering: OfferingAdminDto) => void`.
**Responsibilities**: Wraps rows in a dnd-kit `DndContext`/`SortableContext` (Functional Design Q1), delegating the drag-end orchestration to `useOfferingsDnd` rather than handling it inline.
**Hooks used**: `useOfferingsDnd(offerings)` (see below).
## `useOfferingsDnd` (`hooks/useOfferingsDnd.ts`)
**Not an API-calling hook** — deliberately kept out of `services/`, since it owns dnd-kit sensor setup and local drag-state, and only calls into a `services/` hook at the end. A feature's own `hooks/` folder is a legitimate place for this kind of feature-local, non-API hook logic — it doesn't need to be either "in `services/`" or "promoted to the top-level `frontend/src/hooks/`"; those aren't the only two options.
**Responsibilities**: Configures dnd-kit sensors, computes the new order on drag end, optimistically updates local list state, and calls `useReorderOfferings()` (from `services/`) with the resulting ordered id list.
**Returns**: `{ items, sensors, handleDragEnd }` for `OfferingsList` to spread onto its `DndContext`/`SortableContext`.
**API calls (indirect, via the hook below)**: `useReorderOfferings()` (`PUT /api/v1/offerings/admin/reorder`).
## `OfferingRow` (`components/OfferingRow.tsx`)
**Props**: `offering: OfferingAdminDto`, `isFirst: boolean`, `isLast: boolean`, `onDeleteRequested: () => void`.
**Responsibilities**: Displays title/price/featured badge; a "featured" star icon (toggle, Functional Design Q5 — calls `useUpdateOffering()` with only `featured` flipped, reusing the row's already-loaded data); "move up"/"move down" buttons (disabled per `isFirst`/`isLast`, US-09); edit link to `/admin/offerings/:id/edit`; delete button (calls `onDeleteRequested`).
**Data-testid convention**: `offering-row-{id}-edit-link`, `offering-row-{id}-delete-button`, `offering-row-{id}-move-up-button`, `offering-row-{id}-move-down-button`, `offering-row-{id}-featured-toggle`.
**API calls**: `useUpdateOffering()` (featured toggle), `useMoveOffering(direction)` (`POST /api/v1/offerings/admin/{id}/move-up` or `/move-down`).
## `DeleteOfferingDialog` (`components/DeleteOfferingDialog.tsx`)
**Props**: `offering: OfferingAdminDto | null` (null = closed), `onConfirm: () => void`, `onCancel: () => void`.
**Responsibilities**: Confirmation dialog (Functional Design Q4) — "Weet je zeker dat je '[title]' wilt verwijderen?" / English equivalent per i18n.
**API calls**: none directly — the parent page calls `useDeleteOffering()` on confirm.
## `OfferingFormPage` (`pages/OfferingFormPage.tsx`)
**Routes**: `/admin/offerings/new` (create) and `/admin/offerings/:id/edit` (edit — loads the existing offering via `useOffering(id)` first).
**Responsibilities**: Hosts `OfferingForm`; on successful submit, navigates back to `/admin/offerings`.
## `OfferingForm` (`components/OfferingForm.tsx`)
**Props**: `initialValues?: OfferingAdminDto` (undefined for create), `onSubmit: (values: OfferingFormData) => void`, `isSubmitting: boolean`.
**Form fields**: `title`, `description`, `price`, `priceNote`, `features` (dynamic list, add/remove item, 1-10 items), `ctaLabel`, `featured` (checkbox/toggle, Functional Design Q5).
**Validation**: `react-hook-form` + `zod`, schema in `schemas/offering.ts`, mirroring BR-OFF-04's server-side bounds (Functional Design Q3): `title` ≤100, `description` ≤500, `price` ≤50, `priceNote` ≤100, `ctaLabel` ≤50, `features` 1-10 items each ≤200 characters, all required except `featured` (defaults `false`).
**API calls**: `useCreateOffering()` (`POST /api/v1/offerings/admin`) or `useUpdateOffering()` (`PUT /api/v1/offerings/admin/{id}`), selected by whether `initialValues` is present.
## Hooks
Two hook locations in this feature, split by what the hook actually does — not by a "shared vs. feature-specific" rule (a feature's own `hooks/` folder is a legitimate, separate option; it isn't limited to either "lives in `services/`" or "gets promoted to the top-level `frontend/src/hooks/`"):
- **`services/`** — React Query hooks that call the backend API, following the existing codebase convention (`frontend/src/features/cms/services/` already holds hooks like `useCmsInstances.ts`, not OOP-style service classes — the folder name is the established convention, the contents are hooks). Table below.
- **`hooks/`** — feature-local hooks that are not themselves API calls. Currently just `useOfferingsDnd` (drag-and-drop orchestration, which calls a `services/` hook internally but isn't one itself).
### `services/` (API-calling hooks)
| Hook | Method/Route | Purpose |
|---|---|---|
| `useOfferings()` | `GET /api/v1/offerings/admin` | List for `OfferingsListPage` |
| `useOffering(id)` | (derived from `useOfferings()` cache, or a dedicated fetch if not cached) | Prefill `OfferingFormPage` in edit mode |
| `useCreateOffering()` | `POST /api/v1/offerings/admin` | Create |
| `useUpdateOffering()` | `PUT /api/v1/offerings/admin/{id}` | Edit form submit, and the list-row featured toggle |
| `useDeleteOffering()` | `DELETE /api/v1/offerings/admin/{id}` | Delete, after confirmation |
| `useReorderOfferings()` | `PUT /api/v1/offerings/admin/reorder` | Drag-and-drop |
| `useMoveOffering(direction)` | `POST /api/v1/offerings/admin/{id}/move-up` or `/move-down` | Button-based reorder |
All mutating hooks invalidate the `['offerings', 'admin']` query key on success, matching the existing `useAddCmsInstance`/`useUpdateCmsInstanceStatus` pattern in `features/cms/services/`.
@@ -0,0 +1,21 @@
# Logical Components — Unit: Offerings
## Component: `OfferingsService` Transactional Operations
**Type**: Application-service logic (existing component from Application Design, no new class) — three of its methods gain an explicit transaction boundary per NFR Design Pattern 1.
**Scope**: `CreateAsync`/`UpdateAsync` (when `Featured` transitions to `true`), `ReorderAsync`, `MoveUpAsync`, `MoveDownAsync`. Each wraps its read-modify-write sequence in a single EF Core transaction, committed on success and rolled back on any exception (letting the exception propagate to `GlobalExceptionHandler` unchanged — no new error-handling path).
## Component: `offerings-public` Rate-Limiting Policy
**Type**: Configuration + attribute, not a new class — an addition to the existing `AddCmsRateLimiting` registration (see `tech-stack-decisions.md`), applied via `[EnableRateLimiting("offerings-public")]` on `OfferingsController`'s public `GET` action only.
**Consumers**: `OfferingsController` (public GET action).
## Component: Structured Audit Logging in `OfferingsService`
**Type**: Logging calls within the existing service, not a new component — `ILogger<OfferingsService>.LogInformation` on create/update/delete, per NFR Design Pattern 3's field list.
## No Other New Logical Components
This unit introduces no new queues, caches, background jobs, or infrastructure components — the only additions are the transaction boundary around three existing service methods, one rate-limiting policy, and structured logging calls.
@@ -0,0 +1,27 @@
# NFR Design Patterns — Unit: Offerings
## Pattern 1: Transactional Multi-Row Operations (Data Integrity)
**Decision** (Q1 = A): every `OfferingsService` operation that touches more than one row in a single logical action runs inside one explicit DB transaction (`BeginTransactionAsync`/`CommitAsync`, rolled back on any exception):
- **Featured-exclusivity swap** (US-10): un-featuring the previously-featured offering and saving the newly-featured one.
- **Full reorder** (US-08): reassigning `DisplayOrder` across the entire list from the drag-and-drop UI.
- **Adjacent swap** (US-09): swapping `DisplayOrder` between two neighboring offerings.
**Rationale**: a crash or connection failure mid-operation must never leave the dataset in an inconsistent state (two offerings both un-featured, duplicate `DisplayOrder` values). This closes the open item `services.md` deferred to Functional Design but that was never actually decided there.
**Pattern**: standard EF Core `DbContext.Database.BeginTransactionAsync()` wrapping the read-modify-write sequence within each of the three `OfferingsService` methods (`CreateAsync`/`UpdateAsync` when `Featured` transitions to `true`, `ReorderAsync`, `MoveUpAsync`/`MoveDownAsync`). Single-row operations (plain create/update without a featured transition, soft-delete) do not need an explicit transaction — a single `SaveChangesAsync()` call is already atomic.
## Pattern 2: Rate Limiting Applied at the Action Level
**Decision**: `[EnableRateLimiting("offerings-public")]` is placed on the public `GET` action method only, following the exact precedent in `AuthController` (`[EnableRateLimiting("login")]`/`[EnableRateLimiting("refresh")]` on individual actions, not the whole controller). `OfferingsController`'s admin mutation actions carry no rate-limiting attribute — consistent with NFR-OFF-01 (Q1 = A) scoping the policy to the public endpoint only.
**No new pattern beyond this** — the `offerings-public` policy itself (config-driven `FixedWindowLimiter`) is already fully specified in `tech-stack-decisions.md`.
## Pattern 3: Audit Logging — Structured Fields
**Decision**: `OfferingsService` emits one `LogInformation` structured log entry per create/update/delete, with these fields: `OfferingId` (Guid), `Action` (`"Created"`/`"Updated"`/`"Deleted"`), `LastModifiedByUserId` (Guid, the same value persisted on the entity). No before/after value diffing — consistent with NFR-OFF-03's accepted scope (minimal audit trail, not a full audit-log table).
## No Other New Patterns
Scalability and Performance categories are N/A for this unit (see `offerings-nfr-design-plan.md`) — no new pattern required beyond what's already covered above and in `nfr-requirements.md`.
@@ -0,0 +1,34 @@
# NFR Requirements — Unit: Offerings
## NFR-OFF-01: Rate Limiting on the Public Read Endpoint (SECURITY-11)
**Requirement**: `GET /api/v1/offerings` gets its own named rate-limiting policy (`offerings-public`), following the existing `login`/`refresh`/`sentry-tunnel` pattern in `AddCmsRateLimiting`.
**Rationale**: NFR Requirements Q1 = A — the public, anonymous, high-traffic endpoint is the one worth defending against scraping/abuse; admin endpoints are already behind authentication (`AdminOnly`), judged lower priority for a dedicated limiter in this unit.
**Scope for Code Generation**: A new `AddFixedWindowLimiter("offerings-public", ...)` entry, configuration-driven via a new `RateLimiting:OfferingsPublic` appsettings section (mirroring `RateLimiting:Login` etc.'s `PermitLimit`/`WindowSeconds` shape). Exact default values are a Code Generation Planning detail — generous enough not to affect legitimate site traffic, consistent with the existing `sentry-tunnel` policy's "generous but bounded" framing.
## NFR-OFF-02: No HTTP Caching Headers (Deliberate, Not an Oversight)
**Requirement**: `GET /api/v1/offerings` does not set `Cache-Control`/ETag headers in this unit, despite the external hand-off doc inviting it.
**Rationale**: NFR Requirements Q2 = B — the frontend already uses TanStack Query with default settings (refetch on mount/focus, no custom `staleTime`), so there's no functional caching gap to close; adding HTTP-level caching now would be optimizing a path with no observed or anticipated problem.
## NFR-OFF-03: Audit Trail Completion — "Who" Alongside "When" (SECURITY-13)
**Requirement**: `Offering.LastModifiedByUserId` (added to the entity, see domain-entities.md) is set from the authenticated admin's user id on every create, update, and delete.
**Rationale**: NFR Requirements Q3 = A — closes the remaining half of the SECURITY-13 open item flagged in requirements.md (`CreatedAt`/`UpdatedAt` from Functional Design already covered "when"). Still a minimal audit trail, not a full audit-log table with before/after value history — that remains a documented, accepted gap (consistent with the original SECURITY-13 assessment in requirements.md).
**Explicitly rejected approach**: extending `SlpModularCms.Core.Observability.SecurityEvents` (the `RateLimitTriggered`-style structured Sentry-alerting mechanism) to also log content mutations. Investigated and rejected: that class is purpose-built for alertable anomalies at Warning level feeding Sentry alert rules (SECURITY-14) — a routine, expected "admin edited an offering" event is not an anomaly, and logging it through the same channel would pollute the exact alerting mechanism SECURITY-14 depends on. The audit fields on the entity itself are the right mechanism for this unit's scope.
## NFR-OFF-04: Test Coverage Standard (Consistency with Prior Modules)
**Requirement**: `SlpModularCms.Modules.Offerings` targets the same ≥80% test coverage standard already established for new modules (`master-cms-module`'s NFR-MASTER-05).
**Rationale**: NFR Requirements Q4 = A — consistency across modules rather than a new, unit-specific bar.
## Out of Scope for This Unit
- Property-based testing: explicitly not enforced for this feature (D-12/Q12 = C from requirements.md) — standard example-based xUnit + FluentAssertions + NSubstitute tests, matching every existing module's test project.
- New infrastructure/technology: none — reuses the existing MariaDB/EF Core/rate-limiting/logging stack.
@@ -0,0 +1,21 @@
# Tech Stack Decisions — Unit: Offerings
## New Backend Technology: None
No new package, library, or infrastructure is introduced for this unit. Offerings reuses the existing stack end-to-end:
- EF Core + Pomelo MySql provider against the existing MariaDB instance (same server, isolated logical database per environment, same pattern as every other module)
- ASP.NET Core rate limiting middleware (already registered via `AddCmsRateLimiting`) — extended with one new named policy, not a new mechanism
- Standard `ILogger<T>` structured logging — extended with routine `LogInformation` calls in `OfferingsService`, not a new logging mechanism (see NFR-OFF-03's explicit rejection of extending `SecurityEvents`)
- xUnit + FluentAssertions + NSubstitute + coverlet for tests — same as every existing module's test project
## Rate Limiting Policy Addition
Per NFR-OFF-01 (NFR Requirements Q1 = A), one new named policy is added to the existing `AddCmsRateLimiting` extension:
| Policy Name | Applies To | Config Section | Notes |
|---|---|---|---|
| `offerings-public` | `GET /api/v1/offerings` (public, anonymous) only | `RateLimiting:OfferingsPublic` (new) | Follows the exact `PermitLimit`/`WindowSeconds` shape used by `RateLimiting:Login`/`RateLimiting:Refresh`/`RateLimiting:SentryTunnel`. `FixedWindowLimiter`, matching the existing policies' limiter type. |
Admin CRUD endpoints (`POST`/`PUT`/`DELETE` on `/api/v1/offerings`) get no dedicated policy in this unit — they're already behind `AdminOnly` authentication (Q1 = A explicitly scoped rate limiting to the public endpoint only).
Exact `PermitLimit`/`WindowSeconds` default values are deferred to Code Generation Planning, to be set generous enough not to affect legitimate anonymous website traffic.