Skip to content
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
3 changes: 3 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
return [
'routes' => [
// Dashboard routes
// First-time setup wizard (ADR-042) - the standard CnSetupWizard contract.
['name' => 'setup#status', 'url' => '/api/setup/status', 'verb' => 'GET'],
['name' => 'setup#runAction', 'url' => '/api/setup/action/{actionId}', 'verb' => 'POST', 'requirements' => ['actionId' => '[a-z0-9\\-]+']],
['name' => 'dashboard#page', 'url' => '/', 'verb' => 'GET'],
['name' => 'dashboard#index', 'url' => '/api/dashboard', 'verb' => 'GET'],

Expand Down
5 changes: 5 additions & 0 deletions l10n/nl.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
OC.L10N.register(
"stackiq",
{
"Welcome": "Welkom",
"A short setup to get this app ready. Nothing here is required; you can close it and come back later.": "Een korte installatie om deze app klaar te zetten. Niets hiervan is verplicht; je kunt dit sluiten en later terugkomen.",
"Demo data (optional)": "Demovoorbeelddata (optioneel)",
"Load a small example dataset so the lists, detail pages and dashboards show a working product straight away. The data is obviously sample data, it is safe to run more than once, and it can be removed afterwards. Skip this on a production install.": "Laad een kleine voorbeeldset zodat de lijsten, detailpagina's en dashboards meteen een werkend product laten zien. De data is duidelijk voorbeelddata, veilig om meerdere keren uit te voeren en achteraf te verwijderen. Sla dit over op een productie-installatie.",
"All set": "Klaar",
"AMEF elements": "Amef elementen",
"AMEF standards": "Standaarden AMEF",
"Acquisition start date": "Startdatum Verwerving",
Expand Down
5 changes: 5 additions & 0 deletions l10n/nl.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
{
"translations": {
"Welcome": "Welkom",
"A short setup to get this app ready. Nothing here is required; you can close it and come back later.": "Een korte installatie om deze app klaar te zetten. Niets hiervan is verplicht; je kunt dit sluiten en later terugkomen.",
"Demo data (optional)": "Demovoorbeelddata (optioneel)",
"Load a small example dataset so the lists, detail pages and dashboards show a working product straight away. The data is obviously sample data, it is safe to run more than once, and it can be removed afterwards. Skip this on a production install.": "Laad een kleine voorbeeldset zodat de lijsten, detailpagina's en dashboards meteen een werkend product laten zien. De data is duidelijk voorbeelddata, veilig om meerdere keren uit te voeren en achteraf te verwijderen. Sla dit over op een productie-installatie.",
"All set": "Klaar",
"AMEF elements": "Amef elementen",
"AMEF standards": "Standaarden AMEF",
"Acquisition start date": "Startdatum Verwerving",
Expand Down
180 changes: 180 additions & 0 deletions lib/Controller/SetupController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
<?php
/**
* Stackiq SetupController.
*
* The ADR-042 first-time setup contract, in its smallest honest form:
*
* GET /api/setup/status per-step state
* POST /api/setup/action/{actionId} run a privileged server-side action
*
* This app declares no configuration of its own yet, so the wizard orients and
* offers the demo data the app ALREADY ships — a dataset generated from its own
* schemas that no operator could previously reach. It deliberately does not
* invent configuration steps: a wizard that asks questions the app does not act
* on is worse than none.
*
* @category Controller
* @package OCA\Stackiq\Controller
*
* @author Conduction Development Team <info@conduction.nl>
* @copyright 2026 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* @link https://conduction.nl
*/

declare(strict_types=1);

namespace OCA\Stackiq\Controller;

use OCA\Stackiq\AppInfo\Application;
use OCA\Stackiq\Settings\StackiqAdmin;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IAppConfig;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
use OCA\Stackiq\Service\DemoDataService;

/**
* First-time setup wizard endpoints.
*
* @spec exclude First-time-setup action dispatch; ADR-042 contract, no per-app behavioural spec.
*/
class SetupController extends Controller {
/**
* Setup contract version; matches manifest.setup.version.
*
* @var integer
*/
private const SETUP_VERSION = 1;

/**
* App-config key recording that the demo-data step was DEALT WITH.
*
* Not "objects exist": an operator who declines has finished the step, and
* re-offering the import on every visit would make "no thanks" impossible to
* express. Since @conduction/nextcloud-vue 2.21 that also matters visually —
* an OUTSTANDING OPTIONAL step opens the wizard over every page
* (nextcloud-vue#806), so a step that can never be marked done is a dialog
* that never closes.
*
* @var string
*/
private const DEMO_DECIDED_KEY = 'demo_data_decided';

/**
* Constructor.
*
* @param IRequest $request The request.
* @param IAppConfig $appConfig Records the demo-data decision.
* @param LoggerInterface $logger Records a failed import.
* @param DemoDataService $demoDataService Imports the shipped demo dataset.
*
* @return void
*/
public function __construct(
IRequest $request,
private readonly IAppConfig $appConfig,
private readonly LoggerInterface $logger,
private readonly DemoDataService $demoDataService,
) {
parent::__construct(appName: Application::APP_ID, request: $request);

}//end __construct()

/**
* Report per-step setup status for the wizard.
*
* `completed` is deliberately TRUE: this app declares no REQUIRED step, so
* setup must never gate the app. The demo-data step is reported so the wizard
* can stop asking once it has an answer.
*
* @return JSONResponse The status document.
*
* @spec exclude Setup status document; ADR-042 contract, no per-app behavioural spec.
*/
#[AuthorizedAdminSetting(StackiqAdmin::class)]
public function status(): JSONResponse {
$demoDecided = $this->appConfig->getValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, '') !== '';

return new JSONResponse(
data: [
'version' => self::SETUP_VERSION,
'completed' => true,
'steps' => [
'demo-data' => ['done' => $demoDecided],
],
]
);

}//end status()

/**
* Run a privileged server-side setup action.
*
* Admin-only by Nextcloud's default for an un-attributed method.
*
* @param string $actionId One of `install-demo-data` | `skip-demo-data`.
*
* @return JSONResponse `{ success, message }`.
*
* @spec exclude Setup action dispatch; ADR-042 contract, no per-app behavioural spec.
*/
#[AuthorizedAdminSetting(StackiqAdmin::class)]
public function runAction(string $actionId): JSONResponse {
if ($actionId === 'install-demo-data') {
return $this->installDemoData();
}

// DECLINING IS AN ANSWER — see DEMO_DECIDED_KEY.
if ($actionId === 'skip-demo-data') {
$this->appConfig->setValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, 'skipped');

return new JSONResponse(data: ['success' => true, 'message' => 'Demo data skipped.']);
}

return new JSONResponse(
data: ['success' => false, 'message' => 'Unknown setup action: ' . $actionId],
statusCode: Http::STATUS_NOT_FOUND,
);

}//end runAction()

/**
* Import the shipped demo dataset.
*
* Reports the FAILURE rather than a quiet success: an operator who asked for
* demo data and got none must be told, which is why DemoDataService::install()
* throws instead of returning an empty result.
*
* @return JSONResponse `{ success, message }`.
*/
private function installDemoData(): JSONResponse {
try {
$imported = $this->demoDataService->install();
} catch (\Throwable $e) {
$this->logger->error(
'Setup install-demo-data failed: ' . $e->getMessage(),
['app' => Application::APP_ID, 'exception' => $e]
);

return new JSONResponse(
data: ['success' => false, 'message' => 'Could not import the demo data: ' . $e->getMessage()],
statusCode: Http::STATUS_INTERNAL_SERVER_ERROR,
);
}

$this->appConfig->setValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, 'installed');

return new JSONResponse(
data: [
'success' => true,
'message' => 'Imported ' . $imported['objects'] . ' demo object(s).',
]
);

}//end installDemoData()
}//end class
185 changes: 185 additions & 0 deletions lib/Service/DemoDataService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
<?php
/**
* Stackiq DemoDataService.
*
* Imports `lib/Settings/stackiq_mock_register.json` — a `type: mock` descriptor generated from this
* app's own schemas by `hydra-gates/scripts/lib/generate_mock_register.py`, so
* every value is conformant BY CONSTRUCTION rather than written to look
* plausible.
*
* 🔴 ON DEMAND ONLY, NEVER ON INSTALL. A mock register has no Repair step: demo
* data is something an operator asks for from the setup wizard, and an install
* that silently seeds example objects into a production instance is a defect,
* not a convenience.
*
* @category Service
* @package OCA\Stackiq\Service
*
* @author Conduction Development Team <info@conduction.nl>
* @copyright 2026 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* @link https://conduction.nl
*/

declare(strict_types=1);

namespace OCA\Stackiq\Service;

use OCA\Stackiq\AppInfo\Application;
use OCP\App\IAppManager;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use RuntimeException;

/**
* Imports the shipped demo dataset on request.
*
* @spec exclude Demo-data import; ADR-111 rule 1, no per-app behavioural spec.
*/
class DemoDataService {
/**
* App-relative path to the generated mock descriptor.
*
* @var string
*/
private const DESCRIPTOR = '/lib/Settings/stackiq_mock_register.json';

/**
* Configuration identity for the demo import.
*
* 🔴 ITS OWN NAMESPACE, not the app id. Sharing the app's identity would make
* the demo import and the real configuration import share one version gate, so
* installing demo data could mask a pending configuration update — or be
* masked by one.
*
* @var string
*/
private const CONFIG_APP_ID = Application::APP_ID . '.demo';

/**
* Constructor.
*
* @param IAppManager $appManager Resolves this app's path and version.
* @param ContainerInterface $container Resolves OpenRegister's importer.
* @param LoggerInterface $logger Records what was imported.
*
* @return void
*/
public function __construct(
private readonly IAppManager $appManager,
private readonly ContainerInterface $container,
private readonly LoggerInterface $logger,
) {
}//end __construct()

/**
* Whether this app ships a demo dataset at all.
*
* @return boolean True when the descriptor is present on disk.
*
* @spec exclude Demo-data availability probe; ADR-111 rule 1 has no per-app behavioural spec.
*/
public function isAvailable(): bool {
return is_file($this->descriptorPath()) === true;
}//end isAvailable()

/**
* Import the demo dataset.
*
* 🔴 THROWS RATHER THAN RETURNING A QUIET FAILURE. The caller reports the
* outcome to an operator who just asked for this, so "nothing happened" must
* not be presentable as success.
*
* @return array{objects: integer, registers: integer, schemas: integer} What was imported.
*
* @throws RuntimeException When the descriptor is missing, unreadable, or OpenRegister is absent.
*
* @spec exclude Demo-data import; ADR-111 rule 1 has no per-app behavioural spec.
*/
public function install(): array {
$path = $this->descriptorPath();
if (is_file($path) === false) {
throw new RuntimeException('No demo dataset ships with this app (' . self::DESCRIPTOR . ' not found).');
}

$raw = file_get_contents($path);
if ($raw === false) {
throw new RuntimeException('The demo dataset could not be read: ' . $path);
}

$data = json_decode($raw, true);
if (is_array($data) === false) {
throw new RuntimeException('The demo dataset is not valid JSON: ' . $path);
}

// Counted from the FILE, not the importer's reply, so the number reported
// is the number ASKED FOR. An object whose schema does not resolve is
// SKIPPED rather than errored, so a discrepancy here is a real condition
// an operator should be able to see.
$objects = 0;
$components = ($data['components'] ?? []);
if (is_array($components) === true && is_array(($components['objects'] ?? null)) === true) {
$objects = count($components['objects']);
}

$result = $this->configurationService()->importFromApp(
appId: self::CONFIG_APP_ID,
data: $data,
version: $this->appManager->getAppVersion(Application::APP_ID),
force: true
);

$imported = [
'objects' => $objects,
'registers' => count((array)($result['registers'] ?? [])),
'schemas' => count((array)($result['schemas'] ?? [])),
];

$this->logger->info(
'[DemoDataService] imported demo data: '
. $imported['objects'] . ' object(s), '
. $imported['registers'] . ' register(s), '
. $imported['schemas'] . ' schema(s).',
['app' => Application::APP_ID]
);

return $imported;
}//end install()

/**
* Absolute path to the shipped descriptor.
*
* @return string The path.
*/
private function descriptorPath(): string {
return $this->appManager->getAppPath(Application::APP_ID) . self::DESCRIPTOR;
}//end descriptorPath()

/**
* OpenRegister's configuration importer.
*
* 🔴 A CROSS-APP CLASS IS A RUNTIME LOOKUP. OpenRegister may not be installed,
* and asking the container for a class from a missing app raises something the
* caller cannot act on. Check first and say which app is missing.
*
* 🔴 THE RETURN TYPE IS `object`, NOT THE CLASS, AND THAT IS THE POINT. Naming
* a class from an OPTIONAL app in a native return type makes PHP resolve it
* whenever this method returns, so on an instance without OpenRegister the
* failure is a TypeError about a class nobody mentioned instead of the
* RuntimeException above that names the missing app.
*
* @return object The importer — an OCA\OpenRegister\Service\ConfigurationService.
*
* @psalm-return \OCA\OpenRegister\Service\ConfigurationService
*
* @throws RuntimeException When OpenRegister is not installed.
*/
private function configurationService(): object {
if (in_array('openregister', $this->appManager->getInstalledApps(), true) === false) {
throw new RuntimeException('Demo data needs OpenRegister, which is not installed.');
}

return $this->container->get('OCA\OpenRegister\Service\ConfigurationService');
}//end configurationService()
}//end class
Loading
Loading