diff --git a/.composer-require-checker.json b/.composer-require-checker.json
index 09556d7..66de5dd 100644
--- a/.composer-require-checker.json
+++ b/.composer-require-checker.json
@@ -14,7 +14,13 @@
"callable",
"iterable",
"void",
- "object"
+ "object",
+ "attr",
+ "xlt",
+ "OpenEMR\\Common\\Crypto\\CryptoGen",
+ "OpenEMR\\Common\\Database\\QueryUtils",
+ "OpenEMR\\Events\\Globals\\GlobalsInitializedEvent",
+ "OpenEMR\\Services\\Globals\\GlobalSetting"
],
"php-core-extensions": [
"Core",
diff --git a/composer.json b/composer.json
index 5e743b1..2fdf4de 100644
--- a/composer.json
+++ b/composer.json
@@ -26,11 +26,13 @@
"require": {
"php": ">=8.2",
"ext-filter": "*",
+ "symfony/event-dispatcher": "^6.4 || ^7.0",
"symfony/http-foundation": "^6.4 || ^7.0",
"symfony/yaml": "^6.4 || ^7.0"
},
"require-dev": {
"ergebnis/composer-normalize": "^2.44",
+ "openemr/openemr": ">=8.0.0 || 8.1.0.x-dev || dev-master",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^11.0",
"rector/rector": "^2.0",
@@ -39,6 +41,59 @@
"suggest": {
"google/cloud-secret-manager": "Required for gcp-secret-manager provider in _secrets block (^2.1)"
},
+ "repositories": [
+ {
+ "type": "package",
+ "package": {
+ "name": "openemr/openemr",
+ "version": "8.0.0",
+ "autoload": {
+ "psr-4": {
+ "OpenEMR\\": "src/"
+ }
+ },
+ "source": {
+ "type": "git",
+ "url": "https://github.com/openemr/openemr.git",
+ "reference": "v8_0_0"
+ }
+ }
+ },
+ {
+ "type": "package",
+ "package": {
+ "name": "openemr/openemr",
+ "version": "8.1.0.x-dev",
+ "autoload": {
+ "psr-4": {
+ "OpenEMR\\": "src/"
+ }
+ },
+ "source": {
+ "type": "git",
+ "url": "https://github.com/openemr/openemr.git",
+ "reference": "rel-810"
+ }
+ }
+ },
+ {
+ "type": "package",
+ "package": {
+ "name": "openemr/openemr",
+ "version": "dev-master",
+ "autoload": {
+ "psr-4": {
+ "OpenEMR\\": "src/"
+ }
+ },
+ "source": {
+ "type": "git",
+ "url": "https://github.com/openemr/openemr.git",
+ "reference": "master"
+ }
+ }
+ }
+ ],
"minimum-stability": "stable",
"prefer-stable": true,
"autoload": {
diff --git a/src/ConfigService.php b/src/ConfigService.php
new file mode 100644
index 0000000..81f4193
--- /dev/null
+++ b/src/ConfigService.php
@@ -0,0 +1,40 @@
+
+ * @copyright Copyright (c) 2026 OpenCoreEMR Inc.
+ */
+class ConfigService
+{
+ private const UPSERT_SQL = <<<'SQL'
+ INSERT INTO `globals` (`gl_name`, `gl_index`, `gl_value`)
+ VALUES (?, 0, ?)
+ ON DUPLICATE KEY UPDATE `gl_value` = ?
+ SQL;
+
+ /**
+ * Save a setting to the globals table (plaintext).
+ */
+ public function saveSetting(string $key, string $value): void
+ {
+ QueryUtils::sqlStatementThrowException(self::UPSERT_SQL, [$key, $value, $value]);
+ }
+
+ /**
+ * Encrypt a value with CryptoGen and save it to the globals table.
+ */
+ public function saveEncryptedSetting(string $key, string $value): void
+ {
+ $encrypted = (new CryptoGen())->encryptStandard($value);
+ $this->saveSetting($key, $encrypted);
+ }
+}
diff --git a/src/GlobalsRegistrar.php b/src/GlobalsRegistrar.php
new file mode 100644
index 0000000..df75b88
--- /dev/null
+++ b/src/GlobalsRegistrar.php
@@ -0,0 +1,89 @@
+
+ * @copyright Copyright (c) 2026 OpenCoreEMR Inc.
+ */
+class GlobalsRegistrar
+{
+ public function __construct(
+ private readonly ParameterBag $globalsBag,
+ ) {
+ }
+
+ /**
+ * Subscribe to GlobalsInitializedEvent and register the module's globals section.
+ */
+ public function register(
+ EventDispatcherInterface $eventDispatcher,
+ GlobalsSectionDescriptor $descriptor,
+ ): void {
+ $globalsBag = $this->globalsBag;
+
+ $eventDispatcher->addListener(
+ GlobalsInitializedEvent::EVENT_HANDLE,
+ static function (GlobalsInitializedEvent $event) use ($descriptor, $globalsBag): void {
+ $service = $event->getGlobalsService();
+
+ $service->createSection($descriptor->sectionName);
+
+ // Enable/disable toggle
+ $enableSetting = new GlobalSetting(
+ sprintf(xlt('Enable %s'), $descriptor->sectionName),
+ GlobalSetting::DATA_TYPE_BOOL,
+ '0',
+ sprintf(xlt('Enable or disable the %s module'), $descriptor->sectionName),
+ );
+ $service->appendToSection(
+ $descriptor->sectionName,
+ $descriptor->enableKey,
+ $enableSetting,
+ );
+
+ // Settings page link
+ $webroot = $globalsBag->getString('webroot');
+ $settingsPath = $webroot
+ . '/interface/modules/custom_modules/' . $descriptor->moduleDirName
+ . '/public/settings.php';
+
+ $linkSetting = new GlobalSetting(
+ xlt('Module Settings'),
+ GlobalSetting::DATA_TYPE_HTML_DISPLAY_SECTION,
+ '',
+ xlt('Link to the module settings page'),
+ );
+ $linkSetting->addFieldOption(
+ GlobalSetting::DATA_TYPE_OPTION_RENDER_CALLBACK,
+ static function () use ($settingsPath, $descriptor): string {
+ $url = attr($settingsPath);
+ $label = xlt('Open Module Settings');
+ $description = xlt($descriptor->settingsDescription);
+ return <<{$description}
+ {$label}
+ HTML;
+ },
+ );
+ $service->appendToSection(
+ $descriptor->sectionName,
+ $descriptor->enableKey . '_settings_link',
+ $linkSetting,
+ );
+ },
+ );
+ }
+}
diff --git a/src/GlobalsSectionDescriptor.php b/src/GlobalsSectionDescriptor.php
new file mode 100644
index 0000000..325ef9f
--- /dev/null
+++ b/src/GlobalsSectionDescriptor.php
@@ -0,0 +1,28 @@
+
+ * @copyright Copyright (c) 2026 OpenCoreEMR Inc.
+ */
+final readonly class GlobalsSectionDescriptor
+{
+ /**
+ * @param string $sectionName Display name for the globals section (e.g. "OpenCoreEMR Sinch Conversations")
+ * @param string $moduleDirName Module directory name under custom_modules/ (e.g. "oce-module-sinch-conversations")
+ * @param string $enableKey Globals key for the enable/disable toggle (e.g. "oce_sinch_conversations_enabled")
+ * @param string $settingsDescription Help text shown above the settings link
+ */
+ public function __construct(
+ public string $sectionName,
+ public string $moduleDirName,
+ public string $enableKey,
+ public string $settingsDescription,
+ ) {
+ }
+}
diff --git a/tests/Mocks/MockCryptoGen.php b/tests/Mocks/MockCryptoGen.php
new file mode 100644
index 0000000..f5d9784
--- /dev/null
+++ b/tests/Mocks/MockCryptoGen.php
@@ -0,0 +1,16 @@
+}> */
+ private static array $queries = [];
+
+ private static ?\Throwable $nextException = null;
+
+ /**
+ * @param list $binds
+ */
+ public static function sqlStatementThrowException(string $sql, array $binds = []): true
+ {
+ self::$queries[] = ['sql' => $sql, 'binds' => $binds];
+ if (self::$nextException !== null) {
+ $e = self::$nextException;
+ self::$nextException = null;
+ throw $e;
+ }
+ return true;
+ }
+
+ public static function setNextException(\Throwable $e): void
+ {
+ self::$nextException = $e;
+ }
+
+ /** @return list}> */
+ public static function getQueries(): array
+ {
+ return self::$queries;
+ }
+
+ public static function reset(): void
+ {
+ self::$queries = [];
+ self::$nextException = null;
+ }
+}
diff --git a/tests/Mocks/openemr_functions.php b/tests/Mocks/openemr_functions.php
new file mode 100644
index 0000000..445d1ee
--- /dev/null
+++ b/tests/Mocks/openemr_functions.php
@@ -0,0 +1,20 @@
+service = new ConfigService();
+ }
+
+ protected function tearDown(): void
+ {
+ QueryUtils::reset();
+ }
+
+ public function testSaveSettingExecutesUpsert(): void
+ {
+ $this->service->saveSetting('oce_test_key', 'test_value');
+
+ $queries = QueryUtils::getQueries();
+ $this->assertCount(1, $queries);
+ $this->assertStringContainsString('INSERT INTO `globals`', $queries[0]['sql']);
+ $this->assertStringContainsString('ON DUPLICATE KEY UPDATE', $queries[0]['sql']);
+ $this->assertSame(['oce_test_key', 'test_value', 'test_value'], $queries[0]['binds']);
+ }
+
+ public function testSaveEncryptedSettingEncryptsBeforeSaving(): void
+ {
+ $this->service->saveEncryptedSetting('oce_secret_key', 'secret_value');
+
+ $queries = QueryUtils::getQueries();
+ $this->assertCount(1, $queries);
+ $this->assertSame('oce_secret_key', $queries[0]['binds'][0]);
+ // MockCryptoGen uses base64; value is bound twice for INSERT and UPDATE
+ $encrypted = base64_encode('secret_value');
+ $this->assertSame($encrypted, $queries[0]['binds'][1]);
+ $this->assertSame($encrypted, $queries[0]['binds'][2]);
+ }
+
+ public function testSaveSettingPropagatesExceptions(): void
+ {
+ QueryUtils::setNextException(new \RuntimeException('DB error'));
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('DB error');
+
+ $this->service->saveSetting('oce_test_key', 'value');
+ }
+
+ public function testMultipleSaveSettingCallsAreRecorded(): void
+ {
+ $this->service->saveSetting('key_a', 'val_a');
+ $this->service->saveSetting('key_b', 'val_b');
+
+ $queries = QueryUtils::getQueries();
+ $this->assertCount(2, $queries);
+ $this->assertSame(['key_a', 'val_a', 'val_a'], $queries[0]['binds']);
+ $this->assertSame(['key_b', 'val_b', 'val_b'], $queries[1]['binds']);
+ }
+}
diff --git a/tests/Unit/GlobalsRegistrarTest.php b/tests/Unit/GlobalsRegistrarTest.php
new file mode 100644
index 0000000..d29b813
--- /dev/null
+++ b/tests/Unit/GlobalsRegistrarTest.php
@@ -0,0 +1,109 @@
+descriptor = new GlobalsSectionDescriptor(
+ sectionName: 'OpenCoreEMR Test Module',
+ moduleDirName: 'oce-module-test',
+ enableKey: 'oce_test_module_enabled',
+ settingsDescription: 'Configure test module options.',
+ );
+ $this->globalsBag = new ParameterBag(['webroot' => '/openemr']);
+ }
+
+ public function testRegisterAddsListener(): void
+ {
+ $dispatcher = new EventDispatcher();
+ $registrar = new GlobalsRegistrar($this->globalsBag);
+ $registrar->register($dispatcher, $this->descriptor);
+
+ $this->assertTrue($dispatcher->hasListeners(GlobalsInitializedEvent::EVENT_HANDLE));
+ }
+
+ protected function tearDown(): void
+ {
+ // Clean up global state written by GlobalsService::save()
+ // phpcs:ignore Squiz.PHP.GlobalKeyword.NotAllowed
+ global $GLOBALS_METADATA;
+ $GLOBALS_METADATA = null;
+ }
+
+ /**
+ * Dispatch the event and return the captured globals metadata.
+ *
+ * @return array>
+ */
+ private function dispatchAndCapture(): array
+ {
+ $dispatcher = new EventDispatcher();
+ $registrar = new GlobalsRegistrar($this->globalsBag);
+ $registrar->register($dispatcher, $this->descriptor);
+
+ $globalsService = new GlobalsService([], [], []);
+ $event = new GlobalsInitializedEvent($globalsService);
+ $dispatcher->dispatch($event, GlobalsInitializedEvent::EVENT_HANDLE);
+
+ // GlobalsService::save() writes to global vars — use that to capture state
+ $globalsService->save();
+ // phpcs:ignore Squiz.PHP.GlobalKeyword.NotAllowed
+ global $GLOBALS_METADATA;
+ /** @var array> $metadata */
+ $metadata = $GLOBALS_METADATA ?? [];
+ return $metadata;
+ }
+
+ public function testCreatesSectionWithEnableToggle(): void
+ {
+ $metadata = $this->dispatchAndCapture();
+
+ $this->assertArrayHasKey('OpenCoreEMR Test Module', $metadata);
+
+ $section = $metadata['OpenCoreEMR Test Module'];
+ $this->assertArrayHasKey('oce_test_module_enabled', $section);
+
+ /** @var list $enableEntry */
+ $enableEntry = $section['oce_test_module_enabled'];
+ $this->assertSame('bool', $enableEntry[1]);
+ $this->assertSame('0', $enableEntry[2]);
+ }
+
+ public function testCreatesSectionWithSettingsLink(): void
+ {
+ $metadata = $this->dispatchAndCapture();
+
+ $this->assertArrayHasKey('OpenCoreEMR Test Module', $metadata);
+ $section = $metadata['OpenCoreEMR Test Module'];
+ $this->assertArrayHasKey('oce_test_module_enabled_settings_link', $section);
+
+ /** @var list $linkEntry */
+ $linkEntry = $section['oce_test_module_enabled_settings_link'];
+ $this->assertSame('html_display_section', $linkEntry[1]);
+
+ /** @var array $options */
+ $options = $linkEntry[4];
+ $this->assertArrayHasKey('render_callback', $options);
+
+ $html = ($options['render_callback'])();
+ $this->assertStringContainsString('oce-module-test', $html);
+ $this->assertStringContainsString('/openemr/interface/modules/custom_modules/', $html);
+ $this->assertStringContainsString('settings.php', $html);
+ $this->assertStringContainsString('Configure test module options.', $html);
+ }
+}
diff --git a/tests/Unit/GlobalsSectionDescriptorTest.php b/tests/Unit/GlobalsSectionDescriptorTest.php
new file mode 100644
index 0000000..eb8c8e9
--- /dev/null
+++ b/tests/Unit/GlobalsSectionDescriptorTest.php
@@ -0,0 +1,26 @@
+assertSame('OpenCoreEMR Test Module', $descriptor->sectionName);
+ $this->assertSame('oce-module-test', $descriptor->moduleDirName);
+ $this->assertSame('oce_test_module_enabled', $descriptor->enableKey);
+ $this->assertSame('Configure API credentials and module options.', $descriptor->settingsDescription);
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index d7a32e3..22dd046 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -7,3 +7,8 @@
// Suppress error_log output during tests
ini_set('error_log', '/dev/null');
+
+// Load mock classes to shadow real OpenEMR classes (must come after autoloader)
+require_once __DIR__ . '/Mocks/MockQueryUtils.php';
+require_once __DIR__ . '/Mocks/MockCryptoGen.php';
+require_once __DIR__ . '/Mocks/openemr_functions.php';