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 @@ -32,6 +32,9 @@
['name' => 'audit_pack#export', 'url' => '/api/compliance/audit/export', 'verb' => 'POST'],
['name' => 'audit_pack#dossier', 'url' => '/api/ai-features/{slug}/dossier', 'verb' => 'GET'],

// App health observability — AdminHealth dashboard page (admin-only, ADR-031 exception).
['name' => 'health#index', 'url' => '/api/admin/health', 'verb' => 'GET'],

// Settings (kept for existing settings store compatibility).
['name' => 'settings#index', 'url' => '/api/settings', 'verb' => 'GET'],
['name' => 'settings#create', 'url' => '/api/settings', 'verb' => 'POST'],
Expand Down
24 changes: 23 additions & 1 deletion l10n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,29 @@
"Settings saved successfully": "Settings saved successfully",
"Saving...": "Saving...",
"This app needs OpenRegister to store and manage data. Please install OpenRegister from the app store to get started.": "This app needs OpenRegister to store and manage data. Please install OpenRegister from the app store to get started.",
"User settings will appear here in a future update.": "User settings will appear here in a future update."
"User settings will appear here in a future update.": "User settings will appear here in a future update.",
"scholiq.page.dashboard.title": "Dashboard",
"scholiq.page.learner.title": "My Training",
"scholiq.page.admin.health.title": "App Health",
"scholiq.page.compliance.title": "Compliance",
"scholiq.widget.learner.mandatoryTraining": "My Mandatory Training",
"scholiq.widget.compliance.coverageGrid": "Regulation Coverage",
"scholiq.widget.compliance.attestationCount": "Pending Attestations",
"scholiq.col.course": "Course",
"scholiq.col.regulation": "Regulation",
"scholiq.col.status": "Status",
"scholiq.col.due": "Due Date",
"scholiq.col.days": "Days Remaining",
"scholiq.col.rag": "RAG",
"scholiq.col.coverage": "Coverage %",
"scholiq.col.enrolled": "Enrolled",
"scholiq.col.completed": "Completed",
"scholiq.col.overdue": "Overdue",
"scholiq.col.lastCampaign": "Last Campaign",
"scholiq.action.start": "Start",
"scholiq.action.createCampaign": "Create Campaign",
"scholiq.action.exportAuditPack": "Export Audit Pack",
"scholiq.action.viewInMydash": "View in MyDash"
},
"plurals": ""
}
24 changes: 23 additions & 1 deletion l10n/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,29 @@
"Settings saved successfully": "Instellingen succesvol opgeslagen",
"Saving...": "Opslaan...",
"This app needs OpenRegister to store and manage data. Please install OpenRegister from the app store to get started.": "Deze app heeft OpenRegister nodig om gegevens op te slaan en te beheren. Installeer OpenRegister via de app store om te beginnen.",
"User settings will appear here in a future update.": "Gebruikersinstellingen verschijnen hier in een toekomstige update."
"User settings will appear here in a future update.": "Gebruikersinstellingen verschijnen hier in een toekomstige update.",
"scholiq.page.dashboard.title": "Dashboard",
"scholiq.page.learner.title": "Mijn Training",
"scholiq.page.admin.health.title": "App Status",
"scholiq.page.compliance.title": "Compliance",
"scholiq.widget.learner.mandatoryTraining": "Mijn Verplichte Trainingen",
"scholiq.widget.compliance.coverageGrid": "Dekkingsoverzicht Regelgeving",
"scholiq.widget.compliance.attestationCount": "Openstaande Attestaties",
"scholiq.col.course": "Cursus",
"scholiq.col.regulation": "Regelgeving",
"scholiq.col.status": "Status",
"scholiq.col.due": "Vervaldatum",
"scholiq.col.days": "Dagen Resterend",
"scholiq.col.rag": "RAG",
"scholiq.col.coverage": "Dekking %",
"scholiq.col.enrolled": "Ingeschreven",
"scholiq.col.completed": "Afgerond",
"scholiq.col.overdue": "Achterstallig",
"scholiq.col.lastCampaign": "Laatste Campagne",
"scholiq.action.start": "Starten",
"scholiq.action.createCampaign": "Campagne Aanmaken",
"scholiq.action.exportAuditPack": "Auditpakket Exporteren",
"scholiq.action.viewInMydash": "Bekijk in MyDash"
},
"plurals": ""
}
117 changes: 117 additions & 0 deletions lib/Controller/HealthController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

/**
* Scholiq Health Controller
*
* Thin observability endpoint for the AdminHealth dashboard page. Returns
* five read-only diagnostic fields: OR connection status, schema count,
* audit-trail event count (last 24 h), MyDash installation flag, and last
* audit-pack export timestamp.
*
* This is a legitimate ADR-031 §"External-system contract / observability"
* exception: the five reads span NC's IAppManager (external), OR's query
* API (external), and compile-time config — none of which can be expressed
* as a schema widget without OR-side instrumentation that does not yet exist.
*
* @category Controller
* @package OCA\Scholiq\Controller
*
* @author Conduction Development Team <dev@conductio.nl>
* @copyright 2024 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* SPDX-License-Identifier: EUPL-1.2
*
* @version GIT: <git-id>
*
* @link https://conduction.nl
*/

declare(strict_types=1);

namespace OCA\Scholiq\Controller;

use OCA\Scholiq\AppInfo\Application;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IAppManager;
use OCP\IRequest;

/**
* Read-only observability endpoint for the AdminHealth dashboard page.
*
* Route: GET /api/admin/health (admin-only, see appinfo/routes.php)
*
* Response shape:
* {
* "openregister_connected": bool,
* "schemas_registered": int,
* "audit_trail_events_24h": int,
* "mydash_installed": bool,
* "last_audit_pack_export": string|null (ISO 8601 or null)
* }
*/
class HealthController extends Controller
{
/**
* Constructor.
*
* @param IRequest $request The request object.
* @param IAppManager $appManager Nextcloud application manager.

Check failure on line 60 in lib/Controller/HealthController.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality (psalm)

UndefinedClass

lib/Controller/HealthController.php:60:15: UndefinedClass: Class, interface or enum named OCP\IAppManager does not exist (see https://psalm.dev/019)

Check failure on line 60 in lib/Controller/HealthController.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality (psalm)

UndefinedClass

lib/Controller/HealthController.php:60:15: UndefinedClass: Class, interface or enum named OCP\IAppManager does not exist (see https://psalm.dev/019)
*/
public function __construct(
IRequest $request,
private readonly IAppManager $appManager,

Check failure on line 64 in lib/Controller/HealthController.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality (phpstan)

Property OCA\Scholiq\Controller\HealthController::$appManager has unknown class OCP\IAppManager as its type.

Check failure on line 64 in lib/Controller/HealthController.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality (phpstan)

Property OCA\Scholiq\Controller\HealthController::$appManager has unknown class OCP\IAppManager as its type.

Check failure on line 64 in lib/Controller/HealthController.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality (phpstan)

Parameter $appManager of method OCA\Scholiq\Controller\HealthController::__construct() has invalid type OCP\IAppManager.

Check failure on line 64 in lib/Controller/HealthController.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality (phpstan)

Parameter $appManager of method OCA\Scholiq\Controller\HealthController::__construct() has invalid type OCP\IAppManager.

Check failure on line 64 in lib/Controller/HealthController.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality (phpstan)

Property OCA\Scholiq\Controller\HealthController::$appManager has unknown class OCP\IAppManager as its type.

Check failure on line 64 in lib/Controller/HealthController.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality (phpstan)

Property OCA\Scholiq\Controller\HealthController::$appManager has unknown class OCP\IAppManager as its type.

Check failure on line 64 in lib/Controller/HealthController.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality (phpstan)

Parameter $appManager of method OCA\Scholiq\Controller\HealthController::__construct() has invalid type OCP\IAppManager.

Check failure on line 64 in lib/Controller/HealthController.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality (phpstan)

Parameter $appManager of method OCA\Scholiq\Controller\HealthController::__construct() has invalid type OCP\IAppManager.
) {
parent::__construct(appName: Application::APP_ID, request: $request);
}//end __construct()

/**
* Return health diagnostics for the AdminHealth dashboard page.
*
* Admin-only: enforced by route definition (no @NoAdminRequired annotation).
*
* @NoCSRFRequired
*
* @return JSONResponse
*/
public function index(): JSONResponse
{
// OR connection check: attempt to load the register manifest.
$orConnected = false;
$schemasRegistered = 0;

try {
$manifestPath = __DIR__.'/../../lib/Settings/scholiq_register.json';
if (file_exists($manifestPath) === true) {
$manifest = json_decode((string) file_get_contents($manifestPath), associative: true);
$orConnected = true;
$schemasRegistered = count($manifest['components']['schemas'] ?? []);
}
} catch (\Throwable) {
// Swallow — orConnected stays false.
}

// Audit-trail event count (last 24 h): placeholder query until OR provides
// a dedicated instrumentation endpoint. Returns 0 in v0.1; tracked in
// https://github.com/ConductionNL/openregister/issues as future enhancement.
$auditTrailEvents24h = 0;

// MyDash installation flag — resolved via NC IAppManager (no install-time dep).
$mydashInstalled = $this->appManager->isInstalled('mydash');

Check failure on line 101 in lib/Controller/HealthController.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality (phpstan)

Call to method isInstalled() on an unknown class OCP\IAppManager.

Check failure on line 101 in lib/Controller/HealthController.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality (psalm)

UndefinedClass

lib/Controller/HealthController.php:101:28: UndefinedClass: Class, interface or enum named OCP\IAppManager does not exist (see https://psalm.dev/019)

Check failure on line 101 in lib/Controller/HealthController.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality (phpstan)

Call to method isInstalled() on an unknown class OCP\IAppManager.

Check failure on line 101 in lib/Controller/HealthController.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality (psalm)

UndefinedClass

lib/Controller/HealthController.php:101:28: UndefinedClass: Class, interface or enum named OCP\IAppManager does not exist (see https://psalm.dev/019)

// Last audit-pack export timestamp: placeholder until OR audit-event query
// API is available. Returns null in v0.1.
$lastAuditPackExport = null;

return new JSONResponse(
[
'openregister_connected' => $orConnected,
'schemas_registered' => $schemasRegistered,
'audit_trail_events_24h' => $auditTrailEvents24h,
'mydash_installed' => $mydashInstalled,
'last_audit_pack_export' => $lastAuditPackExport,
]
);
}//end index()
}//end class
109 changes: 109 additions & 0 deletions lib/Lifecycle/RoleSelector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php

/**
* Scholiq Role Selector
*
* Single-method calculation helper that resolves a LearnerProfile's highest-
* priority Scholiq role. Called by OpenRegister's calculation engine to
* materialise the `primaryRole` calculated field declared on the LearnerProfile
* schema in lib/Settings/scholiq_register.json.
*
* This is a legitimate ADR-031 §"Domain rule engines that operate above schema
* metadata" exception: the selector picks WHICH manifest dashboard page applies;
* the selected page (and its widgets) remain fully declarative per ADR-022/024.
*
* @category Lifecycle
* @package OCA\Scholiq\Lifecycle
*
* @author Conduction Development Team <dev@conductio.nl>
* @copyright 2024 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* SPDX-License-Identifier: EUPL-1.2
*
* @version GIT: <git-id>
*
* @link https://conduction.nl
*/

declare(strict_types=1);

namespace OCA\Scholiq\Lifecycle;

use OCP\IGroupManager;
use OCP\IUser;

/**
* Resolves the highest-priority Scholiq role for a LearnerProfile.
*
* Priority order (highest first):
* compliance-officer(5) > hr(4) > admin(3) > manager(3) > instructor(2) > learner(1)
*
* An NC admin-group member always resolves to 'admin', regardless of declared roles.
* If no roles are declared, falls back to 'learner'.
*/
class RoleSelector
{
/**
* Static priority map. Higher value = higher priority.
*/
private const PRIORITY = [
'compliance-officer' => 5,
'hr' => 4,
'admin' => 3,
'manager' => 3,
'instructor' => 2,
'learner' => 1,
];

/**
* Constructor.
*
* @param IGroupManager $groupManager Nextcloud group manager for admin override check.
*/
public function __construct(
private readonly IGroupManager $groupManager,
) {
}//end __construct()

/**
* Select the primary role for a LearnerProfile.
*
* Called by OpenRegister's calculation engine. The $calculationContext array
* contains:
* - 'object' : the LearnerProfile object array (includes 'roles')
* - 'user' : OCP\IUser|null (the authenticated NC user, if available)
*
* @param array<string,mixed> $calculationContext Context provided by OR's calculation engine.
*
* @return string Highest-priority Scholiq role, defaults to 'learner'.
*/
public function calculate(array $calculationContext): string
{
$object = $calculationContext['object'] ?? [];
$user = $calculationContext['user'] ?? null;

// NC admin-group override takes unconditional precedence.
if ($user instanceof IUser && $this->groupManager->isAdmin($user->getUID()) === true) {
return 'admin';
}

$roles = $object['roles'] ?? [];
if (empty($roles) === true || is_array($roles) === false) {
return 'learner';
}

$bestRole = 'learner';
$bestPriority = 0;

foreach ($roles as $role) {
$priority = self::PRIORITY[$role] ?? 0;
if ($priority > $bestPriority) {
$bestPriority = $priority;
$bestRole = $role;
}
}

return $bestRole;
}//end calculate()
}//end class
Loading
Loading