# 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`, stored as JSON column), `CtaLabel`, `Featured`, `DisplayOrder`, `IsDeleted`, `DeletedAt`, `CreatedAt`, `UpdatedAt`, `LastModifiedByUserId`) - [x] `Data/OfferingsDbContext.cs` — `DbSet Offerings`, `OnModelCreating`: `ToTable("OfferingsOfferings")`, field max-lengths matching `domain-entities.md`, `Features` mapped via a value converter (JSON string ⇄ `List`), 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` (MySQL, `NonLockingMySQLHistoryRepository`, matching `MasterModule`'s exact pattern), `AddScoped`, `AddScoped`, `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 `` 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.