Skip to content
Draft
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
188 changes: 172 additions & 16 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,12 @@ OpenEMR modules follow a **Symfony-inspired MVC architecture** with:
│ └── assets/ # Static assets (CSS, JS, images)
├── src/
│ ├── Bootstrap.php # Module initialization and DI
│ ├── GlobalsAccessor.php # Globals access wrapper
│ ├── ConfigAccessorInterface.php # Configuration access abstraction
│ ├── ConfigFactory.php # Factory for config accessor selection
│ ├── EnvironmentConfigAccessor.php # Env var config (for containers)
│ ├── GlobalsAccessor.php # Database-backed config (OpenEMR globals)
│ ├── GlobalConfig.php # Centralized configuration wrapper
│ ├── ModuleAccessGuard.php # Entry point access guard
│ ├── Command/ # Console commands (removed after setup)
│ │ └── SetupCommand.php
│ ├── Controller/ # Request handlers
Expand All @@ -101,11 +106,15 @@ OpenEMR modules follow a **Symfony-inspired MVC architecture** with:
│ ├── Service/ # Business logic
│ │ ├── {Feature}Service.php
│ │ └── ...
│ ├── Exception/ # Custom exception types
│ │ ├── {ModuleName}ExceptionInterface.php
│ │ ├── {ModuleName}Exception.php
│ │ └── {Specific}Exception.php
│ └── GlobalConfig.php # Configuration wrapper (you create this)
│ └── Exception/ # Custom exception types
│ ├── {ModuleName}ExceptionInterface.php
│ ├── {ModuleName}Exception.php
│ ├── {ModuleName}NotFoundException.php
│ ├── {ModuleName}UnauthorizedException.php
│ ├── {ModuleName}AccessDeniedException.php
│ ├── {ModuleName}ValidationException.php
│ ├── {ModuleName}ConfigurationException.php
│ └── {ModuleName}ApiException.php
├── templates/
│ └── {feature}/
│ ├── {view}.html.twig
Expand All @@ -115,6 +124,92 @@ OpenEMR modules follow a **Symfony-inspired MVC architecture** with:
└── openemr.bootstrap.php # Module loader
```

## Configuration Abstraction Layer

The template includes a flexible configuration system that supports both database-backed (OpenEMR globals) and environment variable configurations:

### Key Components

| File | Purpose |
|------|---------|
| `ConfigAccessorInterface` | Common interface for all config accessors |
| `GlobalsAccessor` | Reads config from OpenEMR database globals |
| `EnvironmentConfigAccessor` | Reads config from environment variables |
| `ConfigFactory` | Selects the appropriate accessor based on environment |
| `GlobalConfig` | Centralized wrapper providing typed access to all module config |

### Usage Pattern

```php
// In Bootstrap or entry points - factory determines config source
$configAccessor = ConfigFactory::createConfigAccessor();
$config = new GlobalConfig($configAccessor);

// Use typed getters
$isEnabled = $config->isEnabled(); // bool
$apiKey = $config->getApiKey(); // string (decrypted in DB mode)
```

### Environment Variable Mode

Set `{VENDOR_PREFIX}_{MODULENAME}_ENV_CONFIG=1` to use environment variables instead of database:

```bash
# Enable env config mode
export {VENDOR_PREFIX}_{MODULENAME}_ENV_CONFIG=1

# Module configuration
export {VENDOR_PREFIX}_{MODULENAME}_ENABLED=true
export {VENDOR_PREFIX}_{MODULENAME}_API_KEY=your-api-key
```

Benefits:
- Container-friendly deployments (no database config needed)
- Secrets can be injected via environment
- Config is immutable (no admin UI editing)

### Adding New Config Options

1. Add constant in `GlobalConfig`:
```php
public const CONFIG_OPTION_API_KEY = '{vendor_prefix}_{modulename}_api_key';
```

2. Add env var mapping in `EnvironmentConfigAccessor`:
```php
private const KEY_MAP = [
GlobalConfig::CONFIG_OPTION_API_KEY => '{VENDOR_PREFIX}_{MODULENAME}_API_KEY',
];
```

3. Add getter in `GlobalConfig`:
```php
public function getApiKey(): string
{
return $this->configAccessor->getString(self::CONFIG_OPTION_API_KEY, '');
}
```

4. Add to `getGlobalSettingSectionConfiguration()` for admin UI.

## Module Access Guard

The `ModuleAccessGuard` prevents access to module endpoints when:
1. Module is not registered in OpenEMR
2. Module is disabled in module management
3. Module's own 'enabled' setting is off

```php
// At top of public entry points
$guardResponse = ModuleAccessGuard::check(Bootstrap::MODULE_NAME);
if ($guardResponse instanceof Response) {
$guardResponse->send();
exit;
}
```

Returns 404 (not 403) to avoid leaking module presence.

## Public Entry Point Pattern

Public PHP files should be short! Just dispatch a controller and send a response. Follow this pattern:
Expand All @@ -124,30 +219,68 @@ Public PHP files should be short! Just dispatch a controller and send a response
/**
* [Description of endpoint]
*
* @package OpenCoreEMR
* @package {VendorName}
* @link http://www.open-emr.org
* @author [Author Name] <[email protected]>
* @copyright Copyright (c) 2025 OpenCoreEMR Inc
* @copyright Copyright (c) 2026 {VendorName}
* @license GNU General Public License 3
*/

$sessionAllowWrite = true;

// Load module autoloader before globals.php
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../../../../globals.php';

use {VendorName}\Modules\{ModuleName}\Bootstrap;
use {VendorName}\Modules\{ModuleName}\ConfigFactory;
use {VendorName}\Modules\{ModuleName}\Exception\{ModuleName}ExceptionInterface;
use {VendorName}\Modules\{ModuleName}\GlobalsAccessor;
use {VendorName}\Modules\{ModuleName}\ModuleAccessGuard;
use Symfony\Component\HttpFoundation\Response;

// Check if module is installed and enabled - return 404 if not
$guardResponse = ModuleAccessGuard::check(Bootstrap::MODULE_NAME);
if ($guardResponse instanceof Response) {
$guardResponse->send();
exit;
}

// Get kernel and bootstrap module
$kernel = $GLOBALS['kernel'];
$bootstrap = new Bootstrap($kernel->getEventDispatcher(), $kernel);
$globalsAccessor = new GlobalsAccessor();
$kernel = $globalsAccessor->get('kernel');
if (!$kernel instanceof \OpenEMR\Core\Kernel) {
throw new \RuntimeException('OpenEMR Kernel not available');
}
$configAccessor = ConfigFactory::createConfigAccessor();
$bootstrap = new Bootstrap($kernel->getEventDispatcher(), $kernel, $configAccessor);

// Get controller
$controller = $bootstrap->get{Feature}Controller();

// Determine action
$action = $_GET['action'] ?? $_POST['action'] ?? 'default';
$actionParam = $_GET['action'] ?? $_POST['action'] ?? 'list';
$action = is_string($actionParam) ? $actionParam : 'list';

// Dispatch to controller and send response
$response = $controller->dispatch($action, $_REQUEST);
$response->send();
try {
$response = $controller->dispatch($action);
$response->send();
} catch ({ModuleName}ExceptionInterface $e) {
error_log("Module error: " . $e->getMessage());
$response = new Response(
"Error: " . htmlspecialchars($e->getMessage()),
$e->getStatusCode()
);
$response->send();
} catch (\Throwable $e) {
error_log("Unexpected error: " . $e->getMessage());
$response = new Response(
"Error: An unexpected error occurred",
Response::HTTP_INTERNAL_SERVER_ERROR
);
$response->send();
}
```

## Controller Pattern
Expand Down Expand Up @@ -366,7 +499,7 @@ return new Response($content);

## Bootstrap Pattern

The `Bootstrap.php` class should provide factory methods for controllers:
The `Bootstrap.php` class should provide factory methods for controllers and accept an optional `ConfigAccessorInterface`:

```php
<?php
Expand All @@ -388,9 +521,11 @@ class Bootstrap
public function __construct(
private readonly EventDispatcherInterface $eventDispatcher,
private readonly Kernel $kernel = new Kernel(),
private readonly GlobalsAccessor $globals = new GlobalsAccessor()
?ConfigAccessorInterface $configAccessor = null
) {
$this->globalsConfig = new GlobalConfig($this->globals);
// Use factory to determine config source if not provided
$configAccessor ??= ConfigFactory::createConfigAccessor();
$this->globalsConfig = new GlobalConfig($configAccessor);

$templatePath = \dirname(__DIR__) . DIRECTORY_SEPARATOR . "templates" . DIRECTORY_SEPARATOR;
$twig = new TwigContainer($templatePath, $this->kernel);
Expand All @@ -411,6 +546,27 @@ class Bootstrap
}
```

### Environment Config Mode in Admin UI

When env config mode is enabled, the global settings section displays an informational message instead of editable fields:

```php
// In addGlobalSettingsSection()
if ($this->globalsConfig->isEnvConfigMode()) {
$setting = new GlobalSetting(
xlt('Configuration Managed Externally'),
GlobalSetting::DATA_TYPE_HTML_DISPLAY_SECTION,
'', '', false
);
$setting->addFieldOption(
GlobalSetting::DATA_TYPE_OPTION_RENDER_CALLBACK,
static fn() => xlt('This module is configured via environment variables.')
);
$service->appendToSection($section, '{vendor_prefix}_{modulename}_env_config_notice', $setting);
return;
}
```

## Twig Template Pattern

Templates should use OpenEMR's translation and sanitization filters:
Expand Down
4 changes: 2 additions & 2 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ class YourFeatureService
* @package OpenCoreEMR
* @link http://www.open-emr.org
* @author Your Name <[email protected]>
* @copyright Copyright (c) 2025 OpenCoreEMR Inc
* @copyright Copyright (c) 2026 OpenCoreEMR Inc
* @license GNU General Public License 3
*/

Expand Down Expand Up @@ -370,7 +370,7 @@ Create `openemr.bootstrap.php` for OpenEMR to discover your module:
* @package OpenCoreEMR
* @link http://www.open-emr.org
* @author Your Name <[email protected]>
* @copyright Copyright (c) 2025 OpenCoreEMR Inc
* @copyright Copyright (c) 2026 OpenCoreEMR Inc
* @license GNU General Public License 3
*/

Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007

Copyright (C) 2025 OpenCoreEMR Inc
Copyright (C) 2026 OpenCoreEMR Inc

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
Expand Down
2 changes: 1 addition & 1 deletion openemr.bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* @package OpenCoreEMR
* @link http://www.open-emr.org
* @author Your Name <[email protected]>
* @copyright Copyright (c) 2025 OpenCoreEMR Inc
* @copyright Copyright (c) 2026 OpenCoreEMR Inc
* @license GNU General Public License 3
*/

Expand Down
2 changes: 0 additions & 2 deletions public/.gitkeep

This file was deleted.

71 changes: 71 additions & 0 deletions public/index.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

/**
* Main interface for the module
*
* This is the primary entry point for the module's web interface.
* Entry points should be minimal - just dispatch to a controller and send the response.
*
* @package OpenCoreEMR
* @link http://www.open-emr.org
* @author Your Name <[email protected]>
* @copyright Copyright (c) 2026 OpenCoreEMR Inc
* @license GNU General Public License 3
*/

$sessionAllowWrite = true;

// Load module autoloader before globals.php so our classes are available
// even when OpenEMR hasn't bootstrapped the module (e.g., module not registered)
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../../../../globals.php';

use {VendorName}\Modules\{ModuleName}\Bootstrap;
use {VendorName}\Modules\{ModuleName}\ConfigFactory;
use {VendorName}\Modules\{ModuleName}\Exception\{ModuleName}ExceptionInterface;
use {VendorName}\Modules\{ModuleName}\GlobalsAccessor;
use {VendorName}\Modules\{ModuleName}\ModuleAccessGuard;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ModuleAccessGuard seems like it should be a very reusable component. cc @Firehed

use Symfony\Component\HttpFoundation\Response;

// Check if module is installed and enabled - return 404 if not
$guardResponse = ModuleAccessGuard::check(Bootstrap::MODULE_NAME);
if ($guardResponse instanceof Response) {
$guardResponse->send();
exit;
}

// Get kernel and bootstrap module
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't all this go in the controller?

$globalsAccessor = new GlobalsAccessor();
$kernel = $globalsAccessor->get('kernel');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As long as we have to get the kernel to do a module, we should have a type-guaranteed accessor for getKernel, not a generic mixed getter.

if (!$kernel instanceof \OpenEMR\Core\Kernel) {
throw new \RuntimeException('OpenEMR Kernel not available');
}
$configAccessor = ConfigFactory::createConfigAccessor();
$bootstrap = new Bootstrap($kernel->getEventDispatcher(), $kernel, $configAccessor);

// Get controller
$controller = $bootstrap->getExampleController();

// Determine action
$actionParam = $_GET['action'] ?? $_POST['action'] ?? 'list';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should directly have to access superglobals.

$action = is_string($actionParam) ? $actionParam : 'list';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure an action should be a primitive. It's bound to map to a callable, isn't it?


// Dispatch to controller and send response
try {
$response = $controller->dispatch($action);
$response->send();
} catch ({ModuleName}ExceptionInterface $e) {
error_log("Module error: " . $e->getMessage());
$response = new Response(
"Error: " . htmlspecialchars($e->getMessage()),
$e->getStatusCode()
);
$response->send();
} catch (\Throwable $e) {
error_log("Unexpected error: " . $e->getMessage());
$response = new Response(
"Error: An unexpected error occurred",
Response::HTTP_INTERNAL_SERVER_ERROR
);
$response->send();
}
Loading