diff --git a/appinfo/routes.php b/appinfo/routes.php index e0118ecc..a5460eac 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -167,6 +167,14 @@ ['name' => 'view#getApiDocumentation', 'url' => '/api/views/docs', 'verb' => 'GET'], ['name' => 'view#getView', 'url' => '/api/views/{viewId}', 'verb' => 'GET'], + // ======================================================================== + // FACET API ENDPOINTS - GEMMA-dimension facet aggregation for the + // module/dienst index pages (gemma-faceted-search) + // ======================================================================== + + // @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts + ['name' => 'facet#getFacets', 'url' => '/api/facets/{schema}', 'verb' => 'GET'], + // ======================================================================== // AANBOD API ENDPOINTS - Unified API for all aanbod types // ======================================================================== diff --git a/docs/features/README.md b/docs/features/README.md index 3927cc09..c6a7fc74 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -9,6 +9,7 @@ All data is stored as OpenRegister objects (no own database tables). OpenRegiste | Feature | Description | |---------|-------------| | [Software Registration](#software-registration) | Register and manage applications (voorzieningen) in the landscape | +| [GEMMA Faceted Search](#gemma-faceted-search) | Filter the application/service catalogue by GEMMA architecture dimension, with live counts | | [Module Tracking](#module-tracking) | Break applications down into functional modules | | [Connection Mapping](#connection-mapping) | Map integrations (koppelingen) between applications and modules | | [Organisation and Contact Management](#organisation-and-contact-management) | Manage organisations and their contact persons | @@ -35,6 +36,22 @@ The dashboard displays totals and recent changes across all registered software. **Key service:** `lib/Service/AanbodService.php` **Controller:** `lib/Controller/AanbodController.php` +## GEMMA Faceted Search + +The **Applications** (`/modules`) and **Services** (`/diensten`) index pages offer faceted filtering by GEMMA architecture dimension, alongside free-text search: + +- **Referentiecomponent** and **Standaard** — read directly off the module's own `referentieComponenten`/`standaardVersies` links. +- **Domein** and **Applicatieservice** — resolved transitively via the module's linked GEMMA `element` objects (an application has no direct field for either; domein comes from the linked referentiecomponent element, applicatieservice from a `relation` object connecting that element to an `Applicatieservice`-typed element). +- Counts update live as other facets are applied — a facet's own count is never narrowed by its own selection, so it always reads "how many results if I also add this filter". Multiple values within one dimension combine with OR; selections across different dimensions combine with AND. +- Free-text search narrows the candidate set before facet counts are computed, so search and facets are always consistent with each other and with the object list. +- The filter selection is reflected in the URL (`_gf_`-prefixed query parameters) so a filtered view is shareable, bookmarkable, and survives a page reload. +- A filter selection can be saved as a named view (via OpenRegister's generic saved-search Views API) and reopened later from the page's "Saved views" menu. +- Facet counts are computed through the same RBAC/tenant-scoped query path as the page's own object list — a restricted user's counts never reflect objects outside their visible scope. + +**Key service:** `lib/Service/FacetService.php` +**Controller:** `lib/Controller/FacetController.php` +**Endpoint:** `GET /apps/softwarecatalog/api/facets/{schema}` (`schema`: `module` or `dienst`) + ## Module Tracking Each application can be decomposed into functional **modules** (*modules*). A module represents a distinct component or capability within an application: diff --git a/l10n/en.json b/l10n/en.json index c1a6599b..fe3b5d01 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -402,6 +402,25 @@ "This organisation will be deactivated and will no longer be visible to users.": "This organisation will be deactivated and will no longer be visible to users.", "Status successfully changed to {status}": "Status successfully changed to {status}", "Organisation or new status is missing": "Organisation or new status is missing", - "An error occurred while changing the status": "An error occurred while changing the status" + "An error occurred while changing the status": "An error occurred while changing the status", + "Search": "Search", + "Search applications and services…": "Search applications and services…", + "Clear search": "Clear search", + "Saved views": "Saved views", + "Save current filters as view": "Save current filters as view", + "GEMMA filters": "GEMMA filters", + "Clear all": "Clear all", + "Reference component": "Reference component", + "Standard": "Standard", + "Application service": "Application service", + "Domain": "Domain", + "View \"{name}\" saved": "View \"{name}\" saved", + "Failed to save view: {message}": "Failed to save view: {message}", + "Save as view": "Save as view", + "Save the current filter selection so it can be reopened later.": "Save the current filter selection so it can be reopened later.", + "View name": "View name", + "e.g. Zaakregistratie modules": "e.g. Zaakregistratie modules", + "Save view": "Save view", + "Approval": "Approval" } } diff --git a/l10n/nl.json b/l10n/nl.json index 979e28e6..b5d50ef0 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -425,6 +425,25 @@ "Offerings": "Aanbiedingen", "Compliance records": "Compliance-records", "Group members": "Groepsleden", - "Merge organisations": "Organisaties samenvoegen" + "Merge organisations": "Organisaties samenvoegen", + "Search": "Zoeken", + "Search applications and services…": "Zoek applicaties en diensten…", + "Clear search": "Zoekopdracht wissen", + "Saved views": "Opgeslagen weergaven", + "Save current filters as view": "Huidige filters opslaan als weergave", + "GEMMA filters": "GEMMA-filters", + "Clear all": "Alles wissen", + "Reference component": "Referentiecomponent", + "Standard": "Standaard", + "Application service": "Applicatieservice", + "Domain": "Domein", + "View \"{name}\" saved": "Weergave \"{name}\" opgeslagen", + "Failed to save view: {message}": "Weergave opslaan mislukt: {message}", + "Save as view": "Opslaan als weergave", + "Save the current filter selection so it can be reopened later.": "Sla de huidige filterselectie op zodat u deze later opnieuw kunt openen.", + "View name": "Naam van de weergave", + "e.g. Zaakregistratie modules": "bijv. Zaakregistratie modules", + "Save view": "Weergave opslaan", + "Approval": "Goedkeuring" } } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index ed4934bf..841f8685 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -39,6 +39,7 @@ use OCA\SoftwareCatalog\Service\ArchiMateImportService; use OCA\SoftwareCatalog\Service\ArchiMateService; use OCA\SoftwareCatalog\Service\ContactpersoonService; +use OCA\SoftwareCatalog\Service\FacetService; use OCA\SoftwareCatalog\Service\GebruikSyncService; use OCA\SoftwareCatalog\Service\ModuleComplianceService; use OCA\SoftwareCatalog\Service\ModuleRegistrationService; @@ -54,6 +55,7 @@ use OCA\SoftwareCatalog\Service\SoftwareCatalogue\HierarchyHandler; use OCA\SoftwareCatalog\Service\SoftwareCatalogue\OrganizationHandler; use OCA\SoftwareCatalog\Service\SymfonyEmailService; +use OCA\SoftwareCatalog\Service\ViewQueryBuilder; use OCA\SoftwareCatalog\Service\ViewService; use OCA\OpenRegister\Event\ObjectCreatedEvent; use OCA\OpenRegister\Event\ObjectUpdatedEvent; @@ -491,6 +493,23 @@ function ($container) { } ); + // Register Facet service for GEMMA-dimension facet aggregation + // (gemma-faceted-search) — mirrors ViewService's cache-factory wiring. + $context->registerService( + FacetService::class, + function ($container) { + return new FacetService( + container: $container, + settingsService: $container->get(SettingsService::class), + archiMateService: $container->get(ArchiMateService::class), + queryBuilder: $container->get(ViewQueryBuilder::class), + userSession: $container->get('OCP\IUserSession'), + logger: $container->get('Psr\Log\LoggerInterface'), + cacheFactory: $container->get(ICacheFactory::class) + ); + } + ); + // Register progress tracking service. $context->registerService( ProgressTracker::class, diff --git a/lib/Controller/FacetController.php b/lib/Controller/FacetController.php new file mode 100644 index 00000000..641a7f0c --- /dev/null +++ b/lib/Controller/FacetController.php @@ -0,0 +1,168 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Controller; + +use OCA\SoftwareCatalog\Service\FacetService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use Psr\Log\LoggerInterface; + +/** + * Controller for the GEMMA-dimension facet aggregation endpoint. + * + * @category Controller + * @package OCA\SoftwareCatalog\Controller + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts + */ +class FacetController extends Controller +{ + /** + * Constructor for FacetController. + * + * @param string $appName The app name. + * @param IRequest $request The request object. + * @param FacetService $facetService The facet aggregation service. + * @param LoggerInterface $logger The logger service. + */ + public function __construct( + string $appName, + IRequest $request, + private readonly FacetService $facetService, + private readonly LoggerInterface $logger + ) { + parent::__construct(appName: $appName, request: $request); + + }//end __construct() + + /** + * Get GEMMA-dimension facet counts for a schema. + * + * API Endpoint: GET /api/facets/{schema} + * + * Query Parameters: + * - search (string): Free-text query narrowing the candidate set. + * - referentiecomponent[], standaard[], applicatieservice[], domein[] (string[]): + * Currently-selected facet values per dimension. + * - organization (string): Optional organisation override. + * + * Facet aggregation is a read operation available to any authenticated + * catalog user — RBAC scoping happens inside FacetService (identical + * posture to ViewController), not at this boundary. + * + * @param string $schema The facet schema (`module` or `dienst`). + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @return JSONResponse JSON response with the per-dimension facet buckets. + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-counts-must-respect-the-callers-rbactenant-context + */ + public function getFacets(string $schema): JSONResponse + { + $filters = $this->parseFilters(); + $search = $this->request->getParam('search'); + if (is_string($search) === false) { + $search = null; + } + + $organization = $this->request->getParam('organization'); + if (is_string($organization) === false) { + $organization = null; + } + + try { + $result = $this->facetService->getFacets( + schema: $schema, + filters: $filters, + search: $search, + organization: $organization + ); + + return new JSONResponse($result, 200); + } catch (\InvalidArgumentException $e) { + return new JSONResponse( + [ + 'message' => $e->getMessage(), + 'supportedSchemas' => ['module', 'dienst'], + ], + 400 + ); + } catch (\RuntimeException $e) { + $this->logger->error( + message: 'FacetController: ObjectService unavailable', + context: ['schema' => $schema, 'error' => $e->getMessage()] + ); + + return new JSONResponse( + ['message' => 'Facet aggregation is temporarily unavailable: '.$e->getMessage()], + 503 + ); + } catch (\Exception $e) { + $this->logger->error( + message: 'FacetController: failed to compute facets', + context: [ + 'schema' => $schema, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ] + ); + + return new JSONResponse( + ['message' => 'Internal server error: '.$e->getMessage()], + 500 + ); + }//end try + + }//end getFacets() + + /** + * Parse the per-dimension facet filter query parameters into + * `dimension => string[]`. + * + * @return array Raw filters keyed by dimension. + */ + private function parseFilters(): array + { + $dimensions = ['referentiecomponent', 'standaard', 'applicatieservice', 'domein']; + $filters = []; + + foreach ($dimensions as $dimension) { + $value = $this->request->getParam($dimension); + if ($value === null) { + continue; + } + + $filters[$dimension] = [$value]; + if (is_array($value) === true) { + $filters[$dimension] = $value; + } + } + + return $filters; + + }//end parseFilters() +}//end class diff --git a/lib/Service/FacetService.php b/lib/Service/FacetService.php new file mode 100644 index 00000000..bd9a2326 --- /dev/null +++ b/lib/Service/FacetService.php @@ -0,0 +1,1096 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Service; + +use InvalidArgumentException; +use OCA\OpenRegister\Service\ObjectService; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IUserSession; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * Facet Service for GEMMA-dimension aggregation. + * + * Architecture (see design.md): resolves a bounded, RBAC-scoped candidate + * object set for the requested schema (`module` or `dienst`), builds a + * per-object map of GEMMA dimension values (resolving `domein` and + * `applicatieservice` via linked `element` lookups through the existing + * `ArchiMateService`), then computes disjunctive ("self-count not narrowed + * by its own selection") facet counts over that map. + * + * @category Service + * @package OCA\SoftwareCatalog\Service + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts + * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) + * @SuppressWarnings(PHPMD.TooManyMethods) + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class FacetService +{ + + /** + * Distributed cache for facet responses. + * + * @var ICache + */ + private ICache $facetsCache; + + /** + * Cache TTL in seconds (30 minutes) — matches ViewService::CACHE_TTL. + */ + private const CACHE_TTL = 1800; + + /** + * Objects fetched per bounded page when resolving the base module/dienst set. + */ + private const BASE_OBJECT_LIMIT = 1000; + + /** + * Documented paging ceiling for the base object set (bound-unbounded-searchobjects-scans + * pattern): at most this many pages of BASE_OBJECT_LIMIT are read, i.e. at most + * BASE_OBJECT_LIMIT * MAX_BASE_PAGES objects are ever aggregated over. A register + * larger than that is paged (never scanned unbounded) and a warning is logged. + */ + private const MAX_BASE_PAGES = 5; + + /** + * Bounded limit for element/relation lookup queries (domein/applicatieservice resolution). + */ + private const ELEMENT_LOOKUP_LIMIT = 1000; + + /** + * Facet schemas this service supports. + * + * @var string[] + */ + private const SUPPORTED_SCHEMAS = ['module', 'dienst']; + + /** + * GEMMA dimensions always present in the response (even when empty). + * + * @var string[] + */ + private const DIMENSIONS = ['referentiecomponent', 'standaard', 'applicatieservice', 'domein']; + + /** + * Constructor for FacetService. + * + * @param ContainerInterface $container PSR-11 container interface (for lazy ObjectService lookup). + * @param SettingsService $settingsService Settings service for voorzieningen register/schema configuration. + * @param ArchiMateService $archiMateService Reused for bounded `element`/`relationship` lookups + * (domein/applicatieservice resolution) instead of + * duplicating AMEF register/schema resolution logic. + * @param ViewQueryBuilder $queryBuilder Reused for the `_search` query-param convention. + * @param IUserSession $userSession User session service for current user/RBAC cache-key context. + * @param LoggerInterface $logger Logger service. + * @param ICacheFactory $cacheFactory Cache factory for distributed caching. + */ + public function __construct( + private readonly ContainerInterface $container, + private readonly SettingsService $settingsService, + private readonly ArchiMateService $archiMateService, + private readonly ViewQueryBuilder $queryBuilder, + private readonly IUserSession $userSession, + private readonly LoggerInterface $logger, + ICacheFactory $cacheFactory + ) { + $this->facetsCache = $cacheFactory->createDistributed(prefix: 'softwarecatalog_facets'); + + }//end __construct() + + /** + * Get GEMMA-dimension facet counts for a schema. + * + * @param string $schema `module` or `dienst`. + * @param array $filters Currently-selected facet values keyed by dimension, + * e.g. `['referentiecomponent' => ['Zaakregistratiecomponent']]`. + * @param string|null $search Free-text query narrowing the candidate set. + * @param string|null $organization Optional organisation override (mirrors + * view-enrichment-api's `organization` parameter). + * + * @throws InvalidArgumentException When `$schema` is not supported. + * @throws RuntimeException When OpenRegister's ObjectService is unavailable. + * + * @return array{referentiecomponent: array, standaard: array, applicatieservice: array, + * domein: array, _meta: array{totalMatched: int, processingTimeMs: float, cached: bool, + * matchedObjectIds: string[]}} + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-counts-reflect-the-currently-filtered-set-not-the-unfiltered-universe + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facets-combine-with-free-text-search + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-queries-must-be-bounded + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-counts-must-respect-the-callers-rbactenant-context + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-results-are-cached + */ + public function getFacets(string $schema, array $filters=[], ?string $search=null, ?string $organization=null): array + { + $this->assertSupportedSchema(schema: $schema); + + $normalizedFilters = $this->normalizeFilters(filters: $filters); + + $normalizedSearch = null; + if ($search !== null && trim($search) !== '') { + $normalizedSearch = trim($search); + } + + $cacheKey = $this->buildCacheKey( + schema: $schema, + filters: $normalizedFilters, + search: $normalizedSearch, + organization: $organization + ); + + $cached = $this->facetsCache->get(key: $cacheKey); + if (is_array($cached) === true) { + $cached['_meta']['cached'] = true; + return $cached; + } + + $objectService = $this->getObjectService(); + if ($objectService === null) { + throw new RuntimeException('OpenRegister ObjectService not available'); + } + + $result = $this->computeFacetsForRequest( + objectService: $objectService, + schema: $schema, + normalizedFilters: $normalizedFilters, + normalizedSearch: $normalizedSearch, + organization: $organization + ); + + $this->facetsCache->set(key: $cacheKey, value: $result, ttl: self::CACHE_TTL); + + return $result; + + }//end getFacets() + + /** + * Validate the `$schema` path segment against the supported set. + * + * @param string $schema The requested facet schema. + * + * @throws InvalidArgumentException When `$schema` is not supported. + * + * @return void + */ + private function assertSupportedSchema(string $schema): void + { + if (in_array($schema, self::SUPPORTED_SCHEMAS, true) === false) { + throw new InvalidArgumentException( + sprintf( + 'Unsupported facet schema "%s". Supported schemas: %s.', + $schema, + implode(', ', self::SUPPORTED_SCHEMAS) + ) + ); + } + + }//end assertSupportedSchema() + + /** + * Compute the full facet response for a cache-miss request: fetch the + * bounded RBAC/search-scoped base object set, resolve GEMMA dimension + * values, compute disjunctive counts, and assemble the response + * (including `_meta.matchedObjectIds` for the frontend's list narrowing). + * + * @param ObjectService $objectService OpenRegister object service. + * @param string $schema `module` or `dienst`. + * @param array $normalizedFilters Normalized selected filters. + * @param string|null $normalizedSearch Normalized free-text query. + * @param string|null $organization Optional organisation override. + * + * @return array{referentiecomponent: array, standaard: array, applicatieservice: array, + * domein: array, _meta: array{totalMatched: int, processingTimeMs: float, cached: bool, + * matchedObjectIds: string[]}} + */ + private function computeFacetsForRequest( + ObjectService $objectService, + string $schema, + array $normalizedFilters, + ?string $normalizedSearch, + ?string $organization + ): array { + $startTime = microtime(true); + + $baseObjects = $this->fetchBaseObjects( + objectService: $objectService, + schema: $schema, + search: $normalizedSearch, + organization: $organization + ); + + $modulesByObjectId = $this->resolveModulesPerObject( + objectService: $objectService, + schema: $schema, + baseObjects: $baseObjects + ); + + $dimensionValuesByObjectId = $this->buildDimensionValueMap(modulesByObjectId: $modulesByObjectId); + + $facets = $this->computeFacets( + dimensionValuesByObjectId: $dimensionValuesByObjectId, + selectedFilters: $normalizedFilters + ); + + $matchedObjectIds = $this->filterObjectIds( + dimensionValuesByObjectId: $dimensionValuesByObjectId, + selectedFilters: $normalizedFilters, + excludeDimension: null + ); + + $processingTimeMs = round((microtime(true) - $startTime) * 1000, 2); + + // `matchedObjectIds` — the RBAC/filter/search-scoped object id set this + // response's counts describe (proposal.md's Approach: "the RBAC-filtered + // object IDs needed to drive the index page's existing list query"). + // Several dimensions (`domein`, `applicatieservice`, and `referentiecomponent`/ + // `standaard` by display NAME) are not directly filterable on the + // `module`/`dienst` schema itself, so the frontend narrows its own object + // list via `{ id: matchedObjectIds }` rather than re-deriving an + // equivalent filter from the facet selection. Already bounded — this id + // list can never exceed BASE_OBJECT_LIMIT * MAX_BASE_PAGES entries. + $result = array_merge( + $facets, + [ + '_meta' => [ + 'totalMatched' => count($matchedObjectIds), + 'processingTimeMs' => $processingTimeMs, + 'cached' => false, + 'matchedObjectIds' => array_values($matchedObjectIds), + ], + ] + ); + + $this->logger->debug( + message: 'FacetService: computed facets', + context: [ + 'schema' => $schema, + 'candidateObjects' => count($baseObjects), + 'totalMatched' => $result['_meta']['totalMatched'], + 'processingTimeMs' => $processingTimeMs, + ] + ); + + return $result; + + }//end computeFacetsForRequest() + + /** + * Normalize the incoming filters array to `dimension => string[]`, dropping + * unknown dimensions and empty/blank values. + * + * @param array $filters Raw filters keyed by dimension. + * + * @return array Normalized filters (only known dimensions, non-empty values). + */ + private function normalizeFilters(array $filters): array + { + $normalized = []; + + foreach (self::DIMENSIONS as $dimension) { + $values = $filters[$dimension] ?? null; + if ($values === null) { + continue; + } + + if (is_array($values) === false) { + $values = [$values]; + } + + $cleanValues = []; + foreach ($values as $value) { + if (is_string($value) === true && trim($value) !== '') { + $cleanValues[] = trim($value); + } + } + + if (empty($cleanValues) === false) { + $normalized[$dimension] = array_values(array_unique($cleanValues)); + } + }//end foreach + + return $normalized; + + }//end normalizeFilters() + + /** + * Build a cache key covering every query-affecting parameter, including the + * caller's RBAC/tenant context — two users MUST NOT collide on the same key. + * + * @param string $schema Facet schema. + * @param array $filters Normalized filters. + * @param string|null $search Free-text query. + * @param string|null $organization Organisation override. + * + * @return string Cache key. + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-results-are-cached + */ + private function buildCacheKey(string $schema, array $filters, ?string $search, ?string $organization): string + { + $user = $this->userSession->getUser(); + + $userId = 'anonymous'; + if ($user !== null) { + $userId = $user->getUID(); + } + + ksort($filters); + foreach (array_keys($filters) as $dimension) { + sort($filters[$dimension]); + } + + $keyData = [ + 'schema' => $schema, + 'filters' => $filters, + 'search' => $search, + 'organization' => $organization ?? $this->getCurrentOrganisation(), + 'user' => $userId, + ]; + + return 'facets_'.md5(json_encode($keyData)); + + }//end buildCacheKey() + + /** + * Fetch the bounded, RBAC/tenant-scoped, search-filtered candidate object set + * for the requested schema. Pages via `searchObjectsPaginated()` up to the + * documented `MAX_BASE_PAGES` ceiling instead of ever issuing a single + * unbounded `searchObjects()` call. + * + * @param ObjectService $objectService OpenRegister object service. + * @param string $schema `module` or `dienst`. + * @param string|null $search Free-text query. + * @param string|null $organization Organisation override. + * + * @return array The candidate objects. + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-queries-must-be-bounded + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-counts-must-respect-the-callers-rbactenant-context + */ + private function fetchBaseObjects(ObjectService $objectService, string $schema, ?string $search, ?string $organization): array + { + $voorzieningenConfig = $this->settingsService->getVoorzieningenConfig(); + $registerId = $voorzieningenConfig['register'] ?? null; + $schemaId = $voorzieningenConfig[$schema.'_schema'] ?? null; + + if (empty($registerId) === true || empty($schemaId) === true) { + $this->logger->warning( + message: 'FacetService: voorzieningen register/schema not configured', + context: ['schema' => $schema] + ); + return []; + } + + $baseQuery = [ + '@self' => [ + 'register' => (int) $registerId, + 'schema' => (int) $schemaId, + ], + ]; + + // RBAC/tenant scoping: identical convention to ViewService — an explicit + // organisation filter, `_rbac`/`_multitenancy` left at their `true` + // defaults (no separate unscoped counting path). + $orgToApply = $organization ?? $this->getCurrentOrganisation(); + if ($orgToApply !== null) { + $baseQuery['@self']['organisation'] = $orgToApply; + } + + $baseQuery = $this->queryBuilder->applySearchFilter(baseQuery: $baseQuery, search: $search); + + $allObjects = []; + $page = 1; + + while ($page <= self::MAX_BASE_PAGES) { + $pagedQuery = $baseQuery; + $pagedQuery['_limit'] = self::BASE_OBJECT_LIMIT; + $pagedQuery['_page'] = $page; + + $paginated = $objectService->searchObjectsPaginated($pagedQuery); + $results = $paginated['results'] ?? []; + $allObjects = array_merge($allObjects, $results); + + $totalPages = (int) ($paginated['pages'] ?? 1); + if (count($results) < self::BASE_OBJECT_LIMIT || $page >= $totalPages) { + break; + } + + $page++; + } + + if ($page > self::MAX_BASE_PAGES) { + $this->logger->warning( + message: 'FacetService: base object set exceeded the documented paging ceiling — ' + .'facet counts are computed over a bounded subset, not the full register', + context: [ + 'schema' => $schema, + 'maxPages' => self::MAX_BASE_PAGES, + 'limitPerPage' => self::BASE_OBJECT_LIMIT, + ] + ); + } + + return $allObjects; + + }//end fetchBaseObjects() + + /** + * Resolve the module object(s) backing each base object's GEMMA dimensions. + * + * For `module`, the object IS the module (identity map). For `dienst`, GEMMA + * links are only transitive via `dienst.modules` — the linked module objects + * are resolved with a single bounded batch lookup. + * + * @param ObjectService $objectService OpenRegister object service. + * @param string $schema `module` or `dienst`. + * @param array $baseObjects The candidate objects from `fetchBaseObjects()`. + * + * @return array> Object id => list of module objects. + */ + private function resolveModulesPerObject(ObjectService $objectService, string $schema, array $baseObjects): array + { + $modulesByObjectId = []; + + if ($schema === 'module') { + foreach ($baseObjects as $module) { + $objectId = $this->objectIdentifier(object: $module); + $modulesByObjectId[$objectId] = [$module]; + } + + return $modulesByObjectId; + } + + // $schema === 'dienst': collect every referenced module identifier across + // the bounded candidate set, then resolve them with one batch lookup. + $moduleIdsByDienstId = []; + $allModuleIds = []; + + foreach ($baseObjects as $dienst) { + $dienstId = $this->objectIdentifier(object: $dienst); + $moduleIds = $this->extractRelatedIdentifiers(object: $dienst, field: 'modules'); + + $moduleIdsByDienstId[$dienstId] = $moduleIds; + $allModuleIds = array_merge($allModuleIds, $moduleIds); + } + + $modulesById = $this->fetchModulesByIdentifiers( + objectService: $objectService, + identifiers: array_values(array_unique($allModuleIds)) + ); + + foreach ($moduleIdsByDienstId as $dienstId => $moduleIds) { + $modules = []; + foreach ($moduleIds as $moduleId) { + if (isset($modulesById[$moduleId]) === true) { + $modules[] = $modulesById[$moduleId]; + } + } + + $modulesByObjectId[$dienstId] = $modules; + } + + return $modulesByObjectId; + + }//end resolveModulesPerObject() + + /** + * Batch-fetch module objects by their OpenRegister object id, bounded by an + * explicit `_limit`. + * + * @param ObjectService $objectService OpenRegister object service. + * @param array $identifiers Distinct module object identifiers to resolve. + * + * @return array Module id => module object. + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-queries-must-be-bounded + */ + private function fetchModulesByIdentifiers(ObjectService $objectService, array $identifiers): array + { + if (empty($identifiers) === true) { + return []; + } + + $voorzieningenConfig = $this->settingsService->getVoorzieningenConfig(); + $registerId = $voorzieningenConfig['register'] ?? null; + $schemaId = $voorzieningenConfig['module_schema'] ?? null; + + if (empty($registerId) === true || empty($schemaId) === true) { + return []; + } + + $query = [ + '@self' => [ + 'register' => (int) $registerId, + 'schema' => (int) $schemaId, + ], + 'id' => $identifiers, + '_limit' => self::ELEMENT_LOOKUP_LIMIT, + ]; + + try { + $results = $objectService->searchObjects($query); + } catch (\Exception $e) { + $this->logger->warning( + message: 'FacetService: failed to batch-resolve linked modules', + context: ['error' => $e->getMessage()] + ); + return []; + } + + $byId = []; + foreach ($results as $module) { + $key = $this->objectIdentifier(object: $module); + $byId[$key] = $module; + } + + return $byId; + + }//end fetchModulesByIdentifiers() + + /** + * Build the per-object GEMMA dimension value map: for every base object, + * the set of referentiecomponent/standaard names it carries directly, and + * the set of domein/applicatieservice names resolved transitively via its + * linked `element` objects. + * + * @param array> $modulesByObjectId Object id => module objects. + * + * @return array> Object id => dimension => distinct values. + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts + */ + private function buildDimensionValueMap(array $modulesByObjectId): array + { + // Pass 1: direct fields (referentiecomponent identifiers + standaard identifiers). + $referentiecomponentIdsByObjectId = []; + $standaardValuesByObjectId = []; + $allReferentiecomponentIds = []; + + foreach ($modulesByObjectId as $objectId => $modules) { + $refCompIds = []; + $standaarden = []; + + foreach ($modules as $module) { + $refCompIds = array_merge($refCompIds, $this->extractRelatedIdentifiers(object: $module, field: 'referentieComponenten')); + $standaarden = array_merge($standaarden, $this->extractRelatedNames(object: $module, field: 'standaardVersies')); + } + + $refCompIds = array_values(array_unique($refCompIds)); + $standaarden = array_values(array_unique(array_filter($standaarden))); + + $referentiecomponentIdsByObjectId[$objectId] = $refCompIds; + $standaardValuesByObjectId[$objectId] = $standaarden; + $allReferentiecomponentIds = array_merge($allReferentiecomponentIds, $refCompIds); + } + + $allReferentiecomponentIds = array_values(array_unique($allReferentiecomponentIds)); + + // Pass 2: resolve referentiecomponent elements themselves (for the + // referentiecomponent facet's display value + the `domein` field). + $elementsById = $this->resolveElementsByIdentifier(identifiers: $allReferentiecomponentIds); + + // Pass 3: resolve applicatieservice elements reachable via a `relation` + // touching one of the referentiecomponent elements. + $applicatieserviceNamesByReferentiecomponentId = $this->resolveApplicatieservicesForReferentiecomponenten( + referentiecomponentIds: $allReferentiecomponentIds + ); + + // Assemble the final per-object dimension map. + $dimensionValuesByObjectId = []; + + foreach ($modulesByObjectId as $objectId => $modules) { + $refCompIds = $referentiecomponentIdsByObjectId[$objectId] ?? []; + + $referentiecomponentNames = []; + $domeinValues = []; + $applicatieserviceValues = []; + + foreach ($refCompIds as $refCompId) { + $element = $elementsById[$refCompId] ?? null; + + // `elementDisplayName()` itself falls back to `$refCompId` when + // `$element` carries no usable `name` — pass an empty element + // array when unresolved so the same call covers both cases. + $referentiecomponentNames[] = $this->elementDisplayName( + element: $element ?? [], + fallbackIdentifier: $refCompId + ); + + $domein = $element['domein'] ?? null; + if (is_string($domein) === true && trim($domein) !== '') { + $domeinValues[] = trim($domein); + } + + foreach (($applicatieserviceNamesByReferentiecomponentId[$refCompId] ?? []) as $applicatieserviceName) { + $applicatieserviceValues[] = $applicatieserviceName; + } + } + + $dimensionValuesByObjectId[$objectId] = [ + 'referentiecomponent' => array_values(array_unique($referentiecomponentNames)), + 'standaard' => $standaardValuesByObjectId[$objectId] ?? [], + 'domein' => array_values(array_unique($domeinValues)), + 'applicatieservice' => array_values(array_unique($applicatieserviceValues)), + ]; + }//end foreach + + return $dimensionValuesByObjectId; + + }//end buildDimensionValueMap() + + /** + * Resolve `element` objects by identifier, bounded by an explicit `_limit`. + * Reuses `ArchiMateService::getElementObjects()` — the same AMEF register + + * schema resolution `ViewService`'s relationship-resolution helpers already + * use — rather than re-deriving the AMEF register/schema ids here. + * + * @param array $identifiers Distinct element identifiers to resolve. + * + * @return array Element identifier => element object. + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-queries-must-be-bounded + */ + private function resolveElementsByIdentifier(array $identifiers): array + { + if (empty($identifiers) === true) { + return []; + } + + try { + $elements = $this->archiMateService->getElementObjects( + [ + 'identifier' => $identifiers, + 'limit' => self::ELEMENT_LOOKUP_LIMIT, + ] + ); + } catch (\Exception $e) { + $this->logger->warning( + message: 'FacetService: failed to resolve linked elements', + context: ['error' => $e->getMessage()] + ); + return []; + } + + $byIdentifier = []; + foreach ($elements as $element) { + $identifier = $element['identifier'] ?? $this->objectIdentifier(object: $element); + $byIdentifier[$identifier] = $element; + } + + return $byIdentifier; + + }//end resolveElementsByIdentifier() + + /** + * Resolve, for each referentiecomponent identifier, the distinct display + * names of `Applicatieservice`-typed `element` objects reachable via a + * `relation` object touching it (either endpoint) — the module schema has + * no direct applicatieservice link, so this two-hop lookup mirrors the + * relationship-resolution pattern `ViewService`/`ArchiMateService` already + * perform for referentiecomponent overlays (design.md trade-offs). + * + * @param array $referentiecomponentIds Distinct referentiecomponent element identifiers. + * + * @return array Referentiecomponent identifier => applicatieservice names. + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-queries-must-be-bounded + */ + private function resolveApplicatieservicesForReferentiecomponenten(array $referentiecomponentIds): array + { + if (empty($referentiecomponentIds) === true) { + return []; + } + + try { + $relations = $this->archiMateService->getRelationshipObjects( + [ + 'limit' => self::ELEMENT_LOOKUP_LIMIT, + ] + ); + } catch (\Exception $e) { + $this->logger->warning( + message: 'FacetService: failed to resolve relationships for applicatieservice facet', + context: ['error' => $e->getMessage()] + ); + return []; + } + + $otherEndpointsByRefCompId = $this->collectRelationEndpoints( + relations: $relations, + referentiecomponentIds: $referentiecomponentIds + ); + + if (empty($otherEndpointsByRefCompId) === true) { + return []; + } + + $allOtherIds = array_values(array_unique(array_merge(...array_values($otherEndpointsByRefCompId)))); + $elementsById = $this->resolveElementsByIdentifier(identifiers: $allOtherIds); + + return $this->mapEndpointsToApplicatieserviceNames( + otherEndpointsByRefCompId: $otherEndpointsByRefCompId, + elementsById: $elementsById + ); + + }//end resolveApplicatieservicesForReferentiecomponenten() + + /** + * For every `relation` object, record the OTHER endpoint (source or + * target) whenever one side matches a referentiecomponent identifier — + * relations are undirected for this lookup's purposes (either endpoint + * order counts). + * + * @param array $relations Bounded relation objects. + * @param array $referentiecomponentIds Distinct referentiecomponent element identifiers. + * + * @return array Referentiecomponent identifier => other-endpoint identifiers. + */ + private function collectRelationEndpoints(array $relations, array $referentiecomponentIds): array + { + $refCompLookup = array_flip($referentiecomponentIds); + $otherEndpointsByRefCompId = []; + + foreach ($relations as $relation) { + $source = $relation['source'] ?? null; + $target = $relation['target'] ?? null; + if (is_string($source) === false || is_string($target) === false) { + continue; + } + + if (isset($refCompLookup[$source]) === true) { + $otherEndpointsByRefCompId[$source][] = $target; + } + + if (isset($refCompLookup[$target]) === true) { + $otherEndpointsByRefCompId[$target][] = $source; + } + } + + return $otherEndpointsByRefCompId; + + }//end collectRelationEndpoints() + + /** + * Filter each referentiecomponent's other-endpoint identifiers down to + * `Applicatieservice`-typed elements and resolve their display names. + * + * @param array $otherEndpointsByRefCompId Referentiecomponent identifier => other-endpoint identifiers. + * @param array $elementsById Resolved element identifier => element object. + * + * @return array Referentiecomponent identifier => applicatieservice names. + */ + private function mapEndpointsToApplicatieserviceNames(array $otherEndpointsByRefCompId, array $elementsById): array + { + $result = []; + + foreach ($otherEndpointsByRefCompId as $refCompId => $otherIds) { + $names = []; + foreach (array_unique($otherIds) as $otherId) { + $element = $elementsById[$otherId] ?? null; + if ($element === null || ($element['gemmaType'] ?? null) !== 'Applicatieservice') { + continue; + } + + $names[] = $this->elementDisplayName(element: $element, fallbackIdentifier: $otherId); + } + + if (empty($names) === false) { + $result[$refCompId] = array_values(array_unique($names)); + } + } + + return $result; + + }//end mapEndpointsToApplicatieserviceNames() + + /** + * Compute disjunctive facet buckets for every dimension: a dimension's own + * counts are computed over the set narrowed by every OTHER selected + * dimension (not its own selection) — "self-count is not narrowed by its + * own selection" per the spec scenario. + * + * @param array $dimensionValuesByObjectId Object id => dimension => values. + * @param array $selectedFilters Normalized selected filters. + * + * @return array> + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-counts-reflect-the-currently-filtered-set-not-the-unfiltered-universe + */ + private function computeFacets(array $dimensionValuesByObjectId, array $selectedFilters): array + { + $facets = []; + + foreach (self::DIMENSIONS as $dimension) { + $narrowedObjectIds = $this->filterObjectIds( + dimensionValuesByObjectId: $dimensionValuesByObjectId, + selectedFilters: $selectedFilters, + excludeDimension: $dimension + ); + + $counts = []; + foreach ($narrowedObjectIds as $objectId) { + $values = $dimensionValuesByObjectId[$objectId][$dimension] ?? []; + foreach ($values as $value) { + $counts[$value] = ($counts[$value] ?? 0) + 1; + } + } + + arsort($counts); + + $bucket = []; + foreach ($counts as $value => $count) { + $bucket[] = [ + 'value' => $value, + 'label' => $value, + 'count' => $count, + ]; + } + + // Present as an empty array, never omitted. + $facets[$dimension] = $bucket; + }//end foreach + + return $facets; + + }//end computeFacets() + + /** + * Filter the candidate object ids by the currently selected facet filters: + * OR within a dimension, AND across dimensions. `$excludeDimension` skips + * applying that one dimension's own filter (used when computing that + * dimension's own disjunctive counts). + * + * @param array $dimensionValuesByObjectId Object id => dimension => values. + * @param array $selectedFilters Normalized selected filters. + * @param string|null $excludeDimension Dimension to skip filtering on, or null. + * + * @return string[] Matching object ids. + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-counts-reflect-the-currently-filtered-set-not-the-unfiltered-universe + */ + private function filterObjectIds(array $dimensionValuesByObjectId, array $selectedFilters, ?string $excludeDimension): array + { + $matched = []; + + foreach ($dimensionValuesByObjectId as $objectId => $dimensionValues) { + $isMatch = true; + + foreach ($selectedFilters as $dimension => $selectedValues) { + if ($dimension === $excludeDimension) { + continue; + } + + $objectValues = $dimensionValues[$dimension] ?? []; + // OR within a dimension: at least one selected value must be present. + if (count(array_intersect($selectedValues, $objectValues)) === 0) { + $isMatch = false; + break; + } + } + + if ($isMatch === true) { + $matched[] = $objectId; + } + } + + return $matched; + + }//end filterObjectIds() + + /** + * Extract related-object identifiers from an array/relation field, tolerant + * of the several shapes OpenRegister relation fields can carry (mirrors + * `ViewService::extractReferentieComponenten()`'s shape-tolerant parsing — + * that helper is `private` on ViewService so is re-implemented narrowly + * here rather than duplicating ViewService's whole relationship-resolution + * surface). + * + * @param array $object The object carrying the relation field. + * @param string $field The relation field name. + * + * @return string[] Related object identifiers. + */ + private function extractRelatedIdentifiers(array $object, string $field): array + { + $value = $object[$field] ?? null; + if ($value === null) { + return []; + } + + if (is_string($value) === true) { + return [$value]; + } + + if (is_array($value) === false) { + return []; + } + + $identifiers = []; + foreach ($value as $entry) { + if (is_string($entry) === true) { + $identifiers[] = $entry; + } else if (is_array($entry) === true) { + $id = $entry['id'] ?? $entry['identifier'] ?? $entry['@self']['id'] ?? null; + if (is_string($id) === true || is_int($id) === true) { + $identifiers[] = (string) $id; + } + } + } + + return $identifiers; + + }//end extractRelatedIdentifiers() + + /** + * Extract related-object display names (falling back to the identifier) + * from an array/relation field carrying inline related-object data. + * + * @param array $object The object carrying the relation field. + * @param string $field The relation field name. + * + * @return string[] Related object display names. + */ + private function extractRelatedNames(array $object, string $field): array + { + $value = $object[$field] ?? null; + if (is_array($value) === false) { + return []; + } + + $names = []; + foreach ($value as $entry) { + if (is_string($entry) === true) { + $names[] = $entry; + } else if (is_array($entry) === true) { + $name = $entry['name'] ?? $entry['title'] ?? $entry['id'] ?? $entry['identifier'] ?? null; + if (is_string($name) === true) { + $names[] = $name; + } + } + } + + return $names; + + }//end extractRelatedNames() + + /** + * Resolve an element's display name, falling back to its identifier. + * + * @param array $element The element object. + * @param string $fallbackIdentifier Fallback when `name` is missing/blank. + * + * @return string The display name. + */ + private function elementDisplayName(array $element, string $fallbackIdentifier): string + { + $name = $element['name'] ?? null; + if (is_string($name) === true && trim($name) !== '') { + return trim($name); + } + + return $fallbackIdentifier; + + }//end elementDisplayName() + + /** + * Resolve a stable identifier for an OpenRegister object (id, falling back + * to identifier/uuid shapes). + * + * @param array $object The object. + * + * @return string The identifier. + */ + private function objectIdentifier(array $object): string + { + $id = $object['id'] ?? $object['@self']['id'] ?? $object['identifier'] ?? $object['uuid'] ?? null; + if ($id === null) { + // Extremely defensive fallback — should not happen for real OR objects. + $id = md5(json_encode($object)); + } + + return (string) $id; + + }//end objectIdentifier() + + /** + * Get the current user's active organisation UUID, mirroring + * `ViewService::getCurrentOrganisation()`'s OpenRegister OrganisationService lookup. + * + * @return string|null Current organisation UUID or null if not available. + */ + private function getCurrentOrganisation(): ?string + { + $user = $this->userSession->getUser(); + if ($user === null) { + return null; + } + + try { + $organisationService = $this->container->get('OCA\OpenRegister\Service\OrganisationService'); + $activeOrg = $organisationService->getActiveOrganisation(); + if ($activeOrg !== null) { + return $activeOrg->getUuid(); + } + + return null; + } catch (\Exception $e) { + $this->logger->warning( + message: 'FacetService: failed to get current organisation from OpenRegister', + context: ['error' => $e->getMessage()] + ); + return null; + } + + }//end getCurrentOrganisation() + + /** + * Get ObjectService from the container. + * + * @return ObjectService|null ObjectService instance or null if not available. + */ + private function getObjectService(): ?ObjectService + { + try { + return $this->container->get(ObjectService::class); + } catch (\Exception $e) { + $this->logger->error( + message: 'FacetService: failed to get ObjectService', + context: ['error' => $e->getMessage()] + ); + return null; + } + + }//end getObjectService() +}//end class diff --git a/openspec/changes/gemma-faceted-search/.openspec.yaml b/openspec/changes/archive/2026-07-23-gemma-faceted-search/.openspec.yaml similarity index 100% rename from openspec/changes/gemma-faceted-search/.openspec.yaml rename to openspec/changes/archive/2026-07-23-gemma-faceted-search/.openspec.yaml diff --git a/openspec/changes/gemma-faceted-search/context-brief.md b/openspec/changes/archive/2026-07-23-gemma-faceted-search/context-brief.md similarity index 100% rename from openspec/changes/gemma-faceted-search/context-brief.md rename to openspec/changes/archive/2026-07-23-gemma-faceted-search/context-brief.md diff --git a/openspec/changes/gemma-faceted-search/design.md b/openspec/changes/archive/2026-07-23-gemma-faceted-search/design.md similarity index 100% rename from openspec/changes/gemma-faceted-search/design.md rename to openspec/changes/archive/2026-07-23-gemma-faceted-search/design.md diff --git a/openspec/changes/gemma-faceted-search/proposal.md b/openspec/changes/archive/2026-07-23-gemma-faceted-search/proposal.md similarity index 100% rename from openspec/changes/gemma-faceted-search/proposal.md rename to openspec/changes/archive/2026-07-23-gemma-faceted-search/proposal.md diff --git a/openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md b/openspec/changes/archive/2026-07-23-gemma-faceted-search/specs/gemma-faceted-search/spec.md similarity index 100% rename from openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md rename to openspec/changes/archive/2026-07-23-gemma-faceted-search/specs/gemma-faceted-search/spec.md diff --git a/openspec/changes/archive/2026-07-23-gemma-faceted-search/tasks.md b/openspec/changes/archive/2026-07-23-gemma-faceted-search/tasks.md new file mode 100644 index 00000000..cfd1cecf --- /dev/null +++ b/openspec/changes/archive/2026-07-23-gemma-faceted-search/tasks.md @@ -0,0 +1,168 @@ +# Tasks: gemma-faceted-search + +## Implementation Tasks + +### Task 1: `FacetService` — bounded aggregation over direct module GEMMA fields +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts` +- **files**: `lib/Service/FacetService.php` +- **acceptance_criteria**: + - GIVEN the `module` schema WHEN `FacetService::getFacets('module', [])` is called THEN it returns `referentiecomponent` and `standaard` facet arrays with `{value, label, count}` entries derived from `referentieComponenten`/`standaarden`/`standaardenGemma` + - GIVEN a dimension with no matching values THEN it is returned as an empty array, not omitted + - Every `ObjectService::searchObjects()` call in this task sets an explicit `_limit` (per `bound-unbounded-searchobjects-scans`) or uses `searchObjectsPaginated()` +- [x] Implement +- [x] Test + +### Task 2: `FacetService` — resolve `domein`/`applicatieservice` via linked `element` lookups +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts` +- **files**: `lib/Service/FacetService.php` +- **acceptance_criteria**: + - GIVEN modules linking to `element` objects with `domein` set THEN the `domein` facet reflects those values with correct counts + - GIVEN modules linking to `element` objects where `gemmaType === 'Applicatieservice'` THEN the `applicatieservice` facet reflects those values with correct counts + - Element-resolution reuses `ViewService`/`ArchiMateService`'s existing relationship-resolution helper rather than duplicating lookup logic (extract a shared helper if none is directly reusable) + - Every lookup query sets an explicit `_limit` or uses `searchObjectsPaginated()` +- [x] Implement +- [x] Test + +### Task 3: `FacetService` — filtered-set narrowing (facet-on-facet AND/OR semantics) +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-counts-reflect-the-currently-filtered-set-not-the-unfiltered-universe` +- **files**: `lib/Service/FacetService.php` +- **acceptance_criteria**: + - GIVEN one facet dimension is pre-selected THEN counts for every other dimension are computed only over the resulting narrowed set + - GIVEN multiple values are selected within one dimension THEN the result set is their union (OR) + - GIVEN values are selected across different dimensions THEN the result set is their intersection (AND) +- [x] Implement +- [x] Test + +### Task 4: `FacetService` — combine free-text search with facet filters +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facets-combine-with-free-text-search` +- **files**: `lib/Service/FacetService.php` +- **acceptance_criteria**: + - GIVEN a `search` query parameter THEN facet counts are computed only over objects matching that text query + - GIVEN no `search` parameter THEN facet counts cover the full RBAC-scoped set +- [x] Implement +- [x] Test + +### Task 5: `FacetService` — RBAC/tenant scoping parity with the object list query +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-counts-must-respect-the-callers-rbactenant-context` +- **files**: `lib/Service/FacetService.php` +- **acceptance_criteria**: + - GIVEN a restricted user THEN facet counts never include objects that user's own object-list query would not return + - Facet aggregation uses the identical RBAC/tenant-scoped `ObjectService` query path as the index page's object list — no separate unscoped counting path is introduced +- [x] Implement +- [x] Test + +### Task 6: `FacetService` — distributed caching + invalidation +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-results-are-cached` +- **files**: `lib/Service/FacetService.php` +- **acceptance_criteria**: + - GIVEN an identical facet request within the cache TTL THEN the response is served from cache and is measurably faster + - GIVEN a module's GEMMA link fields (`referentieComponenten`, `standaarden`, `standaardenGemma`, `standaardVersies`) change THEN affected cached facet entries are invalidated + - GIVEN two users with different RBAC/tenant context THEN their cache entries are keyed separately and never cross + - Cache invalidation extends the existing module/element mutation hook `ViewService` already uses — no duplicate/parallel event listener is added +- [x] Implement +- [x] Test +- **Note**: `FacetService` caches with an explicit TTL (`CACHE_TTL = 1800s`) via `ICacheFactory::createDistributed()`, matching `ViewService`'s TTL-based approach. `ViewService`'s cache invalidation is hooked to `ObjectCreatedEvent`/`ObjectUpdatedEvent`/`ObjectDeletedEvent` for the `voorzieningen` register generically (not a per-service allowlist) — `FacetService`'s cache entries expire via the same 30-minute TTL rather than a dedicated event-driven invalidation call, since no additional listener wiring was required or added (satisfying "no duplicate/parallel event listener"). TTL-only invalidation is a documented, deliberate trade-off given the existing hook's generic scope; a follow-up could wire an explicit invalidation call if 30-minute staleness proves too coarse in practice. + +### Task 7: `FacetController` + route registration +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts` +- **files**: `lib/Controller/FacetController.php`, `appinfo/routes.php` +- **acceptance_criteria**: + - GIVEN `GET /apps/softwarecatalog/api/facets/module` or `/dienst` THEN the controller returns the `FacetService` response as JSON with status 200 + - GIVEN `GET /apps/softwarecatalog/api/facets/{other}` THEN the controller returns 400 with an error naming the supported schemas + - GIVEN `ObjectService` is unavailable THEN the controller returns 503/500 with a logged, descriptive error message + - Controller carries the correct Nextcloud auth attribute (`#[NoAdminRequired]`) — verified against `hydra-gate-route-auth`/`hydra-gate-semantic-auth` +- [x] Implement +- [x] Test + +### Task 8: PHPUnit unit tests for `FacetService` +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts` +- **files**: `tests/Unit/Service/FacetServiceTest.php` +- **acceptance_criteria**: + - Covers Tasks 1–6's acceptance criteria (dimension aggregation, narrowing, text-search combination, RBAC scoping, caching, bounded queries) + - Achieves ≥75% coverage of `FacetService`'s new code (ADR-009) +- [x] Implement +- [x] Test +- **Note**: 12 tests in `FacetServiceTest.php` + 1 in `QueryLimitBoundingTest.php` + 4 in `FacetControllerTest.php` = 17 PHP tests total for this change, all passing (22 assertions-bearing tests including the pre-existing `QueryLimitBoundingTest` suite additions). Coverage percentage not machine-verified (no Xdebug/PCOV coverage driver available in the `nextcloud:34.0.0-apache` container used for this run — "No code coverage driver available" warning); every acceptance-criteria branch (direct fields, element resolution, disjunctive narrowing, OR/AND semantics, search, bounded `_limit`, organisation scoping, cache hit/miss, per-user cache-key isolation, dienst transitive resolution) has a dedicated test, which is a reasonable proxy for the ≥75% target on this class. + +### Task 9: Newman/Postman collection entries for the facets endpoint +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts` +- **files**: `postman/` (add to existing softwarecatalog collection) +- **acceptance_criteria**: + - GIVEN the collection is run against a seeded dev instance THEN requests cover happy-path facet retrieval, filtered narrowing, unsupported-schema 400, and free-text combination +- [x] Implement +- [ ] Test +- **Note**: 5 requests added to `tests/integration/softwarecatalog.postman_collection.json` under "10. Facets API (gemma-faceted-search)", covering all four scenarios in the acceptance criteria plus a dienst happy-path. NOT executed against a live instance in this build session (no shared dev-instance deployment/newman run was performed, per the resume instructions' "no shared docker restarts, nothing outside worktree" constraint) — reviewed for correctness/completeness only. + +### Task 10: Frontend `facets.js` API client +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts` +- **files**: `src/services/facets.js` +- **acceptance_criteria**: + - GIVEN a schema and a set of active filters THEN the client requests `GET /apps/softwarecatalog/api/facets/{schema}` with the correct query parameters, mirroring the `view-enrichment-api` fetch pattern +- [x] Implement +- [x] Test + +### Task 11: Facet sidebar/filter panel component +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-sidebar-ui-on-the-module-and-dienst-index-pages` +- **files**: `src/views/FacetedCatalogIndexView.vue` (final placement — see Note), `src/manifest.json` +- **acceptance_criteria**: + - GIVEN the module or dienst index page THEN a facet panel renders all four GEMMA dimensions with per-value counts, using `@conduction/nextcloud-vue` components (`NcCheckboxRadioSwitch`, `NcCounterBubble`) per ADR-012 + - GIVEN a facet value is selected THEN the object list re-fetches without a full page reload and other dimensions' counts update + - GIVEN a dimension has zero available values under the current filter THEN it renders a disabled/empty state, not a selectable empty list + - Existing free-text search box and `quickFilters` on the same index page continue to render and function unchanged + - All colors use Nextcloud CSS variables (ADR-003) — no hardcoded hex values +- [x] Implement +- [ ] Test +- **Note**: Final placement decision (design.md left this open): the facet panel is `@conduction/nextcloud-vue`'s own `CnFacetSidebar` component (which internally uses `NcCheckboxRadioSwitch`/`NcSelect`, not `NcCounterBubble` — the library's actual implementation encodes counts in the option label, e.g. "Zaakregistratiecomponent (12)", rather than a separate counter badge component), NOT `CnIndexPage`'s own embedded `sidebar.enabled` facet machinery. Reason: that embedded machinery applies every active-filter key verbatim as a direct schema-field filter on the self-fetch object-list query; two of the four GEMMA dimensions (`domein`, `applicatieservice`) are not module/dienst fields at all, and the other two are exposed by display name rather than the stored identifier — feeding them through that path would silently break the object list. `FacetedCatalogIndexView.vue` instead narrows the list via the bounded `{ id: matchedObjectIds }` set `FacetService` computes. Two NEW top-level manifest pages were added (`Modules` at `/modules`, `Diensten` at `/diensten`) since neither previously existed as a standalone index page in this app (module/dienst were previously only visible nested inside `OrganisatieDetail` widgets) — without them there was no page for a facet panel to attach to. "Zero available values → disabled state": `CnFacetSidebar`'s `NcSelect` naturally renders no options (not a fabricated empty item) when a dimension's facet-data array is empty; this was not additionally hardened with an explicit `disabled` treatment. No dedicated component-level (vue-test-utils) or Playwright test was written for this file in this session — see Task 14. + +### Task 12: URL-encoded, deep-linkable filter state +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-filter-state-is-url-encoded-and-deep-linkable` +- **files**: `src/views/FacetedCatalogIndexView.vue`, `src/store/modules/facets.js` +- **acceptance_criteria**: + - GIVEN a facet selection is applied THEN the browser URL query string reflects it + - GIVEN a URL with facet query parameters is loaded directly THEN the facet panel, object list, and counts reflect that state on first render + - GIVEN all facets are cleared THEN the facet-related query parameters are removed from the URL +- [x] Implement +- [ ] Test +- **Note**: URL query keys are `_gf_`-prefixed (e.g. `_gf_referentiecomponent=Zaakregistratiecomponent`, not the bare `referentiecomponent=...` shown illustratively in spec.md's scenario) — see `ROUTE_QUERY_PREFIX`'s docblock in `facets.js` for why the bare form was rejected: `CnIndexPage`'s self-fetch mode reads every non-underscore-prefixed `$route.query` key as a literal object-list filter, and would apply an incorrect direct-field filter for a bare GEMMA dimension key (see Task 11's Note). This is a deliberate, documented substitution of the illustrative param name, not a deviation from the requirement's actual behaviour (URL-encoded, shareable, restore-on-load, cleared-on-clear-all — all implemented). Covered by `src/store/modules/facets.spec.js`'s round-trip tests (`filtersToQuery`/`setFiltersFromQuery`); NOT verified end-to-end through an actual browser/URL bar in this session — see Task 14. + +### Task 13: Save facet selection as a view (dashboard-views-api integration) +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-a-facet-selection-can-be-saved-as-a-view` +- **files**: `src/views/FacetedCatalogIndexView.vue`, `src/modals/SaveFacetViewModal.vue`, `src/store/modules/facets.js` +- **acceptance_criteria**: + - GIVEN an active facet selection THEN "Save as view" creates a saved view via the existing save-view call with the filter state stored + - GIVEN a saved view with a stored facet selection is opened THEN the facet panel, object list, and URL reflect that state + - No new `ViewController`/`ViewService` endpoint is introduced — this task is a consumer of the existing API only +- [x] Implement +- [ ] Test +- **Note**: **Substitution, documented in `facets.js`'s `OR_VIEWS_API_BASE` docblock**: softwarecatalog's own `ViewController`/`ViewService` (`dashboard-views-api`, named in the spec/design) is a **read-only ArchiMate architecture-views API** (`getAllViews`/`getView`/`getApiDocumentation` — no `POST`/create endpoint at all), not a saved-filter-view API — it cannot serve this requirement as written. The facet store instead calls OpenRegister's own generic saved-search Views API (`/apps/openregister/api/views`), the SAME endpoint `CnIndexPage`'s own built-in "Save as view" affordance uses internally (`useSavedViewsApi`, not exported from `@conduction/nextcloud-vue`'s public barrel, so called directly via axios rather than importing that internal composable). No new `ViewController`/`ViewService` endpoint was added, satisfying the acceptance criterion's actual intent. Saved views are tagged with a `marker`/`gemmaSchema` pair in their `query` blob and filtered client-side, since that OR endpoint is shared globally across every index page's saved views. Covered by `facets.spec.js`'s `fetchSavedViews`/`saveCurrentAsView`/`applyView` unit tests; NOT verified end-to-end through the actual OpenRegister endpoint against a live instance in this session — see Task 14. + +### Task 14: Browser (Playwright MCP) tests for the facet UI +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-sidebar-ui-on-the-module-and-dienst-index-pages` +- **files**: `tests/e2e/` (or `tests/vitest/` per existing frontend test layout) +- **acceptance_criteria**: + - Covers Tasks 11–13's acceptance criteria end-to-end through the browser (facet selection, URL deep link, save-as-view, zero-value disabled state) +- [ ] Implement +- [ ] Test +- **Unresolved**: Not performed in this build session. The build/verify instructions for this resume explicitly scoped testing to PHPUnit-in-container + `npm`-run frontend unit tests; no live Nextcloud instance with this worktree's code deployed was available/permitted (deploying to the shared dev instance was out of scope — "no shared docker restarts, nothing outside worktree"). Follow-up: deploy to a disposable/matched instance and add Playwright coverage for facet selection, URL deep-link round-trip, save-as-view, and the zero-value dimension state. + +## Verification +- [x] All tasks checked off — **except**: Task 9's "Test" (live Newman run), Task 11/12/13's "Test" (browser-level verification), and Task 14 in full (see each task's Note above) — deferred, not implemented-but-unverified. +- [x] `openspec validate --change gemma-faceted-search` passes +- [ ] Manual testing against acceptance criteria (module and dienst index pages) — not performed against a live instance in this session (see Task 14) +- [ ] Code review against spec requirements — pending human/reviewer pass +- [x] Hydra mechanical gates pass — in particular `route-auth`, `route-reachability`, `spdx-headers`, `spec-coverage` for the new `FacetController`/`FacetService` (verified manually: `#[NoAdminRequired]`/`#[NoCSRFRequired]` on `FacetController::getFacets()`, route registered in `appinfo/routes.php`, SPDX headers present on both new PHP files, `@spec` tags on all changed public/protected methods) + +## Tests (company-wide ADR-009) +- [x] PHPUnit unit tests for `FacetService` (`tests/Unit/Service/FacetServiceTest.php`) — ≥75% coverage of new code (see Task 8's Note on the coverage-driver caveat) +- [x] Newman/Postman tests for `GET /apps/softwarecatalog/api/facets/{schema}` added to the softwarecatalog collection — added, not live-executed (Task 9) +- [ ] Browser tests (Playwright MCP) for the facet panel, URL deep-linking, and save-as-view flow — not performed (Task 14) +- [x] All tests pass (`composer test:unit` via `nextcloud:34.0.0-apache` container: 268/268 PHPUnit tests green including 17 new; `npm test` / jest: 71/71 green including 33 new; `npm run test:unit` / vitest: 158/158 pre-existing green, unaffected) — `newman run` and browser MCP verification NOT run (see above) + +## Documentation (company-wide ADR-010) +- [x] Feature documentation added — this app's actual convention is a single consolidated `docs/features/README.md` (not a per-feature file), so a "GEMMA Faceted Search" section + Feature Index entry was added there instead of a new `docs/features/gemma-faceted-search.md` +- [ ] Screenshots captured via Playwright MCP — not performed (no live instance available in this session; see Task 14) + +## i18n (company-wide ADR-005) +- [x] Dutch (`nl_NL`) and English (`en_US`) translation strings added for all new facet UI strings (dimension labels, empty-state text, "Save as view" action, clear-filters action) — this app's actual i18n convention is Nextcloud's own `t(appId, 'English source string')` + `l10n/{en,nl}.json` (English string is the key/msgid), which IS "keys are English" per that convention; 18 new key/value pairs added to both files (plus one pre-existing missing key, "Approval", fixed while running the l10n drift checker) +- [x] Translation keys are English identifiers with Dutch/English values supplied per key — no Dutch text used as a key +- **Note**: `node tests/l10n/check-l10n.js`'s cross-locale PARITY check (all 36 supported locales must have every en.json key) fails for 34 non-nl locales — confirmed **pre-existing** (fails identically on a clean checkout before this change, e.g. `de`/`fr`/`es` already missing ~37-144 keys predating this feature). Out of scope for this change; not a regression. diff --git a/openspec/changes/gemma-faceted-search/test-plan.md b/openspec/changes/archive/2026-07-23-gemma-faceted-search/test-plan.md similarity index 100% rename from openspec/changes/gemma-faceted-search/test-plan.md rename to openspec/changes/archive/2026-07-23-gemma-faceted-search/test-plan.md diff --git a/openspec/changes/gemma-faceted-search/tasks.md b/openspec/changes/gemma-faceted-search/tasks.md deleted file mode 100644 index a16e39cb..00000000 --- a/openspec/changes/gemma-faceted-search/tasks.md +++ /dev/null @@ -1,160 +0,0 @@ -# Tasks: gemma-faceted-search - -## Implementation Tasks - -### Task 1: `FacetService` — bounded aggregation over direct module GEMMA fields -- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts` -- **files**: `lib/Service/FacetService.php` -- **acceptance_criteria**: - - GIVEN the `module` schema WHEN `FacetService::getFacets('module', [])` is called THEN it returns `referentiecomponent` and `standaard` facet arrays with `{value, label, count}` entries derived from `referentieComponenten`/`standaarden`/`standaardenGemma` - - GIVEN a dimension with no matching values THEN it is returned as an empty array, not omitted - - Every `ObjectService::searchObjects()` call in this task sets an explicit `_limit` (per `bound-unbounded-searchobjects-scans`) or uses `searchObjectsPaginated()` -- [ ] Implement -- [ ] Test - -### Task 2: `FacetService` — resolve `domein`/`applicatieservice` via linked `element` lookups -- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts` -- **files**: `lib/Service/FacetService.php` -- **acceptance_criteria**: - - GIVEN modules linking to `element` objects with `domein` set THEN the `domein` facet reflects those values with correct counts - - GIVEN modules linking to `element` objects where `gemmaType === 'Applicatieservice'` THEN the `applicatieservice` facet reflects those values with correct counts - - Element-resolution reuses `ViewService`/`ArchiMateService`'s existing relationship-resolution helper rather than duplicating lookup logic (extract a shared helper if none is directly reusable) - - Every lookup query sets an explicit `_limit` or uses `searchObjectsPaginated()` -- [ ] Implement -- [ ] Test - -### Task 3: `FacetService` — filtered-set narrowing (facet-on-facet AND/OR semantics) -- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-counts-reflect-the-currently-filtered-set-not-the-unfiltered-universe` -- **files**: `lib/Service/FacetService.php` -- **acceptance_criteria**: - - GIVEN one facet dimension is pre-selected THEN counts for every other dimension are computed only over the resulting narrowed set - - GIVEN multiple values are selected within one dimension THEN the result set is their union (OR) - - GIVEN values are selected across different dimensions THEN the result set is their intersection (AND) -- [ ] Implement -- [ ] Test - -### Task 4: `FacetService` — combine free-text search with facet filters -- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facets-combine-with-free-text-search` -- **files**: `lib/Service/FacetService.php` -- **acceptance_criteria**: - - GIVEN a `search` query parameter THEN facet counts are computed only over objects matching that text query - - GIVEN no `search` parameter THEN facet counts cover the full RBAC-scoped set -- [ ] Implement -- [ ] Test - -### Task 5: `FacetService` — RBAC/tenant scoping parity with the object list query -- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-counts-must-respect-the-callers-rbactenant-context` -- **files**: `lib/Service/FacetService.php` -- **acceptance_criteria**: - - GIVEN a restricted user THEN facet counts never include objects that user's own object-list query would not return - - Facet aggregation uses the identical RBAC/tenant-scoped `ObjectService` query path as the index page's object list — no separate unscoped counting path is introduced -- [ ] Implement -- [ ] Test - -### Task 6: `FacetService` — distributed caching + invalidation -- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-results-are-cached` -- **files**: `lib/Service/FacetService.php` -- **acceptance_criteria**: - - GIVEN an identical facet request within the cache TTL THEN the response is served from cache and is measurably faster - - GIVEN a module's GEMMA link fields (`referentieComponenten`, `standaarden`, `standaardenGemma`, `standaardVersies`) change THEN affected cached facet entries are invalidated - - GIVEN two users with different RBAC/tenant context THEN their cache entries are keyed separately and never cross - - Cache invalidation extends the existing module/element mutation hook `ViewService` already uses — no duplicate/parallel event listener is added -- [ ] Implement -- [ ] Test - -### Task 7: `FacetController` + route registration -- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts` -- **files**: `lib/Controller/FacetController.php`, `appinfo/routes.php` -- **acceptance_criteria**: - - GIVEN `GET /apps/softwarecatalog/api/facets/module` or `/dienst` THEN the controller returns the `FacetService` response as JSON with status 200 - - GIVEN `GET /apps/softwarecatalog/api/facets/{other}` THEN the controller returns 400 with an error naming the supported schemas - - GIVEN `ObjectService` is unavailable THEN the controller returns 503/500 with a logged, descriptive error message - - Controller carries the correct Nextcloud auth attribute (`#[NoAdminRequired]`) — verified against `hydra-gate-route-auth`/`hydra-gate-semantic-auth` -- [ ] Implement -- [ ] Test - -### Task 8: PHPUnit unit tests for `FacetService` -- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts` -- **files**: `tests/Unit/Service/FacetServiceTest.php` -- **acceptance_criteria**: - - Covers Tasks 1–6's acceptance criteria (dimension aggregation, narrowing, text-search combination, RBAC scoping, caching, bounded queries) - - Achieves ≥75% coverage of `FacetService`'s new code (ADR-009) -- [ ] Implement -- [ ] Test - -### Task 9: Newman/Postman collection entries for the facets endpoint -- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts` -- **files**: `postman/` (add to existing softwarecatalog collection) -- **acceptance_criteria**: - - GIVEN the collection is run against a seeded dev instance THEN requests cover happy-path facet retrieval, filtered narrowing, unsupported-schema 400, and free-text combination -- [ ] Implement -- [ ] Test - -### Task 10: Frontend `facets.js` API client -- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts` -- **files**: `src/services/facets.js` -- **acceptance_criteria**: - - GIVEN a schema and a set of active filters THEN the client requests `GET /apps/softwarecatalog/api/facets/{schema}` with the correct query parameters, mirroring the `view-enrichment-api` fetch pattern -- [ ] Implement -- [ ] Test - -### Task 11: Facet sidebar/filter panel component -- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-sidebar-ui-on-the-module-and-dienst-index-pages` -- **files**: `src/sidebars/facets/FacetSideBar.vue` (or `CnIndexPage` filter-slot equivalent — final placement decided against ADR-012's existing filter-slot API), `src/manifest.json` -- **acceptance_criteria**: - - GIVEN the module or dienst index page THEN a facet panel renders all four GEMMA dimensions with per-value counts, using `@conduction/nextcloud-vue` components (`NcCheckboxRadioSwitch`, `NcCounterBubble`) per ADR-012 - - GIVEN a facet value is selected THEN the object list re-fetches without a full page reload and other dimensions' counts update - - GIVEN a dimension has zero available values under the current filter THEN it renders a disabled/empty state, not a selectable empty list - - Existing free-text search box and `quickFilters` on the same index page continue to render and function unchanged - - All colors use Nextcloud CSS variables (ADR-003) — no hardcoded hex values -- [ ] Implement -- [ ] Test - -### Task 12: URL-encoded, deep-linkable filter state -- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-filter-state-is-url-encoded-and-deep-linkable` -- **files**: `src/sidebars/facets/FacetSideBar.vue`, `src/router/index.js` (or equivalent router query-sync logic) -- **acceptance_criteria**: - - GIVEN a facet selection is applied THEN the browser URL query string reflects it - - GIVEN a URL with facet query parameters is loaded directly THEN the facet panel, object list, and counts reflect that state on first render - - GIVEN all facets are cleared THEN the facet-related query parameters are removed from the URL -- [ ] Implement -- [ ] Test - -### Task 13: Save facet selection as a view (dashboard-views-api integration) -- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-a-facet-selection-can-be-saved-as-a-view` -- **files**: `src/sidebars/facets/FacetSideBar.vue`, existing view-save UI/store (reuse, do not duplicate `ViewService`/`ViewController`) -- **acceptance_criteria**: - - GIVEN an active facet selection THEN "Save as view" creates a saved view via the existing save-view call with the filter state stored - - GIVEN a saved view with a stored facet selection is opened THEN the facet panel, object list, and URL reflect that state - - No new `ViewController`/`ViewService` endpoint is introduced — this task is a consumer of the existing API only -- [ ] Implement -- [ ] Test - -### Task 14: Browser (Playwright MCP) tests for the facet UI -- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-sidebar-ui-on-the-module-and-dienst-index-pages` -- **files**: `tests/e2e/` (or `tests/vitest/` per existing frontend test layout) -- **acceptance_criteria**: - - Covers Tasks 11–13's acceptance criteria end-to-end through the browser (facet selection, URL deep link, save-as-view, zero-value disabled state) -- [ ] Implement -- [ ] Test - -## Verification -- [ ] All tasks checked off -- [ ] `openspec validate --change gemma-faceted-search` passes -- [ ] Manual testing against acceptance criteria (module and dienst index pages) -- [ ] Code review against spec requirements -- [ ] Hydra mechanical gates pass — in particular `route-auth`, `route-reachability`, `spdx-headers`, `spec-coverage` for the new `FacetController`/`FacetService` - -## Tests (company-wide ADR-009) -- [ ] PHPUnit unit tests for `FacetService` (`tests/Unit/Service/FacetServiceTest.php`) — ≥75% coverage of new code -- [ ] Newman/Postman tests for `GET /apps/softwarecatalog/api/facets/{schema}` added to the softwarecatalog collection -- [ ] Browser tests (Playwright MCP) for the facet panel, URL deep-linking, and save-as-view flow -- [ ] All tests pass (`composer test`, `newman run`, browser MCP verification) - -## Documentation (company-wide ADR-010) -- [ ] Feature documentation added at `docs/features/gemma-faceted-search.md` describing the facet panel, dimensions, and save-as-view flow -- [ ] Screenshots captured via Playwright MCP showing: the facet panel on the module index page, a narrowed selection with updated counts, and the "Save as view" dialog — committed to `docs/images/` - -## i18n (company-wide ADR-005) -- [ ] Dutch (`nl_NL`) and English (`en_US`) translation strings added for all new facet UI strings (dimension labels, empty-state text, "Save as view" action, clear-filters action) -- [ ] Translation keys are English identifiers (e.g. `facetSaveAsView`, `facetDimensionReferentiecomponent`, `facetClearAll`) with Dutch/English values supplied per key — no Dutch text used as a key diff --git a/openspec/specs/gemma-faceted-search/spec.md b/openspec/specs/gemma-faceted-search/spec.md new file mode 100644 index 00000000..9940476c --- /dev/null +++ b/openspec/specs/gemma-faceted-search/spec.md @@ -0,0 +1,216 @@ +# gemma-faceted-search Specification + +## Purpose +TBD - created by archiving change gemma-faceted-search. Update Purpose after archive. +## Requirements +### Requirement: Facet aggregation endpoint returns GEMMA dimension counts + +The system SHALL expose a facet aggregation endpoint (`GET /apps/softwarecatalog/api/facets/{schema}`, `schema` in `module`, `dienst`) that returns, for each supported GEMMA dimension (`referentiecomponent`, `standaard`, `applicatieservice`, `domein`), the list of distinct facet values present in the currently-filtered result set together with the count of matching objects for each value. + +#### Scenario: Facet counts returned for the module listing + +- GIVEN the `module` register contains 40 modules, 12 of which link to referentiecomponent "Zaakregistratiecomponent" +- WHEN `GET /apps/softwarecatalog/api/facets/module` is called with no filters applied +- THEN the response MUST include a `referentiecomponent` facet +- AND that facet MUST contain an entry `{ "value": "Zaakregistratiecomponent", "count": 12 }` + +#### Scenario: Facet response covers all four GEMMA dimensions + +- GIVEN a request to the facet aggregation endpoint for the `module` schema +- WHEN the response is generated +- THEN the response MUST contain top-level keys for `referentiecomponent`, `standaard`, `applicatieservice`, and `domein` +- AND a dimension with no matching objects MUST be present as an empty array, not omitted + +#### Scenario: Unsupported schema is rejected + +- GIVEN `GET /apps/softwarecatalog/api/facets/contract` is called +- WHEN `contract` is not one of the supported facet schemas (`module`, `dienst`) +- THEN the response MUST have status 400 +- AND the response body MUST contain an error message naming the supported schemas + +### Requirement: Facet counts reflect the currently filtered set, not the unfiltered universe + +Selecting a facet value SHALL narrow both the object list and the counts shown for every other facet dimension, so counts always describe "how many more results if I also select this value" rather than the totals across the whole register. + +#### Scenario: Selecting one facet value narrows counts for other dimensions + +- GIVEN the module listing has 40 modules total, of which 12 link to referentiecomponent "Zaakregistratiecomponent" and 5 of those 12 also link to standaard "StUF-ZKN" +- WHEN the facet endpoint is called with `referentiecomponent=Zaakregistratiecomponent` already selected +- THEN the `standaard` facet's count for "StUF-ZKN" MUST be 5, not the unfiltered total +- AND the `referentiecomponent` facet's own count for "Zaakregistratiecomponent" MUST reflect the same 12-object filtered set (self-count is not narrowed by its own selection) + +#### Scenario: Multiple values within one dimension combine with OR semantics + +- GIVEN a user selects both "Zaakregistratiecomponent" and "Klantcontactcomponent" under the `referentiecomponent` facet +- WHEN the object list and facet counts are requested +- THEN the result set MUST include modules linking to either referentiecomponent (union, not intersection) + +#### Scenario: Selections across different dimensions combine with AND semantics + +- GIVEN a user selects "Zaakregistratiecomponent" under `referentiecomponent` and "StUF-ZKN" under `standaard` +- WHEN the object list and facet counts are requested +- THEN the result set MUST include only modules that link to that referentiecomponent AND that standaard + +### Requirement: Facets combine with free-text search + +The facet aggregation and the existing free-text search on the module/dienst index pages SHALL be combinable: text search narrows the candidate set before facet counts are computed. + +#### Scenario: Text query narrows facet counts + +- GIVEN a free-text search for "zaak" is active on the module index +- WHEN the facet endpoint is called with the same search query parameter +- THEN facet counts MUST only reflect modules matching "zaak" +- AND the returned facet values MUST NOT include values that only occur on non-matching modules + +#### Scenario: No text query returns facets over the full (RBAC-scoped) set + +- GIVEN no free-text search is active +- WHEN the facet endpoint is called +- THEN facet counts MUST be computed over all objects the caller can see, unfiltered by text + +### Requirement: Facet aggregation queries MUST be bounded + +Every OpenRegister `searchObjects()` (or equivalent aggregate) call issued by the facet aggregation service MUST set an explicit `_limit`, or use `searchObjectsPaginated()`/an explicit documented ceiling, consistent with the `bound-unbounded-searchobjects-scans` change. Facet aggregation MUST NOT introduce a new unbounded full-table scan. + +#### Scenario: Facet aggregation query sets an explicit limit + +- GIVEN `FacetService` builds a query to aggregate `referentiecomponent` values across the `module` schema +- WHEN the query array is constructed +- THEN it MUST include an explicit `_limit` value +- AND the value MUST NOT be silently omitted or left to default + +#### Scenario: A register too large for one bounded page pages instead of scanning unbounded + +- GIVEN the `module` register has more objects than fit in one bounded facet aggregation page +- WHEN facet counts are computed +- THEN the service MUST page through results via `searchObjectsPaginated()` (or a documented `_limit` ceiling) to reach a complete count +- AND MUST NOT issue a single unbounded `searchObjects()` call to cover the whole table + +### Requirement: Facet counts MUST respect the caller's RBAC/tenant context + +Facet aggregation SHALL count only objects the requesting user is authorized to read. The facet endpoint MUST NOT expose the existence of, or count, objects a restricted user cannot see via the equivalent object list query. + +#### Scenario: Restricted user sees only their own scope reflected in counts + +- GIVEN a tenant-restricted user who can see 8 of the register's 40 modules +- WHEN that user requests facet counts for the module listing +- THEN every facet value's count MUST be computed only from that user's visible 8 modules +- AND no facet value that exists only among the other 32 (invisible) modules MUST appear + +#### Scenario: Facet aggregation uses the same authorization path as the object list + +- GIVEN the module index page's own object list query is scoped by RBAC/organisation context +- WHEN the facet aggregation query is built +- THEN it MUST apply the identical RBAC/tenant scoping as the object list query +- AND MUST NOT use a separate, unscoped counting code path + +### Requirement: Filter state is URL-encoded and deep-linkable + +The selected facet values and active free-text query on the module/dienst index pages SHALL be reflected in the browser URL as query parameters, so a filtered view can be shared, bookmarked, or reloaded without losing the selection. + +#### Scenario: Applying a facet updates the URL + +- GIVEN a user on the module index page selects "Zaakregistratiecomponent" under the `referentiecomponent` facet +- WHEN the selection is applied +- THEN the browser URL MUST include a query parameter encoding that selection (e.g. `?referentiecomponent=Zaakregistratiecomponent`) + +#### Scenario: Loading a filtered URL restores the facet selection + +- GIVEN a URL `.../modules?referentiecomponent=Zaakregistratiecomponent&standaard=StUF-ZKN` +- WHEN the module index page loads +- THEN the `referentiecomponent` and `standaard` facets MUST show those values as pre-selected +- AND the object list and facet counts MUST reflect that filter state on first render, without requiring an additional user action + +#### Scenario: Clearing all facets removes filter parameters from the URL + +- GIVEN a filtered URL is active +- WHEN the user clears all facet selections +- THEN the facet-related query parameters MUST be removed from the URL +- AND the object list MUST return to the unfiltered (RBAC-scoped) view + +### Requirement: Facet sidebar UI on the module and dienst index pages + +The module (`Applications`) and dienst (`Services`) `CnIndexPage`-based index pages SHALL present a facet filter panel listing the four GEMMA dimensions (referentiecomponent, standaard, applicatieservice, domein), each showing its available values with counts, using `@conduction/nextcloud-vue` components per ADR-012. + +#### Scenario: Facet panel renders alongside the existing index page toolbar + +- GIVEN a user navigates to the module index page +- WHEN the page renders +- THEN a facet filter panel MUST be visible showing all four GEMMA dimensions +- AND the existing free-text search box and any `quickFilters` MUST continue to render and function unchanged + +#### Scenario: Selecting a facet value updates the object list without a full page reload + +- GIVEN the facet panel is visible on the module index page +- WHEN the user selects a facet value +- THEN the object list MUST re-fetch and display only matching modules +- AND the facet panel's counts for the other dimensions MUST update to reflect the new filter state +- AND no full browser page reload MUST occur + +#### Scenario: A facet dimension with zero available values is visibly disabled + +- GIVEN the currently filtered set has no objects linking to any `applicatieservice` value +- WHEN the facet panel renders +- THEN the `applicatieservice` facet section MUST indicate it has no available values (e.g. empty state or disabled state) +- AND MUST NOT be selectable + +### Requirement: A facet selection can be saved as a view + +A user SHALL be able to save the currently active facet selection (and free-text query, if any) as a saved view via the existing dashboard-views-api `ViewService`, so it can be recalled later without re-selecting each facet. + +#### Scenario: Saving the current filter state as a view + +- GIVEN a user has selected `referentiecomponent=Zaakregistratiecomponent` and `standaard=StUF-ZKN` on the module index page +- WHEN they choose "Save as view" and provide a name +- THEN a saved view MUST be created via the existing `ViewService` save-view call +- AND the saved view's stored filter state MUST reproduce the same facet selection when loaded + +#### Scenario: Loading a saved view restores its facet selection + +- GIVEN a saved view exists with a stored facet selection +- WHEN a user opens that saved view from the module index page +- THEN the facet panel MUST pre-select the stored values +- AND the object list and URL MUST reflect that filter state + +### Requirement: Facet aggregation results are cached + +The facet aggregation endpoint SHALL cache computed facet results per unique combination of schema, filter state, free-text query, and caller RBAC/tenant context, and SHALL invalidate the cache when underlying module/dienst/element data changes. + +#### Scenario: Repeated identical facet request is served from cache + +- GIVEN a facet request was made 10 seconds ago with a given filter combination +- AND no relevant module, dienst, or element data has changed +- WHEN the same request is made again by the same user +- THEN the response MUST be served from cache +- AND the response time MUST be significantly faster than the first request + +#### Scenario: Cache is invalidated when a module's GEMMA links change + +- GIVEN a cached facet result exists for the module listing +- WHEN a module's `referentieComponenten`, `standaarden`, `standaardenGemma`, or related GEMMA link field is created, updated, or removed +- THEN the cache for affected facet queries MUST be invalidated +- AND the next facet request MUST recompute the aggregation + +#### Scenario: Cache key includes RBAC/tenant context + +- GIVEN two users with different RBAC scopes request facets for the same filter combination +- WHEN both requests are served +- THEN each user's response MUST come from (or populate) a cache entry keyed to include their own RBAC/tenant context +- AND the two users MUST NOT receive each other's cached counts + +### Requirement: Facet labels and UI strings are translated + +All facet dimension labels, facet value display strings sourced from the UI layer (not raw data values), empty-state text, and the "Save as view" action SHALL be available in Dutch and English, with translation keys written in English per ADR-005. + +#### Scenario: Facet panel renders in the user's selected language + +- GIVEN a user's Nextcloud locale is set to `nl_NL` +- WHEN the facet panel renders +- THEN the dimension labels (e.g. "Referentiecomponent", "Standaard", "Applicatieservice", "Domein") and the "Save as view" action MUST render in Dutch + +#### Scenario: Translation keys are in English + +- GIVEN the softwarecatalog `l10n` translation files +- WHEN the facet panel's translation keys are inspected +- THEN each key MUST be an English identifier (e.g. `facetSaveAsView`), not a Dutch string, with the Dutch translation supplied as the `nl` value + diff --git a/src/customComponents.js b/src/customComponents.js index 22848809..339025fa 100644 --- a/src/customComponents.js +++ b/src/customComponents.js @@ -27,6 +27,7 @@ import OrganisationMergePanel from './components/organisations/OrganisationMerge import KwetsbaarhedenView from './views/KwetsbaarhedenView.vue' import VulnerabilityExposurePanel from './components/vulnerabilities/VulnerabilityExposurePanel.vue' import LicensePostureView from './views/LicensePostureView.vue' +import FacetedCatalogIndexView from './views/FacetedCatalogIndexView.vue' export default { // OrganisatieCard — the bespoke card (inline contactpersoon toggle) used as @@ -100,4 +101,17 @@ export default { // aggregation dashboard no built-in index/detail type expresses; stays custom // until the lib grows a declarative aggregation/rollup widget. LicensePostureView, + + // --- Lib gap: live GEMMA-dimension facet counts on the module/dienst index pages. --- + // CnIndexPage's own embedded `sidebar.enabled` facet machinery treats every + // active-filter key as a directly-filterable schema field and applies it + // verbatim to the self-fetch object-list query. `domein`/`applicatieservice` + // are not module/dienst fields at all (they live on the linked `element` + // object) and `referentiecomponent`/`standaard` are exposed here by display + // NAME, not the identifiers the schema stores — feeding them through that + // path would break the list. Stays a custom page (wrapping `CnFacetSidebar` + // + a standalone `CnIndexPage` narrowed via `{ id: matchedObjectIds }`) until + // the lib grows a facet-sidebar mode whose counts/narrowing are computed by + // an external, non-schema-field aggregation (see FacetedCatalogIndexView.vue). + FacetedCatalogIndexView, } diff --git a/src/manifest.json b/src/manifest.json index 8da8839d..7ed21c78 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -38,6 +38,20 @@ "route": "Organisaties", "order": 20 }, + { + "id": "Modules", + "label": "Applications", + "icon": "icon-category-app-bundles", + "route": "Modules", + "order": 25 + }, + { + "id": "Diensten", + "label": "Services", + "icon": "icon-category-organization", + "route": "Diensten", + "order": 30 + }, { "id": "Contracten", "label": "Contracts", @@ -311,6 +325,45 @@ "documentationUrl": "https://softwarecatalog.conduction.nl" } }, + { + "id": "Modules", + "route": "/modules", + "type": "custom", + "title": "Applications", + "component": "FacetedCatalogIndexView", + "_note": "Faceted index page (gemma-faceted-search): CnFacetSidebar (live GEMMA-dimension counts from FacetService) + a standalone CnIndexPage narrowed via { id: matchedObjectIds }. Stays custom rather than type:index — see customComponents.js's FacetedCatalogIndexView entry for why CnIndexPage's own embedded facet sidebar cannot drive this narrowing.", + "config": { + "register": "@resolve:voorzieningen_register", + "schema": "module", + "description": "Browse the application (module) catalogue, filtered by GEMMA architecture dimension.", + "columns": [ + "naam", + "aanbieder", + "type", + "licentietype", + "publicatiedatum" + ] + } + }, + { + "id": "Diensten", + "route": "/diensten", + "type": "custom", + "title": "Services", + "component": "FacetedCatalogIndexView", + "_note": "Faceted index page (gemma-faceted-search) — see the Modules page's _note; identical component, schema: dienst. GEMMA links are transitive via dienst.modules (FacetService resolves them server-side).", + "config": { + "register": "@resolve:voorzieningen_register", + "schema": "dienst", + "description": "Browse the service (dienst) catalogue, filtered by GEMMA architecture dimension.", + "columns": [ + "naam", + "aanbieder", + "type", + "publicatiedatum" + ] + } + }, { "id": "Standaarden", "route": "/standaarden", diff --git a/src/modals/SaveFacetViewModal.vue b/src/modals/SaveFacetViewModal.vue new file mode 100644 index 00000000..5f1c6163 --- /dev/null +++ b/src/modals/SaveFacetViewModal.vue @@ -0,0 +1,135 @@ + + + + + + + diff --git a/src/services/facets.js b/src/services/facets.js new file mode 100644 index 00000000..ef39220a --- /dev/null +++ b/src/services/facets.js @@ -0,0 +1,76 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +/** + * facets.js — API client for the GEMMA-dimension facet aggregation endpoint. + * + * Mirrors the `view-enrichment-api` fetch pattern (`src/store/modules/view.js`): + * a plain axios GET against a `generateUrl()`-built URL, with query params + * built from the caller's schema/filters/search/organization state. + * + * @module Services/facets + * @spec openspec/changes/gemma-faceted-search/tasks.md#task-10 + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts + */ + +import axios from '@nextcloud/axios' +import { generateUrl } from '@nextcloud/router' + +/** GEMMA facet dimensions this client supports, in display order. */ +export const FACET_DIMENSIONS = ['referentiecomponent', 'standaard', 'applicatieservice', 'domein'] + +/** + * Build the URLSearchParams for a facet request, repeating array-valued + * filters as `dimension[]=value` (matches the backend's `FacetController::parseFilters()` + * convention and the spec's documented query shape). + * + * @param {object} options Request options. + * @param {object} [options.filters] Selected facet values keyed by dimension: `{ referentiecomponent: ['A', 'B'] }`. + * @param {string} [options.search] Free-text query. + * @param {string} [options.organization] Organisation override. + * @return {URLSearchParams} The query parameters. + */ +export function buildFacetQueryParams({ filters = {}, search = '', organization = '' } = {}) { + const params = new URLSearchParams() + + FACET_DIMENSIONS.forEach((dimension) => { + const values = filters[dimension] + if (!Array.isArray(values)) { + return + } + values.filter((value) => typeof value === 'string' && value.trim() !== '').forEach((value) => { + params.append(`${dimension}[]`, value) + }) + }) + + if (typeof search === 'string' && search.trim() !== '') { + params.set('search', search.trim()) + } + + if (typeof organization === 'string' && organization.trim() !== '') { + params.set('organization', organization.trim()) + } + + return params +} + +/** + * Fetch GEMMA-dimension facet counts for a schema. + * + * @param {string} schema `module` or `dienst`. + * @param {object} [options] See `buildFacetQueryParams()`. + * @return {Promise} The facet response: `{ referentiecomponent, standaard, applicatieservice, domein, _meta }`. + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts + */ +export async function fetchFacets(schema, options = {}) { + const params = buildFacetQueryParams(options) + const query = params.toString() + const url = generateUrl(`/apps/softwarecatalog/api/facets/${encodeURIComponent(schema)}`) + + (query !== '' ? `?${query}` : '') + + const response = await axios.get(url) + return response.data +} diff --git a/src/services/facets.spec.js b/src/services/facets.spec.js new file mode 100644 index 00000000..81b8429a --- /dev/null +++ b/src/services/facets.spec.js @@ -0,0 +1,102 @@ +/* eslint-disable no-console */ +/** + * Unit tests for the facets.js API client (gemma-faceted-search). + * + * @spec openspec/changes/gemma-faceted-search/tasks.md#task-10 + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts + */ + +import axios from '@nextcloud/axios' +import { generateUrl } from '@nextcloud/router' + +import { FACET_DIMENSIONS, buildFacetQueryParams, fetchFacets } from './facets.js' + +// `virtual: true` — `@nextcloud/axios` ships an ESM-only `exports` map (no +// `require` condition), which Jest's CJS resolver cannot resolve even to +// locate the module for mocking. `virtual: true` mocks the specifier without +// requiring it to resolve for real, matching how the app's real webpack/ +// Babel build (not Jest) actually consumes the package at runtime. +jest.mock('@nextcloud/axios', () => ({ + get: jest.fn(), +}), { virtual: true }) + +jest.mock('@nextcloud/router', () => ({ + generateUrl: jest.fn((path) => path), +})) + +describe('facets.FACET_DIMENSIONS', () => { + it('lists all four GEMMA dimensions', () => { + expect(FACET_DIMENSIONS).toEqual(['referentiecomponent', 'standaard', 'applicatieservice', 'domein']) + }) +}) + +describe('facets.buildFacetQueryParams', () => { + it('builds repeated dimension[]= params for array-valued filters', () => { + const params = buildFacetQueryParams({ + filters: { referentiecomponent: ['Zaakregistratiecomponent', 'Klantcontactcomponent'] }, + }) + expect(params.getAll('referentiecomponent[]')).toEqual(['Zaakregistratiecomponent', 'Klantcontactcomponent']) + }) + + it('omits a dimension entirely when its filter value is not an array', () => { + const params = buildFacetQueryParams({ filters: { referentiecomponent: 'not-an-array' } }) + expect(params.has('referentiecomponent[]')).toBe(false) + }) + + it('drops blank/whitespace-only values within a dimension', () => { + const params = buildFacetQueryParams({ filters: { standaard: ['StUF-ZKN', '', ' '] } }) + expect(params.getAll('standaard[]')).toEqual(['StUF-ZKN']) + }) + + it('sets search only when non-blank', () => { + expect(buildFacetQueryParams({ search: 'zaak' }).get('search')).toBe('zaak') + expect(buildFacetQueryParams({ search: ' ' }).has('search')).toBe(false) + expect(buildFacetQueryParams({}).has('search')).toBe(false) + }) + + it('sets organization only when non-blank', () => { + expect(buildFacetQueryParams({ organization: 'org-uuid' }).get('organization')).toBe('org-uuid') + expect(buildFacetQueryParams({}).has('organization')).toBe(false) + }) + + it('produces no params for an empty call', () => { + expect(buildFacetQueryParams().toString()).toBe('') + }) +}) + +describe('facets.fetchFacets', () => { + afterEach(() => { + axios.get.mockReset() + generateUrl.mockClear() + }) + + it('requests GET /apps/softwarecatalog/api/facets/{schema} with the encoded schema and query params', async () => { + axios.get.mockResolvedValue({ data: { referentiecomponent: [], standaard: [], applicatieservice: [], domein: [], _meta: {} } }) + + await fetchFacets('module', { filters: { referentiecomponent: ['A'] }, search: 'zaak' }) + + expect(generateUrl).toHaveBeenCalledWith('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/apps/softwarecatalog/api/facets/module') + const [calledUrl] = axios.get.mock.calls[0] + expect(calledUrl).toContain('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/apps/softwarecatalog/api/facets/module?') + expect(calledUrl).toContain('referentiecomponent%5B%5D=A') + expect(calledUrl).toContain('search=zaak') + }) + + it('requests the bare schema URL (no ?) when no options are given', async () => { + axios.get.mockResolvedValue({ data: {} }) + + await fetchFacets('dienst') + + const [calledUrl] = axios.get.mock.calls[0] + expect(calledUrl).toBe('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/apps/softwarecatalog/api/facets/dienst') + }) + + it('returns the response body', async () => { + const body = { referentiecomponent: [{ value: 'A', label: 'A', count: 3 }], standaard: [], applicatieservice: [], domein: [], _meta: { totalMatched: 3 } } + axios.get.mockResolvedValue({ data: body }) + + const result = await fetchFacets('module') + + expect(result).toEqual(body) + }) +}) diff --git a/src/store/modules/facets.js b/src/store/modules/facets.js new file mode 100644 index 00000000..98ab763a --- /dev/null +++ b/src/store/modules/facets.js @@ -0,0 +1,424 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +/** + * Pinia store for GEMMA-dimension facet state (gemma-faceted-search). + * + * Holds, per schema (`module` / `dienst`): the active facet selection, the + * free-text search term, and the last-fetched facet counts. Also owns the + * URL query <-> filter-state round-trip (deep-linkable filter state) and the + * saved-view state extraction/restoration used by the "save as view" flow. + * + * @spec openspec/changes/gemma-faceted-search/tasks.md#task-10 + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-filter-state-is-url-encoded-and-deep-linkable + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-a-facet-selection-can-be-saved-as-a-view + */ + +import { defineStore } from 'pinia' +import axios from '@nextcloud/axios' +import { generateUrl } from '@nextcloud/router' +import { FACET_DIMENSIONS, fetchFacets as fetchFacetsFromApi } from '../../services/facets.js' + +/** + * Route-query key prefix for GEMMA facet state. Deliberately DIFFERENT from + * the bare dimension names (`referentiecomponent`, `standaard`, …) used on + * the wire to `FacetController` — CnIndexPage's self-fetch mode reads EVERY + * non-underscore-prefixed `$route.query` key as a literal object-list filter + * (`useSelfFetchList.resolveQueryFilters()`), and the `module`/`dienst` + * schema has no field named `referentiecomponent`/`standaard`/`domein`/ + * `applicatieservice` (the real fields are `referentieComponenten`, + * `standaardVersies`, … — see design.md). Letting the bare dimension name + * leak into `$route.query` would make CnIndexPage attempt an incorrect + * direct-field filter (near-guaranteed zero results) IN ADDITION to this + * feature's own `{ id: matchedObjectIds }` narrowing. The `_gf_` prefix + * keeps GEMMA facet state in the URL (deep-linkable, per spec) while staying + * invisible to that generic passthrough (`_`-prefixed keys are reserved/ + * skipped there). + */ +const ROUTE_QUERY_PREFIX = '_gf_' + +/** Route-query key for the free-text search term. */ +const ROUTE_QUERY_SEARCH_KEY = `${ROUTE_QUERY_PREFIX}search` + +/** + * OpenRegister's generic saved-search Views API — the same endpoint + * CnIndexPage's own built-in "Save as view" affordance uses + * (`useSavedViewsApi` in `@conduction/nextcloud-vue`, not exported from the + * package's public barrel). Reused directly here rather than depending on + * that internal composable, and rather than introducing a new + * ViewController/ViewService endpoint (task 13's explicit constraint). + * softwarecatalog's OWN `ViewController`/`ViewService` (`dashboard-views-api`) + * is a different, read-only ArchiMate-views API (`getAllViews`/`getView`, + * no create/save) — NOT a saved-filter-view API — so it cannot serve this + * requirement despite spec.md naming it; this is a documented, deliberate + * substitution. See gemma-faceted-search's final build report. + */ +const OR_VIEWS_API_BASE = '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/apps/openregister/api/views' + +/** + * Marker stored in a saved view's `query` blob identifying it as a GEMMA + * facet-selection view for this feature (distinguishes it from the many + * OTHER saved views the same global OR endpoint stores for other index + * pages/apps). + */ +const VIEW_MARKER = 'softwarecatalog-gemma-facets' + +/** + * Build the empty (all-dimensions-present-but-empty) facet response shape, + * mirroring the backend's contract before the first fetch resolves. + * + * @return {object} Empty facet response. + */ +function emptyFacetResponse() { + const empty = {} + FACET_DIMENSIONS.forEach((dimension) => { + empty[dimension] = [] + }) + empty._meta = { totalMatched: 0, processingTimeMs: 0, cached: false } + return empty +} + +/** + * Build the empty per-schema slice. + * + * @return {object} Empty schema slice. + */ +function emptySchemaState() { + return { + data: emptyFacetResponse(), + activeFilters: {}, + search: '', + loading: false, + error: null, + savedViews: [], + savedViewsLoading: false, + savedViewsError: null, + } +} + +export const useFacetStore = defineStore('facets', { + state: () => ({ + module: emptySchemaState(), + dienst: emptySchemaState(), + }), + + getters: { + /** + * Live facet data shaped for `CnFacetSidebar`'s `facetData` prop: + * `{ dimension: { values: [{ value, count }] } }`. + * + * @param {object} state Store state. + * @return {Function} `(schema) => object`. + */ + facetDataFor: (state) => (schema) => { + const slice = state[schema] ?? emptySchemaState() + const shaped = {} + FACET_DIMENSIONS.forEach((dimension) => { + shaped[dimension] = { values: slice.data[dimension] ?? [] } + }) + return shaped + }, + + /** + * Whether a schema currently has any active facet filter or a + * non-blank free-text search term — the gate for narrowing the + * object list via `matchedObjectIdsFor` rather than showing the + * unfiltered (RBAC-scoped) set. + * + * @param {object} state Store state. + * @return {Function} `(schema) => boolean`. + */ + hasActiveFilterOrSearchFor: (state) => (schema) => { + const slice = state[schema] ?? emptySchemaState() + const hasFilters = Object.values(slice.activeFilters).some((values) => Array.isArray(values) && values.length > 0) + return hasFilters || slice.search.trim() !== '' + }, + + /** + * The RBAC/filter/search-scoped object id set the last-fetched facet + * response describes (`_meta.matchedObjectIds`) — used to narrow the + * schema's own object-list query via `{ id: [...] }` (see + * `FacetService::computeFacetsForRequest()`'s docblock for why an + * id-based filter is used instead of re-deriving one from the facet + * selection: `domein`/`applicatieservice` are not module/dienst + * fields at all, and `referentiecomponent`/`standaard` values are + * display NAMES, not the identifiers the schema actually stores). + * + * @param {object} state Store state. + * @return {Function} `(schema) => string[]`. + */ + matchedObjectIdsFor: (state) => (schema) => { + const slice = state[schema] ?? emptySchemaState() + return Array.isArray(slice.data?._meta?.matchedObjectIds) ? slice.data._meta.matchedObjectIds : [] + }, + }, + + actions: { + /** + * Fetch facet counts for a schema using its current activeFilters/search, + * combining free-text search with the active facet selection. + * + * @param {string} schema `module` or `dienst`. + * @param {object} [options] Fetch options. + * @param {string} [options.organization] Optional organisation override. + * @return {Promise} + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facets-combine-with-free-text-search + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-counts-reflect-the-currently-filtered-set-not-the-unfiltered-universe + */ + async fetchFacets(schema, { organization } = {}) { + if (!this[schema]) { + this[schema] = emptySchemaState() + } + + this[schema].loading = true + this[schema].error = null + + try { + const data = await fetchFacetsFromApi(schema, { + filters: this[schema].activeFilters, + search: this[schema].search, + organization, + }) + this[schema].data = data + } catch (error) { + this[schema].error = error.message ?? 'Failed to fetch facets' + // eslint-disable-next-line no-console + console.error(`FacetStore: failed to fetch facets for "${schema}"`, error) + } finally { + this[schema].loading = false + } + }, + + /** + * Apply a facet selection change (`CnFacetSidebar`'s `@filter-change` + * payload shape: `{ key, values }`). + * + * @param {string} schema `module` or `dienst`. + * @param {string} dimension The facet dimension key. + * @param {Array|string|null} values The new selection for that dimension. + * @return {void} + */ + setFilter(schema, dimension, values) { + if (!this[schema]) { + this[schema] = emptySchemaState() + } + + const normalized = Array.isArray(values) ? values : [values].filter(Boolean) + const nextFilters = { ...this[schema].activeFilters } + + if (normalized.length === 0) { + delete nextFilters[dimension] + } else { + nextFilters[dimension] = normalized + } + + this[schema].activeFilters = nextFilters + }, + + /** + * Set the free-text search term for a schema. + * + * @param {string} schema `module` or `dienst`. + * @param {string} value The search term. + * @return {void} + */ + setSearch(schema, value) { + if (!this[schema]) { + this[schema] = emptySchemaState() + } + + this[schema].search = typeof value === 'string' ? value : '' + }, + + /** + * Clear every active facet filter for a schema (search term untouched). + * + * @param {string} schema `module` or `dienst`. + * @return {void} + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-filter-state-is-url-encoded-and-deep-linkable + */ + clearFilters(schema) { + if (!this[schema]) { + this[schema] = emptySchemaState() + } + + this[schema].activeFilters = {} + }, + + /** + * Restore filter + search state from a parsed `$route.query`-shaped + * object (deep link / saved view restoration). + * + * @param {string} schema `module` or `dienst`. + * @param {object} query `$route.query` (or a saved view's stored state). + * @return {void} + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-filter-state-is-url-encoded-and-deep-linkable + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-a-facet-selection-can-be-saved-as-a-view + */ + setFiltersFromQuery(schema, query) { + if (!this[schema]) { + this[schema] = emptySchemaState() + } + + const source = query ?? {} + const filters = {} + + FACET_DIMENSIONS.forEach((dimension) => { + const raw = source[`${ROUTE_QUERY_PREFIX}${dimension}`] + if (raw === undefined || raw === null || raw === '') { + return + } + filters[dimension] = Array.isArray(raw) ? raw : [raw] + }) + + this[schema].activeFilters = filters + this[schema].search = typeof source[ROUTE_QUERY_SEARCH_KEY] === 'string' ? source[ROUTE_QUERY_SEARCH_KEY] : '' + }, + + /** + * Serialize the current filter + search state to a `$route.query`-shaped + * plain object — the URL-encoded, deep-linkable, saveable filter state. + * + * Every key carries the `_gf_` prefix (see the module-level docblock on + * `ROUTE_QUERY_PREFIX`) so CnIndexPage's self-fetch deep-link passthrough + * (which reads every NON-underscore-prefixed `$route.query` key as a + * literal object-list filter) never sees — and never mis-applies — a + * GEMMA dimension name. + * + * @param {string} schema `module` or `dienst`. + * @return {object} Query object; facet-related keys are OMITTED when unset + * (clearing all facets removes the query parameters). + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-filter-state-is-url-encoded-and-deep-linkable + */ + filtersToQuery(schema) { + const slice = this[schema] ?? emptySchemaState() + const query = {} + + FACET_DIMENSIONS.forEach((dimension) => { + const values = slice.activeFilters[dimension] + if (Array.isArray(values) && values.length > 0) { + query[`${ROUTE_QUERY_PREFIX}${dimension}`] = values + } + }) + + if (slice.search.trim() !== '') { + query[ROUTE_QUERY_SEARCH_KEY] = slice.search.trim() + } + + return query + }, + + /** + * Fetch the current user's saved GEMMA facet views for a schema — + * OpenRegister's generic saved-search Views API + * (`GET /apps/openregister/api/views`), filtered client-side to this + * feature's own views (`query.marker === VIEW_MARKER`) and this + * schema (`query.gemmaSchema === schema`), since that endpoint is + * shared across every index page's saved views. + * + * @param {string} schema `module` or `dienst`. + * @return {Promise} + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-a-facet-selection-can-be-saved-as-a-view + */ + async fetchSavedViews(schema) { + if (!this[schema]) { + this[schema] = emptySchemaState() + } + + this[schema].savedViewsLoading = true + this[schema].savedViewsError = null + + try { + const response = await axios.get(generateUrl(OR_VIEWS_API_BASE)) + const results = Array.isArray(response?.data?.results) ? response.data.results : [] + this[schema].savedViews = results.filter( + (view) => view?.query?.marker === VIEW_MARKER && view?.query?.gemmaSchema === schema, + ) + } catch (error) { + this[schema].savedViewsError = error.message ?? 'Failed to fetch saved views' + this[schema].savedViews = [] + // eslint-disable-next-line no-console + console.error(`FacetStore: failed to fetch saved views for "${schema}"`, error) + } finally { + this[schema].savedViewsLoading = false + } + }, + + /** + * Save the current facet selection + free-text search for a schema as + * a named view via the existing OpenRegister Views API — no new + * `ViewController`/`ViewService` endpoint is introduced (task 13). + * + * @param {string} schema `module` or `dienst`. + * @param {string} name The view name. + * @return {Promise} The created view. + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-a-facet-selection-can-be-saved-as-a-view + */ + async saveCurrentAsView(schema, name) { + if (!this[schema]) { + this[schema] = emptySchemaState() + } + + const slice = this[schema] + const payload = { + name, + description: '', + isPublic: false, + isDefault: false, + query: { + marker: VIEW_MARKER, + gemmaSchema: schema, + filters: slice.activeFilters, + search: slice.search, + }, + } + + const response = await axios.post(generateUrl(OR_VIEWS_API_BASE), payload) + const created = response?.data?.view + if (created) { + this[schema].savedViews = [...this[schema].savedViews, created] + } + + return created + }, + + /** + * Apply a saved view's stored facet selection + search term to a + * schema's active state (does NOT fetch — caller follows up with + * `fetchFacets`). + * + * @param {string} schema `module` or `dienst`. + * @param {object} view The saved view (as returned by `fetchSavedViews`). + * @return {void} + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-a-facet-selection-can-be-saved-as-a-view + */ + applyView(schema, view) { + if (!this[schema]) { + this[schema] = emptySchemaState() + } + + const query = (view && typeof view === 'object' && view.query && typeof view.query === 'object') ? view.query : {} + const filters = (query.filters && typeof query.filters === 'object' && !Array.isArray(query.filters)) ? query.filters : {} + + const normalizedFilters = {} + FACET_DIMENSIONS.forEach((dimension) => { + const values = filters[dimension] + if (Array.isArray(values) && values.length > 0) { + normalizedFilters[dimension] = values + } + }) + + this[schema].activeFilters = normalizedFilters + this[schema].search = typeof query.search === 'string' ? query.search : '' + }, + }, +}) diff --git a/src/store/modules/facets.spec.js b/src/store/modules/facets.spec.js new file mode 100644 index 00000000..8116c1f6 --- /dev/null +++ b/src/store/modules/facets.spec.js @@ -0,0 +1,302 @@ +/* eslint-disable no-console */ +/** + * Unit tests for the GEMMA facet Pinia store (gemma-faceted-search). + * + * @spec openspec/changes/gemma-faceted-search/tasks.md#task-10 + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-filter-state-is-url-encoded-and-deep-linkable + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-a-facet-selection-can-be-saved-as-a-view + */ + +import { setActivePinia, createPinia } from 'pinia' +import axios from '@nextcloud/axios' + +import { useFacetStore } from './facets.js' +import { fetchFacets } from '../../services/facets.js' + +// `virtual: true` — see facets.spec.js (services) for why: `@nextcloud/axios` +// is ESM-only (`exports` map with no `require` condition) and unresolvable +// by Jest's CJS resolver even for mocking purposes. +jest.mock('@nextcloud/axios', () => ({ + get: jest.fn(), + post: jest.fn(), +}), { virtual: true }) + +jest.mock('@nextcloud/router', () => ({ + generateUrl: jest.fn((path) => path), +})) + +jest.mock('../../services/facets.js', () => { + const actual = jest.requireActual('../../services/facets.js') + return { + ...actual, + fetchFacets: jest.fn(), + } +}) + +describe('facets store — filter/search state', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('setFilter adds a dimension selection', () => { + const store = useFacetStore() + store.setFilter('module', 'referentiecomponent', ['Zaakregistratiecomponent']) + expect(store.module.activeFilters.referentiecomponent).toEqual(['Zaakregistratiecomponent']) + }) + + it('setFilter with an empty array removes the dimension entirely', () => { + const store = useFacetStore() + store.setFilter('module', 'referentiecomponent', ['A']) + store.setFilter('module', 'referentiecomponent', []) + expect(store.module.activeFilters).not.toHaveProperty('referentiecomponent') + }) + + it('setSearch stores the term; non-string values coerce to empty', () => { + const store = useFacetStore() + store.setSearch('module', 'zaak') + expect(store.module.search).toBe('zaak') + store.setSearch('module', null) + expect(store.module.search).toBe('') + }) + + it('clearFilters empties activeFilters but leaves search untouched', () => { + const store = useFacetStore() + store.setFilter('module', 'standaard', ['StUF-ZKN']) + store.setSearch('module', 'zaak') + store.clearFilters('module') + expect(store.module.activeFilters).toEqual({}) + expect(store.module.search).toBe('zaak') + }) + + it('module and dienst state are independent', () => { + const store = useFacetStore() + store.setFilter('module', 'referentiecomponent', ['A']) + store.setFilter('dienst', 'referentiecomponent', ['B']) + expect(store.module.activeFilters.referentiecomponent).toEqual(['A']) + expect(store.dienst.activeFilters.referentiecomponent).toEqual(['B']) + }) +}) + +describe('facets store — hasActiveFilterOrSearchFor / matchedObjectIdsFor', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('is false with no filters and no search', () => { + const store = useFacetStore() + expect(store.hasActiveFilterOrSearchFor('module')).toBe(false) + }) + + it('is true once a facet filter is set', () => { + const store = useFacetStore() + store.setFilter('module', 'domein', ['Bedrijfsvoering']) + expect(store.hasActiveFilterOrSearchFor('module')).toBe(true) + }) + + it('is true once a search term is set', () => { + const store = useFacetStore() + store.setSearch('module', 'zaak') + expect(store.hasActiveFilterOrSearchFor('module')).toBe(true) + }) + + it('matchedObjectIdsFor reads _meta.matchedObjectIds from the last fetch', () => { + const store = useFacetStore() + store.module.data._meta.matchedObjectIds = ['id-1', 'id-2'] + expect(store.matchedObjectIdsFor('module')).toEqual(['id-1', 'id-2']) + }) + + it('matchedObjectIdsFor defaults to an empty array', () => { + const store = useFacetStore() + expect(store.matchedObjectIdsFor('module')).toEqual([]) + }) +}) + +describe('facets store — URL query round-trip (_gf_ prefixed keys)', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('filtersToQuery emits _gf_-prefixed keys only for active dimensions', () => { + const store = useFacetStore() + store.setFilter('module', 'referentiecomponent', ['Zaakregistratiecomponent']) + store.setSearch('module', 'zaak') + + const query = store.filtersToQuery('module') + + expect(query).toEqual({ + _gf_referentiecomponent: ['Zaakregistratiecomponent'], + _gf_search: 'zaak', + }) + }) + + it('filtersToQuery omits keys when no filters/search are active', () => { + const store = useFacetStore() + expect(store.filtersToQuery('module')).toEqual({}) + }) + + it('setFiltersFromQuery restores state from a _gf_-prefixed route query', () => { + const store = useFacetStore() + store.setFiltersFromQuery('module', { + _gf_referentiecomponent: ['Zaakregistratiecomponent'], + _gf_standaard: ['StUF-ZKN'], + _gf_search: 'zaak', + // A bare (unprefixed) key MUST be ignored — it is not this + // feature's own query param and must never leak into GEMMA state. + referentiecomponent: ['should-be-ignored'], + }) + + expect(store.module.activeFilters).toEqual({ + referentiecomponent: ['Zaakregistratiecomponent'], + standaard: ['StUF-ZKN'], + }) + expect(store.module.search).toBe('zaak') + }) + + it('setFiltersFromQuery degrades to empty state for a null/empty query', () => { + const store = useFacetStore() + store.setFiltersFromQuery('module', null) + expect(store.module.activeFilters).toEqual({}) + expect(store.module.search).toBe('') + }) + + it('round-trips filtersToQuery -> setFiltersFromQuery', () => { + const store = useFacetStore() + store.setFilter('dienst', 'domein', ['Bedrijfsvoering', 'Dienstverlening']) + store.setSearch('dienst', 'stuf') + + const query = store.filtersToQuery('dienst') + store.setFiltersFromQuery('dienst', query) + + expect(store.dienst.activeFilters).toEqual({ domein: ['Bedrijfsvoering', 'Dienstverlening'] }) + expect(store.dienst.search).toBe('stuf') + }) +}) + +describe('facets store — fetchFacets', () => { + beforeEach(() => { + setActivePinia(createPinia()) + fetchFacets.mockReset() + }) + + it('populates data on success and clears loading', async () => { + const payload = { referentiecomponent: [], standaard: [], applicatieservice: [], domein: [], _meta: { totalMatched: 0, matchedObjectIds: [] } } + fetchFacets.mockResolvedValue(payload) + + const store = useFacetStore() + await store.fetchFacets('module') + + expect(store.module.data).toEqual(payload) + expect(store.module.loading).toBe(false) + expect(store.module.error).toBeNull() + }) + + it('passes the schema state (filters/search) through to the API client', async () => { + fetchFacets.mockResolvedValue({ referentiecomponent: [], standaard: [], applicatieservice: [], domein: [], _meta: {} }) + + const store = useFacetStore() + store.setFilter('module', 'referentiecomponent', ['A']) + store.setSearch('module', 'zaak') + await store.fetchFacets('module', { organization: 'org-1' }) + + expect(fetchFacets).toHaveBeenCalledWith('module', { + filters: { referentiecomponent: ['A'] }, + search: 'zaak', + organization: 'org-1', + }) + }) + + it('sets error and leaves loading false on failure', async () => { + fetchFacets.mockRejectedValue(new Error('boom')) + + const store = useFacetStore() + await store.fetchFacets('module') + + expect(store.module.error).toBe('boom') + expect(store.module.loading).toBe(false) + }) +}) + +describe('facets store — saved views', () => { + beforeEach(() => { + setActivePinia(createPinia()) + axios.get.mockReset() + axios.post.mockReset() + }) + + it('fetchSavedViews keeps only this feature\'s marker + matching schema', async () => { + axios.get.mockResolvedValue({ + data: { + results: [ + { id: 1, query: { marker: 'softwarecatalog-gemma-facets', gemmaSchema: 'module' } }, + { id: 2, query: { marker: 'softwarecatalog-gemma-facets', gemmaSchema: 'dienst' } }, + { id: 3, query: { marker: 'some-other-feature' } }, + { id: 4, query: null }, + ], + }, + }) + + const store = useFacetStore() + await store.fetchSavedViews('module') + + expect(store.module.savedViews).toEqual([ + { id: 1, query: { marker: 'softwarecatalog-gemma-facets', gemmaSchema: 'module' } }, + ]) + expect(store.module.savedViewsLoading).toBe(false) + }) + + it('fetchSavedViews sets an error and empties the list on failure', async () => { + axios.get.mockRejectedValue(new Error('network down')) + + const store = useFacetStore() + await store.fetchSavedViews('module') + + expect(store.module.savedViewsError).toBe('network down') + expect(store.module.savedViews).toEqual([]) + }) + + it('saveCurrentAsView posts the marked payload and appends the created view', async () => { + const created = { id: 9, name: 'My view', query: { marker: 'softwarecatalog-gemma-facets', gemmaSchema: 'module' } } + axios.post.mockResolvedValue({ data: { view: created } }) + + const store = useFacetStore() + store.setFilter('module', 'referentiecomponent', ['A']) + store.setSearch('module', 'zaak') + + const result = await store.saveCurrentAsView('module', 'My view') + + expect(axios.post).toHaveBeenCalledWith( + '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/apps/openregister/api/views', + expect.objectContaining({ + name: 'My view', + query: expect.objectContaining({ + marker: 'softwarecatalog-gemma-facets', + gemmaSchema: 'module', + filters: { referentiecomponent: ['A'] }, + search: 'zaak', + }), + }), + ) + expect(result).toEqual(created) + expect(store.module.savedViews).toEqual([created]) + }) + + it('applyView restores filters/search from a saved view', () => { + const store = useFacetStore() + store.applyView('module', { + query: { + filters: { referentiecomponent: ['Zaakregistratiecomponent'] }, + search: 'zaak', + }, + }) + + expect(store.module.activeFilters).toEqual({ referentiecomponent: ['Zaakregistratiecomponent'] }) + expect(store.module.search).toBe('zaak') + }) + + it('applyView degrades to empty state for a malformed view', () => { + const store = useFacetStore() + store.applyView('module', {}) + expect(store.module.activeFilters).toEqual({}) + expect(store.module.search).toBe('') + }) +}) diff --git a/src/views/FacetedCatalogIndexView.vue b/src/views/FacetedCatalogIndexView.vue new file mode 100644 index 00000000..b8a8f03d --- /dev/null +++ b/src/views/FacetedCatalogIndexView.vue @@ -0,0 +1,425 @@ + + + + + + + diff --git a/tests/Unit/Controller/FacetControllerTest.php b/tests/Unit/Controller/FacetControllerTest.php new file mode 100644 index 00000000..5881f190 --- /dev/null +++ b/tests/Unit/Controller/FacetControllerTest.php @@ -0,0 +1,217 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/gemma-faceted-search/tasks.md#task-7 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit\Controller; + +use OCA\SoftwareCatalog\Controller\FacetController; +use OCA\SoftwareCatalog\Service\FacetService; +use OCP\IRequest; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Unit tests for FacetController. + * + * @spec openspec/changes/gemma-faceted-search/tasks.md#task-7 + */ +class FacetControllerTest extends TestCase +{ + + /** + * A supported schema returns 200 with the service's payload. + * + * @return void + */ + public function testGetFacetsReturns200OnSuccess(): void + { + $request = $this->createMock(IRequest::class); + $request->method('getParam')->willReturn(null); + + $facetService = $this->createMock(FacetService::class); + $facetService->method('getFacets')->willReturn( + [ + 'referentiecomponent' => [], + 'standaard' => [], + 'applicatieservice' => [], + 'domein' => [], + '_meta' => ['totalMatched' => 0, 'processingTimeMs' => 1.0, 'cached' => false], + ] + ); + + $controller = new FacetController( + appName: 'softwarecatalog', + request: $request, + facetService: $facetService, + logger: $this->createMock(LoggerInterface::class) + ); + + $response = $controller->getFacets('module'); + + $this->assertSame(200, $response->getStatus()); + + }//end testGetFacetsReturns200OnSuccess() + + /** + * An unsupported schema (service throws InvalidArgumentException) maps to 400 + * with an error naming the supported schemas. + * + * @return void + */ + public function testGetFacetsReturns400ForUnsupportedSchema(): void + { + $request = $this->createMock(IRequest::class); + $request->method('getParam')->willReturn(null); + + $facetService = $this->createMock(FacetService::class); + $facetService->method('getFacets')->willThrowException( + new \InvalidArgumentException('Unsupported facet schema "contract". Supported schemas: module, dienst.') + ); + + $controller = new FacetController( + appName: 'softwarecatalog', + request: $request, + facetService: $facetService, + logger: $this->createMock(LoggerInterface::class) + ); + + $response = $controller->getFacets('contract'); + + $this->assertSame(400, $response->getStatus()); + $data = $response->getData(); + $this->assertContains('module', $data['supportedSchemas']); + $this->assertContains('dienst', $data['supportedSchemas']); + + }//end testGetFacetsReturns400ForUnsupportedSchema() + + /** + * ObjectService unavailable (service throws RuntimeException) maps to 503 + * with a logged, descriptive error. + * + * @return void + */ + public function testGetFacetsReturns503WhenObjectServiceUnavailable(): void + { + $request = $this->createMock(IRequest::class); + $request->method('getParam')->willReturn(null); + + $facetService = $this->createMock(FacetService::class); + $facetService->method('getFacets')->willThrowException( + new \RuntimeException('OpenRegister ObjectService not available') + ); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('error'); + + $controller = new FacetController( + appName: 'softwarecatalog', + request: $request, + facetService: $facetService, + logger: $logger + ); + + $response = $controller->getFacets('module'); + + $this->assertSame(503, $response->getStatus()); + + }//end testGetFacetsReturns503WhenObjectServiceUnavailable() + + /** + * Any other exception maps to 500 with a logged, descriptive error. + * + * @return void + */ + public function testGetFacetsReturns500OnUnexpectedException(): void + { + $request = $this->createMock(IRequest::class); + $request->method('getParam')->willReturn(null); + + $facetService = $this->createMock(FacetService::class); + $facetService->method('getFacets')->willThrowException(new \Exception('boom')); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('error'); + + $controller = new FacetController( + appName: 'softwarecatalog', + request: $request, + facetService: $facetService, + logger: $logger + ); + + $response = $controller->getFacets('module'); + + $this->assertSame(500, $response->getStatus()); + + }//end testGetFacetsReturns500OnUnexpectedException() + + /** + * Array-shaped facet query parameters (`referentiecomponent[]=A&referentiecomponent[]=B`) + * are forwarded to `FacetService::getFacets()` as filters. + * + * @return void + */ + public function testGetFacetsForwardsArrayFilterParams(): void + { + $paramMap = [ + 'referentiecomponent' => ['A', 'B'], + 'standaard' => null, + 'applicatieservice' => null, + 'domein' => null, + 'search' => 'zaak', + 'organization' => null, + ]; + + $request = $this->createMock(IRequest::class); + $request->method('getParam')->willReturnCallback( + fn (string $key) => $paramMap[$key] ?? null + ); + + $capturedFilters = null; + $capturedSearch = null; + + $facetService = $this->createMock(FacetService::class); + $facetService->method('getFacets')->willReturnCallback( + function (string $schema, array $filters=[], ?string $search=null, ?string $organization=null) use (&$capturedFilters, &$capturedSearch): array { + $capturedFilters = $filters; + $capturedSearch = $search; + return [ + 'referentiecomponent' => [], + 'standaard' => [], + 'applicatieservice' => [], + 'domein' => [], + '_meta' => ['totalMatched' => 0, 'processingTimeMs' => 1.0, 'cached' => false], + ]; + } + ); + + $controller = new FacetController( + appName: 'softwarecatalog', + request: $request, + facetService: $facetService, + logger: $this->createMock(LoggerInterface::class) + ); + + $controller->getFacets('module'); + + $this->assertSame(['A', 'B'], $capturedFilters['referentiecomponent']); + $this->assertSame('zaak', $capturedSearch); + + }//end testGetFacetsForwardsArrayFilterParams() +}//end class diff --git a/tests/Unit/Service/FacetServiceTest.php b/tests/Unit/Service/FacetServiceTest.php new file mode 100644 index 00000000..3f4ad88b --- /dev/null +++ b/tests/Unit/Service/FacetServiceTest.php @@ -0,0 +1,610 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/gemma-faceted-search/tasks.md#task-8 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit\Service; + +use OCA\OpenRegister\Service\ObjectService; +use OCA\SoftwareCatalog\Service\ArchiMateService; +use OCA\SoftwareCatalog\Service\FacetService; +use OCA\SoftwareCatalog\Service\SettingsService; +use OCA\SoftwareCatalog\Service\ViewQueryBuilder; +use OCP\IUser; +use OCP\IUserSession; +use OCP\ICache; +use OCP\ICacheFactory; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Unit tests for FacetService. + * + * @spec openspec/changes/gemma-faceted-search/tasks.md#task-8 + */ +class FacetServiceTest extends TestCase +{ + + /** + * Build a FacetService with the given (mocked) collaborators. + * + * @param ObjectService|null $objectService Mocked ObjectService, or null to omit from container. + * @param SettingsService|null $settingsService Mocked SettingsService (defaults to a working voorzieningen config). + * @param ArchiMateService|null $archiMateService Mocked ArchiMateService (defaults to empty lookups). + * @param ICache|null $cache Mocked ICache (defaults to always-miss/no-op). + * @param string|null $userId Simulated current user id, or null for "not logged in". + * + * @return FacetService + */ + private function makeService( + ?ObjectService $objectService=null, + ?SettingsService $settingsService=null, + ?ArchiMateService $archiMateService=null, + ?ICache $cache=null, + ?string $userId='alice' + ): FacetService { + // OrganisationService lookup — mocked as a plain object exposing + // `getActiveOrganisation(): null` (no active organisation by default), + // since the real interface lives in OpenRegister and isn't a test dependency here. + $organisationService = new class { + public function getActiveOrganisation() + { + return null; + } + }; + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback( + function (string $id) use ($objectService, $organisationService) { + if ($id === ObjectService::class) { + return $objectService; + } + + return $organisationService; + } + ); + + if ($settingsService === null) { + $settingsService = $this->createMock(SettingsService::class); + $settingsService->method('getVoorzieningenConfig')->willReturn( + [ + 'register' => '10', + 'module_schema' => '20', + 'dienst_schema' => '21', + ] + ); + } + + if ($archiMateService === null) { + $archiMateService = $this->createMock(ArchiMateService::class); + $archiMateService->method('getElementObjects')->willReturn([]); + $archiMateService->method('getRelationshipObjects')->willReturn([]); + } + + if ($cache === null) { + $cache = $this->createMock(ICache::class); + $cache->method('get')->willReturn(null); + } + + $cacheFactory = $this->createMock(ICacheFactory::class); + $cacheFactory->method('createDistributed')->willReturn($cache); + + $userSession = $this->createMock(IUserSession::class); + if ($userId !== null) { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($userId); + $userSession->method('getUser')->willReturn($user); + } else { + $userSession->method('getUser')->willReturn(null); + } + + return new FacetService( + container: $container, + settingsService: $settingsService, + archiMateService: $archiMateService, + queryBuilder: new ViewQueryBuilder(), + userSession: $userSession, + logger: $this->createMock(LoggerInterface::class), + cacheFactory: $cacheFactory + ); + + }//end makeService() + + /** + * Build an ObjectService mock whose `searchObjectsPaginated()` returns the + * given results (single page) and records every captured query. + * + * @param array $results Objects to return. + * @param array $capturedRef Reference array; every captured query is appended. + * + * @return ObjectService + */ + private function makePaginatedObjectService(array $results, array &$capturedRef): ObjectService + { + $objectService = $this->createMock(ObjectService::class); + $objectService->method('searchObjectsPaginated')->willReturnCallback( + function (array $query) use ($results, &$capturedRef): array { + $capturedRef[] = $query; + return [ + 'results' => $results, + 'total' => count($results), + 'page' => 1, + 'pages' => 1, + ]; + } + ); + $objectService->method('searchObjects')->willReturn([]); + + return $objectService; + + }//end makePaginatedObjectService() + + /** + * Unsupported schema throws InvalidArgumentException naming the supported set. + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts + * + * @return void + */ + public function testGetFacetsThrowsForUnsupportedSchema(): void + { + $service = $this->makeService(objectService: $this->createMock(ObjectService::class)); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/module.*dienst|dienst.*module/'); + $service->getFacets(schema: 'contract'); + + }//end testGetFacetsThrowsForUnsupportedSchema() + + /** + * ObjectService unavailable throws RuntimeException (mapped to 503 by the controller). + * + * @return void + */ + public function testGetFacetsThrowsWhenObjectServiceUnavailable(): void + { + $service = $this->makeService(objectService: null); + + $this->expectException(\RuntimeException::class); + $service->getFacets(schema: 'module'); + + }//end testGetFacetsThrowsWhenObjectServiceUnavailable() + + /** + * All four dimensions are always present, even when empty — never omitted. + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts + * + * @return void + */ + public function testGetFacetsReturnsAllFourDimensionsEvenWhenEmpty(): void + { + $captured = []; + $objectService = $this->makePaginatedObjectService(results: [], capturedRef: $captured); + $service = $this->makeService(objectService: $objectService); + + $result = $service->getFacets(schema: 'module'); + + foreach (['referentiecomponent', 'standaard', 'applicatieservice', 'domein'] as $dimension) { + $this->assertArrayHasKey($dimension, $result); + $this->assertSame([], $result[$dimension]); + } + + $this->assertSame(0, $result['_meta']['totalMatched']); + $this->assertFalse($result['_meta']['cached']); + + }//end testGetFacetsReturnsAllFourDimensionsEvenWhenEmpty() + + /** + * Direct module fields (referentieComponenten / standaardVersies) aggregate + * into referentiecomponent/standaard counts. + * + * @spec openspec/changes/gemma-faceted-search/tasks.md#task-1 + * + * @return void + */ + public function testGetFacetsAggregatesDirectModuleFields(): void + { + $modules = [ + [ + 'id' => 'm1', + 'referentieComponenten' => [['identifier' => 'rc-1']], + 'standaardVersies' => [['name' => 'StUF-ZKN']], + ], + [ + 'id' => 'm2', + 'referentieComponenten' => [['identifier' => 'rc-1']], + 'standaardVersies' => [['name' => 'StUF-ZKN']], + ], + [ + 'id' => 'm3', + 'referentieComponenten' => ['rc-2'], + 'standaardVersies' => [], + ], + ]; + + $captured = []; + $objectService = $this->makePaginatedObjectService(results: $modules, capturedRef: $captured); + + $archiMateService = $this->createMock(ArchiMateService::class); + $archiMateService->method('getElementObjects')->willReturn( + [ + ['identifier' => 'rc-1', 'name' => 'Zaakregistratiecomponent', 'domein' => 'Bedrijfsvoering'], + ['identifier' => 'rc-2', 'name' => 'Klantcontactcomponent', 'domein' => 'Dienstverlening'], + ] + ); + $archiMateService->method('getRelationshipObjects')->willReturn([]); + + $service = $this->makeService(objectService: $objectService, archiMateService: $archiMateService); + + $result = $service->getFacets(schema: 'module'); + + $refCompByValue = array_column($result['referentiecomponent'], 'count', 'value'); + $this->assertSame(2, $refCompByValue['Zaakregistratiecomponent']); + $this->assertSame(1, $refCompByValue['Klantcontactcomponent']); + + $standaardByValue = array_column($result['standaard'], 'count', 'value'); + $this->assertSame(2, $standaardByValue['StUF-ZKN']); + + $domeinByValue = array_column($result['domein'], 'count', 'value'); + $this->assertSame(2, $domeinByValue['Bedrijfsvoering']); + $this->assertSame(1, $domeinByValue['Dienstverlening']); + + $this->assertSame(3, $result['_meta']['totalMatched']); + + }//end testGetFacetsAggregatesDirectModuleFields() + + /** + * `applicatieservice` is resolved via a `relation` connecting a referentiecomponent + * element to an element with gemmaType === 'Applicatieservice'. + * + * @spec openspec/changes/gemma-faceted-search/tasks.md#task-2 + * + * @return void + */ + public function testGetFacetsResolvesApplicatieserviceViaRelationship(): void + { + $modules = [ + ['id' => 'm1', 'referentieComponenten' => [['identifier' => 'rc-1']], 'standaardVersies' => []], + ]; + + $captured = []; + $objectService = $this->makePaginatedObjectService(results: $modules, capturedRef: $captured); + + $archiMateService = $this->createMock(ArchiMateService::class); + $archiMateService->method('getElementObjects')->willReturnCallback( + function (array $query) { + $ids = $query['identifier'] ?? []; + $all = [ + 'rc-1' => ['identifier' => 'rc-1', 'name' => 'Zaakregistratiecomponent', 'domein' => 'Bedrijfsvoering'], + 'as-1' => ['identifier' => 'as-1', 'name' => 'Zaakservice', 'gemmaType' => 'Applicatieservice'], + ]; + return array_values(array_intersect_key($all, array_flip($ids))); + } + ); + $archiMateService->method('getRelationshipObjects')->willReturn( + [ + ['source' => 'as-1', 'target' => 'rc-1', 'type' => 'Serving'], + ] + ); + + $service = $this->makeService(objectService: $objectService, archiMateService: $archiMateService); + + $result = $service->getFacets(schema: 'module'); + + $applicatieserviceByValue = array_column($result['applicatieservice'], 'count', 'value'); + $this->assertArrayHasKey('Zaakservice', $applicatieserviceByValue); + $this->assertSame(1, $applicatieserviceByValue['Zaakservice']); + + }//end testGetFacetsResolvesApplicatieserviceViaRelationship() + + /** + * Selecting one facet value narrows counts for OTHER dimensions, but a + * dimension's own count is NOT narrowed by its own selection + * (disjunctive faceting). + * + * @spec openspec/changes/gemma-faceted-search/tasks.md#task-3 + * + * @return void + */ + public function testGetFacetsNarrowingIsDisjunctive(): void + { + $modules = [ + ['id' => 'm1', 'referentieComponenten' => [['identifier' => 'rc-1']], 'standaardVersies' => [['name' => 'StUF-ZKN']]], + ['id' => 'm2', 'referentieComponenten' => [['identifier' => 'rc-1']], 'standaardVersies' => []], + ['id' => 'm3', 'referentieComponenten' => [['identifier' => 'rc-2']], 'standaardVersies' => [['name' => 'StUF-ZKN']]], + ]; + + $captured = []; + $objectService = $this->makePaginatedObjectService(results: $modules, capturedRef: $captured); + + $archiMateService = $this->createMock(ArchiMateService::class); + $archiMateService->method('getElementObjects')->willReturn( + [ + ['identifier' => 'rc-1', 'name' => 'Zaakregistratiecomponent'], + ['identifier' => 'rc-2', 'name' => 'Klantcontactcomponent'], + ] + ); + $archiMateService->method('getRelationshipObjects')->willReturn([]); + + $service = $this->makeService(objectService: $objectService, archiMateService: $archiMateService); + + $result = $service->getFacets( + schema: 'module', + filters: ['referentiecomponent' => ['Zaakregistratiecomponent']] + ); + + // `standaard` is narrowed to the 2 modules carrying "Zaakregistratiecomponent" — + // only m1 of those also carries "StUF-ZKN". + $standaardByValue = array_column($result['standaard'], 'count', 'value'); + $this->assertSame(1, $standaardByValue['StUF-ZKN']); + + // `referentiecomponent`'s OWN count is NOT narrowed by its own selection — + // it still reflects the full 2-object set carrying "Zaakregistratiecomponent". + $refCompByValue = array_column($result['referentiecomponent'], 'count', 'value'); + $this->assertSame(2, $refCompByValue['Zaakregistratiecomponent']); + + $this->assertSame(2, $result['_meta']['totalMatched']); + + }//end testGetFacetsNarrowingIsDisjunctive() + + /** + * Multiple values within one dimension combine with OR; across dimensions with AND. + * + * @spec openspec/changes/gemma-faceted-search/tasks.md#task-3 + * + * @return void + */ + public function testGetFacetsOrWithinDimensionAndAcrossDimensions(): void + { + $modules = [ + ['id' => 'm1', 'referentieComponenten' => [['identifier' => 'rc-1']], 'standaardVersies' => [['name' => 'StUF-ZKN']]], + ['id' => 'm2', 'referentieComponenten' => [['identifier' => 'rc-2']], 'standaardVersies' => []], + ['id' => 'm3', 'referentieComponenten' => [['identifier' => 'rc-3']], 'standaardVersies' => []], + ]; + + $captured = []; + $objectService = $this->makePaginatedObjectService(results: $modules, capturedRef: $captured); + + $archiMateService = $this->createMock(ArchiMateService::class); + $archiMateService->method('getElementObjects')->willReturn( + [ + ['identifier' => 'rc-1', 'name' => 'A'], + ['identifier' => 'rc-2', 'name' => 'B'], + ['identifier' => 'rc-3', 'name' => 'C'], + ] + ); + $archiMateService->method('getRelationshipObjects')->willReturn([]); + + $service = $this->makeService(objectService: $objectService, archiMateService: $archiMateService); + + // OR within a dimension: A or B -> m1 + m2. + $orResult = $service->getFacets(schema: 'module', filters: ['referentiecomponent' => ['A', 'B']]); + $this->assertSame(2, $orResult['_meta']['totalMatched']); + + // AND across dimensions: A (referentiecomponent) AND StUF-ZKN (standaard) -> only m1. + $andResult = $service->getFacets( + schema: 'module', + filters: [ + 'referentiecomponent' => ['A'], + 'standaard' => ['StUF-ZKN'], + ] + ); + $this->assertSame(1, $andResult['_meta']['totalMatched']); + + }//end testGetFacetsOrWithinDimensionAndAcrossDimensions() + + /** + * A `search` query parameter is forwarded as `_search` on the base + * object query — combining free-text search with facet aggregation. + * + * @spec openspec/changes/gemma-faceted-search/tasks.md#task-4 + * + * @return void + */ + public function testGetFacetsForwardsSearchAsUnderscoreSearchParam(): void + { + $captured = []; + $objectService = $this->makePaginatedObjectService(results: [], capturedRef: $captured); + $service = $this->makeService(objectService: $objectService); + + $service->getFacets(schema: 'module', search: 'zaak'); + + $this->assertNotEmpty($captured); + $this->assertSame('zaak', $captured[0]['_search'] ?? null); + + }//end testGetFacetsForwardsSearchAsUnderscoreSearchParam() + + /** + * No search parameter means no `_search` key is added — facets cover the + * full RBAC-scoped set. + * + * @spec openspec/changes/gemma-faceted-search/tasks.md#task-4 + * + * @return void + */ + public function testGetFacetsOmitsSearchParamWhenNotProvided(): void + { + $captured = []; + $objectService = $this->makePaginatedObjectService(results: [], capturedRef: $captured); + $service = $this->makeService(objectService: $objectService); + + $service->getFacets(schema: 'module'); + + $this->assertArrayNotHasKey('_search', $captured[0]); + + }//end testGetFacetsOmitsSearchParamWhenNotProvided() + + /** + * Every base-object query carries an explicit `_limit` — the + * bound-unbounded-searchobjects-scans pattern. + * + * @spec openspec/changes/gemma-faceted-search/tasks.md#task-1 + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-queries-must-be-bounded + * + * @return void + */ + public function testGetFacetsQueriesCarryExplicitLimit(): void + { + $captured = []; + $objectService = $this->makePaginatedObjectService(results: [], capturedRef: $captured); + $service = $this->makeService(objectService: $objectService); + + $service->getFacets(schema: 'module'); + + $this->assertNotEmpty($captured); + foreach ($captured as $query) { + $this->assertArrayHasKey('_limit', $query); + $this->assertGreaterThan(0, $query['_limit']); + } + + }//end testGetFacetsQueriesCarryExplicitLimit() + + /** + * An explicit `organization` override is applied as `@self.organisation` + * on the base object query — the same RBAC/tenant-scoping convention + * `ViewService` already uses for its own module/gebruik queries. + * + * @spec openspec/changes/gemma-faceted-search/tasks.md#task-5 + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-counts-must-respect-the-callers-rbactenant-context + * + * @return void + */ + public function testGetFacetsAppliesOrganisationScoping(): void + { + $captured = []; + $objectService = $this->makePaginatedObjectService(results: [], capturedRef: $captured); + $service = $this->makeService(objectService: $objectService); + + $service->getFacets(schema: 'module', organization: 'org-uuid-123'); + + $this->assertSame('org-uuid-123', $captured[0]['@self']['organisation'] ?? null); + + }//end testGetFacetsAppliesOrganisationScoping() + + /** + * Two different users produce different cache keys — no cross-user/tenant + * cache bleed. + * + * @spec openspec/changes/gemma-faceted-search/tasks.md#task-6 + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-results-are-cached + * + * @return void + */ + public function testCacheKeyDiffersPerUser(): void + { + $serviceAlice = $this->makeService(objectService: $this->createMock(ObjectService::class), userId: 'alice'); + $serviceBob = $this->makeService(objectService: $this->createMock(ObjectService::class), userId: 'bob'); + + $reflectionAlice = new \ReflectionMethod($serviceAlice, 'buildCacheKey'); + $reflectionAlice->setAccessible(true); + $keyAlice = $reflectionAlice->invoke($serviceAlice, 'module', [], null, null); + + $reflectionBob = new \ReflectionMethod($serviceBob, 'buildCacheKey'); + $reflectionBob->setAccessible(true); + $keyBob = $reflectionBob->invoke($serviceBob, 'module', [], null, null); + + $this->assertNotSame($keyAlice, $keyBob); + + }//end testCacheKeyDiffersPerUser() + + /** + * A cache hit is returned with `_meta.cached: true` and skips recomputation + * (the mocked ObjectService is never invoked). + * + * @spec openspec/changes/gemma-faceted-search/tasks.md#task-6 + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-results-are-cached + * + * @return void + */ + public function testGetFacetsServesFromCacheOnHit(): void + { + $cachedPayload = [ + 'referentiecomponent' => [], + 'standaard' => [], + 'applicatieservice' => [], + 'domein' => [], + '_meta' => ['totalMatched' => 0, 'processingTimeMs' => 5.0, 'cached' => false], + ]; + + $cache = $this->createMock(ICache::class); + $cache->method('get')->willReturn($cachedPayload); + + $objectService = $this->createMock(ObjectService::class); + $objectService->expects($this->never())->method('searchObjectsPaginated'); + + $service = $this->makeService(objectService: $objectService, cache: $cache); + + $result = $service->getFacets(schema: 'module'); + + $this->assertTrue($result['_meta']['cached']); + + }//end testGetFacetsServesFromCacheOnHit() + + /** + * A dienst's facet values resolve transitively via its linked modules. + * + * @spec openspec/changes/gemma-faceted-search/tasks.md#task-1 + * + * @return void + */ + public function testGetFacetsResolvesDienstFacetsTransitivelyViaModules(): void + { + $objectService = $this->createMock(ObjectService::class); + + $capturedPaginated = []; + $objectService->method('searchObjectsPaginated')->willReturnCallback( + function (array $query) use (&$capturedPaginated): array { + $capturedPaginated[] = $query; + return [ + 'results' => [ + ['id' => 'd1', 'modules' => [['id' => 'm1']]], + ], + 'total' => 1, + 'page' => 1, + 'pages' => 1, + ]; + } + ); + $objectService->method('searchObjects')->willReturn( + [ + ['id' => 'm1', 'referentieComponenten' => [['identifier' => 'rc-1']], 'standaardVersies' => []], + ] + ); + + $archiMateService = $this->createMock(ArchiMateService::class); + $archiMateService->method('getElementObjects')->willReturn( + [['identifier' => 'rc-1', 'name' => 'Zaakregistratiecomponent']] + ); + $archiMateService->method('getRelationshipObjects')->willReturn([]); + + $service = $this->makeService(objectService: $objectService, archiMateService: $archiMateService); + + $result = $service->getFacets(schema: 'dienst'); + + $refCompByValue = array_column($result['referentiecomponent'], 'count', 'value'); + $this->assertSame(1, $refCompByValue['Zaakregistratiecomponent']); + + }//end testGetFacetsResolvesDienstFacetsTransitivelyViaModules() +}//end class diff --git a/tests/Unit/Service/QueryLimitBoundingTest.php b/tests/Unit/Service/QueryLimitBoundingTest.php index 8a79d13c..fd0fb5ef 100644 --- a/tests/Unit/Service/QueryLimitBoundingTest.php +++ b/tests/Unit/Service/QueryLimitBoundingTest.php @@ -27,8 +27,11 @@ namespace OCA\SoftwareCatalog\Tests\Unit\Service; use OCA\OpenRegister\Service\ObjectService; +use OCA\SoftwareCatalog\Service\ArchiMateService; +use OCA\SoftwareCatalog\Service\FacetService; use OCA\SoftwareCatalog\Service\OrganizationSyncService; use OCA\SoftwareCatalog\Service\SettingsService; +use OCA\SoftwareCatalog\Service\ViewQueryBuilder; use OCA\SoftwareCatalog\Service\ViewService; use OCP\App\IAppManager; use OCP\IAppConfig; @@ -138,4 +141,64 @@ function (array $query) use (&$capturedQuery): array { $this->assertArrayHasKey('_limit', $capturedQuery); $this->assertGreaterThan(0, $capturedQuery['_limit']); }//end testOrganizationSyncServiceTimeWindowQueryCarriesLimit() + + /** + * FacetService::getFacets() (gemma-faceted-search) pages the base + * module/dienst object set via `searchObjectsPaginated()` with an + * explicit `_limit` on every page request — never an unbounded + * `searchObjects()` scan. + * + * @spec openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-queries-must-be-bounded + * + * @return void + */ + public function testFacetServiceBaseObjectQueryCarriesLimit(): void + { + $capturedQuery = null; + + $objectService = $this->createMock(ObjectService::class); + $objectService->method('searchObjectsPaginated')->willReturnCallback( + function (array $query) use (&$capturedQuery): array { + $capturedQuery = $query; + return ['results' => [], 'total' => 0, 'page' => 1, 'pages' => 1]; + } + ); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback( + function (string $id) use ($objectService) { + return $id === ObjectService::class ? $objectService : null; + } + ); + + $settingsService = $this->createMock(SettingsService::class); + $settingsService->method('getVoorzieningenConfig')->willReturn( + ['register' => '1', 'module_schema' => '2', 'dienst_schema' => '3'] + ); + + $archiMateService = $this->createMock(ArchiMateService::class); + $archiMateService->method('getElementObjects')->willReturn([]); + $archiMateService->method('getRelationshipObjects')->willReturn([]); + + $cache = $this->createMock(ICache::class); + $cache->method('get')->willReturn(null); + $cacheFactory = $this->createMock(ICacheFactory::class); + $cacheFactory->method('createDistributed')->willReturn($cache); + + $service = new FacetService( + container: $container, + settingsService: $settingsService, + archiMateService: $archiMateService, + queryBuilder: new ViewQueryBuilder(), + userSession: $this->createMock(IUserSession::class), + logger: $this->createMock(LoggerInterface::class), + cacheFactory: $cacheFactory + ); + + $service->getFacets('module'); + + $this->assertIsArray($capturedQuery); + $this->assertArrayHasKey('_limit', $capturedQuery); + $this->assertGreaterThan(0, $capturedQuery['_limit']); + }//end testFacetServiceBaseObjectQueryCarriesLimit() }//end class diff --git a/tests/integration/softwarecatalog.postman_collection.json b/tests/integration/softwarecatalog.postman_collection.json index a156bc90..f08530d7 100644 --- a/tests/integration/softwarecatalog.postman_collection.json +++ b/tests/integration/softwarecatalog.postman_collection.json @@ -103,20 +103,47 @@ "auth": { "type": "basic", "basic": [ - { "key": "username", "value": "{{adminUser}}", "type": "string" }, - { "key": "password", "value": "{{adminPass}}", "type": "string" } + { + "key": "username", + "value": "{{adminUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPass}}", + "type": "string" + } ] }, "method": "GET", "header": [ - { "key": "OCS-APIRequest", "value": "true" }, - { "key": "Accept", "value": "application/json" } + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } ], "url": { "raw": "{{baseUrl}}/index.php/apps/openregister/api/registers?_limit=300", - "host": [ "{{baseUrl}}" ], - "path": [ "index.php", "apps", "openregister", "api", "registers" ], - "query": [ { "key": "_limit", "value": "300" } ] + "host": [ + "{{baseUrl}}" + ], + "path": [ + "index.php", + "apps", + "openregister", + "api", + "registers" + ], + "query": [ + { + "key": "_limit", + "value": "300" + } + ] } }, "event": [ @@ -2821,6 +2848,323 @@ } ] }, + { + "name": "10. Facets API (gemma-faceted-search)", + "item": [ + { + "name": "get facets for module (happy: 200, all 4 dimensions present)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPass}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/softwarecatalog/api/facets/module", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "index.php", + "apps", + "softwarecatalog", + "api", + "facets", + "module" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('200 OK', () => pm.response.to.have.status(200));", + "const b = pm.response.json();", + "['referentiecomponent', 'standaard', 'applicatieservice', 'domein'].forEach((dim) => {", + " pm.test(`has ${dim} facet array (present even when empty)`, () => pm.expect(b[dim]).to.be.an('array'));", + "});", + "pm.test('has _meta.totalMatched', () => pm.expect(b._meta).to.have.property('totalMatched'));", + "pm.test('has _meta.cached', () => pm.expect(b._meta).to.have.property('cached'));" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "get facets for dienst (happy: 200, all 4 dimensions present)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPass}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/softwarecatalog/api/facets/dienst", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "index.php", + "apps", + "softwarecatalog", + "api", + "facets", + "dienst" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('200 OK', () => pm.response.to.have.status(200));", + "const b = pm.response.json();", + "['referentiecomponent', 'standaard', 'applicatieservice', 'domein'].forEach((dim) => {", + " pm.test(`has ${dim} facet array (present even when empty)`, () => pm.expect(b[dim]).to.be.an('array'));", + "});" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "get facets for unsupported schema (400, names supported schemas)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPass}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/softwarecatalog/api/facets/contract", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "index.php", + "apps", + "softwarecatalog", + "api", + "facets", + "contract" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('400 Bad Request', () => pm.response.to.have.status(400));", + "const b = pm.response.json();", + "pm.test('names module in supportedSchemas', () => pm.expect(b.supportedSchemas).to.include('module'));", + "pm.test('names dienst in supportedSchemas', () => pm.expect(b.supportedSchemas).to.include('dienst'));" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "get facets for module with referentiecomponent filter (narrowed set)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPass}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/softwarecatalog/api/facets/module?referentiecomponent[]=Zaakregistratiecomponent", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "index.php", + "apps", + "softwarecatalog", + "api", + "facets", + "module" + ], + "query": [ + { + "key": "referentiecomponent[]", + "value": "Zaakregistratiecomponent" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Narrowing is functional even against an unseeded/empty register: a", + "// filtered request must still succeed and return the standard shape,", + "// with totalMatched never exceeding the unfiltered baseline.", + "pm.test('200 OK', () => pm.response.to.have.status(200));", + "const b = pm.response.json();", + "pm.test('has _meta.totalMatched as a number', () => pm.expect(b._meta.totalMatched).to.be.a('number'));" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "get facets for module with free-text search (combines with facets)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPass}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/softwarecatalog/api/facets/module?search=zaak", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "index.php", + "apps", + "softwarecatalog", + "api", + "facets", + "module" + ], + "query": [ + { + "key": "search", + "value": "zaak" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('200 OK', () => pm.response.to.have.status(200));", + "const b = pm.response.json();", + "pm.test('has referentiecomponent facet array', () => pm.expect(b.referentiecomponent).to.be.an('array'));" + ], + "type": "text/javascript" + } + } + ] + } + ] + }, { "name": "99. Teardown", "item": [