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:
@@ -388,11 +388,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm build
|
pnpm build
|
||||||
mkdir -p ../src/SlpModularCms.Api/wwwroot/admin
|
mkdir -p ../src/SlpModularCms.Api.SlpSoftware/wwwroot/admin
|
||||||
cp -r dist/. ../src/SlpModularCms.Api/wwwroot/admin/
|
cp -r dist/. ../src/SlpModularCms.Api.SlpSoftware/wwwroot/admin/
|
||||||
|
|
||||||
- name: Publish (test)
|
- name: Publish (test)
|
||||||
working-directory: src/SlpModularCms.Api
|
working-directory: src/SlpModularCms.Api.SlpSoftware
|
||||||
run: >
|
run: >
|
||||||
dotnet publish -c Release -r ${{ env.PUBLISH_RID }} --self-contained false
|
dotnet publish -c Release -r ${{ env.PUBLISH_RID }} --self-contained false
|
||||||
-o ${{ github.workspace }}/${{ env.ARTIFACT_NAME_TEST }}
|
-o ${{ github.workspace }}/${{ env.ARTIFACT_NAME_TEST }}
|
||||||
@@ -457,11 +457,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm build
|
pnpm build
|
||||||
mkdir -p ../src/SlpModularCms.Api/wwwroot/admin
|
mkdir -p ../src/SlpModularCms.Api.SlpSoftware/wwwroot/admin
|
||||||
cp -r dist/. ../src/SlpModularCms.Api/wwwroot/admin/
|
cp -r dist/. ../src/SlpModularCms.Api.SlpSoftware/wwwroot/admin/
|
||||||
|
|
||||||
- name: Publish (production)
|
- name: Publish (production)
|
||||||
working-directory: src/SlpModularCms.Api
|
working-directory: src/SlpModularCms.Api.SlpSoftware
|
||||||
run: >
|
run: >
|
||||||
dotnet publish -c Release -r ${{ env.PUBLISH_RID }} --self-contained false
|
dotnet publish -c Release -r ${{ env.PUBLISH_RID }} --self-contained false
|
||||||
-o ${{ github.workspace }}/${{ env.ARTIFACT_NAME_PRODUCTION }}
|
-o ${{ github.workspace }}/${{ env.ARTIFACT_NAME_PRODUCTION }}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Een modulaire monolith CMS gebouwd met .NET 10.
|
|||||||
## Projectstructuur
|
## Projectstructuur
|
||||||
|
|
||||||
- `src/SlpModularCms.Api`: De host applicatie en API shell (lokale ontwikkeling / dev-host).
|
- `src/SlpModularCms.Api`: De host applicatie en API shell (lokale ontwikkeling / dev-host).
|
||||||
- `src/SlpModularCms.Api.SlpSoftware`: Tweede Client-project, bedoeld als de uiteindelijk gedeployde API voor `test.slpsoftware.nl`/`slpsoftware.nl`. Host dezelfde modules als `SlpModularCms.Api` via dezelfde gedeelde `CmsHost`-compositie (zie hieronder), plus (later) de `Offerings`-module. Heeft een eigen, geïsoleerde lokale ontwikkeldatabase — zie `appsettings.Development.json` in dat project.
|
- `src/SlpModularCms.Api.SlpSoftware`: Tweede Client-project, de daadwerkelijk gedeployde API voor `test.slpsoftware.nl`/`slpsoftware.nl` (CI/CD-cutover vanaf `SlpModularCms.Api`). Host dezelfde modules als `SlpModularCms.Api` via dezelfde gedeelde `CmsHost`-compositie (zie hieronder), plus de `Offerings`-module. Heeft een eigen, geïsoleerde lokale ontwikkeldatabase — zie `appsettings.Development.json` in dat project.
|
||||||
- `src/SlpModularCms.Core`: Kern functionaliteiten, data modellen en interfaces. Bevat ook `Core/Hosting/CmsHost.cs` — de gedeelde service- en pipeline-compositie die elk Client-project (`Api`, `Api.SlpSoftware`) vanuit zijn eigen dunne `Program.cs` aanroept, zodat beide projecten niet uit elkaar kunnen groeien.
|
- `src/SlpModularCms.Core`: Kern functionaliteiten, data modellen en interfaces. Bevat ook `Core/Hosting/CmsHost.cs` — de gedeelde service- en pipeline-compositie die elk Client-project (`Api`, `Api.SlpSoftware`) vanuit zijn eigen dunne `Program.cs` aanroept, zodat beide projecten niet uit elkaar kunnen groeien.
|
||||||
- `src/SlpModularCms.Modules.*`: Onafhankelijke functionele modules.
|
- `src/SlpModularCms.Modules.*`: Onafhankelijke functionele modules.
|
||||||
- `frontend/`: De CMS admin web-UI (Vite + React + TypeScript). Bewust buiten `src/` gehouden om de .NET solution schoon te houden.
|
- `frontend/`: De CMS admin web-UI (Vite + React + TypeScript). Bewust buiten `src/` gehouden om de .NET solution schoon te houden.
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SlpModularCms.Api.SlpSoftwa
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SlpModularCms.Api.Tests", "src\SlpModularCms.Api.Tests\SlpModularCms.Api.Tests.csproj", "{623A9526-9511-4B3E-957D-0D7E7E3D782B}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SlpModularCms.Api.Tests", "src\SlpModularCms.Api.Tests\SlpModularCms.Api.Tests.csproj", "{623A9526-9511-4B3E-957D-0D7E7E3D782B}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SlpModularCms.Modules.Offerings", "src\SlpModularCms.Modules.Offerings\SlpModularCms.Modules.Offerings.csproj", "{43DE02A5-DD13-42D1-9BF9-434E706B8500}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SlpModularCms.Modules.Offerings.Tests", "src\SlpModularCms.Modules.Offerings.Tests\SlpModularCms.Modules.Offerings.Tests.csproj", "{BF1054BD-DBD7-4D78-B99A-73FDC1362EA5}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -188,6 +192,30 @@ Global
|
|||||||
{623A9526-9511-4B3E-957D-0D7E7E3D782B}.Release|x64.Build.0 = Release|Any CPU
|
{623A9526-9511-4B3E-957D-0D7E7E3D782B}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{623A9526-9511-4B3E-957D-0D7E7E3D782B}.Release|x86.ActiveCfg = Release|Any CPU
|
{623A9526-9511-4B3E-957D-0D7E7E3D782B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{623A9526-9511-4B3E-957D-0D7E7E3D782B}.Release|x86.Build.0 = Release|Any CPU
|
{623A9526-9511-4B3E-957D-0D7E7E3D782B}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{43DE02A5-DD13-42D1-9BF9-434E706B8500}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{43DE02A5-DD13-42D1-9BF9-434E706B8500}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{43DE02A5-DD13-42D1-9BF9-434E706B8500}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{43DE02A5-DD13-42D1-9BF9-434E706B8500}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{43DE02A5-DD13-42D1-9BF9-434E706B8500}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{43DE02A5-DD13-42D1-9BF9-434E706B8500}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{43DE02A5-DD13-42D1-9BF9-434E706B8500}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{43DE02A5-DD13-42D1-9BF9-434E706B8500}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{43DE02A5-DD13-42D1-9BF9-434E706B8500}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{43DE02A5-DD13-42D1-9BF9-434E706B8500}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{43DE02A5-DD13-42D1-9BF9-434E706B8500}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{43DE02A5-DD13-42D1-9BF9-434E706B8500}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{BF1054BD-DBD7-4D78-B99A-73FDC1362EA5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{BF1054BD-DBD7-4D78-B99A-73FDC1362EA5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{BF1054BD-DBD7-4D78-B99A-73FDC1362EA5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{BF1054BD-DBD7-4D78-B99A-73FDC1362EA5}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{BF1054BD-DBD7-4D78-B99A-73FDC1362EA5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{BF1054BD-DBD7-4D78-B99A-73FDC1362EA5}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{BF1054BD-DBD7-4D78-B99A-73FDC1362EA5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{BF1054BD-DBD7-4D78-B99A-73FDC1362EA5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{BF1054BD-DBD7-4D78-B99A-73FDC1362EA5}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{BF1054BD-DBD7-4D78-B99A-73FDC1362EA5}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{BF1054BD-DBD7-4D78-B99A-73FDC1362EA5}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{BF1054BD-DBD7-4D78-B99A-73FDC1362EA5}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -207,5 +235,7 @@ Global
|
|||||||
{BCBB1ABB-6C22-4F81-BB06-D4FE81B4BED3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{BCBB1ABB-6C22-4F81-BB06-D4FE81B4BED3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{9843F0E6-6FC2-4774-B2CD-8B99AB926149} = {D72703E6-B021-4360-B1EE-0E99999B5899}
|
{9843F0E6-6FC2-4774-B2CD-8B99AB926149} = {D72703E6-B021-4360-B1EE-0E99999B5899}
|
||||||
{623A9526-9511-4B3E-957D-0D7E7E3D782B} = {2F43D186-C7D5-4AB1-B821-4D595CA2ECB3}
|
{623A9526-9511-4B3E-957D-0D7E7E3D782B} = {2F43D186-C7D5-4AB1-B821-4D595CA2ECB3}
|
||||||
|
{43DE02A5-DD13-42D1-9BF9-434E706B8500} = {30D8F44D-4B6E-4980-8D1F-29D1A64F438C}
|
||||||
|
{BF1054BD-DBD7-4D78-B99A-73FDC1362EA5} = {77DF6642-7863-4D67-BAEE-217EC2D99894}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
+11
-1
@@ -242,6 +242,16 @@ so keeping the two in sync is a manual discipline, not something enforced automa
|
|||||||
|
|
||||||
### 1.6 systemd User Units
|
### 1.6 systemd User Units
|
||||||
|
|
||||||
|
**Updated for the `slpsoftware-api` feature's D-15 cutover**: the deployed executable is
|
||||||
|
`SlpModularCms.Api.SlpSoftware.dll`, not `SlpModularCms.Api.dll` — the pipeline's build/deploy
|
||||||
|
target switched, one project replacing the other, not running side by side (D-7/D-15). This is a
|
||||||
|
**host-side change only this document describes** — `deploy-scp.yaml` never creates or edits a
|
||||||
|
systemd unit file (§ 1, "everything here is host configuration the workflow assumes already
|
||||||
|
exists"), so if these two units already exist on the Pi from before this cutover, **you must edit
|
||||||
|
`ExecStart` in both by hand** (`sudo -u gitea-workflow $EDITOR ~/.config/systemd/user/slpsoftware-*.service`
|
||||||
|
in a real login shell — § 1.3), then `systemctl --user daemon-reload` and restart both services. A
|
||||||
|
fresh, first-time setup can just use the corrected filename below directly.
|
||||||
|
|
||||||
👤 **pi-main / `gitea-workflow`** — create `~/.config/systemd/user/slpsoftware-test.service`:
|
👤 **pi-main / `gitea-workflow`** — create `~/.config/systemd/user/slpsoftware-test.service`:
|
||||||
```ini
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
@@ -250,7 +260,7 @@ After=network.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
WorkingDirectory=%h/apps/slpsoftware/test/current
|
WorkingDirectory=%h/apps/slpsoftware/test/current
|
||||||
ExecStart=/usr/bin/dotnet %h/apps/slpsoftware/test/current/SlpModularCms.Api.dll
|
ExecStart=/usr/bin/dotnet %h/apps/slpsoftware/test/current/SlpModularCms.Api.SlpSoftware.dll
|
||||||
EnvironmentFile=%h/apps/slpsoftware/test/shared/env
|
EnvironmentFile=%h/apps/slpsoftware/test/shared/env
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
- **Feature Slug**: slpsoftware-api
|
- **Feature Slug**: slpsoftware-api
|
||||||
- **Project Type**: Brownfield
|
- **Project Type**: Brownfield
|
||||||
- **Start Date**: 2026-08-01T00:00:00Z
|
- **Start Date**: 2026-08-01T00:00:00Z
|
||||||
- **Current Stage**: CONSTRUCTION - Unit 1 "SlpSoftware Client Setup" - Code Generation complete
|
- **Current Stage**: CONSTRUCTION complete — both units done, feature-wide Build and Test green; ready for OPERATIONS phase
|
||||||
- **Branch**: master (no feature branch created yet)
|
- **Branch**: feature/slpsoftware-api (PR #9 open against master)
|
||||||
|
|
||||||
## Workspace State
|
## Workspace State
|
||||||
- **Existing Code**: Yes
|
- **Existing Code**: Yes
|
||||||
@@ -71,25 +71,25 @@
|
|||||||
- [x] NFR Requirements — Complete (new pipeline-level regression tests required; `CmsHostOptions` added, must never touch the Data Protection discriminator)
|
- [x] NFR Requirements — Complete (new pipeline-level regression tests required; `CmsHostOptions` added, must never touch the Data Protection discriminator)
|
||||||
- [x] NFR Design — Complete (regression tests scoped to `Api` only; `CmsHostOptions` stays an empty placeholder for now)
|
- [x] NFR Design — Complete (regression tests scoped to `Api` only; `CmsHostOptions` stays an empty placeholder for now)
|
||||||
- [x] Infrastructure Design — Complete (cutover target documented: same Pi/systemd units/ports/DB, only the deployed `.dll` changes at Operations time; `Api.SlpSoftware` gets its own isolated local dev database)
|
- [x] Infrastructure Design — Complete (cutover target documented: same Pi/systemd units/ports/DB, only the deployed `.dll` changes at Operations time; `Api.SlpSoftware` gets its own isolated local dev database)
|
||||||
- [x] Code Generation — Complete (build succeeded after 2 fixes; Core.Tests 196/196, new Api.Tests 4/4)
|
- [x] Code Generation — Complete and **approved** (build succeeded after 2 fixes; CI green after a further CI-only MariaDB fix; 372/372 tests passing across the whole solution)
|
||||||
|
|
||||||
#### Unit 2: Offerings
|
#### Unit 2: Offerings
|
||||||
- [ ] Functional Design
|
- [x] Functional Design — Complete (drag-and-drop + buttons for reorder; separate create/edit pages not modals; validation bounds set; delete confirmation; featured toggle available from both the form and the list row)
|
||||||
- [ ] NFR Requirements
|
- [x] NFR Requirements — Complete (rate limiting on public GET only via new `offerings-public` policy; no HTTP caching; `LastModifiedByUserId` closes SECURITY-13's "who"; ≥80% coverage standard, same as `master-cms-module`)
|
||||||
- [ ] NFR Design
|
- [x] NFR Design — Complete (multi-row operations transactional per-operation; rate-limiting attribute on public GET action only, following `AuthController` precedent; structured audit-log fields defined)
|
||||||
- [ ] Infrastructure Design
|
- [x] Infrastructure Design — **SKIPPED** (reuses existing MariaDB / per-module-migration infrastructure, nothing new to map — matches the preliminary assessment in `unit-of-work.md`; confirmed at this stage, no new deployment target/ports/services introduced)
|
||||||
- [ ] Code Generation
|
- [x] Code Generation — Complete (backend: full `Modules.Offerings` module, 38 new tests, 414/414 solution-wide green; frontend: full `features/offerings` feature, 43 new tests, 254/254 suite-wide green, build+lint clean)
|
||||||
|
|
||||||
#### Feature-wide
|
#### Feature-wide
|
||||||
- [ ] Build and Test — EXECUTE (ALWAYS, after both units complete)
|
- [x] Build and Test — Complete. Full solution: build succeeded, 414/414 tests passing. Frontend: `pnpm build` succeeded, `pnpm lint` clean, `pnpm test` 254/254 passing. No regressions.
|
||||||
|
|
||||||
### 🟡 OPERATIONS PHASE
|
### 🟡 OPERATIONS PHASE
|
||||||
- [ ] Deployment Setup — EXECUTE
|
- [x] Deployment Setup — Complete. `continuous_integration.yaml` retargeted to `Api.SlpSoftware` (D-15 cutover); `deploy-scp.yaml`/`rollback-plan.md` needed no changes; `deployment-instructions.md` § 1.6 updated with an explicit manual host-side action item for the user (edit the Pi's existing systemd units). No nginx/database/Gitea-variable changes (D-6).
|
||||||
- [ ] Monitoring Setup — EXECUTE
|
- [ ] Monitoring Setup — EXECUTE
|
||||||
- [ ] Production Readiness Validation — EXECUTE (ALWAYS, once phase reached)
|
- [ ] Production Readiness Validation — EXECUTE (ALWAYS, once phase reached)
|
||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
- **Lifecycle Phase**: CONSTRUCTION
|
- **Lifecycle Phase**: OPERATIONS
|
||||||
- **Current Stage**: Unit 1 "SlpSoftware Client Setup" — Code Generation complete
|
- **Current Stage**: Deployment Setup — Complete (repo-side changes done; user still needs to manually update the Pi's two systemd unit files)
|
||||||
- **Next Stage**: Unit 2 "Offerings" (Functional Design → NFR Requirements → NFR Design → Infrastructure Design → Code Generation), then feature-wide Build and Test
|
- **Next Stage**: Monitoring Setup, then Production Readiness Validation
|
||||||
- **Status**: Ready to proceed
|
- **Status**: Awaiting user approval to proceed
|
||||||
|
|||||||
@@ -575,3 +575,327 @@
|
|||||||
**Context**: Real bug fix, and a boundary call: `.gitea/workflows/continuous_integration.yaml` is nominally owned by the `gitea-deployment-workflow` feature, but this specific change (making the `backend-test` gate pass for a test project this feature introduced) is Construction/Build-and-Test correctness for `slpsoftware-api`, not a deploy-target retarget (D-15, still deferred to Operations) -- judged in-scope to fix directly rather than blocking the PR on a separate feature's process.
|
**Context**: Real bug fix, and a boundary call: `.gitea/workflows/continuous_integration.yaml` is nominally owned by the `gitea-deployment-workflow` feature, but this specific change (making the `backend-test` gate pass for a test project this feature introduced) is Construction/Build-and-Test correctness for `slpsoftware-api`, not a deploy-target retarget (D-15, still deferred to Operations) -- judged in-scope to fix directly rather than blocking the PR on a separate feature's process.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## CI Fix, Round 2: `services:` Block Didn't Actually Work — Diagnosed From Real Logs, Not Re-Guessed
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T12:15:00Z
|
||||||
|
|
||||||
|
**User Input**: N/A — proactively monitored the pushed fix's CI run via the Gitea API (polling with scheduled wakeups) rather than waiting to be asked.
|
||||||
|
|
||||||
|
**AI Response**: "The `services:` block fix from the previous entry did NOT actually work — `backend-test` failed again with the exact same 'Access denied ... using password: YES' error. Rather than re-guessing, fetched the real job log via the Gitea API (`/repos/.../actions/jobs/{id}/logs`) and found the actual cause: this runner (`raspberry-pi-arm64`, self-hosted) runs job AND service containers in Docker host-network mode ('--network and --net in the options will be ignored' appears verbatim in the log), so the `ports: - 3306:3306` mapping was silently ignored — the mariadb:11 service ended up bound directly to the host's own port 3306, which something else on this runner already answers on, rejecting our password because it was never the container we just started. Replaced the `services:` block with an explicit `docker run` step publishing on host port 3307 instead (avoiding the collision entirely) plus a readiness-wait loop, and updated the connection string env var to match. Verified the whole thing locally before pushing again: first attempt at the readiness check used `mysqladmin ping`, which doesn't exist in the `mariadb:11` image (confirmed via `podman exec` — only `mariadb-admin` and `healthcheck.sh` are present); fixed the command name, then confirmed all 4 `Api.Tests` pass end-to-end against a container on port 3307 exactly as CI will use it, before committing."
|
||||||
|
|
||||||
|
**Context**: Second-round CI fix, verified against real job logs and a local reproduction rather than trusting the first, unverified guess. Awaiting the user pushing this commit so CI reruns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI Fix Confirmed Green — Unit 1 Code Generation Approved
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T12:10:00Z
|
||||||
|
|
||||||
|
**User Input**: "alles slaagt dus ga verder" (after monitoring the Gitea Actions run for commit b6e9c07 to completion via the API: `backend-test` succeeded in 7m35s, all 372 tests passed — 82 Availability, 57 Master, 196 Core, 37 Identity, 4 new Api.Tests)
|
||||||
|
|
||||||
|
**AI Response**: "Unit 1 'SlpSoftware Client Setup' Code Generation approved, CI green on the feature branch. Proceeding to Unit 2 'Offerings'."
|
||||||
|
|
||||||
|
**Context**: Construction, Unit 1 Code Generation stage complete and approved (including the CI fix round-trip). Proceeding to Unit 2 'Offerings' per-unit loop, starting with Functional Design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Construction — Unit 2 "Offerings" Functional Design: Plan Created
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T12:20:00Z
|
||||||
|
|
||||||
|
**User Input**: N/A (no new user input this step; proceeding per approved workflow)
|
||||||
|
|
||||||
|
**AI Response**: "Investigated before drafting questions: read the existing `frontend/src/features/cms/` folder (Master-module admin UI) as the closest structural precedent for an admin list+CRUD screen in this codebase, and checked `frontend/package.json` for a drag-and-drop library — none exists, meaning US-08 (drag-and-drop reorder) would introduce a genuinely new frontend dependency, not something already available. Created `construction/plans/offerings-functional-design-plan.md` (checklist only, per functional-design.md's rule that plans and questions live in separate files) and a dedicated `construction/plans/offerings-fd-questions.md` with 5 questions: whether to add a new drag-and-drop dependency now or ship button-based reorder only for v1, whether to follow the existing cms-feature's list+modal page pattern, concrete validation length bounds for SECURITY-05 (none had been set numerically yet), whether to confirm before delete given soft-delete has no exposed restore UI, and the UI trigger for the featured flag (form toggle vs. list-row action)."
|
||||||
|
|
||||||
|
**Context**: Construction, Unit 2, Functional Design stage, Steps 1-5. Awaiting user answers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Construction — Unit 2 Functional Design: Answers Received
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T12:30:00Z
|
||||||
|
|
||||||
|
**User Input**: "ik heb de vragen beantwoord" (offerings-fd-questions.md: Q1=A both drag-and-drop and buttons; Q2=X custom — separate pages/routes (effectively option B), reasoning that the CMS is master-only tooling not meant for reuse across other sites so cross-feature UI consistency matters less; Q3=A validation bounds as proposed; Q4=A confirm before delete; Q5="kan A en B beide?" — can it be both A and B)
|
||||||
|
|
||||||
|
**AI Response**: "Q2 and Q5 needed resolution, not re-asking. Q2's custom answer is a clear, actionable decision (separate create/edit pages, not modals) with stated reasoning — no ambiguity. Q5 is a direct question back, not a vague answer — resolved it: yes, both can coexist, because the 'featured' flag is just a field on the existing Create/Update flow (per application-design/component-methods.md), so a quick star-icon action in the list row can reuse the same Update call with the row's already-loaded data (only the Featured field changed) — no new backend endpoint or business logic needed, the exclusivity enforcement in the service layer already covers either UI entry point identically. Proceeding to generate all 4 Functional Design artifacts with these 5 resolved decisions."
|
||||||
|
|
||||||
|
**Context**: Construction, Unit 2, Functional Design stage, Step 6-7. No follow-up questions needed. Proceeding to artifact generation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Construction — Unit 2 Functional Design: Artifacts Generated
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T12:40:00Z
|
||||||
|
|
||||||
|
**User Input**: N/A (no new user input this step; proceeding per approved plan)
|
||||||
|
|
||||||
|
**AI Response**: "Generated `domain-entities.md` (Offering entity with 12 fields incl. added CreatedAt/UpdatedAt audit timestamps, partially closing the SECURITY-13 open item from requirements.md; two DTO projections), `business-rules.md` (4 Mermaid decision-flow rules: featured exclusivity, soft-delete-never-blocked, reorder boundaries, field validation), `business-logic-model.md` (5 sequence diagrams: create/update, delete-with-confirmation, drag-and-drop reorder, button reorder, list-row featured toggle), and `frontend-components.md` (new `features/offerings/` folder mirroring the existing `features/cms/` pattern, component hierarchy diagram, per-component props/state/API calls, service hooks table). Marked all 5 plan steps complete. Updated aidlc-state.md."
|
||||||
|
|
||||||
|
**Context**: Construction, Unit 2, Functional Design stage, Step 8. Presenting standardized 2-option completion message; awaiting approval before NFR Requirements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Diagram Readability Fix (Round 3) + Frontend Terminology Fix
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T12:45:00Z
|
||||||
|
|
||||||
|
**User Input**: "De mermaid-diagrammen waren altijd zo goed. Nu maak je weer diagrammen die slecht leesbaar zijn... Zorg dat die bij lichte kleuren donkere teksten hebben en bij donkere achtergronden lichte teksten hebben" + (mid-turn) "in `frontend-components.md` staan Services, maar zijn dat geen hooks die ik daar zie staan zoals useOfferings()?"
|
||||||
|
|
||||||
|
**AI Response**: "New hypothesis, more surgical than the previous attempt: the earlier deployment-architecture.md fix round had reverted a `themeVariables` init directive entirely because it broke arrow visibility, but that break most likely came specifically from the `lineColor` override, not the text-color variables. Re-added a `%%{init: {'themeVariables': {...}}}%%` directive to every `graph TD`/`graph LR` diagram in this feature's docs, this time setting ONLY `primaryTextColor`/`textColor`/`tertiaryTextColor` to black and deliberately NOT touching `lineColor` — applied to `business-rules.md` (4 diagrams), `domain-entities.md`, `frontend-components.md`, `unit-of-work-dependency.md`, and `deployment-architecture.md` (2 diagrams, which already had `linkStyle` for arrows from the earlier round — left untouched). Also bumped every classDef to `stroke-width:2px` + `font-weight:bold` for extra contrast. Separately, fixed the 'Services' section heading in `frontend-components.md` — the user correctly noted the listed items are React Query hooks (`useOfferings()` etc.), not services; clarified the heading references the existing codebase's folder-naming convention (`features/cms/services/` already holds hooks, e.g. `useCmsInstances.ts`) while being explicit that the contents are hooks."
|
||||||
|
|
||||||
|
**Context**: Diagram-rendering fix (experimental, not independently visually verified) applied consistently across all graph-type diagrams in this feature to date, plus a documentation terminology correction. Returning to the Unit 2 Functional Design completion gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Diagram Readability Fix (Round 4) + Frontend Folder Convention Clarified
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T12:55:00Z
|
||||||
|
|
||||||
|
**User Input**: Shared a screenshot of the rendered `domain-entities.md` Entity Relationships diagram — node fills (light blue/orange) rendered correctly, but node label text rendered in a washed-out, low-contrast pale color despite `color:#000000` in classDef and the round-3 theme-init fix; edge labels (dark boxes with white text) rendered fine. Also, mid-turn: "Verder dacht ik dat we voor de front-end hadden gezegd dat hooks ook gewoon in de hooks map moest komen. Services zijn anders."
|
||||||
|
|
||||||
|
**AI Response**: "The screenshot proves `fill`/`stroke` from classDef ARE respected by this renderer, but `color` is not — for either mechanism tried so far (classDef color property, themeVariables init). Rather than tweak the same losing mechanism a third time, switched to a fundamentally different, lower-risk approach: white (`#ffffff`) node fill with a thick (3px) colored border for category differentiation instead of colored fill — black bold text on pure white background is far less likely to be overridden into unreadable territory than black text on a pastel background, regardless of whatever theme mechanism is suppressing classDef's `color`. Applied consistently via `sed` across every classDef in `business-rules.md` (4 diagrams), `domain-entities.md`, `frontend-components.md`, `unit-of-work-dependency.md`, and `deployment-architecture.md` (2 diagrams) — verified via grep that no `fill:#ffffff` mismatches remain. Separately, investigated the frontend hooks/services question directly: listed every existing `features/*` folder — all 8 (auth, availability, cms, invitation, profile, setup, system, users) use `services/` for their feature-specific React Query hooks, none has a `hooks/` subfolder; a single top-level `frontend/src/hooks/` exists for generic, cross-feature hooks (currently just `useTheme.ts`). Concluded `frontend-components.md`'s use of `services/` for the Offerings feature's data-fetching hooks is consistent with the actual, current, universal convention — explained this distinction to the user rather than changing anything, since changing it would break from the established pattern."
|
||||||
|
|
||||||
|
**Context**: Fourth diagram-rendering fix attempt (different strategy: white fill + border-only color-coding), plus a frontend-folder-convention clarification backed by directly inspecting every existing feature folder. Awaiting user confirmation on diagram readability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Diagrams Reverted; Hooks/Services Distinction Refined
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T13:05:00Z
|
||||||
|
|
||||||
|
**User Input**: "nee het is nu afgrijselijk! Laat de kleur van de blokken met rust... hoe het was was het beter, maar de tekst is gewoon te licht en altijd grijs" — then, separately: "over de hooks en services. features kunnen ook eigen hooks hebben. de algemene hooks folder is niet de enige mogelijkheid. in theorie zou alles wat in een feature zit ook als gedeelde/common object kunnen worden gebruikt"
|
||||||
|
|
||||||
|
**AI Response**: "Reverted every classDef in business-rules.md, domain-entities.md, frontend-components.md, unit-of-work-dependency.md, and deployment-architecture.md from the white-fill experiment back to the exact prior pastel fill/stroke/width values via targeted sed per stroke-color mapping, verified via grep that no `fill:#ffffff` remained. Acknowledged plainly that none of the three attempted fixes (classDef color, theme-init, white fill) demonstrably fixed the actual gray-text problem, and asked which rendering tool the user uses rather than guessing a fourth cosmetic variant blindly. Separately, refined `frontend-components.md` per the user's correction: split the 'Hooks' section into `services/` (API-calling hooks, existing convention) and a new `hooks/` folder for feature-local non-API hooks — extracted the previously-inline dnd-kit drag-and-drop orchestration out of `OfferingsList` into a new `useOfferingsDnd` hook (`hooks/useOfferingsDnd.ts`), added it to the component hierarchy diagram, and rewrote the explanatory note to state that a feature's own `hooks/` folder is a legitimate option independent of both `services/` and the top-level `frontend/src/hooks/` — not a two-way 'shared vs. feature-specific' split."
|
||||||
|
|
||||||
|
**Context**: Diagrams reverted to the pre-white-fill state pending the user's answer on their rendering tool. Frontend hook-location design refined per user feedback. Returning to the Unit 2 Functional Design completion gate once the user confirms.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mermaid Rendering Root Cause Confirmed (External Tool Bug) + Services/Hooks Naming Deferred
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T13:15:00Z
|
||||||
|
|
||||||
|
**User Input**: "Ik gebruik Rider met de mermaid extensie" — plus, separately, chose "Hernoem overal naar hooks (apart traject)" in response to an AskUserQuestion about the services/hooks folder-naming scope.
|
||||||
|
|
||||||
|
**AI Response**: "Searched the web for known Rider/JetBrains Mermaid dark-theme text-color issues rather than guessing a fifth fix. Found confirmed, documented JetBrains YouTrack bugs (IJPL-97749, MERMAID-154, MERMAID-158): JetBrains' bundled Mermaid preview auto-brightens node text specifically in dark theme, overriding classDef color regardless of what the diagram source specifies — exactly matching the symptom (fill correct, text washed out). This is an external rendering bug, not fixable from the Mermaid source; stopped attempting further diagram-source color tweaks and suggested practical workarounds instead (light theme for the preview pane, or an external renderer like mermaid.live/Gitea's web preview). Also recorded the user's decision on the services/hooks folder naming: renaming `services/` to `hooks/` project-wide (all 8 existing feature folders) is explicitly deferred to a separate, later refactor track, not bundled into this feature — Offerings keeps using `services/` for its API-calling hooks for consistency with the current convention in the meantime. Saved both as project memories (`project_frontend_services_hooks_rename.md` already covers the deferred rename; the Rider bug finding is now on record here rather than as a separate memory, since it's a one-time diagnostic fact rather than an ongoing project fact)."
|
||||||
|
|
||||||
|
**Context**: Diagram-source styling accepted as correct; the remaining readability gap is an external JetBrains bug, out of this session's control. Returning to the Unit 2 Functional Design completion gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Construction — Unit 2 Functional Design Approved
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T13:20:00Z
|
||||||
|
|
||||||
|
**User Input**: "Continue to Next Stage"
|
||||||
|
|
||||||
|
**AI Response**: "Functional Design approved for Unit 2. Proceeding to NFR Requirements."
|
||||||
|
|
||||||
|
**Context**: Construction, Unit 2, Functional Design stage complete. Proceeding to NFR Requirements stage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Construction — Unit 2 NFR Requirements: Plan Created
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T13:25:00Z
|
||||||
|
|
||||||
|
**User Input**: N/A (no new user input this step; proceeding per approved workflow)
|
||||||
|
|
||||||
|
**AI Response**: "Investigated before drafting: read `SecurityEvents.cs` and confirmed it's purpose-built for alertable anomalies (Warning-level, feeds Sentry alert rules per SECURITY-14) — decided a routine content-mutation event does not belong there (would pollute the alerting mechanism), so SECURITY-13's audit trail is better served by plain Information-level structured logging, not by extending that class. Also confirmed `AddCmsRateLimiting` already has an established per-endpoint named-policy pattern (`login`, `refresh`, `sentry-tunnel`) that a new `offerings` policy would follow identically. Created `construction/plans/offerings-nfr-requirements-plan.md` with 4 questions: which endpoints need rate limiting, whether to add HTTP caching headers to the public endpoint (explicitly invited by the hand-off doc), whether to close the remaining 'who' half of the SECURITY-13 open item with a `LastModifiedByUserId` field, and whether the existing ≥80% test coverage standard (from `master-cms-module`'s NFR-MASTER-05) applies here too."
|
||||||
|
|
||||||
|
**Context**: Construction, Unit 2, NFR Requirements stage, Steps 1-5. Awaiting user answers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Construction — Unit 2 NFR Requirements: Answers Received
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T13:30:00Z
|
||||||
|
|
||||||
|
**User Input**: "ik heb de vragen beantwoord" (offerings-nfr-requirements-plan.md: Q1=A rate limit public GET only; Q2=B no HTTP caching; Q3=A add LastModifiedByUserId; Q4=A same ≥80% coverage standard)
|
||||||
|
|
||||||
|
**AI Response**: "All 4 answers unambiguous. Proceeding to generate nfr-requirements.md and tech-stack-decisions.md."
|
||||||
|
|
||||||
|
**Context**: Construction, Unit 2, NFR Requirements stage, Step 6-7. Proceeding to artifact generation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Construction — Unit 2 NFR Requirements: Artifacts Generated
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T13:35:00Z
|
||||||
|
|
||||||
|
**User Input**: N/A (no new user input this step; proceeding per approved workflow)
|
||||||
|
|
||||||
|
**AI Response**: "Created `nfr-requirements.md` (NFR-OFF-01 rate limiting via new `offerings-public` policy on the public GET endpoint only; NFR-OFF-02 no HTTP caching, deliberate; NFR-OFF-03 `LastModifiedByUserId` closes SECURITY-13's 'who', with the `SecurityEvents` extension explicitly rejected and the reasoning documented; NFR-OFF-04 ≥80% coverage standard) and `tech-stack-decisions.md` (no new backend technology; `offerings-public` policy added to `AddCmsRateLimiting` via a new `RateLimiting:OfferingsPublic` config section, following the `Login`/`Refresh`/`SentryTunnel` naming convention). Also updated `domain-entities.md` earlier in this stage to add `LastModifiedByUserId` per Q3=A. Marked both checklist steps complete in `offerings-nfr-requirements-plan.md`, updated `aidlc-state.md` (Unit 2 NFR Requirements → Complete, Current Status → NFR Design next)."
|
||||||
|
|
||||||
|
**Context**: Construction, Unit 2, NFR Requirements stage, Step 8. Presenting completion gate; awaiting user approval to proceed to NFR Design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Construction — Unit 2 NFR Design: Plan Created
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T13:40:00Z
|
||||||
|
|
||||||
|
**User Input**: "Continue to Next Stage"
|
||||||
|
|
||||||
|
**AI Response**: "NFR Requirements approved. Investigated before drafting: confirmed rate-limiting attribute placement has a direct precedent (`[EnableRateLimiting(\"login\")]`/`[EnableRateLimiting(\"refresh\")]` on individual action methods in `AuthController`), so `[EnableRateLimiting(\"offerings-public\")]` on just the public GET action is decided directly, no question needed. Scalability/Performance categories marked N/A with rationale (small dataset, no caching, simple indexed queries). Found one genuine open item: `services.md` explicitly deferred the transactional-boundary decision for multi-row operations (featured-exclusivity swap, full reorder, adjacent swap) to Functional Design, but neither `business-logic-model.md` nor `business-rules.md` actually decided it — raised as Question 1 in `offerings-nfr-design-plan.md` rather than deciding silently, since it's a real data-integrity trade-off (single DB transaction vs. sequential saves)."
|
||||||
|
|
||||||
|
**Context**: Construction, Unit 2, NFR Design stage, Steps 1-5. Awaiting user answer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Construction — Unit 2 NFR Design: Artifacts Generated
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T13:45:00Z
|
||||||
|
|
||||||
|
**User Input**: "ik heb de vraag beantwoord" (offerings-nfr-design-plan.md: Q1=A single DB transaction per multi-row operation)
|
||||||
|
|
||||||
|
**AI Response**: "Created `nfr-design-patterns.md` (Pattern 1: explicit EF Core transaction wrapping `CreateAsync`/`UpdateAsync` featured-transitions, `ReorderAsync`, `MoveUpAsync`/`MoveDownAsync`; Pattern 2: `[EnableRateLimiting(\"offerings-public\")]` on the public GET action only; Pattern 3: structured `LogInformation` fields — `OfferingId`/`Action`/`LastModifiedByUserId`) and `logical-components.md` (transactional service methods, rate-limiting policy attachment, audit logging — all extensions of existing components, no new classes). Marked both checklist steps complete in `offerings-nfr-design-plan.md`, updated `aidlc-state.md` (Unit 2 NFR Design → Complete, Current Status → Infrastructure Design next)."
|
||||||
|
|
||||||
|
**Context**: Construction, Unit 2, NFR Design stage, Step 6. Presenting completion gate; awaiting user approval to proceed to Infrastructure Design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Construction — Unit 2 Infrastructure Design: Skipped
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T13:50:00Z
|
||||||
|
|
||||||
|
**User Input**: "Continue to Next Stage"
|
||||||
|
|
||||||
|
**AI Response**: "NFR Design approved. Assessed Infrastructure Design for Unit 2 against the preliminary assessment in `unit-of-work.md` ('likely SKIP — reuses the existing MariaDB / per-module-migration infrastructure with nothing new to map'): confirmed at this stage rather than blindly trusting the earlier prediction — `OfferingsDbContext` uses the same MariaDB instance/connection string as every other module (no new server, port, or deployment target), and its migrations follow the exact per-module pattern already established by `MasterDbContext`/`AvailabilityDbContext`. Skipped, logged rationale, proceeded directly to Code Generation planning."
|
||||||
|
|
||||||
|
**Context**: Construction, Unit 2, Infrastructure Design stage. Skipped with rationale logged (matches the precedent set for Unit 1's Functional Design skip — no full completion message for a stage that didn't run). Proceeding to Code Generation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Construction — Unit 2 Code Generation: Plan Created (Planning)
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T13:55:00Z
|
||||||
|
|
||||||
|
**User Input**: N/A (no new user input this step; proceeding per approved workflow)
|
||||||
|
|
||||||
|
**AI Response**: "Investigated the actual codebase before drafting the plan rather than assuming: read `MasterModule.cs`/`CmsInstanceRepository.cs`/`CmsInstanceService.cs`/`CmsInstanceController.cs`/`UsersController.cs`/`MasterDbContext.cs`/`AvailabilityDbContext.cs` for backend conventions (table-name prefixing per module, repository/service/controller shapes, how caller identity is resolved via claims), and `router.tsx`/`Sidebar.tsx`/`ModuleGuard.tsx`/`useCmsInstances.ts`/`useAddCmsInstance.ts`/`cms.ts` (schemas)/`api-client.ts` for frontend conventions (TanStack Router route objects with RoleGuard/ModuleGuard, the services/ hook shape, zod schema shape). Confirmed no existing DataAnnotations/FluentValidation precedent, no existing transaction-wrapping precedent (this unit introduces both, per NFR Design), and no dnd-kit dependency yet (per Functional Design Q1=A, to be added). Created `construction/plans/offerings-code-generation-plan.md` with an 18-step checklist covering: domain layer + DbContext (table `OfferingsOfferings`, JSON-mapped `Features`, soft-delete query filter), repository, transactional service, DataAnnotations-validated models + a custom features-length validator, controller (route/auth table per `component-methods.md`), module registration, the new `offerings-public` rate-limiting policy in Core's `AddCmsRateLimiting`, project/solution wiring, backend tests, and the full frontend feature (dnd-kit dependency, schema, services/ hooks, feature-local hooks/, components, pages, routing/nav/i18n, frontend tests), plus documentation and the always-execute Build and Test verification step."
|
||||||
|
|
||||||
|
**Context**: Construction, Unit 2, Code Generation stage, Planning phase. Presenting plan for approval before Generation begins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Construction — Unit 2 Code Generation Plan Approved
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T14:00:00Z
|
||||||
|
|
||||||
|
**User Input**: "Approve Plan"
|
||||||
|
|
||||||
|
**AI Response**: "Proceeding to Generation: `offerings-code-generation-plan.md` Steps 1-18, in order (backend first, then frontend, then build/test verification)."
|
||||||
|
|
||||||
|
**Context**: Construction, Unit 2, Code Generation stage, Part 1 complete. Proceeding to Part 2 Generation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Construction — Unit 2 Code Generation: Backend Complete (Steps 1-9)
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T15:30:00Z
|
||||||
|
|
||||||
|
**User Input**: N/A (no new user input this step; proceeding per approved workflow)
|
||||||
|
|
||||||
|
**AI Response**: "Generated the full `SlpModularCms.Modules.Offerings` backend: `Offering` entity + `OfferingsDbContext` (table `OfferingsOfferings`, JSON-converted `Features` with a `ValueComparer`, `HasQueryFilter` for soft delete), `IOfferingRepository`/`OfferingRepository` (incl. `GetPreviousAsync`/`GetNextAsync` neighbor lookups and a `BeginTransactionAsync` wrapper), `IOfferingsService`/`OfferingsService` (BR-OFF-01..04, explicit transactions per NFR Design Pattern 1, `LastModifiedByUserId`/audit logging per NFR-OFF-03), DTOs/requests with DataAnnotations + a custom `FeaturesValidationAttribute`, `OfferingsController` (route/auth table exactly per `component-methods.md`), `OfferingsModule`, the `offerings-public` rate-limiting policy in Core's `AddCmsRateLimiting` plus its `appsettings.json` section, project/solution wiring, and the EF Core `InitialCreate` migration (generated via `dotnet ef migrations add --project Modules.Offerings --startup-project Api.SlpSoftware`). Wrote 38 new tests (repository via EF Core InMemory, service via NSubstitute, controller) covering all four business rules plus the soft-delete query filter. One bug caught and fixed during test-writing: controller tests failed with `ArgumentNullException` on `User.FindFirstValue` because a bare `new OfferingsController(service)` has no `ControllerContext`/`HttpContext.User` — fixed by giving the test's `CreateSut()` a `ClaimsPrincipal` with a `NameIdentifier` claim, mirroring `UsersControllerTests`' existing pattern. Full solution build succeeded; full backend test suite green (414/414 across all test projects, no regressions)."
|
||||||
|
|
||||||
|
**Context**: Construction, Unit 2, Code Generation stage, Steps 1-9 of `offerings-code-generation-plan.md` complete and verified (build + test green). Proceeding to the frontend (Steps 10-15).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Construction — Unit 2 Code Generation: Complete (Steps 10-18)
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T16:15:00Z
|
||||||
|
|
||||||
|
**User Input**: N/A (no new user input this step; proceeding per approved workflow)
|
||||||
|
|
||||||
|
**AI Response**: "Generated the full `frontend/src/features/offerings/` feature: added `@dnd-kit/core`/`@dnd-kit/sortable`/`@dnd-kit/utilities` (pnpm version mismatch between the global `pnpm` binary (10.33.2) and the store the repo's `node_modules` was linked with (v11) required running the install via `npx pnpm@11.18.0` instead — a local environment quirk, not a project change); `schemas/offering.ts` (zod, mirrors BR-OFF-04); all 7 `services/` API hooks plus `types.ts`; the feature-local `hooks/useOfferingsDnd.ts`; `OfferingsList`/`OfferingRow`/`DeleteOfferingDialog`/`OfferingForm` components and `OfferingsListPage`/`OfferingFormPage`; three routes in `router.tsx` (list/new/edit) behind `RoleGuard`+`ModuleGuard(\"Offerings\")`; a `Sidebar.tsx` nav entry; full `nav.offerings`/`offerings.*` i18n key sets in both `en`/`nl`; `features/offerings/mocks/handlers.ts` plus registration in `mocks/index.ts`, and added `'Offerings'` to the shared `system/mocks/handlers.ts` capabilities fixture so `ModuleGuard`-gated content renders in tests by default. Wrote 43 new frontend tests (schema, an `OfferingsListPage` integration suite, an `OfferingFormPage` suite) following the `features/cms` test-style precedent. Two issues caught and fixed during this stage: (1) `useFieldArray` doesn't type-check against a plain `string[]` field for the dynamic features list — switched to `useWatch`+`setValue`, verified by a clean `tsc -b` build; (2) `pnpm lint` flagged `react-hooks/set-state-in-effect` in `useOfferingsDnd`'s prop-sync logic — fixed by adjusting state during render (comparing against a stored previous-props reference) instead of inside a `useEffect`, per the React docs' recommended pattern. `pnpm build` succeeded, `pnpm lint` clean, `pnpm test` green (254/254 across 41 files, 43 new, no regressions). Created `construction/offerings/code/summary.md`. Backend was already verified green in the prior step (414/414). Marked `offerings-code-generation-plan.md`'s all 18 steps complete, updated `aidlc-state.md` (Unit 2 Code Generation → Complete, Current Status → feature-wide Build and Test next)."
|
||||||
|
|
||||||
|
**Context**: Construction, Unit 2, Code Generation stage, Steps 10-18 of `offerings-code-generation-plan.md` complete and verified (build + lint + test green, both backend and frontend). Unit 2 "Offerings" Code Generation is now fully complete. Presenting completion gate; awaiting user approval to proceed to feature-wide Build and Test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug Fix — Record DataAnnotations Targeting Property Instead of Constructor Parameter
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T16:30:00Z
|
||||||
|
|
||||||
|
**User Input**: Pasted a live `POST /api/v1/offerings/admin` failure — `GlobalExceptionHandler` logged an unhandled `System.InvalidOperationException`: "Record type 'CreateOfferingRequest' has validation metadata defined on property 'CtaLabel' that will be ignored. 'CtaLabel' is a parameter in the record primary constructor and validation metadata must be associated with the constructor parameter," thrown from `ModelMetadata.ThrowIfRecordTypeHasValidationOnProperties()` during model validation.
|
||||||
|
|
||||||
|
**AI Response**: "Root cause: `CreateOfferingRequest`/`UpdateOfferingRequest`/`ReorderOfferingsRequest` used `[property: Required, MaxLength(...)]`-style attributes on record primary-constructor parameters. ASP.NET Core's model-binding/validation metadata system (a runtime check added for record types) requires validation attributes to target the constructor parameter itself, not the compiler-generated property — `[property: ...]` explicitly routes them to the property, which the validator then flags and throws on rather than silently ignoring. Fixed by removing the `property:` target from every attribute in all three request records, so they land on the parameters as ASP.NET Core expects. Verified: `Modules.Offerings` project builds clean standalone, and the 38 `Modules.Offerings.Tests` still pass unchanged. Could not run a full solution build to verify `Api.SlpSoftware` picks up the fix, because the user's locally-running `Api.SlpSoftware.exe` process holds a lock on its own `Modules.Offerings.dll` output — user needs to stop and restart that process to load the fix."
|
||||||
|
|
||||||
|
**Context**: Real bug in Unit 2's generated code, caught via live testing rather than assumption. Fixed directly per the project's fix-small-things-now convention; not filed as tech debt. Unit 2 Code Generation completion gate still stands once the user confirms the fix works after restarting the app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug Fix — CORS: Public SlpSoftware Website Origin Not Allowed
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T16:40:00Z
|
||||||
|
|
||||||
|
**User Input**: "Ik krijg een cors error als ik de data op probeer te halen met de website die draait op http://localhost:5173/"
|
||||||
|
|
||||||
|
**AI Response**: "Checked `Api.SlpSoftware/appsettings.Development.json`'s `Cors:AllowedOrigins` — it only listed `localhost:5175` (this repo's admin frontend, per `pnpm dev:slpsoftware`), deliberately excluding 5173 per its own comment ('that's Api's own frontend dev port'). That comment was correct about *this* repo's Api project, but didn't account for the actual external SlpSoftware website (a separate workspace, `K:\Development\SlpSoftware\Projects\SlpSoftware`) coincidentally also running on Vite's default port 5173 when calling the anonymous `GET /api/v1/offerings` endpoint. Added `http://localhost:5173`/`https://localhost:5173` to the allowed origins, with a comment distinguishing the two same-numbered-but-different-origin dev servers so this isn't miscategorized as a duplicate/mistake again later. No code in the external website workspace was touched, per that workspace's read-only-for-this-feature constraint — only this repo's CORS config changed."
|
||||||
|
|
||||||
|
**Context**: Real bug found via live testing — the public website is the actual consumer FR-6 was built for, so this is a legitimate integration gap, not a code-generation defect. Fixed directly. Returning to the Unit 2 Code Generation completion gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UX Fix — Drag-and-Drop Reorder Caused Page Scrollbars to Appear
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T16:50:00Z
|
||||||
|
|
||||||
|
**User Input**: "ik zie scrollbalken tevoorschijn komen al sik ga slepen voor de volgorde. Kan je zorgen dat ik die scrollbalken niet te zien krijg?"
|
||||||
|
|
||||||
|
**AI Response**: "Root cause: `OfferingRow` applied dnd-kit's `transform` directly to the actual `<tr>` being dragged (`CSS.Transform.toString(transform)` on the row's own `style`). Per the CSS Transforms spec, a transformed element's post-transform geometry contributes to its nearest scrolling ancestor's scrollable overflow — so dragging a row far enough temporarily grows the page's scrollable area, which is exactly the scrollbar flicker reported. This is standard, well-documented dnd-kit behavior when you skip `DragOverlay` and transform the sortable item in place. Fixed by introducing a `DragOverlay`: `useOfferingsDnd` now tracks `activeId`/`activeItem` and exposes a `handleDragStart`; `OfferingsList` renders a `<DragOverlay>` (a React portal, `position: fixed`, entirely outside document flow) showing a lightweight floating card (grip icon, title, price) for the item being dragged; `OfferingRow` now sets `opacity: 0` on itself via `isDragging` (from `useSortable`) instead of visually moving, since the overlay is what the user now sees following the cursor. Verified: `pnpm build`/`pnpm lint` clean, full test suite still green (254/254, no regressions)."
|
||||||
|
|
||||||
|
**Context**: Real UX bug found via live testing/dragging, not a functional/business-rule defect (US-08's drag-and-drop behavior itself was already correct). Fixed directly per the project's fix-small-things-now convention. Returning to the Unit 2 Code Generation completion gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Construction — Unit 2 "Offerings" Code Generation Approved
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T16:55:00Z
|
||||||
|
|
||||||
|
**User Input**: "OKee, ziet er goed uit, ga verder"
|
||||||
|
|
||||||
|
**AI Response**: "Unit 2 'Offerings' Code Generation approved, including the three post-completion live-testing fixes (record DataAnnotations targeting, CORS for the public website origin, drag-and-drop scrollbar UX). Both units are now code-complete and individually verified green. Proceeding to feature-wide Build and Test — the always-execute stage that does one final combined pass across the whole feature before Operations."
|
||||||
|
|
||||||
|
**Context**: Construction, Unit 2 Code Generation stage complete and approved. Proceeding to the feature-wide Build and Test stage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature-wide Build and Test — Complete
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T17:05:00Z
|
||||||
|
|
||||||
|
**User Input**: N/A (no new user input this step; proceeding per approved workflow)
|
||||||
|
|
||||||
|
**AI Response**: "Ran one final combined verification pass across the whole feature (confirmed the user's locally-running `Api.SlpSoftware.exe` had been stopped, so the build could overwrite its output): `dotnet build SlpModularCms.sln` succeeded; `dotnet test SlpModularCms.sln` — 414/414 passing across all six test projects (`Core.Tests` 196, `Modules.Identity.Tests` 37, `Modules.Availability.Tests` 82, `Modules.Master.Tests` 57, `Modules.Offerings.Tests` 38, `Api.Tests` 4), no regressions. Frontend: `pnpm build` succeeded, `pnpm lint` clean, `pnpm test` — 254/254 passing across 41 files, no regressions. Both units (`SlpSoftware Client Setup`, `Offerings`) are now fully complete and verified together. Updated `aidlc-state.md`: Construction phase marked complete, Current Status → Operations phase next (Deployment Setup: retargeting `gitea-deployment-workflow`'s pipeline to `Api.SlpSoftware` per D-15's cutover decision)."
|
||||||
|
|
||||||
|
**Context**: CONSTRUCTION phase complete for the `slpsoftware-api` feature. Presenting completion gate; awaiting user approval to proceed to the OPERATIONS phase, starting with Deployment Setup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operations — Deployment Setup: Plan Created (No Questions)
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T17:10:00Z
|
||||||
|
|
||||||
|
**User Input**: "Continue to Next Stage"
|
||||||
|
|
||||||
|
**AI Response**: "Read `continuous_integration.yaml`, `deploy-scp.yaml`, and `gitea-deployment-workflow`'s `deployment-instructions.md`/`rollback-plan.md` in full before drafting anything (D-7's 'extend, don't duplicate'). Found: `deploy-scp.yaml` and `rollback-plan.md` need zero changes (neither ever hardcodes an entry-point `.dll` name — both operate on the release-directory/symlink level or a `service_name` input string); `continuous_integration.yaml` hardcodes `SlpModularCms.Api` in exactly 6 lines, all inside `publish-test`/`publish-production`'s 'Build admin frontend' and 'Publish' steps (every other `Api` reference in that file is `Api.Tests`, correctly untouched — Unit 1's own regression suite, scoped to `Api` per NFR-CS-01, and `Api` itself stays as local dev host per D-8); `deployment-instructions.md` § 1.6's systemd `ExecStart` lines are host configuration that only this feature's documentation can update — actually applying the change to the Pi's already-running units is the user's own manual step. No nginx, database, or Gitea-variable changes needed (D-6, same customer/domain/service-names). Created `operations/plans/deployment-setup-plan.md` with this investigation and an explicit 'no questions this time' rationale — every decision this stage would normally ask about was already made in Requirements Analysis (D-6/D-7/D-8/D-15); this stage is mechanical execution of already-approved decisions, not new design."
|
||||||
|
|
||||||
|
**Context**: Operations, Deployment Setup stage. No user answers needed — proceeding directly to execution per the plan's checklist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operations — Deployment Setup: Complete
|
||||||
|
|
||||||
|
**Timestamp**: 2026-08-02T17:20:00Z
|
||||||
|
|
||||||
|
**User Input**: N/A (no new user input this step; proceeding per approved workflow)
|
||||||
|
|
||||||
|
**AI Response**: "Executed all 4 checklist items: retargeted `continuous_integration.yaml`'s `publish-test`/`publish-production` jobs from `src/SlpModularCms.Api` to `src/SlpModularCms.Api.SlpSoftware` (6 lines, verified via `git diff` — a clean, minimal change touching only the two 'Build admin frontend'/'Publish' step pairs, `SlpModularCms.Api.Tests` references correctly left alone); updated `gitea-deployment-workflow`'s `deployment-instructions.md` § 1.6 systemd `ExecStart` documentation to `SlpModularCms.Api.SlpSoftware.dll`, with an explicit callout that this requires a manual edit on the Pi's already-existing units (directly answering the user's earlier open question about whether `slpsoftware-test.service`/`slpsoftware-production.service` need changes — yes); created `operations/deployment/deployment-instructions.md` as this feature's pointer/summary document per the 'extend, don't duplicate' relationship with `gitea-deployment-workflow`; verified via `grep` that no other file hardcodes `SlpModularCms.Api` as a deploy target (only `Api.Tests` and doc cross-references to `Api.csproj`'s own comment remain, both correctly unaffected). Drive-by fix: README.md's Projectstructuur section still said 'plus (later) de Offerings-module' — updated now that Offerings is actually done, and reworded the sentence to state the cutover as fact rather than a future plan."
|
||||||
|
|
||||||
|
**Context**: Operations, Deployment Setup stage complete — all repo-side changes done and verified via diff/grep. The one remaining action (editing the Pi's two systemd unit files) is inherently the user's own manual step, documented clearly in both `deployment-instructions.md` locations. Presenting completion gate; awaiting user approval to proceed to Monitoring Setup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -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.
|
**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)
|
## 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):
|
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.
|
**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
|
```mermaid
|
||||||
|
%%{init: {'themeVariables': {'primaryTextColor':'#000000','textColor':'#000000','tertiaryTextColor':'#000000'}}}%%
|
||||||
graph TD
|
graph TD
|
||||||
visitor["Site Visitor / CMS Administrator browser"]
|
visitor["Site Visitor / CMS Administrator browser"]
|
||||||
proxy["Proxy Pi<br/>nginx + TLS (certbot)"]
|
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)
|
## Local Development (Current Scope of This Unit)
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
|
%%{init: {'themeVariables': {'primaryTextColor':'#000000','textColor':'#000000','tertiaryTextColor':'#000000'}}}%%
|
||||||
graph LR
|
graph LR
|
||||||
dev["Developer machine"]
|
dev["Developer machine"]
|
||||||
api["SlpModularCms.Api<br/>(existing dev host)"]
|
api["SlpModularCms.Api<br/>(existing dev host)"]
|
||||||
|
|||||||
+3
-2
@@ -10,13 +10,14 @@
|
|||||||
## Sequencing
|
## Sequencing
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
|
%%{init: {'themeVariables': {'primaryTextColor':'#000000','textColor':'#000000','tertiaryTextColor':'#000000'}}}%%
|
||||||
graph LR
|
graph LR
|
||||||
U1["Unit 1: SlpSoftware Client Setup<br/>(FR-1, FR-2, FR-3)"]
|
U1["Unit 1: SlpSoftware Client Setup<br/>(FR-1, FR-2, FR-3)"]
|
||||||
U2["Unit 2: Offerings<br/>(FR-4..FR-8, US-01..US-12)"]
|
U2["Unit 2: Offerings<br/>(FR-4..FR-8, US-01..US-12)"]
|
||||||
U1 -->|"Api.SlpSoftware project must exist first"| U2
|
U1 -->|"Api.SlpSoftware project must exist first"| U2
|
||||||
|
|
||||||
classDef foundation fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000;
|
classDef foundation fill:#bee3f8,stroke:#2b6cb0,stroke-width:2px,color:#000000,font-weight:bold;
|
||||||
classDef feature fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
|
classDef feature fill:#fefcbf,stroke:#c05621,stroke-width:2px,color:#000000,font-weight:bold;
|
||||||
|
|
||||||
class U1 foundation;
|
class U1 foundation;
|
||||||
class U2 feature;
|
class U2 feature;
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Deployment Setup — slpsoftware-api (D-15 Cutover)
|
||||||
|
|
||||||
|
This feature does not own a separate deployment pipeline. Per D-7, its Operations phase **extends**
|
||||||
|
the pipeline owned by `gitea-deployment-workflow` rather than duplicating it. Full instructions
|
||||||
|
(host setup, systemd units, TLS, Gitea variables, database backup, rollback) remain at:
|
||||||
|
|
||||||
|
- `aidlc-docs/features/gitea-deployment-workflow/operations/deployment/deployment-instructions.md`
|
||||||
|
- `aidlc-docs/features/gitea-deployment-workflow/operations/deployment/rollback-plan.md`
|
||||||
|
|
||||||
|
## What This Feature Changed
|
||||||
|
|
||||||
|
Per D-15 (cutover, not side-by-side): `SlpModularCms.Api.SlpSoftware` replaces `SlpModularCms.Api`
|
||||||
|
as the artifact built, tested, and deployed to `test.slpsoftware.nl` / `slpsoftware.nl`.
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `.gitea/workflows/continuous_integration.yaml` | `publish-test`/`publish-production`'s "Build admin frontend" and "Publish" steps retargeted from `src/SlpModularCms.Api` to `src/SlpModularCms.Api.SlpSoftware` (6 lines). Everything else — the six CI gates, `deploy-scp.yaml` calls, Gitea variables/secrets, `SlpModularCms.Api.Tests` (Unit 1's own regression suite, scoped to `Api` per NFR-CS-01) — is unchanged. |
|
||||||
|
| `deploy-scp.yaml` | **No change.** It never hardcodes an entry-point `.dll` name; it uploads whatever the artifact contains and restarts a `service_name` input string. |
|
||||||
|
| `gitea-deployment-workflow/operations/deployment/deployment-instructions.md` § 1.6 | systemd unit `ExecStart` documentation updated to `SlpModularCms.Api.SlpSoftware.dll`, with an explicit note that this is a manual, host-side edit for already-existing units — this session cannot reach the Pi to apply it. |
|
||||||
|
| `rollback-plan.md` | **No change.** Operates on the release-directory/symlink level, not a specific `.dll` name. |
|
||||||
|
| nginx (proxy Pi) | **No change** (D-6) — the proxy forwards to a port, not a named executable. |
|
||||||
|
| Database, Gitea Actions variables/secrets | **No change** — same customer, same domain, same paths, same service names. |
|
||||||
|
|
||||||
|
## Action Required From You
|
||||||
|
|
||||||
|
The Pi's `slpsoftware-test.service`/`slpsoftware-production.service` unit files (created during the
|
||||||
|
original `gitea-deployment-workflow` setup) still point at `SlpModularCms.Api.dll`. **Edit both by
|
||||||
|
hand** on the Pi (see the updated § 1.6 above for the exact `ExecStart` line and login-shell caveat),
|
||||||
|
then `systemctl --user daemon-reload` and restart both services — the CI pipeline deploys the new
|
||||||
|
`SlpModularCms.Api.SlpSoftware` build correctly starting with the next run regardless, but the
|
||||||
|
*existing* systemd unit will keep launching the old `SlpModularCms.Api.dll` from that release
|
||||||
|
directory until you make this edit.
|
||||||
|
|
||||||
|
`SlpModularCms.Api` itself is not removed from the repository or decommissioned (D-8) — it remains
|
||||||
|
the local development host, same role as `Api.Slave` today. Only its production/test deployment for
|
||||||
|
the `slpsoftware` customer instance is retired, per this cutover.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Deployment Setup Plan — slpsoftware-api (D-15 Cutover)
|
||||||
|
|
||||||
|
## Investigation (Before Drafting Anything)
|
||||||
|
|
||||||
|
Read `.gitea/workflows/continuous_integration.yaml`, `.gitea/workflows/deploy-scp.yaml`, and
|
||||||
|
`gitea-deployment-workflow/operations/deployment/{deployment-instructions.md,rollback-plan.md}` in
|
||||||
|
full before touching anything, per D-7's "extend, don't duplicate" instruction.
|
||||||
|
|
||||||
|
- **`deploy-scp.yaml` needs zero changes.** It never hardcodes an entry-point `.dll` name anywhere —
|
||||||
|
it uploads whatever the publish artifact contains and restarts a `service_name` input (a plain
|
||||||
|
string). The cutover is invisible to this file.
|
||||||
|
- **`continuous_integration.yaml` hardcodes `SlpModularCms.Api` in exactly 6 lines**, all inside
|
||||||
|
`publish-test`/`publish-production` (3 each): the "Build admin frontend" step's
|
||||||
|
`mkdir -p ../src/SlpModularCms.Api/wwwroot/admin` + `cp -r dist/. ../src/SlpModularCms.Api/wwwroot/admin/`,
|
||||||
|
and the "Publish" step's `working-directory: src/SlpModularCms.Api`. Every other `SlpModularCms.Api`
|
||||||
|
reference in the file is `SlpModularCms.Api.Tests` (the `backend-test` job) — Unit 1's own pipeline
|
||||||
|
regression suite, deliberately scoped to `Api` only (NFR-CS-01), and correctly untouched here: `Api`
|
||||||
|
keeps existing as the local dev host (D-8), so its own regression tests still make sense to run.
|
||||||
|
- **`deployment-instructions.md` § 1.6**'s systemd unit `ExecStart` lines reference
|
||||||
|
`SlpModularCms.Api.dll` explicitly — this is **host configuration**, per the doc's own framing
|
||||||
|
("everything here is host configuration the workflow assumes already exists — `deploy-scp.yaml`
|
||||||
|
never creates any of it"). Updating the *documented* command is this feature's job; applying it to
|
||||||
|
the *actual, already-running* systemd units on the Pi is the user's own manual step, same as every
|
||||||
|
other host-side item in that document — this session has no access to the Pi.
|
||||||
|
- **No nginx changes** (D-6, already decided) — the proxy Pi's server blocks point at pi-main's port,
|
||||||
|
not at a specific executable; whichever `.dll` is running behind that port is invisible to nginx.
|
||||||
|
- **No database change** — same customer/instance, same domain, same `DB_NAME` (`SlpSoftware<Env>`);
|
||||||
|
the cutover only changes which project's compiled output the existing systemd units execute.
|
||||||
|
- **No Gitea Actions variables/secrets change** — `DEPLOY_PATH_*`, `SERVICE_NAME_*`,
|
||||||
|
`HEALTH_CHECK_URL_*`, and every other `vars.*`/`secrets.*` already describe the *target* (paths,
|
||||||
|
service names, domains), none of which change in a same-host, same-service-name cutover.
|
||||||
|
- **`rollback-plan.md` needs zero changes** — it operates entirely at the release-directory/symlink
|
||||||
|
level (`current` → `releases/<timestamp>`), never referencing a specific `.dll` name.
|
||||||
|
|
||||||
|
## Why No Questions This Time
|
||||||
|
|
||||||
|
Every decision this stage would normally ask about is already made and traced in `requirements.md`:
|
||||||
|
D-6 (no nginx change), D-7 (extend the existing pipeline, don't fork it), D-8 (`Api` stays as local
|
||||||
|
dev host, unaffected), D-15 (this is a cutover, not side-by-side). This stage is mechanical execution
|
||||||
|
of already-approved decisions, not new design — flagging that explicitly rather than manufacturing a
|
||||||
|
question with no real alternative to weigh.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [x] Update `continuous_integration.yaml`: `SlpModularCms.Api` → `SlpModularCms.Api.SlpSoftware` in
|
||||||
|
the 6 identified lines (3 in `publish-test`, 3 in `publish-production`) — nowhere else
|
||||||
|
- [x] Update `gitea-deployment-workflow/operations/deployment/deployment-instructions.md` § 1.6:
|
||||||
|
systemd unit `ExecStart` lines → `SlpModularCms.Api.SlpSoftware.dll`, with an explicit note
|
||||||
|
that this is a **cutover the user must apply by hand** to the Pi's already-existing
|
||||||
|
`slpsoftware-test.service`/`slpsoftware-production.service` unit files (this session cannot
|
||||||
|
reach the Pi) — directly answering the user's earlier question about whether those two unit
|
||||||
|
files need editing
|
||||||
|
- [x] Create `aidlc-docs/features/slpsoftware-api/operations/deployment/deployment-instructions.md`
|
||||||
|
as a short feature-local pointer document: this feature does not own a separate deployment
|
||||||
|
pipeline, it extends `gitea-deployment-workflow`'s — record that relationship plus exactly what
|
||||||
|
changed and why, rather than duplicating the full instructions
|
||||||
|
- [x] Verify no other file in the repo hardcodes `SlpModularCms.Api` in a way that's actually about
|
||||||
|
*this pipeline's deploy target* (as opposed to `Api`'s own continued existence as local dev
|
||||||
|
host, or `Api.Tests`, both of which are correctly unaffected)
|
||||||
@@ -18,6 +18,9 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.17",
|
"@radix-ui/react-dialog": "^1.1.17",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.18",
|
"@radix-ui/react-dropdown-menu": "^2.1.18",
|
||||||
|
|||||||
Generated
+120
-62
@@ -8,6 +8,15 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@dnd-kit/core':
|
||||||
|
specifier: ^6.3.1
|
||||||
|
version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
|
'@dnd-kit/sortable':
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)
|
||||||
|
'@dnd-kit/utilities':
|
||||||
|
specifier: ^3.2.2
|
||||||
|
version: 3.2.2(react@19.2.7)
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.4.0
|
specifier: ^5.4.0
|
||||||
version: 5.4.0(react-hook-form@7.79.0(react@19.2.7))
|
version: 5.4.0(react-hook-form@7.79.0(react@19.2.7))
|
||||||
@@ -74,7 +83,7 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^10.0.1
|
specifier: ^10.0.1
|
||||||
version: 10.0.1(eslint@10.5.0(jiti@2.7.0))
|
version: 10.0.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.3.1
|
specifier: ^4.3.1
|
||||||
version: 4.3.1(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0))
|
version: 4.3.1(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0))
|
||||||
@@ -107,13 +116,13 @@ importers:
|
|||||||
version: 9.2.3
|
version: 9.2.3
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^10.3.0
|
specifier: ^10.3.0
|
||||||
version: 10.5.0(jiti@2.7.0)
|
version: 10.5.0(jiti@2.7.0)(supports-color@8.1.1)
|
||||||
eslint-plugin-react-hooks:
|
eslint-plugin-react-hooks:
|
||||||
specifier: ^7.1.1
|
specifier: ^7.1.1
|
||||||
version: 7.1.1(eslint@10.5.0(jiti@2.7.0))
|
version: 7.1.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)
|
||||||
eslint-plugin-react-refresh:
|
eslint-plugin-react-refresh:
|
||||||
specifier: ^0.5.2
|
specifier: ^0.5.2
|
||||||
version: 0.5.3(eslint@10.5.0(jiti@2.7.0))
|
version: 0.5.3(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))
|
||||||
globals:
|
globals:
|
||||||
specifier: ^17.6.0
|
specifier: ^17.6.0
|
||||||
version: 17.6.0
|
version: 17.6.0
|
||||||
@@ -134,7 +143,7 @@ importers:
|
|||||||
version: 6.0.3
|
version: 6.0.3
|
||||||
typescript-eslint:
|
typescript-eslint:
|
||||||
specifier: ^8.59.2
|
specifier: ^8.59.2
|
||||||
version: 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)
|
version: 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)
|
||||||
vite:
|
vite:
|
||||||
specifier: ^8.0.12
|
specifier: ^8.0.12
|
||||||
version: 8.0.16(@types/node@24.13.2)(jiti@2.7.0)
|
version: 8.0.16(@types/node@24.13.2)(jiti@2.7.0)
|
||||||
@@ -277,6 +286,28 @@ packages:
|
|||||||
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
||||||
engines: {node: '>=20.19.0'}
|
engines: {node: '>=20.19.0'}
|
||||||
|
|
||||||
|
'@dnd-kit/accessibility@3.1.1':
|
||||||
|
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/core@6.3.1':
|
||||||
|
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
react-dom: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/sortable@10.0.0':
|
||||||
|
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@dnd-kit/core': ^6.3.0
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/utilities@3.2.2':
|
||||||
|
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
'@emnapi/core@1.10.0':
|
'@emnapi/core@1.10.0':
|
||||||
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
||||||
|
|
||||||
@@ -2377,20 +2408,20 @@ snapshots:
|
|||||||
|
|
||||||
'@babel/compat-data@7.29.7': {}
|
'@babel/compat-data@7.29.7': {}
|
||||||
|
|
||||||
'@babel/core@7.29.7':
|
'@babel/core@7.29.7(supports-color@8.1.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.7
|
'@babel/code-frame': 7.29.7
|
||||||
'@babel/generator': 7.29.7
|
'@babel/generator': 7.29.7
|
||||||
'@babel/helper-compilation-targets': 7.29.7
|
'@babel/helper-compilation-targets': 7.29.7
|
||||||
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
|
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
|
||||||
'@babel/helpers': 7.29.7
|
'@babel/helpers': 7.29.7
|
||||||
'@babel/parser': 7.29.7
|
'@babel/parser': 7.29.7
|
||||||
'@babel/template': 7.29.7
|
'@babel/template': 7.29.7
|
||||||
'@babel/traverse': 7.29.7
|
'@babel/traverse': 7.29.7(supports-color@8.1.1)
|
||||||
'@babel/types': 7.29.7
|
'@babel/types': 7.29.7
|
||||||
'@jridgewell/remapping': 2.3.5
|
'@jridgewell/remapping': 2.3.5
|
||||||
convert-source-map: 2.0.0
|
convert-source-map: 2.0.0
|
||||||
debug: 4.4.3
|
debug: 4.4.3(supports-color@8.1.1)
|
||||||
gensync: 1.0.0-beta.2
|
gensync: 1.0.0-beta.2
|
||||||
json5: 2.2.3
|
json5: 2.2.3
|
||||||
semver: 6.3.1
|
semver: 6.3.1
|
||||||
@@ -2415,19 +2446,19 @@ snapshots:
|
|||||||
|
|
||||||
'@babel/helper-globals@7.29.7': {}
|
'@babel/helper-globals@7.29.7': {}
|
||||||
|
|
||||||
'@babel/helper-module-imports@7.29.7':
|
'@babel/helper-module-imports@7.29.7(supports-color@8.1.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/traverse': 7.29.7
|
'@babel/traverse': 7.29.7(supports-color@8.1.1)
|
||||||
'@babel/types': 7.29.7
|
'@babel/types': 7.29.7
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
|
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.7
|
'@babel/core': 7.29.7(supports-color@8.1.1)
|
||||||
'@babel/helper-module-imports': 7.29.7
|
'@babel/helper-module-imports': 7.29.7(supports-color@8.1.1)
|
||||||
'@babel/helper-validator-identifier': 7.29.7
|
'@babel/helper-validator-identifier': 7.29.7
|
||||||
'@babel/traverse': 7.29.7
|
'@babel/traverse': 7.29.7(supports-color@8.1.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -2454,7 +2485,7 @@ snapshots:
|
|||||||
'@babel/parser': 7.29.7
|
'@babel/parser': 7.29.7
|
||||||
'@babel/types': 7.29.7
|
'@babel/types': 7.29.7
|
||||||
|
|
||||||
'@babel/traverse@7.29.7':
|
'@babel/traverse@7.29.7(supports-color@8.1.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.7
|
'@babel/code-frame': 7.29.7
|
||||||
'@babel/generator': 7.29.7
|
'@babel/generator': 7.29.7
|
||||||
@@ -2462,7 +2493,7 @@ snapshots:
|
|||||||
'@babel/parser': 7.29.7
|
'@babel/parser': 7.29.7
|
||||||
'@babel/template': 7.29.7
|
'@babel/template': 7.29.7
|
||||||
'@babel/types': 7.29.7
|
'@babel/types': 7.29.7
|
||||||
debug: 4.4.3
|
debug: 4.4.3(supports-color@8.1.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -2501,6 +2532,31 @@ snapshots:
|
|||||||
|
|
||||||
'@csstools/css-tokenizer@4.0.0': {}
|
'@csstools/css-tokenizer@4.0.0': {}
|
||||||
|
|
||||||
|
'@dnd-kit/accessibility@3.1.1(react@19.2.7)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.7
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/accessibility': 3.1.1(react@19.2.7)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@19.2.7)
|
||||||
|
react: 19.2.7
|
||||||
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)':
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/core': 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@19.2.7)
|
||||||
|
react: 19.2.7
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/utilities@3.2.2(react@19.2.7)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.7
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@emnapi/core@1.10.0':
|
'@emnapi/core@1.10.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/wasi-threads': 1.2.1
|
'@emnapi/wasi-threads': 1.2.1
|
||||||
@@ -2517,17 +2573,17 @@ snapshots:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@eslint-community/eslint-utils@4.9.1(eslint@10.5.0(jiti@2.7.0))':
|
'@eslint-community/eslint-utils@4.9.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
eslint: 10.5.0(jiti@2.7.0)
|
eslint: 10.5.0(jiti@2.7.0)(supports-color@8.1.1)
|
||||||
eslint-visitor-keys: 3.4.3
|
eslint-visitor-keys: 3.4.3
|
||||||
|
|
||||||
'@eslint-community/regexpp@4.12.2': {}
|
'@eslint-community/regexpp@4.12.2': {}
|
||||||
|
|
||||||
'@eslint/config-array@0.23.5':
|
'@eslint/config-array@0.23.5(supports-color@8.1.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint/object-schema': 3.0.5
|
'@eslint/object-schema': 3.0.5
|
||||||
debug: 4.4.3
|
debug: 4.4.3(supports-color@8.1.1)
|
||||||
minimatch: 10.2.5
|
minimatch: 10.2.5
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -2540,9 +2596,9 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/json-schema': 7.0.15
|
'@types/json-schema': 7.0.15
|
||||||
|
|
||||||
'@eslint/js@10.0.1(eslint@10.5.0(jiti@2.7.0))':
|
'@eslint/js@10.0.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
eslint: 10.5.0(jiti@2.7.0)
|
eslint: 10.5.0(jiti@2.7.0)(supports-color@8.1.1)
|
||||||
|
|
||||||
'@eslint/object-schema@3.0.5': {}
|
'@eslint/object-schema@3.0.5': {}
|
||||||
|
|
||||||
@@ -3253,15 +3309,15 @@ snapshots:
|
|||||||
|
|
||||||
'@types/statuses@2.0.6': {}
|
'@types/statuses@2.0.6': {}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)':
|
'@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
'@typescript-eslint/parser': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)
|
'@typescript-eslint/parser': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)
|
||||||
'@typescript-eslint/scope-manager': 8.61.1
|
'@typescript-eslint/scope-manager': 8.61.1
|
||||||
'@typescript-eslint/type-utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)
|
'@typescript-eslint/type-utils': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)
|
||||||
'@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)
|
'@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)
|
||||||
'@typescript-eslint/visitor-keys': 8.61.1
|
'@typescript-eslint/visitor-keys': 8.61.1
|
||||||
eslint: 10.5.0(jiti@2.7.0)
|
eslint: 10.5.0(jiti@2.7.0)(supports-color@8.1.1)
|
||||||
ignore: 7.0.5
|
ignore: 7.0.5
|
||||||
natural-compare: 1.4.0
|
natural-compare: 1.4.0
|
||||||
ts-api-utils: 2.5.0(typescript@6.0.3)
|
ts-api-utils: 2.5.0(typescript@6.0.3)
|
||||||
@@ -3269,23 +3325,23 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)':
|
'@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/scope-manager': 8.61.1
|
'@typescript-eslint/scope-manager': 8.61.1
|
||||||
'@typescript-eslint/types': 8.61.1
|
'@typescript-eslint/types': 8.61.1
|
||||||
'@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3)
|
'@typescript-eslint/typescript-estree': 8.61.1(supports-color@8.1.1)(typescript@6.0.3)
|
||||||
'@typescript-eslint/visitor-keys': 8.61.1
|
'@typescript-eslint/visitor-keys': 8.61.1
|
||||||
debug: 4.4.3
|
debug: 4.4.3(supports-color@8.1.1)
|
||||||
eslint: 10.5.0(jiti@2.7.0)
|
eslint: 10.5.0(jiti@2.7.0)(supports-color@8.1.1)
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@typescript-eslint/project-service@8.61.1(typescript@6.0.3)':
|
'@typescript-eslint/project-service@8.61.1(supports-color@8.1.1)(typescript@6.0.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/tsconfig-utils': 8.61.1(typescript@6.0.3)
|
'@typescript-eslint/tsconfig-utils': 8.61.1(typescript@6.0.3)
|
||||||
'@typescript-eslint/types': 8.61.1
|
'@typescript-eslint/types': 8.61.1
|
||||||
debug: 4.4.3
|
debug: 4.4.3(supports-color@8.1.1)
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -3299,13 +3355,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
|
|
||||||
'@typescript-eslint/type-utils@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)':
|
'@typescript-eslint/type-utils@8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/types': 8.61.1
|
'@typescript-eslint/types': 8.61.1
|
||||||
'@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3)
|
'@typescript-eslint/typescript-estree': 8.61.1(supports-color@8.1.1)(typescript@6.0.3)
|
||||||
'@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)
|
'@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)
|
||||||
debug: 4.4.3
|
debug: 4.4.3(supports-color@8.1.1)
|
||||||
eslint: 10.5.0(jiti@2.7.0)
|
eslint: 10.5.0(jiti@2.7.0)(supports-color@8.1.1)
|
||||||
ts-api-utils: 2.5.0(typescript@6.0.3)
|
ts-api-utils: 2.5.0(typescript@6.0.3)
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -3313,13 +3369,13 @@ snapshots:
|
|||||||
|
|
||||||
'@typescript-eslint/types@8.61.1': {}
|
'@typescript-eslint/types@8.61.1': {}
|
||||||
|
|
||||||
'@typescript-eslint/typescript-estree@8.61.1(typescript@6.0.3)':
|
'@typescript-eslint/typescript-estree@8.61.1(supports-color@8.1.1)(typescript@6.0.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/project-service': 8.61.1(typescript@6.0.3)
|
'@typescript-eslint/project-service': 8.61.1(supports-color@8.1.1)(typescript@6.0.3)
|
||||||
'@typescript-eslint/tsconfig-utils': 8.61.1(typescript@6.0.3)
|
'@typescript-eslint/tsconfig-utils': 8.61.1(typescript@6.0.3)
|
||||||
'@typescript-eslint/types': 8.61.1
|
'@typescript-eslint/types': 8.61.1
|
||||||
'@typescript-eslint/visitor-keys': 8.61.1
|
'@typescript-eslint/visitor-keys': 8.61.1
|
||||||
debug: 4.4.3
|
debug: 4.4.3(supports-color@8.1.1)
|
||||||
minimatch: 10.2.5
|
minimatch: 10.2.5
|
||||||
semver: 7.8.4
|
semver: 7.8.4
|
||||||
tinyglobby: 0.2.17
|
tinyglobby: 0.2.17
|
||||||
@@ -3328,13 +3384,13 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@typescript-eslint/utils@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)':
|
'@typescript-eslint/utils@8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0))
|
'@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))
|
||||||
'@typescript-eslint/scope-manager': 8.61.1
|
'@typescript-eslint/scope-manager': 8.61.1
|
||||||
'@typescript-eslint/types': 8.61.1
|
'@typescript-eslint/types': 8.61.1
|
||||||
'@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3)
|
'@typescript-eslint/typescript-estree': 8.61.1(supports-color@8.1.1)(typescript@6.0.3)
|
||||||
eslint: 10.5.0(jiti@2.7.0)
|
eslint: 10.5.0(jiti@2.7.0)(supports-color@8.1.1)
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -3530,9 +3586,11 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@noble/hashes'
|
- '@noble/hashes'
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3(supports-color@8.1.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
optionalDependencies:
|
||||||
|
supports-color: 8.1.1
|
||||||
|
|
||||||
decimal.js@10.6.0: {}
|
decimal.js@10.6.0: {}
|
||||||
|
|
||||||
@@ -3565,20 +3623,20 @@ snapshots:
|
|||||||
|
|
||||||
escape-string-regexp@4.0.0: {}
|
escape-string-regexp@4.0.0: {}
|
||||||
|
|
||||||
eslint-plugin-react-hooks@7.1.1(eslint@10.5.0(jiti@2.7.0)):
|
eslint-plugin-react-hooks@7.1.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.7
|
'@babel/core': 7.29.7(supports-color@8.1.1)
|
||||||
'@babel/parser': 7.29.7
|
'@babel/parser': 7.29.7
|
||||||
eslint: 10.5.0(jiti@2.7.0)
|
eslint: 10.5.0(jiti@2.7.0)(supports-color@8.1.1)
|
||||||
hermes-parser: 0.25.1
|
hermes-parser: 0.25.1
|
||||||
zod: 4.4.3
|
zod: 4.4.3
|
||||||
zod-validation-error: 4.0.2(zod@4.4.3)
|
zod-validation-error: 4.0.2(zod@4.4.3)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-plugin-react-refresh@0.5.3(eslint@10.5.0(jiti@2.7.0)):
|
eslint-plugin-react-refresh@0.5.3(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
eslint: 10.5.0(jiti@2.7.0)
|
eslint: 10.5.0(jiti@2.7.0)(supports-color@8.1.1)
|
||||||
|
|
||||||
eslint-scope@9.1.2:
|
eslint-scope@9.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3591,11 +3649,11 @@ snapshots:
|
|||||||
|
|
||||||
eslint-visitor-keys@5.0.1: {}
|
eslint-visitor-keys@5.0.1: {}
|
||||||
|
|
||||||
eslint@10.5.0(jiti@2.7.0):
|
eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0))
|
'@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
'@eslint/config-array': 0.23.5
|
'@eslint/config-array': 0.23.5(supports-color@8.1.1)
|
||||||
'@eslint/config-helpers': 0.6.0
|
'@eslint/config-helpers': 0.6.0
|
||||||
'@eslint/core': 1.2.1
|
'@eslint/core': 1.2.1
|
||||||
'@eslint/plugin-kit': 0.7.2
|
'@eslint/plugin-kit': 0.7.2
|
||||||
@@ -3605,7 +3663,7 @@ snapshots:
|
|||||||
'@types/estree': 1.0.9
|
'@types/estree': 1.0.9
|
||||||
ajv: 6.15.0
|
ajv: 6.15.0
|
||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
debug: 4.4.3
|
debug: 4.4.3(supports-color@8.1.1)
|
||||||
escape-string-regexp: 4.0.0
|
escape-string-regexp: 4.0.0
|
||||||
eslint-scope: 9.1.2
|
eslint-scope: 9.1.2
|
||||||
eslint-visitor-keys: 5.0.1
|
eslint-visitor-keys: 5.0.1
|
||||||
@@ -4210,13 +4268,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tagged-tag: 1.0.0
|
tagged-tag: 1.0.0
|
||||||
|
|
||||||
typescript-eslint@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3):
|
typescript-eslint@8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)
|
'@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)
|
||||||
'@typescript-eslint/parser': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)
|
'@typescript-eslint/parser': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)
|
||||||
'@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3)
|
'@typescript-eslint/typescript-estree': 8.61.1(supports-color@8.1.1)(typescript@6.0.3)
|
||||||
'@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)
|
'@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)
|
||||||
eslint: 10.5.0(jiti@2.7.0)
|
eslint: 10.5.0(jiti@2.7.0)(supports-color@8.1.1)
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { LayoutDashboard, Users, FileText, Settings, X } from 'lucide-react';
|
import { LayoutDashboard, Users, FileText, Package, Settings, X } from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAuth } from '@/features/auth/context/auth-context';
|
import { useAuth } from '@/features/auth/context/auth-context';
|
||||||
@@ -27,6 +27,14 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, testId: 'nav-dashboard' },
|
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, testId: 'nav-dashboard' },
|
||||||
{ to: '/users', labelKey: 'nav.users', icon: Users, testId: 'nav-users', roles: ['Owner', 'Administrator'] },
|
{ to: '/users', labelKey: 'nav.users', icon: Users, testId: 'nav-users', roles: ['Owner', 'Administrator'] },
|
||||||
{ to: '/cms', labelKey: 'nav.cms', icon: FileText, testId: 'nav-cms', roles: ['Owner'], requiredModule: 'Master' },
|
{ to: '/cms', labelKey: 'nav.cms', icon: FileText, testId: 'nav-cms', roles: ['Owner'], requiredModule: 'Master' },
|
||||||
|
{
|
||||||
|
to: '/offerings',
|
||||||
|
labelKey: 'nav.offerings',
|
||||||
|
icon: Package,
|
||||||
|
testId: 'nav-offerings',
|
||||||
|
roles: ['Owner', 'Administrator'],
|
||||||
|
requiredModule: 'Offerings',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const SETTINGS_ITEM: NavItem = {
|
const SETTINGS_ITEM: NavItem = {
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import type { OfferingAdminDto } from '../services/types';
|
||||||
|
|
||||||
|
interface DeleteOfferingDialogProps {
|
||||||
|
offering: OfferingAdminDto | null;
|
||||||
|
isDeleting: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteOfferingDialog({ offering, isDeleting, onConfirm, onCancel }: DeleteOfferingDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={offering !== null} onOpenChange={(open) => { if (!open) onCancel(); }}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('offerings.deleteDialog.title')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground" data-testid="delete-offering-message">
|
||||||
|
{t('offerings.deleteDialog.message', { title: offering?.title ?? '' })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={onCancel} data-testid="delete-offering-cancel">
|
||||||
|
{t('offerings.deleteDialog.cancelButton')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isDeleting}
|
||||||
|
data-testid="delete-offering-confirm"
|
||||||
|
>
|
||||||
|
{isDeleting ? '…' : t('offerings.deleteDialog.confirmButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { FieldError } from '@/components/ui/FieldError';
|
||||||
|
import { offeringFormSchema, type OfferingFormData } from '../schemas/offering';
|
||||||
|
import type { OfferingAdminDto } from '../services/types';
|
||||||
|
|
||||||
|
interface OfferingFormProps {
|
||||||
|
initialValues?: OfferingAdminDto;
|
||||||
|
onSubmit: (values: OfferingFormData) => void;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OfferingForm({ initialValues, onSubmit, isSubmitting }: OfferingFormProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<OfferingFormData>({
|
||||||
|
resolver: zodResolver(offeringFormSchema),
|
||||||
|
mode: 'onTouched',
|
||||||
|
defaultValues: initialValues ?? {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
price: '',
|
||||||
|
priceNote: '',
|
||||||
|
features: [''],
|
||||||
|
ctaLabel: '',
|
||||||
|
featured: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// useFieldArray requires object-shaped array items — features is a plain string[],
|
||||||
|
// so add/remove are handled directly via setValue instead.
|
||||||
|
const features = useWatch({ control, name: 'features' });
|
||||||
|
|
||||||
|
function addFeature() {
|
||||||
|
setValue('features', [...features, ''], { shouldValidate: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFeature(index: number) {
|
||||||
|
setValue('features', features.filter((_, i) => i !== index), { shouldValidate: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="offering-title">{t('offerings.form.titleLabel')}</Label>
|
||||||
|
<Input id="offering-title" data-testid="offering-title" aria-invalid={errors.title !== undefined} {...register('title')} />
|
||||||
|
{errors.title && <FieldError message={errors.title.message} testId="offering-title-error" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="offering-description">{t('offerings.form.descriptionLabel')}</Label>
|
||||||
|
<textarea
|
||||||
|
id="offering-description"
|
||||||
|
data-testid="offering-description"
|
||||||
|
aria-invalid={errors.description !== undefined}
|
||||||
|
className="flex min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
{...register('description')}
|
||||||
|
/>
|
||||||
|
{errors.description && <FieldError message={errors.description.message} testId="offering-description-error" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="offering-price">{t('offerings.form.priceLabel')}</Label>
|
||||||
|
<Input id="offering-price" data-testid="offering-price" aria-invalid={errors.price !== undefined} {...register('price')} />
|
||||||
|
{errors.price && <FieldError message={errors.price.message} testId="offering-price-error" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="offering-price-note">{t('offerings.form.priceNoteLabel')}</Label>
|
||||||
|
<Input
|
||||||
|
id="offering-price-note"
|
||||||
|
data-testid="offering-price-note"
|
||||||
|
aria-invalid={errors.priceNote !== undefined}
|
||||||
|
{...register('priceNote')}
|
||||||
|
/>
|
||||||
|
{errors.priceNote && <FieldError message={errors.priceNote.message} testId="offering-price-note-error" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('offerings.form.featuresLabel')}</Label>
|
||||||
|
{features.map((_, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
data-testid={`offering-feature-${index}`}
|
||||||
|
aria-invalid={errors.features?.[index] !== undefined}
|
||||||
|
{...register(`features.${index}` as const)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-testid={`offering-feature-${index}-remove`}
|
||||||
|
aria-label={t('offerings.form.removeFeatureButton')}
|
||||||
|
disabled={features.length <= 1}
|
||||||
|
onClick={() => removeFeature(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{errors.features?.message && <FieldError message={errors.features.message} testId="offering-features-error" />}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
data-testid="offering-add-feature"
|
||||||
|
disabled={features.length >= 10}
|
||||||
|
onClick={addFeature}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
{t('offerings.form.addFeatureButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="offering-cta-label">{t('offerings.form.ctaLabelLabel')}</Label>
|
||||||
|
<Input
|
||||||
|
id="offering-cta-label"
|
||||||
|
data-testid="offering-cta-label"
|
||||||
|
aria-invalid={errors.ctaLabel !== undefined}
|
||||||
|
{...register('ctaLabel')}
|
||||||
|
/>
|
||||||
|
{errors.ctaLabel && <FieldError message={errors.ctaLabel.message} testId="offering-cta-label-error" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="offering-featured"
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="offering-featured"
|
||||||
|
className="size-4 rounded border-input"
|
||||||
|
{...register('featured')}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="offering-featured">{t('offerings.form.featuredLabel')}</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isSubmitting} data-testid="offering-form-submit">
|
||||||
|
{isSubmitting ? '…' : t('offerings.form.submitButton')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { GripVertical, Star, ChevronUp, ChevronDown, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { TableCell, TableRow } from '@/components/ui/table';
|
||||||
|
import { useUpdateOffering } from '../services/useUpdateOffering';
|
||||||
|
import { useMoveOffering } from '../services/useMoveOffering';
|
||||||
|
import type { OfferingAdminDto } from '../services/types';
|
||||||
|
|
||||||
|
interface OfferingRowProps {
|
||||||
|
offering: OfferingAdminDto;
|
||||||
|
isFirst: boolean;
|
||||||
|
isLast: boolean;
|
||||||
|
onDeleteRequested: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OfferingRow({ offering, isFirst, isLast, onDeleteRequested }: OfferingRowProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: offering.id });
|
||||||
|
const updateOffering = useUpdateOffering();
|
||||||
|
const moveUp = useMoveOffering('up');
|
||||||
|
const moveDown = useMoveOffering('down');
|
||||||
|
|
||||||
|
// The moving visual is rendered by <DragOverlay> instead (see OfferingsList) — a portal
|
||||||
|
// outside the document flow, so it can't grow the page's scrollable area the way
|
||||||
|
// transforming this row in place would. This row just hides itself while dragging.
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow ref={setNodeRef} style={style} data-testid={`offering-row-${offering.id}`}>
|
||||||
|
<TableCell>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cursor-grab text-muted-foreground touch-none"
|
||||||
|
aria-label={t('offerings.actions.drag')}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical className="size-4" />
|
||||||
|
</button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{offering.title}</TableCell>
|
||||||
|
<TableCell>{offering.price}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-testid={`offering-row-${offering.id}-featured-toggle`}
|
||||||
|
aria-label={offering.featured ? t('offerings.actions.unmarkFeatured') : t('offerings.actions.markFeatured')}
|
||||||
|
onClick={() =>
|
||||||
|
updateOffering.mutate({
|
||||||
|
id: offering.id,
|
||||||
|
title: offering.title,
|
||||||
|
description: offering.description,
|
||||||
|
price: offering.price,
|
||||||
|
priceNote: offering.priceNote,
|
||||||
|
features: offering.features,
|
||||||
|
ctaLabel: offering.ctaLabel,
|
||||||
|
featured: !offering.featured,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Star className={`size-4 ${offering.featured ? 'fill-yellow-400 text-yellow-400' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-testid={`offering-row-${offering.id}-move-up-button`}
|
||||||
|
aria-label={t('offerings.actions.moveUp')}
|
||||||
|
disabled={isFirst}
|
||||||
|
onClick={() => moveUp.mutate(offering.id)}
|
||||||
|
>
|
||||||
|
<ChevronUp className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-testid={`offering-row-${offering.id}-move-down-button`}
|
||||||
|
aria-label={t('offerings.actions.moveDown')}
|
||||||
|
disabled={isLast}
|
||||||
|
onClick={() => moveDown.mutate(offering.id)}
|
||||||
|
>
|
||||||
|
<ChevronDown className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" asChild data-testid={`offering-row-${offering.id}-edit-link`}>
|
||||||
|
<Link to="/offerings/$id/edit" params={{ id: offering.id }} aria-label={t('offerings.actions.edit')}>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-testid={`offering-row-${offering.id}-delete-button`}
|
||||||
|
aria-label={t('offerings.actions.delete')}
|
||||||
|
onClick={onDeleteRequested}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { DndContext, DragOverlay, closestCenter } from '@dnd-kit/core';
|
||||||
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { GripVertical } from 'lucide-react';
|
||||||
|
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { OfferingRow } from './OfferingRow';
|
||||||
|
import { useOfferingsDnd } from '../hooks/useOfferingsDnd';
|
||||||
|
import type { OfferingAdminDto } from '../services/types';
|
||||||
|
|
||||||
|
interface OfferingsListProps {
|
||||||
|
offerings: OfferingAdminDto[];
|
||||||
|
onDeleteRequested: (offering: OfferingAdminDto) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OfferingsList({ offerings, onDeleteRequested }: OfferingsListProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { items, sensors, activeItem, handleDragStart, handleDragEnd } = useOfferingsDnd(offerings);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead />
|
||||||
|
<TableHead>{t('offerings.table.title')}</TableHead>
|
||||||
|
<TableHead>{t('offerings.table.price')}</TableHead>
|
||||||
|
<TableHead>{t('offerings.table.featured')}</TableHead>
|
||||||
|
<TableHead>{t('offerings.table.actions')}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<SortableContext items={items.map((o) => o.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
{items.map((offering, index) => (
|
||||||
|
<OfferingRow
|
||||||
|
key={offering.id}
|
||||||
|
offering={offering}
|
||||||
|
isFirst={index === 0}
|
||||||
|
isLast={index === items.length - 1}
|
||||||
|
onDeleteRequested={() => onDeleteRequested(offering)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{/* Rendered in a portal outside document flow, so the dragged item's position
|
||||||
|
never grows the page's scrollable area the way transforming a table row in
|
||||||
|
place would (a real browser behavior with in-place transforms, not a bug in
|
||||||
|
dnd-kit itself). */}
|
||||||
|
<DragOverlay>
|
||||||
|
{activeItem && (
|
||||||
|
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3 shadow-lg">
|
||||||
|
<GripVertical className="size-4 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{activeItem.title}</span>
|
||||||
|
<span className="text-muted-foreground">{activeItem.price}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
PointerSensor,
|
||||||
|
KeyboardSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragStartEvent,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||||
|
import { useReorderOfferings } from '../services/useReorderOfferings';
|
||||||
|
import type { OfferingAdminDto } from '../services/types';
|
||||||
|
|
||||||
|
export function useOfferingsDnd(offerings: OfferingAdminDto[]) {
|
||||||
|
const [items, setItems] = useState(offerings);
|
||||||
|
const [syncedOfferings, setSyncedOfferings] = useState(offerings);
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const reorderMutation = useReorderOfferings();
|
||||||
|
|
||||||
|
// Adjusting state during render (not in an effect) avoids the extra render pass
|
||||||
|
// an effect-based sync would cause — see https://react.dev/learn/you-might-not-need-an-effect.
|
||||||
|
if (offerings !== syncedOfferings) {
|
||||||
|
setSyncedOfferings(offerings);
|
||||||
|
setItems(offerings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleDragStart(event: DragStartEvent) {
|
||||||
|
setActiveId(event.active.id as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
|
setActiveId(null);
|
||||||
|
const { active, over } = event;
|
||||||
|
if (over === null || active.id === over.id) return;
|
||||||
|
|
||||||
|
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||||
|
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return;
|
||||||
|
|
||||||
|
const reordered = arrayMove(items, oldIndex, newIndex);
|
||||||
|
setItems(reordered);
|
||||||
|
reorderMutation.mutate(reordered.map((item) => item.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeItem = activeId === null ? null : (items.find((item) => item.id === activeId) ?? null);
|
||||||
|
|
||||||
|
return { items, sensors, activeItem, handleDragStart, handleDragEnd };
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import type { OfferingAdminDto, CreateOfferingRequest, UpdateOfferingRequest } from '@/features/offerings/services/types';
|
||||||
|
import { API_BASE } from '@/features/auth/mocks/fixtures';
|
||||||
|
|
||||||
|
const seed: OfferingAdminDto[] = [
|
||||||
|
{
|
||||||
|
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||||
|
title: 'Starter',
|
||||||
|
description: 'A simple website',
|
||||||
|
price: '€ 300',
|
||||||
|
priceNote: 'one-time',
|
||||||
|
features: ['1 page', 'Contact form'],
|
||||||
|
ctaLabel: 'Get started',
|
||||||
|
featured: false,
|
||||||
|
displayOrder: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
||||||
|
title: 'Pro',
|
||||||
|
description: 'A full website',
|
||||||
|
price: '€ 800',
|
||||||
|
priceNote: 'one-time',
|
||||||
|
features: ['5 pages', 'Contact form', 'SEO'],
|
||||||
|
ctaLabel: 'Get started',
|
||||||
|
featured: true,
|
||||||
|
displayOrder: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let mockOfferings: OfferingAdminDto[] = [...seed];
|
||||||
|
|
||||||
|
export const resetMockOfferings = () => {
|
||||||
|
mockOfferings = [...seed];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMockOfferings = () => mockOfferings;
|
||||||
|
|
||||||
|
export const offeringsHandlers = [
|
||||||
|
http.get(`${API_BASE}/api/v1/offerings`, () =>
|
||||||
|
HttpResponse.json(mockOfferings.map(({ displayOrder: _displayOrder, ...dto }) => dto)),
|
||||||
|
),
|
||||||
|
|
||||||
|
http.get(`${API_BASE}/api/v1/offerings/admin`, () => HttpResponse.json(mockOfferings)),
|
||||||
|
|
||||||
|
http.post(`${API_BASE}/api/v1/offerings/admin`, async ({ request }) => {
|
||||||
|
const body = (await request.json()) as CreateOfferingRequest;
|
||||||
|
const newOffering: OfferingAdminDto = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
...body,
|
||||||
|
displayOrder: mockOfferings.length,
|
||||||
|
};
|
||||||
|
mockOfferings = [...mockOfferings, newOffering];
|
||||||
|
return HttpResponse.json(newOffering, { status: 201 });
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.put(`${API_BASE}/api/v1/offerings/admin/:id`, async ({ params, request }) => {
|
||||||
|
const { id } = params as { id: string };
|
||||||
|
const body = (await request.json()) as UpdateOfferingRequest;
|
||||||
|
const existing = mockOfferings.find((o) => o.id === id);
|
||||||
|
if (!existing) return new HttpResponse(null, { status: 404 });
|
||||||
|
|
||||||
|
const updated: OfferingAdminDto = { ...existing, ...body };
|
||||||
|
mockOfferings = mockOfferings.map((o) => (o.id === id ? updated : o));
|
||||||
|
return HttpResponse.json(updated);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.delete(`${API_BASE}/api/v1/offerings/admin/:id`, ({ params }) => {
|
||||||
|
const { id } = params as { id: string };
|
||||||
|
mockOfferings = mockOfferings.filter((o) => o.id !== id);
|
||||||
|
return new HttpResponse(null, { status: 204 });
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.put(`${API_BASE}/api/v1/offerings/admin/reorder`, async ({ request }) => {
|
||||||
|
const body = (await request.json()) as { orderedIds: string[] };
|
||||||
|
mockOfferings = body.orderedIds
|
||||||
|
.map((id, index) => {
|
||||||
|
const offering = mockOfferings.find((o) => o.id === id);
|
||||||
|
return offering ? { ...offering, displayOrder: index } : null;
|
||||||
|
})
|
||||||
|
.filter((o): o is OfferingAdminDto => o !== null);
|
||||||
|
return new HttpResponse(null, { status: 204 });
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post(`${API_BASE}/api/v1/offerings/admin/:id/move-up`, ({ params }) => {
|
||||||
|
const { id } = params as { id: string };
|
||||||
|
moveAdjacent(id, -1);
|
||||||
|
return new HttpResponse(null, { status: 204 });
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post(`${API_BASE}/api/v1/offerings/admin/:id/move-down`, ({ params }) => {
|
||||||
|
const { id } = params as { id: string };
|
||||||
|
moveAdjacent(id, 1);
|
||||||
|
return new HttpResponse(null, { status: 204 });
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
function moveAdjacent(id: string, delta: number) {
|
||||||
|
const sorted = [...mockOfferings].sort((a, b) => a.displayOrder - b.displayOrder);
|
||||||
|
const index = sorted.findIndex((o) => o.id === id);
|
||||||
|
const swapIndex = index + delta;
|
||||||
|
if (index === -1 || swapIndex < 0 || swapIndex >= sorted.length) return;
|
||||||
|
|
||||||
|
const a = sorted[index];
|
||||||
|
const b = sorted[swapIndex];
|
||||||
|
[a.displayOrder, b.displayOrder] = [b.displayOrder, a.displayOrder];
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { renderApp, mockAuthenticated } from '@/test/utils';
|
||||||
|
import { resetMockOfferings } from '@/features/offerings/mocks/handlers';
|
||||||
|
import { _resetSetupStatusCache } from '@/router';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
_resetSetupStatusCache();
|
||||||
|
resetMockOfferings();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OfferingFormPage', () => {
|
||||||
|
it('creates a new offering and returns to the list', async () => {
|
||||||
|
mockAuthenticated();
|
||||||
|
renderApp('/offerings/new');
|
||||||
|
|
||||||
|
await screen.findByTestId('offering-form-submit', {}, { timeout: 10000 });
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByTestId('offering-title'), 'Enterprise');
|
||||||
|
await userEvent.type(screen.getByTestId('offering-description'), 'A large website');
|
||||||
|
await userEvent.type(screen.getByTestId('offering-price'), '€ 2000');
|
||||||
|
await userEvent.type(screen.getByTestId('offering-price-note'), 'one-time');
|
||||||
|
await userEvent.type(screen.getByTestId('offering-feature-0'), 'Unlimited pages');
|
||||||
|
await userEvent.type(screen.getByTestId('offering-cta-label'), 'Contact us');
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByTestId('offering-form-submit'));
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('offerings-title', {}, { timeout: 10000 })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Enterprise')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a validation error when required fields are missing', async () => {
|
||||||
|
mockAuthenticated();
|
||||||
|
renderApp('/offerings/new');
|
||||||
|
|
||||||
|
await screen.findByTestId('offering-form-submit', {}, { timeout: 10000 });
|
||||||
|
await userEvent.click(screen.getByTestId('offering-form-submit'));
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('offering-title-error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pre-fills the form when editing an existing offering', async () => {
|
||||||
|
mockAuthenticated();
|
||||||
|
renderApp('/offerings/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/edit');
|
||||||
|
|
||||||
|
const titleInput = await screen.findByTestId('offering-title', {}, { timeout: 10000 });
|
||||||
|
expect(titleInput).toHaveValue('Starter');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from '@tanstack/react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { FormErrorBanner } from '@/components/ui/FormErrorBanner';
|
||||||
|
import { OfferingForm } from '../components/OfferingForm';
|
||||||
|
import { useOffering } from '../services/useOffering';
|
||||||
|
import { useCreateOffering } from '../services/useCreateOffering';
|
||||||
|
import { useUpdateOffering } from '../services/useUpdateOffering';
|
||||||
|
import { NetworkError } from '@/lib/api-client';
|
||||||
|
import type { OfferingFormData } from '../schemas/offering';
|
||||||
|
|
||||||
|
export function OfferingFormPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams({ strict: false }) as { id?: string };
|
||||||
|
const isEdit = id !== undefined;
|
||||||
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: existingOffering, isPending: isLoadingExisting } = useOffering(id);
|
||||||
|
const createOffering = useCreateOffering();
|
||||||
|
const updateOffering = useUpdateOffering();
|
||||||
|
|
||||||
|
const isSubmitting = createOffering.isPending || updateOffering.isPending;
|
||||||
|
|
||||||
|
const onSubmit = async (values: OfferingFormData) => {
|
||||||
|
setServerError(null);
|
||||||
|
try {
|
||||||
|
if (isEdit && id !== undefined) {
|
||||||
|
await updateOffering.mutateAsync({ id, ...values });
|
||||||
|
} else {
|
||||||
|
await createOffering.mutateAsync(values);
|
||||||
|
}
|
||||||
|
toast.success(t('offerings.form.successToast'));
|
||||||
|
navigate({ to: '/offerings' });
|
||||||
|
} catch (err) {
|
||||||
|
setServerError(err instanceof NetworkError ? t('errors.network') : t('errors.generic'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit && isLoadingExisting) {
|
||||||
|
return <p className="p-4 text-sm text-muted-foreground">{t('common.loading')}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl space-y-4 p-4">
|
||||||
|
<h1 className="text-2xl font-semibold" data-testid="offering-form-title">
|
||||||
|
{isEdit ? t('offerings.form.editTitle') : t('offerings.form.createTitle')}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<FormErrorBanner
|
||||||
|
error={serverError !== null ? { message: serverError } : null}
|
||||||
|
onDismiss={() => setServerError(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OfferingForm initialValues={existingOffering} onSubmit={onSubmit} isSubmitting={isSubmitting} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { renderApp, mockAuthenticated, mockGuest } from '@/test/utils';
|
||||||
|
import { server } from '@/mocks/server';
|
||||||
|
import { API_BASE } from '@/features/auth/mocks/fixtures';
|
||||||
|
import { resetMockOfferings } from '@/features/offerings/mocks/handlers';
|
||||||
|
import { _resetSetupStatusCache } from '@/router';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
_resetSetupStatusCache();
|
||||||
|
resetMockOfferings();
|
||||||
|
});
|
||||||
|
|
||||||
|
const OFFERINGS_URL = `${API_BASE}/api/v1/offerings/admin`;
|
||||||
|
|
||||||
|
describe('OfferingsListPage', () => {
|
||||||
|
it('renders the page title and Add button', async () => {
|
||||||
|
mockAuthenticated();
|
||||||
|
renderApp('/offerings');
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('offerings-title', {}, { timeout: 10000 })).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('offerings-add-button')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the seeded offerings', async () => {
|
||||||
|
mockAuthenticated();
|
||||||
|
renderApp('/offerings');
|
||||||
|
|
||||||
|
expect(await screen.findByText('Starter', {}, { timeout: 10000 })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Pro')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty state when no offerings exist', async () => {
|
||||||
|
mockAuthenticated();
|
||||||
|
server.use(http.get(OFFERINGS_URL, () => HttpResponse.json([])));
|
||||||
|
renderApp('/offerings');
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('offerings-empty-state', {}, { timeout: 10000 })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes an offering after confirmation', async () => {
|
||||||
|
mockAuthenticated();
|
||||||
|
renderApp('/offerings');
|
||||||
|
|
||||||
|
const deleteButton = await screen.findByTestId(
|
||||||
|
'offering-row-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa-delete-button',
|
||||||
|
{},
|
||||||
|
{ timeout: 10000 },
|
||||||
|
);
|
||||||
|
await userEvent.click(deleteButton);
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByTestId('delete-offering-confirm'));
|
||||||
|
|
||||||
|
expect(screen.queryByText('Starter')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Pro')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels delete without removing the offering', async () => {
|
||||||
|
mockAuthenticated();
|
||||||
|
renderApp('/offerings');
|
||||||
|
|
||||||
|
const deleteButton = await screen.findByTestId(
|
||||||
|
'offering-row-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa-delete-button',
|
||||||
|
{},
|
||||||
|
{ timeout: 10000 },
|
||||||
|
);
|
||||||
|
await userEvent.click(deleteButton);
|
||||||
|
await userEvent.click(await screen.findByTestId('delete-offering-cancel'));
|
||||||
|
|
||||||
|
expect(screen.getByText('Starter')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the move-up button on the first row and move-down on the last', async () => {
|
||||||
|
mockAuthenticated();
|
||||||
|
renderApp('/offerings');
|
||||||
|
|
||||||
|
await screen.findByText('Starter', {}, { timeout: 10000 });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('offering-row-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa-move-up-button'),
|
||||||
|
).toBeDisabled();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('offering-row-bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb-move-down-button'),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects unauthenticated users to login', async () => {
|
||||||
|
mockGuest();
|
||||||
|
renderApp('/offerings');
|
||||||
|
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { Package } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { OfferingsList } from '../components/OfferingsList';
|
||||||
|
import { DeleteOfferingDialog } from '../components/DeleteOfferingDialog';
|
||||||
|
import { useOfferings } from '../services/useOfferings';
|
||||||
|
import { useDeleteOffering } from '../services/useDeleteOffering';
|
||||||
|
import type { OfferingAdminDto } from '../services/types';
|
||||||
|
|
||||||
|
export function OfferingsListPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [offeringPendingDelete, setOfferingPendingDelete] = useState<OfferingAdminDto | null>(null);
|
||||||
|
const { data: offerings, isPending, isError } = useOfferings();
|
||||||
|
const deleteOffering = useDeleteOffering({
|
||||||
|
onSuccess: () => setOfferingPendingDelete(null),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-semibold" data-testid="offerings-title">
|
||||||
|
{t('offerings.title')}
|
||||||
|
</h1>
|
||||||
|
<Button asChild data-testid="offerings-add-button">
|
||||||
|
<Link to="/offerings/new">{t('offerings.addButton')}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPending && <p className="text-sm text-muted-foreground">{t('common.loading')}</p>}
|
||||||
|
|
||||||
|
{isError && <p className="text-sm text-destructive">{t('errors.generic')}</p>}
|
||||||
|
|
||||||
|
{!isPending && !isError && offerings?.length === 0 && (
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center justify-center py-16 text-center space-y-4"
|
||||||
|
data-testid="offerings-empty-state"
|
||||||
|
>
|
||||||
|
<Package className="size-12 text-muted-foreground" />
|
||||||
|
<h2 className="text-xl font-semibold">{t('offerings.emptyState.heading')}</h2>
|
||||||
|
<p className="text-muted-foreground max-w-sm">{t('offerings.emptyState.description')}</p>
|
||||||
|
<Button asChild data-testid="offerings-empty-add-button">
|
||||||
|
<Link to="/offerings/new">{t('offerings.addButton')}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isPending && !isError && offerings && offerings.length > 0 && (
|
||||||
|
<OfferingsList offerings={offerings} onDeleteRequested={setOfferingPendingDelete} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DeleteOfferingDialog
|
||||||
|
offering={offeringPendingDelete}
|
||||||
|
isDeleting={deleteOffering.isPending}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (offeringPendingDelete) deleteOffering.mutate(offeringPendingDelete.id);
|
||||||
|
}}
|
||||||
|
onCancel={() => setOfferingPendingDelete(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { offeringFormSchema } from './offering';
|
||||||
|
|
||||||
|
function validOffering() {
|
||||||
|
return {
|
||||||
|
title: 'Starter',
|
||||||
|
description: 'A simple website',
|
||||||
|
price: '€ 300',
|
||||||
|
priceNote: 'one-time',
|
||||||
|
features: ['1 page'],
|
||||||
|
ctaLabel: 'Get started',
|
||||||
|
featured: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('offeringFormSchema', () => {
|
||||||
|
it('accepts valid data', () => {
|
||||||
|
const result = offeringFormSchema.safeParse(validOffering());
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects title over 100 characters', () => {
|
||||||
|
const result = offeringFormSchema.safeParse({ ...validOffering(), title: 'a'.repeat(101) });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects description over 500 characters', () => {
|
||||||
|
const result = offeringFormSchema.safeParse({ ...validOffering(), description: 'a'.repeat(501) });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty features list', () => {
|
||||||
|
const result = offeringFormSchema.safeParse({ ...validOffering(), features: [] });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects more than 10 features', () => {
|
||||||
|
const result = offeringFormSchema.safeParse({ ...validOffering(), features: Array(11).fill('Feature') });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a feature over 200 characters', () => {
|
||||||
|
const result = offeringFormSchema.safeParse({ ...validOffering(), features: ['a'.repeat(201)] });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing required fields', () => {
|
||||||
|
const result = offeringFormSchema.safeParse({ ...validOffering(), title: '' });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const offeringFormSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Title is required').max(100, 'Title must be 100 characters or fewer'),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Description is required')
|
||||||
|
.max(500, 'Description must be 500 characters or fewer'),
|
||||||
|
price: z.string().min(1, 'Price is required').max(50, 'Price must be 50 characters or fewer'),
|
||||||
|
priceNote: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Price note is required')
|
||||||
|
.max(100, 'Price note must be 100 characters or fewer'),
|
||||||
|
features: z
|
||||||
|
.array(z.string().min(1, 'Feature cannot be empty').max(200, 'Feature must be 200 characters or fewer'))
|
||||||
|
.min(1, 'At least 1 feature is required')
|
||||||
|
.max(10, 'At most 10 features are allowed'),
|
||||||
|
ctaLabel: z.string().min(1, 'CTA label is required').max(50, 'CTA label must be 50 characters or fewer'),
|
||||||
|
featured: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OfferingFormData = z.infer<typeof offeringFormSchema>;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export interface OfferingAdminDto {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
price: string;
|
||||||
|
priceNote: string;
|
||||||
|
features: string[];
|
||||||
|
ctaLabel: string;
|
||||||
|
featured: boolean;
|
||||||
|
displayOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOfferingRequest {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
price: string;
|
||||||
|
priceNote: string;
|
||||||
|
features: string[];
|
||||||
|
ctaLabel: string;
|
||||||
|
featured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateOfferingRequest = CreateOfferingRequest;
|
||||||
|
|
||||||
|
export type MoveDirection = 'up' | 'down';
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { CreateOfferingRequest, OfferingAdminDto } from './types';
|
||||||
|
|
||||||
|
export function useCreateOffering(
|
||||||
|
options?: Pick<UseMutationOptions<OfferingAdminDto, Error, CreateOfferingRequest>, 'onSuccess' | 'onError'>,
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<OfferingAdminDto, Error, CreateOfferingRequest>({
|
||||||
|
mutationFn: (data) => api.post<OfferingAdminDto>('/api/v1/offerings/admin', data),
|
||||||
|
onSuccess: (data, variables, ...rest) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['offerings', 'admin'] });
|
||||||
|
options?.onSuccess?.(data, variables, ...rest);
|
||||||
|
},
|
||||||
|
onError: options?.onError,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
|
||||||
|
export function useDeleteOffering(
|
||||||
|
options?: Pick<UseMutationOptions<void, Error, string>, 'onSuccess' | 'onError'>,
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<void, Error, string>({
|
||||||
|
mutationFn: (id) => api.delete<void>(`/api/v1/offerings/admin/${id}`),
|
||||||
|
onSuccess: (data, variables, ...rest) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['offerings', 'admin'] });
|
||||||
|
options?.onSuccess?.(data, variables, ...rest);
|
||||||
|
},
|
||||||
|
onError: options?.onError,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { MoveDirection } from './types';
|
||||||
|
|
||||||
|
export function useMoveOffering(
|
||||||
|
direction: MoveDirection,
|
||||||
|
options?: Pick<UseMutationOptions<void, Error, string>, 'onSuccess' | 'onError'>,
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<void, Error, string>({
|
||||||
|
mutationFn: (id) => api.post<void>(`/api/v1/offerings/admin/${id}/move-${direction}`),
|
||||||
|
onSuccess: (data, variables, ...rest) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['offerings', 'admin'] });
|
||||||
|
options?.onSuccess?.(data, variables, ...rest);
|
||||||
|
},
|
||||||
|
onError: options?.onError,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { useOfferings } from './useOfferings';
|
||||||
|
|
||||||
|
/** Derived from the `useOfferings()` admin-list cache — no dedicated fetch needed. */
|
||||||
|
export function useOffering(id: string | undefined) {
|
||||||
|
const query = useOfferings();
|
||||||
|
const offering = id === undefined ? undefined : query.data?.find((o) => o.id === id);
|
||||||
|
return { ...query, data: offering };
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { OfferingAdminDto } from './types';
|
||||||
|
|
||||||
|
export function useOfferings() {
|
||||||
|
return useQuery<OfferingAdminDto[], Error>({
|
||||||
|
queryKey: ['offerings', 'admin'],
|
||||||
|
queryFn: () => api.get<OfferingAdminDto[]>('/api/v1/offerings/admin'),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
|
||||||
|
export function useReorderOfferings(
|
||||||
|
options?: Pick<UseMutationOptions<void, Error, string[]>, 'onSuccess' | 'onError'>,
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<void, Error, string[]>({
|
||||||
|
mutationFn: (orderedIds) => api.put<void>('/api/v1/offerings/admin/reorder', { orderedIds }),
|
||||||
|
onSuccess: (data, variables, ...rest) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['offerings', 'admin'] });
|
||||||
|
options?.onSuccess?.(data, variables, ...rest);
|
||||||
|
},
|
||||||
|
onError: options?.onError,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { OfferingAdminDto, UpdateOfferingRequest } from './types';
|
||||||
|
|
||||||
|
type UpdateVariables = { id: string } & UpdateOfferingRequest;
|
||||||
|
|
||||||
|
export function useUpdateOffering(
|
||||||
|
options?: Pick<UseMutationOptions<OfferingAdminDto, Error, UpdateVariables>, 'onSuccess' | 'onError'>,
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<OfferingAdminDto, Error, UpdateVariables>({
|
||||||
|
mutationFn: ({ id, ...body }) => api.put<OfferingAdminDto>(`/api/v1/offerings/admin/${id}`, body),
|
||||||
|
onSuccess: (data, variables, ...rest) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['offerings', 'admin'] });
|
||||||
|
options?.onSuccess?.(data, variables, ...rest);
|
||||||
|
},
|
||||||
|
onError: options?.onError,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { http, HttpResponse } from 'msw';
|
|||||||
export const systemHandlers = [
|
export const systemHandlers = [
|
||||||
http.get('*/System/capabilities', () =>
|
http.get('*/System/capabilities', () =>
|
||||||
HttpResponse.json({
|
HttpResponse.json({
|
||||||
modules: ['Availability', 'Identity', 'Master'],
|
modules: ['Availability', 'Identity', 'Master', 'Offerings'],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"cms": "CMS",
|
"cms": "CMS",
|
||||||
|
"offerings": "Offerings",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"openMenu": "Open navigation",
|
"openMenu": "Open navigation",
|
||||||
@@ -230,6 +231,50 @@
|
|||||||
"successUnreachableToast": "Status saved — cliënt unreachable"
|
"successUnreachableToast": "Status saved — cliënt unreachable"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"offerings": {
|
||||||
|
"title": "Offerings",
|
||||||
|
"addButton": "Add Offering",
|
||||||
|
"emptyState": {
|
||||||
|
"heading": "No offerings yet",
|
||||||
|
"description": "Add your first offering to get started."
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"title": "Title",
|
||||||
|
"price": "Price",
|
||||||
|
"featured": "Featured",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"drag": "Drag to reorder",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"moveUp": "Move up",
|
||||||
|
"moveDown": "Move down",
|
||||||
|
"markFeatured": "Mark as featured",
|
||||||
|
"unmarkFeatured": "Remove featured"
|
||||||
|
},
|
||||||
|
"deleteDialog": {
|
||||||
|
"title": "Delete offering",
|
||||||
|
"message": "Are you sure you want to delete '{{title}}'?",
|
||||||
|
"confirmButton": "Delete",
|
||||||
|
"cancelButton": "Cancel"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"createTitle": "New Offering",
|
||||||
|
"editTitle": "Edit Offering",
|
||||||
|
"titleLabel": "Title",
|
||||||
|
"descriptionLabel": "Description",
|
||||||
|
"priceLabel": "Price",
|
||||||
|
"priceNoteLabel": "Price note",
|
||||||
|
"featuresLabel": "Features",
|
||||||
|
"addFeatureButton": "Add feature",
|
||||||
|
"removeFeatureButton": "Remove",
|
||||||
|
"ctaLabelLabel": "Button text",
|
||||||
|
"featuredLabel": "Featured",
|
||||||
|
"submitButton": "Save",
|
||||||
|
"successToast": "Offering saved successfully"
|
||||||
|
}
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"403": {
|
"403": {
|
||||||
"title": "Access Denied",
|
"title": "Access Denied",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"users": "Gebruikers",
|
"users": "Gebruikers",
|
||||||
"cms": "CMS",
|
"cms": "CMS",
|
||||||
|
"offerings": "Aanbiedingen",
|
||||||
"profile": "Profiel",
|
"profile": "Profiel",
|
||||||
"settings": "Instellingen",
|
"settings": "Instellingen",
|
||||||
"openMenu": "Navigatie openen",
|
"openMenu": "Navigatie openen",
|
||||||
@@ -230,6 +231,50 @@
|
|||||||
"successUnreachableToast": "Status opgeslagen — cliënt niet bereikbaar"
|
"successUnreachableToast": "Status opgeslagen — cliënt niet bereikbaar"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"offerings": {
|
||||||
|
"title": "Aanbiedingen",
|
||||||
|
"addButton": "Aanbieding toevoegen",
|
||||||
|
"emptyState": {
|
||||||
|
"heading": "Nog geen aanbiedingen",
|
||||||
|
"description": "Voeg je eerste aanbieding toe om te beginnen."
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"title": "Titel",
|
||||||
|
"price": "Prijs",
|
||||||
|
"featured": "Uitgelicht",
|
||||||
|
"actions": "Acties"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"drag": "Sleep om te herordenen",
|
||||||
|
"edit": "Bewerken",
|
||||||
|
"delete": "Verwijderen",
|
||||||
|
"moveUp": "Omhoog verplaatsen",
|
||||||
|
"moveDown": "Omlaag verplaatsen",
|
||||||
|
"markFeatured": "Markeren als uitgelicht",
|
||||||
|
"unmarkFeatured": "Uitgelicht verwijderen"
|
||||||
|
},
|
||||||
|
"deleteDialog": {
|
||||||
|
"title": "Aanbieding verwijderen",
|
||||||
|
"message": "Weet je zeker dat je '{{title}}' wilt verwijderen?",
|
||||||
|
"confirmButton": "Verwijderen",
|
||||||
|
"cancelButton": "Annuleren"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"createTitle": "Nieuwe aanbieding",
|
||||||
|
"editTitle": "Aanbieding bewerken",
|
||||||
|
"titleLabel": "Titel",
|
||||||
|
"descriptionLabel": "Beschrijving",
|
||||||
|
"priceLabel": "Prijs",
|
||||||
|
"priceNoteLabel": "Prijsnotitie",
|
||||||
|
"featuresLabel": "Kenmerken",
|
||||||
|
"addFeatureButton": "Kenmerk toevoegen",
|
||||||
|
"removeFeatureButton": "Verwijderen",
|
||||||
|
"ctaLabelLabel": "Knoptekst",
|
||||||
|
"featuredLabel": "Uitgelicht",
|
||||||
|
"submitButton": "Opslaan",
|
||||||
|
"successToast": "Aanbieding succesvol opgeslagen"
|
||||||
|
}
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"403": {
|
"403": {
|
||||||
"title": "Toegang geweigerd",
|
"title": "Toegang geweigerd",
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { invitationHandlers } from '@/features/invitation/mocks/handlers';
|
|||||||
import { availabilityHandlers } from '@/features/availability/mocks/handlers';
|
import { availabilityHandlers } from '@/features/availability/mocks/handlers';
|
||||||
import { cmsHandlers } from '@/features/cms/mocks/handlers';
|
import { cmsHandlers } from '@/features/cms/mocks/handlers';
|
||||||
import { systemHandlers } from '@/features/system/mocks/handlers';
|
import { systemHandlers } from '@/features/system/mocks/handlers';
|
||||||
|
import { offeringsHandlers } from '@/features/offerings/mocks/handlers';
|
||||||
|
|
||||||
/** All default MSW handlers, composed from feature folders (Q3-B). */
|
/** All default MSW handlers, composed from feature folders (Q3-B). */
|
||||||
export const handlers = [...authHandlers, ...userHandlers, ...setupHandlers, ...invitationHandlers, ...availabilityHandlers, ...cmsHandlers, ...systemHandlers];
|
export const handlers = [...authHandlers, ...userHandlers, ...setupHandlers, ...invitationHandlers, ...availabilityHandlers, ...cmsHandlers, ...systemHandlers, ...offeringsHandlers];
|
||||||
|
|
||||||
export { authHandlers } from '@/features/auth/mocks/handlers';
|
export { authHandlers } from '@/features/auth/mocks/handlers';
|
||||||
export { userHandlers } from '@/features/users/mocks/handlers';
|
export { userHandlers } from '@/features/users/mocks/handlers';
|
||||||
@@ -16,4 +17,5 @@ export { invitationHandlers } from '@/features/invitation/mocks/handlers';
|
|||||||
export { availabilityHandlers } from '@/features/availability/mocks/handlers';
|
export { availabilityHandlers } from '@/features/availability/mocks/handlers';
|
||||||
export { cmsHandlers, resetMockCmsInstances, getMockCmsInstances } from '@/features/cms/mocks/handlers';
|
export { cmsHandlers, resetMockCmsInstances, getMockCmsInstances } from '@/features/cms/mocks/handlers';
|
||||||
export { systemHandlers } from '@/features/system/mocks/handlers';
|
export { systemHandlers } from '@/features/system/mocks/handlers';
|
||||||
|
export { offeringsHandlers, resetMockOfferings, getMockOfferings } from '@/features/offerings/mocks/handlers';
|
||||||
export * from '@/features/auth/mocks/fixtures';
|
export * from '@/features/auth/mocks/fixtures';
|
||||||
|
|||||||
+46
-1
@@ -197,6 +197,42 @@ const cmsRoute = createRoute({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const offeringsRoute = createRoute({
|
||||||
|
getParentRoute: () => authenticatedRoute,
|
||||||
|
path: '/offerings',
|
||||||
|
component: () => (
|
||||||
|
<RoleGuard allowedRoles={['Owner', 'Administrator']}>
|
||||||
|
<ModuleGuard requiredModule="Offerings">
|
||||||
|
{lazyPage(() => import('@/features/offerings/pages/OfferingsListPage'), 'OfferingsListPage')()}
|
||||||
|
</ModuleGuard>
|
||||||
|
</RoleGuard>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const offeringsNewRoute = createRoute({
|
||||||
|
getParentRoute: () => authenticatedRoute,
|
||||||
|
path: '/offerings/new',
|
||||||
|
component: () => (
|
||||||
|
<RoleGuard allowedRoles={['Owner', 'Administrator']}>
|
||||||
|
<ModuleGuard requiredModule="Offerings">
|
||||||
|
{lazyPage(() => import('@/features/offerings/pages/OfferingFormPage'), 'OfferingFormPage')()}
|
||||||
|
</ModuleGuard>
|
||||||
|
</RoleGuard>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const offeringsEditRoute = createRoute({
|
||||||
|
getParentRoute: () => authenticatedRoute,
|
||||||
|
path: '/offerings/$id/edit',
|
||||||
|
component: () => (
|
||||||
|
<RoleGuard allowedRoles={['Owner', 'Administrator']}>
|
||||||
|
<ModuleGuard requiredModule="Offerings">
|
||||||
|
{lazyPage(() => import('@/features/offerings/pages/OfferingFormPage'), 'OfferingFormPage')()}
|
||||||
|
</ModuleGuard>
|
||||||
|
</RoleGuard>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
const settingsRoute = createRoute({
|
const settingsRoute = createRoute({
|
||||||
getParentRoute: () => authenticatedRoute,
|
getParentRoute: () => authenticatedRoute,
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
@@ -225,7 +261,16 @@ export const routeTree = rootRoute.addChildren([
|
|||||||
setupRoute,
|
setupRoute,
|
||||||
inviteCompleteRoute,
|
inviteCompleteRoute,
|
||||||
accessDeniedRoute,
|
accessDeniedRoute,
|
||||||
authenticatedRoute.addChildren([dashboardRoute, usersRoute, cmsRoute, settingsRoute, profileRoute]),
|
authenticatedRoute.addChildren([
|
||||||
|
dashboardRoute,
|
||||||
|
usersRoute,
|
||||||
|
cmsRoute,
|
||||||
|
offeringsRoute,
|
||||||
|
offeringsNewRoute,
|
||||||
|
offeringsEditRoute,
|
||||||
|
settingsRoute,
|
||||||
|
profileRoute,
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<ProjectReference Include="..\SlpModularCms.Modules.Availability\SlpModularCms.Modules.Availability.csproj" />
|
<ProjectReference Include="..\SlpModularCms.Modules.Availability\SlpModularCms.Modules.Availability.csproj" />
|
||||||
<ProjectReference Include="..\SlpModularCms.Modules.Identity\SlpModularCms.Modules.Identity.csproj" />
|
<ProjectReference Include="..\SlpModularCms.Modules.Identity\SlpModularCms.Modules.Identity.csproj" />
|
||||||
<ProjectReference Include="..\SlpModularCms.Modules.Master\SlpModularCms.Modules.Master.csproj" />
|
<ProjectReference Include="..\SlpModularCms.Modules.Master\SlpModularCms.Modules.Master.csproj" />
|
||||||
|
<ProjectReference Include="..\SlpModularCms.Modules.Offerings\SlpModularCms.Modules.Offerings.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -34,12 +34,19 @@
|
|||||||
"MasterUrl": "https://localhost:7223"
|
"MasterUrl": "https://localhost:7223"
|
||||||
},
|
},
|
||||||
"Cors": {
|
"Cors": {
|
||||||
// Port 5175, not 5173: that's Api's own frontend dev port (see frontend/package.json's
|
// 5175: this repo's admin frontend, reached via `pnpm dev:slpsoftware` (see
|
||||||
// plain `pnpm dev`). This project is reached via `pnpm dev:slpsoftware`, which runs the
|
// .env.slpsoftware.local / package.json) — NOT the same as 5173, which is Api's own
|
||||||
// frontend dev server on 5175 (see .env.slpsoftware.local / package.json).
|
// (Master) admin frontend dev port and would be the wrong origin to allow here.
|
||||||
|
// 5173: the actual public SlpSoftware website (separate repo/workspace,
|
||||||
|
// K:\Development\SlpSoftware\Projects\SlpSoftware), calling the anonymous
|
||||||
|
// GET /api/v1/offerings endpoint from its own Vite dev server. Coincidentally the same
|
||||||
|
// port number as Api's admin frontend above, but a different codebase/origin entirely —
|
||||||
|
// both need to be allowed here since Api.SlpSoftware serves both consumers.
|
||||||
"AllowedOrigins": [
|
"AllowedOrigins": [
|
||||||
"http://localhost:5175",
|
"http://localhost:5175",
|
||||||
"https://localhost:5175"
|
"https://localhost:5175",
|
||||||
|
"http://localhost:5173",
|
||||||
|
"https://localhost:5173"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"RateLimiting": {
|
"RateLimiting": {
|
||||||
|
|||||||
@@ -48,6 +48,10 @@
|
|||||||
"SentryTunnel": {
|
"SentryTunnel": {
|
||||||
"PermitLimit": 60,
|
"PermitLimit": 60,
|
||||||
"WindowSeconds": 60
|
"WindowSeconds": 60
|
||||||
|
},
|
||||||
|
"OfferingsPublic": {
|
||||||
|
"PermitLimit": 120,
|
||||||
|
"WindowSeconds": 60
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"SecurityHeaders": {
|
"SecurityHeaders": {
|
||||||
|
|||||||
@@ -184,6 +184,17 @@ public static class ServiceCollectionExtensions
|
|||||||
opt.Window = TimeSpan.FromSeconds(settings.GetValue<int>("WindowSeconds", 60));
|
opt.Window = TimeSpan.FromSeconds(settings.GetValue<int>("WindowSeconds", 60));
|
||||||
opt.QueueLimit = 0;
|
opt.QueueLimit = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Guards the anonymous public offerings listing (NFR-OFF-01) against scraping.
|
||||||
|
// Only the public GET carries this policy — admin mutation endpoints are already
|
||||||
|
// behind AdminOnly authentication (Offerings NFR Requirements Q1 = A).
|
||||||
|
options.AddFixedWindowLimiter("offerings-public", opt =>
|
||||||
|
{
|
||||||
|
var settings = configuration.GetSection("RateLimiting:OfferingsPublic");
|
||||||
|
opt.PermitLimit = settings.GetValue<int>("PermitLimit", 120);
|
||||||
|
opt.Window = TimeSpan.FromSeconds(settings.GetValue<int>("WindowSeconds", 60));
|
||||||
|
opt.QueueLimit = 0;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using NSubstitute;
|
||||||
|
using SlpModularCms.Modules.Offerings.Controllers;
|
||||||
|
using SlpModularCms.Modules.Offerings.Models;
|
||||||
|
using SlpModularCms.Modules.Offerings.Services;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Tests.Controllers;
|
||||||
|
|
||||||
|
public class OfferingsControllerTests
|
||||||
|
{
|
||||||
|
private readonly IOfferingsService _service = Substitute.For<IOfferingsService>();
|
||||||
|
|
||||||
|
private OfferingsController CreateSut()
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||||
|
[new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString())], "TestAuth")),
|
||||||
|
};
|
||||||
|
return new OfferingsController(_service) { ControllerContext = new ControllerContext { HttpContext = httpContext } };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OfferingDto PublicDto() => new(
|
||||||
|
Guid.NewGuid().ToString(), "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", false);
|
||||||
|
|
||||||
|
private static OfferingAdminDto AdminDto(int displayOrder = 0) => new(
|
||||||
|
Guid.NewGuid().ToString(), "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", false, displayOrder);
|
||||||
|
|
||||||
|
private static CreateOfferingRequest CreateRequest() => new(
|
||||||
|
"Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact");
|
||||||
|
|
||||||
|
private static UpdateOfferingRequest UpdateRequest() => new(
|
||||||
|
"Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact");
|
||||||
|
|
||||||
|
// --- GetOfferings (public) ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetOfferings_Returns200_WithList()
|
||||||
|
{
|
||||||
|
_service.GetPublicOfferingsAsync().Returns([PublicDto()]);
|
||||||
|
|
||||||
|
var result = await CreateSut().GetOfferings();
|
||||||
|
|
||||||
|
result.Should().BeOfType<OkObjectResult>().Which.StatusCode.Should().Be(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GetAllForAdmin ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAllForAdmin_Returns200_WithList()
|
||||||
|
{
|
||||||
|
_service.GetAllForAdminAsync().Returns([AdminDto()]);
|
||||||
|
|
||||||
|
var result = await CreateSut().GetAllForAdmin();
|
||||||
|
|
||||||
|
result.Should().BeOfType<OkObjectResult>().Which.StatusCode.Should().Be(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Create ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_Returns201Created()
|
||||||
|
{
|
||||||
|
_service.CreateAsync(Arg.Any<CreateOfferingRequest>(), Arg.Any<Guid>()).Returns(AdminDto());
|
||||||
|
|
||||||
|
var result = await CreateSut().Create(CreateRequest());
|
||||||
|
|
||||||
|
result.Should().BeOfType<CreatedAtActionResult>().Which.StatusCode.Should().Be(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Update ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_Returns200_WhenFound()
|
||||||
|
{
|
||||||
|
_service.UpdateAsync(Arg.Any<Guid>(), Arg.Any<UpdateOfferingRequest>(), Arg.Any<Guid>()).Returns(AdminDto());
|
||||||
|
|
||||||
|
var result = await CreateSut().Update(Guid.NewGuid(), UpdateRequest());
|
||||||
|
|
||||||
|
result.Should().BeOfType<OkObjectResult>().Which.StatusCode.Should().Be(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_Returns404_WhenNotFound()
|
||||||
|
{
|
||||||
|
_service.UpdateAsync(Arg.Any<Guid>(), Arg.Any<UpdateOfferingRequest>(), Arg.Any<Guid>())
|
||||||
|
.Returns((OfferingAdminDto?)null);
|
||||||
|
|
||||||
|
var result = await CreateSut().Update(Guid.NewGuid(), UpdateRequest());
|
||||||
|
|
||||||
|
result.Should().BeOfType<NotFoundResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Delete ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Delete_Returns204_WhenDeleted()
|
||||||
|
{
|
||||||
|
_service.DeleteAsync(Arg.Any<Guid>(), Arg.Any<Guid>()).Returns(true);
|
||||||
|
|
||||||
|
var result = await CreateSut().Delete(Guid.NewGuid());
|
||||||
|
|
||||||
|
result.Should().BeOfType<NoContentResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Delete_Returns404_WhenNotFound()
|
||||||
|
{
|
||||||
|
_service.DeleteAsync(Arg.Any<Guid>(), Arg.Any<Guid>()).Returns(false);
|
||||||
|
|
||||||
|
var result = await CreateSut().Delete(Guid.NewGuid());
|
||||||
|
|
||||||
|
result.Should().BeOfType<NotFoundResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reorder / MoveUp / MoveDown ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reorder_Returns204()
|
||||||
|
{
|
||||||
|
var result = await CreateSut().Reorder(new ReorderOfferingsRequest([Guid.NewGuid()]));
|
||||||
|
|
||||||
|
result.Should().BeOfType<NoContentResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveUp_Returns204()
|
||||||
|
{
|
||||||
|
var result = await CreateSut().MoveUp(Guid.NewGuid());
|
||||||
|
|
||||||
|
result.Should().BeOfType<NoContentResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveDown_Returns204()
|
||||||
|
{
|
||||||
|
var result = await CreateSut().MoveDown(Guid.NewGuid());
|
||||||
|
|
||||||
|
result.Should().BeOfType<NoContentResult>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SlpModularCms.Modules.Offerings.Data;
|
||||||
|
using SlpModularCms.Modules.Offerings.Data.Entities;
|
||||||
|
using SlpModularCms.Modules.Offerings.Repositories;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Tests.Repositories;
|
||||||
|
|
||||||
|
public class OfferingRepositoryTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly OfferingsDbContext _context;
|
||||||
|
private readonly OfferingRepository _sut;
|
||||||
|
|
||||||
|
public OfferingRepositoryTests()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<OfferingsDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
_context = new OfferingsDbContext(options);
|
||||||
|
_sut = new OfferingRepository(_context);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAllAsync_OrdersByDisplayOrder()
|
||||||
|
{
|
||||||
|
await _sut.AddAsync(Offering("B", displayOrder: 1));
|
||||||
|
await _sut.AddAsync(Offering("A", displayOrder: 0));
|
||||||
|
await _sut.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _sut.GetAllAsync();
|
||||||
|
|
||||||
|
result.Select(o => o.Title).Should().ContainInOrder("A", "B");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAllAsync_ExcludesSoftDeleted()
|
||||||
|
{
|
||||||
|
var deleted = Offering("Deleted", displayOrder: 0);
|
||||||
|
deleted.IsDeleted = true;
|
||||||
|
await _sut.AddAsync(deleted);
|
||||||
|
await _sut.AddAsync(Offering("Active", displayOrder: 1));
|
||||||
|
await _sut.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _sut.GetAllAsync();
|
||||||
|
|
||||||
|
result.Should().ContainSingle(o => o.Title == "Active");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByIdAsync_ReturnsNull_WhenSoftDeleted()
|
||||||
|
{
|
||||||
|
var offering = Offering("Deleted", displayOrder: 0);
|
||||||
|
offering.IsDeleted = true;
|
||||||
|
await _sut.AddAsync(offering);
|
||||||
|
await _sut.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _sut.GetByIdAsync(offering.Id);
|
||||||
|
|
||||||
|
result.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetMaxDisplayOrderAsync_ReturnsMinusOne_WhenEmpty()
|
||||||
|
{
|
||||||
|
var result = await _sut.GetMaxDisplayOrderAsync();
|
||||||
|
|
||||||
|
result.Should().Be(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetMaxDisplayOrderAsync_ReturnsHighestValue()
|
||||||
|
{
|
||||||
|
await _sut.AddAsync(Offering("A", displayOrder: 3));
|
||||||
|
await _sut.AddAsync(Offering("B", displayOrder: 7));
|
||||||
|
await _sut.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _sut.GetMaxDisplayOrderAsync();
|
||||||
|
|
||||||
|
result.Should().Be(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetFeaturedAsync_ReturnsFeaturedOffering()
|
||||||
|
{
|
||||||
|
var featured = Offering("Featured", displayOrder: 0);
|
||||||
|
featured.Featured = true;
|
||||||
|
await _sut.AddAsync(featured);
|
||||||
|
await _sut.AddAsync(Offering("Other", displayOrder: 1));
|
||||||
|
await _sut.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _sut.GetFeaturedAsync();
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result!.Title.Should().Be("Featured");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetFeaturedAsync_ReturnsNull_WhenNoneFeatured()
|
||||||
|
{
|
||||||
|
await _sut.AddAsync(Offering("A", displayOrder: 0));
|
||||||
|
await _sut.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _sut.GetFeaturedAsync();
|
||||||
|
|
||||||
|
result.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPreviousAsync_ReturnsClosestLowerDisplayOrder()
|
||||||
|
{
|
||||||
|
await _sut.AddAsync(Offering("First", displayOrder: 0));
|
||||||
|
await _sut.AddAsync(Offering("Second", displayOrder: 1));
|
||||||
|
await _sut.AddAsync(Offering("Third", displayOrder: 2));
|
||||||
|
await _sut.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _sut.GetPreviousAsync(2);
|
||||||
|
|
||||||
|
result!.Title.Should().Be("Second");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPreviousAsync_ReturnsNull_WhenFirst()
|
||||||
|
{
|
||||||
|
await _sut.AddAsync(Offering("First", displayOrder: 0));
|
||||||
|
await _sut.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _sut.GetPreviousAsync(0);
|
||||||
|
|
||||||
|
result.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetNextAsync_ReturnsClosestHigherDisplayOrder()
|
||||||
|
{
|
||||||
|
await _sut.AddAsync(Offering("First", displayOrder: 0));
|
||||||
|
await _sut.AddAsync(Offering("Second", displayOrder: 1));
|
||||||
|
await _sut.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _sut.GetNextAsync(0);
|
||||||
|
|
||||||
|
result!.Title.Should().Be("Second");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetNextAsync_ReturnsNull_WhenLast()
|
||||||
|
{
|
||||||
|
await _sut.AddAsync(Offering("Last", displayOrder: 0));
|
||||||
|
await _sut.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _sut.GetNextAsync(0);
|
||||||
|
|
||||||
|
result.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_PersistsChanges()
|
||||||
|
{
|
||||||
|
var offering = Offering("Original", displayOrder: 0);
|
||||||
|
await _sut.AddAsync(offering);
|
||||||
|
await _sut.SaveChangesAsync();
|
||||||
|
|
||||||
|
offering.Title = "Updated";
|
||||||
|
_sut.Update(offering);
|
||||||
|
await _sut.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _sut.GetByIdAsync(offering.Id);
|
||||||
|
result!.Title.Should().Be("Updated");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Offering Offering(string title, int displayOrder) => new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Title = title,
|
||||||
|
Description = "Description",
|
||||||
|
Price = "€ 100",
|
||||||
|
PriceNote = "per month",
|
||||||
|
Features = ["Feature 1"],
|
||||||
|
CtaLabel = "Contact",
|
||||||
|
DisplayOrder = displayOrder,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow,
|
||||||
|
LastModifiedByUserId = Guid.NewGuid(),
|
||||||
|
};
|
||||||
|
|
||||||
|
public void Dispose() => _context.Dispose();
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using NSubstitute;
|
||||||
|
using SlpModularCms.Modules.Offerings.Data.Entities;
|
||||||
|
using SlpModularCms.Modules.Offerings.Models;
|
||||||
|
using SlpModularCms.Modules.Offerings.Repositories;
|
||||||
|
using SlpModularCms.Modules.Offerings.Services;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Tests.Services;
|
||||||
|
|
||||||
|
public class OfferingsServiceTests
|
||||||
|
{
|
||||||
|
private readonly IOfferingRepository _repo = Substitute.For<IOfferingRepository>();
|
||||||
|
private readonly ILogger<OfferingsService> _logger = Substitute.For<ILogger<OfferingsService>>();
|
||||||
|
|
||||||
|
public OfferingsServiceTests()
|
||||||
|
{
|
||||||
|
_repo.BeginTransactionAsync().Returns(Substitute.For<IDbContextTransaction>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private OfferingsService CreateSut() => new(_repo, _logger);
|
||||||
|
|
||||||
|
private static CreateOfferingRequest CreateRequest(bool featured = false) => new(
|
||||||
|
"Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", featured);
|
||||||
|
|
||||||
|
private static Offering ExistingOffering(bool featured = false, int displayOrder = 0) => new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Title = "Existing",
|
||||||
|
Description = "Description",
|
||||||
|
Price = "€ 100",
|
||||||
|
PriceNote = "per month",
|
||||||
|
Features = ["Feature 1"],
|
||||||
|
CtaLabel = "Contact",
|
||||||
|
Featured = featured,
|
||||||
|
DisplayOrder = displayOrder,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow,
|
||||||
|
LastModifiedByUserId = Guid.NewGuid(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- CreateAsync / BR-OFF-01 featured exclusivity ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_AppendsToEndOfDisplayOrder()
|
||||||
|
{
|
||||||
|
_repo.GetMaxDisplayOrderAsync().Returns(4);
|
||||||
|
var callerId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var result = await CreateSut().CreateAsync(CreateRequest(), callerId);
|
||||||
|
|
||||||
|
result.DisplayOrder.Should().Be(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_SetsLastModifiedByUserId()
|
||||||
|
{
|
||||||
|
_repo.GetMaxDisplayOrderAsync().Returns(-1);
|
||||||
|
var callerId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var dto = await CreateSut().CreateAsync(CreateRequest(), callerId);
|
||||||
|
|
||||||
|
// The DTO doesn't expose LastModifiedByUserId (never shown to any caller) —
|
||||||
|
// verify via the entity actually persisted through the repository instead.
|
||||||
|
await _repo.Received(1).AddAsync(Arg.Is<Offering>(o => o.LastModifiedByUserId == callerId));
|
||||||
|
dto.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_UnfeaturesCurrentlyFeaturedOffering_WhenNewOneIsFeatured()
|
||||||
|
{
|
||||||
|
var current = ExistingOffering(featured: true);
|
||||||
|
_repo.GetFeaturedAsync().Returns(current);
|
||||||
|
_repo.GetMaxDisplayOrderAsync().Returns(-1);
|
||||||
|
|
||||||
|
await CreateSut().CreateAsync(CreateRequest(featured: true), Guid.NewGuid());
|
||||||
|
|
||||||
|
current.Featured.Should().BeFalse();
|
||||||
|
await _repo.Received(1).BeginTransactionAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_DoesNotTouchFeatured_WhenNewOfferingNotFeatured()
|
||||||
|
{
|
||||||
|
_repo.GetMaxDisplayOrderAsync().Returns(-1);
|
||||||
|
|
||||||
|
await CreateSut().CreateAsync(CreateRequest(featured: false), Guid.NewGuid());
|
||||||
|
|
||||||
|
await _repo.DidNotReceive().GetFeaturedAsync();
|
||||||
|
await _repo.DidNotReceive().BeginTransactionAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UpdateAsync ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_ReturnsNull_WhenOfferingNotFound()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(Arg.Any<Guid>()).Returns((Offering?)null);
|
||||||
|
|
||||||
|
var request = new UpdateOfferingRequest("Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact");
|
||||||
|
var result = await CreateSut().UpdateAsync(Guid.NewGuid(), request, Guid.NewGuid());
|
||||||
|
|
||||||
|
result.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_UnfeaturesOtherOffering_ExceptItself()
|
||||||
|
{
|
||||||
|
var target = ExistingOffering(featured: false);
|
||||||
|
var other = ExistingOffering(featured: true);
|
||||||
|
_repo.GetByIdAsync(target.Id).Returns(target);
|
||||||
|
_repo.GetFeaturedAsync().Returns(other);
|
||||||
|
|
||||||
|
var request = new UpdateOfferingRequest("Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", true);
|
||||||
|
await CreateSut().UpdateAsync(target.Id, request, Guid.NewGuid());
|
||||||
|
|
||||||
|
target.Featured.Should().BeTrue();
|
||||||
|
other.Featured.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_DoesNotUnfeatureItself_WhenAlreadyFeatured()
|
||||||
|
{
|
||||||
|
var target = ExistingOffering(featured: true);
|
||||||
|
_repo.GetByIdAsync(target.Id).Returns(target);
|
||||||
|
_repo.GetFeaturedAsync().Returns(target);
|
||||||
|
|
||||||
|
var request = new UpdateOfferingRequest("Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", true);
|
||||||
|
await CreateSut().UpdateAsync(target.Id, request, Guid.NewGuid());
|
||||||
|
|
||||||
|
target.Featured.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DeleteAsync / BR-OFF-02 ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteAsync_ReturnsFalse_WhenNotFound()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(Arg.Any<Guid>()).Returns((Offering?)null);
|
||||||
|
|
||||||
|
var result = await CreateSut().DeleteAsync(Guid.NewGuid(), Guid.NewGuid());
|
||||||
|
|
||||||
|
result.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteAsync_SoftDeletes_EvenForLastRemainingOffering()
|
||||||
|
{
|
||||||
|
var offering = ExistingOffering();
|
||||||
|
_repo.GetByIdAsync(offering.Id).Returns(offering);
|
||||||
|
|
||||||
|
var result = await CreateSut().DeleteAsync(offering.Id, Guid.NewGuid());
|
||||||
|
|
||||||
|
result.Should().BeTrue();
|
||||||
|
offering.IsDeleted.Should().BeTrue();
|
||||||
|
offering.DeletedAt.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ReorderAsync / BR-OFF-03 ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReorderAsync_ReassignsDisplayOrderSequentially()
|
||||||
|
{
|
||||||
|
var first = ExistingOffering(displayOrder: 5);
|
||||||
|
var second = ExistingOffering(displayOrder: 2);
|
||||||
|
_repo.GetByIdAsync(first.Id).Returns(first);
|
||||||
|
_repo.GetByIdAsync(second.Id).Returns(second);
|
||||||
|
|
||||||
|
await CreateSut().ReorderAsync([first.Id, second.Id], Guid.NewGuid());
|
||||||
|
|
||||||
|
first.DisplayOrder.Should().Be(0);
|
||||||
|
second.DisplayOrder.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MoveUpAsync / MoveDownAsync / BR-OFF-03 boundaries ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveUpAsync_IsNoOp_WhenAlreadyFirst()
|
||||||
|
{
|
||||||
|
var offering = ExistingOffering(displayOrder: 0);
|
||||||
|
_repo.GetByIdAsync(offering.Id).Returns(offering);
|
||||||
|
_repo.GetPreviousAsync(0).Returns((Offering?)null);
|
||||||
|
|
||||||
|
await CreateSut().MoveUpAsync(offering.Id, Guid.NewGuid());
|
||||||
|
|
||||||
|
await _repo.DidNotReceive().BeginTransactionAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveUpAsync_SwapsDisplayOrder_WithPrevious()
|
||||||
|
{
|
||||||
|
var offering = ExistingOffering(displayOrder: 1);
|
||||||
|
var previous = ExistingOffering(displayOrder: 0);
|
||||||
|
_repo.GetByIdAsync(offering.Id).Returns(offering);
|
||||||
|
_repo.GetPreviousAsync(1).Returns(previous);
|
||||||
|
|
||||||
|
await CreateSut().MoveUpAsync(offering.Id, Guid.NewGuid());
|
||||||
|
|
||||||
|
offering.DisplayOrder.Should().Be(0);
|
||||||
|
previous.DisplayOrder.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveDownAsync_IsNoOp_WhenAlreadyLast()
|
||||||
|
{
|
||||||
|
var offering = ExistingOffering(displayOrder: 0);
|
||||||
|
_repo.GetByIdAsync(offering.Id).Returns(offering);
|
||||||
|
_repo.GetNextAsync(0).Returns((Offering?)null);
|
||||||
|
|
||||||
|
await CreateSut().MoveDownAsync(offering.Id, Guid.NewGuid());
|
||||||
|
|
||||||
|
await _repo.DidNotReceive().BeginTransactionAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveDownAsync_SwapsDisplayOrder_WithNext()
|
||||||
|
{
|
||||||
|
var offering = ExistingOffering(displayOrder: 0);
|
||||||
|
var next = ExistingOffering(displayOrder: 1);
|
||||||
|
_repo.GetByIdAsync(offering.Id).Returns(offering);
|
||||||
|
_repo.GetNextAsync(0).Returns(next);
|
||||||
|
|
||||||
|
await CreateSut().MoveDownAsync(offering.Id, Guid.NewGuid());
|
||||||
|
|
||||||
|
offering.DisplayOrder.Should().Be(1);
|
||||||
|
next.DisplayOrder.Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GetPublicOfferingsAsync / GetAllForAdminAsync ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPublicOfferingsAsync_MapsToPublicDto()
|
||||||
|
{
|
||||||
|
_repo.GetAllAsync().Returns([ExistingOffering()]);
|
||||||
|
|
||||||
|
var result = await CreateSut().GetPublicOfferingsAsync();
|
||||||
|
|
||||||
|
result.Should().ContainSingle();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAllForAdminAsync_IncludesDisplayOrder()
|
||||||
|
{
|
||||||
|
var offering = ExistingOffering(displayOrder: 3);
|
||||||
|
_repo.GetAllAsync().Returns([offering]);
|
||||||
|
|
||||||
|
var result = await CreateSut().GetAllForAdminAsync();
|
||||||
|
|
||||||
|
result[0].DisplayOrder.Should().Be(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="FluentAssertions" Version="8.10.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="10.0.9" />
|
||||||
|
<!-- Pinned above the version DataProtection pulls transitively (FR-22, OPEN-03) — see Core.csproj -->
|
||||||
|
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.10" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Microsoft.Extensions.Options" />
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\SlpModularCms.Modules.Offerings\SlpModularCms.Modules.Offerings.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using SlpModularCms.Modules.Offerings.Models;
|
||||||
|
using SlpModularCms.Modules.Offerings.Services;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("offerings")]
|
||||||
|
[Authorize(Policy = "AdminOnly")]
|
||||||
|
public class OfferingsController(IOfferingsService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[EnableRateLimiting("offerings-public")]
|
||||||
|
public async Task<IActionResult> GetOfferings()
|
||||||
|
{
|
||||||
|
var offerings = await service.GetPublicOfferingsAsync();
|
||||||
|
return Ok(offerings);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("admin")]
|
||||||
|
public async Task<IActionResult> GetAllForAdmin()
|
||||||
|
{
|
||||||
|
var offerings = await service.GetAllForAdminAsync();
|
||||||
|
return Ok(offerings);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("admin")]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateOfferingRequest request)
|
||||||
|
{
|
||||||
|
var dto = await service.CreateAsync(request, GetCallerId());
|
||||||
|
return CreatedAtAction(nameof(GetAllForAdmin), null, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("admin/{id:guid}")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateOfferingRequest request)
|
||||||
|
{
|
||||||
|
var dto = await service.UpdateAsync(id, request, GetCallerId());
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("admin/{id:guid}")]
|
||||||
|
public async Task<IActionResult> Delete(Guid id)
|
||||||
|
{
|
||||||
|
var deleted = await service.DeleteAsync(id, GetCallerId());
|
||||||
|
return deleted ? NoContent() : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("admin/reorder")]
|
||||||
|
public async Task<IActionResult> Reorder([FromBody] ReorderOfferingsRequest request)
|
||||||
|
{
|
||||||
|
await service.ReorderAsync(request.OrderedIds, GetCallerId());
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("admin/{id:guid}/move-up")]
|
||||||
|
public async Task<IActionResult> MoveUp(Guid id)
|
||||||
|
{
|
||||||
|
await service.MoveUpAsync(id, GetCallerId());
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("admin/{id:guid}/move-down")]
|
||||||
|
public async Task<IActionResult> MoveDown(Guid id)
|
||||||
|
{
|
||||||
|
await service.MoveDownAsync(id, GetCallerId());
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Guid GetCallerId()
|
||||||
|
{
|
||||||
|
var claim = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||||
|
return Guid.TryParse(claim, out var id) ? id : Guid.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace SlpModularCms.Modules.Offerings.Data.Entities;
|
||||||
|
|
||||||
|
public class Offering
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public string Price { get; set; } = string.Empty;
|
||||||
|
public string PriceNote { get; set; } = string.Empty;
|
||||||
|
public List<string> Features { get; set; } = [];
|
||||||
|
public string CtaLabel { get; set; } = string.Empty;
|
||||||
|
public bool Featured { get; set; }
|
||||||
|
public int DisplayOrder { get; set; }
|
||||||
|
public bool IsDeleted { get; set; }
|
||||||
|
public DateTimeOffset? DeletedAt { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
public DateTimeOffset UpdatedAt { get; set; }
|
||||||
|
public Guid LastModifiedByUserId { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||||
|
using SlpModularCms.Modules.Offerings.Data.Entities;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Data;
|
||||||
|
|
||||||
|
public class OfferingsDbContext(DbContextOptions<OfferingsDbContext> options) : DbContext(options)
|
||||||
|
{
|
||||||
|
public DbSet<Offering> Offerings => Set<Offering>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Offering>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("OfferingsOfferings");
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.Property(e => e.Title).IsRequired().HasMaxLength(100);
|
||||||
|
entity.Property(e => e.Description).IsRequired().HasMaxLength(500);
|
||||||
|
entity.Property(e => e.Price).IsRequired().HasMaxLength(50);
|
||||||
|
entity.Property(e => e.PriceNote).IsRequired().HasMaxLength(100);
|
||||||
|
entity.Property(e => e.CtaLabel).IsRequired().HasMaxLength(50);
|
||||||
|
entity.Property(e => e.DisplayOrder).IsRequired();
|
||||||
|
entity.Property(e => e.IsDeleted).IsRequired();
|
||||||
|
entity.Property(e => e.CreatedAt).IsRequired();
|
||||||
|
entity.Property(e => e.UpdatedAt).IsRequired();
|
||||||
|
entity.Property(e => e.LastModifiedByUserId).IsRequired();
|
||||||
|
|
||||||
|
// Features is a small, always-fully-replaced string list (max 10 items of max 200
|
||||||
|
// chars) — a JSON column is simpler than a child table for something never queried
|
||||||
|
// by individual feature value.
|
||||||
|
entity.Property(e => e.Features)
|
||||||
|
.HasConversion(
|
||||||
|
features => JsonSerializer.Serialize(features, (JsonSerializerOptions?)null),
|
||||||
|
json => JsonSerializer.Deserialize<List<string>>(json, (JsonSerializerOptions?)null) ?? new List<string>())
|
||||||
|
.Metadata.SetValueComparer(new ValueComparer<List<string>>(
|
||||||
|
(a, b) => (a ?? new List<string>()).SequenceEqual(b ?? new List<string>()),
|
||||||
|
v => v.Aggregate(0, (hash, item) => HashCode.Combine(hash, item.GetHashCode())),
|
||||||
|
v => v.ToList()));
|
||||||
|
|
||||||
|
entity.HasQueryFilter(e => !e.IsDeleted);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using SlpModularCms.Modules.Offerings.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(OfferingsDbContext))]
|
||||||
|
[Migration("20260802124751_InitialCreate")]
|
||||||
|
partial class InitialCreate
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
||||||
|
|
||||||
|
modelBuilder.Entity("SlpModularCms.Modules.Offerings.Data.Entities.Offering", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("datetime");
|
||||||
|
|
||||||
|
b.Property<string>("CtaLabel")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("varchar(50)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("varchar(500)");
|
||||||
|
|
||||||
|
b.Property<int>("DisplayOrder")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("Featured")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Features")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<Guid>("LastModifiedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("Price")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("varchar(50)");
|
||||||
|
|
||||||
|
b.Property<string>("PriceNote")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("varchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("varchar(100)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("OfferingsOfferings", (string)null);
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialCreate : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterDatabase()
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "OfferingsOfferings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
Title = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
|
||||||
|
Description = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false),
|
||||||
|
Price = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false),
|
||||||
|
PriceNote = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
|
||||||
|
Features = table.Column<string>(type: "longtext", nullable: false),
|
||||||
|
CtaLabel = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false),
|
||||||
|
Featured = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
DisplayOrder = table.Column<int>(type: "int", nullable: false),
|
||||||
|
IsDeleted = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTimeOffset>(type: "datetime", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "datetime", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "datetime", nullable: false),
|
||||||
|
LastModifiedByUserId = table.Column<Guid>(type: "char(36)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_OfferingsOfferings", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "OfferingsOfferings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using SlpModularCms.Modules.Offerings.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(OfferingsDbContext))]
|
||||||
|
partial class OfferingsDbContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
||||||
|
|
||||||
|
modelBuilder.Entity("SlpModularCms.Modules.Offerings.Data.Entities.Offering", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("datetime");
|
||||||
|
|
||||||
|
b.Property<string>("CtaLabel")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("varchar(50)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("varchar(500)");
|
||||||
|
|
||||||
|
b.Property<int>("DisplayOrder")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("Featured")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Features")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<Guid>("LastModifiedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("Price")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("varchar(50)");
|
||||||
|
|
||||||
|
b.Property<string>("PriceNote")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("varchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("varchar(100)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("OfferingsOfferings", (string)null);
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Models;
|
||||||
|
|
||||||
|
public record CreateOfferingRequest(
|
||||||
|
[Required, MaxLength(100)] string Title,
|
||||||
|
[Required, MaxLength(500)] string Description,
|
||||||
|
[Required, MaxLength(50)] string Price,
|
||||||
|
[Required, MaxLength(100)] string PriceNote,
|
||||||
|
[Required, FeaturesValidation] List<string> Features,
|
||||||
|
[Required, MaxLength(50)] string CtaLabel,
|
||||||
|
bool Featured = false
|
||||||
|
);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DataAnnotations has no built-in way to bound both collection size and each item's
|
||||||
|
/// length (BR-OFF-04: 1-10 features, each ≤200 characters) — this attribute covers
|
||||||
|
/// what <see cref="MinLengthAttribute"/>/<see cref="MaxLengthAttribute"/> alone cannot.
|
||||||
|
/// </summary>
|
||||||
|
public class FeaturesValidationAttribute : ValidationAttribute
|
||||||
|
{
|
||||||
|
private const int MinItems = 1;
|
||||||
|
private const int MaxItems = 10;
|
||||||
|
private const int MaxItemLength = 200;
|
||||||
|
|
||||||
|
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
|
||||||
|
{
|
||||||
|
if (value is not List<string> features)
|
||||||
|
return new ValidationResult("Features must be a list of strings.", [validationContext.MemberName!]);
|
||||||
|
|
||||||
|
if (features.Count is < MinItems or > MaxItems)
|
||||||
|
return new ValidationResult(
|
||||||
|
$"Features must contain between {MinItems} and {MaxItems} items.",
|
||||||
|
[validationContext.MemberName!]);
|
||||||
|
|
||||||
|
if (features.Any(f => string.IsNullOrWhiteSpace(f) || f.Length > MaxItemLength))
|
||||||
|
return new ValidationResult(
|
||||||
|
$"Each feature must be non-empty and at most {MaxItemLength} characters.",
|
||||||
|
[validationContext.MemberName!]);
|
||||||
|
|
||||||
|
return ValidationResult.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Models;
|
||||||
|
|
||||||
|
[ExcludeFromCodeCoverage]
|
||||||
|
public record OfferingAdminDto(
|
||||||
|
string Id,
|
||||||
|
string Title,
|
||||||
|
string Description,
|
||||||
|
string Price,
|
||||||
|
string PriceNote,
|
||||||
|
IReadOnlyList<string> Features,
|
||||||
|
string CtaLabel,
|
||||||
|
bool Featured,
|
||||||
|
int DisplayOrder
|
||||||
|
);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Models;
|
||||||
|
|
||||||
|
[ExcludeFromCodeCoverage]
|
||||||
|
public record OfferingDto(
|
||||||
|
string Id,
|
||||||
|
string Title,
|
||||||
|
string Description,
|
||||||
|
string Price,
|
||||||
|
string PriceNote,
|
||||||
|
IReadOnlyList<string> Features,
|
||||||
|
string CtaLabel,
|
||||||
|
bool Featured
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Models;
|
||||||
|
|
||||||
|
public record ReorderOfferingsRequest(
|
||||||
|
[Required, MinLength(1)] List<Guid> OrderedIds
|
||||||
|
);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Models;
|
||||||
|
|
||||||
|
public record UpdateOfferingRequest(
|
||||||
|
[Required, MaxLength(100)] string Title,
|
||||||
|
[Required, MaxLength(500)] string Description,
|
||||||
|
[Required, MaxLength(50)] string Price,
|
||||||
|
[Required, MaxLength(100)] string PriceNote,
|
||||||
|
[Required, FeaturesValidation] List<string> Features,
|
||||||
|
[Required, MaxLength(50)] string CtaLabel,
|
||||||
|
bool Featured = false
|
||||||
|
);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SlpModularCms.Core.Hosting;
|
||||||
|
using SlpModularCms.Core.Modules;
|
||||||
|
using SlpModularCms.Modules.Offerings.Data;
|
||||||
|
using SlpModularCms.Modules.Offerings.Repositories;
|
||||||
|
using SlpModularCms.Modules.Offerings.Services;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings;
|
||||||
|
|
||||||
|
[ExcludeFromCodeCoverage]
|
||||||
|
public class OfferingsModule : IModule
|
||||||
|
{
|
||||||
|
public string Name => "Offerings";
|
||||||
|
public string Version => "1.0.0";
|
||||||
|
|
||||||
|
public void RegisterServices(IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddDbContext<OfferingsDbContext>((serviceProvider, options) =>
|
||||||
|
{
|
||||||
|
var config = serviceProvider.GetRequiredService<IConfiguration>();
|
||||||
|
options.UseMySQL(config.GetConnectionString("DefaultConnection")!);
|
||||||
|
options.ReplaceService<IHistoryRepository, NonLockingMySQLHistoryRepository>();
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddScoped<IOfferingRepository, OfferingRepository>();
|
||||||
|
services.AddScoped<IOfferingsService, OfferingsService>();
|
||||||
|
services.AddHttpContextAccessor();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UseModule(IApplicationBuilder app)
|
||||||
|
{
|
||||||
|
using var scope = app.ApplicationServices.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<OfferingsDbContext>();
|
||||||
|
db.Database.Migrate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
|
using SlpModularCms.Modules.Offerings.Data.Entities;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Repositories;
|
||||||
|
|
||||||
|
public interface IOfferingRepository
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<Offering>> GetAllAsync();
|
||||||
|
Task<Offering?> GetByIdAsync(Guid id);
|
||||||
|
Task AddAsync(Offering offering);
|
||||||
|
void Update(Offering offering);
|
||||||
|
Task<int> GetMaxDisplayOrderAsync();
|
||||||
|
Task<Offering?> GetFeaturedAsync();
|
||||||
|
Task<Offering?> GetPreviousAsync(int displayOrder);
|
||||||
|
Task<Offering?> GetNextAsync(int displayOrder);
|
||||||
|
Task SaveChangesAsync();
|
||||||
|
Task<IDbContextTransaction> BeginTransactionAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
|
using SlpModularCms.Modules.Offerings.Data;
|
||||||
|
using SlpModularCms.Modules.Offerings.Data.Entities;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Repositories;
|
||||||
|
|
||||||
|
public class OfferingRepository(OfferingsDbContext context) : IOfferingRepository
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<Offering>> GetAllAsync()
|
||||||
|
=> await context.Offerings.OrderBy(o => o.DisplayOrder).ToListAsync();
|
||||||
|
|
||||||
|
public async Task<Offering?> GetByIdAsync(Guid id)
|
||||||
|
=> await context.Offerings.FirstOrDefaultAsync(o => o.Id == id);
|
||||||
|
|
||||||
|
public async Task AddAsync(Offering offering)
|
||||||
|
=> await context.Offerings.AddAsync(offering);
|
||||||
|
|
||||||
|
public void Update(Offering offering)
|
||||||
|
=> context.Offerings.Update(offering);
|
||||||
|
|
||||||
|
public async Task<int> GetMaxDisplayOrderAsync()
|
||||||
|
=> await context.Offerings.Select(o => (int?)o.DisplayOrder).MaxAsync() ?? -1;
|
||||||
|
|
||||||
|
public async Task<Offering?> GetFeaturedAsync()
|
||||||
|
=> await context.Offerings.FirstOrDefaultAsync(o => o.Featured);
|
||||||
|
|
||||||
|
public async Task<Offering?> GetPreviousAsync(int displayOrder)
|
||||||
|
=> await context.Offerings
|
||||||
|
.Where(o => o.DisplayOrder < displayOrder)
|
||||||
|
.OrderByDescending(o => o.DisplayOrder)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
public async Task<Offering?> GetNextAsync(int displayOrder)
|
||||||
|
=> await context.Offerings
|
||||||
|
.Where(o => o.DisplayOrder > displayOrder)
|
||||||
|
.OrderBy(o => o.DisplayOrder)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
public async Task SaveChangesAsync()
|
||||||
|
=> await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
public async Task<IDbContextTransaction> BeginTransactionAsync()
|
||||||
|
=> await context.Database.BeginTransactionAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using SlpModularCms.Modules.Offerings.Models;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Services;
|
||||||
|
|
||||||
|
public interface IOfferingsService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<OfferingDto>> GetPublicOfferingsAsync();
|
||||||
|
Task<IReadOnlyList<OfferingAdminDto>> GetAllForAdminAsync();
|
||||||
|
Task<OfferingAdminDto> CreateAsync(CreateOfferingRequest request, Guid callerId);
|
||||||
|
Task<OfferingAdminDto?> UpdateAsync(Guid id, UpdateOfferingRequest request, Guid callerId);
|
||||||
|
Task<bool> DeleteAsync(Guid id, Guid callerId);
|
||||||
|
Task ReorderAsync(IReadOnlyList<Guid> orderedIds, Guid callerId);
|
||||||
|
Task MoveUpAsync(Guid id, Guid callerId);
|
||||||
|
Task MoveDownAsync(Guid id, Guid callerId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using SlpModularCms.Modules.Offerings.Data.Entities;
|
||||||
|
using SlpModularCms.Modules.Offerings.Models;
|
||||||
|
using SlpModularCms.Modules.Offerings.Repositories;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Services;
|
||||||
|
|
||||||
|
public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsService> logger) : IOfferingsService
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<OfferingDto>> GetPublicOfferingsAsync()
|
||||||
|
{
|
||||||
|
var offerings = await repository.GetAllAsync();
|
||||||
|
return offerings.Select(ToPublicDto).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<OfferingAdminDto>> GetAllForAdminAsync()
|
||||||
|
{
|
||||||
|
var offerings = await repository.GetAllAsync();
|
||||||
|
return offerings.Select(ToAdminDto).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OfferingAdminDto> CreateAsync(CreateOfferingRequest request, Guid callerId)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var offering = new Offering
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Title = request.Title,
|
||||||
|
Description = request.Description,
|
||||||
|
Price = request.Price,
|
||||||
|
PriceNote = request.PriceNote,
|
||||||
|
Features = request.Features,
|
||||||
|
CtaLabel = request.CtaLabel,
|
||||||
|
Featured = request.Featured,
|
||||||
|
DisplayOrder = await repository.GetMaxDisplayOrderAsync() + 1,
|
||||||
|
CreatedAt = now,
|
||||||
|
UpdatedAt = now,
|
||||||
|
LastModifiedByUserId = callerId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// BR-OFF-01 (featured exclusivity) touches two rows in one logical action —
|
||||||
|
// transactional per NFR Design Pattern 1 so a mid-operation failure can never
|
||||||
|
// leave two offerings both featured or the new one unsaved after the old one
|
||||||
|
// was already un-featured.
|
||||||
|
if (request.Featured)
|
||||||
|
{
|
||||||
|
await using var transaction = await repository.BeginTransactionAsync();
|
||||||
|
await UnfeatureCurrentAsync();
|
||||||
|
await repository.AddAsync(offering);
|
||||||
|
await repository.SaveChangesAsync();
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await repository.AddAsync(offering);
|
||||||
|
await repository.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Offering {OfferingId} {Action} by user {LastModifiedByUserId}",
|
||||||
|
offering.Id, "Created", callerId);
|
||||||
|
|
||||||
|
return ToAdminDto(offering);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OfferingAdminDto?> UpdateAsync(Guid id, UpdateOfferingRequest request, Guid callerId)
|
||||||
|
{
|
||||||
|
var offering = await repository.GetByIdAsync(id);
|
||||||
|
if (offering is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
offering.Title = request.Title;
|
||||||
|
offering.Description = request.Description;
|
||||||
|
offering.Price = request.Price;
|
||||||
|
offering.PriceNote = request.PriceNote;
|
||||||
|
offering.Features = request.Features;
|
||||||
|
offering.CtaLabel = request.CtaLabel;
|
||||||
|
offering.Featured = request.Featured;
|
||||||
|
offering.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
offering.LastModifiedByUserId = callerId;
|
||||||
|
|
||||||
|
if (request.Featured)
|
||||||
|
{
|
||||||
|
await using var transaction = await repository.BeginTransactionAsync();
|
||||||
|
await UnfeatureCurrentAsync(exceptId: offering.Id);
|
||||||
|
repository.Update(offering);
|
||||||
|
await repository.SaveChangesAsync();
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
repository.Update(offering);
|
||||||
|
await repository.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Offering {OfferingId} {Action} by user {LastModifiedByUserId}",
|
||||||
|
offering.Id, "Updated", callerId);
|
||||||
|
|
||||||
|
return ToAdminDto(offering);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteAsync(Guid id, Guid callerId)
|
||||||
|
{
|
||||||
|
var offering = await repository.GetByIdAsync(id);
|
||||||
|
if (offering is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// BR-OFF-02: soft delete is never blocked, including for the last remaining offering.
|
||||||
|
offering.IsDeleted = true;
|
||||||
|
offering.DeletedAt = DateTimeOffset.UtcNow;
|
||||||
|
offering.UpdatedAt = offering.DeletedAt.Value;
|
||||||
|
offering.LastModifiedByUserId = callerId;
|
||||||
|
|
||||||
|
repository.Update(offering);
|
||||||
|
await repository.SaveChangesAsync();
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Offering {OfferingId} {Action} by user {LastModifiedByUserId}",
|
||||||
|
offering.Id, "Deleted", callerId);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReorderAsync(IReadOnlyList<Guid> orderedIds, Guid callerId)
|
||||||
|
{
|
||||||
|
await using var transaction = await repository.BeginTransactionAsync();
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
for (var i = 0; i < orderedIds.Count; i++)
|
||||||
|
{
|
||||||
|
var offering = await repository.GetByIdAsync(orderedIds[i]);
|
||||||
|
if (offering is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
offering.DisplayOrder = i;
|
||||||
|
offering.UpdatedAt = now;
|
||||||
|
offering.LastModifiedByUserId = callerId;
|
||||||
|
repository.Update(offering);
|
||||||
|
}
|
||||||
|
|
||||||
|
await repository.SaveChangesAsync();
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Offerings reordered ({Count} items) by user {LastModifiedByUserId}",
|
||||||
|
orderedIds.Count, callerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MoveUpAsync(Guid id, Guid callerId)
|
||||||
|
{
|
||||||
|
var offering = await repository.GetByIdAsync(id);
|
||||||
|
if (offering is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var previous = await repository.GetPreviousAsync(offering.DisplayOrder);
|
||||||
|
if (previous is null)
|
||||||
|
return; // BR-OFF-03: already first, no-op
|
||||||
|
|
||||||
|
await using var transaction = await repository.BeginTransactionAsync();
|
||||||
|
SwapDisplayOrder(offering, previous, callerId);
|
||||||
|
await repository.SaveChangesAsync();
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MoveDownAsync(Guid id, Guid callerId)
|
||||||
|
{
|
||||||
|
var offering = await repository.GetByIdAsync(id);
|
||||||
|
if (offering is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var next = await repository.GetNextAsync(offering.DisplayOrder);
|
||||||
|
if (next is null)
|
||||||
|
return; // BR-OFF-03: already last, no-op
|
||||||
|
|
||||||
|
await using var transaction = await repository.BeginTransactionAsync();
|
||||||
|
SwapDisplayOrder(offering, next, callerId);
|
||||||
|
await repository.SaveChangesAsync();
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SwapDisplayOrder(Offering a, Offering b, Guid callerId)
|
||||||
|
{
|
||||||
|
(a.DisplayOrder, b.DisplayOrder) = (b.DisplayOrder, a.DisplayOrder);
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
a.UpdatedAt = now;
|
||||||
|
b.UpdatedAt = now;
|
||||||
|
a.LastModifiedByUserId = callerId;
|
||||||
|
b.LastModifiedByUserId = callerId;
|
||||||
|
repository.Update(a);
|
||||||
|
repository.Update(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UnfeatureCurrentAsync(Guid? exceptId = null)
|
||||||
|
{
|
||||||
|
var current = await repository.GetFeaturedAsync();
|
||||||
|
if (current is null || current.Id == exceptId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
current.Featured = false;
|
||||||
|
current.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
repository.Update(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OfferingDto ToPublicDto(Offering o) => new(
|
||||||
|
o.Id.ToString(),
|
||||||
|
o.Title,
|
||||||
|
o.Description,
|
||||||
|
o.Price,
|
||||||
|
o.PriceNote,
|
||||||
|
o.Features,
|
||||||
|
o.CtaLabel,
|
||||||
|
o.Featured
|
||||||
|
);
|
||||||
|
|
||||||
|
private static OfferingAdminDto ToAdminDto(Offering o) => new(
|
||||||
|
o.Id.ToString(),
|
||||||
|
o.Title,
|
||||||
|
o.Description,
|
||||||
|
o.Price,
|
||||||
|
o.PriceNote,
|
||||||
|
o.Features,
|
||||||
|
o.CtaLabel,
|
||||||
|
o.Featured,
|
||||||
|
o.DisplayOrder
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||||
|
<_Parameter1>SlpModularCms.Modules.Offerings.Tests</_Parameter1>
|
||||||
|
</AssemblyAttribute>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\SlpModularCms.Core\SlpModularCms.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user