diff --git a/composer.json b/composer.json index ecf5e6b63..c4d856d54 100755 --- a/composer.json +++ b/composer.json @@ -87,7 +87,10 @@ "Unusualify\\Modularous\\Providers\\ModularousProvider" ], "aliases": { - "ModularousVite": "Unusualify\\Modularous\\Facades\\ModularousVite" + "ModularousVite": "Unusualify\\Modularous\\Facades\\ModularousVite", + "SystemSettings": "Unusualify\\Modularous\\Facades\\SystemSettings", + "CmsSettings": "Unusualify\\Modularous\\Facades\\CmsSettings", + "SiteSettings": "Unusualify\\Modularous\\Facades\\SiteSettings" } }, "branch-alias": { @@ -146,11 +149,11 @@ ], "test:fast:coverage": [ "@test-clean", - "bin/with-coverage-driver paratest --processes=2 --stop-on-defect" + "bin/with-coverage-driver paratest --processes=4 --stop-on-defect" ], "test:fast:coverage:html": [ "@test-clean", - "COVERAGE_MEMORY_LIMIT=-1 bin/with-coverage-driver paratest -c phpunit.coverage-html.xml --processes=2 --stop-on-defect" + "bin/with-coverage-driver paratest -c phpunit.coverage-html.xml --processes=4 --stop-on-defect" ], "test:brokers": "vendor/bin/phpunit --stop-on-defect --no-coverage tests/Brokers", "test:events": "vendor/bin/phpunit --stop-on-defect --no-coverage tests/Events", @@ -194,6 +197,7 @@ "minimum-stability": "dev", "prefer-stable": true, "config": { + "process-timeout": 300, "sort-packages": true, "preferred-install": "dist", "optimize-autoloader": true, diff --git a/config/merges/cms_seo.php b/config/merges/cms_seo.php index 231f1968a..bf8d639d6 100644 --- a/config/merges/cms_seo.php +++ b/config/merges/cms_seo.php @@ -7,6 +7,7 @@ use Modules\Cms\Services\CmsAdminWarnings; use Modules\Cms\Services\CmsSiteSeoSettingsService; use Modules\Cms\Support\CmsPublicSeo; +use Modules\SystemSetting\Support\MigrateRobotsTxtFromSiteSetting; use Unusualify\Modularous\Entities\Traits\Core\HasScopes; return [ @@ -32,19 +33,23 @@ * Global robots.txt (served at GET /robots.txt when route enabled). * * @see RobotsTxtController + * @see CmsSiteSeoSettingsService */ 'robots' => [ 'route_enabled' => env('MODULAROUS_CMS_ROBOTS_TXT_ROUTE_ENABLED', true), 'global_robots_txt' => env('MODULAROUS_CMS_SEO_GLOBAL_ROBOTS_TXT', ''), /** - * When true, GET /robots.txt prefers {@see CmsSiteSeoSettingsService} (um_cms_site_settings). + * When true, GET /robots.txt prefers {@see CmsSiteSeoSettingsService} + * ({@see \Unusualify\Modularous\Facades\SiteSettings} → CmsSettings with SystemSettings fallback). * When false, only env/config {@code global_robots_txt} is used (legacy / headless deploys). */ - 'use_site_settings' => env('MODULAROUS_CMS_SEO_ROBOTS_USE_SITE_SETTINGS', true), + 'use_system_settings' => env('MODULAROUS_CMS_SEO_ROBOTS_USE_SYSTEM_SETTINGS', env('MODULAROUS_CMS_SEO_ROBOTS_USE_SITE_SETTINGS', true)), /** - * Composite key for the global robots.txt body row (must match unique index on site_settings). + * Legacy KV row keys used only by {@see MigrateRobotsTxtFromSiteSetting}. + * + * @deprecated Robots.txt is stored on SystemSetting General / Cms SiteSetting (IsSingular). */ - 'site_setting' => [ + 'legacy_site_setting' => [ 'group_key' => env('MODULAROUS_CMS_SEO_ROBOTS_SITE_GROUP', 'seo'), 'key' => env('MODULAROUS_CMS_SEO_ROBOTS_SITE_KEY', 'global_robots_txt'), 'locale' => env('MODULAROUS_CMS_SEO_ROBOTS_SITE_LOCALE', '*'), diff --git a/config/merges/cms_settings.php b/config/merges/cms_settings.php new file mode 100644 index 000000000..d019a3b96 --- /dev/null +++ b/config/merges/cms_settings.php @@ -0,0 +1,8 @@ + (int) env('MODULAROUS_CMS_SETTINGS_CACHE_TTL', env('MODULAROUS_SYSTEM_SETTINGS_CACHE_TTL', 3600)), + 'sensitive_keys' => [ + 'smtp.password', + ], +]; diff --git a/config/merges/input_types.php b/config/merges/input_types.php index 14c80e655..23575bb6d 100644 --- a/config/merges/input_types.php +++ b/config/merges/input_types.php @@ -3,6 +3,24 @@ use Camroncade\Timezone\Timezone; return [ + '@collapsible_wrap' => [ + 'type' => 'wrap', + 'typeInt' => 'ue-collapsible', + 'typeIntModelValue' => false, + 'typeIntTitle' => 'Wrap', + 'noLabel' => true, + 'bordered' => true, + 'col' => ['cols' => 12,'sm' => 12,'md' => 12,'lg' => 12,'xl' => 12], + ], + '@collapsible_group' => [ + 'type' => 'group', + 'typeInt' => 'ue-collapsible', + 'typeIntModelValue' => false, + 'typeIntTitle' => 'Group', + 'noLabel' => true, + 'bordered' => true, + 'col' => ['cols' => 12,'sm' => 12,'md' => 12,'lg' => 12,'xl' => 12], + ], '_language' => [ 'type' => 'select', 'name' => 'language', diff --git a/config/merges/system_settings.php b/config/merges/system_settings.php new file mode 100644 index 000000000..7ad9b005c --- /dev/null +++ b/config/merges/system_settings.php @@ -0,0 +1,45 @@ + 'SystemSetting', + 'system_prefix' => true, + 'group' => 'system', + 'headline' => 'System Settings', + 'cache_ttl' => (int) env('MODULAROUS_SYSTEM_SETTINGS_CACHE_TTL', 3600), + /** + * Fallback when SystemSettings `analytics.enabled` is unset in the DB. + * Staging should leave DB off (or unset) so this stays false. + * Prod: set analytics.enabled=true in System Settings (or MODULAROUS_ANALYTICS_ENABLED=true as seed default). + */ + 'analytics_enabled_default' => (bool) env('MODULAROUS_ANALYTICS_ENABLED', false), + /** + * When true, smtp.* from System Settings overrides config/mail.php. + * Prefer .env MAIL_* unless you explicitly need CMS-managed SMTP. + */ + 'mail_override_enabled' => (bool) env('MODULAROUS_SYSTEM_SETTINGS_MAIL_OVERRIDE', false), + 'sensitive_keys' => [ + 'smtp.password', + ], + 'extra_inputs' => [], + 'input_overrides' => [], + 'hidden_inputs' => [], + 'input_aliases' => [ + '@system_social_links' => [ + 'type' => 'json-repeater', + 'name' => 'social', + 'label' => 'Social Links', + 'default' => [], + 'draggable' => true, + 'orderKey' => 'position', + 'noHeaders' => true, + 'collapsible' => true, + 'collapsibleTitleField' => 'platform', + 'collapsibleDefaultOpen' => false, + 'schema' => [ + ['name' => 'platform', 'type' => 'text', 'label' => 'Platform', 'rules' => 'required'], + ['name' => 'url', 'type' => 'text', 'label' => 'URL', 'rules' => 'required'], + ['name' => 'icon', 'type' => 'image', 'label' => 'Icon', 'rules' => 'nullable'], + ], + ] + ], +]; diff --git a/docs/src/pages/system-reference/backend/facades/cms-settings.md b/docs/src/pages/system-reference/backend/facades/cms-settings.md new file mode 100644 index 000000000..f659483ee --- /dev/null +++ b/docs/src/pages/system-reference/backend/facades/cms-settings.md @@ -0,0 +1,52 @@ +--- +sidebarPos: 2 +sidebarTitle: CmsSettings +--- + +# CmsSettings + +**Facade**: `Unusualify\Modularous\Facades\CmsSettings` +**Accessor**: `cms.settings` +**Underlying**: `Modules\Cms\Services\CmsSettingsService` +**Model**: `Modules\Cms\Entities\SiteSetting` (`IsSingular`) + +Frontend/CMS-facing settings singleton. No System fallback — use [`SiteSettings`](./site-settings) when empty CMS keys should fall through to System. Full guide: [Settings Services](/system-reference/backend/services/settings/overview). + +## Sections + +`site`, `social`, `contact`, `seo` only. + +`smtp`, `analytics`, and `locale` are **not** editable on `SiteSetting` — use [`SystemSettings`](./system-settings). + +## Methods + +Shared singular API — see [Shared API](/system-reference/backend/services/settings/overview#shared-api). + +| Method | Signature | Description | +|--------|-----------|-------------| +| `get` / `value` | `(string $key, mixed $default = null, ?string $locale = null): mixed` | Dot-path read with locale / media-leaf expansion | +| `first` | `(string $key, mixed $default = null, ?string $locale = null): mixed` | First list element when applicable | +| `all` | `(?string $locale = null): array` | Locale-resolved section tree | +| `has` | `(string $key): bool` | Key exists in snapshot | +| `filled` | `(string $key, ?string $locale = null): bool` | Resolved value is non-empty | +| `set` | `(string $key, mixed $value): void` | Persist + forget cache | +| `forgetCache` | `(): void` | Clear cached snapshot | +| `snapshot` | `(): array` | Raw cached sections | + +## Usage + +```php +use Unusualify\Modularous\Facades\CmsSettings; + +CmsSettings::set('site.name', 'Public site name'); +$tagline = CmsSettings::get('site.tagline'); + +if (! CmsSettings::filled('seo.default_meta_title')) { + // CMS empty — SiteSettings::forFrontend() would fall back to System +} +``` + +## Notes + +- Seed empty CMS from System with [`DuplicateSystemSettingsToCmsSettings`](/system-reference/backend/services/settings/overview#duplicatesystemsettingstocmssettings). +- Cache key: `cms_settings.snapshot` (`modularous.cms_settings.cache_ttl`). diff --git a/docs/src/pages/system-reference/backend/facades/overview.md b/docs/src/pages/system-reference/backend/facades/overview.md index 1a096d1c6..26c748544 100644 --- a/docs/src/pages/system-reference/backend/facades/overview.md +++ b/docs/src/pages/system-reference/backend/facades/overview.md @@ -5,7 +5,7 @@ sidebarTitle: Overview # Facades -Modularous registers 18 Laravel facades, all under the `Unusualify\Modularous\Facades\` namespace. Each facade is an alias to a bound service container entry, providing static-style access to the underlying service class. +Modularous registers 21 Laravel facades, all under the `Unusualify\Modularous\Facades\` namespace. Each facade is an alias to a bound service container entry, providing static-style access to the underlying service class. ## Overview @@ -28,8 +28,13 @@ Modularous registers 18 Laravel facades, all under the `Unusualify\Modularous\Fa | [Register](./register) | `auth.register` | `RegisterBrokerManager` | | [HostRouting](./host-routing) | `unusualify.hosting` | `Support\HostRouting` | | [HostRoutingRegistrar](./host-routing-registrar) | `unusualify.hostRouting` | `Support\HostRouting` | +| [SystemSettings](./system-settings) | `system.settings` | `Modules\SystemSetting\Services\SystemSettingsService` | +| [CmsSettings](./cms-settings) | `cms.settings` | `Modules\Cms\Services\CmsSettingsService` | +| [SiteSettings](./site-settings) | `site.settings` | `Modules\Cms\Services\SiteSettingsService` | | [UFinder](./u-finder) *(deprecated)* | `Finder::class` | `Support\Finder` | +Settings facades share one API and differ by backing store / context routing — see [Settings Services](/system-reference/backend/services/settings/overview). + ## Usage Pattern All Modularous facades follow standard Laravel facade usage: diff --git a/docs/src/pages/system-reference/backend/facades/site-settings.md b/docs/src/pages/system-reference/backend/facades/site-settings.md new file mode 100644 index 000000000..14fd5f9ae --- /dev/null +++ b/docs/src/pages/system-reference/backend/facades/site-settings.md @@ -0,0 +1,68 @@ +--- +sidebarPos: 19 +sidebarTitle: SiteSettings +--- + +# SiteSettings + +**Facade**: `Unusualify\Modularous\Facades\SiteSettings` +**Accessor**: `site.settings` +**Underlying**: `Modules\Cms\Services\SiteSettingsService` + +Context-aware router over CMS and System settings. Prefer this facade in public layouts and when code may run in both frontend and panel contexts. Full guide: [Settings Services](/system-reference/backend/services/settings/overview). + +| Context | Behaviour | +|---------|-----------| +| Frontend (non-panel HTTP) | [`CmsSettings`](./cms-settings) with fallback to [`SystemSettings`](./system-settings) | +| Backend / panel / console | [`SystemSettings`](./system-settings) only | + +## Methods + +### Context + +| Method | Signature | Description | +|--------|-----------|-------------| +| `forFrontend` | `(): SiteSettingsService` | Force CMS layer + System fallback | +| `forBackend` | `(): SiteSettingsService` | Force System only | +| `forCms` | `(): SiteSettingsService` | Alias of `forFrontend()` | +| `forSystem` | `(): SiteSettingsService` | Alias of `forBackend()` | +| `usesCmsLayer` | `(): bool` | Whether the CMS layer is active | +| `cms` | `(): CmsSettingsService` | Underlying CMS service | +| `system` | `(): SystemSettingsService` | Underlying System service | + +### Shared read/write + +Same signatures as the singular services — see [Shared API](/system-reference/backend/services/settings/overview#shared-api): `get`, `value`, `first`, `all`, `has`, `filled`, `set`, `forgetCache`, `snapshot`. + +On the CMS layer, `set` writes to CMS only; `forgetCache` clears both layers. + +## Usage + +```blade +{{ SiteSettings::get('site.name') }} +``` + +```php +use Unusualify\Modularous\Facades\SiteSettings; + +// Auto: frontend → CMS+fallback, panel/console → System +$name = SiteSettings::get('site.name', config('app.name')); + +// Explicit +$public = SiteSettings::forFrontend()->get('seo.robots_txt'); +SiteSettings::forBackend()->set('seo.robots_txt', $body); + +// System-only keys still resolve on frontend via fallback +$gtm = SiteSettings::forFrontend()->get('analytics.gtm_id'); +``` + +```php +public function __construct( + protected \Modules\Cms\Services\SiteSettingsService $siteSettings, +) {} +``` + +## Notes + +- Analytics snippets: [`AnalyticsScripts`](/system-reference/backend/services/settings/overview#analyticsscripts) (reads through this router). +- Empty CMS leaves do not overwrite System values in `all()` / `snapshot()`. diff --git a/docs/src/pages/system-reference/backend/facades/system-settings.md b/docs/src/pages/system-reference/backend/facades/system-settings.md new file mode 100644 index 000000000..bd77bd29e --- /dev/null +++ b/docs/src/pages/system-reference/backend/facades/system-settings.md @@ -0,0 +1,53 @@ +--- +sidebarPos: 20 +sidebarTitle: SystemSettings +--- + +# SystemSettings + +**Facade**: `Unusualify\Modularous\Facades\SystemSettings` +**Accessor**: `system.settings` +**Underlying**: `Modules\SystemSetting\Services\SystemSettingsService` +**Model**: `Modules\SystemSetting\Entities\General` (`IsSingular`) + +Backend / system source of truth for global settings. Full usage guide: [Settings Services](/system-reference/backend/services/settings/overview). + +## Sections + +`site`, `social`, `contact`, `seo`, `smtp`, `analytics`, `locale` + +## Methods + +Shared singular API — see [Shared API](/system-reference/backend/services/settings/overview#shared-api). + +| Method | Signature | Description | +|--------|-----------|-------------| +| `get` / `value` | `(string $key, mixed $default = null, ?string $locale = null): mixed` | Dot-path read with locale / media-leaf expansion | +| `first` | `(string $key, mixed $default = null, ?string $locale = null): mixed` | First list element when applicable | +| `all` | `(?string $locale = null): array` | Locale-resolved section tree | +| `has` | `(string $key): bool` | Key exists in snapshot | +| `filled` | `(string $key, ?string $locale = null): bool` | Resolved value is non-empty | +| `set` | `(string $key, mixed $value): void` | Persist + forget cache | +| `forgetCache` | `(): void` | Clear cached snapshot | +| `snapshot` | `(): array` | Raw cached sections | + +## Usage + +```php +use Unusualify\Modularous\Facades\SystemSettings; + +$host = SystemSettings::get('smtp.host'); +SystemSettings::set('locale.timezone', 'Europe/Amsterdam'); + +$favicon = SystemSettings::value('site.favicon.original'); +``` + +```blade + +``` + +## Notes + +- Prefer `SystemSettings` (or `SiteSettings::forBackend()`) for SMTP, analytics, and locale — those sections are not on `SiteSetting`. +- Optional mail override: [ApplySmtpMailConfig](/system-reference/backend/services/settings/overview#applysmtpmailconfig) via `MODULAROUS_SYSTEM_SETTINGS_MAIL_OVERRIDE`. +- Cache key: `system_settings.snapshot` (`modularous.system_settings.cache_ttl`). diff --git a/docs/src/pages/system-reference/backend/facades/u-finder.md b/docs/src/pages/system-reference/backend/facades/u-finder.md index 128e4d24b..bf3e62ceb 100644 --- a/docs/src/pages/system-reference/backend/facades/u-finder.md +++ b/docs/src/pages/system-reference/backend/facades/u-finder.md @@ -1,5 +1,5 @@ --- -sidebarPos: 19 +sidebarPos: 22 sidebarTitle: UFinder (deprecated) --- diff --git a/docs/src/pages/system-reference/backend/facades/utm.md b/docs/src/pages/system-reference/backend/facades/utm.md index 21365896f..01bd50723 100644 --- a/docs/src/pages/system-reference/backend/facades/utm.md +++ b/docs/src/pages/system-reference/backend/facades/utm.md @@ -1,5 +1,5 @@ --- -sidebarPos: 18 +sidebarPos: 21 sidebarTitle: Utm --- diff --git a/docs/src/pages/system-reference/backend/overview.md b/docs/src/pages/system-reference/backend/overview.md index f73f5a40c..cf4385d5f 100644 --- a/docs/src/pages/system-reference/backend/overview.md +++ b/docs/src/pages/system-reference/backend/overview.md @@ -235,9 +235,11 @@ Events fire at lifecycle boundaries (model created/updated, user registered, sta **Directory**: `src/Facades/` · **Namespace**: `Unusualify\Modularous\Facades` -18 Laravel facades providing static-style access to bound services. Each facade aliases a container entry to a concrete service class. +21 Laravel facades providing static-style access to bound services. Each facade aliases a container entry to a concrete service class. -**Selected facades**: [`Coverage`](/system-reference/backend/facades/coverage), [`CurrencyExchange`](/system-reference/backend/facades/currency-exchange), [`Filepond`](/system-reference/backend/facades/filepond), [`HostRouting`](/system-reference/backend/facades/host-routing), [`MigrationBackup`](/system-reference/backend/facades/migration-backup), [`Modularous`](/system-reference/backend/facades/modularous), [`ModularousCache`](/system-reference/backend/facades/modularous-cache), [`ModularousRoutes`](/system-reference/backend/facades/modularous-routes), [`ModularousVite`](/system-reference/backend/facades/modularous-vite), [`Navigation`](/system-reference/backend/facades/navigation), [`Redirect`](/system-reference/backend/facades/redirect), [`Register`](/system-reference/backend/facades/register), [`RelationshipGraph`](/system-reference/backend/facades/relationship-graph), [`Utm`](/system-reference/backend/facades/utm). +**Selected facades**: [`Coverage`](/system-reference/backend/facades/coverage), [`CurrencyExchange`](/system-reference/backend/facades/currency-exchange), [`Filepond`](/system-reference/backend/facades/filepond), [`HostRouting`](/system-reference/backend/facades/host-routing), [`MigrationBackup`](/system-reference/backend/facades/migration-backup), [`Modularous`](/system-reference/backend/facades/modularous), [`ModularousCache`](/system-reference/backend/facades/modularous-cache), [`ModularousRoutes`](/system-reference/backend/facades/modularous-routes), [`ModularousVite`](/system-reference/backend/facades/modularous-vite), [`Navigation`](/system-reference/backend/facades/navigation), [`Redirect`](/system-reference/backend/facades/redirect), [`Register`](/system-reference/backend/facades/register), [`RelationshipGraph`](/system-reference/backend/facades/relationship-graph), [`SiteSettings`](/system-reference/backend/facades/site-settings), [`SystemSettings`](/system-reference/backend/facades/system-settings), [`CmsSettings`](/system-reference/backend/facades/cms-settings), [`Utm`](/system-reference/backend/facades/utm). + +Settings deep-dive: [Settings Services](/system-reference/backend/services/settings/overview). → [Full Facades reference](/system-reference/backend/facades/overview) @@ -446,6 +448,7 @@ Bound in the service container; injected via constructor or accessed through the | **Cache concerns** | CacheHelpers, CacheInvalidation, CacheTags | [Cache Concerns →](/system-reference/backend/services/cache-concerns/overview) | | **Core services** | Assets, BroadcastManager, Connector, CoverageService, CurrencyExchangeService, FilepondManager, FileTranslation, MessageStage, MigrationBackup, ModularousCacheService, RedirectService, Translation, UtmParameters | [Services →](/system-reference/backend/services/overview) | | **Currency** | NullCurrencyProvider, SystemPricingCurrencyProvider | [Currency →](/system-reference/backend/services/currency/overview) | +| **Settings** | AbstractSingularSettingsService, SystemSettingsService, CmsSettingsService, SiteSettingsService | [Settings →](/system-reference/backend/services/settings/overview) | | **FileLibrary** | Disk, FileService | [FileLibrary →](/system-reference/backend/services/file-library/overview) | | **MediaLibrary** | Glide, Imgix, Local, TwicPics drivers | [MediaLibrary →](/system-reference/backend/services/media-library/overview) | | **Uploader** | SignAzureUpload, SignS3Upload, SignUploadListener | [Uploader →](/system-reference/backend/services/uploader/overview) | diff --git a/docs/src/pages/system-reference/backend/services/overview.md b/docs/src/pages/system-reference/backend/services/overview.md index be2049677..c1a73a610 100644 --- a/docs/src/pages/system-reference/backend/services/overview.md +++ b/docs/src/pages/system-reference/backend/services/overview.md @@ -25,3 +25,4 @@ The `src/Services/` directory contains the top-level service classes that power | [CurrencyExchangeService](/system-reference/backend/services/currency-exchange-service) | `CurrencyExchange` | Fetches and caches live exchange rates | | [CacheRelationshipGraph](/system-reference/backend/services/cache-relationship-graph) | — | Builds a model→module-route dependency graph for targeted cache invalidation | | [CoverageService](/system-reference/backend/services/coverage-service) | `coverage.service` | Parses Clover XML reports; generates coverage reports and PR checks | +| [Settings](/system-reference/backend/services/settings/overview) | `SystemSettings` / `CmsSettings` / `SiteSettings` | Singular system & CMS settings (dot paths, locale, media leaves, context routing) | diff --git a/docs/src/pages/system-reference/backend/services/settings/overview.md b/docs/src/pages/system-reference/backend/services/settings/overview.md new file mode 100644 index 000000000..6eeb03d9e --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/settings/overview.md @@ -0,0 +1,280 @@ +--- +sidebarPos: 22 +sidebarTitle: Overview +sidebarGroupTitle: Settings +outline: deep +--- + +# Settings Services + +Singular settings are stored as JSON sections on `IsSingular` models and read through a shared API. Three facades expose the same read/write surface with different backing stores and routing rules. + +| Facade | Accessor | Service | Model | Sections | +|--------|----------|---------|-------|----------| +| [`SystemSettings`](/system-reference/backend/facades/system-settings) | `system.settings` | `Modules\SystemSetting\Services\SystemSettingsService` | `General` | `site`, `social`, `contact`, `seo`, `smtp`, `analytics`, `locale` | +| [`CmsSettings`](/system-reference/backend/facades/cms-settings) | `cms.settings` | `Modules\Cms\Services\CmsSettingsService` | `SiteSetting` | `site`, `social`, `contact`, `seo` only | +| [`SiteSettings`](/system-reference/backend/facades/site-settings) | `site.settings` | `Modules\Cms\Services\SiteSettingsService` | (router) | Context-aware — see below | + +**Abstract base**: `Unusualify\Modularous\Services\Settings\AbstractSingularSettingsService` — shared by `SystemSettingsService` and `CmsSettingsService`. + +## Which facade to use + +| Context | Prefer | Why | +|---------|--------|-----| +| Public Blade / frontend layouts | `SiteSettings` | Uses CMS values when filled; falls back to System | +| Admin / panel / console | `SystemSettings` or `SiteSettings::forBackend()` | System is the backend source of truth | +| Explicit CMS-only read/write | `CmsSettings` | No System fallback | +| SMTP, analytics, locale | `SystemSettings` | Not editable on `SiteSetting` | + +`smtp`, `analytics`, and `locale` exist only on `General` / SystemSettings. Do not treat them as CMS Site Setting fields. + +## Context routing (`SiteSettings`) + +``` +SiteSettings +├── Frontend (non-panel HTTP) → CmsSettings ──(empty)──► SystemSettings +├── Backend / panel / console → SystemSettings only +└── Forced + ├── forFrontend() / forCms() → frontend semantics + └── forBackend() / forSystem() → backend semantics +``` + +Detection (when not forced): + +- Console → backend (System only) +- HTTP → CMS layer when `Modularous::isPanelUrl()` is false; otherwise System only + +Direct layer access: + +```php +SiteSettings::cms(); // CmsSettingsService +SiteSettings::system(); // SystemSettingsService +SiteSettings::usesCmsLayer(); // bool +``` + +## Shared API + +Available on `SystemSettings`, `CmsSettings`, and `SiteSettings` (and on the two singular services). + +| Method | Signature | Description | +|--------|-----------|-------------| +| `get` | `(string $key, mixed $default = null, ?string $locale = null): mixed` | Dot-path read with locale / media-leaf expansion | +| `value` | `(string $key, mixed $default = null, ?string $locale = null): mixed` | Alias of `get()` | +| `first` | `(string $key, mixed $default = null, ?string $locale = null): mixed` | Like `get()`; if the result is a list, returns the first element | +| `all` | `(?string $locale = null): array` | Locale-resolved tree of all sections | +| `has` | `(string $key): bool` | Raw key exists in the snapshot (`Arr::has`) | +| `filled` | `(string $key, ?string $locale = null): bool` | Resolved value is non-empty after locale/leaf expansion | +| `set` | `(string $key, mixed $value): void` | Persist via repository, then forget cache | +| `forgetCache` | `(): void` | Clear request + cache snapshots | +| `snapshot` | `(): array` | Cached raw section tree (SMTP password decrypted when present) | + +### Media leaf expansion + +When a direct path is empty, `get` / `value` expand the last segment through locale (and optional list index): + +- `site.logo.frontend` → `site.logo.{locale}.frontend` → `site.logo.{locale}.0.frontend` → … +- `site.favicon.original` / `seo.og_image.original` work the same way + +Locale fallback order: request locale → Modularous fallback locale → `*`. + +### SiteSettings fallback rules + +On the frontend layer: + +- `get` / `value` / `first` — if `CmsSettings::filled($key)` use CMS; otherwise System (then `$default`) +- `all` / `snapshot` — `array_replace_recursive(System, filterEmpty(Cms))` so empty CMS leaves do not blank System values +- `has` / `filled` — true if either layer has / is filled +- `set` — writes to CMS only +- `forgetCache` — clears both layers + +On the backend layer, every method delegates to SystemSettings only. + +--- + +## Usage examples + +### Blade (frontend) + +```blade +{{-- Logo / favicon with media leaf expansion --}} +{{ SiteSettings::get('site.name', config('app.name')) }} + + +{{-- SEO defaults --}} +{{ SiteSettings::get('seo.default_meta_title', config('app.name')) }} + + +{{-- Social repeater --}} +@foreach (SiteSettings::get('social', []) as $link) + {{ $link['platform'] ?? '' }} +@endforeach +``` + +### System-only (SMTP / backend) + +```php +use Unusualify\Modularous\Facades\SystemSettings; + +$host = SystemSettings::get('smtp.host'); +$timezone = SystemSettings::get('locale.timezone', 'UTC'); +``` + +```blade +{{-- Admin favicon from System Settings --}} + +``` + +### PHP — facade vs injection + +```php +use Unusualify\Modularous\Facades\SiteSettings; +use Unusualify\Modularous\Facades\SystemSettings; +use Modules\Cms\Services\SiteSettingsService; +use Modules\SystemSetting\Services\SystemSettingsService; + +// Facade +$name = SiteSettings::get('site.name', config('app.name')); +SystemSettings::set('smtp.host', 'smtp.example.com'); + +// Constructor injection +public function __construct( + protected SiteSettingsService $siteSettings, + protected SystemSettingsService $systemSettings, +) {} + +public function logoUrl(): ?string +{ + return $this->siteSettings->forFrontend()->value('site.logo.frontend'); +} +``` + +### Frontend vs backend routing + +```php +use Unusualify\Modularous\Facades\SiteSettings; + +// Force public-site semantics (CMS → System fallback) +$publicName = SiteSettings::forFrontend()->get('site.name'); + +// Force admin / System-only semantics +SiteSettings::forBackend()->set('seo.robots_txt', $body); + +// Aliases +SiteSettings::forCms(); // === forFrontend() +SiteSettings::forSystem(); // === forBackend() +``` + +### Fallback: empty CMS → System + +```php +SystemSettings::set('site.name', 'System Name'); +SystemSettings::set('site.email', 'system@example.com'); + +// CMS empty → reads System +SiteSettings::forFrontend()->get('site.name'); // "System Name" +SiteSettings::forFrontend()->get('site.email'); // "system@example.com" + +CmsSettings::set('site.name', 'Cms Name'); + +// Filled CMS key wins; still-empty keys keep System +SiteSettings::forFrontend()->get('site.name'); // "Cms Name" +SiteSettings::forFrontend()->get('site.email'); // "system@example.com" + +// Backend ignores CMS +SiteSettings::forBackend()->get('site.name'); // "System Name" +``` + +Analytics keys are System-only; frontend `SiteSettings` still resolves them via System fallback: + +```php +SystemSettings::set('analytics.gtm_id', 'GTM-XXXXXXX'); +SiteSettings::forFrontend()->get('analytics.gtm_id'); // "GTM-XXXXXXX" +CmsSettings::filled('analytics.gtm_id'); // false +``` + +### Set + cache invalidation + +```php +use Unusualify\Modularous\Facades\CmsSettings; +use Unusualify\Modularous\Facades\SystemSettings; + +SystemSettings::set('contact.support_email', 'support@example.com'); +// set() persists through the repository and calls forgetCache() + +CmsSettings::set('site.tagline', 'Welcome'); +CmsSettings::forgetCache(); // also safe to call explicitly + +// Repository saves (admin forms) also forget system.settings / cms.settings / site.settings caches +``` + +Cache keys: `system_settings.snapshot`, `cms_settings.snapshot`. TTLs: + +| Config | Env | Default | +|--------|-----|---------| +| `modularous.system_settings.cache_ttl` | `MODULAROUS_SYSTEM_SETTINGS_CACHE_TTL` | `3600` | +| `modularous.cms_settings.cache_ttl` | `MODULAROUS_CMS_SETTINGS_CACHE_TTL` | same as system | + +--- + +## Related support + +### `ApplySmtpMailConfig` + +`Modules\SystemSetting\Support\ApplySmtpMailConfig` — opt-in override of Laravel mail from SystemSettings SMTP. + +- Disabled by default; prefer `.env` `MAIL_*` +- Enable with `MODULAROUS_SYSTEM_SETTINGS_MAIL_OVERRIDE=true` (`modularous.system_settings.mail_override_enabled`) +- Applies only when `smtp.host` is non-empty — sets `mail.default` to `smtp` and merges host / port / username / password, plus optional `from` address / name +- Encryption helper maps `tls` / `smtp` → Laravel scheme `smtp`, and `ssl` / `smtps` → `smtps` +- Booted from `SystemSettingServiceProvider` + +```env +MODULAROUS_SYSTEM_SETTINGS_MAIL_OVERRIDE=true +``` + +### `AnalyticsScripts` + +`Modules\SystemSetting\Support\AnalyticsScripts` — builds GTM / optional GA4 snippets. + +- Reads via `SiteSettingsService` (frontend CMS → System fallback; analytics values live on System) +- Enablement: DB `analytics.enabled` when set; otherwise `modularous.system_settings.analytics_enabled_default` / `MODULAROUS_ANALYTICS_ENABLED` +- Validates IDs (`GTM-…`, `G-…`) + +```blade +{!! app(\Modules\SystemSetting\Support\AnalyticsScripts::class)->headHtml() !!} +{{-- immediately after --}} +{!! app(\Modules\SystemSetting\Support\AnalyticsScripts::class)->bodyOpenHtml() !!} +``` + +Or via `SiteSettings` / `SystemSettings` directly: + +```php +SiteSettings::get('analytics.gtm_id'); +SystemSettings::get('analytics.enabled'); +``` + +### `DuplicateSystemSettingsToCmsSettings` + +`Modules\Cms\Support\DuplicateSystemSettingsToCmsSettings` — one-time seed that copies `General` sections into an empty `SiteSetting` (CMS sections only: `site`, `social`, `contact`, `seo`). Skips when CMS already has content. Invoked from `CmsServiceProvider` boot (and host operations when needed). + +```php +app(\Modules\Cms\Support\DuplicateSystemSettingsToCmsSettings::class)->duplicateIfNeeded(); +``` + +--- + +## Registration + +| Binding | Provider | +|---------|----------| +| `SystemSettingsService` → `system.settings` | `Modules\SystemSetting\Providers\SystemSettingServiceProvider` | +| `CmsSettingsService` → `cms.settings` | `Modules\Cms\Providers\CmsServiceProvider` | +| `SiteSettingsService` → `site.settings` | `Modules\Cms\Providers\CmsServiceProvider` | + +Composer aliases: `SystemSettings`, `CmsSettings`, `SiteSettings` → `Unusualify\Modularous\Facades\*`. + +## Facade pages + +- [SystemSettings](/system-reference/backend/facades/system-settings) +- [CmsSettings](/system-reference/backend/facades/cms-settings) +- [SiteSettings](/system-reference/backend/facades/site-settings) diff --git a/lang/en.json b/lang/en.json index 27f78e0c6..f2e12c7c3 100755 --- a/lang/en.json +++ b/lang/en.json @@ -100,6 +100,7 @@ "Terms of Service": "Terms of Service", "Thank you for your support!": "Thank you for your support!", + "This company has registered users and cannot be deleted.": "This company has registered users and cannot be deleted.", "This password reset link will expire in :count minutes.": "This password reset link will expire in :count minutes.", "This verification link will expire in :count minutes.": "This verification link will expire in :count minutes.", "Total": "Total", diff --git a/lang/en/modules.php b/lang/en/modules.php index e3cd91e84..15631bedf 100755 --- a/lang/en/modules.php +++ b/lang/en/modules.php @@ -107,4 +107,9 @@ 'name' => 'State | States | {n} State', ], ], + 'system_setting' => [ + 'general' => [ + 'name' => 'General Settings | General Settings | {n} General Settings', + ], + ], ]; diff --git a/lang/tr.json b/lang/tr.json index f89d2384c..cb3c88f18 100755 --- a/lang/tr.json +++ b/lang/tr.json @@ -99,6 +99,7 @@ "Terms of Service": "Hizmet Şartları", "Thank you for your support!": "Desteğiniz için teşekkür ederiz!", + "This company has registered users and cannot be deleted.": "Bu şirkete kayıtlı kullanıcılar olduğu için bu şirket silinemez.", "This password reset link will expire in :count minutes.": "Bu şifre oluşturma linki :count dakika içerisinde sonlanacak.", "This verification link will expire in :count minutes.": "Bu doğrulama linki :count dakika içerisinde sonlanacak.", "Total": "Toplam", diff --git a/modules/Cms/Config/config.php b/modules/Cms/Config/config.php index 7473e9f05..2f18c29e0 100644 --- a/modules/Cms/Config/config.php +++ b/modules/Cms/Config/config.php @@ -442,22 +442,52 @@ 'url' => 'site-settings', 'route_name' => 'site_setting', 'icon' => 'mdi-cog-sync-outline', - 'title_column_key' => 'key', - 'table_options' => [ - 'createOnModal' => true, - 'editOnModal' => true, - ], - 'headers' => [ - ['title' => 'Group', 'key' => 'group_key', 'searchable' => true], - ['title' => 'Key', 'key' => 'key', 'searchable' => true], - ['title' => 'Locale', 'key' => 'locale', 'searchable' => true], - ['title' => 'Actions', 'key' => 'actions', 'sortable' => false], - ], + 'title_column_key' => 'site.name', 'inputs' => [ - ['name' => 'group_key', 'label' => 'Group', 'type' => 'text', 'rules' => 'required'], - ['name' => 'key', 'label' => 'Key', 'type' => 'text', 'rules' => 'required'], - ['name' => 'locale', 'label' => 'Locale', 'type' => 'locale', 'rules' => 'required'], - ['name' => 'value', 'label' => 'Value', 'type' => 'textarea'], + [ + 'type' => '@collapsible_group', + 'name' => 'site', + 'typeIntTitle' => 'Site', + 'schema' => [ + ['name' => 'name', 'type' => 'text', 'label' => 'Site name', 'translated' => true, 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'tagline', 'type' => 'text', 'label' => 'Tagline', 'translated' => true, 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'logo', 'type' => 'image', 'label' => 'Logo', 'translated' => true, 'col' => ['cols' => 12, 'lg' => 6], 'imageCol' => ['cols' => 12, 'lg' => 12, 'md' => 12]], + ['name' => 'favicon', 'type' => 'image', 'label' => 'Favicon', 'translated' => true, 'col' => ['cols' => 12, 'lg' => 6], 'imageCol' => ['cols' => 12, 'lg' => 12, 'md' => 12]], + ['name' => 'email', 'type' => 'text', 'label' => 'Email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'phone', 'type' => 'input-phone', 'label' => 'Phone', 'rules' => 'nullable|string|max:64', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'address', 'type' => 'textarea', 'label' => 'Address', 'translated' => true, 'rules' => 'nullable|string'], + ], + ], + [ + 'type' => '@collapsible_wrap', + 'typeIntTitle' => 'Social Links', + 'schema' => [ + ['type' => '@system_social_links'], + ], + ], + [ + 'type' => '@collapsible_group', + 'typeIntTitle' => 'Contact', + 'name' => 'contact', + 'schema' => [ + ['name' => 'support_email', 'type' => 'text', 'label' => 'Support email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'sales_email', 'type' => 'text', 'label' => 'Sales email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'cc', 'type' => 'text', 'label' => 'Contact CC', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'bcc', 'type' => 'text', 'label' => 'Contact BCC', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ], + ], + [ + 'type' => '@collapsible_group', + 'name' => 'seo', + 'typeIntTitle' => 'SEO', + 'schema' => [ + ['name' => 'robots_txt', 'type' => 'textarea', 'label' => 'Global robots.txt', 'rules' => 'nullable|string'], + ['name' => 'default_meta_title', 'type' => 'text', 'label' => 'Default meta title', 'translated' => true, 'rules' => 'nullable|string|max:255'], + ['name' => 'default_meta_description', 'type' => 'textarea', 'label' => 'Default meta description', 'translated' => true, 'rules' => 'nullable|string'], + ['name' => 'og_image', 'type' => 'image', 'label' => 'Default OG image', 'rules' => 'nullable'], + ['name' => 'json_schema', 'type' => 'textarea', 'label' => 'Global JSON-LD (Schema.org)', 'rules' => 'nullable|string', 'hint' => 'Raw JSON-LD object/array for the site homepage defaults'], + ], + ], ], ], 'redirect' => [ diff --git a/modules/Cms/Entities/SiteSetting.php b/modules/Cms/Entities/SiteSetting.php index 029dbcafa..34d6646cc 100644 --- a/modules/Cms/Entities/SiteSetting.php +++ b/modules/Cms/Entities/SiteSetting.php @@ -1,25 +1,51 @@ + */ protected $fillable = [ - 'group_key', - 'key', - 'locale', - 'value', - 'is_active', + 'published', + 'site', + 'social', + 'contact', + 'seo', ]; + /** + * @var array + */ protected $casts = [ - 'is_active' => 'boolean', + 'site' => 'array', + 'social' => 'array', + 'contact' => 'array', + 'seo' => 'array', + 'published' => 'boolean', ]; - public function getTable(): string + /** + * @return list + */ + public static function settingsSections(): array { - return modularousConfig('tables.cms_site_settings', 'um_cms_site_settings'); + return ['site', 'social', 'contact', 'seo']; } } diff --git a/modules/Cms/Http/Controllers/SiteSeoSettingsController.php b/modules/Cms/Http/Controllers/SiteSeoSettingsController.php index 706e8812d..d67fd3388 100644 --- a/modules/Cms/Http/Controllers/SiteSeoSettingsController.php +++ b/modules/Cms/Http/Controllers/SiteSeoSettingsController.php @@ -5,28 +5,17 @@ use Illuminate\Http\JsonResponse; use Illuminate\Routing\Controller; use Modules\Cms\Http\Requests\SiteSeoSettingsRequest; -use Modules\Cms\Services\CmsSiteSeoSettingsService; /** - * Persists site-wide SEO fields (session web route for the panel). + * @deprecated Global robots.txt is edited under System Settings → General. */ class SiteSeoSettingsController extends Controller { - public function update(SiteSeoSettingsRequest $request, CmsSiteSeoSettingsService $service): JsonResponse + public function update(SiteSeoSettingsRequest $request): JsonResponse { - if (! modularousConfig('cms_seo.robots.use_site_settings', true)) { - return response()->json([ - 'ok' => false, - 'message' => __('Database-backed site SEO is disabled in configuration.'), - ], 422); - } - - $data = $request->validated(); - $service->saveGlobalRobotsTxt($data['global_robots_txt'] ?? null); - return response()->json([ - 'ok' => true, - 'message' => __('Site SEO settings saved.'), - ]); + 'ok' => false, + 'message' => __('Global robots.txt is managed under System Settings → General.'), + ], 410); } } diff --git a/modules/Cms/Http/Controllers/SiteSeoToolController.php b/modules/Cms/Http/Controllers/SiteSeoToolController.php index fd3bb3f79..639a26997 100644 --- a/modules/Cms/Http/Controllers/SiteSeoToolController.php +++ b/modules/Cms/Http/Controllers/SiteSeoToolController.php @@ -7,12 +7,11 @@ use Illuminate\Support\Facades\Route; use Inertia\Inertia; use Inertia\Response; -use Modules\Cms\Services\CmsSiteSeoSettingsService; use Unusualify\Modularous\Facades\Modularous; use Unusualify\Modularous\Http\Controllers\BaseController; /** - * Inertia shell for CMS site-wide SEO (global robots.txt body stored in site_settings). + * Inertia shell for CMS site-wide SEO tools (page-level SEO; robots.txt lives in System Settings). */ class SiteSeoToolController extends BaseController { @@ -25,7 +24,7 @@ public function __construct(Application $app, Request $request) parent::__construct($app, $request); } - public function __invoke(CmsSiteSeoSettingsService $siteSeo): Response + public function __invoke(): Response { $pageTitle = __('Site SEO') . ' - ' . Modularous::pageTitle(); $headerTitle = __('Site SEO'); @@ -40,20 +39,32 @@ public function __invoke(CmsSiteSeoSettingsService $siteSeo): Response $this->shareInertiaStoreVariables(); - $prefix = $this->module->panelRouteNamePrefix() . '.'; + $systemSettingsUrl = $this->resolveSystemSettingsUrl(); return Inertia::render('SiteSeo', [ - 'siteSeoEndpoints' => [ - 'save' => route($prefix . 'siteSeo.save'), - ], - 'globalRobotsTxt' => $siteSeo->globalRobotsTxtForEditor(), - 'useSiteSettings' => (bool) modularousConfig('cms_seo.robots.use_site_settings', true), + 'systemSettingsUrl' => $systemSettingsUrl, 'endpoints' => new \stdClass, 'mainConfiguration' => $this->getInertiaMainConfiguration($data), 'headLayoutData' => $this->getHeadLayoutData($data), ]); } + protected function resolveSystemSettingsUrl(): ?string + { + $candidates = [ + systemRouteNamePrefix() . '.systemsetting.general.index', + modularousConfig('admin_route_name_prefix', 'admin') . '.system.systemsetting.general.index', + ]; + + foreach ($candidates as $routeName) { + if (Route::has($routeName)) { + return route($routeName); + } + } + + return null; + } + /** * @return array */ diff --git a/modules/Cms/Http/Requests/SiteSettingRequest.php b/modules/Cms/Http/Requests/SiteSettingRequest.php index 1500809da..4d63afd7e 100644 --- a/modules/Cms/Http/Requests/SiteSettingRequest.php +++ b/modules/Cms/Http/Requests/SiteSettingRequest.php @@ -8,13 +8,7 @@ class SiteSettingRequest extends Request { public function rulesForAll() { - return [ - 'group_key' => 'required|string|max:100', - 'key' => 'required|string|max:100', - 'locale' => 'required|string|max:12', - 'value' => 'nullable|string', - 'is_active' => 'nullable|boolean', - ]; + return []; } public function rulesForCreate() diff --git a/modules/Cms/Providers/CmsServiceProvider.php b/modules/Cms/Providers/CmsServiceProvider.php index 62405903c..6da2fbe53 100644 --- a/modules/Cms/Providers/CmsServiceProvider.php +++ b/modules/Cms/Providers/CmsServiceProvider.php @@ -46,6 +46,7 @@ use Modules\Cms\Services\CmsParentSegmentResolver; use Modules\Cms\Services\CmsPromotionService; use Modules\Cms\Services\CmsPublicModelResolver; +use Modules\Cms\Services\CmsSettingsService; use Modules\Cms\Services\CmsSignedPreviewTargetResolver; use Modules\Cms\Services\CmsSignedPreviewUrlGenerator; use Modules\Cms\Services\CmsSitemapBuildService; @@ -58,6 +59,7 @@ use Modules\Cms\Services\DefaultCmsPromotionScopeApplier; use Modules\Cms\Services\NullLeadDelivery; use Modules\Cms\Services\RedirectValidationService; +use Modules\Cms\Services\SiteSettingsService; use Modules\Cms\Services\Stylesheet\FrameworkArtifactResolver; use Modules\Cms\Services\Stylesheet\FrameworkScriptResolver; use Modules\Cms\Services\Stylesheet\RootVariablesEmitter; @@ -66,6 +68,7 @@ use Modules\Cms\Services\Stylesheet\UtilityCssGenerator; use Modules\Cms\Support\CmsPublicUrlRegistryAboutReporter; use Modules\Cms\Support\CmsPublicUrlRegistryCacheManager; +use Modules\Cms\Support\DuplicateSystemSettingsToCmsSettings; use Unusualify\Modularous\Facades\ModularousCache; use Unusualify\Modularous\Services\Security\SecurityService; use Unusualify\Modularous\Services\SlugInputValidationService; @@ -110,6 +113,11 @@ public function register(): void $this->app->singleton(CmsSitemapCacheService::class); $this->app->singleton(CmsAdminWarnings::class); $this->app->singleton(CmsSiteSeoSettingsService::class); + $this->app->singleton(CmsSettingsService::class); + $this->app->alias(CmsSettingsService::class, 'cms.settings'); + $this->app->singleton(SiteSettingsService::class); + $this->app->alias(SiteSettingsService::class, 'site.settings'); + $this->app->singleton(DuplicateSystemSettingsToCmsSettings::class); $this->app->singleton(SlugInputValidationService::class, CmsSlugInputValidationService::class); $this->app->singleton(CmsSignedPreviewUrlGenerator::class); $this->app->singleton(CmsSignedPreviewTargetResolver::class); @@ -188,6 +196,12 @@ public function boot(): void $this->registerCmsSignedPreviewRoutes(); $this->registerCmsPublicStylesheetRoutes(); $this->registerCmsPublishSchedule(); + + try { + $this->app->make(DuplicateSystemSettingsToCmsSettings::class)->duplicateIfNeeded(); + } catch (\Throwable) { + // Seed is best-effort when singletons / DB are unavailable. + } } /** diff --git a/modules/Cms/Repositories/SiteSettingRepository.php b/modules/Cms/Repositories/SiteSettingRepository.php index 4c8ee11e2..e56faa0d6 100644 --- a/modules/Cms/Repositories/SiteSettingRepository.php +++ b/modules/Cms/Repositories/SiteSettingRepository.php @@ -1,58 +1,37 @@ model = $model; } /** - * Single site-setting row for the given composite key (includes soft-deleted for restore semantics). + * @param \Illuminate\Database\Eloquent\Model $object + * @param array $fields */ - public function findScoped(string $groupKey, string $key, string $locale): ?SiteSetting + public function afterSave($object, $fields): void { - return $this->model->newQuery() - ->withTrashed() - ->where('group_key', $groupKey) - ->where('key', $key) - ->where('locale', $locale) - ->first(); - } + parent::afterSave($object, $fields); - /** - * Persist a value or remove the row when {@code $value} is null or whitespace-only (revert to env/config fallback). - */ - public function putScoped(string $groupKey, string $key, string $locale, ?string $value): void - { - if ($value === null || trim($value) === '') { - $existing = $this->findScoped($groupKey, $key, $locale); - if ($existing !== null) { - $existing->forceDelete(); - } - - return; + if (app()->bound('cms.settings')) { + app('cms.settings')->forgetCache(); } - $model = $this->findScoped($groupKey, $key, $locale) ?? $this->model->newInstance([ - 'group_key' => $groupKey, - 'key' => $key, - 'locale' => $locale, - ]); - - if ($model->trashed()) { - $model->restore(); + if (app()->bound('site.settings')) { + app('site.settings')->forgetCache(); } - - $model->fill([ - 'value' => $value, - 'is_active' => true, - ]); - $model->save(); } } diff --git a/modules/Cms/Resources/views/layout_builder/shell.blade.php b/modules/Cms/Resources/views/layout_builder/shell.blade.php index 381e29e41..089f63cb0 100644 --- a/modules/Cms/Resources/views/layout_builder/shell.blade.php +++ b/modules/Cms/Resources/views/layout_builder/shell.blade.php @@ -5,25 +5,44 @@ - @if(! empty($seoDescription)) - - @endif - @if(! empty($canonicalUrl)) - - @endif - @if(! empty($robotsMeta)) - - @endif + + @php + $resolvedSiteName = SiteSettings::get('site.name', config('app.name')); + $resolvedMetaTitle = $seoTitle ?? $siteTitle ?? SiteSettings::get('seo.default_meta_title', $resolvedSiteName); + $resolvedMetaDescription = $seoDescription ?? SiteSettings::get('seo.default_meta_description', ''); + $resolvedFavicon = SiteSettings::value('site.favicon.original') ?: asset('favicon.ico'); + @endphp + + {{ $resolvedMetaTitle }} + + + + + + + + + + + + {{-- Optional CMP / consent (e.g. CookieFirst) must precede GTM --}} + {!! $consentHeadHtml ?? '' !!} + {!! app(\Modules\SystemSetting\Support\AnalyticsScripts::class)->headHtml() !!} + + + @foreach ($stylesheetHrefs ?? [] as $href) @endforeach + {!! $headHtml ?? '' !!} @if (! empty($cmsLayoutUseInjectionMarkers)) {!! $cmsLayoutMarkerHeadAppend ?? '' !!} @endif + {!! app(\Modules\SystemSetting\Support\AnalyticsScripts::class)->bodyOpenHtml() !!} {!! $bodyHtml ?? '' !!} @if (! empty($cmsLayoutUseInjectionMarkers)) {!! $cmsLayoutMarkerBeforeFooter ?? '' !!} diff --git a/modules/Cms/Routes/web.php b/modules/Cms/Routes/web.php index e03099076..da2b5063b 100644 --- a/modules/Cms/Routes/web.php +++ b/modules/Cms/Routes/web.php @@ -2,10 +2,11 @@ use Illuminate\Support\Facades\Route; use Modules\Cms\Http\Controllers\API\PromotionController; +use Modules\Cms\Http\Controllers\LayoutBuilderHtmlPreviewController; +use Modules\Cms\Http\Controllers\LayoutBuilderShellDraftPreviewController; use Modules\Cms\Http\Controllers\PromotionToolController; use Modules\Cms\Http\Controllers\SignedPublicPreviewMintController; use Modules\Cms\Http\Controllers\SitemapController; -use Modules\Cms\Http\Controllers\SiteSeoSettingsController; use Modules\Cms\Http\Controllers\SiteSeoToolController; use Unusualify\Modularous\Facades\ModularousRoutes; diff --git a/modules/Cms/Services/CmsSettingsService.php b/modules/Cms/Services/CmsSettingsService.php new file mode 100644 index 000000000..628b0d3b4 --- /dev/null +++ b/modules/Cms/Services/CmsSettingsService.php @@ -0,0 +1,47 @@ +siteSettingRepository; + } + + protected function settingsSections(): array + { + return SiteSetting::settingsSections(); + } +} diff --git a/modules/Cms/Services/CmsSiteSeoSettingsService.php b/modules/Cms/Services/CmsSiteSeoSettingsService.php index 7d1475ab7..d53226230 100644 --- a/modules/Cms/Services/CmsSiteSeoSettingsService.php +++ b/modules/Cms/Services/CmsSiteSeoSettingsService.php @@ -2,23 +2,15 @@ namespace Modules\Cms\Services; -use Modules\Cms\Entities\SiteSetting; -use Modules\Cms\Http\Controllers\Front\RobotsTxtController; -use Modules\Cms\Repositories\SiteSettingRepository; use Modules\Cms\Support\CmsPublicSeo; +use Unusualify\Modularous\Facades\SiteSettings; /** - * Persists site-wide SEO options in {@see SiteSetting} (key-value rows). - * - * Global robots.txt body is stored under the configured group/key/locale and read by - * {@see RobotsTxtController} when `cms_seo.robots.use_site_settings` is true. + * Site-wide SEO helpers. Global robots.txt is resolved via {@see SiteSettings} + * (CmsSettings on frontend with SystemSettings fallback). */ class CmsSiteSeoSettingsService { - public function __construct( - protected SiteSettingRepository $siteSettings, - ) {} - /** * Body served at GET /robots.txt (normalized trailing newline). */ @@ -31,7 +23,7 @@ public function resolvedRobotsTxtBody(): string $default = "User-agent: *\nAllow: /"; $raw = null; - if (modularousConfig('cms_seo.robots.use_site_settings', true)) { + if (modularousConfig('cms_seo.robots.use_system_settings', true)) { $persisted = $this->persistedGlobalRobotsTxt(); if ($persisted !== null) { $raw = trim($persisted); @@ -53,14 +45,17 @@ public function resolvedRobotsTxtBody(): string } /** - * Raw value from DB, or null when unset (use env/config in UI and public fallback). + * Raw value from SiteSettings, or null when unset (use env/config in UI and public fallback). */ public function persistedGlobalRobotsTxt(): ?string { - [$g, $k, $locale] = $this->robotsSettingKeys(); - $row = $this->siteSettings->findScoped($g, $k, $locale); + if (! SiteSettings::forFrontend()->has('seo.robots_txt')) { + return null; + } - return $row !== null ? (string) $row->value : null; + $value = SiteSettings::forFrontend()->get('seo.robots_txt'); + + return $value !== null ? (string) $value : null; } /** @@ -88,21 +83,9 @@ public function globalRobotsTxtForEditor(): string public function saveGlobalRobotsTxt(?string $value): void { - [$g, $k, $locale] = $this->robotsSettingKeys(); - $this->siteSettings->putScoped($g, $k, $locale, $value); - } - - /** - * @return array{0: string, 1: string, 2: string} - */ - protected function robotsSettingKeys(): array - { - $cfg = (array) modularousConfig('cms_seo.robots.site_setting', []); + $normalized = $value === null || trim($value) === '' ? null : rtrim($value, "\r\n"); - return [ - (string) ($cfg['group_key'] ?? 'seo'), - (string) ($cfg['key'] ?? 'global_robots_txt'), - (string) ($cfg['locale'] ?? '*'), - ]; + // Panel SEO tool writes the system-wide default; CMS Site Settings can override per frontend. + SiteSettings::forBackend()->set('seo.robots_txt', $normalized); } } diff --git a/modules/Cms/Services/SiteSettingsService.php b/modules/Cms/Services/SiteSettingsService.php new file mode 100644 index 000000000..7340c83e5 --- /dev/null +++ b/modules/Cms/Services/SiteSettingsService.php @@ -0,0 +1,219 @@ +forcedContext = 'frontend'; + + return $clone; + } + + /** + * Force SystemSettings only (backend semantics). + */ + public function forBackend(): static + { + $clone = clone $this; + $clone->forcedContext = 'backend'; + + return $clone; + } + + /** + * Alias of {@see forFrontend()}. + */ + public function forCms(): static + { + return $this->forFrontend(); + } + + /** + * Alias of {@see forBackend()}. + */ + public function forSystem(): static + { + return $this->forBackend(); + } + + public function usesCmsLayer(): bool + { + if ($this->forcedContext === 'frontend') { + return true; + } + + if ($this->forcedContext === 'backend') { + return false; + } + + if (app()->runningInConsole()) { + return false; + } + + try { + return ! Modularous::isPanelUrl(); + } catch (\Throwable) { + return true; + } + } + + public function get(string $key, mixed $default = null, ?string $locale = null): mixed + { + if ($this->usesCmsLayer()) { + if ($this->cmsSettings->filled($key, $locale)) { + return $this->cmsSettings->get($key, null, $locale); + } + + return $this->systemSettings->get($key, $default, $locale); + } + + return $this->systemSettings->get($key, $default, $locale); + } + + public function value(string $key, mixed $default = null, ?string $locale = null): mixed + { + return $this->get($key, $default, $locale); + } + + public function first(string $key, mixed $default = null, ?string $locale = null): mixed + { + if ($this->usesCmsLayer()) { + if ($this->cmsSettings->filled($key, $locale)) { + return $this->cmsSettings->first($key, null, $locale); + } + + return $this->systemSettings->first($key, $default, $locale); + } + + return $this->systemSettings->first($key, $default, $locale); + } + + /** + * @return array + */ + public function all(?string $locale = null): array + { + if (! $this->usesCmsLayer()) { + return $this->systemSettings->all($locale); + } + + return array_replace_recursive( + $this->systemSettings->all($locale), + $this->filterEmptyBranches($this->cmsSettings->all($locale)), + ); + } + + public function has(string $key): bool + { + if ($this->usesCmsLayer()) { + return $this->cmsSettings->has($key) || $this->systemSettings->has($key); + } + + return $this->systemSettings->has($key); + } + + public function filled(string $key, ?string $locale = null): bool + { + if ($this->usesCmsLayer()) { + return $this->cmsSettings->filled($key, $locale) || $this->systemSettings->filled($key, $locale); + } + + return $this->systemSettings->filled($key, $locale); + } + + public function set(string $key, mixed $value): void + { + if ($this->usesCmsLayer()) { + $this->cmsSettings->set($key, $value); + + return; + } + + $this->systemSettings->set($key, $value); + } + + public function forgetCache(): void + { + $this->cmsSettings->forgetCache(); + $this->systemSettings->forgetCache(); + } + + /** + * @return array + */ + public function snapshot(): array + { + if ($this->usesCmsLayer()) { + return array_replace_recursive( + $this->systemSettings->snapshot(), + $this->filterEmptyBranches($this->cmsSettings->snapshot()), + ); + } + + return $this->systemSettings->snapshot(); + } + + public function cms(): CmsSettingsService + { + return $this->cmsSettings; + } + + public function system(): SystemSettingsService + { + return $this->systemSettings; + } + + /** + * Drop empty leaves so SystemSettings values remain visible under array_replace_recursive. + * + * @param array $tree + * @return array + */ + protected function filterEmptyBranches(array $tree): array + { + $filtered = []; + + foreach ($tree as $key => $value) { + if (is_array($value)) { + $nested = $this->filterEmptyBranches($value); + if ($nested !== []) { + $filtered[$key] = $nested; + } + + continue; + } + + if ($value !== null && $value !== '') { + $filtered[$key] = $value; + } + } + + return $filtered; + } +} diff --git a/modules/Cms/Support/DuplicateSystemSettingsToCmsSettings.php b/modules/Cms/Support/DuplicateSystemSettingsToCmsSettings.php new file mode 100644 index 000000000..499c4931d --- /dev/null +++ b/modules/Cms/Support/DuplicateSystemSettingsToCmsSettings.php @@ -0,0 +1,72 @@ +hasMeaningfulContent($siteSetting)) { + return false; + } + + if (! $this->hasMeaningfulContent($general)) { + return false; + } + + $fields = ['published' => (bool) ($general->published ?? true)]; + + foreach (SiteSetting::settingsSections() as $section) { + $value = $general->getAttribute($section); + $fields[$section] = is_array($value) ? $value : []; + } + + $this->siteSettingRepository->update($siteSetting->id, $fields); + $this->cmsSettings->forgetCache(); + + return true; + } + + protected function hasMeaningfulContent(object $model): bool + { + foreach (SiteSetting::settingsSections() as $section) { + $value = $model->getAttribute($section); + + if (is_array($value) && $value !== []) { + return true; + } + + if ($value !== null && $value !== '' && $value !== []) { + return true; + } + } + + return false; + } +} diff --git a/modules/Cms/Transformers/SiteSettingResource.php b/modules/Cms/Transformers/SiteSettingResource.php new file mode 100644 index 000000000..8caad966f --- /dev/null +++ b/modules/Cms/Transformers/SiteSettingResource.php @@ -0,0 +1,33 @@ + + */ + public function toArray($request): array + { + $data = parent::toArray($request); + + if (isset($data['smtp']) && is_array($data['smtp']) && array_key_exists('password', $data['smtp'])) { + $password = $data['smtp']['password']; + $data['smtp']['password'] = ($password !== null && $password !== '') ? '********' : null; + } + + foreach ((array) modularousConfig('cms_settings.sensitive_keys', modularousConfig('system_settings.sensitive_keys', ['smtp.password'])) as $dotKey) { + if (Arr::has($data, $dotKey)) { + $value = Arr::get($data, $dotKey); + Arr::set($data, $dotKey, ($value !== null && $value !== '') ? '********' : null); + } + } + + return $data; + } +} diff --git a/modules/SystemSetting/Config/config.php b/modules/SystemSetting/Config/config.php index 2cf8f658f..325dce1dd 100644 --- a/modules/SystemSetting/Config/config.php +++ b/modules/SystemSetting/Config/config.php @@ -8,68 +8,115 @@ 'routes' => [ 'general' => [ 'name' => 'General', - 'headline' => 'Generals', + 'headline' => 'General Settings', 'url' => 'generals', 'route_name' => 'general', 'icon' => '$submodule', - 'title_column_key' => 'name', - 'table_options' => [ - 'createOnModal' => true, - 'editOnModal' => true, - 'isRowEditing' => false, - 'rowActionsType' => 'inline', - ], - 'headers' => [ + 'title_column_key' => 'site.name', + 'inputs' => [ [ - 'title' => 'Name', - 'key' => 'name', - 'formatter' => [ - 'edit', + 'type' => '@collapsible_group', + 'typeIntTitle' => 'SMTP', + 'name' => 'smtp', + 'col' => ['cols' => 12], + 'schema' => [ + ['name' => 'host', 'type' => 'text', 'label' => 'Host', 'placeholder' => 'smtp.example.com', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'port', 'type' => 'number-input', 'label' => 'Port', 'placeholder' => 587, 'rules' => 'nullable|integer', 'col' => ['cols' => 12, 'lg' => 6]], + [ + 'name' => 'encryption', + 'type' => 'select', + 'label' => 'Encryption', + 'col' => ['cols' => 12, 'lg' => 6], + 'returnObject' => false, + 'itemTitle' => 'title', + 'itemValue' => 'value', + 'setFirstDefault' => true, + 'default' => 'tls', + 'items' => [ + ['title' => 'TLS', 'value' => 'tls'], + ['title' => 'SSL', 'value' => 'ssl'], + ], + 'rules' => 'nullable|string|in:,tls,ssl', + ], + ['name' => 'username', 'type' => 'text', 'label' => 'Username', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'password', 'type' => 'password', 'label' => 'Password', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'from_address', 'type' => 'text', 'label' => 'From address', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'from_name', 'type' => 'text', 'label' => 'From name', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], ], - 'searchable' => true, ], [ - 'title' => 'Created Time', - 'key' => 'created_at', - 'formatter' => [ - 'date', - 'long', + 'type' => '@collapsible_group', + 'typeIntTitle' => 'Contact', + 'name' => 'contact', + 'schema' => [ + ['name' => 'support_email', 'type' => 'text', 'label' => 'Support email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'sales_email', 'type' => 'text', 'label' => 'Sales email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'cc', 'type' => 'text', 'label' => 'Contact CC', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'bcc', 'type' => 'text', 'label' => 'Contact BCC', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], ], - 'searchable' => true, ], [ - 'title' => 'Actions', - 'key' => 'actions', - 'sortable' => false, + 'type' => '@collapsible_group', + 'typeIntTitle' => 'Locale & region', + 'name' => 'locale', + 'schema' => [ + ['name' => 'timezone', 'type' => 'text', 'label' => 'Timezone', 'rules' => 'nullable|string|max:64'], + ['name' => 'default_currency', 'type' => 'text', 'label' => 'Default currency', 'rules' => 'nullable|string|max:8'], + ['name' => 'date_format', 'type' => 'text', 'label' => 'Date format', 'rules' => 'nullable|string|max:32'], + ], ], - ], - 'inputs' => [ [ - 'name' => 'name', - 'label' => 'Name', - 'type' => 'text', + 'type' => '@collapsible_group', + 'typeIntTitle' => 'Analytics', + 'name' => 'analytics', + 'schema' => [ + [ + 'name' => 'enabled', + 'type' => 'switch', + 'label' => 'Enable analytics', + 'default' => false, + 'hideDetails' => 'auto', + 'rules' => 'nullable|boolean', + 'col' => ['cols' => 12], + ], + ['name' => 'gtm_id', 'type' => 'text', 'label' => 'GTM container ID', 'placeholder' => 'GTM-XXXXXXX', 'rules' => 'nullable|string|max:64', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'ga_measurement_id', 'type' => 'text', 'label' => 'GA measurement ID', 'placeholder' => 'G-XXXXXXXXXX', 'rules' => 'nullable|string|max:64', 'col' => ['cols' => 12, 'lg' => 6]], + ], ], - // [ - // 'name' => 'balance', - // 'label' => 'Balance', - // 'type' => 'text', - // 'spreadable' => true, - // ], + [ - 'name' => 'cont', - 'label' => 'Spread', - 'type' => 'spread', - 'connector' => 'SystemSetting:General', - 'reservedKeys' => ['name'], - 'height' => '250px', - // 'scrollable' => true, + 'type' => '@collapsible_group', + 'name' => 'site', + 'typeIntTitle' => 'Site', + 'schema' => [ + ['name' => 'name', 'type' => 'text', 'label' => 'Site name', 'translated' => true, 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'tagline', 'type' => 'text', 'label' => 'Tagline', 'translated' => true, 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'logo', 'type' => 'image', 'label' => 'Logo', 'translated' => true, 'col' => ['cols' => 12, 'lg' => 6], 'imageCol' => ['cols' => 12, 'lg' => 12, 'md' => 12]], + ['name' => 'favicon', 'type' => 'image', 'label' => 'Favicon', 'translated' => true, 'col' => ['cols' => 12, 'lg' => 6], 'imageCol' => ['cols' => 12, 'lg' => 12, 'md' => 12]], + ['name' => 'email', 'type' => 'text', 'label' => 'Email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'phone', 'type' => 'input-phone', 'label' => 'Phone', 'rules' => 'nullable|string|max:64', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'address', 'type' => 'textarea', 'label' => 'Address', 'translated' => true, 'rules' => 'nullable|string'], + ], ], [ - 'name' => 'logo', - 'label' => 'Logo', - 'type' => 'image', - 'translated' => true, + 'type' => '@collapsible_wrap', + 'typeIntTitle' => 'Social Links', + 'schema' => [ + ['type' => '@system_social_links'], + ], ], + [ + 'type' => '@collapsible_group', + 'name' => 'seo', + 'typeIntTitle' => 'SEO', + 'schema' => [ + ['name' => 'robots_txt', 'type' => 'textarea', 'label' => 'Global robots.txt', 'rules' => 'nullable|string'], + ['name' => 'default_meta_title', 'type' => 'text', 'label' => 'Default meta title', 'translated' => true, 'rules' => 'nullable|string|max:255'], + ['name' => 'default_meta_description', 'type' => 'textarea', 'label' => 'Default meta description', 'translated' => true, 'rules' => 'nullable|string'], + ['name' => 'og_image', 'type' => 'image', 'label' => 'Default OG image', 'rules' => 'nullable'], + ['name' => 'json_schema', 'type' => 'textarea', 'label' => 'Global JSON-LD (Schema.org)', 'rules' => 'nullable|string', 'hint' => 'Raw JSON-LD object/array for the site homepage defaults'], + ], + ] ], ], ], diff --git a/modules/SystemSetting/Entities/General.php b/modules/SystemSetting/Entities/General.php index 5e8a9e60c..ffb179366 100644 --- a/modules/SystemSetting/Entities/General.php +++ b/modules/SystemSetting/Entities/General.php @@ -1,33 +1,51 @@ - */ - use HasImages, HasSpreadable, IsSingular; + use HasImages, IsSingular, HasRepeaters; /** - * Override the default `spread_payload` so the trait's - * `getSpreadableSavingKey()` returns `cont`, matching the input - * name in Config/config.php and the entry in `$fillable`. Without - * this, SpreadHydrate would rewrite the input name to - * `spread_payload` and the form column would silently desync. + * @var list */ - protected static $spreadableSavingKey = 'cont'; - protected $fillable = [ - 'name', 'published', - 'cont', + 'site', + 'social', + 'contact', + 'seo', + 'smtp', + 'analytics', + 'locale', ]; + + /** + * @var array + */ + protected $casts = [ + 'site' => 'array', + 'social' => 'array', + 'contact' => 'array', + 'seo' => 'array', + 'smtp' => 'array', + 'analytics' => 'array', + 'locale' => 'array', + 'published' => 'boolean', + ]; + + /** + * @return list + */ + public static function settingsSections(): array + { + return ['site', 'social', 'contact', 'seo', 'smtp', 'analytics', 'locale']; + } } diff --git a/modules/SystemSetting/Providers/SystemSettingServiceProvider.php b/modules/SystemSetting/Providers/SystemSettingServiceProvider.php new file mode 100644 index 000000000..5e3d4a349 --- /dev/null +++ b/modules/SystemSetting/Providers/SystemSettingServiceProvider.php @@ -0,0 +1,76 @@ +app->singleton(SystemSettingsService::class); + $this->app->alias(SystemSettingsService::class, 'system.settings'); + + $this->app->singleton(AnalyticsScripts::class); + $this->app->singleton(ApplySmtpMailConfig::class); + $this->app->singleton(SystemSettingsInputMerger::class); + $this->app->singleton(MigrateRobotsTxtFromSiteSetting::class); + } + + public function boot(): void + { + $this->registerInputAliases(); + $this->mergeRouteInputs(); + + $this->app->make(ApplySmtpMailConfig::class)->apply(); + try { + } catch (\Throwable) { + // Mail override is best-effort when settings / DB are unavailable. + } + + if ($this->app->runningInConsole()) { + return; + } + + try { + $this->app->make(MigrateRobotsTxtFromSiteSetting::class)->migrateIfNeeded(); + } catch (\Throwable) { + // Migration is best-effort when CMS tables are absent. + } + } + + protected function mergeRouteInputs(): void + { + $module = Modularous::find('SystemSetting'); + + if ($module === null) { + return; + } + + $defaultInputs = (array) $module->getConfig('routes.general.inputs', []); + $merged = $this->app->make(SystemSettingsInputMerger::class)->merge($defaultInputs); + + $module->setConfig($merged, 'routes.general.inputs'); + } + + protected function registerInputAliases(): void + { + $aliases = (array) modularousConfig('system_settings.input_aliases', []); + + foreach ($aliases as $alias => $definition) { + if (! is_string($alias) || ! is_array($definition)) { + continue; + } + + config(["modularous.input_types.{$alias}" => $definition]); + } + } +} diff --git a/modules/SystemSetting/Repositories/GeneralRepository.php b/modules/SystemSetting/Repositories/GeneralRepository.php index a070964b9..aca9c5186 100644 --- a/modules/SystemSetting/Repositories/GeneralRepository.php +++ b/modules/SystemSetting/Repositories/GeneralRepository.php @@ -1,17 +1,97 @@ model = $model; } + + public function prepareFieldsBeforeCreate($fields) + { + $fields = $this->encryptSmtpPassword($fields); + + return parent::prepareFieldsBeforeCreate($fields); + } + + /** + * @param Model $object + * @param array $fields + * @return array + */ + public function prepareFieldsBeforeSave($object, $fields) + { + $fields = $this->encryptSmtpPassword($fields, $object); + + return parent::prepareFieldsBeforeSave($object, $fields); + } + + /** + * @param Model $object + * @param array $fields + */ + public function afterSave($object, $fields): void + { + parent::afterSave($object, $fields); + + if (app()->bound('system.settings')) { + app('system.settings')->forgetCache(); + } + + if (app()->bound('site.settings')) { + app('site.settings')->forgetCache(); + } + } + + /** + * @param array $fields + * @return array + */ + protected function encryptSmtpPassword(array $fields, mixed $object = null): array + { + $password = Arr::get($fields, 'smtp.password'); + + if ($password === null) { + return $fields; + } + + if ($password === '') { + if ($object !== null) { + $existing = is_array($object->smtp ?? null) ? $object->smtp : []; + Arr::set($fields, 'smtp.password', $existing['password'] ?? null); + } + + return $fields; + } + + if (is_string($password) && ! $this->alreadyEncrypted($password)) { + Arr::set($fields, 'smtp.password', encrypt($password, false)); + } + + return $fields; + } + + protected function alreadyEncrypted(string $value): bool + { + try { + decrypt($value, false); + + return true; + } catch (\Throwable) { + return false; + } + } } diff --git a/modules/SystemSetting/Services/SystemSettingsService.php b/modules/SystemSetting/Services/SystemSettingsService.php new file mode 100644 index 000000000..118bbde21 --- /dev/null +++ b/modules/SystemSetting/Services/SystemSettingsService.php @@ -0,0 +1,47 @@ +generalRepository; + } + + protected function settingsSections(): array + { + return General::settingsSections(); + } +} diff --git a/modules/SystemSetting/Support/AnalyticsScripts.php b/modules/SystemSetting/Support/AnalyticsScripts.php new file mode 100644 index 000000000..7a93c3cf3 --- /dev/null +++ b/modules/SystemSetting/Support/AnalyticsScripts.php @@ -0,0 +1,132 @@ +settings->get('analytics.enabled'); + + if ($db === null || $db === '') { + return (bool) modularousConfig( + 'system_settings.analytics_enabled_default', + env('MODULAROUS_ANALYTICS_ENABLED', false) + ); + } + + return filter_var($db, FILTER_VALIDATE_BOOLEAN); + } + + public function gtmId(): ?string + { + return $this->normalizeId( + $this->settings->get('analytics.gtm_id'), + '/^GTM-[A-Z0-9]+$/i' + ); + } + + public function gaMeasurementId(): ?string + { + return $this->normalizeId( + $this->settings->get('analytics.ga_measurement_id'), + '/^G-[A-Z0-9]+$/i' + ); + } + + /** + * GTM bootstrap + optional gtag.js for GA4 (only when measurement ID is set). + */ + public function headHtml(): string + { + if (! $this->isEnabled()) { + return ''; + } + + $parts = []; + + $gtmId = $this->gtmId(); + if ($gtmId !== null) { + $escaped = e($gtmId); + $parts[] = << + + +HTML; + } + + $gaId = $this->gaMeasurementId(); + if ($gaId !== null) { + $escaped = e($gaId); + $parts[] = << + + +HTML; + } + + return implode("\n", $parts); + } + + /** + * GTM noscript iframe for immediately after the opening body tag. + */ + public function bodyOpenHtml(): string + { + if (! $this->isEnabled()) { + return ''; + } + + $gtmId = $this->gtmId(); + if ($gtmId === null) { + return ''; + } + + $escaped = e($gtmId); + + return << + + +HTML; + } + + protected function normalizeId(mixed $value, string $pattern): ?string + { + if (! is_string($value) && ! is_numeric($value)) { + return null; + } + + $id = trim((string) $value); + if ($id === '' || ! preg_match($pattern, $id)) { + return null; + } + + return strtoupper($id); + } +} diff --git a/modules/SystemSetting/Support/ApplySmtpMailConfig.php b/modules/SystemSetting/Support/ApplySmtpMailConfig.php new file mode 100644 index 000000000..78b3b92d6 --- /dev/null +++ b/modules/SystemSetting/Support/ApplySmtpMailConfig.php @@ -0,0 +1,80 @@ + 'smtp', + 'host' => $host, + 'port' => is_numeric($port) ? (int) $port : null, + 'username' => is_string($username) && $username !== '' ? $username : null, + 'password' => is_string($password) && $password !== '' ? $password : null, + // 'scheme' => $this->mapEncryptionToScheme($encryption), + ], static fn ($value) => $value !== null); + + config([ + 'mail.default' => 'smtp', + 'mail.mailers.smtp' => array_merge( + (array) config('mail.mailers.smtp', []), + $mailerConfig, + ), + ]); + + if (is_string($fromAddress) && $fromAddress !== '') { + config(['mail.from.address' => $fromAddress]); + } + + if (is_string($fromName) && $fromName !== '') { + config(['mail.from.name' => $fromName]); + } + } + + /** + * Map admin encryption values to Laravel SMTP mailer schemes. + * + * Laravel only accepts "smtp" and "smtps" as schemes. + */ + private function mapEncryptionToScheme(mixed $encryption): ?string + { + if (! is_string($encryption) || $encryption === '') { + return null; + } + + return match (strtolower($encryption)) { + 'ssl', 'smtps' => 'smtps', + 'tls', 'smtp' => 'smtp', + default => 'smtp', + }; + } +} diff --git a/modules/SystemSetting/Support/MigrateRobotsTxtFromSiteSetting.php b/modules/SystemSetting/Support/MigrateRobotsTxtFromSiteSetting.php new file mode 100644 index 000000000..4d6dec839 --- /dev/null +++ b/modules/SystemSetting/Support/MigrateRobotsTxtFromSiteSetting.php @@ -0,0 +1,79 @@ +legacyRobotsKeys(); + + $row = DB::table($legacyTable) + ->where('group_key', $group) + ->where('key', $key) + ->where('locale', $locale) + ->whereNull('deleted_at') + ->first(); + + if ($row === null || trim((string) ($row->value ?? '')) === '') { + return false; + } + + $general = General::single(); + $seo = is_array($general->seo) ? $general->seo : []; + $seo['robots_txt'] = rtrim((string) $row->value, "\r\n"); + + $this->generalRepository->update($general->id, [ + 'seo' => $seo, + ]); + + $this->systemSettings->forgetCache(); + + return true; + } + + /** + * @return array{0: string, 1: string, 2: string} + */ + protected function legacyRobotsKeys(): array + { + $cfg = (array) modularousConfig('cms_seo.robots.legacy_site_setting', []); + + return [ + (string) ($cfg['group_key'] ?? 'seo'), + (string) ($cfg['key'] ?? 'global_robots_txt'), + (string) ($cfg['locale'] ?? '*'), + ]; + } +} diff --git a/modules/SystemSetting/Support/SystemSettingsInputMerger.php b/modules/SystemSetting/Support/SystemSettingsInputMerger.php new file mode 100644 index 000000000..abe3c145f --- /dev/null +++ b/modules/SystemSetting/Support/SystemSettingsInputMerger.php @@ -0,0 +1,95 @@ +|string> $defaultInputs + * @return array> + */ + public function merge(array $defaultInputs): array + { + $config = (array) modularousConfig('system_settings', []); + $inputs = $this->expandAliasesInList($defaultInputs); + $inputs = $this->applyOverrides($inputs, (array) ($config['input_overrides'] ?? [])); + $inputs = array_merge($inputs, $this->expandAliasesInList((array) ($config['extra_inputs'] ?? []))); + + $hidden = array_values(array_filter((array) ($config['hidden_inputs'] ?? []))); + + if ($hidden === []) { + return $inputs; + } + + return array_values(array_filter( + $inputs, + fn (array $input) => ! in_array((string) ($input['name'] ?? ''), $hidden, true) + )); + } + + /** + * @param array> $inputs + * @param array> $overrides + * @return array> + */ + protected function applyOverrides(array $inputs, array $overrides): array + { + if ($overrides === []) { + return $inputs; + } + + return array_map(function (array $input) use ($overrides): array { + $name = (string) ($input['name'] ?? ''); + + if ($name === '' || ! isset($overrides[$name])) { + return $input; + } + + return array_replace_recursive($input, $overrides[$name]); + }, $inputs); + } + + /** + * @param array|string> $inputs + * @return array> + */ + protected function expandAliasesInList(array $inputs): array + { + $resolved = []; + + foreach ($inputs as $input) { + if (is_string($input)) { + $definition = modularousConfig('input_types.' . $input); + if (is_array($definition)) { + $resolved[] = $definition; + + continue; + } + + $hydrated = function_exists('hydrate_input_type') + ? hydrate_input_type(['type' => $input]) + : null; + if (is_array($hydrated)) { + $resolved[] = $hydrated; + } + + continue; + } + + if (is_array($input)) { + if (isset($input['type']) && is_string($input['type']) && str_starts_with($input['type'], '@')) { + $input = function_exists('hydrate_input_type') ? hydrate_input_type($input) : $input; + } + + $resolved[] = $input; + } + } + + return $resolved; + } +} diff --git a/modules/SystemSetting/Transformers/GeneralResource.php b/modules/SystemSetting/Transformers/GeneralResource.php index e451b44b6..9569b6950 100644 --- a/modules/SystemSetting/Transformers/GeneralResource.php +++ b/modules/SystemSetting/Transformers/GeneralResource.php @@ -1,16 +1,33 @@ */ public function toArray($request): array { - return parent::toArray($request); + $data = parent::toArray($request); + + if (isset($data['smtp']) && is_array($data['smtp']) && array_key_exists('password', $data['smtp'])) { + $password = $data['smtp']['password']; + $data['smtp']['password'] = ($password !== null && $password !== '') ? '********' : null; + } + + foreach ((array) modularousConfig('system_settings.sensitive_keys', ['smtp.password']) as $dotKey) { + if (Arr::has($data, $dotKey)) { + $value = Arr::get($data, $dotKey); + Arr::set($data, $dotKey, ($value !== null && $value !== '') ? '********' : null); + } + } + + return $data; } } diff --git a/modules/SystemSetting/module.json b/modules/SystemSetting/module.json index 2a14a347b..9fa86d6fa 100644 --- a/modules/SystemSetting/module.json +++ b/modules/SystemSetting/module.json @@ -5,7 +5,7 @@ "keywords": [], "priority": 0, "providers": [ - + "Modules\\SystemSetting\\Providers\\SystemSettingServiceProvider" ], "files": [] } diff --git a/modules/SystemUser/Http/Requests/CompanyRequest.php b/modules/SystemUser/Http/Requests/CompanyRequest.php index 91a782341..d2fb71b0c 100755 --- a/modules/SystemUser/Http/Requests/CompanyRequest.php +++ b/modules/SystemUser/Http/Requests/CompanyRequest.php @@ -45,6 +45,22 @@ public function rulesForUpdate() ]; } + public function rulesForDelete() + { + return [ + 'id' => [ + 'required', + function ($attribute, $value, $fail) { + $company = $this->model()->find($value); + + if ($company && $company->users()->exists()) { + $fail(__('This company has registered users and cannot be deleted.')); + } + }, + ], + ]; + } + public function messages() { return [ diff --git a/resources/views/partials/favicons.blade.php b/resources/views/partials/favicons.blade.php index f9e79ebfe..c918891e3 100644 --- a/resources/views/partials/favicons.blade.php +++ b/resources/views/partials/favicons.blade.php @@ -4,14 +4,24 @@ $faviconVersion = fn (string $path) => file_exists(public_path($path)) ? asset($path) . '?v=' . filemtime(public_path($path)) : asset($path); + + // Panel/admin always uses SystemSettings (not SiteSettings / CmsSettings). + $dbFavicon = SystemSettings::value('site.favicon.original'); @endphp - - - - -@if (file_exists(public_path('favicon.svg'))) - +@if (! empty($dbFavicon)) + + + + +@else + + + + + @if (file_exists(public_path('favicon.svg'))) + + @endif + + @endif - - diff --git a/src/Entities/Media.php b/src/Entities/Media.php index c01f4231c..1477175f5 100755 --- a/src/Entities/Media.php +++ b/src/Entities/Media.php @@ -86,6 +86,7 @@ public function mediableFormat() 'name' => $this->filename, 'thumbnail' => ImageService::getCmsUrl($this->uuid, ['h' => '256']), 'original' => ImageService::getRawUrl($this->uuid), + 'frontend' => ImageService::getFrontendUrl($this->uuid), 'medium' => ImageService::getUrl($this->uuid, ['h' => '430']), 'width' => $this->width, 'height' => $this->height, diff --git a/src/Entities/Traits/Core/ModelHelpers.php b/src/Entities/Traits/Core/ModelHelpers.php index a07ce2183..777b735c2 100755 --- a/src/Entities/Traits/Core/ModelHelpers.php +++ b/src/Entities/Traits/Core/ModelHelpers.php @@ -96,9 +96,13 @@ public static function bootModelHelpers() // dd($attributesToBeLogged); if ($batchActivityModel) { + $batchActivityProperties = $batchActivityModel->properties; + if($batchActivityProperties instanceof \Illuminate\Support\Collection) { + $batchActivityProperties = $batchActivityProperties->toArray(); + } $batchActivityModel->update( [ - 'properties' => array_merge_recursive_preserve($batchActivityModel->properties, $properties), + 'properties' => array_merge_recursive_preserve($batchActivityProperties, $properties), ] ); } else { diff --git a/src/Facades/CmsSettings.php b/src/Facades/CmsSettings.php new file mode 100644 index 000000000..0f8f59b80 --- /dev/null +++ b/src/Facades/CmsSettings.php @@ -0,0 +1,29 @@ +getFormRequestClass(); + $params = $this->request->route()->parameters(); $id = last($params); diff --git a/src/Http/Requests/Request.php b/src/Http/Requests/Request.php index f60b80cb3..944f4c28a 100755 --- a/src/Http/Requests/Request.php +++ b/src/Http/Requests/Request.php @@ -3,11 +3,14 @@ namespace Unusualify\Modularous\Http\Requests; use Closure; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Str; use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; +use Unusualify\Modularity\Services\MessageStage; use Unusualify\Modularous\Traits\ManageTraits; abstract class Request extends FormRequest @@ -45,12 +48,48 @@ public function rules() case 'PUT': return $this->mergeRules(array_merge($this->rulesForAll(), $this->rulesForUpdate())); + case 'DELETE': + return $this->rulesForDelete(); + default:break; } return []; } + public function rulesForDelete() + { + return []; + } + + protected function prepareForValidation() + { + if ($this->method() === 'DELETE' && $this->route()) { + $parameters = $this->route()->parameters(); + + if (! empty($parameters) && ! $this->has('id')) { + $this->merge(['id' => last($parameters)]); + } + } + } + + + protected function failedValidation(Validator $validator) + { + if ($this->method() === 'DELETE') { + $response = response()->json([ + 'message' => $validator->errors()->first(), + 'errors' => $validator->errors(), + 'variant' => MessageStage::ERROR->value, + ], 422); + + throw new ValidationException($validator, $response); + } + + parent::failedValidation($validator); + } + + public function mergeRules($rules) { $locales = getLocales(); diff --git a/src/Modularous.php b/src/Modularous.php index 65ca03570..281a40f1a 100755 --- a/src/Modularous.php +++ b/src/Modularous.php @@ -84,8 +84,6 @@ public function __construct(Container $app, $path = null) if (isset($versions['unusualify/modularous'])) { $this->vendorPath = realpath($versions['unusualify/modularous']['install_path']); - } elseif (isset($versions['unusualify/modularous'])) { - $this->vendorPath = realpath($versions['unusualify/modularous']['install_path']); } else { throw new \Exception('Modularous or Modularous not found in composer.json'); } @@ -541,10 +539,6 @@ public function getTranslations() $cache = Cache::store('file'); - if ($cache->has($cache_key) && false) { - return $cache->get($cache_key); - } - $translations = app('translator')->getTranslations(); $cache->set($cache_key, json_encode($translations), 600); diff --git a/src/Repositories/Logic/MethodTransformers.php b/src/Repositories/Logic/MethodTransformers.php index d6dd4ed9a..5a8b7f708 100644 --- a/src/Repositories/Logic/MethodTransformers.php +++ b/src/Repositories/Logic/MethodTransformers.php @@ -307,8 +307,9 @@ public function getFormFields($object, $schema = [], $noSerialization = false) case 'text': case 'textarea': case 'input-date': + case 'input-phone': $found = true; - $acc[$item['name']] = $object->{$item['name']} ?? null; + $acc[$item['name']] = data_get($object, $item['name'], null) ?? null; break; default: diff --git a/src/Repositories/Traits/Concerns/InteractsWithAttachmentPayloads.php b/src/Repositories/Traits/Concerns/InteractsWithAttachmentPayloads.php index 75f2ecb74..388d55c74 100644 --- a/src/Repositories/Traits/Concerns/InteractsWithAttachmentPayloads.php +++ b/src/Repositories/Traits/Concerns/InteractsWithAttachmentPayloads.php @@ -79,6 +79,10 @@ protected function attachmentRoleIsPresentInFields(array $fields, string $role): return true; } + if(data_get($fields, $role) !== null) { + return true; + } + foreach (getLocales() as $locale) { if (array_key_exists($role, $fields[$locale] ?? [])) { return true; @@ -97,6 +101,10 @@ protected function getAttachmentPayloadForRole(array $fields, string $role): mix return $fields[$role]; } + if(($notation = data_get($fields, $role)) !== null) { + return $notation; + } + $nested = []; foreach (getLocales() as $locale) { if (array_key_exists($role, $fields[$locale] ?? [])) { diff --git a/src/Repositories/Traits/FilesTrait.php b/src/Repositories/Traits/FilesTrait.php index 6c4b604a3..a835a9f0b 100755 --- a/src/Repositories/Traits/FilesTrait.php +++ b/src/Repositories/Traits/FilesTrait.php @@ -101,17 +101,19 @@ public function afterSaveFilesTrait($object, $fields) */ private function detachFilesForRoleLocale($object, string $role, string $locale): void { - $relatedKey = $object->files()->getRelated()->getQualifiedKeyName(); - $ids = $object->files() - ->wherePivot('role', $role) - ->wherePivot('locale', $locale) - ->pluck($relatedKey); - - if ($ids->isEmpty()) { + $relation = $object->files(); + $deleted = $relation->newPivotStatement() + ->where($relation->getMorphType(), $object->getMorphClass()) + ->where($relation->getForeignPivotKeyName(), $object->getKey()) + ->where('role', $role) + ->where('locale', $locale) + ->delete(); + + if ($deleted === 0) { return; } - $object->files()->detach($ids->all()); + $object->unsetRelation('files'); $this->mustTouchEloquentModel(); } diff --git a/src/Repositories/Traits/ImagesTrait.php b/src/Repositories/Traits/ImagesTrait.php index be744b086..edb91287c 100755 --- a/src/Repositories/Traits/ImagesTrait.php +++ b/src/Repositories/Traits/ImagesTrait.php @@ -6,9 +6,11 @@ use Illuminate\Support\Collection; use Unusualify\Modularous\Entities\Media; use Unusualify\Modularous\Entities\Model; +use Unusualify\Modularous\Repositories\Traits\Concerns\InteractsWithAttachmentPayloads; trait ImagesTrait { + use InteractsWithAttachmentPayloads; /** * When true, {@see RevisionsTrait::bypassAfterSaves} may set `passAfterSaveImagesTrait` during pending-only * revision saves so {@see afterSaveImagesTrait} is skipped. @@ -199,20 +201,21 @@ public function afterSaveImagesTrait($object, $fields) */ private function detachMediasForRoleLocale($object, string $role, string $locale): void { - $relatedKey = $object->medias()->getRelated()->getQualifiedKeyName(); - $relation = $object->medias()->wherePivot('role', $role); + $relation = $object->medias(); + $query = $relation->newPivotStatement() + ->where($relation->getMorphType(), $object->getMorphClass()) + ->where($relation->getForeignPivotKeyName(), $object->getKey()) + ->where('role', $role); if (modularousConfig('media_library.translated_form_fields', false)) { - $relation->wherePivot('locale', $locale); + $query->where('locale', $locale); } - $ids = $relation->pluck($relatedKey); - - if ($ids->isEmpty()) { + if ($query->delete() === 0) { return; } - $object->medias()->detach($ids->all()); + $object->unsetRelation('medias'); $this->mustTouchEloquentModel(); } @@ -242,9 +245,10 @@ private function attachImageSpecsFromRows($object, array $rows, string $role, st public function getFormFieldsImagesTrait($object, $fields, $schema) { $imageInputs = $this->getColumns(__TRAIT__); + if (! empty($imageInputs) && $object->has('medias')) { $schema = $schema ?? $this->inputs(); - $schema = $this->chunkInputs($schema, all: true, noGroupChunk: true); + $schema = $this->chunkInputs($schema, all: true, noGroupChunk: false); $mediasByRole = $object->medias->groupBy('pivot.role'); $default_locale = config('app.locale'); $fallback_locale = config('app.fallback_locale'); diff --git a/src/Services/MediaLibrary/Local.php b/src/Services/MediaLibrary/Local.php index 53379a3cb..909ffe244 100755 --- a/src/Services/MediaLibrary/Local.php +++ b/src/Services/MediaLibrary/Local.php @@ -64,6 +64,21 @@ public function getCmsUrl($id, array $params = []) return $this->getRawUrl($id); } + /** + * @param string $id + * @param array $params + * @return string + */ + public function getFrontendUrl($id, array $params = []) + { + /** @var FilesystemAdapter $disk */ + $disk = Storage::disk(config(modularousBaseKey().'.media_library.disk')); + $storageUrl = $disk->url($id); + $endpoint = parse_url($storageUrl, PHP_URL_PATH) ?: $storageUrl; + + return rtrim(modularousConfig('app_url'), '/') . '/' . ltrim($endpoint, '/'); + } + /** * @param string $id * @return string diff --git a/src/Services/Settings/AbstractSingularSettingsService.php b/src/Services/Settings/AbstractSingularSettingsService.php new file mode 100644 index 000000000..c62c7c8ca --- /dev/null +++ b/src/Services/Settings/AbstractSingularSettingsService.php @@ -0,0 +1,415 @@ +|null */ + private ?array $requestSnapshot = null; + + abstract protected function cacheKey(): string; + + abstract protected function cacheTtl(): int; + + /** + * @return class-string + */ + abstract protected function modelClass(): string; + + abstract protected function repository(): Repository; + + /** + * @return list + */ + abstract protected function settingsSections(): array; + + /** + * Get a nested setting by dot path, resolving locale maps along the way. + * + * When the direct path is empty and the key has at least two segments, expands through + * locale (+ optional first list index) using the last segment as an arbitrary leaf: + * `site.logo.frontend` → `site.logo.{locale}.frontend` + * → `site.logo.{locale}.0.frontend` + * → `site.logo.{locale}.frontend.0` + */ + public function get(string $key, mixed $default = null, ?string $locale = null): mixed + { + $resolvedLocale = $locale ?? app()->getLocale(); + $snapshot = $this->snapshot(); + $segments = $key === '' ? [] : explode('.', $key); + + $value = $this->walkPath($snapshot, $key, $resolvedLocale); + + if (! $this->isEmptySettingValue($value) && ! $this->isExpandableListResult($value, $segments)) { + return $value; + } + + if (count($segments) >= 2) { + $leaf = (string) $segments[array_key_last($segments)]; + $parentKey = implode('.', array_slice($segments, 0, -1)); + $expanded = $this->resolveLeafViaLocalePaths($snapshot, $parentKey, $leaf, $resolvedLocale); + + if (! $this->isEmptySettingValue($expanded)) { + return $expanded; + } + } + + if (! $this->isEmptySettingValue($value)) { + return $value; + } + + return $default; + } + + /** + * Alias of {@see get()}. + */ + public function value(string $key, mixed $default = null, ?string $locale = null): mixed + { + return $this->get($key, $default, $locale); + } + + /** + * Get a setting; when the resolved value is a list, return its first element. + */ + public function first(string $key, mixed $default = null, ?string $locale = null): mixed + { + $resolved = $this->get($key, null, $locale); + + if ($resolved === null) { + return $default; + } + + if (is_array($resolved) && Arr::isList($resolved)) { + return $resolved[0] ?? $default; + } + + return $resolved; + } + + /** + * @return array + */ + public function all(?string $locale = null): array + { + $snapshot = $this->snapshot(); + $resolvedLocale = $locale ?? app()->getLocale(); + + return $this->resolveTranslatedTree($snapshot, $resolvedLocale); + } + + public function has(string $key): bool + { + return Arr::has($this->snapshot(), $key); + } + + /** + * True when the resolved value for {@see $key} is non-empty (after locale/leaf expansion). + */ + public function filled(string $key, ?string $locale = null): bool + { + return ! $this->isEmptySettingValue($this->get($key, null, $locale)); + } + + public function set(string $key, mixed $value): void + { + $modelClass = $this->modelClass(); + $model = $modelClass::single(); + $fields = $this->snapshotFromModel($model); + Arr::set($fields, $key, $value); + + $this->repository()->update($model->id, $fields); + $this->forgetCache(); + } + + public function forgetCache(): void + { + $this->requestSnapshot = null; + Cache::forget($this->cacheKey()); + ModularousCache::forget($this->cacheKey()); + } + + /** + * @return array + */ + public function snapshot(): array + { + if ($this->requestSnapshot !== null) { + return $this->requestSnapshot; + } + + $ttl = $this->cacheTtl(); + + $this->requestSnapshot = Cache::remember($this->cacheKey(), $ttl, function (): array { + return $this->buildSnapshotFromDatabase(); + }); + + return $this->requestSnapshot; + } + + /** + * @return array + */ + protected function buildSnapshotFromDatabase(): array + { + $modelClass = $this->modelClass(); + + if (! class_exists($modelClass)) { + return $this->defaultSnapshot(); + } + + try { + $model = $modelClass::single(); + } catch (\Throwable) { + return $this->defaultSnapshot(); + } + + return $this->snapshotFromModel($model); + } + + /** + * @param TModel $model + * @return array + */ + protected function snapshotFromModel(object $model): array + { + $snapshot = $this->defaultSnapshot(); + + foreach ($this->settingsSections() as $section) { + $value = $model->getAttribute($section); + if ($value !== null) { + $snapshot[$section] = is_array($value) ? $value : []; + } + } + + if (isset($snapshot['smtp']['password']) && is_string($snapshot['smtp']['password']) && $snapshot['smtp']['password'] !== '') { + $snapshot['smtp']['password'] = $this->decryptSecret($snapshot['smtp']['password']); + } + + return $snapshot; + } + + /** + * @return array + */ + protected function defaultSnapshot(): array + { + $snapshot = []; + + foreach ($this->settingsSections() as $section) { + $snapshot[$section] = []; + } + + return $snapshot; + } + + protected function decryptSecret(string $value): string + { + try { + return decrypt($value, false); + } catch (DecryptException) { + return $value; + } + } + + /** + * @param array $data + */ + protected function walkPath(array $data, string $key, string $locale): mixed + { + if ($key === '') { + return $this->resolveTranslatedValue($data, $locale); + } + + $cursor = $data; + + foreach (explode('.', $key) as $segment) { + if (is_array($cursor) && $this->looksLikeLocaleMap($cursor)) { + $cursor = $this->resolveTranslatedValue($cursor, $locale); + } + + if (! is_array($cursor) || ! array_key_exists($segment, $cursor)) { + return null; + } + + $cursor = $cursor[$segment]; + } + + return $this->resolveTranslatedValue($cursor, $locale); + } + + /** + * @param array $data + */ + protected function resolveLeafViaLocalePaths(array $data, string $parentKey, string $leaf, string $locale): mixed + { + $parent = $parentKey === '' + ? $data + : $this->walkPathRaw($data, $parentKey); + + if (! is_array($parent)) { + return null; + } + + $fallback = (string) modularousConfig('fallback_locale', config('app.fallback_locale', 'en')); + $locales = []; + + foreach ([$locale, $fallback, '*'] as $candidate) { + if ($candidate === '' || in_array($candidate, $locales, true)) { + continue; + } + + $locales[] = $candidate; + } + + foreach ($locales as $tryLocale) { + if (! array_key_exists($tryLocale, $parent)) { + continue; + } + + $listDeferred = null; + + foreach ([ + "{$tryLocale}.{$leaf}", + "{$tryLocale}.0.{$leaf}", + "{$tryLocale}.{$leaf}.0", + ] as $relativePath) { + $candidate = data_get($parent, $relativePath); + + if ($this->isEmptySettingValue($candidate)) { + continue; + } + + if (is_array($candidate) && Arr::isList($candidate)) { + $listDeferred ??= $candidate; + + continue; + } + + return $candidate; + } + + if ($listDeferred !== null) { + return $listDeferred; + } + } + + return null; + } + + /** + * @param array $data + */ + protected function walkPathRaw(array $data, string $key): mixed + { + if ($key === '') { + return $data; + } + + $cursor = $data; + + foreach (explode('.', $key) as $segment) { + if (! is_array($cursor) || ! array_key_exists($segment, $cursor)) { + return null; + } + + $cursor = $cursor[$segment]; + } + + return $cursor; + } + + /** + * @param list $segments + */ + protected function isExpandableListResult(mixed $value, array $segments): bool + { + return count($segments) >= 2 + && is_array($value) + && Arr::isList($value); + } + + protected function isEmptySettingValue(mixed $value): bool + { + return $value === null || $value === '' || $value === []; + } + + protected function resolveTranslatedValue(mixed $value, string $locale): mixed + { + if (! is_array($value)) { + return $value; + } + + if ($this->looksLikeLocaleMap($value)) { + $fallback = (string) modularousConfig('fallback_locale', config('app.fallback_locale', 'en')); + + return $value[$locale] + ?? $value[$fallback] + ?? $value['*'] + ?? reset($value); + } + + return $value; + } + + /** + * @param array $tree + * @return array + */ + protected function resolveTranslatedTree(array $tree, string $locale): array + { + $resolved = []; + + foreach ($tree as $key => $value) { + if (is_array($value) && ! $this->looksLikeLocaleMap($value) && Arr::isAssoc($value)) { + $resolved[$key] = $this->resolveTranslatedTree($value, $locale); + + continue; + } + + $resolved[$key] = $this->resolveTranslatedValue($value, $locale); + } + + return $resolved; + } + + /** + * @param array $value + */ + protected function looksLikeLocaleMap(array $value): bool + { + if ($value === [] || Arr::isList($value)) { + return false; + } + + foreach (array_keys($value) as $key) { + if (! is_string($key) || ! $this->looksLikeLocaleKey($key)) { + return false; + } + } + + return true; + } + + protected function looksLikeLocaleKey(string $key): bool + { + if ($key === '*') { + return true; + } + + return (bool) preg_match('/^[a-z]{2}([_-][A-Za-z]{2,8})?$/', $key); + } +} diff --git a/src/Traits/ManageTraits.php b/src/Traits/ManageTraits.php index 461638d13..24448cea5 100755 --- a/src/Traits/ManageTraits.php +++ b/src/Traits/ManageTraits.php @@ -37,6 +37,12 @@ public function hasTranslatedInput($schema = []) $hasTranslated = true; break; + } elseif (isset($input['schema']) && is_array($input['schema'])) { + $hasTranslated = $this->hasTranslatedInput($input['schema']); + + if ($hasTranslated) { + break; + } } } diff --git a/test-modules/TestModule/Console/TestModuleCommand.php b/test-modules/TestModule/Console/TestModuleCommand.php new file mode 100644 index 000000000..ab0f0d16a --- /dev/null +++ b/test-modules/TestModule/Console/TestModuleCommand.php @@ -0,0 +1,17 @@ + 'test-image.jpg', 'thumbnail' => ImageService::getCmsUrl('test-uuid-1', ['h' => '256']), 'original' => ImageService::getRawUrl('test-uuid-1'), + 'frontend' => ImageService::getFrontendUrl('test-uuid-1'), 'medium' => ImageService::getUrl('test-uuid-1', ['h' => '430']), 'width' => 1920, 'height' => 1080, diff --git a/tests/Models/Traits/Core/ModelHelpersTest.php b/tests/Models/Traits/Core/ModelHelpersTest.php index 30c2b6329..738070582 100644 --- a/tests/Models/Traits/Core/ModelHelpersTest.php +++ b/tests/Models/Traits/Core/ModelHelpersTest.php @@ -501,8 +501,6 @@ public function test_activity_log_options_with_json_attributes() public function test_translatable_model_updates_existing_batch_activity() { - $this->expectException(\TypeError::class); - $this->app['config']->set('activitylog.enabled', true); Auth::login($this->user); @@ -531,7 +529,7 @@ public function test_translatable_model_updates_existing_batch_activity() $batchUuid = LogBatch::getUuid(); $this->assertNotNull($batchUuid); - Activity::create([ + $batchActivity = Activity::create([ 'log_name' => 'default', 'description' => 'created', 'subject_type' => TranslatableModel::class, @@ -545,9 +543,30 @@ public function test_translatable_model_updates_existing_batch_activity() ], ]); + $activitiesBefore = Activity::forBatch($batchUuid) + ->where('subject_type', TranslatableModel::class) + ->where('subject_id', $model->id) + ->count(); + $model->translate('en')->name = 'Updated Name'; $model->translate('en')->description = 'Updated Description'; - $model->save(); // TypeError thrown here + $model->save(); + + $activitiesAfter = Activity::forBatch($batchUuid) + ->where('subject_type', TranslatableModel::class) + ->where('subject_id', $model->id) + ->count(); + + $this->assertEquals($activitiesBefore, $activitiesAfter); + + $batchActivity->refresh(); + + $this->assertEquals('Updated Name', $batchActivity->properties['attributes']['en']['name']); + $this->assertEquals('Updated Description', $batchActivity->properties['attributes']['en']['description']); + $this->assertEquals('Original Name', $batchActivity->properties['old']['en']['name']); + $this->assertEquals('Original Description', $batchActivity->properties['old']['en']['description']); + + LogBatch::endBatch(); } public function test_translatable_model_creates_new_activity_when_no_batch_activity_exists() diff --git a/tests/ModularousTest.php b/tests/ModularousTest.php index 62c7a99c9..17747ba2c 100644 --- a/tests/ModularousTest.php +++ b/tests/ModularousTest.php @@ -3,6 +3,12 @@ namespace Unusualify\Modularous\Tests; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; +use Mockery; +use Nwidart\Modules\Contracts\ActivatorInterface; +use TestModules\TestModule\Entities\Item as TestModuleItem; +use Unusualify\Modularous\Activators\ModularousActivator; +use Unusualify\Modularous\Contracts\CurrencyProviderInterface; use Unusualify\Modularous\Exceptions\ModularousSystemPathException; use Unusualify\Modularous\Modularous; @@ -36,6 +42,9 @@ public function test_format_cached_on_cache_enabled() { $app = app(); $app['config']->set('modules.cache.enabled', true); + $app['config']->set('modules.cache.driver', 'array'); + $app['config']->set('modules.cache.key', 'modularous-test-format-cache'); + $app['config']->set('modules.cache.lifetime', 600); $path = $app['config']->get('modules.paths.modules'); @@ -47,6 +56,71 @@ public function test_format_cached_on_cache_enabled() $this->assertArrayHasKey('testmodule', $allModules); } + public function test_get_cached_reads_from_cache_store_after_first_population(): void + { + $this->app['config']->set('modules.cache.enabled', true); + $this->app['config']->set('modules.cache.driver', 'array'); + $this->app['config']->set('modules.cache.key', 'modularous-test-get-cached'); + $this->app['config']->set('modules.cache.lifetime', 600); + + $modularous = new Modularous($this->app, $this->app['config']->get('modules.paths.modules')); + $method = new \ReflectionMethod($modularous, 'getCached'); + $method->setAccessible(true); + + $first = $method->invoke($modularous); + $this->assertIsArray($first); + $this->assertTrue($this->app['cache']->store('array')->has('modularous-test-get-cached')); + + $second = $method->invoke($modularous); + $this->assertSame($first, $second); + } + + public function test_format_cached_resets_when_cached_path_is_outside_application(): void + { + $modularous = new Modularous($this->app, $this->app['config']->get('modules.paths.modules')); + $method = new \ReflectionMethod($modularous, 'formatCached'); + $method->setAccessible(true); + + $modules = $method->invoke($modularous, [ + 'ghost' => ['path' => '/tmp/not-under-base-path/module'], + ]); + + $this->assertArrayHasKey('testmodule', $modules); + $this->assertArrayHasKey('systemmodule', $modules); + } + + public function test_format_cached_reuses_valid_cached_module_paths(): void + { + $modularous = new Modularous($this->app, $this->app['config']->get('modules.paths.modules')); + $method = new \ReflectionMethod($modularous, 'formatCached'); + $method->setAccessible(true); + + $testModulePath = $modularous->find('TestModule')->getPath(); + $modules = $method->invoke($modularous, [ + 'testmodule' => ['path' => $testModulePath], + ]); + + $this->assertArrayHasKey('testmodule', $modules); + $this->assertSame($testModulePath, $modules['testmodule']->getPath()); + } + + public function test_all_uses_cached_modules_when_cache_enabled_and_not_in_console(): void + { + $this->app['config']->set('modules.cache.enabled', true); + $this->app['config']->set('modules.cache.driver', 'array'); + $this->app['config']->set('modules.cache.key', 'modularous-test-all-cache'); + $this->app['config']->set('modules.cache.lifetime', 600); + + $app = Mockery::mock($this->app)->makePartial(); + $app->shouldReceive('runningInConsole')->andReturn(false); + + $modularous = new Modularous($app, $this->app['config']->get('modules.paths.modules')); + $modules = $modularous->all(); + + $this->assertArrayHasKey('testmodule', $modules); + $this->assertArrayHasKey('systemmodule', $modules); + } + public function test_has_module() { $this->assertTrue($this->modularous->has('systemmodule')); @@ -66,6 +140,26 @@ public function test_has_module() // $this->assertArrayNotHasKey('testmodule', $activeModules); // } + public function test_get_by_status_returns_only_matching_modules(): void + { + $this->writeModuleActivationStatuses([ + 'TestModule' => false, + 'SystemModule' => true, + ]); + + $activeModules = $this->modularous->getByStatus(true); + $inactiveModules = $this->modularous->getByStatus(false); + + $this->assertArrayHasKey('systemmodule', $activeModules); + $this->assertArrayNotHasKey('testmodule', $activeModules); + $this->assertArrayHasKey('testmodule', $inactiveModules); + } + + public function test_get_auth_guard_name(): void + { + $this->assertSame('modularous', Modularous::getAuthGuardName()); + } + public function test_development_production() { $this->assertFalse($this->modularous->isDevelopment()); @@ -76,6 +170,14 @@ public function test_feature_methods() { $this->assertTrue($this->modularous->shouldUseInertia()); + $this->app['config']->set('modularous.use_collation_for_search', true); + $this->app['config']->set('modularous.include_transaction_fee', true); + $this->app['config']->set('modularous.use_country_based_vat_rates', true); + + $this->assertTrue($this->modularous->shouldUseCollationForSearch()); + $this->assertTrue($this->modularous->shouldIncludeTransactionFee()); + $this->assertTrue($this->modularous->shouldUseCountryBasedVatRates()); + $this->assertEquals(config('app.name'), $this->modularous->pageTitle()); Modularous::createPageTitle(fn () => 'Test Page Title'); $this->assertEquals('Test Page Title', $this->modularous->pageTitle()); @@ -103,6 +205,26 @@ public function test_clear_cache() $this->assertFalse($this->app['cache']->has('test-modules-cache')); } + public function test_clear_cache_flushes_activator_cache_when_supported(): void + { + $activator = new class($this->app) extends ModularousActivator + { + public int $flushCount = 0; + + public function flushCache(): void + { + $this->flushCount++; + } + }; + + $this->app->instance(ActivatorInterface::class, $activator); + + $modularous = new Modularous($this->app, $this->app['config']->get('modules.paths.modules')); + $modularous->clearCache(); + + $this->assertSame(1, $activator->flushCount); + } + public function test_disable_cache() { $this->modularous->disableCache(); @@ -131,22 +253,26 @@ public function test_get_modules_path() public function test_set_and_revert_system_modules_path() { - // Skip if production - if ($this->modularous->isProduction()) { - $this->expectException(ModularousSystemPathException::class); - $this->modularous->setSystemModulesPath(); - } else { - $originalPath = config('modules.paths.modules'); + $this->expectException(ModularousSystemPathException::class); + $this->modularous->setSystemModulesPath(); + } + + public function test_revert_system_modules_path_restores_retained_modules_path(): void + { + $retainedPath = config('modules.paths.modules'); - $this->modularous->setSystemModulesPath(); - $newPath = config('modules.paths.modules'); - $this->assertNotEquals($originalPath, $newPath); - $this->assertStringContainsString('modules', $newPath); + config(['modules.paths.modules' => '/tmp/changed-modules-path']); - $this->modularous->revertSystemModulesPath(); - $revertedPath = config('modules.paths.modules'); - $this->assertEquals($originalPath, $revertedPath); - } + $this->modularous->revertSystemModulesPath(); + + $this->assertSame($retainedPath, config('modules.paths.modules')); + } + + public function test_get_app_url(): void + { + $this->app['config']->set('modularous.app_url', 'http://example.test'); + + $this->assertSame('http://example.test', $this->modularous->getAppUrl()); } public function test_get_app_host() @@ -188,6 +314,25 @@ public function test_is_panel_url_with_admin_path() $this->assertFalse($this->modularous->isPanelUrl('http://localhost/home')); } + public function test_is_panel_url_returns_false_when_request_has_no_segment_and_url_is_provided(): void + { + $this->app['config']->set('modularous.app_url', 'http://localhost'); + $this->app['config']->set('modularous.admin_app_url', ''); + $this->app['config']->set('modularous.admin_app_path', 'admin'); + + $request = Request::create('http://localhost', 'GET'); + $this->app->instance('request', $request); + + $this->assertFalse($this->modularous->isPanelUrl('http://localhost/admin/dashboard')); + } + + public function test_get_admin_url_prefix_returns_false_when_using_admin_subdomain(): void + { + $this->app['config']->set('modularous.admin_app_url', 'http://admin.localhost'); + + $this->assertFalse($this->modularous->getAdminUrlPrefix()); + } + public function test_is_modularous_route() { $this->app['config']->set('modularous.admin_route_name_prefix', 'admin'); @@ -213,30 +358,27 @@ public function test_get_system_route_name_prefix() public function test_get_translations() { - try { - $translations = $this->modularous->getTranslations(); - $this->assertIsArray($translations); - } catch (\UnexpectedValueException $e) { - // Translation directory might not exist in test environment, which is acceptable - $this->assertTrue(true); - } + // Use a real translator binding (instance, not facade mock) so the + // assertion is deterministic without fragile facade expectations that + // break when the full suite runs. + $translator = Mockery::mock(\Illuminate\Contracts\Translation\Translator::class); + $translator->shouldReceive('getTranslations')->andReturn(['en' => ['greeting' => 'Hello']]); + $this->app->instance('translator', $translator); + + Cache::store('file')->forget('modularous-languages'); + + $translations = $this->modularous->getTranslations(); + + $this->assertSame(['en' => ['greeting' => 'Hello']], $translations); } public function test_clear_translations() { - try { - // Populate translations cache - $this->modularous->getTranslations(); + Cache::put('modularous-languages', ['cached'], 600); - // Clear translations - $this->modularous->clearTranslations(); + $this->modularous->clearTranslations(); - // Verify it doesn't throw errors - $this->assertTrue(true); - } catch (\UnexpectedValueException $e) { - // Translation directory might not exist in test environment, which is acceptable - $this->assertTrue(true); - } + $this->assertFalse(Cache::has('modularous-languages')); } public function test_get_grouped_modules() @@ -344,4 +486,89 @@ public function test_should_use_language_based_prices_without_callback() $shouldUse = $this->modularous->shouldUseLanguageBasedPrices(); $this->assertFalse($shouldUse); } + + public function test_get_currency_for_language_based_prices_returns_false_when_disabled(): void + { + Modularous::createDisableLanguageBasedPrices(null); + $this->app['config']->set('modularous.use_language_based_prices', false); + + $this->assertFalse($this->modularous->getCurrencyForLanguageBasedPrices()); + } + + public function test_get_currency_for_language_based_prices_returns_false_when_provider_unavailable(): void + { + $this->app['config']->set('modularous.use_language_based_prices', true); + + $provider = Mockery::mock(CurrencyProviderInterface::class); + $provider->shouldReceive('isAvailable')->once()->andReturn(false); + $this->app->instance(CurrencyProviderInterface::class, $provider); + + $this->assertFalse($this->modularous->getCurrencyForLanguageBasedPrices()); + } + + public function test_get_currency_for_language_based_prices_returns_currency_for_locale(): void + { + $this->app['config']->set('modularous.use_language_based_prices', true); + $this->app['config']->set('modularous.language_currencies', ['en' => 'USD']); + $this->app->setLocale('en'); + + $currency = (object) ['id' => 1, 'iso_4217' => 'USD']; + $provider = Mockery::mock(CurrencyProviderInterface::class); + $provider->shouldReceive('isAvailable')->once()->andReturn(true); + $provider->shouldReceive('findByIso4217')->once()->with('USD')->andReturn($currency); + $this->app->instance(CurrencyProviderInterface::class, $provider); + + $this->assertSame($currency, $this->modularous->getCurrencyForLanguageBasedPrices()); + } + + public function test_get_currency_for_language_based_prices_returns_false_without_locale_mapping(): void + { + $this->app['config']->set('modularous.use_language_based_prices', true); + $this->app['config']->set('modularous.language_currencies', []); + $this->app->setLocale('en'); + + $provider = Mockery::mock(CurrencyProviderInterface::class); + $provider->shouldReceive('isAvailable')->once()->andReturn(true); + $this->app->instance(CurrencyProviderInterface::class, $provider); + + $this->assertFalse($this->modularous->getCurrencyForLanguageBasedPrices()); + } + + public function test_get_models_finds_enabled_module_entities(): void + { + $models = $this->modularous->getModels('Item'); + + $this->assertContains(TestModuleItem::class, $models); + } + + public function test_get_module_route_model_select_items_and_resolve_target(): void + { + $items = $this->modularous->getModuleRouteModelSelectItems(); + + $this->assertNotEmpty($items); + + $testModuleItem = collect($items)->first( + fn (array $item): bool => $item['value'] === TestModuleItem::class + ); + + $this->assertNotNull($testModuleItem); + $this->assertSame('TestModule - Item', $testModuleItem['title']); + + $this->assertSame( + 'TestModule::Item', + $this->modularous->resolveTargetModuleRouteForModelClass(TestModuleItem::class) + ); + $this->assertNull($this->modularous->resolveTargetModuleRouteForModelClass('Unknown\\Model')); + } + + public function test_get_module_route_model_select_items_supports_trait_filters(): void + { + $parentSegmentOnly = $this->modularous->getModuleRouteModelSelectItems(true, false); + $pageLayoutOnly = $this->modularous->getModuleRouteModelSelectItems(false, true); + $both = $this->modularous->getModuleRouteModelSelectItems(true, true); + + $this->assertIsArray($parentSegmentOnly); + $this->assertIsArray($pageLayoutOnly); + $this->assertIsArray($both); + } } diff --git a/tests/ModuleTest.php b/tests/ModuleTest.php index 2ccde0e8d..0943a45f4 100644 --- a/tests/ModuleTest.php +++ b/tests/ModuleTest.php @@ -4,6 +4,9 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Route; +use Illuminate\Support\Facades\Schema; +use Illuminate\Database\Schema\Blueprint; use Mockery; use Unusualify\Modularous\Exceptions\ModularousException; use Unusualify\Modularous\Facades\Modularous; @@ -29,6 +32,8 @@ protected function getEnvironmentSetUp($app) $app['config']->set('modules.paths.generator.model', ['path' => 'Entities', 'namespace' => 'Entities', 'generate' => false]); $app['config']->set('modules.paths.generator.repository', ['path' => 'Repositories', 'namespace' => 'Repositories', 'generate' => false]); $app['config']->set('modules.paths.generator.controller', ['path' => 'Controllers', 'namespace' => 'Controllers', 'generate' => false]); + $app['config']->set('modules.paths.generator.filter', ['path' => 'Http/Middleware', 'namespace' => 'Http\\Middleware', 'generate' => true]); + $app['config']->set('modules.paths.generator.command', ['path' => 'Console', 'generate' => false]); Modularous::boot(); } @@ -479,4 +484,330 @@ public function test_is_status_throws_modularous_exception_when_activator_fails( $this->module->isStatus(true); } + + /** + * @param array $config + */ + private function replaceModuleConfig(array $config): void + { + Config::set('test_module', $config); + + $property = new \ReflectionProperty($this->module, 'rawConfigCache'); + $property->setAccessible(true); + $property->setValue($this->module, $config); + } + + private function registerNamedRoute(string $method, string $uri, string $name): void + { + Route::$method($uri, static fn () => 'ok')->name($name); + Route::getRoutes()->refreshNameLookups(); + } + + public function test_is_status_returns_true_when_activator_reports_enabled(): void + { + $activator = Mockery::mock($this->module->getActivator()); + $activator->shouldReceive('hasStatus')->once()->with($this->module, true)->andReturnTrue(); + + $property = new \ReflectionProperty($this->module, 'moduleActivator'); + $property->setAccessible(true); + $property->setValue($this->module, $activator); + + $this->assertTrue($this->module->isStatus(true)); + } + + public function test_register_aliases_registers_manifest_aliases(): void + { + $this->module->registerAliases(); + + $aliases = \Illuminate\Foundation\AliasLoader::getInstance()->getAliases(); + + $this->assertSame( + 'TestModules\TestModule\Entities\Item', + $aliases['TestModuleItemAlias'] ?? null + ); + } + + public function test_set_middlewares_discovers_item_middleware(): void + { + $property = new \ReflectionProperty($this->module, 'middlewares'); + $property->setAccessible(true); + $middlewares = $property->getValue($this->module); + + $this->assertArrayHasKey('item', $middlewares); + $this->assertSame('modules.test_module.item', $middlewares['item']['alias']); + $this->assertSame( + 'TestModules\TestModule\Http\Middleware\ItemMiddleware', + $middlewares['item']['class'] + ); + } + + public function test_create_middleware_aliases_registers_discovered_middleware(): void + { + $this->module->createMiddlewareAliases(); + + $router = $this->app->make('router'); + + $this->assertSame( + 'TestModules\TestModule\Http\Middleware\ItemMiddleware', + $router->getMiddleware()['modules.test_module.item'] ?? null + ); + } + + public function test_get_route_middleware_aliases_includes_auto_middleware(): void + { + $aliases = $this->module->getRouteMiddlewareAliases('Item'); + + $this->assertContains('modules.test_module.item', $aliases); + } + + public function test_load_commands_registers_console_command(): void + { + \Illuminate\Console\Application::forgetBootstrappers(); + + $this->module->loadCommands(); + + $artisan = new \Illuminate\Console\Application( + $this->app, + $this->app->make('events'), + '11.0' + ); + + $this->assertTrue($artisan->has('test-module:ping')); + + \Illuminate\Console\Application::forgetBootstrappers(); + } + + public function test_flush_module_cache_when_cache_enabled(): void + { + Config::set('modularous.cache.enabled', true); + + $method = new \ReflectionMethod($this->module, 'flushModuleCache'); + $method->setAccessible(true); + $method->invoke($this->module); + + $this->addToAssertionCount(1); + } + + public function test_prefix_and_full_prefix_with_system_prefix(): void + { + $config = $this->module->getRawConfig(); + $config['system_prefix'] = true; + $this->replaceModuleConfig($config); + + $this->assertStringContainsString(systemUrlPrefix(), $this->module->fullPrefix()); + $this->assertNotEmpty($this->module->prefix()); + $this->assertStringContainsString( + $this->module->systemRouteNamePrefix(), + $this->module->fullRouteNamePrefix() + ); + } + + public function test_prefix_uses_parent_route_url_when_parent_exists(): void + { + $config = $this->module->getRawConfig(); + $config['routes'] = [ + 'parent' => [ + 'name' => 'Parent', + 'url' => 'parent-items', + 'parent' => true, + ], + 'item' => $this->module->getRawRouteConfig('Item'), + ]; + $this->replaceModuleConfig($config); + + $this->assertSame('parent-items', $this->module->prefix()); + $this->assertTrue($this->module->hasParentRoute()); + $this->assertTrue($this->module->isParentRoute('Parent')); + } + + public function test_get_route_configs_valid_filters_nameless_entries(): void + { + $this->module->loadConfig(); + $this->module->setConfig([ + 'valid' => ['name' => 'Valid'], + 'invalid' => ['headline' => 'Missing name'], + ], 'routes'); + + $validOnly = $this->module->getRouteConfigs(valid: true); + + $this->assertArrayHasKey('valid', $validOnly); + $this->assertArrayNotHasKey('invalid', $validOnly); + } + + public function test_get_repository_returns_false_for_unknown_route(): void + { + $repository = $this->module->getRepository('UnknownRoute'); + + $this->assertNotInstanceOf(\Unusualify\Modularous\Repositories\Repository::class, $repository); + } + + public function test_route_has_table_when_table_exists(): void + { + Schema::create('test_module_items', function (Blueprint $table) { + $table->id(); + $table->string('name')->nullable(); + }); + + $this->assertTrue($this->module->routeHasTable('Item')); + } + + public function test_route_has_table_uses_notation_fallback(): void + { + Schema::create('test_module_items', function (Blueprint $table) { + $table->id(); + $table->string('name')->nullable(); + }); + + $this->assertTrue($this->module->routeHasTable(null, 'Item')); + } + + public function test_route_has_table_returns_false_without_repository(): void + { + $this->assertFalse($this->module->routeHasTable('MissingRoute')); + } + + public function test_get_route_action_url_returns_absolute_fallback_url(): void + { + $routeName = $this->module->fullRouteNamePrefix() . '.item.show'; + $uri = trim($this->module->fullPrefix() . '/items/{item}', '/'); + + $this->registerNamedRoute('get', $uri, $routeName); + + $url = $this->module->getRouteActionUrl('Item', 'show', [], true, false); + + $this->assertStringStartsWith('http://', $url); + $this->assertStringContainsString('/items/', $url); + } + + public function test_user_has_permission_returns_true_for_authenticated_user(): void + { + $user = Mockery::mock(); + $user->shouldReceive('hasPermission')->once()->with('item_view')->andReturnTrue(); + + Modularous::shouldReceive('getAuthGuardName')->andReturn('modularous'); + Auth::shouldReceive('guard')->with('modularous')->andReturnSelf(); + Auth::shouldReceive('user')->andReturn($user); + + $this->assertTrue($this->module->userHasPermission('view', 'Item')); + } + + public function test_get_route_action_url_returns_registered_panel_route(): void + { + $routeName = $this->module->fullRouteNamePrefix() . '.item.index'; + $uri = trim($this->module->fullPrefix() . '/items', '/'); + + $this->registerNamedRoute('get', $uri, $routeName); + + $url = $this->module->getRouteActionUrl('Item', 'index', [], false, false); + + $this->assertSame('/' . $uri, $url); + } + + public function test_get_route_action_url_falls_back_when_route_helper_fails(): void + { + $routeName = $this->module->fullRouteNamePrefix() . '.item.show'; + $uri = trim($this->module->fullPrefix() . '/items/{item}', '/'); + + $this->registerNamedRoute('get', $uri, $routeName); + + $url = $this->module->getRouteActionUrl('Item', 'show', ['item' => 5], false, false); + + $this->assertSame('/' . str_replace('{item}', '5', $uri), $url); + } + + public function test_get_route_panel_urls_without_prefix_and_with_binding(): void + { + $routeName = rtrim($this->module->panelRouteNamePrefix(), '.') . '.item.edit'; + $uri = trim($this->module->fullPrefix() . '/items/{item}/edit', '/'); + + $this->registerNamedRoute('get', $uri, $routeName); + + $withoutPrefix = $this->module->getRoutePanelUrls('Item', true); + $this->assertArrayHasKey('edit', $withoutPrefix); + + $withBinding = $this->module->getRoutePanelUrls('Item', true, '42'); + $this->assertStringContainsString('42', (string) ($withBinding['edit'] ?? '')); + } + + public function test_get_navigation_actions_includes_custom_and_nested_actions(): void + { + $config = $this->module->getRawConfig(); + $config['routes']['item']['table_row_actions'] = [ + [ + 'name' => 'custom', + 'url' => '/custom-action', + ], + ]; + $config['routes']['comment'] = [ + 'name' => 'Comment', + 'belongs' => ['item'], + 'url' => 'comments', + ]; + $this->replaceModuleConfig($config); + + $nestedRouteName = rtrim($this->module->panelRouteNamePrefix(), '.') . '.item.nested.comment.index'; + $nestedUri = trim($this->module->fullPrefix() . '/comments/{item}', '/'); + $this->registerNamedRoute('get', $nestedUri, $nestedRouteName); + + $actions = $this->module->getNavigationActions('Item'); + + $this->assertNotEmpty($actions); + $this->assertSame('custom', $actions[0]['name']); + $this->assertSame('/custom-action', $actions[0]['url']); + $this->assertSame('link', $actions[1]['name'] ?? null); + } + + public function test_get_module_urls_includes_parent_route_name_pattern(): void + { + $config = $this->module->getRawConfig(); + $config['routes']['parent'] = [ + 'name' => 'Parent', + 'route_name' => 'parent_route', + 'parent' => true, + ]; + $this->replaceModuleConfig($config); + + $this->registerNamedRoute('get', '/parent-route/items', 'parent_route.item.index'); + + $urls = $this->module->getModuleUrls(); + + $this->assertArrayHasKey('parent_route.item.index', $urls); + } + + public function test_get_module_urls_includes_system_prefix_with_parent_route_name(): void + { + $config = $this->module->getRawConfig(); + $config['system_prefix'] = true; + $config['routes']['parent'] = [ + 'name' => 'Parent', + 'route_name' => 'parent_route', + 'parent' => true, + ]; + $this->replaceModuleConfig($config); + + $routeName = $this->module->systemRouteNamePrefix() . '.parent_route.item.index'; + $this->registerNamedRoute('get', '/system-parent/items', $routeName); + + $urls = $this->module->getModuleUrls(); + + $this->assertArrayHasKey($routeName, $urls); + } + + public function test_get_raw_config_reads_from_repository_config_when_present(): void + { + Config::set('test_module', ['from' => 'repository']); + + $freshModule = MockModuleManager::getTestModule(); + + $this->assertSame('repository', $freshModule->getRawConfig('from')); + } + + public function test_is_modularous_module_detects_vendor_module_path(): void + { + $vendorModulePath = Modularous::getVendorPath('modules') . '/SystemUser'; + $vendorModule = new Module($this->app, 'SystemUser', $vendorModulePath); + + $this->assertTrue($vendorModule->isModularousModule()); + $this->assertFalse($this->module->isModularousModule()); + } } diff --git a/tests/Modules/Cms/SiteSettingsServiceTest.php b/tests/Modules/Cms/SiteSettingsServiceTest.php new file mode 100644 index 000000000..808be8e3b --- /dev/null +++ b/tests/Modules/Cms/SiteSettingsServiceTest.php @@ -0,0 +1,97 @@ +app->singleton(SystemSettingsService::class); + $this->app->alias(SystemSettingsService::class, 'system.settings'); + $this->app->singleton(CmsSettingsService::class); + $this->app->alias(CmsSettingsService::class, 'cms.settings'); + $this->app->singleton(SiteSettingsService::class); + $this->app->alias(SiteSettingsService::class, 'site.settings'); + } + + public function test_backend_context_reads_system_settings_only(): void + { + SystemSettings::set('site.name', 'System Name'); + CmsSettings::set('site.name', 'Cms Name'); + + $this->assertSame('System Name', SiteSettings::forBackend()->get('site.name')); + } + + public function test_frontend_context_prefers_cms_then_falls_back_to_system(): void + { + SystemSettings::set('site.name', 'System Name'); + SystemSettings::set('site.email', 'system@example.com'); + + $this->assertSame('System Name', SiteSettings::forFrontend()->get('site.name')); + $this->assertSame('system@example.com', SiteSettings::forFrontend()->get('site.email')); + + CmsSettings::set('site.name', 'Cms Name'); + + $this->assertSame('Cms Name', SiteSettings::forFrontend()->get('site.name')); + $this->assertSame('system@example.com', SiteSettings::forFrontend()->get('site.email')); + } + + public function test_frontend_falls_back_to_system_for_analytics_section(): void + { + SystemSettings::set('analytics.gtm_id', 'GTM-SYSTEM01'); + SystemSettings::set('analytics.enabled', true); + + $this->assertSame( + ['site', 'social', 'contact', 'seo'], + SiteSetting::settingsSections() + ); + $this->assertFalse(CmsSettings::filled('analytics.gtm_id')); + $this->assertSame('GTM-SYSTEM01', SiteSettings::forFrontend()->get('analytics.gtm_id')); + $this->assertTrue((bool) SiteSettings::forFrontend()->get('analytics.enabled')); + } + + public function test_duplicate_copies_general_into_empty_site_setting(): void + { + SystemSettings::set('site.name', 'Copied Name'); + SystemSettings::set('contact.sales_email', 'sales@example.com'); + SystemSettings::set('analytics.gtm_id', 'GTM-SHOULD-NOT-COPY'); + + $site = SiteSetting::single(); + $this->assertTrue($this->app->make(DuplicateSystemSettingsToCmsSettings::class)->duplicateIfNeeded()); + + $site->refresh(); + $this->assertSame('Copied Name', data_get($site->site, 'name')); + $this->assertSame('sales@example.com', data_get($site->contact, 'sales_email')); + $this->assertNull(data_get($site->getAttributes(), 'analytics')); + $this->assertSame('GTM-SHOULD-NOT-COPY', SiteSettings::forFrontend()->get('analytics.gtm_id')); + + $this->assertFalse($this->app->make(DuplicateSystemSettingsToCmsSettings::class)->duplicateIfNeeded()); + } + + public function test_site_setting_is_singular_like_general(): void + { + $a = SiteSetting::single(); + $b = SiteSetting::single(); + + $this->assertSame($a->id, $b->id); + $this->assertSame(General::single()->getTable(), $a->getTable()); + } +} diff --git a/tests/Modules/SystemSetting/AnalyticsScriptsTest.php b/tests/Modules/SystemSetting/AnalyticsScriptsTest.php new file mode 100644 index 000000000..f83a9c662 --- /dev/null +++ b/tests/Modules/SystemSetting/AnalyticsScriptsTest.php @@ -0,0 +1,146 @@ +app->singleton(SystemSettingsService::class); + $this->app->alias(SystemSettingsService::class, 'system.settings'); + $this->app->singleton(CmsSettingsService::class); + $this->app->alias(CmsSettingsService::class, 'cms.settings'); + $this->app->singleton(SiteSettingsService::class); + $this->app->alias(SiteSettingsService::class, 'site.settings'); + $this->app->singleton(AnalyticsScripts::class); + + config(['modularous.system_settings.analytics_enabled_default' => false]); + + $this->analytics = $this->app->make(AnalyticsScripts::class); + } + + public function test_disabled_emits_empty_snippets(): void + { + SystemSettings::set('analytics.enabled', false); + SystemSettings::set('analytics.gtm_id', 'GTM-MHL7RP8'); + SystemSettings::set('analytics.ga_measurement_id', 'G-ABCDEF123'); + + $this->assertFalse($this->analytics->isEnabled()); + $this->assertSame('', $this->analytics->headHtml()); + $this->assertSame('', $this->analytics->bodyOpenHtml()); + } + + public function test_unset_enabled_falls_back_to_config_default(): void + { + $this->assertFalse($this->analytics->isEnabled()); + + config(['modularous.system_settings.analytics_enabled_default' => true]); + SystemSettings::set('analytics.gtm_id', 'GTM-MHL7RP8'); + + $this->assertTrue($this->analytics->isEnabled()); + $this->assertStringContainsString('GTM-MHL7RP8', $this->analytics->headHtml()); + } + + public function test_enabled_with_gtm_emits_head_and_body_snippets(): void + { + SystemSettings::set('analytics.enabled', true); + SystemSettings::set('analytics.gtm_id', 'GTM-MHL7RP8'); + + $head = $this->analytics->headHtml(); + $body = $this->analytics->bodyOpenHtml(); + + $this->assertStringContainsString('Google Tag Manager', $head); + $this->assertStringContainsString('GTM-MHL7RP8', $head); + $this->assertStringContainsString("dataLayer','GTM-MHL7RP8'", $head); + + $this->assertStringContainsString('googletagmanager.com/ns.html?id=GTM-MHL7RP8', $body); + $this->assertStringContainsString('