From 21665a9546bc5350b9993adf3c52f797d889c36e Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 24 May 2026 20:47:54 +0200 Subject: [PATCH] retrofit: draft aanbod-listings spec + annotate 4 methods Reverse-spec for the AanbodController public HTTP surface. New 'aanbod-listings' capability with 3 REQs: list / accept / deny aanbod objects. Annotates 4 methods (getAanbod, acceptAanbod, denyAanbod, parseQueryOptions). Service-side behaviour stays under method-decomposition#REQ-DECOMP-011. Refs #285 --- lib/Controller/AanbodController.php | 8 ++ .../design.md | 25 ++++++ .../proposal.md | 18 +++++ .../specs/aanbod-listings/spec.md | 76 +++++++++++++++++++ .../tasks.md | 5 ++ 5 files changed, 132 insertions(+) create mode 100644 openspec/changes/retrofit-2026-05-24-aanbod-listings/design.md create mode 100644 openspec/changes/retrofit-2026-05-24-aanbod-listings/proposal.md create mode 100644 openspec/changes/retrofit-2026-05-24-aanbod-listings/specs/aanbod-listings/spec.md create mode 100644 openspec/changes/retrofit-2026-05-24-aanbod-listings/tasks.md diff --git a/lib/Controller/AanbodController.php b/lib/Controller/AanbodController.php index 72d60e31..c79caa30 100644 --- a/lib/Controller/AanbodController.php +++ b/lib/Controller/AanbodController.php @@ -82,6 +82,8 @@ public function __construct( * @NoAdminRequired * @NoCSRFRequired * @PublicPage + * + * @spec openspec/changes/retrofit-2026-05-24-aanbod-listings/tasks.md#task-1 */ public function getAanbod(): JSONResponse { @@ -157,6 +159,8 @@ public function getAanbod(): JSONResponse * @NoAdminRequired * @NoCSRFRequired * @PublicPage + * + * @spec openspec/changes/retrofit-2026-05-24-aanbod-listings/tasks.md#task-2 */ public function acceptAanbod(string $uuid): JSONResponse { @@ -256,6 +260,8 @@ function ($key) { * @NoAdminRequired * @NoCSRFRequired * @PublicPage + * + * @spec openspec/changes/retrofit-2026-05-24-aanbod-listings/tasks.md#task-3 */ public function denyAanbod(string $uuid): JSONResponse { @@ -344,6 +350,8 @@ function ($key) { * Parse query parameters into options array. * * @return array Parsed options array + * + * @spec openspec/changes/retrofit-2026-05-24-aanbod-listings/tasks.md#task-1 */ private function parseQueryOptions(): array { diff --git a/openspec/changes/retrofit-2026-05-24-aanbod-listings/design.md b/openspec/changes/retrofit-2026-05-24-aanbod-listings/design.md new file mode 100644 index 00000000..55a76fa8 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-24-aanbod-listings/design.md @@ -0,0 +1,25 @@ +# Design — Retrofit Aanbod Listings + +Retrofit change. Tasks describe retroactive annotation, not new implementation work. + +## Context + +`AanbodController` exposes the public HTTP surface for browsing and acting on software-catalogue offers. The companion `AanbodService` is already in Bucket 1 (covered by `method-decomposition#REQ-DECOMP-011`). This change captures the controller-level contract that REQ-DECOMP-011 only touches obliquely. + +## Decisions + +- **Cluster mode** — no existing capability covers HTTP-level aanbod behaviour. +- **3 REQs / 4 methods** — `parseQueryOptions` collapses into REQ-001 as plumbing. List / accept / deny are the three observable behaviours. +- **HTTP status mapping spelled out per REQ.** The controller has a tight per-endpoint mapping (400 / 403 / 404 / 500 / 200) — every distinct mapping is captured because callers depend on it. +- **No fix for `@PublicPage` posture.** All three endpoints are public and rely on session-level org context for authorisation. This is the observed behaviour and is documented in Notes; revisiting it belongs to a security ADR pass. + +## Out of scope + +- Refactoring `AanbodController` against ADR-005 (auth annotations) — open a separate issue if needed. +- Service-side behaviour — already in `method-decomposition#REQ-DECOMP-011`. + +## References + +- Umbrella: ConductionNL/softwarecatalog#285 +- Coverage report: openspec/coverage-report.md (2026-05-24) +- Source: lib/Controller/AanbodController.php diff --git a/openspec/changes/retrofit-2026-05-24-aanbod-listings/proposal.md b/openspec/changes/retrofit-2026-05-24-aanbod-listings/proposal.md new file mode 100644 index 00000000..02ee7aae --- /dev/null +++ b/openspec/changes/retrofit-2026-05-24-aanbod-listings/proposal.md @@ -0,0 +1,18 @@ +# Retrofit — aanbod-listings + +Describes observed behavior of 4 methods in `AanbodController` as 3 new REQs under a new `aanbod-listings` capability. Code already exists — this change retroactively specifies it. + +## Affected code units + +- lib/Controller/AanbodController.php::getAanbod +- lib/Controller/AanbodController.php::acceptAanbod +- lib/Controller/AanbodController.php::denyAanbod +- lib/Controller/AanbodController.php::parseQueryOptions + +## Approach + +- For each method: describe observed inputs, outputs, pre/postconditions, failure modes from the existing source. +- Draft REQs that match observed behavior (not aspirational). +- Notes section flags overlap with `AanbodService` REQs already captured in Bucket 1 (Service-side methods inherit from method-decomposition spec scenarios). + +Source: openspec/coverage-report.md generated 2026-05-24. Umbrella: ConductionNL/softwarecatalog#285. diff --git a/openspec/changes/retrofit-2026-05-24-aanbod-listings/specs/aanbod-listings/spec.md b/openspec/changes/retrofit-2026-05-24-aanbod-listings/specs/aanbod-listings/spec.md new file mode 100644 index 00000000..4f48ae04 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-24-aanbod-listings/specs/aanbod-listings/spec.md @@ -0,0 +1,76 @@ +--- +status: draft +retrofit: true +--- + +# Aanbod Listings Specification + +## Purpose + +Captures observed behavior of the public HTTP surface for `aanbod` (software-catalogue offers — modules, diensten, koppelingen, gebruiks) exposed via `OCA\SoftwareCatalog\Controller\AanbodController`. Allows the active organisation to browse offers it consumes or provides, and to accept (claim ownership) or deny (delete) individual offers. Reverse-spec'd from existing code 2026-05-24. + +## ADDED Requirements + +### REQ-001: The system SHALL list aanbod objects for the active organisation + +`GET /api/aanbod` MUST return modules, diensten, and koppelingen where the current organisation is in the `aanbieder` property, plus gebruiks where the current organisation is in the `afnemer` property. Objects whose `@self.organisation` equals the current organisation MUST be excluded. The endpoint MUST honour pagination via `limit`, `offset`, `page` query parameters and MUST force `_source: database` for real-time data. On service-level failure the response MUST be HTTP 500 with `error` set; on unexpected exception the response MUST be HTTP 500 with `error: 'Internal server error: '` and empty paginated envelope. + +#### Scenario: Successful listing returns 200 with paginated envelope +- GIVEN the active organisation has 5 aanbod objects assigned +- WHEN `GET /api/aanbod?limit=20` is called +- THEN the response status MUST be `200` +- AND the JSON body MUST contain `results` (array) and `total` (int) + +#### Scenario: Service error returns 500 with error key +- GIVEN `AanbodService::getAanbod()` returns a payload containing `error` +- WHEN the controller handles the request +- THEN the response status MUST be `500` +- AND the response body MUST preserve the service payload (including `error`) + +#### Scenario: Unhandled exception returns canonical envelope +- GIVEN the service throws an exception +- WHEN the controller catches it +- THEN the response status MUST be `500` +- AND the body MUST equal `{results: [], total: 0, page: 1, pages: 0, limit: 20, offset: 0, error: "Internal server error: "}` + +### REQ-002: The system SHALL allow accepting an aanbod offer + +`PUT /api/aanbod/{uuid}/accept` MUST set the offer's `@self.organisation` to the active organisation, allowed only when the active org is the offer's `afnemer` (for gebruiks) or `aanbieder` (for modules/diensten/koppelingen). Empty `uuid` MUST return HTTP 400 with `{success: false, error: "Aanbod UUID is required", aanbod: null}`. The endpoint MUST forward optional body parameters (excluding `uuid`) to `AanbodService::acceptAanbod`. Status code mapping MUST be: success → 200; `Aanbod object not found` → 404; service error string containing `Operation not allowed` → 403; any other service failure or exception → 500. + +#### Scenario: Empty uuid is rejected before service call +- GIVEN the request path resolves uuid to an empty string +- WHEN the controller runs +- THEN the response status MUST be `400` +- AND the body MUST equal `{success: false, error: "Aanbod UUID is required", aanbod: null}` + +#### Scenario: Permission denial maps to 403 +- GIVEN `AanbodService::acceptAanbod()` returns `{success: false, error: "Operation not allowed: not the afnemer"}` +- WHEN the controller maps the response +- THEN the response status MUST be `403` + +#### Scenario: Not-found maps to 404 +- GIVEN the service returns `{success: false, error: "Aanbod object not found"}` +- WHEN the controller maps the response +- THEN the response status MUST be `404` + +### REQ-003: The system SHALL allow denying an aanbod offer by deletion + +`DELETE /api/aanbod/{uuid}/deny` MUST delete the offer when the active org is the `afnemer` (for gebruiks) or `aanbieder` (for modules/diensten/koppelingen). Empty `uuid` MUST return HTTP 400 with `{success: false, error: "Aanbod UUID is required", deleted: false}`. Optional body parameters (excluding `uuid`) MUST be forwarded to `AanbodService::denyAanbod`. Status code mapping MUST mirror REQ-002 (success → 200; not found → 404; `Operation not allowed` → 403; otherwise → 500). + +#### Scenario: Successful deletion returns 200 +- GIVEN the service returns `{success: true, deleted: true}` +- WHEN the controller maps the response +- THEN the response status MUST be `200` +- AND the response body MUST contain `deleted: true` + +#### Scenario: Empty uuid is rejected +- GIVEN the request path resolves uuid to an empty string +- WHEN the controller runs +- THEN the response status MUST be `400` + +## Notes + +- **`parseQueryOptions` is private plumbing**, not a separate REQ. Its behaviour (force `_source: database`, duplicate `limit`/`_limit` keys for compatibility) is implied by REQ-001's contract and tested indirectly via the listing scenarios. +- **`@PublicPage` + `@NoCSRFRequired` + `@NoAdminRequired`** are all present on every endpoint. The endpoints accept unauthenticated requests but rely on session-resolved `aanbieder`/`afnemer` matching for authorisation. This is intentional for the listing UX but should be documented in the security ADRs (out of scope here). +- **Service-side behaviour** (`AanbodService::getAanbod / acceptAanbod / denyAanbod`) is already covered by Bucket 1 REQ-DECOMP-011; this spec scopes the HTTP-level contract only. +- **Acceptance Criteria:** Endpoint contracts are covered by Newman collection runs against `/api/aanbod*`; no PHPUnit coverage of the controller layer at retrofit time. diff --git a/openspec/changes/retrofit-2026-05-24-aanbod-listings/tasks.md b/openspec/changes/retrofit-2026-05-24-aanbod-listings/tasks.md new file mode 100644 index 00000000..6f706cba --- /dev/null +++ b/openspec/changes/retrofit-2026-05-24-aanbod-listings/tasks.md @@ -0,0 +1,5 @@ +# Tasks + +- [x] task-1: aanbod-listings#REQ-001 — System SHALL list aanbod objects for the active organisation (retroactive annotation) +- [x] task-2: aanbod-listings#REQ-002 — System SHALL allow accepting an aanbod offer (retroactive annotation) +- [x] task-3: aanbod-listings#REQ-003 — System SHALL allow denying an aanbod offer by deletion (retroactive annotation)