Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 57 additions & 7 deletions lib/BackgroundJob/RunExportJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand All @@ -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.
*
Expand Down
12 changes: 11 additions & 1 deletion lib/Controller/ApplicationsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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']
Expand Down
72 changes: 54 additions & 18 deletions lib/Service/ApplicationInsightsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -115,13 +116,15 @@ 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
*/
public function __construct(
private readonly ObjectService $objectService,
private readonly AuditTrailMapper $auditTrailMapper,
private readonly SchemaMapper $schemaMapper,
private readonly LoggerInterface $logger,
) {
}//end __construct()
Expand Down Expand Up @@ -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];

Expand Down Expand Up @@ -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<int, string> $schemaSlugs Schema slugs from the manifest.
*
* @return array<int, int> 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
Expand Down Expand Up @@ -542,8 +578,8 @@ private function extractManifest(array $version): ?array
* that has not yet landed the `openregister-distinct-actor-aggregation`
* change.
*
* @param array<int, string> $schemaIds Unique schema IDs.
* @param int $hours Window hours.
* @param array<int, int> $schemaIds Integer schema IDs.
* @param int $hours Window hours.
*
* @return int Distinct actor count, or 0 when the aggregation API is unavailable.
*
Expand All @@ -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}',
Expand All @@ -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<int, string> $schemaIds Unique schema IDs (strings — coerced as needed).
* @param string $registerSlug The version's register slug.
* @param array<int, int> $schemaIds Integer schema IDs.
* @param string $registerSlug The version's register slug.
*
* @return int Total object count across the schema-set.
*
Expand Down Expand Up @@ -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<int, string> $schemaIds Unique schema IDs.
* @param string $registerSlug The version's register slug (reserved for future use).
* @param array<int, int> $schemaIds Integer schema IDs.
*
* @return int File count.
*
Expand All @@ -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) {
Expand Down Expand Up @@ -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<int, string> $schemaIds Unique schema IDs.
* @param int $hours Window hours.
* @param array<int, int> $schemaIds Integer schema IDs.
* @param int $hours Window hours.
*
* @return int Audit-event count.
*
Expand All @@ -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}',
Expand All @@ -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);
}
Expand Down Expand Up @@ -774,9 +810,9 @@ private function sumChartSeries(mixed $chart): int
*
* Returns an empty array when the schema-set is empty.
*
* @param array<int, string> $schemaIds Unique schema IDs.
* @param int $hours Window hours.
* @param string $registerSlug The register slug (reserved for future use).
* @param array<int, int> $schemaIds Integer schema IDs.
* @param int $hours Window hours.
* @param string $registerSlug The register slug (reserved for future use).
*
* @return array<int, array{timestamp: string, eventCount: int}>
*
Expand All @@ -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);
Expand Down
45 changes: 45 additions & 0 deletions lib/Service/ExportJobService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed>|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.
*
Expand Down
7 changes: 4 additions & 3 deletions lib/Service/ManifestResolverService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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) {
Expand Down
Loading