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
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:
@@ -0,0 +1,37 @@
|
||||
# Code Generation Summary — Unit: Offerings
|
||||
|
||||
Implements all 12 user stories (US-01–US-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`.
|
||||
+139
@@ -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).
|
||||
+105
@@ -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).
|
||||
+66
@@ -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.
|
||||
+124
@@ -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/`.
|
||||
+21
@@ -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.
|
||||
+27
@@ -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`.
|
||||
+34
@@ -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.
|
||||
+21
@@ -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.
|
||||
+108
@@ -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?
|
||||
+15
@@ -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
|
||||
+59
@@ -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
|
||||
+2
@@ -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):
|
||||
|
||||
+2
@@ -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)"]
|
||||
|
||||
Reference in New Issue
Block a user