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
14 KiB
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→ tableOfferingsOfferings. - Validation mechanism (BR-OFF-04 requires field-level errors; no existing precedent for DataAnnotations or FluentValidation in this codebase —
CmsInstanceService/UsersControllerdo manualArgumentException/flat-message checks instead): deciding directly, no question needed — DataAnnotations attributes ([Required],[MaxLength]) onCreateOfferingRequest/UpdateOfferingRequest, relying on[ApiController]'s automaticValidationProblemDetails(RFC 9457-compatible, already howGlobalExceptionHandlerframes every other error). This is the idiomatic ASP.NET Core mechanism and needs no new library. TheFeatureslist's per-item length/count bounds (1-10 items, each ≤200 chars) need a small customValidationAttributesince DataAnnotations doesn't validate collection-item length out of the box. - Rate limiting registration location:
AddCmsRateLimitinglives inSlpModularCms.Core.Hosting.ServiceCollectionExtensions, shared by all Client projects viaCmsHost. The newofferings-publicpolicy is added there (Core), not per-module — harmless onApi/Api.Slavesince nothing references that policy name without theOfferingsControlleraction'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 newcreateRouteentries underauthenticatedRoute, using the existingRoleGuard/ModuleGuard/lazyPagepatterns fromcmsRoute(Owner+Administrator roles,requiredModule="Offerings"so the nav item/route gracefully no-ops onApi/Api.Slavebuilds that never referenceModules.Offerings). Nav entry added toSidebar.tsx'sNAV_ITEMS. - Transactions (NFR Design Pattern 1):
MasterDbContext/CmsInstanceRepositoryshow no existing transaction-wrapping precedent in this codebase — this unit introduces the first one, viacontext.Database.BeginTransactionAsync()in the threeOfferingsServicemethods identified in NFR Design.
Steps
Step 1 — Domain Layer (SlpModularCms.Modules.Offerings)
Data/Entities/Offering.cs— all 13 fields perdomain-entities.md(Id,Title,Description,Price,PriceNote,Features(List<string>, stored as JSON column),CtaLabel,Featured,DisplayOrder,IsDeleted,DeletedAt,CreatedAt,UpdatedAt,LastModifiedByUserId)Data/OfferingsDbContext.cs—DbSet<Offering> Offerings,OnModelCreating:ToTable("OfferingsOfferings"), field max-lengths matchingdomain-entities.md,Featuresmapped via a value converter (JSON string ⇄List<string>), a globalHasQueryFilter(o => !o.IsDeleted)so soft-deleted rows are never returned to any caller without every repository method needing its own.Where(!IsDeleted)(matchesfrontend-components.md/domain-entities.md's "soft-deleted rows are never returned to any caller")- Add
Microsoft.EntityFrameworkCore.Design-driven initial migration (dotnet ef migrations add InitialCreate, run at Step 8 once the context compiles)
Step 2 — Repository (Repositories/)
IOfferingRepository+OfferingRepositorypercomponent-methods.md:GetAllAsync,GetByIdAsync,AddAsync,UpdateAsync,GetMaxDisplayOrderAsync,GetFeaturedAsync— plusGetByDisplayOrderNeighborAsync-style helpers as needed forMoveUpAsync/MoveDownAsync's adjacent-swap lookup (Code Generation detail, not previously specified at method-signature level)- Expose
OfferingsDbContext.Database(or a thinBeginTransactionAsync/CommitAsyncwrapper) soOfferingsServicecan own the transaction boundary without the repository leakingDbContextinternals beyond whatCmsInstanceRepository's existingSaveChangesAsync()-exposing pattern already does
Step 3 — Service (Services/)
IOfferingsService+OfferingsServicepercomponent-methods.md, implementing:GetPublicOfferingsAsync/GetAllForAdminAsync— simple projections toOfferingDto/OfferingAdminDtoCreateAsync/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) whenFeaturedtransitions totrue; setsLastModifiedByUserIdfrom the authenticated caller (NFR-OFF-03) andCreatedAt/UpdatedAtDeleteAsync— BR-OFF-02 (soft delete, never blocked), setsLastModifiedByUserIdReorderAsync— BR-OFF-03, full-list resequence, transactionalMoveUpAsync/MoveDownAsync— BR-OFF-03 boundary no-op, transactional swap- Structured
LogInformationcall on every create/update/delete (NFR Design Pattern 3:OfferingId,Action,LastModifiedByUserId)
LastModifiedByUserIdsourced the same wayUsersControllersources the caller's id today:ClaimTypes.NameIdentifier/"sub"claim, resolved viaIHttpContextAccessor(mirroringMasterServiceDependencies's existing use ofIHttpContextAccessorfor 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/)
OfferingDto,OfferingAdminDto(records, perdomain-entities.md's exact field lists)CreateOfferingRequest,UpdateOfferingRequest— DataAnnotations per BR-OFF-04 ([Required],[MaxLength(100)]etc.), plus the customFeaturesValidationAttributefor per-item length/countReorderOfferingsRequest(orderedGuid[])
Step 5 — Controller (Controllers/OfferingsController.cs)
- 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) [ApiController]+[Route("offerings")](matchesCmsHost'sApiPrefixConvention("api/v1"), giving the final/api/v1/offeringsroute FR-6 requires)
Step 6 — Module Registration (OfferingsModule.cs)
RegisterServices:AddDbContext<OfferingsDbContext>(MySQL,NonLockingMySQLHistoryRepository, matchingMasterModule's exact pattern),AddScoped<IOfferingRepository, OfferingRepository>,AddScoped<IOfferingsService, OfferingsService>,AddHttpContextAccessor()(for Step 3's caller-id resolution)UseModule:db.Database.Migrate()
Step 7 — Rate Limiting Policy (Core, shared)
SlpModularCms.Core.Hosting.ServiceCollectionExtensions.AddCmsRateLimiting: addoptions.AddFixedWindowLimiter("offerings-public", opt => { ... RateLimiting:OfferingsPublic ... }), following the exactlogin/refresh/sentry-tunnelshape (defaultPermitLimitgenerous, e.g. 120/60s — final default decided at implementation time, config-overridable)- Add
RateLimiting:OfferingsPublicsection toApi.SlpSoftware/appsettings.json(and.Development.jsonif defaults should differ)
Step 8 — Project Wiring
src/SlpModularCms.Modules.Offerings/SlpModularCms.Modules.Offerings.csproj— mirrorsModules.Master.csprojshape (InternalsVisibleTo→Modules.Offerings.Tests,ProjectReference→Core)src/SlpModularCms.Modules.Offerings.Tests/SlpModularCms.Modules.Offerings.Tests.csproj— mirrorsModules.Master.Tests.csproj(xunit, FluentAssertions, NSubstitute, EF Core InMemory, coverlet)- Add
<ProjectReference>toModules.OfferingsinSlpModularCms.Api.SlpSoftware.csproj(this unit's responsibility perunit-of-work.md, not Unit 1's) SlpModularCms.sln: add both new projects (Modules solution folder forOfferings, Tests/Modules forOfferings.Tests, perCLAUDE.md's structure rules),ProjectConfigurationPlatforms,NestedProjects- Generate and apply the EF Core migration from Step 1 once the project compiles
Step 9 — Backend Tests (≥80% coverage, NFR-OFF-04)
OfferingRepositoryTests(EF Core InMemory) —GetAllAsyncexcludes soft-deleted, ordering byDisplayOrder,GetFeaturedAsyncOfferingsServiceTests(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)OfferingsControllerTests(WebApplicationFactory or direct controller instantiation with a substituted service, matching this codebase's existing test style) — route/auth assertions ([AllowAnonymous]on public GET,AdminOnlyon the rest), validation-failure →400with field-level errors, rate limiter →429after the configured burst on the public GET only
Step 10 — Frontend: Dependency + Schema
frontend/package.json: add@dnd-kit/core+@dnd-kit/sortable(Functional Design Q1 = A)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/)
useOfferings(),useOffering(id),useCreateOffering(),useUpdateOffering(),useDeleteOffering(),useReorderOfferings(),useMoveOffering(direction)— perfrontend-components.md's table, mirroringuseCmsInstances.ts/useAddCmsInstance.ts's exact TanStack Query shape (query key['offerings', 'admin'], invalidated by every mutating hook)services/types.ts—OfferingAdminDto,CreateOfferingRequest,UpdateOfferingRequestfrontend-side types matching the backend DTOs field-for-field
Step 12 — Frontend: Hooks (feature-local, hooks/)
hooks/useOfferingsDnd.ts— dnd-kit sensor setup, drag-end order computation, optimistic local update, callsuseReorderOfferings()(perfrontend-components.md)
Step 13 — Frontend: Components and Pages
components/OfferingsList.tsx,components/OfferingRow.tsx,components/DeleteOfferingDialog.tsx,components/OfferingForm.tsxpages/OfferingsListPage.tsx,pages/OfferingFormPage.tsx- All per
frontend-components.md's props/responsibilities/data-testid convention exactly
Step 14 — Frontend: Routing, Navigation, i18n
router.tsx:offeringsRoute(/offerings),offeringsNewRoute(/offerings/new),offeringsEditRoute(/offerings/$id/edit) underauthenticatedRoute, each wrapped inRoleGuard allowedRoles={['Owner', 'Administrator']}+ModuleGuard requiredModule="Offerings", usinglazyPageSidebar.tsx: newNAV_ITEMSentry (to: '/offerings',roles: ['Owner', 'Administrator'],requiredModule: 'Offerings', an appropriatelucide-reacticon e.g.Package)i18nlocale 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 inbusiness-logic-model.md)
Step 15 — Frontend Tests
- Component/page tests mirroring
features/cms's existing.test.tsx/.test.tscoverage style (schema tests, hook tests with MSW-style mocks permocks/handlers.tspattern, component render/interaction tests)
Step 16 — Documentation
aidlc-docs/features/slpsoftware-api/construction/offerings/code/summary.md— summarizing everything generated across Steps 1-15- No root
README.mdchange 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
- 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)
- Backend:
dotnet buildfull solution,dotnet testforModules.Offerings.Tests(new) and the full solution (regression check), confirm ≥80% coverage on the new module - Frontend:
pnpm test(new Offerings tests + full suite regression),pnpm build(or equivalent typecheck/lint) to confirm no compile errors - 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.