From 0c404d73401fdf5fa66e7aea5677ef1efa284c19 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 29 Sep 2026 22:17:52 +0200 Subject: [PATCH 1/3] test(maintenance): the window schema, rules, owner resolution, pages and roadmap, red before the change --- tests/Stubs/Event/ObjectCreatedEvent.php | 57 ++++ .../MaintenanceRecipientsListenerTest.php | 282 ++++++++++++++++++ .../MaintenanceRoadmapFragmentTest.php | 199 ++++++++++++ tests/bootstrap-unit.php | 2 + tests/vitest/maintenance.spec.js | 195 ++++++++++++ 5 files changed, 735 insertions(+) create mode 100644 tests/Stubs/Event/ObjectCreatedEvent.php create mode 100644 tests/Unit/EventListener/MaintenanceRecipientsListenerTest.php create mode 100644 tests/Unit/Settings/MaintenanceRoadmapFragmentTest.php create mode 100644 tests/vitest/maintenance.spec.js diff --git a/tests/Stubs/Event/ObjectCreatedEvent.php b/tests/Stubs/Event/ObjectCreatedEvent.php new file mode 100644 index 000000000..dc2310c51 --- /dev/null +++ b/tests/Stubs/Event/ObjectCreatedEvent.php @@ -0,0 +1,57 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT: + * @link https://github.com/ConductionNL/stackiq + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Event; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCP\EventDispatcher\Event; + +/** + * Dispatched after an object is created. + */ +class ObjectCreatedEvent extends Event { + + /** + * The created object. + * + * @var ObjectEntity + */ + private ObjectEntity $object; + + /** + * Constructor. + * + * @param ObjectEntity $object The created object. + */ + public function __construct(ObjectEntity $object) { + parent::__construct(); + $this->object = $object; + }//end __construct() + + /** + * The created object. + * + * @return ObjectEntity The object. + */ + public function getObject(): ObjectEntity { + return $this->object; + }//end getObject() +}//end class diff --git a/tests/Unit/EventListener/MaintenanceRecipientsListenerTest.php b/tests/Unit/EventListener/MaintenanceRecipientsListenerTest.php new file mode 100644 index 000000000..601495bdc --- /dev/null +++ b/tests/Unit/EventListener/MaintenanceRecipientsListenerTest.php @@ -0,0 +1,282 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT: + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/lifecycle-maintenance-and-supplier-roadmap/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\EventListener; + +use DateTimeImmutable; +use OCA\OpenRegister\Contract\ObjectServiceInterface; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Event\ObjectCreatedEvent; +use OCA\Stackiq\BackgroundJob\MaintenanceRecipientsJob; +use OCA\Stackiq\EventListener\MaintenanceRecipientsListener; +use OCA\Stackiq\Service\MaintenanceRecipientService; +use OCA\Stackiq\Service\SettingsService; +use OCA\Stackiq\Service\StackiqContactSyncService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\EventDispatcher\Event; +use OCP\IUser; +use OCP\IUserManager; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Two municipalities use product X; both usages name owners. + */ +class MaintenanceRecipientsListenerTest extends TestCase { + + private const REGISTER = 7; + + private const SCHEMAS = ['maintenanceWindow' => 40, 'usage' => 41, 'contactPerson' => 42]; + + /** + * The object service double, with every save it received. + * + * @var ObjectServiceInterface&\PHPUnit\Framework\MockObject\MockObject + */ + private $objectService; + + /** + * The saves the object service received. + * + * @var array> + */ + private array $saved = []; + + /** + * The jobs the listener queued. + * + * @var array + */ + private array $queued = []; + + /** + * The windows the object service can find, by id. + * + * @var array + */ + private array $windows = []; + + /** + * The job list double. + * + * @var IJobList&\PHPUnit\Framework\MockObject\MockObject + */ + private $jobList; + + /** + * The real owner resolution. + * + * @var MaintenanceRecipientService + */ + private MaintenanceRecipientService $service; + + /** + * An object entity double. + * + * @param string $uuid The id. + * @param int $schema The schema id. + * @param array $data The object data. + * + * @return ObjectEntity The double. + */ + private function entity(string $uuid, int $schema, array $data): ObjectEntity { + $entity = $this->createMock(ObjectEntity::class); + $entity->method('getUuid')->willReturn($uuid); + $entity->method('getSchema')->willReturn((string) $schema); + $entity->method('getRegister')->willReturn((string) self::REGISTER); + $entity->method('getObject')->willReturn($data); + return $entity; + }//end entity() + + /** + * The listener with its real service and doubles at the edges. + * + * @return MaintenanceRecipientsListener The listener. + */ + private function listener(): MaintenanceRecipientsListener { + $settings = $this->createMock(SettingsService::class); + $settings->method('getSchemaIdForObjectType')->willReturnCallback(fn (string $t): ?int => self::SCHEMAS[$t] ?? null); + $settings->method('getRegisterIdForObjectType')->willReturn(self::REGISTER); + + $usages = [ + $this->entity('u1', self::SCHEMAS['usage'], ['module' => 'x', 'businessOwner' => 'anna', 'technicalOwner' => ['id' => 'bram']]), + $this->entity('u2', self::SCHEMAS['usage'], ['module' => 'x', 'businessOwner' => 'carla']), + $this->entity('u3', self::SCHEMAS['usage'], ['module' => 'x']), + ]; + $people = [ + 'anna' => $this->entity('anna', self::SCHEMAS['contactPerson'], ['contactsUid' => 'c-anna']), + 'bram' => $this->entity('bram', self::SCHEMAS['contactPerson'], ['contactsUid' => 'c-bram']), + 'carla' => $this->entity('carla', self::SCHEMAS['contactPerson'], ['contactsUid' => 'c-carla']), + ]; + + $this->objectService = $this->createMock(ObjectServiceInterface::class); + $this->objectService->method('searchObjects')->willReturnCallback( + function (array $query=[], bool $_rbac=true, bool $_multitenancy=true, ?array $ids=null) use ($usages, $people): array { + if ($query['schema'] === self::SCHEMAS['usage']) { + $this->assertSame('x', $query['module']); + return $usages; + } + + return array_values(array_intersect_key($people, array_flip($ids ?? []))); + } + ); + $this->objectService->method('saveObject')->willReturnCallback( + function (array $object) { + $this->saved[] = $object; + return $this->createMock(ObjectEntity::class); + } + ); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturn($this->objectService); + + $contacts = $this->createMock(StackiqContactSyncService::class); + $contacts->method('findContactByUid')->willReturnCallback( + fn (string $uid): ?array => [ + 'c-anna' => ['UID' => 'anna.nc', 'isLocalSystemBook' => true], + 'c-bram' => ['UID' => 'c-bram', 'EMAIL' => ['bram@leiden.nl']], + 'c-carla' => ['UID' => 'c-carla', 'EMAIL' => [['value' => 'carla@delft.nl']]], + ][$uid] ?? null + ); + + $users = $this->createMock(IUserManager::class); + $users->method('userExists')->willReturnCallback(fn (string $uid): bool => $uid === 'anna.nc'); + $users->method('getByEmail')->willReturnCallback( + function (string $email): array { + $uid = ['bram@leiden.nl' => 'bram.nc', 'carla@delft.nl' => 'carla.nc'][$email] ?? null; + if ($uid === null) { + return []; + } + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + return [$user]; + } + ); + + $this->objectService->method('find')->willReturnCallback(fn (int|string $id): ?ObjectEntity => $this->windows[$id] ?? null); + + $this->jobList = $this->createMock(IJobList::class); + $this->jobList->method('add')->willReturnCallback( + function (string $job, mixed $argument): void { + $this->queued[] = [$job, $argument]; + } + ); + + $logger = $this->createMock(LoggerInterface::class); + $this->service = new MaintenanceRecipientService($settings, $contacts, $users, $container, $logger); + return new MaintenanceRecipientsListener($this->service, $this->jobList, $logger); + }//end listener() + + /** + * Run the jobs the listener queued, the way cron runs them. + * + * @return void + */ + private function runQueuedJobs(): void { + foreach ($this->queued as [$class, $argument]) { + $this->assertSame(MaintenanceRecipientsJob::class, $class); + $job = new MaintenanceRecipientsJob($this->createMock(ITimeFactory::class), $this->service); + $run = new \ReflectionMethod($job, 'run'); + $run->invoke($job, $argument); + } + }//end runQueuedJobs() + + /** + * Announcing a window on X records the owners of both usages as users. + * + * @return void + */ + public function testTheOwnersOfEveryUsageAreRecorded(): void { + $listener = $this->listener(); + $window = $this->entity('w1', self::SCHEMAS['maintenanceWindow'], ['module' => ['id' => 'x'], 'title' => 'Database upgrade', 'status' => 'planned']); + + $this->windows['w1'] = $window; + + $listener->handle(new ObjectCreatedEvent($window)); + + $this->assertSame([], $this->saved, 'the listener only queues; the write runs in the job'); + $this->assertSame([[MaintenanceRecipientsJob::class, ['uuid' => 'w1', 'register' => '7', 'schema' => '40']]], $this->queued); + + $this->runQueuedJobs(); + + $this->assertCount(1, $this->saved); + $this->assertSame(['anna.nc', 'bram.nc', 'carla.nc'], $this->saved[0]['notifyUserIds']); + $this->assertNotFalse(DateTimeImmutable::createFromFormat(DATE_ATOM, $this->saved[0]['recipientsResolvedAt'])); + $this->assertSame('Database upgrade', $this->saved[0]['title']); + }//end testTheOwnersOfEveryUsageAreRecorded() + + /** + * An object of another schema is left alone. + * + * @return void + */ + public function testAnotherSchemaIsIgnored(): void { + $listener = $this->listener(); + $listener->handle(new ObjectCreatedEvent($this->entity('m1', 99, ['module' => 'x']))); + + $this->assertSame([], $this->queued); + }//end testAnotherSchemaIsIgnored() + + /** + * A window whose owners were already resolved is not written again. + * + * @return void + */ + public function testAResolvedWindowIsNotWrittenAgain(): void { + $listener = $this->listener(); + $window = $this->entity('w2', self::SCHEMAS['maintenanceWindow'], ['module' => 'x', 'recipientsResolvedAt' => '2026-09-29T10:00:00+00:00']); + + $this->windows['w2'] = $window; + + $listener->handle(new ObjectCreatedEvent($window)); + $this->runQueuedJobs(); + + $this->assertSame([], $this->saved); + }//end testAResolvedWindowIsNotWrittenAgain() + + /** + * Another event type is ignored. + * + * @return void + */ + public function testAnotherEventIsIgnored(): void { + $listener = $this->listener(); + $listener->handle(new Event()); + + $this->assertSame([], $this->queued); + }//end testAnotherEventIsIgnored() + + /** + * The listener is registered for the created event in the app. + * + * @return void + */ + public function testTheListenerIsRegistered(): void { + $source = (string) file_get_contents(__DIR__ . '/../../../lib/AppInfo/Application.php'); + + $this->assertMatchesRegularExpression( + '/registerEventListener\(\s*ObjectCreatedEvent::class,\s*MaintenanceRecipientsListener::class\s*\)/', + $source + ); + }//end testTheListenerIsRegistered() +}//end class diff --git a/tests/Unit/Settings/MaintenanceRoadmapFragmentTest.php b/tests/Unit/Settings/MaintenanceRoadmapFragmentTest.php new file mode 100644 index 000000000..70703f59e --- /dev/null +++ b/tests/Unit/Settings/MaintenanceRoadmapFragmentTest.php @@ -0,0 +1,199 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT: + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/lifecycle-maintenance-and-supplier-roadmap/specs/maintenance-and-supplier-roadmap/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Settings; + +use OCA\Stackiq\Service\SettingsService; +use OCP\App\IAppManager; +use OCP\IAppConfig; +use OCP\IGroupManager; +use OCP\IL10N; +use OCP\IRequest; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use ReflectionMethod; + +/** + * Asserts the schema, its lifecycle and notification rules, the roadmap field + * and the schema id lookup. + */ +class MaintenanceRoadmapFragmentTest extends TestCase { + + /** + * The merged register. + * + * @return array + */ + private function register(): array { + $dir = __DIR__ . '/../../../lib/Settings'; + $base = json_decode((string) file_get_contents($dir . '/softwarecatalogus_register.json'), true); + $fragment = json_decode((string) file_get_contents($dir . '/register.d/maintenance-and-roadmap.json'), true); + + $merge = new ReflectionMethod(SettingsService::class, 'deepMergeConfig'); + return $merge->invoke(null, $base, $fragment); + }//end register() + + /** + * The maintenanceWindow schema. + * + * @return array + */ + private function window(): array { + return $this->register()['components']['schemas']['maintenanceWindow']; + }//end window() + + /** + * The window names its product, a version of that product, a time window, an impact and a status. + * + * @return void + */ + public function testTheWindowCarriesProductTimeImpactAndStatus(): void { + $window = $this->window(); + $props = $window['properties']; + + $this->assertSame(['module', 'title', 'startsAt', 'endsAt'], $window['required']); + $this->assertSame('#/components/schemas/module', $props['module']['$ref']); + $this->assertSame('#/components/schemas/moduleVersion', $props['moduleVersion']['$ref']); + $this->assertSame(['module' => '@object.module'], $props['moduleVersion']['x-relation-filter']); + $this->assertSame('date-time', $props['startsAt']['format']); + $this->assertSame('date-time', $props['endsAt']['format']); + $this->assertSame(['no impact', 'degraded', 'unavailable'], $props['impact']['enum']); + $this->assertSame(['planned', 'in progress', 'completed', 'cancelled'], $props['status']['enum']); + $this->assertSame($props['status']['enum'], array_keys($props['status']['x-enum-labels'])); + foreach ($props as $key => $prop) { + $this->assertNotEmpty($prop['title'] ?? '', $key); + $this->assertNotEmpty($prop['description'] ?? '', $key); + } + + $this->assertContains('maintenanceWindow', $this->register()['components']['registers']['stackiq']['schemas']); + }//end testTheWindowCarriesProductTimeImpactAndStatus() + + /** + * Start, complete and cancel move between the status values the enum holds. + * + * @return void + */ + public function testTheLifecycleNamesTheEnumValues(): void { + $window = $this->window(); + $lifecycle = $window['configuration']['x-openregister-lifecycle']; + $enum = $window['properties']['status']['enum']; + + $this->assertSame('planned', $lifecycle['initial']); + $this->assertSame(['start', 'complete', 'cancel'], array_keys($lifecycle['transitions'])); + foreach ($lifecycle['transitions'] as $name => $transition) { + $this->assertContains($transition['to'], $enum, $name); + foreach ($transition['from'] as $from) { + $this->assertContains($from, $enum, $name); + } + } + }//end testTheLifecycleNamesTheEnumValues() + + /** + * The announcement fires when the owners are recorded, the reminder the day before a planned start, both to the recorded owners. + * + * @return void + */ + public function testTheRulesReachTheRecordedOwners(): void { + $window = $this->window(); + $rules = $window['x-openregister-notifications']; + + $this->assertSame(['maintenance-announced', 'maintenance-starts-tomorrow'], array_keys($rules)); + foreach ($rules as $name => $rule) { + $this->assertSame([['kind' => 'relation', 'relation' => 'notifyUserIds']], $rule['recipients'], $name); + $this->assertSame(['nc-notification'], $rule['channels'], $name); + $this->assertStringContainsString('{{title}}', $rule['subject']['en'], $name); + $this->assertStringContainsString('{{title}}', $rule['subject']['nl'], $name); + } + + $this->assertSame( + ['type' => 'updated', 'condition' => ['field' => 'recipientsResolvedAt', 'operator' => 'changed']], + $rules['maintenance-announced']['trigger'] + ); + $this->assertSame('string', $window['properties']['recipientsResolvedAt']['type'], 'a changed condition compares scalars'); + $this->assertSame('array', $window['properties']['notifyUserIds']['type']); + $this->assertTrue($window['properties']['notifyUserIds']['hideOnForm']); + + $reminder = $rules['maintenance-starts-tomorrow']['trigger']; + $this->assertSame('scheduled', $reminder['type']); + $this->assertSame(['operator' => 'withinNext', 'value' => 'P1D'], $reminder['filter']['startsAt']); + $this->assertSame(['operator' => 'equals', 'value' => 'planned'], $reminder['filter']['status']); + }//end testTheRulesReachTheRecordedOwners() + + /** + * Only the supplier and the catalogue admins announce and change maintenance. + * + * @return void + */ + public function testOnlyTheSupplierAnnouncesMaintenance(): void { + $auth = $this->window()['authorization']; + + $this->assertSame(['software-catalog-admins', 'aanbod-beheerder'], $auth['create']); + $this->assertSame(['public'], $auth['read']); + foreach (['update', 'delete'] as $action) { + $this->assertSame( + ['software-catalog-admins', ['group' => 'aanbod-beheerder', 'match' => ['_organisation' => '$organisation']]], + $auth[$action], + $action + ); + } + }//end testOnlyTheSupplierAnnouncesMaintenance() + + /** + * A product carries its supplier's roadmap statement, and the module version moves up so the import takes it. + * + * @return void + */ + public function testAProductCarriesARoadmapStatement(): void { + $module = $this->register()['components']['schemas']['module']; + + $this->assertSame('markdown', $module['properties']['roadmapStatement']['format']); + $this->assertSame('Roadmap', $module['properties']['roadmapStatement']['title']); + $this->assertTrue(version_compare($module['version'], '0.3.3', '>')); + $this->assertArrayHasKey('provider', $module['properties']); + }//end testAProductCarriesARoadmapStatement() + + /** + * The app resolves the maintenanceWindow schema id from the stored config, which the owner resolution relies on. + * + * @return void + */ + public function testTheSchemaIdResolves(): void { + $store = ['voorzieningen_config' => json_encode(['register' => '7', 'maintenanceWindow_schema' => '42'])]; + $config = $this->createMock(IAppConfig::class); + $config->method('getValueString')->willReturnCallback( + fn (string $app, string $key, string $default=''): string => ($store[$key] ?? $default) + ); + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willThrowException(new \Exception('not resolvable in a unit context')); + + $settings = new SettingsService( + config: $config, + request: $this->createMock(IRequest::class), + container: $container, + appManager: $this->createMock(IAppManager::class), + logger: $this->createMock(LoggerInterface::class), + groupManager: $this->createMock(IGroupManager::class), + l10n: $this->createMock(IL10N::class) + ); + + $this->assertSame(42, $settings->getSchemaIdForObjectType('maintenanceWindow')); + $this->assertSame(7, $settings->getRegisterIdForObjectType('maintenanceWindow')); + }//end testTheSchemaIdResolves() +}//end class diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index 97084ee81..3ba8c6368 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -57,6 +57,8 @@ // OpenRegister stubs — Db entities and Services used by tests. 'OCA\\OpenRegister\\Db\\' => __DIR__ . '/Stubs/Db/', 'OCA\\OpenRegister\\Service\\' => __DIR__ . '/Stubs/Service/', + // A copy of OpenRegister's ObjectCreatedEvent, so listener tests construct the real shape. + 'OCA\\OpenRegister\\Event\\' => __DIR__ . '/Stubs/Event/', ]; foreach ($prefixMap as $prefix => $dir) { diff --git a/tests/vitest/maintenance.spec.js b/tests/vitest/maintenance.spec.js new file mode 100644 index 000000000..5854189ec --- /dev/null +++ b/tests/vitest/maintenance.spec.js @@ -0,0 +1,195 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * Planned maintenance and the supplier roadmap: which windows an organisation + * sees, the roadmap events, the pages as the app builds them (manifest.d + * merged the way src/main.js merges it), and the seeded maintenance windows + * validated against the real maintenanceWindow schema in the register. + * + * @spec openspec/changes/lifecycle-maintenance-and-supplier-roadmap/specs/maintenance-and-supplier-roadmap/spec.md + */ + +import addFormats from 'ajv-formats' +import Ajv2020 from 'ajv/dist/2020.js' +import * as fs from 'fs' +import * as path from 'path' +import { describe, expect, it, vi } from 'vitest' +import register from '../../lib/Settings/softwarecatalogus_register.json' +import mock from '../../lib/Settings/stackiq_mock_register.json' +import manifestSchema from '../../node_modules/@conduction/nextcloud-vue/src/schemas/app-manifest-v2.schema.json' +import { buildManifest } from '../../node_modules/@conduction/nextcloud-vue/src/utils/buildManifest.js' +import base from '../../src/manifest.json' +import menuLayout from '../../src/menu-layout.json' +import { + loadUpcomingMaintenance, + roadmapEvents, + upcomingMaintenance, +} from '../../src/utils/maintenance.js' + +const dir = path.resolve(__dirname, '../../src/manifest.d') +const merged = buildManifest( + base, + fs + .readdirSync(dir) + .filter((f) => f.endsWith('.json')) + .sort() + .map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))), + menuLayout, +) +const page = (id) => merged.pages.find((p) => p.id === id) +const widget = (pageId, widgetId) => + page(pageId).config.widgets.find((w) => w.id === widgetId) +const schema = register.components.schemas.maintenanceWindow +const now = new Date('2026-10-01T09:00:00Z') + +const windows = [ + { id: 'w1', module: 'x', status: 'planned', startsAt: '2026-10-03T06:00:00Z', endsAt: '2026-10-03T10:00:00Z', impact: 'unavailable' }, + { id: 'w2', module: { id: 'x' }, status: 'planned', startsAt: '2026-10-02T06:00:00Z', endsAt: '2026-10-02T07:00:00Z' }, + { id: 'w3', module: 'y', status: 'planned', startsAt: '2026-10-03T06:00:00Z', endsAt: '2026-10-03T07:00:00Z' }, + { id: 'w4', module: 'x', status: 'cancelled', startsAt: '2026-10-03T06:00:00Z', endsAt: '2026-10-03T07:00:00Z' }, + { id: 'w5', module: 'x', status: 'planned', startsAt: '2026-12-01T06:00:00Z', endsAt: '2026-12-01T07:00:00Z' }, + { id: 'w6', module: 'x', status: 'planned', startsAt: '2026-09-01T06:00:00Z', endsAt: '2026-09-01T07:00:00Z' }, +] + +/** + * A validator built from the real maintenanceWindow properties. A relation is + * checked as an object or an id string, the way OpenRegister accepts it. + * + * @return {Function} The compiled Ajv validator. + */ +function compileWindow() { + const ajv = new Ajv2020({ allErrors: true, strict: false }) + addFormats(ajv) + const properties = Object.fromEntries( + Object.entries(schema.properties).map(([key, prop]) => [ + key, + prop.$ref + ? { type: ['object', 'string'] } + : { type: prop.type, enum: prop.enum, format: prop.format }, + ]), + ) + return ajv.compile({ type: 'object', required: schema.required, properties }) +} + +describe('upcoming maintenance', () => { + it('lists the planned windows of the next 30 days on the products in use, earliest first', () => { + expect(upcomingMaintenance(windows, ['x'], now).map((w) => w.id)).toEqual(['w2', 'w1']) + }) + + it('reads the usages of the organisation and then the windows on their products', async () => { + const fetchList = vi.fn(async (type) => + type === 'usage' ? [{ module: 'x' }, { module: { id: 'x' } }, { module: null }] : windows, + ) + const result = await loadUpcomingMaintenance('org-1', fetchList, now) + expect(fetchList).toHaveBeenNthCalledWith(1, 'usage', expect.objectContaining({ consumer: 'org-1' })) + expect(fetchList).toHaveBeenNthCalledWith( + 2, + 'maintenanceWindow', + expect.objectContaining({ module: ['x'], status: 'planned' }), + ) + expect(result.map((w) => w.id)).toEqual(['w2', 'w1']) + }) + + it('reads nothing without an organisation or usages', async () => { + const fetchList = vi.fn(async () => []) + expect(await loadUpcomingMaintenance(null, fetchList, now)).toEqual([]) + expect(await loadUpcomingMaintenance('org-1', fetchList, now)).toEqual([]) + expect(fetchList).toHaveBeenCalledTimes(1) + }) +}) + +describe('the roadmap', () => { + it('places each version on its go-live date, planned ones marked planned, undated ones left out', () => { + const events = roadmapEvents([ + { id: 'v3', version: '3.0', status: 'in development', dateInUse: '2027-03-01' }, + { id: 'v2', version: '2.1', status: 'in use', dateInUse: '2026-05-01' }, + { id: 'v4', version: '4.0', status: 'in development', dateInDevelopment: '2026-09-01' }, + { id: 'v1', version: '1.0', status: 'withdrawn' }, + ]) + expect(events.map((e) => [e.title, e.start, e.kind])).toEqual([ + ['3.0', '2027-03-01', 'planned'], + ['2.1', '2026-05-01', 'released'], + ['4.0', '2026-09-01', 'planned'], + ]) + }) +}) + +describe('the pages', () => { + it('builds a manifest the v2 schema accepts', () => { + const ajv = new Ajv2020({ allErrors: true, strict: false }) + addFormats(ajv) + const validate = ajv.compile(manifestSchema) + expect(validate(merged), JSON.stringify(validate.errors)).toBe(true) + }) + + it('lists the planned maintenance on the application page, with Announce maintenance', () => { + const list = widget('ModuleDetail', 'md-maintenance') + expect(list.content).toMatchObject({ + schema: 'maintenanceWindow', + filter: { module: '@objectId' }, + sort: { field: 'startsAt', dir: 'asc' }, + addLabel: 'Announce maintenance', + }) + for (const column of list.content.columns) { + expect(schema.properties, column.key).toHaveProperty(column.key) + } + for (const field of list.content.formIncludeFields) { + expect(schema.properties, field).toHaveProperty(field) + } + expect(page('ModuleDetail').config.layout.map((l) => l.widgetId)).toContain('md-maintenance') + }) + + it('shows the roadmap on the application page', () => { + const body = page('ModuleDetail').config.bodyWidgets.find((w) => w.id === 'md-roadmap') + expect(body).toMatchObject({ component: 'ProductRoadmap', props: { objectId: '@objectId' } }) + const registry = fs.readFileSync(path.resolve(__dirname, '../../src/customComponents.js'), 'utf8') + expect(registry).toMatch(/\bProductRoadmap,/) + }) + + it('shows planned maintenance on the dashboard', () => { + const dashboard = page('Dashboard').config + const w = dashboard.widgets.find((x) => x.id === 'upcoming-maintenance') + expect(w.type).toBe('upcoming-maintenance') + expect(dashboard.layout.map((l) => l.widgetId)).toContain('upcoming-maintenance') + const main = fs.readFileSync(path.resolve(__dirname, '../../src/main.js'), 'utf8') + expect(main).toMatch(/registerDashboardWidget\('upcoming-maintenance',\s*\{\s*renderer: UpcomingMaintenanceWidget/) + }) + + it('filters the module versions on planned releases, a real status value', () => { + const index = page('Moduleversies').config + const planned = index.quickFilters.find((q) => q.label === 'Planned releases') + expect(register.components.schemas.moduleVersion.properties.status.enum).toContain(planned.filter.status) + expect(index.columns).toContain('dateInDevelopment') + }) +}) + +describe('the maintenanceWindow schema', () => { + it('offers start, complete and cancel on its own status values', () => { + const lifecycle = schema.configuration['x-openregister-lifecycle'] + const states = schema.properties.status.enum + for (const transition of Object.values(lifecycle.transitions)) { + expect(states).toContain(transition.to) + transition.from.forEach((from) => expect(states).toContain(from)) + } + expect(states).toContain(lifecycle.initial) + }) + + it('notifies the owners it resolved, on a field the schema declares', () => { + for (const rule of Object.values(schema['x-openregister-notifications'])) { + expect(rule.recipients).toEqual([{ kind: 'relation', relation: 'notifyUserIds' }]) + } + expect(schema.properties.notifyUserIds.type).toBe('array') + const announced = schema['x-openregister-notifications']['maintenance-announced'].trigger + expect(schema.properties).toHaveProperty(announced.condition.field) + }) + + it('has seeded windows in both registers that the schema accepts', () => { + const validate = compileWindow() + const seeds = mock.components.objects.filter((o) => o['@self']?.schema === 'maintenanceWindow') + expect(new Set(seeds.map((s) => s['@self'].register))).toEqual(new Set(['stackiq', 'vng-gemma'])) + for (const seed of seeds) { + expect(validate(seed), `${seed['@self'].slug}: ${JSON.stringify(validate.errors)}`).toBe(true) + } + }) +}) From 5e9c6a9bc98246d66c44a2a68741bb7d52ae0c8c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 29 Sep 2026 22:23:12 +0200 Subject: [PATCH 2/3] feat(maintenance): follow planned maintenance on the applications you use, and read a supplier's roadmap --- docs/features/maintenance-and-roadmap.md | 35 ++ l10n/en.js | 42 ++- l10n/en.json | 42 ++- l10n/nl.js | 42 ++- l10n/nl.json | 42 ++- lib/AppInfo/Application.php | 4 + .../MaintenanceRecipientsJob.php | 69 ++++ .../MaintenanceRecipientsListener.php | 93 +++++ lib/Service/MaintenanceRecipientService.php | 338 ++++++++++++++++++ lib/Service/SettingsService.php | 4 + .../register.d/maintenance-and-roadmap.json | 20 ++ lib/Settings/softwarecatalogus_register.json | 284 ++++++++++++++- lib/Settings/stackiq_mock_register.json | 56 +++ .../.openspec.yaml | 0 .../design.md | 8 + .../proposal.md | 0 .../maintenance-and-supplier-roadmap/spec.md | 2 +- .../tasks.md | 20 +- openspec/parity/capabilities.json | 30 +- .../maintenance-and-supplier-roadmap/spec.md | 57 +++ .../maintenance/UpcomingMaintenanceWidget.vue | 234 ++++++++++++ src/components/roadmap/ProductRoadmap.vue | 185 ++++++++++ src/customComponents.js | 6 + src/icons.js | 2 + src/main.js | 11 + src/manifest.json | 28 +- src/utils/maintenance.js | 110 ++++++ .../MaintenanceRecipientsListenerTest.php | 2 +- .../MaintenanceRoadmapFragmentTest.php | 2 +- tests/e2e/workflows/maintenance.spec.ts | 161 +++++++++ tests/vitest/maintenance.spec.js | 144 ++++++-- 31 files changed, 2008 insertions(+), 65 deletions(-) create mode 100644 docs/features/maintenance-and-roadmap.md create mode 100644 lib/BackgroundJob/MaintenanceRecipientsJob.php create mode 100644 lib/EventListener/MaintenanceRecipientsListener.php create mode 100644 lib/Service/MaintenanceRecipientService.php create mode 100644 lib/Settings/register.d/maintenance-and-roadmap.json rename openspec/changes/{lifecycle-maintenance-and-supplier-roadmap => archive/2026-09-29-lifecycle-maintenance-and-supplier-roadmap}/.openspec.yaml (100%) rename openspec/changes/{lifecycle-maintenance-and-supplier-roadmap => archive/2026-09-29-lifecycle-maintenance-and-supplier-roadmap}/design.md (69%) rename openspec/changes/{lifecycle-maintenance-and-supplier-roadmap => archive/2026-09-29-lifecycle-maintenance-and-supplier-roadmap}/proposal.md (100%) rename openspec/changes/{lifecycle-maintenance-and-supplier-roadmap => archive/2026-09-29-lifecycle-maintenance-and-supplier-roadmap}/specs/maintenance-and-supplier-roadmap/spec.md (95%) rename openspec/changes/{lifecycle-maintenance-and-supplier-roadmap => archive/2026-09-29-lifecycle-maintenance-and-supplier-roadmap}/tasks.md (83%) create mode 100644 openspec/specs/maintenance-and-supplier-roadmap/spec.md create mode 100644 src/components/maintenance/UpcomingMaintenanceWidget.vue create mode 100644 src/components/roadmap/ProductRoadmap.vue create mode 100644 src/utils/maintenance.js create mode 100644 tests/e2e/workflows/maintenance.spec.ts diff --git a/docs/features/maintenance-and-roadmap.md b/docs/features/maintenance-and-roadmap.md new file mode 100644 index 000000000..880f87e94 --- /dev/null +++ b/docs/features/maintenance-and-roadmap.md @@ -0,0 +1,35 @@ + + +# Maintenance and roadmap + +Suppliers announce planned maintenance on their applications and publish where each application is heading. Organisations that use an application see the maintenance on their dashboard and on the application's page, and read the roadmap before they plan an upgrade. + +Specification: [`openspec/specs/maintenance-and-supplier-roadmap/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/maintenance-and-supplier-roadmap/spec.md). + +## Announcing maintenance + +Open your application's page under **Applications** and click **Announce maintenance** in the **Planned maintenance** section. Fill in: + +- **Title** and **Description**: what happens and what users should do. +- **Version**: only when the maintenance concerns one version. +- **Starts at** and **Ends at**. +- **Impact**: No impact, Degraded or Unavailable. + +A new window starts as Planned. From its page you move it to In progress, Completed or Cancelled. + +## Who is told + +When you announce a window, stackiq looks up every organisation that uses the application and the business owner and technical owner of each usage. Those owners get a Nextcloud notification, and a reminder the day before the window starts while it is still planned. Owners are set on the usage, under **Applications in use**; a usage without owners still shows the window on its organisation's dashboard. + +## Following maintenance + +The dashboard lists **Planned maintenance** on the applications your organisation uses in the next 30 days, with the time window and the impact. Each application's page lists all its planned maintenance. + +## The roadmap + +A supplier writes the direction of an application in the **Roadmap** field of the application. The application's page shows it with the application's versions on a timeline, the planned versions first. A version is placed on its go-live date, or on the date development started when it has no go-live date yet. + +Under **Module versions**, the **Planned releases** tab lists the versions still in development, with the date development started. A supplier releases a planned version from its page with **Release**. diff --git a/l10n/en.js b/l10n/en.js index f6352539d..78ad2bf27 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -828,7 +828,47 @@ OC.L10N.register( "Add to our landscape": "Add to our landscape", "Connections and services": "Connections and services", "The applications your organisation uses, with the version it runs, where it stands and who owns it.": "The applications your organisation uses, with the version it runs, where it stands and who owns it.", - "No applications in use recorded for this organisation yet": "No applications in use recorded for this organisation yet" + "No applications in use recorded for this organisation yet": "No applications in use recorded for this organisation yet", + "Maintenance window": "Maintenance window", + "Maintenance a supplier plans on one of its products, with the time window and the expected impact.": "Maintenance a supplier plans on one of its products, with the time window and the expected impact.", + "The product the maintenance is on.": "The product the maintenance is on.", + "The version the maintenance is on, when it concerns one version.": "The version the maintenance is on, when it concerns one version.", + "What the maintenance is, in a few words.": "What the maintenance is, in a few words.", + "What changes and what users should do.": "What changes and what users should do.", + "Starts at": "Starts at", + "When the maintenance starts.": "When the maintenance starts.", + "Ends at": "Ends at", + "When the maintenance ends.": "When the maintenance ends.", + "Impact": "Impact", + "What users notice while the maintenance runs.": "What users notice while the maintenance runs.", + "Where the maintenance stands.": "Where the maintenance stands.", + "Owners to notify": "Owners to notify", + "The Nextcloud users who own a usage of the product, resolved when the maintenance is announced.": "The Nextcloud users who own a usage of the product, resolved when the maintenance is announced.", + "Owners resolved at": "Owners resolved at", + "When the owners to notify were resolved.": "When the owners to notify were resolved.", + "No impact": "No impact", + "Degraded": "Degraded", + "Unavailable": "Unavailable", + "In progress": "In progress", + "Completed": "Completed", + "Cancelled": "Cancelled", + "Start the maintenance.": "Start the maintenance.", + "Complete the maintenance.": "Complete the maintenance.", + "Cancel the maintenance.": "Cancel the maintenance.", + "Roadmap": "Roadmap", + "The direction the supplier takes with this application: what the next versions bring and when.": "The direction the supplier takes with this application: what the next versions bring and when.", + "Planned maintenance": "Planned maintenance", + "Announce maintenance": "Announce maintenance", + "No maintenance planned on this application": "No maintenance planned on this application", + "Planned releases": "Planned releases", + "Loading planned maintenance": "Loading planned maintenance", + "The planned maintenance could not be loaded.": "The planned maintenance could not be loaded.", + "No maintenance planned on the applications you use in the next 30 days": "No maintenance planned on the applications you use in the next 30 days", + "{start} to {end}": "{start} to {end}", + "Loading the roadmap": "Loading the roadmap", + "The roadmap could not be loaded.": "The roadmap could not be loaded.", + "The supplier has not published a roadmap for this application": "The supplier has not published a roadmap for this application", + "No versions with a date yet": "No versions with a date yet" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index 0bb16ff62..a8fc89421 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -827,6 +827,46 @@ "Add to our landscape": "Add to our landscape", "Connections and services": "Connections and services", "The applications your organisation uses, with the version it runs, where it stands and who owns it.": "The applications your organisation uses, with the version it runs, where it stands and who owns it.", - "No applications in use recorded for this organisation yet": "No applications in use recorded for this organisation yet" + "No applications in use recorded for this organisation yet": "No applications in use recorded for this organisation yet", + "Maintenance window": "Maintenance window", + "Maintenance a supplier plans on one of its products, with the time window and the expected impact.": "Maintenance a supplier plans on one of its products, with the time window and the expected impact.", + "The product the maintenance is on.": "The product the maintenance is on.", + "The version the maintenance is on, when it concerns one version.": "The version the maintenance is on, when it concerns one version.", + "What the maintenance is, in a few words.": "What the maintenance is, in a few words.", + "What changes and what users should do.": "What changes and what users should do.", + "Starts at": "Starts at", + "When the maintenance starts.": "When the maintenance starts.", + "Ends at": "Ends at", + "When the maintenance ends.": "When the maintenance ends.", + "Impact": "Impact", + "What users notice while the maintenance runs.": "What users notice while the maintenance runs.", + "Where the maintenance stands.": "Where the maintenance stands.", + "Owners to notify": "Owners to notify", + "The Nextcloud users who own a usage of the product, resolved when the maintenance is announced.": "The Nextcloud users who own a usage of the product, resolved when the maintenance is announced.", + "Owners resolved at": "Owners resolved at", + "When the owners to notify were resolved.": "When the owners to notify were resolved.", + "No impact": "No impact", + "Degraded": "Degraded", + "Unavailable": "Unavailable", + "In progress": "In progress", + "Completed": "Completed", + "Cancelled": "Cancelled", + "Start the maintenance.": "Start the maintenance.", + "Complete the maintenance.": "Complete the maintenance.", + "Cancel the maintenance.": "Cancel the maintenance.", + "Roadmap": "Roadmap", + "The direction the supplier takes with this application: what the next versions bring and when.": "The direction the supplier takes with this application: what the next versions bring and when.", + "Planned maintenance": "Planned maintenance", + "Announce maintenance": "Announce maintenance", + "No maintenance planned on this application": "No maintenance planned on this application", + "Planned releases": "Planned releases", + "Loading planned maintenance": "Loading planned maintenance", + "The planned maintenance could not be loaded.": "The planned maintenance could not be loaded.", + "No maintenance planned on the applications you use in the next 30 days": "No maintenance planned on the applications you use in the next 30 days", + "{start} to {end}": "{start} to {end}", + "Loading the roadmap": "Loading the roadmap", + "The roadmap could not be loaded.": "The roadmap could not be loaded.", + "The supplier has not published a roadmap for this application": "The supplier has not published a roadmap for this application", + "No versions with a date yet": "No versions with a date yet" } } diff --git a/l10n/nl.js b/l10n/nl.js index ac011f44f..b2a3bd845 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -898,7 +898,47 @@ OC.L10N.register( "Add to our landscape": "Toevoegen aan ons landschap", "Connections and services": "Koppelingen en diensten", "The applications your organisation uses, with the version it runs, where it stands and who owns it.": "De applicaties die uw organisatie gebruikt, met de versie die draait, de fase waarin ze staan en wie de eigenaar is.", - "No applications in use recorded for this organisation yet": "Nog geen applicaties in gebruik vastgelegd voor deze organisatie" + "No applications in use recorded for this organisation yet": "Nog geen applicaties in gebruik vastgelegd voor deze organisatie", + "Maintenance window": "Onderhoudsvenster", + "Maintenance a supplier plans on one of its products, with the time window and the expected impact.": "Onderhoud dat een leverancier plant op een van zijn producten, met het tijdvenster en de verwachte impact.", + "The product the maintenance is on.": "Het product waarop het onderhoud plaatsvindt.", + "The version the maintenance is on, when it concerns one version.": "De versie waarop het onderhoud plaatsvindt, als het om één versie gaat.", + "What the maintenance is, in a few words.": "Wat het onderhoud is, in een paar woorden.", + "What changes and what users should do.": "Wat er verandert en wat gebruikers moeten doen.", + "Starts at": "Begint op", + "When the maintenance starts.": "Wanneer het onderhoud begint.", + "Ends at": "Eindigt op", + "When the maintenance ends.": "Wanneer het onderhoud eindigt.", + "Impact": "Impact", + "What users notice while the maintenance runs.": "Wat gebruikers merken zolang het onderhoud loopt.", + "Where the maintenance stands.": "Waar het onderhoud staat.", + "Owners to notify": "Te informeren eigenaren", + "The Nextcloud users who own a usage of the product, resolved when the maintenance is announced.": "De Nextcloud-gebruikers die eigenaar zijn van een gebruik van het product, bepaald bij de aankondiging van het onderhoud.", + "Owners resolved at": "Eigenaren bepaald op", + "When the owners to notify were resolved.": "Wanneer de te informeren eigenaren zijn bepaald.", + "No impact": "Geen impact", + "Degraded": "Verminderd", + "Unavailable": "Niet beschikbaar", + "In progress": "Bezig", + "Completed": "Afgerond", + "Cancelled": "Geannuleerd", + "Start the maintenance.": "Start het onderhoud.", + "Complete the maintenance.": "Rond het onderhoud af.", + "Cancel the maintenance.": "Annuleer het onderhoud.", + "Roadmap": "Roadmap", + "The direction the supplier takes with this application: what the next versions bring and when.": "De richting die de leverancier met deze applicatie kiest: wat de volgende versies brengen en wanneer.", + "Planned maintenance": "Gepland onderhoud", + "Announce maintenance": "Onderhoud aankondigen", + "No maintenance planned on this application": "Geen onderhoud gepland op deze applicatie", + "Planned releases": "Geplande releases", + "Loading planned maintenance": "Gepland onderhoud laden", + "The planned maintenance could not be loaded.": "Het geplande onderhoud kon niet worden geladen.", + "No maintenance planned on the applications you use in the next 30 days": "Geen onderhoud gepland op de applicaties die u gebruikt in de komende 30 dagen", + "{start} to {end}": "{start} tot {end}", + "Loading the roadmap": "Roadmap laden", + "The roadmap could not be loaded.": "De roadmap kon niet worden geladen.", + "The supplier has not published a roadmap for this application": "De leverancier heeft geen roadmap voor deze applicatie gepubliceerd", + "No versions with a date yet": "Nog geen versies met een datum" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index 14eaf9a18..d4af67c76 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -897,6 +897,46 @@ "Add to our landscape": "Toevoegen aan ons landschap", "Connections and services": "Koppelingen en diensten", "The applications your organisation uses, with the version it runs, where it stands and who owns it.": "De applicaties die uw organisatie gebruikt, met de versie die draait, de fase waarin ze staan en wie de eigenaar is.", - "No applications in use recorded for this organisation yet": "Nog geen applicaties in gebruik vastgelegd voor deze organisatie" + "No applications in use recorded for this organisation yet": "Nog geen applicaties in gebruik vastgelegd voor deze organisatie", + "Maintenance window": "Onderhoudsvenster", + "Maintenance a supplier plans on one of its products, with the time window and the expected impact.": "Onderhoud dat een leverancier plant op een van zijn producten, met het tijdvenster en de verwachte impact.", + "The product the maintenance is on.": "Het product waarop het onderhoud plaatsvindt.", + "The version the maintenance is on, when it concerns one version.": "De versie waarop het onderhoud plaatsvindt, als het om één versie gaat.", + "What the maintenance is, in a few words.": "Wat het onderhoud is, in een paar woorden.", + "What changes and what users should do.": "Wat er verandert en wat gebruikers moeten doen.", + "Starts at": "Begint op", + "When the maintenance starts.": "Wanneer het onderhoud begint.", + "Ends at": "Eindigt op", + "When the maintenance ends.": "Wanneer het onderhoud eindigt.", + "Impact": "Impact", + "What users notice while the maintenance runs.": "Wat gebruikers merken zolang het onderhoud loopt.", + "Where the maintenance stands.": "Waar het onderhoud staat.", + "Owners to notify": "Te informeren eigenaren", + "The Nextcloud users who own a usage of the product, resolved when the maintenance is announced.": "De Nextcloud-gebruikers die eigenaar zijn van een gebruik van het product, bepaald bij de aankondiging van het onderhoud.", + "Owners resolved at": "Eigenaren bepaald op", + "When the owners to notify were resolved.": "Wanneer de te informeren eigenaren zijn bepaald.", + "No impact": "Geen impact", + "Degraded": "Verminderd", + "Unavailable": "Niet beschikbaar", + "In progress": "Bezig", + "Completed": "Afgerond", + "Cancelled": "Geannuleerd", + "Start the maintenance.": "Start het onderhoud.", + "Complete the maintenance.": "Rond het onderhoud af.", + "Cancel the maintenance.": "Annuleer het onderhoud.", + "Roadmap": "Roadmap", + "The direction the supplier takes with this application: what the next versions bring and when.": "De richting die de leverancier met deze applicatie kiest: wat de volgende versies brengen en wanneer.", + "Planned maintenance": "Gepland onderhoud", + "Announce maintenance": "Onderhoud aankondigen", + "No maintenance planned on this application": "Geen onderhoud gepland op deze applicatie", + "Planned releases": "Geplande releases", + "Loading planned maintenance": "Gepland onderhoud laden", + "The planned maintenance could not be loaded.": "Het geplande onderhoud kon niet worden geladen.", + "No maintenance planned on the applications you use in the next 30 days": "Geen onderhoud gepland op de applicaties die u gebruikt in de komende 30 dagen", + "{start} to {end}": "{start} tot {end}", + "Loading the roadmap": "Roadmap laden", + "The roadmap could not be loaded.": "De roadmap kon niet worden geladen.", + "The supplier has not published a roadmap for this application": "De leverancier heeft geen roadmap voor deze applicatie gepubliceerd", + "No versions with a date yet": "Nog geen versies met een datum" } } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index dbaefa0c7..57e9303b2 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -32,6 +32,7 @@ use OCA\Stackiq\Controller\ContactpersonenController; use OCA\Stackiq\Dashboard\ConceptOrganisatiesWidget; use OCA\Stackiq\EventListener\DecisionConcludedListener; +use OCA\Stackiq\EventListener\MaintenanceRecipientsListener; use OCA\Stackiq\EventListener\ModuleComplianceSubscriber; use OCA\Stackiq\EventListener\ModuleRegistrationSubscriber; use OCA\Stackiq\EventListener\TestEventListener; @@ -807,6 +808,9 @@ private function registerEventListeners(IRegistrationContext $context): void { $context->registerEventListener(ObjectCreatedEvent::class, ModuleRegistrationSubscriber::class); $context->registerEventListener(ObjectUpdatedEvent::class, ModuleRegistrationSubscriber::class); + // Queue the owner resolution when a supplier announces maintenance (lifecycle-maintenance-and-supplier-roadmap). + $context->registerEventListener(ObjectCreatedEvent::class, MaintenanceRecipientsListener::class); + // Sync user profile updates into the contactpersoon mirror. $context->registerEventListener(UserProfileUpdatedEvent::class, UserProfileUpdatedEventListener::class); diff --git a/lib/BackgroundJob/MaintenanceRecipientsJob.php b/lib/BackgroundJob/MaintenanceRecipientsJob.php new file mode 100644 index 000000000..4798ea300 --- /dev/null +++ b/lib/BackgroundJob/MaintenanceRecipientsJob.php @@ -0,0 +1,69 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT: + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\BackgroundJob; + +use OCA\Stackiq\Service\MaintenanceRecipientService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\QueuedJob; + +/** + * One owner resolution for one maintenance window. + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ +class MaintenanceRecipientsJob extends QueuedJob { + + /** + * Constructor. + * + * @param ITimeFactory $time The time factory. + * @param MaintenanceRecipientService $recipients The owner resolution. + */ + public function __construct( + ITimeFactory $time, + private readonly MaintenanceRecipientService $recipients, + ) { + parent::__construct(time: $time); + }//end __construct() + + /** + * Resolve and record the owners for the window in the argument. + * + * @param mixed $argument `{uuid, register, schema}` of the window. + * + * @return void + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ + protected function run($argument): void { + if (is_array($argument) === false || is_string($argument['uuid'] ?? null) === false) { + return; + } + + $this->recipients->recordRecipientsFor( + uuid: $argument['uuid'], + register: ($argument['register'] ?? null), + schema: ($argument['schema'] ?? null) + ); + }//end run() +}//end class diff --git a/lib/EventListener/MaintenanceRecipientsListener.php b/lib/EventListener/MaintenanceRecipientsListener.php new file mode 100644 index 000000000..c9c05a859 --- /dev/null +++ b/lib/EventListener/MaintenanceRecipientsListener.php @@ -0,0 +1,93 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT: + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\EventListener; + +use OCA\OpenRegister\Event\ObjectCreatedEvent; +use OCA\Stackiq\BackgroundJob\MaintenanceRecipientsJob; +use OCA\Stackiq\Service\MaintenanceRecipientService; +use OCP\BackgroundJob\IJobList; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; + +/** + * Queues the owner resolution for a newly created maintenance window. + * + * @template-implements IEventListener + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ +class MaintenanceRecipientsListener implements IEventListener { + + /** + * Constructor. + * + * @param MaintenanceRecipientService $recipients The schema check. + * @param IJobList $jobList The background job list. + * @param LoggerInterface $logger The logger. + */ + public function __construct( + private readonly MaintenanceRecipientService $recipients, + private readonly IJobList $jobList, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Handle an object created event. + * + * @param Event $event The event. + * + * @return void + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ + public function handle(Event $event): void { + if (($event instanceof ObjectCreatedEvent) === false) { + return; + } + + $object = $event->getObject(); + try { + if ($this->recipients->isMaintenanceWindow(object: $object) === false) { + return; + } + + $this->jobList->add( + MaintenanceRecipientsJob::class, + [ + 'uuid' => $object->getUuid(), + 'register' => $object->getRegister(), + 'schema' => $object->getSchema(), + ] + ); + } catch (\Throwable $e) { + $this->logger->error( + 'MaintenanceRecipientsListener: could not queue the owner resolution', + ['uuid' => $object->getUuid(), 'error' => $e->getMessage()] + ); + } + }//end handle() +}//end class diff --git a/lib/Service/MaintenanceRecipientService.php b/lib/Service/MaintenanceRecipientService.php new file mode 100644 index 000000000..7006e8820 --- /dev/null +++ b/lib/Service/MaintenanceRecipientService.php @@ -0,0 +1,338 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT: + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service; + +use DateTimeImmutable; +use DateTimeInterface; +use OCA\OpenRegister\Contract\ObjectEntityInterface; +use OCA\OpenRegister\Contract\ObjectServiceInterface; +use OCP\IUserManager; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Resolves the owners of every usage of a product to Nextcloud user ids and + * records them on a maintenance window. + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ +class MaintenanceRecipientService { + + /** + * The usage fields that name an owner. + * + * @var array + */ + public const OWNER_FIELDS = ['businessOwner', 'technicalOwner']; + + /** + * Upper bound on the usages read for one product. + */ + private const USAGE_LIMIT = 1000; + + /** + * Constructor. + * + * @param SettingsService $settingsService The register and schema lookups. + * @param StackiqContactSyncService $contacts The Nextcloud Contacts lookups. + * @param IUserManager $userManager The Nextcloud user manager. + * @param ContainerInterface $container The DI container, for OpenRegister's ObjectService. + * @param LoggerInterface $logger The logger. + */ + public function __construct( + private readonly SettingsService $settingsService, + private readonly StackiqContactSyncService $contacts, + private readonly IUserManager $userManager, + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Whether an object belongs to the maintenanceWindow schema. + * + * @param ObjectEntityInterface $object The object from the event. + * + * @return boolean True for a maintenance window. + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ + public function isMaintenanceWindow(ObjectEntityInterface $object): bool { + $schemaId = $this->settingsService->getSchemaIdForObjectType('maintenanceWindow'); + return $schemaId !== null && (string) $schemaId === (string) $object->getSchema(); + }//end isMaintenanceWindow() + + /** + * Load a maintenance window and record the owners to notify on it. + * + * @param string $uuid The window's id. + * @param string|int|null $register The register it lives in. + * @param string|int|null $schema Its schema. + * + * @return array|null The user ids written, or null when nothing was written. + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ + public function recordRecipientsFor(string $uuid, string|int|null $register, string|int|null $schema): ?array { + $objectService = $this->getObjectService(); + if ($objectService === null) { + return null; + } + + try { + $window = $objectService->find(id: $uuid, register: $register, schema: $schema, _rbac: false, _multitenancy: false); + } catch (\Throwable $e) { + $this->logger->error( + 'MaintenanceRecipientService: could not read the maintenance window', + ['uuid' => $uuid, 'error' => $e->getMessage()] + ); + return null; + } + + if ($window === null) { + return null; + } + + return $this->recordRecipients(window: $window); + }//end recordRecipientsFor() + + /** + * Record the owners to notify on a newly announced maintenance window. + * + * Writes `notifyUserIds` and `recipientsResolvedAt`; the rule + * `maintenance-announced` fires on the change of the latter. A window that + * already carries a resolved time is left alone, so the write this method + * makes cannot start it again. + * + * @param ObjectEntityInterface $window The maintenance window. + * @param DateTimeImmutable|null $now The moment of resolution (defaults to now). + * + * @return array|null The user ids written, or null when nothing was written. + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ + public function recordRecipients(ObjectEntityInterface $window, ?DateTimeImmutable $now=null): ?array { + $data = $window->getObject(); + if (empty($data['recipientsResolvedAt']) === false) { + return null; + } + + $moduleId = self::referenceId(value: ($data['module'] ?? null)); + $objectService = $this->getObjectService(); + if ($moduleId === null || $objectService === null) { + return null; + } + + $userIds = $this->ownerUserIds(objectService: $objectService, moduleId: $moduleId); + + $data['notifyUserIds'] = $userIds; + $data['recipientsResolvedAt'] = ($now ?? new DateTimeImmutable())->format(DateTimeInterface::ATOM); + + try { + $objectService->saveObject( + object: $data, + extend: [], + register: $window->getRegister(), + schema: $window->getSchema(), + uuid: $window->getUuid(), + _rbac: false, + _multitenancy: false + ); + } catch (\Throwable $e) { + $this->logger->error( + 'MaintenanceRecipientService: could not record the owners to notify', + ['uuid' => $window->getUuid(), 'error' => $e->getMessage()] + ); + return null; + } + + return $userIds; + }//end recordRecipients() + + /** + * The Nextcloud user ids of the owners of every usage of a product. + * + * @param ObjectServiceInterface $objectService OpenRegister's object service. + * @param string $moduleId The product's id. + * + * @return array Unique user ids, in the order found. + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ + public function ownerUserIds(ObjectServiceInterface $objectService, string $moduleId): array { + $contactIds = $this->ownerContactIds(objectService: $objectService, moduleId: $moduleId); + if ($contactIds === []) { + return []; + } + + $register = $this->settingsService->getRegisterIdForObjectType('contactPerson'); + $schema = $this->settingsService->getSchemaIdForObjectType('contactPerson'); + if ($register === null || $schema === null) { + return []; + } + + try { + $people = $objectService->searchObjects( + query: ['register' => $register, 'schema' => $schema, '_limit' => count($contactIds)], + _rbac: false, + _multitenancy: false, + ids: $contactIds + ); + } catch (\Throwable $e) { + $this->logger->error('MaintenanceRecipientService: could not read the owners', ['error' => $e->getMessage()]); + return []; + } + + $userIds = []; + foreach ((array) $people as $person) { + $uid = $this->userIdForContactPerson(person: $person->getObject()); + if ($uid !== null && in_array($uid, $userIds, true) === false) { + $userIds[] = $uid; + } + } + + return $userIds; + }//end ownerUserIds() + + /** + * The contact person ids named as owner on the usages of a product. + * + * @param ObjectServiceInterface $objectService OpenRegister's object service. + * @param string $moduleId The product's id. + * + * @return array Unique contact person ids. + */ + private function ownerContactIds(ObjectServiceInterface $objectService, string $moduleId): array { + $register = $this->settingsService->getRegisterIdForObjectType('usage'); + $schema = $this->settingsService->getSchemaIdForObjectType('usage'); + if ($register === null || $schema === null) { + return []; + } + + try { + $usages = $objectService->searchObjects( + query: ['register' => $register, 'schema' => $schema, 'module' => $moduleId, '_limit' => self::USAGE_LIMIT], + _rbac: false, + _multitenancy: false + ); + } catch (\Throwable $e) { + $this->logger->error('MaintenanceRecipientService: could not read the usages', ['error' => $e->getMessage()]); + return []; + } + + $ids = []; + foreach ((array) $usages as $usage) { + $data = $usage->getObject(); + foreach (self::OWNER_FIELDS as $field) { + $id = self::referenceId(value: ($data[$field] ?? null)); + if ($id !== null && in_array($id, $ids, true) === false) { + $ids[] = $id; + } + } + } + + return $ids; + }//end ownerContactIds() + + /** + * The Nextcloud user a contact person stands for. + * + * A contact in the system address book carries the user id as its UID; + * any other contact is matched to a user by e-mail address. + * + * @param array $person The contact person object. + * + * @return string|null The user id, or null when no user matches. + */ + private function userIdForContactPerson(array $person): ?string { + $contact = $this->contacts->findContactByUid((string) ($person['contactsUid'] ?? '')); + if ($contact === null) { + return null; + } + + if (($contact['isLocalSystemBook'] ?? false) === true && $this->userManager->userExists((string) ($contact['UID'] ?? '')) === true) { + return (string) $contact['UID']; + } + + foreach ((array) ($contact['EMAIL'] ?? []) as $email) { + if (is_array($email) === true) { + $email = ($email['value'] ?? ''); + } + + if (is_string($email) === false || $email === '') { + continue; + } + + $users = $this->userManager->getByEmail($email); + if (count($users) === 1) { + return $users[0]->getUID(); + } + } + + return null; + }//end userIdForContactPerson() + + /** + * The id a relation value points at: a plain id, or an object carrying one. + * + * @param mixed $value The stored relation value. + * + * @return string|null The id, or null when the value names none. + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ + public static function referenceId(mixed $value): ?string { + if (is_string($value) === true && $value !== '') { + return $value; + } + + if (is_array($value) === true) { + foreach (['id', 'uuid'] as $key) { + if (is_string($value[$key] ?? null) === true && $value[$key] !== '') { + return $value[$key]; + } + } + } + + return null; + }//end referenceId() + + /** + * Lazily resolve OpenRegister's ObjectService. + * + * @return ObjectServiceInterface|null The service, or null when OpenRegister is absent. + */ + private function getObjectService(): ?ObjectServiceInterface { + try { + $service = $this->container->get(ObjectServiceInterface::class); + if ($service instanceof ObjectServiceInterface) { + return $service; + } + } catch (\Throwable $e) { + $this->logger->debug('MaintenanceRecipientService: ObjectService not resolvable', ['error' => $e->getMessage()]); + } + + return null; + }//end getObjectService() +}//end class diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index fc8e4eb61..3195de0f1 100644 --- a/lib/Service/SettingsService.php +++ b/lib/Service/SettingsService.php @@ -912,6 +912,8 @@ public function getSchemaIdForObjectType(string $objectType): ?int { 'suite' => 'suite_schema', 'vulnerability' => 'kwetsbaarheid_schema', 'sector' => 'sector_schema', + // Planned maintenance on a product (lifecycle-maintenance-and-supplier-roadmap). + 'maintenanceWindow' => 'maintenanceWindow_schema', ]; // Only check voorzieningen config if object type exists in the key map. @@ -4224,6 +4226,7 @@ private function configureVoorzieningen(): array { 'moduleVersion' => 'moduleVersie_schema', 'sector' => 'sector_schema', 'sbomComponent' => 'sbomComponent_schema', + 'maintenanceWindow' => 'maintenanceWindow_schema', ]; $config = [ 'register' => (string)($targetRegister['id'] ?? '') ]; @@ -4657,6 +4660,7 @@ private function normalizeVoorzieningenConfig(array $input): array { 'moduleVersie_schema', 'sector_schema', 'sbomComponent_schema', + 'maintenanceWindow_schema', ]; // Copy any present schema keys; ignore sources/registers. diff --git a/lib/Settings/register.d/maintenance-and-roadmap.json b/lib/Settings/register.d/maintenance-and-roadmap.json new file mode 100644 index 000000000..a974dc1cd --- /dev/null +++ b/lib/Settings/register.d/maintenance-and-roadmap.json @@ -0,0 +1,20 @@ +{ + "components": { + "schemas": { + "module": { + "version": "0.3.4", + "properties": { + "roadmapStatement": { + "type": "string", + "format": "markdown", + "title": "Roadmap", + "description": "The direction the supplier takes with this application: what the next versions bring and when.", + "maxLength": 5000, + "facetable": false, + "order": 4 + } + } + } + } + } +} diff --git a/lib/Settings/softwarecatalogus_register.json b/lib/Settings/softwarecatalogus_register.json index 7d788425a..85f71c906 100644 --- a/lib/Settings/softwarecatalogus_register.json +++ b/lib/Settings/softwarecatalogus_register.json @@ -3,8 +3,8 @@ "info": { "title": "Software Catalog Register", "description": "Register containing AMEF and Voorzieningen schemas for the VNG Software Catalog application. This configuration includes schemas for applications, services, organizations, and compliance tracking.", - "version": "2.5.4", - "changelog": "2.5.4: usage (1.5.2) gains businessOwner and technicalOwner, contact persons of the consumer organisation; a usage is named after its application and organisation; status becomes facetable for the Applications in use filters (landscape-usage-registration). 2.5.3: the aiSystem schema (0.1.0) joins the stackiq register, for the AI systems an organisation uses and their EU AI Act classification (landscape-ai-system-inventory). 2.5.2: the connection schema (0.3.3) asked its national provision picker for gemmaType Buitengemeentenlijke voorziening, a spelling the GEMMA model does not use (it says Buitengemeentelijke voorziening), so the picker found nothing; its name template named the keys gegevensuitwisselingRichting and buitengemeentelijkVoorziening and the values AnaarB, BnaarA and bi-directioneel, all renamed since, so a connection had no readable name; and type, status and dataExchangeDirection become facetable for the new Connections page (connections-catalogue-pages). 2.5.1: the x-openregister-lifecycle blocks of usage, catalogContract, connection and moduleVersion still named the Dutch states (Verwerving/Gepland/In productie/Uit te faseren/Uitgefaseerd, In onderhandeling/Actief/Verlopen, in ontwikkeling/in gebruik/einde ondersteuning/teruggetrokken) while their status enums and the rows RenameDutchCatalogValues migrated are English, so no transition was ever offered on those records (stackiq#1140). The states now use the enum values, and the four schema versions are bumped (usage 1.5.1, catalogContract 0.1.2, connection 0.3.2, moduleVersion 0.1.5) because a lifecycle-only edit does not deploy without one, as 2.4.4 records. 2.4.4: organization.status was left behind by #520's enum translation — its `default` was still 'Concept' and its whole x-openregister-lifecycle block still named Concept/Actief/Deactief, while the enum and the migrated rows are Draft/Active/Inactive/merged. A default outside its own enum makes every newly created organisation fall out of the Organisations index filter, and a lifecycle whose from/to values match no row offers no transition at all — neither raises an error. The schema version is bumped with it because a deployed version >= the declared one makes the import SKIP, and OpenRegister's schemaContentDiffers() escape hatch compares only properties/required/authorization — never `configuration` — so a lifecycle-only edit would never have deployed. 2.4.3: Re-authored Dutch schema-level titles to English (dienst, kwetsbaarheid, contactpersoon, organisatie, gebruik, koppeling, beoordeeling, module, bioMaatregel, moduleVersie, sbomComponent); schema keys unchanged, Dutch labels now come from the app's l10n translation files. 2.4.2: Moved SBOM provenance properties (sbomLastImportedAt, sbomFormat, sbomFileName, sbomComponents) from the organisatie schema to moduleVersie, where SBOM imports actually record them; without this the moduleVersie magic table lacked the columns so recordProvenance() writes were silently dropped and the import-status endpoint always reported 'never imported'. 2.4.1: Re-authored Dutch schema property titles to English (property keys unchanged); Dutch labels now come from the app's l10n translation files." + "version": "2.5.5", + "changelog": "2.5.5: the maintenanceWindow schema (0.1.0) joins the stackiq register, for maintenance a supplier plans on its products, with the owners of every usage notified (lifecycle-maintenance-and-supplier-roadmap). 2.5.4: usage (1.5.2) gains businessOwner and technicalOwner, contact persons of the consumer organisation; a usage is named after its application and organisation; status becomes facetable for the Applications in use filters (landscape-usage-registration). 2.5.3: the aiSystem schema (0.1.0) joins the stackiq register, for the AI systems an organisation uses and their EU AI Act classification (landscape-ai-system-inventory). 2.5.2: the connection schema (0.3.3) asked its national provision picker for gemmaType Buitengemeentenlijke voorziening, a spelling the GEMMA model does not use (it says Buitengemeentelijke voorziening), so the picker found nothing; its name template named the keys gegevensuitwisselingRichting and buitengemeentelijkVoorziening and the values AnaarB, BnaarA and bi-directioneel, all renamed since, so a connection had no readable name; and type, status and dataExchangeDirection become facetable for the new Connections page (connections-catalogue-pages). 2.5.1: the x-openregister-lifecycle blocks of usage, catalogContract, connection and moduleVersion still named the Dutch states (Verwerving/Gepland/In productie/Uit te faseren/Uitgefaseerd, In onderhandeling/Actief/Verlopen, in ontwikkeling/in gebruik/einde ondersteuning/teruggetrokken) while their status enums and the rows RenameDutchCatalogValues migrated are English, so no transition was ever offered on those records (stackiq#1140). The states now use the enum values, and the four schema versions are bumped (usage 1.5.1, catalogContract 0.1.2, connection 0.3.2, moduleVersion 0.1.5) because a lifecycle-only edit does not deploy without one, as 2.4.4 records. 2.4.4: organization.status was left behind by #520's enum translation — its `default` was still 'Concept' and its whole x-openregister-lifecycle block still named Concept/Actief/Deactief, while the enum and the migrated rows are Draft/Active/Inactive/merged. A default outside its own enum makes every newly created organisation fall out of the Organisations index filter, and a lifecycle whose from/to values match no row offers no transition at all — neither raises an error. The schema version is bumped with it because a deployed version >= the declared one makes the import SKIP, and OpenRegister's schemaContentDiffers() escape hatch compares only properties/required/authorization — never `configuration` — so a lifecycle-only edit would never have deployed. 2.4.3: Re-authored Dutch schema-level titles to English (dienst, kwetsbaarheid, contactpersoon, organisatie, gebruik, koppeling, beoordeeling, module, bioMaatregel, moduleVersie, sbomComponent); schema keys unchanged, Dutch labels now come from the app's l10n translation files. 2.4.2: Moved SBOM provenance properties (sbomLastImportedAt, sbomFormat, sbomFileName, sbomComponents) from the organisatie schema to moduleVersie, where SBOM imports actually record them; without this the moduleVersie magic table lacked the columns so recordProvenance() writes were silently dropped and the import-status endpoint always reported 'never imported'. 2.4.1: Re-authored Dutch schema property titles to English (property keys unchanged); Dutch labels now come from the app's l10n translation files." }, "x-openregister": { "type": "application", @@ -836,7 +836,8 @@ "moduleVersion", "sbomComponent", "bioMeasure", - "aiSystem" + "aiSystem", + "maintenanceWindow" ], "source": "internal", "tablePrefix": "", @@ -8370,6 +8371,283 @@ } ] } + }, + "maintenanceWindow": { + "uri": null, + "slug": "maintenanceWindow", + "title": "Maintenance window", + "x-schema-org": "schema:Event", + "description": "Maintenance a supplier plans on one of its products, with the time window and the expected impact.", + "version": "0.1.0", + "icon": "Calendar", + "required": [ + "module", + "title", + "startsAt", + "endsAt" + ], + "source": "internal", + "hardValidation": false, + "immutable": false, + "searchable": true, + "maxDepth": 0, + "properties": { + "module": { + "type": "object", + "$ref": "#/components/schemas/module", + "objectConfiguration": { + "handling": "related-object" + }, + "title": "Application", + "description": "The product the maintenance is on.", + "facetable": true, + "order": 1, + "table": { + "default": true + } + }, + "moduleVersion": { + "type": "object", + "$ref": "#/components/schemas/moduleVersion", + "objectConfiguration": { + "handling": "related-object" + }, + "x-relation-filter": { + "module": "@object.module" + }, + "title": "Version", + "description": "The version the maintenance is on, when it concerns one version.", + "facetable": false, + "order": 2 + }, + "title": { + "type": "string", + "title": "Title", + "description": "What the maintenance is, in a few words.", + "maxLength": 255, + "facetable": false, + "order": 3, + "table": { + "default": true + } + }, + "description": { + "type": "string", + "format": "markdown", + "title": "Description", + "description": "What changes and what users should do.", + "maxLength": 5000, + "facetable": false, + "order": 4 + }, + "startsAt": { + "type": "string", + "format": "date-time", + "title": "Starts at", + "description": "When the maintenance starts.", + "facetable": false, + "order": 5, + "table": { + "default": true + } + }, + "endsAt": { + "type": "string", + "format": "date-time", + "title": "Ends at", + "description": "When the maintenance ends.", + "facetable": false, + "order": 6, + "table": { + "default": true + } + }, + "impact": { + "type": "string", + "enum": [ + "no impact", + "degraded", + "unavailable" + ], + "x-enum-labels": { + "no impact": "No impact", + "degraded": "Degraded", + "unavailable": "Unavailable" + }, + "default": "degraded", + "title": "Impact", + "description": "What users notice while the maintenance runs.", + "facetable": true, + "order": 7, + "table": { + "default": true + } + }, + "status": { + "type": "string", + "enum": [ + "planned", + "in progress", + "completed", + "cancelled" + ], + "x-enum-labels": { + "planned": "Planned", + "in progress": "In progress", + "completed": "Completed", + "cancelled": "Cancelled" + }, + "default": "planned", + "title": "Status", + "description": "Where the maintenance stands.", + "facetable": true, + "order": 8, + "table": { + "default": true + } + }, + "notifyUserIds": { + "type": "array", + "items": { + "type": "string" + }, + "hideOnForm": true, + "visible": false, + "title": "Owners to notify", + "description": "The Nextcloud users who own a usage of the product, resolved when the maintenance is announced.", + "facetable": false, + "order": 9 + }, + "recipientsResolvedAt": { + "type": "string", + "format": "date-time", + "hideOnForm": true, + "visible": false, + "title": "Owners resolved at", + "description": "When the owners to notify were resolved.", + "facetable": false, + "order": 10 + } + }, + "configuration": { + "objectNameField": "title", + "objectDescriptionField": "description", + "autoPublish": true, + "x-openregister-lifecycle": { + "field": "status", + "initial": "planned", + "final": [ + "completed", + "cancelled" + ], + "transitions": { + "start": { + "from": [ + "planned" + ], + "to": "in progress", + "description": "Start the maintenance." + }, + "complete": { + "from": [ + "in progress" + ], + "to": "completed", + "description": "Complete the maintenance." + }, + "cancel": { + "from": [ + "planned", + "in progress" + ], + "to": "cancelled", + "description": "Cancel the maintenance." + } + } + } + }, + "x-openregister-notifications": { + "maintenance-announced": { + "trigger": { + "type": "updated", + "condition": { + "field": "recipientsResolvedAt", + "operator": "changed" + } + }, + "enabled": true, + "channels": [ + "nc-notification" + ], + "recipients": [ + { + "kind": "relation", + "relation": "notifyUserIds" + } + ], + "subject": { + "nl": "Gepland onderhoud: {{title}}", + "en": "Planned maintenance: {{title}}" + } + }, + "maintenance-starts-tomorrow": { + "trigger": { + "type": "scheduled", + "intervalSec": 86400, + "filter": { + "startsAt": { + "operator": "withinNext", + "value": "P1D" + }, + "status": { + "operator": "equals", + "value": "planned" + } + } + }, + "enabled": true, + "channels": [ + "nc-notification" + ], + "recipients": [ + { + "kind": "relation", + "relation": "notifyUserIds" + } + ], + "subject": { + "nl": "Onderhoud begint morgen: {{title}}", + "en": "Maintenance starts tomorrow: {{title}}" + } + } + }, + "authorization": { + "create": [ + "software-catalog-admins", + "aanbod-beheerder" + ], + "read": [ + "public" + ], + "update": [ + "software-catalog-admins", + { + "group": "aanbod-beheerder", + "match": { + "_organisation": "$organisation" + } + } + ], + "delete": [ + "software-catalog-admins", + { + "group": "aanbod-beheerder", + "match": { + "_organisation": "$organisation" + } + } + ] + } } }, "objects": [ diff --git a/lib/Settings/stackiq_mock_register.json b/lib/Settings/stackiq_mock_register.json index d39fdf97c..6d52905a4 100644 --- a/lib/Settings/stackiq_mock_register.json +++ b/lib/Settings/stackiq_mock_register.json @@ -10933,6 +10933,62 @@ "aiActRiskCategory": "minimal risk", "aiActRole": "deployer", "status": "in use" + }, + { + "@self": { + "register": "stackiq", + "schema": "maintenanceWindow", + "slug": "maintenance-database-upgrade" + }, + "module": {}, + "title": "Database upgrade", + "description": "The supplier moves the application to a new database version. Log out before the window starts.", + "startsAt": "2026-10-10T06:00:00+02:00", + "endsAt": "2026-10-10T10:00:00+02:00", + "impact": "unavailable", + "status": "planned" + }, + { + "@self": { + "register": "stackiq", + "schema": "maintenanceWindow", + "slug": "maintenance-security-patch" + }, + "module": {}, + "title": "Security patch", + "description": "A security patch is installed; the application stays available but may respond slowly.", + "startsAt": "2026-10-17T20:00:00+02:00", + "endsAt": "2026-10-17T21:00:00+02:00", + "impact": "degraded", + "status": "planned" + }, + { + "@self": { + "register": "vng-gemma", + "schema": "maintenanceWindow", + "slug": "maintenance-database-upgrade" + }, + "module": {}, + "title": "Database upgrade", + "description": "The supplier moves the application to a new database version. Log out before the window starts.", + "startsAt": "2026-10-10T06:00:00+02:00", + "endsAt": "2026-10-10T10:00:00+02:00", + "impact": "unavailable", + "status": "planned" + }, + { + "@self": { + "register": "vng-gemma", + "schema": "maintenanceWindow", + "slug": "maintenance-security-patch" + }, + "module": {}, + "title": "Security patch", + "description": "A security patch is installed; the application stays available but may respond slowly.", + "startsAt": "2026-10-17T20:00:00+02:00", + "endsAt": "2026-10-17T21:00:00+02:00", + "impact": "degraded", + "status": "planned" } ] } diff --git a/openspec/changes/lifecycle-maintenance-and-supplier-roadmap/.openspec.yaml b/openspec/changes/archive/2026-09-29-lifecycle-maintenance-and-supplier-roadmap/.openspec.yaml similarity index 100% rename from openspec/changes/lifecycle-maintenance-and-supplier-roadmap/.openspec.yaml rename to openspec/changes/archive/2026-09-29-lifecycle-maintenance-and-supplier-roadmap/.openspec.yaml diff --git a/openspec/changes/lifecycle-maintenance-and-supplier-roadmap/design.md b/openspec/changes/archive/2026-09-29-lifecycle-maintenance-and-supplier-roadmap/design.md similarity index 69% rename from openspec/changes/lifecycle-maintenance-and-supplier-roadmap/design.md rename to openspec/changes/archive/2026-09-29-lifecycle-maintenance-and-supplier-roadmap/design.md index 638e6d149..f78e67f6f 100644 --- a/openspec/changes/lifecycle-maintenance-and-supplier-roadmap/design.md +++ b/openspec/changes/archive/2026-09-29-lifecycle-maintenance-and-supplier-roadmap/design.md @@ -55,3 +55,11 @@ One demo maintenance window next week on a demo product with a usage, and a road ## Risks - The listener runs on create; owners added to a usage later are not notified for an already announced window. The widget still shows it to them. + +## Changed at build (29 Sep, development `a4a28c49`) + +- D1: `maintenanceWindow` lives in `lib/Settings/softwarecatalogus_register.json` itself (register 2.5.5), not in a `register.d` fragment. This repo's fragments may only overlay a schema the monolith declares, and the relation-dialect gate reads a fragment on its own, so a new schema with relations cannot live in one. `roadmapStatement` does stay in the fragment `register.d/maintenance-and-roadmap.json` (module 0.3.4). The schema also carries `recipientsResolvedAt` (see D3), and its icon is Calendar, the maintenance-like glyph both icon registries hold. +- D3: the listener does not resolve the owners itself. It queues `MaintenanceRecipientsJob`, and the job calls `MaintenanceRecipientService`, so the usage reads and the write run off the supplier's request (ADR-078, gate 61). Two rule details follow from OpenRegister at `4abd8343`: the `field` recipient kind takes one string, so the rules use `{ kind: relation, relation: notifyUserIds }`, which reads an array of user ids; and an `updated` trigger's `changed` condition compares scalar values only, so `announced` fires on the change of `recipientsResolvedAt`, which the job writes together with `notifyUserIds`. A contact person maps to a Nextcloud user by the system address book UID, else by a unique e-mail match (`IUserManager::getByEmail`). The rules pass OpenRegister's own `NotificationAnnotationValidator`. The app resolves the schema id through `SettingsService` (`maintenanceWindow_schema` in the import map, the lookup map and the stored config's key list; a test proved the third was needed). +- D4: the roadmap timeline sorts newest first, so planned versions (dated in the future) come first, and groups by month. +- D5: already done before this change was built, in register 2.5.1 (stackiq#1140, moduleVersion 0.1.5 with the lifecycle on the enum values). Nothing to do. +- D2: the Planned maintenance list offers "Announce maintenance" (the list's create button); the list filter fills in the product. diff --git a/openspec/changes/lifecycle-maintenance-and-supplier-roadmap/proposal.md b/openspec/changes/archive/2026-09-29-lifecycle-maintenance-and-supplier-roadmap/proposal.md similarity index 100% rename from openspec/changes/lifecycle-maintenance-and-supplier-roadmap/proposal.md rename to openspec/changes/archive/2026-09-29-lifecycle-maintenance-and-supplier-roadmap/proposal.md diff --git a/openspec/changes/lifecycle-maintenance-and-supplier-roadmap/specs/maintenance-and-supplier-roadmap/spec.md b/openspec/changes/archive/2026-09-29-lifecycle-maintenance-and-supplier-roadmap/specs/maintenance-and-supplier-roadmap/spec.md similarity index 95% rename from openspec/changes/lifecycle-maintenance-and-supplier-roadmap/specs/maintenance-and-supplier-roadmap/spec.md rename to openspec/changes/archive/2026-09-29-lifecycle-maintenance-and-supplier-roadmap/specs/maintenance-and-supplier-roadmap/spec.md index ffd42cee5..4a3ee52f4 100644 --- a/openspec/changes/lifecycle-maintenance-and-supplier-roadmap/specs/maintenance-and-supplier-roadmap/spec.md +++ b/openspec/changes/archive/2026-09-29-lifecycle-maintenance-and-supplier-roadmap/specs/maintenance-and-supplier-roadmap/spec.md @@ -38,7 +38,7 @@ The product page SHALL list its maintenance windows, and the dashboard SHALL lis When a supplier announces a window, stackiq SHALL notify the business and technical owners of every usage of the product, and SHALL remind them a day before the window starts while it is still planned. #### Scenario: Owners get the announcement -@e2e exclude Delivered by OpenRegister's notification engine; tests/Unit/Listener/MaintenanceRecipientsListenerTest.php asserts the resolved owners and tests/Unit/Settings/MaintenanceRoadmapFragmentTest.php the rules. +@e2e exclude Delivered by OpenRegister's notification engine; tests/Unit/EventListener/MaintenanceRecipientsListenerTest.php asserts the resolved owners and tests/Unit/Settings/MaintenanceRoadmapFragmentTest.php the rules. - **GIVEN** two municipalities use product X and both usages have a business owner - **WHEN** the supplier announces a window on X diff --git a/openspec/changes/lifecycle-maintenance-and-supplier-roadmap/tasks.md b/openspec/changes/archive/2026-09-29-lifecycle-maintenance-and-supplier-roadmap/tasks.md similarity index 83% rename from openspec/changes/lifecycle-maintenance-and-supplier-roadmap/tasks.md rename to openspec/changes/archive/2026-09-29-lifecycle-maintenance-and-supplier-roadmap/tasks.md index cf2ed4296..0ce82205b 100644 --- a/openspec/changes/lifecycle-maintenance-and-supplier-roadmap/tasks.md +++ b/openspec/changes/archive/2026-09-29-lifecycle-maintenance-and-supplier-roadmap/tasks.md @@ -7,24 +7,24 @@ - **files**: `lib/Settings/register.d/maintenance-and-roadmap.json`, `lib/Settings/softwarecatalogus_register.json` (moduleVersion lifecycle and version), `lib/Settings/stackiq_mock_register.json` - **acceptance_criteria**: - GIVEN the merged register WHEN it is imported THEN maintenanceWindow exists and a planned version offers the release transition -- [ ] Implement -- [ ] Test (PHPUnit `tests/Unit/Settings/MaintenanceRoadmapFragmentTest.php`: schema, lifecycle states are enum values on both schemas) +- [x] Implement +- [x] Test (PHPUnit `tests/Unit/Settings/MaintenanceRoadmapFragmentTest.php`: schema, lifecycle states are enum values on both schemas) ### Task 2: Maintenance on the product page and the dashboard - **spec_ref**: openspec/changes/lifecycle-maintenance-and-supplier-roadmap/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-002-organisations-that-use-a-product-see-its-planned-maintenance - **files**: `src/manifest.json` (ModuleDetail list, Dashboard widget), `src/components/maintenance/UpcomingMaintenanceWidget.vue`, `src/customComponents.js`, `l10n/en.json`, `l10n/nl.json` - **acceptance_criteria**: - GIVEN a window next week on product X WHEN a user of a municipality that uses X opens the dashboard THEN the widget lists it -- [ ] Implement -- [ ] Test (vitest `tests/vitest/upcomingMaintenance.spec.js`; Playwright `tests/e2e/workflows/maintenance.spec.ts`) +- [x] Implement +- [x] Test (vitest `tests/vitest/maintenance.spec.js`; Playwright `tests/e2e/workflows/maintenance.spec.ts`) ### Task 3: Owner notifications - **spec_ref**: openspec/changes/lifecycle-maintenance-and-supplier-roadmap/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified - **files**: `lib/Listener/MaintenanceRecipientsListener.php`, `lib/AppInfo/Application.php`, `lib/Settings/register.d/maintenance-and-roadmap.json` (rules) - **acceptance_criteria**: - GIVEN two usages of X with owners WHEN the supplier creates a window THEN notifyUserIds holds the owners' user ids -- [ ] Implement -- [ ] Test (PHPUnit `tests/Unit/Listener/MaintenanceRecipientsListenerTest.php` with the real event class) +- [x] Implement +- [x] Test (PHPUnit `tests/Unit/EventListener/MaintenanceRecipientsListenerTest.php` with the real event class) ### Task 4: Roadmap on the product page and planned releases - **spec_ref**: openspec/changes/lifecycle-maintenance-and-supplier-roadmap/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-004-a-product-page-shows-the-suppliers-roadmap @@ -32,16 +32,16 @@ - **acceptance_criteria**: - GIVEN product X with a roadmap statement and a planned version WHEN a buyer opens its page THEN the statement and the timeline with the planned version show - GIVEN the Module versions list WHEN the user picks Planned releases THEN only versions in development remain -- [ ] Implement -- [ ] Test (vitest `tests/vitest/productRoadmap.spec.js`; Playwright case in `tests/e2e/workflows/maintenance.spec.ts`) +- [x] Implement +- [x] Test (vitest `tests/vitest/maintenance.spec.js`; Playwright case in `tests/e2e/workflows/maintenance.spec.ts`) ### Task 5: Documentation - **spec_ref**: openspec/changes/lifecycle-maintenance-and-supplier-roadmap/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-004-a-product-page-shows-the-suppliers-roadmap - **files**: `docs/features/maintenance-and-roadmap.md`, `docs/images/product-roadmap.png` - **acceptance_criteria**: - GIVEN the docs site WHEN a reader opens Maintenance and roadmap THEN announcing, following and the roadmap are explained with a screenshot -- [ ] Implement -- [ ] Test (docs build, screenshot with Playwright) +- [x] Implement +- [ ] Test (docs build, screenshot with Playwright): the page is written; the screenshot waits for a seeded instance. The Playwright file lists 4 tests and was not run, no seeded instance ## Verification diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json index c5f34ca52..fa3ef27bd 100644 --- a/openspec/parity/capabilities.json +++ b/openspec/parity/capabilities.json @@ -2001,21 +2001,22 @@ "bluedolphin": "unknown", "glpi": "no", "topdesk": "unknown", - "stackiq": "partial", + "stackiq": "yes", "built": { - "state": "specified", - "evidence": "register :7651 moduleVersion with status enum in development/in use/end of support/withdrawn and dateInDevelopment/dateInUse/dateEndSupport, read public; src/manifest.json:915 Moduleversies index + ModuleversieDetail", - "owner": "ConductionNL/stackiq" + "state": "built", + "evidence": "lib/Settings/register.d/maintenance-and-roadmap.json module.roadmapStatement; src/components/roadmap/ProductRoadmap.vue on ModuleDetail (statement + versions on CnTimelineView, planned first); Moduleversies Planned releases quick filter and dateInDevelopment column; tests/vitest/maintenance.spec.js", + "owner": "ConductionNL/stackiq", + "change": "2026-09-29-lifecycle-maintenance-and-supplier-roadmap" }, - "reachedOn": "Module versions /moduleversies (main menu)", + "reachedOn": "application page Roadmap section; Module versions > Planned releases", "provider": "stackiq", "providerHow": "read-from-code", "feature": "maintenance-and-supplier-roadmap", "featureConfidence": "medium", - "note": "A supplier can register a future version with status 'in development' and planned dates, which reads as a crude release plan. There is no roadmap view or declared roadmap; the overlay marks maintenance-and-supplier-roadmap as 'soon'. Specified in openspec/changes/lifecycle-maintenance-and-supplier-roadmap (OpenSpec pass 2026-09-27).", + "note": "A supplier can register a future version with status 'in development' and planned dates, which reads as a crude release plan. There is no roadmap view or declared roadmap; the overlay marks maintenance-and-supplier-roadmap as 'soon'. Specified in openspec/changes/lifecycle-maintenance-and-supplier-roadmap (OpenSpec pass 2026-09-27). Built by openspec/changes/archive/2026-09-29-lifecycle-maintenance-and-supplier-roadmap.", "evidence": { "vng-softwarecatalogus": "https://www.softwarecatalogus.nl/node/13683: \"Alle pakketversies en planningen ... gelijk zichtbaar de planning van de diverse pakketversies\" (read 2026-09-26); https://www.softwarecatalogus.nl/pakketversies: facet \"Status planning ... Filter op in ontwikkeling en zie de distributie planningsdata\" (read 2026-09-26). Reached on: Wat is er te vinden > Alle pakketversies en planningen.", - "stackiq": "register :7651 moduleVersion with status enum in development/in use/end of support/withdrawn and dateInDevelopment/dateInUse/dateEndSupport, read public; src/manifest.json:915 Moduleversies index + ModuleversieDetail", + "stackiq": "lib/Settings/register.d/maintenance-and-roadmap.json module.roadmapStatement; src/components/roadmap/ProductRoadmap.vue on ModuleDetail (statement + versions on CnTimelineView, planned first); Moduleversies Planned releases quick filter and dateInDevelopment column; tests/vitest/maintenance.spec.js", "topdesk": "unknown: TOPdesk is a single-organisation tool; no market-wide catalogue is described; searched the full-text search index of docs.topdesk.com (https://docs.topdesk.com/en/js/fuzzydata.js, 987 pages) (read 2026-09-26)", "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; roadmap templates are for the customer's own plans (https://help.bluedolphin.io/en/articles/11967586-roadmap), suppliers' declared roadmaps are not documented (read 2026-09-26)", "sap-leanix": "https://help.sap.com/docs/leanix/ea/it-components-in-reference-catalog: 'The catalog provides standardized IT component data including lifecycle dates, vendor information ... You no longer need to track vendor lifecycle dates manually'. Vendor lifecycle and support dates, not planned releases; needs Technology Risk and Compliance (read 2026-09-26). Reached on: IT Component fact sheet linked to the reference catalog.", @@ -2394,20 +2395,21 @@ "bluedolphin": "unknown", "glpi": "partial", "topdesk": "yes", - "stackiq": "no", + "stackiq": "yes", "built": { - "state": "specified", - "evidence": "openspec/features.overlay.json maintenance-and-supplier-roadmap status 'soon'; no maintenance schema in lib/Settings/softwarecatalogus_register.json and no page in src/manifest.json", - "owner": "ConductionNL/stackiq" + "state": "built", + "evidence": "lib/Settings/softwarecatalogus_register.json schema maintenanceWindow (module, moduleVersion, startsAt, endsAt, impact, status with lifecycle start/complete/cancel, notification rules maintenance-announced and maintenance-starts-tomorrow to the usage owners); lib/EventListener/MaintenanceRecipientsListener.php queues lib/BackgroundJob/MaintenanceRecipientsJob.php, lib/Service/MaintenanceRecipientService.php; ModuleDetail md-maintenance; Dashboard widget upcoming-maintenance (src/components/maintenance/UpcomingMaintenanceWidget.vue); tests/Unit/Settings/MaintenanceRoadmapFragmentTest.php, tests/Unit/EventListener/MaintenanceRecipientsListenerTest.php, tests/vitest/maintenance.spec.js", + "owner": "ConductionNL/stackiq", + "change": "2026-09-29-lifecycle-maintenance-and-supplier-roadmap" }, - "reachedOn": "nothing reaches it", + "reachedOn": "Dashboard Planned maintenance widget; application page Planned maintenance section with Announce maintenance", "provider": "stackiq", "providerHow": "read-from-code", "feature": "maintenance-and-supplier-roadmap", "featureConfidence": "high", - "note": "Listed as 'soon' in the feature overlay; nothing is built. Specified in openspec/changes/lifecycle-maintenance-and-supplier-roadmap (OpenSpec pass 2026-09-27).", + "note": "Listed as 'soon' in the feature overlay; nothing is built. Specified in openspec/changes/lifecycle-maintenance-and-supplier-roadmap (OpenSpec pass 2026-09-27). Built by openspec/changes/archive/2026-09-29-lifecycle-maintenance-and-supplier-roadmap.", "evidence": { - "stackiq": "openspec/features.overlay.json maintenance-and-supplier-roadmap status 'soon'; no maintenance schema in lib/Settings/softwarecatalogus_register.json and no page in src/manifest.json", + "stackiq": "lib/Settings/softwarecatalogus_register.json schema maintenanceWindow (module, moduleVersion, startsAt, endsAt, impact, status with lifecycle start/complete/cancel, notification rules maintenance-announced and maintenance-starts-tomorrow to the usage owners); lib/EventListener/MaintenanceRecipientsListener.php queues lib/BackgroundJob/MaintenanceRecipientsJob.php, lib/Service/MaintenanceRecipientService.php; ModuleDetail md-maintenance; Dashboard widget upcoming-maintenance (src/components/maintenance/UpcomingMaintenanceWidget.vue); tests/Unit/Settings/MaintenanceRoadmapFragmentTest.php, tests/Unit/EventListener/MaintenanceRecipientsListenerTest.php, tests/vitest/maintenance.spec.js", "topdesk": "https://docs.topdesk.com/en/operations-management.html: \"In TOPdesk you can easily schedule operational activities in the user-friendly planner. If you wish to schedule a recurring activity, you can use a series\" (read 2026-09-26); https://docs.topdesk.com/en/linking-assets-to-cards.html: assets can be linked to \"Operational Activity\" cards (read 2026-09-26). Reached on: Modules > Operations Management > Planner.", "vng-softwarecatalogus": "unknown: no maintenance announcements are described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)", "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; planned maintenance of applications is not documented (read 2026-09-26)", diff --git a/openspec/specs/maintenance-and-supplier-roadmap/spec.md b/openspec/specs/maintenance-and-supplier-roadmap/spec.md new file mode 100644 index 000000000..b1ce96557 --- /dev/null +++ b/openspec/specs/maintenance-and-supplier-roadmap/spec.md @@ -0,0 +1,57 @@ +# maintenance-and-supplier-roadmap Specification + +## Purpose +Organisations follow the maintenance suppliers plan on the products they use, and read each supplier's roadmap and planned releases. Matrix rows `stackiq:life-maintenance-window` and `stackiq:mkt-supplier-roadmap`. + +## Requirements + +### Requirement: REQ-MSR-001 A supplier announces planned maintenance on a product + +A supplier SHALL record a maintenance window on its own product with a title, a start and an end, the expected impact and a status that moves from planned to in progress to completed, or to cancelled. + +#### Scenario: A supplier announces a maintenance window +@e2e tests/e2e/workflows/maintenance.spec.ts + +- **GIVEN** a supplier of product X +- **WHEN** the supplier opens the page of X, clicks Add under Planned maintenance and saves a window next Saturday 08:00 to 12:00 with impact unavailable +- **THEN** the Planned maintenance section of X lists the window as planned + +### Requirement: REQ-MSR-002 Organisations that use a product see its planned maintenance + +The product page SHALL list its maintenance windows, and the dashboard SHALL list the planned windows of the next 30 days for products the user's organisation uses. + +#### Scenario: A municipality sees the window on its dashboard +@e2e tests/e2e/workflows/maintenance.spec.ts + +- **GIVEN** a municipality with a usage of product X and a planned window on X next Saturday +- **WHEN** its information manager opens the dashboard +- **THEN** the Planned maintenance widget lists X with next Saturday's window and impact unavailable + +### Requirement: REQ-MSR-003 The owners of every usage are notified + +When a supplier announces a window, stackiq SHALL notify the business and technical owners of every usage of the product, and SHALL remind them a day before the window starts while it is still planned. + +#### Scenario: Owners get the announcement +@e2e exclude Delivered by OpenRegister's notification engine; tests/Unit/EventListener/MaintenanceRecipientsListenerTest.php asserts the resolved owners and tests/Unit/Settings/MaintenanceRoadmapFragmentTest.php the rules. + +- **GIVEN** two municipalities use product X and both usages have a business owner +- **WHEN** the supplier announces a window on X +- **THEN** both business owners receive a Nextcloud notification naming X and the window + +### Requirement: REQ-MSR-004 A product page shows the supplier's roadmap + +The product page SHALL show the supplier's roadmap statement and the product's versions on a timeline, planned versions first, and the Module versions list SHALL filter on planned releases. A supplier SHALL release a planned version from its page through the version lifecycle. + +#### Scenario: A buyer reads what ships next +@e2e tests/e2e/workflows/maintenance.spec.ts + +- **GIVEN** product X with a roadmap statement and version 3.0 in development with a planned date in March +- **WHEN** a municipal buyer opens the page of X +- **THEN** the roadmap shows the statement and version 3.0 in March on the timeline + +#### Scenario: A supplier releases the planned version +@e2e tests/e2e/workflows/maintenance.spec.ts + +- **GIVEN** version 3.0 of product X in development +- **WHEN** the supplier opens the version and clicks Release +- **THEN** its status reads in use diff --git a/src/components/maintenance/UpcomingMaintenanceWidget.vue b/src/components/maintenance/UpcomingMaintenanceWidget.vue new file mode 100644 index 000000000..9334b9049 --- /dev/null +++ b/src/components/maintenance/UpcomingMaintenanceWidget.vue @@ -0,0 +1,234 @@ + + + + + + diff --git a/src/components/roadmap/ProductRoadmap.vue b/src/components/roadmap/ProductRoadmap.vue new file mode 100644 index 000000000..044b20f23 --- /dev/null +++ b/src/components/roadmap/ProductRoadmap.vue @@ -0,0 +1,185 @@ + + + + + + diff --git a/src/customComponents.js b/src/customComponents.js index 3d75c2da6..25912a30f 100644 --- a/src/customComponents.js +++ b/src/customComponents.js @@ -25,6 +25,7 @@ import ContractApprovalPanel from './components/contracts/ContractApprovalPanel. import ContractSeatsPanel from './components/contracts/ContractSeatsPanel.vue' import OrganisationMergePanel from './components/organisations/OrganisationMergePanel.vue' import ReviewsPanel from './components/reviews/ReviewsPanel.vue' +import ProductRoadmap from './components/roadmap/ProductRoadmap.vue' import SbomComponentsPanel from './components/sbom/SbomComponentsPanel.vue' import VulnerabilityExposurePanel from './components/vulnerabilities/VulnerabilityExposurePanel.vue' import ComplianceMatrixView from './views/ComplianceMatrixView.vue' @@ -91,6 +92,11 @@ export default { // Licences in use against licences bought (contracts-licence-seats). ContractSeatsPanel, + // The supplier's roadmap statement and the product's versions on a timeline, + // a bodyWidgets section on ModuleDetail (lifecycle-maintenance-and-supplier-roadmap): + // it reads the module and its versions, which no built-in widget combines. + ProductRoadmap, + // AI Act evidence per tag on the AI system page (landscape-ai-system-inventory): // it reads the object's files and their tags, which no built-in widget lists per tag. AiActChecklist, diff --git a/src/icons.js b/src/icons.js index 78210e070..6a08b1924 100644 --- a/src/icons.js +++ b/src/icons.js @@ -21,6 +21,7 @@ import ArrowRight from 'vue-material-design-icons/ArrowRight.vue' import BookOpenVariant from 'vue-material-design-icons/BookOpenVariant.vue' import BookOpenVariantOutline from 'vue-material-design-icons/BookOpenVariantOutline.vue' import BriefcaseOutline from 'vue-material-design-icons/BriefcaseOutline.vue' +import Calendar from 'vue-material-design-icons/Calendar.vue' import ChartBar from 'vue-material-design-icons/ChartBar.vue' import ChartBoxOutline from 'vue-material-design-icons/ChartBoxOutline.vue' import ChartLine from 'vue-material-design-icons/ChartLine.vue' @@ -81,6 +82,7 @@ export default { BookOpenVariant, BookOpenVariantOutline, BriefcaseOutline, + Calendar, ChartBar, ChartBoxOutline, ChartLine, diff --git a/src/main.js b/src/main.js index 3a27436eb..0e6041f91 100644 --- a/src/main.js +++ b/src/main.js @@ -30,6 +30,7 @@ import { createApp, h } from 'vue' import { createRouter, createWebHistory } from 'vue-router' import App from './App.vue' import CatalogPanels from './components/CatalogPanels.vue' +import UpcomingMaintenanceWidget from './components/maintenance/UpcomingMaintenanceWidget.vue' import customComponents from './customComponents.js' import appIcons from './icons.js' import bundledManifest from './manifest.json' @@ -72,6 +73,16 @@ registerDashboardWidget('catalog-panels', { icon: 'DatabaseOutline', card: true, }) +// Planned maintenance on the applications the active organisation uses +// (lifecycle-maintenance-and-supplier-roadmap): it joins the organisation's +// usages to the maintenance windows of their applications. +registerDashboardWidget('upcoming-maintenance', { + renderer: UpcomingMaintenanceWidget, + defaultContent: {}, + displayName: 'Planned maintenance', + icon: 'Calendar', + card: true, +}) try { registerTranslations() } catch (e) { diff --git a/src/manifest.json b/src/manifest.json index 19a97a2b9..d690db613 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -329,6 +329,12 @@ "type": "catalog-panels", "title": "Object statistics", "_note": "The management info-box and the two per-object-type statistics tables, moved out of the former hand-written Dashboard view into src/components/CatalogPanels.vue. Registered as a widget TYPE via registerDashboardWidget() in main.js: CnDashboardPage resolves a widget's type against the LIBRARY catalog, not the app registry, and an unregistered type renders 'Widget not available' silently." + }, + { + "id": "upcoming-maintenance", + "type": "upcoming-maintenance", + "title": "Planned maintenance", + "_note": "lifecycle-maintenance-and-supplier-roadmap: planned maintenance in the next 30 days on the applications the active organisation uses. A widget TYPE registered in main.js, like catalog-panels, because it joins the organisation's usages to the maintenance windows of their applications, which no built-in widget does." } ], "layout": [ @@ -368,11 +374,19 @@ "gridHeight": 2, "showTitle": false }, + { + "id": "6", + "widgetId": "upcoming-maintenance", + "gridX": 0, + "gridY": 2, + "gridWidth": 12, + "gridHeight": 4 + }, { "id": "5", "widgetId": "catalog-panels", "gridX": 0, - "gridY": 2, + "gridY": 6, "gridWidth": 12, "gridHeight": 8, "showTitle": false, @@ -507,7 +521,8 @@ { "id": "md-usages", "type": "object-list", "title": "Usages", "icon": "OfficeBuilding", "content": { "register": "@resolve:voorzieningen_register", "schema": "usage", "filter": { "module": "@objectId" }, "columns": [ { "key": "consumer", "label": "Organisation" }, { "key": "moduleVersion", "label": "Version" }, { "key": "status", "label": "Status" } ], "limit": 50, "rowRoute": "GebruikDetail", "viewAllRoute": "Gebruik", "viewAllQuery": { "module": "@objectId" }, "addLabel": "Add to our landscape", "formIncludeFields": [ "consumer", "moduleVersion", "status", "businessOwner", "technicalOwner" ], "emptyText": "No organisation registered a usage yet" } }, { "id": "md-connections-out", "type": "object-list", "title": "Connections from this application", "icon": "LinkVariant", "content": { "register": "@resolve:voorzieningen_register", "schema": "connection", "filter": { "moduleA": "@objectId" }, "columns": [ { "key": "moduleB", "label": "To application" }, { "key": "nonMunicipalProvision", "label": "To national provision" }, { "key": "type", "label": "Type" }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "KoppelingDetail", "viewAllRoute": "Koppelingen", "viewAllQuery": { "moduleA": "@objectId" }, "allowCreate": false, "emptyText": "No connections start at this application" } }, { "id": "md-connections-in", "type": "object-list", "title": "Connections to this application", "icon": "LinkVariant", "content": { "register": "@resolve:voorzieningen_register", "schema": "connection", "filter": { "moduleB": "@objectId" }, "columns": [ { "key": "moduleA", "label": "From application" }, { "key": "type", "label": "Type" }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "KoppelingDetail", "viewAllRoute": "Koppelingen", "viewAllQuery": { "moduleB": "@objectId" }, "allowCreate": false, "emptyText": "No connections end at this application" } }, - { "id": "md-ai-systems", "type": "object-list", "title": "AI systems", "icon": "RobotOutline", "content": { "register": "@resolve:voorzieningen_register", "schema": "aiSystem", "filter": { "module": "@objectId" }, "columns": [ { "key": "kind", "label": "Kind" }, { "key": "aiActRiskCategory", "label": "AI Act risk category" }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "AiSystemDetail", "viewAllRoute": "AiSystems", "viewAllQuery": { "module": "@objectId" }, "allowCreate": false, "emptyText": "No AI systems registered for this application" } } + { "id": "md-ai-systems", "type": "object-list", "title": "AI systems", "icon": "RobotOutline", "content": { "register": "@resolve:voorzieningen_register", "schema": "aiSystem", "filter": { "module": "@objectId" }, "columns": [ { "key": "kind", "label": "Kind" }, { "key": "aiActRiskCategory", "label": "AI Act risk category" }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "AiSystemDetail", "viewAllRoute": "AiSystems", "viewAllQuery": { "module": "@objectId" }, "allowCreate": false, "emptyText": "No AI systems registered for this application" } }, + { "id": "md-maintenance", "type": "object-list", "title": "Planned maintenance", "icon": "Calendar", "content": { "register": "@resolve:voorzieningen_register", "schema": "maintenanceWindow", "filter": { "module": "@objectId" }, "sort": { "field": "startsAt", "dir": "asc" }, "columns": [ { "key": "title", "label": "Title" }, { "key": "startsAt", "label": "Starts at" }, { "key": "endsAt", "label": "Ends at" }, { "key": "impact", "label": "Impact" }, { "key": "status", "label": "Status" } ], "limit": 25, "addLabel": "Announce maintenance", "formIncludeFields": [ "title", "description", "moduleVersion", "startsAt", "endsAt", "impact" ], "emptyText": "No maintenance planned on this application" } } ], "layout": [ { "id": "1", "widgetId": "md-data", "gridX": 0, "gridY": 0, "gridWidth": 8, "gridHeight": 8 }, @@ -518,9 +533,11 @@ { "id": "6", "widgetId": "md-compliance", "gridX": 0, "gridY": 12, "gridWidth": 12, "gridHeight": 4 }, { "id": "7", "widgetId": "md-connections-out", "gridX": 0, "gridY": 16, "gridWidth": 6, "gridHeight": 4 }, { "id": "8", "widgetId": "md-connections-in", "gridX": 6, "gridY": 16, "gridWidth": 6, "gridHeight": 4 }, - { "id": "9", "widgetId": "md-ai-systems", "gridX": 0, "gridY": 20, "gridWidth": 12, "gridHeight": 4 } + { "id": "9", "widgetId": "md-ai-systems", "gridX": 0, "gridY": 20, "gridWidth": 12, "gridHeight": 4 }, + { "id": "10", "widgetId": "md-maintenance", "gridX": 0, "gridY": 24, "gridWidth": 12, "gridHeight": 4 } ], "bodyWidgets": [ + { "id": "md-roadmap", "component": "ProductRoadmap", "props": { "objectId": "@objectId" }, "placement": "end", "colSpan": 12 }, { "id": "md-contracts", "component": "ApplicationContractsPanel", "props": { "objectId": "@objectId" }, "placement": "end", "colSpan": 12 }, { "id": "md-reviews", "component": "ReviewsPanel", "props": { "objectId": "@objectId", "subjectType": "module" }, "placement": "end", "colSpan": 12 } ], @@ -935,9 +952,14 @@ "columns": [ "version", "module", + "dateInDevelopment", "dateInUse", "status" ], + "quickFilters": [ + { "label": "All", "filter": {}, "default": true }, + { "label": "Planned releases", "filter": { "status": "in development" }, "icon": "MapMarkerPath" } + ], "sidebar": { "enabled": true, "showMetadata": true diff --git a/src/utils/maintenance.js b/src/utils/maintenance.js new file mode 100644 index 000000000..1995e6f33 --- /dev/null +++ b/src/utils/maintenance.js @@ -0,0 +1,110 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * Planned maintenance and the supplier roadmap: which maintenance windows an + * organisation should see, and a product's versions as timeline events. + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md + */ + +const PAGE = 500 +const DAY_MS = 24 * 60 * 60 * 1000 + +/** + * The id of a row or a relation value. + * + * @param {object|string|null} value A row, a relation object or an id. + * @return {string|null} The id. + */ +export function refId(value) { + if (typeof value === 'string') { + return value || null + } + return value?.id ?? value?.['@self']?.id ?? value?.uuid ?? null +} + +/** + * The planned windows on the given products that start within `days` days. + * + * @param {Array} windows Maintenance windows. + * @param {Array} moduleIds The products the organisation uses. + * @param {Date} [now] The current moment. + * @param {number} [days] How far ahead to look. + * @return {Array} The windows, earliest first. + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-002-organisations-that-use-a-product-see-its-planned-maintenance + */ +export function upcomingMaintenance( + windows, + moduleIds, + now = new Date(), + days = 30, +) { + const used = new Set(moduleIds) + const from = now.getTime() + const until = from + days * DAY_MS + return (windows ?? []) + .filter((w) => w?.status === 'planned' && used.has(refId(w.module))) + .filter((w) => { + const start = Date.parse(w.startsAt) + const end = Date.parse(w.endsAt || w.startsAt) + return !Number.isNaN(start) && end >= from && start <= until + }) + .sort((a, b) => Date.parse(a.startsAt) - Date.parse(b.startsAt)) +} + +/** + * Load the upcoming maintenance on the products an organisation uses. + * + * @param {string} organisationId The active organisation. + * @param {(schema: string, params: object) => Promise>} fetchList Reads a list of objects. + * @param {Date} [now] The current moment. + * @return {Promise>} The windows, earliest first. + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-002-organisations-that-use-a-product-see-its-planned-maintenance + */ +export async function loadUpcomingMaintenance( + organisationId, + fetchList, + now = new Date(), +) { + if (!organisationId) { + return [] + } + const usages = await fetchList('usage', { + consumer: organisationId, + _limit: PAGE, + }) + const moduleIds = [ + ...new Set((usages ?? []).map((u) => refId(u.module)).filter(Boolean)), + ] + if (moduleIds.length === 0) { + return [] + } + const windows = await fetchList('maintenanceWindow', { + module: moduleIds, + status: 'planned', + _limit: PAGE, + }) + return upcomingMaintenance(windows, moduleIds, now) +} + +/** + * A product's versions as timeline events, placed on the date they go or went + * into use, or the date development started when no go-live date is set. + * + * @param {Array} versions Module versions. + * @return {Array} Events for CnTimelineView. + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-004-a-product-page-shows-the-supplier-s-roadmap + */ +export function roadmapEvents(versions) { + return (versions ?? []) + .map((v) => ({ + id: refId(v) ?? v.version, + title: v.version || '', + start: v.dateInUse || v.dateInDevelopment || '', + description: v.shortDescription || '', + kind: v.status === 'in development' ? 'planned' : 'released', + status: v.status || '', + })) + .filter((e) => e.start && !Number.isNaN(Date.parse(e.start))) +} diff --git a/tests/Unit/EventListener/MaintenanceRecipientsListenerTest.php b/tests/Unit/EventListener/MaintenanceRecipientsListenerTest.php index 601495bdc..c4e205b43 100644 --- a/tests/Unit/EventListener/MaintenanceRecipientsListenerTest.php +++ b/tests/Unit/EventListener/MaintenanceRecipientsListenerTest.php @@ -13,7 +13,7 @@ * @version GIT: * @link https://github.com/ConductionNL/stackiq * - * @spec openspec/changes/lifecycle-maintenance-and-supplier-roadmap/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified */ declare(strict_types=1); diff --git a/tests/Unit/Settings/MaintenanceRoadmapFragmentTest.php b/tests/Unit/Settings/MaintenanceRoadmapFragmentTest.php index 70703f59e..ec6d971ce 100644 --- a/tests/Unit/Settings/MaintenanceRoadmapFragmentTest.php +++ b/tests/Unit/Settings/MaintenanceRoadmapFragmentTest.php @@ -12,7 +12,7 @@ * @version GIT: * @link https://github.com/ConductionNL/stackiq * - * @spec openspec/changes/lifecycle-maintenance-and-supplier-roadmap/specs/maintenance-and-supplier-roadmap/spec.md + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md */ declare(strict_types=1); diff --git a/tests/e2e/workflows/maintenance.spec.ts b/tests/e2e/workflows/maintenance.spec.ts new file mode 100644 index 000000000..c26227a57 --- /dev/null +++ b/tests/e2e/workflows/maintenance.spec.ts @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2026 Conduction B.V. +/** + * Maintenance and roadmap: announcing a window on a product page, the + * dashboard widget for an organisation that uses the product, the roadmap + * on the product page and releasing a planned version. + * + * Seeds an organisation, a product with a roadmap statement and a planned + * version, a usage and a maintenance window carrying this run's RUN_ID + * through the objects API (the call the Add form makes), and removes exactly + * those rows afterwards. The rules, the lifecycle and the owner resolution + * are covered by tests/Unit/Settings/MaintenanceRoadmapFragmentTest.php, + * tests/Unit/EventListener/MaintenanceRecipientsListenerTest.php and + * tests/vitest/maintenance.spec.js. + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md + */ +import type { APIRequestContext } from '@playwright/test' +import type { VoorzieningenConfig } from './_fixtures.ts' + +import { expect, test } from '@playwright/test' +import { + createObject, + deleteObject, + newApiContext, + resolveConfig, + RUN_ID, +} from './_fixtures.ts' +import { dismissSupportDialog, gotoAppRoute } from './_ui.ts' + +let apiCtx: APIRequestContext +let cfg: VoorzieningenConfig +const seeded: Array<[string, string]> = [] +const ids: Record = {} +const product = `${RUN_ID} product X` +const seededWindow = `${RUN_ID} database upgrade` +const announced = `${RUN_ID} storage move` +const statement = `${RUN_ID} we move to a cloud edition next year` + +/** + * Create a row and remember it for cleanup. + * + * @param schema The schema slug. + * @param data The object. + * @return The new id. + */ +async function seed(schema: string, data: Record): Promise { + const id = await createObject(apiCtx, cfg.register, schema, data) + seeded.push([schema, id]) + return id +} + +/** + * An ISO date-time a number of days from now. + * + * @param days Days ahead. + * @param hour The hour of the day. + * @return The date-time. + */ +function daysAhead(days: number, hour: number): string { + const date = new Date() + date.setDate(date.getDate() + days) + date.setHours(hour, 0, 0, 0) + return date.toISOString() +} + +test.beforeAll(async () => { + apiCtx = await newApiContext() + cfg = await resolveConfig(apiCtx) + ids.org = await seed('organization', { name: `${RUN_ID} municipality` }) + ids.x = await seed('module', { name: product, roadmapStatement: statement }) + ids.v3 = await seed('moduleVersion', { + module: ids.x, + version: '3.0', + status: 'in development', + dateInUse: daysAhead(150, 12), + }) + ids.usage = await seed('usage', { + consumer: ids.org, + module: ids.x, + status: 'In production', + }) + ids.window = await seed('maintenanceWindow', { + module: ids.x, + title: seededWindow, + startsAt: daysAhead(5, 8), + endsAt: daysAhead(5, 12), + impact: 'unavailable', + status: 'planned', + }) +}) + +test.afterAll(async () => { + if (!apiCtx) return + for (const [schema, id] of seeded.reverse()) { + await deleteObject(apiCtx, cfg.register, schema, id) + } + await apiCtx.dispose() +}) + +// @e2e maintenance-and-supplier-roadmap::a-supplier-announces-a-maintenance-window +test('announcing maintenance lists the window as planned on the product page', async ({ + page, +}) => { + await gotoAppRoute(page, `/modules/${ids.x}`) + await dismissSupportDialog(page) + await page.getByRole('button', { name: 'Announce maintenance' }).first().click() + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible({ timeout: 30000 }) + await dialog.getByLabel('Title').fill(announced) + await dialog.getByLabel('Starts at').fill(daysAhead(9, 8).slice(0, 16)) + await dialog.getByLabel('Ends at').fill(daysAhead(9, 12).slice(0, 16)) + await dialog + .getByRole('button', { name: /save|create/i }) + .last() + .click() + await expect(dialog).toBeHidden({ timeout: 30000 }) + const row = page.getByRole('row').filter({ hasText: announced }) + await expect(row).toContainText(/planned/i, { timeout: 30000 }) + const found = await apiCtx.get( + `/index.php/apps/openregister/api/objects/${cfg.register}/maintenanceWindow?title=${encodeURIComponent(announced)}`, + ) + for (const result of (await found.json()).results ?? []) { + seeded.push(['maintenanceWindow', result.id ?? result['@self']?.id]) + } +}) + +// @e2e maintenance-and-supplier-roadmap::a-municipality-sees-the-window-on-its-dashboard +test('the dashboard lists the window with its impact', async ({ page }) => { + await gotoAppRoute(page, '/') + await dismissSupportDialog(page) + const item = page + .getByTestId('upcoming-maintenance-item') + .filter({ hasText: seededWindow }) + await expect(item).toBeVisible({ timeout: 30000 }) + await expect(item).toContainText('Unavailable') +}) + +// @e2e maintenance-and-supplier-roadmap::a-buyer-reads-what-ships-next +test('the product page shows the roadmap statement and the planned version', async ({ + page, +}) => { + await gotoAppRoute(page, `/modules/${ids.x}`) + await dismissSupportDialog(page) + await expect(page.getByTestId('product-roadmap-statement')).toContainText( + statement, + { timeout: 30000 }, + ) + await expect(page.getByTestId('product-roadmap')).toContainText('3.0') +}) + +// @e2e maintenance-and-supplier-roadmap::a-supplier-releases-the-planned-version +test('Release moves the planned version into use', async ({ page }) => { + await gotoAppRoute(page, `/moduleversies/${ids.v3}`) + await dismissSupportDialog(page) + await page + .getByRole('button', { name: /release/i }) + .first() + .click() + await expect(page.getByText(/in use/i).first()).toBeVisible({ timeout: 30000 }) +}) diff --git a/tests/vitest/maintenance.spec.js b/tests/vitest/maintenance.spec.js index 5854189ec..153b902a8 100644 --- a/tests/vitest/maintenance.spec.js +++ b/tests/vitest/maintenance.spec.js @@ -7,7 +7,7 @@ * merged the way src/main.js merges it), and the seeded maintenance windows * validated against the real maintenanceWindow schema in the register. * - * @spec openspec/changes/lifecycle-maintenance-and-supplier-roadmap/specs/maintenance-and-supplier-roadmap/spec.md + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md */ import addFormats from 'ajv-formats' @@ -38,18 +38,56 @@ const merged = buildManifest( menuLayout, ) const page = (id) => merged.pages.find((p) => p.id === id) -const widget = (pageId, widgetId) => - page(pageId).config.widgets.find((w) => w.id === widgetId) +function widget(pageId, widgetId) { + return page(pageId).config.widgets.find((w) => w.id === widgetId) +} const schema = register.components.schemas.maintenanceWindow const now = new Date('2026-10-01T09:00:00Z') const windows = [ - { id: 'w1', module: 'x', status: 'planned', startsAt: '2026-10-03T06:00:00Z', endsAt: '2026-10-03T10:00:00Z', impact: 'unavailable' }, - { id: 'w2', module: { id: 'x' }, status: 'planned', startsAt: '2026-10-02T06:00:00Z', endsAt: '2026-10-02T07:00:00Z' }, - { id: 'w3', module: 'y', status: 'planned', startsAt: '2026-10-03T06:00:00Z', endsAt: '2026-10-03T07:00:00Z' }, - { id: 'w4', module: 'x', status: 'cancelled', startsAt: '2026-10-03T06:00:00Z', endsAt: '2026-10-03T07:00:00Z' }, - { id: 'w5', module: 'x', status: 'planned', startsAt: '2026-12-01T06:00:00Z', endsAt: '2026-12-01T07:00:00Z' }, - { id: 'w6', module: 'x', status: 'planned', startsAt: '2026-09-01T06:00:00Z', endsAt: '2026-09-01T07:00:00Z' }, + { + id: 'w1', + module: 'x', + status: 'planned', + startsAt: '2026-10-03T06:00:00Z', + endsAt: '2026-10-03T10:00:00Z', + impact: 'unavailable', + }, + { + id: 'w2', + module: { id: 'x' }, + status: 'planned', + startsAt: '2026-10-02T06:00:00Z', + endsAt: '2026-10-02T07:00:00Z', + }, + { + id: 'w3', + module: 'y', + status: 'planned', + startsAt: '2026-10-03T06:00:00Z', + endsAt: '2026-10-03T07:00:00Z', + }, + { + id: 'w4', + module: 'x', + status: 'cancelled', + startsAt: '2026-10-03T06:00:00Z', + endsAt: '2026-10-03T07:00:00Z', + }, + { + id: 'w5', + module: 'x', + status: 'planned', + startsAt: '2026-12-01T06:00:00Z', + endsAt: '2026-12-01T07:00:00Z', + }, + { + id: 'w6', + module: 'x', + status: 'planned', + startsAt: '2026-09-01T06:00:00Z', + endsAt: '2026-09-01T07:00:00Z', + }, ] /** @@ -74,15 +112,24 @@ function compileWindow() { describe('upcoming maintenance', () => { it('lists the planned windows of the next 30 days on the products in use, earliest first', () => { - expect(upcomingMaintenance(windows, ['x'], now).map((w) => w.id)).toEqual(['w2', 'w1']) + expect(upcomingMaintenance(windows, ['x'], now).map((w) => w.id)).toEqual([ + 'w2', + 'w1', + ]) }) it('reads the usages of the organisation and then the windows on their products', async () => { const fetchList = vi.fn(async (type) => - type === 'usage' ? [{ module: 'x' }, { module: { id: 'x' } }, { module: null }] : windows, + type === 'usage' + ? [{ module: 'x' }, { module: { id: 'x' } }, { module: null }] + : windows, ) const result = await loadUpcomingMaintenance('org-1', fetchList, now) - expect(fetchList).toHaveBeenNthCalledWith(1, 'usage', expect.objectContaining({ consumer: 'org-1' })) + expect(fetchList).toHaveBeenNthCalledWith( + 1, + 'usage', + expect.objectContaining({ consumer: 'org-1' }), + ) expect(fetchList).toHaveBeenNthCalledWith( 2, 'maintenanceWindow', @@ -102,9 +149,19 @@ describe('upcoming maintenance', () => { describe('the roadmap', () => { it('places each version on its go-live date, planned ones marked planned, undated ones left out', () => { const events = roadmapEvents([ - { id: 'v3', version: '3.0', status: 'in development', dateInUse: '2027-03-01' }, + { + id: 'v3', + version: '3.0', + status: 'in development', + dateInUse: '2027-03-01', + }, { id: 'v2', version: '2.1', status: 'in use', dateInUse: '2026-05-01' }, - { id: 'v4', version: '4.0', status: 'in development', dateInDevelopment: '2026-09-01' }, + { + id: 'v4', + version: '4.0', + status: 'in development', + dateInDevelopment: '2026-09-01', + }, { id: 'v1', version: '1.0', status: 'withdrawn' }, ]) expect(events.map((e) => [e.title, e.start, e.kind])).toEqual([ @@ -137,13 +194,23 @@ describe('the pages', () => { for (const field of list.content.formIncludeFields) { expect(schema.properties, field).toHaveProperty(field) } - expect(page('ModuleDetail').config.layout.map((l) => l.widgetId)).toContain('md-maintenance') + expect(page('ModuleDetail').config.layout.map((l) => l.widgetId)).toContain( + 'md-maintenance', + ) }) it('shows the roadmap on the application page', () => { - const body = page('ModuleDetail').config.bodyWidgets.find((w) => w.id === 'md-roadmap') - expect(body).toMatchObject({ component: 'ProductRoadmap', props: { objectId: '@objectId' } }) - const registry = fs.readFileSync(path.resolve(__dirname, '../../src/customComponents.js'), 'utf8') + const body = page('ModuleDetail').config.bodyWidgets.find( + (w) => w.id === 'md-roadmap', + ) + expect(body).toMatchObject({ + component: 'ProductRoadmap', + props: { objectId: '@objectId' }, + }) + const registry = fs.readFileSync( + path.resolve(__dirname, '../../src/customComponents.js'), + 'utf8', + ) expect(registry).toMatch(/\bProductRoadmap,/) }) @@ -151,15 +218,26 @@ describe('the pages', () => { const dashboard = page('Dashboard').config const w = dashboard.widgets.find((x) => x.id === 'upcoming-maintenance') expect(w.type).toBe('upcoming-maintenance') - expect(dashboard.layout.map((l) => l.widgetId)).toContain('upcoming-maintenance') - const main = fs.readFileSync(path.resolve(__dirname, '../../src/main.js'), 'utf8') - expect(main).toMatch(/registerDashboardWidget\('upcoming-maintenance',\s*\{\s*renderer: UpcomingMaintenanceWidget/) + expect(dashboard.layout.map((l) => l.widgetId)).toContain( + 'upcoming-maintenance', + ) + const main = fs.readFileSync( + path.resolve(__dirname, '../../src/main.js'), + 'utf8', + ) + expect(main).toMatch( + /registerDashboardWidget\('upcoming-maintenance',\s*\{\s*renderer: UpcomingMaintenanceWidget/, + ) }) it('filters the module versions on planned releases, a real status value', () => { const index = page('Moduleversies').config - const planned = index.quickFilters.find((q) => q.label === 'Planned releases') - expect(register.components.schemas.moduleVersion.properties.status.enum).toContain(planned.filter.status) + const planned = index.quickFilters.find( + (q) => q.label === 'Planned releases', + ) + expect( + register.components.schemas.moduleVersion.properties.status.enum, + ).toContain(planned.filter.status) expect(index.columns).toContain('dateInDevelopment') }) }) @@ -177,19 +255,29 @@ describe('the maintenanceWindow schema', () => { it('notifies the owners it resolved, on a field the schema declares', () => { for (const rule of Object.values(schema['x-openregister-notifications'])) { - expect(rule.recipients).toEqual([{ kind: 'relation', relation: 'notifyUserIds' }]) + expect(rule.recipients).toEqual([ + { kind: 'relation', relation: 'notifyUserIds' }, + ]) } expect(schema.properties.notifyUserIds.type).toBe('array') - const announced = schema['x-openregister-notifications']['maintenance-announced'].trigger + const announced = + schema['x-openregister-notifications']['maintenance-announced'].trigger expect(schema.properties).toHaveProperty(announced.condition.field) }) it('has seeded windows in both registers that the schema accepts', () => { const validate = compileWindow() - const seeds = mock.components.objects.filter((o) => o['@self']?.schema === 'maintenanceWindow') - expect(new Set(seeds.map((s) => s['@self'].register))).toEqual(new Set(['stackiq', 'vng-gemma'])) + const seeds = mock.components.objects.filter( + (o) => o['@self']?.schema === 'maintenanceWindow', + ) + expect(new Set(seeds.map((s) => s['@self'].register))).toEqual( + new Set(['stackiq', 'vng-gemma']), + ) for (const seed of seeds) { - expect(validate(seed), `${seed['@self'].slug}: ${JSON.stringify(validate.errors)}`).toBe(true) + expect( + validate(seed), + `${seed['@self'].slug}: ${JSON.stringify(validate.errors)}`, + ).toBe(true) } }) }) From 0c8bf5ead42b1830d43e4f65d10b965f806dd4f1 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 29 Sep 2026 23:16:49 +0200 Subject: [PATCH 3/3] fix(maintenance): a third demo window per register, a spec tag on refId, and the usages test lint error #1194 left --- lib/Settings/stackiq_mock_register.json | 28 +++++++++++++++++++++++++ src/utils/maintenance.js | 1 + tests/vitest/usages.spec.js | 5 +++-- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/lib/Settings/stackiq_mock_register.json b/lib/Settings/stackiq_mock_register.json index 6d52905a4..07cbadb7e 100644 --- a/lib/Settings/stackiq_mock_register.json +++ b/lib/Settings/stackiq_mock_register.json @@ -10962,6 +10962,20 @@ "impact": "degraded", "status": "planned" }, + { + "@self": { + "register": "stackiq", + "schema": "maintenanceWindow", + "slug": "maintenance-storage-move" + }, + "module": {}, + "title": "Storage move", + "description": "Documents move to new storage. Nothing changes for users.", + "startsAt": "2026-10-24T22:00:00+02:00", + "endsAt": "2026-10-25T02:00:00+02:00", + "impact": "no impact", + "status": "completed" + }, { "@self": { "register": "vng-gemma", @@ -10989,6 +11003,20 @@ "endsAt": "2026-10-17T21:00:00+02:00", "impact": "degraded", "status": "planned" + }, + { + "@self": { + "register": "vng-gemma", + "schema": "maintenanceWindow", + "slug": "maintenance-storage-move" + }, + "module": {}, + "title": "Storage move", + "description": "Documents move to new storage. Nothing changes for users.", + "startsAt": "2026-10-24T22:00:00+02:00", + "endsAt": "2026-10-25T02:00:00+02:00", + "impact": "no impact", + "status": "completed" } ] } diff --git a/src/utils/maintenance.js b/src/utils/maintenance.js index 1995e6f33..107e96609 100644 --- a/src/utils/maintenance.js +++ b/src/utils/maintenance.js @@ -16,6 +16,7 @@ const DAY_MS = 24 * 60 * 60 * 1000 * * @param {object|string|null} value A row, a relation object or an id. * @return {string|null} The id. + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-002-organisations-that-use-a-product-see-its-planned-maintenance */ export function refId(value) { if (typeof value === 'string') { diff --git a/tests/vitest/usages.spec.js b/tests/vitest/usages.spec.js index 1e523f4e1..3bbc33a9f 100644 --- a/tests/vitest/usages.spec.js +++ b/tests/vitest/usages.spec.js @@ -33,8 +33,9 @@ const merged = buildManifest( menuLayout, ) const page = (id) => merged.pages.find((p) => p.id === id) -const widget = (pageId, widgetId) => - page(pageId).config.widgets.find((w) => w.id === widgetId) +function widget(pageId, widgetId) { + return page(pageId).config.widgets.find((w) => w.id === widgetId) +} const usage = register.components.schemas.usage