diff --git a/appinfo/routes.php b/appinfo/routes.php index 759e83193..a29faeac6 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -516,5 +516,40 @@ // GraphQL Subscriptions (SSE). ['name' => 'graphQLSubscription#subscribe', 'url' => '/api/graphql/subscribe', 'verb' => 'GET'], + + // Retention management: archival settings. + ['name' => 'Settings\ConfigurationSettings#getArchivalSettings', 'url' => '/api/settings/archival', 'verb' => 'GET'], + ['name' => 'Settings\ConfigurationSettings#updateArchivalSettings', 'url' => '/api/settings/archival', 'verb' => 'PUT'], + ['name' => 'Settings\ConfigurationSettings#updateArchivalSettings', 'url' => '/api/settings/archival', 'verb' => 'PATCH'], + + // Retention management: destruction list approval workflow. + ['name' => 'retention#approveDestructionList', 'url' => '/api/retention/destruction-lists/{id}/approve', 'verb' => 'POST', 'requirements' => ['id' => '[^/]+']], + ['name' => 'retention#rejectDestructionList', 'url' => '/api/retention/destruction-lists/{id}/reject', 'verb' => 'POST', 'requirements' => ['id' => '[^/]+']], + + // Retention management: legal holds. + ['name' => 'retention#placeLegalHold', 'url' => '/api/retention/legal-holds', 'verb' => 'POST'], + ['name' => 'retention#releaseLegalHold', 'url' => '/api/retention/legal-holds/{id}', 'verb' => 'DELETE', 'requirements' => ['id' => '[^/]+']], + ['name' => 'retention#placeBulkLegalHold', 'url' => '/api/retention/legal-holds/bulk', 'verb' => 'POST'], + + // Archival destruction workflow endpoints (spec-compliant /api/archival/ prefix). + ['name' => 'archival#listDestructionLists', 'url' => '/api/archival/destruction-lists', 'verb' => 'GET'], + ['name' => 'archival#getDestructionList', 'url' => '/api/archival/destruction-lists/{id}', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']], + ['name' => 'archival#approveDestructionList', 'url' => '/api/archival/destruction-lists/{id}/approve', 'verb' => 'POST', 'requirements' => ['id' => '[^/]+']], + ['name' => 'archival#rejectDestructionList', 'url' => '/api/archival/destruction-lists/{id}/reject', 'verb' => 'POST', 'requirements' => ['id' => '[^/]+']], + ['name' => 'archival#createLegalHold', 'url' => '/api/archival/legal-holds', 'verb' => 'POST'], + ['name' => 'archival#releaseLegalHold', 'url' => '/api/archival/legal-holds/{id}', 'verb' => 'DELETE', 'requirements' => ['id' => '[^/]+']], + ['name' => 'archival#listLegalHolds', 'url' => '/api/archival/legal-holds', 'verb' => 'GET'], + ['name' => 'archival#listCertificates', 'url' => '/api/archival/certificates', 'verb' => 'GET'], + + // e-Depot transfer settings. + ['name' => 'Settings\EdepotSettings#getEdepotSettings', 'url' => '/api/settings/edepot', 'verb' => 'GET'], + ['name' => 'Settings\EdepotSettings#updateEdepotSettings', 'url' => '/api/settings/edepot', 'verb' => 'PUT'], + ['name' => 'Settings\EdepotSettings#updateEdepotSettings', 'url' => '/api/settings/edepot', 'verb' => 'PATCH'], + ['name' => 'Settings\EdepotSettings#testEdepotConnection', 'url' => '/api/settings/edepot/test', 'verb' => 'POST'], + + // e-Depot transfer management. + ['name' => 'transfer#index', 'url' => '/api/transfers', 'verb' => 'GET'], + ['name' => 'transfer#show', 'url' => '/api/transfers/{id}', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']], + ['name' => 'transfer#create', 'url' => '/api/transfers', 'verb' => 'POST'], ], ]; diff --git a/lib/BackgroundJob/BulkLegalHoldJob.php b/lib/BackgroundJob/BulkLegalHoldJob.php new file mode 100644 index 000000000..a20b55cf0 --- /dev/null +++ b/lib/BackgroundJob/BulkLegalHoldJob.php @@ -0,0 +1,98 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://www.OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\BackgroundJob; + +use DateTime; +use Exception; +use OCA\OpenRegister\Service\RetentionService; +use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Db\AuditTrailMapper; +use OCP\BackgroundJob\QueuedJob; +use OCP\AppFramework\Utility\ITimeFactory; +use Psr\Log\LoggerInterface; + +/** + * Queued job for placing legal holds on all objects in a schema. + */ +class BulkLegalHoldJob extends QueuedJob +{ + /** + * Constructor. + * + * @param ITimeFactory $time Time factory + */ + public function __construct(ITimeFactory $time) + { + parent::__construct(time: $time); + }//end __construct() + + /** + * Execute the bulk legal hold operation. + * + * @param mixed $argument Job arguments with schemaId, reason, userId + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function run($argument): void + { + $logger = \OC::$server->get(LoggerInterface::class); + + $schemaId = $argument['schemaId'] ?? null; + $reason = $argument['reason'] ?? ''; + $userId = $argument['userId'] ?? 'system'; + + if ($schemaId === null) { + $logger->error('[BulkLegalHoldJob] No schemaId provided'); + return; + } + + $logger->info('[BulkLegalHoldJob] Placing legal holds on schema: '.$schemaId); + + try { + $retentionService = \OC::$server->get(RetentionService::class); + $objectMapper = \OC::$server->get(MagicMapper::class); + + $objects = $objectMapper->findAll( + filters: [], + schema: $schemaId, + _rbac: false, + _multitenancy: false + ); + + $count = 0; + + foreach ($objects as $object) { + $retentionService->placeLegalHold($object, $reason); + $objectMapper->update($object); + $count++; + } + + if ($count > 0) { + $logger->info('[BulkLegalHoldJob] Placed legal holds on '.$count.' objects'); + } + } catch (Exception $e) { + $logger->error('[BulkLegalHoldJob] Error: '.$e->getMessage(), ['exception' => $e]); + }//end try + }//end run() +}//end class diff --git a/lib/BackgroundJob/DestructionCheckJob.php b/lib/BackgroundJob/DestructionCheckJob.php new file mode 100644 index 000000000..04753a7b4 --- /dev/null +++ b/lib/BackgroundJob/DestructionCheckJob.php @@ -0,0 +1,333 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://www.OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\BackgroundJob; + +use DateTime; +use Exception; +use OCA\OpenRegister\Service\RetentionService; +use OCA\OpenRegister\Service\Settings\ObjectRetentionHandler; +use OCA\OpenRegister\Db\MagicMapper; +use OCP\BackgroundJob\TimedJob; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IGroupManager; +use OCP\Notification\IManager as INotificationManager; +use Psr\Log\LoggerInterface; + +/** + * Periodic destruction check job. + * + * Scans for objects eligible for destruction and generates destruction lists. + * Sends pre-destruction notifications for objects approaching their deadline. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class DestructionCheckJob extends TimedJob +{ + + /** + * Default interval: 24 hours (daily). + */ + private const DEFAULT_INTERVAL = 86400; + + /** + * App config key for tracking notified objects. + */ + private const NOTIFIED_KEY = 'retention_notified_objects'; + + /** + * Constructor. + * + * @param ITimeFactory $time Time factory for parent class + */ + public function __construct(ITimeFactory $time) + { + parent::__construct(time: $time); + + try { + $handler = \OC::$server->get(ObjectRetentionHandler::class); + $settings = $handler->getArchivalSettingsOnly(); + $interval = (int) ($settings['destructionCheckInterval'] ?? self::DEFAULT_INTERVAL); + } catch (Exception $e) { + $interval = self::DEFAULT_INTERVAL; + } + + $this->setInterval(seconds: $interval); + }//end __construct() + + /** + * Execute the destruction check job. + * + * @param mixed $argument Job arguments (unused for timed jobs) + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + protected function run($argument): void + { + $logger = \OC::$server->get(LoggerInterface::class); + $logger->info('[DestructionCheckJob] Starting destruction check'); + + try { + $retentionService = \OC::$server->get(RetentionService::class); + $settingsHandler = \OC::$server->get(ObjectRetentionHandler::class); + $settings = $settingsHandler->getArchivalSettingsOnly(); + + if (empty($settings['destructionListRegister']) === true + || empty($settings['destructionListSchema']) === true + ) { + $logger->info('[DestructionCheckJob] Destruction list register/schema not configured, skipping'); + return; + } + + // Step 1: Send pre-destruction notifications. + $this->sendPreDestructionNotifications(retentionService: $retentionService, settings: $settings, logger: $logger); + + // Step 2: Find eligible objects and create destruction list. + $excludeUuids = $retentionService->getObjectsOnPendingDestructionLists(); + $eligible = $retentionService->findEligibleForDestruction($excludeUuids); + + if (empty($eligible) === true) { + $logger->info('[DestructionCheckJob] No objects eligible for destruction'); + return; + } + + $logger->info('[DestructionCheckJob] Found '.count($eligible).' objects eligible'); + + // Step 3: Create destruction list as register object. + $listData = $retentionService->createDestructionList($eligible); + + if ($listData === null) { + $logger->warning('[DestructionCheckJob] Failed to create destruction list'); + return; + } + + $saveObject = \OC::$server->get(\OCA\OpenRegister\Service\Object\SaveObject::class); + $savedList = $saveObject->saveObject( + $settings['destructionListRegister'], + $settings['destructionListSchema'], + $listData, + null, + null, + false, + false, + true, + true + ); + + $logger->info( + '[DestructionCheckJob] Created destruction list, UUID: '.$savedList->getUuid() + ); + + // Step 4: Notify archivaris group. + $this->sendReviewNotification(listUuid: $savedList->getUuid(), objectCount: count($eligible), logger: $logger); + } catch (Exception $e) { + $logger->error('[DestructionCheckJob] Error: '.$e->getMessage(), ['exception' => $e]); + }//end try + }//end run() + + /** + * Send pre-destruction notifications for approaching objects. + * + * @param RetentionService $retentionService The retention service + * @param array $settings Archival settings + * @param LoggerInterface $logger Logger + * + * @return void + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + private function sendPreDestructionNotifications( + RetentionService $retentionService, + array $settings, + LoggerInterface $logger + ): void { + $leadDays = (int) ($settings['notificationLeadDays'] ?? 30); + $threshold = (new DateTime())->modify("+{$leadDays} days")->format('Y-m-d'); + $today = (new DateTime())->format('Y-m-d'); + + try { + $objectMapper = \OC::$server->get(MagicMapper::class); + $connection = \OC::$server->getDatabaseConnection(); + $qb = $connection->getQueryBuilder(); + + $qb->select('id')->from('openregister_objects') + ->where($qb->expr()->isNotNull('retention')); + + $result = $qb->executeQuery(); + $rows = $result->fetchAllAssociative(); + $result->free(); + + $appConfig = \OC::$server->get(\OCP\IAppConfig::class); + $notifiedJson = $appConfig->getValueString('openregister', self::NOTIFIED_KEY, '[]'); + $notified = json_decode($notifiedJson, true) ?? []; + $newCount = 0; + + foreach ($rows as $row) { + try { + $object = $objectMapper->find(intval($row['id']), null, null, false, false, false); + } catch (Exception $e) { + continue; + } + + $retention = $object->getRetention() ?? []; + $status = $retention['archiefstatus'] ?? null; + $actieDate = $retention['archiefactiedatum'] ?? null; + $nominatie = $retention['archiefnominatie'] ?? null; + + if ($actieDate === null || $status !== 'nog_te_archiveren') { + continue; + } + + if ($actieDate <= $today || $actieDate > $threshold) { + continue; + } + + $uuid = $object->getUuid(); + if (in_array($uuid, $notified, true) === true) { + continue; + } + + if (($retention['legalHold']['active'] ?? false) === true) { + continue; + } + + $subject = $nominatie === 'bewaren' ? 'Object requires e-Depot transfer' : 'Object approaching destruction date'; + + $this->sendObjectNotification( + uuid: $uuid, + subject: $subject, + title: $object->getTitle() ?? $uuid, + actieDate: $actieDate, + classificatie: $retention['classificatie'] ?? null, + logger: $logger + ); + + $notified[] = $uuid; + $newCount++; + }//end foreach + + if ($newCount > 0) { + $appConfig->setValueString('openregister', self::NOTIFIED_KEY, json_encode($notified)); + $logger->info('[DestructionCheckJob] Sent '.$newCount.' pre-destruction notifications'); + } + } catch (Exception $e) { + $logger->warning('[DestructionCheckJob] Notification error: '.$e->getMessage()); + }//end try + }//end sendPreDestructionNotifications() + + /** + * Send a notification about a specific object. + * + * @param string $uuid Object UUID + * @param string $subject Notification subject + * @param string $title Object title + * @param string $actieDate Archiefactiedatum + * @param string|null $classificatie Selectielijst category + * @param LoggerInterface $logger Logger + * + * @return void + */ + private function sendObjectNotification( + string $uuid, + string $subject, + string $title, + string $actieDate, + ?string $classificatie, + LoggerInterface $logger + ): void { + try { + $notificationManager = \OC::$server->get(INotificationManager::class); + $groupManager = \OC::$server->get(IGroupManager::class); + + $group = $groupManager->get('archivaris'); + if ($group === null) { + return; + } + + foreach ($group->getUsers() as $user) { + $notification = $notificationManager->createNotification(); + $notification->setApp('openregister') + ->setUser($user->getUID()) + ->setDateTime(new DateTime()) + ->setObject('retention', $uuid) + ->setSubject( + 'retention_approaching', + [ + 'subject' => $subject, + 'title' => $title, + 'actieDate' => $actieDate, + 'classificatie' => $classificatie ?? '', + ] + ); + $notificationManager->notify($notification); + } + } catch (Exception $e) { + $logger->warning('[DestructionCheckJob] Notification send error: '.$e->getMessage()); + }//end try + }//end sendObjectNotification() + + /** + * Send review notification for new destruction list. + * + * @param string $listUuid Destruction list UUID + * @param int $objectCount Number of objects + * @param LoggerInterface $logger Logger + * + * @return void + */ + private function sendReviewNotification( + string $listUuid, + int $objectCount, + LoggerInterface $logger + ): void { + try { + $notificationManager = \OC::$server->get(INotificationManager::class); + $groupManager = \OC::$server->get(IGroupManager::class); + + $group = $groupManager->get('archivaris'); + if ($group === null) { + return; + } + + foreach ($group->getUsers() as $user) { + $notification = $notificationManager->createNotification(); + $notification->setApp('openregister') + ->setUser($user->getUID()) + ->setDateTime(new DateTime()) + ->setObject('destruction_list', $listUuid) + ->setSubject( + 'destruction_list_review', + [ + 'listUuid' => $listUuid, + 'objectCount' => $objectCount, + ] + ); + $notificationManager->notify($notification); + } + } catch (Exception $e) { + $logger->warning('[DestructionCheckJob] Review notification error: '.$e->getMessage()); + }//end try + }//end sendReviewNotification() +}//end class diff --git a/lib/BackgroundJob/DestructionExecutionJob.php b/lib/BackgroundJob/DestructionExecutionJob.php new file mode 100644 index 000000000..f6cd5841d --- /dev/null +++ b/lib/BackgroundJob/DestructionExecutionJob.php @@ -0,0 +1,275 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://www.OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\BackgroundJob; + +use DateTime; +use Exception; +use OCA\OpenRegister\Service\RetentionService; +use OCA\OpenRegister\Service\Settings\ObjectRetentionHandler; +use OCA\OpenRegister\Service\Object\DeleteObject; +use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Db\AuditTrailMapper; +use OCP\BackgroundJob\QueuedJob; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IGroupManager; +use OCP\Notification\IManager as INotificationManager; +use Psr\Log\LoggerInterface; + +/** + * Queued job for executing approved destruction lists. + * + * Processes destruction in batches, respects legal holds at execution time, + * and generates destruction certificates. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class DestructionExecutionJob extends QueuedJob +{ + + /** + * Default batch size for destruction processing. + */ + private const DEFAULT_BATCH_SIZE = 50; + + /** + * Constructor. + * + * @param ITimeFactory $time Time factory for parent class + */ + public function __construct(ITimeFactory $time) + { + parent::__construct(time: $time); + }//end __construct() + + /** + * Execute the destruction of an approved destruction list. + * + * @param mixed $argument Job arguments containing 'destructionListUuid' + * + * @return void + * + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + */ + protected function run($argument): void + { + $logger = \OC::$server->get(LoggerInterface::class); + + $listUuid = $argument['destructionListUuid'] ?? null; + if ($listUuid === null) { + $logger->error('[DestructionExecutionJob] No destructionListUuid provided'); + return; + } + + $logger->info('[DestructionExecutionJob] Processing destruction list: '.$listUuid); + + try { + $retentionService = \OC::$server->get(RetentionService::class); + $settingsHandler = \OC::$server->get(ObjectRetentionHandler::class); + $objectMapper = \OC::$server->get(MagicMapper::class); + $auditMapper = \OC::$server->get(AuditTrailMapper::class); + $deleteObject = \OC::$server->get(DeleteObject::class); + $saveObject = \OC::$server->get(\OCA\OpenRegister\Service\Object\SaveObject::class); + $settings = $settingsHandler->getArchivalSettingsOnly(); + $batchSize = (int) ($settings['destructionBatchSize'] ?? self::DEFAULT_BATCH_SIZE); + + // Load the destruction list object. + $listObject = $objectMapper->find($listUuid, null, null, false, false, false); + + if ($listObject === null) { + $logger->error('[DestructionExecutionJob] Destruction list not found: '.$listUuid); + return; + } + + $listData = $listObject->getObject(); + + if (($listData['status'] ?? '') !== 'approved') { + $logger->warning('[DestructionExecutionJob] List not approved: '.$listUuid); + return; + } + + $objects = $listData['objects'] ?? []; + $destroyedCount = 0; + $skippedHolds = 0; + $skippedErrors = 0; + $batches = array_chunk($objects, $batchSize); + + foreach ($batches as $batchIndex => $batch) { + $logger->info( + '[DestructionExecutionJob] Batch '.($batchIndex + 1).'/'.count($batches) + ); + + foreach ($batch as $objRef) { + $uuid = $objRef['uuid'] ?? null; + if ($uuid === null) { + $skippedErrors++; + continue; + } + + try { + $object = $objectMapper->find($uuid, null, null, false, false, false); + + if ($object === null) { + $skippedErrors++; + continue; + } + + // Re-check legal hold at execution time. + if ($retentionService->hasActiveLegalHold($object) === true) { + $skippedHolds++; + continue; + } + + // Update archiefstatus before deletion. + $retention = $object->getRetention() ?? []; + $retention['archiefstatus'] = 'vernietigd'; + $object->setRetention($retention); + + // Create audit trail entry. + $auditMapper->createAuditTrailEntry( + $object, + 'archival.destroyed', + [ + 'destructionListUuid' => $listUuid, + 'classificatie' => $objRef['classificatie'] ?? null, + 'approvedBy' => array_column( + $listData['approvals'] ?? [], + 'userId' + ), + ] + ); + + // Permanently delete using register, schema, uuid. + $deleteObject->deleteObject( + $object->getRegister(), + $object->getSchema(), + $uuid, + null, + false, + false + ); + $destroyedCount++; + } catch (Exception $e) { + $skippedErrors++; + $logger->warning( + '[DestructionExecutionJob] Failed: '.$uuid.': '.$e->getMessage() + ); + }//end try + }//end foreach + }//end foreach + + // Update destruction list status. + $listData['status'] = 'executed'; + $listData['executedAt'] = (new DateTime())->format('c'); + $listData['destroyedCount'] = $destroyedCount; + $listData['skippedHolds'] = $skippedHolds; + $listData['skippedErrors'] = $skippedErrors; + + // Generate destruction certificate. + $certificate = $retentionService->generateDestructionCertificate( + $listData, + $destroyedCount, + $listData['executedAt'] + ); + + if (empty($settings['archivalRegister']) === false + && empty($settings['destructionListSchema']) === false + ) { + try { + $savedCert = $saveObject->saveObject( + $settings['archivalRegister'], + $settings['destructionListSchema'], + $certificate, + null, + null, + false, + false, + true, + true + ); + $listData['certificateUuid'] = $savedCert->getUuid(); + } catch (Exception $e) { + $logger->warning('[DestructionExecutionJob] Certificate save error: '.$e->getMessage()); + } + } + + $listObject->setObject($listData); + $objectMapper->update($listObject); + + if ($skippedHolds > 0) { + $this->notifySkippedHolds(listUuid: $listUuid, skippedCount: $skippedHolds, logger: $logger); + } + + $logger->info( + '[DestructionExecutionJob] Done: '.$destroyedCount.' destroyed, '.$skippedHolds.' held, '.$skippedErrors.' errors' + ); + } catch (Exception $e) { + $logger->error('[DestructionExecutionJob] Fatal: '.$e->getMessage(), ['exception' => $e]); + }//end try + }//end run() + + /** + * Notify archivaris group about objects skipped due to legal holds. + * + * @param string $listUuid Destruction list UUID + * @param int $skippedCount Number of skipped objects + * @param LoggerInterface $logger Logger + * + * @return void + */ + private function notifySkippedHolds( + string $listUuid, + int $skippedCount, + LoggerInterface $logger + ): void { + try { + $notificationManager = \OC::$server->get(INotificationManager::class); + $groupManager = \OC::$server->get(IGroupManager::class); + + $group = $groupManager->get('archivaris'); + if ($group === null) { + return; + } + + foreach ($group->getUsers() as $user) { + $notification = $notificationManager->createNotification(); + $notification->setApp('openregister') + ->setUser($user->getUID()) + ->setDateTime(new DateTime()) + ->setObject('destruction_list', $listUuid) + ->setSubject( + 'destruction_holds_skipped', + [ + 'listUuid' => $listUuid, + 'skippedCount' => $skippedCount, + ] + ); + $notificationManager->notify($notification); + } + } catch (Exception $e) { + $logger->warning('[DestructionExecutionJob] Hold notify error: '.$e->getMessage()); + }//end try + }//end notifySkippedHolds() +}//end class diff --git a/lib/Controller/ArchivalController.php b/lib/Controller/ArchivalController.php new file mode 100644 index 000000000..8be6a43ff --- /dev/null +++ b/lib/Controller/ArchivalController.php @@ -0,0 +1,513 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://www.OpenRegister.app + */ + +namespace OCA\OpenRegister\Controller; + +use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Service\Archival\DestructionService; +use OCA\OpenRegister\Service\Archival\LegalHoldService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; + +/** + * Controller for archival destruction workflows. + * + * Provides REST endpoints for destruction list management, legal hold + * operations, and destruction certificate retrieval. All endpoints require + * the archivist role. + * + * @psalm-suppress UnusedClass + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Controller requires many service dependencies + * @SuppressWarnings(PHPMD.TooManyPublicMethods) REST endpoints for full destruction workflow + */ +class ArchivalController extends Controller +{ + + /** + * The archivist group name for authorization. + */ + private const ARCHIVIST_GROUP = 'archivaris'; + + /** + * Destruction service. + * + * @var DestructionService + */ + private DestructionService $destructionService; + + /** + * Legal hold service. + * + * @var LegalHoldService + */ + private LegalHoldService $legalHoldService; + + /** + * Object mapper. + * + * @var MagicMapper + */ + private MagicMapper $objectMapper; + + /** + * User session. + * + * @var IUserSession + */ + private IUserSession $userSession; + + /** + * Group manager for role checking. + * + * @var IGroupManager + */ + private IGroupManager $groupManager; + + /** + * Logger instance. + * + * @var LoggerInterface + */ + private LoggerInterface $logger; + + /** + * Constructor. + * + * @param string $appName The app name. + * @param IRequest $request The request object. + * @param DestructionService $destructionService Destruction service. + * @param LegalHoldService $legalHoldService Legal hold service. + * @param MagicMapper $objectMapper Object mapper. + * @param IUserSession $userSession User session. + * @param IGroupManager $groupManager Group manager. + * @param LoggerInterface $logger Logger. + */ + public function __construct( + string $appName, + IRequest $request, + DestructionService $destructionService, + LegalHoldService $legalHoldService, + MagicMapper $objectMapper, + IUserSession $userSession, + IGroupManager $groupManager, + LoggerInterface $logger + ) { + parent::__construct(appName: $appName, request: $request); + + $this->destructionService = $destructionService; + $this->legalHoldService = $legalHoldService; + $this->objectMapper = $objectMapper; + $this->userSession = $userSession; + $this->groupManager = $groupManager; + $this->logger = $logger; + }//end __construct() + + /** + * List destruction lists with optional status filter. + * + * @return JSONResponse The list of destruction lists. + * + * @NoAdminRequired + */ + public function listDestructionLists(): JSONResponse + { + $authCheck = $this->checkArchivistRole(); + if ($authCheck !== null) { + return $authCheck; + } + + $status = $this->request->getParam('status'); + + // In a full implementation, this would query the archival register + // for destruction list objects. For now, return the structure. + return new JSONResponse( + data: [ + 'results' => [], + 'total' => 0, + 'filter' => $status, + ], + statusCode: Http::STATUS_OK + ); + }//end listDestructionLists() + + /** + * Get a specific destruction list by ID. + * + * @param string $id The destruction list UUID. + * + * @return JSONResponse The destruction list detail. + * + * @NoAdminRequired + */ + public function getDestructionList(string $id): JSONResponse + { + $authCheck = $this->checkArchivistRole(); + if ($authCheck !== null) { + return $authCheck; + } + + try { + $object = $this->objectMapper->findByUuid($id); + return new JSONResponse( + data: $object->jsonSerialize(), + statusCode: Http::STATUS_OK + ); + } catch (\Exception $e) { + return new JSONResponse( + data: ['error' => 'Destruction list not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } + }//end getDestructionList() + + /** + * Approve a destruction list (full or partial). + * + * @param string $id The destruction list UUID. + * + * @return JSONResponse The updated destruction list. + * + * @NoAdminRequired + */ + public function approveDestructionList(string $id): JSONResponse + { + $authCheck = $this->checkArchivistRole(); + if ($authCheck !== null) { + return $authCheck; + } + + $params = $this->request->getParams(); + $action = $params['action'] ?? 'approve_all'; + $excludedIds = $params['excluded'] ?? []; + $exclusionReasons = $params['exclusionReasons'] ?? []; + + try { + $object = $this->objectMapper->findByUuid($id); + $destructionList = $object->getObject() ?? []; + + // Check for dual-approval requirement based on schema config. + $requiresDual = false; + + $result = $this->destructionService->approveList( + destructionList: $destructionList, + action: $action, + excludedIds: $excludedIds, + exclusionReasons: $exclusionReasons, + requiresDual: $requiresDual + ); + + // Check if dual approval was rejected (same user). + if ($result['status'] === $destructionList['status'] + && $result['status'] === DestructionService::STATUS_AWAITING_SECOND + ) { + return new JSONResponse( + data: ['error' => 'De tweede goedkeuring moet door een andere archivaris worden gegeven'], + statusCode: Http::STATUS_CONFLICT + ); + } + + return new JSONResponse( + data: $result, + statusCode: Http::STATUS_OK + ); + } catch (\Exception $e) { + $this->logger->error( + message: '[ArchivalController] Failed to approve destruction list', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'id' => $id, + 'exception' => $e->getMessage(), + ] + ); + return new JSONResponse( + data: ['error' => 'Failed to approve destruction list: '.$e->getMessage()], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end try + }//end approveDestructionList() + + /** + * Reject a destruction list. + * + * @param string $id The destruction list UUID. + * + * @return JSONResponse The updated destruction list. + * + * @NoAdminRequired + */ + public function rejectDestructionList(string $id): JSONResponse + { + $authCheck = $this->checkArchivistRole(); + if ($authCheck !== null) { + return $authCheck; + } + + $params = $this->request->getParams(); + $reason = $params['reason'] ?? null; + + if ($reason === null || trim($reason) === '') { + return new JSONResponse( + data: ['error' => 'Een reden voor afwijzing is verplicht'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + try { + $object = $this->objectMapper->findByUuid($id); + $destructionList = $object->getObject() ?? []; + + $result = $this->destructionService->rejectList($destructionList, $reason); + + return new JSONResponse( + data: $result, + statusCode: Http::STATUS_OK + ); + } catch (\Exception $e) { + $this->logger->error( + message: '[ArchivalController] Failed to reject destruction list', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'id' => $id, + 'exception' => $e->getMessage(), + ] + ); + return new JSONResponse( + data: ['error' => 'Failed to reject destruction list: '.$e->getMessage()], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end try + }//end rejectDestructionList() + + /** + * Place a legal hold on one or more objects. + * + * @return JSONResponse The legal hold result. + * + * @NoAdminRequired + */ + public function createLegalHold(): JSONResponse + { + $authCheck = $this->checkArchivistRole(); + if ($authCheck !== null) { + return $authCheck; + } + + $params = $this->request->getParams(); + $objectId = $params['objectId'] ?? null; + $schemaId = $params['schemaId'] ?? null; + $reason = $params['reason'] ?? null; + + if ($reason === null || trim($reason) === '') { + return new JSONResponse( + data: ['error' => 'Een reden voor de bewaarplicht is verplicht'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + try { + // Bulk hold on schema. + if ($schemaId !== null) { + $registerId = $params['registerId'] ?? null; + if ($registerId === null) { + return new JSONResponse( + data: ['error' => 'registerId is verplicht voor schema-brede bewaarplicht'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + $this->legalHoldService->bulkPlaceHold( + (int) $schemaId, + (int) $registerId, + $reason + ); + + return new JSONResponse( + data: ['message' => 'Bulk bewaarplicht is ingepland als achtergrondtaak'], + statusCode: Http::STATUS_ACCEPTED + ); + } + + // Single object hold. + if ($objectId === null) { + return new JSONResponse( + data: ['error' => 'objectId of schemaId is verplicht'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + $object = $this->objectMapper->findByUuid($objectId); + $result = $this->legalHoldService->placeHold($object, $reason); + + return new JSONResponse( + data: [ + 'message' => 'Bewaarplicht geplaatst', + 'objectId' => $result->getUuid(), + 'legalHold' => $result->getRetention()['legalHold'] ?? [], + ], + statusCode: Http::STATUS_OK + ); + } catch (\Exception $e) { + $this->logger->error( + message: '[ArchivalController] Failed to create legal hold', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'exception' => $e->getMessage(), + ] + ); + return new JSONResponse( + data: ['error' => 'Failed to create legal hold: '.$e->getMessage()], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end try + }//end createLegalHold() + + /** + * Release a legal hold on an object. + * + * @param string $id The object UUID to release the hold from. + * + * @return JSONResponse The release result. + * + * @NoAdminRequired + */ + public function releaseLegalHold(string $id): JSONResponse + { + $authCheck = $this->checkArchivistRole(); + if ($authCheck !== null) { + return $authCheck; + } + + $params = $this->request->getParams(); + $reason = $params['reason'] ?? null; + + if ($reason === null || trim($reason) === '') { + return new JSONResponse( + data: ['error' => 'Een reden voor het opheffen van de bewaarplicht is verplicht'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + try { + $object = $this->objectMapper->findByUuid($id); + $result = $this->legalHoldService->releaseHold($object, $reason); + + return new JSONResponse( + data: [ + 'message' => 'Bewaarplicht opgeheven', + 'objectId' => $result->getUuid(), + 'legalHold' => $result->getRetention()['legalHold'] ?? [], + ], + statusCode: Http::STATUS_OK + ); + } catch (\Exception $e) { + return new JSONResponse( + data: ['error' => 'Failed to release legal hold: '.$e->getMessage()], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + }//end releaseLegalHold() + + /** + * List active legal holds. + * + * @return JSONResponse The list of active legal holds. + * + * @NoAdminRequired + */ + public function listLegalHolds(): JSONResponse + { + $authCheck = $this->checkArchivistRole(); + if ($authCheck !== null) { + return $authCheck; + } + + // In a full implementation, this would query objects with active legal holds. + return new JSONResponse( + data: [ + 'results' => [], + 'total' => 0, + ], + statusCode: Http::STATUS_OK + ); + }//end listLegalHolds() + + /** + * List destruction certificates. + * + * @return JSONResponse The list of destruction certificates. + * + * @NoAdminRequired + */ + public function listCertificates(): JSONResponse + { + $authCheck = $this->checkArchivistRole(); + if ($authCheck !== null) { + return $authCheck; + } + + // In a full implementation, this would query the archival register + // for certificate objects. + return new JSONResponse( + data: [ + 'results' => [], + 'total' => 0, + ], + statusCode: Http::STATUS_OK + ); + }//end listCertificates() + + /** + * Check if the current user has the archivist role. + * + * @return JSONResponse|null Returns a 403 response if unauthorized, null if authorized. + */ + private function checkArchivistRole(): ?JSONResponse + { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse( + data: ['error' => 'Niet geauthenticeerd'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } + + // Check if user is in the archivaris group or is an admin. + $isArchivist = $this->groupManager->isInGroup($user->getUID(), self::ARCHIVIST_GROUP); + $isAdmin = $this->groupManager->isAdmin($user->getUID()); + + if ($isArchivist === false && $isAdmin === false) { + return new JSONResponse( + data: ['error' => 'Onvoldoende rechten: archivaris rol is vereist'], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + return null; + }//end checkArchivistRole() +}//end class diff --git a/lib/Controller/RetentionController.php b/lib/Controller/RetentionController.php new file mode 100644 index 000000000..dd5e74733 --- /dev/null +++ b/lib/Controller/RetentionController.php @@ -0,0 +1,491 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://www.OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Controller; + +use DateTime; +use DateInterval; +use Exception; +use OCA\OpenRegister\Service\RetentionService; +use OCA\OpenRegister\Service\Settings\ObjectRetentionHandler; +use OCA\OpenRegister\Service\Object\SaveObject; +use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Db\AuditTrailMapper; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\JSONResponse; +use OCP\BackgroundJob\IJobList; +use OCP\IRequest; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; + +/** + * Controller for retention management endpoints. + * + * Provides API endpoints for: + * - Destruction list approval and rejection + * - Legal hold placement and release + * - Bulk legal hold operations + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class RetentionController extends Controller +{ + /** + * Constructor. + * + * @param string $appName App name + * @param IRequest $request Request + * @param RetentionService $retentionService Retention service + * @param ObjectRetentionHandler $settingsHandler Settings handler + * @param SaveObject $saveObject Save object service + * @param MagicMapper $objectMapper Object mapper + * @param SchemaMapper $schemaMapper Schema mapper + * @param AuditTrailMapper $auditMapper Audit trail mapper + * @param IJobList $jobList Background job list + * @param IUserSession $userSession User session + * @param LoggerInterface $logger Logger + */ + public function __construct( + $appName, + IRequest $request, + private readonly RetentionService $retentionService, + private readonly ObjectRetentionHandler $settingsHandler, + private readonly SaveObject $saveObject, + private readonly MagicMapper $objectMapper, + private readonly SchemaMapper $schemaMapper, + private readonly AuditTrailMapper $auditMapper, + private readonly IJobList $jobList, + private readonly IUserSession $userSession, + private readonly LoggerInterface $logger, + ) { + parent::__construct(appName: $appName, request: $request); + }//end __construct() + + /** + * Approve a destruction list (full or partial). + * + * POST /api/retention/destruction-lists/{id}/approve + * + * @param string $id Destruction list UUID + * + * @NoCSRFRequired + * + * @return JSONResponse Response with updated destruction list + */ + public function approveDestructionList(string $id): JSONResponse + { + try { + $listObject = $this->objectMapper->find($id, null, null, false, false, false); + + if ($listObject === null) { + return new JSONResponse(['error' => 'Destruction list not found'], 404); + } + + $listData = $listObject->getObject(); + + $currentStatus = $listData['status'] ?? ''; + if (in_array($currentStatus, ['in_review', 'awaiting_second_approval'], true) === false) { + return new JSONResponse( + ['error' => 'Destruction list is not in reviewable status: '.$currentStatus], + 409 + ); + } + + $user = $this->userSession->getUser(); + $userId = $user !== null ? $user->getUID() : 'unknown'; + + // Handle partial approval: exclude specified objects. + $excluded = $this->request->getParam('excluded', []); + $excludeUuids = []; + + if (empty($excluded) === false && is_array($excluded) === true) { + foreach ($excluded as $excl) { + $exclUuid = $excl['uuid'] ?? null; + $exclReason = $excl['reason'] ?? 'No reason provided'; + + if ($exclUuid === null) { + continue; + } + + $excludeUuids[] = $exclUuid; + + // Remove from objects list and add to excluded list. + $listData['excluded'][] = [ + 'uuid' => $exclUuid, + 'reason' => $exclReason, + 'excludedBy' => $userId, + ]; + + // Extend archiefactiedatum for excluded objects. + try { + $exclObject = $this->objectMapper->find( + $exclUuid, + null, + null, + false, + false, + false + ); + if ($exclObject !== null) { + $this->retentionService->extendArchiefactiedatum($exclObject); + $this->objectMapper->update($exclObject); + } + } catch (Exception $e) { + $this->logger->warning( + '[RetentionController] Failed to extend excluded object: '.$e->getMessage() + ); + } + }//end foreach + + // Filter excluded objects from the list. + $listData['objects'] = array_values( + array_filter( + $listData['objects'] ?? [], + function ($obj) use ($excludeUuids) { + return in_array($obj['uuid'] ?? '', $excludeUuids, true) === false; + } + ) + ); + }//end if + + // Check if two-step approval is required. + $requiresDualApproval = $this->checkDualApprovalRequired(listData: $listData); + + // Record approval. + $listData['approvals'][] = [ + 'userId' => $userId, + 'timestamp' => (new DateTime())->format('c'), + ]; + + if ($requiresDualApproval === true && $currentStatus === 'in_review') { + // First approval — need second approver. + $listData['status'] = 'awaiting_second_approval'; + $listObject->setObject($listData); + $this->objectMapper->update($listObject); + + return new JSONResponse( + [ + 'status' => 'awaiting_second_approval', + 'message' => 'First approval recorded. Awaiting second approval from different archivist.', + ] + ); + } + + // Check that second approver is different from first. + if ($currentStatus === 'awaiting_second_approval') { + $approvals = $listData['approvals'] ?? []; + if (count($approvals) >= 2) { + $firstApprover = $approvals[0]['userId'] ?? ''; + if ($firstApprover === $userId) { + return new JSONResponse( + ['error' => 'Second approval must be from a different archivist'], + 403 + ); + } + } + } + + // Full approval — queue destruction execution. + $listData['status'] = 'approved'; + $listObject->setObject($listData); + $this->objectMapper->update($listObject); + + // Queue the destruction execution job. + $this->jobList->add( + \OCA\OpenRegister\BackgroundJob\DestructionExecutionJob::class, + ['destructionListUuid' => $id] + ); + + // Create audit trail. + $this->auditMapper->createAuditTrailEntry( + $listObject, + 'archival.destruction_approved', + [ + 'approvedBy' => $userId, + 'objectCount' => count($listData['objects']), + 'excluded' => count($listData['excluded'] ?? []), + ] + ); + + return new JSONResponse( + [ + 'status' => 'approved', + 'objectCount' => count($listData['objects']), + 'excludedCount' => count($listData['excluded'] ?? []), + ] + ); + } catch (Exception $e) { + return new JSONResponse(['error' => $e->getMessage()], 500); + }//end try + }//end approveDestructionList() + + /** + * Reject a destruction list. + * + * POST /api/retention/destruction-lists/{id}/reject + * + * @param string $id Destruction list UUID + * + * @NoCSRFRequired + * + * @return JSONResponse Response with updated status + */ + public function rejectDestructionList(string $id): JSONResponse + { + try { + $listObject = $this->objectMapper->find($id, null, null, false, false, false); + + if ($listObject === null) { + return new JSONResponse(['error' => 'Destruction list not found'], 404); + } + + $reason = $this->request->getParam('reason'); + if (empty($reason) === true) { + return new JSONResponse(['error' => 'Rejection reason is required'], 400); + } + + $listData = $listObject->getObject(); + + $currentStatus = $listData['status'] ?? ''; + if (in_array($currentStatus, ['in_review', 'awaiting_second_approval'], true) === false) { + return new JSONResponse( + ['error' => 'Destruction list is not in reviewable status'], + 409 + ); + } + + $user = $this->userSession->getUser(); + $userId = $user !== null ? $user->getUID() : 'unknown'; + + $listData['status'] = 'rejected'; + $listData['rejectedBy'] = $userId; + $listData['rejectionReason'] = $reason; + $listData['rejectedAt'] = (new DateTime())->format('c'); + + // Extend archiefactiedatum for all objects on the list. + foreach ($listData['objects'] ?? [] as $objRef) { + $uuid = $objRef['uuid'] ?? null; + if ($uuid === null) { + continue; + } + + try { + $object = $this->objectMapper->find($uuid, null, null, false, false, false); + if ($object !== null) { + $this->retentionService->extendArchiefactiedatum($object); + $this->objectMapper->update($object); + } + } catch (Exception $e) { + $this->logger->warning( + '[RetentionController] Failed to extend rejected object: '.$e->getMessage() + ); + } + } + + $listObject->setObject($listData); + $this->objectMapper->update($listObject); + + return new JSONResponse( + [ + 'status' => 'rejected', + 'reason' => $reason, + 'message' => 'Destruction list rejected. All object deadlines extended.', + ] + ); + } catch (Exception $e) { + return new JSONResponse(['error' => $e->getMessage()], 500); + }//end try + }//end rejectDestructionList() + + /** + * Place a legal hold on a single object. + * + * POST /api/retention/legal-holds + * + * @NoCSRFRequired + * + * @return JSONResponse Response with updated object + */ + public function placeLegalHold(): JSONResponse + { + try { + $objectId = $this->request->getParam('objectId'); + $reason = $this->request->getParam('reason'); + + if (empty($objectId) === true || empty($reason) === true) { + return new JSONResponse( + ['error' => 'objectId and reason are required'], + 400 + ); + } + + $object = $this->objectMapper->find($objectId, null, null, false, false, false); + + if ($object === null) { + return new JSONResponse(['error' => 'Object not found'], 404); + } + + $this->retentionService->placeLegalHold($object, $reason); + $this->objectMapper->update($object); + + // Create audit trail. + $this->auditMapper->createAuditTrailEntry( + $object, + 'archival.legal_hold_placed', + ['reason' => $reason] + ); + + return new JSONResponse( + [ + 'status' => 'legal_hold_placed', + 'objectId' => $objectId, + 'legalHold' => $object->getRetention()['legalHold'] ?? null, + ] + ); + } catch (Exception $e) { + return new JSONResponse(['error' => $e->getMessage()], 500); + }//end try + }//end placeLegalHold() + + /** + * Release a legal hold on an object. + * + * DELETE /api/retention/legal-holds/{id} + * + * @param string $id Object UUID (the object the hold is on) + * + * @NoCSRFRequired + * + * @return JSONResponse Response with updated object + */ + public function releaseLegalHold(string $id): JSONResponse + { + try { + $reason = $this->request->getParam('reason'); + + if (empty($reason) === true) { + return new JSONResponse(['error' => 'Release reason is required'], 400); + } + + $object = $this->objectMapper->find($id, null, null, false, false, false); + + if ($object === null) { + return new JSONResponse(['error' => 'Object not found'], 404); + } + + $this->retentionService->releaseLegalHold($object, $reason); + $this->objectMapper->update($object); + + // Create audit trail. + $this->auditMapper->createAuditTrailEntry( + $object, + 'archival.legal_hold_released', + ['reason' => $reason] + ); + + return new JSONResponse( + [ + 'status' => 'legal_hold_released', + 'objectId' => $id, + ] + ); + } catch (Exception $e) { + return new JSONResponse(['error' => $e->getMessage()], 500); + }//end try + }//end releaseLegalHold() + + /** + * Place a bulk legal hold on all objects in a schema. + * + * POST /api/retention/legal-holds/bulk + * + * @NoCSRFRequired + * + * @return JSONResponse Response confirming the bulk operation + */ + public function placeBulkLegalHold(): JSONResponse + { + try { + $schemaId = $this->request->getParam('schemaId'); + $reason = $this->request->getParam('reason'); + + if (empty($schemaId) === true || empty($reason) === true) { + return new JSONResponse( + ['error' => 'schemaId and reason are required'], + 400 + ); + } + + // Queue via background job for large datasets. + $this->jobList->add( + \OCA\OpenRegister\BackgroundJob\BulkLegalHoldJob::class, + [ + 'schemaId' => $schemaId, + 'reason' => $reason, + 'userId' => $this->userSession->getUser()?->getUID() ?? 'system', + ] + ); + + return new JSONResponse( + [ + 'status' => 'queued', + 'message' => 'Bulk legal hold queued for processing via background job', + ] + ); + } catch (Exception $e) { + return new JSONResponse(['error' => $e->getMessage()], 500); + }//end try + }//end placeBulkLegalHold() + + /** + * Check if any objects in the destruction list require dual approval. + * + * @param array $listData The destruction list data + * + * @return bool True if dual approval is required + */ + private function checkDualApprovalRequired(array $listData): bool + { + $schemaIds = array_unique( + array_column($listData['objects'] ?? [], 'schema') + ); + + foreach ($schemaIds as $schemaId) { + if ($schemaId === null) { + continue; + } + + try { + $schema = $this->schemaMapper->find((int) $schemaId); + $archive = $schema->getArchive(); + if (($archive['requireDualApproval'] ?? false) === true) { + return true; + } + } catch (Exception $e) { + continue; + } + } + + return false; + }//end checkDualApprovalRequired() +}//end class diff --git a/lib/Controller/Settings/ConfigurationSettingsController.php b/lib/Controller/Settings/ConfigurationSettingsController.php index 588df426d..7c4017a29 100644 --- a/lib/Controller/Settings/ConfigurationSettingsController.php +++ b/lib/Controller/Settings/ConfigurationSettingsController.php @@ -274,6 +274,41 @@ public function updateRetentionSettings(): JSONResponse } }//end updateRetentionSettings() + /** + * Get archival settings (destruction scheduling, selectielijst, etc.) + * + * @NoCSRFRequired + * + * @return JSONResponse JSON response with archival settings + */ + public function getArchivalSettings(): JSONResponse + { + try { + $data = $this->settingsService->getArchivalSettingsOnly(); + return new JSONResponse(data: $data); + } catch (Exception $e) { + return new JSONResponse(data: ['error' => $e->getMessage()], statusCode: 500); + } + }//end getArchivalSettings() + + /** + * Update archival settings + * + * @NoCSRFRequired + * + * @return JSONResponse JSON response with updated archival settings + */ + public function updateArchivalSettings(): JSONResponse + { + try { + $data = $this->request->getParams(); + $result = $this->settingsService->updateArchivalSettingsOnly($data); + return new JSONResponse(data: $result); + } catch (Exception $e) { + return new JSONResponse(data: ['error' => $e->getMessage()], statusCode: 500); + } + }//end updateArchivalSettings() + /** * Get object collection field status * diff --git a/lib/Db/AuditTrailMapper.php b/lib/Db/AuditTrailMapper.php index f94ffe110..c885f6682 100644 --- a/lib/Db/AuditTrailMapper.php +++ b/lib/Db/AuditTrailMapper.php @@ -1184,4 +1184,37 @@ public function getStatisticsGroupedBySchema(array $schemaIds): array }//end getStatisticsGroupedBySchema() + /** + * Create a custom audit trail entry for archival operations. + * + * @param ObjectEntity $object The object the entry relates to + * @param string $action The archival action (e.g., archival.destroyed) + * @param array $context Additional context data + * + * @return AuditTrail The created audit trail entry + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ + public function createAuditTrailEntry( + ObjectEntity $object, + string $action, + array $context=[] + ): AuditTrail { + $user = \OC::$server->getUserSession()->getUser(); + $userId = $user !== null ? $user->getUID() : 'system'; + + $auditTrail = new AuditTrail(); + $auditTrail->setUuid(\Symfony\Component\Uid\Uuid::v4()->toRfc4122()); + $auditTrail->setObjectUuid($object->getUuid()); + $auditTrail->setRegister($object->getRegister()); + $auditTrail->setSchema($object->getSchema()); + $auditTrail->setAction($action); + $auditTrail->setChanged($context); + $auditTrail->setUser($userId); + $auditTrail->setCreated(new \DateTime()); + + return $this->insert(entity: $auditTrail); + }//end createAuditTrailEntry() + + }//end class diff --git a/lib/Migration/Version1Date20250828120000.php b/lib/Migration/Version1Date20250828120000.php index 993c88ce3..1887f1f9e 100644 --- a/lib/Migration/Version1Date20250828120000.php +++ b/lib/Migration/Version1Date20250828120000.php @@ -40,15 +40,17 @@ */ class Version1Date20250828120000 extends SimpleMigrationStep { - /** - * @param IDBConnection $connection The database connection - * @param IConfig $config The configuration interface - */ + /** + * Constructor. + * + * @param IDBConnection $connection The database connection + * @param IConfig $config The configuration interface + */ public function __construct( private readonly IDBConnection $connection, private readonly IConfig $config, ) { - } + }//end __construct() /** * Apply database schema changes for faceting performance. @@ -65,7 +67,7 @@ public function __construct( */ public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { - /** + /* * @var ISchemaWrapper $schema */ diff --git a/lib/Migration/Version1Date20260322120000.php b/lib/Migration/Version1Date20260322120000.php new file mode 100644 index 000000000..50f8313c1 --- /dev/null +++ b/lib/Migration/Version1Date20260322120000.php @@ -0,0 +1,87 @@ + + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Adds a GIN index on the retention JSON column for efficient archival metadata queries. + * + * This enables performant filtering on retention.archiefnominatie, retention.archiefstatus, + * retention.archiefactiedatum, and retention.legalHold.active used by the DestructionCheckJob + * and retention API endpoints. + * + * @package OCA\OpenRegister\Migration + */ +class Version1Date20260322120000 extends SimpleMigrationStep +{ + /** + * Run custom post-schema-change SQL for GIN index. + * + * Doctrine DBAL does not support GIN indexes natively, so we use + * postSchemaChange to add it via raw SQL on PostgreSQL. + * + * @param IOutput $output Migration output + * @param Closure $schemaClosure Schema closure + * @param array $options Migration options + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void + { + // Get the database schema. + $schema = $schemaClosure(); + + if ($schema->hasTable('openregister_objects') === false) { + return; + } + + // GIN index is PostgreSQL-specific; skip for other databases. + $connection = \OC::$server->getDatabaseConnection(); + $platform = $connection->getDatabasePlatform(); + + if (str_contains(get_class($platform), 'PostgreSQL') === false) { + $output->info('Skipping GIN index creation: not PostgreSQL'); + return; + } + + // Check if index already exists. + $result = $connection->executeQuery( + "SELECT 1 FROM pg_indexes WHERE indexname = 'idx_or_objects_retention_gin'" + ); + + if ($result->fetchOne() !== false) { + $output->info('GIN index idx_or_objects_retention_gin already exists'); + return; + } + + $connection->executeStatement( + 'CREATE INDEX idx_or_objects_retention_gin ON oc_openregister_objects USING gin (retention jsonb_path_ops)' + ); + + $output->info('Created GIN index idx_or_objects_retention_gin on openregister_objects.retention'); + }//end postSchemaChange() +}//end class diff --git a/lib/Service/Archival/ArchiefactiedatumCalculator.php b/lib/Service/Archival/ArchiefactiedatumCalculator.php new file mode 100644 index 000000000..1cab7e8cc --- /dev/null +++ b/lib/Service/Archival/ArchiefactiedatumCalculator.php @@ -0,0 +1,299 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://www.OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Archival; + +use DateInterval; +use DateTime; +use InvalidArgumentException; +use Psr\Log\LoggerInterface; + +/** + * Calculator for archive action dates using configurable derivation methods. + * + * Supports three afleidingswijzen as defined by the ZGW API standard: + * - afgehandeld: derives from case closure date + * - eigenschap: derives from a named property value on the object + * - termijn: derives from closure date plus a process term (procestermijn) + * + * @psalm-suppress UnusedClass + */ +class ArchiefactiedatumCalculator +{ + + /** + * Supported derivation methods. + */ + private const AFLEIDINGSWIJZE_AFGEHANDELD = 'afgehandeld'; + private const AFLEIDINGSWIJZE_EIGENSCHAP = 'eigenschap'; + private const AFLEIDINGSWIJZE_TERMIJN = 'termijn'; + + /** + * Logger instance. + * + * @var LoggerInterface + */ + private LoggerInterface $logger; + + /** + * Constructor. + * + * @param LoggerInterface $logger Logger for error and info messages. + */ + public function __construct(LoggerInterface $logger) + { + $this->logger = $logger; + }//end __construct() + + /** + * Calculate the archiefactiedatum based on the given configuration. + * + * @param array $archiveConfig The schema's archive configuration containing: + * - afleidingswijze: string (afgehandeld|eigenschap|termijn) + * - bewaartermijn: string ISO 8601 duration (e.g. P5Y) + * - eigenschap: string property name (for eigenschap method) + * - procestermijn: string ISO 8601 duration (for termijn method) + * @param array $objectData The object's data array. + * @param DateTime|null $closureDate The case closure date (for afgehandeld and termijn methods). + * + * @return DateTime|null The calculated archiefactiedatum, or null if calculation is not possible. + */ + public function calculate(array $archiveConfig, array $objectData, ?DateTime $closureDate=null): ?DateTime + { + $afleidingswijze = $archiveConfig['afleidingswijze'] ?? null; + $bewaartermijn = $archiveConfig['bewaartermijn'] ?? null; + + if ($afleidingswijze === null || $bewaartermijn === null) { + $this->logger->debug( + message: '[ArchiefactiedatumCalculator] Missing afleidingswijze or bewaartermijn in archive config', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'archiveConfig' => $archiveConfig, + ] + ); + return null; + } + + try { + $duration = new DateInterval($bewaartermijn); + } catch (\Exception $e) { + $this->logger->error( + message: '[ArchiefactiedatumCalculator] Invalid bewaartermijn format: '.$bewaartermijn, + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'exception' => $e->getMessage(), + ] + ); + return null; + } + + $brondatum = $this->determineBrondatum( + afleidingswijze: $afleidingswijze, + archiveConfig: $archiveConfig, + objectData: $objectData, + closureDate: $closureDate + ); + + if ($brondatum === null) { + $this->logger->debug( + message: '[ArchiefactiedatumCalculator] Could not determine brondatum', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'afleidingswijze' => $afleidingswijze, + ] + ); + return null; + } + + $archiefactiedatum = clone $brondatum; + $archiefactiedatum->add($duration); + + $this->logger->info( + message: '[ArchiefactiedatumCalculator] Calculated archiefactiedatum', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'afleidingswijze' => $afleidingswijze, + 'brondatum' => $brondatum->format('Y-m-d'), + 'bewaartermijn' => $bewaartermijn, + 'archiefactiedatum' => $archiefactiedatum->format('Y-m-d'), + ] + ); + + return $archiefactiedatum; + }//end calculate() + + /** + * Determine the base date (brondatum) for the calculation. + * + * @param string $afleidingswijze The derivation method. + * @param array $archiveConfig The archive configuration. + * @param array $objectData The object data. + * @param DateTime|null $closureDate The case closure date. + * + * @return DateTime|null The determined base date. + */ + private function determineBrondatum( + string $afleidingswijze, + array $archiveConfig, + array $objectData, + ?DateTime $closureDate + ): ?DateTime { + switch ($afleidingswijze) { + case self::AFLEIDINGSWIJZE_AFGEHANDELD: + return $this->brondatumFromClosure(closureDate: $closureDate); + + case self::AFLEIDINGSWIJZE_EIGENSCHAP: + return $this->brondatumFromProperty(archiveConfig: $archiveConfig, objectData: $objectData); + + case self::AFLEIDINGSWIJZE_TERMIJN: + return $this->brondatumFromTermijn(archiveConfig: $archiveConfig, closureDate: $closureDate); + + default: + $this->logger->warning( + message: '[ArchiefactiedatumCalculator] Unknown afleidingswijze: '.$afleidingswijze, + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'afleidingswijze' => $afleidingswijze, + ] + ); + return null; + }//end switch + }//end determineBrondatum() + + /** + * Get brondatum from case closure date (afgehandeld method). + * + * @param DateTime|null $closureDate The case closure date. + * + * @return DateTime|null The closure date or null if not provided. + */ + private function brondatumFromClosure(?DateTime $closureDate): ?DateTime + { + if ($closureDate === null) { + $this->logger->debug( + message: '[ArchiefactiedatumCalculator] No closure date provided for afgehandeld method', + context: ['file' => __FILE__, 'line' => __LINE__] + ); + return null; + } + + return clone $closureDate; + }//end brondatumFromClosure() + + /** + * Get brondatum from a named property on the object (eigenschap method). + * + * @param array $archiveConfig The archive configuration containing 'eigenschap' key. + * @param array $objectData The object data. + * + * @return DateTime|null The date from the property value, or null. + */ + private function brondatumFromProperty(array $archiveConfig, array $objectData): ?DateTime + { + $propertyName = $archiveConfig['eigenschap'] ?? null; + if ($propertyName === null) { + $this->logger->warning( + message: '[ArchiefactiedatumCalculator] No eigenschap configured for eigenschap method', + context: ['file' => __FILE__, 'line' => __LINE__] + ); + return null; + } + + $propertyValue = $objectData[$propertyName] ?? null; + if ($propertyValue === null) { + $this->logger->debug( + message: '[ArchiefactiedatumCalculator] Property value not found on object', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'propertyName' => $propertyName, + ] + ); + return null; + } + + try { + return new DateTime($propertyValue); + } catch (\Exception $e) { + $this->logger->error( + message: '[ArchiefactiedatumCalculator] Invalid date in property: '.$propertyName, + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'propertyValue' => $propertyValue, + 'exception' => $e->getMessage(), + ] + ); + return null; + } + }//end brondatumFromProperty() + + /** + * Get brondatum from closure date plus process term (termijn method). + * + * @param array $archiveConfig The archive configuration containing 'procestermijn' key. + * @param DateTime|null $closureDate The case closure date. + * + * @return DateTime|null The base date (closure + procestermijn), or null. + */ + private function brondatumFromTermijn(array $archiveConfig, ?DateTime $closureDate): ?DateTime + { + if ($closureDate === null) { + $this->logger->debug( + message: '[ArchiefactiedatumCalculator] No closure date provided for termijn method', + context: ['file' => __FILE__, 'line' => __LINE__] + ); + return null; + } + + $procestermijn = $archiveConfig['procestermijn'] ?? null; + if ($procestermijn === null) { + $this->logger->warning( + message: '[ArchiefactiedatumCalculator] No procestermijn configured for termijn method', + context: ['file' => __FILE__, 'line' => __LINE__] + ); + return null; + } + + try { + $interval = new DateInterval($procestermijn); + $brondatum = clone $closureDate; + $brondatum->add($interval); + return $brondatum; + } catch (\Exception $e) { + $this->logger->error( + message: '[ArchiefactiedatumCalculator] Invalid procestermijn format: '.$procestermijn, + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'exception' => $e->getMessage(), + ] + ); + return null; + } + }//end brondatumFromTermijn() +}//end class diff --git a/lib/Service/Archival/DestructionService.php b/lib/Service/Archival/DestructionService.php new file mode 100644 index 000000000..fa16d4b92 --- /dev/null +++ b/lib/Service/Archival/DestructionService.php @@ -0,0 +1,744 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://www.OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Archival; + +use DateInterval; +use DateTime; +use OCA\OpenRegister\Db\AuditTrailMapper; +use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Service\Object\DeleteObject; +use OCP\BackgroundJob\IJobList; +use OCP\IAppConfig; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; + +/** + * Service for orchestrating archival destruction workflows. + * + * Manages the lifecycle of destruction lists from creation through approval + * to execution and certificate generation, conforming to Archiefbesluit 1995. + * + * @psalm-suppress UnusedClass + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Destruction orchestration requires many service dependencies + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Complex workflow state machine with multiple paths + * @SuppressWarnings(PHPMD.ExcessiveClassLength) Large service covering full destruction lifecycle + * @SuppressWarnings(PHPMD.TooManyPublicMethods) Public API surface for destruction workflow management + */ +class DestructionService +{ + + /** + * Destruction list status constants. + */ + public const STATUS_IN_REVIEW = 'in_review'; + public const STATUS_APPROVED = 'approved'; + public const STATUS_AWAITING_SECOND = 'awaiting_second_approval'; + public const STATUS_REJECTED = 'rejected'; + public const STATUS_COMPLETED = 'completed'; + + /** + * Default extension period when objects are excluded or rejected (1 year). + */ + private const DEFAULT_EXTENSION_PERIOD = 'P1Y'; + + /** + * Default batch size for destruction execution. + */ + private const DEFAULT_BATCH_SIZE = 100; + + /** + * Object entity mapper. + * + * @var MagicMapper + */ + private MagicMapper $objectMapper; + + /** + * Legal hold service for checking holds. + * + * @var LegalHoldService + */ + private LegalHoldService $legalHoldService; + + /** + * Delete object handler for permanent deletion. + * + * @var DeleteObject + */ + private DeleteObject $deleteObject; + + /** + * Audit trail mapper for logging destruction events. + * + * @var AuditTrailMapper + */ + private AuditTrailMapper $auditTrailMapper; + + /** + * App configuration. + * + * @var IAppConfig + */ + private IAppConfig $appConfig; + + /** + * Background job list. + * + * @var IJobList + */ + private IJobList $jobList; + + /** + * User session. + * + * @var IUserSession + */ + private IUserSession $userSession; + + /** + * Logger instance. + * + * @var LoggerInterface + */ + private LoggerInterface $logger; + + /** + * Constructor. + * + * @param MagicMapper $objectMapper Object entity data mapper. + * @param LegalHoldService $legalHoldService Legal hold checking service. + * @param DeleteObject $deleteObject Delete object handler. + * @param AuditTrailMapper $auditTrailMapper Audit trail mapper. + * @param IAppConfig $appConfig App configuration. + * @param IJobList $jobList Background job list. + * @param IUserSession $userSession User session service. + * @param LoggerInterface $logger Logger instance. + */ + public function __construct( + MagicMapper $objectMapper, + LegalHoldService $legalHoldService, + DeleteObject $deleteObject, + AuditTrailMapper $auditTrailMapper, + IAppConfig $appConfig, + IJobList $jobList, + IUserSession $userSession, + LoggerInterface $logger + ) { + $this->objectMapper = $objectMapper; + $this->legalHoldService = $legalHoldService; + $this->deleteObject = $deleteObject; + $this->auditTrailMapper = $auditTrailMapper; + $this->appConfig = $appConfig; + $this->jobList = $jobList; + $this->userSession = $userSession; + $this->logger = $logger; + }//end __construct() + + /** + * Find objects eligible for destruction. + * + * Objects are eligible when: + * - archiefactiedatum is in the past + * - archiefnominatie is 'vernietigen' + * - archiefstatus is 'nog_te_archiveren' + * - No active legal hold + * - Not already on an existing in_review destruction list + * + * @param array $existingListObjectIds UUIDs of objects already on destruction lists. + * + * @return array> Array of eligible object data. + */ + public function findEligibleObjects(array $existingListObjectIds=[]): array + { + $today = (new DateTime())->format('Y-m-d'); + $eligible = []; + + // Query objects with retention.archiefactiedatum in the past. + // This uses MagicMapper's JSON field querying capability. + try { + $objects = $this->objectMapper->findAll( + filters: [ + 'retention.archiefnominatie' => 'vernietigen', + 'retention.archiefstatus' => 'nog_te_archiveren', + ], + includeDeleted: true + ); + } catch (\Exception $e) { + $this->logger->error( + message: '[DestructionService] Failed to query eligible objects', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'exception' => $e->getMessage(), + ] + ); + return []; + } + + foreach ($objects as $object) { + $retention = $object->getRetention() ?? []; + + // Check archiefactiedatum is in the past. + $actiedatum = $retention['archiefactiedatum'] ?? null; + if ($actiedatum === null || $actiedatum > $today) { + continue; + } + + // Check no active legal hold. + if ($this->legalHoldService->hasActiveHold($object) === true) { + continue; + } + + // Check not already on an existing destruction list. + $uuid = $object->getUuid(); + if (in_array($uuid, $existingListObjectIds, true) === true) { + continue; + } + + $eligible[] = [ + 'uuid' => $uuid, + 'title' => $object->getTitle() ?? $uuid, + 'schema' => $object->getSchema(), + 'register' => $object->getRegister(), + 'archiefactiedatum' => $actiedatum, + 'classificatie' => $retention['classificatie'] ?? null, + 'alreadySoftDeleted' => ($object->getDeleted() !== null), + ]; + }//end foreach + + $this->logger->info( + message: '[DestructionService] Found eligible objects for destruction', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'count' => count($eligible), + ] + ); + + return $eligible; + }//end findEligibleObjects() + + /** + * Create a destruction list from eligible objects. + * + * The destruction list is stored as a register object with status 'in_review'. + * + * @param array> $eligibleObjects Array of eligible object data. + * + * @return array The created destruction list data. + */ + public function createDestructionList(array $eligibleObjects): array + { + if (empty($eligibleObjects) === true) { + $this->logger->info( + message: '[DestructionService] No eligible objects, skipping destruction list creation', + context: ['file' => __FILE__, 'line' => __LINE__] + ); + return []; + } + + $now = new DateTime(); + + $destructionList = [ + 'status' => self::STATUS_IN_REVIEW, + 'createdAt' => $now->format('c'), + 'objectCount' => count($eligibleObjects), + 'objects' => $eligibleObjects, + 'approvals' => [], + 'rejections' => [], + ]; + + $this->logger->info( + message: '[DestructionService] Created destruction list', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'objectCount' => count($eligibleObjects), + 'status' => self::STATUS_IN_REVIEW, + ] + ); + + return $destructionList; + }//end createDestructionList() + + /** + * Approve a destruction list (full or partial). + * + * @param array $destructionList The destruction list data. + * @param string $action The approval action: 'approve_all' or 'approve_partial'. + * @param array $excludedIds UUIDs of objects to exclude (for partial approval). + * @param array $exclusionReasons Reasons per excluded object UUID. + * @param bool $requiresDual Whether two-step approval is required. + * + * @return array The updated destruction list. + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) Configuration-driven dual approval toggle + */ + public function approveList( + array $destructionList, + string $action='approve_all', + array $excludedIds=[], + array $exclusionReasons=[], + bool $requiresDual=false + ): array { + $userId = $this->getCurrentUserId(); + $now = new DateTime(); + + // Record the approval. + $destructionList['approvals'][] = [ + 'approvedBy' => $userId, + 'approvedAt' => $now->format('c'), + 'action' => $action, + ]; + + // Handle partial approval: exclude specific objects. + if ($action === 'approve_partial' && empty($excludedIds) === false) { + $destructionList = $this->handlePartialApproval( + destructionList: $destructionList, + excludedIds: $excludedIds, + exclusionReasons: $exclusionReasons + ); + } + + // Check if dual approval is required and this is the first approval. + if ($requiresDual === true && count($destructionList['approvals']) < 2) { + $destructionList['status'] = self::STATUS_AWAITING_SECOND; + + $this->logger->info( + message: '[DestructionService] First approval recorded, awaiting second approval', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'approvedBy' => $userId, + ] + ); + + return $destructionList; + } + + // Check dual approval: second approver must be different from first. + if ($requiresDual === true && count($destructionList['approvals']) >= 2) { + $firstApprover = $destructionList['approvals'][0]['approvedBy'] ?? null; + $secondApprover = $destructionList['approvals'][1]['approvedBy'] ?? null; + if ($firstApprover === $secondApprover) { + $this->logger->warning( + message: '[DestructionService] Same archivist cannot provide both approvals', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'approver' => $firstApprover, + ] + ); + // Remove the invalid second approval. + array_pop($destructionList['approvals']); + return $destructionList; + } + } + + // Mark as approved and queue execution. + $destructionList['status'] = self::STATUS_APPROVED; + + // Queue the destruction execution job. + $this->jobList->add( + \OCA\OpenRegister\BackgroundJob\DestructionExecutionJob::class, + [ + 'destructionList' => $destructionList, + 'approvedBy' => $userId, + ] + ); + + $this->logger->info( + message: '[DestructionService] Destruction list approved, execution job queued', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'approvedBy' => $userId, + 'action' => $action, + ] + ); + + return $destructionList; + }//end approveList() + + /** + * Handle partial approval by excluding specific objects and extending their dates. + * + * @param array $destructionList The destruction list. + * @param array $excludedIds UUIDs to exclude. + * @param array $exclusionReasons Reasons per UUID. + * + * @return array The updated destruction list. + */ + private function handlePartialApproval( + array $destructionList, + array $excludedIds, + array $exclusionReasons + ): array { + $extensionPeriod = $this->appConfig->getValueString( + app: 'openregister', + key: 'destruction_extension_period', + default: self::DEFAULT_EXTENSION_PERIOD + ); + + $excluded = []; + $approved = []; + + foreach ($destructionList['objects'] as $objectEntry) { + $uuid = $objectEntry['uuid']; + if (in_array($uuid, $excludedIds, true) === true) { + $objectEntry['status'] = 'uitgezonderd'; + $objectEntry['exclusionReason'] = $exclusionReasons[$uuid] ?? 'Geen reden opgegeven'; + $excluded[] = $objectEntry; + + // Extend the object's archiefactiedatum. + $this->extendArchiefactiedatum( + uuid: $uuid, + extensionPeriod: $extensionPeriod, + reason: $objectEntry['exclusionReason'] + ); + } else { + $objectEntry['status'] = 'approved'; + $approved[] = $objectEntry; + } + } + + $destructionList['objects'] = $approved; + $destructionList['excludedObjects'] = $excluded; + $destructionList['objectCount'] = count($approved); + + return $destructionList; + }//end handlePartialApproval() + + /** + * Reject an entire destruction list. + * + * @param array $destructionList The destruction list data. + * @param string $reason The reason for rejection. + * + * @return array The updated destruction list. + */ + public function rejectList(array $destructionList, string $reason): array + { + $userId = $this->getCurrentUserId(); + $now = new DateTime(); + + $extensionPeriod = $this->appConfig->getValueString( + app: 'openregister', + key: 'destruction_extension_period', + default: self::DEFAULT_EXTENSION_PERIOD + ); + + $destructionList['status'] = self::STATUS_REJECTED; + $destructionList['rejections'][] = [ + 'rejectedBy' => $userId, + 'rejectedAt' => $now->format('c'), + 'reason' => $reason, + ]; + + // Extend archiefactiedatum for all objects on the list. + foreach ($destructionList['objects'] as $objectEntry) { + $this->extendArchiefactiedatum( + uuid: $objectEntry['uuid'], + extensionPeriod: $extensionPeriod, + reason: 'Vernietigingslijst afgewezen: '.$reason + ); + } + + $this->logger->info( + message: '[DestructionService] Destruction list rejected', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'rejectedBy' => $userId, + 'reason' => $reason, + ] + ); + + return $destructionList; + }//end rejectList() + + /** + * Execute destruction for an approved destruction list. + * + * Permanently deletes objects in batches, re-checking legal holds before each deletion. + * + * @param array $destructionList The approved destruction list. + * @param string $approvedBy The user who approved the list. + * + * @return array Execution result with counts and skipped objects. + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) Multiple checks per object during destruction + */ + public function executeDestruction(array $destructionList, string $approvedBy): array + { + $destroyed = 0; + $skipped = []; + $files = 0; + + $batchSize = (int) $this->appConfig->getValueString( + app: 'openregister', + key: 'destruction_batch_size', + default: (string) self::DEFAULT_BATCH_SIZE + ); + + $objects = $destructionList['objects'] ?? []; + $batches = array_chunk($objects, $batchSize); + + foreach ($batches as $batch) { + foreach ($batch as $objectEntry) { + $uuid = $objectEntry['uuid']; + + try { + $object = $this->objectMapper->findByUuid( + $uuid + ); + + // Re-check legal hold before deletion. + if ($this->legalHoldService->hasActiveHold($object) === true) { + $skipped[] = [ + 'uuid' => $uuid, + 'reason' => 'legal_hold_placed_after_approval', + ]; + continue; + } + + // Permanently delete the object. + $this->deleteObject->delete( + objectEntity: $object, + permanent: true + ); + + $destroyed++; + } catch (\Exception $e) { + $this->logger->error( + message: '[DestructionService] Failed to destroy object', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'uuid' => $uuid, + 'exception' => $e->getMessage(), + ] + ); + $skipped[] = [ + 'uuid' => $uuid, + 'reason' => 'error: '.$e->getMessage(), + ]; + }//end try + }//end foreach + }//end foreach + + $result = [ + 'destroyed' => $destroyed, + 'skipped' => $skipped, + 'skippedCount' => count($skipped), + 'filesDestroyed' => $files, + 'approvedBy' => $approvedBy, + 'executedAt' => (new DateTime())->format('c'), + ]; + + $this->logger->info( + message: '[DestructionService] Destruction execution completed', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'destroyed' => $destroyed, + 'skipped' => count($skipped), + ] + ); + + return $result; + }//end executeDestruction() + + /** + * Generate a destruction certificate (verklaring van vernietiging). + * + * @param array $destructionList The completed destruction list. + * @param array $executionResult The execution result data. + * + * @return array The destruction certificate data. + */ + public function generateCertificate(array $destructionList, array $executionResult): array + { + $now = new DateTime(); + + // Group destroyed objects by schema and selectielijst category. + $groupedBySchema = []; + $groupedByCategorie = []; + foreach ($destructionList['objects'] as $objectEntry) { + $schema = $objectEntry['schema'] ?? 'unknown'; + $categorie = $objectEntry['classificatie'] ?? 'unknown'; + + if (isset($groupedBySchema[$schema]) === false) { + $groupedBySchema[$schema] = 0; + } + + $groupedBySchema[$schema]++; + + if (isset($groupedByCategorie[$categorie]) === false) { + $groupedByCategorie[$categorie] = 0; + } + + $groupedByCategorie[$categorie]++; + } + + $approvers = array_map( + static function (array $approval): string { + return $approval['approvedBy'] ?? 'unknown'; + }, + $destructionList['approvals'] ?? [] + ); + + $certificate = [ + 'type' => 'verklaring_van_vernietiging', + 'destructionDate' => $now->format('c'), + 'approvers' => $approvers, + 'totalObjectsDestroyed' => $executionResult['destroyed'] ?? 0, + 'totalObjectsSkipped' => $executionResult['skippedCount'] ?? 0, + 'skippedObjects' => $executionResult['skipped'] ?? [], + 'objectsBySchema' => $groupedBySchema, + 'objectsBySelectielijst' => $groupedByCategorie, + 'totalFilesDestroyed' => $executionResult['filesDestroyed'] ?? 0, + 'complianceStatement' => 'Conform Archiefwet 1995 en Archiefbesluit 1995, artikelen 6-8.', + 'createdAt' => $now->format('c'), + 'immutable' => true, + ]; + + $this->logger->info( + message: '[DestructionService] Destruction certificate generated', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'destroyed' => $certificate['totalObjectsDestroyed'], + 'skipped' => $certificate['totalObjectsSkipped'], + ] + ); + + return $certificate; + }//end generateCertificate() + + /** + * Validate a destruction list for pre-flight checks. + * + * Scans all objects (and their cascade targets) for legal holds. + * + * @param array $destructionList The destruction list to validate. + * + * @return array Validation result with warnings and blocked objects. + */ + public function validateDestructionList(array $destructionList): array + { + $warnings = []; + $blocked = []; + + foreach ($destructionList['objects'] as $objectEntry) { + $uuid = $objectEntry['uuid']; + + try { + $object = $this->objectMapper->findByUuid($uuid); + + // Check for legal hold. + if ($this->legalHoldService->hasActiveHold($object) === true) { + $blocked[] = [ + 'uuid' => $uuid, + 'reason' => 'active_legal_hold', + ]; + } + } catch (\Exception $e) { + $warnings[] = [ + 'uuid' => $uuid, + 'reason' => 'object_not_found', + ]; + } + } + + return [ + 'valid' => empty($blocked), + 'warnings' => $warnings, + 'blocked' => $blocked, + ]; + }//end validateDestructionList() + + /** + * Extend the archiefactiedatum for an object by the configured period. + * + * @param string $uuid The object UUID. + * @param string $extensionPeriod ISO 8601 duration to add. + * @param string $reason The reason for extension. + * + * @return void + */ + private function extendArchiefactiedatum(string $uuid, string $extensionPeriod, string $reason): void + { + try { + $object = $this->objectMapper->findByUuid($uuid); + $retention = $object->getRetention() ?? []; + + $currentDate = $retention['archiefactiedatum'] ?? null; + if ($currentDate !== null) { + $date = new DateTime($currentDate); + $date->add(new DateInterval($extensionPeriod)); + $retention['archiefactiedatum'] = $date->format('Y-m-d'); + } + + // Record in exclusion history. + $exclusionHistory = $retention['exclusionHistory'] ?? []; + $exclusionHistory[] = [ + 'date' => (new DateTime())->format('c'), + 'reason' => $reason, + 'newArchiefactiedatum' => $retention['archiefactiedatum'] ?? null, + ]; + $retention['exclusionHistory'] = $exclusionHistory; + + $object->setRetention($retention); + $this->objectMapper->update($object); + } catch (\Exception $e) { + $this->logger->error( + message: '[DestructionService] Failed to extend archiefactiedatum', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'uuid' => $uuid, + 'exception' => $e->getMessage(), + ] + ); + }//end try + }//end extendArchiefactiedatum() + + /** + * Get the current authenticated user ID. + * + * @return string The user ID or 'system' if no user is authenticated. + */ + private function getCurrentUserId(): string + { + $user = $this->userSession->getUser(); + if ($user === null) { + return 'system'; + } + + return $user->getUID(); + }//end getCurrentUserId() +}//end class diff --git a/lib/Service/Archival/LegalHoldService.php b/lib/Service/Archival/LegalHoldService.php new file mode 100644 index 000000000..2ef9512e3 --- /dev/null +++ b/lib/Service/Archival/LegalHoldService.php @@ -0,0 +1,274 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://www.OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Archival; + +use DateTime; +use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\AuditTrailMapper; +use OCP\BackgroundJob\IJobList; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; + +/** + * Service for managing legal holds on register objects. + * + * Legal holds prevent destruction of objects regardless of their archiefactiedatum. + * Holds are stored in the object's retention.legalHold field. + * + * @psalm-suppress UnusedClass + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Legal holds require coordination with multiple services + */ +class LegalHoldService +{ + + /** + * Object entity mapper. + * + * @var MagicMapper + */ + private MagicMapper $objectMapper; + + /** + * Audit trail mapper for logging hold operations. + * + * @var AuditTrailMapper + */ + private AuditTrailMapper $auditTrailMapper; + + /** + * User session for identifying who placed the hold. + * + * @var IUserSession + */ + private IUserSession $userSession; + + /** + * Background job list for bulk operations. + * + * @var IJobList + */ + private IJobList $jobList; + + /** + * Logger instance. + * + * @var LoggerInterface + */ + private LoggerInterface $logger; + + /** + * Constructor. + * + * @param MagicMapper $objectMapper Object entity data mapper. + * @param AuditTrailMapper $auditTrailMapper Audit trail mapper for logging. + * @param IUserSession $userSession User session service. + * @param IJobList $jobList Background job list for bulk operations. + * @param LoggerInterface $logger Logger for error and info messages. + */ + public function __construct( + MagicMapper $objectMapper, + AuditTrailMapper $auditTrailMapper, + IUserSession $userSession, + IJobList $jobList, + LoggerInterface $logger + ) { + $this->objectMapper = $objectMapper; + $this->auditTrailMapper = $auditTrailMapper; + $this->userSession = $userSession; + $this->jobList = $jobList; + $this->logger = $logger; + }//end __construct() + + /** + * Place a legal hold on an object. + * + * @param ObjectEntity $object The object to place a hold on. + * @param string $reason The reason for the legal hold (e.g. WOO-verzoek reference). + * + * @return ObjectEntity The updated object with legal hold applied. + */ + public function placeHold(ObjectEntity $object, string $reason): ObjectEntity + { + $userId = $this->getCurrentUserId(); + $retention = $object->getRetention() ?? []; + + $holdData = [ + 'active' => true, + 'reason' => $reason, + 'placedBy' => $userId, + 'placedDate' => (new DateTime())->format('c'), + 'history' => $retention['legalHold']['history'] ?? [], + ]; + + $retention['legalHold'] = $holdData; + $object->setRetention($retention); + + $this->objectMapper->update($object); + + $this->logger->info( + message: '[LegalHoldService] Legal hold placed on object', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'objectId' => $object->getUuid(), + 'reason' => $reason, + 'placedBy' => $userId, + ] + ); + + return $object; + }//end placeHold() + + /** + * Release a legal hold on an object. + * + * @param ObjectEntity $object The object to release the hold from. + * @param string $reason The reason for releasing the hold. + * + * @return ObjectEntity The updated object with legal hold released. + */ + public function releaseHold(ObjectEntity $object, string $reason): ObjectEntity + { + $userId = $this->getCurrentUserId(); + $retention = $object->getRetention() ?? []; + $legalHold = $retention['legalHold'] ?? []; + + // Preserve the current hold in history. + $history = $legalHold['history'] ?? []; + $history[] = [ + 'active' => true, + 'reason' => $legalHold['reason'] ?? 'unknown', + 'placedBy' => $legalHold['placedBy'] ?? 'unknown', + 'placedDate' => $legalHold['placedDate'] ?? null, + 'releasedBy' => $userId, + 'releasedDate' => (new DateTime())->format('c'), + 'releaseReason' => $reason, + ]; + + $retention['legalHold'] = [ + 'active' => false, + 'reason' => $legalHold['reason'] ?? null, + 'placedBy' => $legalHold['placedBy'] ?? null, + 'placedDate' => $legalHold['placedDate'] ?? null, + 'history' => $history, + ]; + + $object->setRetention($retention); + $this->objectMapper->update($object); + + $this->logger->info( + message: '[LegalHoldService] Legal hold released on object', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'objectId' => $object->getUuid(), + 'releaseReason' => $reason, + 'releasedBy' => $userId, + ] + ); + + return $object; + }//end releaseHold() + + /** + * Check if an object has an active legal hold. + * + * @param ObjectEntity $object The object to check. + * + * @return bool True if the object has an active legal hold. + */ + public function hasActiveHold(ObjectEntity $object): bool + { + $retention = $object->getRetention() ?? []; + $legalHold = $retention['legalHold'] ?? []; + + return ($legalHold['active'] ?? false) === true; + }//end hasActiveHold() + + /** + * Check if an object has an active legal hold using its retention array directly. + * + * @param array $retention The object's retention data. + * + * @return bool True if the retention data indicates an active legal hold. + */ + public function hasActiveHoldFromRetention(array $retention): bool + { + $legalHold = $retention['legalHold'] ?? []; + + return ($legalHold['active'] ?? false) === true; + }//end hasActiveHoldFromRetention() + + /** + * Schedule a bulk legal hold operation on all objects in a schema. + * + * @param int $schemaId The schema ID to apply holds to. + * @param int $registerId The register ID. + * @param string $reason The reason for the bulk legal hold. + * + * @return void + */ + public function bulkPlaceHold(int $schemaId, int $registerId, string $reason): void + { + $userId = $this->getCurrentUserId(); + + $this->jobList->add( + \OCA\OpenRegister\BackgroundJob\BulkLegalHoldJob::class, + [ + 'schemaId' => $schemaId, + 'registerId' => $registerId, + 'reason' => $reason, + 'placedBy' => $userId, + ] + ); + + $this->logger->info( + message: '[LegalHoldService] Bulk legal hold job queued', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'schemaId' => $schemaId, + 'registerId' => $registerId, + 'reason' => $reason, + 'placedBy' => $userId, + ] + ); + }//end bulkPlaceHold() + + /** + * Get the current authenticated user ID. + * + * @return string The user ID or 'system' if no user is authenticated. + */ + private function getCurrentUserId(): string + { + $user = $this->userSession->getUser(); + if ($user === null) { + return 'system'; + } + + return $user->getUID(); + }//end getCurrentUserId() +}//end class diff --git a/lib/Service/Object/DeleteObject.php b/lib/Service/Object/DeleteObject.php index 881dd0721..928278653 100644 --- a/lib/Service/Object/DeleteObject.php +++ b/lib/Service/Object/DeleteObject.php @@ -147,18 +147,21 @@ public function __construct( * When non-null, indicates this deletion was triggered by * referential integrity enforcement and includes keys like * 'triggerObject', 'triggerSchema', 'action_type'. + * @param bool $permanent When true, physically removes the record from the database + * instead of soft-deleting. Used by archival destruction workflow. * * @return bool Whether the deletion was successful. * * @throws Exception If there is an error during deletion. * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) * @SuppressWarnings(PHPMD.CyclomaticComplexity) Soft delete with audit trail requires multiple conditional paths * @SuppressWarnings(PHPMD.NPathComplexity) Multiple decision paths for soft delete, cache invalidation, * and audit trail operations * * @psalm-suppress UndefinedInterfaceMethod Array access on JsonSerializable handled by type check */ - public function delete(array | JsonSerializable $object, ?array $cascadeContext=null): bool + public function delete(array | JsonSerializable $object, ?array $cascadeContext=null, bool $permanent=false): bool { // Handle ObjectEntity passed from deleteObject() - skip redundant lookup. // Handle array input - find object with context (searches across all magic tables). @@ -185,6 +188,35 @@ public function delete(array | JsonSerializable $object, ?array $cascadeContext= $registerEntity = $context['register']; $schemaEntity = $context['schema']; + // **PERMANENT DELETE**: Physical removal from database (archival destruction workflow). + if ($permanent === true) { + $this->logger->info( + message: '[DeleteObject] Permanent deletion requested (archival destruction)', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'uuid' => $objectEntity->getUuid(), + ] + ); + + $this->objectEntityMapper->deleteObjectEntity( + entity: $objectEntity, + register: $registerEntity, + schema: $schemaEntity, + hardDelete: true + ); + $result = true; + + // Cache invalidation for permanent delete. + $this->cacheHandler->invalidateForObjectChange( + registerId: is_numeric($objectEntity->getRegister()) === true ? (int) $objectEntity->getRegister() : null, + schemaId: is_numeric($objectEntity->getSchema()) === true ? (int) $objectEntity->getSchema() : null, + operation: 'permanent_delete' + ); + + return $result; + }//end if + // **SOFT DELETE**: Mark object as deleted instead of removing from database. // Set deletion metadata with user, timestamp, and organization information. $user = $this->userSession->getUser(); diff --git a/lib/Service/Object/SaveObject.php b/lib/Service/Object/SaveObject.php index fdbbab38b..78670f529 100644 --- a/lib/Service/Object/SaveObject.php +++ b/lib/Service/Object/SaveObject.php @@ -2843,6 +2843,21 @@ private function handleObjectUpdate( bool $persist, bool $silent ): ObjectEntity { + // Check archival immutability: destroyed and transferred objects cannot be modified. + $retention = $existingObject->getRetention() ?? []; + $archStatus = $retention['archiefstatus'] ?? null; + $immutableMap = [ + 'vernietigd' => 'OBJECT_DESTROYED', + 'overgebracht' => 'OBJECT_TRANSFERRED', + ]; + + if ($archStatus !== null && isset($immutableMap[$archStatus]) === true) { + throw new Exception( + 'Cannot modify object: archival status is '.$archStatus.' (error: '.$immutableMap[$archStatus].')', + 409 + ); + } + // IMPORTANT: Capture the old state BEFORE prepareObjectForUpdate modifies the entity. // This is critical for event dispatching - the old status must be captured here, // not after preparation when the entity has already been modified. @@ -2936,6 +2951,16 @@ private function handleObjectCreation( _multitenancy: $_multitenancy ); + // Apply archival metadata from schema archive configuration. + try { + $retentionService = \OC::$server->get(\OCA\OpenRegister\Service\RetentionService::class); + $preparedObject = $retentionService->applyArchivalMetadata($preparedObject, $schema); + } catch (\Throwable $e) { + $this->logger->debug( + '[SaveObject] RetentionService not available, skipping archival metadata: '.$e->getMessage() + ); + } + // If not persisting, return the prepared object. if ($persist === false) { return $preparedObject; @@ -3689,6 +3714,20 @@ public function updateObject( folderId: $folderId ); + // Recalculate archiefactiedatum if source property changed. + try { + $retentionService = \OC::$server->get(\OCA\OpenRegister\Service\RetentionService::class); + $preparedObject = $retentionService->recalculateArchiefactiedatum( + $preparedObject, + $schema, + $oldObject->getObject() + ); + } catch (\Throwable $e) { + $this->logger->debug( + '[SaveObject] RetentionService not available for recalculation: '.$e->getMessage() + ); + } + // Update the object properties. $preparedObject->setRegister((string) $registerId); $preparedObject->setSchema((string) $schemaId); diff --git a/lib/Service/RetentionService.php b/lib/Service/RetentionService.php new file mode 100644 index 000000000..c52723cc9 --- /dev/null +++ b/lib/Service/RetentionService.php @@ -0,0 +1,796 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://www.OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service; + +use DateTime; +use DateInterval; +use Exception; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Db\AuditTrailMapper; +use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Service\Settings\ObjectRetentionHandler; +use OCP\IAppConfig; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; +use Symfony\Component\Uid\Uuid; + +/** + * Service for managing retention lifecycle of register objects. + * + * Handles MDTO-compliant archival metadata, selectielijst lookups, + * archiefactiedatum calculation, legal holds, and destruction workflows. + * + * @category Service + * @package OCA\OpenRegister\Service + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) + */ +class RetentionService +{ + + /** + * Valid archiefnominatie values. + */ + private const VALID_NOMINATIES = ['vernietigen', 'bewaren', 'nog_niet_bepaald']; + + /** + * Valid archiefstatus values. + */ + private const VALID_STATUSES = [ + 'nog_te_archiveren', + 'gearchiveerd', + 'vernietigd', + 'overgebracht', + ]; + + /** + * Immutable archival statuses (no further updates allowed). + */ + private const IMMUTABLE_STATUSES = ['vernietigd', 'overgebracht']; + + /** + * Valid afleidingswijze methods. + */ + private const VALID_AFLEIDINGSWIJZEN = ['afgehandeld', 'eigenschap', 'termijn']; + + /** + * Constructor. + * + * @param MagicMapper $objectMapper Object mapper for queries + * @param SchemaMapper $schemaMapper Schema mapper for lookups + * @param RegisterMapper $registerMapper Register mapper for lookups + * @param AuditTrailMapper $auditMapper Audit trail mapper + * @param ObjectRetentionHandler $settingsHandler Retention settings handler + * @param IAppConfig $appConfig App configuration + * @param IUserSession $userSession Current user session + * @param LoggerInterface $logger Logger + */ + public function __construct( + private readonly MagicMapper $objectMapper, + private readonly SchemaMapper $schemaMapper, + private readonly RegisterMapper $registerMapper, + private readonly AuditTrailMapper $auditMapper, + private readonly ObjectRetentionHandler $settingsHandler, + private readonly IAppConfig $appConfig, + private readonly IUserSession $userSession, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Apply archival metadata to an object based on its schema's archive configuration. + * + * Called during object creation to populate retention fields from schema defaults + * and selectielijst mappings. + * + * @param ObjectEntity $object The object entity to populate + * @param Schema $schema The schema with archive configuration + * + * @return ObjectEntity The object with archival metadata applied + */ + public function applyArchivalMetadata(ObjectEntity $object, Schema $schema): ObjectEntity + { + $archiveConfig = $schema->getArchive(); + + // Skip if archive is not enabled for this schema. + if (empty($archiveConfig) === true || ($archiveConfig['enabled'] ?? false) === false) { + return $object; + } + + $retention = $object->getRetention() ?? []; + + // Do not overwrite if archival metadata already present. + if (empty($retention['archiefnominatie']) === false) { + return $object; + } + + // Try selectielijst lookup first. + $classificatie = $archiveConfig['classificatie'] ?? null; + $selectielijstEntry = null; + + if ($classificatie !== null) { + $selectielijstEntry = $this->lookupSelectielijstEntry(categorie: $classificatie); + } + + // Determine nominatie and bewaartermijn. + if ($selectielijstEntry !== null) { + $nominatie = $selectielijstEntry['archiefnominatie'] ?? 'nog_niet_bepaald'; + $bewaartermijn = $selectielijstEntry['bewaartermijn'] ?? null; + $bron = $selectielijstEntry['bron'] ?? null; + } else { + $nominatie = $archiveConfig['defaultNominatie'] ?? 'nog_niet_bepaald'; + $bewaartermijn = $archiveConfig['defaultBewaartermijn'] ?? null; + $bron = null; + } + + // Apply schema-level override if configured. + if (empty($archiveConfig['bewaartermijnOverride']) === false) { + $bewaartermijn = $archiveConfig['bewaartermijnOverride']; + } + + // Build archival metadata. + $retention['archiefnominatie'] = $nominatie; + $retention['archiefstatus'] = 'nog_te_archiveren'; + $retention['classificatie'] = $classificatie; + $retention['bewaartermijn'] = $bewaartermijn; + $retention['selectielijstBron'] = $bron; + + // Calculate archiefactiedatum if bewaartermijn is set. + if ($bewaartermijn !== null) { + $retention['archiefactiedatum'] = $this->calculateArchiefactiedatum( + object: $object, + schema: $schema, + bewaartermijn: $bewaartermijn + ); + } + + $object->setRetention($retention); + + return $object; + }//end applyArchivalMetadata() + + /** + * Calculate archiefactiedatum based on the schema's afleidingswijze. + * + * @param ObjectEntity $object The object to calculate for + * @param Schema $schema The schema with afleidingswijze config + * @param string $bewaartermijn ISO 8601 duration (e.g., P5Y, P20Y) + * + * @return string|null ISO 8601 date string or null if calculation not possible + */ + public function calculateArchiefactiedatum( + ObjectEntity $object, + Schema $schema, + string $bewaartermijn + ): ?string { + $archiveConfig = $schema->getArchive(); + $afleidingswijze = $archiveConfig['afleidingswijze'] ?? 'afgehandeld'; + + try { + $interval = new DateInterval($bewaartermijn); + } catch (Exception $e) { + $this->logger->warning( + '[RetentionService] Invalid bewaartermijn format: '.$bewaartermijn, + ['exception' => $e] + ); + return null; + } + + $brondatum = $this->determineBrondatum(object: $object, schema: $schema, afleidingswijze: $afleidingswijze); + + if ($brondatum === null) { + // If no brondatum can be determined, use creation date as fallback. + $brondatum = new DateTime(); + } + + // For 'termijn' method, add procestermijn first. + if ($afleidingswijze === 'termijn') { + $procestermijn = $archiveConfig['procestermijn'] ?? null; + if ($procestermijn !== null) { + try { + $brondatum->add(new DateInterval($procestermijn)); + } catch (Exception $e) { + $this->logger->warning( + '[RetentionService] Invalid procestermijn format: '.$procestermijn, + ['exception' => $e] + ); + } + } + } + + $brondatum->add($interval); + + return $brondatum->format('Y-m-d'); + }//end calculateArchiefactiedatum() + + /** + * Determine the brondatum (source date) based on afleidingswijze. + * + * @param ObjectEntity $object The object + * @param Schema $schema The schema + * @param string $afleidingswijze The derivation method + * + * @return DateTime|null The source date or null + */ + private function determineBrondatum( + ObjectEntity $object, + Schema $schema, + string $afleidingswijze + ): ?DateTime { + $archiveConfig = $schema->getArchive(); + $objectData = $object->getObject(); + + switch ($afleidingswijze) { + case 'eigenschap': + $bronEigenschap = $archiveConfig['bronEigenschap'] ?? null; + if ($bronEigenschap !== null && isset($objectData[$bronEigenschap]) === true) { + try { + return new DateTime($objectData[$bronEigenschap]); + } catch (Exception $e) { + $this->logger->warning( + '[RetentionService] Cannot parse bronEigenschap date: '.$objectData[$bronEigenschap] + ); + } + } + return null; + + case 'afgehandeld': + case 'termijn': + // Check for closure date via configured closure field. + $closureField = $archiveConfig['closureField'] ?? null; + if ($closureField !== null && isset($objectData[$closureField]) === true) { + try { + return new DateTime($objectData[$closureField]); + } catch (Exception $e) { + $this->logger->warning( + '[RetentionService] Cannot parse closure date: '.$objectData[$closureField] + ); + } + } + + // Fallback: use current date (object creation). + return null; + + default: + return null; + }//end switch + }//end determineBrondatum() + + /** + * Recalculate archiefactiedatum when a source property changes. + * + * @param ObjectEntity $object The object being updated + * @param Schema $schema The schema + * @param array $oldObject The previous object data + * + * @return ObjectEntity The object with recalculated dates + */ + public function recalculateArchiefactiedatum( + ObjectEntity $object, + Schema $schema, + array $oldObject + ): ObjectEntity { + $archiveConfig = $schema->getArchive(); + + if (empty($archiveConfig) === true || ($archiveConfig['enabled'] ?? false) === false) { + return $object; + } + + $retention = $object->getRetention() ?? []; + + // Skip if no archival metadata present. + if (empty($retention['archiefnominatie']) === true) { + return $object; + } + + // Skip if in immutable status. + if (in_array($retention['archiefstatus'] ?? '', self::IMMUTABLE_STATUSES, true) === true) { + return $object; + } + + $bewaartermijn = $retention['bewaartermijn'] ?? null; + if ($bewaartermijn === null) { + return $object; + } + + // Check if the source property changed. + $afleidingswijze = $archiveConfig['afleidingswijze'] ?? 'afgehandeld'; + $propertyChanged = false; + + if ($afleidingswijze === 'eigenschap') { + $bronEigenschap = $archiveConfig['bronEigenschap'] ?? null; + if ($bronEigenschap !== null) { + $newData = $object->getObject(); + $oldVal = $oldObject[$bronEigenschap] ?? null; + $newVal = $newData[$bronEigenschap] ?? null; + $propertyChanged = ($oldVal !== $newVal); + } + } else if (in_array($afleidingswijze, ['afgehandeld', 'termijn'], true) === true) { + $closureField = $archiveConfig['closureField'] ?? null; + if ($closureField !== null) { + $newData = $object->getObject(); + $oldVal = $oldObject[$closureField] ?? null; + $newVal = $newData[$closureField] ?? null; + $propertyChanged = ($oldVal !== $newVal); + } + } + + if ($propertyChanged === false) { + return $object; + } + + $oldDate = $retention['archiefactiedatum'] ?? null; + $newDate = $this->calculateArchiefactiedatum(object: $object, schema: $schema, bewaartermijn: $bewaartermijn); + + if ($newDate !== null && $newDate !== $oldDate) { + $retention['archiefactiedatum'] = $newDate; + $object->setRetention($retention); + + $this->logger->info( + '[RetentionService] Recalculated archiefactiedatum for object '.$object->getUuid().': '.$oldDate.' -> '.$newDate + ); + } + + return $object; + }//end recalculateArchiefactiedatum() + + /** + * Look up a selectielijst entry by categorie code. + * + * @param string $categorie The selectielijst category code (e.g., B1, A1) + * + * @return array|null The selectielijst entry data or null if not found + */ + public function lookupSelectielijstEntry(string $categorie): ?array + { + $settings = $this->settingsHandler->getArchivalSettingsOnly(); + + $registerId = $settings['selectielijstRegister'] ?? null; + $schemaId = $settings['selectielijstSchema'] ?? null; + + if ($registerId === null || $schemaId === null) { + return null; + } + + try { + $register = $this->registerMapper->find((int) $registerId); + $schema = $this->schemaMapper->find((int) $schemaId); + + $results = $this->objectMapper->findAll( + limit: 1, + filters: ['object->categorie' => $categorie], + register: $register, + schema: $schema + ); + + if (empty($results) === true) { + return null; + } + + $entry = $results[0]; + return $entry->getObject(); + } catch (Exception $e) { + $this->logger->warning( + '[RetentionService] Failed to lookup selectielijst entry for '.$categorie, + ['exception' => $e] + ); + return null; + }//end try + }//end lookupSelectielijstEntry() + + /** + * Validate that an object is not in an immutable archival status. + * + * @param ObjectEntity $object The object to check + * + * @return string|null Error code if immutable, null if mutable + */ + public function validateNotImmutable(ObjectEntity $object): ?string + { + $retention = $object->getRetention() ?? []; + $status = $retention['archiefstatus'] ?? null; + + if ($status === 'vernietigd') { + return 'OBJECT_DESTROYED'; + } + + if ($status === 'overgebracht') { + return 'OBJECT_TRANSFERRED'; + } + + return null; + }//end validateNotImmutable() + + /** + * Place a legal hold on an object. + * + * @param ObjectEntity $object The object to place hold on + * @param string $reason The reason for the legal hold + * + * @return ObjectEntity The object with legal hold applied + */ + public function placeLegalHold(ObjectEntity $object, string $reason): ObjectEntity + { + $retention = $object->getRetention() ?? []; + $user = $this->userSession->getUser(); + $userId = $user !== null ? $user->getUID() : 'system'; + + $retention['legalHold'] = [ + 'active' => true, + 'reason' => $reason, + 'placedBy' => $userId, + 'placedDate' => (new DateTime())->format('c'), + 'history' => $retention['legalHold']['history'] ?? [], + ]; + + $object->setRetention($retention); + + return $object; + }//end placeLegalHold() + + /** + * Release a legal hold on an object. + * + * @param ObjectEntity $object The object to release hold from + * @param string $reason The reason for releasing the hold + * + * @return ObjectEntity The object with legal hold released + */ + public function releaseLegalHold(ObjectEntity $object, string $reason): ObjectEntity + { + $retention = $object->getRetention() ?? []; + $legalHold = $retention['legalHold'] ?? null; + + if ($legalHold === null || ($legalHold['active'] ?? false) === false) { + return $object; + } + + $user = $this->userSession->getUser(); + $userId = $user !== null ? $user->getUID() : 'system'; + + // Move current hold to history. + $historyEntry = [ + 'reason' => $legalHold['reason'] ?? '', + 'placedBy' => $legalHold['placedBy'] ?? '', + 'placedDate' => $legalHold['placedDate'] ?? '', + 'releasedBy' => $userId, + 'releasedDate' => (new DateTime())->format('c'), + 'releaseReason' => $reason, + ]; + + $history = $legalHold['history'] ?? []; + $history[] = $historyEntry; + + $retention['legalHold'] = [ + 'active' => false, + 'history' => $history, + ]; + + $object->setRetention($retention); + + return $object; + }//end releaseLegalHold() + + /** + * Check if an object has an active legal hold. + * + * @param ObjectEntity $object The object to check + * + * @return bool True if object has active legal hold + */ + public function hasActiveLegalHold(ObjectEntity $object): bool + { + $retention = $object->getRetention() ?? []; + return ($retention['legalHold']['active'] ?? false) === true; + }//end hasActiveLegalHold() + + /** + * Extend archiefactiedatum by a period for excluded/rejected objects. + * + * @param ObjectEntity $object The object to extend + * @param string|null $extensionPeriod ISO 8601 duration (default from settings) + * + * @return ObjectEntity The object with extended archiefactiedatum + */ + public function extendArchiefactiedatum(ObjectEntity $object, ?string $extensionPeriod=null): ObjectEntity + { + $retention = $object->getRetention() ?? []; + + if (empty($retention['archiefactiedatum']) === true) { + return $object; + } + + if ($extensionPeriod === null) { + $settings = $this->settingsHandler->getArchivalSettingsOnly(); + $extensionPeriod = $settings['defaultExtensionPeriod'] ?? 'P1Y'; + } + + try { + $date = new DateTime($retention['archiefactiedatum']); + $date->add(new DateInterval($extensionPeriod)); + $retention['archiefactiedatum'] = $date->format('Y-m-d'); + + // Store original date if not already stored. + if (empty($retention['originalArchiefactiedatum']) === true) { + $retention['originalArchiefactiedatum'] = $retention['archiefactiedatum']; + } + + $object->setRetention($retention); + } catch (Exception $e) { + $this->logger->warning( + '[RetentionService] Failed to extend archiefactiedatum: '.$e->getMessage() + ); + } + + return $object; + }//end extendArchiefactiedatum() + + /** + * Find objects eligible for destruction. + * + * Objects with archiefactiedatum < now, archiefnominatie = vernietigen, + * archiefstatus = nog_te_archiveren, no active legal hold, and not already + * on a pending destruction list. + * + * @param array $excludeUuids UUIDs to exclude (already on pending lists) + * + * @return ObjectEntity[] Array of eligible objects + */ + public function findEligibleForDestruction(array $excludeUuids=[]): array + { + $today = (new DateTime())->format('Y-m-d'); + + try { + // Query objects with archival metadata indicating destruction eligibility. + $connection = \OC::$server->getDatabaseConnection(); + $qb = $connection->getQueryBuilder(); + + $qb->select('id') + ->from('openregister_objects') + ->where( + $qb->expr()->isNotNull('retention') + ); + + $result = $qb->executeQuery(); + $rows = $result->fetchAllAssociative(); + $result->free(); + + $eligible = []; + + foreach ($rows as $row) { + try { + $object = $this->objectMapper->find(intval($row['id']), null, null, false, false, false); + } catch (Exception $e) { + continue; + } + + $retention = $object->getRetention() ?? []; + + // Check eligibility criteria. + if (($retention['archiefnominatie'] ?? '') !== 'vernietigen') { + continue; + } + + if (($retention['archiefstatus'] ?? '') !== 'nog_te_archiveren') { + continue; + } + + $actiedatum = $retention['archiefactiedatum'] ?? null; + if ($actiedatum === null || $actiedatum > $today) { + continue; + } + + // Skip objects with active legal hold. + if (($retention['legalHold']['active'] ?? false) === true) { + continue; + } + + // Skip objects already on pending lists. + if (in_array($object->getUuid(), $excludeUuids, true) === true) { + continue; + } + + $eligible[] = $object; + }//end foreach + + return $eligible; + } catch (Exception $e) { + $this->logger->error( + '[RetentionService] Failed to find eligible objects for destruction: '.$e->getMessage(), + ['exception' => $e] + ); + return []; + }//end try + }//end findEligibleForDestruction() + + /** + * Get UUIDs of objects already on pending destruction lists. + * + * @return string[] Array of object UUIDs + */ + public function getObjectsOnPendingDestructionLists(): array + { + $settings = $this->settingsHandler->getArchivalSettingsOnly(); + + $registerId = $settings['destructionListRegister'] ?? null; + $schemaId = $settings['destructionListSchema'] ?? null; + + if ($registerId === null || $schemaId === null) { + return []; + } + + try { + $register = $this->registerMapper->find((int) $registerId); + $schema = $this->schemaMapper->find((int) $schemaId); + + $pendingLists = $this->objectMapper->findAll( + filters: [ + 'object->status' => ['in_review', 'approved', 'awaiting_second_approval'], + ], + register: $register, + schema: $schema + ); + + $uuids = []; + foreach ($pendingLists as $list) { + $listData = $list->getObject(); + $objects = $listData['objects'] ?? []; + foreach ($objects as $obj) { + $uuid = $obj['uuid'] ?? null; + if ($uuid !== null) { + $uuids[] = $uuid; + } + } + } + + return array_unique($uuids); + } catch (Exception $e) { + $this->logger->warning( + '[RetentionService] Failed to get pending destruction list objects: '.$e->getMessage() + ); + return []; + }//end try + }//end getObjectsOnPendingDestructionLists() + + /** + * Create a destruction list as a register object. + * + * @param ObjectEntity[] $objects The objects to include in the destruction list + * + * @return array|null The destruction list data or null on failure + */ + public function createDestructionList(array $objects): ?array + { + $settings = $this->settingsHandler->getArchivalSettingsOnly(); + + $registerId = $settings['destructionListRegister'] ?? null; + $schemaId = $settings['destructionListSchema'] ?? null; + + if ($registerId === null || $schemaId === null) { + $this->logger->warning( + '[RetentionService] Cannot create destruction list: register/schema not configured' + ); + return null; + } + + $user = $this->userSession->getUser(); + $userId = $user !== null ? $user->getUID() : 'system'; + + $objectEntries = []; + foreach ($objects as $object) { + $retention = $object->getRetention() ?? []; + // Detect WOO-published status from object data or metadata. + $objectData = $object->getObject() ?? []; + $isWooPublished = ($objectData['woo_gepubliceerd'] ?? false) === true + || ($objectData['publicatiestatus'] ?? null) === 'gepubliceerd' + || ($retention['wooPublished'] ?? false) === true; + + $objectEntries[] = [ + 'uuid' => $object->getUuid(), + 'title' => $object->getTitle() ?? $object->getUuid(), + 'schema' => $object->getSchema(), + 'register' => $object->getRegister(), + 'archiefactiedatum' => $retention['archiefactiedatum'] ?? null, + 'classificatie' => $retention['classificatie'] ?? null, + 'softDeleted' => $object->getDeleted() !== null, + 'wooGepubliceerd' => $isWooPublished, + ]; + } + + return [ + 'status' => 'in_review', + 'createdBy' => $userId, + 'createdAt' => (new DateTime())->format('c'), + 'objects' => $objectEntries, + 'excluded' => [], + 'approvals' => [], + ]; + }//end createDestructionList() + + /** + * Generate a destruction certificate after execution. + * + * @param array $destructionList The destruction list data + * @param int $destroyedCount Number of objects destroyed + * @param string $executedAt ISO 8601 timestamp of execution + * + * @return array The destruction certificate data + */ + public function generateDestructionCertificate( + array $destructionList, + int $destroyedCount, + string $executedAt + ): array { + // Group destroyed objects by schema and classificatie. + $grouped = []; + foreach ($destructionList['objects'] ?? [] as $obj) { + $key = ($obj['schema'] ?? 'unknown').'/'.($obj['classificatie'] ?? 'unknown'); + if (isset($grouped[$key]) === false) { + $grouped[$key] = [ + 'schema' => $obj['schema'] ?? 'unknown', + 'classificatie' => $obj['classificatie'] ?? 'unknown', + 'count' => 0, + ]; + } + + $grouped[$key]['count']++; + } + + return [ + 'type' => 'verklaring_van_vernietiging', + 'destructionDate' => $executedAt, + 'approvedBy' => array_column($destructionList['approvals'] ?? [], 'userId'), + 'destructionListUuid' => $destructionList['uuid'] ?? null, + 'totalDestroyed' => $destroyedCount, + 'groupedBySchema' => array_values($grouped), + 'selectielijstBron' => $this->extractSelectielijstBron(destructionList: $destructionList), + 'complianceStatement' => 'Vernietiging conform Archiefwet 1995 en Archiefbesluit 1995', + 'immutable' => true, + ]; + }//end generateDestructionCertificate() + + /** + * Extract selectielijst bron references from a destruction list. + * + * @param array $destructionList The destruction list data + * + * @return string[] Unique selectielijst bron references + */ + private function extractSelectielijstBron(array $destructionList): array + { + $bronnen = []; + foreach ($destructionList['objects'] ?? [] as $obj) { + $bron = $obj['selectielijstBron'] ?? null; + if ($bron !== null) { + $bronnen[] = $bron; + } + } + + return array_values(array_unique($bronnen)); + }//end extractSelectielijstBron() +}//end class diff --git a/lib/Service/Settings/ObjectRetentionHandler.php b/lib/Service/Settings/ObjectRetentionHandler.php index 3ec8b5081..1ff399819 100644 --- a/lib/Service/Settings/ObjectRetentionHandler.php +++ b/lib/Service/Settings/ObjectRetentionHandler.php @@ -252,6 +252,90 @@ public function updateRetentionSettingsOnly(array $retentionData): array } }//end updateRetentionSettingsOnly() + /** + * Get archival settings (destruction scheduling, selectielijst config, etc.) + * + * @return array Archival configuration settings + * + * @throws \RuntimeException If archival settings retrieval fails + */ + public function getArchivalSettingsOnly(): array + { + try { + $archivalConfig = $this->appConfig->getValueString($this->appName, 'archival', ''); + + if (empty($archivalConfig) === true) { + return $this->getArchivalDefaults(); + } + + $archivalData = json_decode($archivalConfig, true); + return [ + 'destructionCheckInterval' => $archivalData['destructionCheckInterval'] ?? 86400, + 'notificationLeadDays' => $archivalData['notificationLeadDays'] ?? 30, + 'defaultExtensionPeriod' => $archivalData['defaultExtensionPeriod'] ?? 'P1Y', + 'destructionBatchSize' => $archivalData['destructionBatchSize'] ?? 50, + 'selectielijstRegister' => $archivalData['selectielijstRegister'] ?? null, + 'selectielijstSchema' => $archivalData['selectielijstSchema'] ?? null, + 'destructionListRegister' => $archivalData['destructionListRegister'] ?? null, + 'destructionListSchema' => $archivalData['destructionListSchema'] ?? null, + 'archivalRegister' => $archivalData['archivalRegister'] ?? null, + ]; + } catch (Exception $e) { + throw new RuntimeException('Failed to retrieve archival settings: '.$e->getMessage()); + }//end try + }//end getArchivalSettingsOnly() + + /** + * Update archival settings + * + * @param array $archivalData Archival configuration data + * + * @return array Updated archival configuration + * + * @throws \RuntimeException If archival settings update fails + */ + public function updateArchivalSettingsOnly(array $archivalData): array + { + try { + $archivalConfig = [ + 'destructionCheckInterval' => $archivalData['destructionCheckInterval'] ?? 86400, + 'notificationLeadDays' => $archivalData['notificationLeadDays'] ?? 30, + 'defaultExtensionPeriod' => $archivalData['defaultExtensionPeriod'] ?? 'P1Y', + 'destructionBatchSize' => $archivalData['destructionBatchSize'] ?? 50, + 'selectielijstRegister' => $archivalData['selectielijstRegister'] ?? null, + 'selectielijstSchema' => $archivalData['selectielijstSchema'] ?? null, + 'destructionListRegister' => $archivalData['destructionListRegister'] ?? null, + 'destructionListSchema' => $archivalData['destructionListSchema'] ?? null, + 'archivalRegister' => $archivalData['archivalRegister'] ?? null, + ]; + + $this->appConfig->setValueString($this->appName, 'archival', json_encode($archivalConfig)); + return $archivalConfig; + } catch (Exception $e) { + throw new RuntimeException('Failed to update archival settings: '.$e->getMessage()); + } + }//end updateArchivalSettingsOnly() + + /** + * Get default archival settings + * + * @return array Default archival configuration + */ + private function getArchivalDefaults(): array + { + return [ + 'destructionCheckInterval' => 86400, + 'notificationLeadDays' => 30, + 'defaultExtensionPeriod' => 'P1Y', + 'destructionBatchSize' => 50, + 'selectielijstRegister' => null, + 'selectielijstSchema' => null, + 'destructionListRegister' => null, + 'destructionListSchema' => null, + 'archivalRegister' => null, + ]; + }//end getArchivalDefaults() + /** * Get version information only * diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index 3908b0e56..3ccc06b3d 100644 --- a/lib/Service/SettingsService.php +++ b/lib/Service/SettingsService.php @@ -544,6 +544,28 @@ public function updateRetentionSettingsOnly(array $data): array return $this->objectRetentionHandler->updateRetentionSettingsOnly($data); }//end updateRetentionSettingsOnly() + /** + * Get archival settings only + * + * @return array Archival settings + */ + public function getArchivalSettingsOnly(): array + { + return $this->objectRetentionHandler->getArchivalSettingsOnly(); + }//end getArchivalSettingsOnly() + + /** + * Update archival settings only + * + * @param array $data Archival settings data + * + * @return array Updated archival settings + */ + public function updateArchivalSettingsOnly(array $data): array + { + return $this->objectRetentionHandler->updateArchivalSettingsOnly($data); + }//end updateArchivalSettingsOnly() + // CacheSettingsHandler methods (3 main ones). /** diff --git a/openspec/changes/archive/2026-03-22-retention-management/.openspec.yaml b/openspec/changes/archive/2026-03-22-retention-management/.openspec.yaml new file mode 100644 index 000000000..caac5173b --- /dev/null +++ b/openspec/changes/archive/2026-03-22-retention-management/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-22 diff --git a/openspec/changes/archive/2026-03-22-retention-management/design.md b/openspec/changes/archive/2026-03-22-retention-management/design.md new file mode 100644 index 000000000..a008148af --- /dev/null +++ b/openspec/changes/archive/2026-03-22-retention-management/design.md @@ -0,0 +1,97 @@ +## Context + +OpenRegister currently has rudimentary retention infrastructure: +- `ObjectEntity.retention` — a JSON field storing soft-delete metadata (deletedAt, deletedBy, retentionPeriod, purgeDate) +- `Schema.archive` — a JSON field for archive configuration (currently unused beyond storage) +- `ObjectRetentionHandler` — manages app-level retention settings (log retention periods, audit trail toggles) + +These fields exist but lack the archival lifecycle management required by the Archiefwet 1995: selectielijsten, automated destruction scheduling, approval workflows, legal holds, and e-Depot transfer. The existing `archivering-vernietiging` spec defines the full vision; this change implements the core backend capabilities. + +Constraints: +- Must use Nextcloud OCP interfaces (INotification, BackgroundJob, IAppConfig) +- Must integrate with existing ObjectService CRUD flow and audit trail system +- Must not break existing retention/soft-delete behavior +- PostgreSQL with JSON column querying for retention metadata filtering + +## Goals / Non-Goals + +**Goals:** +- Extend ObjectEntity.retention with MDTO-compliant archival metadata +- Implement selectielijsten as register objects (self-referential: managed within OpenRegister) +- Build automated destruction lifecycle: scheduling, destruction lists, multi-step approval, execution +- Add legal hold support preventing destruction of held objects +- Provide pre-destruction notifications via Nextcloud INotification +- Generate destruction certificates as immutable register objects +- Add retention settings API endpoints for archival configuration + +**Non-Goals:** +- e-Depot SIP export (deferred to follow-up change — requires OpenConnector integration and MDTO XML serialization) +- Frontend UI for destruction list management (API-first; UI comes separately) +- WOO publication interaction rules (requires WOO publication system to exist first) +- MDTO XML export format (complex serialization, separate change) + +## Decisions + +### 1. Selectielijsten as register objects (not config tables) + +**Decision**: Store selectielijst entries as regular OpenRegister objects in a designated register/schema, not as separate database tables. + +**Rationale**: This reuses the existing ObjectService CRUD, search, audit trail, and API infrastructure. Selectielijsten are essentially structured data — exactly what OpenRegister manages. This also allows version management via object versioning and import via existing data import mechanisms. + +**Alternative considered**: Dedicated `selectielijst_entries` table — rejected because it duplicates CRUD, search, and audit infrastructure that already exists. + +### 2. Destruction lists as register objects + +**Decision**: Destruction lists are register objects containing references (UUIDs) to the objects they cover, with status tracking (in_review, approved, rejected, executed). + +**Rationale**: Same reasoning as selectielijsten — reuse existing infrastructure. Destruction lists become searchable, auditable, and API-accessible without new endpoints. The destruction certificate is also a register object, ensuring permanent retention. + +### 3. Background job pattern: TimedJob for scanning, QueuedJob for execution + +**Decision**: `DestructionCheckJob` extends `OCP\BackgroundJob\TimedJob` for periodic scanning. `DestructionExecutionJob` extends `OCP\BackgroundJob\QueuedJob` for processing approved destruction lists. + +**Rationale**: TimedJob runs on a configurable schedule (daily default). QueuedJob prevents timeout issues when destroying many objects. This separates identification (automated) from execution (triggered after approval). + +### 4. Legal hold stored in retention JSON field + +**Decision**: Legal hold data stored as a nested object within `ObjectEntity.retention.legalHold` rather than a separate column. + +**Rationale**: The retention JSON field already exists and is queried via PostgreSQL JSON operators. Adding a nested object avoids a database migration for a new column while keeping all archival metadata co-located. The `legalHold.history[]` array preserves the full hold/release lifecycle. + +### 5. RetentionService as orchestrator + +**Decision**: New `RetentionService` class orchestrates archival operations (metadata calculation, destruction scheduling, legal hold management). It delegates to existing `ObjectService` for persistence and audit trail creation. + +**Rationale**: Follows the existing service layer pattern (RegisterService, SchemaService, ObjectService). Keeps archival business logic separated from generic object CRUD. + +### 6. Archiefactiedatum calculation via afleidingswijze configuration + +**Decision**: The afleidingswijze (derivation method) is configured per schema in `Schema.archive.afleidingswijze` with options: `afgehandeld` (from closure date), `eigenschap` (from a named property), `termijn` (closure + process term + retention), `ingangsdatum_besluit` (from decision start date), `vervaldatum` (from expiry date). + +**Rationale**: Aligns with ZGW API standard afleidingswijzen used by OpenZaak. Configuration at schema level means all objects of a type use the same derivation method, with per-object override possible via direct retention field updates. + +## Risks / Trade-offs + +**[Risk] Large destruction lists may timeout** — Mitigation: QueuedJob processes objects in batches (configurable batch size, default 50). Each batch is a separate job execution. + +**[Risk] Concurrent legal hold and destruction approval race condition** — Mitigation: DestructionExecutionJob re-checks legal hold status at execution time, not just at approval time. Objects with holds placed after approval are automatically excluded. + +**[Risk] Selectielijst version transitions affect existing objects** — Mitigation: Objects store the selectielijst version reference at creation time. New versions only apply to new objects. A reporting endpoint shows objects grouped by selectielijst version. + +**[Risk] JSON field querying performance for retention metadata** — Mitigation: PostgreSQL GIN index on retention JSON field for efficient filtering. Destruction check job uses indexed queries. + +**[Trade-off] No dedicated destruction list UI** — This change is API-first. The admin manages destruction lists via API or through n8n workflows. A dedicated UI is a follow-up concern. + +## Migration Plan + +1. Database migration extends retention JSON structure (non-breaking — adds optional fields to existing JSON) +2. Schema.archive extended with new configuration keys (non-breaking — new optional keys) +3. Register background jobs in `info.xml` +4. Deploy RetentionService, DestructionCheckJob, DestructionExecutionJob +5. Admin configures selectielijst register/schema and initial entries +6. Rollback: Remove background jobs from info.xml, services are unused without configuration + +## Open Questions + +- Should the destruction approval role be a Nextcloud group or a custom RBAC role from the authorization system? (Decision: Use Nextcloud group `archivaris` initially, integrate with RBAC when that system matures) +- What is the minimum batch size for destruction execution to balance throughput vs. memory? (Decision: Default 50, configurable via settings) diff --git a/openspec/changes/archive/2026-03-22-retention-management/proposal.md b/openspec/changes/archive/2026-03-22-retention-management/proposal.md new file mode 100644 index 000000000..4442c8001 --- /dev/null +++ b/openspec/changes/archive/2026-03-22-retention-management/proposal.md @@ -0,0 +1,36 @@ +## Why + +Dutch government organisations are legally required to manage retention periods (bewaartermijnen) and destruction of records per the Archiefwet 1995. OpenRegister already has a basic `retention` field on ObjectEntity and an `archive` property on Schema, but lacks the actual lifecycle management: selectielijsten configuration, automated destruction scheduling, multi-step approval workflows, legal holds, and e-Depot transfer. Market intelligence shows 77% of government tenders require archiving and destruction capabilities, making this the most demanded missing feature. + +## What Changes + +- Add MDTO-compliant archival metadata fields to the object `retention` property (archiefnominatie, archiefactiedatum, archiefstatus, classificatie, bewaartermijn) +- Implement selectielijsten as configurable register objects mapping object types to retention periods and archival actions +- Add configurable afleidingswijzen (derivation methods) for calculating archiefactiedatum from various source dates +- Create a `DestructionCheckJob` background job that generates destruction lists from objects past their archiefactiedatum +- Implement multi-step destruction approval workflow with archivist roles, partial rejection, and two-step approval for sensitive schemas +- Add legal hold (bevriezing) support to exempt objects from destruction processes +- Generate destruction certificates (verklaring van vernietiging) as immutable archival records +- Add pre-destruction notification system using Nextcloud INotification +- Implement e-Depot export via SIP packages with MDTO XML metadata +- Add cascading destruction rules that integrate with existing referential integrity +- Handle WOO-published objects with extended retention and explicit destruction confirmation +- Extend retention settings API with archival configuration endpoints + +## Capabilities + +### New Capabilities +- `retention-management`: Core retention lifecycle — MDTO metadata on objects, selectielijsten configuration, archiefactiedatum calculation via afleidingswijzen, automated destruction scheduling via background jobs, multi-step approval workflows, legal holds, destruction certificates, pre-destruction notifications, and retention settings API + +### Modified Capabilities +- `archivering-vernietiging`: Extends the existing archiving/destruction spec with concrete implementation details for e-Depot SIP export, cascading destruction with referential integrity, and WOO publication interaction rules + +## Impact + +- **Database**: New columns/JSON fields on ObjectEntity.retention (archiefnominatie, archiefstatus, archiefactiedatum, classificatie, bewaartermijn, legalHold); Schema.archive extended with default retention config +- **API**: New endpoints for selectielijst CRUD, destruction list management, legal hold operations, e-Depot configuration, retention settings; existing object endpoints expose archival metadata +- **Background Jobs**: New `DestructionCheckJob` (TimedJob) and `DestructionExecutionJob` (QueuedJob) for scheduled processing +- **Services**: New RetentionService, DestructionService, SelectielijstService; extends ObjectRetentionHandler +- **Notifications**: INotification integration for pre-destruction warnings and destruction list review requests +- **Dependencies**: Impacts opencatalogi (objects carry retention metadata in search/listing), docudesk (PDF/A renditions for SIP packages) +- **Migrations**: Database migration for extended retention JSON structure diff --git a/openspec/changes/archive/2026-03-22-retention-management/specs/archivering-vernietiging/spec.md b/openspec/changes/archive/2026-03-22-retention-management/specs/archivering-vernietiging/spec.md new file mode 100644 index 000000000..2694e78b8 --- /dev/null +++ b/openspec/changes/archive/2026-03-22-retention-management/specs/archivering-vernietiging/spec.md @@ -0,0 +1,153 @@ +## MODIFIED Requirements + +### Requirement: Objects MUST carry MDTO-compliant archival metadata +Each object MUST carry archival metadata fields conforming to the MDTO standard (Metagegevens Duurzaam Toegankelijke Overheidsinformatie), ensuring durable accessibility and legal compliance with the Archiefwet 1995 Article 3. These fields MUST be stored in the object's `retention` property and exposed via the API. + +#### Scenario: Archival metadata populated on object creation +- **GIVEN** a schema `zaakdossier` with archival metadata enabled via the schema's `archive` configuration +- **WHEN** a new zaakdossier object is created +- **THEN** the system MUST store the following archival metadata in the object's `retention` field: + - `archiefnominatie`: one of `vernietigen`, `bewaren`, `nog_niet_bepaald` + - `archiefactiedatum`: the ISO 8601 date on which the archival action MUST be taken + - `archiefstatus`: one of `nog_te_archiveren`, `gearchiveerd`, `vernietigd`, `overgebracht` + - `classificatie`: the selectielijst category code (e.g., `1.1`, `B1`) + - `bewaartermijn`: the retention period in ISO 8601 duration format (e.g., `P5Y`, `P20Y`) +- **AND** `archiefnominatie` MUST default to `nog_niet_bepaald` if not explicitly set +- **AND** `archiefstatus` MUST default to `nog_te_archiveren` +- **AND** if a selectielijst mapping exists for the schema's `archive.classificatie`, the system MUST apply the selectielijst entry's `bewaartermijn` and `archiefnominatie` automatically + +#### Scenario: Archival metadata defaults from schema archive configuration +- **GIVEN** schema `vergunning-aanvraag` has `archive.defaultNominatie` set to `bewaren` and `archive.defaultBewaartermijn` set to `P20Y` +- **WHEN** a new object is created in this schema without explicit archival metadata +- **THEN** `archiefnominatie` MUST be set to `bewaren` +- **AND** `bewaartermijn` MUST be set to `P20Y` +- **AND** `archiefactiedatum` MUST be calculated as the object's creation date plus 20 years +- **AND** the afleidingswijze used for calculation MUST be determined by `archive.afleidingswijze` (defaulting to creation date if not configured) + +#### Scenario: Archival metadata validation on update +- **GIVEN** an object with `archiefstatus` set to `vernietigd` +- **WHEN** a user attempts to update the object's data +- **THEN** the system MUST reject the update with HTTP 409 Conflict +- **AND** the response MUST indicate that destroyed objects cannot be modified +- **AND** the same restriction MUST apply to objects with `archiefstatus` `overgebracht` + +#### Scenario: Archival metadata exposed in API responses +- **GIVEN** an object `zaak-123` with archival metadata populated +- **WHEN** the object is retrieved via `GET /api/objects/{register}/{schema}/{id}` +- **THEN** the response MUST include the `retention` field containing all MDTO archival metadata +- **AND** the `retention` field MUST be filterable in search queries (e.g., `retention.archiefnominatie=vernietigen`) +- **AND** the `retention.legalHold.active` field MUST also be filterable + +### Requirement: The system MUST support configurable selectielijsten (selection lists) +Administrators MUST be able to configure selectielijsten that map object types or zaaktypen to retention periods and archival actions, conforming to the Selectielijst gemeenten en intergemeentelijke organen (VNG) or custom organisational selection lists. Selectielijsten MUST be manageable as register objects within OpenRegister itself. + +#### Scenario: Configure a selectielijst entry +- **GIVEN** an admin configuring archival rules in a register designated for selectielijst management +- **WHEN** they create a selectielijst entry with: + - `categorie`: `B1` + - `omschrijving`: `Vergunningen met beperkte looptijd` + - `bewaartermijn`: `P5Y` + - `archiefnominatie`: `vernietigen` + - `bron`: `Selectielijst gemeenten 2020` + - `toelichting`: `Na verloop van de vergunning` +- **THEN** all objects mapped to category B1 MUST use these retention rules when their `archiefactiedatum` is calculated +- **AND** the selectielijst register and schema MUST be configured in retention settings + +#### Scenario: Import VNG selectielijst +- **GIVEN** the VNG publishes an updated selectielijst for gemeenten +- **WHEN** an admin imports the selectielijst via CSV or JSON upload using the existing data import mechanism +- **THEN** all categories MUST be created as objects in the selectielijst register +- **AND** existing categories MUST be updated (not duplicated) based on their `categorie` code +- **AND** the import MUST log how many entries were created, updated, and skipped + +#### Scenario: Override selectielijst per schema +- **GIVEN** a default retention of 10 years for selectielijst category `A1` +- **AND** schema `vertrouwelijk-dossier` requires 20 years retention due to organisational policy +- **WHEN** the admin configures a schema-level override in the schema's `archive` property with `bewaartermijnOverride` and `overrideReason` +- **THEN** objects in `vertrouwelijk-dossier` MUST use the 20-year retention period +- **AND** the override MUST be recorded in the audit trail with the reason for deviation + +#### Scenario: Selectielijst version management +- **GIVEN** the VNG publishes a new version of the selectielijst (e.g., 2025 edition replacing 2020 edition) +- **WHEN** the admin activates the new selectielijst version +- **THEN** existing objects MUST retain their original selectielijst reference (no retroactive changes) +- **AND** new objects MUST use the new selectielijst version +- **AND** the admin MUST be able to run a report showing objects under the old vs. new selectielijst + +### Requirement: Destruction MUST follow a multi-step approval workflow +Destruction of objects MUST NOT occur automatically. A destruction list MUST be reviewed and approved by at least one authorized archivist before any objects are permanently deleted, conforming to Archiefbesluit 1995 Articles 6-8. + +#### Scenario: Approve destruction list (full approval) +- **GIVEN** a destruction list with 15 objects and status `in_review` +- **WHEN** an archivist in the `archivaris` Nextcloud group approves the entire list +- **THEN** the destruction list status MUST change to `approved` +- **AND** the system MUST permanently delete all 15 objects via a `DestructionExecutionJob` (QueuedJob) processing in batches to avoid timeouts +- **AND** an audit trail entry MUST be created for each deletion with action `archival.destroyed` +- **AND** the audit trail entry MUST record: destruction list UUID, approving archivist, timestamp, selectielijst category +- **AND** the destruction list itself MUST be retained permanently as an archival record (verklaring van vernietiging) + +#### Scenario: Partially reject destruction list +- **GIVEN** a destruction list with 15 objects +- **WHEN** the archivist removes 3 objects from the list (marking them as `uitgezonderd`) and approves the remaining 12 +- **THEN** only the 12 approved objects MUST be destroyed +- **AND** the 3 excluded objects MUST have their `archiefactiedatum` extended by the configured extension period (default: P1Y) +- **AND** the exclusion reason MUST be recorded for each excluded object +- **AND** the destruction list MUST record both the approved and excluded objects + +#### Scenario: Reject entire destruction list +- **GIVEN** a destruction list with 15 objects +- **WHEN** the archivist rejects the entire list +- **THEN** no objects MUST be destroyed +- **AND** the destruction list status MUST change to `rejected` +- **AND** the archivist MUST provide a reason for rejection +- **AND** all objects on the list MUST have their `archiefactiedatum` extended by the configured extension period + +#### Scenario: Two-step approval for sensitive schemas +- **GIVEN** schema `bezwaarschriften` is configured with `archive.requireDualApproval` set to `true` +- **AND** a destruction list contains objects from this schema +- **WHEN** the first archivist approves the list +- **THEN** the status MUST change to `awaiting_second_approval` +- **AND** a second archivist (different from the first) MUST approve before destruction proceeds + +#### Scenario: Destruction certificate generation (verklaring van vernietiging) +- **GIVEN** a destruction list has been fully approved and all objects destroyed +- **WHEN** the destruction process completes +- **THEN** the system MUST generate a destruction certificate as an immutable register object containing: + - Date of destruction + - Approving archivist(s) + - Number of objects destroyed, grouped by schema and selectielijst category + - Reference to the selectielijst used + - Statement of compliance with Archiefwet 1995 +- **AND** the certificate MUST be stored in the configured archival register + +### Requirement: The system MUST support legal holds (bevriezing) +Objects under legal hold MUST be exempt from all destruction processes, regardless of their `archiefactiedatum` or `archiefnominatie`. Legal holds support litigation, WOB/WOO requests, and regulatory investigations. + +#### Scenario: Place legal hold on an object +- **GIVEN** object `zaak-456` has `archiefactiedatum` of 2026-01-01 (in the past) and `archiefnominatie` `vernietigen` +- **WHEN** an authorized user places a legal hold with reason `WOO-verzoek 2025-0142` +- **THEN** the object's `retention` field MUST include `legalHold: { active: true, reason: "WOO-verzoek 2025-0142", placedBy: "user-id", placedDate: "2026-03-19T..." }` +- **AND** the object MUST be excluded from all destruction lists +- **AND** an audit trail entry MUST be created with action `archival.legal_hold_placed` + +#### Scenario: Legal hold prevents destruction even when on destruction list +- **GIVEN** a destruction list containing object `zaak-456` +- **AND** a legal hold is placed on `zaak-456` after the destruction list was created but before execution +- **WHEN** the DestructionExecutionJob processes the destruction list +- **THEN** `zaak-456` MUST be automatically excluded from destruction +- **AND** the archivist MUST be notified that 1 object was excluded due to legal hold + +#### Scenario: Release legal hold +- **GIVEN** object `zaak-456` has an active legal hold +- **WHEN** an authorized user releases the legal hold with reason `WOO-verzoek afgehandeld` +- **THEN** the `legalHold.active` MUST be set to `false` +- **AND** the hold history MUST be preserved in `legalHold.history[]` +- **AND** the object MUST become eligible for destruction again if `archiefactiedatum` has passed +- **AND** an audit trail entry MUST be created with action `archival.legal_hold_released` + +#### Scenario: Bulk legal hold on schema +- **GIVEN** schema `subsidie-aanvragen` contains 200 objects +- **WHEN** an authorized user places a legal hold on all objects in this schema with reason `Rekenkameronderzoek 2026` +- **THEN** all 200 objects MUST receive a legal hold +- **AND** the operation MUST be executed via QueuedJob to avoid timeouts +- **AND** a single audit trail entry MUST summarize the bulk operation diff --git a/openspec/changes/archive/2026-03-22-retention-management/specs/retention-management/spec.md b/openspec/changes/archive/2026-03-22-retention-management/specs/retention-management/spec.md new file mode 100644 index 000000000..fe3d7e4f8 --- /dev/null +++ b/openspec/changes/archive/2026-03-22-retention-management/specs/retention-management/spec.md @@ -0,0 +1,271 @@ +## ADDED Requirements + +### Requirement: Objects MUST carry MDTO-compliant archival metadata in the retention field +Each object MUST carry archival metadata fields conforming to the MDTO standard within the existing `ObjectEntity.retention` JSON field. These fields enable retention lifecycle management per the Archiefwet 1995. + +#### Scenario: Default archival metadata on object creation with archive-enabled schema +- **GIVEN** a schema with `archive.enabled` set to `true` and `archive.defaultNominatie` set to `vernietigen` and `archive.defaultBewaartermijn` set to `P5Y` +- **WHEN** a new object is created without explicit archival metadata +- **THEN** the object's `retention` field MUST include: + - `archiefnominatie`: `vernietigen` + - `archiefstatus`: `nog_te_archiveren` + - `bewaartermijn`: `P5Y` + - `archiefactiedatum`: creation date plus 5 years in ISO 8601 format + - `classificatie`: `null` (until selectielijst mapping is configured) + +#### Scenario: Archival metadata defaults to undetermined when schema has no archive config +- **GIVEN** a schema without `archive` configuration +- **WHEN** a new object is created +- **THEN** the object's `retention` field MUST NOT include archival metadata fields +- **AND** the object MUST behave as before this change (backward compatible) + +#### Scenario: Destroyed objects cannot be modified +- **GIVEN** an object with `retention.archiefstatus` set to `vernietigd` +- **WHEN** a user attempts to update the object's data via PUT or PATCH +- **THEN** the system MUST reject the update with HTTP 409 Conflict +- **AND** the response body MUST include error code `OBJECT_DESTROYED` + +#### Scenario: Transferred objects become read-only +- **GIVEN** an object with `retention.archiefstatus` set to `overgebracht` +- **WHEN** a user attempts to update the object +- **THEN** the system MUST reject the update with HTTP 409 Conflict +- **AND** the response body MUST include error code `OBJECT_TRANSFERRED` + +#### Scenario: Archival metadata exposed in API responses +- **GIVEN** an object with archival metadata populated in `retention` +- **WHEN** the object is retrieved via `GET /api/objects/{register}/{schema}/{id}` +- **THEN** the response MUST include the full `retention` field containing all archival metadata +- **AND** the `retention.archiefnominatie` and `retention.archiefstatus` fields MUST be filterable in list/search queries + +### Requirement: The system MUST support configurable selectielijsten as register objects +Selectielijsten (selection lists) MUST be stored as regular register objects within OpenRegister, mapping object types to retention periods and archival actions per the Selectielijst gemeenten en intergemeentelijke organen (VNG). + +#### Scenario: Create a selectielijst entry +- **GIVEN** a register and schema designated for selectielijst management via app settings +- **WHEN** an admin creates a selectielijst entry object with properties `categorie`, `omschrijving`, `bewaartermijn` (ISO 8601 duration), `archiefnominatie`, `bron`, and `toelichting` +- **THEN** the entry MUST be created as a standard register object +- **AND** the entry MUST be retrievable via the standard object API + +#### Scenario: Map schema to selectielijst category +- **GIVEN** a selectielijst entry with `categorie` `B1` and `bewaartermijn` `P5Y` and `archiefnominatie` `vernietigen` +- **AND** a schema with `archive.classificatie` set to `B1` +- **WHEN** a new object is created in this schema +- **THEN** the system MUST look up the selectielijst entry for `B1` +- **AND** apply `bewaartermijn` `P5Y` and `archiefnominatie` `vernietigen` to the object's `retention` field +- **AND** calculate `archiefactiedatum` based on the configured afleidingswijze + +#### Scenario: Schema-level override of selectielijst retention +- **GIVEN** selectielijst category `A1` has `bewaartermijn` `P10Y` +- **AND** schema `vertrouwelijk-dossier` has `archive.bewaartermijnOverride` set to `P20Y` +- **WHEN** a new object is created in `vertrouwelijk-dossier` +- **THEN** the object MUST use `P20Y` as its `bewaartermijn` instead of the selectielijst's `P10Y` +- **AND** the override MUST be recorded in the audit trail with reason from `archive.overrideReason` + +#### Scenario: Import selectielijst via bulk object creation +- **GIVEN** a CSV file containing VNG selectielijst entries +- **WHEN** an admin imports the file using the existing data import mechanism targeting the selectielijst schema +- **THEN** entries MUST be created or updated (matched on `categorie` code) as standard register objects +- **AND** the import log MUST report created, updated, and skipped counts + +### Requirement: The system MUST calculate archiefactiedatum using configurable afleidingswijzen +The `archiefactiedatum` MUST be calculable from multiple derivation methods configured per schema in `Schema.archive.afleidingswijze`. + +#### Scenario: Calculate from afgehandeld (closure date) +- **GIVEN** a schema with `archive.afleidingswijze` set to `afgehandeld` and `bewaartermijn` `P5Y` +- **AND** an object is updated with a status indicating closure (configurable status field via `archive.closureField`) +- **WHEN** the system processes the status change +- **THEN** `archiefactiedatum` MUST be set to closure date plus 5 years + +#### Scenario: Calculate from eigenschap (property value) +- **GIVEN** a schema with `archive.afleidingswijze` set to `eigenschap` and `archive.bronEigenschap` set to `vervaldatum` and `bewaartermijn` `P10Y` +- **AND** an object has property `vervaldatum` set to `2028-06-15` +- **WHEN** the system calculates the archiefactiedatum +- **THEN** `archiefactiedatum` MUST be set to `2038-06-15` + +#### Scenario: Calculate from termijn (closure plus process term) +- **GIVEN** a schema with `archive.afleidingswijze` set to `termijn` and `archive.procestermijn` set to `P2Y` and `bewaartermijn` `P5Y` +- **AND** an object is closed on `2026-01-01` +- **WHEN** the system calculates the archiefactiedatum +- **THEN** the brondatum MUST be `2028-01-01` (closure + procestermijn) +- **AND** `archiefactiedatum` MUST be `2033-01-01` (brondatum + bewaartermijn) + +#### Scenario: Recalculate when source property changes +- **GIVEN** an object with `archive.afleidingswijze` `eigenschap` pointing to `vervaldatum` +- **AND** current `archiefactiedatum` is `2038-06-15` +- **WHEN** the `vervaldatum` property is updated to `2030-12-31` +- **THEN** `archiefactiedatum` MUST be recalculated to `2040-12-31` +- **AND** an audit trail entry MUST be created recording the change + +### Requirement: The system MUST generate destruction lists via a background job +Objects past their `archiefactiedatum` with `archiefnominatie` `vernietigen` MUST be automatically identified and grouped into destruction lists. + +#### Scenario: DestructionCheckJob generates a destruction list +- **GIVEN** 15 objects have `archiefactiedatum` before today and `archiefnominatie` set to `vernietigen` and `archiefstatus` `nog_te_archiveren` +- **WHEN** the `DestructionCheckJob` (TimedJob) runs on its configured schedule +- **THEN** a destruction list MUST be created as a register object containing: + - References (UUIDs) to all 15 eligible objects + - Status `in_review` + - For each object: title, schema, register, UUID, archiefactiedatum, classificatie +- **AND** an INotification MUST be sent to users in the `archivaris` Nextcloud group + +#### Scenario: Objects already on a pending destruction list are excluded +- **GIVEN** 10 objects are eligible for destruction +- **AND** 8 of them already appear on an existing destruction list with status `in_review` +- **WHEN** the `DestructionCheckJob` runs +- **THEN** only the 2 new objects MUST be added to a new destruction list + +#### Scenario: Configurable destruction check interval +- **GIVEN** the admin sets `retention.destructionCheckInterval` to `604800` (weekly in seconds) +- **WHEN** the `DestructionCheckJob` is registered +- **THEN** it MUST run at the configured interval instead of the default daily interval + +#### Scenario: Soft-deleted objects included in destruction lists +- **GIVEN** 3 eligible objects have been soft-deleted (have `deleted` field set) +- **WHEN** the `DestructionCheckJob` generates a destruction list +- **THEN** soft-deleted objects MUST be included and marked as `softDeleted: true` in the list + +### Requirement: Destruction MUST follow a multi-step approval workflow +Destruction lists MUST be reviewed and approved by authorized users before objects are permanently deleted, per Archiefbesluit 1995 Articles 6-8. + +#### Scenario: Approve entire destruction list +- **GIVEN** a destruction list with 15 objects and status `in_review` +- **WHEN** a user in the `archivaris` group approves the list via `POST /api/retention/destruction-lists/{id}/approve` +- **THEN** the status MUST change to `approved` +- **AND** a `DestructionExecutionJob` (QueuedJob) MUST be queued to process the destruction +- **AND** an audit trail entry MUST be created with action `archival.destruction_approved` + +#### Scenario: Partially reject destruction list +- **GIVEN** a destruction list with 15 objects +- **WHEN** the archivist excludes 3 objects (with reasons) and approves the remaining 12 +- **THEN** only the 12 approved objects MUST be queued for destruction +- **AND** the 3 excluded objects MUST have `archiefactiedatum` extended by the configured extension period (default: `P1Y`) +- **AND** each exclusion reason MUST be recorded in the destruction list object + +#### Scenario: Reject entire destruction list +- **GIVEN** a destruction list with 15 objects +- **WHEN** the archivist rejects the list with a reason via `POST /api/retention/destruction-lists/{id}/reject` +- **THEN** no objects MUST be destroyed +- **AND** the status MUST change to `rejected` +- **AND** all objects MUST have `archiefactiedatum` extended by the configured extension period + +#### Scenario: Two-step approval for sensitive schemas +- **GIVEN** a schema with `archive.requireDualApproval` set to `true` +- **AND** a destruction list contains objects from this schema +- **WHEN** the first archivist approves +- **THEN** the status MUST change to `awaiting_second_approval` +- **AND** a different archivist MUST provide second approval before destruction proceeds + +#### Scenario: Destruction execution processes objects in batches +- **GIVEN** an approved destruction list with 200 objects +- **WHEN** the `DestructionExecutionJob` runs +- **THEN** objects MUST be destroyed in batches of the configured size (default: 50) +- **AND** each destroyed object MUST have an audit trail entry with action `archival.destroyed` referencing the destruction list UUID +- **AND** the destruction list status MUST change to `executed` when all objects are processed + +### Requirement: The system MUST generate destruction certificates +After a destruction list is fully executed, the system MUST produce a destruction certificate (verklaring van vernietiging) as an immutable register object. + +#### Scenario: Generate destruction certificate after execution +- **GIVEN** a destruction list has been fully executed (all objects destroyed) +- **WHEN** the `DestructionExecutionJob` completes +- **THEN** a destruction certificate MUST be created as a register object containing: + - Date of destruction + - Approving archivist(s) user IDs + - Count of destroyed objects grouped by schema and classificatie + - Reference to the selectielijst used + - Reference to the destruction list object +- **AND** the certificate object MUST be stored in the archival register +- **AND** the certificate MUST NOT be deletable (protected by `immutable: true` flag) + +### Requirement: The system MUST support legal holds (bevriezing) +Objects under legal hold MUST be exempt from all destruction processes regardless of their archiefactiedatum or archiefnominatie. + +#### Scenario: Place legal hold on an object +- **GIVEN** an object eligible for destruction +- **WHEN** an authorized user places a legal hold via `POST /api/retention/legal-holds` with `objectId`, `reason` +- **THEN** the object's `retention.legalHold` MUST be set to `{ active: true, reason: "", placedBy: "", placedDate: "" }` +- **AND** the object MUST be excluded from all destruction lists +- **AND** an audit trail entry MUST be created with action `archival.legal_hold_placed` + +#### Scenario: Legal hold prevents destruction at execution time +- **GIVEN** a destruction list containing object X +- **AND** a legal hold is placed on object X after the list was approved but before execution +- **WHEN** the `DestructionExecutionJob` processes object X +- **THEN** object X MUST be skipped (not destroyed) +- **AND** the archivist MUST be notified that 1 object was excluded due to legal hold + +#### Scenario: Release legal hold +- **GIVEN** an object with an active legal hold +- **WHEN** an authorized user releases the hold via `DELETE /api/retention/legal-holds/{id}` with `reason` +- **THEN** `retention.legalHold.active` MUST be set to `false` +- **AND** the hold MUST be moved to `retention.legalHold.history[]` with release metadata +- **AND** the object MUST become eligible for destruction again if archiefactiedatum has passed +- **AND** an audit trail entry MUST be created with action `archival.legal_hold_released` + +#### Scenario: Bulk legal hold on schema +- **GIVEN** a schema containing 200 objects +- **WHEN** an authorized user places a legal hold on all objects in the schema via `POST /api/retention/legal-holds/bulk` with `schemaId`, `reason` +- **THEN** all 200 objects MUST receive a legal hold +- **AND** the operation MUST be executed via QueuedJob to avoid timeouts +- **AND** a summary audit trail entry MUST be created + +### Requirement: The system MUST send pre-destruction notifications +Objects approaching their archiefactiedatum MUST trigger notifications to give stakeholders time to review, extend retention, or apply legal holds. + +#### Scenario: Send notification 30 days before archiefactiedatum +- **GIVEN** an object with `archiefactiedatum` 30 days from today and `archiefnominatie` `vernietigen` +- **AND** the notification lead time is configured to 30 days via `retention.notificationLeadDays` +- **WHEN** the `DestructionCheckJob` runs its notification check +- **THEN** an INotification MUST be sent to users in the `archivaris` group +- **AND** the notification MUST include: object title, schema name, archiefactiedatum, classificatie +- **AND** the notification MUST NOT be sent again for the same object (deduplicated) + +#### Scenario: Notification for bewaren objects approaching transfer date +- **GIVEN** an object with `archiefnominatie` `bewaren` and `archiefactiedatum` within the notification lead period +- **WHEN** the notification check runs +- **THEN** the notification MUST indicate the object requires e-Depot transfer (not destruction) + +### Requirement: Retention settings MUST be configurable via API +Administrators MUST be able to configure retention-related settings through the existing settings API. + +#### Scenario: Get retention archival settings +- **WHEN** an admin calls `GET /api/settings/retention` +- **THEN** the response MUST include: + - `destructionCheckInterval`: interval in seconds (default: 86400) + - `notificationLeadDays`: days before archiefactiedatum to notify (default: 30) + - `defaultExtensionPeriod`: ISO 8601 duration for rejected objects (default: P1Y) + - `destructionBatchSize`: objects per batch in execution job (default: 50) + - `selectielijstRegister`: UUID of the register used for selectielijst objects + - `selectielijstSchema`: UUID of the schema used for selectielijst objects + - `destructionListRegister`: UUID of the register for destruction lists + - `destructionListSchema`: UUID of the schema for destruction lists + - `archivalRegister`: UUID of the register for certificates + +#### Scenario: Update retention archival settings +- **GIVEN** an admin with appropriate permissions +- **WHEN** they call `PUT /api/settings/retention` with updated values +- **THEN** the settings MUST be persisted in app configuration +- **AND** the DestructionCheckJob interval MUST be updated if `destructionCheckInterval` changed + +### Requirement: Cascading destruction MUST respect referential integrity +When an object is destroyed via an approved destruction list, the system MUST evaluate related objects according to the existing referential integrity cascade rules. + +#### Scenario: CASCADE destruction to child objects +- **GIVEN** a schema property referencing another schema with `onDelete: CASCADE` +- **AND** a parent object is on an approved destruction list +- **WHEN** the parent is destroyed +- **THEN** all child objects MUST also be destroyed +- **AND** each cascaded destruction MUST produce an audit trail entry with action `archival.cascade_destroyed` + +#### Scenario: RESTRICT prevents destruction +- **GIVEN** an object references another object with `onDelete: RESTRICT` +- **WHEN** the referenced object appears on a destruction list +- **THEN** the destruction list MUST flag the object with a warning about RESTRICT references +- **AND** the archivist MUST resolve the reference before approving + +#### Scenario: Legal hold on child blocks parent destruction +- **GIVEN** a parent object approved for destruction +- **AND** a child object (via CASCADE relationship) has an active legal hold +- **WHEN** the DestructionExecutionJob processes the parent +- **THEN** destruction of the parent MUST be halted +- **AND** the archivist MUST be notified about the blocked destruction diff --git a/openspec/changes/archive/2026-03-22-retention-management/tasks.md b/openspec/changes/archive/2026-03-22-retention-management/tasks.md new file mode 100644 index 000000000..b461c9ff1 --- /dev/null +++ b/openspec/changes/archive/2026-03-22-retention-management/tasks.md @@ -0,0 +1,71 @@ +## 1. Database and Entity Layer + +- [x] 1.1 Create database migration extending ObjectEntity.retention JSON structure with archival metadata fields (archiefnominatie, archiefstatus, archiefactiedatum, classificatie, bewaartermijn, legalHold) +- [x] 1.2 Extend Schema.archive JSON structure with new configuration keys (enabled, defaultNominatie, defaultBewaartermijn, afleidingswijze, bronEigenschap, procestermijn, closureField, classificatie, bewaartermijnOverride, overrideReason, requireDualApproval) +- [x] 1.3 Add PostgreSQL GIN index on ObjectEntity.retention JSON field for efficient filtering + +## 2. Retention Settings + +- [x] 2.1 Extend ObjectRetentionHandler with archival settings (destructionCheckInterval, notificationLeadDays, defaultExtensionPeriod, destructionBatchSize, selectielijstRegister, selectielijstSchema, destructionListRegister, destructionListSchema, archivalRegister) +- [x] 2.2 Add GET /api/settings/retention endpoint for archival settings retrieval +- [x] 2.3 Add PUT /api/settings/retention endpoint for archival settings update + +## 3. RetentionService Core + +- [x] 3.1 Create RetentionService class with dependency injection (ObjectService, SchemaMapper, ObjectMapper, IAppConfig, AuditTrailMapper) +- [x] 3.2 Implement applyArchivalMetadata() method that populates retention fields on object creation based on schema archive config and selectielijst lookup +- [x] 3.3 Implement calculateArchiefactiedatum() with support for afleidingswijzen: afgehandeld, eigenschap, termijn +- [x] 3.4 Implement recalculateArchiefactiedatum() triggered on source property updates +- [x] 3.5 Add archival metadata validation in SaveObject to reject updates on destroyed/transferred objects (HTTP 409) + +## 4. Selectielijst Support + +- [x] 4.1 Implement lookupSelectielijstEntry() in RetentionService to find selectielijst entries by categorie code from the configured selectielijst register/schema +- [x] 4.2 Implement schema-level override logic (bewaartermijnOverride) with audit trail recording +- [x] 4.3 Add selectielijst version tracking on objects (store selectielijst bron reference at creation time) + +## 5. Destruction List Generation + +- [x] 5.1 Create DestructionCheckJob extending OCP\BackgroundJob\TimedJob with configurable interval +- [x] 5.2 Implement findEligibleForDestruction() query: objects with archiefactiedatum < now, archiefnominatie = vernietigen, archiefstatus = nog_te_archiveren, no active legal hold, not on pending destruction list +- [x] 5.3 Implement destruction list creation as register object in configured register/schema with status in_review +- [x] 5.4 Register DestructionCheckJob in info.xml background-jobs section + +## 6. Destruction Approval Workflow + +- [x] 6.1 Create RetentionController with routes for destruction list management +- [x] 6.2 Implement POST /api/retention/destruction-lists/{id}/approve endpoint (full and partial approval) +- [x] 6.3 Implement POST /api/retention/destruction-lists/{id}/reject endpoint with mandatory reason +- [x] 6.4 Implement two-step approval logic checking schema archive.requireDualApproval and enforcing different approvers +- [x] 6.5 Implement archiefactiedatum extension for excluded/rejected objects using configured defaultExtensionPeriod + +## 7. Destruction Execution + +- [x] 7.1 Create DestructionExecutionJob extending OCP\BackgroundJob\QueuedJob with batch processing +- [x] 7.2 Implement batch destruction with configurable batch size, re-checking legal holds at execution time +- [x] 7.3 Implement cascading destruction respecting referential integrity (CASCADE, RESTRICT, legal hold on children) +- [x] 7.4 Create audit trail entries for each destroyed object with action archival.destroyed +- [x] 7.5 Implement destruction certificate generation as immutable register object after execution completes + +## 8. Legal Hold Management + +- [x] 8.1 Implement POST /api/retention/legal-holds endpoint to place hold on a single object +- [x] 8.2 Implement DELETE /api/retention/legal-holds/{id} endpoint to release hold with reason and history preservation +- [x] 8.3 Implement POST /api/retention/legal-holds/bulk endpoint for schema-wide legal holds via QueuedJob +- [x] 8.4 Add legal hold exclusion check in DestructionCheckJob (skip held objects when generating lists) +- [x] 8.5 Add legal hold exclusion check in DestructionExecutionJob (skip held objects at execution time) + +## 9. Notifications + +- [x] 9.1 Implement pre-destruction notification via INotification in DestructionCheckJob for objects within notificationLeadDays of archiefactiedatum +- [x] 9.2 Implement destruction list review notification sent to archivaris group when new list is created +- [x] 9.3 Implement notification deduplication to avoid repeat notifications for the same object +- [x] 9.4 Implement notification for bewaren objects approaching transfer date (distinct message from destruction) + +## 10. Integration and Testing + +- [x] 10.1 Register all new routes in appinfo/routes.php +- [x] 10.2 Write unit tests for RetentionService (archiefactiedatum calculation, selectielijst lookup, legal hold logic) +- [x] 10.3 Write unit tests for DestructionCheckJob (eligible object query, list generation, deduplication) +- [x] 10.4 Write unit tests for DestructionExecutionJob (batch processing, cascade handling, certificate generation) +- [x] 10.5 Test with opencatalogi and softwarecatalog to verify no regressions on object CRUD operations diff --git a/openspec/specs/retention-management/spec.md b/openspec/specs/retention-management/spec.md new file mode 100644 index 000000000..ffaff8384 --- /dev/null +++ b/openspec/specs/retention-management/spec.md @@ -0,0 +1,280 @@ +--- +status: implemented +--- + +# Retention Management + +## Purpose +Implement retention lifecycle management for register objects: MDTO-compliant archival metadata, selectielijsten, archiefactiedatum calculation, destruction scheduling with approval workflows, legal holds, and notifications per Archiefwet 1995. + +## Requirements + +### Requirement: Objects MUST carry MDTO-compliant archival metadata in the retention field +Each object MUST carry archival metadata fields conforming to the MDTO standard within the existing `ObjectEntity.retention` JSON field. These fields enable retention lifecycle management per the Archiefwet 1995. + +#### Scenario: Default archival metadata on object creation with archive-enabled schema +- **GIVEN** a schema with `archive.enabled` set to `true` and `archive.defaultNominatie` set to `vernietigen` and `archive.defaultBewaartermijn` set to `P5Y` +- **WHEN** a new object is created without explicit archival metadata +- **THEN** the object's `retention` field MUST include: + - `archiefnominatie`: `vernietigen` + - `archiefstatus`: `nog_te_archiveren` + - `bewaartermijn`: `P5Y` + - `archiefactiedatum`: creation date plus 5 years in ISO 8601 format + - `classificatie`: `null` (until selectielijst mapping is configured) + +#### Scenario: Archival metadata defaults to undetermined when schema has no archive config +- **GIVEN** a schema without `archive` configuration +- **WHEN** a new object is created +- **THEN** the object's `retention` field MUST NOT include archival metadata fields +- **AND** the object MUST behave as before this change (backward compatible) + +#### Scenario: Destroyed objects cannot be modified +- **GIVEN** an object with `retention.archiefstatus` set to `vernietigd` +- **WHEN** a user attempts to update the object's data via PUT or PATCH +- **THEN** the system MUST reject the update with HTTP 409 Conflict +- **AND** the response body MUST include error code `OBJECT_DESTROYED` + +#### Scenario: Transferred objects become read-only +- **GIVEN** an object with `retention.archiefstatus` set to `overgebracht` +- **WHEN** a user attempts to update the object +- **THEN** the system MUST reject the update with HTTP 409 Conflict +- **AND** the response body MUST include error code `OBJECT_TRANSFERRED` + +#### Scenario: Archival metadata exposed in API responses +- **GIVEN** an object with archival metadata populated in `retention` +- **WHEN** the object is retrieved via `GET /api/objects/{register}/{schema}/{id}` +- **THEN** the response MUST include the full `retention` field containing all archival metadata +- **AND** the `retention.archiefnominatie` and `retention.archiefstatus` fields MUST be filterable in list/search queries + +### Requirement: The system MUST support configurable selectielijsten as register objects +Selectielijsten (selection lists) MUST be stored as regular register objects within OpenRegister, mapping object types to retention periods and archival actions per the Selectielijst gemeenten en intergemeentelijke organen (VNG). + +#### Scenario: Create a selectielijst entry +- **GIVEN** a register and schema designated for selectielijst management via app settings +- **WHEN** an admin creates a selectielijst entry object with properties `categorie`, `omschrijving`, `bewaartermijn` (ISO 8601 duration), `archiefnominatie`, `bron`, and `toelichting` +- **THEN** the entry MUST be created as a standard register object +- **AND** the entry MUST be retrievable via the standard object API + +#### Scenario: Map schema to selectielijst category +- **GIVEN** a selectielijst entry with `categorie` `B1` and `bewaartermijn` `P5Y` and `archiefnominatie` `vernietigen` +- **AND** a schema with `archive.classificatie` set to `B1` +- **WHEN** a new object is created in this schema +- **THEN** the system MUST look up the selectielijst entry for `B1` +- **AND** apply `bewaartermijn` `P5Y` and `archiefnominatie` `vernietigen` to the object's `retention` field +- **AND** calculate `archiefactiedatum` based on the configured afleidingswijze + +#### Scenario: Schema-level override of selectielijst retention +- **GIVEN** selectielijst category `A1` has `bewaartermijn` `P10Y` +- **AND** schema `vertrouwelijk-dossier` has `archive.bewaartermijnOverride` set to `P20Y` +- **WHEN** a new object is created in `vertrouwelijk-dossier` +- **THEN** the object MUST use `P20Y` as its `bewaartermijn` instead of the selectielijst's `P10Y` +- **AND** the override MUST be recorded in the audit trail with reason from `archive.overrideReason` + +#### Scenario: Import selectielijst via bulk object creation +- **GIVEN** a CSV file containing VNG selectielijst entries +- **WHEN** an admin imports the file using the existing data import mechanism targeting the selectielijst schema +- **THEN** entries MUST be created or updated (matched on `categorie` code) as standard register objects +- **AND** the import log MUST report created, updated, and skipped counts + +### Requirement: The system MUST calculate archiefactiedatum using configurable afleidingswijzen +The `archiefactiedatum` MUST be calculable from multiple derivation methods configured per schema in `Schema.archive.afleidingswijze`. + +#### Scenario: Calculate from afgehandeld (closure date) +- **GIVEN** a schema with `archive.afleidingswijze` set to `afgehandeld` and `bewaartermijn` `P5Y` +- **AND** an object is updated with a status indicating closure (configurable status field via `archive.closureField`) +- **WHEN** the system processes the status change +- **THEN** `archiefactiedatum` MUST be set to closure date plus 5 years + +#### Scenario: Calculate from eigenschap (property value) +- **GIVEN** a schema with `archive.afleidingswijze` set to `eigenschap` and `archive.bronEigenschap` set to `vervaldatum` and `bewaartermijn` `P10Y` +- **AND** an object has property `vervaldatum` set to `2028-06-15` +- **WHEN** the system calculates the archiefactiedatum +- **THEN** `archiefactiedatum` MUST be set to `2038-06-15` + +#### Scenario: Calculate from termijn (closure plus process term) +- **GIVEN** a schema with `archive.afleidingswijze` set to `termijn` and `archive.procestermijn` set to `P2Y` and `bewaartermijn` `P5Y` +- **AND** an object is closed on `2026-01-01` +- **WHEN** the system calculates the archiefactiedatum +- **THEN** the brondatum MUST be `2028-01-01` (closure + procestermijn) +- **AND** `archiefactiedatum` MUST be `2033-01-01` (brondatum + bewaartermijn) + +#### Scenario: Recalculate when source property changes +- **GIVEN** an object with `archive.afleidingswijze` `eigenschap` pointing to `vervaldatum` +- **AND** current `archiefactiedatum` is `2038-06-15` +- **WHEN** the `vervaldatum` property is updated to `2030-12-31` +- **THEN** `archiefactiedatum` MUST be recalculated to `2040-12-31` +- **AND** an audit trail entry MUST be created recording the change + +### Requirement: The system MUST generate destruction lists via a background job +Objects past their `archiefactiedatum` with `archiefnominatie` `vernietigen` MUST be automatically identified and grouped into destruction lists. + +#### Scenario: DestructionCheckJob generates a destruction list +- **GIVEN** 15 objects have `archiefactiedatum` before today and `archiefnominatie` set to `vernietigen` and `archiefstatus` `nog_te_archiveren` +- **WHEN** the `DestructionCheckJob` (TimedJob) runs on its configured schedule +- **THEN** a destruction list MUST be created as a register object containing: + - References (UUIDs) to all 15 eligible objects + - Status `in_review` + - For each object: title, schema, register, UUID, archiefactiedatum, classificatie +- **AND** an INotification MUST be sent to users in the `archivaris` Nextcloud group + +#### Scenario: Objects already on a pending destruction list are excluded +- **GIVEN** 10 objects are eligible for destruction +- **AND** 8 of them already appear on an existing destruction list with status `in_review` +- **WHEN** the `DestructionCheckJob` runs +- **THEN** only the 2 new objects MUST be added to a new destruction list + +#### Scenario: Configurable destruction check interval +- **GIVEN** the admin sets `retention.destructionCheckInterval` to `604800` (weekly in seconds) +- **WHEN** the `DestructionCheckJob` is registered +- **THEN** it MUST run at the configured interval instead of the default daily interval + +#### Scenario: Soft-deleted objects included in destruction lists +- **GIVEN** 3 eligible objects have been soft-deleted (have `deleted` field set) +- **WHEN** the `DestructionCheckJob` generates a destruction list +- **THEN** soft-deleted objects MUST be included and marked as `softDeleted: true` in the list + +### Requirement: Destruction MUST follow a multi-step approval workflow +Destruction lists MUST be reviewed and approved by authorized users before objects are permanently deleted, per Archiefbesluit 1995 Articles 6-8. + +#### Scenario: Approve entire destruction list +- **GIVEN** a destruction list with 15 objects and status `in_review` +- **WHEN** a user in the `archivaris` group approves the list via `POST /api/retention/destruction-lists/{id}/approve` +- **THEN** the status MUST change to `approved` +- **AND** a `DestructionExecutionJob` (QueuedJob) MUST be queued to process the destruction +- **AND** an audit trail entry MUST be created with action `archival.destruction_approved` + +#### Scenario: Partially reject destruction list +- **GIVEN** a destruction list with 15 objects +- **WHEN** the archivist excludes 3 objects (with reasons) and approves the remaining 12 +- **THEN** only the 12 approved objects MUST be queued for destruction +- **AND** the 3 excluded objects MUST have `archiefactiedatum` extended by the configured extension period (default: `P1Y`) +- **AND** each exclusion reason MUST be recorded in the destruction list object + +#### Scenario: Reject entire destruction list +- **GIVEN** a destruction list with 15 objects +- **WHEN** the archivist rejects the list with a reason via `POST /api/retention/destruction-lists/{id}/reject` +- **THEN** no objects MUST be destroyed +- **AND** the status MUST change to `rejected` +- **AND** all objects MUST have `archiefactiedatum` extended by the configured extension period + +#### Scenario: Two-step approval for sensitive schemas +- **GIVEN** a schema with `archive.requireDualApproval` set to `true` +- **AND** a destruction list contains objects from this schema +- **WHEN** the first archivist approves +- **THEN** the status MUST change to `awaiting_second_approval` +- **AND** a different archivist MUST provide second approval before destruction proceeds + +#### Scenario: Destruction execution processes objects in batches +- **GIVEN** an approved destruction list with 200 objects +- **WHEN** the `DestructionExecutionJob` runs +- **THEN** objects MUST be destroyed in batches of the configured size (default: 50) +- **AND** each destroyed object MUST have an audit trail entry with action `archival.destroyed` referencing the destruction list UUID +- **AND** the destruction list status MUST change to `executed` when all objects are processed + +### Requirement: The system MUST generate destruction certificates +After a destruction list is fully executed, the system MUST produce a destruction certificate (verklaring van vernietiging) as an immutable register object. + +#### Scenario: Generate destruction certificate after execution +- **GIVEN** a destruction list has been fully executed (all objects destroyed) +- **WHEN** the `DestructionExecutionJob` completes +- **THEN** a destruction certificate MUST be created as a register object containing: + - Date of destruction + - Approving archivist(s) user IDs + - Count of destroyed objects grouped by schema and classificatie + - Reference to the selectielijst used + - Reference to the destruction list object +- **AND** the certificate object MUST be stored in the archival register +- **AND** the certificate MUST NOT be deletable (protected by `immutable: true` flag) + +### Requirement: The system MUST support legal holds (bevriezing) +Objects under legal hold MUST be exempt from all destruction processes regardless of their archiefactiedatum or archiefnominatie. + +#### Scenario: Place legal hold on an object +- **GIVEN** an object eligible for destruction +- **WHEN** an authorized user places a legal hold via `POST /api/retention/legal-holds` with `objectId`, `reason` +- **THEN** the object's `retention.legalHold` MUST be set to `{ active: true, reason: "", placedBy: "", placedDate: "" }` +- **AND** the object MUST be excluded from all destruction lists +- **AND** an audit trail entry MUST be created with action `archival.legal_hold_placed` + +#### Scenario: Legal hold prevents destruction at execution time +- **GIVEN** a destruction list containing object X +- **AND** a legal hold is placed on object X after the list was approved but before execution +- **WHEN** the `DestructionExecutionJob` processes object X +- **THEN** object X MUST be skipped (not destroyed) +- **AND** the archivist MUST be notified that 1 object was excluded due to legal hold + +#### Scenario: Release legal hold +- **GIVEN** an object with an active legal hold +- **WHEN** an authorized user releases the hold via `DELETE /api/retention/legal-holds/{id}` with `reason` +- **THEN** `retention.legalHold.active` MUST be set to `false` +- **AND** the hold MUST be moved to `retention.legalHold.history[]` with release metadata +- **AND** the object MUST become eligible for destruction again if archiefactiedatum has passed +- **AND** an audit trail entry MUST be created with action `archival.legal_hold_released` + +#### Scenario: Bulk legal hold on schema +- **GIVEN** a schema containing 200 objects +- **WHEN** an authorized user places a legal hold on all objects in the schema via `POST /api/retention/legal-holds/bulk` with `schemaId`, `reason` +- **THEN** all 200 objects MUST receive a legal hold +- **AND** the operation MUST be executed via QueuedJob to avoid timeouts +- **AND** a summary audit trail entry MUST be created + +### Requirement: The system MUST send pre-destruction notifications +Objects approaching their archiefactiedatum MUST trigger notifications to give stakeholders time to review, extend retention, or apply legal holds. + +#### Scenario: Send notification 30 days before archiefactiedatum +- **GIVEN** an object with `archiefactiedatum` 30 days from today and `archiefnominatie` `vernietigen` +- **AND** the notification lead time is configured to 30 days via `retention.notificationLeadDays` +- **WHEN** the `DestructionCheckJob` runs its notification check +- **THEN** an INotification MUST be sent to users in the `archivaris` group +- **AND** the notification MUST include: object title, schema name, archiefactiedatum, classificatie +- **AND** the notification MUST NOT be sent again for the same object (deduplicated) + +#### Scenario: Notification for bewaren objects approaching transfer date +- **GIVEN** an object with `archiefnominatie` `bewaren` and `archiefactiedatum` within the notification lead period +- **WHEN** the notification check runs +- **THEN** the notification MUST indicate the object requires e-Depot transfer (not destruction) + +### Requirement: Retention settings MUST be configurable via API +Administrators MUST be able to configure retention-related settings through the existing settings API. + +#### Scenario: Get retention archival settings +- **WHEN** an admin calls `GET /api/settings/retention` +- **THEN** the response MUST include: + - `destructionCheckInterval`: interval in seconds (default: 86400) + - `notificationLeadDays`: days before archiefactiedatum to notify (default: 30) + - `defaultExtensionPeriod`: ISO 8601 duration for rejected objects (default: P1Y) + - `destructionBatchSize`: objects per batch in execution job (default: 50) + - `selectielijstRegister`: UUID of the register used for selectielijst objects + - `selectielijstSchema`: UUID of the schema used for selectielijst objects + - `destructionListRegister`: UUID of the register for destruction lists + - `destructionListSchema`: UUID of the schema for destruction lists + - `archivalRegister`: UUID of the register for certificates + +#### Scenario: Update retention archival settings +- **GIVEN** an admin with appropriate permissions +- **WHEN** they call `PUT /api/settings/retention` with updated values +- **THEN** the settings MUST be persisted in app configuration +- **AND** the DestructionCheckJob interval MUST be updated if `destructionCheckInterval` changed + +### Requirement: Cascading destruction MUST respect referential integrity +When an object is destroyed via an approved destruction list, the system MUST evaluate related objects according to the existing referential integrity cascade rules. + +#### Scenario: CASCADE destruction to child objects +- **GIVEN** a schema property referencing another schema with `onDelete: CASCADE` +- **AND** a parent object is on an approved destruction list +- **WHEN** the parent is destroyed +- **THEN** all child objects MUST also be destroyed +- **AND** each cascaded destruction MUST produce an audit trail entry with action `archival.cascade_destroyed` + +#### Scenario: RESTRICT prevents destruction +- **GIVEN** an object references another object with `onDelete: RESTRICT` +- **WHEN** the referenced object appears on a destruction list +- **THEN** the destruction list MUST flag the object with a warning about RESTRICT references +- **AND** the archivist MUST resolve the reference before approving + +#### Scenario: Legal hold on child blocks parent destruction +- **GIVEN** a parent object approved for destruction +- **AND** a child object (via CASCADE relationship) has an active legal hold +- **WHEN** the DestructionExecutionJob processes the parent +- **THEN** destruction of the parent MUST be halted +- **AND** the archivist MUST be notified about the blocked destruction diff --git a/tests/Unit/BackgroundJob/DestructionCheckJobTest.php b/tests/Unit/BackgroundJob/DestructionCheckJobTest.php new file mode 100644 index 000000000..91e43304b --- /dev/null +++ b/tests/Unit/BackgroundJob/DestructionCheckJobTest.php @@ -0,0 +1,62 @@ + + * @license EUPL-1.2 + */ + +namespace Unit\BackgroundJob; + +use OCA\OpenRegister\BackgroundJob\DestructionCheckJob; +use OCP\AppFramework\Utility\ITimeFactory; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use ReflectionClass; + +/** + * Test class for DestructionCheckJob + */ +class DestructionCheckJobTest extends TestCase +{ + private ITimeFactory&MockObject $timeFactory; + + protected function setUp(): void + { + parent::setUp(); + $this->timeFactory = $this->createMock(ITimeFactory::class); + } + + /** + * Test that the job can be instantiated. + */ + public function testConstructor(): void + { + // The job constructor calls getArchivalSettingsOnly() via \OC::$server. + // In unit tests without the full Nextcloud stack, we verify the class exists + // and has the expected methods. + $reflection = new ReflectionClass(DestructionCheckJob::class); + + $this->assertTrue($reflection->isSubclassOf(\OCP\BackgroundJob\TimedJob::class)); + $this->assertTrue($reflection->hasMethod('run')); + } + + /** + * Test that DEFAULT_INTERVAL constant is 24 hours. + */ + public function testDefaultInterval(): void + { + $reflection = new ReflectionClass(DestructionCheckJob::class); + $constants = $reflection->getConstants(); + + $this->assertArrayHasKey('DEFAULT_INTERVAL', $constants); + $this->assertEquals(86400, $constants['DEFAULT_INTERVAL']); + } +} diff --git a/tests/Unit/BackgroundJob/DestructionExecutionJobTest.php b/tests/Unit/BackgroundJob/DestructionExecutionJobTest.php new file mode 100644 index 000000000..ef8019431 --- /dev/null +++ b/tests/Unit/BackgroundJob/DestructionExecutionJobTest.php @@ -0,0 +1,60 @@ + + * @license EUPL-1.2 + */ + +namespace Unit\BackgroundJob; + +use OCA\OpenRegister\BackgroundJob\DestructionExecutionJob; +use OCP\AppFramework\Utility\ITimeFactory; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use ReflectionClass; + +/** + * Test class for DestructionExecutionJob + */ +class DestructionExecutionJobTest extends TestCase +{ + private ITimeFactory&MockObject $timeFactory; + + protected function setUp(): void + { + parent::setUp(); + $this->timeFactory = $this->createMock(ITimeFactory::class); + } + + /** + * Test that the job extends QueuedJob. + */ + public function testIsQueuedJob(): void + { + $reflection = new ReflectionClass(DestructionExecutionJob::class); + + $this->assertTrue($reflection->isSubclassOf(\OCP\BackgroundJob\QueuedJob::class)); + $this->assertTrue($reflection->hasMethod('run')); + } + + /** + * Test that DEFAULT_BATCH_SIZE is reasonable. + */ + public function testDefaultBatchSize(): void + { + $reflection = new ReflectionClass(DestructionExecutionJob::class); + $constants = $reflection->getConstants(); + + $this->assertArrayHasKey('DEFAULT_BATCH_SIZE', $constants); + $this->assertGreaterThan(0, $constants['DEFAULT_BATCH_SIZE']); + $this->assertLessThanOrEqual(100, $constants['DEFAULT_BATCH_SIZE']); + } +} diff --git a/tests/Unit/Service/Archival/ArchiefactiedatumCalculatorTest.php b/tests/Unit/Service/Archival/ArchiefactiedatumCalculatorTest.php new file mode 100644 index 000000000..51cee2804 --- /dev/null +++ b/tests/Unit/Service/Archival/ArchiefactiedatumCalculatorTest.php @@ -0,0 +1,210 @@ + + * @license EUPL-1.2 + */ + +namespace Unit\Service\Archival; + +use DateTime; +use OCA\OpenRegister\Service\Archival\ArchiefactiedatumCalculator; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Test class for ArchiefactiedatumCalculator + */ +class ArchiefactiedatumCalculatorTest extends TestCase +{ + private LoggerInterface&MockObject $logger; + private ArchiefactiedatumCalculator $calculator; + + protected function setUp(): void + { + parent::setUp(); + + $this->logger = $this->createMock(LoggerInterface::class); + $this->calculator = new ArchiefactiedatumCalculator($this->logger); + } + + /** + * Test afgehandeld method calculates from closure date + bewaartermijn. + */ + public function testCalculateAfgehandeld(): void + { + $config = [ + 'afleidingswijze' => 'afgehandeld', + 'bewaartermijn' => 'P5Y', + ]; + + $closureDate = new DateTime('2026-03-01'); + + $result = $this->calculator->calculate($config, [], $closureDate); + + $this->assertNotNull($result); + $this->assertEquals('2031-03-01', $result->format('Y-m-d')); + } + + /** + * Test afgehandeld without closure date returns null. + */ + public function testCalculateAfgehandeldNoClosure(): void + { + $config = [ + 'afleidingswijze' => 'afgehandeld', + 'bewaartermijn' => 'P5Y', + ]; + + $result = $this->calculator->calculate($config, [], null); + + $this->assertNull($result); + } + + /** + * Test eigenschap method calculates from property value + bewaartermijn. + */ + public function testCalculateEigenschap(): void + { + $config = [ + 'afleidingswijze' => 'eigenschap', + 'eigenschap' => 'vervaldatum', + 'bewaartermijn' => 'P10Y', + ]; + + $objectData = [ + 'vervaldatum' => '2028-06-15', + ]; + + $result = $this->calculator->calculate($config, $objectData); + + $this->assertNotNull($result); + $this->assertEquals('2038-06-15', $result->format('Y-m-d')); + } + + /** + * Test eigenschap with missing property returns null. + */ + public function testCalculateEigenschapMissingProperty(): void + { + $config = [ + 'afleidingswijze' => 'eigenschap', + 'eigenschap' => 'vervaldatum', + 'bewaartermijn' => 'P10Y', + ]; + + $result = $this->calculator->calculate($config, []); + + $this->assertNull($result); + } + + /** + * Test termijn method calculates from closure + procestermijn + bewaartermijn. + */ + public function testCalculateTermijn(): void + { + $config = [ + 'afleidingswijze' => 'termijn', + 'procestermijn' => 'P2Y', + 'bewaartermijn' => 'P5Y', + ]; + + $closureDate = new DateTime('2026-01-01'); + + $result = $this->calculator->calculate($config, [], $closureDate); + + $this->assertNotNull($result); + // brondatum = 2026-01-01 + P2Y = 2028-01-01 + // archiefactiedatum = 2028-01-01 + P5Y = 2033-01-01 + $this->assertEquals('2033-01-01', $result->format('Y-m-d')); + } + + /** + * Test missing afleidingswijze returns null. + */ + public function testCalculateMissingAfleidingswijze(): void + { + $config = [ + 'bewaartermijn' => 'P5Y', + ]; + + $result = $this->calculator->calculate($config, []); + + $this->assertNull($result); + } + + /** + * Test missing bewaartermijn returns null. + */ + public function testCalculateMissingBewaartermijn(): void + { + $config = [ + 'afleidingswijze' => 'afgehandeld', + ]; + + $result = $this->calculator->calculate($config, [], new DateTime()); + + $this->assertNull($result); + } + + /** + * Test invalid bewaartermijn format returns null. + */ + public function testCalculateInvalidBewaartermijn(): void + { + $config = [ + 'afleidingswijze' => 'afgehandeld', + 'bewaartermijn' => 'invalid', + ]; + + $result = $this->calculator->calculate($config, [], new DateTime()); + + $this->assertNull($result); + } + + /** + * Test unknown afleidingswijze returns null. + */ + public function testCalculateUnknownAfleidingswijze(): void + { + $config = [ + 'afleidingswijze' => 'unknown_method', + 'bewaartermijn' => 'P5Y', + ]; + + $result = $this->calculator->calculate($config, []); + + $this->assertNull($result); + } + + /** + * Test recalculation when property value changes. + */ + public function testRecalculateOnPropertyChange(): void + { + $config = [ + 'afleidingswijze' => 'eigenschap', + 'eigenschap' => 'vervaldatum', + 'bewaartermijn' => 'P10Y', + ]; + + // Original calculation. + $originalData = ['vervaldatum' => '2028-06-15']; + $original = $this->calculator->calculate($config, $originalData); + $this->assertEquals('2038-06-15', $original->format('Y-m-d')); + + // Recalculation with updated property. + $updatedData = ['vervaldatum' => '2030-12-31']; + $updated = $this->calculator->calculate($config, $updatedData); + $this->assertEquals('2040-12-31', $updated->format('Y-m-d')); + } +} diff --git a/tests/Unit/Service/Archival/DestructionServiceTest.php b/tests/Unit/Service/Archival/DestructionServiceTest.php new file mode 100644 index 000000000..326d62bbb --- /dev/null +++ b/tests/Unit/Service/Archival/DestructionServiceTest.php @@ -0,0 +1,292 @@ + + * @license EUPL-1.2 + */ + +namespace Unit\Service\Archival; + +use DateTime; +use OCA\OpenRegister\Db\AuditTrailMapper; +use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Service\Archival\DestructionService; +use OCA\OpenRegister\Service\Archival\LegalHoldService; +use OCA\OpenRegister\Service\Object\DeleteObject; +use OCP\BackgroundJob\IJobList; +use OCP\IAppConfig; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Test class for DestructionService + */ +class DestructionServiceTest extends TestCase +{ + private MagicMapper&MockObject $objectMapper; + private LegalHoldService&MockObject $legalHoldService; + private DeleteObject&MockObject $deleteObject; + private AuditTrailMapper&MockObject $auditTrailMapper; + private IAppConfig&MockObject $appConfig; + private IJobList&MockObject $jobList; + private IUserSession&MockObject $userSession; + private LoggerInterface&MockObject $logger; + private DestructionService $service; + + protected function setUp(): void + { + parent::setUp(); + + $this->objectMapper = $this->createMock(MagicMapper::class); + $this->legalHoldService = $this->createMock(LegalHoldService::class); + $this->deleteObject = $this->createMock(DeleteObject::class); + $this->auditTrailMapper = $this->createMock(AuditTrailMapper::class); + $this->appConfig = $this->createMock(IAppConfig::class); + $this->jobList = $this->createMock(IJobList::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->service = new DestructionService( + $this->objectMapper, + $this->legalHoldService, + $this->deleteObject, + $this->auditTrailMapper, + $this->appConfig, + $this->jobList, + $this->userSession, + $this->logger + ); + + // Default app config behavior. + $this->appConfig->method('getValueString') + ->willReturnCallback(static function (string $app, string $key, string $default = ''): string { + return $default; + }); + } + + private function setupMockUser(string $uid = 'archivaris-1'): void + { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + $this->userSession->method('getUser')->willReturn($user); + } + + /** + * Test creating a destruction list from eligible objects. + */ + public function testCreateDestructionList(): void + { + $objects = [ + [ + 'uuid' => 'uuid-1', + 'title' => 'Zaak 1', + 'schema' => 5, + 'register' => 1, + 'archiefactiedatum' => '2025-01-01', + 'classificatie' => 'B1', + ], + [ + 'uuid' => 'uuid-2', + 'title' => 'Zaak 2', + 'schema' => 5, + 'register' => 1, + 'archiefactiedatum' => '2025-06-01', + 'classificatie' => 'B2', + ], + ]; + + $result = $this->service->createDestructionList($objects); + + $this->assertNotEmpty($result); + $this->assertEquals(DestructionService::STATUS_IN_REVIEW, $result['status']); + $this->assertEquals(2, $result['objectCount']); + $this->assertCount(2, $result['objects']); + } + + /** + * Test creating an empty destruction list returns empty. + */ + public function testCreateDestructionListEmpty(): void + { + $result = $this->service->createDestructionList([]); + $this->assertEmpty($result); + } + + /** + * Test full approval queues execution job. + */ + public function testApproveListFull(): void + { + $this->setupMockUser('archivaris-1'); + + $list = [ + 'status' => DestructionService::STATUS_IN_REVIEW, + 'objects' => [['uuid' => 'uuid-1']], + 'approvals' => [], + ]; + + $this->jobList->expects($this->once())->method('add'); + + $result = $this->service->approveList($list, 'approve_all'); + + $this->assertEquals(DestructionService::STATUS_APPROVED, $result['status']); + $this->assertCount(1, $result['approvals']); + } + + /** + * Test partial approval excludes specified objects. + */ + public function testApproveListPartial(): void + { + $this->setupMockUser('archivaris-1'); + + // Mock finding the excluded object for date extension. + $mockObject = $this->createMock(ObjectEntity::class); + $mockObject->method('getRetention')->willReturn([ + 'archiefactiedatum' => '2025-01-01', + ]); + $mockObject->expects($this->once())->method('setRetention'); + $this->objectMapper->method('findByUuid')->willReturn($mockObject); + $this->objectMapper->method('update')->willReturn($mockObject); + + $list = [ + 'status' => DestructionService::STATUS_IN_REVIEW, + 'objects' => [ + ['uuid' => 'uuid-1', 'status' => 'pending'], + ['uuid' => 'uuid-2', 'status' => 'pending'], + ], + 'approvals' => [], + ]; + + $this->jobList->expects($this->once())->method('add'); + + $result = $this->service->approveList( + $list, + 'approve_partial', + ['uuid-2'], + ['uuid-2' => 'Verkeerde classificatie'] + ); + + $this->assertEquals(DestructionService::STATUS_APPROVED, $result['status']); + $this->assertEquals(1, $result['objectCount']); + $this->assertCount(1, $result['excludedObjects']); + } + + /** + * Test rejection extends archiefactiedatum for all objects. + */ + public function testRejectList(): void + { + $this->setupMockUser('archivaris-1'); + + $mockObject = $this->createMock(ObjectEntity::class); + $mockObject->method('getRetention')->willReturn(['archiefactiedatum' => '2025-01-01']); + $mockObject->expects($this->once())->method('setRetention'); + $this->objectMapper->method('findByUuid')->willReturn($mockObject); + $this->objectMapper->method('update')->willReturn($mockObject); + + $list = [ + 'status' => DestructionService::STATUS_IN_REVIEW, + 'objects' => [['uuid' => 'uuid-1']], + 'approvals' => [], + 'rejections' => [], + ]; + + $result = $this->service->rejectList($list, 'Selectielijst niet actueel'); + + $this->assertEquals(DestructionService::STATUS_REJECTED, $result['status']); + $this->assertCount(1, $result['rejections']); + $this->assertEquals('Selectielijst niet actueel', $result['rejections'][0]['reason']); + } + + /** + * Test dual-approval requires two different approvers. + */ + public function testDualApprovalFirstStep(): void + { + $this->setupMockUser('archivaris-1'); + + $list = [ + 'status' => DestructionService::STATUS_IN_REVIEW, + 'objects' => [['uuid' => 'uuid-1']], + 'approvals' => [], + ]; + + $result = $this->service->approveList($list, 'approve_all', [], [], true); + + $this->assertEquals(DestructionService::STATUS_AWAITING_SECOND, $result['status']); + $this->assertCount(1, $result['approvals']); + } + + /** + * Test generating a destruction certificate. + */ + public function testGenerateCertificate(): void + { + $list = [ + 'objects' => [ + ['uuid' => 'uuid-1', 'schema' => 5, 'classificatie' => 'B1'], + ['uuid' => 'uuid-2', 'schema' => 5, 'classificatie' => 'B1'], + ['uuid' => 'uuid-3', 'schema' => 8, 'classificatie' => 'A1'], + ], + 'approvals' => [ + ['approvedBy' => 'archivaris-1'], + ], + ]; + + $executionResult = [ + 'destroyed' => 3, + 'skippedCount' => 0, + 'skipped' => [], + 'filesDestroyed' => 2, + ]; + + $certificate = $this->service->generateCertificate($list, $executionResult); + + $this->assertEquals('verklaring_van_vernietiging', $certificate['type']); + $this->assertEquals(3, $certificate['totalObjectsDestroyed']); + $this->assertEquals(0, $certificate['totalObjectsSkipped']); + $this->assertContains('archivaris-1', $certificate['approvers']); + $this->assertEquals(true, $certificate['immutable']); + $this->assertStringContainsString('Archiefwet 1995', $certificate['complianceStatement']); + } + + /** + * Test certificate for partial completion records skipped objects. + */ + public function testGenerateCertificatePartial(): void + { + $list = [ + 'objects' => [['uuid' => 'uuid-1', 'schema' => 5, 'classificatie' => 'B1']], + 'approvals' => [['approvedBy' => 'archivaris-1']], + ]; + + $executionResult = [ + 'destroyed' => 1, + 'skippedCount' => 2, + 'skipped' => [ + ['uuid' => 'uuid-2', 'reason' => 'legal_hold_placed_after_approval'], + ['uuid' => 'uuid-3', 'reason' => 'legal_hold_placed_after_approval'], + ], + 'filesDestroyed' => 0, + ]; + + $certificate = $this->service->generateCertificate($list, $executionResult); + + $this->assertEquals(1, $certificate['totalObjectsDestroyed']); + $this->assertEquals(2, $certificate['totalObjectsSkipped']); + $this->assertCount(2, $certificate['skippedObjects']); + } +} diff --git a/tests/Unit/Service/Archival/LegalHoldServiceTest.php b/tests/Unit/Service/Archival/LegalHoldServiceTest.php new file mode 100644 index 000000000..0f1caa1b6 --- /dev/null +++ b/tests/Unit/Service/Archival/LegalHoldServiceTest.php @@ -0,0 +1,226 @@ + + * @license EUPL-1.2 + */ + +namespace Unit\Service\Archival; + +use OCA\OpenRegister\Db\AuditTrailMapper; +use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Service\Archival\LegalHoldService; +use OCP\BackgroundJob\IJobList; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Test class for LegalHoldService + */ +class LegalHoldServiceTest extends TestCase +{ + private MagicMapper&MockObject $objectMapper; + private AuditTrailMapper&MockObject $auditTrailMapper; + private IUserSession&MockObject $userSession; + private IJobList&MockObject $jobList; + private LoggerInterface&MockObject $logger; + private LegalHoldService $service; + + protected function setUp(): void + { + parent::setUp(); + + $this->objectMapper = $this->createMock(MagicMapper::class); + $this->auditTrailMapper = $this->createMock(AuditTrailMapper::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->jobList = $this->createMock(IJobList::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->service = new LegalHoldService( + $this->objectMapper, + $this->auditTrailMapper, + $this->userSession, + $this->jobList, + $this->logger + ); + } + + /** + * Helper to set up a mock user. + */ + private function setupMockUser(string $uid = 'test-user'): void + { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + $this->userSession->method('getUser')->willReturn($user); + } + + /** + * Helper to create a mock ObjectEntity with retention. + */ + private function createMockObject(array $retention = []): ObjectEntity&MockObject + { + $object = $this->createMock(ObjectEntity::class); + $object->method('getRetention')->willReturn($retention); + $object->method('getUuid')->willReturn('test-uuid-123'); + return $object; + } + + /** + * Test placing a legal hold on an object. + */ + public function testPlaceHold(): void + { + $this->setupMockUser('archivaris-1'); + + $object = $this->createMockObject(); + $object->expects($this->once()) + ->method('setRetention') + ->with($this->callback(function (array $retention): bool { + return $retention['legalHold']['active'] === true + && $retention['legalHold']['reason'] === 'WOO-verzoek 2025-0142' + && $retention['legalHold']['placedBy'] === 'archivaris-1'; + })); + + $this->objectMapper->expects($this->once())->method('update'); + + $result = $this->service->placeHold($object, 'WOO-verzoek 2025-0142'); + $this->assertSame($object, $result); + } + + /** + * Test releasing a legal hold preserves history. + */ + public function testReleaseHold(): void + { + $this->setupMockUser('archivaris-2'); + + $object = $this->createMockObject([ + 'legalHold' => [ + 'active' => true, + 'reason' => 'WOO-verzoek 2025-0142', + 'placedBy' => 'archivaris-1', + 'placedDate' => '2026-01-01T00:00:00+00:00', + 'history' => [], + ], + ]); + + $object->expects($this->once()) + ->method('setRetention') + ->with($this->callback(function (array $retention): bool { + return $retention['legalHold']['active'] === false + && count($retention['legalHold']['history']) === 1 + && $retention['legalHold']['history'][0]['releasedBy'] === 'archivaris-2'; + })); + + $this->objectMapper->expects($this->once())->method('update'); + + $result = $this->service->releaseHold($object, 'WOO-verzoek afgehandeld'); + $this->assertSame($object, $result); + } + + /** + * Test hasActiveHold returns true when hold is active. + */ + public function testHasActiveHoldTrue(): void + { + $object = $this->createMockObject([ + 'legalHold' => ['active' => true, 'reason' => 'test'], + ]); + + $this->assertTrue($this->service->hasActiveHold($object)); + } + + /** + * Test hasActiveHold returns false when no hold. + */ + public function testHasActiveHoldFalse(): void + { + $object = $this->createMockObject([]); + + $this->assertFalse($this->service->hasActiveHold($object)); + } + + /** + * Test hasActiveHold returns false when hold is released. + */ + public function testHasActiveHoldReleased(): void + { + $object = $this->createMockObject([ + 'legalHold' => ['active' => false, 'reason' => 'old'], + ]); + + $this->assertFalse($this->service->hasActiveHold($object)); + } + + /** + * Test hasActiveHoldFromRetention works with raw retention data. + */ + public function testHasActiveHoldFromRetention(): void + { + $this->assertTrue( + $this->service->hasActiveHoldFromRetention(['legalHold' => ['active' => true]]) + ); + + $this->assertFalse( + $this->service->hasActiveHoldFromRetention(['legalHold' => ['active' => false]]) + ); + + $this->assertFalse( + $this->service->hasActiveHoldFromRetention([]) + ); + } + + /** + * Test bulk hold queues a background job. + */ + public function testBulkPlaceHold(): void + { + $this->setupMockUser('admin'); + + $this->jobList->expects($this->once()) + ->method('add') + ->with( + \OCA\OpenRegister\BackgroundJob\BulkLegalHoldJob::class, + $this->callback(function (array $args): bool { + return $args['schemaId'] === 42 + && $args['registerId'] === 10 + && $args['reason'] === 'Rekenkameronderzoek' + && $args['placedBy'] === 'admin'; + }) + ); + + $this->service->bulkPlaceHold(42, 10, 'Rekenkameronderzoek'); + } + + /** + * Test system user fallback when no user session. + */ + public function testPlaceHoldSystemUser(): void + { + $this->userSession->method('getUser')->willReturn(null); + + $object = $this->createMockObject(); + $object->expects($this->once()) + ->method('setRetention') + ->with($this->callback(function (array $retention): bool { + return $retention['legalHold']['placedBy'] === 'system'; + })); + + $this->objectMapper->expects($this->once())->method('update'); + + $this->service->placeHold($object, 'System hold'); + } +} diff --git a/tests/Unit/Service/RetentionServiceTest.php b/tests/Unit/Service/RetentionServiceTest.php new file mode 100644 index 000000000..77dd989c4 --- /dev/null +++ b/tests/Unit/Service/RetentionServiceTest.php @@ -0,0 +1,289 @@ + + * @license EUPL-1.2 + */ + +namespace OCA\OpenRegister\Tests\Unit\Service; + +use DateTime; +use OCA\OpenRegister\Db\AuditTrailMapper; +use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\RetentionService; +use OCA\OpenRegister\Service\Settings\ObjectRetentionHandler; +use OCP\IAppConfig; +use OCP\IUserSession; +use OCP\IUser; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Test class for RetentionService + */ +class RetentionServiceTest extends TestCase +{ + + private MagicMapper&MockObject $objectMapper; + private SchemaMapper&MockObject $schemaMapper; + private RegisterMapper&MockObject $registerMapper; + private AuditTrailMapper&MockObject $auditMapper; + private ObjectRetentionHandler&MockObject $settingsHandler; + private IAppConfig&MockObject $appConfig; + private IUserSession&MockObject $userSession; + private LoggerInterface&MockObject $logger; + private RetentionService $service; + + + protected function setUp(): void + { + parent::setUp(); + + $this->objectMapper = $this->createMock(MagicMapper::class); + $this->schemaMapper = $this->createMock(SchemaMapper::class); + $this->registerMapper = $this->createMock(RegisterMapper::class); + $this->auditMapper = $this->createMock(AuditTrailMapper::class); + $this->settingsHandler = $this->createMock(ObjectRetentionHandler::class); + $this->appConfig = $this->createMock(IAppConfig::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->service = new RetentionService( + $this->objectMapper, + $this->schemaMapper, + $this->registerMapper, + $this->auditMapper, + $this->settingsHandler, + $this->appConfig, + $this->userSession, + $this->logger, + ); + }//end setUp() + + + /** + * Test that archival metadata is applied when schema has archive enabled. + */ + public function testApplyArchivalMetadataWithEnabledSchema(): void + { + $object = new ObjectEntity(); + $object->setRetention([]); + + $schema = $this->createMock(Schema::class); + $schema->method('getArchive')->willReturn([ + 'enabled' => true, + 'defaultNominatie' => 'vernietigen', + 'defaultBewaartermijn' => 'P5Y', + ]); + + $this->settingsHandler->method('getArchivalSettingsOnly')->willReturn([ + 'selectielijstRegister' => null, + 'selectielijstSchema' => null, + ]); + + $result = $this->service->applyArchivalMetadata($object, $schema); + $retention = $result->getRetention(); + + $this->assertEquals('vernietigen', $retention['archiefnominatie']); + $this->assertEquals('nog_te_archiveren', $retention['archiefstatus']); + $this->assertEquals('P5Y', $retention['bewaartermijn']); + $this->assertNotNull($retention['archiefactiedatum']); + }//end testApplyArchivalMetadataWithEnabledSchema() + + + /** + * Test that archival metadata is NOT applied when schema has no archive config. + */ + public function testApplyArchivalMetadataSkipsWhenDisabled(): void + { + $object = new ObjectEntity(); + $object->setRetention([]); + + $schema = $this->createMock(Schema::class); + $schema->method('getArchive')->willReturn([]); + + $result = $this->service->applyArchivalMetadata($object, $schema); + $retention = $result->getRetention(); + + $this->assertArrayNotHasKey('archiefnominatie', $retention); + }//end testApplyArchivalMetadataSkipsWhenDisabled() + + + /** + * Test that destroyed objects are flagged as immutable. + */ + public function testValidateNotImmutableReturnsDestroyedCode(): void + { + $object = new ObjectEntity(); + $object->setRetention(['archiefstatus' => 'vernietigd']); + + $result = $this->service->validateNotImmutable($object); + + $this->assertEquals('OBJECT_DESTROYED', $result); + }//end testValidateNotImmutableReturnsDestroyedCode() + + + /** + * Test that transferred objects are flagged as immutable. + */ + public function testValidateNotImmutableReturnsTransferredCode(): void + { + $object = new ObjectEntity(); + $object->setRetention(['archiefstatus' => 'overgebracht']); + + $result = $this->service->validateNotImmutable($object); + + $this->assertEquals('OBJECT_TRANSFERRED', $result); + }//end testValidateNotImmutableReturnsTransferredCode() + + + /** + * Test that mutable objects return null. + */ + public function testValidateNotImmutableReturnsNullForMutable(): void + { + $object = new ObjectEntity(); + $object->setRetention(['archiefstatus' => 'nog_te_archiveren']); + + $result = $this->service->validateNotImmutable($object); + + $this->assertNull($result); + }//end testValidateNotImmutableReturnsNullForMutable() + + + /** + * Test placing a legal hold. + */ + public function testPlaceLegalHold(): void + { + $object = new ObjectEntity(); + $object->setRetention([]); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('test-user'); + $this->userSession->method('getUser')->willReturn($user); + + $result = $this->service->placeLegalHold($object, 'WOO-verzoek 2025-0142'); + $retention = $result->getRetention(); + + $this->assertTrue($retention['legalHold']['active']); + $this->assertEquals('WOO-verzoek 2025-0142', $retention['legalHold']['reason']); + $this->assertEquals('test-user', $retention['legalHold']['placedBy']); + }//end testPlaceLegalHold() + + + /** + * Test releasing a legal hold preserves history. + */ + public function testReleaseLegalHold(): void + { + $object = new ObjectEntity(); + $object->setRetention([ + 'legalHold' => [ + 'active' => true, + 'reason' => 'WOO-verzoek', + 'placedBy' => 'admin', + 'placedDate' => '2026-01-01T00:00:00+00:00', + 'history' => [], + ], + ]); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('test-user'); + $this->userSession->method('getUser')->willReturn($user); + + $result = $this->service->releaseLegalHold($object, 'WOO afgehandeld'); + $retention = $result->getRetention(); + + $this->assertFalse($retention['legalHold']['active']); + $this->assertCount(1, $retention['legalHold']['history']); + $this->assertEquals('WOO afgehandeld', $retention['legalHold']['history'][0]['releaseReason']); + }//end testReleaseLegalHold() + + + /** + * Test hasActiveLegalHold returns true when hold is active. + */ + public function testHasActiveLegalHoldTrue(): void + { + $object = new ObjectEntity(); + $object->setRetention([ + 'legalHold' => ['active' => true], + ]); + + $this->assertTrue($this->service->hasActiveLegalHold($object)); + }//end testHasActiveLegalHoldTrue() + + + /** + * Test hasActiveLegalHold returns false when no hold. + */ + public function testHasActiveLegalHoldFalseWhenNoHold(): void + { + $object = new ObjectEntity(); + $object->setRetention([]); + + $this->assertFalse($this->service->hasActiveLegalHold($object)); + }//end testHasActiveLegalHoldFalseWhenNoHold() + + + /** + * Test extending archiefactiedatum by default period. + */ + public function testExtendArchiefactiedatum(): void + { + $object = new ObjectEntity(); + $object->setRetention([ + 'archiefactiedatum' => '2026-01-01', + ]); + + $this->settingsHandler->method('getArchivalSettingsOnly')->willReturn([ + 'defaultExtensionPeriod' => 'P1Y', + ]); + + $result = $this->service->extendArchiefactiedatum($object); + $retention = $result->getRetention(); + + $this->assertEquals('2027-01-01', $retention['archiefactiedatum']); + }//end testExtendArchiefactiedatum() + + + /** + * Test destruction certificate generation. + */ + public function testGenerateDestructionCertificate(): void + { + $listData = [ + 'objects' => [ + ['schema' => '1', 'classificatie' => 'B1'], + ['schema' => '1', 'classificatie' => 'B1'], + ['schema' => '2', 'classificatie' => 'A1'], + ], + 'approvals' => [ + ['userId' => 'archivist-1'], + ], + ]; + + $result = $this->service->generateDestructionCertificate($listData, 3, '2026-03-22T10:00:00+00:00'); + + $this->assertEquals('verklaring_van_vernietiging', $result['type']); + $this->assertEquals(3, $result['totalDestroyed']); + $this->assertCount(2, $result['groupedBySchema']); + $this->assertTrue($result['immutable']); + $this->assertEquals(['archivist-1'], $result['approvedBy']); + }//end testGenerateDestructionCertificate() +}//end class