diff --git a/.env.example b/.env.example index 7cc03f9..737c408 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,13 @@ DB_DATABASE=ladill_link DB_USERNAME=ladill_link DB_PASSWORD= +# Read platform admin settings (AI + Paystack keys from ladilldb.platform_settings). +PLATFORM_DB_HOST=127.0.0.1 +PLATFORM_DB_PORT=3306 +PLATFORM_DB_DATABASE=ladilldb +PLATFORM_DB_USERNAME=ladill_link +PLATFORM_DB_PASSWORD= + SESSION_DRIVER=database SESSION_LIFETIME=120 SESSION_DOMAIN=.ladill.com diff --git a/DEPLOY.md b/DEPLOY.md index ebdada9..977c7ca 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -34,6 +34,14 @@ php artisan passport:client \ Set `BILLING_API_KEY_LINK` and `IDENTITY_API_KEY_LINK` on both monolith and this app. +Grant read access to platform AI settings: + +```bash +sudo mysql -e "GRANT SELECT ON ladilldb.platform_settings TO 'ladill_link'@'127.0.0.1'; FLUSH PRIVILEGES;" +``` + +Afia reads `ai_*` keys from `platform_settings` (same as ladill.com admin). Optional `AFIA_API_KEY` in `.env` overrides for local dev. + ## Legacy URLs `https://ladill.com/q/{code}` continues to work for existing QR and storefront codes. New links use `https://ladl.link/{code}`. Unknown slugs on ladl.link fall back to the legacy resolver. diff --git a/app/Models/PlatformSetting.php b/app/Models/PlatformSetting.php new file mode 100644 index 0000000..b3b603b --- /dev/null +++ b/app/Models/PlatformSetting.php @@ -0,0 +1,27 @@ + 'array', + 'is_secret' => 'boolean', + 'is_active' => 'boolean', + ]; +} diff --git a/app/Services/Afia/AfiaService.php b/app/Services/Afia/AfiaService.php index 62ae01a..37f68c0 100644 --- a/app/Services/Afia/AfiaService.php +++ b/app/Services/Afia/AfiaService.php @@ -10,9 +10,11 @@ use RuntimeException; */ class AfiaService { + public function __construct(private PlatformAiConfig $ai) {} + public function enabled(): bool { - return (bool) config('afia.enabled', true) && (string) config('afia.api_key', '') !== ''; + return $this->ai->enabled(); } /** @@ -25,9 +27,9 @@ class AfiaService throw new RuntimeException('Afia is not configured.'); } - $provider = (string) config('afia.provider', 'openai'); - $model = (string) config('afia.model', 'gpt-4o-mini'); - $apiKey = (string) config('afia.api_key'); + $provider = $this->ai->provider(); + $model = $this->ai->model(); + $apiKey = $this->ai->apiKey(); $messages = [['role' => 'system', 'content' => $this->systemPrompt($context)]]; foreach (array_slice($history, -8) as $turn) { diff --git a/app/Services/Afia/PlatformAiConfig.php b/app/Services/Afia/PlatformAiConfig.php new file mode 100644 index 0000000..5439d6e --- /dev/null +++ b/app/Services/Afia/PlatformAiConfig.php @@ -0,0 +1,90 @@ + */ + private array $cache = []; + + public function enabled(): bool + { + $platformFlag = $this->setting('ai_enabled'); + if ($platformFlag !== null && ! filter_var($platformFlag, FILTER_VALIDATE_BOOLEAN)) { + return false; + } + + if (! (bool) config('afia.enabled', true)) { + return false; + } + + return $this->apiKey() !== ''; + } + + public function provider(): string + { + $provider = strtolower(trim((string) ($this->setting('ai_provider') ?? config('afia.provider', 'openai')))); + + return in_array($provider, ['openai', 'anthropic'], true) ? $provider : 'openai'; + } + + public function model(): string + { + $configured = trim((string) ($this->setting('ai_model') ?? config('afia.model', ''))); + if ($configured !== '') { + return $configured; + } + + return $this->provider() === 'anthropic' ? 'claude-3-5-haiku-latest' : 'gpt-4o-mini'; + } + + public function apiKey(): string + { + $provider = $this->provider(); + + $providerKey = match ($provider) { + 'anthropic' => trim((string) ($this->setting('ai_anthropic_api_key') ?? '')), + default => trim((string) ($this->setting('ai_openai_api_key') ?? '')), + }; + + if ($providerKey !== '') { + return $providerKey; + } + + $shared = trim((string) ($this->setting('ai_api_key') ?? '')); + if ($shared !== '') { + return $shared; + } + + return trim((string) config('afia.api_key', '')); + } + + private function setting(string $key): mixed + { + if (array_key_exists($key, $this->cache)) { + return $this->cache[$key]; + } + + try { + if (! Schema::connection('platform')->hasTable('platform_settings')) { + return $this->cache[$key] = null; + } + + $setting = PlatformSetting::query() + ->where('key', $key) + ->where('is_active', true) + ->first(); + + return $this->cache[$key] = $setting ? ($setting->value['value'] ?? null) : null; + } catch (\Throwable) { + return $this->cache[$key] = null; + } + } +} diff --git a/app/Services/Billing/PaystackService.php b/app/Services/Billing/PaystackService.php index 5fb1fdf..558e4ef 100644 --- a/app/Services/Billing/PaystackService.php +++ b/app/Services/Billing/PaystackService.php @@ -71,7 +71,7 @@ class PaystackService protected function settingValue(string $key, mixed $fallback = null): mixed { try { - if (!Schema::hasTable('platform_settings')) { + if (! Schema::connection('platform')->hasTable('platform_settings')) { return $fallback; } diff --git a/config/database.php b/config/database.php index 64709ce..d001099 100644 --- a/config/database.php +++ b/config/database.php @@ -64,6 +64,26 @@ return [ ]) : [], ], + // Read-only access to platform admin settings (AI keys, Paystack, etc.). + 'platform' => [ + 'driver' => 'mysql', + 'host' => env('PLATFORM_DB_HOST', env('DB_HOST', '127.0.0.1')), + 'port' => env('PLATFORM_DB_PORT', env('DB_PORT', '3306')), + 'database' => env('PLATFORM_DB_DATABASE', 'ladilldb'), + 'username' => env('PLATFORM_DB_USERNAME', env('DB_USERNAME', 'root')), + 'password' => env('PLATFORM_DB_PASSWORD', env('DB_PASSWORD', '')), + 'unix_socket' => env('PLATFORM_DB_SOCKET', env('DB_SOCKET', '')), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + 'mariadb' => [ 'driver' => 'mariadb', 'url' => env('DB_URL'),