Files
InvoiceShelf/app/Services/AiConfigurationService.php
Darko Gjorgjijoski c7fab5d52f feat(ai): Phase 1 — provider configuration, installer step, admin + company settings
Foundation for the AI chatbot + text generation feature. Phase 1 is infrastructure only: driver plumbing, configuration storage with encrypted API keys, global vs per-company resolution, admin + company UI pages, and an optional installer wizard step. The chat assistant and text-generation WYSIWYG integration come in later phases.

**Driver plumbing (app/Support/Ai/)** — AiDriver abstract, AiDriverFactory, AiException, AiChatResponse DTO, OpenRouterDriver concrete implementation. OpenRouter is the OpenAI-compatible aggregator that unlocks hundreds of models behind one API key and one request shape — ideal as the default v1 driver. Drivers are extensible the same way exchange rate drivers are: the module Registry's generic registerDriver('ai', ...) machinery plus a typed Registry::registerAiDriver() convenience wrapper (shipped in the upstream invoiceshelf/modules package in a paired commit).

**AiConfigurationService** — mirrors MailConfigurationService shape but with one deliberate deviation: API keys are encrypted at the service layer via Crypt::encryptString before persistence. OpenRouter bearer tokens have much bigger blast radius than SMTP passwords. Same settings / company_settings tables, same global-vs-per-company pattern, same use_custom_ai_config override toggle. Resolution order: global ai_enabled must be YES, then the company either overrides via use_custom_ai_config=YES (and can opt out with ai_enabled=NO inside the override) or inherits the global config.

**Controllers** — Admin/Settings/AiConfigurationController (global CRUD + driver list + test connection), Company/Settings/CompanyAiConfigurationController (per-company override + test), Setup/AiConfigurationController (installer wizard step, skippable with explicit ai_enabled=NO). API key is always masked as '********' in GET responses — the frontend submits the placeholder back on save and the backend preserves the stored value.

**Installer wizard** — new optional step 7 'AI' between Mail and Account. Default OFF with a Skip button. MailView.vue now routes to installation.ai instead of installation.account; installation.ai then routes to installation.account. Step order comment updated in routes.ts.

**Admin + Company settings pages** — AdminAiConfigView (no toggle, always global) and AiConfigView (with use_custom_ai_config BaseSwitchSection that auto-saves OFF). Both share AiConfigurationForm which renders the driver selector, API key input with show/hide, driver-specific config_fields (base_url for OpenRouter), and per-role enable toggles with free-text model inputs backed by a datalist of suggested models from driver metadata.

**Bootstrap endpoint** — adds an ai block to the response: { enabled, chat_enabled, text_generation_enabled }. All three are booleans resolved through AiConfigurationService::resolveForCompany(). Never leaks the API key. Frontend feature flags read from bootstrapData.ai to decide whether to show Phase 2/3 UI.

**Bouncer ability** 'manage ai config' added to SettingsPolicy, gated on isSuperAdmin() (same pattern as manage email config, manage pdf config).

**Tests** (22 new) — Unit: AiDriverFactory resolves built-in + Registry-contributed drivers, rejects unknown, merges availableDrivers. AiConfigurationService: encryption round-trip, resolution order (3 cases: global off, inherit global, override with company key, override with opt-out), makeDriver null/instance cases, listDrivers metadata. Feature: admin save + read with api key masking, preserve-on-placeholder behavior, company toggle ON/OFF semantics, bootstrap ai flags reflect resolution, company opt-out path.

372 tests pass (was 350, +22). Pint clean. npm run build clean. Phase 2 (chat assistant + tool calling) and Phase 3 (WYSIWYG text generation popup) are separate follow-up commits — this one is the foundation only.
2026-04-11 22:00:00 +02:00

354 lines
12 KiB
PHP

<?php
namespace App\Services;
use App\Models\CompanySetting;
use App\Models\Setting;
use App\Support\Ai\AiDriver;
use App\Support\Ai\AiDriverFactory;
use Illuminate\Support\Facades\Crypt;
use InvoiceShelf\Modules\Registry;
/**
* Reads, writes, and resolves AI configuration at global and per-company scopes.
*
* Mirrors MailConfigurationService in shape: a global config lives in the `settings`
* table under bare keys; a per-company override lives in `company_settings` under
* `company_`-prefixed keys plus a `use_custom_ai_config` toggle. The resolution
* order supports three layered kill-switches:
*
* 1. Global off → no AI for anyone
* 2. Per-company off → no AI for this company (even if global is on)
* 3. Role off (chat / text_generation) → role-level disable at either scope
*
* Deviation from mail: AI API keys are **encrypted** at the service layer via
* Crypt::encryptString before persistence. OpenRouter bearer tokens have much
* bigger blast radius than SMTP passwords; this is worth the pattern break.
*/
class AiConfigurationService
{
public const ROLE_CHAT = 'chat';
public const ROLE_TEXT_GENERATION = 'text_generation';
public const ROLES = [self::ROLE_CHAT, self::ROLE_TEXT_GENERATION];
private const GLOBAL_SCOPE = 'global';
private const COMPANY_SCOPE = 'company';
/**
* Fields stored in the settings table (global scope, bare keys).
*
* Company-scope keys are these prefixed with `company_`.
*/
private const FIELDS = [
'ai_enabled',
'ai_driver',
'ai_api_key',
'ai_base_url',
'ai_chat_enabled',
'ai_chat_model',
'ai_text_generation_enabled',
'ai_text_generation_model',
];
/**
* Fields whose stored values are encrypted at rest.
*/
private const ENCRYPTED_FIELDS = [
'ai_api_key',
];
/**
* Read the global AI config with decrypted secrets.
*
* @return array<string, mixed>
*/
public function getGlobalConfig(): array
{
$raw = Setting::getSettings(self::FIELDS)->all();
return $this->hydrateDefaults($this->decryptFields($raw));
}
/**
* Read the per-company AI config with decrypted secrets.
*
* The response always includes the `use_custom_ai_config` toggle so the
* frontend can render the override switch. Driver fields are present
* regardless of the toggle — the client decides whether to show them.
*
* @return array<string, mixed>
*/
public function getCompanyConfig(int|string $companyId): array
{
$companyKeys = array_merge(
['use_custom_ai_config'],
$this->getCompanySettingKeys(),
);
$raw = CompanySetting::getSettings($companyKeys, $companyId)->all();
return array_merge(
['use_custom_ai_config' => $raw['use_custom_ai_config'] ?? 'NO'],
$this->hydrateDefaults($this->decryptFields($this->stripCompanyPrefix($raw))),
);
}
/**
* Persist the global AI config, encrypting sensitive fields.
*
* @param array<string, mixed> $payload
*/
public function saveGlobalConfig(array $payload): void
{
Setting::setSettings($this->prepareSettingsForStorage($payload, self::GLOBAL_SCOPE));
}
/**
* Persist the per-company AI config.
*
* When `use_custom_ai_config` is NOT 'YES', only the toggle is written —
* driver fields in the payload are discarded. This mirrors the mail pattern
* exactly and prevents stale per-company config from lingering after toggle-off.
*
* @param array<string, mixed> $payload
*/
public function saveCompanyConfig(int|string $companyId, array $payload): void
{
if (($payload['use_custom_ai_config'] ?? 'YES') !== 'YES') {
CompanySetting::setSettings([
'use_custom_ai_config' => 'NO',
], $companyId);
return;
}
$toStore = $this->prepareSettingsForStorage($payload, self::COMPANY_SCOPE);
$toStore['use_custom_ai_config'] = 'YES';
CompanySetting::setSettings($toStore, $companyId);
}
/**
* Resolve the effective AI config for a company.
*
* Returns the decrypted config array, or `null` when AI is unavailable.
* Resolution order:
*
* 1. Global `ai_enabled` must be YES. Otherwise AI is off for everyone.
* 2. If the company has `use_custom_ai_config = YES`, return the company
* config. The company's own `ai_enabled` inside that override controls
* whether AI is on for this company (the company can opt out by
* setting `use_custom_ai_config = YES` and `ai_enabled = NO`).
* 3. Otherwise, return the global config.
*
* @return array<string, mixed>|null
*/
public function resolveForCompany(int|string $companyId): ?array
{
$global = $this->getGlobalConfig();
// Global kill-switch — applies to all companies
if (($global['ai_enabled'] ?? 'NO') !== 'YES') {
return null;
}
$company = $this->getCompanyConfig($companyId);
if (($company['use_custom_ai_config'] ?? 'NO') === 'YES') {
// Company-specific config — can opt out via ai_enabled=NO
if (($company['ai_enabled'] ?? 'NO') !== 'YES') {
return null;
}
return $this->addBooleanFlags($company);
}
return $this->addBooleanFlags($global);
}
/**
* Convenience: resolve config and instantiate a driver for the company.
*
* Returns `null` when AI is disabled for the company.
*/
public function makeDriver(int|string $companyId): ?AiDriver
{
$config = $this->resolveForCompany($companyId);
if ($config === null || empty($config['ai_api_key']) || empty($config['ai_driver'])) {
return null;
}
return AiDriverFactory::make(
$config['ai_driver'],
$config['ai_api_key'],
['base_url' => $config['ai_base_url'] ?? null],
);
}
/**
* Build dynamic validation rules for a save request.
*
* @return array<string, mixed>
*/
public function validationRules(bool $allowDisabledCustomConfig = false): array
{
$availableDrivers = AiDriverFactory::availableDrivers();
return [
'use_custom_ai_config' => $allowDisabledCustomConfig ? ['nullable', 'in:YES,NO'] : ['prohibited'],
'ai_enabled' => ['nullable', 'in:YES,NO'],
'ai_driver' => ['required_if:ai_enabled,YES', 'nullable', 'string', 'in:'.implode(',', $availableDrivers)],
'ai_api_key' => ['required_if:ai_enabled,YES', 'nullable', 'string'],
'ai_base_url' => ['nullable', 'string', 'url'],
'ai_chat_enabled' => ['nullable', 'in:YES,NO'],
'ai_chat_model' => ['nullable', 'string', 'max:200'],
'ai_text_generation_enabled' => ['nullable', 'in:YES,NO'],
'ai_text_generation_model' => ['nullable', 'string', 'max:200'],
];
}
/**
* Get driver metadata for the AI type — what the frontend needs to render forms.
*
* Shape matches the exchange rate driver list so the UI can reuse the same
* data-driven rendering pattern.
*
* @return array<int, array<string, mixed>>
*/
public function listDrivers(): array
{
return collect(Registry::allDrivers('ai'))
->map(fn (array $meta, string $name) => [
'value' => $name,
'label' => $meta['label'] ?? $name,
'website' => $meta['website'] ?? '',
'default_base_url' => $meta['default_base_url'] ?? '',
'supported_roles' => $meta['supported_roles'] ?? [],
'suggested_models' => $meta['suggested_models'] ?? [],
'config_fields' => $meta['config_fields'] ?? [],
])
->values()
->all();
}
/**
* Company-scope keys: the FIELDS list with `company_` prefix.
*
* @return array<int, string>
*/
protected function getCompanySettingKeys(): array
{
return array_map(fn (string $field) => 'company_'.$field, self::FIELDS);
}
/**
* Strip the `company_` prefix from keys when reading company-scoped settings.
*
* @param array<string, mixed> $raw
* @return array<string, mixed>
*/
protected function stripCompanyPrefix(array $raw): array
{
$normalized = [];
foreach ($raw as $key => $value) {
if ($key === 'use_custom_ai_config') {
continue;
}
if (str_starts_with($key, 'company_')) {
$normalized[substr($key, strlen('company_'))] = $value;
}
}
return $normalized;
}
/**
* Decrypt sensitive fields on read.
*
* @param array<string, mixed> $settings
* @return array<string, mixed>
*/
protected function decryptFields(array $settings): array
{
foreach (self::ENCRYPTED_FIELDS as $field) {
if (isset($settings[$field]) && $settings[$field] !== '') {
try {
$settings[$field] = Crypt::decryptString($settings[$field]);
} catch (\Throwable) {
// Backward compat: if the value was stored before encryption
// was introduced, leave it as-is rather than wiping it.
}
}
}
return $settings;
}
/**
* Fill in defaults for fields missing from storage so consumers get a complete array.
*
* @param array<string, mixed> $settings
* @return array<string, mixed>
*/
protected function hydrateDefaults(array $settings): array
{
return array_merge([
'ai_enabled' => 'NO',
'ai_driver' => 'openrouter',
'ai_api_key' => '',
'ai_base_url' => '',
'ai_chat_enabled' => 'NO',
'ai_chat_model' => 'openai/gpt-4o',
'ai_text_generation_enabled' => 'NO',
'ai_text_generation_model' => 'openai/gpt-4o-mini',
], $settings);
}
/**
* Add derived boolean flags to a config array so consumers don't have to
* compare against the 'YES'/'NO' strings everywhere.
*
* @param array<string, mixed> $config
* @return array<string, mixed>
*/
protected function addBooleanFlags(array $config): array
{
$config['chat_enabled'] = ($config['ai_chat_enabled'] ?? 'NO') === 'YES';
$config['text_generation_enabled'] = ($config['ai_text_generation_enabled'] ?? 'NO') === 'YES';
return $config;
}
/**
* Prepare a payload for storage: strip fields the caller doesn't own, encrypt
* sensitive fields, prefix with `company_` for the company scope.
*
* @param array<string, mixed> $payload
* @return array<string, string|null>
*/
protected function prepareSettingsForStorage(array $payload, string $scope): array
{
$prepared = [];
foreach (self::FIELDS as $field) {
if (! array_key_exists($field, $payload)) {
continue;
}
$value = $payload[$field];
if (in_array($field, self::ENCRYPTED_FIELDS, true) && is_string($value) && $value !== '') {
$value = Crypt::encryptString($value);
}
$storageKey = $scope === self::COMPANY_SCOPE ? 'company_'.$field : $field;
$prepared[$storageKey] = $value;
}
return $prepared;
}
}