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.
@@ -0,0 +1,108 @@
# Code Generation Plan — Unit: Offerings
## Unit Context
**Stories implemented**: US-01 through US-12 (all 12).
**Functional requirements implemented**: FR-4, FR-5, FR-6, FR-7, FR-8.
**Dependencies**: Unit 1 "SlpSoftware Client Setup" (complete, approved, CI green) — `SlpModularCms.Api.SlpSoftware` already exists.
**Expected interfaces produced**: `IOfferingsService`/`OfferingsService`, `IOfferingRepository`/`OfferingRepository`, `OfferingsController`, `OfferingsModule`, plus the full admin frontend feature.
**Database entities owned**: `Offering` (new table, new `OfferingsDbContext`, own migration history — same MariaDB instance as every other module).
**Workspace root**: `K:\Development\Projects\SlpModularCms`.
## Investigation Findings That Shape This Plan
- **Table naming convention** (read `MasterDbContext.cs`/`AvailabilityDbContext.cs`): every module prefixes its table name with the module name to avoid collisions in the shared MariaDB database (`MasterCmsInstances`, `AvailabilityMasterRegistrations`). Following this exactly: `Offering` → table `OfferingsOfferings`.
- **Validation mechanism** (BR-OFF-04 requires field-level errors; no existing precedent for DataAnnotations or FluentValidation in this codebase — `CmsInstanceService`/`UsersController` do manual `ArgumentException`/flat-message checks instead): deciding directly, no question needed — DataAnnotations attributes (`[Required]`, `[MaxLength]`) on `CreateOfferingRequest`/`UpdateOfferingRequest`, relying on `[ApiController]`'s automatic `ValidationProblemDetails` (RFC 9457-compatible, already how `GlobalExceptionHandler` frames every other error). This is the idiomatic ASP.NET Core mechanism and needs no new library. The `Features` list's per-item length/count bounds (1-10 items, each ≤200 chars) need a small custom `ValidationAttribute` since DataAnnotations doesn't validate collection-item length out of the box.
- **Rate limiting registration location**: `AddCmsRateLimiting` lives in `SlpModularCms.Core.Hosting.ServiceCollectionExtensions`, shared by all Client projects via `CmsHost`. The new `offerings-public` policy is added there (Core), not per-module — harmless on `Api`/`Api.Slave` since nothing references that policy name without the `OfferingsController` action's attribute.
- **Frontend routing**: this codebase uses TanStack Router (object-based routes in `router.tsx`), not React Router — `frontend-components.md`'s "Admin Router" maps to new `createRoute` entries under `authenticatedRoute`, using the existing `RoleGuard`/`ModuleGuard`/`lazyPage` patterns from `cmsRoute` (Owner+Administrator roles, `requiredModule="Offerings"` so the nav item/route gracefully no-ops on `Api`/`Api.Slave` builds that never reference `Modules.Offerings`). Nav entry added to `Sidebar.tsx`'s `NAV_ITEMS`.
- **Transactions** (NFR Design Pattern 1): `MasterDbContext`/`CmsInstanceRepository` show no existing transaction-wrapping precedent in this codebase — this unit introduces the first one, via `context.Database.BeginTransactionAsync()` in the three `OfferingsService` methods identified in NFR Design.
## Steps
### Step 1 — Domain Layer (`SlpModularCms.Modules.Offerings`)
- [x] `Data/Entities/Offering.cs` — all 13 fields per `domain-entities.md` (`Id`, `Title`, `Description`, `Price`, `PriceNote`, `Features` (`List<string>`, stored as JSON column), `CtaLabel`, `Featured`, `DisplayOrder`, `IsDeleted`, `DeletedAt`, `CreatedAt`, `UpdatedAt`, `LastModifiedByUserId`)
- [x] `Data/OfferingsDbContext.cs``DbSet<Offering> Offerings`, `OnModelCreating`: `ToTable("OfferingsOfferings")`, field max-lengths matching `domain-entities.md`, `Features` mapped via a value converter (JSON string ⇄ `List<string>`), a global `HasQueryFilter(o => !o.IsDeleted)` so soft-deleted rows are never returned to any caller without every repository method needing its own `.Where(!IsDeleted)` (matches `frontend-components.md`/`domain-entities.md`'s "soft-deleted rows are never returned to any caller")
- [x] Add `Microsoft.EntityFrameworkCore.Design`-driven initial migration (`dotnet ef migrations add InitialCreate`, run at Step 8 once the context compiles)
### Step 2 — Repository (`Repositories/`)
- [x] `IOfferingRepository` + `OfferingRepository` per `component-methods.md`: `GetAllAsync`, `GetByIdAsync`, `AddAsync`, `UpdateAsync`, `GetMaxDisplayOrderAsync`, `GetFeaturedAsync` — plus `GetByDisplayOrderNeighborAsync`-style helpers as needed for `MoveUpAsync`/`MoveDownAsync`'s adjacent-swap lookup (Code Generation detail, not previously specified at method-signature level)
- [x] Expose `OfferingsDbContext.Database` (or a thin `BeginTransactionAsync`/`CommitAsync` wrapper) so `OfferingsService` can own the transaction boundary without the repository leaking `DbContext` internals beyond what `CmsInstanceRepository`'s existing `SaveChangesAsync()`-exposing pattern already does
### Step 3 — Service (`Services/`)
- [x] `IOfferingsService` + `OfferingsService` per `component-methods.md`, implementing:
- `GetPublicOfferingsAsync` / `GetAllForAdminAsync` — simple projections to `OfferingDto`/`OfferingAdminDto`
- `CreateAsync`/`UpdateAsync` — BR-OFF-04 validation (via model binding, already enforced before the service runs), BR-OFF-01 featured exclusivity, wrapped in a transaction (NFR Design Pattern 1) when `Featured` transitions to `true`; sets `LastModifiedByUserId` from the authenticated caller (NFR-OFF-03) and `CreatedAt`/`UpdatedAt`
- `DeleteAsync` — BR-OFF-02 (soft delete, never blocked), sets `LastModifiedByUserId`
- `ReorderAsync` — BR-OFF-03, full-list resequence, transactional
- `MoveUpAsync`/`MoveDownAsync` — BR-OFF-03 boundary no-op, transactional swap
- Structured `LogInformation` call on every create/update/delete (NFR Design Pattern 3: `OfferingId`, `Action`, `LastModifiedByUserId`)
- [x] `LastModifiedByUserId` sourced the same way `UsersController` sources the caller's id today: `ClaimTypes.NameIdentifier`/`"sub"` claim, resolved via `IHttpContextAccessor` (mirroring `MasterServiceDependencies`'s existing use of `IHttpContextAccessor` for a different purpose) — passed into the service from the controller, not read directly in the service, to keep the service HTTP-agnostic and unit-testable
### Step 4 — Models (`Models/`)
- [x] `OfferingDto`, `OfferingAdminDto` (records, per `domain-entities.md`'s exact field lists)
- [x] `CreateOfferingRequest`, `UpdateOfferingRequest` — DataAnnotations per BR-OFF-04 (`[Required]`, `[MaxLength(100)]` etc.), plus the custom `FeaturesValidationAttribute` for per-item length/count
- [x] `ReorderOfferingsRequest` (ordered `Guid[]`)
### Step 5 — Controller (`Controllers/OfferingsController.cs`)
- [x] Per `component-methods.md`'s route table exactly: `GetOfferings` (`[AllowAnonymous]`, `[EnableRateLimiting("offerings-public")]`), `GetAllForAdmin`/`Create`/`Update`/`Delete`/`Reorder`/`MoveUp`/`MoveDown` (`[Authorize(Policy = "AdminOnly")]`, no rate-limit attribute)
- [x] `[ApiController]` + `[Route("offerings")]` (matches `CmsHost`'s `ApiPrefixConvention("api/v1")`, giving the final `/api/v1/offerings` route FR-6 requires)
### Step 6 — Module Registration (`OfferingsModule.cs`)
- [x] `RegisterServices`: `AddDbContext<OfferingsDbContext>` (MySQL, `NonLockingMySQLHistoryRepository`, matching `MasterModule`'s exact pattern), `AddScoped<IOfferingRepository, OfferingRepository>`, `AddScoped<IOfferingsService, OfferingsService>`, `AddHttpContextAccessor()` (for Step 3's caller-id resolution)
- [x] `UseModule`: `db.Database.Migrate()`
### Step 7 — Rate Limiting Policy (Core, shared)
- [x] `SlpModularCms.Core.Hosting.ServiceCollectionExtensions.AddCmsRateLimiting`: add `options.AddFixedWindowLimiter("offerings-public", opt => { ... RateLimiting:OfferingsPublic ... })`, following the exact `login`/`refresh`/`sentry-tunnel` shape (default `PermitLimit` generous, e.g. 120/60s — final default decided at implementation time, config-overridable)
- [x] Add `RateLimiting:OfferingsPublic` section to `Api.SlpSoftware/appsettings.json` (and `.Development.json` if defaults should differ)
### Step 8 — Project Wiring
- [x] `src/SlpModularCms.Modules.Offerings/SlpModularCms.Modules.Offerings.csproj` — mirrors `Modules.Master.csproj` shape (`InternalsVisibleTo``Modules.Offerings.Tests`, `ProjectReference``Core`)
- [x] `src/SlpModularCms.Modules.Offerings.Tests/SlpModularCms.Modules.Offerings.Tests.csproj` — mirrors `Modules.Master.Tests.csproj` (xunit, FluentAssertions, NSubstitute, EF Core InMemory, coverlet)
- [x] Add `<ProjectReference>` to `Modules.Offerings` in `SlpModularCms.Api.SlpSoftware.csproj` (this unit's responsibility per `unit-of-work.md`, not Unit 1's)
- [x] `SlpModularCms.sln`: add both new projects (Modules solution folder for `Offerings`, Tests/Modules for `Offerings.Tests`, per `CLAUDE.md`'s structure rules), `ProjectConfigurationPlatforms`, `NestedProjects`
- [x] Generate and apply the EF Core migration from Step 1 once the project compiles
### Step 9 — Backend Tests (≥80% coverage, NFR-OFF-04)
- [x] `OfferingRepositoryTests` (EF Core InMemory) — `GetAllAsync` excludes soft-deleted, ordering by `DisplayOrder`, `GetFeaturedAsync`
- [x] `OfferingsServiceTests` (NSubstitute repository) — BR-OFF-01 (featured exclusivity, both directions), BR-OFF-02 (delete always succeeds incl. last-remaining), BR-OFF-03 (reorder resequence, move-up/down boundary no-ops), BR-OFF-04 is exercised at the model-binding layer (controller test), audit fields set (`LastModifiedByUserId`/`CreatedAt`/`UpdatedAt`)
- [x] `OfferingsControllerTests` (WebApplicationFactory or direct controller instantiation with a substituted service, matching this codebase's existing test style) — route/auth assertions (`[AllowAnonymous]` on public GET, `AdminOnly` on the rest), validation-failure → `400` with field-level errors, rate limiter → `429` after the configured burst on the public GET only
### Step 10 — Frontend: Dependency + Schema
- [x] `frontend/package.json`: add `@dnd-kit/core` + `@dnd-kit/sortable` (Functional Design Q1 = A)
- [x] `frontend/src/features/offerings/schemas/offering.ts` — zod schema mirroring BR-OFF-04 exactly (title ≤100, description ≤500, price ≤50, priceNote ≤100, ctaLabel ≤50, features 1-10 items each ≤200 chars, featured optional/defaults false)
### Step 11 — Frontend: Services (API hooks, `services/`)
- [x] `useOfferings()`, `useOffering(id)`, `useCreateOffering()`, `useUpdateOffering()`, `useDeleteOffering()`, `useReorderOfferings()`, `useMoveOffering(direction)` — per `frontend-components.md`'s table, mirroring `useCmsInstances.ts`/`useAddCmsInstance.ts`'s exact TanStack Query shape (query key `['offerings', 'admin']`, invalidated by every mutating hook)
- [x] `services/types.ts``OfferingAdminDto`, `CreateOfferingRequest`, `UpdateOfferingRequest` frontend-side types matching the backend DTOs field-for-field
### Step 12 — Frontend: Hooks (feature-local, `hooks/`)
- [x] `hooks/useOfferingsDnd.ts` — dnd-kit sensor setup, drag-end order computation, optimistic local update, calls `useReorderOfferings()` (per `frontend-components.md`)
### Step 13 — Frontend: Components and Pages
- [x] `components/OfferingsList.tsx`, `components/OfferingRow.tsx`, `components/DeleteOfferingDialog.tsx`, `components/OfferingForm.tsx`
- [x] `pages/OfferingsListPage.tsx`, `pages/OfferingFormPage.tsx`
- [x] All per `frontend-components.md`'s props/responsibilities/data-testid convention exactly
### Step 14 — Frontend: Routing, Navigation, i18n
- [x] `router.tsx`: `offeringsRoute` (`/offerings`), `offeringsNewRoute` (`/offerings/new`), `offeringsEditRoute` (`/offerings/$id/edit`) under `authenticatedRoute`, each wrapped in `RoleGuard allowedRoles={['Owner', 'Administrator']}` + `ModuleGuard requiredModule="Offerings"`, using `lazyPage`
- [x] `Sidebar.tsx`: new `NAV_ITEMS` entry (`to: '/offerings'`, `roles: ['Owner', 'Administrator']`, `requiredModule: 'Offerings'`, an appropriate `lucide-react` icon e.g. `Package`)
- [x] `i18n` locale files (nl/en): `nav.offerings` + all new page/form/dialog copy (labels, validation messages, delete-confirmation text per BR-OFF-02's Dutch example already drafted in `business-logic-model.md`)
### Step 15 — Frontend Tests
- [x] Component/page tests mirroring `features/cms`'s existing `.test.tsx`/`.test.ts` coverage style (schema tests, hook tests with MSW-style mocks per `mocks/handlers.ts` pattern, component render/interaction tests)
### Step 16 — Documentation
- [x] `aidlc-docs/features/slpsoftware-api/construction/offerings/code/summary.md` — summarizing everything generated across Steps 1-15
- [x] No root `README.md` change expected (Offerings is an internal module addition, not a structural/hosting change like Unit 1 was) — confirm at generation time whether the Projectstructuur section needs a one-line mention
### Step 17 — Deployment Artifacts
- [x] **N/A for this Construction stage** — no new infrastructure (Infrastructure Design was skipped); the CI/CD cutover itself is Operations-phase work (D-7/D-15), untouched here
### Step 18 — Build and Test Verification (automatic)
- [x] Backend: `dotnet build` full solution, `dotnet test` for `Modules.Offerings.Tests` (new) and the full solution (regression check), confirm ≥80% coverage on the new module
- [x] Frontend: `pnpm test` (new Offerings tests + full suite regression), `pnpm build` (or equivalent typecheck/lint) to confirm no compile errors
- [x] Fix and retry on any failure; only surface to the user if a fix requires a decision only they can make
---
**Scope reminder**: this plan implements Unit 2 "Offerings" — the last unit for this feature. Once approved and green, the feature moves to feature-wide Build and Test, then the Operations phase.
@@ -0,0 +1,54 @@
# Functional Design Questions — Unit: Offerings
## Vraag 1 — Drag-and-drop is een nieuwe frontend-dependency
`frontend/package.json` bevat vandaag geen enkele drag-and-drop-library (geen `@dnd-kit/*`, `react-beautiful-dnd`, of vergelijkbaar). US-08 (drag-and-drop herordenen) zou dus een nieuwe dependency betekenen, terwijl US-09 (omhoog/omlaag-knoppen) de functionele eis al volledig dekt zonder nieuwe dependency.
Hoe wil je dit voor de eerste versie aanpakken?
A) Beide bouwen zoals in de Inception-fase besloten — nieuwe dependency toevoegen (`@dnd-kit/core` + `@dnd-kit/sortable`, de huidige de-facto standaard voor React) voor drag-and-drop, plús de knoppen als toegankelijke fallback
B) Nu alleen de omhoog/omlaag-knoppen bouwen (US-09) — geen nieuwe dependency; drag-and-drop (US-08) wordt een latere, aparte toevoeging
X) Anders (beschrijf hieronder na de [Answer]:-tag)
[Answer]:A
## Vraag 2 — Pagina-structuur admin-CRUD
De bestaande `cms`-feature (Master-module) gebruikt het patroon: één lijstpagina (`CmsPage.tsx` + `CmsInstanceList.tsx`) met een modal-dialoog voor "toevoegen" (`AddCmsInstanceDialog.tsx`) en een aparte modal voor statuswijziging.
Moet de Offerings-admin-UI hetzelfde patroon volgen?
A) Ja — één lijstpagina met rij-acties (bewerken/verwijderen/omhoog/omlaag), en een modal-dialoog die zowel voor "aanmaken" als "bewerken" hergebruikt wordt (met de featured-toggle erin)
B) Aparte pagina's/routes voor aanmaken en bewerken in plaats van modals
X) Anders (beschrijf hieronder na de [Answer]:-tag)
[Answer]: X, het hoeft niet hetzelfde patroon te zijn. Voor consistentie wel mooi, maar het CMS-stuk is vooral voor de master en zal niet bij andere websittes komen. Dus Optie B is prima
## Vraag 3 — Validatiegrenzen (SECURITY-05)
Requirements.md vereist lengtebeperkingen op tekstvelden, maar noemt geen concrete getallen. Op basis van de bestaande content (langste titel "Landingspagina" = 14 tekens, langste description ≈ 70 tekens) stel ik ruime maar begrensde limieten voor.
Welke bovengrenzen wil je hanteren?
A) Title 100, Description 500, Price 50, PriceNote 100, CtaLabel 50 tekens; Features: min 1, max 10 items, elk item max 200 tekens — ruim genoeg voor toekomstig hergebruik (fotografie-pakketten etc.), maar begrensd tegen misbruik
B) Strakkere limieten, dicht bij de huidige content (Title 50, Description 200, Features max 6 items van elk 100 tekens)
X) Anders (geef zelf de getallen op na de [Answer]:-tag)
[Answer]:A
## Vraag 4 — Bevestiging bij verwijderen
Verwijderen is een soft delete (D-Q5=B), maar er is geen "herstel"-functie in de admin-UI voorzien (buiten scope, FR-7 noemt alleen create/edit/delete/reorder). Vanuit het perspectief van de CMS Administrator is verwijderen dus onomkeerbaar.
Moet de admin-UI een bevestigingsdialoog tonen vóór het verwijderen van een offering?
A) Ja — een bevestigingsdialoog ("Weet je zeker dat je '[titel]' wilt verwijderen?") vóór de delete-aanroep
B) Nee — direct verwijderen zonder bevestiging
X) Anders (beschrijf hieronder na de [Answer]:-tag)
[Answer]:A
## Vraag 5 — UI-trigger voor de featured-vlag
US-10 beschrijft dat het systeem "precies 0 of 1 featured offering" afdwingt, maar niet hoe de CMS Administrator dat instelt in de UI.
A) Een checkbox/toggle "Meest gekozen" in het aanmaak-/bewerkformulier van elke offering — bij opslaan met deze toggle aan wordt de vorige featured-offering automatisch uitgezet
B) Een aparte actie in de lijst-view zelf (bijv. een ster-icoon per rij om direct featured te maken, los van het bewerkformulier)
X) Anders (beschrijf hieronder na de [Answer]:-tag)
[Answer]: kan A en B beide?
@@ -0,0 +1,15 @@
# Functional Design Plan — Unit: Offerings
Context loaded from `inception/application-design/unit-of-work.md` (unit scope: FR-4..FR-8, all 12 user stories) and `unit-of-work-story-map.md` (story assignment confirmation).
Investigated before drafting questions (not assumed):
- `frontend/src/features/cms/` (the existing Master-module admin UI — `CmsPage.tsx`, `CmsInstanceList.tsx`, `AddCmsInstanceDialog.tsx`, `services/use*.ts`, `schemas/*.ts`) is the closest existing precedent for an admin list+CRUD screen in this codebase — used as the structural template below.
- `frontend/package.json` has **no drag-and-drop library** (no `@dnd-kit/*`, no `react-beautiful-dnd`, nothing matching "dnd"/"sortable"/"drag"). US-08 (drag-and-drop reorder) would be a genuinely new frontend dependency, not something already available — see Vraag 1.
## Uitvoeringschecklist
- [x] Stap A — `business-logic-model.md`: procesflow voor create/update (met featured-exclusiviteit) en de twee reorder-interacties, als Mermaid-diagrammen
- [x] Stap B — `business-rules.md`: gedetailleerde regels als Mermaid-beslisdiagrammen (featured-exclusiviteit, soft-delete-gedrag, reorder-grenzen, validatieregels)
- [x] Stap C — `domain-entities.md`: `Offering`-entiteit met velden, types, en relatie tot de DTO's (publiek vs. admin) als Mermaid-diagram
- [x] Stap D — `frontend-components.md`: componenthiërarchie voor de admin-CRUD-schermen (Mermaid `graph TD`), props/state per component, formuliervalidatie, welke backend-endpoints elk component aanroept
- [x] Stap E — Consistentiecontrole tegen requirements.md, stories.md en application-design/component-methods.md — geen gaten gevonden; FR-8 (referentiecontent) blijft buiten deze unit's codegeneratie (D-5, handmatige invoer door gebruiker)
@@ -0,0 +1,26 @@
# NFR Design Plan — Unit: Offerings
**Categorieën die niet van toepassing zijn (met onderbouwing, niet zomaar overgeslagen)**:
- **Scalability Patterns**: N/A — een freelancer-website met een handvol offerings (naar verwachting < 50 rijen); geen schaal-grens om voor te ontwerpen.
- **Performance Patterns**: N/A buiten wat al besloten is — geen caching (NFR-OFF-02), geen zwaar rekenwerk; `GetAllAsync`/`GetPublicOfferingsAsync` zijn simpele, geïndexeerde queries.
- **Security Patterns (rate limiting)**: geen losse vraag nodig — bestaand precedent in `AuthController` (`[EnableRateLimiting("login")]`/`[EnableRateLimiting("refresh")]`, per-actiemethode) wordt direct hergebruikt: `[EnableRateLimiting("offerings-public")]` komt op de publieke `GET`-actiemethode in `OfferingsController`, niet op de admin-mutatie-acties (die geen policy krijgen, per NFR-OFF-01 Q1=A).
**Wél een open ontwerpvraag gevonden**: `services.md` (Application Design) had de transactiegrens voor multi-row-operaties expliciet doorgeschoven naar Functional Design ("Exact transactional boundaries ... are a Functional Design decision for the Offerings unit, not decided here"), maar noch `business-logic-model.md` noch `business-rules.md` heeft dit vastgelegd. Dit raakt direct een NFR (data-integriteit), dus leg ik 'm hier alsnog voor in plaats van 'm stilzwijgend zelf te beslissen.
## Uitvoeringschecklist
- [x] Stap A — `nfr-design-patterns.md`: transactiepatroon, rate-limiting-toepassing, logging-velden vastleggen
- [x] Stap B — `logical-components.md`: `OfferingsService`'s multi-row-operaties en de nieuwe rate-limiting-policy als logische componenten beschrijven
---
## Vragen
### Vraag 1 — Transactiegrens voor multi-row-operaties
Drie operaties in `OfferingsService` raken meer dan één rij in dezelfde logische actie: de featured-exclusiviteitswissel (create/update met `Featured=true`, US-10), de volledige drag-and-drop-reorder (US-08), en de aangrenzende swap via knoppen (US-09). Moeten deze in één DB-transactie (`BeginTransaction`/`Commit`) of als opeenvolgende, niet-transactionele `SaveChangesAsync`-aanroepen?
A) Eén DB-transactie per operatie — garandeert dat bijv. een reorder van 10 rijen nooit half doorgevoerd raakt bij een crash/verbindingsfout; iets meer code (expliciet transactiebeheer), maar dit is precies waar transacties voor bestaan
B) Opeenvolgende `SaveChangesAsync`-aanroepen zonder expliciete transactie — eenvoudiger; een falen halverwege laat de dataset in een inconsistente staat (bijv. twee offerings zonder featured, of dubbele `DisplayOrder`-waarden) tot een volgende succesvolle actie het herstelt; geaccepteerd risico gezien de kleine schaal (1 admin, handmatig direct zichtbaar/herstelbaar)
X) Anders (beschrijf hieronder na de [Answer]:-tag)
[Answer]:A
@@ -0,0 +1,59 @@
# NFR Requirements Plan — Unit: Offerings
Investigated before drafting questions:
- `SlpModularCms.Core.Observability.SecurityEvents` (the existing structured security-alerting mechanism, `RateLimitTriggered` etc.) is purpose-built for **alertable anomalies** (brute force, forged tokens) — Warning-level, feeds Sentry alert rules (SECURITY-14). A routine "admin edited an offering" event is not an anomaly and would pollute that exact alerting mechanism if bolted on. SECURITY-13 (audit trail) is better served by plain `LogInformation`-level structured logging in `OfferingsService`, not by extending `SecurityEvents`.
- `ServiceCollectionExtensions.AddCmsRateLimiting` already defines three named policies (`login`, `refresh`, `sentry-tunnel`) via `appsettings.json`'s `RateLimiting:*` section — adding an `offerings` (or `offerings-public`/`offerings-admin`) policy would follow the exact same established pattern.
## Uitvoeringschecklist
- [x] Stap A — `nfr-requirements.md`: NFR's voor deze unit vastleggen (rate limiting, caching, audit-trail-afronding, testdekking)
- [x] Stap B — `tech-stack-decisions.md`: bevestigen dat geen nieuwe backend-technologie nodig is; vastleggen welke `RateLimiting`-policy(s) worden toegevoegd
---
## Vragen
### Vraag 1 — Rate limiting op de nieuwe endpoints
De publieke `GET /api/v1/offerings` en de admin-CRUD-endpoints hebben nog geen rate-limiting-policy (in tegenstelling tot Login/Refresh/SentryTunnel).
Welke endpoints moeten een rate-limiting-policy krijgen?
A) Alleen de publieke `GET`-endpoint (tegen scraping/misbruik van een anonieme, veelgebruikte endpoint) — admin-endpoints zijn al achter authenticatie, dus lager risico
B) Zowel de publieke `GET` als de admin-mutatie-endpoints — consistente verdediging in de diepte (SECURITY-11), ook al zijn admin-endpoints al geauthenticeerd
C) Geen van beide nu — dit achterwege laten, eventueel later toevoegen
X) Anders (beschrijf hieronder na de [Answer]:-tag)
[Answer]:A
### Vraag 2 — HTTP-caching op de publieke endpoint
De externe hand-off-doc noemt expliciet: "adding reasonable HTTP caching is welcome since this content changes rarely" voor `GET /api/v1/offerings`.
Wil je dit nu meenemen?
A) Ja — een simpele `Cache-Control: public, max-age=<N>` header op de publieke `GET`-response (bijv. 60-300 seconden); geen ETag/conditional-requests-complexiteit voor nu
B) Nee — geen caching-headers in deze unit; de frontend gebruikt toch al TanStack Query zonder custom staleTime (ziet er functioneel niet uit als een probleem)
X) Anders (beschrijf hieronder na de [Answer]:-tag)
[Answer]:B
### Vraag 3 — SECURITY-13-afronding: "wie" naast "wanneer"
Functional Design voegde `CreatedAt`/`UpdatedAt` toe aan `Offering` (het "wanneer"-deel van SECURITY-13). Het "wie"-deel (welke admin de wijziging maakte) staat nog los.
Wil je dat ook vastleggen?
A) Ja — voeg `LastModifiedByUserId` (of vergelijkbaar) toe aan `Offering`, gevuld vanuit de geauthenticeerde admin-gebruiker bij elke create/update/delete
B) Nee — `CreatedAt`/`UpdatedAt` is voldoende voor nu; "wie" blijft een bekend, geaccepteerd gat (net als de rest van SECURITY-13, al genoteerd als open item in requirements.md)
X) Anders (beschrijf hieronder na de [Answer]:-tag)
[Answer]: A
### Vraag 4 — Testdekkingsnorm
De `master-cms-module`-feature hanteerde een bestaande projectnorm van ≥80% testdekking voor nieuwe modules (NFR-MASTER-05).
Geldt dezelfde norm voor de Offerings-module?
A) Ja — zelfde ≥80%-norm aanhouden
B) Nee — andere norm (geef aan welke na de [Answer]:-tag)
X) Anders (beschrijf hieronder na de [Answer]:-tag)
[Answer]:A
@@ -49,6 +49,8 @@ The Gitea Actions `backend-test` job (`.gitea/workflows/continuous_integration.y
**Scope note**: `continuous_integration.yaml` is nominally owned by the `gitea-deployment-workflow` feature. This specific change (making the test gate pass for a test project this feature introduced) was judged in-scope to fix directly as Build-and-Test correctness — distinct from the actual deploy-target retarget (D-15), which remains deferred to this feature's own Operations phase.
**Round 2**: the `services:` block above did not actually work in CI — the self-hosted runner (`raspberry-pi-arm64`) runs job and service containers in Docker host-network mode, so the `ports:` mapping was silently ignored and the service ended up on the host's own port 3306, which something else on the runner already answers on. Replaced with an explicit `docker run` step publishing on host port 3307 instead, plus a readiness loop using `mariadb-admin ping` (not `mysqladmin`, which this image doesn't provide — confirmed locally). Diagnosed from the actual Gitea Actions job log (fetched via the API) rather than guessed, and the replacement was verified locally end-to-end before pushing again.
## Build and Test Verification (Step 13.5)
Two real build fixes were needed and applied during this step (not deviations from the plan — the plan didn't anticipate these, since they only surface once the code actually compiles):
@@ -3,6 +3,7 @@
**Status note**: this diagram documents the **target state after** the Operations-phase cutover (D-15) — it is reference/planning context for Code Generation, not something this unit deploys itself. No infrastructure changes happen as part of this Construction stage.
```mermaid
%%{init: {'themeVariables': {'primaryTextColor':'#000000','textColor':'#000000','tertiaryTextColor':'#000000'}}}%%
graph TD
visitor["Site Visitor / CMS Administrator browser"]
proxy["Proxy Pi<br/>nginx + TLS (certbot)"]
@@ -40,6 +41,7 @@ Text alternative: a visitor's browser reaches the proxy Pi over HTTPS, which for
## Local Development (Current Scope of This Unit)
```mermaid
%%{init: {'themeVariables': {'primaryTextColor':'#000000','textColor':'#000000','tertiaryTextColor':'#000000'}}}%%
graph LR
dev["Developer machine"]
api["SlpModularCms.Api<br/>(existing dev host)"]