diff --git a/lib/BackgroundJob/RunExportJob.php b/lib/BackgroundJob/RunExportJob.php index efee0c61..08783c3f 100644 --- a/lib/BackgroundJob/RunExportJob.php +++ b/lib/BackgroundJob/RunExportJob.php @@ -136,19 +136,41 @@ private function extractJobUuid($argument): string */ private function executePipeline(string $jobUuid): void { + // Load the queued ExportJob record so we have the real application + // identity — uuid, version slug, and slug (= appId). The job was + // persisted by ExportJobService::queue() before this background job + // was dispatched, so it must exist in OR. + $job = $this->exportJobService->loadJob(jobUuid: $jobUuid); + if ($job === null) { + throw new \RuntimeException( + sprintf('OpenBuilt RunExportJob: could not load ExportJob record for UUID %s', $jobUuid) + ); + } + + $applicationUuid = (string) ($job['applicationUuid'] ?? ''); + $applicationVersion = (string) ($job['applicationVersion'] ?? '0.1.0'); + $applicationSlug = (string) ($job['applicationSlug'] ?? 'exported-app'); + $license = (string) ($job['license'] ?? 'EUPL-1.2'); + + if ($applicationUuid === '') { + throw new \RuntimeException( + sprintf('OpenBuilt RunExportJob: ExportJob %s has an empty applicationUuid', $jobUuid) + ); + } + $context = [ - 'appId' => 'exported-app', - 'appNamespace' => 'ExportedApp', - 'appName' => 'Exported App', - 'appVersion' => '0.1.0', + 'appId' => $applicationSlug, + 'appNamespace' => $this->slugToNamespace(slug: $applicationSlug), + 'appName' => $this->slugToLabel(slug: $applicationSlug), + 'appVersion' => $applicationVersion, 'authorName' => 'OpenBuilt Citizen Developer', 'authorEmail' => 'dev@conduction.nl', - 'license' => 'EUPL-1.2', + 'license' => $license, ]; $zipPath = $this->exportService->generateAppZip( - applicationUuid: $jobUuid, - versionSlug: '0.1.0', + applicationUuid: $applicationUuid, + versionSlug: $applicationVersion, context: $context, jobUuid: $jobUuid ); @@ -161,6 +183,34 @@ private function executePipeline(string $jobUuid): void $this->logger->info('OpenBuilt export succeeded', ['jobUuid' => $jobUuid]); }//end executePipeline() + /** + * Convert a kebab-case app slug to a PascalCase PHP namespace segment. + * + * E.g. `my-virtual-app` → `MyVirtualApp`. + * + * @param string $slug The application slug. + * + * @return string PascalCase namespace. + */ + private function slugToNamespace(string $slug): string + { + return str_replace(' ', '', ucwords(str_replace('-', ' ', $slug))); + }//end slugToNamespace() + + /** + * Convert a kebab-case app slug to a human-readable label. + * + * E.g. `my-virtual-app` → `My Virtual App`. + * + * @param string $slug The application slug. + * + * @return string Human-readable label. + */ + private function slugToLabel(string $slug): string + { + return ucwords(str_replace('-', ' ', $slug)); + }//end slugToLabel() + /** * Fetch the PAT once and push to GitHub if one was supplied. * diff --git a/lib/Controller/ApplicationsController.php b/lib/Controller/ApplicationsController.php index 189264c5..9b8c4a39 100644 --- a/lib/Controller/ApplicationsController.php +++ b/lib/Controller/ApplicationsController.php @@ -51,6 +51,7 @@ use DateTimeImmutable; use DateTimeInterface; use OCA\OpenBuilt\AppInfo\Application; +use OCA\OpenBuilt\Service\ApplicationVersionService; use OCA\OpenBuilt\Service\ManifestResolverService; use OCA\OpenBuilt\Service\PermissionResolver; use OCA\OpenRegister\Db\AuditTrailMapper; @@ -416,7 +417,7 @@ private function resolveVersionBlob(string $token, array $application, string $a $version = $this->objectService->find( id: $token, register: 'openbuilt', - schema: 'application-version' + schema: ApplicationVersionService::APPLICATION_VERSION_SCHEMA ); if ($version === null) { @@ -1060,6 +1061,15 @@ private function persistApplication( 'slug' => (string) ($template['slug'] ?? $templateSlug), 'version' => (string) ($template['version'] ?? ''), ], + // Grant the creating user full ownership so PermissionResolver + // grants them access to read/edit/delete their own clone + // (REQ-OBP-008). Without this block, matchesCaller returns false + // on empty permissions and the user is immediately 403'd. + 'permissions' => [ + 'owners' => ['user:'.$ownerUid], + 'editors' => [], + 'viewers' => [], + ], ], register: $ctx['register'], schema: $ctx['applicationSchema'] diff --git a/lib/Service/ApplicationInsightsService.php b/lib/Service/ApplicationInsightsService.php index fdb7ba48..de8f5af7 100644 --- a/lib/Service/ApplicationInsightsService.php +++ b/lib/Service/ApplicationInsightsService.php @@ -56,6 +56,7 @@ use DateTime; use OCA\OpenRegister\Db\AuditTrailMapper; +use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Service\ObjectService; use OCP\IUser; use Psr\Log\LoggerInterface; @@ -115,6 +116,7 @@ class ApplicationInsightsService * * @param ObjectService $objectService OR object surface * @param AuditTrailMapper $auditTrailMapper Audit-trail aggregations (chart + actors + counts) + * @param SchemaMapper $schemaMapper Schema slug-to-integer-ID resolver * @param LoggerInterface $logger PSR logger * * @return void @@ -122,6 +124,7 @@ class ApplicationInsightsService public function __construct( private readonly ObjectService $objectService, private readonly AuditTrailMapper $auditTrailMapper, + private readonly SchemaMapper $schemaMapper, private readonly LoggerInterface $logger, ) { }//end __construct() @@ -223,8 +226,9 @@ public function computeInsights( $versionSlug = (string) ($version['slug'] ?? ''); $registerSlug = sprintf('openbuilt-%s-%s', $appSlug, $versionSlug); - $manifest = $this->extractManifest(version: $version); - $schemaIds = $this->deriveSchemaIds(manifest: $manifest, registerSlug: $registerSlug); + $manifest = $this->extractManifest(version: $version); + $schemaSlugs = $this->deriveSchemaIds(manifest: $manifest, registerSlug: $registerSlug); + $schemaIds = $this->resolveSchemaSlugsToIntIds(schemaSlugs: $schemaSlugs); $hours = self::WINDOW_HOURS[$window]; @@ -289,6 +293,38 @@ public function deriveSchemaIds(?array $manifest, string $registerSlug): array return array_keys($schemaIds); }//end deriveSchemaIds() + /** + * Resolve an array of schema slugs to their integer database IDs via + * SchemaMapper::find(). Slugs that cannot be resolved (not found, OR + * not available) are silently skipped so a single bad slug in the + * manifest does not zero-out all KPIs. + * + * @param array $schemaSlugs Schema slugs from the manifest. + * + * @return array Integer schema IDs suitable for AuditTrailMapper queries. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-openbuilt/tasks.md#task-18 + */ + private function resolveSchemaSlugsToIntIds(array $schemaSlugs): array + { + $intIds = []; + foreach ($schemaSlugs as $slug) { + try { + $intId = $this->schemaMapper->find($slug, _multitenancy: false)->getId(); + if ($intId !== null) { + $intIds[] = (int) $intId; + } + } catch (Throwable $e) { + $this->logger->debug( + 'OpenBuilt: could not resolve schema slug "{slug}" to integer ID: {message}', + ['slug' => $slug, 'message' => $e->getMessage()] + ); + } + }//end foreach + + return array_values(array_unique($intIds)); + }//end resolveSchemaSlugsToIntIds() + /** * Extract a schema ID from a manifest page entry IF the entry's * `config.register` matches the supplied register slug AND @@ -542,8 +578,8 @@ private function extractManifest(array $version): ?array * that has not yet landed the `openregister-distinct-actor-aggregation` * change. * - * @param array $schemaIds Unique schema IDs. - * @param int $hours Window hours. + * @param array $schemaIds Integer schema IDs. + * @param int $hours Window hours. * * @return int Distinct actor count, or 0 when the aggregation API is unavailable. * @@ -564,7 +600,7 @@ private function safeDistinctActorCount(array $schemaIds, int $hours): int } try { - return (int) $this->auditTrailMapper->getDistinctActorCount(array_map('intval', $schemaIds), $hours); + return (int) $this->auditTrailMapper->getDistinctActorCount($schemaIds, $hours); } catch (Throwable $e) { $this->logger->warning( 'OpenBuilt: getDistinctActorCount failed: {message}', @@ -581,8 +617,8 @@ private function safeDistinctActorCount(array $schemaIds, int $hours): int * Per OR's ObjectService::count() signature, we pass register + * schema via the config array. Schema-set may be empty (returns 0). * - * @param array $schemaIds Unique schema IDs (strings — coerced as needed). - * @param string $registerSlug The version's register slug. + * @param array $schemaIds Integer schema IDs. + * @param string $registerSlug The version's register slug. * * @return int Total object count across the schema-set. * @@ -626,8 +662,8 @@ private function countObjects(array $schemaIds, string $registerSlug): int * * Returns 0 when the schema-set is empty. * - * @param string $registerSlug The version's register slug (reserved for future use). - * @param array $schemaIds Unique schema IDs. + * @param string $registerSlug The version's register slug (reserved for future use). + * @param array $schemaIds Integer schema IDs. * * @return int File count. * @@ -646,7 +682,7 @@ private function countAttachedFiles(string $registerSlug, array $schemaIds): int } try { - $stats = $this->auditTrailMapper->getStatisticsGroupedBySchema(array_map('intval', $schemaIds)); + $stats = $this->auditTrailMapper->getStatisticsGroupedBySchema($schemaIds); $total = 0; foreach ($stats as $row) { @@ -675,8 +711,8 @@ private function countAttachedFiles(string $registerSlug, array $schemaIds): int * `countByRegisterAndWindow` is unavailable on the OR floor (today * it is unavailable; this method becomes a one-liner when it lands). * - * @param array $schemaIds Unique schema IDs. - * @param int $hours Window hours. + * @param array $schemaIds Integer schema IDs. + * @param int $hours Window hours. * * @return int Audit-event count. * @@ -690,7 +726,7 @@ private function countAuditEvents(array $schemaIds, int $hours): int if (method_exists($this->auditTrailMapper, 'countByRegisterAndWindow') === true) { try { - return (int) $this->auditTrailMapper->countByRegisterAndWindow(array_map('intval', $schemaIds), $hours); + return (int) $this->auditTrailMapper->countByRegisterAndWindow($schemaIds, $hours); } catch (Throwable $e) { $this->logger->debug( 'OpenBuilt: countByRegisterAndWindow failed: {message}', @@ -711,7 +747,7 @@ private function countAuditEvents(array $schemaIds, int $hours): int from: $from, till: $till, registerId: null, - schemaId: (int) $schemaId + schemaId: $schemaId ); $total += $this->sumChartSeries(chart: $chart); } @@ -774,9 +810,9 @@ private function sumChartSeries(mixed $chart): int * * Returns an empty array when the schema-set is empty. * - * @param array $schemaIds Unique schema IDs. - * @param int $hours Window hours. - * @param string $registerSlug The register slug (reserved for future use). + * @param array $schemaIds Integer schema IDs. + * @param int $hours Window hours. + * @param string $registerSlug The register slug (reserved for future use). * * @return array * @@ -802,7 +838,7 @@ private function buildActivityTimeline(array $schemaIds, int $hours, string $reg $from, $till, null, - (int) $schemaId + $schemaId ); $this->mergeChartIntoBuckets(chart: $chart, buckets: $merged); diff --git a/lib/Service/ExportJobService.php b/lib/Service/ExportJobService.php index f0ac554c..ed005568 100644 --- a/lib/Service/ExportJobService.php +++ b/lib/Service/ExportJobService.php @@ -370,6 +370,51 @@ public function credentialKey(string $jobUuid): string return self::PAT_CREDENTIAL_PREFIX.$jobUuid.self::PAT_CREDENTIAL_SUFFIX; }//end credentialKey() + /** + * Load an ExportJob record from OR by its UUID. + * + * Returns the job data as an array, or null when the record cannot be + * found (OR unavailable, or unknown UUID). Callers should treat null + * as a fatal-for-this-run condition. + * + * @param string $jobUuid ExportJob UUID. + * + * @return array|null Job data, or null on failure. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-openbuilt/tasks.md#task-33 + */ + public function loadJob(string $jobUuid): ?array + { + try { + if ($this->container->has('OCA\\OpenRegister\\Service\\ObjectService') === false) { + return null; + } + + $service = $this->container->get('OCA\\OpenRegister\\Service\\ObjectService'); + if (method_exists($service, 'find') === false) { + return null; + } + + $object = $service->find($jobUuid); + if ($object === null) { + return null; + } + + if (method_exists($object, 'getObject') === true) { + $data = $object->getObject() ?? []; + return is_array($data) === true ? $data : null; + } + + // Some OR versions return the array directly. + return is_array($object) === true ? $object : null; + } catch (\Throwable $e) { + $this->logger->warning( + 'OpenBuilt ExportJobService: loadJob failed for job '.$jobUuid.': '.$e->getMessage() + ); + return null; + }//end try + }//end loadJob() + /** * Generate a UUIDv4. * diff --git a/lib/Service/ManifestResolverService.php b/lib/Service/ManifestResolverService.php index d7d65301..056a7093 100644 --- a/lib/Service/ManifestResolverService.php +++ b/lib/Service/ManifestResolverService.php @@ -43,6 +43,7 @@ namespace OCA\OpenBuilt\Service; +use OCA\OpenBuilt\Service\ApplicationVersionService; use OCA\OpenBuilt\Service\PermissionResolver; use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\SchemaMapper; @@ -342,7 +343,7 @@ private function findVersionBySlug(array $application, string $versionSlug): ?ar } $registerId = $this->registerMapper->find('openbuilt', _multitenancy: false)->getId(); - $schemaId = $this->schemaMapper->find('application-version', _multitenancy: false)->getId(); + $schemaId = $this->schemaMapper->find(ApplicationVersionService::APPLICATION_VERSION_SCHEMA, _multitenancy: false)->getId(); // Two-step: filter ApplicationVersions by parent application UUID + slug. // OR compound-filter note: OR's searchObjects supports direct property @@ -397,7 +398,7 @@ private function findVersionBySlugFallback(string $applicationUuid, string $vers { try { $registerId = $this->registerMapper->find('openbuilt', _multitenancy: false)->getId(); - $schemaId = $this->schemaMapper->find('application-version', _multitenancy: false)->getId(); + $schemaId = $this->schemaMapper->find(ApplicationVersionService::APPLICATION_VERSION_SCHEMA, _multitenancy: false)->getId(); $allVersions = $this->objectService->searchObjects( query: [ @@ -441,7 +442,7 @@ private function findVersionByUuid(string $uuid): ?array $version = $this->objectService->find( id: $uuid, register: 'openbuilt', - schema: 'application-version' + schema: ApplicationVersionService::APPLICATION_VERSION_SCHEMA ); if ($version === null) { diff --git a/tests/Unit/BackgroundJob/RunExportJobTest.php b/tests/Unit/BackgroundJob/RunExportJobTest.php index c31d3099..932a5dfd 100644 --- a/tests/Unit/BackgroundJob/RunExportJobTest.php +++ b/tests/Unit/BackgroundJob/RunExportJobTest.php @@ -119,6 +119,23 @@ private function buildJob(?\Psr\Log\LoggerInterface $logger=null): RunExportJob ); }//end buildJob() + /** + * Standard ExportJob fixture returned by the loadJob mock. + * + * @param string $applicationUuid Optional application UUID override. + * + * @return array + */ + private function jobFixture(string $applicationUuid='app-uuid-test'): array + { + return [ + 'applicationUuid' => $applicationUuid, + 'applicationVersion' => '1.0.0', + 'applicationSlug' => 'test-app', + 'license' => 'EUPL-1.2', + ]; + }//end jobFixture() + /** * Happy path: the job transitions queued → running → succeeded via * the declarative TransitionEngine (proxied through ExportJobService). @@ -132,6 +149,8 @@ public function testRunTransitionsThroughRunningToSucceeded(): void { $jobUuid = 'job-success-uuid'; + $this->exportJobService->method('loadJob')->willReturn($this->jobFixture()); + $this->exportJobService ->expects(self::exactly(2)) ->method('transitionJob') @@ -177,6 +196,8 @@ public function testRunTransitionsToFailedOnException(): void { $jobUuid = 'job-fail-uuid'; + $this->exportJobService->method('loadJob')->willReturn($this->jobFixture()); + $this->exportService ->method('generateAppZip') ->willThrowException(new \RuntimeException('disk full')); @@ -217,6 +238,7 @@ public function testRunTransitionsToFailedOnException(): void public function testClearPatAlwaysCalledOnSuccess(): void { $jobUuid = 'pat-cleanup-success'; + $this->exportJobService->method('loadJob')->willReturn($this->jobFixture()); $this->exportService->method('generateAppZip')->willReturn('/tmp/x.zip'); $this->exportJobService->method('fetchPat')->willReturn(null); $this->exportJobService->method('transitionJob')->willReturn(true); @@ -242,6 +264,7 @@ public function testClearPatAlwaysCalledOnFailure(): void { $jobUuid = 'pat-cleanup-failure'; + $this->exportJobService->method('loadJob')->willReturn($this->jobFixture()); $this->exportService ->method('generateAppZip') ->willThrowException(new \RuntimeException('boom')); @@ -269,6 +292,8 @@ public function testRerunWithSameParamsProducesEquivalentInvocations(): void { $jobUuid = 'idempotent-rerun-uuid'; + $this->exportJobService->method('loadJob')->willReturn($this->jobFixture('app-uuid-idempotent')); + $captured = []; $this->exportService ->expects(self::exactly(2)) @@ -332,6 +357,7 @@ public function log($level, \Stringable|string $message, array $context=[]): voi } }; + $this->exportJobService->method('loadJob')->willReturn($this->jobFixture()); $this->exportService->method('generateAppZip')->willReturn('/tmp/out.zip'); $this->exportJobService->method('fetchPat')->willReturn($pat); $this->exportJobService->method('transitionJob')->willReturn(true); diff --git a/tests/Unit/Service/ApplicationInsightsServiceTest.php b/tests/Unit/Service/ApplicationInsightsServiceTest.php index c9f3eacc..e237f8b0 100644 --- a/tests/Unit/Service/ApplicationInsightsServiceTest.php +++ b/tests/Unit/Service/ApplicationInsightsServiceTest.php @@ -34,6 +34,7 @@ use OCA\OpenBuilt\Service\ApplicationInsightsService; use OCA\OpenRegister\Db\AuditTrailMapper; use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Service\ObjectService; use OCP\IUser; use PHPUnit\Framework\MockObject\MockObject; @@ -57,6 +58,11 @@ class ApplicationInsightsServiceTest extends TestCase */ private AuditTrailMapper&MockObject $auditTrailMapper; + /** + * @var SchemaMapper&MockObject + */ + private SchemaMapper&MockObject $schemaMapper; + /** * @var LoggerInterface&MockObject */ @@ -78,11 +84,13 @@ protected function setUp(): void $this->objectService = $this->createMock(ObjectService::class); $this->auditTrailMapper = $this->createMock(AuditTrailMapper::class); + $this->schemaMapper = $this->createMock(SchemaMapper::class); $this->logger = $this->createMock(LoggerInterface::class); $this->service = new ApplicationInsightsService( objectService: $this->objectService, auditTrailMapper: $this->auditTrailMapper, + schemaMapper: $this->schemaMapper, logger: $this->logger, ); }//end setUp()