Skip to content

Commit ce54c58

Browse files
authored
retrofit: draft aanbod-listings spec + annotate 4 methods (#289)
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
1 parent 30a507a commit ce54c58

5 files changed

Lines changed: 132 additions & 0 deletions

File tree

lib/Controller/AanbodController.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ public function __construct(
8282
* @NoAdminRequired
8383
* @NoCSRFRequired
8484
* @PublicPage
85+
*
86+
* @spec openspec/changes/retrofit-2026-05-24-aanbod-listings/tasks.md#task-1
8587
*/
8688
public function getAanbod(): JSONResponse
8789
{
@@ -157,6 +159,8 @@ public function getAanbod(): JSONResponse
157159
* @NoAdminRequired
158160
* @NoCSRFRequired
159161
* @PublicPage
162+
*
163+
* @spec openspec/changes/retrofit-2026-05-24-aanbod-listings/tasks.md#task-2
160164
*/
161165
public function acceptAanbod(string $uuid): JSONResponse
162166
{
@@ -256,6 +260,8 @@ function ($key) {
256260
* @NoAdminRequired
257261
* @NoCSRFRequired
258262
* @PublicPage
263+
*
264+
* @spec openspec/changes/retrofit-2026-05-24-aanbod-listings/tasks.md#task-3
259265
*/
260266
public function denyAanbod(string $uuid): JSONResponse
261267
{
@@ -344,6 +350,8 @@ function ($key) {
344350
* Parse query parameters into options array.
345351
*
346352
* @return array Parsed options array
353+
*
354+
* @spec openspec/changes/retrofit-2026-05-24-aanbod-listings/tasks.md#task-1
347355
*/
348356
private function parseQueryOptions(): array
349357
{
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Design — Retrofit Aanbod Listings
2+
3+
Retrofit change. Tasks describe retroactive annotation, not new implementation work.
4+
5+
## Context
6+
7+
`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.
8+
9+
## Decisions
10+
11+
- **Cluster mode** — no existing capability covers HTTP-level aanbod behaviour.
12+
- **3 REQs / 4 methods**`parseQueryOptions` collapses into REQ-001 as plumbing. List / accept / deny are the three observable behaviours.
13+
- **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.
14+
- **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.
15+
16+
## Out of scope
17+
18+
- Refactoring `AanbodController` against ADR-005 (auth annotations) — open a separate issue if needed.
19+
- Service-side behaviour — already in `method-decomposition#REQ-DECOMP-011`.
20+
21+
## References
22+
23+
- Umbrella: ConductionNL/softwarecatalog#285
24+
- Coverage report: openspec/coverage-report.md (2026-05-24)
25+
- Source: lib/Controller/AanbodController.php
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Retrofit — aanbod-listings
2+
3+
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.
4+
5+
## Affected code units
6+
7+
- lib/Controller/AanbodController.php::getAanbod
8+
- lib/Controller/AanbodController.php::acceptAanbod
9+
- lib/Controller/AanbodController.php::denyAanbod
10+
- lib/Controller/AanbodController.php::parseQueryOptions
11+
12+
## Approach
13+
14+
- For each method: describe observed inputs, outputs, pre/postconditions, failure modes from the existing source.
15+
- Draft REQs that match observed behavior (not aspirational).
16+
- Notes section flags overlap with `AanbodService` REQs already captured in Bucket 1 (Service-side methods inherit from method-decomposition spec scenarios).
17+
18+
Source: openspec/coverage-report.md generated 2026-05-24. Umbrella: ConductionNL/softwarecatalog#285.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
status: draft
3+
retrofit: true
4+
---
5+
6+
# Aanbod Listings Specification
7+
8+
## Purpose
9+
10+
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.
11+
12+
## ADDED Requirements
13+
14+
### REQ-001: The system SHALL list aanbod objects for the active organisation
15+
16+
`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: <message>'` and empty paginated envelope.
17+
18+
#### Scenario: Successful listing returns 200 with paginated envelope
19+
- GIVEN the active organisation has 5 aanbod objects assigned
20+
- WHEN `GET /api/aanbod?limit=20` is called
21+
- THEN the response status MUST be `200`
22+
- AND the JSON body MUST contain `results` (array) and `total` (int)
23+
24+
#### Scenario: Service error returns 500 with error key
25+
- GIVEN `AanbodService::getAanbod()` returns a payload containing `error`
26+
- WHEN the controller handles the request
27+
- THEN the response status MUST be `500`
28+
- AND the response body MUST preserve the service payload (including `error`)
29+
30+
#### Scenario: Unhandled exception returns canonical envelope
31+
- GIVEN the service throws an exception
32+
- WHEN the controller catches it
33+
- THEN the response status MUST be `500`
34+
- AND the body MUST equal `{results: [], total: 0, page: 1, pages: 0, limit: 20, offset: 0, error: "Internal server error: <message>"}`
35+
36+
### REQ-002: The system SHALL allow accepting an aanbod offer
37+
38+
`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.
39+
40+
#### Scenario: Empty uuid is rejected before service call
41+
- GIVEN the request path resolves uuid to an empty string
42+
- WHEN the controller runs
43+
- THEN the response status MUST be `400`
44+
- AND the body MUST equal `{success: false, error: "Aanbod UUID is required", aanbod: null}`
45+
46+
#### Scenario: Permission denial maps to 403
47+
- GIVEN `AanbodService::acceptAanbod()` returns `{success: false, error: "Operation not allowed: not the afnemer"}`
48+
- WHEN the controller maps the response
49+
- THEN the response status MUST be `403`
50+
51+
#### Scenario: Not-found maps to 404
52+
- GIVEN the service returns `{success: false, error: "Aanbod object not found"}`
53+
- WHEN the controller maps the response
54+
- THEN the response status MUST be `404`
55+
56+
### REQ-003: The system SHALL allow denying an aanbod offer by deletion
57+
58+
`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).
59+
60+
#### Scenario: Successful deletion returns 200
61+
- GIVEN the service returns `{success: true, deleted: true}`
62+
- WHEN the controller maps the response
63+
- THEN the response status MUST be `200`
64+
- AND the response body MUST contain `deleted: true`
65+
66+
#### Scenario: Empty uuid is rejected
67+
- GIVEN the request path resolves uuid to an empty string
68+
- WHEN the controller runs
69+
- THEN the response status MUST be `400`
70+
71+
## Notes
72+
73+
- **`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.
74+
- **`@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).
75+
- **Service-side behaviour** (`AanbodService::getAanbod / acceptAanbod / denyAanbod`) is already covered by Bucket 1 REQ-DECOMP-011; this spec scopes the HTTP-level contract only.
76+
- **Acceptance Criteria:** Endpoint contracts are covered by Newman collection runs against `/api/aanbod*`; no PHPUnit coverage of the controller layer at retrofit time.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Tasks
2+
3+
- [x] task-1: aanbod-listings#REQ-001 — System SHALL list aanbod objects for the active organisation (retroactive annotation)
4+
- [x] task-2: aanbod-listings#REQ-002 — System SHALL allow accepting an aanbod offer (retroactive annotation)
5+
- [x] task-3: aanbod-listings#REQ-003 — System SHALL allow denying an aanbod offer by deletion (retroactive annotation)

0 commit comments

Comments
 (0)