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,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.