From 38b4a7406f1c6e604148309bca72f8e605f0946b Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 12 Oct 2026 14:50:13 +0100 Subject: [PATCH] move into sonardesk --- AI/Config/config.php | 9 + AI/Console/PruneAIExecutions.php | 22 + AI/Console/TestAIProvider.php | 21 + AI/Contracts/AIManager.php | 13 + AI/Contracts/AIProviderDriver.php | 12 + .../2026_07_24_140000_create_ai_tables.php | 90 +++ AI/Drivers/OpenAICompatibleDriver.php | 36 + AI/Http/Controllers/AIApiController.php | 20 + AI/Http/Controllers/AIController.php | 69 ++ AI/Models/AIExecution.php | 12 + AI/Models/AIKnowledgeChunk.php | 12 + AI/Models/AIKnowledgeSource.php | 24 + AI/Models/AIPrompt.php | 17 + AI/Models/AIProvider.php | 14 + AI/Providers/AIServiceProvider.php | 28 + AI/Providers/RouteServiceProvider.php | 15 + AI/Resources/views/admin/index.blade.php | 21 + AI/Routes/api.php | 6 + AI/Routes/web.php | 13 + AI/Services/AIManager.php | 65 ++ AI/Services/KnowledgeIndexer.php | 39 ++ AI/Tests/Feature/ModuleBootTest.php | 16 + AI/module.json | 1 + Administration/Config/config.php | 6 + .../Http/Controllers/DashboardController.php | 82 +++ .../Http/Controllers/ModuleController.php | 27 + .../AdministrationServiceProvider.php | 21 + .../Providers/RouteServiceProvider.php | 17 + .../Resources/views/modules/index.blade.php | 1 + Administration/Routes/web.php | 8 + Administration/Support/ModuleInfo.php | 16 + .../Feature/AdministrationModuleTest.php | 17 + Administration/composer.json | 10 + Administration/module.json | 20 + Analytics/Config/config.php | 10 + Analytics/Console/AggregateAnalytics.php | 20 + Analytics/Console/PruneAnalytics.php | 21 + Analytics/Console/RunScheduledReports.php | 21 + ...6_07_24_150000_create_analytics_tables.php | 80 +++ .../Http/Controllers/AnalyticsController.php | 43 ++ .../Http/Controllers/TrackingController.php | 25 + Analytics/Models/AnalyticsEvent.php | 12 + Analytics/Models/AnalyticsReport.php | 12 + Analytics/Models/AnalyticsReportRun.php | 12 + Analytics/Models/DailyMetric.php | 14 + .../Providers/AnalyticsServiceProvider.php | 32 + Analytics/Providers/RouteServiceProvider.php | 15 + Analytics/Resources/views/index.blade.php | 14 + Analytics/Routes/api.php | 6 + Analytics/Routes/web.php | 10 + Analytics/Services/AnalyticsAggregator.php | 32 + Analytics/Services/AnalyticsTracker.php | 61 ++ .../Tests/Feature/AnalyticsModuleTest.php | 17 + Analytics/module.json | 1 + Api/Config/config.php | 3 + ...6_07_24_090000_create_api_tokens_table.php | 27 + ...26_07_24_090100_create_webhooks_tables.php | 43 ++ Api/Http/Controllers/ApiController.php | 49 ++ Api/Http/Controllers/ContentController.php | 54 ++ Api/Http/Controllers/OpenApiController.php | 13 + Api/Http/Middleware/AuthenticateApiToken.php | 28 + Api/Http/Resources/ContentResource.php | 14 + Api/Models/ApiToken.php | 40 ++ Api/Models/Webhook.php | 28 + Api/Models/WebhookDelivery.php | 21 + Api/Providers/ApiServiceProvider.php | 22 + Api/Providers/RouteServiceProvider.php | 15 + Api/Resources/views/admin/index.blade.php | 1 + Api/Routes/api.php | 18 + Api/Routes/web.php | 12 + Api/Services/WebhookDispatcher.php | 28 + Api/Tests/Feature/ApiModuleBootTest.php | 13 + Api/module.json | 1 + Audit/Config/config.php | 6 + ...6_07_23_000200_create_audit_logs_table.php | 28 + Audit/Http/Controllers/AuditController.php | 14 + Audit/Models/AuditLog.php | 17 + Audit/Providers/AuditServiceProvider.php | 21 + Audit/Providers/RouteServiceProvider.php | 17 + Audit/Resources/views/index.blade.php | 1 + Audit/Routes/web.php | 6 + Audit/Support/ModuleInfo.php | 16 + Audit/Tests/Feature/AuditModuleTest.php | 5 + Audit/composer.json | 10 + Audit/module.json | 20 + Authors/Config/config.php | 3 + ...2026_07_24_030300_create_authors_table.php | 31 + Authors/Http/Controllers/AuthorController.php | 57 ++ Authors/Models/Author.php | 22 + Authors/Providers/AuthorsServiceProvider.php | 20 + Authors/Providers/RouteServiceProvider.php | 15 + Authors/Resources/views/admin/form.blade.php | 1 + Authors/Resources/views/admin/index.blade.php | 1 + Authors/Routes/web.php | 13 + Authors/Tests/Feature/ModuleBootTest.php | 13 + Authors/module.json | 12 + Automation/Config/config.php | 8 + .../Console/DispatchScheduledAutomations.php | 30 + Automation/Contracts/ActionRegistry.php | 12 + Automation/Contracts/TriggerRegistry.php | 10 + ..._07_24_130000_create_automation_tables.php | 67 ++ .../Controllers/AutomationApiController.php | 24 + .../Http/Controllers/AutomationController.php | 63 ++ Automation/Jobs/ExecuteAutomation.php | 46 ++ Automation/Models/Automation.php | 28 + Automation/Models/AutomationRun.php | 24 + Automation/Models/AutomationRunLog.php | 14 + Automation/Models/AutomationStep.php | 18 + .../Providers/AutomationServiceProvider.php | 30 + Automation/Providers/RouteServiceProvider.php | 15 + .../Resources/views/admin/index.blade.php | 26 + Automation/Routes/api.php | 6 + Automation/Routes/web.php | 13 + .../Services/AutomationActionRegistry.php | 79 +++ Automation/Services/AutomationRunner.php | 46 ++ .../Services/AutomationTriggerRegistry.php | 23 + Automation/Tests/Feature/ModuleBootTest.php | 16 + Automation/module.json | 1 + Banners/Config/config.php | 3 + Banners/Http/Controllers/BannerController.php | 60 ++ Banners/Models/Banner.php | 23 + Banners/Providers/BannersServiceProvider.php | 20 + Banners/Providers/RouteServiceProvider.php | 15 + Banners/Resources/views/admin/form.blade.php | 9 + Banners/Resources/views/admin/index.blade.php | 5 + Banners/Routes/web.php | 13 + Banners/Support/ModuleInfo.php | 16 + Banners/Tests/Feature/ModuleBootTest.php | 7 + Banners/module.json | 20 + Blog/Config/config.php | 3 + ...26_07_24_03041_create_blog_posts_table.php | 40 ++ Blog/Http/Controllers/BlogController.php | 66 ++ .../Http/Controllers/PublicBlogController.php | 20 + Blog/Models/Blog.php | 48 ++ Blog/Providers/BlogServiceProvider.php | 20 + Blog/Providers/RouteServiceProvider.php | 15 + Blog/Resources/views/admin/form.blade.php | 1 + Blog/Resources/views/admin/index.blade.php | 1 + Blog/Resources/views/public/index.blade.php | 31 + Blog/Resources/views/public/show.blade.php | 23 + Blog/Routes/web.php | 16 + Blog/module.json | 12 + Builder/Components/ContentComponent.php | 38 ++ Builder/Components/HeadingComponent.php | 40 ++ Builder/Components/HeroComponent.php | 38 ++ Builder/Config/config.php | 8 + Builder/Contracts/BuilderComponent.php | 18 + ...026_07_24_160000_create_builder_tables.php | 58 ++ .../Http/Controllers/BuilderApiController.php | 27 + .../Http/Controllers/BuilderController.php | 61 ++ Builder/Models/BuilderDocument.php | 18 + Builder/Models/BuilderDocumentVersion.php | 12 + Builder/Models/BuilderTemplate.php | 12 + Builder/Providers/BuilderServiceProvider.php | 24 + Builder/Providers/RouteServiceProvider.php | 15 + .../Resources/views/components/hero.blade.php | 1 + Builder/Resources/views/editor.blade.php | 10 + Builder/Resources/views/index.blade.php | 7 + Builder/Routes/api.php | 11 + Builder/Routes/web.php | 13 + Builder/Services/BuilderDocumentService.php | 28 + Builder/Services/BuilderRegistry.php | 56 ++ Builder/Services/BuilderRenderer.php | 19 + Builder/Tests/Feature/BuilderModuleTest.php | 16 + Builder/module.json | 1 + CRM/Config/config.php | 3 + .../2026_07_24_04000_create_crm_tables.php | 79 +++ CRM/Http/Controllers/CRMController.php | 55 ++ CRM/Models/Activity.php | 23 + CRM/Models/Company.php | 23 + CRM/Models/Contact.php | 28 + CRM/Models/Lead.php | 23 + CRM/Models/Opportunity.php | 28 + CRM/Providers/CRMServiceProvider.php | 20 + CRM/Providers/RouteServiceProvider.php | 15 + CRM/Resources/views/admin/index.blade.php | 1 + CRM/Routes/web.php | 13 + CRM/Tests/Feature/ModuleBootTest.php | 13 + CRM/module.json | 12 + Careers/Config/config.php | 3 + ...2026_07_24_04300_create_careers_tables.php | 43 ++ Careers/Http/Controllers/CareerController.php | 32 + Careers/Models/Application.php | 12 + Careers/Models/Job.php | 23 + Careers/Providers/CareersServiceProvider.php | 20 + Careers/Providers/RouteServiceProvider.php | 15 + Careers/Resources/views/admin/index.blade.php | 1 + Careers/Routes/web.php | 10 + Careers/Tests/Feature/ModuleBootTest.php | 13 + Careers/module.json | 12 + Categories/Config/config.php | 3 + ...6_07_24_030100_create_categories_table.php | 28 + .../Http/Controllers/CategoryController.php | 56 ++ Categories/Models/Category.php | 27 + .../Providers/CategoriesServiceProvider.php | 20 + Categories/Providers/RouteServiceProvider.php | 15 + .../Resources/views/admin/form.blade.php | 1 + .../Resources/views/admin/index.blade.php | 1 + Categories/Routes/web.php | 13 + Categories/Tests/Feature/ModuleBootTest.php | 13 + Categories/module.json | 12 + CloudDevOps/Config/config.php | 9 + .../Commands/CloudDevOpsHealthCommand.php | 22 + .../Commands/CloudDevOpsPruneCommand.php | 22 + ...7_24_180000_create_cloud_devops_tables.php | 122 ++++ .../Controllers/CloudDevOpsApiController.php | 21 + .../Controllers/CloudDevOpsController.php | 50 ++ CloudDevOps/Models/DeploymentEnvironment.php | 36 + CloudDevOps/Models/DeploymentHealthCheck.php | 15 + CloudDevOps/Models/DeploymentPipeline.php | 15 + CloudDevOps/Models/DeploymentRelease.php | 27 + CloudDevOps/Models/DeploymentRollback.php | 10 + CloudDevOps/Models/DeploymentSecret.php | 15 + CloudDevOps/Models/DeploymentStep.php | 15 + CloudDevOps/Models/DeploymentTarget.php | 15 + .../Providers/CloudDevOpsServiceProvider.php | 25 + .../Providers/RouteServiceProvider.php | 15 + CloudDevOps/Resources/views/index.blade.php | 44 ++ CloudDevOps/Routes/api.php | 9 + CloudDevOps/Routes/web.php | 14 + CloudDevOps/Services/DeploymentManager.php | 40 ++ CloudDevOps/Services/HealthCheckRunner.php | 24 + .../Tests/Feature/CloudDevOpsModuleTest.php | 18 + CloudDevOps/module.json | 9 + Comments/Config/config.php | 3 + ...026_07_24_030600_create_comments_table.php | 31 + .../Http/Controllers/CommentController.php | 38 ++ Comments/Models/Comment.php | 21 + .../Providers/CommentsServiceProvider.php | 20 + Comments/Providers/RouteServiceProvider.php | 15 + .../Resources/views/admin/index.blade.php | 1 + Comments/Routes/web.php | 10 + Comments/Tests/Feature/ModuleBootTest.php | 13 + Comments/module.json | 12 + Commerce/Config/config.php | 8 + Commerce/Contracts/InventoryProvider.php | 12 + Commerce/Contracts/PaymentProvider.php | 12 + Commerce/Contracts/ShippingProvider.php | 8 + Commerce/Contracts/TaxProvider.php | 8 + ...26_07_24_110000_create_commerce_tables.php | 172 +++++ .../Http/Controllers/CommerceController.php | 53 ++ .../Http/Controllers/ProductApiController.php | 20 + Commerce/Models/Category.php | 17 + Commerce/Models/Customer.php | 17 + Commerce/Models/InventoryMovement.php | 17 + Commerce/Models/Order.php | 17 + Commerce/Models/OrderItem.php | 17 + Commerce/Models/Product.php | 20 + Commerce/Models/ProductVariant.php | 17 + Commerce/Models/Promotion.php | 17 + Commerce/Models/Relations.php | 3 + .../Providers/CommerceServiceProvider.php | 32 + Commerce/Providers/RouteServiceProvider.php | 15 + .../Resources/views/admin/index.blade.php | 5 + Commerce/Routes/api.php | 7 + Commerce/Routes/web.php | 12 + Commerce/Services/ConfiguredTaxProvider.php | 13 + .../Services/DatabaseInventoryProvider.php | 24 + .../Services/FlatRateShippingProvider.php | 13 + Commerce/Services/ManualPaymentProvider.php | 19 + Commerce/Tests/Feature/ModuleBootTest.php | 19 + Commerce/module.json | 1 + Content/Config/config.php | 5 + Content/Console/InstallSaaslandContent.php | 244 +++++++ ...0000_create_content_composition_tables.php | 62 ++ .../Controllers/ContentDocumentController.php | 146 ++++ .../Controllers/PageCompositionController.php | 189 ++++++ Content/Models/ContentDocument.php | 17 + Content/Models/ContentType.php | 12 + Content/Models/PageContent.php | 15 + Content/Providers/ContentServiceProvider.php | 22 + Content/README.md | 37 + .../views/admin/composition.blade.php | 147 ++++ Content/Resources/views/admin/form.blade.php | 636 ++++++++++++++++++ Content/Resources/views/admin/index.blade.php | 53 ++ .../views/documents/unsupported.blade.php | 3 + Content/Routes/web.php | 24 + Content/Services/PageRenderer.php | 83 +++ Content/module.json | 12 + Content/views/admin/form.blade.php | 399 +++++++++++ Core/Config/config.php | 6 + Core/Contracts/ModuleRegistry.php | 11 + Core/Providers/CoreServiceProvider.php | 24 + Core/Providers/RouteServiceProvider.php | 17 + Core/Routes/web.php | 3 + Core/Services/LaravelModuleRegistry.php | 38 ++ Core/Support/ModuleInfo.php | 16 + Core/Tests/Feature/CoreModuleTest.php | 5 + Core/composer.json | 10 + Core/module.json | 18 + Developer/Config/config.php | 8 + .../Commands/InspectModulesCommand.php | 44 ++ .../Console/Commands/MakeCrudCommand.php | 37 + .../Console/Commands/MakeModuleCommand.php | 32 + .../Console/Commands/MakeThemeCommand.php | 30 + .../Http/Controllers/DeveloperController.php | 27 + .../Providers/DeveloperServiceProvider.php | 32 + Developer/Providers/RouteServiceProvider.php | 14 + .../Resources/views/admin/index.blade.php | 26 + Developer/Routes/web.php | 11 + Developer/Support/CrudGenerator.php | 69 ++ Developer/Support/Generator.php | 43 ++ Developer/Support/ModuleGenerator.php | 55 ++ Developer/Support/ThemeGenerator.php | 24 + .../Tests/Feature/DeveloperModuleBootTest.php | 11 + Developer/module.json | 12 + Documents/Config/config.php | 3 + ...26_07_24_000500_create_documents_table.php | 26 + .../Http/Controllers/DocumentController.php | 58 ++ Documents/Models/Document.php | 23 + .../Providers/DocumentsServiceProvider.php | 20 + Documents/Providers/RouteServiceProvider.php | 15 + .../Resources/views/admin/form.blade.php | 8 + .../Resources/views/admin/index.blade.php | 5 + Documents/Routes/web.php | 13 + Documents/Support/ModuleInfo.php | 16 + Documents/Tests/Feature/ModuleBootTest.php | 7 + Documents/module.json | 20 + Enterprise/Config/config.php | 11 + Enterprise/Console/BackupCreateCommand.php | 21 + Enterprise/Console/BackupPruneCommand.php | 20 + Enterprise/Console/HealthCheckCommand.php | 28 + ...00_create_enterprise_health_runs_table.php | 25 + ...100100_create_enterprise_backups_table.php | 28 + .../Http/Controllers/EnterpriseController.php | 82 +++ Enterprise/Models/Backup.php | 24 + Enterprise/Models/HealthRun.php | 24 + .../Providers/EnterpriseServiceProvider.php | 30 + Enterprise/Providers/RouteServiceProvider.php | 14 + .../Resources/views/admin/index.blade.php | 24 + Enterprise/Routes/web.php | 18 + Enterprise/Services/BackupService.php | 76 +++ Enterprise/Services/SystemHealthService.php | 94 +++ .../Feature/EnterpriseModuleBootTest.php | 10 + Enterprise/module.json | 1 + Events/Config/config.php | 3 + .../2026_07_24_04200_create_events_tables.php | 51 ++ Events/Http/Controllers/EventController.php | 32 + Events/Models/Event.php | 27 + Events/Models/Registration.php | 12 + Events/Models/Venue.php | 12 + Events/Providers/EventsServiceProvider.php | 20 + Events/Providers/RouteServiceProvider.php | 15 + Events/Resources/views/admin/index.blade.php | 1 + Events/Routes/web.php | 10 + Events/Tests/Feature/ModuleBootTest.php | 13 + Events/module.json | 12 + Faq/Config/config.php | 3 + .../2026_07_24_000700_create_faqs_table.php | 27 + Faq/Http/Controllers/FaqController.php | 60 ++ Faq/Models/Faq.php | 24 + Faq/Providers/FaqServiceProvider.php | 20 + Faq/Providers/RouteServiceProvider.php | 15 + Faq/Resources/views/admin/form.blade.php | 9 + Faq/Resources/views/admin/index.blade.php | 5 + Faq/Routes/web.php | 13 + Faq/Support/ModuleInfo.php | 16 + Faq/Tests/Feature/ModuleBootTest.php | 7 + Faq/module.json | 20 + Feeds/Config/config.php | 3 + Feeds/Http/Controllers/FeedController.php | 21 + Feeds/Providers/FeedsServiceProvider.php | 20 + Feeds/Providers/RouteServiceProvider.php | 15 + Feeds/Resources/views/rss.blade.php | 1 + Feeds/Routes/web.php | 7 + Feeds/Tests/Feature/ModuleBootTest.php | 13 + Feeds/module.json | 12 + Forms/Config/config.php | 3 + .../2026_07_24_000400_create_forms_table.php | 27 + Forms/Http/Controllers/FormController.php | 66 ++ Forms/Models/Form.php | 24 + Forms/Providers/FormsServiceProvider.php | 20 + Forms/Providers/RouteServiceProvider.php | 15 + Forms/Resources/views/admin/form.blade.php | 32 + Forms/Resources/views/admin/index.blade.php | 43 ++ Forms/Routes/web.php | 13 + Forms/Support/ModuleInfo.php | 16 + Forms/Tests/Feature/ModuleBootTest.php | 7 + Forms/module.json | 20 + Licensing/Config/.gitkeep | 0 Licensing/Config/config.php | 8 + Licensing/Console/.gitkeep | 0 Licensing/Console/SonarDeskLicenseDeliver.php | 20 + Licensing/Console/SonarDeskLicenseIssue.php | 29 + Licensing/Console/SonarDeskLicenseRevoke.php | 22 + Licensing/Console/SonarDeskProductCreate.php | 21 + Licensing/Database/Migrations/.gitkeep | 0 ...0000_create_sonardesk_licensing_tables.php | 92 +++ ..._08_210000_add_sonardesk_portal_fields.php | 30 + Licensing/Database/Seeders/.gitkeep | 0 .../Seeders/LicensingDatabaseSeeder.php | 21 + Licensing/Database/factories/.gitkeep | 0 Licensing/Helpers/.gitkeep | 0 Licensing/Helpers/Helper.php | 12 + Licensing/Http/Controllers/.gitkeep | 0 .../Admin/LicensingDashboardController.php | 37 + .../Api/LicenseActivationController.php | 164 +++++ .../Api/PaymentWebhookController.php | 191 ++++++ .../Http/Controllers/LicensingController.php | 79 +++ .../Controllers/Portal/PortalController.php | 143 ++++ Licensing/Http/Middleware/.gitkeep | 0 Licensing/Http/Requests/.gitkeep | 0 Licensing/Models/.gitkeep | 0 Licensing/Models/License.php | 36 + Licensing/Models/LicenseActivation.php | 14 + Licensing/Models/LicenseDeliveryOutbox.php | 20 + Licensing/Models/LicenseProduct.php | 12 + Licensing/Models/LicenseWebhookEvent.php | 12 + Licensing/Providers/.gitkeep | 0 .../IntegratedLicensingServiceProvider.php | 35 + .../Providers/LicensingServiceProvider.php | 171 +++++ Licensing/Providers/RouteServiceProvider.php | 69 ++ Licensing/Resources/assets/.gitkeep | 0 Licensing/Resources/assets/js/app.js | 0 Licensing/Resources/assets/sass/app.scss | 0 Licensing/Resources/lang/.gitkeep | 0 Licensing/Resources/views/.gitkeep | 0 .../Resources/views/admin/index.blade.php | 3 + .../Resources/views/admin/layout.blade.php | 3 + .../Resources/views/admin/licenses.blade.php | 3 + .../Resources/views/admin/show.blade.php | 3 + Licensing/Resources/views/index.blade.php | 9 + .../Resources/views/layouts/master.blade.php | 19 + Licensing/Routes/.gitkeep | 0 Licensing/Routes/admin.php | 14 + Licensing/Routes/api.php | 12 + Licensing/Routes/web.php | 13 + Licensing/Services/LicenseDeliveryService.php | 54 ++ Licensing/Services/LicenseIssuer.php | 46 ++ Licensing/Services/LicenseKey.php | 27 + Licensing/Services/LicenseSigner.php | 59 ++ Licensing/Services/LicenseTokenVerifier.php | 58 ++ Licensing/Support/.gitkeep | 0 Licensing/Support/ModuleInfo.php | 26 + Licensing/Tests/Browser/.gitkeep | 0 Licensing/Tests/Feature/.gitkeep | 0 Licensing/Tests/Unit/.gitkeep | 0 Licensing/Traits/.gitkeep | 0 Licensing/View/.gitkeep | 0 Licensing/composer.json | 15 + Licensing/module.json | 14 + Licensing/package.json | 17 + Licensing/webpack.mix.js | 11 + Localization/Concerns/HasTranslations.php | 36 + Localization/Config/config.php | 16 + ...07_24_06000_create_localization_tables.php | 46 ++ .../Database/Seeders/LocaleSeeder.php | 19 + .../Http/Controllers/LocaleController.php | 78 +++ .../Controllers/TranslationController.php | 44 ++ Localization/Http/Middleware/SetLocale.php | 53 ++ Localization/Models/Locale.php | 27 + Localization/Models/LocalizedContent.php | 28 + .../Providers/LocalizationServiceProvider.php | 22 + .../Providers/RouteServiceProvider.php | 15 + .../Resources/views/admin/index.blade.php | 1 + .../Resources/views/admin/translate.blade.php | 1 + Localization/Routes/web.php | 15 + Localization/Services/LocaleManager.php | 28 + Localization/Tests/Feature/ModuleBootTest.php | 14 + Localization/module.json | 9 + Locations/Config/config.php | 3 + ...026_07_24_04400_create_locations_table.php | 34 + .../Http/Controllers/LocationController.php | 31 + Locations/Models/Location.php | 15 + .../Providers/LocationsServiceProvider.php | 20 + Locations/Providers/RouteServiceProvider.php | 15 + .../Resources/views/admin/index.blade.php | 1 + Locations/Routes/web.php | 10 + Locations/Tests/Feature/ModuleBootTest.php | 13 + Locations/module.json | 12 + Marketplace/Config/config.php | 14 + .../Commands/MarketplaceAuditCommand.php | 24 + .../Commands/MarketplaceSyncCommand.php | 24 + ...07_24_170000_create_marketplace_tables.php | 96 +++ .../Controllers/MarketplaceApiController.php | 40 ++ .../Controllers/MarketplaceController.php | 64 ++ Marketplace/Models/Installation.php | 14 + Marketplace/Models/InstalledPackage.php | 31 + Marketplace/Models/MarketplaceLicence.php | 16 + Marketplace/Models/MarketplacePackage.php | 26 + Marketplace/Models/Publisher.php | 20 + .../Providers/MarketplaceServiceProvider.php | 36 + .../Providers/RouteServiceProvider.php | 15 + Marketplace/Resources/views/index.blade.php | 97 +++ .../Resources/views/installed.blade.php | 104 +++ Marketplace/Routes/api.php | 12 + Marketplace/Routes/web.php | 14 + Marketplace/Services/CompatibilityChecker.php | 30 + Marketplace/Services/DependencyResolver.php | 29 + Marketplace/Services/MarketplaceManager.php | 72 ++ Marketplace/Services/PackageManifest.php | 38 ++ Marketplace/Services/PackageVerifier.php | 28 + Marketplace/Services/VersionConstraint.php | 47 ++ .../Tests/Feature/MarketplaceModuleTest.php | 23 + Marketplace/module.json | 1 + Media/Config/config.php | 6 + .../2026_07_23_000300_create_media_table.php | 37 + Media/Http/Controllers/MediaController.php | 34 + Media/Models/Media.php | 26 + Media/Providers/MediaServiceProvider.php | 21 + Media/Providers/RouteServiceProvider.php | 17 + Media/Resources/views/index.blade.php | 1 + Media/Routes/web.php | 10 + Media/Support/ModuleInfo.php | 16 + Media/Tests/Feature/MediaModuleTest.php | 5 + Media/composer.json | 10 + Media/module.json | 20 + Menus/Config/config.php | 3 + .../2026_07_24_000200_create_menus_table.php | 26 + Menus/Http/Controllers/MenuController.php | 90 +++ Menus/Models/Menu.php | 24 + Menus/Providers/MenusServiceProvider.php | 20 + Menus/Providers/RouteServiceProvider.php | 15 + Menus/Resources/views/admin/form.blade.php | 57 ++ Menus/Resources/views/admin/index.blade.php | 14 + Menus/Routes/web.php | 13 + Menus/Support/ModuleInfo.php | 16 + Menus/Tests/Feature/MenuEditorTest.php | 8 + Menus/Tests/Feature/ModuleBootTest.php | 7 + Menus/module.json | 20 + Modules/Licensing/Config/.gitkeep | 0 Modules/Licensing/Config/config.php | 8 + Modules/Licensing/Console/.gitkeep | 0 .../Console/SonarDeskLicenseDeliver.php | 20 + .../Console/SonarDeskLicenseIssue.php | 29 + .../Console/SonarDeskLicenseRevoke.php | 22 + .../Console/SonarDeskProductCreate.php | 21 + .../Licensing/Database/Migrations/.gitkeep | 0 ...0000_create_sonardesk_licensing_tables.php | 92 +++ ..._08_210000_add_sonardesk_portal_fields.php | 30 + Modules/Licensing/Database/Seeders/.gitkeep | 0 .../Seeders/LicensingDatabaseSeeder.php | 21 + Modules/Licensing/Database/factories/.gitkeep | 0 Modules/Licensing/Helpers/.gitkeep | 0 Modules/Licensing/Helpers/Helper.php | 12 + Modules/Licensing/Http/Controllers/.gitkeep | 0 .../Admin/LicensingDashboardController.php | 56 ++ .../Api/LicenseActivationController.php | 164 +++++ .../Api/PaymentWebhookController.php | 191 ++++++ .../Http/Controllers/LicensingController.php | 79 +++ .../Controllers/Portal/PortalController.php | 143 ++++ Modules/Licensing/Http/Middleware/.gitkeep | 0 Modules/Licensing/Http/Requests/.gitkeep | 0 Modules/Licensing/Models/.gitkeep | 0 Modules/Licensing/Models/License.php | 36 + .../Licensing/Models/LicenseActivation.php | 14 + .../Models/LicenseDeliveryOutbox.php | 20 + Modules/Licensing/Models/LicenseProduct.php | 12 + .../Licensing/Models/LicenseWebhookEvent.php | 12 + Modules/Licensing/Providers/.gitkeep | 0 .../IntegratedLicensingServiceProvider.php | 35 + .../Providers/LicensingServiceProvider.php | 171 +++++ .../Providers/RouteServiceProvider.php | 69 ++ Modules/Licensing/Resources/assets/.gitkeep | 0 Modules/Licensing/Resources/assets/js/app.js | 0 .../Licensing/Resources/assets/sass/app.scss | 0 Modules/Licensing/Resources/lang/.gitkeep | 0 Modules/Licensing/Resources/views/.gitkeep | 0 .../Resources/views/admin/index.blade.php | 3 + .../Resources/views/admin/layout.blade.php | 37 + .../Resources/views/admin/licenses.blade.php | 3 + .../Resources/views/admin/show.blade.php | 3 + .../views/admin/subscriptions.blade.php | 25 + .../Licensing/Resources/views/index.blade.php | 9 + .../Resources/views/layouts/master.blade.php | 19 + Modules/Licensing/Routes/.gitkeep | 0 Modules/Licensing/Routes/admin.php | 15 + Modules/Licensing/Routes/api.php | 12 + Modules/Licensing/Routes/web.php | 13 + .../Services/LicenseDeliveryService.php | 54 ++ Modules/Licensing/Services/LicenseIssuer.php | 46 ++ Modules/Licensing/Services/LicenseKey.php | 27 + Modules/Licensing/Services/LicenseSigner.php | 59 ++ .../Services/LicenseTokenVerifier.php | 58 ++ Modules/Licensing/Support/.gitkeep | 0 Modules/Licensing/Support/ModuleInfo.php | 26 + Modules/Licensing/Tests/Browser/.gitkeep | 0 Modules/Licensing/Tests/Feature/.gitkeep | 0 Modules/Licensing/Tests/Unit/.gitkeep | 0 Modules/Licensing/Traits/.gitkeep | 0 Modules/Licensing/View/.gitkeep | 0 Modules/Licensing/composer.json | 15 + Modules/Licensing/module.json | 14 + Modules/Licensing/package.json | 17 + Modules/Licensing/webpack.mix.js | 11 + News/Config/config.php | 3 + ...07_24_03042_create_news_articles_table.php | 40 ++ News/Http/Controllers/NewsController.php | 66 ++ .../Http/Controllers/PublicNewsController.php | 20 + News/Models/News.php | 48 ++ News/Providers/NewsServiceProvider.php | 20 + News/Providers/RouteServiceProvider.php | 15 + News/Resources/views/admin/form.blade.php | 1 + News/Resources/views/admin/index.blade.php | 1 + News/Resources/views/public/index.blade.php | 31 + News/Resources/views/public/show.blade.php | 23 + News/Routes/web.php | 16 + News/Tests/Feature/ModuleBootTest.php | 13 + News/module.json | 12 + Newsletter/Config/config.php | 3 + ...6_07_24_04100_create_newsletter_tables.php | 57 ++ .../Http/Controllers/NewsletterController.php | 52 ++ Newsletter/Models/Campaign.php | 17 + Newsletter/Models/MailingList.php | 17 + Newsletter/Models/Subscriber.php | 17 + .../Providers/NewsletterServiceProvider.php | 20 + Newsletter/Providers/RouteServiceProvider.php | 15 + .../Resources/views/admin/index.blade.php | 1 + Newsletter/Routes/web.php | 13 + Newsletter/Tests/Feature/ModuleBootTest.php | 13 + Newsletter/module.json | 12 + Pages/Config/config.php | 3 + .../2026_07_24_000100_create_pages_table.php | 29 + ...24_030700_enhance_pages_for_publishing.php | 37 + Pages/Http/Controllers/PageController.php | 151 +++++ .../Http/Controllers/PublicPageController.php | 17 + Pages/Models/Page.php | 35 + Pages/Providers/PagesServiceProvider.php | 20 + Pages/Providers/RouteServiceProvider.php | 15 + Pages/Resources/views/admin/form.blade.php | 87 +++ Pages/Resources/views/admin/index.blade.php | 75 +++ Pages/Resources/views/public/show.blade.php | 22 + Pages/Routes/web.php | 17 + Pages/Tests/Feature/ModuleBootTest.php | 7 + Pages/module.json | 20 + Products/Config/config.php | 3 + ...026_07_24_105054_create_products_table.php | 23 + ...0_add_pricing_fields_to_products_table.php | 34 + .../Http/Controllers/ProductController.php | 23 + .../Http/Requests/StoreProductRequest.php | 45 ++ Products/Models/Product.php | 21 + .../Providers/ProductsServiceProvider.php | 20 + Products/Providers/RouteServiceProvider.php | 14 + .../views/admin/products/form.blade.php | 25 + .../views/admin/products/index.blade.php | 26 + Products/Routes/web.php | 7 + Products/Tests/Feature/ProductCrudTest.php | 7 + .../Tests/Feature/ProductsModuleBootTest.php | 7 + Products/module.json | 19 + Redirects/Config/config.php | 3 + ...26_07_24_000300_create_redirects_table.php | 26 + .../Http/Controllers/RedirectController.php | 58 ++ Redirects/Models/Redirect.php | 24 + .../Providers/RedirectsServiceProvider.php | 20 + Redirects/Providers/RouteServiceProvider.php | 15 + .../Resources/views/admin/form.blade.php | 8 + .../Resources/views/admin/index.blade.php | 5 + Redirects/Routes/web.php | 13 + Redirects/Support/ModuleInfo.php | 16 + Redirects/Tests/Feature/ModuleBootTest.php | 7 + Redirects/module.json | 20 + Roles/Config/config.php | 6 + ..._07_23_000050_create_permission_tables.php | 119 ++++ .../Database/Seeders/RolePermissionSeeder.php | 96 +++ Roles/Http/Controllers/RoleController.php | 58 ++ Roles/Providers/RolesServiceProvider.php | 21 + Roles/Providers/RouteServiceProvider.php | 17 + Roles/Resources/views/form.blade.php | 1 + Roles/Resources/views/index.blade.php | 1 + Roles/Routes/web.php | 8 + Roles/Support/ModuleInfo.php | 16 + Roles/Tests/Feature/RolesModuleTest.php | 5 + Roles/composer.json | 10 + Roles/module.json | 20 + Search/Config/config.php | 6 + ...07_23_000500_create_search_index_table.php | 30 + Search/Http/Controllers/SearchController.php | 18 + Search/Providers/RouteServiceProvider.php | 17 + Search/Providers/SearchServiceProvider.php | 21 + Search/Resources/views/index.blade.php | 1 + Search/Routes/web.php | 6 + Search/Support/ModuleInfo.php | 16 + Search/Tests/Feature/SearchModuleTest.php | 5 + Search/composer.json | 10 + Search/module.json | 20 + Security/Config/config.php | 9 + ...26_07_24_120000_create_security_tables.php | 76 +++ .../Http/Controllers/SecurityController.php | 52 ++ .../Http/Middleware/EnforceIpRestrictions.php | 20 + Security/Listeners/RecordFailedLogin.php | 19 + Security/Listeners/RecordLogin.php | 19 + Security/Listeners/RecordLogout.php | 19 + Security/Models/IpRule.php | 14 + Security/Models/LoginEvent.php | 14 + Security/Models/SecurityPolicy.php | 12 + Security/Models/TrustedDevice.php | 14 + Security/Providers/RouteServiceProvider.php | 14 + .../Providers/SecurityServiceProvider.php | 33 + .../Resources/views/admin/index.blade.php | 5 + Security/Routes/web.php | 12 + Security/Services/IpRuleService.php | 39 ++ Security/Tests/Feature/ModuleBootTest.php | 18 + Security/module.json | 1 + Seo/Config/config.php | 6 + ...07_23_000400_create_seo_metadata_table.php | 36 + Seo/Http/Controllers/SeoController.php | 27 + Seo/Models/SeoMetadata.php | 17 + Seo/Providers/RouteServiceProvider.php | 17 + Seo/Providers/SeoServiceProvider.php | 21 + Seo/Resources/views/index.blade.php | 1 + Seo/Routes/web.php | 9 + Seo/Support/ModuleInfo.php | 16 + Seo/Tests/Feature/SeoModuleTest.php | 5 + Seo/composer.json | 10 + Seo/module.json | 20 + Settings/Config/config.php | 6 + ...026_07_23_000100_create_settings_table.php | 27 + .../Http/Controllers/SettingController.php | 109 +++ Settings/Models/Setting.php | 15 + Settings/Providers/RouteServiceProvider.php | 17 + .../Providers/SettingsServiceProvider.php | 21 + Settings/Resources/views/index.blade.php | 55 ++ Settings/Routes/web.php | 9 + Settings/Services/SettingsRepository.php | 137 ++++ Settings/Support/ModuleInfo.php | 16 + Settings/Tests/Feature/SettingsModuleTest.php | 5 + .../Feature/SiteSettingsSingleSourceTest.php | 9 + .../Feature/ThemeSelectionSettingsTest.php | 27 + Settings/composer.json | 10 + Settings/module.json | 20 + Sitemap/Config/config.php | 3 + .../Http/Controllers/SitemapController.php | 23 + Sitemap/Providers/RouteServiceProvider.php | 15 + Sitemap/Providers/SitemapServiceProvider.php | 20 + Sitemap/Resources/views/index.blade.php | 7 + Sitemap/Routes/web.php | 6 + Sitemap/Tests/Feature/ModuleBootTest.php | 13 + Sitemap/module.json | 12 + Sites/Concerns/BelongsToSites.php | 32 + Sites/Config/config.php | 16 + Sites/Contracts/SiteAwareModule.php | 10 + ...6_07_24_070000_create_multisite_tables.php | 69 ++ Sites/Database/Seeders/SiteSeeder.php | 16 + Sites/Http/Controllers/DomainController.php | 33 + Sites/Http/Controllers/SiteController.php | 68 ++ Sites/Http/Middleware/ResolveSite.php | 24 + Sites/Models/Site.php | 44 ++ Sites/Models/SiteContent.php | 29 + Sites/Models/SiteDomain.php | 21 + Sites/Models/SiteSetting.php | 26 + Sites/Providers/RouteServiceProvider.php | 15 + Sites/Providers/SitesServiceProvider.php | 22 + .../views/admin/sites/form.blade.php | 1 + .../views/admin/sites/index.blade.php | 1 + Sites/Routes/web.php | 12 + Sites/Services/SiteManager.php | 76 +++ Sites/Tests/Feature/SitesModuleBootTest.php | 14 + Sites/module.json | 9 + Stylist/Config/config.php | 29 + Stylist/Console/CreateThemeCommand.php | 64 ++ Stylist/Console/FindDuplicateViewsCommand.php | 40 ++ Stylist/Console/InspectThemesCommand.php | 28 + Stylist/Console/PublishAssetsCommand.php | 34 + Stylist/Console/ThemeDoctorCommand.php | 38 ++ Stylist/Console/ValidateThemeCommand.php | 108 +++ ..._add_stylist_fields_to_companies_table.php | 56 ++ ...01_create_stylist_theme_settings_table.php | 24 + Stylist/Diagnostics/ThemeInspector.php | 147 ++++ Stylist/Facades/StylistFacade.php | 13 + Stylist/Facades/ThemeFacade.php | 13 + Stylist/Helpers/helpers.php | 64 ++ Stylist/Html/ThemeHtmlBuilder.php | 99 +++ Stylist/Html/ThemeSeoBuilder.php | 44 ++ .../Http/Controllers/StylistController.php | 23 + .../Http/Controllers/ThemeAssetController.php | 64 ++ .../Controllers/ThemeManagementController.php | 87 +++ Stylist/Livewire/ThemePreview.php | 21 + Stylist/Models/ThemeSetting.php | 14 + Stylist/Providers/RouteServiceProvider.php | 22 + Stylist/Providers/StylistServiceProvider.php | 215 ++++++ Stylist/README.md | 183 +++++ Stylist/Resources/assets/css/app.css | 5 + Stylist/Resources/assets/js/app.js | 1 + Stylist/Resources/lang/en/messages.php | 5 + Stylist/Resources/views/index.blade.php | 78 +++ Stylist/Routes/assets.php | 16 + Stylist/Routes/web.php | 17 + Stylist/Services/FrontendContent.php | 102 +++ Stylist/Services/TenantThemeResolver.php | 65 ++ .../Services/ThemeAccessibilityValidator.php | 32 + Stylist/Services/ThemeOptimizer.php | 57 ++ Stylist/Services/ThemePackageManager.php | 98 +++ Stylist/Services/ThemeSettings.php | 97 +++ Stylist/Services/ThemeValidator.php | 74 ++ Stylist/Support/FrontendNavigation.php | 76 +++ Stylist/Support/ModuleInfo.php | 16 + .../Feature/CommonApplicationViewsTest.php | 56 ++ .../Feature/LegacyThemeNamespaceAliasTest.php | 14 + Stylist/Tests/Feature/StylistModuleTest.php | 21 + .../Feature/ThemeAssetControllerTest.php | 41 ++ .../Tests/Feature/ThemeHtmlBuilderTest.php | 18 + .../Tests/Feature/ThemeInheritanceTest.php | 95 +++ Stylist/Tests/Feature/ThemeManifestTest.php | 157 +++++ .../Feature/ThemePackageRoundTripTest.php | 79 +++ .../Tests/Feature/ThemeViewResolverTest.php | 22 + .../Tests/Feature/WelcomeThemeViewTest.php | 21 + .../InvalidThemeManifestException.php | 18 + .../Exceptions/ThemeJsonNotFoundException.php | 13 + .../Exceptions/ThemeNotFoundException.php | 13 + Stylist/Theme/Json.php | 22 + Stylist/Theme/Loader.php | 83 +++ Stylist/Theme/ManifestValidator.php | 179 +++++ Stylist/Theme/Stylist.php | 103 +++ Stylist/Theme/Theme.php | 161 +++++ Stylist/Theme/UrlGenerator.php | 20 + Stylist/View/ThemeViewResolver.php | 130 ++++ Stylist/changelog.yaml | 24 + Stylist/composer.json | 22 + Stylist/module.json | 20 + Stylist/package.json | 15 + Stylist/vite.config.js | 25 + Tags/Config/config.php | 3 + .../2026_07_24_030200_create_tags_table.php | 25 + Tags/Http/Controllers/TagController.php | 48 ++ Tags/Models/Tag.php | 15 + Tags/Providers/RouteServiceProvider.php | 15 + Tags/Providers/TagsServiceProvider.php | 20 + Tags/Resources/views/admin/form.blade.php | 1 + Tags/Resources/views/admin/index.blade.php | 1 + Tags/Routes/web.php | 13 + Tags/Tests/Feature/ModuleBootTest.php | 13 + Tags/module.json | 12 + Team/Config/config.php | 3 + ...07_24_000800_create_team_members_table.php | 28 + .../Http/Controllers/TeamMemberController.php | 62 ++ Team/Models/TeamMember.php | 24 + Team/Providers/RouteServiceProvider.php | 15 + Team/Providers/TeamServiceProvider.php | 20 + Team/Resources/views/admin/form.blade.php | 10 + Team/Resources/views/admin/index.blade.php | 5 + Team/Routes/web.php | 13 + Team/Support/ModuleInfo.php | 16 + Team/Tests/Feature/ModuleBootTest.php | 7 + Team/module.json | 20 + Testimonials/Config/config.php | 3 + ...07_24_000900_create_testimonials_table.php | 27 + ...0000_add_content_to_testimonials_table.php | 26 + .../Controllers/TestimonialController.php | 60 ++ Testimonials/Models/Testimonial.php | 24 + .../Providers/RouteServiceProvider.php | 15 + .../Providers/TestimonialsServiceProvider.php | 20 + .../Resources/views/admin/form.blade.php | 9 + .../Resources/views/admin/index.blade.php | 5 + Testimonials/Routes/web.php | 13 + Testimonials/Support/ModuleInfo.php | 16 + Testimonials/Tests/Feature/ModuleBootTest.php | 7 + Testimonials/module.json | 20 + Users/Config/config.php | 6 + Users/Http/Controllers/UserController.php | 56 ++ Users/Providers/RouteServiceProvider.php | 17 + Users/Providers/UsersServiceProvider.php | 21 + Users/Routes/web.php | 8 + Users/Support/ModuleInfo.php | 16 + Users/Tests/Feature/UsersModuleTest.php | 5 + Users/composer.json | 10 + Users/module.json | 20 + Workflow/Concerns/HasWorkflow.php | 19 + Workflow/Config/config.php | 3 + Workflow/Contracts/Workflowable.php | 12 + ...026_07_24_05000_create_workflow_tables.php | 81 +++ .../Http/Controllers/WorkflowController.php | 44 ++ Workflow/Models/WorkflowDefinition.php | 26 + Workflow/Models/WorkflowHistory.php | 17 + Workflow/Models/WorkflowInstance.php | 38 ++ Workflow/Models/WorkflowStage.php | 21 + Workflow/Models/WorkflowTransition.php | 15 + Workflow/Providers/RouteServiceProvider.php | 15 + .../Providers/WorkflowServiceProvider.php | 22 + .../Resources/views/admin/index.blade.php | 1 + Workflow/Routes/web.php | 10 + Workflow/Services/WorkflowManager.php | 46 ++ Workflow/Tests/Feature/ModuleBootTest.php | 14 + Workflow/module.json | 11 + integration/PHASE5.md | 25 + integration/PHASE6.md | 22 + integration/PHASE7.md | 20 + modules_statuses.json | 3 +- .../components/layouts/app/sidebar.blade.php | 10 + .../SonarDeskAdminAuthorizationTest.php | 24 + 879 files changed, 24021 insertions(+), 1 deletion(-) create mode 100644 AI/Config/config.php create mode 100644 AI/Console/PruneAIExecutions.php create mode 100644 AI/Console/TestAIProvider.php create mode 100644 AI/Contracts/AIManager.php create mode 100644 AI/Contracts/AIProviderDriver.php create mode 100644 AI/Database/Migrations/2026_07_24_140000_create_ai_tables.php create mode 100644 AI/Drivers/OpenAICompatibleDriver.php create mode 100644 AI/Http/Controllers/AIApiController.php create mode 100644 AI/Http/Controllers/AIController.php create mode 100644 AI/Models/AIExecution.php create mode 100644 AI/Models/AIKnowledgeChunk.php create mode 100644 AI/Models/AIKnowledgeSource.php create mode 100644 AI/Models/AIPrompt.php create mode 100644 AI/Models/AIProvider.php create mode 100644 AI/Providers/AIServiceProvider.php create mode 100644 AI/Providers/RouteServiceProvider.php create mode 100644 AI/Resources/views/admin/index.blade.php create mode 100644 AI/Routes/api.php create mode 100644 AI/Routes/web.php create mode 100644 AI/Services/AIManager.php create mode 100644 AI/Services/KnowledgeIndexer.php create mode 100644 AI/Tests/Feature/ModuleBootTest.php create mode 100644 AI/module.json create mode 100644 Administration/Config/config.php create mode 100644 Administration/Http/Controllers/DashboardController.php create mode 100644 Administration/Http/Controllers/ModuleController.php create mode 100644 Administration/Providers/AdministrationServiceProvider.php create mode 100644 Administration/Providers/RouteServiceProvider.php create mode 100644 Administration/Resources/views/modules/index.blade.php create mode 100644 Administration/Routes/web.php create mode 100644 Administration/Support/ModuleInfo.php create mode 100644 Administration/Tests/Feature/AdministrationModuleTest.php create mode 100644 Administration/composer.json create mode 100644 Administration/module.json create mode 100644 Analytics/Config/config.php create mode 100644 Analytics/Console/AggregateAnalytics.php create mode 100644 Analytics/Console/PruneAnalytics.php create mode 100644 Analytics/Console/RunScheduledReports.php create mode 100644 Analytics/Database/Migrations/2026_07_24_150000_create_analytics_tables.php create mode 100644 Analytics/Http/Controllers/AnalyticsController.php create mode 100644 Analytics/Http/Controllers/TrackingController.php create mode 100644 Analytics/Models/AnalyticsEvent.php create mode 100644 Analytics/Models/AnalyticsReport.php create mode 100644 Analytics/Models/AnalyticsReportRun.php create mode 100644 Analytics/Models/DailyMetric.php create mode 100644 Analytics/Providers/AnalyticsServiceProvider.php create mode 100644 Analytics/Providers/RouteServiceProvider.php create mode 100644 Analytics/Resources/views/index.blade.php create mode 100644 Analytics/Routes/api.php create mode 100644 Analytics/Routes/web.php create mode 100644 Analytics/Services/AnalyticsAggregator.php create mode 100644 Analytics/Services/AnalyticsTracker.php create mode 100644 Analytics/Tests/Feature/AnalyticsModuleTest.php create mode 100644 Analytics/module.json create mode 100644 Api/Config/config.php create mode 100644 Api/Database/Migrations/2026_07_24_090000_create_api_tokens_table.php create mode 100644 Api/Database/Migrations/2026_07_24_090100_create_webhooks_tables.php create mode 100644 Api/Http/Controllers/ApiController.php create mode 100644 Api/Http/Controllers/ContentController.php create mode 100644 Api/Http/Controllers/OpenApiController.php create mode 100644 Api/Http/Middleware/AuthenticateApiToken.php create mode 100644 Api/Http/Resources/ContentResource.php create mode 100644 Api/Models/ApiToken.php create mode 100644 Api/Models/Webhook.php create mode 100644 Api/Models/WebhookDelivery.php create mode 100644 Api/Providers/ApiServiceProvider.php create mode 100644 Api/Providers/RouteServiceProvider.php create mode 100644 Api/Resources/views/admin/index.blade.php create mode 100644 Api/Routes/api.php create mode 100644 Api/Routes/web.php create mode 100644 Api/Services/WebhookDispatcher.php create mode 100644 Api/Tests/Feature/ApiModuleBootTest.php create mode 100644 Api/module.json create mode 100644 Audit/Config/config.php create mode 100644 Audit/Database/Migrations/2026_07_23_000200_create_audit_logs_table.php create mode 100644 Audit/Http/Controllers/AuditController.php create mode 100644 Audit/Models/AuditLog.php create mode 100644 Audit/Providers/AuditServiceProvider.php create mode 100644 Audit/Providers/RouteServiceProvider.php create mode 100644 Audit/Resources/views/index.blade.php create mode 100644 Audit/Routes/web.php create mode 100644 Audit/Support/ModuleInfo.php create mode 100644 Audit/Tests/Feature/AuditModuleTest.php create mode 100644 Audit/composer.json create mode 100644 Audit/module.json create mode 100644 Authors/Config/config.php create mode 100644 Authors/Database/Migrations/2026_07_24_030300_create_authors_table.php create mode 100644 Authors/Http/Controllers/AuthorController.php create mode 100644 Authors/Models/Author.php create mode 100644 Authors/Providers/AuthorsServiceProvider.php create mode 100644 Authors/Providers/RouteServiceProvider.php create mode 100644 Authors/Resources/views/admin/form.blade.php create mode 100644 Authors/Resources/views/admin/index.blade.php create mode 100644 Authors/Routes/web.php create mode 100644 Authors/Tests/Feature/ModuleBootTest.php create mode 100644 Authors/module.json create mode 100644 Automation/Config/config.php create mode 100644 Automation/Console/DispatchScheduledAutomations.php create mode 100644 Automation/Contracts/ActionRegistry.php create mode 100644 Automation/Contracts/TriggerRegistry.php create mode 100644 Automation/Database/Migrations/2026_07_24_130000_create_automation_tables.php create mode 100644 Automation/Http/Controllers/AutomationApiController.php create mode 100644 Automation/Http/Controllers/AutomationController.php create mode 100644 Automation/Jobs/ExecuteAutomation.php create mode 100644 Automation/Models/Automation.php create mode 100644 Automation/Models/AutomationRun.php create mode 100644 Automation/Models/AutomationRunLog.php create mode 100644 Automation/Models/AutomationStep.php create mode 100644 Automation/Providers/AutomationServiceProvider.php create mode 100644 Automation/Providers/RouteServiceProvider.php create mode 100644 Automation/Resources/views/admin/index.blade.php create mode 100644 Automation/Routes/api.php create mode 100644 Automation/Routes/web.php create mode 100644 Automation/Services/AutomationActionRegistry.php create mode 100644 Automation/Services/AutomationRunner.php create mode 100644 Automation/Services/AutomationTriggerRegistry.php create mode 100644 Automation/Tests/Feature/ModuleBootTest.php create mode 100644 Automation/module.json create mode 100644 Banners/Config/config.php create mode 100644 Banners/Http/Controllers/BannerController.php create mode 100644 Banners/Models/Banner.php create mode 100644 Banners/Providers/BannersServiceProvider.php create mode 100644 Banners/Providers/RouteServiceProvider.php create mode 100644 Banners/Resources/views/admin/form.blade.php create mode 100644 Banners/Resources/views/admin/index.blade.php create mode 100644 Banners/Routes/web.php create mode 100644 Banners/Support/ModuleInfo.php create mode 100644 Banners/Tests/Feature/ModuleBootTest.php create mode 100644 Banners/module.json create mode 100644 Blog/Config/config.php create mode 100644 Blog/Database/Migrations/2026_07_24_03041_create_blog_posts_table.php create mode 100644 Blog/Http/Controllers/BlogController.php create mode 100644 Blog/Http/Controllers/PublicBlogController.php create mode 100644 Blog/Models/Blog.php create mode 100644 Blog/Providers/BlogServiceProvider.php create mode 100644 Blog/Providers/RouteServiceProvider.php create mode 100644 Blog/Resources/views/admin/form.blade.php create mode 100644 Blog/Resources/views/admin/index.blade.php create mode 100644 Blog/Resources/views/public/index.blade.php create mode 100644 Blog/Resources/views/public/show.blade.php create mode 100644 Blog/Routes/web.php create mode 100644 Blog/module.json create mode 100644 Builder/Components/ContentComponent.php create mode 100644 Builder/Components/HeadingComponent.php create mode 100644 Builder/Components/HeroComponent.php create mode 100644 Builder/Config/config.php create mode 100644 Builder/Contracts/BuilderComponent.php create mode 100644 Builder/Database/Migrations/2026_07_24_160000_create_builder_tables.php create mode 100644 Builder/Http/Controllers/BuilderApiController.php create mode 100644 Builder/Http/Controllers/BuilderController.php create mode 100644 Builder/Models/BuilderDocument.php create mode 100644 Builder/Models/BuilderDocumentVersion.php create mode 100644 Builder/Models/BuilderTemplate.php create mode 100644 Builder/Providers/BuilderServiceProvider.php create mode 100644 Builder/Providers/RouteServiceProvider.php create mode 100644 Builder/Resources/views/components/hero.blade.php create mode 100644 Builder/Resources/views/editor.blade.php create mode 100644 Builder/Resources/views/index.blade.php create mode 100644 Builder/Routes/api.php create mode 100644 Builder/Routes/web.php create mode 100644 Builder/Services/BuilderDocumentService.php create mode 100644 Builder/Services/BuilderRegistry.php create mode 100644 Builder/Services/BuilderRenderer.php create mode 100644 Builder/Tests/Feature/BuilderModuleTest.php create mode 100644 Builder/module.json create mode 100644 CRM/Config/config.php create mode 100644 CRM/Database/Migrations/2026_07_24_04000_create_crm_tables.php create mode 100644 CRM/Http/Controllers/CRMController.php create mode 100644 CRM/Models/Activity.php create mode 100644 CRM/Models/Company.php create mode 100644 CRM/Models/Contact.php create mode 100644 CRM/Models/Lead.php create mode 100644 CRM/Models/Opportunity.php create mode 100644 CRM/Providers/CRMServiceProvider.php create mode 100644 CRM/Providers/RouteServiceProvider.php create mode 100644 CRM/Resources/views/admin/index.blade.php create mode 100644 CRM/Routes/web.php create mode 100644 CRM/Tests/Feature/ModuleBootTest.php create mode 100644 CRM/module.json create mode 100644 Careers/Config/config.php create mode 100644 Careers/Database/Migrations/2026_07_24_04300_create_careers_tables.php create mode 100644 Careers/Http/Controllers/CareerController.php create mode 100644 Careers/Models/Application.php create mode 100644 Careers/Models/Job.php create mode 100644 Careers/Providers/CareersServiceProvider.php create mode 100644 Careers/Providers/RouteServiceProvider.php create mode 100644 Careers/Resources/views/admin/index.blade.php create mode 100644 Careers/Routes/web.php create mode 100644 Careers/Tests/Feature/ModuleBootTest.php create mode 100644 Careers/module.json create mode 100644 Categories/Config/config.php create mode 100644 Categories/Database/Migrations/2026_07_24_030100_create_categories_table.php create mode 100644 Categories/Http/Controllers/CategoryController.php create mode 100644 Categories/Models/Category.php create mode 100644 Categories/Providers/CategoriesServiceProvider.php create mode 100644 Categories/Providers/RouteServiceProvider.php create mode 100644 Categories/Resources/views/admin/form.blade.php create mode 100644 Categories/Resources/views/admin/index.blade.php create mode 100644 Categories/Routes/web.php create mode 100644 Categories/Tests/Feature/ModuleBootTest.php create mode 100644 Categories/module.json create mode 100644 CloudDevOps/Config/config.php create mode 100644 CloudDevOps/Console/Commands/CloudDevOpsHealthCommand.php create mode 100644 CloudDevOps/Console/Commands/CloudDevOpsPruneCommand.php create mode 100644 CloudDevOps/Database/Migrations/2026_07_24_180000_create_cloud_devops_tables.php create mode 100644 CloudDevOps/Http/Controllers/CloudDevOpsApiController.php create mode 100644 CloudDevOps/Http/Controllers/CloudDevOpsController.php create mode 100644 CloudDevOps/Models/DeploymentEnvironment.php create mode 100644 CloudDevOps/Models/DeploymentHealthCheck.php create mode 100644 CloudDevOps/Models/DeploymentPipeline.php create mode 100644 CloudDevOps/Models/DeploymentRelease.php create mode 100644 CloudDevOps/Models/DeploymentRollback.php create mode 100644 CloudDevOps/Models/DeploymentSecret.php create mode 100644 CloudDevOps/Models/DeploymentStep.php create mode 100644 CloudDevOps/Models/DeploymentTarget.php create mode 100644 CloudDevOps/Providers/CloudDevOpsServiceProvider.php create mode 100644 CloudDevOps/Providers/RouteServiceProvider.php create mode 100644 CloudDevOps/Resources/views/index.blade.php create mode 100644 CloudDevOps/Routes/api.php create mode 100644 CloudDevOps/Routes/web.php create mode 100644 CloudDevOps/Services/DeploymentManager.php create mode 100644 CloudDevOps/Services/HealthCheckRunner.php create mode 100644 CloudDevOps/Tests/Feature/CloudDevOpsModuleTest.php create mode 100644 CloudDevOps/module.json create mode 100644 Comments/Config/config.php create mode 100644 Comments/Database/Migrations/2026_07_24_030600_create_comments_table.php create mode 100644 Comments/Http/Controllers/CommentController.php create mode 100644 Comments/Models/Comment.php create mode 100644 Comments/Providers/CommentsServiceProvider.php create mode 100644 Comments/Providers/RouteServiceProvider.php create mode 100644 Comments/Resources/views/admin/index.blade.php create mode 100644 Comments/Routes/web.php create mode 100644 Comments/Tests/Feature/ModuleBootTest.php create mode 100644 Comments/module.json create mode 100644 Commerce/Config/config.php create mode 100644 Commerce/Contracts/InventoryProvider.php create mode 100644 Commerce/Contracts/PaymentProvider.php create mode 100644 Commerce/Contracts/ShippingProvider.php create mode 100644 Commerce/Contracts/TaxProvider.php create mode 100644 Commerce/Database/Migrations/2026_07_24_110000_create_commerce_tables.php create mode 100644 Commerce/Http/Controllers/CommerceController.php create mode 100644 Commerce/Http/Controllers/ProductApiController.php create mode 100644 Commerce/Models/Category.php create mode 100644 Commerce/Models/Customer.php create mode 100644 Commerce/Models/InventoryMovement.php create mode 100644 Commerce/Models/Order.php create mode 100644 Commerce/Models/OrderItem.php create mode 100644 Commerce/Models/Product.php create mode 100644 Commerce/Models/ProductVariant.php create mode 100644 Commerce/Models/Promotion.php create mode 100644 Commerce/Models/Relations.php create mode 100644 Commerce/Providers/CommerceServiceProvider.php create mode 100644 Commerce/Providers/RouteServiceProvider.php create mode 100644 Commerce/Resources/views/admin/index.blade.php create mode 100644 Commerce/Routes/api.php create mode 100644 Commerce/Routes/web.php create mode 100644 Commerce/Services/ConfiguredTaxProvider.php create mode 100644 Commerce/Services/DatabaseInventoryProvider.php create mode 100644 Commerce/Services/FlatRateShippingProvider.php create mode 100644 Commerce/Services/ManualPaymentProvider.php create mode 100644 Commerce/Tests/Feature/ModuleBootTest.php create mode 100644 Commerce/module.json create mode 100644 Content/Config/config.php create mode 100644 Content/Console/InstallSaaslandContent.php create mode 100644 Content/Database/Migrations/2026_07_27_000000_create_content_composition_tables.php create mode 100644 Content/Http/Controllers/ContentDocumentController.php create mode 100644 Content/Http/Controllers/PageCompositionController.php create mode 100644 Content/Models/ContentDocument.php create mode 100644 Content/Models/ContentType.php create mode 100644 Content/Models/PageContent.php create mode 100644 Content/Providers/ContentServiceProvider.php create mode 100644 Content/README.md create mode 100644 Content/Resources/views/admin/composition.blade.php create mode 100644 Content/Resources/views/admin/form.blade.php create mode 100644 Content/Resources/views/admin/index.blade.php create mode 100644 Content/Resources/views/documents/unsupported.blade.php create mode 100644 Content/Routes/web.php create mode 100644 Content/Services/PageRenderer.php create mode 100644 Content/module.json create mode 100644 Content/views/admin/form.blade.php create mode 100644 Core/Config/config.php create mode 100644 Core/Contracts/ModuleRegistry.php create mode 100644 Core/Providers/CoreServiceProvider.php create mode 100644 Core/Providers/RouteServiceProvider.php create mode 100644 Core/Routes/web.php create mode 100644 Core/Services/LaravelModuleRegistry.php create mode 100644 Core/Support/ModuleInfo.php create mode 100644 Core/Tests/Feature/CoreModuleTest.php create mode 100644 Core/composer.json create mode 100644 Core/module.json create mode 100644 Developer/Config/config.php create mode 100644 Developer/Console/Commands/InspectModulesCommand.php create mode 100644 Developer/Console/Commands/MakeCrudCommand.php create mode 100644 Developer/Console/Commands/MakeModuleCommand.php create mode 100644 Developer/Console/Commands/MakeThemeCommand.php create mode 100644 Developer/Http/Controllers/DeveloperController.php create mode 100644 Developer/Providers/DeveloperServiceProvider.php create mode 100644 Developer/Providers/RouteServiceProvider.php create mode 100644 Developer/Resources/views/admin/index.blade.php create mode 100644 Developer/Routes/web.php create mode 100644 Developer/Support/CrudGenerator.php create mode 100644 Developer/Support/Generator.php create mode 100644 Developer/Support/ModuleGenerator.php create mode 100644 Developer/Support/ThemeGenerator.php create mode 100644 Developer/Tests/Feature/DeveloperModuleBootTest.php create mode 100644 Developer/module.json create mode 100644 Documents/Config/config.php create mode 100644 Documents/Database/Migrations/2026_07_24_000500_create_documents_table.php create mode 100644 Documents/Http/Controllers/DocumentController.php create mode 100644 Documents/Models/Document.php create mode 100644 Documents/Providers/DocumentsServiceProvider.php create mode 100644 Documents/Providers/RouteServiceProvider.php create mode 100644 Documents/Resources/views/admin/form.blade.php create mode 100644 Documents/Resources/views/admin/index.blade.php create mode 100644 Documents/Routes/web.php create mode 100644 Documents/Support/ModuleInfo.php create mode 100644 Documents/Tests/Feature/ModuleBootTest.php create mode 100644 Documents/module.json create mode 100644 Enterprise/Config/config.php create mode 100644 Enterprise/Console/BackupCreateCommand.php create mode 100644 Enterprise/Console/BackupPruneCommand.php create mode 100644 Enterprise/Console/HealthCheckCommand.php create mode 100644 Enterprise/Database/Migrations/2026_07_24_100000_create_enterprise_health_runs_table.php create mode 100644 Enterprise/Database/Migrations/2026_07_24_100100_create_enterprise_backups_table.php create mode 100644 Enterprise/Http/Controllers/EnterpriseController.php create mode 100644 Enterprise/Models/Backup.php create mode 100644 Enterprise/Models/HealthRun.php create mode 100644 Enterprise/Providers/EnterpriseServiceProvider.php create mode 100644 Enterprise/Providers/RouteServiceProvider.php create mode 100644 Enterprise/Resources/views/admin/index.blade.php create mode 100644 Enterprise/Routes/web.php create mode 100644 Enterprise/Services/BackupService.php create mode 100644 Enterprise/Services/SystemHealthService.php create mode 100644 Enterprise/Tests/Feature/EnterpriseModuleBootTest.php create mode 100644 Enterprise/module.json create mode 100644 Events/Config/config.php create mode 100644 Events/Database/Migrations/2026_07_24_04200_create_events_tables.php create mode 100644 Events/Http/Controllers/EventController.php create mode 100644 Events/Models/Event.php create mode 100644 Events/Models/Registration.php create mode 100644 Events/Models/Venue.php create mode 100644 Events/Providers/EventsServiceProvider.php create mode 100644 Events/Providers/RouteServiceProvider.php create mode 100644 Events/Resources/views/admin/index.blade.php create mode 100644 Events/Routes/web.php create mode 100644 Events/Tests/Feature/ModuleBootTest.php create mode 100644 Events/module.json create mode 100644 Faq/Config/config.php create mode 100644 Faq/Database/Migrations/2026_07_24_000700_create_faqs_table.php create mode 100644 Faq/Http/Controllers/FaqController.php create mode 100644 Faq/Models/Faq.php create mode 100644 Faq/Providers/FaqServiceProvider.php create mode 100644 Faq/Providers/RouteServiceProvider.php create mode 100644 Faq/Resources/views/admin/form.blade.php create mode 100644 Faq/Resources/views/admin/index.blade.php create mode 100644 Faq/Routes/web.php create mode 100644 Faq/Support/ModuleInfo.php create mode 100644 Faq/Tests/Feature/ModuleBootTest.php create mode 100644 Faq/module.json create mode 100644 Feeds/Config/config.php create mode 100644 Feeds/Http/Controllers/FeedController.php create mode 100644 Feeds/Providers/FeedsServiceProvider.php create mode 100644 Feeds/Providers/RouteServiceProvider.php create mode 100644 Feeds/Resources/views/rss.blade.php create mode 100644 Feeds/Routes/web.php create mode 100644 Feeds/Tests/Feature/ModuleBootTest.php create mode 100644 Feeds/module.json create mode 100644 Forms/Config/config.php create mode 100644 Forms/Database/Migrations/2026_07_24_000400_create_forms_table.php create mode 100644 Forms/Http/Controllers/FormController.php create mode 100644 Forms/Models/Form.php create mode 100644 Forms/Providers/FormsServiceProvider.php create mode 100644 Forms/Providers/RouteServiceProvider.php create mode 100644 Forms/Resources/views/admin/form.blade.php create mode 100644 Forms/Resources/views/admin/index.blade.php create mode 100644 Forms/Routes/web.php create mode 100644 Forms/Support/ModuleInfo.php create mode 100644 Forms/Tests/Feature/ModuleBootTest.php create mode 100644 Forms/module.json create mode 100644 Licensing/Config/.gitkeep create mode 100644 Licensing/Config/config.php create mode 100644 Licensing/Console/.gitkeep create mode 100644 Licensing/Console/SonarDeskLicenseDeliver.php create mode 100755 Licensing/Console/SonarDeskLicenseIssue.php create mode 100755 Licensing/Console/SonarDeskLicenseRevoke.php create mode 100755 Licensing/Console/SonarDeskProductCreate.php create mode 100644 Licensing/Database/Migrations/.gitkeep create mode 100755 Licensing/Database/Migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php create mode 100755 Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php create mode 100644 Licensing/Database/Seeders/.gitkeep create mode 100644 Licensing/Database/Seeders/LicensingDatabaseSeeder.php create mode 100644 Licensing/Database/factories/.gitkeep create mode 100644 Licensing/Helpers/.gitkeep create mode 100644 Licensing/Helpers/Helper.php create mode 100644 Licensing/Http/Controllers/.gitkeep create mode 100644 Licensing/Http/Controllers/Admin/LicensingDashboardController.php create mode 100755 Licensing/Http/Controllers/Api/LicenseActivationController.php create mode 100755 Licensing/Http/Controllers/Api/PaymentWebhookController.php create mode 100644 Licensing/Http/Controllers/LicensingController.php create mode 100755 Licensing/Http/Controllers/Portal/PortalController.php create mode 100644 Licensing/Http/Middleware/.gitkeep create mode 100644 Licensing/Http/Requests/.gitkeep create mode 100644 Licensing/Models/.gitkeep create mode 100755 Licensing/Models/License.php create mode 100755 Licensing/Models/LicenseActivation.php create mode 100755 Licensing/Models/LicenseDeliveryOutbox.php create mode 100755 Licensing/Models/LicenseProduct.php create mode 100755 Licensing/Models/LicenseWebhookEvent.php create mode 100644 Licensing/Providers/.gitkeep create mode 100644 Licensing/Providers/IntegratedLicensingServiceProvider.php create mode 100644 Licensing/Providers/LicensingServiceProvider.php create mode 100644 Licensing/Providers/RouteServiceProvider.php create mode 100644 Licensing/Resources/assets/.gitkeep create mode 100644 Licensing/Resources/assets/js/app.js create mode 100644 Licensing/Resources/assets/sass/app.scss create mode 100644 Licensing/Resources/lang/.gitkeep create mode 100644 Licensing/Resources/views/.gitkeep create mode 100644 Licensing/Resources/views/admin/index.blade.php create mode 100644 Licensing/Resources/views/admin/layout.blade.php create mode 100644 Licensing/Resources/views/admin/licenses.blade.php create mode 100644 Licensing/Resources/views/admin/show.blade.php create mode 100644 Licensing/Resources/views/index.blade.php create mode 100644 Licensing/Resources/views/layouts/master.blade.php create mode 100644 Licensing/Routes/.gitkeep create mode 100644 Licensing/Routes/admin.php create mode 100644 Licensing/Routes/api.php create mode 100644 Licensing/Routes/web.php create mode 100755 Licensing/Services/LicenseDeliveryService.php create mode 100755 Licensing/Services/LicenseIssuer.php create mode 100755 Licensing/Services/LicenseKey.php create mode 100755 Licensing/Services/LicenseSigner.php create mode 100755 Licensing/Services/LicenseTokenVerifier.php create mode 100644 Licensing/Support/.gitkeep create mode 100644 Licensing/Support/ModuleInfo.php create mode 100644 Licensing/Tests/Browser/.gitkeep create mode 100644 Licensing/Tests/Feature/.gitkeep create mode 100644 Licensing/Tests/Unit/.gitkeep create mode 100644 Licensing/Traits/.gitkeep create mode 100644 Licensing/View/.gitkeep create mode 100644 Licensing/composer.json create mode 100644 Licensing/module.json create mode 100644 Licensing/package.json create mode 100644 Licensing/webpack.mix.js create mode 100644 Localization/Concerns/HasTranslations.php create mode 100644 Localization/Config/config.php create mode 100644 Localization/Database/Migrations/2026_07_24_06000_create_localization_tables.php create mode 100644 Localization/Database/Seeders/LocaleSeeder.php create mode 100644 Localization/Http/Controllers/LocaleController.php create mode 100644 Localization/Http/Controllers/TranslationController.php create mode 100644 Localization/Http/Middleware/SetLocale.php create mode 100644 Localization/Models/Locale.php create mode 100644 Localization/Models/LocalizedContent.php create mode 100644 Localization/Providers/LocalizationServiceProvider.php create mode 100644 Localization/Providers/RouteServiceProvider.php create mode 100644 Localization/Resources/views/admin/index.blade.php create mode 100644 Localization/Resources/views/admin/translate.blade.php create mode 100644 Localization/Routes/web.php create mode 100644 Localization/Services/LocaleManager.php create mode 100644 Localization/Tests/Feature/ModuleBootTest.php create mode 100644 Localization/module.json create mode 100644 Locations/Config/config.php create mode 100644 Locations/Database/Migrations/2026_07_24_04400_create_locations_table.php create mode 100644 Locations/Http/Controllers/LocationController.php create mode 100644 Locations/Models/Location.php create mode 100644 Locations/Providers/LocationsServiceProvider.php create mode 100644 Locations/Providers/RouteServiceProvider.php create mode 100644 Locations/Resources/views/admin/index.blade.php create mode 100644 Locations/Routes/web.php create mode 100644 Locations/Tests/Feature/ModuleBootTest.php create mode 100644 Locations/module.json create mode 100644 Marketplace/Config/config.php create mode 100644 Marketplace/Console/Commands/MarketplaceAuditCommand.php create mode 100644 Marketplace/Console/Commands/MarketplaceSyncCommand.php create mode 100644 Marketplace/Database/Migrations/2026_07_24_170000_create_marketplace_tables.php create mode 100644 Marketplace/Http/Controllers/MarketplaceApiController.php create mode 100644 Marketplace/Http/Controllers/MarketplaceController.php create mode 100644 Marketplace/Models/Installation.php create mode 100644 Marketplace/Models/InstalledPackage.php create mode 100644 Marketplace/Models/MarketplaceLicence.php create mode 100644 Marketplace/Models/MarketplacePackage.php create mode 100644 Marketplace/Models/Publisher.php create mode 100644 Marketplace/Providers/MarketplaceServiceProvider.php create mode 100644 Marketplace/Providers/RouteServiceProvider.php create mode 100644 Marketplace/Resources/views/index.blade.php create mode 100644 Marketplace/Resources/views/installed.blade.php create mode 100644 Marketplace/Routes/api.php create mode 100644 Marketplace/Routes/web.php create mode 100644 Marketplace/Services/CompatibilityChecker.php create mode 100644 Marketplace/Services/DependencyResolver.php create mode 100644 Marketplace/Services/MarketplaceManager.php create mode 100644 Marketplace/Services/PackageManifest.php create mode 100644 Marketplace/Services/PackageVerifier.php create mode 100644 Marketplace/Services/VersionConstraint.php create mode 100644 Marketplace/Tests/Feature/MarketplaceModuleTest.php create mode 100644 Marketplace/module.json create mode 100644 Media/Config/config.php create mode 100644 Media/Database/Migrations/2026_07_23_000300_create_media_table.php create mode 100644 Media/Http/Controllers/MediaController.php create mode 100644 Media/Models/Media.php create mode 100644 Media/Providers/MediaServiceProvider.php create mode 100644 Media/Providers/RouteServiceProvider.php create mode 100644 Media/Resources/views/index.blade.php create mode 100644 Media/Routes/web.php create mode 100644 Media/Support/ModuleInfo.php create mode 100644 Media/Tests/Feature/MediaModuleTest.php create mode 100644 Media/composer.json create mode 100644 Media/module.json create mode 100644 Menus/Config/config.php create mode 100644 Menus/Database/Migrations/2026_07_24_000200_create_menus_table.php create mode 100644 Menus/Http/Controllers/MenuController.php create mode 100644 Menus/Models/Menu.php create mode 100644 Menus/Providers/MenusServiceProvider.php create mode 100644 Menus/Providers/RouteServiceProvider.php create mode 100644 Menus/Resources/views/admin/form.blade.php create mode 100644 Menus/Resources/views/admin/index.blade.php create mode 100644 Menus/Routes/web.php create mode 100644 Menus/Support/ModuleInfo.php create mode 100644 Menus/Tests/Feature/MenuEditorTest.php create mode 100644 Menus/Tests/Feature/ModuleBootTest.php create mode 100644 Menus/module.json create mode 100644 Modules/Licensing/Config/.gitkeep create mode 100644 Modules/Licensing/Config/config.php create mode 100644 Modules/Licensing/Console/.gitkeep create mode 100644 Modules/Licensing/Console/SonarDeskLicenseDeliver.php create mode 100755 Modules/Licensing/Console/SonarDeskLicenseIssue.php create mode 100755 Modules/Licensing/Console/SonarDeskLicenseRevoke.php create mode 100755 Modules/Licensing/Console/SonarDeskProductCreate.php create mode 100644 Modules/Licensing/Database/Migrations/.gitkeep create mode 100755 Modules/Licensing/Database/Migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php create mode 100755 Modules/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php create mode 100644 Modules/Licensing/Database/Seeders/.gitkeep create mode 100644 Modules/Licensing/Database/Seeders/LicensingDatabaseSeeder.php create mode 100644 Modules/Licensing/Database/factories/.gitkeep create mode 100644 Modules/Licensing/Helpers/.gitkeep create mode 100644 Modules/Licensing/Helpers/Helper.php create mode 100644 Modules/Licensing/Http/Controllers/.gitkeep create mode 100644 Modules/Licensing/Http/Controllers/Admin/LicensingDashboardController.php create mode 100755 Modules/Licensing/Http/Controllers/Api/LicenseActivationController.php create mode 100755 Modules/Licensing/Http/Controllers/Api/PaymentWebhookController.php create mode 100644 Modules/Licensing/Http/Controllers/LicensingController.php create mode 100755 Modules/Licensing/Http/Controllers/Portal/PortalController.php create mode 100644 Modules/Licensing/Http/Middleware/.gitkeep create mode 100644 Modules/Licensing/Http/Requests/.gitkeep create mode 100644 Modules/Licensing/Models/.gitkeep create mode 100755 Modules/Licensing/Models/License.php create mode 100755 Modules/Licensing/Models/LicenseActivation.php create mode 100755 Modules/Licensing/Models/LicenseDeliveryOutbox.php create mode 100755 Modules/Licensing/Models/LicenseProduct.php create mode 100755 Modules/Licensing/Models/LicenseWebhookEvent.php create mode 100644 Modules/Licensing/Providers/.gitkeep create mode 100644 Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php create mode 100644 Modules/Licensing/Providers/LicensingServiceProvider.php create mode 100644 Modules/Licensing/Providers/RouteServiceProvider.php create mode 100644 Modules/Licensing/Resources/assets/.gitkeep create mode 100644 Modules/Licensing/Resources/assets/js/app.js create mode 100644 Modules/Licensing/Resources/assets/sass/app.scss create mode 100644 Modules/Licensing/Resources/lang/.gitkeep create mode 100644 Modules/Licensing/Resources/views/.gitkeep create mode 100644 Modules/Licensing/Resources/views/admin/index.blade.php create mode 100644 Modules/Licensing/Resources/views/admin/layout.blade.php create mode 100644 Modules/Licensing/Resources/views/admin/licenses.blade.php create mode 100644 Modules/Licensing/Resources/views/admin/show.blade.php create mode 100644 Modules/Licensing/Resources/views/admin/subscriptions.blade.php create mode 100644 Modules/Licensing/Resources/views/index.blade.php create mode 100644 Modules/Licensing/Resources/views/layouts/master.blade.php create mode 100644 Modules/Licensing/Routes/.gitkeep create mode 100644 Modules/Licensing/Routes/admin.php create mode 100644 Modules/Licensing/Routes/api.php create mode 100644 Modules/Licensing/Routes/web.php create mode 100755 Modules/Licensing/Services/LicenseDeliveryService.php create mode 100755 Modules/Licensing/Services/LicenseIssuer.php create mode 100755 Modules/Licensing/Services/LicenseKey.php create mode 100755 Modules/Licensing/Services/LicenseSigner.php create mode 100755 Modules/Licensing/Services/LicenseTokenVerifier.php create mode 100644 Modules/Licensing/Support/.gitkeep create mode 100644 Modules/Licensing/Support/ModuleInfo.php create mode 100644 Modules/Licensing/Tests/Browser/.gitkeep create mode 100644 Modules/Licensing/Tests/Feature/.gitkeep create mode 100644 Modules/Licensing/Tests/Unit/.gitkeep create mode 100644 Modules/Licensing/Traits/.gitkeep create mode 100644 Modules/Licensing/View/.gitkeep create mode 100644 Modules/Licensing/composer.json create mode 100644 Modules/Licensing/module.json create mode 100644 Modules/Licensing/package.json create mode 100644 Modules/Licensing/webpack.mix.js create mode 100644 News/Config/config.php create mode 100644 News/Database/Migrations/2026_07_24_03042_create_news_articles_table.php create mode 100644 News/Http/Controllers/NewsController.php create mode 100644 News/Http/Controllers/PublicNewsController.php create mode 100644 News/Models/News.php create mode 100644 News/Providers/NewsServiceProvider.php create mode 100644 News/Providers/RouteServiceProvider.php create mode 100644 News/Resources/views/admin/form.blade.php create mode 100644 News/Resources/views/admin/index.blade.php create mode 100644 News/Resources/views/public/index.blade.php create mode 100644 News/Resources/views/public/show.blade.php create mode 100644 News/Routes/web.php create mode 100644 News/Tests/Feature/ModuleBootTest.php create mode 100644 News/module.json create mode 100644 Newsletter/Config/config.php create mode 100644 Newsletter/Database/Migrations/2026_07_24_04100_create_newsletter_tables.php create mode 100644 Newsletter/Http/Controllers/NewsletterController.php create mode 100644 Newsletter/Models/Campaign.php create mode 100644 Newsletter/Models/MailingList.php create mode 100644 Newsletter/Models/Subscriber.php create mode 100644 Newsletter/Providers/NewsletterServiceProvider.php create mode 100644 Newsletter/Providers/RouteServiceProvider.php create mode 100644 Newsletter/Resources/views/admin/index.blade.php create mode 100644 Newsletter/Routes/web.php create mode 100644 Newsletter/Tests/Feature/ModuleBootTest.php create mode 100644 Newsletter/module.json create mode 100644 Pages/Config/config.php create mode 100644 Pages/Database/Migrations/2026_07_24_000100_create_pages_table.php create mode 100644 Pages/Database/Migrations/2026_07_24_030700_enhance_pages_for_publishing.php create mode 100644 Pages/Http/Controllers/PageController.php create mode 100644 Pages/Http/Controllers/PublicPageController.php create mode 100644 Pages/Models/Page.php create mode 100644 Pages/Providers/PagesServiceProvider.php create mode 100644 Pages/Providers/RouteServiceProvider.php create mode 100644 Pages/Resources/views/admin/form.blade.php create mode 100644 Pages/Resources/views/admin/index.blade.php create mode 100644 Pages/Resources/views/public/show.blade.php create mode 100644 Pages/Routes/web.php create mode 100644 Pages/Tests/Feature/ModuleBootTest.php create mode 100644 Pages/module.json create mode 100644 Products/Config/config.php create mode 100644 Products/Database/Migrations/2026_07_24_105054_create_products_table.php create mode 100644 Products/Database/Migrations/2026_07_24_150000_add_pricing_fields_to_products_table.php create mode 100644 Products/Http/Controllers/ProductController.php create mode 100644 Products/Http/Requests/StoreProductRequest.php create mode 100644 Products/Models/Product.php create mode 100644 Products/Providers/ProductsServiceProvider.php create mode 100644 Products/Providers/RouteServiceProvider.php create mode 100644 Products/Resources/views/admin/products/form.blade.php create mode 100644 Products/Resources/views/admin/products/index.blade.php create mode 100644 Products/Routes/web.php create mode 100644 Products/Tests/Feature/ProductCrudTest.php create mode 100644 Products/Tests/Feature/ProductsModuleBootTest.php create mode 100644 Products/module.json create mode 100644 Redirects/Config/config.php create mode 100644 Redirects/Database/Migrations/2026_07_24_000300_create_redirects_table.php create mode 100644 Redirects/Http/Controllers/RedirectController.php create mode 100644 Redirects/Models/Redirect.php create mode 100644 Redirects/Providers/RedirectsServiceProvider.php create mode 100644 Redirects/Providers/RouteServiceProvider.php create mode 100644 Redirects/Resources/views/admin/form.blade.php create mode 100644 Redirects/Resources/views/admin/index.blade.php create mode 100644 Redirects/Routes/web.php create mode 100644 Redirects/Support/ModuleInfo.php create mode 100644 Redirects/Tests/Feature/ModuleBootTest.php create mode 100644 Redirects/module.json create mode 100644 Roles/Config/config.php create mode 100644 Roles/Database/Migrations/2026_07_23_000050_create_permission_tables.php create mode 100644 Roles/Database/Seeders/RolePermissionSeeder.php create mode 100644 Roles/Http/Controllers/RoleController.php create mode 100644 Roles/Providers/RolesServiceProvider.php create mode 100644 Roles/Providers/RouteServiceProvider.php create mode 100644 Roles/Resources/views/form.blade.php create mode 100644 Roles/Resources/views/index.blade.php create mode 100644 Roles/Routes/web.php create mode 100644 Roles/Support/ModuleInfo.php create mode 100644 Roles/Tests/Feature/RolesModuleTest.php create mode 100644 Roles/composer.json create mode 100644 Roles/module.json create mode 100644 Search/Config/config.php create mode 100644 Search/Database/Migrations/2026_07_23_000500_create_search_index_table.php create mode 100644 Search/Http/Controllers/SearchController.php create mode 100644 Search/Providers/RouteServiceProvider.php create mode 100644 Search/Providers/SearchServiceProvider.php create mode 100644 Search/Resources/views/index.blade.php create mode 100644 Search/Routes/web.php create mode 100644 Search/Support/ModuleInfo.php create mode 100644 Search/Tests/Feature/SearchModuleTest.php create mode 100644 Search/composer.json create mode 100644 Search/module.json create mode 100644 Security/Config/config.php create mode 100644 Security/Database/Migrations/2026_07_24_120000_create_security_tables.php create mode 100644 Security/Http/Controllers/SecurityController.php create mode 100644 Security/Http/Middleware/EnforceIpRestrictions.php create mode 100644 Security/Listeners/RecordFailedLogin.php create mode 100644 Security/Listeners/RecordLogin.php create mode 100644 Security/Listeners/RecordLogout.php create mode 100644 Security/Models/IpRule.php create mode 100644 Security/Models/LoginEvent.php create mode 100644 Security/Models/SecurityPolicy.php create mode 100644 Security/Models/TrustedDevice.php create mode 100644 Security/Providers/RouteServiceProvider.php create mode 100644 Security/Providers/SecurityServiceProvider.php create mode 100644 Security/Resources/views/admin/index.blade.php create mode 100644 Security/Routes/web.php create mode 100644 Security/Services/IpRuleService.php create mode 100644 Security/Tests/Feature/ModuleBootTest.php create mode 100644 Security/module.json create mode 100644 Seo/Config/config.php create mode 100644 Seo/Database/Migrations/2026_07_23_000400_create_seo_metadata_table.php create mode 100644 Seo/Http/Controllers/SeoController.php create mode 100644 Seo/Models/SeoMetadata.php create mode 100644 Seo/Providers/RouteServiceProvider.php create mode 100644 Seo/Providers/SeoServiceProvider.php create mode 100644 Seo/Resources/views/index.blade.php create mode 100644 Seo/Routes/web.php create mode 100644 Seo/Support/ModuleInfo.php create mode 100644 Seo/Tests/Feature/SeoModuleTest.php create mode 100644 Seo/composer.json create mode 100644 Seo/module.json create mode 100644 Settings/Config/config.php create mode 100644 Settings/Database/Migrations/2026_07_23_000100_create_settings_table.php create mode 100644 Settings/Http/Controllers/SettingController.php create mode 100644 Settings/Models/Setting.php create mode 100644 Settings/Providers/RouteServiceProvider.php create mode 100644 Settings/Providers/SettingsServiceProvider.php create mode 100644 Settings/Resources/views/index.blade.php create mode 100644 Settings/Routes/web.php create mode 100644 Settings/Services/SettingsRepository.php create mode 100644 Settings/Support/ModuleInfo.php create mode 100644 Settings/Tests/Feature/SettingsModuleTest.php create mode 100644 Settings/Tests/Feature/SiteSettingsSingleSourceTest.php create mode 100644 Settings/Tests/Feature/ThemeSelectionSettingsTest.php create mode 100644 Settings/composer.json create mode 100644 Settings/module.json create mode 100644 Sitemap/Config/config.php create mode 100644 Sitemap/Http/Controllers/SitemapController.php create mode 100644 Sitemap/Providers/RouteServiceProvider.php create mode 100644 Sitemap/Providers/SitemapServiceProvider.php create mode 100644 Sitemap/Resources/views/index.blade.php create mode 100644 Sitemap/Routes/web.php create mode 100644 Sitemap/Tests/Feature/ModuleBootTest.php create mode 100644 Sitemap/module.json create mode 100644 Sites/Concerns/BelongsToSites.php create mode 100644 Sites/Config/config.php create mode 100644 Sites/Contracts/SiteAwareModule.php create mode 100644 Sites/Database/Migrations/2026_07_24_070000_create_multisite_tables.php create mode 100644 Sites/Database/Seeders/SiteSeeder.php create mode 100644 Sites/Http/Controllers/DomainController.php create mode 100644 Sites/Http/Controllers/SiteController.php create mode 100644 Sites/Http/Middleware/ResolveSite.php create mode 100644 Sites/Models/Site.php create mode 100644 Sites/Models/SiteContent.php create mode 100644 Sites/Models/SiteDomain.php create mode 100644 Sites/Models/SiteSetting.php create mode 100644 Sites/Providers/RouteServiceProvider.php create mode 100644 Sites/Providers/SitesServiceProvider.php create mode 100644 Sites/Resources/views/admin/sites/form.blade.php create mode 100644 Sites/Resources/views/admin/sites/index.blade.php create mode 100644 Sites/Routes/web.php create mode 100644 Sites/Services/SiteManager.php create mode 100644 Sites/Tests/Feature/SitesModuleBootTest.php create mode 100644 Sites/module.json create mode 100644 Stylist/Config/config.php create mode 100644 Stylist/Console/CreateThemeCommand.php create mode 100644 Stylist/Console/FindDuplicateViewsCommand.php create mode 100644 Stylist/Console/InspectThemesCommand.php create mode 100644 Stylist/Console/PublishAssetsCommand.php create mode 100644 Stylist/Console/ThemeDoctorCommand.php create mode 100644 Stylist/Console/ValidateThemeCommand.php create mode 100644 Stylist/Database/Migrations/2026_07_08_000000_add_stylist_fields_to_companies_table.php create mode 100644 Stylist/Database/Migrations/2026_07_21_000001_create_stylist_theme_settings_table.php create mode 100644 Stylist/Diagnostics/ThemeInspector.php create mode 100644 Stylist/Facades/StylistFacade.php create mode 100644 Stylist/Facades/ThemeFacade.php create mode 100644 Stylist/Helpers/helpers.php create mode 100644 Stylist/Html/ThemeHtmlBuilder.php create mode 100644 Stylist/Html/ThemeSeoBuilder.php create mode 100644 Stylist/Http/Controllers/StylistController.php create mode 100644 Stylist/Http/Controllers/ThemeAssetController.php create mode 100644 Stylist/Http/Controllers/ThemeManagementController.php create mode 100644 Stylist/Livewire/ThemePreview.php create mode 100644 Stylist/Models/ThemeSetting.php create mode 100644 Stylist/Providers/RouteServiceProvider.php create mode 100644 Stylist/Providers/StylistServiceProvider.php create mode 100644 Stylist/README.md create mode 100644 Stylist/Resources/assets/css/app.css create mode 100644 Stylist/Resources/assets/js/app.js create mode 100644 Stylist/Resources/lang/en/messages.php create mode 100644 Stylist/Resources/views/index.blade.php create mode 100644 Stylist/Routes/assets.php create mode 100644 Stylist/Routes/web.php create mode 100644 Stylist/Services/FrontendContent.php create mode 100644 Stylist/Services/TenantThemeResolver.php create mode 100644 Stylist/Services/ThemeAccessibilityValidator.php create mode 100644 Stylist/Services/ThemeOptimizer.php create mode 100644 Stylist/Services/ThemePackageManager.php create mode 100644 Stylist/Services/ThemeSettings.php create mode 100644 Stylist/Services/ThemeValidator.php create mode 100644 Stylist/Support/FrontendNavigation.php create mode 100644 Stylist/Support/ModuleInfo.php create mode 100644 Stylist/Tests/Feature/CommonApplicationViewsTest.php create mode 100644 Stylist/Tests/Feature/LegacyThemeNamespaceAliasTest.php create mode 100644 Stylist/Tests/Feature/StylistModuleTest.php create mode 100644 Stylist/Tests/Feature/ThemeAssetControllerTest.php create mode 100644 Stylist/Tests/Feature/ThemeHtmlBuilderTest.php create mode 100644 Stylist/Tests/Feature/ThemeInheritanceTest.php create mode 100644 Stylist/Tests/Feature/ThemeManifestTest.php create mode 100644 Stylist/Tests/Feature/ThemePackageRoundTripTest.php create mode 100644 Stylist/Tests/Feature/ThemeViewResolverTest.php create mode 100644 Stylist/Tests/Feature/WelcomeThemeViewTest.php create mode 100644 Stylist/Theme/Exceptions/InvalidThemeManifestException.php create mode 100644 Stylist/Theme/Exceptions/ThemeJsonNotFoundException.php create mode 100644 Stylist/Theme/Exceptions/ThemeNotFoundException.php create mode 100644 Stylist/Theme/Json.php create mode 100644 Stylist/Theme/Loader.php create mode 100644 Stylist/Theme/ManifestValidator.php create mode 100644 Stylist/Theme/Stylist.php create mode 100644 Stylist/Theme/Theme.php create mode 100644 Stylist/Theme/UrlGenerator.php create mode 100644 Stylist/View/ThemeViewResolver.php create mode 100644 Stylist/changelog.yaml create mode 100644 Stylist/composer.json create mode 100644 Stylist/module.json create mode 100644 Stylist/package.json create mode 100644 Stylist/vite.config.js create mode 100644 Tags/Config/config.php create mode 100644 Tags/Database/Migrations/2026_07_24_030200_create_tags_table.php create mode 100644 Tags/Http/Controllers/TagController.php create mode 100644 Tags/Models/Tag.php create mode 100644 Tags/Providers/RouteServiceProvider.php create mode 100644 Tags/Providers/TagsServiceProvider.php create mode 100644 Tags/Resources/views/admin/form.blade.php create mode 100644 Tags/Resources/views/admin/index.blade.php create mode 100644 Tags/Routes/web.php create mode 100644 Tags/Tests/Feature/ModuleBootTest.php create mode 100644 Tags/module.json create mode 100644 Team/Config/config.php create mode 100644 Team/Database/Migrations/2026_07_24_000800_create_team_members_table.php create mode 100644 Team/Http/Controllers/TeamMemberController.php create mode 100644 Team/Models/TeamMember.php create mode 100644 Team/Providers/RouteServiceProvider.php create mode 100644 Team/Providers/TeamServiceProvider.php create mode 100644 Team/Resources/views/admin/form.blade.php create mode 100644 Team/Resources/views/admin/index.blade.php create mode 100644 Team/Routes/web.php create mode 100644 Team/Support/ModuleInfo.php create mode 100644 Team/Tests/Feature/ModuleBootTest.php create mode 100644 Team/module.json create mode 100644 Testimonials/Config/config.php create mode 100644 Testimonials/Database/Migrations/2026_07_24_000900_create_testimonials_table.php create mode 100644 Testimonials/Database/Migrations/2026_07_27_070000_add_content_to_testimonials_table.php create mode 100644 Testimonials/Http/Controllers/TestimonialController.php create mode 100644 Testimonials/Models/Testimonial.php create mode 100644 Testimonials/Providers/RouteServiceProvider.php create mode 100644 Testimonials/Providers/TestimonialsServiceProvider.php create mode 100644 Testimonials/Resources/views/admin/form.blade.php create mode 100644 Testimonials/Resources/views/admin/index.blade.php create mode 100644 Testimonials/Routes/web.php create mode 100644 Testimonials/Support/ModuleInfo.php create mode 100644 Testimonials/Tests/Feature/ModuleBootTest.php create mode 100644 Testimonials/module.json create mode 100644 Users/Config/config.php create mode 100644 Users/Http/Controllers/UserController.php create mode 100644 Users/Providers/RouteServiceProvider.php create mode 100644 Users/Providers/UsersServiceProvider.php create mode 100644 Users/Routes/web.php create mode 100644 Users/Support/ModuleInfo.php create mode 100644 Users/Tests/Feature/UsersModuleTest.php create mode 100644 Users/composer.json create mode 100644 Users/module.json create mode 100644 Workflow/Concerns/HasWorkflow.php create mode 100644 Workflow/Config/config.php create mode 100644 Workflow/Contracts/Workflowable.php create mode 100644 Workflow/Database/Migrations/2026_07_24_05000_create_workflow_tables.php create mode 100644 Workflow/Http/Controllers/WorkflowController.php create mode 100644 Workflow/Models/WorkflowDefinition.php create mode 100644 Workflow/Models/WorkflowHistory.php create mode 100644 Workflow/Models/WorkflowInstance.php create mode 100644 Workflow/Models/WorkflowStage.php create mode 100644 Workflow/Models/WorkflowTransition.php create mode 100644 Workflow/Providers/RouteServiceProvider.php create mode 100644 Workflow/Providers/WorkflowServiceProvider.php create mode 100644 Workflow/Resources/views/admin/index.blade.php create mode 100644 Workflow/Routes/web.php create mode 100644 Workflow/Services/WorkflowManager.php create mode 100644 Workflow/Tests/Feature/ModuleBootTest.php create mode 100644 Workflow/module.json create mode 100644 integration/PHASE5.md create mode 100644 integration/PHASE6.md create mode 100644 integration/PHASE7.md create mode 100644 tests/Feature/SonarDeskAdminAuthorizationTest.php diff --git a/AI/Config/config.php b/AI/Config/config.php new file mode 100644 index 0000000..573f94d --- /dev/null +++ b/AI/Config/config.php @@ -0,0 +1,9 @@ + env('AI_DEFAULT_PROVIDER'), + 'timeout' => (int) env('AI_TIMEOUT', 60), + 'max_output_tokens' => (int) env('AI_MAX_OUTPUT_TOKENS', 2048), + 'temperature' => (float) env('AI_TEMPERATURE', 0.3), + 'retain_executions_days' => (int) env('AI_RETAIN_EXECUTIONS_DAYS', 90), +]; diff --git a/AI/Console/PruneAIExecutions.php b/AI/Console/PruneAIExecutions.php new file mode 100644 index 0000000..5993517 --- /dev/null +++ b/AI/Console/PruneAIExecutions.php @@ -0,0 +1,22 @@ +option('days') ?: config('ai.retain_executions_days', 90)); + $count = AIExecution::where('created_at', '<', now()->subDays($days))->delete(); + $this->info("Deleted {$count} AI executions."); + + return self::SUCCESS; + } +} diff --git a/AI/Console/TestAIProvider.php b/AI/Console/TestAIProvider.php new file mode 100644 index 0000000..7b192bd --- /dev/null +++ b/AI/Console/TestAIProvider.php @@ -0,0 +1,21 @@ +testProvider((int) $this->argument('provider')); + $this->{$result['ok'] ? 'info' : 'error'}($result['message']); + + return $result['ok'] ? self::SUCCESS : self::FAILURE; + } +} diff --git a/AI/Contracts/AIManager.php b/AI/Contracts/AIManager.php new file mode 100644 index 0000000..d7cfe3a --- /dev/null +++ b/AI/Contracts/AIManager.php @@ -0,0 +1,13 @@ +id(); + $table->string('name'); + $table->string('driver', 40)->default('openai_compatible'); + $table->string('base_url')->nullable(); + $table->text('api_key')->nullable(); + $table->string('default_model')->nullable(); + $table->json('models')->nullable(); + $table->json('options')->nullable(); + $table->boolean('is_active')->default(true)->index(); + $table->boolean('is_default')->default(false)->index(); + $table->timestamp('last_tested_at')->nullable(); + $table->string('last_test_status', 20)->nullable(); + $table->text('last_test_message')->nullable(); + $table->timestamps(); + }); + Schema::create('ai_prompts', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('purpose')->nullable()->index(); + $table->text('system_prompt')->nullable(); + $table->longText('user_template'); + $table->json('variables')->nullable(); + $table->unsignedInteger('version')->default(1); + $table->boolean('is_active')->default(true)->index(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + Schema::create('ai_executions', function (Blueprint $table) { + $table->id(); + $table->uuid('execution_id')->unique(); + $table->foreignId('ai_provider_id')->nullable()->constrained('ai_providers')->nullOnDelete(); + $table->foreignId('ai_prompt_id')->nullable()->constrained('ai_prompts')->nullOnDelete(); + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('model')->nullable(); + $table->string('purpose')->nullable()->index(); + $table->longText('input')->nullable(); + $table->longText('output')->nullable(); + $table->json('context')->nullable(); + $table->json('usage')->nullable(); + $table->string('status', 20)->default('pending')->index(); + $table->text('error')->nullable(); + $table->unsignedInteger('duration_ms')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + }); + Schema::create('ai_knowledge_sources', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('source_kind', 40)->default('manual')->index(); + $table->nullableMorphs('source'); + $table->longText('content')->nullable(); + $table->string('checksum', 64)->nullable()->index(); + $table->json('metadata')->nullable(); + $table->string('status', 20)->default('pending')->index(); + $table->unsignedInteger('chunk_count')->default(0); + $table->timestamp('indexed_at')->nullable(); + $table->timestamps(); + }); + Schema::create('ai_knowledge_chunks', function (Blueprint $table) { + $table->id(); + $table->foreignId('ai_knowledge_source_id')->constrained('ai_knowledge_sources')->cascadeOnDelete(); + $table->unsignedInteger('position'); + $table->longText('content'); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->unique(['ai_knowledge_source_id', 'position']); + }); + } + + public function down(): void + { + Schema::dropIfExists('ai_knowledge_chunks'); + Schema::dropIfExists('ai_knowledge_sources'); + Schema::dropIfExists('ai_executions'); + Schema::dropIfExists('ai_prompts'); + Schema::dropIfExists('ai_providers'); + } +}; diff --git a/AI/Drivers/OpenAICompatibleDriver.php b/AI/Drivers/OpenAICompatibleDriver.php new file mode 100644 index 0000000..74a7d5a --- /dev/null +++ b/AI/Drivers/OpenAICompatibleDriver.php @@ -0,0 +1,36 @@ +timeout((int) config('ai.timeout', 60)); + + return filled($provider->api_key) ? $client->withToken($provider->api_key) : $client; + } + + public function generate(AIProvider $provider, array $messages, array $options = []): array + { + $base = rtrim((string) ($provider->base_url ?: 'https://api.openai.com/v1'), '/'); + $payload = ['model' => $options['model'] ?? $provider->default_model, 'messages' => $messages, 'temperature' => $options['temperature'] ?? config('ai.temperature', 0.3), 'max_tokens' => $options['max_tokens'] ?? config('ai.max_output_tokens', 2048)]; + $response = $this->client($provider)->post($base.'/chat/completions', array_filter($payload, fn ($v) => $v !== null)); + $response->throw(); + $json = $response->json(); + + return ['text' => (string) data_get($json, 'choices.0.message.content', ''), 'usage' => (array) data_get($json, 'usage', []), 'raw' => $json]; + } + + public function test(AIProvider $provider): array + { + $result = $this->generate($provider, [['role' => 'user', 'content' => 'Reply with OK only.']], ['max_tokens' => 8, 'temperature' => 0]); + + return ['ok' => true, 'message' => trim($result['text']) ?: 'Provider responded successfully.']; + } +} diff --git a/AI/Http/Controllers/AIApiController.php b/AI/Http/Controllers/AIApiController.php new file mode 100644 index 0000000..77000a1 --- /dev/null +++ b/AI/Http/Controllers/AIApiController.php @@ -0,0 +1,20 @@ +validate(['variables' => 'nullable|array', 'provider_id' => 'nullable|integer', 'model' => 'nullable|string|max:150']); + $execution = $manager->execute($prompt, $data['variables'] ?? [], array_filter(['provider_id' => $data['provider_id'] ?? null, 'model' => $data['model'] ?? null])); + + return response()->json(['data' => ['execution_id' => $execution->execution_id, 'status' => $execution->status, 'output' => $execution->output, 'usage' => $execution->usage, 'error' => $execution->error]], $execution->status === 'completed' ? 200 : 422); + } +} diff --git a/AI/Http/Controllers/AIController.php b/AI/Http/Controllers/AIController.php new file mode 100644 index 0000000..96c1f5f --- /dev/null +++ b/AI/Http/Controllers/AIController.php @@ -0,0 +1,69 @@ + AIProvider::latest()->get(), 'prompts' => AIPrompt::latest()->paginate(15, '*', 'prompts'), 'executions' => AIExecution::latest()->limit(15)->get(), 'sources' => AIKnowledgeSource::latest()->limit(15)->get()]); + } + + public function storeProvider(Request $request): RedirectResponse + { + $data = $request->validate(['name' => 'required|string|max:100', 'driver' => 'required|in:openai_compatible,openai,lm_studio,ollama', 'base_url' => 'nullable|url', 'api_key' => 'nullable|string', 'default_model' => 'required|string|max:150', 'is_default' => 'nullable|boolean']); + if ($request->boolean('is_default')) { + AIProvider::query()->update(['is_default' => false]); + } + $data['is_default'] = $request->boolean('is_default'); + AIProvider::create($data); + + return back()->with('status', 'AI provider created.'); + } + + public function testProvider(AIProvider $provider, AIManager $manager): RedirectResponse + { + $result = $manager->testProvider($provider->id); + + return back()->with($result['ok'] ? 'status' : 'error', $result['message']); + } + + public function storePrompt(Request $request): RedirectResponse + { + $data = $request->validate(['name' => 'required|string|max:120', 'slug' => 'nullable|alpha_dash|max:140', 'purpose' => 'nullable|string|max:100', 'system_prompt' => 'nullable|string', 'user_template' => 'required|string']); + $data['slug'] = $data['slug'] ?: Str::slug($data['name']); + $data['created_by'] = auth()->id(); + AIPrompt::create($data); + + return back()->with('status', 'Prompt created.'); + } + + public function execute(Request $request, AIPrompt $prompt, AIManager $manager): RedirectResponse + { + $data = $request->validate(['input' => 'required|string|max:50000', 'provider_id' => 'nullable|exists:ai_providers,id']); + $execution = $manager->execute($prompt, ['input' => $data['input']], ['provider_id' => $data['provider_id'] ?? null]); + + return back()->with($execution->status === 'completed' ? 'status' : 'error', $execution->status === 'completed' ? 'AI execution completed.' : $execution->error); + } + + public function storeSource(Request $request, KnowledgeIndexer $indexer): RedirectResponse + { + $data = $request->validate(['name' => 'required|string|max:150', 'content' => 'required|string|max:1000000']); + $source = AIKnowledgeSource::create(['name' => $data['name'], 'source_kind' => 'manual', 'content' => $data['content'], 'status' => 'pending']); + $indexer->index($source); + + return back()->with('status','Knowledge source indexed.'); + } +} diff --git a/AI/Models/AIExecution.php b/AI/Models/AIExecution.php new file mode 100644 index 0000000..62ea778 --- /dev/null +++ b/AI/Models/AIExecution.php @@ -0,0 +1,12 @@ + 'array', 'usage' => 'array', 'completed_at' => 'datetime']; +} diff --git a/AI/Models/AIKnowledgeChunk.php b/AI/Models/AIKnowledgeChunk.php new file mode 100644 index 0000000..632ed3c --- /dev/null +++ b/AI/Models/AIKnowledgeChunk.php @@ -0,0 +1,12 @@ + 'array']; +} diff --git a/AI/Models/AIKnowledgeSource.php b/AI/Models/AIKnowledgeSource.php new file mode 100644 index 0000000..f863a52 --- /dev/null +++ b/AI/Models/AIKnowledgeSource.php @@ -0,0 +1,24 @@ + 'array', 'indexed_at' => 'datetime']; + + public function source(): MorphTo + { + return $this->morphTo(); + } + + public function chunks(): HasMany + { + return $this->hasMany(AIKnowledgeChunk::class); + } +} diff --git a/AI/Models/AIPrompt.php b/AI/Models/AIPrompt.php new file mode 100644 index 0000000..5e1a23f --- /dev/null +++ b/AI/Models/AIPrompt.php @@ -0,0 +1,17 @@ + 'array', 'is_active' => 'boolean']; + + public function render(array $variables): string + { + return preg_replace_callback('/\{\{\s*([\w.]+)\s*\}\}/', fn ($m) => (string) data_get($variables, $m[1], ''), $this->user_template) ?? $this->user_template; + } +} diff --git a/AI/Models/AIProvider.php b/AI/Models/AIProvider.php new file mode 100644 index 0000000..f96e235 --- /dev/null +++ b/AI/Models/AIProvider.php @@ -0,0 +1,14 @@ + 'encrypted', 'models' => 'array', 'options' => 'array', 'is_active' => 'boolean', 'is_default' => 'boolean', 'last_tested_at' => 'datetime']; + + protected $hidden = ['api_key']; +} diff --git a/AI/Providers/AIServiceProvider.php b/AI/Providers/AIServiceProvider.php new file mode 100644 index 0000000..e7fec1b --- /dev/null +++ b/AI/Providers/AIServiceProvider.php @@ -0,0 +1,28 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('AI', 'Config/config.php'), 'ai'); + $this->app->singleton(AIManagerContract::class, AIManager::class); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('AI', 'Database/Migrations')); + $this->loadViewsFrom(module_path('AI', 'Resources/views'), 'ai'); + if ($this->app->runningInConsole()) { + $this->commands([TestAIProvider::class, PruneAIExecutions::class]); + } + } +} diff --git a/AI/Providers/RouteServiceProvider.php b/AI/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..4306ef6 --- /dev/null +++ b/AI/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +group(module_path('AI', 'Routes/web.php')); + Route::middleware('api')->prefix('api/v1')->group(module_path('AI', 'Routes/api.php')); + } +} diff --git a/AI/Resources/views/admin/index.blade.php b/AI/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..3948fa0 --- /dev/null +++ b/AI/Resources/views/admin/index.blade.php @@ -0,0 +1,21 @@ + +
+
AI PlatformConfigure hosted or local models, reusable prompts, execution history and knowledge sources.
+@if(session('status')){{ session('status') }}@endif +@if(session('error')){{ session('error') }}@endif +
+Providers{{ $providers->count() }} +Prompts{{ $prompts->total() }} +Executions{{ \Modules\AI\Models\AIExecution::count() }} +Knowledge sources{{ \Modules\AI\Models\AIKnowledgeSource::count() }} +
+
+Provider
@csrf + +Add provider +
@foreach($providers as $provider)
{{ $provider->name }}
{{ $provider->driver }} · {{ $provider->default_model }}
@csrfTest
@endforeach
+Reusable prompt
@csrf +Add prompt
+Knowledge source
@csrfIndex source
@foreach($sources as $source)
{{ $source->name }}
{{ $source->status }} · {{ $source->chunk_count }} chunks
@endforeach
+Recent executions
@forelse($executions as $execution)
{{ $execution->purpose ?: 'Prompt execution' }}{{ $execution->status }}
{{ $execution->model }} · {{ $execution->duration_ms }} ms · {{ $execution->created_at?->diffForHumans() }}
@emptyNo executions yet.@endforelse
+
diff --git a/AI/Routes/api.php b/AI/Routes/api.php new file mode 100644 index 0000000..775f54c --- /dev/null +++ b/AI/Routes/api.php @@ -0,0 +1,6 @@ +middleware(['auth.api', 'ability:ai.execute'])->name('api.ai.execute'); diff --git a/AI/Routes/web.php b/AI/Routes/web.php new file mode 100644 index 0000000..c2acf21 --- /dev/null +++ b/AI/Routes/web.php @@ -0,0 +1,13 @@ +prefix('administration/ai')->name('administration.ai.')->group(function () { + Route::get('/', [AIController::class, 'index'])->name('index'); + Route::post('/providers', [AIController::class, 'storeProvider'])->middleware('permission:ai.manage')->name('providers.store'); + Route::post('/providers/{provider}/test', [AIController::class, 'testProvider'])->middleware('permission:ai.manage')->name('providers.test'); + Route::post('/prompts', [AIController::class, 'storePrompt'])->middleware('permission:ai.manage')->name('prompts.store'); + Route::post('/prompts/{prompt}/execute', [AIController::class, 'execute'])->middleware('permission:ai.execute')->name('prompts.execute'); + Route::post('/knowledge', [AIController::class, 'storeSource'])->middleware('permission:ai.manage')->name('knowledge.store'); +}); diff --git a/AI/Services/AIManager.php b/AI/Services/AIManager.php new file mode 100644 index 0000000..2f1c2c0 --- /dev/null +++ b/AI/Services/AIManager.php @@ -0,0 +1,65 @@ +where('is_active', true)->firstOrFail() : $prompt; + $provider = $this->provider($options['provider_id'] ?? null); + $input = $promptModel->render($variables); + $execution = AIExecution::create(['execution_id' => (string) Str::uuid(), 'ai_provider_id' => $provider->id, 'ai_prompt_id' => $promptModel->id, 'user_id' => auth()->id(), 'model' => $options['model'] ?? $provider->default_model, 'purpose' => $promptModel->purpose, 'input' => $input, 'context' => $variables, 'status' => 'running']); + $started = microtime(true); + try { + $messages = []; + if (filled($promptModel->system_prompt)) { + $messages[] = ['role' => 'system', 'content' => $promptModel->system_prompt]; + } + $messages[] = ['role' => 'user', 'content' => $input]; + $result = $this->driver($provider)->generate($provider, $messages, $options); + $execution->update(['output' => $result['text'], 'usage' => $result['usage'], 'status' => 'completed', 'duration_ms' => (int) ((microtime(true) - $started) * 1000), 'completed_at' => now()]); + } catch (\Throwable $e) { + report($e); + $execution->update(['status' => 'failed', 'error' => $e->getMessage(), 'duration_ms' => (int) ((microtime(true) - $started) * 1000), 'completed_at' => now()]); + } + + return $execution->refresh(); + } + + public function testProvider(int $providerId): array + { + $provider = AIProvider::findOrFail($providerId); + try { + $result = $this->driver($provider)->test($provider); + $provider->update(['last_tested_at' => now(), 'last_test_status' => 'passed', 'last_test_message' => $result['message']]); + + return $result; + } catch (\Throwable $e) { + $provider->update(['last_tested_at' => now(), 'last_test_status' => 'failed', 'last_test_message' => $e->getMessage()]); + + return ['ok' => false, 'message' => $e->getMessage()]; + } + } + + private function provider(?int $id): AIProvider + { + return AIProvider::query()->when($id, fn ($q) => $q->whereKey($id))->when(! $id, fn ($q) => $q->where('is_default', true))->where('is_active', true)->first() + ?? AIProvider::where('is_active', true)->firstOrFail(); + } + + private function driver(AIProvider $provider): AIProviderDriver + { + return match ($provider->driver) { + 'openai_compatible','openai','lm_studio','ollama' => app(OpenAICompatibleDriver::class), default => throw new \InvalidArgumentException("Unsupported AI driver [{$provider->driver}].") + }; + } +} diff --git a/AI/Services/KnowledgeIndexer.php b/AI/Services/KnowledgeIndexer.php new file mode 100644 index 0000000..9c21d83 --- /dev/null +++ b/AI/Services/KnowledgeIndexer.php @@ -0,0 +1,39 @@ +content); + $source->chunks()->delete(); + $chunks = $content === '' ? [] : $this->split($content, max(500, $chunkSize)); + foreach ($chunks as $position => $chunk) { + $source->chunks()->create(['position' => $position + 1, 'content' => $chunk]); + } + $source->update(['checksum' => hash('sha256', $content), 'status' => 'indexed', 'chunk_count' => count($chunks), 'indexed_at' => now()]); + + return $source->refresh(); + } + + private function split(string $content, int $size): array + { + $paragraphs = preg_split('/\R{2,}/', $content) ?: [$content]; + $chunks = []; + $buffer = ''; + foreach ($paragraphs as $paragraph) { + if (strlen($buffer) + strlen($paragraph) + 2 > $size && $buffer !== '') { + $chunks[] = trim($buffer); + $buffer = ''; + } $buffer .= $paragraph."\n\n"; + } + if (trim($buffer) !== '') { + $chunks[] = trim($buffer); + } + +return $chunks; + } +} diff --git a/AI/Tests/Feature/ModuleBootTest.php b/AI/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..ebc1cac --- /dev/null +++ b/AI/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,16 @@ +assertTrue(class_exists(AIServiceProvider::class)); + $this->assertTrue(app()->bound(AIManager::class)); + } +} diff --git a/AI/module.json b/AI/module.json new file mode 100644 index 0000000..dda2c66 --- /dev/null +++ b/AI/module.json @@ -0,0 +1 @@ +{"name":"AI","alias":"ai","version":"1.0.0","description":"Provider-neutral artificial intelligence services, reusable prompts, execution history and knowledge sources.","keywords":["ai","llm","prompts","knowledge","rag"],"priority":140,"providers":["Modules\\AI\\Providers\\AIServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Users","Roles","Audit","Media","Documents","Automation"]} diff --git a/Administration/Config/config.php b/Administration/Config/config.php new file mode 100644 index 0000000..bb128b8 --- /dev/null +++ b/Administration/Config/config.php @@ -0,0 +1,6 @@ + 'Administration', + 'enabled' => true, +]; diff --git a/Administration/Http/Controllers/DashboardController.php b/Administration/Http/Controllers/DashboardController.php new file mode 100644 index 0000000..fd399d5 --- /dev/null +++ b/Administration/Http/Controllers/DashboardController.php @@ -0,0 +1,82 @@ +backend()->entryView(), [ + 'userCount' => User::count(), + 'roleCount' => Schema::hasTable('roles') ? Role::count() : 0, + 'mediaCount' => Schema::hasTable('media') ? Media::count() : 0, + 'auditCount' => Schema::hasTable('audit_logs') ? AuditLog::count() : 0, + 'recentAudit' => $this->recentAudit(), + 'modules' => $this->modules(), + ]); + } + + private function recentAudit(): LengthAwarePaginator + { + if (! Schema::hasTable('audit_logs')) { + return new LengthAwarePaginator( + items: [], + total: 0, + perPage: 8, + currentPage: Paginator::resolveCurrentPage('audit_page'), + options: [ + 'path' => request()->url(), + 'pageName' => 'audit_page', + ], + ); + } + + return AuditLog::query() + ->latest('created_at') + ->paginate(8, ['*'], 'audit_page') + ->withQueryString(); + } + + private function modules(): LengthAwarePaginator + { + $statuses = json_decode((string) file_get_contents(base_path('modules_statuses.json')), true) ?: []; + $modules = []; + + foreach (glob(base_path('Modules/*/module.json')) ?: [] as $manifest) { + $data = json_decode((string) file_get_contents($manifest), true) ?: []; + $name = $data['name'] ?? basename(dirname($manifest)); + + $modules[] = [ + 'name' => $name, + 'version' => $data['version'] ?? '1.0.0', + 'enabled' => (bool) ($statuses[$name] ?? false), + ]; + } + + usort($modules, fn (array $a, array $b): int => strcasecmp($a['name'], $b['name'])); + + $pageName = 'modules_page'; + $perPage = 8; + $currentPage = Paginator::resolveCurrentPage($pageName); + + return (new LengthAwarePaginator( + items: array_slice($modules, ($currentPage - 1) * $perPage, $perPage), + total: count($modules), + perPage: $perPage, + currentPage: $currentPage, + options: [ + 'path' => request()->url(), + 'pageName' => $pageName, + ], + ))->withQueryString(); + } +} diff --git a/Administration/Http/Controllers/ModuleController.php b/Administration/Http/Controllers/ModuleController.php new file mode 100644 index 0000000..e277d1c --- /dev/null +++ b/Administration/Http/Controllers/ModuleController.php @@ -0,0 +1,27 @@ +map(function (string $manifest) use ($statuses): array { + $data = json_decode((string) file_get_contents($manifest), true) ?: []; + $name = $data['name'] ?? basename(dirname($manifest)); + + return [ + 'name' => $name, + 'description' => $data['description'] ?? 'Laravel application module.', + 'version' => $data['version'] ?? '1.0.0', + 'enabled' => (bool) ($statuses[$name] ?? false), + 'providers' => $data['providers'] ?? [], + ]; + })->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE)->values(); + + return view('administration::modules.index', compact('modules')); + } +} diff --git a/Administration/Providers/AdministrationServiceProvider.php b/Administration/Providers/AdministrationServiceProvider.php new file mode 100644 index 0000000..9cf7292 --- /dev/null +++ b/Administration/Providers/AdministrationServiceProvider.php @@ -0,0 +1,21 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower()); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations')); + $this->loadViewsFrom(module_path(ModuleInfo::name(), 'Resources/views'), ModuleInfo::nameLower()); + } +} diff --git a/Administration/Providers/RouteServiceProvider.php b/Administration/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..2f44254 --- /dev/null +++ b/Administration/Providers/RouteServiceProvider.php @@ -0,0 +1,17 @@ +routes(function (): void { + Route::middleware('web')->group(module_path('Administration', 'Routes/web.php')); + }); + } +} diff --git a/Administration/Resources/views/modules/index.blade.php b/Administration/Resources/views/modules/index.blade.php new file mode 100644 index 0000000..69bbd42 --- /dev/null +++ b/Administration/Resources/views/modules/index.blade.php @@ -0,0 +1 @@ +

Modules

Installed platform modules and runtime status.

@foreach($modules as $module)@endforeach
ModuleVersionStatusProviders
{{ $module['name'] }}
{{ $module['description'] }}
{{ $module['version'] }}{{ $module['enabled'] ? 'Enabled' : 'Disabled' }}{{ implode(', ', $module['providers']) ?: '—' }}
diff --git a/Administration/Routes/web.php b/Administration/Routes/web.php new file mode 100644 index 0000000..ab08cb2 --- /dev/null +++ b/Administration/Routes/web.php @@ -0,0 +1,8 @@ +prefix('administration')->name('administration.')->group(function (): void { + Route::get('/modules', [ModuleController::class, 'index'])->name('modules.index'); +}); diff --git a/Administration/Support/ModuleInfo.php b/Administration/Support/ModuleInfo.php new file mode 100644 index 0000000..51ef6a8 --- /dev/null +++ b/Administration/Support/ModuleInfo.php @@ -0,0 +1,16 @@ +toBe('Administration'); +}); + +it('uses the primary dashboard for administration overview content', function (): void { + $dashboard = Route::getRoutes()->getByName('dashboard'); + + expect($dashboard)->not->toBeNull() + ->and($dashboard->getActionName())->toBe(DashboardController::class) + ->and(view()->exists('stylist-theme-admin::dashboard'))->toBeTrue() + ->and(Route::has('administration.dashboard'))->toBeFalse(); +}); diff --git a/Administration/composer.json b/Administration/composer.json new file mode 100644 index 0000000..3a6aff0 --- /dev/null +++ b/Administration/composer.json @@ -0,0 +1,10 @@ +{ + "name": "absdev/administration-module", + "description": "Administration foundation module for the ABSDEV Laravel application platform.", + "type": "laravel-module", + "autoload": { + "psr-4": { + "Modules\\Administration\\": "" + } + } +} diff --git a/Administration/module.json b/Administration/module.json new file mode 100644 index 0000000..74cfa82 --- /dev/null +++ b/Administration/module.json @@ -0,0 +1,20 @@ +{ + "name": "Administration", + "alias": "administration", + "version": "1.0.0", + "description": "Administration foundation module for the ABSDEV Laravel application platform.", + "keywords": [ + "laravel", + "modules", + "administration" + ], + "priority": 10, + "providers": [ + "Modules\\Administration\\Providers\\AdministrationServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [ + "Core" + ] +} diff --git a/Analytics/Config/config.php b/Analytics/Config/config.php new file mode 100644 index 0000000..b2d0b76 --- /dev/null +++ b/Analytics/Config/config.php @@ -0,0 +1,10 @@ + (bool) env('ANALYTICS_ENABLED', true), + 'respect_dnt' => (bool) env('ANALYTICS_RESPECT_DNT', true), + 'anonymize_ip' => (bool) env('ANALYTICS_ANONYMIZE_IP', true), + 'retention_days' => (int) env('ANALYTICS_RETENTION_DAYS', 365), + 'exclude_paths' => array_values(array_filter(array_map('trim', explode(',', (string) env('ANALYTICS_EXCLUDE_PATHS', 'administration,api'))))), + 'queue' => env('ANALYTICS_QUEUE', 'default'), +]; diff --git a/Analytics/Console/AggregateAnalytics.php b/Analytics/Console/AggregateAnalytics.php new file mode 100644 index 0000000..ae896e5 --- /dev/null +++ b/Analytics/Console/AggregateAnalytics.php @@ -0,0 +1,20 @@ +info('Metrics written: '.$aggregator->aggregate($this->option('date'))); + + return self::SUCCESS; + } +} diff --git a/Analytics/Console/PruneAnalytics.php b/Analytics/Console/PruneAnalytics.php new file mode 100644 index 0000000..ead8b95 --- /dev/null +++ b/Analytics/Console/PruneAnalytics.php @@ -0,0 +1,21 @@ +subDays(config('analytics.retention_days', 365)))->delete(); + $this->info("Deleted {$count} events."); + + return self::SUCCESS; + } +} diff --git a/Analytics/Console/RunScheduledReports.php b/Analytics/Console/RunScheduledReports.php new file mode 100644 index 0000000..e169620 --- /dev/null +++ b/Analytics/Console/RunScheduledReports.php @@ -0,0 +1,21 @@ +whereNotNull('next_run_at')->where('next_run_at', '<=', now())->update(['last_run_at' => now(), 'next_run_at' => now()->addDay()]); + $this->info("Processed {$count} scheduled reports."); + + return self::SUCCESS; + } +} diff --git a/Analytics/Database/Migrations/2026_07_24_150000_create_analytics_tables.php b/Analytics/Database/Migrations/2026_07_24_150000_create_analytics_tables.php new file mode 100644 index 0000000..4cea18d --- /dev/null +++ b/Analytics/Database/Migrations/2026_07_24_150000_create_analytics_tables.php @@ -0,0 +1,80 @@ +id(); + $table->uuid('event_id')->unique(); + $table->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete(); + $table->string('event_type', 40)->index(); + $table->string('path', 2048)->nullable(); + $table->string('title')->nullable(); + $table->string('referrer', 2048)->nullable(); + $table->string('visitor_hash', 64)->nullable()->index(); + $table->string('session_hash', 64)->nullable()->index(); + $table->string('ip_hash', 64)->nullable(); + $table->string('country_code', 2)->nullable()->index(); + $table->string('device_type', 20)->nullable()->index(); + $table->string('browser', 40)->nullable(); + $table->string('operating_system', 40)->nullable(); + $table->nullableMorphs('subject'); + $table->json('properties')->nullable(); + $table->timestamp('occurred_at')->index(); + $table->timestamps(); + $table->index(['site_id', 'event_type', 'occurred_at']); + }); + Schema::create('analytics_daily_metrics', function (Blueprint $table) { + $table->id(); + $table->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete(); + $table->date('metric_date')->index(); + $table->string('metric', 60); + $table->string('dimension', 120)->default('all'); + $table->unsignedBigInteger('value')->default(0); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->unique(['site_id', 'metric_date', 'metric', 'dimension'], 'analytics_daily_unique'); + }); + Schema::create('analytics_reports', function (Blueprint $table) { + $table->id(); + $table->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete(); + $table->string('name'); + $table->string('report_type', 40)->default('overview'); + $table->json('filters')->nullable(); + $table->json('columns')->nullable(); + $table->string('schedule')->nullable(); + $table->string('format', 10)->default('csv'); + $table->json('recipients')->nullable(); + $table->boolean('is_active')->default(true)->index(); + $table->timestamp('last_run_at')->nullable(); + $table->timestamp('next_run_at')->nullable()->index(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + Schema::create('analytics_report_runs', function (Blueprint $table) { + $table->id(); + $table->foreignId('analytics_report_id')->constrained('analytics_reports')->cascadeOnDelete(); + $table->string('status', 20)->default('pending')->index(); + $table->string('disk')->nullable(); + $table->string('path')->nullable(); + $table->unsignedBigInteger('row_count')->default(0); + $table->text('error')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('analytics_report_runs'); + Schema::dropIfExists('analytics_reports'); + Schema::dropIfExists('analytics_daily_metrics'); + Schema::dropIfExists('analytics_events'); + } +}; diff --git a/Analytics/Http/Controllers/AnalyticsController.php b/Analytics/Http/Controllers/AnalyticsController.php new file mode 100644 index 0000000..4552d84 --- /dev/null +++ b/Analytics/Http/Controllers/AnalyticsController.php @@ -0,0 +1,43 @@ +integer('days', 30))); + $from = now()->subDays($days - 1)->startOfDay(); + $events = AnalyticsEvent::query()->where('occurred_at', '>=', $from); + + return view('analytics::index', [ + 'days' => $days, 'pageViews' => $events->clone()->where('event_type', 'page_view')->count(), + 'visitors' => $events->clone()->whereNotNull('visitor_hash')->distinct('visitor_hash')->count('visitor_hash'), + 'conversions' => $events->clone()->where('event_type', 'conversion')->count(), + 'searches' => $events->clone()->where('event_type', 'search')->count(), + 'topPages' => $events->clone()->where('event_type', 'page_view')->selectRaw('path, count(*) as total')->groupBy('path')->orderByDesc('total')->limit(10)->get(), + 'recentEvents' => $events->clone()->latest('occurred_at')->limit(20)->get(), 'reports' => AnalyticsReport::latest()->limit(20)->get(), + ]); + } + + public function storeReport(Request $request): RedirectResponse + { + AnalyticsReport::create($request->validate(['name' => 'required|string|max:255', 'report_type' => 'required|in:overview,content,search,conversion,commerce', 'schedule' => 'nullable|string|max:50', 'format' => 'required|in:csv,json', 'is_active' => 'nullable|boolean']) + ['created_by' => $request->user()->id, 'is_active' => $request->boolean('is_active', true)]); + + return back()->with('status', 'Report created.'); + } + + public function destroyReport(AnalyticsReport $report): RedirectResponse + { + $report->delete(); + + return back()->with('status','Report deleted.'); + } +} diff --git a/Analytics/Http/Controllers/TrackingController.php b/Analytics/Http/Controllers/TrackingController.php new file mode 100644 index 0000000..0523987 --- /dev/null +++ b/Analytics/Http/Controllers/TrackingController.php @@ -0,0 +1,25 @@ +validate([ + 'event_type' => ['required', Rule::in(['page_view', 'search', 'conversion', 'content_view', 'form_submit', 'commerce'])], + 'path' => ['nullable', 'string', 'max:2048'], 'title' => ['nullable', 'string', 'max:255'], 'referrer' => ['nullable', 'string', 'max:2048'], + 'visitor_id' => ['nullable', 'string', 'max:255'], 'session_id' => ['nullable', 'string', 'max:255'], + 'subject_type' => ['nullable', 'string', 'max:255'], 'subject_id' => ['nullable', 'integer'], 'properties' => ['nullable', 'array'], + ]); + $event = $tracker->track($request, $data); + + return response()->json(['recorded' => (bool) $event], $event ? 201 : 202); + } +} diff --git a/Analytics/Models/AnalyticsEvent.php b/Analytics/Models/AnalyticsEvent.php new file mode 100644 index 0000000..389e863 --- /dev/null +++ b/Analytics/Models/AnalyticsEvent.php @@ -0,0 +1,12 @@ + 'array', 'occurred_at' => 'datetime']; +} diff --git a/Analytics/Models/AnalyticsReport.php b/Analytics/Models/AnalyticsReport.php new file mode 100644 index 0000000..3d8a81c --- /dev/null +++ b/Analytics/Models/AnalyticsReport.php @@ -0,0 +1,12 @@ + 'array', 'columns' => 'array', 'recipients' => 'array', 'is_active' => 'boolean', 'last_run_at' => 'datetime', 'next_run_at' => 'datetime']; +} diff --git a/Analytics/Models/AnalyticsReportRun.php b/Analytics/Models/AnalyticsReportRun.php new file mode 100644 index 0000000..53930be --- /dev/null +++ b/Analytics/Models/AnalyticsReportRun.php @@ -0,0 +1,12 @@ + 'datetime', 'completed_at' => 'datetime']; +} diff --git a/Analytics/Models/DailyMetric.php b/Analytics/Models/DailyMetric.php new file mode 100644 index 0000000..745d269 --- /dev/null +++ b/Analytics/Models/DailyMetric.php @@ -0,0 +1,14 @@ + 'date', 'metadata' => 'array']; +} diff --git a/Analytics/Providers/AnalyticsServiceProvider.php b/Analytics/Providers/AnalyticsServiceProvider.php new file mode 100644 index 0000000..ce1b3d5 --- /dev/null +++ b/Analytics/Providers/AnalyticsServiceProvider.php @@ -0,0 +1,32 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Analytics', 'Config/config.php'), 'analytics'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Analytics', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Analytics', 'Resources/views'), 'analytics'); + if ($this->app->runningInConsole()) { + $this->commands([AggregateAnalytics::class, PruneAnalytics::class, RunScheduledReports::class]); + $this->callAfterResolving(Schedule::class, function (Schedule $schedule): void { + $schedule->command('analytics:aggregate')->hourly()->withoutOverlapping(); + $schedule->command('analytics:reports')->everyFifteenMinutes()->withoutOverlapping(); + $schedule->command('analytics:prune')->dailyAt('02:30')->withoutOverlapping(); + }); + } + } +} diff --git a/Analytics/Providers/RouteServiceProvider.php b/Analytics/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..9d62eec --- /dev/null +++ b/Analytics/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +group(module_path('Analytics', 'Routes/web.php')); + Route::middleware('api')->prefix('api/v1')->group(module_path('Analytics', 'Routes/api.php')); + } +} diff --git a/Analytics/Resources/views/index.blade.php b/Analytics/Resources/views/index.blade.php new file mode 100644 index 0000000..66e7cd8 --- /dev/null +++ b/Analytics/Resources/views/index.blade.php @@ -0,0 +1,14 @@ + +
+
AnalyticsVisitor, content, search and conversion performance.
+ @if(session('status')){{ session('status') }}@endif +
+ @foreach([['Page views',$pageViews],['Visitors',$visitors],['Conversions',$conversions],['Searches',$searches]] as [$label,$value])
{{ $label }}
{{ number_format($value) }}
@endforeach +
+
+
Top pages
@forelse($topPages as $page)
{{ $page->path ?: '/' }}{{ number_format($page->total) }}
@empty

No page views recorded.

@endforelse
+
Recent events
@forelse($recentEvents as $event)
{{ str($event->event_type)->replace('_',' ')->title() }}{{ $event->occurred_at?->diffForHumans() }}

{{ $event->path ?: data_get($event->properties,'query','—') }}

@empty

No events recorded.

@endforelse
+
+ @can('analytics.manage')
Scheduled reports
@csrf
Create report
@forelse($reports as $report)
{{ $report->name }}

{{ ucfirst($report->report_type) }} · {{ strtoupper($report->format) }}

@csrf @method('DELETE')Delete
@empty

No reports configured.

@endforelse
@endcan +
+
diff --git a/Analytics/Routes/api.php b/Analytics/Routes/api.php new file mode 100644 index 0000000..4ae8da7 --- /dev/null +++ b/Analytics/Routes/api.php @@ -0,0 +1,6 @@ +middleware('throttle:60,1')->name('api.analytics.events.store'); diff --git a/Analytics/Routes/web.php b/Analytics/Routes/web.php new file mode 100644 index 0000000..5a5437e --- /dev/null +++ b/Analytics/Routes/web.php @@ -0,0 +1,10 @@ +prefix('administration/analytics')->name('administration.analytics.')->group(function () { + Route::get('/', [AnalyticsController::class, 'index'])->name('index'); + Route::post('/reports', [AnalyticsController::class, 'storeReport'])->middleware('permission:analytics.manage')->name('reports.store'); + Route::delete('/reports/{report}', [AnalyticsController::class, 'destroyReport'])->middleware('permission:analytics.manage')->name('reports.destroy'); +}); diff --git a/Analytics/Services/AnalyticsAggregator.php b/Analytics/Services/AnalyticsAggregator.php new file mode 100644 index 0000000..0f08c0d --- /dev/null +++ b/Analytics/Services/AnalyticsAggregator.php @@ -0,0 +1,32 @@ +subHour()->toDateString(); + $written = 0; + $base = AnalyticsEvent::query()->whereDate('occurred_at', $date); + foreach ($base->clone()->select('site_id')->distinct()->pluck('site_id') as $siteId) { + $query = $base->clone()->where('site_id', $siteId); + $metrics = [ + ['page_views', 'all', $query->clone()->where('event_type', 'page_view')->count()], + ['visitors', 'all', $query->clone()->whereNotNull('visitor_hash')->distinct('visitor_hash')->count('visitor_hash')], + ['sessions', 'all', $query->clone()->whereNotNull('session_hash')->distinct('session_hash')->count('session_hash')], + ['conversions', 'all', $query->clone()->where('event_type', 'conversion')->count()], + ['searches', 'all', $query->clone()->where('event_type', 'search')->count()], + ]; + foreach ($metrics as [$metric,$dimension,$value]) { + DailyMetric::updateOrCreate(['site_id' => $siteId, 'metric_date' => $date, 'metric' => $metric, 'dimension' => $dimension], ['value' => $value]); + $written++; + } + } + + return $written; + } +} diff --git a/Analytics/Services/AnalyticsTracker.php b/Analytics/Services/AnalyticsTracker.php new file mode 100644 index 0000000..4c37c77 --- /dev/null +++ b/Analytics/Services/AnalyticsTracker.php @@ -0,0 +1,61 @@ +header('DNT') === '1') { + return null; + } + + $path = ltrim((string) ($payload['path'] ?? ''), '/'); + + foreach (config('analytics.exclude_paths', []) as $excluded) { + if ($excluded !== '' && str_starts_with($path, trim($excluded, '/'))) { + return null; + } + } + + $ip = (string) $request->ip(); + $visitorIdentifier = (string) ($payload['visitor_id'] ?? $request->cookie('analytics_visitor', 'guest')); + $sessionIdentifier = (string) ($payload['session_id'] ?? $this->sessionIdentifier($request, $visitorIdentifier)); + + return AnalyticsEvent::create([ + 'event_id' => (string) Str::uuid(), + 'site_id' => app()->bound('currentSite') ? app('currentSite')?->id : null, + 'event_type' => $payload['event_type'] ?? 'page_view', + 'path' => '/'.$path, + 'title' => $payload['title'] ?? null, + 'referrer' => $payload['referrer'] ?? $request->headers->get('referer'), + 'visitor_hash' => hash('sha256', $visitorIdentifier), + 'session_hash' => hash('sha256', $sessionIdentifier), + 'ip_hash' => config('analytics.anonymize_ip') ? hash('sha256', $ip.config('app.key')) : $ip, + 'device_type' => $payload['device_type'] ?? null, + 'browser' => $payload['browser'] ?? null, + 'operating_system' => $payload['operating_system'] ?? null, + 'subject_type' => $payload['subject_type'] ?? null, + 'subject_id' => $payload['subject_id'] ?? null, + 'properties' => $payload['properties'] ?? [], + 'occurred_at' => now(), + ]); + } + + private function sessionIdentifier(Request $request, string $visitorIdentifier): string + { + if ($request->hasSession()) { + return $request->session()->getId(); + } + + return (string) ($request->header('X-Analytics-Session') ?: $visitorIdentifier ?: 'stateless'); + } +} diff --git a/Analytics/Tests/Feature/AnalyticsModuleTest.php b/Analytics/Tests/Feature/AnalyticsModuleTest.php new file mode 100644 index 0000000..ce99fab --- /dev/null +++ b/Analytics/Tests/Feature/AnalyticsModuleTest.php @@ -0,0 +1,17 @@ +postJson('/api/v1/analytics/events', ['event_type' => 'page_view', 'path' => '/about'])->assertCreated()->assertJson(['recorded' => true]); + $this->assertDatabaseHas('analytics_events', ['event_type' => 'page_view', 'path' => '/about']); + } +} diff --git a/Analytics/module.json b/Analytics/module.json new file mode 100644 index 0000000..7afb408 --- /dev/null +++ b/Analytics/module.json @@ -0,0 +1 @@ +{"name":"Analytics","alias":"analytics","version":"1.0.0","description":"Privacy-conscious visitor, content, search, conversion and report analytics.","keywords":["analytics","reports","metrics","visitors","conversions"],"priority":150,"providers":["Modules\\Analytics\\Providers\\AnalyticsServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Users","Roles","Sites","Search","Forms","Newsletter","Commerce"]} diff --git a/Api/Config/config.php b/Api/Config/config.php new file mode 100644 index 0000000..b11bfae --- /dev/null +++ b/Api/Config/config.php @@ -0,0 +1,3 @@ + 'api/v1', 'token_prefix' => 'cms_', 'pagination' => 20, 'webhook_timeout' => 10, 'openapi' => ['title' => env('APP_NAME', 'Content Manager').' API', 'version' => '1.0.0']]; diff --git a/Api/Database/Migrations/2026_07_24_090000_create_api_tokens_table.php b/Api/Database/Migrations/2026_07_24_090000_create_api_tokens_table.php new file mode 100644 index 0000000..bb0d7b7 --- /dev/null +++ b/Api/Database/Migrations/2026_07_24_090000_create_api_tokens_table.php @@ -0,0 +1,27 @@ +id(); + $t->foreignId('user_id')->constrained()->cascadeOnDelete(); + $t->string('name'); + $t->string('token', 64)->unique(); + $t->json('abilities')->nullable(); + $t->timestamp('last_used_at')->nullable(); + $t->timestamp('expires_at')->nullable(); + $t->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('api_tokens'); + } +}; diff --git a/Api/Database/Migrations/2026_07_24_090100_create_webhooks_tables.php b/Api/Database/Migrations/2026_07_24_090100_create_webhooks_tables.php new file mode 100644 index 0000000..113840c --- /dev/null +++ b/Api/Database/Migrations/2026_07_24_090100_create_webhooks_tables.php @@ -0,0 +1,43 @@ +id(); + $t->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete(); + $t->string('name'); + $t->string('url'); + $t->string('secret'); + $t->json('events'); + $t->boolean('is_active')->default(true); + $t->unsignedSmallInteger('failure_count')->default(0); + $t->timestamp('last_triggered_at')->nullable(); + $t->timestamps(); + }); + Schema::create('webhook_deliveries', function (Blueprint $t): void { + $t->id(); + $t->foreignId('webhook_id')->constrained()->cascadeOnDelete(); + $t->uuid('uuid')->unique(); + $t->string('event'); + $t->json('payload'); + $t->unsignedSmallInteger('response_status')->nullable(); + $t->text('response_body')->nullable(); + $t->unsignedSmallInteger('attempts')->default(0); + $t->timestamp('delivered_at')->nullable(); + $t->timestamp('failed_at')->nullable(); + $t->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('webhook_deliveries'); + Schema::dropIfExists('webhooks'); + } +}; diff --git a/Api/Http/Controllers/ApiController.php b/Api/Http/Controllers/ApiController.php new file mode 100644 index 0000000..fbe22bf --- /dev/null +++ b/Api/Http/Controllers/ApiController.php @@ -0,0 +1,49 @@ + ApiToken::with('user')->latest()->paginate(20, '*', 'tokens'), 'webhooks' => Webhook::latest()->paginate(20, '*', 'webhooks')]); + } + + public function storeToken(Request $r): RedirectResponse + { + $d = $r->validate(['name' => 'required|string|max:255', 'abilities' => 'nullable|string', 'expires_at' => 'nullable|date|after:now']); + $abilities = array_values(array_filter(array_map('trim', explode(',', $d['abilities'] ?: '*')))); + [,$plain] = ApiToken::issue($r->user(), $d['name'], $abilities, isset($d['expires_at']) ? new \DateTimeImmutable($d['expires_at']) : null); + + return back()->with('api_token', $plain)->with('status', 'API token created.'); + } + + public function destroyToken(ApiToken $token): RedirectResponse + { + $token->delete(); + + return back()->with('status', 'API token revoked.'); + } + + public function storeWebhook(Request $r): RedirectResponse + { + $d = $r->validate(['name' => 'required|string|max:255', 'url' => 'required|url|max:2048', 'events' => 'required|string', 'site_id' => 'nullable|integer|exists:sites,id']); + Webhook::create(['name' => $d['name'], 'url' => $d['url'], 'events' => array_values(array_filter(array_map('trim', explode(',', $d['events'])))), 'site_id' => $d['site_id'] ?? null, 'secret' => Str::random(48), 'is_active' => true]); + + return back()->with('status', 'Webhook created.'); + } + + public function destroyWebhook(Webhook $webhook): RedirectResponse + { + $webhook->delete(); + + return back()->with('status','Webhook deleted.'); + } +} diff --git a/Api/Http/Controllers/ContentController.php b/Api/Http/Controllers/ContentController.php new file mode 100644 index 0000000..5be30a0 --- /dev/null +++ b/Api/Http/Controllers/ContentController.php @@ -0,0 +1,54 @@ +many(Page::query()->where('status', 'published')); + } + + public function blog(): AnonymousResourceCollection + { + return $this->many(Blog::query()->published()); + } + + public function news(): AnonymousResourceCollection + { + return $this->many(News::query()->published()); + } + + public function page(Page $page): ContentResource + { + abort_unless($page->status === 'published', 404); + + return new ContentResource($page); + } + + public function blogPost(Blog $blog): ContentResource + { + abort_unless(Blog::query()->published()->whereKey($blog)->exists(), 404); + + return new ContentResource($blog); + } + + public function newsArticle(News $news): ContentResource + { + abort_unless(News::query()->published()->whereKey($news)->exists(), 404); + + return new ContentResource($news); + } + + private function many(Builder $q): AnonymousResourceCollection + { + return ContentResource::collection($q->latest('updated_at')->paginate((int) config('api.pagination', 20))); + } +} diff --git a/Api/Http/Controllers/OpenApiController.php b/Api/Http/Controllers/OpenApiController.php new file mode 100644 index 0000000..f609f27 --- /dev/null +++ b/Api/Http/Controllers/OpenApiController.php @@ -0,0 +1,13 @@ +json(['openapi' => '3.1.0', 'info' => config('api.openapi'), 'servers' => [['url' => url('/'.config('api.prefix', 'api/v1'))]], 'components' => ['securitySchemes' => ['bearerAuth' => ['type' => 'http', 'scheme' => 'bearer']]], 'paths' => ['/pages' => ['get' => ['summary' => 'List published pages', 'responses' => ['200' => ['description' => 'OK']]]], '/blog' => ['get' => ['summary' => 'List published blog posts', 'responses' => ['200' => ['description' => 'OK']]]], '/news' => ['get' => ['summary' => 'List published news', 'responses' => ['200' => ['description' => 'OK']]]], '/user' => ['get' => ['summary' => 'Authenticated user', 'security' => [['bearerAuth' => []]], 'responses' => ['200' => ['description' => 'OK'], '401' => ['description' => 'Unauthenticated']]]]]]); + } +} diff --git a/Api/Http/Middleware/AuthenticateApiToken.php b/Api/Http/Middleware/AuthenticateApiToken.php new file mode 100644 index 0000000..236b1b5 --- /dev/null +++ b/Api/Http/Middleware/AuthenticateApiToken.php @@ -0,0 +1,28 @@ +bearerToken(); + if (! $plain) { + return response()->json(['message' => 'Unauthenticated.'], 401); + }$token = ApiToken::with('user')->where('token', hash('sha256', $plain))->first(); + if (! $token || ($token->expires_at && $token->expires_at->isPast()) || ! $token->user) { + return response()->json(['message' => 'Invalid or expired API token.'], 401); + }if ($ability && ! $token->allows($ability)) { + return response()->json(['message' => 'Insufficient token ability.'], 403); + }$token->update(['last_used_at' => now()]); + auth()->setUser($token->user); + $r->attributes->set('apiToken', $token); + + return $next($r); + } +} diff --git a/Api/Http/Resources/ContentResource.php b/Api/Http/Resources/ContentResource.php new file mode 100644 index 0000000..1ac7b0a --- /dev/null +++ b/Api/Http/Resources/ContentResource.php @@ -0,0 +1,14 @@ + $this->id, 'type' => $this->resource->getTable(), 'title' => $this->title, 'slug' => $this->slug, 'excerpt' => $this->excerpt ?? null, 'content' => $this->content ?? null, 'status' => $this->status ?? null, 'featured_image' => $this->featured_image ?? null, 'published_at' => $this->published_at ?? null, 'updated_at' => $this->updated_at]; + } +} diff --git a/Api/Models/ApiToken.php b/Api/Models/ApiToken.php new file mode 100644 index 0000000..6de3874 --- /dev/null +++ b/Api/Models/ApiToken.php @@ -0,0 +1,40 @@ + 'array', 'last_used_at' => 'datetime', 'expires_at' => 'datetime']; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public static function issue(User $u, string $name, array $abilities = ['*'], ?\DateTimeInterface $expires = null): array + { + $plain = config('api.token_prefix', 'cms_').Str::random(64); + $record = self::query()->create(['user_id' => $u->id, 'name' => $name, 'token' => hash('sha256', $plain), 'abilities' => $abilities, 'expires_at' => $expires]); + + return [$record, $plain]; + } + + public function allows(string $ability): bool + { + $a = $this->abilities ?: []; + + return in_array('*', $a, true) || in_array($ability, $a, true); + } +} diff --git a/Api/Models/Webhook.php b/Api/Models/Webhook.php new file mode 100644 index 0000000..28d08ef --- /dev/null +++ b/Api/Models/Webhook.php @@ -0,0 +1,28 @@ + 'array', 'is_active' => 'boolean', 'last_triggered_at' => 'datetime']; + } + + public function deliveries(): HasMany + { + return $this->hasMany(WebhookDelivery::class); + } + + public function subscribesTo(string $event): bool + { + return in_array('*', $this->events ?: [], true) || in_array($event, $this->events ?: [], true); + } +} diff --git a/Api/Models/WebhookDelivery.php b/Api/Models/WebhookDelivery.php new file mode 100644 index 0000000..a07b1a4 --- /dev/null +++ b/Api/Models/WebhookDelivery.php @@ -0,0 +1,21 @@ + 'array', 'delivered_at' => 'datetime', 'failed_at' => 'datetime']; + } + + public function webhook(): BelongsTo + { + return $this->belongsTo(Webhook::class); + } +} diff --git a/Api/Providers/ApiServiceProvider.php b/Api/Providers/ApiServiceProvider.php new file mode 100644 index 0000000..1f1122c --- /dev/null +++ b/Api/Providers/ApiServiceProvider.php @@ -0,0 +1,22 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Api', 'Config/config.php'), 'api'); + $this->app->singleton(WebhookDispatcher::class); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Api', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Api', 'Resources/views'), 'api'); + } +} diff --git a/Api/Providers/RouteServiceProvider.php b/Api/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..f6699cb --- /dev/null +++ b/Api/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +prefix(config('api.prefix', 'api/v1'))->name('api.v1.')->group(module_path('Api', 'Routes/api.php')); + Route::middleware('web')->group(module_path('Api', 'Routes/web.php')); + } +} diff --git a/Api/Resources/views/admin/index.blade.php b/Api/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..b5d5117 --- /dev/null +++ b/Api/Resources/views/admin/index.blade.php @@ -0,0 +1 @@ +

{{ __('API & Webhooks') }}

{{ __('Manage headless access, bearer tokens and webhook subscriptions.') }}

@if(session('status')){{ session('status') }}@endif @if(session('api_token')){{ __('Copy this token now') }}
{{ session('api_token') }}
@endif

{{ __('Create token') }}

@csrf{{ __('Create') }}

{{ __('Create webhook') }}

@csrf{{ __('Create') }}

{{ __('Tokens') }}

OpenAPI JSON
@forelse($tokens as $token)
{{ $token->name }} {{ implode(', ', $token->abilities ?? []) }}
@csrf @method('DELETE'){{ __('Revoke') }}
@empty

{{ __('No tokens.') }}

@endforelse{{ $tokens->links() }}

{{ __('Webhooks') }}

@forelse($webhooks as $webhook)
{{ $webhook->name }} {{ $webhook->url }}
@csrf @method('DELETE'){{ __('Delete') }}
@empty

{{ __('No webhooks.') }}

@endforelse{{ $webhooks->links() }}
diff --git a/Api/Routes/api.php b/Api/Routes/api.php new file mode 100644 index 0000000..63c733c --- /dev/null +++ b/Api/Routes/api.php @@ -0,0 +1,18 @@ +name('openapi'); +Route::controller(ContentController::class)->group(function (): void { + Route::get('/pages', 'pages'); + Route::get('/pages/{page}', 'page'); + Route::get('/blog', 'blog'); + Route::get('/blog/{blog}', 'blogPost'); + Route::get('/news', 'news'); + Route::get('/news/{news}', 'newsArticle'); +}); +Route::middleware(AuthenticateApiToken::class)->get('/user', fn (Request $r) => $r->user()); diff --git a/Api/Routes/web.php b/Api/Routes/web.php new file mode 100644 index 0000000..1b2d91a --- /dev/null +++ b/Api/Routes/web.php @@ -0,0 +1,12 @@ +prefix('administration/api')->name('administration.api.')->controller(ApiController::class)->group(function (): void { + Route::get('/', 'index')->name('index'); + Route::post('/tokens', 'storeToken')->name('tokens.store'); + Route::delete('/tokens/{token}', 'destroyToken')->name('tokens.destroy'); + Route::post('/webhooks', 'storeWebhook')->name('webhooks.store'); + Route::delete('/webhooks/{webhook}', 'destroyWebhook')->name('webhooks.destroy'); +}); diff --git a/Api/Services/WebhookDispatcher.php b/Api/Services/WebhookDispatcher.php new file mode 100644 index 0000000..05da44d --- /dev/null +++ b/Api/Services/WebhookDispatcher.php @@ -0,0 +1,28 @@ +get()->filter(fn (Webhook $h) => ($h->site_id === null || $h->site_id === $siteId) && $h->subscribesTo($event))->each(function (Webhook $h) use ($event, $payload): void { + $uuid = (string) Str::uuid(); + $json = json_encode($payload, JSON_UNESCAPED_SLASHES) ?: '{}'; + $delivery = WebhookDelivery::create(['webhook_id' => $h->id, 'uuid' => $uuid, 'event' => $event, 'payload' => $payload, 'attempts' => 1]); + try { + $res = Http::timeout((int) config('api.webhook_timeout', 10))->withHeaders(['X-CMS-Event' => $event, 'X-CMS-Delivery' => $uuid, 'X-CMS-Signature' => 'sha256='.hash_hmac('sha256', $json, $h->getRawOriginal('secret'))])->withBody($json, 'application/json')->post($h->url); + $delivery->update(['response_status' => $res->status(), 'response_body' => Str::limit($res->body(), 10000), 'delivered_at' => $res->successful() ? now() : null, 'failed_at' => $res->successful() ? null : now()]); + $h->update(['last_triggered_at' => now(), 'failure_count' => $res->successful() ? 0 : $h->failure_count + 1]); + } catch (\Throwable $e) { + $delivery->update(['response_body' => $e->getMessage(), 'failed_at' => now()]); + $h->increment('failure_count'); + } + }); + } +} diff --git a/Api/Tests/Feature/ApiModuleBootTest.php b/Api/Tests/Feature/ApiModuleBootTest.php new file mode 100644 index 0000000..f3b4f0a --- /dev/null +++ b/Api/Tests/Feature/ApiModuleBootTest.php @@ -0,0 +1,13 @@ + expect(app()->providerIsLoaded(ApiServiceProvider::class))->toBeTrue()); +it('hashes issued api tokens', function (): void { + $u = User::factory()->create(); + [$t,$plain] = ApiToken::issue($u, 'Test', ['content.read']); + expect($t->getRawOriginal('token'))->toBe(hash('sha256', $plain))->not->toBe($plain); +}); +it('publishes openapi documentation', fn () => $this->getJson('/api/v1/openapi.json')->assertOk()->assertJsonPath('openapi', '3.1.0')); diff --git a/Api/module.json b/Api/module.json new file mode 100644 index 0000000..e9574cd --- /dev/null +++ b/Api/module.json @@ -0,0 +1 @@ +{"name":"Api","alias":"api","version":"1.0.0","description":"REST APIs, bearer tokens, webhooks and OpenAPI.","keywords":["api","headless","webhooks"],"priority":90,"providers":["Modules\\Api\\Providers\\ApiServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Sites"]} diff --git a/Audit/Config/config.php b/Audit/Config/config.php new file mode 100644 index 0000000..b43f43a --- /dev/null +++ b/Audit/Config/config.php @@ -0,0 +1,6 @@ + 'Audit', + 'enabled' => true, +]; diff --git a/Audit/Database/Migrations/2026_07_23_000200_create_audit_logs_table.php b/Audit/Database/Migrations/2026_07_23_000200_create_audit_logs_table.php new file mode 100644 index 0000000..81771b0 --- /dev/null +++ b/Audit/Database/Migrations/2026_07_23_000200_create_audit_logs_table.php @@ -0,0 +1,28 @@ +id(); + $table->nullableMorphs('actor'); + $table->string('event')->index(); + $table->nullableMorphs('auditable'); + $table->json('old_values')->nullable(); + $table->json('new_values')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->timestamp('created_at')->useCurrent(); + }); + } + + public function down(): void + { + Schema::dropIfExists('audit_logs'); + } +}; diff --git a/Audit/Http/Controllers/AuditController.php b/Audit/Http/Controllers/AuditController.php new file mode 100644 index 0000000..f28a59b --- /dev/null +++ b/Audit/Http/Controllers/AuditController.php @@ -0,0 +1,14 @@ + AuditLog::query()->latest('created_at')->paginate(50)]); + } +} diff --git a/Audit/Models/AuditLog.php b/Audit/Models/AuditLog.php new file mode 100644 index 0000000..6dce2ae --- /dev/null +++ b/Audit/Models/AuditLog.php @@ -0,0 +1,17 @@ + 'array', 'new_values' => 'array', 'created_at' => 'datetime']; + } +} diff --git a/Audit/Providers/AuditServiceProvider.php b/Audit/Providers/AuditServiceProvider.php new file mode 100644 index 0000000..939ae99 --- /dev/null +++ b/Audit/Providers/AuditServiceProvider.php @@ -0,0 +1,21 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower()); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations')); + $this->loadViewsFrom(module_path(ModuleInfo::name(), 'Resources/views'), ModuleInfo::nameLower()); + } +} diff --git a/Audit/Providers/RouteServiceProvider.php b/Audit/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..ce59b66 --- /dev/null +++ b/Audit/Providers/RouteServiceProvider.php @@ -0,0 +1,17 @@ +routes(function (): void { + Route::middleware('web')->group(module_path('Audit', 'Routes/web.php')); + }); + } +} diff --git a/Audit/Resources/views/index.blade.php b/Audit/Resources/views/index.blade.php new file mode 100644 index 0000000..7bbc774 --- /dev/null +++ b/Audit/Resources/views/index.blade.php @@ -0,0 +1 @@ +

Audit log

Recorded platform activity.

@forelse($logs as $log)@empty@endforelse
EventActorIPDate
{{ $log->event }}{{ $log->actor_type ? class_basename($log->actor_type).' #'.$log->actor_id : 'System' }}{{ $log->ip_address ?: '—' }}{{ optional($log->created_at)->format('d M Y H:i') }}
No audit events recorded.
{{ $logs->links() }}
diff --git a/Audit/Routes/web.php b/Audit/Routes/web.php new file mode 100644 index 0000000..f4c314e --- /dev/null +++ b/Audit/Routes/web.php @@ -0,0 +1,6 @@ +get('administration/audit', [AuditController::class, 'index'])->name('administration.audit.index'); diff --git a/Audit/Support/ModuleInfo.php b/Audit/Support/ModuleInfo.php new file mode 100644 index 0000000..96f4251 --- /dev/null +++ b/Audit/Support/ModuleInfo.php @@ -0,0 +1,16 @@ +toBe('Audit'); +}); diff --git a/Audit/composer.json b/Audit/composer.json new file mode 100644 index 0000000..b14ddd2 --- /dev/null +++ b/Audit/composer.json @@ -0,0 +1,10 @@ +{ + "name": "absdev/audit-module", + "description": "Audit foundation module for the ABSDEV Laravel application platform.", + "type": "laravel-module", + "autoload": { + "psr-4": { + "Modules\\Audit\\": "" + } + } +} diff --git a/Audit/module.json b/Audit/module.json new file mode 100644 index 0000000..6a2042e --- /dev/null +++ b/Audit/module.json @@ -0,0 +1,20 @@ +{ + "name": "Audit", + "alias": "audit", + "version": "1.0.0", + "description": "Audit foundation module for the ABSDEV Laravel application platform.", + "keywords": [ + "laravel", + "modules", + "audit" + ], + "priority": 10, + "providers": [ + "Modules\\Audit\\Providers\\AuditServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [ + "Core" + ] +} diff --git a/Authors/Config/config.php b/Authors/Config/config.php new file mode 100644 index 0000000..9255ff8 --- /dev/null +++ b/Authors/Config/config.php @@ -0,0 +1,3 @@ + 'Authors']; diff --git a/Authors/Database/Migrations/2026_07_24_030300_create_authors_table.php b/Authors/Database/Migrations/2026_07_24_030300_create_authors_table.php new file mode 100644 index 0000000..fb9ddce --- /dev/null +++ b/Authors/Database/Migrations/2026_07_24_030300_create_authors_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('job_title')->nullable(); + $table->text('biography')->nullable(); + $table->string('avatar_path')->nullable(); + $table->json('social_links')->nullable(); + $table->string('meta_title')->nullable(); + $table->text('meta_description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('authors'); + } +}; diff --git a/Authors/Http/Controllers/AuthorController.php b/Authors/Http/Controllers/AuthorController.php new file mode 100644 index 0000000..a25fa53 --- /dev/null +++ b/Authors/Http/Controllers/AuthorController.php @@ -0,0 +1,57 @@ + Author::with('user')->latest()->paginate(25)]); + } + + public function create(): View + { + return view('authors::admin.form', ['users' => User::orderBy('name')->get()]); + } + + public function store(Request $r): RedirectResponse + { + Author::create($this->data($r)); + + return redirect()->route('administration.authors.index')->with('status', 'Author created.'); + } + + public function edit(Author $author): View + { + return view('authors::admin.form', ['record' => $author, 'users' => User::orderBy('name')->get()]); + } + + public function update(Request $r, Author $author): RedirectResponse + { + $author->update($this->data($r)); + + return redirect()->route('administration.authors.index')->with('status', 'Author updated.'); + } + + public function destroy(Author $author): RedirectResponse + { + $author->delete(); + + return back()->with('status', 'Author deleted.'); + } + + private function data(Request $r): array + { + $d = $r->validate(['user_id' => ['nullable', 'exists:users,id'], 'name' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255'], 'job_title' => ['nullable', 'string', 'max:255'], 'biography' => ['nullable', 'string'], 'avatar_path' => ['nullable', 'string', 'max:255'], 'meta_title' => ['nullable', 'string', 'max:255'], 'meta_description' => ['nullable', 'string'], 'is_active' => ['sometimes', 'boolean']]); + $d['is_active'] = $r->boolean('is_active'); + + return $d; + } +} diff --git a/Authors/Models/Author.php b/Authors/Models/Author.php new file mode 100644 index 0000000..7fa531e --- /dev/null +++ b/Authors/Models/Author.php @@ -0,0 +1,22 @@ + 'array', 'is_active' => 'boolean']; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/Authors/Providers/AuthorsServiceProvider.php b/Authors/Providers/AuthorsServiceProvider.php new file mode 100644 index 0000000..1df491a --- /dev/null +++ b/Authors/Providers/AuthorsServiceProvider.php @@ -0,0 +1,20 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Authors', 'Config/config.php'), 'authors'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Authors', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Authors', 'Resources/views'), 'authors'); + } +} diff --git a/Authors/Providers/RouteServiceProvider.php b/Authors/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..f3e7ff9 --- /dev/null +++ b/Authors/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +group(module_path('Authors', 'Routes/web.php')); + } +} diff --git a/Authors/Resources/views/admin/form.blade.php b/Authors/Resources/views/admin/form.blade.php new file mode 100644 index 0000000..c224794 --- /dev/null +++ b/Authors/Resources/views/admin/form.blade.php @@ -0,0 +1 @@ +
@csrf @isset($record) @method('PUT') @endisset @foreach($users as $user)@endforeach{{ old('biography',$record->biography ?? '') }}Save
diff --git a/Authors/Resources/views/admin/index.blade.php b/Authors/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..5db3b07 --- /dev/null +++ b/Authors/Resources/views/admin/index.blade.php @@ -0,0 +1 @@ +

Authors

Manage publishing authors.

Create
@if(session('status'))
{{ session('status') }}
@endif
@forelse($records as $record)@empty@endforelse
Name / titleStatusActions
{{ $record->title ?? $record->name }}{{ $record->status ?? ($record->is_active ? 'Active' : 'Inactive') }}Edit
@csrf @method('DELETE')Delete
No records yet.
{{ $records->links() }}
diff --git a/Authors/Routes/web.php b/Authors/Routes/web.php new file mode 100644 index 0000000..9209fa8 --- /dev/null +++ b/Authors/Routes/web.php @@ -0,0 +1,13 @@ +prefix('administration/authors')->name('administration.authors.')->group(function (): void { + Route::get('/', [AuthorController::class, 'index'])->name('index'); + Route::get('/create', [AuthorController::class, 'create'])->name('create'); + Route::post('/', [AuthorController::class, 'store'])->name('store'); + Route::get('/{author}/edit', [AuthorController::class, 'edit'])->name('edit'); + Route::match(['put', 'patch'], '/{author}', [AuthorController::class, 'update'])->name('update'); + Route::delete('/{author}', [AuthorController::class, 'destroy'])->name('destroy'); +}); diff --git a/Authors/Tests/Feature/ModuleBootTest.php b/Authors/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..256a4e6 --- /dev/null +++ b/Authors/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,13 @@ +assertSame('Authors', config('authors.name')); + } +} diff --git a/Authors/module.json b/Authors/module.json new file mode 100644 index 0000000..c972181 --- /dev/null +++ b/Authors/module.json @@ -0,0 +1,12 @@ +{ + "name": "Authors", + "alias": "authors", + "version": "1.0.0", + "description": "Authors publishing module.", + "keywords": ["publishing", "authors"], + "priority": 30, + "providers": ["Modules\\Authors\\Providers\\AuthorsServiceProvider"], + "aliases": {}, + "files": [], + "requires": ["Core", "Administration"] +} diff --git a/Automation/Config/config.php b/Automation/Config/config.php new file mode 100644 index 0000000..0d56d36 --- /dev/null +++ b/Automation/Config/config.php @@ -0,0 +1,8 @@ + env('AUTOMATION_QUEUE', 'default'), + 'max_steps' => (int) env('AUTOMATION_MAX_STEPS', 50), + 'webhook_timeout' => (int) env('AUTOMATION_WEBHOOK_TIMEOUT', 10), + 'retain_runs_days' => (int) env('AUTOMATION_RETAIN_RUNS_DAYS', 90), +]; diff --git a/Automation/Console/DispatchScheduledAutomations.php b/Automation/Console/DispatchScheduledAutomations.php new file mode 100644 index 0000000..b80d5fd --- /dev/null +++ b/Automation/Console/DispatchScheduledAutomations.php @@ -0,0 +1,30 @@ +where('status', 'active')->where('trigger_type', 'schedule')->whereNotNull('next_run_at')->where('next_run_at', '<=', now())->chunkById(100, function ($items) use ($runner, &$count) { + foreach ($items as $automation) { + $runner->dispatch($automation, ['scheduled_at' => now()->toIso8601String()], 'schedule'); + $interval = max(1, (int) ($automation->trigger_config['interval_minutes'] ?? 60)); + $automation->update(['next_run_at' => now()->addMinutes($interval)]); + $count++; + } + }); + $this->info("Dispatched {$count} scheduled automation(s)."); + + return self::SUCCESS; + } +} diff --git a/Automation/Contracts/ActionRegistry.php b/Automation/Contracts/ActionRegistry.php new file mode 100644 index 0000000..49c599c --- /dev/null +++ b/Automation/Contracts/ActionRegistry.php @@ -0,0 +1,12 @@ +id(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('trigger_type')->index(); + $table->json('trigger_config')->nullable(); + $table->string('status', 20)->default('draft')->index(); + $table->boolean('stop_on_failure')->default(true); + $table->unsignedInteger('run_count')->default(0); + $table->timestamp('last_ran_at')->nullable(); + $table->timestamp('next_run_at')->nullable()->index(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + Schema::create('automation_steps', function (Blueprint $table) { + $table->id(); + $table->foreignId('automation_id')->constrained('automations')->cascadeOnDelete(); + $table->unsignedSmallInteger('position')->default(1); + $table->string('action_type'); + $table->string('name')->nullable(); + $table->json('configuration')->nullable(); + $table->boolean('is_enabled')->default(true); + $table->timestamps(); + $table->unique(['automation_id', 'position']); + }); + Schema::create('automation_runs', function (Blueprint $table) { + $table->id(); + $table->foreignId('automation_id')->constrained('automations')->cascadeOnDelete(); + $table->uuid('execution_id')->unique(); + $table->string('trigger_type'); + $table->json('payload')->nullable(); + $table->string('status', 20)->default('queued')->index(); + $table->unsignedSmallInteger('current_step')->default(0); + $table->text('error')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('finished_at')->nullable(); + $table->timestamps(); + }); + Schema::create('automation_run_logs', function (Blueprint $table) { + $table->id(); + $table->foreignId('automation_run_id')->constrained('automation_runs')->cascadeOnDelete(); + $table->foreignId('automation_step_id')->nullable()->constrained('automation_steps')->nullOnDelete(); + $table->string('level', 20)->default('info'); + $table->string('message'); + $table->json('context')->nullable(); + $table->timestamp('created_at')->useCurrent(); + }); + } + + public function down(): void + { + Schema::dropIfExists('automation_run_logs'); + Schema::dropIfExists('automation_runs'); + Schema::dropIfExists('automation_steps'); + Schema::dropIfExists('automations'); + } +}; diff --git a/Automation/Http/Controllers/AutomationApiController.php b/Automation/Http/Controllers/AutomationApiController.php new file mode 100644 index 0000000..72701c4 --- /dev/null +++ b/Automation/Http/Controllers/AutomationApiController.php @@ -0,0 +1,24 @@ +trigger_type === 'webhook', 404); + $secret = (string) ($automation->trigger_config['secret'] ?? ''); + if ($secret !== '') { + abort_unless(hash_equals($secret, (string) $request->header('X-Automation-Secret')), 401, 'Invalid automation secret.'); + } + $run = $runner->dispatch($automation, $request->all(), 'webhook'); + + return response()->json(['data' => ['execution_id' => $run->execution_id, 'status' => $run->status]], 202); + } +} diff --git a/Automation/Http/Controllers/AutomationController.php b/Automation/Http/Controllers/AutomationController.php new file mode 100644 index 0000000..38524c9 --- /dev/null +++ b/Automation/Http/Controllers/AutomationController.php @@ -0,0 +1,63 @@ + Automation::withCount(['steps', 'runs'])->latest()->paginate(20), 'recentRuns' => AutomationRun::with('automation')->latest()->limit(15)->get(), 'triggers' => $triggers->all(), 'actions' => $actions->all()]); + } + + public function store(Request $request, TriggerRegistry $triggers): RedirectResponse + { + $data = $request->validate(['name' => ['required', 'string', 'max:255'], 'description' => ['nullable', 'string'], 'trigger_type' => ['required', 'string'], 'status' => ['required', 'in:draft,active,paused'], 'next_run_at' => ['nullable', 'date'], 'trigger_config' => ['nullable', 'json']]); + abort_unless($triggers->has($data['trigger_type']), 422, 'Unknown trigger type.'); + $data['trigger_config'] = filled($data['trigger_config'] ?? null) ? json_decode($data['trigger_config'], true, 512, JSON_THROW_ON_ERROR) : []; + $data['stop_on_failure'] = $request->boolean('stop_on_failure'); + $data['created_by'] = $request->user()->id; + Automation::create($data); + + return back()->with('status', 'Automation created.'); + } + + public function addStep(Request $request, Automation $automation, ActionRegistry $actions): RedirectResponse + { + $data = $request->validate(['action_type' => ['required', 'string'], 'name' => ['nullable', 'string', 'max:255'], 'configuration' => ['nullable', 'json']]); + abort_unless(array_key_exists($data['action_type'], $actions->all()), 422, 'Unknown action type.'); + $automation->steps()->create(['position' => (int) $automation->steps()->max('position') + 1, 'action_type' => $data['action_type'], 'name' => $data['name'] ?? null, 'configuration' => filled($data['configuration'] ?? null) ? json_decode($data['configuration'], true, 512, JSON_THROW_ON_ERROR) : [], 'is_enabled' => true]); + + return back()->with('status', 'Automation step added.'); + } + + public function run(Automation $automation, AutomationRunner $runner): RedirectResponse + { + $run = $runner->dispatch($automation, ['initiated_by' => request()->user()->id], 'manual'); + + return back()->with('status', "Automation queued as {$run->execution_id}."); + } + + public function toggle(Automation $automation): RedirectResponse + { + $automation->update(['status' => $automation->status === 'active' ? 'paused' : 'active']); + + return back()->with('status', 'Automation status updated.'); + } + + public function destroy(Automation $automation): RedirectResponse + { + $automation->delete(); + + return back()->with('status','Automation deleted.'); + } +} diff --git a/Automation/Jobs/ExecuteAutomation.php b/Automation/Jobs/ExecuteAutomation.php new file mode 100644 index 0000000..b84d34d --- /dev/null +++ b/Automation/Jobs/ExecuteAutomation.php @@ -0,0 +1,46 @@ +findOrFail($this->runId); + $automation = $run->automation; + $run->update(['status' => 'running', 'started_at' => now()]); + try { + foreach ($automation->steps->where('is_enabled', true) as $step) { + $run->update(['current_step' => $step->position]); + try { + $result = $actions->execute($step, $run->payload ?? []); + $run->logs()->create(['automation_step_id' => $step->id, 'level' => 'info', 'message' => $step->name ?: $step->action_type.' completed', 'context' => $result, 'created_at' => now()]); + } catch (\Throwable $e) { + $run->logs()->create(['automation_step_id' => $step->id, 'level' => 'error', 'message' => $e->getMessage(), 'created_at' => now()]); + if ($automation->stop_on_failure) { + throw $e; + } + } + } + $run->update(['status' => 'completed', 'finished_at' => now()]); + } catch (\Throwable $e) { + $run->update(['status' => 'failed', 'error' => $e->getMessage(), 'finished_at' => now()]); + throw $e; + } finally { + $automation->increment('run_count'); + $automation->update(['last_ran_at' => now()]); + } + } +} diff --git a/Automation/Models/Automation.php b/Automation/Models/Automation.php new file mode 100644 index 0000000..0a060ed --- /dev/null +++ b/Automation/Models/Automation.php @@ -0,0 +1,28 @@ + 'array', 'stop_on_failure' => 'boolean', 'last_ran_at' => 'datetime', 'next_run_at' => 'datetime']; + + public function steps(): HasMany + { + return $this->hasMany(AutomationStep::class)->orderBy('position'); + } + + public function runs(): HasMany + { + return $this->hasMany(AutomationRun::class); + } + + public function isActive(): bool + { + return $this->status === 'active'; + } +} diff --git a/Automation/Models/AutomationRun.php b/Automation/Models/AutomationRun.php new file mode 100644 index 0000000..38b5fb5 --- /dev/null +++ b/Automation/Models/AutomationRun.php @@ -0,0 +1,24 @@ + 'array', 'started_at' => 'datetime', 'finished_at' => 'datetime']; + + public function automation(): BelongsTo + { + return $this->belongsTo(Automation::class); + } + + public function logs(): HasMany + { + return $this->hasMany(AutomationRunLog::class); + } +} diff --git a/Automation/Models/AutomationRunLog.php b/Automation/Models/AutomationRunLog.php new file mode 100644 index 0000000..3d93839 --- /dev/null +++ b/Automation/Models/AutomationRunLog.php @@ -0,0 +1,14 @@ + 'array', 'created_at' => 'datetime']; +} diff --git a/Automation/Models/AutomationStep.php b/Automation/Models/AutomationStep.php new file mode 100644 index 0000000..2627289 --- /dev/null +++ b/Automation/Models/AutomationStep.php @@ -0,0 +1,18 @@ + 'array', 'is_enabled' => 'boolean']; + + public function automation(): BelongsTo + { + return $this->belongsTo(Automation::class); + } +} diff --git a/Automation/Providers/AutomationServiceProvider.php b/Automation/Providers/AutomationServiceProvider.php new file mode 100644 index 0000000..b4c3b7c --- /dev/null +++ b/Automation/Providers/AutomationServiceProvider.php @@ -0,0 +1,30 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Automation', 'Config/config.php'), 'automation'); + $this->app->singleton(ActionRegistry::class, AutomationActionRegistry::class); + $this->app->singleton(TriggerRegistry::class, AutomationTriggerRegistry::class); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Automation', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Automation', 'Resources/views'), 'automation'); + if ($this->app->runningInConsole()) { + $this->commands([DispatchScheduledAutomations::class]); + } + } +} diff --git a/Automation/Providers/RouteServiceProvider.php b/Automation/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..f03573a --- /dev/null +++ b/Automation/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +group(module_path('Automation', 'Routes/web.php')); + Route::middleware('api')->prefix('api/v1')->group(module_path('Automation', 'Routes/api.php')); + } +} diff --git a/Automation/Resources/views/admin/index.blade.php b/Automation/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..1033167 --- /dev/null +++ b/Automation/Resources/views/admin/index.blade.php @@ -0,0 +1,26 @@ + +
+
Automation PlatformCreate event-driven, scheduled and webhook automations.
+@if(session('status')){{ session('status') }}@endif +
+Automations{{ $automations->total() }} +Active{{ \Modules\Automation\Models\Automation::where('status','active')->count() }} +Completed runs{{ \Modules\Automation\Models\AutomationRun::where('status','completed')->count() }} +Failed runs{{ \Modules\Automation\Models\AutomationRun::where('status','failed')->count() }} +
+@can('automation.manage') +Create automation
@csrf +@foreach($triggers as $key=>$trigger)@endforeach + + +
Create automation
+
+@endcan +
+@forelse($automations as $automation)
{{ $automation->name }}{{ $automation->trigger_type }} · {{ $automation->steps_count }} steps · {{ $automation->runs_count }} runs
{{ ucfirst($automation->status) }}
+@if($automation->description){{ $automation->description }}@endif +
@can('automation.execute')
@csrfRun
@endcan @can('automation.manage')
@csrf @method('PATCH'){{ $automation->status==='active'?'Pause':'Activate' }}
@csrf @method('DELETE')Delete
@endcan
+@can('automation.manage')
@csrf@foreach($actions as $key=>$action)@endforeach
Add step
@endcan +
@emptyNo automations have been created.@endforelse +{{ $automations->links() }}
Recent runs
@forelse($recentRuns as $run)
{{ $run->automation?->name }}{{ $run->status }}
{{ $run->execution_id }}
@emptyNo runs yet.@endforelse
+
diff --git a/Automation/Routes/api.php b/Automation/Routes/api.php new file mode 100644 index 0000000..f90901d --- /dev/null +++ b/Automation/Routes/api.php @@ -0,0 +1,6 @@ +middleware('throttle:60,1')->name('api.automations.trigger'); diff --git a/Automation/Routes/web.php b/Automation/Routes/web.php new file mode 100644 index 0000000..342d7c0 --- /dev/null +++ b/Automation/Routes/web.php @@ -0,0 +1,13 @@ +name('administration.automation.')->middleware(['auth', 'permission:automation.view'])->group(function () { + Route::get('/', [AutomationController::class, 'index'])->name('index'); + Route::post('/', [AutomationController::class, 'store'])->middleware('permission:automation.manage')->name('store'); + Route::post('/{automation}/steps', [AutomationController::class, 'addStep'])->middleware('permission:automation.manage')->name('steps.store'); + Route::post('/{automation}/run', [AutomationController::class, 'run'])->middleware('permission:automation.execute')->name('run'); + Route::patch('/{automation}/toggle', [AutomationController::class, 'toggle'])->middleware('permission:automation.manage')->name('toggle'); + Route::delete('/{automation}', [AutomationController::class, 'destroy'])->middleware('permission:automation.manage')->name('destroy'); +}); diff --git a/Automation/Services/AutomationActionRegistry.php b/Automation/Services/AutomationActionRegistry.php new file mode 100644 index 0000000..b8e7f2c --- /dev/null +++ b/Automation/Services/AutomationActionRegistry.php @@ -0,0 +1,79 @@ + ['label' => 'Write log', 'fields' => ['message']], + 'webhook' => ['label' => 'Send webhook', 'fields' => ['url', 'method']], + 'email' => ['label' => 'Send email', 'fields' => ['to', 'subject', 'body']], + 'delay' => ['label' => 'Delay', 'fields' => ['seconds']], + ]; + } + + public function execute(AutomationStep $step, array $payload): array + { + $config = $step->configuration ?? []; + + return match ($step->action_type) { + 'log' => $this->log($config, $payload), + 'webhook' => $this->webhook($config, $payload), + 'email' => $this->email($config, $payload), + 'delay' => $this->delay($config), + default => throw new \InvalidArgumentException("Unknown automation action [{$step->action_type}]."), + }; + } + + private function log(array $config, array $payload): array + { + $message = (string) ($config['message'] ?? 'Automation executed'); + Log::info($message, $payload); + + return ['message' => $message]; + } + + private function webhook(array $config, array $payload): array + { + $url = (string) ($config['url'] ?? ''); + if ($url === '') { + throw new \InvalidArgumentException('Webhook URL is required.'); + } + $method = strtolower((string) ($config['method'] ?? 'post')); + $response = Http::timeout(config('automation.webhook_timeout', 10))->send($method, $url, ['json' => $payload]); + $response->throw(); + + return ['status' => $response->status()]; + } + + private function email(array $config, array $payload): array + { + $to = (string) ($config['to'] ?? ''); + if ($to === '') { + throw new \InvalidArgumentException('Email recipient is required.'); + } + $subject = (string) ($config['subject'] ?? 'Automation notification'); + $body = (string) ($config['body'] ?? json_encode($payload, JSON_PRETTY_PRINT)); + Mail::raw($body, fn ($message) => $message->to($to)->subject($subject)); + + return ['to' => $to]; + } + + private function delay(array $config): array + { + $seconds = max(0, min(30, (int) ($config['seconds'] ?? 1))); + if ($seconds > 0) { + sleep($seconds); + } + +return ['seconds' => $seconds]; + } +} diff --git a/Automation/Services/AutomationRunner.php b/Automation/Services/AutomationRunner.php new file mode 100644 index 0000000..254ba8a --- /dev/null +++ b/Automation/Services/AutomationRunner.php @@ -0,0 +1,46 @@ +isActive(), 422, 'Automation is not active.'); + $run = $automation->runs()->create(['execution_id' => (string) Str::uuid(), 'trigger_type' => $triggerType ?? $automation->trigger_type, 'payload' => $payload, 'status' => 'queued']); + ExecuteAutomation::dispatch($run->id)->onQueue(config('automation.queue', 'default')); + + return $run; + } + + public function dispatchMatching(string $triggerType, array $payload = [], array $constraints = []): int + { + $query = Automation::query()->where('status', 'active')->where('trigger_type', $triggerType); + $count = 0; + foreach ($query->get() as $automation) { + if ($this->matches($automation, $constraints)) { + $this->dispatch($automation, $payload, $triggerType); + $count++; + } + } + + return $count; + } + + private function matches(Automation $automation, array $constraints): bool + { + $config = $automation->trigger_config ?? []; + foreach ($constraints as $key => $value) { + if (isset($config[$key]) && (string) $config[$key] !== (string) $value) { + return false; + } + } + + return true; + } +} diff --git a/Automation/Services/AutomationTriggerRegistry.php b/Automation/Services/AutomationTriggerRegistry.php new file mode 100644 index 0000000..c0716f4 --- /dev/null +++ b/Automation/Services/AutomationTriggerRegistry.php @@ -0,0 +1,23 @@ + ['label' => 'Manual', 'description' => 'Run from the administration area or API.'], + 'schedule' => ['label' => 'Schedule', 'description' => 'Run when next_run_at is due.'], + 'webhook' => ['label' => 'Incoming webhook', 'description' => 'Run from a signed API request.'], + 'domain_event' => ['label' => 'Domain event', 'description' => 'Run when an application event is dispatched.'], + ]; + } + + public function has(string $type): bool + { + return array_key_exists($type, $this->all()); + } +} diff --git a/Automation/Tests/Feature/ModuleBootTest.php b/Automation/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..984262f --- /dev/null +++ b/Automation/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,16 @@ +assertTrue(class_exists(AutomationServiceProvider::class)); + $this->assertTrue(app()->bound(ActionRegistry::class)); + } +} diff --git a/Automation/module.json b/Automation/module.json new file mode 100644 index 0000000..c39c7ae --- /dev/null +++ b/Automation/module.json @@ -0,0 +1 @@ +{"name":"Automation","alias":"automation","version":"1.0.0","description":"Event-driven and scheduled automations with reusable triggers, actions, runs and execution logs.","keywords":["automation","events","triggers","actions","scheduler"],"priority":130,"providers":["Modules\\Automation\\Providers\\AutomationServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Users","Roles","Audit","Api"]} diff --git a/Banners/Config/config.php b/Banners/Config/config.php new file mode 100644 index 0000000..b2a20d0 --- /dev/null +++ b/Banners/Config/config.php @@ -0,0 +1,3 @@ + 'Banners']; diff --git a/Banners/Http/Controllers/BannerController.php b/Banners/Http/Controllers/BannerController.php new file mode 100644 index 0000000..a66e3f0 --- /dev/null +++ b/Banners/Http/Controllers/BannerController.php @@ -0,0 +1,60 @@ + Banner::query()->latest()->paginate(20)]); + } + + public function create(): View + { + return view('banners::admin.form'); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate(['title' => ['required', 'string', 'max:255'], + 'content' => ['nullable', 'string'], + 'link_url' => ['nullable', 'string'], + 'position' => ['required', 'string', 'max:255'], + 'enabled' => ['sometimes', 'boolean']]); + $data['enabled'] = $request->boolean('enabled'); + Banner::create($data); + + return redirect()->route('administration.banners.index')->with('status', 'Banner created.'); + } + + public function edit(Banner $banner): View + { + return view('banners::admin.form', ['record' => $banner]); + } + + public function update(Request $request, Banner $banner): RedirectResponse + { + $data = $request->validate(['title' => ['required', 'string', 'max:255'], + 'content' => ['nullable', 'string'], + 'link_url' => ['nullable', 'string'], + 'position' => ['required', 'string', 'max:255'], + 'enabled' => ['sometimes', 'boolean']]); + $data['enabled'] = $request->boolean('enabled'); + $banner->update($data); + + return redirect()->route('administration.banners.index')->with('status', 'Banner updated.'); + } + + public function destroy(Banner $banner): RedirectResponse + { + $banner->delete(); + + return back()->with('status', 'Banner deleted.'); + } +} diff --git a/Banners/Models/Banner.php b/Banners/Models/Banner.php new file mode 100644 index 0000000..7ba1279 --- /dev/null +++ b/Banners/Models/Banner.php @@ -0,0 +1,23 @@ + 'boolean', + ]; + } +} diff --git a/Banners/Providers/BannersServiceProvider.php b/Banners/Providers/BannersServiceProvider.php new file mode 100644 index 0000000..eda98f7 --- /dev/null +++ b/Banners/Providers/BannersServiceProvider.php @@ -0,0 +1,20 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Banners', 'Config/config.php'), 'banners'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Banners', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Banners', 'Resources/views'), 'banners'); + } +} diff --git a/Banners/Providers/RouteServiceProvider.php b/Banners/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..c948511 --- /dev/null +++ b/Banners/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +routes(fn () => Route::middleware('web')->group(module_path('Banners', 'Routes/web.php'))); + } +} diff --git a/Banners/Resources/views/admin/form.blade.php b/Banners/Resources/views/admin/form.blade.php new file mode 100644 index 0000000..b78be52 --- /dev/null +++ b/Banners/Resources/views/admin/form.blade.php @@ -0,0 +1,9 @@ + +

{{ isset($record) ? 'Edit' : 'Create' }} Banner

@csrf @isset($record) @method('PUT') @endisset +
@error('title')

{{ $message }}

@enderror
+
@error('content')

{{ $message }}

@enderror
+
@error('link_url')

{{ $message }}

@enderror
+
@error('position')

{{ $message }}

@enderror
+ +
Cancel
+
diff --git a/Banners/Resources/views/admin/index.blade.php b/Banners/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..4ac0c2d --- /dev/null +++ b/Banners/Resources/views/admin/index.blade.php @@ -0,0 +1,5 @@ + +

Banners

Manage banners content.

Add new
+@if(session('status'))
{{ session('status') }}
@endif +
@forelse($records as $record)@empty@endforelse
TitleContentLink UrlActions
{{ is_array($record->title) ? json_encode($record->title) : Str::limit((string) $record->title, 70) }}{{ is_array($record->content) ? json_encode($record->content) : Str::limit((string) $record->content, 70) }}{{ is_array($record->link_url) ? json_encode($record->link_url) : Str::limit((string) $record->link_url, 70) }}Edit
@csrf @method('DELETE')
No records yet.
{{ $records->links() }}
+
diff --git a/Banners/Routes/web.php b/Banners/Routes/web.php new file mode 100644 index 0000000..3651a6c --- /dev/null +++ b/Banners/Routes/web.php @@ -0,0 +1,13 @@ +prefix('administration/banners')->name('administration.banners.')->group(function (): void { + Route::get('/', [BannerController::class, 'index'])->name('index'); + Route::get('/create', [BannerController::class, 'create'])->name('create'); + Route::post('/', [BannerController::class, 'store'])->name('store'); + Route::get('/{banner}/edit', [BannerController::class, 'edit'])->name('edit'); + Route::match(['put', 'patch'], '/{banner}', [BannerController::class, 'update'])->name('update'); + Route::delete('/{banner}', [BannerController::class, 'destroy'])->name('destroy'); +}); diff --git a/Banners/Support/ModuleInfo.php b/Banners/Support/ModuleInfo.php new file mode 100644 index 0000000..09e1bda --- /dev/null +++ b/Banners/Support/ModuleInfo.php @@ -0,0 +1,16 @@ +toBeTrue(); +}); diff --git a/Banners/module.json b/Banners/module.json new file mode 100644 index 0000000..238aec6 --- /dev/null +++ b/Banners/module.json @@ -0,0 +1,20 @@ +{ + "name": "Banners", + "alias": "banners", + "version": "1.0.0", + "description": "Banners CMS module.", + "keywords": [ + "cms", + "banners" + ], + "priority": 26, + "providers": [ + "Modules\\Banners\\Providers\\BannersServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [ + "Core", + "Administration" + ] +} diff --git a/Blog/Config/config.php b/Blog/Config/config.php new file mode 100644 index 0000000..97da7a5 --- /dev/null +++ b/Blog/Config/config.php @@ -0,0 +1,3 @@ + 'Blog']; diff --git a/Blog/Database/Migrations/2026_07_24_03041_create_blog_posts_table.php b/Blog/Database/Migrations/2026_07_24_03041_create_blog_posts_table.php new file mode 100644 index 0000000..30684a4 --- /dev/null +++ b/Blog/Database/Migrations/2026_07_24_03041_create_blog_posts_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('author_id')->nullable()->constrained('authors')->nullOnDelete(); + $table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete(); + $table->string('title'); + $table->string('slug')->unique(); + $table->text('excerpt')->nullable(); + $table->longText('content')->nullable(); + $table->string('featured_image')->nullable(); + $table->string('status')->default('draft')->index(); + $table->boolean('is_featured')->default(false); + $table->timestamp('published_at')->nullable()->index(); + $table->timestamp('expires_at')->nullable(); + $table->string('meta_title')->nullable(); + $table->text('meta_description')->nullable(); + $table->timestamps(); + }); + Schema::create('blog_post_tag', function (Blueprint $table): void { + $table->foreignId('blog_post_id')->constrained('blog_posts')->cascadeOnDelete(); + $table->foreignId('tag_id')->constrained('tags')->cascadeOnDelete(); + $table->primary(['blog_post_id', 'tag_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('blog_post_tag'); + Schema::dropIfExists('blog_posts'); + } +}; diff --git a/Blog/Http/Controllers/BlogController.php b/Blog/Http/Controllers/BlogController.php new file mode 100644 index 0000000..0e96878 --- /dev/null +++ b/Blog/Http/Controllers/BlogController.php @@ -0,0 +1,66 @@ + Blog::with(['author', 'category'])->latest()->paginate(25)]); + } + + public function create(): View + { + return view('blog::admin.form', $this->lookups()); + } + + public function store(Request $r): RedirectResponse + { + $record = Blog::create($this->data($r)); + $record->tags()->sync($r->input('tag_ids', [])); + + return redirect()->route('administration.blog.index')->with('status', 'Blog created.'); + } + + public function edit(Blog $blog_post): View + { + return view('blog::admin.form', array_merge($this->lookups(), ['record' => $blog_post])); + } + + public function update(Request $r, Blog $blog_post): RedirectResponse + { + $blog_post->update($this->data($r)); + $blog_post->tags()->sync($r->input('tag_ids', [])); + + return redirect()->route('administration.blog.index')->with('status', 'Blog updated.'); + } + + public function destroy(Blog $blog_post): RedirectResponse + { + $blog_post->delete(); + + return back()->with('status', 'Blog deleted.'); + } + + private function lookups(): array + { + return ['authors' => Author::where('is_active', true)->orderBy('name')->get(), 'categories' => Category::where('is_active', true)->orderBy('name')->get(), 'tags' => Tag::orderBy('name')->get()]; + } + + private function data(Request $r): array + { + $d = $r->validate(['author_id' => ['nullable', 'exists:authors,id'], 'category_id' => ['nullable', 'exists:categories,id'], 'title' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255'], 'excerpt' => ['nullable', 'string'], 'content' => ['nullable', 'string'], 'featured_image' => ['nullable', 'string', 'max:255'], 'status' => ['required', 'in:draft,review,published,archived'], 'is_featured' => ['sometimes', 'boolean'], 'published_at' => ['nullable', 'date'], 'expires_at' => ['nullable', 'date', 'after:published_at'], 'meta_title' => ['nullable', 'string', 'max:255'], 'meta_description' => ['nullable', 'string']]); + $d['is_featured'] = $r->boolean('is_featured'); + + return $d; + } +} diff --git a/Blog/Http/Controllers/PublicBlogController.php b/Blog/Http/Controllers/PublicBlogController.php new file mode 100644 index 0000000..6fb2096 --- /dev/null +++ b/Blog/Http/Controllers/PublicBlogController.php @@ -0,0 +1,20 @@ + Blog::published()->with(['author', 'category'])->latest('published_at')->paginate(12)]); + } + + public function show(string $slug): View + { + return view('blog::public.show', ['record' => Blog::published()->with(['author', 'category', 'tags'])->where('slug', $slug)->firstOrFail()]); + } +} diff --git a/Blog/Models/Blog.php b/Blog/Models/Blog.php new file mode 100644 index 0000000..6f58ea0 --- /dev/null +++ b/Blog/Models/Blog.php @@ -0,0 +1,48 @@ + 'boolean', 'published_at' => 'datetime', 'expires_at' => 'datetime']; + } + + public function author(): BelongsTo + { + return $this->belongsTo(Author::class); + } + + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + + public function tags(): BelongsToMany + { + return $this->belongsToMany(Tag::class, 'blog_post_tag'); + } + + public function scopePublished(Builder $query): Builder + { + return $query->where('status', 'published')->where(fn (Builder $q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()))->where(fn (Builder $q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now())); + } +} diff --git a/Blog/Providers/BlogServiceProvider.php b/Blog/Providers/BlogServiceProvider.php new file mode 100644 index 0000000..74e8bcf --- /dev/null +++ b/Blog/Providers/BlogServiceProvider.php @@ -0,0 +1,20 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Blog', 'Config/config.php'), 'blog'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Blog', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Blog', 'Resources/views'), 'blog'); + } +} diff --git a/Blog/Providers/RouteServiceProvider.php b/Blog/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..6c7b0c0 --- /dev/null +++ b/Blog/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +group(module_path('Blog', 'Routes/web.php')); + } +} diff --git a/Blog/Resources/views/admin/form.blade.php b/Blog/Resources/views/admin/form.blade.php new file mode 100644 index 0000000..cf54f2b --- /dev/null +++ b/Blog/Resources/views/admin/form.blade.php @@ -0,0 +1 @@ +
@csrf @isset($record) @method('PUT') @endisset
@foreach($authors as $author)@endforeach@foreach($categories as $category)@endforeach@foreach(['draft','review','published','archived'] as $status)@endforeach
{{ old('excerpt',$record->excerpt ?? '') }}{{ old('content',$record->content ?? '') }}
@foreach($tags as $tag)@endforeach
{{ old('meta_description',$record->meta_description ?? '') }}Save
diff --git a/Blog/Resources/views/admin/index.blade.php b/Blog/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..9ec9eda --- /dev/null +++ b/Blog/Resources/views/admin/index.blade.php @@ -0,0 +1 @@ +

Blog

Manage publishing blog.

Create
@if(session('status'))
{{ session('status') }}
@endif
@forelse($records as $record)@empty@endforelse
Name / titleStatusActions
{{ $record->title ?? $record->name }}{{ $record->status ?? ($record->is_active ? 'Active' : 'Inactive') }}Edit
@csrf @method('DELETE')Delete
No records yet.
{{ $records->links() }}
diff --git a/Blog/Resources/views/public/index.blade.php b/Blog/Resources/views/public/index.blade.php new file mode 100644 index 0000000..b616fa9 --- /dev/null +++ b/Blog/Resources/views/public/index.blade.php @@ -0,0 +1,31 @@ +@extends('stylist-theme-saasland::layouts.app') + +@php + $title = 'Blog'; + $description = 'Ideas, guides and updates from our team.'; + $eyebrow = 'Insights'; + $breadcrumbs = ['Blog' => null]; +@endphp + +@section('content') +
+
+
+ @forelse($records as $record) +
+
+
+ +

{{ $record->title }}

+ @if($record->excerpt)

{{ $record->excerpt }}

@endif + Read article → +
+
+ @empty +
No blog posts have been published yet.
+ @endforelse +
+
{{ $records->links() }}
+
+
+@endsection diff --git a/Blog/Resources/views/public/show.blade.php b/Blog/Resources/views/public/show.blade.php new file mode 100644 index 0000000..c710f44 --- /dev/null +++ b/Blog/Resources/views/public/show.blade.php @@ -0,0 +1,23 @@ +@extends('stylist-theme-saasland::layouts.app') + +@php + $title = $record->title; + $description = $record->excerpt; + $eyebrow = 'Blog'; + $breadcrumbs = ['Blog' => route('blog.public.index'), $record->title => null]; +@endphp + +@section('content') +
+
+
+ + @if($record->excerpt)

{{ $record->excerpt }}

@endif +
{!! nl2br(e($record->content)) !!}
+
+
+
+@endsection diff --git a/Blog/Routes/web.php b/Blog/Routes/web.php new file mode 100644 index 0000000..01f9b3d --- /dev/null +++ b/Blog/Routes/web.php @@ -0,0 +1,16 @@ +prefix('administration/blog')->name('administration.blog.')->group(function (): void { + Route::get('/', [BlogController::class, 'index'])->name('index'); + Route::get('/create', [BlogController::class, 'create'])->name('create'); + Route::post('/', [BlogController::class, 'store'])->name('store'); + Route::get('/{blog_post}/edit', [BlogController::class, 'edit'])->name('edit'); + Route::match(['put', 'patch'], '/{blog_post}', [BlogController::class, 'update'])->name('update'); + Route::delete('/{blog_post}', [BlogController::class, 'destroy'])->name('destroy'); +}); +Route::get('/blog', [PublicBlogController::class, 'index'])->name('blog.public.index'); +Route::get('/blog/{slug}', [PublicBlogController::class, 'show'])->name('blog.public.show'); diff --git a/Blog/module.json b/Blog/module.json new file mode 100644 index 0000000..e72c145 --- /dev/null +++ b/Blog/module.json @@ -0,0 +1,12 @@ +{ + "name": "Blog", + "alias": "blog", + "version": "1.0.0", + "description": "Blog publishing module.", + "keywords": ["publishing", "blog"], + "priority": 30, + "providers": ["Modules\\Blog\\Providers\\BlogServiceProvider"], + "aliases": {}, + "files": [], + "requires": ["Core", "Administration"] +} diff --git a/Builder/Components/ContentComponent.php b/Builder/Components/ContentComponent.php new file mode 100644 index 0000000..63dc588 --- /dev/null +++ b/Builder/Components/ContentComponent.php @@ -0,0 +1,38 @@ + ['type' => 'textarea', 'required' => true]]; + } + + public function defaults(): array + { + return ['content' => '

Add your content here.

']; + } + + public function render(array $properties, array $context = []): string + { + return (string) ($properties['content'] ?? ''); + } +} diff --git a/Builder/Components/HeadingComponent.php b/Builder/Components/HeadingComponent.php new file mode 100644 index 0000000..e619242 --- /dev/null +++ b/Builder/Components/HeadingComponent.php @@ -0,0 +1,40 @@ + ['type' => 'text', 'required' => true], 'level' => ['type' => 'select', 'options' => [1, 2, 3, 4, 5, 6]]]; + } + + public function defaults(): array + { + return ['text' => 'Heading', 'level' => 2]; + } + + public function render(array $properties, array $context = []): string + { + $level = max(1, min(6, (int) ($properties['level'] ?? 2))); + + return ''.e($properties['text'] ?? '').''; + } +} diff --git a/Builder/Components/HeroComponent.php b/Builder/Components/HeroComponent.php new file mode 100644 index 0000000..f017dea --- /dev/null +++ b/Builder/Components/HeroComponent.php @@ -0,0 +1,38 @@ + ['type' => 'text', 'required' => true], 'content' => ['type' => 'textarea'], 'button_label' => ['type' => 'text'], 'button_url' => ['type' => 'text']]; + } + + public function defaults(): array + { + return ['heading' => 'A compelling headline', 'content' => 'Add supporting content here.', 'button_label' => 'Learn more', 'button_url' => '#']; + } + + public function render(array $properties, array $context = []): string + { + return view('builder::components.hero', ['properties' => $properties])->render(); + } +} diff --git a/Builder/Config/config.php b/Builder/Config/config.php new file mode 100644 index 0000000..b3a8362 --- /dev/null +++ b/Builder/Config/config.php @@ -0,0 +1,8 @@ + (int) env('BUILDER_MAX_DOCUMENT_SIZE_KB', 512), + 'autosave_seconds' => (int) env('BUILDER_AUTOSAVE_SECONDS', 30), + 'preview_route' => env('BUILDER_PREVIEW_ROUTE', 'builder.preview'), + 'allowed_types' => ['page', 'form', 'dashboard', 'report', 'menu', 'email', 'query', 'workflow'], +]; diff --git a/Builder/Contracts/BuilderComponent.php b/Builder/Contracts/BuilderComponent.php new file mode 100644 index 0000000..e16fad7 --- /dev/null +++ b/Builder/Contracts/BuilderComponent.php @@ -0,0 +1,18 @@ +id(); + $table->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete(); + $table->string('type', 30)->index(); + $table->string('name'); + $table->string('slug')->nullable(); + $table->string('status', 20)->default('draft')->index(); + $table->unsignedInteger('version')->default(1); + $table->json('document')->nullable(); + $table->json('settings')->nullable(); + $table->nullableMorphs('buildable'); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + $table->unique(['site_id', 'type', 'slug'], 'builder_document_site_type_slug_unique'); + }); + Schema::create('builder_document_versions', function (Blueprint $table) { + $table->id(); + $table->foreignId('builder_document_id')->constrained('builder_documents')->cascadeOnDelete(); + $table->unsignedInteger('version'); + $table->json('document'); + $table->json('settings')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->unique(['builder_document_id', 'version']); + }); + Schema::create('builder_templates', function (Blueprint $table) { + $table->id(); + $table->string('type', 30)->index(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->json('document'); + $table->json('settings')->nullable(); + $table->boolean('is_system')->default(false); + $table->boolean('is_active')->default(true)->index(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('builder_templates'); + Schema::dropIfExists('builder_document_versions'); + Schema::dropIfExists('builder_documents'); + } +}; diff --git a/Builder/Http/Controllers/BuilderApiController.php b/Builder/Http/Controllers/BuilderApiController.php new file mode 100644 index 0000000..57296aa --- /dev/null +++ b/Builder/Http/Controllers/BuilderApiController.php @@ -0,0 +1,27 @@ +json(['data' => $registry->components(), 'widgets' => $registry->widgets(), 'fields' => $registry->fields()]); + } + + public function show(BuilderDocument $document): JsonResponse + { + return response()->json(['data' => $document]); + } + + public function preview(BuilderDocument $document, BuilderRenderer $renderer): JsonResponse + { + return response()->json(['html' => $renderer->render($document->document ?? [])]); + } +} diff --git a/Builder/Http/Controllers/BuilderController.php b/Builder/Http/Controllers/BuilderController.php new file mode 100644 index 0000000..70443cc --- /dev/null +++ b/Builder/Http/Controllers/BuilderController.php @@ -0,0 +1,61 @@ +string('type')->toString(); + $documents = BuilderDocument::query()->when($type, fn ($q) => $q->where('type', $type))->latest()->paginate(20)->withQueryString(); + + return view('builder::index', compact('documents', 'type')); + } + + public function create(BuilderRegistry $registry): View + { + return view('builder::editor', ['document' => new BuilderDocument(['type' => request('type', 'page'), 'document' => ['nodes' => []]]), 'components' => $registry->components()]); + } + + public function edit(BuilderDocument $document, BuilderRegistry $registry): View + { + return view('builder::editor', ['document' => $document, 'components' => $registry->components()]); + } + + public function store(Request $request, BuilderDocumentService $service): RedirectResponse + { + $document = $service->save(new BuilderDocument, $this->validated($request), $request->user()?->id); + + return redirect()->route('administration.builder.edit', $document)->with('status', 'Builder document created.'); + } + + public function update(Request $request, BuilderDocument $document, BuilderDocumentService $service): RedirectResponse + { + $service->save($document, $this->validated($request), $request->user()?->id); + + return back()->with('status', 'Builder document saved.'); + } + + public function destroy(BuilderDocument $document): RedirectResponse + { + $document->delete(); + + return redirect()->route('administration.builder.index')->with('status', 'Builder document deleted.'); + } + + private function validated(Request $request): array + { + $data = $request->validate(['type' => ['required', 'in:'.implode(',', config('builder.allowed_types'))], 'name' => ['required', 'string', 'max:255'], 'slug' => ['nullable', 'string', 'max:255'], 'status' => ['required', 'in:draft,published,archived'], 'document_json' => ['required', 'json'], 'settings_json' => ['nullable', 'json']]); + + return ['type' => $data['type'], 'name' => $data['name'], 'slug' => $data['slug'] ?: Str::slug($data['name']), 'status' => $data['status'], 'document' => json_decode($data['document_json'], true, 512, JSON_THROW_ON_ERROR), 'settings' => json_decode($data['settings_json'] ?: '{}', true, 512, JSON_THROW_ON_ERROR), 'published_at' => $data['status'] === 'published' ? now() : null]; + } +} diff --git a/Builder/Models/BuilderDocument.php b/Builder/Models/BuilderDocument.php new file mode 100644 index 0000000..edb53ad --- /dev/null +++ b/Builder/Models/BuilderDocument.php @@ -0,0 +1,18 @@ + 'array', 'settings' => 'array', 'published_at' => 'datetime']; + + public function versions(): HasMany + { + return $this->hasMany(BuilderDocumentVersion::class); + } +} diff --git a/Builder/Models/BuilderDocumentVersion.php b/Builder/Models/BuilderDocumentVersion.php new file mode 100644 index 0000000..30d6a78 --- /dev/null +++ b/Builder/Models/BuilderDocumentVersion.php @@ -0,0 +1,12 @@ + 'array', 'settings' => 'array']; +} diff --git a/Builder/Models/BuilderTemplate.php b/Builder/Models/BuilderTemplate.php new file mode 100644 index 0000000..68520d6 --- /dev/null +++ b/Builder/Models/BuilderTemplate.php @@ -0,0 +1,12 @@ + 'array', 'settings' => 'array', 'is_system' => 'boolean', 'is_active' => 'boolean']; +} diff --git a/Builder/Providers/BuilderServiceProvider.php b/Builder/Providers/BuilderServiceProvider.php new file mode 100644 index 0000000..d1d9c21 --- /dev/null +++ b/Builder/Providers/BuilderServiceProvider.php @@ -0,0 +1,24 @@ +app->singleton(BuilderRegistry::class); + $this->app->alias(BuilderRegistry::class, 'builder.registry'); + $this->app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Builder', 'Config/config.php'), 'builder'); + } + + public function boot(BuilderRegistry $registry): void + { + $this->loadMigrationsFrom(module_path('Builder', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Builder', 'Resources/views'), 'builder'); + $registry->registerDefaults(); + } +} diff --git a/Builder/Providers/RouteServiceProvider.php b/Builder/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..e63c673 --- /dev/null +++ b/Builder/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +group(module_path('Builder', 'Routes/web.php')); + Route::middleware('api')->prefix('api/v1')->group(module_path('Builder', 'Routes/api.php')); + } +} diff --git a/Builder/Resources/views/components/hero.blade.php b/Builder/Resources/views/components/hero.blade.php new file mode 100644 index 0000000..34083c8 --- /dev/null +++ b/Builder/Resources/views/components/hero.blade.php @@ -0,0 +1 @@ +

{{ $properties['heading'] ?? '' }}

{{ $properties['content'] ?? '' }}

@if(!empty($properties['button_label'])){{ $properties['button_label'] }}@endif
diff --git a/Builder/Resources/views/editor.blade.php b/Builder/Resources/views/editor.blade.php new file mode 100644 index 0000000..82c8070 --- /dev/null +++ b/Builder/Resources/views/editor.blade.php @@ -0,0 +1,10 @@ + +
+
{{ $document->exists ? 'Edit' : 'Create' }} Builder DocumentUse the component palette and JSON document canvas. Visual drag-and-drop clients can use the component API.
+
@csrf @if($document->exists) @method('PUT') @endif +
@foreach(config('builder.allowed_types') as $type)@endforeach
+ +
{{ old('document_json',json_encode($document->document ?: ['nodes'=>[]],JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)) }}{{ old('settings_json',json_encode($document->settings ?: new stdClass(),JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)) }}
+
SaveCancel
+ +
diff --git a/Builder/Resources/views/index.blade.php b/Builder/Resources/views/index.blade.php new file mode 100644 index 0000000..c146805 --- /dev/null +++ b/Builder/Resources/views/index.blade.php @@ -0,0 +1,7 @@ + +
+
Low-Code BuilderPages, forms, dashboards, reports, menus, emails, queries and workflows.
@can('builder.manage')Create@endcan
+
@foreach(config('builder.allowed_types') as $builderType)
{{ str($builderType)->headline() }}
@endforeach
+
@forelse($documents as $document)@empty@endforelse
NameTypeStatusVersion
{{ $document->name }}{{ str($document->type)->headline() }}{{ ucfirst($document->status) }}{{ $document->version }}Edit
No builder documents yet.
+ {{ $documents->links() }} +
diff --git a/Builder/Routes/api.php b/Builder/Routes/api.php new file mode 100644 index 0000000..41b80ee --- /dev/null +++ b/Builder/Routes/api.php @@ -0,0 +1,11 @@ +prefix('builder')->name('api.builder.')->group(function () { + Route::get('/components', [BuilderApiController::class, 'components'])->name('components'); + Route::get('/documents/{document}', [BuilderApiController::class, 'show'])->name('show'); + Route::get('/documents/{document}/preview', [BuilderApiController::class, 'preview'])->name('preview'); +}); diff --git a/Builder/Routes/web.php b/Builder/Routes/web.php new file mode 100644 index 0000000..ed43359 --- /dev/null +++ b/Builder/Routes/web.php @@ -0,0 +1,13 @@ +prefix('administration/builder')->name('administration.builder.')->group(function () { + Route::get('/', [BuilderController::class, 'index'])->name('index'); + Route::get('/create', [BuilderController::class, 'create'])->middleware('permission:builder.manage')->name('create'); + Route::post('/', [BuilderController::class, 'store'])->middleware('permission:builder.manage')->name('store'); + Route::get('/{document}/edit', [BuilderController::class, 'edit'])->middleware('permission:builder.manage')->name('edit'); + Route::put('/{document}', [BuilderController::class, 'update'])->middleware('permission:builder.manage')->name('update'); + Route::delete('/{document}', [BuilderController::class, 'destroy'])->middleware('permission:builder.manage')->name('destroy'); +}); diff --git a/Builder/Services/BuilderDocumentService.php b/Builder/Services/BuilderDocumentService.php new file mode 100644 index 0000000..29f2368 --- /dev/null +++ b/Builder/Services/BuilderDocumentService.php @@ -0,0 +1,28 @@ +exists && $document->isDirty()) { + $document->versions()->create(['version' => $document->version, 'document' => $document->getOriginal('document') ?: [], 'settings' => $document->getOriginal('settings') ?: [], 'created_by' => $userId]); + } + $document->fill($payload); + $document->updated_by = $userId; + if (! $document->exists) { + $document->created_by = $userId; + } else { + $document->version++; + } + $document->save(); + + return $document->refresh(); + }); + } +} diff --git a/Builder/Services/BuilderRegistry.php b/Builder/Services/BuilderRegistry.php new file mode 100644 index 0000000..0e63193 --- /dev/null +++ b/Builder/Services/BuilderRegistry.php @@ -0,0 +1,56 @@ +registerComponent(new $component); + } + foreach (['metric', 'chart', 'table', 'activity', 'calendar'] as $widget) { + $this->widgets[$widget] = ['type' => $widget, 'label' => str($widget)->headline()->toString()]; + } + foreach (['text', 'textarea', 'email', 'number', 'select', 'checkbox', 'date', 'file'] as $field) { + $this->fields[$field] = ['type' => $field, 'label' => str($field)->headline()->toString()]; + } + } + + public function registerComponent(BuilderComponent $component): void + { + $this->components[$component->type()] = $component; + } + + public function component(string $type): BuilderComponent + { + return $this->components[$type] ?? throw new InvalidArgumentException("Unknown builder component [$type]."); + } + + public function components(): array + { + return array_map(fn (BuilderComponent $c) => ['type' => $c->type(), 'label' => $c->label(), 'category' => $c->category(), 'schema' => $c->schema(), 'defaults' => $c->defaults()], array_values($this->components)); + } + + public function widgets(): array + { + return array_values($this->widgets); + } + + public function fields(): array + { + return array_values($this->fields); + } +} diff --git a/Builder/Services/BuilderRenderer.php b/Builder/Services/BuilderRenderer.php new file mode 100644 index 0000000..2b8a647 --- /dev/null +++ b/Builder/Services/BuilderRenderer.php @@ -0,0 +1,19 @@ +map(function (array $node) use ($context) { + if (($node['kind'] ?? 'component') === 'container') { + return '
'.$this->render($node, $context).'
'; + } + + return $this->registry->component((string) ($node['type'] ?? 'content'))->render($node['properties'] ?? [], $context); + })->implode("\n"); + } +} diff --git a/Builder/Tests/Feature/BuilderModuleTest.php b/Builder/Tests/Feature/BuilderModuleTest.php new file mode 100644 index 0000000..0844567 --- /dev/null +++ b/Builder/Tests/Feature/BuilderModuleTest.php @@ -0,0 +1,16 @@ +components(); + $this->assertNotEmpty($components); + $this->assertContains('hero', array_column($components, 'type')); + } +} diff --git a/Builder/module.json b/Builder/module.json new file mode 100644 index 0000000..a17c5b0 --- /dev/null +++ b/Builder/module.json @@ -0,0 +1 @@ +{"name":"Builder","alias":"builder","version":"1.0.0","description":"Low-code visual builders for pages, forms, dashboards, reports, menus, emails, queries and workflows.","keywords":["builder","low-code","visual","components","layouts"],"priority":160,"providers":["Modules\\Builder\\Providers\\BuilderServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Users","Roles","Pages","Forms","Menus","Workflow","Analytics","Sites"]} diff --git a/CRM/Config/config.php b/CRM/Config/config.php new file mode 100644 index 0000000..c94cac3 --- /dev/null +++ b/CRM/Config/config.php @@ -0,0 +1,3 @@ + 'CRM']; diff --git a/CRM/Database/Migrations/2026_07_24_04000_create_crm_tables.php b/CRM/Database/Migrations/2026_07_24_04000_create_crm_tables.php new file mode 100644 index 0000000..cf3bc64 --- /dev/null +++ b/CRM/Database/Migrations/2026_07_24_04000_create_crm_tables.php @@ -0,0 +1,79 @@ +id(); + $t->string('name'); + $t->string('website')->nullable(); + $t->string('phone')->nullable(); + $t->string('email')->nullable(); + $t->string('industry')->nullable(); + $t->text('address')->nullable(); + $t->text('notes')->nullable(); + $t->boolean('is_active')->default(true); + $t->timestamps(); + }); + Schema::create('crm_contacts', function (Blueprint $t): void { + $t->id(); + $t->foreignId('company_id')->nullable()->constrained('crm_companies')->nullOnDelete(); + $t->string('first_name'); + $t->string('last_name'); + $t->string('email')->nullable()->index(); + $t->string('phone')->nullable(); + $t->string('job_title')->nullable(); + $t->text('notes')->nullable(); + $t->boolean('is_active')->default(true); + $t->timestamps(); + }); + Schema::create('crm_leads', function (Blueprint $t): void { + $t->id(); + $t->foreignId('contact_id')->nullable()->constrained('crm_contacts')->nullOnDelete(); + $t->string('title'); + $t->string('source')->nullable(); + $t->string('status')->default('new')->index(); + $t->decimal('estimated_value', 12, 2)->nullable(); + $t->text('notes')->nullable(); + $t->timestamp('next_action_at')->nullable(); + $t->timestamps(); + }); + Schema::create('crm_opportunities', function (Blueprint $t): void { + $t->id(); + $t->foreignId('company_id')->nullable()->constrained('crm_companies')->nullOnDelete(); + $t->foreignId('contact_id')->nullable()->constrained('crm_contacts')->nullOnDelete(); + $t->string('name'); + $t->string('stage')->default('qualification')->index(); + $t->decimal('value', 12, 2)->default(0); + $t->unsignedTinyInteger('probability')->default(0); + $t->date('expected_close_at')->nullable(); + $t->text('notes')->nullable(); + $t->timestamps(); + }); + Schema::create('crm_activities', function (Blueprint $t): void { + $t->id(); + $t->nullableMorphs('subject'); + $t->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $t->string('type'); + $t->string('title'); + $t->text('description')->nullable(); + $t->timestamp('due_at')->nullable(); + $t->timestamp('completed_at')->nullable(); + $t->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('crm_activities'); + Schema::dropIfExists('crm_opportunities'); + Schema::dropIfExists('crm_leads'); + Schema::dropIfExists('crm_contacts'); + Schema::dropIfExists('crm_companies'); + } +}; diff --git a/CRM/Http/Controllers/CRMController.php b/CRM/Http/Controllers/CRMController.php new file mode 100644 index 0000000..0d85cd6 --- /dev/null +++ b/CRM/Http/Controllers/CRMController.php @@ -0,0 +1,55 @@ + Company::withCount('contacts')->latest()->paginate(20), 'contacts' => Contact::with('company')->latest()->limit(10)->get(), 'leads' => Lead::with('contact')->latest()->limit(10)->get(), 'opportunities' => Opportunity::with('company')->latest()->limit(10)->get(), 'pipelineValue' => Opportunity::whereNotIn('stage', ['won', 'lost'])->sum('value')]); + } + + public function storeCompany(Request $r): RedirectResponse + { + Company::create($r->validate(['name' => ['required', 'string', 'max:255'], 'website' => ['nullable', 'url', 'max:255'], 'phone' => ['nullable', 'string', 'max:80'], 'email' => ['nullable', 'email', 'max:255'], 'industry' => ['nullable', 'string', 'max:120'], 'address' => ['nullable', 'string'], 'notes' => ['nullable', 'string']]) + ['is_active' => $r->boolean('is_active', true)]); + + return back()->with('status', 'Company created.'); + } + + public function storeContact(Request $r): RedirectResponse + { + Contact::create($r->validate(['company_id' => ['nullable', 'exists:crm_companies,id'], 'first_name' => ['required', 'string', 'max:120'], 'last_name' => ['required', 'string', 'max:120'], 'email' => ['nullable', 'email', 'max:255'], 'phone' => ['nullable', 'string', 'max:80'], 'job_title' => ['nullable', 'string', 'max:120'], 'notes' => ['nullable', 'string']]) + ['is_active' => $r->boolean('is_active', true)]); + + return back()->with('status', 'Contact created.'); + } + + public function storeLead(Request $r): RedirectResponse + { + Lead::create($r->validate(['contact_id' => ['nullable', 'exists:crm_contacts,id'], 'title' => ['required', 'string', 'max:255'], 'source' => ['nullable', 'string', 'max:120'], 'status' => ['required', 'in:new,qualified,proposal,won,lost'], 'estimated_value' => ['nullable', 'numeric', 'min:0'], 'notes' => ['nullable', 'string'], 'next_action_at' => ['nullable', 'date']])); + + return back()->with('status', 'Lead created.'); + } + + public function storeOpportunity(Request $r): RedirectResponse + { + Opportunity::create($r->validate(['company_id' => ['nullable', 'exists:crm_companies,id'], 'contact_id' => ['nullable', 'exists:crm_contacts,id'], 'name' => ['required', 'string', 'max:255'], 'stage' => ['required', 'in:qualification,proposal,negotiation,won,lost'], 'value' => ['required', 'numeric', 'min:0'], 'probability' => ['required', 'integer', 'between:0,100'], 'expected_close_at' => ['nullable', 'date'], 'notes' => ['nullable', 'string']])); + + return back()->with('status', 'Opportunity created.'); + } + + public function destroyCompany(Company $company): RedirectResponse + { + $company->delete(); + + return back()->with('status', 'Company deleted.'); + } +} diff --git a/CRM/Models/Activity.php b/CRM/Models/Activity.php new file mode 100644 index 0000000..1a2e8b6 --- /dev/null +++ b/CRM/Models/Activity.php @@ -0,0 +1,23 @@ + 'datetime', 'completed_at' => 'datetime']; + } + + public function subject(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/CRM/Models/Company.php b/CRM/Models/Company.php new file mode 100644 index 0000000..76bcf10 --- /dev/null +++ b/CRM/Models/Company.php @@ -0,0 +1,23 @@ + 'boolean']; + } + + public function contacts(): HasMany + { + return $this->hasMany(Contact::class); + } +} diff --git a/CRM/Models/Contact.php b/CRM/Models/Contact.php new file mode 100644 index 0000000..e1f08cb --- /dev/null +++ b/CRM/Models/Contact.php @@ -0,0 +1,28 @@ + 'boolean']; + } + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + public function getNameAttribute(): string + { + return trim($this->first_name.' '.$this->last_name); + } +} diff --git a/CRM/Models/Lead.php b/CRM/Models/Lead.php new file mode 100644 index 0000000..571b5dd --- /dev/null +++ b/CRM/Models/Lead.php @@ -0,0 +1,23 @@ + 'decimal:2', 'next_action_at' => 'datetime']; + } + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class); + } +} diff --git a/CRM/Models/Opportunity.php b/CRM/Models/Opportunity.php new file mode 100644 index 0000000..6cd7884 --- /dev/null +++ b/CRM/Models/Opportunity.php @@ -0,0 +1,28 @@ + 'decimal:2', 'expected_close_at' => 'date']; + } + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class); + } +} diff --git a/CRM/Providers/CRMServiceProvider.php b/CRM/Providers/CRMServiceProvider.php new file mode 100644 index 0000000..ba46a79 --- /dev/null +++ b/CRM/Providers/CRMServiceProvider.php @@ -0,0 +1,20 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('CRM', 'Config/config.php'), 'crm'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('CRM', 'Database/Migrations')); + $this->loadViewsFrom(module_path('CRM', 'Resources/views'), 'crm'); + } +} diff --git a/CRM/Providers/RouteServiceProvider.php b/CRM/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..94e56b2 --- /dev/null +++ b/CRM/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +group(module_path('CRM', 'Routes/web.php')); + } +} diff --git a/CRM/Resources/views/admin/index.blade.php b/CRM/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..4a4868f --- /dev/null +++ b/CRM/Resources/views/admin/index.blade.php @@ -0,0 +1 @@ +

CRM

Companies, contacts, leads and sales opportunities.

@if(session('status'))
{{ session('status') }}
@endif
Companies
{{ $companies->total() }}
Contacts
{{ \Modules\CRM\Models\Contact::count() }}
Open leads
{{ \Modules\CRM\Models\Lead::whereNotIn('status',['won','lost'])->count() }}
Pipeline
£{{ number_format($pipelineValue,2) }}
@csrf

Add company

Create company
@csrf

Add contact

@foreach($companies as $company)@endforeach
Create contact
@forelse($companies as $company)@empty@endforelse
CompanyIndustryContacts
{{ $company->name }}{{ $company->industry ?: '—' }}{{ $company->contacts_count }}
@csrf @method('DELETE')Delete
No companies yet.
{{ $companies->links() }}
diff --git a/CRM/Routes/web.php b/CRM/Routes/web.php new file mode 100644 index 0000000..5333122 --- /dev/null +++ b/CRM/Routes/web.php @@ -0,0 +1,13 @@ +prefix('administration/crm')->name('administration.crm.')->group(function (): void { + Route::get('/', [CRMController::class, 'index'])->name('index'); + Route::post('/companies', [CRMController::class, 'storeCompany'])->name('companies.store'); + Route::delete('/companies/{company}', [CRMController::class, 'destroyCompany'])->name('companies.destroy'); + Route::post('/contacts', [CRMController::class, 'storeContact'])->name('contacts.store'); + Route::post('/leads', [CRMController::class, 'storeLead'])->name('leads.store'); + Route::post('/opportunities', [CRMController::class, 'storeOpportunity'])->name('opportunities.store'); +}); diff --git a/CRM/Tests/Feature/ModuleBootTest.php b/CRM/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..3bbcf6f --- /dev/null +++ b/CRM/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,13 @@ +assertSame('CRM', config('crm.name')); + } +} diff --git a/CRM/module.json b/CRM/module.json new file mode 100644 index 0000000..8d2e7b2 --- /dev/null +++ b/CRM/module.json @@ -0,0 +1,12 @@ +{ + "name": "CRM", + "alias": "crm", + "version": "1.0.0", + "description": "Customer relationship management for companies, contacts, leads, opportunities and activities.", + "keywords": ["business", "crm"], + "priority": 40, + "providers": ["Modules\\CRM\\Providers\\CRMServiceProvider"], + "aliases": {}, + "files": [], + "requires": ["Core", "Administration"] +} diff --git a/Careers/Config/config.php b/Careers/Config/config.php new file mode 100644 index 0000000..46a8b16 --- /dev/null +++ b/Careers/Config/config.php @@ -0,0 +1,3 @@ + 'Careers']; diff --git a/Careers/Database/Migrations/2026_07_24_04300_create_careers_tables.php b/Careers/Database/Migrations/2026_07_24_04300_create_careers_tables.php new file mode 100644 index 0000000..c8627af --- /dev/null +++ b/Careers/Database/Migrations/2026_07_24_04300_create_careers_tables.php @@ -0,0 +1,43 @@ +id(); + $t->string('title'); + $t->string('slug')->unique(); + $t->string('department')->nullable(); + $t->string('location')->nullable(); + $t->string('employment_type')->default('full-time'); + $t->longText('description')->nullable(); + $t->string('status')->default('draft')->index(); + $t->timestamp('published_at')->nullable(); + $t->timestamp('closes_at')->nullable(); + $t->timestamps(); + }); + Schema::create('career_applications', function (Blueprint $t): void { + $t->id(); + $t->foreignId('job_id')->constrained('career_jobs')->cascadeOnDelete(); + $t->string('name'); + $t->string('email')->index(); + $t->string('phone')->nullable(); + $t->string('cv_path')->nullable(); + $t->text('cover_letter')->nullable(); + $t->string('status')->default('new')->index(); + $t->text('notes')->nullable(); + $t->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('career_applications'); + Schema::dropIfExists('career_jobs'); + } +}; diff --git a/Careers/Http/Controllers/CareerController.php b/Careers/Http/Controllers/CareerController.php new file mode 100644 index 0000000..d9c2bad --- /dev/null +++ b/Careers/Http/Controllers/CareerController.php @@ -0,0 +1,32 @@ + Job::withCount('applications')->latest()->paginate(20), 'applications' => Application::latest()->limit(10)->get()]); + } + + public function store(Request $r): RedirectResponse + { + Job::create($r->validate(['title' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255', 'unique:career_jobs,slug'], 'department' => ['nullable', 'string', 'max:120'], 'location' => ['nullable', 'string', 'max:120'], 'employment_type' => ['required', 'in:full-time,part-time,contract,temporary,internship'], 'description' => ['nullable', 'string'], 'status' => ['required', 'in:draft,published,closed'], 'published_at' => ['nullable', 'date'], 'closes_at' => ['nullable', 'date', 'after:published_at']])); + + return back()->with('status', 'Job created.'); + } + + public function destroy(Job $job): RedirectResponse + { + $job->delete(); + + return back()->with('status', 'Job deleted.'); + } +} diff --git a/Careers/Models/Application.php b/Careers/Models/Application.php new file mode 100644 index 0000000..e85d73e --- /dev/null +++ b/Careers/Models/Application.php @@ -0,0 +1,12 @@ + 'datetime', 'closes_at' => 'datetime']; + } + + public function applications(): HasMany + { + return $this->hasMany(Application::class); + } +} diff --git a/Careers/Providers/CareersServiceProvider.php b/Careers/Providers/CareersServiceProvider.php new file mode 100644 index 0000000..5f4cfff --- /dev/null +++ b/Careers/Providers/CareersServiceProvider.php @@ -0,0 +1,20 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Careers', 'Config/config.php'), 'careers'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Careers', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Careers', 'Resources/views'), 'careers'); + } +} diff --git a/Careers/Providers/RouteServiceProvider.php b/Careers/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..4e15858 --- /dev/null +++ b/Careers/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +group(module_path('Careers', 'Routes/web.php')); + } +} diff --git a/Careers/Resources/views/admin/index.blade.php b/Careers/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..bac71a2 --- /dev/null +++ b/Careers/Resources/views/admin/index.blade.php @@ -0,0 +1 @@ +

Careers

Manage vacancies and candidate applications.

@if(session('status'))
{{ session('status') }}
@endif
@csrf

Create vacancy

Create vacancy
@forelse($jobs as $job)@empty@endforelse
VacancyDepartmentStatusApplications
{{ $job->title }}{{ $job->department ?: '—' }}{{ ucfirst($job->status) }}{{ $job->applications_count }}
@csrf @method('DELETE')Delete
No vacancies yet.
{{ $jobs->links() }}
diff --git a/Careers/Routes/web.php b/Careers/Routes/web.php new file mode 100644 index 0000000..ed2f45f --- /dev/null +++ b/Careers/Routes/web.php @@ -0,0 +1,10 @@ +prefix('administration/careers')->name('administration.careers.')->group(function (): void { + Route::get('/', [CareerController::class, 'index'])->name('index'); + Route::post('/', [CareerController::class, 'store'])->name('store'); + Route::delete('/{job}', [CareerController::class, 'destroy'])->name('destroy'); +}); diff --git a/Careers/Tests/Feature/ModuleBootTest.php b/Careers/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..28b7a14 --- /dev/null +++ b/Careers/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,13 @@ +assertSame('Careers', config('careers.name')); + } +} diff --git a/Careers/module.json b/Careers/module.json new file mode 100644 index 0000000..cda83cf --- /dev/null +++ b/Careers/module.json @@ -0,0 +1,12 @@ +{ + "name": "Careers", + "alias": "careers", + "version": "1.0.0", + "description": "Job vacancies and candidate application workflow.", + "keywords": ["business", "careers"], + "priority": 43, + "providers": ["Modules\\Careers\\Providers\\CareersServiceProvider"], + "aliases": {}, + "files": [], + "requires": ["Core", "Administration"] +} diff --git a/Categories/Config/config.php b/Categories/Config/config.php new file mode 100644 index 0000000..f310716 --- /dev/null +++ b/Categories/Config/config.php @@ -0,0 +1,3 @@ + 'Categories']; diff --git a/Categories/Database/Migrations/2026_07_24_030100_create_categories_table.php b/Categories/Database/Migrations/2026_07_24_030100_create_categories_table.php new file mode 100644 index 0000000..9796bc5 --- /dev/null +++ b/Categories/Database/Migrations/2026_07_24_030100_create_categories_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('parent_id')->nullable()->constrained('categories')->nullOnDelete(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->string('type')->default('content')->index(); + $table->unsignedInteger('sort_order')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('categories'); + } +}; diff --git a/Categories/Http/Controllers/CategoryController.php b/Categories/Http/Controllers/CategoryController.php new file mode 100644 index 0000000..feca75e --- /dev/null +++ b/Categories/Http/Controllers/CategoryController.php @@ -0,0 +1,56 @@ + Category::with('parent')->orderBy('type')->orderBy('sort_order')->paginate(30)]); + } + + public function create(): View + { + return view('categories::admin.form', ['parents' => Category::orderBy('name')->get()]); + } + + public function store(Request $request): RedirectResponse + { + Category::create($this->validated($request)); + + return redirect()->route('administration.categories.index')->with('status', 'Category created.'); + } + + public function edit(Category $category): View + { + return view('categories::admin.form', ['record' => $category, 'parents' => Category::whereKeyNot($category->getKey())->orderBy('name')->get()]); + } + + public function update(Request $request, Category $category): RedirectResponse + { + $category->update($this->validated($request)); + + return redirect()->route('administration.categories.index')->with('status', 'Category updated.'); + } + + public function destroy(Category $category): RedirectResponse + { + $category->delete(); + + return back()->with('status', 'Category deleted.'); + } + + private function validated(Request $request): array + { + $data = $request->validate(['parent_id' => ['nullable', 'exists:categories,id'], 'name' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255'], 'description' => ['nullable', 'string'], 'type' => ['required', 'string', 'max:50'], 'sort_order' => ['nullable', 'integer', 'min:0'], 'is_active' => ['sometimes', 'boolean']]); + $data['is_active'] = $request->boolean('is_active'); + + return $data; + } +} diff --git a/Categories/Models/Category.php b/Categories/Models/Category.php new file mode 100644 index 0000000..8835411 --- /dev/null +++ b/Categories/Models/Category.php @@ -0,0 +1,27 @@ + 'boolean', 'sort_order' => 'integer']; + } + + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id')->orderBy('sort_order'); + } +} diff --git a/Categories/Providers/CategoriesServiceProvider.php b/Categories/Providers/CategoriesServiceProvider.php new file mode 100644 index 0000000..8a20b37 --- /dev/null +++ b/Categories/Providers/CategoriesServiceProvider.php @@ -0,0 +1,20 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Categories', 'Config/config.php'), 'categories'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Categories', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Categories', 'Resources/views'), 'categories'); + } +} diff --git a/Categories/Providers/RouteServiceProvider.php b/Categories/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..1423c92 --- /dev/null +++ b/Categories/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +group(module_path('Categories', 'Routes/web.php')); + } +} diff --git a/Categories/Resources/views/admin/form.blade.php b/Categories/Resources/views/admin/form.blade.php new file mode 100644 index 0000000..3ec530b --- /dev/null +++ b/Categories/Resources/views/admin/form.blade.php @@ -0,0 +1 @@ +
@csrf @isset($record) @method('PUT') @endisset @foreach($parents as $parent)@endforeach{{ old('description',$record->description ?? '') }}Save
diff --git a/Categories/Resources/views/admin/index.blade.php b/Categories/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..8b15801 --- /dev/null +++ b/Categories/Resources/views/admin/index.blade.php @@ -0,0 +1 @@ +

Categories

Manage publishing categories.

Create
@if(session('status'))
{{ session('status') }}
@endif
@forelse($records as $record)@empty@endforelse
Name / titleStatusActions
{{ $record->title ?? $record->name }}{{ $record->status ?? ($record->is_active ? 'Active' : 'Inactive') }}Edit
@csrf @method('DELETE')Delete
No records yet.
{{ $records->links() }}
diff --git a/Categories/Routes/web.php b/Categories/Routes/web.php new file mode 100644 index 0000000..6ec5ed1 --- /dev/null +++ b/Categories/Routes/web.php @@ -0,0 +1,13 @@ +prefix('administration/categories')->name('administration.categories.')->group(function (): void { + Route::get('/', [CategoryController::class, 'index'])->name('index'); + Route::get('/create', [CategoryController::class, 'create'])->name('create'); + Route::post('/', [CategoryController::class, 'store'])->name('store'); + Route::get('/{category}/edit', [CategoryController::class, 'edit'])->name('edit'); + Route::match(['put', 'patch'], '/{category}', [CategoryController::class, 'update'])->name('update'); + Route::delete('/{category}', [CategoryController::class, 'destroy'])->name('destroy'); +}); diff --git a/Categories/Tests/Feature/ModuleBootTest.php b/Categories/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..479e0ae --- /dev/null +++ b/Categories/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,13 @@ +assertSame('Categories', config('categories.name')); + } +} diff --git a/Categories/module.json b/Categories/module.json new file mode 100644 index 0000000..bde3d86 --- /dev/null +++ b/Categories/module.json @@ -0,0 +1,12 @@ +{ + "name": "Categories", + "alias": "categories", + "version": "1.0.0", + "description": "Categories publishing module.", + "keywords": ["publishing", "categories"], + "priority": 30, + "providers": ["Modules\\Categories\\Providers\\CategoriesServiceProvider"], + "aliases": {}, + "files": [], + "requires": ["Core", "Administration"] +} diff --git a/CloudDevOps/Config/config.php b/CloudDevOps/Config/config.php new file mode 100644 index 0000000..8278777 --- /dev/null +++ b/CloudDevOps/Config/config.php @@ -0,0 +1,9 @@ + env('CLOUD_DEVOPS_ENABLED', true), + 'allow_execution' => env('CLOUD_DEVOPS_ALLOW_EXECUTION', false), + 'default_timeout' => (int) env('CLOUD_DEVOPS_TIMEOUT', 900), + 'retention_days' => (int) env('CLOUD_DEVOPS_RETENTION_DAYS', 90), + 'allowed_drivers' => ['local', 'ssh', 'docker', 'kubernetes'], +]; diff --git a/CloudDevOps/Console/Commands/CloudDevOpsHealthCommand.php b/CloudDevOps/Console/Commands/CloudDevOpsHealthCommand.php new file mode 100644 index 0000000..565e25b --- /dev/null +++ b/CloudDevOps/Console/Commands/CloudDevOpsHealthCommand.php @@ -0,0 +1,22 @@ +each(fn ($check) => $runner->run($check)); + $this->info('Health checks completed.'); + + return self::SUCCESS; + } +} diff --git a/CloudDevOps/Console/Commands/CloudDevOpsPruneCommand.php b/CloudDevOps/Console/Commands/CloudDevOpsPruneCommand.php new file mode 100644 index 0000000..4309b43 --- /dev/null +++ b/CloudDevOps/Console/Commands/CloudDevOpsPruneCommand.php @@ -0,0 +1,22 @@ +option('days') ?: config('clouddevops.retention_days', 90)); + $count = DeploymentRelease::where('created_at', '<', now()->subDays($days))->whereIn('status', ['succeeded', 'failed', 'cancelled'])->delete(); + $this->info("Pruned {$count} releases."); + + return self::SUCCESS; + } +} diff --git a/CloudDevOps/Database/Migrations/2026_07_24_180000_create_cloud_devops_tables.php b/CloudDevOps/Database/Migrations/2026_07_24_180000_create_cloud_devops_tables.php new file mode 100644 index 0000000..9b9d98e --- /dev/null +++ b/CloudDevOps/Database/Migrations/2026_07_24_180000_create_cloud_devops_tables.php @@ -0,0 +1,122 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('type')->default('production'); + $table->string('status')->default('active'); + $table->boolean('is_protected')->default(false); + $table->json('variables')->nullable(); + $table->timestamps(); + }); + + Schema::create('deployment_targets', function (Blueprint $table): void { + $table->id(); + $table->foreignId('environment_id')->constrained('deployment_environments')->cascadeOnDelete(); + $table->string('name'); + $table->string('driver')->default('ssh'); + $table->string('host')->nullable(); + $table->unsignedInteger('port')->nullable(); + $table->string('path')->nullable(); + $table->json('configuration')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + Schema::create('deployment_pipelines', function (Blueprint $table): void { + $table->id(); + $table->foreignId('environment_id')->constrained('deployment_environments')->cascadeOnDelete(); + $table->string('name'); + $table->string('trigger')->default('manual'); + $table->json('steps'); + $table->boolean('requires_approval')->default(false); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + Schema::create('deployment_releases', function (Blueprint $table): void { + $table->id(); + $table->foreignId('environment_id')->constrained('deployment_environments')->cascadeOnDelete(); + $table->foreignId('pipeline_id')->nullable()->constrained('deployment_pipelines')->nullOnDelete(); + $table->string('version'); + $table->string('commit_hash')->nullable(); + $table->string('status')->default('pending'); + $table->foreignId('requested_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->text('failure_message')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->index(['environment_id', 'status']); + }); + + Schema::create('deployment_steps', function (Blueprint $table): void { + $table->id(); + $table->foreignId('release_id')->constrained('deployment_releases')->cascadeOnDelete(); + $table->string('name'); + $table->unsignedInteger('position')->default(0); + $table->string('status')->default('pending'); + $table->text('output')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('deployment_health_checks', function (Blueprint $table): void { + $table->id(); + $table->foreignId('environment_id')->constrained('deployment_environments')->cascadeOnDelete(); + $table->string('name'); + $table->string('type')->default('http'); + $table->string('endpoint')->nullable(); + $table->unsignedInteger('expected_status')->default(200); + $table->string('status')->default('unknown'); + $table->unsignedInteger('response_time_ms')->nullable(); + $table->timestamp('checked_at')->nullable(); + $table->text('message')->nullable(); + $table->timestamps(); + }); + + Schema::create('deployment_rollbacks', function (Blueprint $table): void { + $table->id(); + $table->foreignId('environment_id')->constrained('deployment_environments')->cascadeOnDelete(); + $table->foreignId('from_release_id')->constrained('deployment_releases')->cascadeOnDelete(); + $table->foreignId('to_release_id')->nullable()->constrained('deployment_releases')->nullOnDelete(); + $table->string('status')->default('pending'); + $table->foreignId('requested_by')->nullable()->constrained('users')->nullOnDelete(); + $table->text('reason')->nullable(); + $table->timestamps(); + }); + + Schema::create('deployment_secrets', function (Blueprint $table): void { + $table->id(); + $table->foreignId('environment_id')->constrained('deployment_environments')->cascadeOnDelete(); + $table->string('key'); + $table->string('provider')->default('database'); + $table->string('reference')->nullable(); + $table->timestamp('rotated_at')->nullable(); + $table->timestamps(); + $table->unique(['environment_id', 'key']); + }); + } + + public function down(): void + { + Schema::dropIfExists('deployment_secrets'); + Schema::dropIfExists('deployment_rollbacks'); + Schema::dropIfExists('deployment_health_checks'); + Schema::dropIfExists('deployment_steps'); + Schema::dropIfExists('deployment_releases'); + Schema::dropIfExists('deployment_pipelines'); + Schema::dropIfExists('deployment_targets'); + Schema::dropIfExists('deployment_environments'); + } +}; diff --git a/CloudDevOps/Http/Controllers/CloudDevOpsApiController.php b/CloudDevOps/Http/Controllers/CloudDevOpsApiController.php new file mode 100644 index 0000000..54897f1 --- /dev/null +++ b/CloudDevOps/Http/Controllers/CloudDevOpsApiController.php @@ -0,0 +1,21 @@ +json(['data' => DeploymentEnvironment::withCount(['targets', 'pipelines', 'releases'])->get()]); + } + + public function releases(): JsonResponse + { + return response()->json(['data' => DeploymentRelease::with(['environment', 'steps'])->latest()->paginate()]); + } +} diff --git a/CloudDevOps/Http/Controllers/CloudDevOpsController.php b/CloudDevOps/Http/Controllers/CloudDevOpsController.php new file mode 100644 index 0000000..dcc212b --- /dev/null +++ b/CloudDevOps/Http/Controllers/CloudDevOpsController.php @@ -0,0 +1,50 @@ + DeploymentEnvironment::withCount(['targets', 'pipelines', 'releases'])->latest()->get(), + 'releases' => DeploymentRelease::with('environment')->latest()->limit(10)->get(), + 'checks' => DeploymentHealthCheck::with('environment')->latest('checked_at')->limit(10)->get(), + ]); + } + + public function storeEnvironment(Request $request): RedirectResponse + { + $data = $request->validate(['name' => 'required|string|max:120', 'slug' => 'required|alpha_dash|max:120|unique:deployment_environments,slug', 'type' => 'required|in:development,staging,production']); + DeploymentEnvironment::create($data); + + return back()->with('status', 'Environment created.'); + } + + public function deploy(Request $request, DeploymentEnvironment $environment, DeploymentManager $manager): RedirectResponse + { + $data = $request->validate(['pipeline_id' => 'required|integer', 'version' => 'nullable|string|max:120', 'commit_hash' => 'nullable|string|max:120']); + $pipeline = DeploymentPipeline::where('environment_id', $environment->id)->findOrFail($data['pipeline_id']); + $manager->createRelease($environment, $pipeline, $data + ['requested_by' => $request->user()?->id]); + + return back()->with('status', 'Release created.'); + } + + public function runCheck(DeploymentHealthCheck $check, HealthCheckRunner $runner): RedirectResponse + { + $runner->run($check); + + return back()->with('status', 'Health check completed.'); + } +} diff --git a/CloudDevOps/Models/DeploymentEnvironment.php b/CloudDevOps/Models/DeploymentEnvironment.php new file mode 100644 index 0000000..55bc24b --- /dev/null +++ b/CloudDevOps/Models/DeploymentEnvironment.php @@ -0,0 +1,36 @@ + 'encrypted:array', 'is_protected' => 'boolean']; + } + + public function targets(): HasMany + { + return $this->hasMany(DeploymentTarget::class, 'environment_id'); + } + + public function pipelines(): HasMany + { + return $this->hasMany(DeploymentPipeline::class, 'environment_id'); + } + + public function releases(): HasMany + { + return $this->hasMany(DeploymentRelease::class, 'environment_id'); + } + + public function healthChecks(): HasMany + { + return $this->hasMany(DeploymentHealthCheck::class, 'environment_id'); + } +} diff --git a/CloudDevOps/Models/DeploymentHealthCheck.php b/CloudDevOps/Models/DeploymentHealthCheck.php new file mode 100644 index 0000000..2ff53fb --- /dev/null +++ b/CloudDevOps/Models/DeploymentHealthCheck.php @@ -0,0 +1,15 @@ + 'datetime']; + } +} diff --git a/CloudDevOps/Models/DeploymentPipeline.php b/CloudDevOps/Models/DeploymentPipeline.php new file mode 100644 index 0000000..1aa3ad2 --- /dev/null +++ b/CloudDevOps/Models/DeploymentPipeline.php @@ -0,0 +1,15 @@ + 'array', 'requires_approval' => 'boolean', 'is_active' => 'boolean']; + } +} diff --git a/CloudDevOps/Models/DeploymentRelease.php b/CloudDevOps/Models/DeploymentRelease.php new file mode 100644 index 0000000..5eca011 --- /dev/null +++ b/CloudDevOps/Models/DeploymentRelease.php @@ -0,0 +1,27 @@ + 'array', 'started_at' => 'datetime', 'completed_at' => 'datetime']; + } + + public function environment(): BelongsTo + { + return $this->belongsTo(DeploymentEnvironment::class, 'environment_id'); + } + + public function steps(): HasMany + { + return $this->hasMany(DeploymentStep::class, 'release_id')->orderBy('position'); + } +} diff --git a/CloudDevOps/Models/DeploymentRollback.php b/CloudDevOps/Models/DeploymentRollback.php new file mode 100644 index 0000000..8c4cb66 --- /dev/null +++ b/CloudDevOps/Models/DeploymentRollback.php @@ -0,0 +1,10 @@ + 'datetime']; + } +} diff --git a/CloudDevOps/Models/DeploymentStep.php b/CloudDevOps/Models/DeploymentStep.php new file mode 100644 index 0000000..fc2e05b --- /dev/null +++ b/CloudDevOps/Models/DeploymentStep.php @@ -0,0 +1,15 @@ + 'datetime', 'completed_at' => 'datetime']; + } +} diff --git a/CloudDevOps/Models/DeploymentTarget.php b/CloudDevOps/Models/DeploymentTarget.php new file mode 100644 index 0000000..d5de432 --- /dev/null +++ b/CloudDevOps/Models/DeploymentTarget.php @@ -0,0 +1,15 @@ + 'encrypted:array', 'is_active' => 'boolean']; + } +} diff --git a/CloudDevOps/Providers/CloudDevOpsServiceProvider.php b/CloudDevOps/Providers/CloudDevOpsServiceProvider.php new file mode 100644 index 0000000..763eab7 --- /dev/null +++ b/CloudDevOps/Providers/CloudDevOpsServiceProvider.php @@ -0,0 +1,25 @@ +mergeConfigFrom(module_path('CloudDevOps', 'Config/config.php'), 'clouddevops'); + $this->app->register(RouteServiceProvider::class); + } + + public function boot(): void + { + $this->loadViewsFrom(module_path('CloudDevOps', 'Resources/views'), 'clouddevops'); + $this->loadMigrationsFrom(module_path('CloudDevOps', 'Database/Migrations')); + if ($this->app->runningInConsole()) { + $this->commands([CloudDevOpsHealthCommand::class, CloudDevOpsPruneCommand::class]); + } + } +} diff --git a/CloudDevOps/Providers/RouteServiceProvider.php b/CloudDevOps/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..1b62500 --- /dev/null +++ b/CloudDevOps/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +group(module_path('CloudDevOps', 'Routes/web.php')); + Route::middleware('api')->prefix('api')->group(module_path('CloudDevOps', 'Routes/api.php')); + } +} diff --git a/CloudDevOps/Resources/views/index.blade.php b/CloudDevOps/Resources/views/index.blade.php new file mode 100644 index 0000000..cefb0ce --- /dev/null +++ b/CloudDevOps/Resources/views/index.blade.php @@ -0,0 +1,44 @@ + +
+
+ Cloud & DevOps + Manage environments, deployment releases, pipelines and service health. +
+ + @if (session('status')) + {{ session('status') }} + @endif + +
+

Environments

{{ $environments->count() }}

+

Recent releases

{{ $releases->count() }}

+

Healthy checks

{{ $checks->where('status', 'healthy')->count() }}

+
+ + @can('cloud-devops.manage') +
+ @csrf + + + +
Create environment
+ + @endcan + +
+ Environments +
+ @forelse ($environments as $environment) +
+

{{ $environment->name }}

{{ ucfirst($environment->type) }} · {{ ucfirst($environment->status) }}

@if ($environment->is_protected)Protected@endif
+
Targets

{{ $environment->targets_count }}

Pipelines

{{ $environment->pipelines_count }}

Releases

{{ $environment->releases_count }}

+
+ @empty +
No deployment environments configured.
+ @endforelse +
+
+ +
Recent releases
@forelse ($releases as $release)@empty@endforelse
VersionEnvironmentStatusCreated
{{ $release->version }}{{ $release->environment?->name }}{{ ucfirst(str_replace('_', ' ', $release->status)) }}{{ $release->created_at?->diffForHumans() }}
No releases yet.
+
+
diff --git a/CloudDevOps/Routes/api.php b/CloudDevOps/Routes/api.php new file mode 100644 index 0000000..89d17bf --- /dev/null +++ b/CloudDevOps/Routes/api.php @@ -0,0 +1,9 @@ +prefix('v1/cloud-devops')->group(function (): void { + Route::get('/environments', [CloudDevOpsApiController::class, 'environments']); + Route::get('/releases', [CloudDevOpsApiController::class, 'releases']); +}); diff --git a/CloudDevOps/Routes/web.php b/CloudDevOps/Routes/web.php new file mode 100644 index 0000000..fb5ebf6 --- /dev/null +++ b/CloudDevOps/Routes/web.php @@ -0,0 +1,14 @@ +prefix('administration/cloud-devops') + ->name('administration.cloud-devops.') + ->group(function (): void { + Route::get('/', [CloudDevOpsController::class, 'index'])->name('index'); + Route::post('/environments', [CloudDevOpsController::class, 'storeEnvironment'])->middleware('permission:cloud-devops.manage')->name('environments.store'); + Route::post('/environments/{environment}/deploy', [CloudDevOpsController::class, 'deploy'])->middleware('permission:cloud-devops.deploy')->name('deploy'); + Route::post('/health-checks/{check}/run', [CloudDevOpsController::class, 'runCheck'])->middleware('permission:cloud-devops.manage')->name('checks.run'); + }); diff --git a/CloudDevOps/Services/DeploymentManager.php b/CloudDevOps/Services/DeploymentManager.php new file mode 100644 index 0000000..1b9c665 --- /dev/null +++ b/CloudDevOps/Services/DeploymentManager.php @@ -0,0 +1,40 @@ +releases()->create([ + 'pipeline_id' => $pipeline->getKey(), + 'version' => $attributes['version'] ?? now()->format('YmdHis').'-'.Str::lower(Str::random(6)), + 'commit_hash' => $attributes['commit_hash'] ?? null, + 'status' => $pipeline->requires_approval ? 'awaiting_approval' : 'pending', + 'requested_by' => $attributes['requested_by'] ?? null, + 'metadata' => $attributes['metadata'] ?? [], + ]); + + foreach ($pipeline->steps as $position => $step) { + $release->steps()->create(['name' => $step['name'] ?? 'Step '.($position + 1), 'position' => $position]); + } + + return $release->load('steps'); + }); + } + + public function execute(DeploymentRelease $release): DeploymentRelease + { + abort_unless(config('clouddevops.allow_execution'), 403, 'Deployment execution is disabled.'); + $release->update(['status' => 'running', 'started_at' => now()]); + + return $release->refresh(); + } +} diff --git a/CloudDevOps/Services/HealthCheckRunner.php b/CloudDevOps/Services/HealthCheckRunner.php new file mode 100644 index 0000000..3c9f32a --- /dev/null +++ b/CloudDevOps/Services/HealthCheckRunner.php @@ -0,0 +1,24 @@ +get((string) $check->endpoint); + $status = $response->status() === $check->expected_status ? 'healthy' : 'unhealthy'; + $message = 'HTTP '.$response->status(); + } catch (\Throwable $e) { + $status = 'unhealthy'; + $message = $e->getMessage(); + } $check->update(['status' => $status, 'response_time_ms' => (int) ((microtime(true) - $started) * 1000), 'checked_at' => now(), 'message' => $message]); + + return $check->refresh(); + } +} diff --git a/CloudDevOps/Tests/Feature/CloudDevOpsModuleTest.php b/CloudDevOps/Tests/Feature/CloudDevOpsModuleTest.php new file mode 100644 index 0000000..5425d71 --- /dev/null +++ b/CloudDevOps/Tests/Feature/CloudDevOpsModuleTest.php @@ -0,0 +1,18 @@ + 'Production', 'slug' => 'production', 'type' => 'production']); + $this->assertDatabaseHas('deployment_environments', ['id' => $environment->id, 'slug' => 'production']); + } +} diff --git a/CloudDevOps/module.json b/CloudDevOps/module.json new file mode 100644 index 0000000..5a14f70 --- /dev/null +++ b/CloudDevOps/module.json @@ -0,0 +1,9 @@ +{ + "name": "CloudDevOps", + "alias": "clouddevops", + "description": "Cloud deployment and DevOps operations for Content Manager.", + "keywords": ["cloud", "devops", "deployments", "pipelines"], + "priority": 18, + "providers": ["Modules\\CloudDevOps\\Providers\\CloudDevOpsServiceProvider"], + "files": [] +} diff --git a/Comments/Config/config.php b/Comments/Config/config.php new file mode 100644 index 0000000..e205f05 --- /dev/null +++ b/Comments/Config/config.php @@ -0,0 +1,3 @@ + 'Comments']; diff --git a/Comments/Database/Migrations/2026_07_24_030600_create_comments_table.php b/Comments/Database/Migrations/2026_07_24_030600_create_comments_table.php new file mode 100644 index 0000000..89de713 --- /dev/null +++ b/Comments/Database/Migrations/2026_07_24_030600_create_comments_table.php @@ -0,0 +1,31 @@ +id(); + $table->nullableMorphs('commentable'); + $table->foreignId('parent_id')->nullable()->constrained('comments')->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('author_name'); + $table->string('author_email'); + $table->text('body'); + $table->string('status')->default('pending')->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('moderation_notes')->nullable(); + $table->timestamp('approved_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('comments'); + } +}; diff --git a/Comments/Http/Controllers/CommentController.php b/Comments/Http/Controllers/CommentController.php new file mode 100644 index 0000000..c8948af --- /dev/null +++ b/Comments/Http/Controllers/CommentController.php @@ -0,0 +1,38 @@ +filled('status')) { + $q->where('status', $r->string('status')); + } + +return view('comments::admin.index', ['records' => $q->paginate(30)]); + } + + public function update(Request $r, Comment $comment): RedirectResponse + { + $data = $r->validate(['status' => ['required', 'in:pending,approved,spam,rejected'], 'moderation_notes' => ['nullable', 'string']]); + $data['approved_at'] = $data['status'] === 'approved' ? now() : null; + $comment->update($data); + + return back()->with('status', 'Comment moderated.'); + } + + public function destroy(Comment $comment): RedirectResponse + { + $comment->delete(); + + return back()->with('status', 'Comment deleted.'); + } +} diff --git a/Comments/Models/Comment.php b/Comments/Models/Comment.php new file mode 100644 index 0000000..0da0238 --- /dev/null +++ b/Comments/Models/Comment.php @@ -0,0 +1,21 @@ + 'datetime']; + } + + public function commentable(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/Comments/Providers/CommentsServiceProvider.php b/Comments/Providers/CommentsServiceProvider.php new file mode 100644 index 0000000..d82e931 --- /dev/null +++ b/Comments/Providers/CommentsServiceProvider.php @@ -0,0 +1,20 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Comments', 'Config/config.php'), 'comments'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Comments', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Comments', 'Resources/views'), 'comments'); + } +} diff --git a/Comments/Providers/RouteServiceProvider.php b/Comments/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..b83240b --- /dev/null +++ b/Comments/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +group(module_path('Comments', 'Routes/web.php')); + } +} diff --git a/Comments/Resources/views/admin/index.blade.php b/Comments/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..a9e6652 --- /dev/null +++ b/Comments/Resources/views/admin/index.blade.php @@ -0,0 +1 @@ +

Comment moderation

@if(session('status'))
{{ session('status') }}
@endif
@forelse($records as $comment)
{{ $comment->author_name }}{{ $comment->status }}

{{ $comment->body }}

@csrf @method('PUT')Update
@empty

No comments awaiting moderation.

@endforelse
{{ $records->links() }}
diff --git a/Comments/Routes/web.php b/Comments/Routes/web.php new file mode 100644 index 0000000..d40cb18 --- /dev/null +++ b/Comments/Routes/web.php @@ -0,0 +1,10 @@ +prefix('administration/comments')->name('administration.comments.')->group(function (): void { + Route::get('/', [CommentController::class, 'index'])->name('index'); + Route::put('/{comment}', [CommentController::class, 'update'])->name('update'); + Route::delete('/{comment}', [CommentController::class, 'destroy'])->name('destroy'); +}); diff --git a/Comments/Tests/Feature/ModuleBootTest.php b/Comments/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..bf969d3 --- /dev/null +++ b/Comments/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,13 @@ +assertSame('Comments', config('comments.name')); + } +} diff --git a/Comments/module.json b/Comments/module.json new file mode 100644 index 0000000..ab9f85e --- /dev/null +++ b/Comments/module.json @@ -0,0 +1,12 @@ +{ + "name": "Comments", + "alias": "comments", + "version": "1.0.0", + "description": "Comments publishing module.", + "keywords": ["publishing", "comments"], + "priority": 30, + "providers": ["Modules\\Comments\\Providers\\CommentsServiceProvider"], + "aliases": {}, + "files": [], + "requires": ["Core", "Administration"] +} diff --git a/Commerce/Config/config.php b/Commerce/Config/config.php new file mode 100644 index 0000000..7eb951b --- /dev/null +++ b/Commerce/Config/config.php @@ -0,0 +1,8 @@ + env('COMMERCE_CURRENCY', 'GBP'), + 'currency_symbol' => env('COMMERCE_CURRENCY_SYMBOL', '£'), + 'prices_include_tax' => (bool) env('COMMERCE_PRICES_INCLUDE_TAX', true), + 'low_stock_threshold' => (int) env('COMMERCE_LOW_STOCK_THRESHOLD', 5), +]; diff --git a/Commerce/Contracts/InventoryProvider.php b/Commerce/Contracts/InventoryProvider.php new file mode 100644 index 0000000..d2aaa5c --- /dev/null +++ b/Commerce/Contracts/InventoryProvider.php @@ -0,0 +1,12 @@ +id(); + $table->foreignId('parent_id')->nullable()->constrained('commerce_categories')->nullOnDelete(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + }); + Schema::create('commerce_products', function (Blueprint $table) { + $table->id(); + $table->foreignId('category_id')->nullable()->constrained('commerce_categories')->nullOnDelete(); + $table->string('type')->default('physical'); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('sku')->nullable()->unique(); + $table->text('summary')->nullable(); + $table->longText('description')->nullable(); + $table->decimal('price', 12, 2)->default(0); + $table->decimal('compare_at_price', 12, 2)->nullable(); + $table->decimal('cost_price', 12, 2)->nullable(); + $table->string('currency', 3)->default('GBP'); + $table->boolean('track_inventory')->default(true); + $table->integer('stock_quantity')->default(0); + $table->integer('low_stock_threshold')->default(5); + $table->boolean('is_featured')->default(false); + $table->string('status')->default('draft'); + $table->timestamp('published_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->index(['status', 'published_at']); + }); + Schema::create('commerce_product_variants', function (Blueprint $table) { + $table->id(); + $table->foreignId('product_id')->constrained('commerce_products')->cascadeOnDelete(); + $table->string('name'); + $table->string('sku')->unique(); + $table->decimal('price', 12, 2)->nullable(); + $table->integer('stock_quantity')->default(0); + $table->json('options')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + Schema::create('commerce_customers', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('email')->index(); + $table->string('first_name'); + $table->string('last_name'); + $table->string('company')->nullable(); + $table->string('phone')->nullable(); + $table->string('tax_number')->nullable(); + $table->json('billing_address')->nullable(); + $table->json('shipping_address')->nullable(); + $table->string('status')->default('active'); + $table->timestamps(); + }); + Schema::create('commerce_orders', function (Blueprint $table) { + $table->id(); + $table->string('number')->unique(); + $table->foreignId('customer_id')->nullable()->constrained('commerce_customers')->nullOnDelete(); + $table->string('email'); + $table->string('status')->default('pending'); + $table->string('payment_status')->default('unpaid'); + $table->string('fulfilment_status')->default('unfulfilled'); + $table->string('currency', 3)->default('GBP'); + $table->decimal('subtotal', 12, 2)->default(0); + $table->decimal('discount_total', 12, 2)->default(0); + $table->decimal('shipping_total', 12, 2)->default(0); + $table->decimal('tax_total', 12, 2)->default(0); + $table->decimal('grand_total', 12, 2)->default(0); + $table->json('billing_address')->nullable(); + $table->json('shipping_address')->nullable(); + $table->text('notes')->nullable(); + $table->timestamp('placed_at')->nullable(); + $table->timestamps(); + $table->index(['status', 'payment_status']); + }); + Schema::create('commerce_order_items', function (Blueprint $table) { + $table->id(); + $table->foreignId('order_id')->constrained('commerce_orders')->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->constrained('commerce_products')->nullOnDelete(); + $table->foreignId('variant_id')->nullable()->constrained('commerce_product_variants')->nullOnDelete(); + $table->string('name'); + $table->string('sku')->nullable(); + $table->unsignedInteger('quantity'); + $table->decimal('unit_price', 12, 2); + $table->decimal('tax_total', 12, 2)->default(0); + $table->decimal('line_total', 12, 2); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + Schema::create('commerce_inventory_movements', function (Blueprint $table) { + $table->id(); + $table->foreignId('product_id')->constrained('commerce_products')->cascadeOnDelete(); + $table->foreignId('variant_id')->nullable()->constrained('commerce_product_variants')->cascadeOnDelete(); + $table->integer('quantity'); + $table->string('reason'); + $table->string('reference_type')->nullable(); + $table->unsignedBigInteger('reference_id')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->index(['reference_type', 'reference_id']); + }); + Schema::create('commerce_promotions', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('code')->nullable()->unique(); + $table->string('type')->default('percentage'); + $table->decimal('value', 12, 2); + $table->decimal('minimum_order', 12, 2)->nullable(); + $table->unsignedInteger('usage_limit')->nullable(); + $table->unsignedInteger('times_used')->default(0); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->boolean('is_active')->default(true); + $table->json('conditions')->nullable(); + $table->timestamps(); + }); + Schema::create('commerce_shipping_methods', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('code')->unique(); + $table->decimal('price', 12, 2)->default(0); + $table->decimal('free_over', 12, 2)->nullable(); + $table->boolean('is_active')->default(true); + $table->json('settings')->nullable(); + $table->timestamps(); + }); + Schema::create('commerce_tax_rates', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('country_code', 2)->nullable(); + $table->decimal('rate', 7, 4)->default(0); + $table->boolean('is_default')->default(false); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + Schema::create('commerce_payment_transactions', function (Blueprint $table) { + $table->id(); + $table->foreignId('order_id')->constrained('commerce_orders')->cascadeOnDelete(); + $table->string('provider'); + $table->string('provider_reference')->nullable()->index(); + $table->string('type')->default('payment'); + $table->string('status')->default('pending'); + $table->decimal('amount', 12, 2); + $table->string('currency', 3); + $table->json('payload')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + foreach (['commerce_payment_transactions', 'commerce_tax_rates', 'commerce_shipping_methods', 'commerce_promotions', 'commerce_inventory_movements', 'commerce_order_items', 'commerce_orders', 'commerce_customers', 'commerce_product_variants', 'commerce_products', 'commerce_categories'] as $table) { + Schema::dropIfExists($table); + } + } +}; diff --git a/Commerce/Http/Controllers/CommerceController.php b/Commerce/Http/Controllers/CommerceController.php new file mode 100644 index 0000000..bf7eb83 --- /dev/null +++ b/Commerce/Http/Controllers/CommerceController.php @@ -0,0 +1,53 @@ + Product::latest()->paginate(20), 'orders' => Order::latest()->limit(10)->get(), 'revenue' => Order::where('payment_status', 'paid')->sum('grand_total'), 'lowStock' => Product::where('track_inventory', true)->whereColumn('stock_quantity', '<=', 'low_stock_threshold')->count(), 'customers' => Customer::count(), 'promotions' => Promotion::where('is_active', true)->count()]); + } + + public function storeProduct(Request $request): RedirectResponse + { + $data = $request->validate(['name' => ['required', 'string', 'max:255'], 'slug' => ['nullable', 'string', 'max:255', 'unique:commerce_products,slug'], 'sku' => ['nullable', 'string', 'max:120', 'unique:commerce_products,sku'], 'type' => ['required', 'in:physical,digital,service,bundle'], 'summary' => ['nullable', 'string'], 'description' => ['nullable', 'string'], 'price' => ['required', 'numeric', 'min:0'], 'stock_quantity' => ['required', 'integer'], 'status' => ['required', 'in:draft,active,archived']]); + $data['slug'] = $data['slug'] ?: Str::slug($data['name']); + $data['currency'] = config('commerce.currency', 'GBP'); + $data['published_at'] = $data['status'] === 'active' ? now() : null; + Product::create($data); + + return back()->with('status', 'Product created.'); + } + + public function updateProduct(Request $request, Product $product): RedirectResponse + { + $product->update($request->validate(['name' => ['required', 'string', 'max:255'], 'price' => ['required', 'numeric', 'min:0'], 'stock_quantity' => ['required', 'integer'], 'status' => ['required', 'in:draft,active,archived']])); + + return back()->with('status', 'Product updated.'); + } + + public function destroyProduct(Product $product): RedirectResponse + { + $product->delete(); + + return back()->with('status', 'Product deleted.'); + } + + public function updateOrder(Request $request, Order $order): RedirectResponse + { + $order->update($request->validate(['status' => ['required', 'in:pending,processing,completed,cancelled,refunded'], 'payment_status' => ['required', 'in:unpaid,authorised,paid,partially_refunded,refunded,failed'], 'fulfilment_status' => ['required', 'in:unfulfilled,partial,fulfilled,returned']])); + + return back()->with('status', 'Order updated.'); + } +} diff --git a/Commerce/Http/Controllers/ProductApiController.php b/Commerce/Http/Controllers/ProductApiController.php new file mode 100644 index 0000000..07e871f --- /dev/null +++ b/Commerce/Http/Controllers/ProductApiController.php @@ -0,0 +1,20 @@ +json(Product::query()->where('status', 'active')->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()))->latest()->paginate(24)); + } + + public function show(string $product): JsonResponse + { + return response()->json(Product::where('status', 'active')->where(fn ($q) => $q->where('slug', $product)->orWhere('id', $product))->firstOrFail()); + } +} diff --git a/Commerce/Models/Category.php b/Commerce/Models/Category.php new file mode 100644 index 0000000..76dce14 --- /dev/null +++ b/Commerce/Models/Category.php @@ -0,0 +1,17 @@ + 'boolean']; + } +} diff --git a/Commerce/Models/Customer.php b/Commerce/Models/Customer.php new file mode 100644 index 0000000..2ed400c --- /dev/null +++ b/Commerce/Models/Customer.php @@ -0,0 +1,17 @@ + 'array', 'shipping_address' => 'array']; + } +} diff --git a/Commerce/Models/InventoryMovement.php b/Commerce/Models/InventoryMovement.php new file mode 100644 index 0000000..68c3cb1 --- /dev/null +++ b/Commerce/Models/InventoryMovement.php @@ -0,0 +1,17 @@ + 'decimal:2', 'discount_total' => 'decimal:2', 'shipping_total' => 'decimal:2', 'tax_total' => 'decimal:2', 'grand_total' => 'decimal:2', 'billing_address' => 'array', 'shipping_address' => 'array', 'placed_at' => 'datetime']; + } +} diff --git a/Commerce/Models/OrderItem.php b/Commerce/Models/OrderItem.php new file mode 100644 index 0000000..06072b7 --- /dev/null +++ b/Commerce/Models/OrderItem.php @@ -0,0 +1,17 @@ + 'decimal:2', 'tax_total' => 'decimal:2', 'line_total' => 'decimal:2', 'metadata' => 'array']; + } +} diff --git a/Commerce/Models/Product.php b/Commerce/Models/Product.php new file mode 100644 index 0000000..a58482f --- /dev/null +++ b/Commerce/Models/Product.php @@ -0,0 +1,20 @@ + 'decimal:2', 'compare_at_price' => 'decimal:2', 'cost_price' => 'decimal:2', 'track_inventory' => 'boolean', 'is_featured' => 'boolean', 'published_at' => 'datetime', 'metadata' => 'array']; + } +} diff --git a/Commerce/Models/ProductVariant.php b/Commerce/Models/ProductVariant.php new file mode 100644 index 0000000..72482ce --- /dev/null +++ b/Commerce/Models/ProductVariant.php @@ -0,0 +1,17 @@ + 'decimal:2', 'options' => 'array', 'is_active' => 'boolean']; + } +} diff --git a/Commerce/Models/Promotion.php b/Commerce/Models/Promotion.php new file mode 100644 index 0000000..e16cb92 --- /dev/null +++ b/Commerce/Models/Promotion.php @@ -0,0 +1,17 @@ + 'decimal:2', 'minimum_order' => 'decimal:2', 'starts_at' => 'datetime', 'ends_at' => 'datetime', 'is_active' => 'boolean', 'conditions' => 'array']; + } +} diff --git a/Commerce/Models/Relations.php b/Commerce/Models/Relations.php new file mode 100644 index 0000000..7814843 --- /dev/null +++ b/Commerce/Models/Relations.php @@ -0,0 +1,3 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Commerce', 'Config/config.php'), 'commerce'); + $this->app->bind(InventoryProvider::class, DatabaseInventoryProvider::class); + $this->app->bind(PaymentProvider::class, ManualPaymentProvider::class); + $this->app->bind(ShippingProvider::class, FlatRateShippingProvider::class); + $this->app->bind(TaxProvider::class, ConfiguredTaxProvider::class); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Commerce', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Commerce', 'Resources/views'), 'commerce'); + } +} diff --git a/Commerce/Providers/RouteServiceProvider.php b/Commerce/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..1dcfdb7 --- /dev/null +++ b/Commerce/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +group(module_path('Commerce', 'Routes/web.php')); + Route::prefix('api/v1')->middleware('api')->group(module_path('Commerce', 'Routes/api.php')); + } +} diff --git a/Commerce/Resources/views/admin/index.blade.php b/Commerce/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..4960512 --- /dev/null +++ b/Commerce/Resources/views/admin/index.blade.php @@ -0,0 +1,5 @@ +

Commerce

Catalogue, stock, customers, orders and promotions.

@if(session('status'))
{{ session('status') }}
@endif +
Products
{{ $products->total() }}
Orders
{{ \Modules\Commerce\Models\Order::count() }}
Revenue
{{ config('commerce.currency_symbol','£') }}{{ number_format($revenue,2) }}
Customers
{{ $customers }}
Low stock
{{ $lowStock }}
+
@csrf

Add product

Create product +
@forelse($products as $product)@empty@endforelse
ProductSKUPriceStockStatus
{{ $product->name }}{{ $product->sku ?: '—' }}{{ config('commerce.currency_symbol','£') }}{{ number_format($product->price,2) }}{{ $product->stock_quantity }}{{ ucfirst($product->status) }}
@csrf @method('DELETE')Delete
No products yet.
{{ $products->links() }} +

Recent orders

@forelse($orders as $order)@empty@endforelse
OrderCustomerTotalStatusPayment
{{ $order->number }}{{ $order->email }}{{ $order->currency }} {{ number_format($order->grand_total,2) }}{{ ucfirst($order->status) }}{{ ucfirst($order->payment_status) }}
No orders yet.
diff --git a/Commerce/Routes/api.php b/Commerce/Routes/api.php new file mode 100644 index 0000000..16ddbdc --- /dev/null +++ b/Commerce/Routes/api.php @@ -0,0 +1,7 @@ +name('administration.commerce.')->middleware(['auth', 'permission:commerce.view'])->group(function () { + Route::get('/', [CommerceController::class, 'index'])->name('index'); + Route::post('/products', [CommerceController::class, 'storeProduct'])->middleware('permission:commerce.manage')->name('products.store'); + Route::put('/products/{product}', [CommerceController::class, 'updateProduct'])->middleware('permission:commerce.manage')->name('products.update'); + Route::delete('/products/{product}', [CommerceController::class, 'destroyProduct'])->middleware('permission:commerce.manage')->name('products.destroy'); + Route::put('/orders/{order}', [CommerceController::class, 'updateOrder'])->middleware('permission:commerce.manage')->name('orders.update'); +}); diff --git a/Commerce/Services/ConfiguredTaxProvider.php b/Commerce/Services/ConfiguredTaxProvider.php new file mode 100644 index 0000000..ee0a4ad --- /dev/null +++ b/Commerce/Services/ConfiguredTaxProvider.php @@ -0,0 +1,13 @@ +increment('stock_quantity', $quantity); + InventoryMovement::create(['product_id' => $product->id, 'quantity' => $quantity, 'reason' => $reason, 'created_by' => $userId]); + }); + } + + public function available(Product $product): int + { + return max(0, (int) $product->stock_quantity); + } +} diff --git a/Commerce/Services/FlatRateShippingProvider.php b/Commerce/Services/FlatRateShippingProvider.php new file mode 100644 index 0000000..30e08ed --- /dev/null +++ b/Commerce/Services/FlatRateShippingProvider.php @@ -0,0 +1,13 @@ += 50 ? 0.0 : 4.95; + } +} diff --git a/Commerce/Services/ManualPaymentProvider.php b/Commerce/Services/ManualPaymentProvider.php new file mode 100644 index 0000000..5d7ebed --- /dev/null +++ b/Commerce/Services/ManualPaymentProvider.php @@ -0,0 +1,19 @@ + 'pending', 'provider' => 'manual', 'reference' => null]; + } + + public function refund(Order $order, float $amount): array + { + return ['status' => 'pending', 'provider' => 'manual', 'amount' => $amount]; + } +} diff --git a/Commerce/Tests/Feature/ModuleBootTest.php b/Commerce/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..2932611 --- /dev/null +++ b/Commerce/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,19 @@ +assertTrue(app('router')->has('administration.commerce.index')); + } + + public function test_commerce_contracts_are_bound(): void + { + $this->assertInstanceOf(InventoryProvider::class, app(InventoryProvider::class)); + } +} diff --git a/Commerce/module.json b/Commerce/module.json new file mode 100644 index 0000000..f5f48cb --- /dev/null +++ b/Commerce/module.json @@ -0,0 +1 @@ +{"name":"Commerce","alias":"commerce","version":"1.0.0","description":"Products, inventory, customers, orders, promotions, tax, shipping and payments.","keywords":["commerce","catalog","inventory","orders","customers"],"priority":110,"providers":["Modules\\Commerce\\Providers\\CommerceServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Media","Sites"]} diff --git a/Content/Config/config.php b/Content/Config/config.php new file mode 100644 index 0000000..b0ba6d5 --- /dev/null +++ b/Content/Config/config.php @@ -0,0 +1,5 @@ + 'home', + 'regions' => ['header', 'body', 'footer'], +]; diff --git a/Content/Console/InstallSaaslandContent.php b/Content/Console/InstallSaaslandContent.php new file mode 100644 index 0000000..ba501a6 --- /dev/null +++ b/Content/Console/InstallSaaslandContent.php @@ -0,0 +1,244 @@ + ! Schema::hasTable($table))); + + if ($missingTables !== []) { + $this->components->error('Content composition tables are missing: '.implode(', ', $missingTables).'.'); + $this->components->info('Run php artisan migrate before installing the Saasland composition.'); + return self::FAILURE; + } + + DB::transaction(function (): void { + $this->installSharedSettings(); + $this->installMenus(); + if ($this->option('force')) { + PageContent::query()->where('page_key', 'home')->delete(); + } + + $order = [ + 'site-header' => ['header', 10], 'hero' => ['body', 10], 'feature-grid' => ['body', 20], + 'product-feed' => ['body', 30], 'testimonial-feed' => ['body', 40], 'team-feed' => ['body', 50], + 'blog-feed' => ['body', 60], 'news-feed' => ['body', 70], 'faq-feed' => ['body', 80], + 'newsletter-form' => ['body', 90], 'call-to-action' => ['body', 100], 'site-footer' => ['footer', 10], + ]; + + foreach ($this->definitions() as $key => $definition) { + $type = ContentType::query()->updateOrCreate(['key' => $key], $definition['type']); + $document = ContentDocument::query()->firstOrCreate( + ['slug' => 'saasland-'.$key], + [ + 'content_type_id' => $type->id, + 'title' => $definition['title'], + 'data' => $definition['data'], + 'status' => 'published', + 'published_at' => now(), + ] + ); + + $currentData = is_array($document->data) ? $document->data : []; + $mergedData = $this->mergeMissingDefaults($currentData, $definition['data']); + + $document->forceFill([ + 'content_type_id' => $type->id, + 'data' => $mergedData, + ])->save(); + [$region, $sort] = $order[$key]; + PageContent::query()->updateOrCreate( + ['page_key' => 'home', 'region' => $region, 'sort_order' => $sort], + ['content_document_id' => $document->id, 'is_enabled' => true] + ); + } + }); + + $this->components->info('Saasland content composition installed with editable schemas.'); + return self::SUCCESS; + } + + private function installSharedSettings(): void + { + $settings = app(SettingsRepository::class); + $legacy = []; + + if (Schema::hasTable('stylist_theme_settings')) { + $stored = DB::table('stylist_theme_settings')->where('theme_key', 'saasland')->value('settings'); + $legacy = is_string($stored) ? (json_decode($stored, true) ?: []) : []; + } + + $defaults = [ + 'site_name' => $legacy['site_title'] ?? config('app.name'), + 'site_description' => $legacy['site_description'] ?? 'Modern software for modern companies.', + 'company_name' => $legacy['site_title'] ?? config('app.name'), + 'site_url' => url('/'), + 'logo_url' => $legacy['logo_url'] ?? '', + 'facebook_url' => $legacy['facebook_url'] ?? '', + 'x_url' => $legacy['x_url'] ?? '', + 'linkedin_url' => $legacy['linkedin_url'] ?? '', + 'dashboard_label' => 'Dashboard', + 'login_label' => 'Log in', + 'registration_label' => 'Get started', + ]; + + foreach ($defaults as $key => $value) { + if ($settings->get($key, null, 'site') === null) { + $settings->put($key, $value, 'site', 'string', true); + } + } + } + + private function installMenus(): void + { + if (! Schema::hasTable('menus')) { + return; + } + + $menus = [ + 'primary' => ['Primary navigation', [['enabled' => true, 'label' => 'Home', 'url' => '/'], ['enabled' => true, 'label' => 'Blog', 'url' => '/blog'], ['enabled' => true, 'label' => 'News', 'url' => '/news']]], + 'footer' => ['Footer — Explore', [['enabled' => true, 'label' => 'Home', 'url' => '/'], ['enabled' => true, 'label' => 'Blog', 'url' => '/blog'], ['enabled' => true, 'label' => 'News', 'url' => '/news']]], + 'footer_account' => ['Footer — Account', [['enabled' => true, 'label' => 'Dashboard', 'url' => '/dashboard'], ['enabled' => true, 'label' => 'Profile', 'url' => '/settings/profile']]], + 'footer_legal' => ['Footer — Legal', []], + ]; + + foreach ($menus as $location => [$name, $items]) { + Menu::query()->firstOrCreate(['location' => $location], ['name' => $name, 'items' => $items, 'enabled' => true]); + } + } + + private function mergeMissingDefaults(array $current, array $defaults): array + { + foreach ($defaults as $key => $defaultValue) { + if (! array_key_exists($key, $current)) { + $current[$key] = $defaultValue; + continue; + } + + if ( + is_array($defaultValue) + && is_array($current[$key]) + && ! array_is_list($defaultValue) + && ! array_is_list($current[$key]) + ) { + $current[$key] = $this->mergeMissingDefaults( + $current[$key], + $defaultValue + ); + } + } + + return $current; + } + + private function definitions(): array + { + $field = static fn (string $key, string $label, string $type = 'text', array $extra = []): array => array_merge(compact('key', 'label', 'type'), $extra); + $type = static fn (string $name, string $renderer, array $regions, array $schema): array => [ + 'name' => $name, 'renderer' => $renderer, 'allowed_regions' => $regions, + 'is_system' => true, 'is_active' => true, 'schema' => $schema, + ]; + $section = [$field('enabled', 'Show this section', 'boolean', ['default' => true])]; + $feed = array_merge($section, [ + $field('eyebrow', 'Eyebrow'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'), + $field('limit', 'Maximum items', 'number', ['min' => 1, 'max' => 50]), + $field('include_ids', 'Only show record IDs', 'csv', ['help' => 'Optional comma-separated IDs. Leave blank to use the latest records.']), + $field('exclude_ids', 'Exclude record IDs', 'csv', ['help' => 'Optional comma-separated IDs to hide from this section.']), + ]); + + return [ + 'site-header' => [ + 'title' => 'Main website header', + 'type' => $type('Site header', 'site-header', ['header'], [ + $field('enabled', 'Show header', 'boolean', ['default' => true]), + $field('menu_location', 'Navigation menu location', 'text', ['help' => 'Usually primary. Edit links from Administration → Menus.']), + $field('show_navigation', 'Show navigation', 'boolean', ['default' => true]), + $field('show_dashboard', 'Show dashboard link', 'boolean', ['default' => true]), + $field('show_user_name', 'Show signed-in user', 'boolean', ['default' => true]), + $field('show_login', 'Show login', 'boolean', ['default' => true]), + $field('show_registration', 'Show registration', 'boolean', ['default' => true]), + $field('show_mobile_menu', 'Show mobile menu', 'boolean', ['default' => true]), + ]), + 'data' => ['enabled' => true, 'menu_location' => 'primary', 'show_navigation' => true, 'show_dashboard' => true, 'show_user_name' => true, 'show_login' => true, 'show_registration' => true, 'show_mobile_menu' => true], + ], + 'hero' => [ + 'title' => 'Homepage hero', + 'type' => $type('Hero', 'hero', ['body'], [ + ...$section, $field('eyebrow', 'Eyebrow'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'), + $field('primary_label', 'Primary button label'), $field('primary_url', 'Primary button URL', 'url'), + $field('secondary_label', 'Secondary button label'), $field('secondary_url', 'Secondary button URL', 'url'), + $field('show_metrics', 'Show metrics panel', 'boolean', ['default' => true]), + $field('metrics', 'Metrics', 'repeater', ['fields' => [ + $field('enabled', 'Show item', 'boolean', ['default' => true]), $field('label', 'Label'), $field('value', 'Value'), $field('change', 'Change'), $field('height', 'Chart height (%)', 'number', ['min' => 0, 'max' => 100]), + ]]), + ]), + 'data' => ['enabled' => true, 'eyebrow' => 'A better way to run your business', 'heading' => 'Transform Your Business With Our SaaS Solution', 'description' => 'Streamline operations, boost productivity, and improve customer satisfaction with one connected platform built for modern teams.', 'primary_label' => 'Get started free', 'primary_url' => '/register', 'secondary_label' => 'Book demo', 'secondary_url' => '#contact', 'show_metrics' => true, 'metrics' => [ + ['enabled' => true, 'label' => 'Monthly revenue', 'value' => '£48,240', 'change' => '+18.4%', 'height' => 62], + ['enabled' => true, 'label' => 'Active users', 'value' => '12,842', 'change' => '+9.2%', 'height' => 78], + ['enabled' => true, 'label' => 'Conversion', 'value' => '7.8%', 'change' => '+2.1%', 'height' => 92], + ]], + ], + 'feature-grid' => [ + 'title' => 'Core features', + 'type' => $type('Feature grid', 'feature-grid', ['body'], [ + ...$section, $field('eyebrow', 'Eyebrow'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'), + $field('items', 'Features', 'repeater', ['fields' => [$field('enabled', 'Show item', 'boolean', ['default' => true]), $field('title', 'Title'), $field('description', 'Description', 'textarea')]]), + ]), + 'data' => ['enabled' => true, 'eyebrow' => 'Powerful features', 'heading' => 'Everything your team needs to grow', 'description' => 'Planning, collaboration, reporting, publishing, commerce and customer insight in one extensible platform.', 'items' => [ + ['enabled' => true, 'title' => 'Smart automation', 'description' => 'Remove repetitive work and delays.'], + ['enabled' => true, 'title' => 'Real-time analytics', 'description' => 'Turn activity into useful business insight.'], + ['enabled' => true, 'title' => 'Content operations', 'description' => 'Create, review and publish confidently.'], + ['enabled' => true, 'title' => 'Secure by design', 'description' => 'Protect users and data with dependable controls.'], + ['enabled' => true, 'title' => 'Flexible integrations', 'description' => 'Connect the tools your organisation already uses.'], + ['enabled' => true, 'title' => 'Modular architecture', 'description' => 'Activate only the capabilities each site needs.'], + ]], + ], + 'product-feed' => ['title' => 'Pricing plans', 'type' => $type('Product feed', 'product-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Simple pricing', 'heading' => 'Choose a plan that fits', 'description' => 'Plans are managed from Products & Pricing in the CMS.', 'limit' => 6, 'include_ids' => '', 'exclude_ids' => '']], + 'testimonial-feed' => ['title' => 'Customer stories', 'type' => $type('Testimonial feed', 'testimonial-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Customer stories', 'heading' => 'Trusted by ambitious teams', 'description' => '', 'limit' => 6, 'include_ids' => '', 'exclude_ids' => '']], + 'team-feed' => ['title' => 'Team', 'type' => $type('Team feed', 'team-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Our team', 'heading' => 'People behind the platform', 'description' => '', 'limit' => 8, 'include_ids' => '', 'exclude_ids' => '']], + 'blog-feed' => ['title' => 'Latest blog posts', 'type' => $type('Blog feed', 'blog-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Insights', 'heading' => 'Latest from the blog', 'description' => '', 'limit' => 3, 'include_ids' => '', 'exclude_ids' => '']], + 'news-feed' => ['title' => 'Latest news', 'type' => $type('News feed', 'news-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Newsroom', 'heading' => 'Latest news', 'description' => '', 'limit' => 3, 'include_ids' => '', 'exclude_ids' => '']], + 'faq-feed' => ['title' => 'Frequently asked questions', 'type' => $type('FAQ feed', 'faq-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'FAQ', 'heading' => 'Answers before you begin', 'description' => '', 'limit' => 12, 'include_ids' => '', 'exclude_ids' => '']], + 'newsletter-form' => [ + 'title' => 'Newsletter signup', + 'type' => $type('Newsletter form', 'newsletter-form', ['body'], [...$section, $field('eyebrow', 'Eyebrow'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'), $field('placeholder', 'Email placeholder'), $field('button_label', 'Button label')]), + 'data' => ['enabled' => true, 'eyebrow' => 'Newsletter', 'heading' => 'Useful updates, no noise', 'description' => 'Receive product news, publishing guidance, and practical platform updates.', 'placeholder' => 'you@example.com', 'button_label' => 'Subscribe'], + ], + 'call-to-action' => [ + 'title' => 'Homepage final call to action', + 'type' => $type('Call to action', 'call-to-action', ['body'], [...$section, $field('anchor', 'Section anchor'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'), $field('button_label', 'Button label'), $field('button_url', 'Button URL', 'url')]), + 'data' => ['enabled' => true, 'anchor' => 'contact', 'heading' => 'Ready to transform your business?', 'description' => 'Start building a faster, more connected operation today.', 'button_label' => 'Get started free', 'button_url' => '/register'], + ], + 'site-footer' => [ + 'title' => 'Main website footer', + 'type' => $type('Site footer', 'site-footer', ['footer'], [ + $field('enabled', 'Show footer', 'boolean', ['default' => true]), + $field('explore_heading', 'Explore column heading'), + $field('explore_menu_location', 'Explore menu location'), + $field('account_heading', 'Account column heading'), + $field('account_menu_location', 'Account menu location'), + $field('legal_heading', 'Legal column heading'), + $field('legal_menu_location', 'Legal menu location'), + $field('show_back_to_top', 'Show back-to-top link', 'boolean', ['default' => true]), + $field('back_to_top_label', 'Back-to-top label'), + ]), + 'data' => ['enabled' => true, 'explore_heading' => 'Explore', 'explore_menu_location' => 'footer', 'account_heading' => 'Account', 'account_menu_location' => 'footer_account', 'legal_heading' => 'Legal', 'legal_menu_location' => 'footer_legal', 'show_back_to_top' => true, 'back_to_top_label' => 'Back to top ↑'], + ], + ]; + } +} diff --git a/Content/Database/Migrations/2026_07_27_000000_create_content_composition_tables.php b/Content/Database/Migrations/2026_07_27_000000_create_content_composition_tables.php new file mode 100644 index 0000000..2fcd905 --- /dev/null +++ b/Content/Database/Migrations/2026_07_27_000000_create_content_composition_tables.php @@ -0,0 +1,62 @@ +id(); + $table->string('name'); + $table->string('key')->unique(); + $table->text('description')->nullable(); + $table->json('schema')->nullable(); + $table->string('renderer'); + $table->json('allowed_regions')->nullable(); + $table->boolean('is_system')->default(false); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + Schema::create('content_documents', function (Blueprint $table): void { + $table->id(); + $table->foreignId('content_type_id')->constrained('content_types')->cascadeOnDelete(); + $table->string('title'); + $table->string('slug')->unique(); + $table->json('data')->nullable(); + $table->string('status')->default('draft')->index(); + $table->timestamp('published_at')->nullable()->index(); + $table->timestamp('expires_at')->nullable(); + $table->string('language', 12)->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('page_content', function (Blueprint $table): void { + $table->id(); + $table->foreignId('page_id')->nullable()->constrained('pages')->cascadeOnDelete(); + $table->string('page_key')->nullable()->index(); + $table->foreignId('content_document_id')->constrained('content_documents')->cascadeOnDelete(); + $table->string('region')->index(); + $table->unsignedInteger('sort_order')->default(0); + $table->boolean('is_enabled')->default(true); + $table->json('visibility_rules')->nullable(); + $table->json('presentation')->nullable(); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->timestamps(); + $table->unique(['page_key', 'region', 'sort_order'], 'page_content_key_region_order_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('page_content'); + Schema::dropIfExists('content_documents'); + Schema::dropIfExists('content_types'); + } +}; diff --git a/Content/Http/Controllers/ContentDocumentController.php b/Content/Http/Controllers/ContentDocumentController.php new file mode 100644 index 0000000..af35ce9 --- /dev/null +++ b/Content/Http/Controllers/ContentDocumentController.php @@ -0,0 +1,146 @@ +with('type') + ->withCount('placements') + ->when($request->string('type')->isNotEmpty(), fn ($query) => $query->whereHas('type', fn ($type) => $type->where('key', $request->string('type')->toString()))) + ->when($request->string('status')->isNotEmpty(), fn ($query) => $query->where('status', $request->string('status')->toString())) + ->when($request->string('search')->isNotEmpty(), fn ($query) => $query->where('title', 'like', '%'.$request->string('search')->toString().'%')) + ->orderBy('title') + ->paginate(30) + ->withQueryString(); + + return view('content::admin.index', [ + 'documents' => $documents, + 'types' => ContentType::query()->where('is_active', true)->orderBy('name')->get(), + ]); + } + + public function create(Request $request): View + { + $types = ContentType::query()->where('is_active', true)->orderBy('name')->get(); + $selectedType = $types->firstWhere('id', (int) $request->integer('type')) ?? $types->first(); + + return view('content::admin.form', [ + 'document' => new ContentDocument(['status' => 'draft', 'data' => []]), + 'types' => $types, + 'selectedType' => $selectedType, + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $this->validateDocument($request); + $type = ContentType::query()->where('is_active', true)->findOrFail($validated['content_type_id']); + + $document = ContentDocument::query()->create([ + 'content_type_id' => $type->id, + 'title' => $validated['title'], + 'slug' => $this->uniqueSlug($validated['slug'] ?: $validated['title']), + 'status' => $validated['status'], + 'published_at' => $validated['status'] === 'published' ? ($validated['published_at'] ?? now()) : null, + 'expires_at' => $validated['expires_at'] ?? null, + 'data' => json_decode($validated['data_json'], true, 512, JSON_THROW_ON_ERROR), + 'created_by' => $request->user()?->id, + 'updated_by' => $request->user()?->id, + ]); + + return redirect()->route('content.documents.edit', $document)->with('status', 'Content document created.'); + } + + public function edit(ContentDocument $document): View + { + $document->load('type', 'placements'); + + return view('content::admin.form', [ + 'document' => $document, + 'types' => ContentType::query()->where('is_active', true)->orderBy('name')->get(), + 'selectedType' => $document->type, + ]); + } + + public function update(Request $request, ContentDocument $document): RedirectResponse + { + $validated = $this->validateDocument($request, $document); + + $document->update([ + 'content_type_id' => $validated['content_type_id'], + 'title' => $validated['title'], + 'slug' => $this->uniqueSlug($validated['slug'] ?: $validated['title'], $document), + 'status' => $validated['status'], + 'published_at' => $validated['status'] === 'published' ? ($validated['published_at'] ?? $document->published_at ?? now()) : null, + 'expires_at' => $validated['expires_at'] ?? null, + 'data' => json_decode($validated['data_json'], true, 512, JSON_THROW_ON_ERROR), + 'updated_by' => $request->user()?->id, + ]); + + return back()->with('status', 'Content document updated.'); + } + + public function duplicate(Request $request, ContentDocument $document): RedirectResponse + { + $copy = $document->replicate(['slug', 'status', 'published_at', 'created_by', 'updated_by']); + $copy->title = $document->title.' copy'; + $copy->slug = $this->uniqueSlug($copy->title); + $copy->status = 'draft'; + $copy->published_at = null; + $copy->created_by = $request->user()?->id; + $copy->updated_by = $request->user()?->id; + $copy->save(); + + return redirect()->route('content.documents.edit', $copy)->with('status', 'Independent draft copy created.'); + } + + public function destroy(ContentDocument $document): RedirectResponse + { + if ($document->placements()->exists()) { + return back()->withErrors(['document' => 'Remove this document from every page before deleting it.']); + } + + $document->delete(); + + return redirect()->route('content.index')->with('status', 'Content document deleted.'); + } + + private function validateDocument(Request $request, ?ContentDocument $document = null): array + { + return $request->validate([ + 'content_type_id' => ['required', 'integer', Rule::exists('content_types', 'id')->where('is_active', true)], + 'title' => ['required', 'string', 'max:255'], + 'slug' => ['nullable', 'string', 'max:255'], + 'status' => ['required', Rule::in(['draft', 'published'])], + 'published_at' => ['nullable', 'date'], + 'expires_at' => ['nullable', 'date', 'after:published_at'], + 'data_json' => ['required', 'json'], + ]); + } + + private function uniqueSlug(string $value, ?ContentDocument $except = null): string + { + $base = Str::slug($value) ?: 'content-document'; + $slug = $base; + $suffix = 2; + + while (ContentDocument::withTrashed()->where('slug', $slug)->when($except, fn ($query) => $query->whereKeyNot($except->getKey()))->exists()) { + $slug = $base.'-'.$suffix++; + } + + return $slug; + } +} diff --git a/Content/Http/Controllers/PageCompositionController.php b/Content/Http/Controllers/PageCompositionController.php new file mode 100644 index 0000000..d3c21ad --- /dev/null +++ b/Content/Http/Controllers/PageCompositionController.php @@ -0,0 +1,189 @@ +with('document.type') + ->where('page_key', $pageKey) + ->orderByRaw("CASE region WHEN 'header' THEN 1 WHEN 'body' THEN 2 ELSE 3 END") + ->orderBy('sort_order') + ->get() + ->groupBy('region'); + + $documents = ContentDocument::query() + ->with('type') + ->withCount('placements') + ->orderBy('title') + ->get(); + + return view('content::admin.composition', compact('pageKey', 'placements', 'documents')); + } + + + public function editPage(Page $page): View + { + $placements = PageContent::query() + ->with('document.type') + ->where(fn ($query) => $query->where('page_id', $page->id)->orWhere('page_key', $page->slug)) + ->orderByRaw("CASE region WHEN 'header' THEN 1 WHEN 'body' THEN 2 ELSE 3 END") + ->orderBy('sort_order') + ->get(); + + PageContent::query() + ->whereNull('page_id') + ->where('page_key', $page->slug) + ->update(['page_id' => $page->id]); + + $documents = ContentDocument::query() + ->with('type') + ->withCount('placements') + ->orderBy('title') + ->get(); + + return view('content::admin.composition', [ + 'pageKey' => $page->slug, + 'page' => $page, + 'placements' => $placements->groupBy('region'), + 'documents' => $documents, + ]); + } + + public function preview(string $pageKey): View + { + abort_unless($pageKey === 'home', 404, 'This phase currently provides the Saasland homepage preview.'); + + return view('stylist-theme-saasland::home', ['pageKey' => $pageKey]); + } + + public function store(Request $request, string $pageKey): RedirectResponse + { + $validated = $request->validate([ + 'content_document_id' => ['required', 'integer', Rule::exists('content_documents', 'id')->whereNull('deleted_at')], + 'region' => ['required', Rule::in(self::REGIONS)], + ]); + + $document = ContentDocument::query()->with('type')->findOrFail($validated['content_document_id']); + abort_unless(in_array($validated['region'], $document->type?->allowed_regions ?? [], true), 422, 'This content type is not allowed in the selected region.'); + + $page = Page::query()->where('slug', $pageKey)->first(); + $nextOrder = ((int) PageContent::query()->where(fn ($query) => $query->where('page_key', $pageKey)->when($page, fn ($q) => $q->orWhere('page_id', $page->id)))->where('region', $validated['region'])->max('sort_order')) + 10; + PageContent::query()->create([ + 'page_id' => $page?->id, + 'page_key' => $pageKey, + 'content_document_id' => $document->id, + 'region' => $validated['region'], + 'sort_order' => $nextOrder, + 'is_enabled' => true, + ]); + + return back()->with('status', 'Document added to the '.$validated['region'].' region.'); + } + + public function update(Request $request, PageContent $placement): RedirectResponse + { + $validated = $request->validate([ + 'is_enabled' => ['nullable', 'boolean'], + 'starts_at' => ['nullable', 'date'], + 'ends_at' => ['nullable', 'date', 'after:starts_at'], + ]); + + $placement->update([ + 'is_enabled' => $request->boolean('is_enabled'), + 'starts_at' => $validated['starts_at'] ?? null, + 'ends_at' => $validated['ends_at'] ?? null, + ]); + + return back()->with('status', 'Page placement updated.'); + } + + public function destroy(PageContent $placement): RedirectResponse + { + $placement->delete(); + $this->normaliseRegion($placement->page_key, $placement->region); + + return back()->with('status', 'Document removed from this page. The reusable document was not deleted.'); + } + + public function duplicate(Request $request, PageContent $placement): RedirectResponse + { + $source = $placement->document()->firstOrFail(); + $copy = DB::transaction(function () use ($request, $placement, $source): ContentDocument { + $copy = $source->replicate(['slug', 'status', 'published_at', 'created_by', 'updated_by']); + $copy->title = $source->title.' copy'; + $copy->slug = $source->slug.'-copy-'.strtolower(str()->random(6)); + $copy->status = 'draft'; + $copy->published_at = null; + $copy->created_by = $request->user()?->id; + $copy->updated_by = $request->user()?->id; + $copy->save(); + + PageContent::query()->create([ + 'page_id' => $placement->page_id, + 'page_key' => $placement->page_key, + 'content_document_id' => $copy->id, + 'region' => $placement->region, + 'sort_order' => $placement->sort_order + 5, + 'is_enabled' => false, + 'visibility_rules' => $placement->visibility_rules, + 'presentation' => $placement->presentation, + ]); + + return $copy; + }); + + $this->normaliseRegion($placement->page_key, $placement->region); + + return redirect()->route('content.documents.edit', $copy)->with('status', 'Page-specific draft copy created and added after the original.'); + } + + public function reorder(Request $request, string $pageKey): JsonResponse|RedirectResponse + { + $validated = $request->validate([ + 'region' => ['required', Rule::in(self::REGIONS)], + 'placements' => ['required', 'array'], + 'placements.*' => ['required', 'integer'], + ]); + + $ids = array_values(array_unique(array_map('intval', $validated['placements']))); + $owned = PageContent::query()->where('page_key', $pageKey)->where('region', $validated['region'])->whereIn('id', $ids)->pluck('id')->all(); + abort_unless(count($owned) === count($ids), 422, 'One or more placements do not belong to this page region.'); + + DB::transaction(function () use ($ids): void { + PageContent::query()->whereIn('id', $ids)->increment('sort_order', 100000); + foreach ($ids as $index => $id) { + PageContent::query()->whereKey($id)->update(['sort_order' => ($index + 1) * 10]); + } + }); + + return $request->expectsJson() + ? response()->json(['message' => 'Order saved.']) + : back()->with('status', 'Page order updated.'); + } + + private function normaliseRegion(?string $pageKey, string $region): void + { + $ids = PageContent::query()->where('page_key', $pageKey)->where('region', $region)->orderBy('sort_order')->pluck('id'); + DB::transaction(function () use ($ids): void { + foreach ($ids as $index => $id) { + PageContent::query()->whereKey($id)->update(['sort_order' => ($index + 1) * 10]); + } + }); + } +} diff --git a/Content/Models/ContentDocument.php b/Content/Models/ContentDocument.php new file mode 100644 index 0000000..edf93be --- /dev/null +++ b/Content/Models/ContentDocument.php @@ -0,0 +1,17 @@ +'array','published_at'=>'datetime','expires_at'=>'datetime']; } + public function type(): BelongsTo { return $this->belongsTo(ContentType::class, 'content_type_id'); } + public function placements(): HasMany { return $this->hasMany(PageContent::class); } + public function scopePublished($query) { return $query->where('status','published')->where(fn($q) => $q->whereNull('published_at')->orWhere('published_at','<=',now()))->where(fn($q) => $q->whereNull('expires_at')->orWhere('expires_at','>',now())); } +} diff --git a/Content/Models/ContentType.php b/Content/Models/ContentType.php new file mode 100644 index 0000000..6b0ffd7 --- /dev/null +++ b/Content/Models/ContentType.php @@ -0,0 +1,12 @@ +'array','allowed_regions'=>'array','is_system'=>'boolean','is_active'=>'boolean']; } + public function documents(): HasMany { return $this->hasMany(ContentDocument::class); } +} diff --git a/Content/Models/PageContent.php b/Content/Models/PageContent.php new file mode 100644 index 0000000..cb204f3 --- /dev/null +++ b/Content/Models/PageContent.php @@ -0,0 +1,15 @@ +'boolean','visibility_rules'=>'array','presentation'=>'array','starts_at'=>'datetime','ends_at'=>'datetime']; } + public function document(): BelongsTo { return $this->belongsTo(ContentDocument::class, 'content_document_id'); } + public function page(): BelongsTo { return $this->belongsTo(Page::class); } +} diff --git a/Content/Providers/ContentServiceProvider.php b/Content/Providers/ContentServiceProvider.php new file mode 100644 index 0000000..0ba0b27 --- /dev/null +++ b/Content/Providers/ContentServiceProvider.php @@ -0,0 +1,22 @@ +mergeConfigFrom(module_path('Content','Config/config.php'),'content'); + $this->app->singleton(PageRenderer::class); + } + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Content','Database/Migrations')); + $this->loadViewsFrom(module_path('Content','Resources/views'),'content'); + $this->loadRoutesFrom(module_path('Content','Routes/web.php')); + if ($this->app->runningInConsole()) $this->commands([InstallSaaslandContent::class]); + } +} diff --git a/Content/README.md b/Content/README.md new file mode 100644 index 0000000..b1a800d --- /dev/null +++ b/Content/README.md @@ -0,0 +1,37 @@ +# Content Composer + +The Content module provides reusable typed documents and ordered page regions. + +## Phase 2 features + +- Header, body and footer composition editor. +- Drag-and-drop ordering with server-side ownership validation. +- Add existing published or draft documents to a page. +- Remove a placement without deleting the reusable document. +- Create an independent page-specific draft copy. +- Shared-use warnings and deletion protection. +- Placement enable/disable and date scheduling. +- Content document creation, duplication, filtering and publishing controls. +- Authenticated Saasland homepage preview, including draft labels. +- Content Composer entry in the administration sidebar. + +## Setup + +```bash +php artisan migrate +php artisan content:install-saasland +``` + +Reset the seeded homepage composition when required: + +```bash +php artisan content:install-saasland --force +``` + +Administration: + +- `/admin/content` +- `/admin/content/pages/home` +- `/admin/content/pages/home/preview` + +The public theme remains backward-compatible: when no composition exists, the legacy Saasland homepage, header and footer are rendered. diff --git a/Content/Resources/views/admin/composition.blade.php b/Content/Resources/views/admin/composition.blade.php new file mode 100644 index 0000000..32a92ce --- /dev/null +++ b/Content/Resources/views/admin/composition.blade.php @@ -0,0 +1,147 @@ + +
+
+
+
Pages/Content composer
+

{{ isset($page) ? $page->title : ucfirst($pageKey).' page' }}

+

Add sections, edit their content and drag them into the order visitors should see.

+
+
+ @isset($page)Page settings@endisset + Create section + @if(isset($page) && $page->status === 'published')Open live page@endif +
+
+ + @if(session('status'))
{{ session('status') }}
@endif + @if($errors->any())
    @foreach($errors->all() as $error)
  • {{ $error }}
  • @endforeach
@endif + +
+
+ @foreach(['header', 'body', 'footer'] as $region) +
+
+

{{ ucfirst($region) }}

{{ $region === 'body' ? 'Ordered content sections' : 'Page-level '.$region.' document' }}

+ {{ $placements->get($region, collect())->count() }} document{{ $placements->get($region, collect())->count() === 1 ? '' : 's' }} +
+
+ @forelse($placements->get($region, collect()) as $placement) +
+
+ +
+
+

{{ $placement->document?->title }}

+ {{ $placement->document?->type?->name }} + @if(($placement->document?->placements()->count() ?? 0) > 1)Shared@endif + @unless($placement->is_enabled)Disabled@endunless +
+

Order {{ $placement->sort_order }} · {{ $placement->document?->status === 'published' ? 'Published' : 'Draft — hidden from public rendering' }}

+
+ Edit +
+ +
+ Placement settings +
+
+ @csrf @method('PUT') + + +
+ + Save +
+ +
+
+
@csrfCreate page-specific copy
+
@csrf @method('DELETE')Remove from page
+
+
+
+ @empty +
No documents assigned to this region.
+ @endforelse +
+
+ @endforeach +
+ + +
+
+ +@push('scripts') + +@endpush +
diff --git a/Content/Resources/views/admin/form.blade.php b/Content/Resources/views/admin/form.blade.php new file mode 100644 index 0000000..4a12768 --- /dev/null +++ b/Content/Resources/views/admin/form.blade.php @@ -0,0 +1,636 @@ + +
+
+
+ + Content documents + +

+ {{ $document->exists ? $document->title : 'Create content document' }} +

+

+ Edit every frontend field below. JSON is retained only as an emergency advanced tool. +

+
+ + @if ($document->exists) +
+
+ @csrf + Create independent copy +
+ +
+ @csrf + @method('DELETE') + Delete +
+
+ @endif +
+ + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + + @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + + @if ($document->exists && $document->placements->count() > 1) +
+ Shared document. + Changes affect all {{ $document->placements->count() }} placements. +
+ @endif + +
+ @csrf + @if ($document->exists) + @method('PUT') + @endif + +
+ +

Document

+ + + + @foreach ($types as $type) + + @endforeach + +
+ + +
+
+

Frontend content

+

+ Switches take effect when this document is saved. +

+
+ +
+ +
+ +
+ Advanced JSON (emergency use only) +

+ Normal editing should be performed with the controls above. +

+ +
+
+
+ + +
+
+ + +
diff --git a/Content/Resources/views/admin/index.blade.php b/Content/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..c219cb4 --- /dev/null +++ b/Content/Resources/views/admin/index.blade.php @@ -0,0 +1,53 @@ + +
+
+
+

Content documents

+

Create and reuse typed content across page regions.

+
+
+ Compose homepage + Create document +
+
+ + @if(session('status')) +
{{ session('status') }}
+ @endif + @error('document')
{{ $message }}
@enderror + +
+ + + + @foreach($types as $type)@endforeach + + + + + + +
FilterReset
+ + +
+ + + + @forelse($documents as $document) + + + + + + + + @empty + + @endforelse + +
DocumentTypeStatusUsesActions
{{ $document->title }}
{{ $document->slug }}
{{ $document->type?->name }}{{ ucfirst($document->status) }}{{ $document->placements_count }}Edit
No content documents match the current filters.
+
+ {{ $documents->links() }} +
+
diff --git a/Content/Resources/views/documents/unsupported.blade.php b/Content/Resources/views/documents/unsupported.blade.php new file mode 100644 index 0000000..b80545d --- /dev/null +++ b/Content/Resources/views/documents/unsupported.blade.php @@ -0,0 +1,3 @@ +@if(app()->hasDebugModeEnabled()) + +@endif diff --git a/Content/Routes/web.php b/Content/Routes/web.php new file mode 100644 index 0000000..910c7f6 --- /dev/null +++ b/Content/Routes/web.php @@ -0,0 +1,24 @@ +prefix('admin/content')->name('content.')->group(function (): void { + Route::get('/', [ContentDocumentController::class, 'index'])->name('index'); + Route::get('/documents/create', [ContentDocumentController::class, 'create'])->name('documents.create'); + Route::post('/documents', [ContentDocumentController::class, 'store'])->name('documents.store'); + Route::get('/documents/{document}/edit', [ContentDocumentController::class, 'edit'])->name('documents.edit'); + Route::put('/documents/{document}', [ContentDocumentController::class, 'update'])->name('documents.update'); + Route::post('/documents/{document}/duplicate', [ContentDocumentController::class, 'duplicate'])->name('documents.duplicate'); + Route::delete('/documents/{document}', [ContentDocumentController::class, 'destroy'])->name('documents.destroy'); + + Route::get('/page/{page}/compose', [PageCompositionController::class, 'editPage'])->name('pages.page.edit'); + Route::get('/pages/{pageKey}', [PageCompositionController::class, 'edit'])->name('pages.edit'); + Route::get('/pages/{pageKey}/preview', [PageCompositionController::class, 'preview'])->name('pages.preview'); + Route::post('/pages/{pageKey}/placements', [PageCompositionController::class, 'store'])->name('placements.store'); + Route::put('/placements/{placement}', [PageCompositionController::class, 'update'])->name('placements.update'); + Route::delete('/placements/{placement}', [PageCompositionController::class, 'destroy'])->name('placements.destroy'); + Route::post('/placements/{placement}/duplicate', [PageCompositionController::class, 'duplicate'])->name('placements.duplicate'); + Route::post('/pages/{pageKey}/reorder', [PageCompositionController::class, 'reorder'])->name('placements.reorder'); +}); diff --git a/Content/Services/PageRenderer.php b/Content/Services/PageRenderer.php new file mode 100644 index 0000000..d02c898 --- /dev/null +++ b/Content/Services/PageRenderer.php @@ -0,0 +1,83 @@ +where('page_key', $pageKey)->where('is_enabled', true)->exists(); + } catch (Throwable) { + return false; + } + } + + public function renderRegion(string $pageKey, string $region): string + { + if (! $this->hasComposition($pageKey)) { + return ''; + } + + $preview = Route::currentRouteNamed('content.pages.preview'); + $placements = PageContent::query() + ->with('document.type') + ->where('page_key', $pageKey) + ->where('region', $region) + ->where('is_enabled', true) + ->where(fn ($query) => $query->whereNull('starts_at')->orWhere('starts_at', '<=', now())) + ->where(fn ($query) => $query->whereNull('ends_at')->orWhere('ends_at', '>', now())) + ->orderBy('sort_order') + ->get(); + + return $placements->map(function (PageContent $placement) use ($preview): string { + $document = $placement->document; + if (! $document || ! $document->type || ! $this->isRenderable($document, $preview)) { + return ''; + } + + $view = 'stylist-theme-saasland::documents.'.$document->type->renderer; + if (! View::exists($view)) { + $view = 'content::documents.unsupported'; + } + + $rendered = View::make($view, [ + 'document' => $document, + 'data' => $document->data ?? [], + 'placement' => $placement, + 'isCompositionPreview' => $preview, + ])->render(); + + if ($preview && $document->status !== 'published') { + return '
Draft preview
'.$rendered.'
'; + } + + return $rendered; + })->implode("\n"); + } + + private function isRenderable(ContentDocument $document, bool $preview): bool + { + if ($preview) { + return true; + } + + if ($document->status !== 'published') { + return false; + } + + if ($document->published_at?->isFuture()) { + return false; + } + + return ! $document->expires_at?->isPast(); + } +} diff --git a/Content/module.json b/Content/module.json new file mode 100644 index 0000000..e34671c --- /dev/null +++ b/Content/module.json @@ -0,0 +1,12 @@ +{ + "name": "Content", + "alias": "content", + "version": "1.0.0", + "description": "Composable typed content documents and page regions.", + "keywords": ["cms", "content", "regions", "composition"], + "priority": 22, + "providers": ["Modules\\Content\\Providers\\ContentServiceProvider"], + "aliases": {}, + "files": [], + "requires": ["Core", "Pages", "Stylist"] +} diff --git a/Content/views/admin/form.blade.php b/Content/views/admin/form.blade.php new file mode 100644 index 0000000..47ae90d --- /dev/null +++ b/Content/views/admin/form.blade.php @@ -0,0 +1,399 @@ + +
+
+
+ + Content documents + +

+ {{ $document->exists ? $document->title : 'Create content document' }} +

+

+ Edit every frontend field below. JSON is retained only as an emergency advanced tool. +

+
+ + @if ($document->exists) +
+
+ @csrf + Create independent copy +
+ +
+ @csrf + @method('DELETE') + Delete +
+
+ @endif +
+ + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + + @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + + @if ($document->exists && $document->placements->count() > 1) +
+ Shared document. + Changes affect all {{ $document->placements->count() }} placements. +
+ @endif + +
+ @csrf + @if ($document->exists) + @method('PUT') + @endif + +
+ +

Document

+ + + + @foreach ($types as $type) + + @endforeach + +
+ + +
+
+

Frontend content

+

+ Switches take effect when this document is saved. +

+
+ +
+ +
+ +
+ Advanced JSON (emergency use only) +

+ Normal editing should be performed with the controls above. +

+ +
+
+
+ + +
+
+ + +
diff --git a/Core/Config/config.php b/Core/Config/config.php new file mode 100644 index 0000000..c00cc12 --- /dev/null +++ b/Core/Config/config.php @@ -0,0 +1,6 @@ + 'Core', + 'enabled' => true, +]; diff --git a/Core/Contracts/ModuleRegistry.php b/Core/Contracts/ModuleRegistry.php new file mode 100644 index 0000000..e05ca63 --- /dev/null +++ b/Core/Contracts/ModuleRegistry.php @@ -0,0 +1,11 @@ + */ + public function all(): array; + + public function enabled(string $name): bool; +} diff --git a/Core/Providers/CoreServiceProvider.php b/Core/Providers/CoreServiceProvider.php new file mode 100644 index 0000000..cee3091 --- /dev/null +++ b/Core/Providers/CoreServiceProvider.php @@ -0,0 +1,24 @@ +app->register(RouteServiceProvider::class); + $this->app->singleton(ModuleRegistry::class, LaravelModuleRegistry::class); + $this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower()); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations')); + $this->loadViewsFrom(module_path(ModuleInfo::name(), 'Resources/views'), ModuleInfo::nameLower()); + } +} diff --git a/Core/Providers/RouteServiceProvider.php b/Core/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..d400e95 --- /dev/null +++ b/Core/Providers/RouteServiceProvider.php @@ -0,0 +1,17 @@ +routes(function (): void { + Route::middleware('web')->group(module_path('Core', 'Routes/web.php')); + }); + } +} diff --git a/Core/Routes/web.php b/Core/Routes/web.php new file mode 100644 index 0000000..4c3fffc --- /dev/null +++ b/Core/Routes/web.php @@ -0,0 +1,3 @@ + $name, + 'alias' => (string) ($data['alias'] ?? strtolower($name)), + 'version' => (string) ($data['version'] ?? '0.0.0'), + 'enabled' => (bool) ($statuses[$name] ?? false), + ]; + } + usort($modules, fn (array $a, array $b): int => $a['name'] <=> $b['name']); + + return $modules; + } + + public function enabled(string $name): bool + { + foreach ($this->all() as $module) { + if (strcasecmp($module['name'], $name) === 0) { + return $module['enabled']; + } + } + + return false; + } +} diff --git a/Core/Support/ModuleInfo.php b/Core/Support/ModuleInfo.php new file mode 100644 index 0000000..5710024 --- /dev/null +++ b/Core/Support/ModuleInfo.php @@ -0,0 +1,16 @@ +toBe('Core'); +}); diff --git a/Core/composer.json b/Core/composer.json new file mode 100644 index 0000000..ca9d3b5 --- /dev/null +++ b/Core/composer.json @@ -0,0 +1,10 @@ +{ + "name": "absdev/core-module", + "description": "Core foundation module for the ABSDEV Laravel application platform.", + "type": "laravel-module", + "autoload": { + "psr-4": { + "Modules\\Core\\": "" + } + } +} diff --git a/Core/module.json b/Core/module.json new file mode 100644 index 0000000..0affe62 --- /dev/null +++ b/Core/module.json @@ -0,0 +1,18 @@ +{ + "name": "Core", + "alias": "core", + "version": "1.0.0", + "description": "Core foundation module for the ABSDEV Laravel application platform.", + "keywords": [ + "laravel", + "modules", + "core" + ], + "priority": 100, + "providers": [ + "Modules\\Core\\Providers\\CoreServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [] +} diff --git a/Developer/Config/config.php b/Developer/Config/config.php new file mode 100644 index 0000000..f3bc1fc --- /dev/null +++ b/Developer/Config/config.php @@ -0,0 +1,8 @@ + 'Modules', + 'modules_path' => base_path('Modules'), + 'themes_path' => resource_path('themes'), + 'overwrite' => false, +]; diff --git a/Developer/Console/Commands/InspectModulesCommand.php b/Developer/Console/Commands/InspectModulesCommand.php new file mode 100644 index 0000000..11f3078 --- /dev/null +++ b/Developer/Console/Commands/InspectModulesCommand.php @@ -0,0 +1,44 @@ +argument('name'); + $rows = []; + foreach ($files as $file) { + $manifest = json_decode((string) file_get_contents($file), true); + if (! is_array($manifest)) { + $this->error("Invalid JSON: {$file}"); + + return self::FAILURE; + } + if ($name && strcasecmp((string) ($manifest['name'] ?? ''), (string) $name) !== 0) { + continue; + } + $rows[] = [ + 'name' => $manifest['name'] ?? basename(dirname($file)), + 'version' => $manifest['version'] ?? 'unknown', + 'requires' => implode(', ', $manifest['requires'] ?? []), + 'providers' => count($manifest['providers'] ?? []), + ]; + } + if ($this->option('json')) { + $this->line(json_encode($rows, JSON_PRETTY_PRINT)); + + return self::SUCCESS; + } + $this->table(['Module', 'Version', 'Requires', 'Providers'], $rows); + + return self::SUCCESS; + } +} diff --git a/Developer/Console/Commands/MakeCrudCommand.php b/Developer/Console/Commands/MakeCrudCommand.php new file mode 100644 index 0000000..d615a48 --- /dev/null +++ b/Developer/Console/Commands/MakeCrudCommand.php @@ -0,0 +1,37 @@ +option('module') ?: $this->choice('Select the target module', $this->modules()); + try { + $files = $generator->generate((string) $module, $this->argument('model'), (bool) $this->option('force')); + } catch (Throwable $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + foreach ($files as $file) { + $this->line("created {$file}"); + } + $this->warn('Register the generated controller routes in the target module Routes/web.php file.'); + + return self::SUCCESS; + } + + private function modules(): array + { + return collect(glob(base_path('Modules/*/module.json')) ?: [])->map(fn (string $file) => basename(dirname($file)))->sort()->values()->all(); + } +} diff --git a/Developer/Console/Commands/MakeModuleCommand.php b/Developer/Console/Commands/MakeModuleCommand.php new file mode 100644 index 0000000..34ece37 --- /dev/null +++ b/Developer/Console/Commands/MakeModuleCommand.php @@ -0,0 +1,32 @@ +generate($this->argument('name'), (bool) $this->option('force')); + } catch (Throwable $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + foreach ($files as $file) { + $this->line("created {$file}"); + } + $this->newLine(); + $this->info('Enable the module in modules_statuses.json, then run composer dump-autoload.'); + + return self::SUCCESS; + } +} diff --git a/Developer/Console/Commands/MakeThemeCommand.php b/Developer/Console/Commands/MakeThemeCommand.php new file mode 100644 index 0000000..2457559 --- /dev/null +++ b/Developer/Console/Commands/MakeThemeCommand.php @@ -0,0 +1,30 @@ +generate($this->argument('name'), (bool) $this->option('force')); + } catch (Throwable $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + foreach ($files as $file) { + $this->line("created {$file}"); + } + + return self::SUCCESS; + } +} diff --git a/Developer/Http/Controllers/DeveloperController.php b/Developer/Http/Controllers/DeveloperController.php new file mode 100644 index 0000000..d921e99 --- /dev/null +++ b/Developer/Http/Controllers/DeveloperController.php @@ -0,0 +1,27 @@ +map(function (string $file): array { + $manifest = json_decode((string) file_get_contents($file), true) ?: []; + + return [ + 'name' => $manifest['name'] ?? basename(dirname($file)), + 'version' => $manifest['version'] ?? 'unknown', + 'description' => $manifest['description'] ?? '', + 'requires' => $manifest['requires'] ?? [], + ]; + }) + ->sortBy('name') + ->values(); + + return view('developer::admin.index', compact('modules')); + } +} diff --git a/Developer/Providers/DeveloperServiceProvider.php b/Developer/Providers/DeveloperServiceProvider.php new file mode 100644 index 0000000..a5f09a9 --- /dev/null +++ b/Developer/Providers/DeveloperServiceProvider.php @@ -0,0 +1,32 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Developer', 'Config/config.php'), 'developer'); + } + + public function boot(): void + { + $this->loadViewsFrom(module_path('Developer', 'Resources/views'), 'developer'); + + if ($this->app->runningInConsole()) { + $this->commands([ + MakeModuleCommand::class, + MakeCrudCommand::class, + MakeThemeCommand::class, + InspectModulesCommand::class, + ]); + } + } +} diff --git a/Developer/Providers/RouteServiceProvider.php b/Developer/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..50cbdde --- /dev/null +++ b/Developer/Providers/RouteServiceProvider.php @@ -0,0 +1,14 @@ +group(module_path('Developer', 'Routes/web.php')); + } +} diff --git a/Developer/Resources/views/admin/index.blade.php b/Developer/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..ca9fe9f --- /dev/null +++ b/Developer/Resources/views/admin/index.blade.php @@ -0,0 +1,26 @@ + +
+
+

{{ __('Developer Tools') }}

+

{{ __('Generate modules, CRUD resources and themes using the platform conventions.') }}

+
+ +
+

{{ __('Module Generator') }}

php artisan module:make Products
+

{{ __('CRUD Generator') }}

php artisan crud:make Product --module=Products
+

{{ __('Theme Generator') }}

php artisan theme:make Corporate
+
+ +
+
{{ __('Installed modules') }}
+
+ @foreach ($modules as $module) +
+
{{ $module['name'] }}
{{ $module['description'] }}
+
v{{ $module['version'] }}
{{ implode(', ', $module['requires']) }}
+
+ @endforeach +
+
+
+
diff --git a/Developer/Routes/web.php b/Developer/Routes/web.php new file mode 100644 index 0000000..5a8712a --- /dev/null +++ b/Developer/Routes/web.php @@ -0,0 +1,11 @@ +prefix('administration/developer') + ->name('administration.developer.') + ->group(function (): void { + Route::get('/', DeveloperController::class)->name('index'); + }); diff --git a/Developer/Support/CrudGenerator.php b/Developer/Support/CrudGenerator.php new file mode 100644 index 0000000..90db77c --- /dev/null +++ b/Developer/Support/CrudGenerator.php @@ -0,0 +1,69 @@ +studly($module); + $model = $this->studly($model); + $table = str($model)->plural()->snake()->toString(); + $route = str($model)->plural()->kebab()->toString(); + $root = rtrim(config('developer.modules_path', base_path('Modules')), '/').'/'.$module; + $timestamp = now()->format('Y_m_d_His'); + + $files = [ + "$root/Models/{$model}.php" => $this->model($module, $model), + "$root/Http/Controllers/{$model}Controller.php" => $this->controller($module, $model), + "$root/Http/Requests/Store{$model}Request.php" => $this->request($module, $model), + "$root/Database/Migrations/{$timestamp}_create_{$table}_table.php" => $this->migration($table), + "$root/Resources/views/admin/{$route}/index.blade.php" => $this->indexView($model, $route), + "$root/Resources/views/admin/{$route}/form.blade.php" => $this->formView($model), + "$root/Tests/Feature/{$model}CrudTest.php" => $this->crudTest($module, $model), + ]; + + foreach ($files as $path => $contents) { + $this->write($path, $contents, $force); + } + + return array_keys($files); + } + + private function model(string $module, string $model): string + { + return " 'boolean'];\n }\n}\n"; + } + + private function controller(string $module, string $model): string + { + $plural = str($model)->plural()->kebab(); + + return "snake()."::admin.{$plural}.index', ['records' => {$model}::query()->latest()->paginate()]);\n }\n\n public function store(Store{$model}Request \$request): RedirectResponse\n {\n {$model}::query()->create(\$request->validated());\n return back()->with('status', '{$model} created.');\n }\n}\n"; + } + + private function request(string $module, string $model): string + { + return "user() !== null; }\n public function rules(): array { return ['name' => ['required', 'string', 'max:255'], 'is_active' => ['sometimes', 'boolean']]; }\n}\n"; + } + + private function migration(string $table): string + { + return "id(); \$table->string('name'); \$table->boolean('is_active')->default(true); \$table->timestamps(); }); }\n public function down(): void { Schema::dropIfExists('{$table}'); }\n};\n"; + } + + private function indexView(string $model, string $route): string + { + return "\n

{$model}

@foreach (\$records as \$record)
{{ \$record->name }}
@endforeach{{ \$records->links() }}
\n
\n"; + } + + private function formView(string $model): string + { + return "
@csrf{{ __('Save {$model}') }}\n"; + } + + private function crudTest(string $module, string $model): string + { + return "create(['name' => 'Example']))->toBeInstanceOf({$model}::class); });\n"; + } +} diff --git a/Developer/Support/Generator.php b/Developer/Support/Generator.php new file mode 100644 index 0000000..a8c8bf8 --- /dev/null +++ b/Developer/Support/Generator.php @@ -0,0 +1,43 @@ +files->isDirectory($path)) { + $this->files->makeDirectory($path, 0755, true); + } + } + + protected function write(string $path, string $contents, bool $force = false): void + { + if ($this->files->exists($path) && ! $force) { + throw new RuntimeException("File already exists: {$path}"); + } + + $this->createDirectory(dirname($path)); + $this->files->put($path, $contents); + } + + protected function studly(string $value): string + { + return str($value)->studly()->toString(); + } + + protected function snake(string $value): string + { + return str($value)->snake()->toString(); + } + + protected function kebab(string $value): string + { + return str($value)->kebab()->toString(); + } +} diff --git a/Developer/Support/ModuleGenerator.php b/Developer/Support/ModuleGenerator.php new file mode 100644 index 0000000..a9668e3 --- /dev/null +++ b/Developer/Support/ModuleGenerator.php @@ -0,0 +1,55 @@ +studly($name); + $alias = $this->kebab($name); + $root = rtrim(config('developer.modules_path', base_path('Modules')), '/').'/'.$name; + $provider = "Modules\\{$name}\\Providers\\{$name}ServiceProvider"; + + $files = [ + "$root/module.json" => json_encode([ + 'name' => $name, + 'alias' => $alias, + 'version' => '1.0.0', + 'description' => "{$name} module.", + 'keywords' => [$alias], + 'priority' => 100, + 'providers' => [$provider], + 'aliases' => (object) [], + 'files' => [], + 'requires' => ['Core', 'Administration'], + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES).PHP_EOL, + "$root/Config/config.php" => " '{$name}'];\n", + "$root/Providers/{$name}ServiceProvider.php" => $this->provider($name), + "$root/Providers/RouteServiceProvider.php" => $this->routeProvider($name), + "$root/Routes/web.php" => "prefix('administration/{$alias}')->name('administration.{$alias}.')->group(function (): void {\n // Register module routes here.\n});\n", + "$root/Tests/Feature/{$name}ModuleBootTest.php" => $this->test($name), + ]; + + foreach ($files as $path => $contents) { + $this->write($path, $contents, $force); + } + + return array_keys($files); + } + + private function provider(string $name): string + { + return "app->register(RouteServiceProvider::class);\n \$this->mergeConfigFrom(module_path('{$name}', 'Config/config.php'), '".str($name)->snake()."');\n }\n\n public function boot(): void\n {\n \$this->loadMigrationsFrom(module_path('{$name}', 'Database/Migrations'));\n \$this->loadViewsFrom(module_path('{$name}', 'Resources/views'), '".str($name)->snake()."');\n }\n}\n"; + } + + private function routeProvider(string $name): string + { + return "group(module_path('{$name}', 'Routes/web.php'));\n }\n}\n"; + } + + private function test(string $name): string + { + return "providerIsLoaded(Modules\\{$name}\\Providers\\{$name}ServiceProvider::class))->toBeTrue();\n});\n"; + } +} diff --git a/Developer/Support/ThemeGenerator.php b/Developer/Support/ThemeGenerator.php new file mode 100644 index 0000000..dd1de18 --- /dev/null +++ b/Developer/Support/ThemeGenerator.php @@ -0,0 +1,24 @@ +kebab($name); + $root = rtrim(config('developer.themes_path', resource_path('themes')), '/').'/'.$name; + $files = [ + "$root/theme.json" => json_encode(['name' => str($name)->headline()->toString(), 'slug' => $name, 'version' => '1.0.0', 'parent' => 'common'], JSON_PRETTY_PRINT).PHP_EOL, + "$root/views/layouts/app.blade.php" => "getLocale()) }}\">{{ \$title ?? config('app.name') }}@vite(['resources/css/app.css', 'resources/js/app.js']){{ \$slot }}\n", + "$root/views/home.blade.php" => "

{{ config('app.name') }}

\n", + "$root/assets/css/theme.css" => ":root { --theme-radius: 0.5rem; }\n", + "$root/README.md" => '# '.str($name)->headline()." Theme\n\nGenerated by `php artisan theme:make {$name}`.\n", + ]; + foreach ($files as $path => $contents) { + $this->write($path, $contents, $force); + } + + return array_keys($files); + } +} diff --git a/Developer/Tests/Feature/DeveloperModuleBootTest.php b/Developer/Tests/Feature/DeveloperModuleBootTest.php new file mode 100644 index 0000000..80f181c --- /dev/null +++ b/Developer/Tests/Feature/DeveloperModuleBootTest.php @@ -0,0 +1,11 @@ +providerIsLoaded(DeveloperServiceProvider::class))->toBeTrue(); +}); + +it('registers developer commands', function (): void { + $this->artisan('module:inspect')->assertSuccessful(); +}); diff --git a/Developer/module.json b/Developer/module.json new file mode 100644 index 0000000..7df5745 --- /dev/null +++ b/Developer/module.json @@ -0,0 +1,12 @@ +{ + "name": "Developer", + "alias": "developer", + "version": "1.0.0", + "description": "Developer generators, module inspection and scaffolding tools.", + "keywords": ["developer", "generator", "scaffold"], + "priority": 80, + "providers": ["Modules\\Developer\\Providers\\DeveloperServiceProvider"], + "aliases": {}, + "files": [], + "requires": ["Core", "Administration"] +} diff --git a/Documents/Config/config.php b/Documents/Config/config.php new file mode 100644 index 0000000..f780704 --- /dev/null +++ b/Documents/Config/config.php @@ -0,0 +1,3 @@ + 'Documents']; diff --git a/Documents/Database/Migrations/2026_07_24_000500_create_documents_table.php b/Documents/Database/Migrations/2026_07_24_000500_create_documents_table.php new file mode 100644 index 0000000..87aaf16 --- /dev/null +++ b/Documents/Database/Migrations/2026_07_24_000500_create_documents_table.php @@ -0,0 +1,26 @@ +id(); + $table->string('title'); + $table->text('description')->nullable(); + $table->string('file_path')->nullable(); + $table->boolean('is_public')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('documents'); + } +}; diff --git a/Documents/Http/Controllers/DocumentController.php b/Documents/Http/Controllers/DocumentController.php new file mode 100644 index 0000000..ba1fc4e --- /dev/null +++ b/Documents/Http/Controllers/DocumentController.php @@ -0,0 +1,58 @@ + Document::query()->latest()->paginate(20)]); + } + + public function create(): View + { + return view('documents::admin.form'); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate(['title' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'file_path' => ['nullable', 'string'], + 'is_public' => ['sometimes', 'boolean']]); + $data['is_public'] = $request->boolean('is_public'); + Document::create($data); + + return redirect()->route('administration.documents.index')->with('status', 'Document created.'); + } + + public function edit(Document $document): View + { + return view('documents::admin.form', ['record' => $document]); + } + + public function update(Request $request, Document $document): RedirectResponse + { + $data = $request->validate(['title' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'file_path' => ['nullable', 'string'], + 'is_public' => ['sometimes', 'boolean']]); + $data['is_public'] = $request->boolean('is_public'); + $document->update($data); + + return redirect()->route('administration.documents.index')->with('status', 'Document updated.'); + } + + public function destroy(Document $document): RedirectResponse + { + $document->delete(); + + return back()->with('status', 'Document deleted.'); + } +} diff --git a/Documents/Models/Document.php b/Documents/Models/Document.php new file mode 100644 index 0000000..2d9d2cf --- /dev/null +++ b/Documents/Models/Document.php @@ -0,0 +1,23 @@ + 'boolean', + ]; + } +} diff --git a/Documents/Providers/DocumentsServiceProvider.php b/Documents/Providers/DocumentsServiceProvider.php new file mode 100644 index 0000000..5e19ae9 --- /dev/null +++ b/Documents/Providers/DocumentsServiceProvider.php @@ -0,0 +1,20 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Documents', 'Config/config.php'), 'documents'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Documents', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Documents', 'Resources/views'), 'documents'); + } +} diff --git a/Documents/Providers/RouteServiceProvider.php b/Documents/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..7c25a1d --- /dev/null +++ b/Documents/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +routes(fn () => Route::middleware('web')->group(module_path('Documents', 'Routes/web.php'))); + } +} diff --git a/Documents/Resources/views/admin/form.blade.php b/Documents/Resources/views/admin/form.blade.php new file mode 100644 index 0000000..d94c9d1 --- /dev/null +++ b/Documents/Resources/views/admin/form.blade.php @@ -0,0 +1,8 @@ + +

{{ isset($record) ? 'Edit' : 'Create' }} Document

@csrf @isset($record) @method('PUT') @endisset +
@error('title')

{{ $message }}

@enderror
+
@error('description')

{{ $message }}

@enderror
+
@error('file_path')

{{ $message }}

@enderror
+ +
Cancel
+
diff --git a/Documents/Resources/views/admin/index.blade.php b/Documents/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..f79986e --- /dev/null +++ b/Documents/Resources/views/admin/index.blade.php @@ -0,0 +1,5 @@ + +

Documents

Manage documents content.

Add new
+@if(session('status'))
{{ session('status') }}
@endif +
@forelse($records as $record)@empty@endforelse
TitleDescriptionFile PathActions
{{ is_array($record->title) ? json_encode($record->title) : Str::limit((string) $record->title, 70) }}{{ is_array($record->description) ? json_encode($record->description) : Str::limit((string) $record->description, 70) }}{{ is_array($record->file_path) ? json_encode($record->file_path) : Str::limit((string) $record->file_path, 70) }}Edit
@csrf @method('DELETE')
No records yet.
{{ $records->links() }}
+
diff --git a/Documents/Routes/web.php b/Documents/Routes/web.php new file mode 100644 index 0000000..675d903 --- /dev/null +++ b/Documents/Routes/web.php @@ -0,0 +1,13 @@ +prefix('administration/documents')->name('administration.documents.')->group(function (): void { + Route::get('/', [DocumentController::class, 'index'])->name('index'); + Route::get('/create', [DocumentController::class, 'create'])->name('create'); + Route::post('/', [DocumentController::class, 'store'])->name('store'); + Route::get('/{document}/edit', [DocumentController::class, 'edit'])->name('edit'); + Route::match(['put', 'patch'], '/{document}', [DocumentController::class, 'update'])->name('update'); + Route::delete('/{document}', [DocumentController::class, 'destroy'])->name('destroy'); +}); diff --git a/Documents/Support/ModuleInfo.php b/Documents/Support/ModuleInfo.php new file mode 100644 index 0000000..c815ec2 --- /dev/null +++ b/Documents/Support/ModuleInfo.php @@ -0,0 +1,16 @@ +toBeTrue(); +}); diff --git a/Documents/module.json b/Documents/module.json new file mode 100644 index 0000000..318fef2 --- /dev/null +++ b/Documents/module.json @@ -0,0 +1,20 @@ +{ + "name": "Documents", + "alias": "documents", + "version": "1.0.0", + "description": "Documents CMS module.", + "keywords": [ + "cms", + "documents" + ], + "priority": 25, + "providers": [ + "Modules\\Documents\\Providers\\DocumentsServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [ + "Core", + "Administration" + ] +} diff --git a/Enterprise/Config/config.php b/Enterprise/Config/config.php new file mode 100644 index 0000000..2dd4c3a --- /dev/null +++ b/Enterprise/Config/config.php @@ -0,0 +1,11 @@ + env('ENTERPRISE_BACKUP_DISK', 'local'), + 'backup_path' => env('ENTERPRISE_BACKUP_PATH', 'backups'), + 'retention_days' => (int) env('ENTERPRISE_BACKUP_RETENTION_DAYS', 30), + 'health' => [ + 'minimum_free_disk_mb' => (int) env('ENTERPRISE_MIN_FREE_DISK_MB', 512), + 'maximum_failed_jobs' => (int) env('ENTERPRISE_MAX_FAILED_JOBS', 0), + ], +]; diff --git a/Enterprise/Console/BackupCreateCommand.php b/Enterprise/Console/BackupCreateCommand.php new file mode 100644 index 0000000..3492f0c --- /dev/null +++ b/Enterprise/Console/BackupCreateCommand.php @@ -0,0 +1,21 @@ +create(); + $this->info('Backup created: '.$backup->path); + + return self::SUCCESS; + } +} diff --git a/Enterprise/Console/BackupPruneCommand.php b/Enterprise/Console/BackupPruneCommand.php new file mode 100644 index 0000000..bd7fbf4 --- /dev/null +++ b/Enterprise/Console/BackupPruneCommand.php @@ -0,0 +1,20 @@ +info($service->prune().' backup(s) pruned.'); + + return self::SUCCESS; + } +} diff --git a/Enterprise/Console/HealthCheckCommand.php b/Enterprise/Console/HealthCheckCommand.php new file mode 100644 index 0000000..40d1b07 --- /dev/null +++ b/Enterprise/Console/HealthCheckCommand.php @@ -0,0 +1,28 @@ +run(); + HealthRun::create(['status' => $result['status'], 'results' => $result, 'ran_at' => now()]); + if ($this->option('json')) { + $this->line(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } else { + $this->table(['Check', 'Status', 'Message'], collect($result['checks'])->map(fn (array $check) => [$check['name'], $check['status'], $check['message']])->all()); + $this->line('Overall: '.$result['status']); + } + + return $result['status'] === 'failed' ? self::FAILURE : self::SUCCESS; + } +} diff --git a/Enterprise/Database/Migrations/2026_07_24_100000_create_enterprise_health_runs_table.php b/Enterprise/Database/Migrations/2026_07_24_100000_create_enterprise_health_runs_table.php new file mode 100644 index 0000000..1413a3f --- /dev/null +++ b/Enterprise/Database/Migrations/2026_07_24_100000_create_enterprise_health_runs_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('status', 20); + $table->json('results'); + $table->timestamp('ran_at'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('enterprise_health_runs'); + } +}; diff --git a/Enterprise/Database/Migrations/2026_07_24_100100_create_enterprise_backups_table.php b/Enterprise/Database/Migrations/2026_07_24_100100_create_enterprise_backups_table.php new file mode 100644 index 0000000..4477ca8 --- /dev/null +++ b/Enterprise/Database/Migrations/2026_07_24_100100_create_enterprise_backups_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('disk'); + $table->string('path'); + $table->unsignedBigInteger('size')->default(0); + $table->string('status', 20)->default('completed'); + $table->text('message')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('enterprise_backups'); + } +}; diff --git a/Enterprise/Http/Controllers/EnterpriseController.php b/Enterprise/Http/Controllers/EnterpriseController.php new file mode 100644 index 0000000..de0182f --- /dev/null +++ b/Enterprise/Http/Controllers/EnterpriseController.php @@ -0,0 +1,82 @@ + $health->run(), + 'latestRun' => HealthRun::latest('ran_at')->first(), + 'backups' => Backup::latest()->limit(10)->get(), + 'failedJobs' => DB::getSchemaBuilder()->hasTable('failed_jobs') ? DB::table('failed_jobs')->count() : 0, + 'queueConnection' => config('queue.default'), + 'cacheStore' => config('cache.default'), + 'schedulerConfigured' => file_exists(base_path('routes/console.php')), + 'phpVersion' => PHP_VERSION, + 'laravelVersion' => app()->version(), + ]); + } + + public function runHealth(SystemHealthService $service): RedirectResponse + { + $result = $service->run(); + HealthRun::create(['user_id' => auth()->id(), 'status' => $result['status'], 'results' => $result, 'ran_at' => now()]); + + return back()->with('status', 'Health checks completed.'); + } + + public function clearCache(): RedirectResponse + { + Artisan::call('optimize:clear'); + Cache::flush(); + + return back()->with('status', 'Application and cache stores cleared.'); + } + + public function runScheduler(): RedirectResponse + { + Artisan::call('schedule:run'); + + return back()->with('status', trim(Artisan::output()) ?: 'Scheduler executed.'); + } + + public function retryFailedJobs(): RedirectResponse + { + Artisan::call('queue:retry', ['id' => 'all']); + + return back()->with('status', trim(Artisan::output()) ?: 'Failed jobs queued for retry.'); + } + + public function createBackup(BackupService $service): RedirectResponse + { + try { + $service->create(auth()->id()); + + return back()->with('status', 'Backup created.'); + } catch (Throwable $e) { + report($e); + + return back()->withErrors(['backup' => $e->getMessage()]); + } + } + + public function deleteBackup(Backup $backup, BackupService $service): RedirectResponse + { + $service->delete($backup); + + return back()->with('status', 'Backup deleted.'); + } +} diff --git a/Enterprise/Models/Backup.php b/Enterprise/Models/Backup.php new file mode 100644 index 0000000..14ecef9 --- /dev/null +++ b/Enterprise/Models/Backup.php @@ -0,0 +1,24 @@ + 'datetime']; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/Enterprise/Models/HealthRun.php b/Enterprise/Models/HealthRun.php new file mode 100644 index 0000000..34824c2 --- /dev/null +++ b/Enterprise/Models/HealthRun.php @@ -0,0 +1,24 @@ + 'array', 'ran_at' => 'datetime']; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/Enterprise/Providers/EnterpriseServiceProvider.php b/Enterprise/Providers/EnterpriseServiceProvider.php new file mode 100644 index 0000000..a532d0a --- /dev/null +++ b/Enterprise/Providers/EnterpriseServiceProvider.php @@ -0,0 +1,30 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Enterprise', 'Config/config.php'), 'enterprise'); + $this->app->singleton(SystemHealthService::class); + $this->app->singleton(BackupService::class); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Enterprise', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Enterprise', 'Resources/views'), 'enterprise'); + if ($this->app->runningInConsole()) { + $this->commands([HealthCheckCommand::class, BackupCreateCommand::class, BackupPruneCommand::class]); + } + } +} diff --git a/Enterprise/Providers/RouteServiceProvider.php b/Enterprise/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..c1150b7 --- /dev/null +++ b/Enterprise/Providers/RouteServiceProvider.php @@ -0,0 +1,14 @@ +group(module_path('Enterprise', 'Routes/web.php')); + } +} diff --git a/Enterprise/Resources/views/admin/index.blade.php b/Enterprise/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..d337ff9 --- /dev/null +++ b/Enterprise/Resources/views/admin/index.blade.php @@ -0,0 +1,24 @@ + +
+

{{ __('Enterprise Services') }}

{{ __('Health, queues, scheduler, cache, backups and production diagnostics.') }}

+ @if(session('status')){{ session('status') }}@endif + @if($errors->any()){{ $errors->first() }}@endif + +
+
{{ __('Overall health') }}
{{ $health['status'] }}
+
{{ __('Queue') }}
{{ $queueConnection }}
{{ $failedJobs }} {{ __('failed') }}
+
{{ __('Cache') }}
{{ $cacheStore }}
+
{{ __('Runtime') }}
PHP {{ $phpVersion }}
Laravel {{ $laravelVersion }}
+
+ + +

{{ __('Health checks') }}

@csrf{{ __('Run checks') }}
+
@foreach($health['checks'] as $check)
{{ $check['name'] }}{{ $check['status'] }}{{ $check['message'] }}
@endforeach
+
+ +
+

{{ __('Operations') }}

@csrf{{ __('Clear cache') }}
@csrf{{ __('Run scheduler') }}
@csrf{{ __('Retry failed jobs') }}
+

{{ __('Backups') }}

@csrf{{ __('Create backup') }}
@forelse($backups as $backup)
{{ $backup->path }}
{{ number_format($backup->size / 1024 / 1024, 2) }} MB · {{ $backup->created_at->diffForHumans() }}
@csrf @method('DELETE'){{ __('Delete') }}
@empty

{{ __('No backups created.') }}

@endforelse
+
+
+
diff --git a/Enterprise/Routes/web.php b/Enterprise/Routes/web.php new file mode 100644 index 0000000..69e772f --- /dev/null +++ b/Enterprise/Routes/web.php @@ -0,0 +1,18 @@ +prefix('administration/enterprise') + ->name('administration.enterprise.') + ->controller(EnterpriseController::class) + ->group(function (): void { + Route::get('/', 'index')->name('index'); + Route::post('/health', 'runHealth')->middleware('permission:enterprise.manage')->name('health.run'); + Route::post('/cache/clear', 'clearCache')->middleware('permission:enterprise.manage')->name('cache.clear'); + Route::post('/scheduler/run', 'runScheduler')->middleware('permission:enterprise.manage')->name('scheduler.run'); + Route::post('/queue/retry', 'retryFailedJobs')->middleware('permission:enterprise.manage')->name('queue.retry'); + Route::post('/backups', 'createBackup')->middleware('permission:enterprise.manage')->name('backups.store'); + Route::delete('/backups/{backup}', 'deleteBackup')->middleware('permission:enterprise.manage')->name('backups.destroy'); + }); diff --git a/Enterprise/Services/BackupService.php b/Enterprise/Services/BackupService.php new file mode 100644 index 0000000..94463da --- /dev/null +++ b/Enterprise/Services/BackupService.php @@ -0,0 +1,76 @@ +format('Ymd-His').'.zip'; + $relativePath = $directory.'/'.$name; + $temporary = storage_path('app/'.$name); + $zip = new ZipArchive; + if ($zip->open($temporary, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException('Unable to create backup archive.'); + } + + $manifest = json_encode([ + 'application' => config('app.name'), + 'environment' => app()->environment(), + 'created_at' => now()->toIso8601String(), + 'php' => PHP_VERSION, + 'laravel' => app()->version(), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $zip->addFromString('manifest.json', $manifest ?: '{}'); + $zip->addFile(base_path('composer.json'), 'composer.json'); + if (File::exists(base_path('modules_statuses.json'))) { + $zip->addFile(base_path('modules_statuses.json'), 'modules_statuses.json'); + } + if (config('database.default') === 'sqlite') { + $database = database_path('database.sqlite'); + if (File::exists($database)) { + $zip->addFile($database, 'database/database.sqlite'); + } + } + $zip->close(); + + Storage::disk($disk)->put($relativePath, File::get($temporary)); + File::delete($temporary); + + return Backup::create([ + 'user_id' => $userId, + 'disk' => $disk, + 'path' => $relativePath, + 'size' => Storage::disk($disk)->size($relativePath), + 'status' => 'completed', + 'completed_at' => now(), + ]); + } + + public function delete(Backup $backup): void + { + Storage::disk($backup->disk)->delete($backup->path); + $backup->delete(); + } + + public function prune(): int + { + $cutoff = now()->subDays((int) config('enterprise.retention_days', 30)); + $backups = Backup::query()->where('created_at', '<', $cutoff)->get(); + $backups->each(fn (Backup $backup) => $this->delete($backup)); + + return $backups->count(); + } +} diff --git a/Enterprise/Services/SystemHealthService.php b/Enterprise/Services/SystemHealthService.php new file mode 100644 index 0000000..b08b319 --- /dev/null +++ b/Enterprise/Services/SystemHealthService.php @@ -0,0 +1,94 @@ +database(), + $this->cache(), + $this->storage(), + $this->queue(), + $this->environment(), + ]; + + return [ + 'status' => collect($checks)->contains(fn (array $check): bool => $check['status'] === 'failed') ? 'failed' : (collect($checks)->contains(fn (array $check): bool => $check['status'] === 'warning') ? 'warning' : 'healthy'), + 'checks' => $checks, + 'generated_at' => now()->toIso8601String(), + ]; + } + + private function database(): array + { + try { + DB::connection()->getPdo(); + + return $this->result('Database', 'healthy', DB::connection()->getDriverName()); + } catch (Throwable $e) { + return $this->result('Database', 'failed', $e->getMessage()); + } + } + + private function cache(): array + { + try { + $key = 'enterprise-health-'.bin2hex(random_bytes(6)); + Cache::put($key, 'ok', 10); + $healthy = Cache::get($key) === 'ok'; + Cache::forget($key); + + return $this->result('Cache', $healthy ? 'healthy' : 'failed', config('cache.default')); + } catch (Throwable $e) { + return $this->result('Cache', 'failed', $e->getMessage()); + } + } + + private function storage(): array + { + try { + Storage::disk('local')->put('enterprise-health.txt', now()->toIso8601String()); + Storage::disk('local')->delete('enterprise-health.txt'); + $freeMb = (int) floor(disk_free_space(storage_path()) / 1024 / 1024); + $minimum = (int) config('enterprise.health.minimum_free_disk_mb', 512); + + return $this->result('Storage', $freeMb < $minimum ? 'warning' : 'healthy', $freeMb.' MB free'); + } catch (Throwable $e) { + return $this->result('Storage', 'failed', $e->getMessage()); + } + } + + private function queue(): array + { + $failed = Schema::hasTable('failed_jobs') ? DB::table('failed_jobs')->count() : 0; + $maximum = (int) config('enterprise.health.maximum_failed_jobs', 0); + + return $this->result('Queue', $failed > $maximum ? 'warning' : 'healthy', $failed.' failed jobs'); + } + + private function environment(): array + { + $warnings = []; + if (app()->environment('production') && config('app.debug')) { + $warnings[] = 'APP_DEBUG is enabled'; + } + if (! app()->environment('production')) { + $warnings[] = 'Not running in production'; + } + + return $this->result('Environment', $warnings ? 'warning' : 'healthy', $warnings ? implode('; ', $warnings) : app()->environment()); + } + + private function result(string $name, string $status, string $message): array + { + return compact('name', 'status', 'message'); + } +} diff --git a/Enterprise/Tests/Feature/EnterpriseModuleBootTest.php b/Enterprise/Tests/Feature/EnterpriseModuleBootTest.php new file mode 100644 index 0000000..fe1d1d1 --- /dev/null +++ b/Enterprise/Tests/Feature/EnterpriseModuleBootTest.php @@ -0,0 +1,10 @@ + expect(app()->providerIsLoaded(EnterpriseServiceProvider::class))->toBeTrue()); +it('runs platform health checks', function (): void { + $result = app(SystemHealthService::class)->run(); + expect($result)->toHaveKeys(['status', 'checks', 'generated_at'])->and($result['checks'])->not->toBeEmpty(); +}); diff --git a/Enterprise/module.json b/Enterprise/module.json new file mode 100644 index 0000000..fdce53e --- /dev/null +++ b/Enterprise/module.json @@ -0,0 +1 @@ +{"name":"Enterprise","alias":"enterprise","version":"1.0.0","description":"Health, diagnostics, cache, queues, scheduler, backups and storage operations.","keywords":["health","diagnostics","queue","scheduler","backup"],"priority":100,"providers":["Modules\\Enterprise\\Providers\\EnterpriseServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration"]} diff --git a/Events/Config/config.php b/Events/Config/config.php new file mode 100644 index 0000000..6e87093 --- /dev/null +++ b/Events/Config/config.php @@ -0,0 +1,3 @@ + 'Events']; diff --git a/Events/Database/Migrations/2026_07_24_04200_create_events_tables.php b/Events/Database/Migrations/2026_07_24_04200_create_events_tables.php new file mode 100644 index 0000000..a974f12 --- /dev/null +++ b/Events/Database/Migrations/2026_07_24_04200_create_events_tables.php @@ -0,0 +1,51 @@ +id(); + $t->string('name'); + $t->text('address')->nullable(); + $t->unsignedInteger('capacity')->nullable(); + $t->timestamps(); + }); + Schema::create('events', function (Blueprint $t): void { + $t->id(); + $t->foreignId('venue_id')->nullable()->constrained('event_venues')->nullOnDelete(); + $t->string('title'); + $t->string('slug')->unique(); + $t->text('summary')->nullable(); + $t->longText('description')->nullable(); + $t->timestamp('starts_at')->index(); + $t->timestamp('ends_at')->nullable(); + $t->string('status')->default('draft')->index(); + $t->unsignedInteger('capacity')->nullable(); + $t->decimal('price', 10, 2)->default(0); + $t->boolean('registration_open')->default(false); + $t->timestamps(); + }); + Schema::create('event_registrations', function (Blueprint $t): void { + $t->id(); + $t->foreignId('event_id')->constrained()->cascadeOnDelete(); + $t->string('name'); + $t->string('email')->index(); + $t->unsignedInteger('quantity')->default(1); + $t->string('status')->default('confirmed')->index(); + $t->text('notes')->nullable(); + $t->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('event_registrations'); + Schema::dropIfExists('events'); + Schema::dropIfExists('event_venues'); + } +}; diff --git a/Events/Http/Controllers/EventController.php b/Events/Http/Controllers/EventController.php new file mode 100644 index 0000000..5668979 --- /dev/null +++ b/Events/Http/Controllers/EventController.php @@ -0,0 +1,32 @@ + Event::with(['venue'])->withCount('registrations')->orderBy('starts_at')->paginate(20), 'venues' => Venue::orderBy('name')->get()]); + } + + public function store(Request $r): RedirectResponse + { + Event::create($r->validate(['venue_id' => ['nullable', 'exists:event_venues,id'], 'title' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255', 'unique:events,slug'], 'summary' => ['nullable', 'string'], 'description' => ['nullable', 'string'], 'starts_at' => ['required', 'date'], 'ends_at' => ['nullable', 'date', 'after:starts_at'], 'status' => ['required', 'in:draft,published,cancelled,completed'], 'capacity' => ['nullable', 'integer', 'min:1'], 'price' => ['required', 'numeric', 'min:0']]) + ['registration_open' => $r->boolean('registration_open')]); + + return back()->with('status', 'Event created.'); + } + + public function destroy(Event $event): RedirectResponse + { + $event->delete(); + + return back()->with('status', 'Event deleted.'); + } +} diff --git a/Events/Models/Event.php b/Events/Models/Event.php new file mode 100644 index 0000000..cb1d6d4 --- /dev/null +++ b/Events/Models/Event.php @@ -0,0 +1,27 @@ + 'datetime', 'ends_at' => 'datetime', 'price' => 'decimal:2', 'registration_open' => 'boolean']; + } + + public function venue(): BelongsTo + { + return $this->belongsTo(Venue::class); + } + + public function registrations(): HasMany + { + return $this->hasMany(Registration::class); + } +} diff --git a/Events/Models/Registration.php b/Events/Models/Registration.php new file mode 100644 index 0000000..1ea8d7f --- /dev/null +++ b/Events/Models/Registration.php @@ -0,0 +1,12 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Events', 'Config/config.php'), 'events'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Events', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Events', 'Resources/views'), 'events'); + } +} diff --git a/Events/Providers/RouteServiceProvider.php b/Events/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..ca21554 --- /dev/null +++ b/Events/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +group(module_path('Events', 'Routes/web.php')); + } +} diff --git a/Events/Resources/views/admin/index.blade.php b/Events/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..8cab5c2 --- /dev/null +++ b/Events/Resources/views/admin/index.blade.php @@ -0,0 +1 @@ +

Events

Manage events, venues and registrations.

@if(session('status'))
{{ session('status') }}
@endif
@csrf

Create event

Create event
@forelse($events as $event)@empty@endforelse
EventStartsStatusRegistrations
{{ $event->title }}{{ $event->starts_at->format('j M Y H:i') }}{{ ucfirst($event->status) }}{{ $event->registrations_count }}
@csrf @method('DELETE')Delete
No events yet.
{{ $events->links() }}
diff --git a/Events/Routes/web.php b/Events/Routes/web.php new file mode 100644 index 0000000..7f27dfa --- /dev/null +++ b/Events/Routes/web.php @@ -0,0 +1,10 @@ +prefix('administration/events')->name('administration.events.')->group(function (): void { + Route::get('/', [EventController::class, 'index'])->name('index'); + Route::post('/', [EventController::class, 'store'])->name('store'); + Route::delete('/{event}', [EventController::class, 'destroy'])->name('destroy'); +}); diff --git a/Events/Tests/Feature/ModuleBootTest.php b/Events/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..27cd6b4 --- /dev/null +++ b/Events/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,13 @@ +assertSame('Events', config('events.name')); + } +} diff --git a/Events/module.json b/Events/module.json new file mode 100644 index 0000000..7990472 --- /dev/null +++ b/Events/module.json @@ -0,0 +1,12 @@ +{ + "name": "Events", + "alias": "events", + "version": "1.0.0", + "description": "Events, venues and registration management.", + "keywords": ["business", "events"], + "priority": 42, + "providers": ["Modules\\Events\\Providers\\EventsServiceProvider"], + "aliases": {}, + "files": [], + "requires": ["Core", "Administration"] +} diff --git a/Faq/Config/config.php b/Faq/Config/config.php new file mode 100644 index 0000000..47a7171 --- /dev/null +++ b/Faq/Config/config.php @@ -0,0 +1,3 @@ + 'Faq']; diff --git a/Faq/Database/Migrations/2026_07_24_000700_create_faqs_table.php b/Faq/Database/Migrations/2026_07_24_000700_create_faqs_table.php new file mode 100644 index 0000000..504d804 --- /dev/null +++ b/Faq/Database/Migrations/2026_07_24_000700_create_faqs_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('question'); + $table->longText('answer'); + $table->string('category')->nullable(); + $table->unsignedInteger('sort_order')->default(0); + $table->boolean('enabled')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('faqs'); + } +}; diff --git a/Faq/Http/Controllers/FaqController.php b/Faq/Http/Controllers/FaqController.php new file mode 100644 index 0000000..b38040d --- /dev/null +++ b/Faq/Http/Controllers/FaqController.php @@ -0,0 +1,60 @@ + Faq::query()->latest()->paginate(20)]); + } + + public function create(): View + { + return view('faq::admin.form'); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate(['question' => ['required', 'string', 'max:255'], + 'answer' => ['required', 'string'], + 'category' => ['nullable', 'string'], + 'sort_order' => ['nullable', 'integer', 'min:0'], + 'enabled' => ['sometimes', 'boolean']]); + $data['enabled'] = $request->boolean('enabled'); + Faq::create($data); + + return redirect()->route('administration.faq.index')->with('status', 'Faq created.'); + } + + public function edit(Faq $faq): View + { + return view('faq::admin.form', ['record' => $faq]); + } + + public function update(Request $request, Faq $faq): RedirectResponse + { + $data = $request->validate(['question' => ['required', 'string', 'max:255'], + 'answer' => ['required', 'string'], + 'category' => ['nullable', 'string'], + 'sort_order' => ['nullable', 'integer', 'min:0'], + 'enabled' => ['sometimes', 'boolean']]); + $data['enabled'] = $request->boolean('enabled'); + $faq->update($data); + + return redirect()->route('administration.faq.index')->with('status', 'Faq updated.'); + } + + public function destroy(Faq $faq): RedirectResponse + { + $faq->delete(); + + return back()->with('status', 'Faq deleted.'); + } +} diff --git a/Faq/Models/Faq.php b/Faq/Models/Faq.php new file mode 100644 index 0000000..b2011f3 --- /dev/null +++ b/Faq/Models/Faq.php @@ -0,0 +1,24 @@ + 'integer', + 'enabled' => 'boolean', + ]; + } +} diff --git a/Faq/Providers/FaqServiceProvider.php b/Faq/Providers/FaqServiceProvider.php new file mode 100644 index 0000000..43f9071 --- /dev/null +++ b/Faq/Providers/FaqServiceProvider.php @@ -0,0 +1,20 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Faq', 'Config/config.php'), 'faq'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Faq', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Faq', 'Resources/views'), 'faq'); + } +} diff --git a/Faq/Providers/RouteServiceProvider.php b/Faq/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..8c5175d --- /dev/null +++ b/Faq/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +routes(fn () => Route::middleware('web')->group(module_path('Faq', 'Routes/web.php'))); + } +} diff --git a/Faq/Resources/views/admin/form.blade.php b/Faq/Resources/views/admin/form.blade.php new file mode 100644 index 0000000..9998104 --- /dev/null +++ b/Faq/Resources/views/admin/form.blade.php @@ -0,0 +1,9 @@ + +

{{ isset($record) ? 'Edit' : 'Create' }} Faq

@csrf @isset($record) @method('PUT') @endisset +
@error('question')

{{ $message }}

@enderror
+
@error('answer')

{{ $message }}

@enderror
+
@error('category')

{{ $message }}

@enderror
+
@error('sort_order')

{{ $message }}

@enderror
+ +
Cancel
+
diff --git a/Faq/Resources/views/admin/index.blade.php b/Faq/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..5bceb8c --- /dev/null +++ b/Faq/Resources/views/admin/index.blade.php @@ -0,0 +1,5 @@ + +

Faq

Manage faq content.

Add new
+@if(session('status'))
{{ session('status') }}
@endif +
@forelse($records as $record)@empty@endforelse
QuestionAnswerCategoryActions
{{ is_array($record->question) ? json_encode($record->question) : Str::limit((string) $record->question, 70) }}{{ is_array($record->answer) ? json_encode($record->answer) : Str::limit((string) $record->answer, 70) }}{{ is_array($record->category) ? json_encode($record->category) : Str::limit((string) $record->category, 70) }}Edit
@csrf @method('DELETE')
No records yet.
{{ $records->links() }}
+
diff --git a/Faq/Routes/web.php b/Faq/Routes/web.php new file mode 100644 index 0000000..031dc8b --- /dev/null +++ b/Faq/Routes/web.php @@ -0,0 +1,13 @@ +prefix('administration/faq')->name('administration.faq.')->group(function (): void { + Route::get('/', [FaqController::class, 'index'])->name('index'); + Route::get('/create', [FaqController::class, 'create'])->name('create'); + Route::post('/', [FaqController::class, 'store'])->name('store'); + Route::get('/{faq}/edit', [FaqController::class, 'edit'])->name('edit'); + Route::match(['put', 'patch'], '/{faq}', [FaqController::class, 'update'])->name('update'); + Route::delete('/{faq}', [FaqController::class, 'destroy'])->name('destroy'); +}); diff --git a/Faq/Support/ModuleInfo.php b/Faq/Support/ModuleInfo.php new file mode 100644 index 0000000..34b932c --- /dev/null +++ b/Faq/Support/ModuleInfo.php @@ -0,0 +1,16 @@ +toBeTrue(); +}); diff --git a/Faq/module.json b/Faq/module.json new file mode 100644 index 0000000..1996222 --- /dev/null +++ b/Faq/module.json @@ -0,0 +1,20 @@ +{ + "name": "Faq", + "alias": "faq", + "version": "1.0.0", + "description": "Faq CMS module.", + "keywords": [ + "cms", + "faq" + ], + "priority": 27, + "providers": [ + "Modules\\Faq\\Providers\\FaqServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [ + "Core", + "Administration" + ] +} diff --git a/Feeds/Config/config.php b/Feeds/Config/config.php new file mode 100644 index 0000000..166f718 --- /dev/null +++ b/Feeds/Config/config.php @@ -0,0 +1,3 @@ + 'Feeds']; diff --git a/Feeds/Http/Controllers/FeedController.php b/Feeds/Http/Controllers/FeedController.php new file mode 100644 index 0000000..8f2fab8 --- /dev/null +++ b/Feeds/Http/Controllers/FeedController.php @@ -0,0 +1,21 @@ +view('feeds::rss', ['title' => config('app.name').' Blog', 'records' => Blog::published()->latest('published_at')->limit(50)->get(), 'routeName' => 'blog.public.show'])->header('Content-Type', 'application/rss+xml; charset=UTF-8'); + } + + public function news(): Response + { + return response()->view('feeds::rss', ['title' => config('app.name').' News', 'records' => News::published()->latest('published_at')->limit(50)->get(), 'routeName' => 'news.public.show'])->header('Content-Type', 'application/rss+xml; charset=UTF-8'); + } +} diff --git a/Feeds/Providers/FeedsServiceProvider.php b/Feeds/Providers/FeedsServiceProvider.php new file mode 100644 index 0000000..c1200c7 --- /dev/null +++ b/Feeds/Providers/FeedsServiceProvider.php @@ -0,0 +1,20 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Feeds', 'Config/config.php'), 'feeds'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Feeds', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Feeds', 'Resources/views'), 'feeds'); + } +} diff --git a/Feeds/Providers/RouteServiceProvider.php b/Feeds/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..89c193a --- /dev/null +++ b/Feeds/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +group(module_path('Feeds', 'Routes/web.php')); + } +} diff --git a/Feeds/Resources/views/rss.blade.php b/Feeds/Resources/views/rss.blade.php new file mode 100644 index 0000000..e516351 --- /dev/null +++ b/Feeds/Resources/views/rss.blade.php @@ -0,0 +1 @@ +{!! '' !!}{{ $title }}{{ url('/') }}{{ $title }}@foreach($records as $record){{ $record->title }}{{ route($routeName, $record->slug) }}{{ route($routeName, $record->slug) }}excerpt }}]]>{{ optional($record->published_at)->toRssString() }}@endforeach diff --git a/Feeds/Routes/web.php b/Feeds/Routes/web.php new file mode 100644 index 0000000..b9ba81f --- /dev/null +++ b/Feeds/Routes/web.php @@ -0,0 +1,7 @@ +name('feeds.blog'); +Route::get('/feeds/news.xml', [FeedController::class, 'news'])->name('feeds.news'); diff --git a/Feeds/Tests/Feature/ModuleBootTest.php b/Feeds/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..7b90716 --- /dev/null +++ b/Feeds/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,13 @@ +assertSame('Feeds', config('feeds.name')); + } +} diff --git a/Feeds/module.json b/Feeds/module.json new file mode 100644 index 0000000..9569047 --- /dev/null +++ b/Feeds/module.json @@ -0,0 +1,12 @@ +{ + "name": "Feeds", + "alias": "feeds", + "version": "1.0.0", + "description": "Feeds publishing module.", + "keywords": ["publishing", "feeds"], + "priority": 30, + "providers": ["Modules\\Feeds\\Providers\\FeedsServiceProvider"], + "aliases": {}, + "files": [], + "requires": ["Core", "Administration"] +} diff --git a/Forms/Config/config.php b/Forms/Config/config.php new file mode 100644 index 0000000..ba58e72 --- /dev/null +++ b/Forms/Config/config.php @@ -0,0 +1,3 @@ + 'Forms']; diff --git a/Forms/Database/Migrations/2026_07_24_000400_create_forms_table.php b/Forms/Database/Migrations/2026_07_24_000400_create_forms_table.php new file mode 100644 index 0000000..aee029b --- /dev/null +++ b/Forms/Database/Migrations/2026_07_24_000400_create_forms_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('name'); + $table->string('slug'); + $table->text('description')->nullable(); + $table->json('schema')->nullable(); + $table->boolean('enabled')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('forms'); + } +}; diff --git a/Forms/Http/Controllers/FormController.php b/Forms/Http/Controllers/FormController.php new file mode 100644 index 0000000..eacb1b8 --- /dev/null +++ b/Forms/Http/Controllers/FormController.php @@ -0,0 +1,66 @@ + Form::query()->latest()->paginate(20)]); + } + + public function create(): View + { + return view('forms::admin.form'); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate(['name' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'schema' => ['nullable', 'array'], + 'enabled' => ['sometimes', 'boolean']]); + if ($request->filled('schema_json')) { + $data['schema'] = json_decode($request->string('schema_json')->toString(), true, 512, JSON_THROW_ON_ERROR); + } + $data['enabled'] = $request->boolean('enabled'); + Form::create($data); + + return redirect()->route('administration.forms.index')->with('status', 'Form created.'); + } + + public function edit(Form $form): View + { + return view('forms::admin.form', ['record' => $form]); + } + + public function update(Request $request, Form $form): RedirectResponse + { + $data = $request->validate(['name' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'schema' => ['nullable', 'array'], + 'enabled' => ['sometimes', 'boolean']]); + if ($request->filled('schema_json')) { + $data['schema'] = json_decode($request->string('schema_json')->toString(), true, 512, JSON_THROW_ON_ERROR); + } + $data['enabled'] = $request->boolean('enabled'); + $form->update($data); + + return redirect()->route('administration.forms.index')->with('status', 'Form updated.'); + } + + public function destroy(Form $form): RedirectResponse + { + $form->delete(); + + return back()->with('status', 'Form deleted.'); + } +} diff --git a/Forms/Models/Form.php b/Forms/Models/Form.php new file mode 100644 index 0000000..d72aec6 --- /dev/null +++ b/Forms/Models/Form.php @@ -0,0 +1,24 @@ + 'array', + 'enabled' => 'boolean', + ]; + } +} diff --git a/Forms/Providers/FormsServiceProvider.php b/Forms/Providers/FormsServiceProvider.php new file mode 100644 index 0000000..53ce6d1 --- /dev/null +++ b/Forms/Providers/FormsServiceProvider.php @@ -0,0 +1,20 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Forms', 'Config/config.php'), 'forms'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Forms', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Forms', 'Resources/views'), 'forms'); + } +} diff --git a/Forms/Providers/RouteServiceProvider.php b/Forms/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..1d81fce --- /dev/null +++ b/Forms/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +routes(fn () => Route::middleware('web')->group(module_path('Forms', 'Routes/web.php'))); + } +} diff --git a/Forms/Resources/views/admin/form.blade.php b/Forms/Resources/views/admin/form.blade.php new file mode 100644 index 0000000..12e5a85 --- /dev/null +++ b/Forms/Resources/views/admin/form.blade.php @@ -0,0 +1,32 @@ + +
+

{{ isset($record) ? 'Edit' : 'Create' }} Form

+
@csrf @isset($record) + @method('PUT') + @endisset +
@error('name') +

{{ $message }}

@enderror
+
@error('slug') +

{{ $message }}

@enderror
+
@error('description') +

{{ $message }}

@enderror
+
+
+ +
+ + Cancel
+
+
+
diff --git a/Forms/Resources/views/admin/index.blade.php b/Forms/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..a8dd83d --- /dev/null +++ b/Forms/Resources/views/admin/index.blade.php @@ -0,0 +1,43 @@ + +
+
+

Forms

+

Manage forms content.

+ Add new +
+ @if(session('status')) +
{{ session('status') }}
+ @endif +
+ + + + + + + + + + @forelse($records as $record) + + + + + + + @empty + + + + @endforelse +
NameSlugDescriptionActions
{{ is_array($record->name) ? json_encode($record->name) : Str::limit((string) $record->name, 70) }}{{ is_array($record->slug) ? json_encode($record->slug) : Str::limit((string) $record->slug, 70) }}{{ is_array($record->description) ? json_encode($record->description) : Str::limit((string) $record->description, 70) }}Edit +
@csrf @method('DELETE') + +
+
No records yet.
+
{{ $records->links() }}
+
diff --git a/Forms/Routes/web.php b/Forms/Routes/web.php new file mode 100644 index 0000000..4da162d --- /dev/null +++ b/Forms/Routes/web.php @@ -0,0 +1,13 @@ +prefix('administration/forms')->name('administration.forms.')->group(function (): void { + Route::get('/', [FormController::class, 'index'])->name('index'); + Route::get('/create', [FormController::class, 'create'])->name('create'); + Route::post('/', [FormController::class, 'store'])->name('store'); + Route::get('/{form}/edit', [FormController::class, 'edit'])->name('edit'); + Route::match(['put', 'patch'], '/{form}', [FormController::class, 'update'])->name('update'); + Route::delete('/{form}', [FormController::class, 'destroy'])->name('destroy'); +}); diff --git a/Forms/Support/ModuleInfo.php b/Forms/Support/ModuleInfo.php new file mode 100644 index 0000000..916091c --- /dev/null +++ b/Forms/Support/ModuleInfo.php @@ -0,0 +1,16 @@ +toBeTrue(); +}); diff --git a/Forms/module.json b/Forms/module.json new file mode 100644 index 0000000..b4450a2 --- /dev/null +++ b/Forms/module.json @@ -0,0 +1,20 @@ +{ + "name": "Forms", + "alias": "forms", + "version": "1.0.0", + "description": "Forms CMS module.", + "keywords": [ + "cms", + "forms" + ], + "priority": 24, + "providers": [ + "Modules\\Forms\\Providers\\FormsServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [ + "Core", + "Administration" + ] +} diff --git a/Licensing/Config/.gitkeep b/Licensing/Config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Licensing/Config/config.php b/Licensing/Config/config.php new file mode 100644 index 0000000..3b94eda --- /dev/null +++ b/Licensing/Config/config.php @@ -0,0 +1,8 @@ + 'Licensing', + 'features' => + [ + ], +]; diff --git a/Licensing/Console/.gitkeep b/Licensing/Console/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Licensing/Console/SonarDeskLicenseDeliver.php b/Licensing/Console/SonarDeskLicenseDeliver.php new file mode 100644 index 0000000..67e5050 --- /dev/null +++ b/Licensing/Console/SonarDeskLicenseDeliver.php @@ -0,0 +1,20 @@ +option('limit'), 100)); + $count = $delivery->deliverPending($limit); + $this->info("Delivered {$count} licence email(s)."); + return self::SUCCESS; + } +} diff --git a/Licensing/Console/SonarDeskLicenseIssue.php b/Licensing/Console/SonarDeskLicenseIssue.php new file mode 100755 index 0000000..12469cb --- /dev/null +++ b/Licensing/Console/SonarDeskLicenseIssue.php @@ -0,0 +1,29 @@ +argument('product'))->firstOrFail(); + [$l,$key] = $issuer->issue($p, $this->option('name') ?: null, $this->option('email') ?: null); + if (! $key) { + $this->warn('Licence already existed; plaintext key is intentionally not recoverable.'); + + return self::FAILURE; + } $this->line('Licence ID: '.$l->id); + $this->line('Licence key: '.$key); + $this->warn('This is the only time the plaintext key is shown.'); + + return self::SUCCESS; + } +} diff --git a/Licensing/Console/SonarDeskLicenseRevoke.php b/Licensing/Console/SonarDeskLicenseRevoke.php new file mode 100755 index 0000000..ad34c16 --- /dev/null +++ b/Licensing/Console/SonarDeskLicenseRevoke.php @@ -0,0 +1,22 @@ +argument('id')); + $l->update(['revoked_at' => now(), 'revoke_reason' => $this->option('reason')]); + $this->info('Licence revoked.'); + + return self::SUCCESS; + } +} diff --git a/Licensing/Console/SonarDeskProductCreate.php b/Licensing/Console/SonarDeskProductCreate.php new file mode 100755 index 0000000..8622a85 --- /dev/null +++ b/Licensing/Console/SonarDeskProductCreate.php @@ -0,0 +1,21 @@ + $this->argument('code')], ['name' => $this->option('name'), 'edition' => $this->option('edition'), 'activation_limit' => (int) $this->option('activations'), 'max_major_version' => $this->option('max-major') !== '' ? (int) $this->option('max-major') : null, 'payment_provider' => $this->option('provider') ?: null, 'external_product_id' => $this->option('product-id') ?: null, 'external_price_id' => $this->option('price-id') ?: null, 'active' => true]); + $this->info("Product {$p->code} ready."); + + return self::SUCCESS; + } +} diff --git a/Licensing/Database/Migrations/.gitkeep b/Licensing/Database/Migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Licensing/Database/Migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php b/Licensing/Database/Migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php new file mode 100755 index 0000000..babc7a3 --- /dev/null +++ b/Licensing/Database/Migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php @@ -0,0 +1,92 @@ +id(); + $table->string('code')->unique(); + $table->string('name'); + $table->string('edition')->default('Professional'); + $table->unsignedInteger('activation_limit')->default(1); + $table->unsignedInteger('max_major_version')->nullable(); + $table->string('payment_provider')->nullable(); + $table->string('external_product_id')->nullable(); + $table->string('external_price_id')->nullable(); + $table->boolean('active')->default(true); + $table->timestamps(); + $table->unique(['payment_provider', 'external_price_id']); + }); + + Schema::create('licenses', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->foreignId('license_product_id')->constrained('license_products'); + $table->string('license_key_hash', 64)->unique(); + $table->string('license_key_last4', 4); + $table->string('customer_name')->nullable(); + $table->string('customer_email')->nullable()->index(); + $table->string('edition'); + $table->unsignedInteger('activation_limit'); + $table->unsignedInteger('max_major_version')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->string('revoke_reason')->nullable(); + $table->string('payment_provider')->nullable(); + $table->string('external_order_id')->nullable(); + $table->string('external_customer_id')->nullable(); + $table->timestamps(); + $table->unique(['payment_provider', 'external_order_id']); + }); + + Schema::create('license_delivery_outbox', function (Blueprint $table) { + $table->id(); + $table->uuid('license_id')->unique(); + $table->text('encrypted_license_key'); + $table->string('recipient_email')->nullable(); + $table->timestamp('delivered_at')->nullable(); + $table->timestamps(); + $table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete(); + }); + + Schema::create('license_activations', function (Blueprint $table) { + $table->id(); + $table->uuid('license_id'); + $table->uuid('installation_id'); + $table->string('platform', 32)->nullable(); + $table->string('app_version', 32)->nullable(); + $table->timestamp('activated_at'); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamp('deactivated_at')->nullable(); + $table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete(); + $table->unique(['license_id', 'installation_id']); + $table->index(['license_id', 'deactivated_at']); + }); + + Schema::create('license_webhook_events', function (Blueprint $table) { + $table->id(); + $table->string('provider'); + $table->string('external_event_id'); + $table->string('event_type'); + $table->string('payload_sha256', 64); + $table->json('payload'); + $table->timestamp('processed_at')->nullable(); + $table->text('processing_error')->nullable(); + $table->timestamps(); + $table->unique(['provider', 'external_event_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('license_webhook_events'); + Schema::dropIfExists('license_activations'); + Schema::dropIfExists('license_delivery_outbox'); + Schema::dropIfExists('licenses'); + Schema::dropIfExists('license_products'); + } +}; diff --git a/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php b/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php new file mode 100755 index 0000000..b302e12 --- /dev/null +++ b/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php @@ -0,0 +1,30 @@ + $table->boolean('is_admin')->default(false)->index()); + } + + if (Schema::hasTable('license_activations') && ! Schema::hasColumn('license_activations', 'installation_name')) { + Schema::table('license_activations', fn (Blueprint $table) => $table->string('installation_name')->nullable()->after('installation_id')); + } + } + + public function down(): void + { + if (Schema::hasTable('license_activations') && Schema::hasColumn('license_activations', 'installation_name')) { + Schema::table('license_activations', fn (Blueprint $table) => $table->dropColumn('installation_name')); + } + + if (Schema::hasTable('users') && Schema::hasColumn('users', 'is_admin')) { + Schema::table('users', fn (Blueprint $table) => $table->dropColumn('is_admin')); + } + } +}; diff --git a/Licensing/Database/Seeders/.gitkeep b/Licensing/Database/Seeders/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Licensing/Database/Seeders/LicensingDatabaseSeeder.php b/Licensing/Database/Seeders/LicensingDatabaseSeeder.php new file mode 100644 index 0000000..fbb0b58 --- /dev/null +++ b/Licensing/Database/Seeders/LicensingDatabaseSeeder.php @@ -0,0 +1,21 @@ +call("OthersTableSeeder"); + } +} diff --git a/Licensing/Database/factories/.gitkeep b/Licensing/Database/factories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Licensing/Helpers/.gitkeep b/Licensing/Helpers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Licensing/Helpers/Helper.php b/Licensing/Helpers/Helper.php new file mode 100644 index 0000000..7607c60 --- /dev/null +++ b/Licensing/Helpers/Helper.php @@ -0,0 +1,12 @@ + License::query()->count(), + 'activeCount' => License::query()->whereNull('revoked_at')->where(function ($q): void { + $q->whereNull('expires_at')->orWhere('expires_at', '>', now()); + })->count(), + 'productCount' => LicenseProduct::query()->count(), + 'recentLicenses' => License::query()->with('product')->latest()->limit(10)->get(), + ]); + } + + public function licenses(): View + { + return view('licensing::admin.licenses', [ + 'licenses' => License::query()->with('product')->latest()->paginate(25), + ]); + } + + public function show(License $license): View + { + return view('licensing::admin.show', [ + 'license' => $license->load(['product', 'activations']), + ]); + } +} diff --git a/Licensing/Http/Controllers/Api/LicenseActivationController.php b/Licensing/Http/Controllers/Api/LicenseActivationController.php new file mode 100755 index 0000000..7580871 --- /dev/null +++ b/Licensing/Http/Controllers/Api/LicenseActivationController.php @@ -0,0 +1,164 @@ +validate([ + 'licenseKey' => ['required', 'string', 'max:128'], + 'installationID' => ['required', 'uuid'], + 'appVersion' => ['required', 'string', 'max:32'], + 'platform' => ['required', 'string', 'max:32'], + ]); + + return DB::transaction(function () use ($data, $signer) { + $license = License::where('license_key_hash', LicenseKey::hash($data['licenseKey'])) + ->lockForUpdate() + ->first(); + + if (! $license) { + throw ValidationException::withMessages(['licenseKey' => 'The licence key is not valid.']); + } + + if (! $license->is_usable) { + return response()->json(['message' => 'This licence is expired or has been revoked.'], 403); + } + + $installationID = strtolower($data['installationID']); + $activation = LicenseActivation::where('license_id', $license->id) + ->where('installation_id', $installationID) + ->first(); + + if ($activation && $activation->deactivated_at === null) { + $activation->update([ + 'last_seen_at' => now(), + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + ]); + } else { + $activeCount = LicenseActivation::where('license_id', $license->id) + ->whereNull('deactivated_at') + ->count(); + + if ($activeCount >= $license->activation_limit) { + return response()->json(['message' => 'This licence has reached its activation limit. Deactivate another Mac first.'], 409); + } + + if ($activation) { + $activation->update([ + 'deactivated_at' => null, + 'activated_at' => now(), + 'last_seen_at' => now(), + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + ]); + } else { + LicenseActivation::create([ + 'license_id' => $license->id, + 'installation_id' => $installationID, + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + 'activated_at' => now(), + 'last_seen_at' => now(), + ]); + } + } + + return response()->json([ + 'licenseToken' => $signer->sign($license, $data['installationID']), + ]); + }); + } + + public function validateInstallation( + Request $request, + LicenseTokenVerifier $verifier, + LicenseSigner $signer, + ): JsonResponse { + $data = $request->validate([ + 'licenseToken' => ['required', 'string', 'max:8192'], + 'installationID' => ['required', 'uuid'], + 'appVersion' => ['required', 'string', 'max:32'], + 'platform' => ['required', 'string', 'max:32'], + ]); + + try { + $payload = $verifier->verify($data['licenseToken']); + } catch (\Throwable) { + return response()->json(['message' => 'The licence token is invalid.'], 400); + } + + if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) { + return response()->json(['message' => 'The installation does not match this licence token.'], 400); + } + + return DB::transaction(function () use ($data, $payload, $signer) { + $license = License::whereKey((string) ($payload['licenseID'] ?? '')) + ->lockForUpdate() + ->first(); + + if (! $license || ! $license->is_usable) { + return response()->json(['message' => 'This licence is expired or has been revoked.'], 403); + } + + $activation = LicenseActivation::where('license_id', $license->id) + ->where('installation_id', strtolower($data['installationID'])) + ->first(); + + if (! $activation || $activation->deactivated_at !== null) { + return response()->json(['message' => 'This Mac has been deactivated from the licence portal.'], 403); + } + + $activation->update([ + 'last_seen_at' => now(), + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + ]); + + return response()->json([ + 'active' => true, + // Refreshing the signed token also applies subscription edition/expiry changes + // without requiring the user to reactivate manually. + 'licenseToken' => $signer->sign($license, $data['installationID']), + ]); + }); + } + + public function deactivate(Request $request, LicenseTokenVerifier $verifier): JsonResponse + { + $data = $request->validate([ + 'licenseToken' => ['required', 'string', 'max:8192'], + 'installationID' => ['required', 'uuid'], + ]); + + try { + $payload = $verifier->verify($data['licenseToken']); + } catch (\Throwable) { + return response()->json(['message' => 'The licence token is invalid.'], 400); + } + + if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) { + return response()->json(['message' => 'The installation does not match this licence token.'], 400); + } + + LicenseActivation::where('license_id', (string) ($payload['licenseID'] ?? '')) + ->where('installation_id', strtolower($data['installationID'])) + ->whereNull('deactivated_at') + ->update(['deactivated_at' => now(), 'last_seen_at' => now()]); + + return response()->json((object) []); + } +} diff --git a/Licensing/Http/Controllers/Api/PaymentWebhookController.php b/Licensing/Http/Controllers/Api/PaymentWebhookController.php new file mode 100755 index 0000000..61d9512 --- /dev/null +++ b/Licensing/Http/Controllers/Api/PaymentWebhookController.php @@ -0,0 +1,191 @@ +getContent(); + + $verifier = match ($provider) { + 'paddle' => app(PaddleWebhookVerifier::class), + 'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class), + default => null, + }; + + if (! $verifier) { + return response()->json([ + 'message' => 'Unknown webhook provider.', + ], 404); + } + + try { + $verifier->verify($request, $raw); + } catch (Throwable $e) { + report($e); + + return response()->json([ + 'message' => 'Invalid webhook signature.', + ], 400); + } + + try { + $payload = json_decode( + $raw, + true, + flags: JSON_THROW_ON_ERROR + ); + } catch (Throwable) { + return response()->json([ + 'message' => 'Invalid JSON.', + ], 400); + } + + $externalID = (string) ( + $provider === 'paddle' + ? ($payload['event_id'] ?? '') + : ($payload['meta']['event_name'] ?? '') + .':' + .($payload['data']['id'] ?? '') + ); + + $eventType = (string) ( + $provider === 'paddle' + ? ($payload['event_type'] ?? 'unknown') + : ($payload['meta']['event_name'] ?? 'unknown') + ); + + if ($externalID === '') { + return response()->json([ + 'message' => 'Webhook event has no stable identifier.', + ], 400); + } + + $payloadHash = hash('sha256', $raw); + + try { + /* + * Paddle can deliver several related events concurrently. + * + * Laravel retries this complete transaction when MySQL reports + * a deadlock. Five attempts gives short-lived lock contention + * enough opportunity to resolve without acknowledging an event + * that was not actually processed. + */ + DB::transaction( + function () use ( + $provider, + $externalID, + $eventType, + $payload, + $payloadHash, + $provisioner + ): void { + /* + * Insert the event if this is its first delivery. + * + * Once present, lock the row so duplicate deliveries of + * the same event cannot provision concurrently. + */ + LicenseWebhookEvent::firstOrCreate( + [ + 'provider' => $provider, + 'external_event_id' => $externalID, + ], + [ + 'event_type' => $eventType, + 'payload_sha256' => $payloadHash, + 'payload' => $payload, + ] + ); + + $event = LicenseWebhookEvent::query() + ->where('provider', $provider) + ->where('external_event_id', $externalID) + ->lockForUpdate() + ->firstOrFail(); + + if ($event->processed_at) { + return; + } + + if (! hash_equals( + (string) $event->payload_sha256, + $payloadHash + )) { + throw new RuntimeException( + 'Webhook ID was reused with a different payload.' + ); + } + + try { + $provisioner->provision( + $provider, + $payload + ); + + $event->processed_at = now(); + $event->processing_error = null; + $event->save(); + } catch (Throwable $e) { + /* + * This assignment is useful when the surrounding + * transaction eventually succeeds. If processing + * throws, the transaction itself is rolled back and + * Paddle will retry the event. + */ + $event->processing_error = mb_substr( + $e->getMessage(), + 0, + 4000 + ); + + $event->save(); + + throw $e; + } + }, + 5 + ); + } catch (Throwable $e) { + report($e); + return response()->json([ + 'message' => 'Webhook could not be processed.', + ], 500); + } + + /* + * Delivery deliberately happens outside the webhook transaction. + * + * A temporary SMTP failure must not undo a successfully processed + * payment event. The outbox retains anything that still needs + * delivery. + */ + try { + $delivery->deliverPending(10); + } catch (Throwable $e) { + report($e); + } + + return response()->json([ + 'ok' => true, + ]); + } +} diff --git a/Licensing/Http/Controllers/LicensingController.php b/Licensing/Http/Controllers/LicensingController.php new file mode 100644 index 0000000..8ece907 --- /dev/null +++ b/Licensing/Http/Controllers/LicensingController.php @@ -0,0 +1,79 @@ +whereRaw('lower(customer_email) = ?', [strtolower($request->user()->email)]); + } + + public function index(Request $request) + { + $licenses = $this->owned($request)->latest()->get(); + $hasProfessional = $licenses->contains(fn (License $license) => $license->is_usable && $license->edition === 'Professional'); + + return view('portal.index', compact('licenses', 'hasProfessional')); + } + + public function show(Request $request, License $license) + { + $this->authorise($request, $license); + $license->load(['product', 'activations']); + + return view('portal.show', compact('license')); + } + + public function upgrade(Request $request, License $license): RedirectResponse + { + $this->authorise($request, $license); + abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422); + abort_unless($license->edition === 'Personal' && $license->is_usable, 422); + + $targetPriceID = $this->professionalPriceFor($license); + $targetProduct = LicenseProduct::query() + ->where('payment_provider', 'paddle') + ->where('external_price_id', $targetPriceID) + ->where('active', true) + ->firstOrFail(); + + try { + $subscription = $this->paddle->updateSubscriptionPrice($license->external_subscription_id, $targetPriceID); + } catch (RuntimeException $exception) { + return back()->withErrors(['licence' => $exception->getMessage()]); + } + + // Apply the entitlement immediately. Paddle webhooks remain authoritative and + // will reconcile subscription status/current-period details afterwards. + $license->update([ + 'license_product_id' => $targetProduct->id, + 'edition' => $targetProduct->edition, + 'activation_limit' => $targetProduct->activation_limit, + 'max_major_version' => $targetProduct->max_major_version, + 'external_price_id' => $targetPriceID, + 'subscription_status' => (string) ($subscription['status'] ?? $license->subscription_status), + 'revoked_at' => null, + 'revoke_reason' => null, + ]); + + return redirect()->route('portal.show', $license)->with('status', 'Upgraded to Professional. Your existing Paddle subscription was changed; no second subscription was created.'); + } + + public function cancel(Request $request, License $license): RedirectResponse + { + $this->authorise($request, $license); + abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422); + abort_unless(! in_array($license->subscription_status, ['canceled'], true), 422); + + try { + $this->paddle->cancelSubscriptionImmediately($license->external_subscription_id); + } catch (RuntimeException $exception) { + return back()->withErrors(['licence' => $exception->getMessage()]); + } + + $license->update([ + 'subscription_status' => 'canceled', + 'expires_at' => now(), + 'revoked_at' => now(), + 'revoke_reason' => 'Subscription canceled from licence portal', + 'cancel_scheduled_at' => null, + ]); + + return redirect()->route('portal.index')->with('status', 'Subscription canceled in Paddle and the licence is no longer active.'); + } + + public function deactivate(Request $request, License $license, int $activation) + { + $this->authorise($request, $license); + $item = $license->activations()->whereKey($activation)->firstOrFail(); + if (! $item->deactivated_at) { + $item->update(['deactivated_at' => now()]); + } + + return back()->with('status', 'Mac deactivated. SonarDesk will detect this through the licensing API.'); + } + + public function resend(Request $request, License $license) + { + $this->authorise($request, $license); + $out = LicenseDeliveryOutbox::where('license_id', $license->id)->first(); + if (! $out) { + return back()->withErrors(['licence' => 'This licence was issued manually and its full key is not recoverable. Contact support.']); + } + + $out->update([ + 'recipient_email' => strtolower($request->user()->email), + 'delivered_at' => null, + ]); + $this->delivery->deliverPending(1); + + return back()->with('status', 'Licence key sent to your verified email address.'); + } + + private function authorise(Request $request, License $license): void + { + abort_unless(strtolower((string) $license->customer_email) === strtolower($request->user()->email), 404); + } + + private function professionalPriceFor(License $license): string + { + $paddle = config('paddle'); + $current = (string) $license->external_price_id; + + if ($current === (string) ($paddle['personal_annual_price_id'] ?? '')) { + return (string) ($paddle['professional_annual_price_id'] ?? ''); + } + + return (string) ($paddle['professional_monthly_price_id'] ?? ''); + } +} diff --git a/Licensing/Http/Middleware/.gitkeep b/Licensing/Http/Middleware/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Licensing/Http/Requests/.gitkeep b/Licensing/Http/Requests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Licensing/Models/.gitkeep b/Licensing/Models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Licensing/Models/License.php b/Licensing/Models/License.php new file mode 100755 index 0000000..86e5c6a --- /dev/null +++ b/Licensing/Models/License.php @@ -0,0 +1,36 @@ + 'datetime', 'revoked_at' => 'datetime', 'activation_limit' => 'integer', 'max_major_version' => 'integer', 'current_period_ends_at' => 'datetime', 'cancel_scheduled_at' => 'datetime']; + + public function product(): BelongsTo + { + return $this->belongsTo(LicenseProduct::class, 'license_product_id'); + } + + public function activations(): HasMany + { + return $this->hasMany(LicenseActivation::class); + } + + public function getIsUsableAttribute(): bool + { + return $this->revoked_at === null && ($this->expires_at === null || $this->expires_at->isFuture()); + } +} diff --git a/Licensing/Models/LicenseActivation.php b/Licensing/Models/LicenseActivation.php new file mode 100755 index 0000000..fb454a0 --- /dev/null +++ b/Licensing/Models/LicenseActivation.php @@ -0,0 +1,14 @@ + 'datetime', 'last_seen_at' => 'datetime', 'deactivated_at' => 'datetime']; +} diff --git a/Licensing/Models/LicenseDeliveryOutbox.php b/Licensing/Models/LicenseDeliveryOutbox.php new file mode 100755 index 0000000..89d1d5f --- /dev/null +++ b/Licensing/Models/LicenseDeliveryOutbox.php @@ -0,0 +1,20 @@ + 'datetime']; + + public function license(): BelongsTo + { + return $this->belongsTo(License::class); + } +} diff --git a/Licensing/Models/LicenseProduct.php b/Licensing/Models/LicenseProduct.php new file mode 100755 index 0000000..eb8a4cc --- /dev/null +++ b/Licensing/Models/LicenseProduct.php @@ -0,0 +1,12 @@ + 'boolean', 'activation_limit' => 'integer', 'max_major_version' => 'integer']; +} diff --git a/Licensing/Models/LicenseWebhookEvent.php b/Licensing/Models/LicenseWebhookEvent.php new file mode 100755 index 0000000..a8cc130 --- /dev/null +++ b/Licensing/Models/LicenseWebhookEvent.php @@ -0,0 +1,12 @@ + 'array', 'processed_at' => 'datetime']; +} diff --git a/Licensing/Providers/.gitkeep b/Licensing/Providers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Licensing/Providers/IntegratedLicensingServiceProvider.php b/Licensing/Providers/IntegratedLicensingServiceProvider.php new file mode 100644 index 0000000..d55e06b --- /dev/null +++ b/Licensing/Providers/IntegratedLicensingServiceProvider.php @@ -0,0 +1,35 @@ + [ + Limit::perMinute(12)->by($request->ip()), + Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))), + ]); + + $this->loadMigrationsFrom(__DIR__.'/../Database/Migrations'); + $this->loadViewsFrom(__DIR__.'/../Resources/views', 'licensing'); + $this->mergeConfigFrom(__DIR__.'/../Config/config.php', 'licensing'); + + // Explicit role-based authorization; deny access by default. + Gate::define('manage-sonardesk-licensing', static fn (User $user): bool => + $user->hasAnyRole(['Super Admin', 'super-admin', 'Administrator']) + ); + + Route::prefix('api')->middleware('api')->group(__DIR__.'/../Routes/api.php'); + Route::group([], __DIR__.'/../Routes/admin.php'); + } +} diff --git a/Licensing/Providers/LicensingServiceProvider.php b/Licensing/Providers/LicensingServiceProvider.php new file mode 100644 index 0000000..c514a59 --- /dev/null +++ b/Licensing/Providers/LicensingServiceProvider.php @@ -0,0 +1,171 @@ + Limit::perMinute(5) + ->by(strtolower((string) $request->input('email')).'|'.$request->ip())); + + RateLimiter::for('registration', fn (Request $request) => + Limit::perHour(5)->by($request->ip()) + ); + + RateLimiter::for('license-activation', function (Request $request): array { + return [ + Limit::perMinute(12)->by($request->ip()), + Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))), + ]; + }); + $this->registerTranslations(); + $this->registerConfig(); + $this->registerViews(); + $this->registerHelpers(); + $this->registerCommands(); + $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations')); + } + + /** + * Register the service provider. + * + * @return void + */ + public function register(): void + { + $this->app->register(RouteServiceProvider::class); + } + + /** + * Register the helpers. + * + * @return void + */ + protected function registerHelpers(): void + { + foreach (glob(__DIR__.'/../Helpers/*.php') as $filename) { + require_once $filename; + } + } + + /** + * Dynamically Register Commands. + * + * @return void + */ + public function registerCommands(): void + { + if (!$this->app->runningInConsole()) { + return; + } + $commandPath = __DIR__ . '/../Console'; + $commands = collect(File::allFiles($commandPath)) + ->map(function ($file) { + $class = 'Modules\\' . $this->moduleName . '\\Console\\' . $file->getFilenameWithoutExtension(); + return $class; + }) + ->filter(function ($class) { + return class_exists($class); + }) + ->values() + ->all(); + $this->commands($commands); + } + + /** + * Register config. + * + * @return void + */ + protected function registerConfig(): void + { + $this->publishes([ + module_path(ModuleInfo::name(), 'Config/config.php') => config_path( ModuleInfo::nameLower(). '.php'), + ], 'config'); + $this->mergeConfigFrom( + module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower() + ); + } + + /** + * Register views. + * + * @return void + */ + public function registerViews(): void + { + $viewPath = resource_path('views/modules/' . ModuleInfo::nameLower()); + $sourcePath = module_path(ModuleInfo::name(), 'Resources/views'); + $this->publishes([ + $sourcePath => $viewPath + ], ['views', ModuleInfo::nameLower() . '-module-views']); + $this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), ModuleInfo::nameLower()); + } + + /** + * Register translations. + * + * @return void + */ + public function registerTranslations(): void + { + $langPath = resource_path('lang/modules/' . ModuleInfo::nameLower()); + + if (is_dir($langPath)) { + $this->loadTranslationsFrom($langPath, ModuleInfo::nameLower()); + } else { + $this->loadTranslationsFrom(module_path(ModuleInfo::name(), 'Resources/lang'), ModuleInfo::nameLower()); + } + } + + /** + * Regsiter View Paths + * @return array + */ + private function getPublishableViewPaths(): array + { + $paths = []; + foreach (Config::get('view.paths') as $path) { + if (is_dir($path . '/modules/' . ModuleInfo::nameLower())) { + $paths[] = $path . '/modules/' . ModuleInfo::nameLower(); + } + } + return $paths; + } +} diff --git a/Licensing/Providers/RouteServiceProvider.php b/Licensing/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..c691ba8 --- /dev/null +++ b/Licensing/Providers/RouteServiceProvider.php @@ -0,0 +1,69 @@ +mapApiRoutes(); + + $this->mapWebRoutes(); + } + + /** + * Define the "web" routes for the application. + * + * These routes all receive session state, CSRF protection, etc. + * + * @return void + */ + protected function mapWebRoutes() + { + Route::middleware('web') + ->namespace($this->moduleNamespace) + ->group(module_path('Licensing', '/Routes/web.php')); + } + + /** + * Define the "api" routes for the application. + * + * These routes are typically stateless. + * + * @return void + */ + protected function mapApiRoutes() + { + Route::prefix('api') + ->middleware('api') + ->namespace($this->moduleNamespace) + ->group(module_path('Licensing', '/Routes/api.php')); + } +} diff --git a/Licensing/Resources/assets/.gitkeep b/Licensing/Resources/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Licensing/Resources/assets/js/app.js b/Licensing/Resources/assets/js/app.js new file mode 100644 index 0000000..e69de29 diff --git a/Licensing/Resources/assets/sass/app.scss b/Licensing/Resources/assets/sass/app.scss new file mode 100644 index 0000000..e69de29 diff --git a/Licensing/Resources/lang/.gitkeep b/Licensing/Resources/lang/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Licensing/Resources/views/.gitkeep b/Licensing/Resources/views/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Licensing/Resources/views/admin/index.blade.php b/Licensing/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..8c72c09 --- /dev/null +++ b/Licensing/Resources/views/admin/index.blade.php @@ -0,0 +1,3 @@ +@extends('licensing::admin.layout') +@section('title', 'Licensing Overview') +@section('content')

SonarDesk licensing

Total licences
{{ number_format($licenseCount) }}
Usable licences
{{ number_format($activeCount) }}
Products
{{ number_format($productCount) }}

Recent licences

@forelse($recentLicenses as $license)@empty@endforelse
CustomerProductEditionStatusCreated
{{ $license->customer_email ?: 'Unknown customer' }}{{ $license->product?->name ?? '—' }}{{ $license->edition }}{{ $license->is_usable ? 'Usable' : 'Inactive' }}{{ $license->created_at?->format('d M Y') }}
No licences found.

View all licences →

@endsection diff --git a/Licensing/Resources/views/admin/layout.blade.php b/Licensing/Resources/views/admin/layout.blade.php new file mode 100644 index 0000000..4bd80e4 --- /dev/null +++ b/Licensing/Resources/views/admin/layout.blade.php @@ -0,0 +1,3 @@ +@yield('title', 'SonarDesk Licensing') · ABSDev
ABSDev / SonarDesk
@yield('content')
diff --git a/Licensing/Resources/views/admin/licenses.blade.php b/Licensing/Resources/views/admin/licenses.blade.php new file mode 100644 index 0000000..9f3f47d --- /dev/null +++ b/Licensing/Resources/views/admin/licenses.blade.php @@ -0,0 +1,3 @@ +@extends('licensing::admin.layout') +@section('title', 'Licences') +@section('content')

Licences

@forelse($licenses as $license)@empty@endforelse
CustomerProductEditionProviderStatusCreated
{{ $license->customer_email ?: 'Unknown customer' }}{{ $license->product?->name ?? '—' }}{{ $license->edition }}{{ $license->payment_provider ?? '—' }}{{ $license->is_usable ? 'Usable' : 'Inactive' }}{{ $license->created_at?->format('d M Y') }}
No licences found.
{{ $licenses->links() }}
@endsection diff --git a/Licensing/Resources/views/admin/show.blade.php b/Licensing/Resources/views/admin/show.blade.php new file mode 100644 index 0000000..42f12b3 --- /dev/null +++ b/Licensing/Resources/views/admin/show.blade.php @@ -0,0 +1,3 @@ +@extends('licensing::admin.layout') +@section('title', 'Licence details') +@section('content')

Licence details

Customer and licence

Customer
{{ $license->customer_name ?? '—' }}
Email
{{ $license->customer_email ?? '—' }}
Product
{{ $license->product?->name ?? '—' }}
Edition
{{ $license->edition }}
Key (last four only)
•••• {{ $license->license_key_last4 }}
Status
{{ $license->is_usable ? 'Usable' : 'Inactive' }}

Subscription

Provider
{{ $license->payment_provider ?? '—' }}
Subscription status
{{ $license->subscription_status ?? '—' }}
Period ends
{{ $license->current_period_ends_at?->format('d M Y') ?? '—' }}
Expires
{{ $license->expires_at?->format('d M Y') ?? '—' }}
Activation limit
{{ $license->activation_limit }}

Device activations

@forelse($license->activations as $activation)@empty@endforelse
PlatformVersionActivatedLast seenStatus
{{ $activation->platform ?? '—' }}{{ $activation->app_version ?? '—' }}{{ $activation->activated_at?->format('d M Y H:i') ?? '—' }}{{ $activation->last_seen_at?->format('d M Y H:i') ?? '—' }}{{ $activation->deactivated_at ? 'Deactivated' : 'Active' }}
No device activations.
@endsection diff --git a/Licensing/Resources/views/index.blade.php b/Licensing/Resources/views/index.blade.php new file mode 100644 index 0000000..4277b06 --- /dev/null +++ b/Licensing/Resources/views/index.blade.php @@ -0,0 +1,9 @@ +@extends('licensing::layouts.master') + +@section('content') +

Hello World

+ +

+ This view is loaded from module: {!! config('licensing.name') !!} +

+@endsection diff --git a/Licensing/Resources/views/layouts/master.blade.php b/Licensing/Resources/views/layouts/master.blade.php new file mode 100644 index 0000000..1e51f1c --- /dev/null +++ b/Licensing/Resources/views/layouts/master.blade.php @@ -0,0 +1,19 @@ + + + + + + + Module Licensing + + {{-- Laravel Mix - CSS File --}} + {{-- --}} + + + + @yield('content') + + {{-- Laravel Mix - JS File --}} + {{-- --}} + + diff --git a/Licensing/Routes/.gitkeep b/Licensing/Routes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Licensing/Routes/admin.php b/Licensing/Routes/admin.php new file mode 100644 index 0000000..592524a --- /dev/null +++ b/Licensing/Routes/admin.php @@ -0,0 +1,14 @@ +prefix('administration/sonardesk') + ->name('administration.sonardesk.') + ->group(function (): void { + Route::get('/', [LicensingDashboardController::class, 'index'])->name('index'); + Route::get('/licenses', [LicensingDashboardController::class, 'licenses'])->name('licenses'); + Route::get('/licenses/{license}', [LicensingDashboardController::class, 'show'])->name('licenses.show'); + }); diff --git a/Licensing/Routes/api.php b/Licensing/Routes/api.php new file mode 100644 index 0000000..a923714 --- /dev/null +++ b/Licensing/Routes/api.php @@ -0,0 +1,12 @@ +group(function (): void { + Route::post('/activate', [LicenseActivationController::class, 'activate'])->middleware('throttle:license-activation'); + Route::post('/validate', [LicenseActivationController::class, 'validateInstallation'])->middleware('throttle:license-activation'); + Route::post('/deactivate', [LicenseActivationController::class, 'deactivate'])->middleware('throttle:license-activation'); + Route::post('/webhooks/{provider}', PaymentWebhookController::class)->whereIn('provider', ['paddle', 'lemon-squeezy']); +}); diff --git a/Licensing/Routes/web.php b/Licensing/Routes/web.php new file mode 100644 index 0000000..cfe4d88 --- /dev/null +++ b/Licensing/Routes/web.php @@ -0,0 +1,13 @@ +prefix('portal')->name('portal.')->group(function (): void { + Route::get('/', [PortalController::class, 'index'])->name('index'); + Route::get('/licences/{license}', [PortalController::class, 'show'])->name('show'); + Route::post('/licences/{license}/upgrade', [PortalController::class, 'upgrade'])->middleware('throttle:5,1')->name('upgrade'); + Route::delete('/licences/{license}/subscription', [PortalController::class, 'cancel'])->middleware('throttle:5,1')->name('cancel'); + Route::post('/licences/{license}/resend', [PortalController::class, 'resend'])->middleware('throttle:3,10')->name('resend'); + Route::delete('/licences/{license}/activations/{activation}', [PortalController::class, 'deactivate'])->name('deactivate'); +}); diff --git a/Licensing/Services/LicenseDeliveryService.php b/Licensing/Services/LicenseDeliveryService.php new file mode 100755 index 0000000..053600d --- /dev/null +++ b/Licensing/Services/LicenseDeliveryService.php @@ -0,0 +1,54 @@ +with('license') + ->whereNull('delivered_at') + ->whereNotNull('recipient_email') + ->where('recipient_email', '!=', '') + ->oldest() + ->limit(max(1, min($limit, 100))) + ->get(); + + foreach ($pending as $outbox) { + $license = $outbox->license; + if (! $license) { + continue; + } + + $plain = Crypt::decryptString($outbox->encrypted_license_key); + $recipient = strtolower(trim((string) $outbox->recipient_email)); + if ($recipient === '') { + continue; + } + + $data = [ + 'customerName' => $license->customer_name, + 'edition' => $license->edition, + 'licenseKey' => $plain, + 'expiresAt' => $license->expires_at, + 'portalUrl' => config('sonardesk.portal_url') ?: url('/portal'), + 'downloadUrl' => route('release'), + ]; + + Mail::send(['html' => 'emails.license-key', 'text' => 'emails.license-key-text'], $data, function ($message) use ($recipient): void { + $message->to($recipient)->subject('Your SonarDesk licence key'); + }); + + $outbox->update(['delivered_at' => now()]); + $delivered++; + } + + return $delivered; + } +} diff --git a/Licensing/Services/LicenseIssuer.php b/Licensing/Services/LicenseIssuer.php new file mode 100755 index 0000000..7171dd8 --- /dev/null +++ b/Licensing/Services/LicenseIssuer.php @@ -0,0 +1,46 @@ +where('external_order_id', $orderID)->first(); + if ($existing) { + return [$existing, null]; + } + } + $plain = LicenseKey::generate(); + $license = License::create([ + 'license_product_id' => $product->id, + 'license_key_hash' => LicenseKey::hash($plain), + 'license_key_last4' => substr(LicenseKey::normalize($plain), -4), + 'customer_name' => $name, + 'customer_email' => $email ? strtolower(trim($email)) : null, + 'edition' => $product->edition, + 'activation_limit' => $product->activation_limit, + 'max_major_version' => $product->max_major_version, + 'payment_provider' => $provider, + 'external_order_id' => $orderID, + 'external_customer_id' => $customerID, + ]); + if ($queueDelivery) { + LicenseDeliveryOutbox::updateOrCreate( + ['license_id' => $license->id], + ['encrypted_license_key' => Crypt::encryptString($plain), 'recipient_email' => $license->customer_email, 'delivered_at' => null] + ); + } + + return [$license, $plain]; + }); + } +} diff --git a/Licensing/Services/LicenseKey.php b/Licensing/Services/LicenseKey.php new file mode 100755 index 0000000..4e2501f --- /dev/null +++ b/Licensing/Services/LicenseKey.php @@ -0,0 +1,27 @@ +privateSeed(); + $pair = sodium_crypto_sign_seed_keypair($seed); + $secret = sodium_crypto_sign_secretkey($pair); + + $payload = [ + 'customerEmail' => $license->customer_email, + 'customerName' => $license->customer_name, + 'edition' => $license->edition, + 'expiresAt' => $license->expires_at?->utc()->format('Y-m-d\\TH:i:s\\Z'), + 'installationID' => strtolower($installationID), + 'issuedAt' => ($issuedAt ?? now())->utc()->format('Y-m-d\\TH:i:s\\Z'), + 'licenseID' => strtolower((string) $license->id), + 'maxMajorVersion' => $license->max_major_version, + ]; + ksort($payload, SORT_STRING); + $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + $sig = sodium_crypto_sign_detached($json, $secret); + + return 'RS3.'.self::b64url($json).'.'.self::b64url($sig); + } + + public function publicKeyBase64(): string + { + $pair = sodium_crypto_sign_seed_keypair($this->privateSeed()); + + return base64_encode(sodium_crypto_sign_publickey($pair)); + } + + private function privateSeed(): string + { + $path = config('sonardesk.private_key_path'); + if (! $path || ! is_file($path)) { + throw new RuntimeException('SONARDESK_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.'); + } + $decoded = base64_decode(trim((string) file_get_contents($path)), true); + if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) { + throw new RuntimeException('The SonarDesk private key must be a Base64-encoded 32-byte Ed25519 seed.'); + } + + return $decoded; + } + + private static function b64url(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } +} diff --git a/Licensing/Services/LicenseTokenVerifier.php b/Licensing/Services/LicenseTokenVerifier.php new file mode 100755 index 0000000..052f633 --- /dev/null +++ b/Licensing/Services/LicenseTokenVerifier.php @@ -0,0 +1,58 @@ +decode($parts[1]); + $signature = $this->decode($parts[2]); + // Derive the verification key from the same private seed used by LicenseSigner. + // Keeping a separate SONARDESK_LICENSE_PUBLIC_KEY allowed the two keys to drift: + // activation would sign a perfectly valid token with the new private key, then + // /validate would reject that same token using a stale public key. + $public = $this->publicKeyFromPrivateSeed(); + if (! sodium_crypto_sign_verify_detached($signature, $payload, $public)) { + throw new RuntimeException('Licence token signature is invalid.'); + } + $decoded = json_decode($payload, true, flags: JSON_THROW_ON_ERROR); + + return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.'); + } + + private function publicKeyFromPrivateSeed(): string + { + $path = config('sonardesk.private_key_path'); + if (! $path || ! is_file($path)) { + throw new RuntimeException('SONARDESK_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.'); + } + + $seed = base64_decode(trim((string) file_get_contents($path)), true); + if ($seed === false || strlen($seed) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) { + throw new RuntimeException('The SonarDesk private key must be a Base64-encoded 32-byte Ed25519 seed.'); + } + + $pair = sodium_crypto_sign_seed_keypair($seed); + + return sodium_crypto_sign_publickey($pair); + } + + private function decode(string $value): string + { + $value = strtr($value, '-_', '+/'); + $value .= str_repeat('=', (4 - strlen($value) % 4) % 4); + $out = base64_decode($value, true); + if ($out === false) { + throw new RuntimeException('Invalid Base64URL value.'); + } + + return $out; + } +} diff --git a/Licensing/Support/.gitkeep b/Licensing/Support/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Licensing/Support/ModuleInfo.php b/Licensing/Support/ModuleInfo.php new file mode 100644 index 0000000..28b9983 --- /dev/null +++ b/Licensing/Support/ModuleInfo.php @@ -0,0 +1,26 @@ +morphMany(LocalizedContent::class, 'translatable'); + } + + public function translated(string $field, ?string $locale = null, mixed $fallback = null): mixed + { + $locale ??= app()->getLocale(); + $translation = $this->translations->first(fn (LocalizedContent $item): bool => $item->field === $field && $item->locale?->code === $locale); + if ($translation && filled($translation->value)) { + return $translation->value; + } + + return $fallback ?? $this->getAttribute($field); + } + + public function setTranslation(string $field, string $localeCode, ?string $value, ?int $userId = null): LocalizedContent + { + $locale = Locale::query()->where('code', $localeCode)->firstOrFail(); + + return $this->translations()->updateOrCreate( + ['locale_id' => $locale->id, 'field' => $field], + ['value' => $value, 'updated_by' => $userId] + ); + } +} diff --git a/Localization/Config/config.php b/Localization/Config/config.php new file mode 100644 index 0000000..da2392d --- /dev/null +++ b/Localization/Config/config.php @@ -0,0 +1,16 @@ + env('APP_LOCALE', 'en'), + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + 'session_key' => 'platform_locale', + 'supported_subjects' => [ + 'pages' => ['model' => Page::class, 'label' => 'Pages', 'fields' => ['title', 'summary', 'content', 'meta_title', 'meta_description']], + 'blog' => ['model' => Blog::class, 'label' => 'Blog', 'fields' => ['title', 'excerpt', 'content', 'meta_title', 'meta_description']], + 'news' => ['model' => News::class, 'label' => 'News', 'fields' => ['title', 'excerpt', 'content', 'meta_title', 'meta_description']], + ], +]; diff --git a/Localization/Database/Migrations/2026_07_24_06000_create_localization_tables.php b/Localization/Database/Migrations/2026_07_24_06000_create_localization_tables.php new file mode 100644 index 0000000..24f8aba --- /dev/null +++ b/Localization/Database/Migrations/2026_07_24_06000_create_localization_tables.php @@ -0,0 +1,46 @@ +id(); + $table->string('code', 20)->unique(); + $table->string('name', 100); + $table->string('native_name', 100); + $table->string('regional_code', 20)->nullable(); + $table->string('direction', 3)->default('ltr'); + $table->string('date_format', 40)->default('d/m/Y'); + $table->string('time_format', 40)->default('H:i'); + $table->string('timezone', 80)->default('UTC'); + $table->string('currency_code', 3)->nullable(); + $table->string('currency_symbol', 10)->nullable(); + $table->unsignedInteger('position')->default(0); + $table->boolean('is_default')->default(false); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + Schema::create('localized_contents', function (Blueprint $table): void { + $table->id(); + $table->morphs('translatable'); + $table->foreignId('locale_id')->constrained()->cascadeOnDelete(); + $table->string('field', 100); + $table->longText('value')->nullable(); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->unique(['translatable_type', 'translatable_id', 'locale_id', 'field'], 'localized_content_unique'); + $table->index(['locale_id', 'field']); + }); + } + + public function down(): void + { + Schema::dropIfExists('localized_contents'); + Schema::dropIfExists('locales'); + } +}; diff --git a/Localization/Database/Seeders/LocaleSeeder.php b/Localization/Database/Seeders/LocaleSeeder.php new file mode 100644 index 0000000..1bf793d --- /dev/null +++ b/Localization/Database/Seeders/LocaleSeeder.php @@ -0,0 +1,19 @@ +updateOrCreate(['code' => 'en'], [ + 'name' => 'English', 'native_name' => 'English', 'regional_code' => 'en-GB', + 'direction' => 'ltr', 'date_format' => 'd/m/Y', 'time_format' => 'H:i', + 'timezone' => config('app.timezone', 'UTC'), 'currency_code' => 'GBP', + 'currency_symbol' => '£', 'position' => 0, 'is_default' => true, 'is_active' => true, + ]); + } +} diff --git a/Localization/Http/Controllers/LocaleController.php b/Localization/Http/Controllers/LocaleController.php new file mode 100644 index 0000000..8e3a54c --- /dev/null +++ b/Localization/Http/Controllers/LocaleController.php @@ -0,0 +1,78 @@ +user()->can('localization.manage'), 403); + $subjects = collect(config('localization.supported_subjects'))->map(function (array $config): array { + $model = $config['model']; + + return $config + ['records' => $model::query()->latest()->limit(8)->get()]; + }); + + return view('localization::admin.index', ['locales' => Locale::query()->orderBy('position')->get(), 'subjects' => $subjects]); + } + + public function switch(string $locale, LocaleManager $manager): RedirectResponse + { + $manager->activate($locale); + + return back(); + } + + public function store(Request $request): RedirectResponse + { + abort_unless($request->user()->can('localization.manage'), 403); + $data = $request->validate($this->rules()); + if (($data['is_default'] ?? false) === true) { + Locale::query()->update(['is_default' => false]); + } + Locale::query()->create($data); + + return back()->with('success', 'Locale created.'); + } + + public function update(Request $request, Locale $locale): RedirectResponse + { + abort_unless($request->user()->can('localization.manage'), 403); + $data = $request->validate($this->rules($locale)); + if (($data['is_default'] ?? false) === true) { + Locale::query()->whereKeyNot($locale->id)->update(['is_default' => false]); + } + $locale->update($data); + + return back()->with('success', 'Locale updated.'); + } + + public function destroy(Request $request, Locale $locale): RedirectResponse + { + abort_unless($request->user()->can('localization.manage'), 403); + abort_if($locale->is_default, 422, 'The default locale cannot be deleted.'); + $locale->delete(); + + return back()->with('success', 'Locale deleted.'); + } + + private function rules(?Locale $locale = null): array + { + return [ + 'code' => ['required', 'string', 'max:20', 'unique:locales,code,'.($locale?->id ?? 'NULL')], + 'name' => ['required', 'string', 'max:100'], 'native_name' => ['required', 'string', 'max:100'], + 'regional_code' => ['nullable', 'string', 'max:20'], 'direction' => ['required', 'in:ltr,rtl'], + 'date_format' => ['required', 'string', 'max:40'], 'time_format' => ['required', 'string', 'max:40'], + 'timezone' => ['required', 'timezone'], 'currency_code' => ['nullable', 'string', 'size:3'], + 'currency_symbol' => ['nullable', 'string', 'max:10'], 'position' => ['nullable', 'integer', 'min:0'], + 'is_default' => ['sometimes', 'boolean'], 'is_active' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/Localization/Http/Controllers/TranslationController.php b/Localization/Http/Controllers/TranslationController.php new file mode 100644 index 0000000..69fd8b5 --- /dev/null +++ b/Localization/Http/Controllers/TranslationController.php @@ -0,0 +1,44 @@ +user()->can('localization.translate'), 403); + [$record, $config] = $this->resolve($subject, $id); + $record->load('translations.locale'); + + return view('localization::admin.translate', ['record' => $record, 'subject' => $subject, 'config' => $config, 'locales' => Locale::query()->active()->get()]); + } + + public function update(Request $request, string $subject, int $id): RedirectResponse + { + abort_unless($request->user()->can('localization.translate'), 403); + [$record, $config] = $this->resolve($subject, $id); + $data = $request->validate(['translations' => ['array'], 'translations.*' => ['array']]); + foreach ($data['translations'] ?? [] as $localeCode => $fields) { + foreach ($config['fields'] as $field) { + $record->setTranslation($field, $localeCode, $fields[$field] ?? null, $request->user()->id); + } + } + + return back()->with('success', 'Translations saved.'); + } + + private function resolve(string $subject, int $id): array + { + $config = config("localization.supported_subjects.$subject"); + abort_unless(is_array($config), 404); + $record = $config['model']::query()->findOrFail($id); + + return [$record, $config]; + } +} diff --git a/Localization/Http/Middleware/SetLocale.php b/Localization/Http/Middleware/SetLocale.php new file mode 100644 index 0000000..1d93a23 --- /dev/null +++ b/Localization/Http/Middleware/SetLocale.php @@ -0,0 +1,53 @@ +route('locale'); + + if (! $code && $request->hasSession()) { + $code = $request->session()->get($sessionKey); + } + + if (! Schema::hasTable('locales')) { + app()->setLocale(is_string($code) && $code !== '' ? $code : $fallback); + + return $next($request); + } + + if (! $code) { + $code = Locale::query() + ->where('is_default', true) + ->value('code') ?: $fallback; + } + + $isActive = Locale::query() + ->where('code', $code) + ->where('is_active', true) + ->exists(); + + if ($isActive) { + app()->setLocale((string) $code); + + if ($request->hasSession()) { + $request->session()->put($sessionKey, $code); + } + } else { + app()->setLocale($fallback); + } + + return $next($request); + } +} diff --git a/Localization/Models/Locale.php b/Localization/Models/Locale.php new file mode 100644 index 0000000..97fa973 --- /dev/null +++ b/Localization/Models/Locale.php @@ -0,0 +1,27 @@ + 'boolean', 'is_active' => 'boolean']; + } + + public function translations(): HasMany + { + return $this->hasMany(LocalizedContent::class); + } + + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true)->orderBy('position')->orderBy('name'); + } +} diff --git a/Localization/Models/LocalizedContent.php b/Localization/Models/LocalizedContent.php new file mode 100644 index 0000000..2a10675 --- /dev/null +++ b/Localization/Models/LocalizedContent.php @@ -0,0 +1,28 @@ +morphTo(); + } + + public function locale(): BelongsTo + { + return $this->belongsTo(Locale::class); + } + + public function editor(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } +} diff --git a/Localization/Providers/LocalizationServiceProvider.php b/Localization/Providers/LocalizationServiceProvider.php new file mode 100644 index 0000000..6383fa8 --- /dev/null +++ b/Localization/Providers/LocalizationServiceProvider.php @@ -0,0 +1,22 @@ +mergeConfigFrom(module_path('Localization', 'Config/config.php'), 'localization'); + $this->app->singleton(LocaleManager::class); + $this->app->register(RouteServiceProvider::class); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Localization', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Localization', 'Resources/views'), 'localization'); + } +} diff --git a/Localization/Providers/RouteServiceProvider.php b/Localization/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..39dae19 --- /dev/null +++ b/Localization/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +group(module_path('Localization', 'Routes/web.php')); + } +} diff --git a/Localization/Resources/views/admin/index.blade.php b/Localization/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..4dca7c8 --- /dev/null +++ b/Localization/Resources/views/admin/index.blade.php @@ -0,0 +1 @@ +
LocalisationLanguages, regional formats and translated content.
Locales@foreach($locales as $locale)
@csrf @method('PUT')
Save
@endforeach
Add locale
@csrfAdd locale
Translate content
@foreach($subjects as $key=>$subject)
{{ $subject['label'] }}
@foreach($subject['records'] as $record){{ $record->title ?? ('#'.$record->id) }}@endforeach
@endforeach
diff --git a/Localization/Resources/views/admin/translate.blade.php b/Localization/Resources/views/admin/translate.blade.php new file mode 100644 index 0000000..c71edbc --- /dev/null +++ b/Localization/Resources/views/admin/translate.blade.php @@ -0,0 +1 @@ +
Translate: {{ $record->title ?? ('#'.$record->id) }}{{ $config['label'] }} · source language values are shown for reference.
@csrf @method('PUT')@foreach($locales as $locale)
{{ $locale->native_name }}{{ $locale->code }}
@foreach($config['fields'] as $field)@php($existing=$record->translations->first(fn($item)=>$item->locale_id===$locale->id&&$item->field===$field)?->value)
{{ str($field)->headline() }}
Source: {{ str(strip_tags((string)$record->getAttribute($field)))->limit(180) }}
@if(in_array($field,['content','summary','excerpt','meta_description'])){{ $existing }}@else@endif
@endforeach
@endforeach
Save translations
diff --git a/Localization/Routes/web.php b/Localization/Routes/web.php new file mode 100644 index 0000000..119020e --- /dev/null +++ b/Localization/Routes/web.php @@ -0,0 +1,15 @@ +name('localization.switch'); +Route::middleware(['auth', 'verified'])->prefix('administration/localization')->name('administration.localization.')->group(function (): void { + Route::get('/', [LocaleController::class, 'index'])->name('index'); + Route::post('/locales', [LocaleController::class, 'store'])->name('locales.store'); + Route::put('/locales/{locale}', [LocaleController::class, 'update'])->name('locales.update'); + Route::delete('/locales/{locale}', [LocaleController::class, 'destroy'])->name('locales.destroy'); + Route::get('/translate/{subject}/{id}', [TranslationController::class, 'edit'])->name('translations.edit'); + Route::put('/translate/{subject}/{id}', [TranslationController::class, 'update'])->name('translations.update'); +}); diff --git a/Localization/Services/LocaleManager.php b/Localization/Services/LocaleManager.php new file mode 100644 index 0000000..0454a58 --- /dev/null +++ b/Localization/Services/LocaleManager.php @@ -0,0 +1,28 @@ +active()->get(); + } + + public function default(): ?Locale + { + return Locale::query()->where('is_default', true)->first() ?? Locale::query()->active()->first(); + } + + public function activate(string $code): Locale + { + $locale = Locale::query()->active()->where('code', $code)->firstOrFail(); + app()->setLocale($locale->code); + session()->put(config('localization.session_key'), $locale->code); + + return $locale; + } +} diff --git a/Localization/Tests/Feature/ModuleBootTest.php b/Localization/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..c4aea49 --- /dev/null +++ b/Localization/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,14 @@ +assertTrue(app()->providerIsLoaded(LocalizationServiceProvider::class)); + } +} diff --git a/Localization/module.json b/Localization/module.json new file mode 100644 index 0000000..a5e28f7 --- /dev/null +++ b/Localization/module.json @@ -0,0 +1,9 @@ +{ + "name": "Localization", + "alias": "localization", + "description": "Locales, translated content, regional settings and language switching.", + "keywords": ["localization", "translation", "locale", "language"], + "priority": 36, + "providers": ["Modules\\Localization\\Providers\\LocalizationServiceProvider"], + "files": [] +} diff --git a/Locations/Config/config.php b/Locations/Config/config.php new file mode 100644 index 0000000..ba035af --- /dev/null +++ b/Locations/Config/config.php @@ -0,0 +1,3 @@ + 'Locations']; diff --git a/Locations/Database/Migrations/2026_07_24_04400_create_locations_table.php b/Locations/Database/Migrations/2026_07_24_04400_create_locations_table.php new file mode 100644 index 0000000..8ef54fb --- /dev/null +++ b/Locations/Database/Migrations/2026_07_24_04400_create_locations_table.php @@ -0,0 +1,34 @@ +id(); + $t->string('name'); + $t->string('slug')->unique(); + $t->string('type')->default('office'); + $t->text('address')->nullable(); + $t->string('postcode')->nullable(); + $t->string('country')->nullable(); + $t->string('phone')->nullable(); + $t->string('email')->nullable(); + $t->decimal('latitude', 10, 7)->nullable(); + $t->decimal('longitude', 10, 7)->nullable(); + $t->json('opening_hours')->nullable(); + $t->boolean('is_active')->default(true); + $t->unsignedInteger('sort_order')->default(0); + $t->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('locations'); + } +}; diff --git a/Locations/Http/Controllers/LocationController.php b/Locations/Http/Controllers/LocationController.php new file mode 100644 index 0000000..a1b2606 --- /dev/null +++ b/Locations/Http/Controllers/LocationController.php @@ -0,0 +1,31 @@ + Location::orderBy('sort_order')->orderBy('name')->paginate(25)]); + } + + public function store(Request $r): RedirectResponse + { + Location::create($r->validate(['name' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255', 'unique:locations,slug'], 'type' => ['required', 'in:office,store,warehouse,venue,service'], 'address' => ['nullable', 'string'], 'postcode' => ['nullable', 'string', 'max:30'], 'country' => ['nullable', 'string', 'max:120'], 'phone' => ['nullable', 'string', 'max:80'], 'email' => ['nullable', 'email', 'max:255'], 'latitude' => ['nullable', 'numeric', 'between:-90,90'], 'longitude' => ['nullable', 'numeric', 'between:-180,180'], 'sort_order' => ['nullable', 'integer', 'min:0']]) + ['is_active' => $r->boolean('is_active', true)]); + + return back()->with('status', 'Location created.'); + } + + public function destroy(Location $location): RedirectResponse + { + $location->delete(); + + return back()->with('status', 'Location deleted.'); + } +} diff --git a/Locations/Models/Location.php b/Locations/Models/Location.php new file mode 100644 index 0000000..913fd40 --- /dev/null +++ b/Locations/Models/Location.php @@ -0,0 +1,15 @@ + 'decimal:7', 'longitude' => 'decimal:7', 'opening_hours' => 'array', 'is_active' => 'boolean']; + } +} diff --git a/Locations/Providers/LocationsServiceProvider.php b/Locations/Providers/LocationsServiceProvider.php new file mode 100644 index 0000000..de2419b --- /dev/null +++ b/Locations/Providers/LocationsServiceProvider.php @@ -0,0 +1,20 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Locations', 'Config/config.php'), 'locations'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Locations', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Locations', 'Resources/views'), 'locations'); + } +} diff --git a/Locations/Providers/RouteServiceProvider.php b/Locations/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..cb2ee3e --- /dev/null +++ b/Locations/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +group(module_path('Locations', 'Routes/web.php')); + } +} diff --git a/Locations/Resources/views/admin/index.blade.php b/Locations/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..3b1f2c1 --- /dev/null +++ b/Locations/Resources/views/admin/index.blade.php @@ -0,0 +1 @@ +

Locations

Manage offices, stores, venues and service locations.

@if(session('status'))
{{ session('status') }}
@endif
@csrf

Add location

Create location
@forelse($locations as $location)@empty@endforelse
LocationTypeContactStatus
{{ $location->name }}
{{ $location->address }}
{{ ucfirst($location->type) }}{{ $location->phone ?: $location->email ?: '—' }}{{ $location->is_active ? 'Active' : 'Inactive' }}
@csrf @method('DELETE')Delete
No locations yet.
{{ $locations->links() }}
diff --git a/Locations/Routes/web.php b/Locations/Routes/web.php new file mode 100644 index 0000000..2e5d437 --- /dev/null +++ b/Locations/Routes/web.php @@ -0,0 +1,10 @@ +prefix('administration/locations')->name('administration.locations.')->group(function (): void { + Route::get('/', [LocationController::class, 'index'])->name('index'); + Route::post('/', [LocationController::class, 'store'])->name('store'); + Route::delete('/{location}', [LocationController::class, 'destroy'])->name('destroy'); +}); diff --git a/Locations/Tests/Feature/ModuleBootTest.php b/Locations/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..c8dec12 --- /dev/null +++ b/Locations/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,13 @@ +assertSame('Locations', config('locations.name')); + } +} diff --git a/Locations/module.json b/Locations/module.json new file mode 100644 index 0000000..86865e1 --- /dev/null +++ b/Locations/module.json @@ -0,0 +1,12 @@ +{ + "name": "Locations", + "alias": "locations", + "version": "1.0.0", + "description": "Office and service location management.", + "keywords": ["business", "locations"], + "priority": 44, + "providers": ["Modules\\Locations\\Providers\\LocationsServiceProvider"], + "aliases": {}, + "files": [], + "requires": ["Core", "Administration"] +} diff --git a/Marketplace/Config/config.php b/Marketplace/Config/config.php new file mode 100644 index 0000000..2274c18 --- /dev/null +++ b/Marketplace/Config/config.php @@ -0,0 +1,14 @@ + (bool) env('MARKETPLACE_ENABLED', true), + 'catalogue_url' => env('MARKETPLACE_CATALOGUE_URL'), + 'allow_remote_install' => (bool) env('MARKETPLACE_ALLOW_REMOTE_INSTALL', false), + 'require_checksum' => (bool) env('MARKETPLACE_REQUIRE_CHECKSUM', true), + 'require_signature' => (bool) env('MARKETPLACE_REQUIRE_SIGNATURE', false), + 'trusted_publishers' => array_values(array_filter(array_map('trim', explode(',', (string) env('MARKETPLACE_TRUSTED_PUBLISHERS', ''))))), + 'storage_disk' => env('MARKETPLACE_STORAGE_DISK', 'local'), + 'package_path' => env('MARKETPLACE_PACKAGE_PATH', 'marketplace/packages'), + 'snapshot_path' => env('MARKETPLACE_SNAPSHOT_PATH', 'marketplace/snapshots'), + 'supported_types' => ['module', 'theme', 'extension'], +]; diff --git a/Marketplace/Console/Commands/MarketplaceAuditCommand.php b/Marketplace/Console/Commands/MarketplaceAuditCommand.php new file mode 100644 index 0000000..b8a36b0 --- /dev/null +++ b/Marketplace/Console/Commands/MarketplaceAuditCommand.php @@ -0,0 +1,24 @@ +with('package')->get(); + foreach ($packages as $package) { + $this->line(sprintf('%s %s [%s]%s', $package->package_name, $package->version, $package->status, $package->hasUpdate() ? ' update available' : '')); + } + $this->info($packages->count().' installed package(s) audited.'); + + return self::SUCCESS; + } +} diff --git a/Marketplace/Console/Commands/MarketplaceSyncCommand.php b/Marketplace/Console/Commands/MarketplaceSyncCommand.php new file mode 100644 index 0000000..060aa58 --- /dev/null +++ b/Marketplace/Console/Commands/MarketplaceSyncCommand.php @@ -0,0 +1,24 @@ +warn('MARKETPLACE_CATALOGUE_URL is not configured.'); + + return self::SUCCESS; + } + $this->info('Catalogue synchronisation is configured. Remote installs remain disabled unless explicitly enabled.'); + + return self::SUCCESS; + } +} diff --git a/Marketplace/Database/Migrations/2026_07_24_170000_create_marketplace_tables.php b/Marketplace/Database/Migrations/2026_07_24_170000_create_marketplace_tables.php new file mode 100644 index 0000000..eb2a3b9 --- /dev/null +++ b/Marketplace/Database/Migrations/2026_07_24_170000_create_marketplace_tables.php @@ -0,0 +1,96 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('email')->nullable(); + $table->string('website')->nullable(); + $table->text('public_key')->nullable(); + $table->boolean('is_verified')->default(false)->index(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + Schema::create('marketplace_packages', function (Blueprint $table) { + $table->id(); + $table->foreignId('publisher_id')->nullable()->constrained('marketplace_publishers')->nullOnDelete(); + $table->string('type', 30)->index(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('package_name')->unique(); + $table->text('description')->nullable(); + $table->string('latest_version')->nullable(); + $table->string('homepage')->nullable(); + $table->string('repository_url')->nullable(); + $table->string('download_url')->nullable(); + $table->string('checksum', 128)->nullable(); + $table->text('signature')->nullable(); + $table->json('manifest')->nullable(); + $table->boolean('is_featured')->default(false)->index(); + $table->boolean('is_active')->default(true)->index(); + $table->timestamp('catalogue_updated_at')->nullable(); + $table->timestamps(); + }); + Schema::create('marketplace_installed_packages', function (Blueprint $table) { + $table->id(); + $table->foreignId('marketplace_package_id')->nullable()->constrained('marketplace_packages')->nullOnDelete(); + $table->string('package_name')->unique(); + $table->string('type', 30)->index(); + $table->string('version'); + $table->string('status', 30)->default('installed')->index(); + $table->json('manifest')->nullable(); + $table->string('checksum', 128)->nullable(); + $table->string('install_path')->nullable(); + $table->boolean('is_enabled')->default(true)->index(); + $table->foreignId('installed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('installed_at')->nullable(); + $table->timestamp('updated_at_marketplace')->nullable(); + $table->timestamps(); + }); + Schema::create('marketplace_installations', function (Blueprint $table) { + $table->id(); + $table->foreignId('installed_package_id')->nullable()->constrained('marketplace_installed_packages')->nullOnDelete(); + $table->string('package_name')->index(); + $table->string('operation', 30)->index(); + $table->string('from_version')->nullable(); + $table->string('to_version')->nullable(); + $table->string('status', 30)->default('pending')->index(); + $table->string('snapshot_path')->nullable(); + $table->text('message')->nullable(); + $table->json('context')->nullable(); + $table->foreignId('performed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + }); + Schema::create('marketplace_licences', function (Blueprint $table) { + $table->id(); + $table->foreignId('marketplace_package_id')->nullable()->constrained('marketplace_packages')->nullOnDelete(); + $table->string('licence_key')->nullable(); + $table->string('status', 30)->default('inactive')->index(); + $table->string('plan')->nullable(); + $table->string('licensed_to')->nullable(); + $table->timestamp('activated_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('marketplace_licences'); + Schema::dropIfExists('marketplace_installations'); + Schema::dropIfExists('marketplace_installed_packages'); + Schema::dropIfExists('marketplace_packages'); + Schema::dropIfExists('marketplace_publishers'); + } +}; diff --git a/Marketplace/Http/Controllers/MarketplaceApiController.php b/Marketplace/Http/Controllers/MarketplaceApiController.php new file mode 100644 index 0000000..958d8f3 --- /dev/null +++ b/Marketplace/Http/Controllers/MarketplaceApiController.php @@ -0,0 +1,40 @@ +json(MarketplacePackage::query()->with('publisher')->where('is_active', true)->when($request->filled('type'), fn ($q) => $q->where('type', $request->string('type')))->paginate(min(100, max(1, $request->integer('per_page', 25))))); + } + + public function installed(): JsonResponse + { + return response()->json(InstalledPackage::query()->with('package')->paginate(50)); + } + + public function install(MarketplacePackage $package, MarketplaceManager $manager, Request $request): JsonResponse + { + return response()->json($manager->install($package, $request->user()?->id)->load('package'), 201); + } + + public function toggle(InstalledPackage $package, MarketplaceManager $manager, Request $request): JsonResponse + { + return response()->json($manager->setEnabled($package, ! $package->is_enabled, $request->user()?->id)); + } + + public function remove(InstalledPackage $package, MarketplaceManager $manager, Request $request): JsonResponse + { + $manager->remove($package, $request->user()?->id); + + return response()->json(['status' => 'removed']); + } +} diff --git a/Marketplace/Http/Controllers/MarketplaceController.php b/Marketplace/Http/Controllers/MarketplaceController.php new file mode 100644 index 0000000..45744a0 --- /dev/null +++ b/Marketplace/Http/Controllers/MarketplaceController.php @@ -0,0 +1,64 @@ +with('publisher')->where('is_active', true)->when($request->filled('type'), fn ($q) => $q->where('type', $request->string('type')))->when($request->filled('search'), fn ($q) => $q->where(fn ($q) => $q->where('name', 'like', '%'.$request->string('search').'%')->orWhere('description', 'like', '%'.$request->string('search').'%')))->latest('is_featured')->paginate(20)->withQueryString(); + + return view('marketplace::index', compact('packages')); + } + + public function installed(): View + { + $packages = InstalledPackage::query()->with('package')->latest()->paginate(25); + + return view('marketplace::installed', compact('packages')); + } + + public function install(MarketplacePackage $package, MarketplaceManager $manager, Request $request): RedirectResponse + { + $manager->install($package, $request->user()?->id); + + return back()->with('status', 'Package installed successfully.'); + } + + public function update(MarketplacePackage $package, MarketplaceManager $manager, Request $request): RedirectResponse + { + $manager->install($package, $request->user()?->id); + + return back()->with('status', 'Package updated successfully.'); + } + + public function toggle(InstalledPackage $package, MarketplaceManager $manager, Request $request): RedirectResponse + { + $manager->setEnabled($package, ! $package->is_enabled, $request->user()?->id); + + return back()->with('status', 'Package state updated.'); + } + + public function remove(InstalledPackage $package, MarketplaceManager $manager, Request $request): RedirectResponse + { + $manager->remove($package, $request->user()?->id); + + return back()->with('status', 'Package removed.'); + } + + public function rollback(InstalledPackage $package, Installation $installation, MarketplaceManager $manager, Request $request): RedirectResponse + { + $manager->rollback($package, $installation, $request->user()?->id); + + return back()->with('status','Package rolled back.'); + } +} diff --git a/Marketplace/Models/Installation.php b/Marketplace/Models/Installation.php new file mode 100644 index 0000000..c152b8c --- /dev/null +++ b/Marketplace/Models/Installation.php @@ -0,0 +1,14 @@ + 'array', 'started_at' => 'datetime', 'completed_at' => 'datetime']; +} diff --git a/Marketplace/Models/InstalledPackage.php b/Marketplace/Models/InstalledPackage.php new file mode 100644 index 0000000..bb7eda5 --- /dev/null +++ b/Marketplace/Models/InstalledPackage.php @@ -0,0 +1,31 @@ + 'array', 'is_enabled' => 'boolean', 'installed_at' => 'datetime', 'updated_at_marketplace' => 'datetime']; + + public function package(): BelongsTo + { + return $this->belongsTo(MarketplacePackage::class, 'marketplace_package_id'); + } + + public function installations(): HasMany + { + return $this->hasMany(Installation::class, 'installed_package_id'); + } + + public function hasUpdate(): bool + { + return $this->package?->latest_version && version_compare($this->package->latest_version, $this->version, '>'); + } +} diff --git a/Marketplace/Models/MarketplaceLicence.php b/Marketplace/Models/MarketplaceLicence.php new file mode 100644 index 0000000..22038e8 --- /dev/null +++ b/Marketplace/Models/MarketplaceLicence.php @@ -0,0 +1,16 @@ + 'datetime', 'expires_at' => 'datetime', 'metadata' => 'array']; +} diff --git a/Marketplace/Models/MarketplacePackage.php b/Marketplace/Models/MarketplacePackage.php new file mode 100644 index 0000000..ad4a0c2 --- /dev/null +++ b/Marketplace/Models/MarketplacePackage.php @@ -0,0 +1,26 @@ + 'array', 'is_featured' => 'boolean', 'is_active' => 'boolean', 'catalogue_updated_at' => 'datetime']; + + public function publisher(): BelongsTo + { + return $this->belongsTo(Publisher::class); + } + + public function licences(): HasMany + { + return $this->hasMany(MarketplaceLicence::class, 'marketplace_package_id'); + } +} diff --git a/Marketplace/Models/Publisher.php b/Marketplace/Models/Publisher.php new file mode 100644 index 0000000..16558ef --- /dev/null +++ b/Marketplace/Models/Publisher.php @@ -0,0 +1,20 @@ + 'boolean', 'metadata' => 'array']; + + public function packages(): HasMany + { + return $this->hasMany(MarketplacePackage::class, 'publisher_id'); + } +} diff --git a/Marketplace/Providers/MarketplaceServiceProvider.php b/Marketplace/Providers/MarketplaceServiceProvider.php new file mode 100644 index 0000000..0edd17f --- /dev/null +++ b/Marketplace/Providers/MarketplaceServiceProvider.php @@ -0,0 +1,36 @@ +mergeConfigFrom(module_path('Marketplace', 'Config/config.php'), 'marketplace'); + $this->app->singleton(PackageManifest::class); + $this->app->singleton(CompatibilityChecker::class); + $this->app->singleton(DependencyResolver::class); + $this->app->singleton(PackageVerifier::class); + $this->app->singleton(MarketplaceManager::class); + $this->app->register(RouteServiceProvider::class); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Marketplace', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Marketplace', 'Resources/views'), 'marketplace'); + $this->loadTranslationsFrom(module_path('Marketplace', 'Resources/lang'), 'marketplace'); + if ($this->app->runningInConsole()) { + $this->commands([MarketplaceSyncCommand::class, MarketplaceAuditCommand::class]); + } + } +} diff --git a/Marketplace/Providers/RouteServiceProvider.php b/Marketplace/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..6bf0725 --- /dev/null +++ b/Marketplace/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +group(module_path('Marketplace', 'Routes/web.php')); + Route::middleware('api')->prefix('api/v1')->group(module_path('Marketplace', 'Routes/api.php')); + } +} diff --git a/Marketplace/Resources/views/index.blade.php b/Marketplace/Resources/views/index.blade.php new file mode 100644 index 0000000..6399b57 --- /dev/null +++ b/Marketplace/Resources/views/index.blade.php @@ -0,0 +1,97 @@ + +
+
+
+ Marketplace + + Discover verified modules, themes and extensions. + +
+ + + Installed packages + +
+ +
+ + + + + + @foreach (config('marketplace.supported_types', []) as $type) + + @endforeach + + + Filter + + +
+ @forelse ($packages as $package) +
+
+
+
+

{{ $package->name }}

+ + @if ($package->is_featured) + Featured + @endif +
+ +

+ {{ $package->package_name }} + · {{ \Illuminate\Support\Str::headline($package->type) }} + · {{ $package->latest_version ?: 'No release' }} +

+
+ + @can('marketplace.install') +
+ @csrf + + + Install + +
+ @endcan +
+ +

{{ $package->description }}

+ + @if ($package->publisher) +

+ Published by {{ $package->publisher->name }} + + @if ($package->publisher->is_verified) + · Verified + @endif +

+ @endif +
+ @empty +
+ No marketplace packages found. +
+ @endforelse +
+ + {{ $packages->links() }} +
+
diff --git a/Marketplace/Resources/views/installed.blade.php b/Marketplace/Resources/views/installed.blade.php new file mode 100644 index 0000000..7609352 --- /dev/null +++ b/Marketplace/Resources/views/installed.blade.php @@ -0,0 +1,104 @@ + +
+
+
+ Installed Packages + + Manage package state, updates and removals. + +
+ + + Browse marketplace + +
+ +
+ + + + + + + + + + + + + @forelse ($packages as $package) + + + + + + + + + + @empty + + + + @endforelse + +
PackageVersionStatusUpdateActions
+
{{ $package->package_name }}
+
+ {{ \Illuminate\Support\Str::headline($package->type) }} +
+
{{ $package->version }}{{ ucfirst($package->status) }} + {{ $package->hasUpdate() ? $package->package->latest_version : 'Current' }} + +
+ @can('marketplace.update') +
+ @csrf + + + {{ $package->is_enabled ? 'Disable' : 'Enable' }} + +
+ + @if ($package->hasUpdate()) +
+ @csrf + + + Update + +
+ @endif + @endcan + + @can('marketplace.remove') +
+ @csrf + @method('DELETE') + + + Remove + +
+ @endcan +
+
+ No packages installed through the marketplace. +
+
+ + {{ $packages->links() }} +
+
diff --git a/Marketplace/Routes/api.php b/Marketplace/Routes/api.php new file mode 100644 index 0000000..5c04c0e --- /dev/null +++ b/Marketplace/Routes/api.php @@ -0,0 +1,12 @@ +prefix('marketplace')->name('api.marketplace.')->group(function () { + Route::get('/packages', [MarketplaceApiController::class, 'packages'])->middleware('permission:marketplace.view')->name('packages'); + Route::get('/installed', [MarketplaceApiController::class, 'installed'])->middleware('permission:marketplace.view')->name('installed'); + Route::post('/packages/{package}/install', [MarketplaceApiController::class, 'install'])->middleware('permission:marketplace.install')->name('install'); + Route::post('/installed/{package}/toggle', [MarketplaceApiController::class, 'toggle'])->middleware('permission:marketplace.update')->name('toggle'); + Route::delete('/installed/{package}', [MarketplaceApiController::class, 'remove'])->middleware('permission:marketplace.remove')->name('remove'); +}); diff --git a/Marketplace/Routes/web.php b/Marketplace/Routes/web.php new file mode 100644 index 0000000..2ac0b30 --- /dev/null +++ b/Marketplace/Routes/web.php @@ -0,0 +1,14 @@ +prefix('administration/marketplace')->name('administration.marketplace.')->group(function () { + Route::get('/', [MarketplaceController::class, 'index'])->middleware('permission:marketplace.view')->name('index'); + Route::get('/installed', [MarketplaceController::class, 'installed'])->middleware('permission:marketplace.view')->name('installed'); + Route::post('/packages/{package}/install', [MarketplaceController::class, 'install'])->middleware('permission:marketplace.install')->name('install'); + Route::post('/packages/{package}/update', [MarketplaceController::class, 'update'])->middleware('permission:marketplace.update')->name('update'); + Route::post('/installed/{package}/toggle', [MarketplaceController::class, 'toggle'])->middleware('permission:marketplace.update')->name('toggle'); + Route::delete('/installed/{package}', [MarketplaceController::class, 'remove'])->middleware('permission:marketplace.remove')->name('remove'); + Route::post('/installed/{package}/rollback/{installation}', [MarketplaceController::class, 'rollback'])->middleware('permission:marketplace.update')->name('rollback'); +}); diff --git a/Marketplace/Services/CompatibilityChecker.php b/Marketplace/Services/CompatibilityChecker.php new file mode 100644 index 0000000..4ef2e12 --- /dev/null +++ b/Marketplace/Services/CompatibilityChecker.php @@ -0,0 +1,30 @@ +constraints->matches(PHP_VERSION, $php)) { + $errors[] = "Requires PHP $php; running ".PHP_VERSION.'.'; + } + if (! $this->constraints->matches(App::version(), $laravel)) { + $errors[] = "Requires Laravel $laravel; running ".App::version().'.'; + } + if (! $this->constraints->matches($platformVersion, $platform)) { + $errors[] = "Requires Content Manager $platform; running $platformVersion."; + } + + return ['compatible' => $errors === [], 'errors' => $errors]; + } +} diff --git a/Marketplace/Services/DependencyResolver.php b/Marketplace/Services/DependencyResolver.php new file mode 100644 index 0000000..02c711a --- /dev/null +++ b/Marketplace/Services/DependencyResolver.php @@ -0,0 +1,29 @@ + $constraint) { + $installed = InstalledPackage::query()->where('package_name', $package)->where('status', 'installed')->first(); + if (! $installed) { + $missing[$package] = $constraint; + + continue; + } + if (! $this->constraints->matches($installed->version, (string) $constraint)) { + $incompatible[$package] = ['required' => $constraint, 'installed' => $installed->version]; + } + } + + return ['satisfied' => $missing === [] && $incompatible === [], 'missing' => $missing, 'incompatible' => $incompatible]; + } +} diff --git a/Marketplace/Services/MarketplaceManager.php b/Marketplace/Services/MarketplaceManager.php new file mode 100644 index 0000000..a457800 --- /dev/null +++ b/Marketplace/Services/MarketplaceManager.php @@ -0,0 +1,72 @@ +manifests->parse($package->manifest ?: ['name' => $package->package_name, 'type' => $package->type, 'version' => $package->latest_version]); + $compatibility = $this->compatibility->check($manifest); + if (! $compatibility['compatible']) { + throw new RuntimeException(implode(' ', $compatibility['errors'])); + } + $dependencies = $this->dependencies->resolve($manifest); + if (! $dependencies['satisfied']) { + throw new RuntimeException('Package dependencies are not satisfied.'); + } + + return DB::transaction(function () use ($package, $manifest, $userId) { + $installed = InstalledPackage::query()->firstOrNew(['package_name' => $package->package_name]); + $from = $installed->exists ? $installed->version : null; + $installed->fill(['marketplace_package_id' => $package->id, 'type' => $package->type, 'version' => $manifest['version'], 'status' => 'installed', 'manifest' => $manifest, 'checksum' => $package->checksum, 'is_enabled' => true, 'installed_by' => $userId, 'installed_at' => $installed->installed_at ?: now(), 'updated_at_marketplace' => now()])->save(); + $this->history($installed, $from ? 'update' : 'install', $from, $manifest['version'], $userId, 'completed'); + + return $installed->refresh(); + }); + } + + public function setEnabled(InstalledPackage $package, bool $enabled, ?int $userId = null): InstalledPackage + { + $package->update(['is_enabled' => $enabled, 'status' => $enabled ? 'installed' : 'disabled']); + $this->history($package, $enabled ? 'enable' : 'disable', $package->version, $package->version, $userId, 'completed'); + + return $package->refresh(); + } + + public function remove(InstalledPackage $package, ?int $userId = null): void + { + DB::transaction(function () use ($package, $userId) { + $this->history($package, 'remove', $package->version, null, $userId, 'completed'); + $package->update(['status' => 'removed', 'is_enabled' => false]); + }); + } + + public function rollback(InstalledPackage $package, Installation $installation, ?int $userId = null): InstalledPackage + { + if ($installation->package_name !== $package->package_name || blank($installation->from_version)) { + throw new RuntimeException('This installation cannot be rolled back.'); + } + $current = $package->version; + $package->update(['version' => $installation->from_version, 'status' => 'installed', 'is_enabled' => true]); + $this->history($package, 'rollback', $current, $installation->from_version, $userId, 'completed'); + + return $package->refresh(); + } + + private function history(InstalledPackage $package, string $operation, ?string $from, ?string $to, ?int $userId, string $status): Installation + { + return Installation::query()->create(['installed_package_id' => $package->id, 'package_name' => $package->package_name, 'operation' => $operation, 'from_version' => $from, 'to_version' => $to, 'status' => $status, 'performed_by' => $userId, 'started_at' => now(), 'completed_at' => now()]); + } +} diff --git a/Marketplace/Services/PackageManifest.php b/Marketplace/Services/PackageManifest.php new file mode 100644 index 0000000..2fecb73 --- /dev/null +++ b/Marketplace/Services/PackageManifest.php @@ -0,0 +1,38 @@ + 'The package manifest is not valid JSON.']); + } + $manifest = $decoded; + } + foreach (['name', 'type', 'version'] as $field) { + if (! filled(Arr::get($manifest, $field))) { + throw ValidationException::withMessages(['manifest' => "Manifest field [$field] is required."]); + } + } + if (! in_array($manifest['type'], config('marketplace.supported_types', []), true)) { + throw ValidationException::withMessages(['manifest' => 'Unsupported marketplace package type.']); + } + $manifest['requires'] = $manifest['requires'] ?? []; + $manifest['content_manager'] = $manifest['content_manager'] ?? '*'; + $manifest['laravel'] = $manifest['laravel'] ?? '*'; + + return $manifest; + } + + public function fromFile(string $path): array + { + return $this->parse((string) file_get_contents($path)); + } +} diff --git a/Marketplace/Services/PackageVerifier.php b/Marketplace/Services/PackageVerifier.php new file mode 100644 index 0000000..1f909f7 --- /dev/null +++ b/Marketplace/Services/PackageVerifier.php @@ -0,0 +1,28 @@ + true, 'checksum' => $actual, 'signature_present' => filled($manifest['signature'] ?? null)]; + } +} diff --git a/Marketplace/Services/VersionConstraint.php b/Marketplace/Services/VersionConstraint.php new file mode 100644 index 0000000..14e206c --- /dev/null +++ b/Marketplace/Services/VersionConstraint.php @@ -0,0 +1,47 @@ +=') && version_compare($version, ($major + 1).'.0.0', '<'); + + continue; + } + if (str_starts_with($part, '~')) { + $base = substr($part, 1); + $bits = array_pad(explode('.', $base), 3, '0'); + $upper = $bits[0].'.'.((int) $bits[1] + 1).'.0'; + $ok = $ok && version_compare($version, $base, '>=') && version_compare($version, $upper, '<'); + + continue; + } + if (preg_match('/^(>=|<=|>|<|=)?\s*([0-9][0-9A-Za-z.\-+]*)$/', $part, $m)) { + $ok = $ok && version_compare($version, $m[2], $m[1] ?: '='); + } else { + $ok = $ok && $version === $part; + } + } + if ($ok) { + return true; + } + } + + return false; + } +} diff --git a/Marketplace/Tests/Feature/MarketplaceModuleTest.php b/Marketplace/Tests/Feature/MarketplaceModuleTest.php new file mode 100644 index 0000000..663809b --- /dev/null +++ b/Marketplace/Tests/Feature/MarketplaceModuleTest.php @@ -0,0 +1,23 @@ +parse(['name' => 'vendor/example', 'type' => 'module', 'version' => '1.0.0']); + $this->assertSame('module', $manifest['type']); + $this->assertSame([], $manifest['requires']); + } + + public function test_current_platform_is_compatible_with_wildcard_manifest(): void + { + $result = app(CompatibilityChecker::class)->check(['php' => '*', 'laravel' => '*', 'content_manager' => '*']); + $this->assertTrue($result['compatible']); + } +} diff --git a/Marketplace/module.json b/Marketplace/module.json new file mode 100644 index 0000000..936c435 --- /dev/null +++ b/Marketplace/module.json @@ -0,0 +1 @@ +{"name":"Marketplace","alias":"marketplace","version":"1.0.0","description":"Secure package catalogue, installation, updates, licences, publishers and rollback management.","keywords":["marketplace","modules","themes","extensions","packages"],"priority":170,"providers":["Modules\\Marketplace\\Providers\\MarketplaceServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Users","Roles","Audit","Developer","Security"]} diff --git a/Media/Config/config.php b/Media/Config/config.php new file mode 100644 index 0000000..ba65b69 --- /dev/null +++ b/Media/Config/config.php @@ -0,0 +1,6 @@ + 'Media', + 'enabled' => true, +]; diff --git a/Media/Database/Migrations/2026_07_23_000300_create_media_table.php b/Media/Database/Migrations/2026_07_23_000300_create_media_table.php new file mode 100644 index 0000000..581799e --- /dev/null +++ b/Media/Database/Migrations/2026_07_23_000300_create_media_table.php @@ -0,0 +1,37 @@ +id(); + $table->uuid('uuid')->unique(); + $table->nullableMorphs('attachable'); + $table->string('collection')->default('default')->index(); + $table->string('disk')->default('public'); + $table->string('path'); + $table->string('original_name'); + $table->string('mime_type')->nullable(); + $table->unsignedBigInteger('size')->default(0); + $table->string('title')->nullable(); + $table->string('alt_text')->nullable(); + $table->text('caption')->nullable(); + $table->json('metadata')->nullable(); + $table->unsignedInteger('sort_order')->default(0); + $table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + $table->index(['attachable_type', 'attachable_id', 'collection', 'sort_order'], 'media_attachable_collection_index'); + }); + } + + public function down(): void + { + Schema::dropIfExists('media'); + } +}; diff --git a/Media/Http/Controllers/MediaController.php b/Media/Http/Controllers/MediaController.php new file mode 100644 index 0000000..ee5c15e --- /dev/null +++ b/Media/Http/Controllers/MediaController.php @@ -0,0 +1,34 @@ + Media::query()->latest()->paginate(30)]); + } + + public function store(Request $request) + { + $request->validate(['file' => ['required', 'file', 'max:20480'], 'title' => ['nullable', 'string', 'max:255'], 'alt_text' => ['nullable', 'string', 'max:255']]); + $file = $request->file('file'); + $path = $file->store('media', 'public'); + Media::create(['disk' => 'public', 'path' => $path, 'original_name' => $file->getClientOriginalName(), 'mime_type' => $file->getMimeType(), 'size' => $file->getSize(), 'title' => $request->string('title')->toString() ?: pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME), 'alt_text' => $request->string('alt_text')->toString() ?: null, 'uploaded_by' => $request->user()->id]); + + return back()->with('status', 'Media uploaded.'); + } + + public function destroy(Media $medium) + { + Storage::disk($medium->disk)->delete($medium->path); + $medium->delete(); + + return back()->with('status', 'Media deleted.'); + } +} diff --git a/Media/Models/Media.php b/Media/Models/Media.php new file mode 100644 index 0000000..56af7ee --- /dev/null +++ b/Media/Models/Media.php @@ -0,0 +1,26 @@ + $m->uuid ??= (string) Str::uuid()); + } + + protected function casts(): array + { + return ['metadata' => 'array', 'size' => 'integer', 'sort_order' => 'integer']; + } +} diff --git a/Media/Providers/MediaServiceProvider.php b/Media/Providers/MediaServiceProvider.php new file mode 100644 index 0000000..eee1f9a --- /dev/null +++ b/Media/Providers/MediaServiceProvider.php @@ -0,0 +1,21 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower()); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations')); + $this->loadViewsFrom(module_path(ModuleInfo::name(), 'Resources/views'), ModuleInfo::nameLower()); + } +} diff --git a/Media/Providers/RouteServiceProvider.php b/Media/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..ec522c8 --- /dev/null +++ b/Media/Providers/RouteServiceProvider.php @@ -0,0 +1,17 @@ +routes(function (): void { + Route::middleware('web')->group(module_path('Media', 'Routes/web.php')); + }); + } +} diff --git a/Media/Resources/views/index.blade.php b/Media/Resources/views/index.blade.php new file mode 100644 index 0000000..e574feb --- /dev/null +++ b/Media/Resources/views/index.blade.php @@ -0,0 +1 @@ +

Media library

Upload and manage reusable files.

@if(session('status'))
{{ session('status') }}
@endif
@csrf
Upload
@forelse($media as $item)
@if(str_starts_with((string)$item->mime_type,'image/')){{ $item->alt_text }}@else
@endif
{{ $item->title ?: $item->original_name }}
{{ Number::fileSize($item->size) }}
@csrf @method('DELETE')Delete
@empty
No media uploaded.
@endforelse
{{ $media->links() }}
diff --git a/Media/Routes/web.php b/Media/Routes/web.php new file mode 100644 index 0000000..172cab2 --- /dev/null +++ b/Media/Routes/web.php @@ -0,0 +1,10 @@ +prefix('administration/media')->name('administration.media.')->group(function (): void { + Route::get('/', [MediaController::class, 'index'])->name('index'); + Route::post('/', [MediaController::class, 'store'])->name('store'); + Route::delete('/{medium}', [MediaController::class, 'destroy'])->name('destroy'); +}); diff --git a/Media/Support/ModuleInfo.php b/Media/Support/ModuleInfo.php new file mode 100644 index 0000000..91bfbd1 --- /dev/null +++ b/Media/Support/ModuleInfo.php @@ -0,0 +1,16 @@ +toBe('Media'); +}); diff --git a/Media/composer.json b/Media/composer.json new file mode 100644 index 0000000..3d22a93 --- /dev/null +++ b/Media/composer.json @@ -0,0 +1,10 @@ +{ + "name": "absdev/media-module", + "description": "Media foundation module for the ABSDEV Laravel application platform.", + "type": "laravel-module", + "autoload": { + "psr-4": { + "Modules\\Media\\": "" + } + } +} diff --git a/Media/module.json b/Media/module.json new file mode 100644 index 0000000..23a97df --- /dev/null +++ b/Media/module.json @@ -0,0 +1,20 @@ +{ + "name": "Media", + "alias": "media", + "version": "1.0.0", + "description": "Media foundation module for the ABSDEV Laravel application platform.", + "keywords": [ + "laravel", + "modules", + "media" + ], + "priority": 10, + "providers": [ + "Modules\\Media\\Providers\\MediaServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [ + "Core" + ] +} diff --git a/Menus/Config/config.php b/Menus/Config/config.php new file mode 100644 index 0000000..634ea78 --- /dev/null +++ b/Menus/Config/config.php @@ -0,0 +1,3 @@ + 'Menus']; diff --git a/Menus/Database/Migrations/2026_07_24_000200_create_menus_table.php b/Menus/Database/Migrations/2026_07_24_000200_create_menus_table.php new file mode 100644 index 0000000..6ab61c5 --- /dev/null +++ b/Menus/Database/Migrations/2026_07_24_000200_create_menus_table.php @@ -0,0 +1,26 @@ +id(); + $table->string('name'); + $table->string('location'); + $table->json('items')->nullable(); + $table->boolean('enabled')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('menus'); + } +}; diff --git a/Menus/Http/Controllers/MenuController.php b/Menus/Http/Controllers/MenuController.php new file mode 100644 index 0000000..3154304 --- /dev/null +++ b/Menus/Http/Controllers/MenuController.php @@ -0,0 +1,90 @@ + Menu::query()->orderBy('location')->paginate(30)]); + } + + public function create(): View + { + return view('menus::admin.form', ['locations' => $this->locations()]); + } + + public function store(Request $request): RedirectResponse + { + Menu::create($this->validated($request)); + + return redirect()->route('administration.menus.index')->with('status', 'Menu created. Every header or footer using this location has been updated.'); + } + + public function edit(Menu $menu): View + { + return view('menus::admin.form', ['record' => $menu, 'locations' => $this->locations()]); + } + + public function update(Request $request, Menu $menu): RedirectResponse + { + $menu->update($this->validated($request)); + + return redirect()->route('administration.menus.index')->with('status', 'Menu updated everywhere it is used.'); + } + + public function destroy(Menu $menu): RedirectResponse + { + $menu->delete(); + + return back()->with('status', 'Menu deleted. Components using its location will use their safe fallback.'); + } + + /** @return array */ + private function validated(Request $request): array + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'location' => ['required', 'string', 'max:100', 'regex:/^[a-z0-9_-]+$/'], + 'items_json' => ['nullable', 'string'], + 'enabled' => ['nullable', 'boolean'], + ]); + + try { + $items = json_decode($data['items_json'] ?: '[]', true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + throw ValidationException::withMessages(['items_json' => 'The menu items could not be saved. Please reload the editor and try again.']); + } + + if (! is_array($items)) { + throw ValidationException::withMessages(['items_json' => 'Menu items must be a list.']); + } + + return [ + 'name' => $data['name'], + 'location' => $data['location'], + 'items' => array_values($items), + 'enabled' => $request->boolean('enabled'), + ]; + } + + /** @return array */ + private function locations(): array + { + return [ + 'primary' => 'Primary navigation', + 'footer' => 'Footer — Explore', + 'footer_account' => 'Footer — Account', + 'footer_legal' => 'Footer — Legal', + 'mobile' => 'Mobile navigation override', + ]; + } +} diff --git a/Menus/Models/Menu.php b/Menus/Models/Menu.php new file mode 100644 index 0000000..0f957c6 --- /dev/null +++ b/Menus/Models/Menu.php @@ -0,0 +1,24 @@ + 'array', + 'enabled' => 'boolean', + ]; + } +} diff --git a/Menus/Providers/MenusServiceProvider.php b/Menus/Providers/MenusServiceProvider.php new file mode 100644 index 0000000..9572a1a --- /dev/null +++ b/Menus/Providers/MenusServiceProvider.php @@ -0,0 +1,20 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Menus', 'Config/config.php'), 'menus'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Menus', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Menus', 'Resources/views'), 'menus'); + } +} diff --git a/Menus/Providers/RouteServiceProvider.php b/Menus/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..2e19213 --- /dev/null +++ b/Menus/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +routes(fn () => Route::middleware('web')->group(module_path('Menus', 'Routes/web.php'))); + } +} diff --git a/Menus/Resources/views/admin/form.blade.php b/Menus/Resources/views/admin/form.blade.php new file mode 100644 index 0000000..73a9d39 --- /dev/null +++ b/Menus/Resources/views/admin/form.blade.php @@ -0,0 +1,57 @@ + + @php($initialItems = old('items_json', json_encode($record->items ?? [], JSON_UNESCAPED_SLASHES))) +
+
+

Reusable navigation

+

{{ isset($record) ? 'Edit' : 'Create' }} menu

+

Edit links once. Every header and footer assigned to this menu location updates automatically.

+
+ +
+ @csrf + @isset($record) @method('PUT') @endisset + +
+ + + +
+ +
+

Menu items

Relative paths such as /blog, anchors and full external URLs are supported.

+ + @error('items_json')

{{ $message }}

@enderror + +
+ +
This menu has no links. Add the first link above.
+
+
+ +
CancelSave menu
+
+
+ + @push('scripts') + + @endpush +
diff --git a/Menus/Resources/views/admin/index.blade.php b/Menus/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..db7b2a4 --- /dev/null +++ b/Menus/Resources/views/admin/index.blade.php @@ -0,0 +1,14 @@ + +
+

Single source of navigation

Menus

Reusable menus update every header and footer that references their location.

+ @if(session('status'))
{{ session('status') }}
@endif +
+ @forelse($records as $record) +
{{ $record->location }}

{{ $record->name }}

{{ count($record->items ?? []) }} links · {{ $record->enabled ? 'Enabled' : 'Disabled' }}

Edit menu
@csrf @method('DELETE')
+ @empty +

No menus yet

Run the Saasland installer or create your first reusable menu.

+ @endforelse +
+ {{ $records->links() }} +
+
diff --git a/Menus/Routes/web.php b/Menus/Routes/web.php new file mode 100644 index 0000000..f76c43f --- /dev/null +++ b/Menus/Routes/web.php @@ -0,0 +1,13 @@ +prefix('administration/menus')->name('administration.menus.')->group(function (): void { + Route::get('/', [MenuController::class, 'index'])->name('index'); + Route::get('/create', [MenuController::class, 'create'])->name('create'); + Route::post('/', [MenuController::class, 'store'])->name('store'); + Route::get('/{menu}/edit', [MenuController::class, 'edit'])->name('edit'); + Route::match(['put', 'patch'], '/{menu}', [MenuController::class, 'update'])->name('update'); + Route::delete('/{menu}', [MenuController::class, 'destroy'])->name('destroy'); +}); diff --git a/Menus/Support/ModuleInfo.php b/Menus/Support/ModuleInfo.php new file mode 100644 index 0000000..e96b94b --- /dev/null +++ b/Menus/Support/ModuleInfo.php @@ -0,0 +1,16 @@ +create(['name' => 'Primary', 'location' => 'primary', 'enabled' => true, 'items' => [['enabled' => true, 'label' => 'Home', 'url' => '/']]]); + expect(app(\Modules\Stylist\Support\FrontendNavigation::class)->primary())->toHaveCount(1)->and(app(\Modules\Stylist\Support\FrontendNavigation::class)->primary()[0]['label'])->toBe('Home'); +}); diff --git a/Menus/Tests/Feature/ModuleBootTest.php b/Menus/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..516d963 --- /dev/null +++ b/Menus/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,7 @@ +toBeTrue(); +}); diff --git a/Menus/module.json b/Menus/module.json new file mode 100644 index 0000000..6474504 --- /dev/null +++ b/Menus/module.json @@ -0,0 +1,20 @@ +{ + "name": "Menus", + "alias": "menus", + "version": "1.0.0", + "description": "Menus CMS module.", + "keywords": [ + "cms", + "menus" + ], + "priority": 22, + "providers": [ + "Modules\\Menus\\Providers\\MenusServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [ + "Core", + "Administration" + ] +} diff --git a/Modules/Licensing/Config/.gitkeep b/Modules/Licensing/Config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Licensing/Config/config.php b/Modules/Licensing/Config/config.php new file mode 100644 index 0000000..3b94eda --- /dev/null +++ b/Modules/Licensing/Config/config.php @@ -0,0 +1,8 @@ + 'Licensing', + 'features' => + [ + ], +]; diff --git a/Modules/Licensing/Console/.gitkeep b/Modules/Licensing/Console/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Licensing/Console/SonarDeskLicenseDeliver.php b/Modules/Licensing/Console/SonarDeskLicenseDeliver.php new file mode 100644 index 0000000..67e5050 --- /dev/null +++ b/Modules/Licensing/Console/SonarDeskLicenseDeliver.php @@ -0,0 +1,20 @@ +option('limit'), 100)); + $count = $delivery->deliverPending($limit); + $this->info("Delivered {$count} licence email(s)."); + return self::SUCCESS; + } +} diff --git a/Modules/Licensing/Console/SonarDeskLicenseIssue.php b/Modules/Licensing/Console/SonarDeskLicenseIssue.php new file mode 100755 index 0000000..12469cb --- /dev/null +++ b/Modules/Licensing/Console/SonarDeskLicenseIssue.php @@ -0,0 +1,29 @@ +argument('product'))->firstOrFail(); + [$l,$key] = $issuer->issue($p, $this->option('name') ?: null, $this->option('email') ?: null); + if (! $key) { + $this->warn('Licence already existed; plaintext key is intentionally not recoverable.'); + + return self::FAILURE; + } $this->line('Licence ID: '.$l->id); + $this->line('Licence key: '.$key); + $this->warn('This is the only time the plaintext key is shown.'); + + return self::SUCCESS; + } +} diff --git a/Modules/Licensing/Console/SonarDeskLicenseRevoke.php b/Modules/Licensing/Console/SonarDeskLicenseRevoke.php new file mode 100755 index 0000000..ad34c16 --- /dev/null +++ b/Modules/Licensing/Console/SonarDeskLicenseRevoke.php @@ -0,0 +1,22 @@ +argument('id')); + $l->update(['revoked_at' => now(), 'revoke_reason' => $this->option('reason')]); + $this->info('Licence revoked.'); + + return self::SUCCESS; + } +} diff --git a/Modules/Licensing/Console/SonarDeskProductCreate.php b/Modules/Licensing/Console/SonarDeskProductCreate.php new file mode 100755 index 0000000..8622a85 --- /dev/null +++ b/Modules/Licensing/Console/SonarDeskProductCreate.php @@ -0,0 +1,21 @@ + $this->argument('code')], ['name' => $this->option('name'), 'edition' => $this->option('edition'), 'activation_limit' => (int) $this->option('activations'), 'max_major_version' => $this->option('max-major') !== '' ? (int) $this->option('max-major') : null, 'payment_provider' => $this->option('provider') ?: null, 'external_product_id' => $this->option('product-id') ?: null, 'external_price_id' => $this->option('price-id') ?: null, 'active' => true]); + $this->info("Product {$p->code} ready."); + + return self::SUCCESS; + } +} diff --git a/Modules/Licensing/Database/Migrations/.gitkeep b/Modules/Licensing/Database/Migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Licensing/Database/Migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php b/Modules/Licensing/Database/Migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php new file mode 100755 index 0000000..babc7a3 --- /dev/null +++ b/Modules/Licensing/Database/Migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php @@ -0,0 +1,92 @@ +id(); + $table->string('code')->unique(); + $table->string('name'); + $table->string('edition')->default('Professional'); + $table->unsignedInteger('activation_limit')->default(1); + $table->unsignedInteger('max_major_version')->nullable(); + $table->string('payment_provider')->nullable(); + $table->string('external_product_id')->nullable(); + $table->string('external_price_id')->nullable(); + $table->boolean('active')->default(true); + $table->timestamps(); + $table->unique(['payment_provider', 'external_price_id']); + }); + + Schema::create('licenses', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->foreignId('license_product_id')->constrained('license_products'); + $table->string('license_key_hash', 64)->unique(); + $table->string('license_key_last4', 4); + $table->string('customer_name')->nullable(); + $table->string('customer_email')->nullable()->index(); + $table->string('edition'); + $table->unsignedInteger('activation_limit'); + $table->unsignedInteger('max_major_version')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->string('revoke_reason')->nullable(); + $table->string('payment_provider')->nullable(); + $table->string('external_order_id')->nullable(); + $table->string('external_customer_id')->nullable(); + $table->timestamps(); + $table->unique(['payment_provider', 'external_order_id']); + }); + + Schema::create('license_delivery_outbox', function (Blueprint $table) { + $table->id(); + $table->uuid('license_id')->unique(); + $table->text('encrypted_license_key'); + $table->string('recipient_email')->nullable(); + $table->timestamp('delivered_at')->nullable(); + $table->timestamps(); + $table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete(); + }); + + Schema::create('license_activations', function (Blueprint $table) { + $table->id(); + $table->uuid('license_id'); + $table->uuid('installation_id'); + $table->string('platform', 32)->nullable(); + $table->string('app_version', 32)->nullable(); + $table->timestamp('activated_at'); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamp('deactivated_at')->nullable(); + $table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete(); + $table->unique(['license_id', 'installation_id']); + $table->index(['license_id', 'deactivated_at']); + }); + + Schema::create('license_webhook_events', function (Blueprint $table) { + $table->id(); + $table->string('provider'); + $table->string('external_event_id'); + $table->string('event_type'); + $table->string('payload_sha256', 64); + $table->json('payload'); + $table->timestamp('processed_at')->nullable(); + $table->text('processing_error')->nullable(); + $table->timestamps(); + $table->unique(['provider', 'external_event_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('license_webhook_events'); + Schema::dropIfExists('license_activations'); + Schema::dropIfExists('license_delivery_outbox'); + Schema::dropIfExists('licenses'); + Schema::dropIfExists('license_products'); + } +}; diff --git a/Modules/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php b/Modules/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php new file mode 100755 index 0000000..b302e12 --- /dev/null +++ b/Modules/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php @@ -0,0 +1,30 @@ + $table->boolean('is_admin')->default(false)->index()); + } + + if (Schema::hasTable('license_activations') && ! Schema::hasColumn('license_activations', 'installation_name')) { + Schema::table('license_activations', fn (Blueprint $table) => $table->string('installation_name')->nullable()->after('installation_id')); + } + } + + public function down(): void + { + if (Schema::hasTable('license_activations') && Schema::hasColumn('license_activations', 'installation_name')) { + Schema::table('license_activations', fn (Blueprint $table) => $table->dropColumn('installation_name')); + } + + if (Schema::hasTable('users') && Schema::hasColumn('users', 'is_admin')) { + Schema::table('users', fn (Blueprint $table) => $table->dropColumn('is_admin')); + } + } +}; diff --git a/Modules/Licensing/Database/Seeders/.gitkeep b/Modules/Licensing/Database/Seeders/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Licensing/Database/Seeders/LicensingDatabaseSeeder.php b/Modules/Licensing/Database/Seeders/LicensingDatabaseSeeder.php new file mode 100644 index 0000000..fbb0b58 --- /dev/null +++ b/Modules/Licensing/Database/Seeders/LicensingDatabaseSeeder.php @@ -0,0 +1,21 @@ +call("OthersTableSeeder"); + } +} diff --git a/Modules/Licensing/Database/factories/.gitkeep b/Modules/Licensing/Database/factories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Licensing/Helpers/.gitkeep b/Modules/Licensing/Helpers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Licensing/Helpers/Helper.php b/Modules/Licensing/Helpers/Helper.php new file mode 100644 index 0000000..7607c60 --- /dev/null +++ b/Modules/Licensing/Helpers/Helper.php @@ -0,0 +1,12 @@ + License::query()->count(), + 'activeCount' => License::query()->whereNull('revoked_at')->where(function ($q): void { + $q->whereNull('expires_at')->orWhere('expires_at', '>', now()); + })->count(), + 'productCount' => LicenseProduct::query()->count(), + 'recentLicenses' => License::query()->with('product')->latest()->limit(10)->get(), + ]); + } + + public function licenses(): View + { + return view('licensing::admin.licenses', [ + 'licenses' => License::query()->with('product')->latest()->paginate(25), + ]); + } + + public function show(License $license): View + { + return view('licensing::admin.show', [ + 'license' => $license->load(['product', 'activations']), + ]); + } + + /** Read-only snapshot of existing licensing subscription metadata. */ + public function subscriptions(): View + { + $subscriptions = License::query() + ->whereNotNull('external_subscription_id') + ->with('product') + ->orderByDesc('created_at') + ->paginate(25); + + $total = License::query()->whereNotNull('external_subscription_id')->count(); + $active = License::query()->whereNotNull('external_subscription_id') + ->whereIn('subscription_status', ['active', 'trialing'])->count(); + $cancelScheduled = License::query()->whereNotNull('external_subscription_id') + ->whereNotNull('cancel_scheduled_at')->count(); + + return view('licensing::admin.subscriptions', compact('subscriptions', 'total', 'active', 'cancelScheduled')); + } + +} diff --git a/Modules/Licensing/Http/Controllers/Api/LicenseActivationController.php b/Modules/Licensing/Http/Controllers/Api/LicenseActivationController.php new file mode 100755 index 0000000..7580871 --- /dev/null +++ b/Modules/Licensing/Http/Controllers/Api/LicenseActivationController.php @@ -0,0 +1,164 @@ +validate([ + 'licenseKey' => ['required', 'string', 'max:128'], + 'installationID' => ['required', 'uuid'], + 'appVersion' => ['required', 'string', 'max:32'], + 'platform' => ['required', 'string', 'max:32'], + ]); + + return DB::transaction(function () use ($data, $signer) { + $license = License::where('license_key_hash', LicenseKey::hash($data['licenseKey'])) + ->lockForUpdate() + ->first(); + + if (! $license) { + throw ValidationException::withMessages(['licenseKey' => 'The licence key is not valid.']); + } + + if (! $license->is_usable) { + return response()->json(['message' => 'This licence is expired or has been revoked.'], 403); + } + + $installationID = strtolower($data['installationID']); + $activation = LicenseActivation::where('license_id', $license->id) + ->where('installation_id', $installationID) + ->first(); + + if ($activation && $activation->deactivated_at === null) { + $activation->update([ + 'last_seen_at' => now(), + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + ]); + } else { + $activeCount = LicenseActivation::where('license_id', $license->id) + ->whereNull('deactivated_at') + ->count(); + + if ($activeCount >= $license->activation_limit) { + return response()->json(['message' => 'This licence has reached its activation limit. Deactivate another Mac first.'], 409); + } + + if ($activation) { + $activation->update([ + 'deactivated_at' => null, + 'activated_at' => now(), + 'last_seen_at' => now(), + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + ]); + } else { + LicenseActivation::create([ + 'license_id' => $license->id, + 'installation_id' => $installationID, + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + 'activated_at' => now(), + 'last_seen_at' => now(), + ]); + } + } + + return response()->json([ + 'licenseToken' => $signer->sign($license, $data['installationID']), + ]); + }); + } + + public function validateInstallation( + Request $request, + LicenseTokenVerifier $verifier, + LicenseSigner $signer, + ): JsonResponse { + $data = $request->validate([ + 'licenseToken' => ['required', 'string', 'max:8192'], + 'installationID' => ['required', 'uuid'], + 'appVersion' => ['required', 'string', 'max:32'], + 'platform' => ['required', 'string', 'max:32'], + ]); + + try { + $payload = $verifier->verify($data['licenseToken']); + } catch (\Throwable) { + return response()->json(['message' => 'The licence token is invalid.'], 400); + } + + if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) { + return response()->json(['message' => 'The installation does not match this licence token.'], 400); + } + + return DB::transaction(function () use ($data, $payload, $signer) { + $license = License::whereKey((string) ($payload['licenseID'] ?? '')) + ->lockForUpdate() + ->first(); + + if (! $license || ! $license->is_usable) { + return response()->json(['message' => 'This licence is expired or has been revoked.'], 403); + } + + $activation = LicenseActivation::where('license_id', $license->id) + ->where('installation_id', strtolower($data['installationID'])) + ->first(); + + if (! $activation || $activation->deactivated_at !== null) { + return response()->json(['message' => 'This Mac has been deactivated from the licence portal.'], 403); + } + + $activation->update([ + 'last_seen_at' => now(), + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + ]); + + return response()->json([ + 'active' => true, + // Refreshing the signed token also applies subscription edition/expiry changes + // without requiring the user to reactivate manually. + 'licenseToken' => $signer->sign($license, $data['installationID']), + ]); + }); + } + + public function deactivate(Request $request, LicenseTokenVerifier $verifier): JsonResponse + { + $data = $request->validate([ + 'licenseToken' => ['required', 'string', 'max:8192'], + 'installationID' => ['required', 'uuid'], + ]); + + try { + $payload = $verifier->verify($data['licenseToken']); + } catch (\Throwable) { + return response()->json(['message' => 'The licence token is invalid.'], 400); + } + + if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) { + return response()->json(['message' => 'The installation does not match this licence token.'], 400); + } + + LicenseActivation::where('license_id', (string) ($payload['licenseID'] ?? '')) + ->where('installation_id', strtolower($data['installationID'])) + ->whereNull('deactivated_at') + ->update(['deactivated_at' => now(), 'last_seen_at' => now()]); + + return response()->json((object) []); + } +} diff --git a/Modules/Licensing/Http/Controllers/Api/PaymentWebhookController.php b/Modules/Licensing/Http/Controllers/Api/PaymentWebhookController.php new file mode 100755 index 0000000..61d9512 --- /dev/null +++ b/Modules/Licensing/Http/Controllers/Api/PaymentWebhookController.php @@ -0,0 +1,191 @@ +getContent(); + + $verifier = match ($provider) { + 'paddle' => app(PaddleWebhookVerifier::class), + 'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class), + default => null, + }; + + if (! $verifier) { + return response()->json([ + 'message' => 'Unknown webhook provider.', + ], 404); + } + + try { + $verifier->verify($request, $raw); + } catch (Throwable $e) { + report($e); + + return response()->json([ + 'message' => 'Invalid webhook signature.', + ], 400); + } + + try { + $payload = json_decode( + $raw, + true, + flags: JSON_THROW_ON_ERROR + ); + } catch (Throwable) { + return response()->json([ + 'message' => 'Invalid JSON.', + ], 400); + } + + $externalID = (string) ( + $provider === 'paddle' + ? ($payload['event_id'] ?? '') + : ($payload['meta']['event_name'] ?? '') + .':' + .($payload['data']['id'] ?? '') + ); + + $eventType = (string) ( + $provider === 'paddle' + ? ($payload['event_type'] ?? 'unknown') + : ($payload['meta']['event_name'] ?? 'unknown') + ); + + if ($externalID === '') { + return response()->json([ + 'message' => 'Webhook event has no stable identifier.', + ], 400); + } + + $payloadHash = hash('sha256', $raw); + + try { + /* + * Paddle can deliver several related events concurrently. + * + * Laravel retries this complete transaction when MySQL reports + * a deadlock. Five attempts gives short-lived lock contention + * enough opportunity to resolve without acknowledging an event + * that was not actually processed. + */ + DB::transaction( + function () use ( + $provider, + $externalID, + $eventType, + $payload, + $payloadHash, + $provisioner + ): void { + /* + * Insert the event if this is its first delivery. + * + * Once present, lock the row so duplicate deliveries of + * the same event cannot provision concurrently. + */ + LicenseWebhookEvent::firstOrCreate( + [ + 'provider' => $provider, + 'external_event_id' => $externalID, + ], + [ + 'event_type' => $eventType, + 'payload_sha256' => $payloadHash, + 'payload' => $payload, + ] + ); + + $event = LicenseWebhookEvent::query() + ->where('provider', $provider) + ->where('external_event_id', $externalID) + ->lockForUpdate() + ->firstOrFail(); + + if ($event->processed_at) { + return; + } + + if (! hash_equals( + (string) $event->payload_sha256, + $payloadHash + )) { + throw new RuntimeException( + 'Webhook ID was reused with a different payload.' + ); + } + + try { + $provisioner->provision( + $provider, + $payload + ); + + $event->processed_at = now(); + $event->processing_error = null; + $event->save(); + } catch (Throwable $e) { + /* + * This assignment is useful when the surrounding + * transaction eventually succeeds. If processing + * throws, the transaction itself is rolled back and + * Paddle will retry the event. + */ + $event->processing_error = mb_substr( + $e->getMessage(), + 0, + 4000 + ); + + $event->save(); + + throw $e; + } + }, + 5 + ); + } catch (Throwable $e) { + report($e); + return response()->json([ + 'message' => 'Webhook could not be processed.', + ], 500); + } + + /* + * Delivery deliberately happens outside the webhook transaction. + * + * A temporary SMTP failure must not undo a successfully processed + * payment event. The outbox retains anything that still needs + * delivery. + */ + try { + $delivery->deliverPending(10); + } catch (Throwable $e) { + report($e); + } + + return response()->json([ + 'ok' => true, + ]); + } +} diff --git a/Modules/Licensing/Http/Controllers/LicensingController.php b/Modules/Licensing/Http/Controllers/LicensingController.php new file mode 100644 index 0000000..8ece907 --- /dev/null +++ b/Modules/Licensing/Http/Controllers/LicensingController.php @@ -0,0 +1,79 @@ +whereRaw('lower(customer_email) = ?', [strtolower($request->user()->email)]); + } + + public function index(Request $request) + { + $licenses = $this->owned($request)->latest()->get(); + $hasProfessional = $licenses->contains(fn (License $license) => $license->is_usable && $license->edition === 'Professional'); + + return view('portal.index', compact('licenses', 'hasProfessional')); + } + + public function show(Request $request, License $license) + { + $this->authorise($request, $license); + $license->load(['product', 'activations']); + + return view('portal.show', compact('license')); + } + + public function upgrade(Request $request, License $license): RedirectResponse + { + $this->authorise($request, $license); + abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422); + abort_unless($license->edition === 'Personal' && $license->is_usable, 422); + + $targetPriceID = $this->professionalPriceFor($license); + $targetProduct = LicenseProduct::query() + ->where('payment_provider', 'paddle') + ->where('external_price_id', $targetPriceID) + ->where('active', true) + ->firstOrFail(); + + try { + $subscription = $this->paddle->updateSubscriptionPrice($license->external_subscription_id, $targetPriceID); + } catch (RuntimeException $exception) { + return back()->withErrors(['licence' => $exception->getMessage()]); + } + + // Apply the entitlement immediately. Paddle webhooks remain authoritative and + // will reconcile subscription status/current-period details afterwards. + $license->update([ + 'license_product_id' => $targetProduct->id, + 'edition' => $targetProduct->edition, + 'activation_limit' => $targetProduct->activation_limit, + 'max_major_version' => $targetProduct->max_major_version, + 'external_price_id' => $targetPriceID, + 'subscription_status' => (string) ($subscription['status'] ?? $license->subscription_status), + 'revoked_at' => null, + 'revoke_reason' => null, + ]); + + return redirect()->route('portal.show', $license)->with('status', 'Upgraded to Professional. Your existing Paddle subscription was changed; no second subscription was created.'); + } + + public function cancel(Request $request, License $license): RedirectResponse + { + $this->authorise($request, $license); + abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422); + abort_unless(! in_array($license->subscription_status, ['canceled'], true), 422); + + try { + $this->paddle->cancelSubscriptionImmediately($license->external_subscription_id); + } catch (RuntimeException $exception) { + return back()->withErrors(['licence' => $exception->getMessage()]); + } + + $license->update([ + 'subscription_status' => 'canceled', + 'expires_at' => now(), + 'revoked_at' => now(), + 'revoke_reason' => 'Subscription canceled from licence portal', + 'cancel_scheduled_at' => null, + ]); + + return redirect()->route('portal.index')->with('status', 'Subscription canceled in Paddle and the licence is no longer active.'); + } + + public function deactivate(Request $request, License $license, int $activation) + { + $this->authorise($request, $license); + $item = $license->activations()->whereKey($activation)->firstOrFail(); + if (! $item->deactivated_at) { + $item->update(['deactivated_at' => now()]); + } + + return back()->with('status', 'Mac deactivated. SonarDesk will detect this through the licensing API.'); + } + + public function resend(Request $request, License $license) + { + $this->authorise($request, $license); + $out = LicenseDeliveryOutbox::where('license_id', $license->id)->first(); + if (! $out) { + return back()->withErrors(['licence' => 'This licence was issued manually and its full key is not recoverable. Contact support.']); + } + + $out->update([ + 'recipient_email' => strtolower($request->user()->email), + 'delivered_at' => null, + ]); + $this->delivery->deliverPending(1); + + return back()->with('status', 'Licence key sent to your verified email address.'); + } + + private function authorise(Request $request, License $license): void + { + abort_unless(strtolower((string) $license->customer_email) === strtolower($request->user()->email), 404); + } + + private function professionalPriceFor(License $license): string + { + $paddle = config('paddle'); + $current = (string) $license->external_price_id; + + if ($current === (string) ($paddle['personal_annual_price_id'] ?? '')) { + return (string) ($paddle['professional_annual_price_id'] ?? ''); + } + + return (string) ($paddle['professional_monthly_price_id'] ?? ''); + } +} diff --git a/Modules/Licensing/Http/Middleware/.gitkeep b/Modules/Licensing/Http/Middleware/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Licensing/Http/Requests/.gitkeep b/Modules/Licensing/Http/Requests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Licensing/Models/.gitkeep b/Modules/Licensing/Models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Licensing/Models/License.php b/Modules/Licensing/Models/License.php new file mode 100755 index 0000000..86e5c6a --- /dev/null +++ b/Modules/Licensing/Models/License.php @@ -0,0 +1,36 @@ + 'datetime', 'revoked_at' => 'datetime', 'activation_limit' => 'integer', 'max_major_version' => 'integer', 'current_period_ends_at' => 'datetime', 'cancel_scheduled_at' => 'datetime']; + + public function product(): BelongsTo + { + return $this->belongsTo(LicenseProduct::class, 'license_product_id'); + } + + public function activations(): HasMany + { + return $this->hasMany(LicenseActivation::class); + } + + public function getIsUsableAttribute(): bool + { + return $this->revoked_at === null && ($this->expires_at === null || $this->expires_at->isFuture()); + } +} diff --git a/Modules/Licensing/Models/LicenseActivation.php b/Modules/Licensing/Models/LicenseActivation.php new file mode 100755 index 0000000..fb454a0 --- /dev/null +++ b/Modules/Licensing/Models/LicenseActivation.php @@ -0,0 +1,14 @@ + 'datetime', 'last_seen_at' => 'datetime', 'deactivated_at' => 'datetime']; +} diff --git a/Modules/Licensing/Models/LicenseDeliveryOutbox.php b/Modules/Licensing/Models/LicenseDeliveryOutbox.php new file mode 100755 index 0000000..89d1d5f --- /dev/null +++ b/Modules/Licensing/Models/LicenseDeliveryOutbox.php @@ -0,0 +1,20 @@ + 'datetime']; + + public function license(): BelongsTo + { + return $this->belongsTo(License::class); + } +} diff --git a/Modules/Licensing/Models/LicenseProduct.php b/Modules/Licensing/Models/LicenseProduct.php new file mode 100755 index 0000000..eb8a4cc --- /dev/null +++ b/Modules/Licensing/Models/LicenseProduct.php @@ -0,0 +1,12 @@ + 'boolean', 'activation_limit' => 'integer', 'max_major_version' => 'integer']; +} diff --git a/Modules/Licensing/Models/LicenseWebhookEvent.php b/Modules/Licensing/Models/LicenseWebhookEvent.php new file mode 100755 index 0000000..a8cc130 --- /dev/null +++ b/Modules/Licensing/Models/LicenseWebhookEvent.php @@ -0,0 +1,12 @@ + 'array', 'processed_at' => 'datetime']; +} diff --git a/Modules/Licensing/Providers/.gitkeep b/Modules/Licensing/Providers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php b/Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php new file mode 100644 index 0000000..65e02df --- /dev/null +++ b/Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php @@ -0,0 +1,35 @@ + [ + Limit::perMinute(12)->by($request->ip()), + Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))), + ]); + + $this->loadMigrationsFrom(__DIR__.'/../Database/Migrations'); + $this->loadViewsFrom(__DIR__.'/../Resources/views', 'licensing'); + $this->mergeConfigFrom(__DIR__.'/../Config/config.php', 'licensing'); + + // Explicit role-based authorization; deny access by default. + Gate::define('manage-sonardesk-licensing', static fn (User $user): bool => + ($user->hasPermissionTo('manage-sonardesk-licensing', 'web') || $user->hasAnyRole(['Super Admin', 'super-admin', 'Administrator'])) + ); + + Route::prefix('api')->middleware('api')->group(__DIR__.'/../Routes/api.php'); + Route::group([], __DIR__.'/../Routes/admin.php'); + } +} diff --git a/Modules/Licensing/Providers/LicensingServiceProvider.php b/Modules/Licensing/Providers/LicensingServiceProvider.php new file mode 100644 index 0000000..c514a59 --- /dev/null +++ b/Modules/Licensing/Providers/LicensingServiceProvider.php @@ -0,0 +1,171 @@ + Limit::perMinute(5) + ->by(strtolower((string) $request->input('email')).'|'.$request->ip())); + + RateLimiter::for('registration', fn (Request $request) => + Limit::perHour(5)->by($request->ip()) + ); + + RateLimiter::for('license-activation', function (Request $request): array { + return [ + Limit::perMinute(12)->by($request->ip()), + Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))), + ]; + }); + $this->registerTranslations(); + $this->registerConfig(); + $this->registerViews(); + $this->registerHelpers(); + $this->registerCommands(); + $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations')); + } + + /** + * Register the service provider. + * + * @return void + */ + public function register(): void + { + $this->app->register(RouteServiceProvider::class); + } + + /** + * Register the helpers. + * + * @return void + */ + protected function registerHelpers(): void + { + foreach (glob(__DIR__.'/../Helpers/*.php') as $filename) { + require_once $filename; + } + } + + /** + * Dynamically Register Commands. + * + * @return void + */ + public function registerCommands(): void + { + if (!$this->app->runningInConsole()) { + return; + } + $commandPath = __DIR__ . '/../Console'; + $commands = collect(File::allFiles($commandPath)) + ->map(function ($file) { + $class = 'Modules\\' . $this->moduleName . '\\Console\\' . $file->getFilenameWithoutExtension(); + return $class; + }) + ->filter(function ($class) { + return class_exists($class); + }) + ->values() + ->all(); + $this->commands($commands); + } + + /** + * Register config. + * + * @return void + */ + protected function registerConfig(): void + { + $this->publishes([ + module_path(ModuleInfo::name(), 'Config/config.php') => config_path( ModuleInfo::nameLower(). '.php'), + ], 'config'); + $this->mergeConfigFrom( + module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower() + ); + } + + /** + * Register views. + * + * @return void + */ + public function registerViews(): void + { + $viewPath = resource_path('views/modules/' . ModuleInfo::nameLower()); + $sourcePath = module_path(ModuleInfo::name(), 'Resources/views'); + $this->publishes([ + $sourcePath => $viewPath + ], ['views', ModuleInfo::nameLower() . '-module-views']); + $this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), ModuleInfo::nameLower()); + } + + /** + * Register translations. + * + * @return void + */ + public function registerTranslations(): void + { + $langPath = resource_path('lang/modules/' . ModuleInfo::nameLower()); + + if (is_dir($langPath)) { + $this->loadTranslationsFrom($langPath, ModuleInfo::nameLower()); + } else { + $this->loadTranslationsFrom(module_path(ModuleInfo::name(), 'Resources/lang'), ModuleInfo::nameLower()); + } + } + + /** + * Regsiter View Paths + * @return array + */ + private function getPublishableViewPaths(): array + { + $paths = []; + foreach (Config::get('view.paths') as $path) { + if (is_dir($path . '/modules/' . ModuleInfo::nameLower())) { + $paths[] = $path . '/modules/' . ModuleInfo::nameLower(); + } + } + return $paths; + } +} diff --git a/Modules/Licensing/Providers/RouteServiceProvider.php b/Modules/Licensing/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..c691ba8 --- /dev/null +++ b/Modules/Licensing/Providers/RouteServiceProvider.php @@ -0,0 +1,69 @@ +mapApiRoutes(); + + $this->mapWebRoutes(); + } + + /** + * Define the "web" routes for the application. + * + * These routes all receive session state, CSRF protection, etc. + * + * @return void + */ + protected function mapWebRoutes() + { + Route::middleware('web') + ->namespace($this->moduleNamespace) + ->group(module_path('Licensing', '/Routes/web.php')); + } + + /** + * Define the "api" routes for the application. + * + * These routes are typically stateless. + * + * @return void + */ + protected function mapApiRoutes() + { + Route::prefix('api') + ->middleware('api') + ->namespace($this->moduleNamespace) + ->group(module_path('Licensing', '/Routes/api.php')); + } +} diff --git a/Modules/Licensing/Resources/assets/.gitkeep b/Modules/Licensing/Resources/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Licensing/Resources/assets/js/app.js b/Modules/Licensing/Resources/assets/js/app.js new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Licensing/Resources/assets/sass/app.scss b/Modules/Licensing/Resources/assets/sass/app.scss new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Licensing/Resources/lang/.gitkeep b/Modules/Licensing/Resources/lang/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Licensing/Resources/views/.gitkeep b/Modules/Licensing/Resources/views/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Licensing/Resources/views/admin/index.blade.php b/Modules/Licensing/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..8c72c09 --- /dev/null +++ b/Modules/Licensing/Resources/views/admin/index.blade.php @@ -0,0 +1,3 @@ +@extends('licensing::admin.layout') +@section('title', 'Licensing Overview') +@section('content')

SonarDesk licensing

Total licences
{{ number_format($licenseCount) }}
Usable licences
{{ number_format($activeCount) }}
Products
{{ number_format($productCount) }}

Recent licences

@forelse($recentLicenses as $license)@empty@endforelse
CustomerProductEditionStatusCreated
{{ $license->customer_email ?: 'Unknown customer' }}{{ $license->product?->name ?? '—' }}{{ $license->edition }}{{ $license->is_usable ? 'Usable' : 'Inactive' }}{{ $license->created_at?->format('d M Y') }}
No licences found.

View all licences →

@endsection diff --git a/Modules/Licensing/Resources/views/admin/layout.blade.php b/Modules/Licensing/Resources/views/admin/layout.blade.php new file mode 100644 index 0000000..88a45f3 --- /dev/null +++ b/Modules/Licensing/Resources/views/admin/layout.blade.php @@ -0,0 +1,37 @@ +{{-- Shared ABSDev administration shell; no separate SonarDesk layout or navigation. --}} + +
+
+
+

ABSDev / Products

+

SonarDesk

+

Licensing administration · Read only

+
+ +
+
@yield('content')
+
+ +
diff --git a/Modules/Licensing/Resources/views/admin/licenses.blade.php b/Modules/Licensing/Resources/views/admin/licenses.blade.php new file mode 100644 index 0000000..9f3f47d --- /dev/null +++ b/Modules/Licensing/Resources/views/admin/licenses.blade.php @@ -0,0 +1,3 @@ +@extends('licensing::admin.layout') +@section('title', 'Licences') +@section('content')

Licences

@forelse($licenses as $license)@empty@endforelse
CustomerProductEditionProviderStatusCreated
{{ $license->customer_email ?: 'Unknown customer' }}{{ $license->product?->name ?? '—' }}{{ $license->edition }}{{ $license->payment_provider ?? '—' }}{{ $license->is_usable ? 'Usable' : 'Inactive' }}{{ $license->created_at?->format('d M Y') }}
No licences found.
{{ $licenses->links() }}
@endsection diff --git a/Modules/Licensing/Resources/views/admin/show.blade.php b/Modules/Licensing/Resources/views/admin/show.blade.php new file mode 100644 index 0000000..42f12b3 --- /dev/null +++ b/Modules/Licensing/Resources/views/admin/show.blade.php @@ -0,0 +1,3 @@ +@extends('licensing::admin.layout') +@section('title', 'Licence details') +@section('content')

Licence details

Customer and licence

Customer
{{ $license->customer_name ?? '—' }}
Email
{{ $license->customer_email ?? '—' }}
Product
{{ $license->product?->name ?? '—' }}
Edition
{{ $license->edition }}
Key (last four only)
•••• {{ $license->license_key_last4 }}
Status
{{ $license->is_usable ? 'Usable' : 'Inactive' }}

Subscription

Provider
{{ $license->payment_provider ?? '—' }}
Subscription status
{{ $license->subscription_status ?? '—' }}
Period ends
{{ $license->current_period_ends_at?->format('d M Y') ?? '—' }}
Expires
{{ $license->expires_at?->format('d M Y') ?? '—' }}
Activation limit
{{ $license->activation_limit }}

Device activations

@forelse($license->activations as $activation)@empty@endforelse
PlatformVersionActivatedLast seenStatus
{{ $activation->platform ?? '—' }}{{ $activation->app_version ?? '—' }}{{ $activation->activated_at?->format('d M Y H:i') ?? '—' }}{{ $activation->last_seen_at?->format('d M Y H:i') ?? '—' }}{{ $activation->deactivated_at ? 'Deactivated' : 'Active' }}
No device activations.
@endsection diff --git a/Modules/Licensing/Resources/views/admin/subscriptions.blade.php b/Modules/Licensing/Resources/views/admin/subscriptions.blade.php new file mode 100644 index 0000000..a4817f6 --- /dev/null +++ b/Modules/Licensing/Resources/views/admin/subscriptions.blade.php @@ -0,0 +1,25 @@ +@extends('licensing::admin.layout') +@section('title', 'SonarDesk Subscriptions') +@section('content') +

Subscriptions

+

Read-only subscription records stored in ABSDev. This is not a live Paddle reconciliation.

+
+
Subscription-linked licences
{{ number_format($total) }}
+
Active / trialing
{{ number_format($active) }}
+
Cancellation scheduled
{{ number_format($cancelScheduled) }}
+
+
+@forelse($subscriptions as $subscription) + + + + + + + + + +@empty@endforelse +
CustomerProductProviderStatusPeriod endsCancellationLicence
{{ $subscription->customer_email ?: '—' }}{{ $subscription->product?->name ?? '—' }}{{ $subscription->payment_provider ?: '—' }}{{ $subscription->subscription_status ?: 'Unknown' }}{{ $subscription->current_period_ends_at?->format('d M Y') ?? '—' }}{{ $subscription->cancel_scheduled_at?->format('d M Y') ?? '—' }}View licence
No subscription-linked licences in the local database.
+
{{ $subscriptions->links() }}
+@endsection diff --git a/Modules/Licensing/Resources/views/index.blade.php b/Modules/Licensing/Resources/views/index.blade.php new file mode 100644 index 0000000..4277b06 --- /dev/null +++ b/Modules/Licensing/Resources/views/index.blade.php @@ -0,0 +1,9 @@ +@extends('licensing::layouts.master') + +@section('content') +

Hello World

+ +

+ This view is loaded from module: {!! config('licensing.name') !!} +

+@endsection diff --git a/Modules/Licensing/Resources/views/layouts/master.blade.php b/Modules/Licensing/Resources/views/layouts/master.blade.php new file mode 100644 index 0000000..1e51f1c --- /dev/null +++ b/Modules/Licensing/Resources/views/layouts/master.blade.php @@ -0,0 +1,19 @@ + + + + + + + Module Licensing + + {{-- Laravel Mix - CSS File --}} + {{-- --}} + + + + @yield('content') + + {{-- Laravel Mix - JS File --}} + {{-- --}} + + diff --git a/Modules/Licensing/Routes/.gitkeep b/Modules/Licensing/Routes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Licensing/Routes/admin.php b/Modules/Licensing/Routes/admin.php new file mode 100644 index 0000000..e11b495 --- /dev/null +++ b/Modules/Licensing/Routes/admin.php @@ -0,0 +1,15 @@ +prefix('administration/sonardesk') + ->name('administration.sonardesk.') + ->group(function (): void { + Route::get('/', [LicensingDashboardController::class, 'index'])->name('index'); + Route::get('/licenses', [LicensingDashboardController::class, 'licenses'])->name('licenses'); + Route::get('/subscriptions', [LicensingDashboardController::class, 'subscriptions'])->name('subscriptions'); + Route::get('/licenses/{license}', [LicensingDashboardController::class, 'show'])->name('licenses.show'); + }); diff --git a/Modules/Licensing/Routes/api.php b/Modules/Licensing/Routes/api.php new file mode 100644 index 0000000..a923714 --- /dev/null +++ b/Modules/Licensing/Routes/api.php @@ -0,0 +1,12 @@ +group(function (): void { + Route::post('/activate', [LicenseActivationController::class, 'activate'])->middleware('throttle:license-activation'); + Route::post('/validate', [LicenseActivationController::class, 'validateInstallation'])->middleware('throttle:license-activation'); + Route::post('/deactivate', [LicenseActivationController::class, 'deactivate'])->middleware('throttle:license-activation'); + Route::post('/webhooks/{provider}', PaymentWebhookController::class)->whereIn('provider', ['paddle', 'lemon-squeezy']); +}); diff --git a/Modules/Licensing/Routes/web.php b/Modules/Licensing/Routes/web.php new file mode 100644 index 0000000..cfe4d88 --- /dev/null +++ b/Modules/Licensing/Routes/web.php @@ -0,0 +1,13 @@ +prefix('portal')->name('portal.')->group(function (): void { + Route::get('/', [PortalController::class, 'index'])->name('index'); + Route::get('/licences/{license}', [PortalController::class, 'show'])->name('show'); + Route::post('/licences/{license}/upgrade', [PortalController::class, 'upgrade'])->middleware('throttle:5,1')->name('upgrade'); + Route::delete('/licences/{license}/subscription', [PortalController::class, 'cancel'])->middleware('throttle:5,1')->name('cancel'); + Route::post('/licences/{license}/resend', [PortalController::class, 'resend'])->middleware('throttle:3,10')->name('resend'); + Route::delete('/licences/{license}/activations/{activation}', [PortalController::class, 'deactivate'])->name('deactivate'); +}); diff --git a/Modules/Licensing/Services/LicenseDeliveryService.php b/Modules/Licensing/Services/LicenseDeliveryService.php new file mode 100755 index 0000000..053600d --- /dev/null +++ b/Modules/Licensing/Services/LicenseDeliveryService.php @@ -0,0 +1,54 @@ +with('license') + ->whereNull('delivered_at') + ->whereNotNull('recipient_email') + ->where('recipient_email', '!=', '') + ->oldest() + ->limit(max(1, min($limit, 100))) + ->get(); + + foreach ($pending as $outbox) { + $license = $outbox->license; + if (! $license) { + continue; + } + + $plain = Crypt::decryptString($outbox->encrypted_license_key); + $recipient = strtolower(trim((string) $outbox->recipient_email)); + if ($recipient === '') { + continue; + } + + $data = [ + 'customerName' => $license->customer_name, + 'edition' => $license->edition, + 'licenseKey' => $plain, + 'expiresAt' => $license->expires_at, + 'portalUrl' => config('sonardesk.portal_url') ?: url('/portal'), + 'downloadUrl' => route('release'), + ]; + + Mail::send(['html' => 'emails.license-key', 'text' => 'emails.license-key-text'], $data, function ($message) use ($recipient): void { + $message->to($recipient)->subject('Your SonarDesk licence key'); + }); + + $outbox->update(['delivered_at' => now()]); + $delivered++; + } + + return $delivered; + } +} diff --git a/Modules/Licensing/Services/LicenseIssuer.php b/Modules/Licensing/Services/LicenseIssuer.php new file mode 100755 index 0000000..7171dd8 --- /dev/null +++ b/Modules/Licensing/Services/LicenseIssuer.php @@ -0,0 +1,46 @@ +where('external_order_id', $orderID)->first(); + if ($existing) { + return [$existing, null]; + } + } + $plain = LicenseKey::generate(); + $license = License::create([ + 'license_product_id' => $product->id, + 'license_key_hash' => LicenseKey::hash($plain), + 'license_key_last4' => substr(LicenseKey::normalize($plain), -4), + 'customer_name' => $name, + 'customer_email' => $email ? strtolower(trim($email)) : null, + 'edition' => $product->edition, + 'activation_limit' => $product->activation_limit, + 'max_major_version' => $product->max_major_version, + 'payment_provider' => $provider, + 'external_order_id' => $orderID, + 'external_customer_id' => $customerID, + ]); + if ($queueDelivery) { + LicenseDeliveryOutbox::updateOrCreate( + ['license_id' => $license->id], + ['encrypted_license_key' => Crypt::encryptString($plain), 'recipient_email' => $license->customer_email, 'delivered_at' => null] + ); + } + + return [$license, $plain]; + }); + } +} diff --git a/Modules/Licensing/Services/LicenseKey.php b/Modules/Licensing/Services/LicenseKey.php new file mode 100755 index 0000000..4e2501f --- /dev/null +++ b/Modules/Licensing/Services/LicenseKey.php @@ -0,0 +1,27 @@ +privateSeed(); + $pair = sodium_crypto_sign_seed_keypair($seed); + $secret = sodium_crypto_sign_secretkey($pair); + + $payload = [ + 'customerEmail' => $license->customer_email, + 'customerName' => $license->customer_name, + 'edition' => $license->edition, + 'expiresAt' => $license->expires_at?->utc()->format('Y-m-d\\TH:i:s\\Z'), + 'installationID' => strtolower($installationID), + 'issuedAt' => ($issuedAt ?? now())->utc()->format('Y-m-d\\TH:i:s\\Z'), + 'licenseID' => strtolower((string) $license->id), + 'maxMajorVersion' => $license->max_major_version, + ]; + ksort($payload, SORT_STRING); + $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + $sig = sodium_crypto_sign_detached($json, $secret); + + return 'RS3.'.self::b64url($json).'.'.self::b64url($sig); + } + + public function publicKeyBase64(): string + { + $pair = sodium_crypto_sign_seed_keypair($this->privateSeed()); + + return base64_encode(sodium_crypto_sign_publickey($pair)); + } + + private function privateSeed(): string + { + $path = config('sonardesk.private_key_path'); + if (! $path || ! is_file($path)) { + throw new RuntimeException('SONARDESK_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.'); + } + $decoded = base64_decode(trim((string) file_get_contents($path)), true); + if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) { + throw new RuntimeException('The SonarDesk private key must be a Base64-encoded 32-byte Ed25519 seed.'); + } + + return $decoded; + } + + private static function b64url(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } +} diff --git a/Modules/Licensing/Services/LicenseTokenVerifier.php b/Modules/Licensing/Services/LicenseTokenVerifier.php new file mode 100755 index 0000000..052f633 --- /dev/null +++ b/Modules/Licensing/Services/LicenseTokenVerifier.php @@ -0,0 +1,58 @@ +decode($parts[1]); + $signature = $this->decode($parts[2]); + // Derive the verification key from the same private seed used by LicenseSigner. + // Keeping a separate SONARDESK_LICENSE_PUBLIC_KEY allowed the two keys to drift: + // activation would sign a perfectly valid token with the new private key, then + // /validate would reject that same token using a stale public key. + $public = $this->publicKeyFromPrivateSeed(); + if (! sodium_crypto_sign_verify_detached($signature, $payload, $public)) { + throw new RuntimeException('Licence token signature is invalid.'); + } + $decoded = json_decode($payload, true, flags: JSON_THROW_ON_ERROR); + + return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.'); + } + + private function publicKeyFromPrivateSeed(): string + { + $path = config('sonardesk.private_key_path'); + if (! $path || ! is_file($path)) { + throw new RuntimeException('SONARDESK_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.'); + } + + $seed = base64_decode(trim((string) file_get_contents($path)), true); + if ($seed === false || strlen($seed) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) { + throw new RuntimeException('The SonarDesk private key must be a Base64-encoded 32-byte Ed25519 seed.'); + } + + $pair = sodium_crypto_sign_seed_keypair($seed); + + return sodium_crypto_sign_publickey($pair); + } + + private function decode(string $value): string + { + $value = strtr($value, '-_', '+/'); + $value .= str_repeat('=', (4 - strlen($value) % 4) % 4); + $out = base64_decode($value, true); + if ($out === false) { + throw new RuntimeException('Invalid Base64URL value.'); + } + + return $out; + } +} diff --git a/Modules/Licensing/Support/.gitkeep b/Modules/Licensing/Support/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Licensing/Support/ModuleInfo.php b/Modules/Licensing/Support/ModuleInfo.php new file mode 100644 index 0000000..28b9983 --- /dev/null +++ b/Modules/Licensing/Support/ModuleInfo.php @@ -0,0 +1,26 @@ + 'News']; diff --git a/News/Database/Migrations/2026_07_24_03042_create_news_articles_table.php b/News/Database/Migrations/2026_07_24_03042_create_news_articles_table.php new file mode 100644 index 0000000..d939ffc --- /dev/null +++ b/News/Database/Migrations/2026_07_24_03042_create_news_articles_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('author_id')->nullable()->constrained('authors')->nullOnDelete(); + $table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete(); + $table->string('title'); + $table->string('slug')->unique(); + $table->text('excerpt')->nullable(); + $table->longText('content')->nullable(); + $table->string('featured_image')->nullable(); + $table->string('status')->default('draft')->index(); + $table->boolean('is_featured')->default(false); + $table->timestamp('published_at')->nullable()->index(); + $table->timestamp('expires_at')->nullable(); + $table->string('meta_title')->nullable(); + $table->text('meta_description')->nullable(); + $table->timestamps(); + }); + Schema::create('news_article_tag', function (Blueprint $table): void { + $table->foreignId('news_article_id')->constrained('news_articles')->cascadeOnDelete(); + $table->foreignId('tag_id')->constrained('tags')->cascadeOnDelete(); + $table->primary(['news_article_id', 'tag_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('news_article_tag'); + Schema::dropIfExists('news_articles'); + } +}; diff --git a/News/Http/Controllers/NewsController.php b/News/Http/Controllers/NewsController.php new file mode 100644 index 0000000..cc69cc2 --- /dev/null +++ b/News/Http/Controllers/NewsController.php @@ -0,0 +1,66 @@ + News::with(['author', 'category'])->latest()->paginate(25)]); + } + + public function create(): View + { + return view('news::admin.form', $this->lookups()); + } + + public function store(Request $r): RedirectResponse + { + $record = News::create($this->data($r)); + $record->tags()->sync($r->input('tag_ids', [])); + + return redirect()->route('administration.news.index')->with('status', 'News created.'); + } + + public function edit(News $news_article): View + { + return view('news::admin.form', array_merge($this->lookups(), ['record' => $news_article])); + } + + public function update(Request $r, News $news_article): RedirectResponse + { + $news_article->update($this->data($r)); + $news_article->tags()->sync($r->input('tag_ids', [])); + + return redirect()->route('administration.news.index')->with('status', 'News updated.'); + } + + public function destroy(News $news_article): RedirectResponse + { + $news_article->delete(); + + return back()->with('status', 'News deleted.'); + } + + private function lookups(): array + { + return ['authors' => Author::where('is_active', true)->orderBy('name')->get(), 'categories' => Category::where('is_active', true)->orderBy('name')->get(), 'tags' => Tag::orderBy('name')->get()]; + } + + private function data(Request $r): array + { + $d = $r->validate(['author_id' => ['nullable', 'exists:authors,id'], 'category_id' => ['nullable', 'exists:categories,id'], 'title' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255'], 'excerpt' => ['nullable', 'string'], 'content' => ['nullable', 'string'], 'featured_image' => ['nullable', 'string', 'max:255'], 'status' => ['required', 'in:draft,review,published,archived'], 'is_featured' => ['sometimes', 'boolean'], 'published_at' => ['nullable', 'date'], 'expires_at' => ['nullable', 'date', 'after:published_at'], 'meta_title' => ['nullable', 'string', 'max:255'], 'meta_description' => ['nullable', 'string']]); + $d['is_featured'] = $r->boolean('is_featured'); + + return $d; + } +} diff --git a/News/Http/Controllers/PublicNewsController.php b/News/Http/Controllers/PublicNewsController.php new file mode 100644 index 0000000..5783072 --- /dev/null +++ b/News/Http/Controllers/PublicNewsController.php @@ -0,0 +1,20 @@ + News::published()->with(['author', 'category'])->latest('published_at')->paginate(12)]); + } + + public function show(string $slug): View + { + return view('news::public.show', ['record' => News::published()->with(['author', 'category', 'tags'])->where('slug', $slug)->firstOrFail()]); + } +} diff --git a/News/Models/News.php b/News/Models/News.php new file mode 100644 index 0000000..586c952 --- /dev/null +++ b/News/Models/News.php @@ -0,0 +1,48 @@ + 'boolean', 'published_at' => 'datetime', 'expires_at' => 'datetime']; + } + + public function author(): BelongsTo + { + return $this->belongsTo(Author::class); + } + + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + + public function tags(): BelongsToMany + { + return $this->belongsToMany(Tag::class, 'news_article_tag'); + } + + public function scopePublished(Builder $query): Builder + { + return $query->where('status', 'published')->where(fn (Builder $q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()))->where(fn (Builder $q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now())); + } +} diff --git a/News/Providers/NewsServiceProvider.php b/News/Providers/NewsServiceProvider.php new file mode 100644 index 0000000..959998d --- /dev/null +++ b/News/Providers/NewsServiceProvider.php @@ -0,0 +1,20 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('News', 'Config/config.php'), 'news'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('News', 'Database/Migrations')); + $this->loadViewsFrom(module_path('News', 'Resources/views'), 'news'); + } +} diff --git a/News/Providers/RouteServiceProvider.php b/News/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..cfa0850 --- /dev/null +++ b/News/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +group(module_path('News', 'Routes/web.php')); + } +} diff --git a/News/Resources/views/admin/form.blade.php b/News/Resources/views/admin/form.blade.php new file mode 100644 index 0000000..2e122f0 --- /dev/null +++ b/News/Resources/views/admin/form.blade.php @@ -0,0 +1 @@ +
@csrf @isset($record) @method('PUT') @endisset
@foreach($authors as $author)@endforeach@foreach($categories as $category)@endforeach@foreach(['draft','review','published','archived'] as $status)@endforeach
{{ old('excerpt',$record->excerpt ?? '') }}{{ old('content',$record->content ?? '') }}
@foreach($tags as $tag)@endforeach
{{ old('meta_description',$record->meta_description ?? '') }}Save
diff --git a/News/Resources/views/admin/index.blade.php b/News/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..f8860b7 --- /dev/null +++ b/News/Resources/views/admin/index.blade.php @@ -0,0 +1 @@ +

News

Manage publishing news.

Create
@if(session('status'))
{{ session('status') }}
@endif
@forelse($records as $record)@empty@endforelse
Name / titleStatusActions
{{ $record->title ?? $record->name }}{{ $record->status ?? ($record->is_active ? 'Active' : 'Inactive') }}Edit
@csrf @method('DELETE')Delete
No records yet.
{{ $records->links() }}
diff --git a/News/Resources/views/public/index.blade.php b/News/Resources/views/public/index.blade.php new file mode 100644 index 0000000..5413c83 --- /dev/null +++ b/News/Resources/views/public/index.blade.php @@ -0,0 +1,31 @@ +@extends('stylist-theme-saasland::layouts.app') + +@php + $title = 'News'; + $description = 'The latest announcements, releases and company updates.'; + $eyebrow = 'Updates'; + $breadcrumbs = ['News' => null]; +@endphp + +@section('content') +
+
+
+ @forelse($records as $record) +
+
+
+ +

{{ $record->title }}

+ @if($record->excerpt)

{{ $record->excerpt }}

@endif + Read update → +
+
+ @empty +
No news has been published yet.
+ @endforelse +
+
{{ $records->links() }}
+
+
+@endsection diff --git a/News/Resources/views/public/show.blade.php b/News/Resources/views/public/show.blade.php new file mode 100644 index 0000000..ddb3795 --- /dev/null +++ b/News/Resources/views/public/show.blade.php @@ -0,0 +1,23 @@ +@extends('stylist-theme-saasland::layouts.app') + +@php + $title = $record->title; + $description = $record->excerpt; + $eyebrow = 'News'; + $breadcrumbs = ['News' => route('news.public.index'), $record->title => null]; +@endphp + +@section('content') +
+
+
+ + @if($record->excerpt)

{{ $record->excerpt }}

@endif +
{!! nl2br(e($record->content)) !!}
+
+
+
+@endsection diff --git a/News/Routes/web.php b/News/Routes/web.php new file mode 100644 index 0000000..672eba1 --- /dev/null +++ b/News/Routes/web.php @@ -0,0 +1,16 @@ +prefix('administration/news')->name('administration.news.')->group(function (): void { + Route::get('/', [NewsController::class, 'index'])->name('index'); + Route::get('/create', [NewsController::class, 'create'])->name('create'); + Route::post('/', [NewsController::class, 'store'])->name('store'); + Route::get('/{news_article}/edit', [NewsController::class, 'edit'])->name('edit'); + Route::match(['put', 'patch'], '/{news_article}', [NewsController::class, 'update'])->name('update'); + Route::delete('/{news_article}', [NewsController::class, 'destroy'])->name('destroy'); +}); +Route::get('/news', [PublicNewsController::class, 'index'])->name('news.public.index'); +Route::get('/news/{slug}', [PublicNewsController::class, 'show'])->name('news.public.show'); diff --git a/News/Tests/Feature/ModuleBootTest.php b/News/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..dbe1c8a --- /dev/null +++ b/News/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,13 @@ +assertSame('News', config('news.name')); + } +} diff --git a/News/module.json b/News/module.json new file mode 100644 index 0000000..dcb3490 --- /dev/null +++ b/News/module.json @@ -0,0 +1,12 @@ +{ + "name": "News", + "alias": "news", + "version": "1.0.0", + "description": "News publishing module.", + "keywords": ["publishing", "news"], + "priority": 30, + "providers": ["Modules\\News\\Providers\\NewsServiceProvider"], + "aliases": {}, + "files": [], + "requires": ["Core", "Administration"] +} diff --git a/Newsletter/Config/config.php b/Newsletter/Config/config.php new file mode 100644 index 0000000..293dea2 --- /dev/null +++ b/Newsletter/Config/config.php @@ -0,0 +1,3 @@ + 'Newsletter']; diff --git a/Newsletter/Database/Migrations/2026_07_24_04100_create_newsletter_tables.php b/Newsletter/Database/Migrations/2026_07_24_04100_create_newsletter_tables.php new file mode 100644 index 0000000..613856a --- /dev/null +++ b/Newsletter/Database/Migrations/2026_07_24_04100_create_newsletter_tables.php @@ -0,0 +1,57 @@ +id(); + $t->string('name'); + $t->text('description')->nullable(); + $t->boolean('is_active')->default(true); + $t->timestamps(); + }); + Schema::create('newsletter_subscribers', function (Blueprint $t): void { + $t->id(); + $t->string('email')->unique(); + $t->string('name')->nullable(); + $t->string('status')->default('pending')->index(); + $t->string('confirmation_token')->nullable()->unique(); + $t->timestamp('confirmed_at')->nullable(); + $t->timestamp('unsubscribed_at')->nullable(); + $t->json('metadata')->nullable(); + $t->timestamps(); + }); + Schema::create('newsletter_list_subscriber', function (Blueprint $t): void { + $t->foreignId('list_id')->constrained('newsletter_lists')->cascadeOnDelete(); + $t->foreignId('subscriber_id')->constrained('newsletter_subscribers')->cascadeOnDelete(); + $t->primary(['list_id', 'subscriber_id']); + }); + Schema::create('newsletter_campaigns', function (Blueprint $t): void { + $t->id(); + $t->foreignId('list_id')->nullable()->constrained('newsletter_lists')->nullOnDelete(); + $t->string('name'); + $t->string('subject'); + $t->longText('content')->nullable(); + $t->string('status')->default('draft')->index(); + $t->timestamp('scheduled_at')->nullable(); + $t->timestamp('sent_at')->nullable(); + $t->unsignedInteger('sent_count')->default(0); + $t->unsignedInteger('open_count')->default(0); + $t->unsignedInteger('click_count')->default(0); + $t->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('newsletter_campaigns'); + Schema::dropIfExists('newsletter_list_subscriber'); + Schema::dropIfExists('newsletter_subscribers'); + Schema::dropIfExists('newsletter_lists'); + } +}; diff --git a/Newsletter/Http/Controllers/NewsletterController.php b/Newsletter/Http/Controllers/NewsletterController.php new file mode 100644 index 0000000..8e37e2c --- /dev/null +++ b/Newsletter/Http/Controllers/NewsletterController.php @@ -0,0 +1,52 @@ + Subscriber::latest()->paginate(20), 'campaigns' => Campaign::latest()->limit(10)->get(), 'lists' => MailingList::orderBy('name')->get()]); + } + + public function storeSubscriber(Request $r): RedirectResponse + { + $d = $r->validate(['email' => ['required', 'email', 'unique:newsletter_subscribers,email'], 'name' => ['nullable', 'string', 'max:255']]); + Subscriber::create($d + ['status' => 'confirmed', 'confirmation_token' => Str::random(48), 'confirmed_at' => now()]); + + return back()->with('status', 'Subscriber added.'); + } + + public function storeCampaign(Request $r): RedirectResponse + { + Campaign::create($r->validate(['list_id' => ['nullable', 'exists:newsletter_lists,id'], 'name' => ['required', 'string', 'max:255'], 'subject' => ['required', 'string', 'max:255'], 'content' => ['nullable', 'string'], 'status' => ['required', 'in:draft,scheduled,sent'], 'scheduled_at' => ['nullable', 'date']])); + + return back()->with('status', 'Campaign created.'); + } + + public function publicSubscribe(Request $r): RedirectResponse + { + $data = $r->validate(['email' => ['required', 'email', 'max:255'], 'name' => ['nullable', 'string', 'max:255']]); + Subscriber::query()->updateOrCreate( + ['email' => strtolower($data['email'])], + ['name' => $data['name'] ?? null, 'status' => 'confirmed', 'confirmation_token' => Str::random(48), 'confirmed_at' => now()] + ); + + return back()->with('status', 'Thank you for subscribing.'); + } + + public function destroySubscriber(Subscriber $subscriber): RedirectResponse + { + $subscriber->delete(); + + return back()->with('status', 'Subscriber deleted.'); + } +} diff --git a/Newsletter/Models/Campaign.php b/Newsletter/Models/Campaign.php new file mode 100644 index 0000000..924a6a8 --- /dev/null +++ b/Newsletter/Models/Campaign.php @@ -0,0 +1,17 @@ + 'datetime', 'sent_at' => 'datetime']; + } +} diff --git a/Newsletter/Models/MailingList.php b/Newsletter/Models/MailingList.php new file mode 100644 index 0000000..786b233 --- /dev/null +++ b/Newsletter/Models/MailingList.php @@ -0,0 +1,17 @@ + 'boolean']; + } +} diff --git a/Newsletter/Models/Subscriber.php b/Newsletter/Models/Subscriber.php new file mode 100644 index 0000000..9d0bd30 --- /dev/null +++ b/Newsletter/Models/Subscriber.php @@ -0,0 +1,17 @@ + 'datetime', 'unsubscribed_at' => 'datetime', 'metadata' => 'array']; + } +} diff --git a/Newsletter/Providers/NewsletterServiceProvider.php b/Newsletter/Providers/NewsletterServiceProvider.php new file mode 100644 index 0000000..d10726f --- /dev/null +++ b/Newsletter/Providers/NewsletterServiceProvider.php @@ -0,0 +1,20 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Newsletter', 'Config/config.php'), 'newsletter'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Newsletter', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Newsletter', 'Resources/views'), 'newsletter'); + } +} diff --git a/Newsletter/Providers/RouteServiceProvider.php b/Newsletter/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..47472fc --- /dev/null +++ b/Newsletter/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +group(module_path('Newsletter', 'Routes/web.php')); + } +} diff --git a/Newsletter/Resources/views/admin/index.blade.php b/Newsletter/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..93c0489 --- /dev/null +++ b/Newsletter/Resources/views/admin/index.blade.php @@ -0,0 +1 @@ +

Newsletter

Manage subscribers and email campaigns.

@if(session('status'))
{{ session('status') }}
@endif
@csrf

Add subscriber

Add subscriber
@csrf

Create campaign

Create campaign
@forelse($subscribers as $subscriber)@empty@endforelse
SubscriberStatusConfirmed
{{ $subscriber->email }}
{{ $subscriber->name }}
{{ ucfirst($subscriber->status) }}{{ optional($subscriber->confirmed_at)->format('j M Y') ?: '—' }}
@csrf @method('DELETE')Delete
No subscribers yet.
{{ $subscribers->links() }}
diff --git a/Newsletter/Routes/web.php b/Newsletter/Routes/web.php new file mode 100644 index 0000000..045e8f9 --- /dev/null +++ b/Newsletter/Routes/web.php @@ -0,0 +1,13 @@ +prefix('administration/newsletter')->name('administration.newsletter.')->group(function (): void { + Route::get('/', [NewsletterController::class, 'index'])->name('index'); + Route::post('/subscribers', [NewsletterController::class, 'storeSubscriber'])->name('subscribers.store'); + Route::delete('/subscribers/{subscriber}', [NewsletterController::class, 'destroySubscriber'])->name('subscribers.destroy'); + Route::post('/campaigns', [NewsletterController::class, 'storeCampaign'])->name('campaigns.store'); +}); + +Route::post('/newsletter/subscribe', [NewsletterController::class, 'publicSubscribe'])->middleware('throttle:10,1')->name('newsletter.public.subscribe'); diff --git a/Newsletter/Tests/Feature/ModuleBootTest.php b/Newsletter/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..d76ba2b --- /dev/null +++ b/Newsletter/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,13 @@ +assertSame('Newsletter', config('newsletter.name')); + } +} diff --git a/Newsletter/module.json b/Newsletter/module.json new file mode 100644 index 0000000..620f465 --- /dev/null +++ b/Newsletter/module.json @@ -0,0 +1,12 @@ +{ + "name": "Newsletter", + "alias": "newsletter", + "version": "1.0.0", + "description": "Subscriber lists and email campaign management.", + "keywords": ["business", "newsletter"], + "priority": 41, + "providers": ["Modules\\Newsletter\\Providers\\NewsletterServiceProvider"], + "aliases": {}, + "files": [], + "requires": ["Core", "Administration"] +} diff --git a/Pages/Config/config.php b/Pages/Config/config.php new file mode 100644 index 0000000..68aabbf --- /dev/null +++ b/Pages/Config/config.php @@ -0,0 +1,3 @@ + 'Pages']; diff --git a/Pages/Database/Migrations/2026_07_24_000100_create_pages_table.php b/Pages/Database/Migrations/2026_07_24_000100_create_pages_table.php new file mode 100644 index 0000000..d4cb696 --- /dev/null +++ b/Pages/Database/Migrations/2026_07_24_000100_create_pages_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('title'); + $table->string('slug'); + $table->text('summary')->nullable(); + $table->longText('content')->nullable(); + $table->string('status')->default('draft'); + $table->timestamp('published_at')->nullable(); + $table->boolean('is_home')->default(false); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('pages'); + } +}; diff --git a/Pages/Database/Migrations/2026_07_24_030700_enhance_pages_for_publishing.php b/Pages/Database/Migrations/2026_07_24_030700_enhance_pages_for_publishing.php new file mode 100644 index 0000000..ac8810e --- /dev/null +++ b/Pages/Database/Migrations/2026_07_24_030700_enhance_pages_for_publishing.php @@ -0,0 +1,37 @@ +string('template')->default('default'); + $table->timestamp('expires_at')->nullable(); + $table->string('preview_token', 64)->nullable()->unique(); + $table->foreignId('locked_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('locked_at')->nullable(); + }); + Schema::create('page_revisions', function (Blueprint $table): void { + $table->id(); + $table->foreignId('page_id')->constrained('pages')->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('title'); + $table->longText('content')->nullable(); + $table->json('snapshot'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('page_revisions'); + Schema::table('pages', function (Blueprint $table): void { + $table->dropConstrainedForeignId('locked_by'); + $table->dropColumn(['template', 'expires_at', 'preview_token', 'locked_at']); + }); + } +}; diff --git a/Pages/Http/Controllers/PageController.php b/Pages/Http/Controllers/PageController.php new file mode 100644 index 0000000..f9f2e02 --- /dev/null +++ b/Pages/Http/Controllers/PageController.php @@ -0,0 +1,151 @@ +withCount('contentPlacements') + ->when($request->string('search')->isNotEmpty(), function ($query) use ($request): void { + $search = $request->string('search')->toString(); + $query->where(fn ($page) => $page + ->where('title', 'like', "%{$search}%") + ->orWhere('slug', 'like', "%{$search}%") + ->orWhere('summary', 'like', "%{$search}%")); + }) + ->when($request->string('status')->isNotEmpty(), fn ($query) => $query->where('status', $request->string('status')->toString())) + ->orderByDesc('is_home') + ->orderBy('title') + ->paginate(24) + ->withQueryString(); + + return view('pages::admin.index', compact('records')); + } + + public function create(): View + { + return view('pages::admin.form', ['record' => new Page(['status' => 'draft', 'template' => 'default'])]); + } + + public function store(Request $request): RedirectResponse + { + $data = $this->validated($request); + $data['slug'] = $this->uniqueSlug($data['slug'] ?: $data['title']); + $data['is_home'] = $request->boolean('is_home'); + $data['published_at'] = $data['status'] === 'published' ? ($data['published_at'] ?? now()) : null; + + $page = Page::query()->create($data); + $this->enforceSingleHomepage($page); + + return redirect()->route('administration.pages.edit', $page)->with('status', 'Page created. Add content sections when you are ready.'); + } + + public function edit(Page $page): View + { + $page->loadCount('contentPlacements'); + + return view('pages::admin.form', ['record' => $page]); + } + + public function update(Request $request, Page $page): RedirectResponse + { + $oldSlug = $page->slug; + $data = $this->validated($request, $page); + $data['slug'] = $this->uniqueSlug($data['slug'] ?: $data['title'], $page); + $data['is_home'] = $request->boolean('is_home'); + $data['published_at'] = $data['status'] === 'published' ? ($data['published_at'] ?? $page->published_at ?? now()) : null; + + $page->update($data); + $this->enforceSingleHomepage($page); + + if ($oldSlug !== $page->slug) { + PageContent::query()->where('page_id', $page->id)->orWhere('page_key', $oldSlug)->update(['page_key' => $page->slug, 'page_id' => $page->id]); + } + + return back()->with('status', 'Page saved.'); + } + + public function duplicate(Request $request, Page $page): RedirectResponse + { + $copy = $page->replicate(['slug', 'status', 'published_at', 'preview_token', 'locked_by', 'locked_at', 'is_home']); + $copy->title = $page->title.' copy'; + $copy->slug = $this->uniqueSlug($copy->title); + $copy->status = 'draft'; + $copy->published_at = null; + $copy->is_home = false; + $copy->save(); + + foreach ($page->contentPlacements()->orderBy('region')->orderBy('sort_order')->get() as $placement) { + $copy->contentPlacements()->create([ + 'page_key' => $copy->slug, + 'content_document_id' => $placement->content_document_id, + 'region' => $placement->region, + 'sort_order' => $placement->sort_order, + 'is_enabled' => $placement->is_enabled, + 'visibility_rules' => $placement->visibility_rules, + 'presentation' => $placement->presentation, + 'starts_at' => $placement->starts_at, + 'ends_at' => $placement->ends_at, + ]); + } + + return redirect()->route('administration.pages.edit', $copy)->with('status', 'Draft copy created with the same content structure.'); + } + + public function destroy(Page $page): RedirectResponse + { + if ($page->is_home) { + return back()->withErrors(['page' => 'Choose another homepage before deleting the current homepage.']); + } + + $page->delete(); + + return redirect()->route('administration.pages.index')->with('status', 'Page moved to the bin.'); + } + + private function validated(Request $request, ?Page $page = null): array + { + return $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'slug' => ['nullable', 'string', 'max:255', Rule::unique('pages', 'slug')->ignore($page?->id)->whereNull('deleted_at')], + 'summary' => ['nullable', 'string', 'max:1000'], + 'content' => ['nullable', 'string'], + 'status' => ['required', Rule::in(['draft', 'published'])], + 'template' => ['required', Rule::in(['default', 'landing', 'full-width'])], + 'published_at' => ['nullable', 'date'], + 'expires_at' => ['nullable', 'date', 'after:published_at'], + 'is_home' => ['sometimes', 'boolean'], + ]); + } + + private function uniqueSlug(string $value, ?Page $except = null): string + { + $base = Str::slug($value) ?: 'page'; + $slug = $base; + $suffix = 2; + + while (Page::withTrashed()->where('slug', $slug)->when($except, fn ($query) => $query->whereKeyNot($except->getKey()))->exists()) { + $slug = $base.'-'.$suffix++; + } + + return $slug; + } + + private function enforceSingleHomepage(Page $page): void + { + if ($page->is_home) { + Page::query()->whereKeyNot($page->id)->where('is_home', true)->update(['is_home' => false]); + } + } +} diff --git a/Pages/Http/Controllers/PublicPageController.php b/Pages/Http/Controllers/PublicPageController.php new file mode 100644 index 0000000..c9e531f --- /dev/null +++ b/Pages/Http/Controllers/PublicPageController.php @@ -0,0 +1,17 @@ +where('slug', $slug)->where('status', 'published')->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()))->firstOrFail(); + + return view('pages::public.show', compact('page')); + } +} diff --git a/Pages/Models/Page.php b/Pages/Models/Page.php new file mode 100644 index 0000000..22e2d3f --- /dev/null +++ b/Pages/Models/Page.php @@ -0,0 +1,35 @@ + 'datetime', + 'expires_at' => 'datetime', + 'is_home' => 'boolean', + ]; + } + + public function contentPlacements(): HasMany + { + return $this->hasMany(PageContent::class)->orderBy('region')->orderBy('sort_order'); + } +} diff --git a/Pages/Providers/PagesServiceProvider.php b/Pages/Providers/PagesServiceProvider.php new file mode 100644 index 0000000..0a1d101 --- /dev/null +++ b/Pages/Providers/PagesServiceProvider.php @@ -0,0 +1,20 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Pages', 'Config/config.php'), 'pages'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Pages', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Pages', 'Resources/views'), 'pages'); + } +} diff --git a/Pages/Providers/RouteServiceProvider.php b/Pages/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..7984a9e --- /dev/null +++ b/Pages/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +routes(fn () => Route::middleware('web')->group(module_path('Pages', 'Routes/web.php'))); + } +} diff --git a/Pages/Resources/views/admin/form.blade.php b/Pages/Resources/views/admin/form.blade.php new file mode 100644 index 0000000..3a513d2 --- /dev/null +++ b/Pages/Resources/views/admin/form.blade.php @@ -0,0 +1,87 @@ + +
+
+
+ ← Pages +
+

{{ $record->exists ? $record->title : 'New page' }}

+ @if($record->exists && $record->is_home)Homepage@endif +
+

Page details, search metadata, publishing and content structure.

+
+ @if($record->exists) +
+ @if($record->status === 'published')Open live page@endif + Edit content +
@csrfDuplicate
+
+ @endif +
+ + @if(session('status'))
{{ session('status') }}
@endif + @if($errors->any())
    @foreach($errors->all() as $error)
  • {{ $error }}
  • @endforeach
@endif + +
+ +
+ +
+ @csrf @if($record->exists) @method('PUT') @endif +
+
+ +

Page identity

Use a clear title editors and visitors can recognise.

+ + {{ old('summary', $record->summary) }} +
+ + +

Content structure

Build the page with ordered, reusable sections in the visual composer.

@if($record->exists){{ $record->content_placements_count ?? 0 }} sections@endif
+ @if($record->exists) +

Page composer

Add hero areas, text, feeds, calls to action and shared site sections, then drag them into order.

Open content composer
+ @else +
Save the page first, then add and arrange its content sections.
+ @endif +
Legacy body content

Kept for compatibility with older templates. New pages should use the content composer.

+
+
+ +
+ +

URL and presentation

Keep URLs short, readable and stable.

+
/
+ +
+

Search preview

{{ url('/') }}/
{{ old('title', $record->title ?: 'Page title') }}

{{ Str::limit(old('summary', $record->summary ?: 'Add a concise summary to improve how this page appears in search and content listings.'), 160) }}

+
+ +
+

Publishing schedule

Publish immediately or define a controlled availability window.

+
+
+ +
+ +
+ + +
diff --git a/Pages/Resources/views/admin/index.blade.php b/Pages/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..1858e3a --- /dev/null +++ b/Pages/Resources/views/admin/index.blade.php @@ -0,0 +1,75 @@ + +
+
+
+

Website

+

Pages

+

Create, organise, compose and publish every page from one place.

+
+
+ Content library + New page +
+
+ + @if(session('status')) +
{{ session('status') }}
+ @endif + @if($errors->any()) +
{{ $errors->first() }}
+ @endif + +
+ + + + + + +
FilterReset
+ + +
+ @forelse($records as $record) + @php + $isLive = $record->status === 'published' && (!$record->published_at || $record->published_at->isPast()) && (!$record->expires_at || $record->expires_at->isFuture()); + @endphp +
+
+
+
+

{{ $record->title }}

+ @if($record->is_home)Homepage@endif + {{ $isLive ? 'Live' : ucfirst($record->status) }} +
+

/{{ $record->slug }}

+
+
+
{{ $record->content_placements_count }}
+
sections
+
+
+ +

{{ Str::limit($record->summary ?: 'No page summary has been added yet.', 150) }}

+ +
+
Updated {{ $record->updated_at?->diffForHumans() }} · {{ ucfirst($record->template ?? 'default') }}
+
+ @if($record->status === 'published')View@endif + Content + Edit page +
+
+
+ @empty +
+

No pages found

+

Create your first page or clear the current filters.

+ Create page +
+ @endforelse +
+ + {{ $records->links() }} +
+
diff --git a/Pages/Resources/views/public/show.blade.php b/Pages/Resources/views/public/show.blade.php new file mode 100644 index 0000000..f066034 --- /dev/null +++ b/Pages/Resources/views/public/show.blade.php @@ -0,0 +1,22 @@ +@extends('stylist-theme-saasland::layouts.app') + +@php + $title = $page->title; + $description = $page->summary; + $eyebrow = 'Page'; + $breadcrumbs = [$page->title => null]; +@endphp + +@section('content') +
+
+
+ @if ($page->summary) +

{{ $page->summary }}

+ @endif + +
{!! $page->content !!}
+
+
+
+@endsection diff --git a/Pages/Routes/web.php b/Pages/Routes/web.php new file mode 100644 index 0000000..dadc7f4 --- /dev/null +++ b/Pages/Routes/web.php @@ -0,0 +1,17 @@ +prefix('administration/pages')->name('administration.pages.')->group(function (): void { + Route::get('/', [PageController::class, 'index'])->name('index'); + Route::get('/create', [PageController::class, 'create'])->name('create'); + Route::post('/', [PageController::class, 'store'])->name('store'); + Route::get('/{page}/edit', [PageController::class, 'edit'])->name('edit'); + Route::match(['put', 'patch'], '/{page}', [PageController::class, 'update'])->name('update'); + Route::post('/{page}/duplicate', [PageController::class, 'duplicate'])->name('duplicate'); + Route::delete('/{page}', [PageController::class, 'destroy'])->name('destroy'); +}); + +Route::get('/pages/{slug}', [PublicPageController::class, 'show'])->name('pages.public.show'); diff --git a/Pages/Tests/Feature/ModuleBootTest.php b/Pages/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..2a22cc4 --- /dev/null +++ b/Pages/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,7 @@ +toBeTrue(); +}); diff --git a/Pages/module.json b/Pages/module.json new file mode 100644 index 0000000..d891304 --- /dev/null +++ b/Pages/module.json @@ -0,0 +1,20 @@ +{ + "name": "Pages", + "alias": "pages", + "version": "1.0.0", + "description": "Pages CMS module.", + "keywords": [ + "cms", + "pages" + ], + "priority": 21, + "providers": [ + "Modules\\Pages\\Providers\\PagesServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [ + "Core", + "Administration" + ] +} diff --git a/Products/Config/config.php b/Products/Config/config.php new file mode 100644 index 0000000..3bfe783 --- /dev/null +++ b/Products/Config/config.php @@ -0,0 +1,3 @@ + 'Products']; diff --git a/Products/Database/Migrations/2026_07_24_105054_create_products_table.php b/Products/Database/Migrations/2026_07_24_105054_create_products_table.php new file mode 100644 index 0000000..a009d1a --- /dev/null +++ b/Products/Database/Migrations/2026_07_24_105054_create_products_table.php @@ -0,0 +1,23 @@ +id(); + $table->string('name'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('products'); + } +}; diff --git a/Products/Database/Migrations/2026_07_24_150000_add_pricing_fields_to_products_table.php b/Products/Database/Migrations/2026_07_24_150000_add_pricing_fields_to_products_table.php new file mode 100644 index 0000000..f6b9a80 --- /dev/null +++ b/Products/Database/Migrations/2026_07_24_150000_add_pricing_fields_to_products_table.php @@ -0,0 +1,34 @@ +string('product_type')->default('product')->after('name'); + $table->text('description')->nullable()->after('product_type'); + $table->decimal('price', 12, 2)->nullable()->after('description'); + $table->string('currency', 3)->default('GBP')->after('price'); + $table->string('billing_period')->nullable()->after('currency'); + $table->json('features')->nullable()->after('billing_period'); + $table->string('cta_label')->nullable()->after('features'); + $table->string('cta_url')->nullable()->after('cta_label'); + $table->boolean('is_featured')->default(false)->after('cta_url'); + $table->unsignedInteger('sort_order')->default(0)->after('is_featured'); + }); + } + + public function down(): void + { + Schema::table('products', function (Blueprint $table): void { + $table->dropColumn([ + 'product_type', 'description', 'price', 'currency', 'billing_period', + 'features', 'cta_label', 'cta_url', 'is_featured', 'sort_order', + ]); + }); + } +}; diff --git a/Products/Http/Controllers/ProductController.php b/Products/Http/Controllers/ProductController.php new file mode 100644 index 0000000..c3ccf6f --- /dev/null +++ b/Products/Http/Controllers/ProductController.php @@ -0,0 +1,23 @@ + Product::query()->latest()->paginate()]); + } + + public function store(StoreProductRequest $request): RedirectResponse + { + Product::query()->create($request->validated()); + + return back()->with('status', 'Product created.'); + } +} diff --git a/Products/Http/Requests/StoreProductRequest.php b/Products/Http/Requests/StoreProductRequest.php new file mode 100644 index 0000000..ae953bc --- /dev/null +++ b/Products/Http/Requests/StoreProductRequest.php @@ -0,0 +1,45 @@ +user() !== null; + } + + protected function prepareForValidation(): void + { + $this->merge([ + 'is_active' => $this->boolean('is_active'), + 'is_featured' => $this->boolean('is_featured'), + 'features' => collect(preg_split('/\r\n|\r|\n/', (string) $this->input('features'))) + ->map(fn (string $feature) => trim($feature)) + ->filter() + ->values() + ->all(), + ]); + } + + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'product_type' => ['required', 'in:product,plan'], + 'description' => ['nullable', 'string'], + 'price' => ['nullable', 'numeric', 'min:0'], + 'currency' => ['required', 'string', 'size:3'], + 'billing_period' => ['nullable', 'string', 'max:40'], + 'features' => ['array'], + 'features.*' => ['string', 'max:255'], + 'cta_label' => ['nullable', 'string', 'max:80'], + 'cta_url' => ['nullable', 'string', 'max:2048'], + 'is_featured' => ['boolean'], + 'sort_order' => ['integer', 'min:0'], + 'is_active' => ['boolean'], + ]; + } +} diff --git a/Products/Models/Product.php b/Products/Models/Product.php new file mode 100644 index 0000000..30dbf31 --- /dev/null +++ b/Products/Models/Product.php @@ -0,0 +1,21 @@ + 'decimal:2', + 'features' => 'array', + 'is_active' => 'boolean', + 'is_featured' => 'boolean', + 'sort_order' => 'integer', + ]; + } +} diff --git a/Products/Providers/ProductsServiceProvider.php b/Products/Providers/ProductsServiceProvider.php new file mode 100644 index 0000000..98d5e46 --- /dev/null +++ b/Products/Providers/ProductsServiceProvider.php @@ -0,0 +1,20 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Products', 'Config/config.php'), 'products'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Products', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Products', 'Resources/views'), 'products'); + } +} diff --git a/Products/Providers/RouteServiceProvider.php b/Products/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..337a787 --- /dev/null +++ b/Products/Providers/RouteServiceProvider.php @@ -0,0 +1,14 @@ +group(module_path('Products', 'Routes/web.php')); + } +} diff --git a/Products/Resources/views/admin/products/form.blade.php b/Products/Resources/views/admin/products/form.blade.php new file mode 100644 index 0000000..a98563c --- /dev/null +++ b/Products/Resources/views/admin/products/form.blade.php @@ -0,0 +1,25 @@ +
+ @csrf +
+ + + + + + + + + +
+ + +
+ + +
+
+ + +
+ {{ __('Save Product') }} + diff --git a/Products/Resources/views/admin/products/index.blade.php b/Products/Resources/views/admin/products/index.blade.php new file mode 100644 index 0000000..757785c --- /dev/null +++ b/Products/Resources/views/admin/products/index.blade.php @@ -0,0 +1,26 @@ + +
+
+

Products & Pricing

+

Manage catalogue products and public-facing pricing plans from one place.

+
+
+ @include('products::admin.products.form') +
+
+ @forelse ($records as $record) +
+
+

{{ $record->name }}

{{ ucfirst($record->product_type ?? 'product') }}

+ @if($record->is_featured)Featured@endif +
+ @if($record->price !== null)

{{ $record->currency }} {{ $record->price }} /{{ $record->billing_period }}

@else

Custom

@endif +

{{ $record->description }}

+
+ @empty +

No products or plans have been created.

+ @endforelse +
+ {{ $records->links() }} +
+
diff --git a/Products/Routes/web.php b/Products/Routes/web.php new file mode 100644 index 0000000..eecbc02 --- /dev/null +++ b/Products/Routes/web.php @@ -0,0 +1,7 @@ +prefix('administration/products')->name('administration.products.')->group(function (): void { + // Register module routes here. +}); diff --git a/Products/Tests/Feature/ProductCrudTest.php b/Products/Tests/Feature/ProductCrudTest.php new file mode 100644 index 0000000..f596dc9 --- /dev/null +++ b/Products/Tests/Feature/ProductCrudTest.php @@ -0,0 +1,7 @@ +create(['name' => 'Example']))->toBeInstanceOf(Product::class); +}); diff --git a/Products/Tests/Feature/ProductsModuleBootTest.php b/Products/Tests/Feature/ProductsModuleBootTest.php new file mode 100644 index 0000000..f89049e --- /dev/null +++ b/Products/Tests/Feature/ProductsModuleBootTest.php @@ -0,0 +1,7 @@ +providerIsLoaded(ProductsServiceProvider::class))->toBeTrue(); +}); diff --git a/Products/module.json b/Products/module.json new file mode 100644 index 0000000..5473559 --- /dev/null +++ b/Products/module.json @@ -0,0 +1,19 @@ +{ + "name": "Products", + "alias": "products", + "version": "1.0.0", + "description": "Products module.", + "keywords": [ + "products" + ], + "priority": 100, + "providers": [ + "Modules\\Products\\Providers\\ProductsServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [ + "Core", + "Administration" + ] +} diff --git a/Redirects/Config/config.php b/Redirects/Config/config.php new file mode 100644 index 0000000..e5170bd --- /dev/null +++ b/Redirects/Config/config.php @@ -0,0 +1,3 @@ + 'Redirects']; diff --git a/Redirects/Database/Migrations/2026_07_24_000300_create_redirects_table.php b/Redirects/Database/Migrations/2026_07_24_000300_create_redirects_table.php new file mode 100644 index 0000000..5635594 --- /dev/null +++ b/Redirects/Database/Migrations/2026_07_24_000300_create_redirects_table.php @@ -0,0 +1,26 @@ +id(); + $table->string('source'); + $table->string('destination'); + $table->unsignedInteger('status_code')->default(301); + $table->boolean('enabled')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('redirects'); + } +}; diff --git a/Redirects/Http/Controllers/RedirectController.php b/Redirects/Http/Controllers/RedirectController.php new file mode 100644 index 0000000..efb0a93 --- /dev/null +++ b/Redirects/Http/Controllers/RedirectController.php @@ -0,0 +1,58 @@ + Redirect::query()->latest()->paginate(20)]); + } + + public function create(): View + { + return view('redirects::admin.form'); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate(['source' => ['required', 'string', 'max:255'], + 'destination' => ['required', 'string', 'max:255'], + 'status_code' => ['nullable', 'integer', 'min:0'], + 'enabled' => ['sometimes', 'boolean']]); + $data['enabled'] = $request->boolean('enabled'); + Redirect::create($data); + + return redirect()->route('administration.redirects.index')->with('status', 'Redirect created.'); + } + + public function edit(Redirect $redirect): View + { + return view('redirects::admin.form', ['record' => $redirect]); + } + + public function update(Request $request, Redirect $redirect): RedirectResponse + { + $data = $request->validate(['source' => ['required', 'string', 'max:255'], + 'destination' => ['required', 'string', 'max:255'], + 'status_code' => ['nullable', 'integer', 'min:0'], + 'enabled' => ['sometimes', 'boolean']]); + $data['enabled'] = $request->boolean('enabled'); + $redirect->update($data); + + return redirect()->route('administration.redirects.index')->with('status', 'Redirect updated.'); + } + + public function destroy(Redirect $redirect): RedirectResponse + { + $redirect->delete(); + + return back()->with('status', 'Redirect deleted.'); + } +} diff --git a/Redirects/Models/Redirect.php b/Redirects/Models/Redirect.php new file mode 100644 index 0000000..ba4db4e --- /dev/null +++ b/Redirects/Models/Redirect.php @@ -0,0 +1,24 @@ + 'integer', + 'enabled' => 'boolean', + ]; + } +} diff --git a/Redirects/Providers/RedirectsServiceProvider.php b/Redirects/Providers/RedirectsServiceProvider.php new file mode 100644 index 0000000..d32dbda --- /dev/null +++ b/Redirects/Providers/RedirectsServiceProvider.php @@ -0,0 +1,20 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Redirects', 'Config/config.php'), 'redirects'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Redirects', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Redirects', 'Resources/views'), 'redirects'); + } +} diff --git a/Redirects/Providers/RouteServiceProvider.php b/Redirects/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..ba5a142 --- /dev/null +++ b/Redirects/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +routes(fn () => Route::middleware('web')->group(module_path('Redirects', 'Routes/web.php'))); + } +} diff --git a/Redirects/Resources/views/admin/form.blade.php b/Redirects/Resources/views/admin/form.blade.php new file mode 100644 index 0000000..18dccf9 --- /dev/null +++ b/Redirects/Resources/views/admin/form.blade.php @@ -0,0 +1,8 @@ + +

{{ isset($record) ? 'Edit' : 'Create' }} Redirect

@csrf @isset($record) @method('PUT') @endisset +
@error('source')

{{ $message }}

@enderror
+
@error('destination')

{{ $message }}

@enderror
+
@error('status_code')

{{ $message }}

@enderror
+ +
Cancel
+
diff --git a/Redirects/Resources/views/admin/index.blade.php b/Redirects/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..cf7590b --- /dev/null +++ b/Redirects/Resources/views/admin/index.blade.php @@ -0,0 +1,5 @@ + +

Redirects

Manage redirects content.

Add new
+@if(session('status'))
{{ session('status') }}
@endif +
@forelse($records as $record)@empty@endforelse
SourceDestinationStatus CodeActions
{{ is_array($record->source) ? json_encode($record->source) : Str::limit((string) $record->source, 70) }}{{ is_array($record->destination) ? json_encode($record->destination) : Str::limit((string) $record->destination, 70) }}{{ is_array($record->status_code) ? json_encode($record->status_code) : Str::limit((string) $record->status_code, 70) }}Edit
@csrf @method('DELETE')
No records yet.
{{ $records->links() }}
+
diff --git a/Redirects/Routes/web.php b/Redirects/Routes/web.php new file mode 100644 index 0000000..9faf228 --- /dev/null +++ b/Redirects/Routes/web.php @@ -0,0 +1,13 @@ +prefix('administration/redirects')->name('administration.redirects.')->group(function (): void { + Route::get('/', [RedirectController::class, 'index'])->name('index'); + Route::get('/create', [RedirectController::class, 'create'])->name('create'); + Route::post('/', [RedirectController::class, 'store'])->name('store'); + Route::get('/{redirect}/edit', [RedirectController::class, 'edit'])->name('edit'); + Route::match(['put', 'patch'], '/{redirect}', [RedirectController::class, 'update'])->name('update'); + Route::delete('/{redirect}', [RedirectController::class, 'destroy'])->name('destroy'); +}); diff --git a/Redirects/Support/ModuleInfo.php b/Redirects/Support/ModuleInfo.php new file mode 100644 index 0000000..a226dac --- /dev/null +++ b/Redirects/Support/ModuleInfo.php @@ -0,0 +1,16 @@ +toBeTrue(); +}); diff --git a/Redirects/module.json b/Redirects/module.json new file mode 100644 index 0000000..cebfd49 --- /dev/null +++ b/Redirects/module.json @@ -0,0 +1,20 @@ +{ + "name": "Redirects", + "alias": "redirects", + "version": "1.0.0", + "description": "Redirects CMS module.", + "keywords": [ + "cms", + "redirects" + ], + "priority": 23, + "providers": [ + "Modules\\Redirects\\Providers\\RedirectsServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [ + "Core", + "Administration" + ] +} diff --git a/Roles/Config/config.php b/Roles/Config/config.php new file mode 100644 index 0000000..60fa263 --- /dev/null +++ b/Roles/Config/config.php @@ -0,0 +1,6 @@ + 'Roles', + 'enabled' => true, +]; diff --git a/Roles/Database/Migrations/2026_07_23_000050_create_permission_tables.php b/Roles/Database/Migrations/2026_07_23_000050_create_permission_tables.php new file mode 100644 index 0000000..d74934a --- /dev/null +++ b/Roles/Database/Migrations/2026_07_23_000050_create_permission_tables.php @@ -0,0 +1,119 @@ +id(); + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + + $table->unique(['name', 'guard_name']); + }); + + Schema::create($tableNames['roles'], function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + + $table->unique(['name', 'guard_name']); + }); + + Schema::create($tableNames['model_has_permissions'], function (Blueprint $table) use ( + $tableNames, + $pivotPermission, + $modelMorphKey + ): void { + $table->unsignedBigInteger($pivotPermission); + $table->string('model_type'); + $table->unsignedBigInteger($modelMorphKey); + + $table->index([$modelMorphKey, 'model_type'], 'model_has_permissions_model_id_model_type_index'); + $table->foreign($pivotPermission) + ->references('id') + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + + $table->primary( + [$pivotPermission, $modelMorphKey, 'model_type'], + 'model_has_permissions_permission_model_type_primary' + ); + }); + + Schema::create($tableNames['model_has_roles'], function (Blueprint $table) use ( + $tableNames, + $pivotRole, + $modelMorphKey + ): void { + $table->unsignedBigInteger($pivotRole); + $table->string('model_type'); + $table->unsignedBigInteger($modelMorphKey); + + $table->index([$modelMorphKey, 'model_type'], 'model_has_roles_model_id_model_type_index'); + $table->foreign($pivotRole) + ->references('id') + ->on($tableNames['roles']) + ->cascadeOnDelete(); + + $table->primary( + [$pivotRole, $modelMorphKey, 'model_type'], + 'model_has_roles_role_model_type_primary' + ); + }); + + Schema::create($tableNames['role_has_permissions'], function (Blueprint $table) use ( + $tableNames, + $pivotRole, + $pivotPermission + ): void { + $table->unsignedBigInteger($pivotPermission); + $table->unsignedBigInteger($pivotRole); + + $table->foreign($pivotPermission) + ->references('id') + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + $table->foreign($pivotRole) + ->references('id') + ->on($tableNames['roles']) + ->cascadeOnDelete(); + + $table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary'); + }); + + app('cache')->store(config('permission.cache.store') === 'default' + ? null + : config('permission.cache.store')) + ->forget(config('permission.cache.key')); + } + + public function down(): void + { + $tableNames = config('permission.table_names'); + + Schema::dropIfExists($tableNames['role_has_permissions']); + Schema::dropIfExists($tableNames['model_has_roles']); + Schema::dropIfExists($tableNames['model_has_permissions']); + Schema::dropIfExists($tableNames['roles']); + Schema::dropIfExists($tableNames['permissions']); + } +}; diff --git a/Roles/Database/Seeders/RolePermissionSeeder.php b/Roles/Database/Seeders/RolePermissionSeeder.php new file mode 100644 index 0000000..9322669 --- /dev/null +++ b/Roles/Database/Seeders/RolePermissionSeeder.php @@ -0,0 +1,96 @@ +forgetCachedPermissions(); + + $permissions = [ + 'administration.access', + 'users.view', + 'users.create', + 'users.update', + 'users.delete', + 'roles.manage', + 'settings.manage', + 'audit.view', + 'media.view', + 'media.upload', + 'media.update', + 'media.delete', + 'seo.manage', + 'search.manage', + 'themes.manage', + 'pages.manage', + 'menus.manage', + 'redirects.manage', + 'forms.manage', + 'documents.manage', + 'banners.manage', + 'faq.manage', + 'team.manage', + 'testimonials.manage', + 'blog.manage', + 'news.manage', + 'categories.manage', + 'tags.manage', + 'authors.manage', + 'comments.moderate', + 'crm.manage', + 'newsletter.manage', + 'events.manage', + 'careers.manage', + 'locations.manage', + 'workflow.view', + 'workflow.manage', + 'workflow.review', + 'localization.manage', + 'localization.translate', + 'sites.view', + 'sites.manage', + 'sites.assign', + 'developer.access', + 'api.view', + 'api.manage', + 'enterprise.view', + 'enterprise.manage', + 'commerce.view', + 'commerce.manage', + 'commerce.orders.manage', + 'commerce.products.manage', + 'security.view', + 'security.manage', + 'security.audit', + 'automation.view', + 'automation.manage', + 'automation.execute', + 'ai.view', + 'ai.manage', + 'ai.execute', + 'analytics.view', + 'analytics.manage', + 'analytics.export', + 'builder.view', 'builder.manage', 'builder.publish', + 'marketplace.view', 'marketplace.install', 'marketplace.update', 'marketplace.remove', + 'cloud-devops.view', 'cloud-devops.manage', 'cloud-devops.deploy', 'cloud-devops.rollback', + ]; + + foreach ($permissions as $permission) { + Permission::findOrCreate($permission, 'web'); + } + + $administrator = Role::findOrCreate('admin', 'web'); + $administrator->syncPermissions($permissions); + + $registrar->forgetCachedPermissions(); + } +} diff --git a/Roles/Http/Controllers/RoleController.php b/Roles/Http/Controllers/RoleController.php new file mode 100644 index 0000000..d5b0e16 --- /dev/null +++ b/Roles/Http/Controllers/RoleController.php @@ -0,0 +1,58 @@ + Role::withCount(['users', 'permissions'])->orderBy('name')->get()]); + } + + public function create() + { + return view('roles::form', ['role' => new Role(['guard_name' => 'web']), 'permissions' => Permission::orderBy('name')->get()]); + } + + public function store(Request $request) + { + $data = $this->validated($request); + $role = Role::create(['name' => $data['name'], 'guard_name' => 'web']); + $role->syncPermissions($data['permissions'] ?? []); + + return redirect()->route('administration.roles.index')->with('status', 'Role created.'); + } + + public function edit(Role $role) + { + return view('roles::form', ['role' => $role->load('permissions'), 'permissions' => Permission::orderBy('name')->get()]); + } + + public function update(Request $request, Role $role) + { + $data = $this->validated($request, $role); + $role->update(['name' => $data['name']]); + $role->syncPermissions($data['permissions'] ?? []); + + return redirect()->route('administration.roles.index')->with('status', 'Role updated.'); + } + + public function destroy(Role $role) + { + abort_if($role->name === 'admin', 422, 'The administrator role cannot be deleted.'); + $role->delete(); + + return back()->with('status', 'Role deleted.'); + } + + private function validated(Request $request, ?Role $role = null): array + { + return $request->validate(['name' => ['required', 'string', 'max:100', Rule::unique('roles', 'name')->ignore($role)], 'permissions' => ['array'], 'permissions.*' => ['string', 'exists:permissions,name']]); + } +} diff --git a/Roles/Providers/RolesServiceProvider.php b/Roles/Providers/RolesServiceProvider.php new file mode 100644 index 0000000..120c1c0 --- /dev/null +++ b/Roles/Providers/RolesServiceProvider.php @@ -0,0 +1,21 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower()); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations')); + $this->loadViewsFrom(module_path(ModuleInfo::name(), 'Resources/views'), ModuleInfo::nameLower()); + } +} diff --git a/Roles/Providers/RouteServiceProvider.php b/Roles/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..a8104fd --- /dev/null +++ b/Roles/Providers/RouteServiceProvider.php @@ -0,0 +1,17 @@ +routes(function (): void { + Route::middleware('web')->group(module_path('Roles', 'Routes/web.php')); + }); + } +} diff --git a/Roles/Resources/views/form.blade.php b/Roles/Resources/views/form.blade.php new file mode 100644 index 0000000..202fec9 --- /dev/null +++ b/Roles/Resources/views/form.blade.php @@ -0,0 +1 @@ +

{{ $role->exists ? 'Edit role' : 'Create role' }}

@csrf @if($role->exists)@method('PUT')@endif
Permissions
@foreach($permissions as $permission)@endforeach
CancelSave
diff --git a/Roles/Resources/views/index.blade.php b/Roles/Resources/views/index.blade.php new file mode 100644 index 0000000..6ccb021 --- /dev/null +++ b/Roles/Resources/views/index.blade.php @@ -0,0 +1 @@ +

Roles & permissions

Control administrative access.

Create role
@if(session('status'))
{{ session('status') }}
@endif
@foreach($roles as $role)
{{ $role->name }}
{{ $role->users_count }} users · {{ $role->permissions_count }} permissions
Edit@if($role->name!=='admin')
@csrf @method('DELETE')Delete
@endif
@endforeach
diff --git a/Roles/Routes/web.php b/Roles/Routes/web.php new file mode 100644 index 0000000..f611599 --- /dev/null +++ b/Roles/Routes/web.php @@ -0,0 +1,8 @@ +prefix('administration/roles')->name('administration.roles.')->group(function (): void { + Route::resource('', RoleController::class)->parameters(['' => 'role'])->except(['show']); +}); diff --git a/Roles/Support/ModuleInfo.php b/Roles/Support/ModuleInfo.php new file mode 100644 index 0000000..339da0a --- /dev/null +++ b/Roles/Support/ModuleInfo.php @@ -0,0 +1,16 @@ +toBe('Roles'); +}); diff --git a/Roles/composer.json b/Roles/composer.json new file mode 100644 index 0000000..0bdaa9f --- /dev/null +++ b/Roles/composer.json @@ -0,0 +1,10 @@ +{ + "name": "absdev/roles-module", + "description": "Roles foundation module for the ABSDEV Laravel application platform.", + "type": "laravel-module", + "autoload": { + "psr-4": { + "Modules\\Roles\\": "" + } + } +} diff --git a/Roles/module.json b/Roles/module.json new file mode 100644 index 0000000..b3f52b7 --- /dev/null +++ b/Roles/module.json @@ -0,0 +1,20 @@ +{ + "name": "Roles", + "alias": "roles", + "version": "1.0.0", + "description": "Roles foundation module for the ABSDEV Laravel application platform.", + "keywords": [ + "laravel", + "modules", + "roles" + ], + "priority": 10, + "providers": [ + "Modules\\Roles\\Providers\\RolesServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [ + "Core" + ] +} diff --git a/Search/Config/config.php b/Search/Config/config.php new file mode 100644 index 0000000..9b3b517 --- /dev/null +++ b/Search/Config/config.php @@ -0,0 +1,6 @@ + 'Search', + 'enabled' => true, +]; diff --git a/Search/Database/Migrations/2026_07_23_000500_create_search_index_table.php b/Search/Database/Migrations/2026_07_23_000500_create_search_index_table.php new file mode 100644 index 0000000..ca700a7 --- /dev/null +++ b/Search/Database/Migrations/2026_07_23_000500_create_search_index_table.php @@ -0,0 +1,30 @@ +id(); + $table->morphs('searchable'); + $table->string('title')->index(); + $table->text('summary')->nullable(); + $table->longText('content')->nullable(); + $table->string('url')->nullable(); + $table->json('metadata')->nullable(); + $table->boolean('is_public')->default(true)->index(); + $table->timestamp('published_at')->nullable()->index(); + $table->timestamps(); + $table->unique(['searchable_type', 'searchable_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('search_index'); + } +}; diff --git a/Search/Http/Controllers/SearchController.php b/Search/Http/Controllers/SearchController.php new file mode 100644 index 0000000..9c591c6 --- /dev/null +++ b/Search/Http/Controllers/SearchController.php @@ -0,0 +1,18 @@ +query('q')); + $results = DB::table('search_index')->when($q !== '', fn ($query) => $query->where(fn ($w) => $w->where('title', 'like', "%{$q}%")->orWhere('summary', 'like', "%{$q}%")->orWhere('content', 'like', "%{$q}%")))->latest('updated_at')->paginate(25)->withQueryString(); + + return view('search::index', compact('results', 'q')); + } +} diff --git a/Search/Providers/RouteServiceProvider.php b/Search/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..d94acd2 --- /dev/null +++ b/Search/Providers/RouteServiceProvider.php @@ -0,0 +1,17 @@ +routes(function (): void { + Route::middleware('web')->group(module_path('Search', 'Routes/web.php')); + }); + } +} diff --git a/Search/Providers/SearchServiceProvider.php b/Search/Providers/SearchServiceProvider.php new file mode 100644 index 0000000..12ad9c9 --- /dev/null +++ b/Search/Providers/SearchServiceProvider.php @@ -0,0 +1,21 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower()); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations')); + $this->loadViewsFrom(module_path(ModuleInfo::name(), 'Resources/views'), ModuleInfo::nameLower()); + } +} diff --git a/Search/Resources/views/index.blade.php b/Search/Resources/views/index.blade.php new file mode 100644 index 0000000..07ea084 --- /dev/null +++ b/Search/Resources/views/index.blade.php @@ -0,0 +1 @@ +

Search index

Inspect content currently available to platform search.

Search
@forelse($results as $result)
{{ $result->title }}
{{ str($result->summary ?: $result->content)->limit(180) }}
{{ $result->searchable_type }} #{{ $result->searchable_id }}
@empty
No indexed content found.
@endforelse
{{ $results->links() }}
diff --git a/Search/Routes/web.php b/Search/Routes/web.php new file mode 100644 index 0000000..ad6c1f7 --- /dev/null +++ b/Search/Routes/web.php @@ -0,0 +1,6 @@ +get('administration/search', [SearchController::class, 'index'])->name('administration.search.index'); diff --git a/Search/Support/ModuleInfo.php b/Search/Support/ModuleInfo.php new file mode 100644 index 0000000..98f93c7 --- /dev/null +++ b/Search/Support/ModuleInfo.php @@ -0,0 +1,16 @@ +toBe('Search'); +}); diff --git a/Search/composer.json b/Search/composer.json new file mode 100644 index 0000000..62e3b1a --- /dev/null +++ b/Search/composer.json @@ -0,0 +1,10 @@ +{ + "name": "absdev/search-module", + "description": "Search foundation module for the ABSDEV Laravel application platform.", + "type": "laravel-module", + "autoload": { + "psr-4": { + "Modules\\Search\\": "" + } + } +} diff --git a/Search/module.json b/Search/module.json new file mode 100644 index 0000000..6eda4a2 --- /dev/null +++ b/Search/module.json @@ -0,0 +1,20 @@ +{ + "name": "Search", + "alias": "search", + "version": "1.0.0", + "description": "Search foundation module for the ABSDEV Laravel application platform.", + "keywords": [ + "laravel", + "modules", + "search" + ], + "priority": 10, + "providers": [ + "Modules\\Search\\Providers\\SearchServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [ + "Core" + ] +} diff --git a/Security/Config/config.php b/Security/Config/config.php new file mode 100644 index 0000000..b4e3156 --- /dev/null +++ b/Security/Config/config.php @@ -0,0 +1,9 @@ + (bool) env('SECURITY_RECORD_AUTH_EVENTS', true), + 'trusted_device_days' => (int) env('SECURITY_TRUSTED_DEVICE_DAYS', 30), + 'session_idle_minutes' => (int) env('SECURITY_SESSION_IDLE_MINUTES', 120), + 'max_failed_logins' => (int) env('SECURITY_MAX_FAILED_LOGINS', 10), + 'lockout_minutes' => (int) env('SECURITY_LOCKOUT_MINUTES', 15), +]; diff --git a/Security/Database/Migrations/2026_07_24_120000_create_security_tables.php b/Security/Database/Migrations/2026_07_24_120000_create_security_tables.php new file mode 100644 index 0000000..a393e34 --- /dev/null +++ b/Security/Database/Migrations/2026_07_24_120000_create_security_tables.php @@ -0,0 +1,76 @@ +id(); + $table->string('name')->default('Default security policy'); + $table->unsignedSmallInteger('minimum_password_length')->default(12); + $table->boolean('require_mixed_case')->default(true); + $table->boolean('require_numbers')->default(true); + $table->boolean('require_symbols')->default(true); + $table->boolean('require_mfa')->default(false); + $table->unsignedInteger('password_expiry_days')->nullable(); + $table->unsignedSmallInteger('password_history')->default(5); + $table->unsignedSmallInteger('max_failed_logins')->default(10); + $table->unsignedSmallInteger('lockout_minutes')->default(15); + $table->unsignedSmallInteger('session_idle_minutes')->default(120); + $table->boolean('single_session')->default(false); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + Schema::create('security_login_events', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('email')->nullable()->index(); + $table->string('event', 30)->index(); + $table->string('ip_address', 45)->nullable()->index(); + $table->text('user_agent')->nullable(); + $table->string('session_id')->nullable()->index(); + $table->string('reason')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamp('occurred_at')->useCurrent(); + $table->timestamps(); + }); + Schema::create('security_devices', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('fingerprint', 64)->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->timestamp('trusted_until')->nullable(); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->timestamps(); + $table->unique(['user_id', 'fingerprint']); + }); + Schema::create('security_ip_rules', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('type', 10)->default('deny'); + $table->string('address', 64); + $table->text('description')->nullable(); + $table->boolean('applies_to_admin')->default(true); + $table->boolean('applies_to_login')->default(true); + $table->boolean('is_active')->default(true); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->index(['type', 'is_active']); + }); + } + + public function down(): void + { + Schema::dropIfExists('security_ip_rules'); + Schema::dropIfExists('security_devices'); + Schema::dropIfExists('security_login_events'); + Schema::dropIfExists('security_policies'); + } +}; diff --git a/Security/Http/Controllers/SecurityController.php b/Security/Http/Controllers/SecurityController.php new file mode 100644 index 0000000..77e2633 --- /dev/null +++ b/Security/Http/Controllers/SecurityController.php @@ -0,0 +1,52 @@ + SecurityPolicy::first(), 'events' => LoginEvent::latest('occurred_at')->paginate(25), 'devices' => TrustedDevice::whereNull('revoked_at')->latest('last_seen_at')->limit(20)->get(), 'rules' => IpRule::latest()->get(), 'failed24h' => LoginEvent::where('event', 'failed')->where('occurred_at', '>=', now()->subDay())->count()]); + } + + public function savePolicy(Request $request): RedirectResponse + { + $data = $request->validate(['minimum_password_length' => ['required', 'integer', 'min:8', 'max:128'], 'password_expiry_days' => ['nullable', 'integer', 'min:1'], 'password_history' => ['required', 'integer', 'min:0', 'max:24'], 'max_failed_logins' => ['required', 'integer', 'min:1', 'max:100'], 'lockout_minutes' => ['required', 'integer', 'min:1', 'max:1440'], 'session_idle_minutes' => ['required', 'integer', 'min:5', 'max:10080']]); + foreach (['require_mixed_case', 'require_numbers', 'require_symbols', 'require_mfa', 'single_session'] as $key) { + $data[$key] = $request->boolean($key); + } + SecurityPolicy::updateOrCreate(['id' => SecurityPolicy::value('id')], $data + ['name' => 'Default security policy', 'is_active' => true]); + + return back()->with('status', 'Security policy updated.'); + } + + public function storeRule(Request $request): RedirectResponse + { + IpRule::create($request->validate(['name' => ['required', 'string', 'max:255'], 'type' => ['required', 'in:allow,deny'], 'address' => ['required', 'string', 'max:64'], 'description' => ['nullable', 'string']]) + ['applies_to_admin' => $request->boolean('applies_to_admin'), 'applies_to_login' => $request->boolean('applies_to_login'), 'is_active' => true, 'created_by' => $request->user()->id]); + + return back()->with('status', 'IP rule created.'); + } + + public function destroyRule(IpRule $rule): RedirectResponse + { + $rule->delete(); + + return back()->with('status', 'IP rule deleted.'); + } + + public function revokeDevice(TrustedDevice $device): RedirectResponse + { + $device->update(['revoked_at' => now()]); + + return back()->with('status', 'Device trust revoked.'); + } +} diff --git a/Security/Http/Middleware/EnforceIpRestrictions.php b/Security/Http/Middleware/EnforceIpRestrictions.php new file mode 100644 index 0000000..e009e1e --- /dev/null +++ b/Security/Http/Middleware/EnforceIpRestrictions.php @@ -0,0 +1,20 @@ +rules->isBlocked((string) $request->ip(), $scope), 403, 'Access is not permitted from this IP address.'); + + return $next($request); + } +} diff --git a/Security/Listeners/RecordFailedLogin.php b/Security/Listeners/RecordFailedLogin.php new file mode 100644 index 0000000..6bcd118 --- /dev/null +++ b/Security/Listeners/RecordFailedLogin.php @@ -0,0 +1,19 @@ +user : null; + LoginEvent::create(['user_id' => $user?->getAuthIdentifier(), 'email' => $user?->email ?? (property_exists($event, 'credentials') ? ($event->credentials['email'] ?? null) : null), 'event' => 'failed', 'ip_address' => $request?->ip(), 'user_agent' => $request?->userAgent(), 'session_id' => $request?->hasSession() ? $request->session()->getId() : null, 'occurred_at' => now()]); + } +} diff --git a/Security/Listeners/RecordLogin.php b/Security/Listeners/RecordLogin.php new file mode 100644 index 0000000..83face4 --- /dev/null +++ b/Security/Listeners/RecordLogin.php @@ -0,0 +1,19 @@ +user : null; + LoginEvent::create(['user_id' => $user?->getAuthIdentifier(), 'email' => $user?->email ?? (property_exists($event, 'credentials') ? ($event->credentials['email'] ?? null) : null), 'event' => 'login', 'ip_address' => $request?->ip(), 'user_agent' => $request?->userAgent(), 'session_id' => $request?->hasSession() ? $request->session()->getId() : null, 'occurred_at' => now()]); + } +} diff --git a/Security/Listeners/RecordLogout.php b/Security/Listeners/RecordLogout.php new file mode 100644 index 0000000..f29feed --- /dev/null +++ b/Security/Listeners/RecordLogout.php @@ -0,0 +1,19 @@ +user : null; + LoginEvent::create(['user_id' => $user?->getAuthIdentifier(), 'email' => $user?->email ?? (property_exists($event, 'credentials') ? ($event->credentials['email'] ?? null) : null), 'event' => 'logout', 'ip_address' => $request?->ip(), 'user_agent' => $request?->userAgent(), 'session_id' => $request?->hasSession() ? $request->session()->getId() : null, 'occurred_at' => now()]); + } +} diff --git a/Security/Models/IpRule.php b/Security/Models/IpRule.php new file mode 100644 index 0000000..2996dfe --- /dev/null +++ b/Security/Models/IpRule.php @@ -0,0 +1,14 @@ + 'boolean', 'applies_to_login' => 'boolean', 'is_active' => 'boolean']; +} diff --git a/Security/Models/LoginEvent.php b/Security/Models/LoginEvent.php new file mode 100644 index 0000000..1fce0ab --- /dev/null +++ b/Security/Models/LoginEvent.php @@ -0,0 +1,14 @@ + 'array', 'occurred_at' => 'datetime']; +} diff --git a/Security/Models/SecurityPolicy.php b/Security/Models/SecurityPolicy.php new file mode 100644 index 0000000..e790c60 --- /dev/null +++ b/Security/Models/SecurityPolicy.php @@ -0,0 +1,12 @@ + 'boolean', 'require_numbers' => 'boolean', 'require_symbols' => 'boolean', 'require_mfa' => 'boolean', 'single_session' => 'boolean', 'is_active' => 'boolean']; +} diff --git a/Security/Models/TrustedDevice.php b/Security/Models/TrustedDevice.php new file mode 100644 index 0000000..6777067 --- /dev/null +++ b/Security/Models/TrustedDevice.php @@ -0,0 +1,14 @@ + 'datetime', 'last_seen_at' => 'datetime', 'revoked_at' => 'datetime']; +} diff --git a/Security/Providers/RouteServiceProvider.php b/Security/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..ed3f4c3 --- /dev/null +++ b/Security/Providers/RouteServiceProvider.php @@ -0,0 +1,14 @@ +group(module_path('Security', 'Routes/web.php')); + } +} diff --git a/Security/Providers/SecurityServiceProvider.php b/Security/Providers/SecurityServiceProvider.php new file mode 100644 index 0000000..cf9e892 --- /dev/null +++ b/Security/Providers/SecurityServiceProvider.php @@ -0,0 +1,33 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Security', 'Config/config.php'), 'security'); + } + + public function boot(Router $router): void + { + $this->loadMigrationsFrom(module_path('Security', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Security', 'Resources/views'), 'security'); + $router->aliasMiddleware('security.ip', EnforceIpRestrictions::class); + Event::listen(Login::class, RecordLogin::class); + Event::listen(Failed::class, RecordFailedLogin::class); + Event::listen(Logout::class, RecordLogout::class); + } +} diff --git a/Security/Resources/views/admin/index.blade.php b/Security/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..b07de13 --- /dev/null +++ b/Security/Resources/views/admin/index.blade.php @@ -0,0 +1,5 @@ +

Security Centre

Identity policy, authentication history, trusted devices and network access controls.

@if(session('status'))
{{ session('status') }}
@endif +
Failed logins (24h)
{{ $failed24h }}
Active trusted devices
{{ $devices->count() }}
IP rules
{{ $rules->count() }}
MFA required
{{ $policy?->require_mfa ? 'Yes' : 'No' }}
+
@csrf @method('PUT')

Security policy

Save policy
+
@csrf

Add IP rule

Create rule
@foreach($rules as $rule)
{{ $rule->name }}{{ $rule->type }}
{{ $rule->address }}
@csrf @method('DELETE')Delete
@endforeach
+
Authentication history
@forelse($events as $event)@empty@endforelse
WhenEventUserIP addressClient
{{ $event->occurred_at?->format('d M Y H:i:s') }}{{ $event->event }}{{ $event->email ?: 'Unknown' }}{{ $event->ip_address ?: '—' }}{{ $event->user_agent ?: '—' }}
No authentication events recorded.
{{ $events->links() }}
diff --git a/Security/Routes/web.php b/Security/Routes/web.php new file mode 100644 index 0000000..b92281e --- /dev/null +++ b/Security/Routes/web.php @@ -0,0 +1,12 @@ +name('administration.security.')->middleware(['auth', 'permission:security.view', 'security.ip:admin'])->group(function () { + Route::get('/', [SecurityController::class, 'index'])->name('index'); + Route::put('/policy', [SecurityController::class, 'savePolicy'])->middleware('permission:security.manage')->name('policy.update'); + Route::post('/ip-rules', [SecurityController::class, 'storeRule'])->middleware('permission:security.manage')->name('rules.store'); + Route::delete('/ip-rules/{rule}', [SecurityController::class, 'destroyRule'])->middleware('permission:security.manage')->name('rules.destroy'); + Route::delete('/devices/{device}', [SecurityController::class, 'revokeDevice'])->middleware('permission:security.manage')->name('devices.revoke'); +}); diff --git a/Security/Services/IpRuleService.php b/Security/Services/IpRuleService.php new file mode 100644 index 0000000..490f000 --- /dev/null +++ b/Security/Services/IpRuleService.php @@ -0,0 +1,39 @@ +where('is_active', true)->where($column, true)->get(); + $allow = $rules->where('type', 'allow'); + if ($allow->isNotEmpty() && ! $allow->contains(fn (IpRule $rule) => $this->matches($ip, $rule->address))) { + return true; + } + + return $rules->where('type', 'deny')->contains(fn (IpRule $rule) => $this->matches($ip, $rule->address)); + } + + private function matches(string $ip, string $rule): bool + { + if ($ip === $rule) { + return true; + } + if (! str_contains($rule, '/')) { + return false; + } + [$subnet,$bits] = explode('/', $rule, 2); + $bits = (int) $bits; + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $mask = -1 << (32 - $bits); + + return (ip2long($ip) & $mask) === (ip2long($subnet) & $mask); + } + + return false; + } +} diff --git a/Security/Tests/Feature/ModuleBootTest.php b/Security/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..7f2e420 --- /dev/null +++ b/Security/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,18 @@ +assertTrue(app('router')->has('administration.security.index')); + } + + public function test_security_middleware_alias_is_registered(): void + { + $this->assertArrayHasKey('security.ip', app('router')->getMiddleware()); + } +} diff --git a/Security/module.json b/Security/module.json new file mode 100644 index 0000000..515918b --- /dev/null +++ b/Security/module.json @@ -0,0 +1 @@ +{"name":"Security","alias":"security","version":"1.0.0","description":"Identity policies, login history, trusted devices, IP restrictions and session security.","keywords":["identity","security","sessions","devices","authentication"],"priority":120,"providers":["Modules\\Security\\Providers\\SecurityServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Users","Roles","Audit"]} diff --git a/Seo/Config/config.php b/Seo/Config/config.php new file mode 100644 index 0000000..b2892d1 --- /dev/null +++ b/Seo/Config/config.php @@ -0,0 +1,6 @@ + 'Seo', + 'enabled' => true, +]; diff --git a/Seo/Database/Migrations/2026_07_23_000400_create_seo_metadata_table.php b/Seo/Database/Migrations/2026_07_23_000400_create_seo_metadata_table.php new file mode 100644 index 0000000..1867c1e --- /dev/null +++ b/Seo/Database/Migrations/2026_07_23_000400_create_seo_metadata_table.php @@ -0,0 +1,36 @@ +id(); + $table->morphs('seoable'); + $table->string('title')->nullable(); + $table->text('description')->nullable(); + $table->string('canonical_url')->nullable(); + $table->string('robots')->default('index,follow'); + $table->string('og_title')->nullable(); + $table->text('og_description')->nullable(); + $table->string('og_image')->nullable(); + $table->string('twitter_card')->default('summary_large_image'); + $table->string('schema_type')->nullable(); + $table->json('schema_data')->nullable(); + $table->boolean('include_in_sitemap')->default(true); + $table->decimal('sitemap_priority', 2, 1)->default(0.5); + $table->string('sitemap_change_frequency')->default('monthly'); + $table->timestamps(); + $table->unique(['seoable_type', 'seoable_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('seo_metadata'); + } +}; diff --git a/Seo/Http/Controllers/SeoController.php b/Seo/Http/Controllers/SeoController.php new file mode 100644 index 0000000..99349c0 --- /dev/null +++ b/Seo/Http/Controllers/SeoController.php @@ -0,0 +1,27 @@ +pluck('value', 'key'); + + return view('seo::index', compact('values')); + } + + public function update(Request $request) + { + $data = $request->validate(['site_title' => ['nullable', 'string', 'max:255'], 'meta_description' => ['nullable', 'string', 'max:500'], 'robots' => ['required', 'string', 'max:100'], 'canonical_base' => ['nullable', 'url', 'max:255']]); + foreach ($data as $key => $value) { + Setting::updateOrCreate(['group' => 'seo', 'key' => $key], ['value' => $value, 'type' => 'string', 'is_public' => true]); + } + +return back()->with('status', 'SEO defaults saved.'); + } +} diff --git a/Seo/Models/SeoMetadata.php b/Seo/Models/SeoMetadata.php new file mode 100644 index 0000000..fa17538 --- /dev/null +++ b/Seo/Models/SeoMetadata.php @@ -0,0 +1,17 @@ + 'array', 'include_in_sitemap' => 'boolean', 'sitemap_priority' => 'decimal:1']; + } +} diff --git a/Seo/Providers/RouteServiceProvider.php b/Seo/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..d2a362d --- /dev/null +++ b/Seo/Providers/RouteServiceProvider.php @@ -0,0 +1,17 @@ +routes(function (): void { + Route::middleware('web')->group(module_path('Seo', 'Routes/web.php')); + }); + } +} diff --git a/Seo/Providers/SeoServiceProvider.php b/Seo/Providers/SeoServiceProvider.php new file mode 100644 index 0000000..1a134fc --- /dev/null +++ b/Seo/Providers/SeoServiceProvider.php @@ -0,0 +1,21 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower()); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations')); + $this->loadViewsFrom(module_path(ModuleInfo::name(), 'Resources/views'), ModuleInfo::nameLower()); + } +} diff --git a/Seo/Resources/views/index.blade.php b/Seo/Resources/views/index.blade.php new file mode 100644 index 0000000..9939a86 --- /dev/null +++ b/Seo/Resources/views/index.blade.php @@ -0,0 +1 @@ +

SEO defaults

Site-wide metadata used when content does not provide its own values.

@if(session('status'))
{{ session('status') }}
@endif
@csrf @method('PUT'){{ old('meta_description',$values['meta_description']??'') }}
Save SEO defaults
diff --git a/Seo/Routes/web.php b/Seo/Routes/web.php new file mode 100644 index 0000000..fa3652f --- /dev/null +++ b/Seo/Routes/web.php @@ -0,0 +1,9 @@ +prefix('administration/seo')->name('administration.seo.')->group(function (): void { + Route::get('/', [SeoController::class, 'index'])->name('index'); + Route::put('/', [SeoController::class, 'update'])->name('update'); +}); diff --git a/Seo/Support/ModuleInfo.php b/Seo/Support/ModuleInfo.php new file mode 100644 index 0000000..ac5145e --- /dev/null +++ b/Seo/Support/ModuleInfo.php @@ -0,0 +1,16 @@ +toBe('Seo'); +}); diff --git a/Seo/composer.json b/Seo/composer.json new file mode 100644 index 0000000..a7eae0a --- /dev/null +++ b/Seo/composer.json @@ -0,0 +1,10 @@ +{ + "name": "absdev/seo-module", + "description": "Seo foundation module for the ABSDEV Laravel application platform.", + "type": "laravel-module", + "autoload": { + "psr-4": { + "Modules\\Seo\\": "" + } + } +} diff --git a/Seo/module.json b/Seo/module.json new file mode 100644 index 0000000..4d886f8 --- /dev/null +++ b/Seo/module.json @@ -0,0 +1,20 @@ +{ + "name": "Seo", + "alias": "seo", + "version": "1.0.0", + "description": "Seo foundation module for the ABSDEV Laravel application platform.", + "keywords": [ + "laravel", + "modules", + "seo" + ], + "priority": 10, + "providers": [ + "Modules\\Seo\\Providers\\SeoServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [ + "Core" + ] +} diff --git a/Settings/Config/config.php b/Settings/Config/config.php new file mode 100644 index 0000000..c9342ba --- /dev/null +++ b/Settings/Config/config.php @@ -0,0 +1,6 @@ + 'Settings', + 'enabled' => true, +]; diff --git a/Settings/Database/Migrations/2026_07_23_000100_create_settings_table.php b/Settings/Database/Migrations/2026_07_23_000100_create_settings_table.php new file mode 100644 index 0000000..62b7134 --- /dev/null +++ b/Settings/Database/Migrations/2026_07_23_000100_create_settings_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('group')->default('general')->index(); + $table->string('key'); + $table->longText('value')->nullable(); + $table->string('type')->default('string'); + $table->boolean('is_public')->default(false); + $table->timestamps(); + $table->unique(['group', 'key']); + }); + } + + public function down(): void + { + Schema::dropIfExists('settings'); + } +}; diff --git a/Settings/Http/Controllers/SettingController.php b/Settings/Http/Controllers/SettingController.php new file mode 100644 index 0000000..ceb3f78 --- /dev/null +++ b/Settings/Http/Controllers/SettingController.php @@ -0,0 +1,109 @@ + $settings->site(), + 'sections' => $this->sections($themes), + ]); + } + + public function update(Request $request, SettingsRepository $settings, Loader $themes): RedirectResponse + { + $rules = []; + foreach ($this->sections($themes) as $fields) { + foreach ($fields as $key => $definition) { + $rules["settings.$key"] = $definition['rules']; + } + } + + $validated = $request->validate($rules); + foreach ($validated['settings'] ?? [] as $key => $value) { + $settings->put($key, $value, 'site', 'string', true); + } + + return back()->with('status', 'Site settings saved. The active frontend and backend themes have been updated.'); + } + + /** @return array>> */ + private function sections(Loader $themes): array + { + $text = ['type' => 'text', 'rules' => ['nullable', 'string', 'max:255']]; + $url = ['type' => 'text', 'rules' => ['nullable', 'string', 'max:2048'], 'placeholder' => '/path or https://example.com']; + $textarea = ['type' => 'textarea', 'rules' => ['nullable', 'string', 'max:5000']]; + + $backendThemes = $themes->all() + ->filter(fn ($theme): bool => $theme->type() === 'backend') + ->mapWithKeys(fn ($theme): array => [$theme->key() => $theme->name()]) + ->all(); + + $frontendThemes = $themes->all() + ->filter(fn ($theme): bool => $theme->type() === 'frontend') + ->mapWithKeys(fn ($theme): array => [$theme->key() => $theme->name()]) + ->all(); + + return [ + 'Themes' => [ + 'backend_theme' => [ + 'type' => 'select', + 'rules' => ['required', 'string', Rule::in(array_keys($backendThemes))], + 'label' => 'Backend theme', + 'help' => 'Used for the authenticated administration area.', + 'options' => $backendThemes, + ], + 'frontend_theme' => [ + 'type' => 'select', + 'rules' => ['required', 'string', Rule::in(array_keys($frontendThemes))], + 'label' => 'Frontend theme', + 'help' => 'Used for the public website.', + 'options' => $frontendThemes, + ], + ], + 'General' => [ + 'site_name' => $text + ['label' => 'Site name', 'help' => 'Used in the header, footer, browser titles and metadata.'], + 'tagline' => $text + ['label' => 'Tagline'], + 'site_description' => $textarea + ['label' => 'Site description'], + 'company_name' => $text + ['label' => 'Legal company name'], + 'site_url' => $url + ['label' => 'Canonical site URL'], + 'copyright' => $text + ['label' => 'Copyright text', 'help' => 'Leave blank to generate it automatically.'], + ], + 'Branding' => [ + 'logo_url' => $url + ['label' => 'Primary logo'], + 'logo_dark_url' => $url + ['label' => 'Dark-mode logo'], + 'favicon_url' => $url + ['label' => 'Favicon'], + ], + 'Contact' => [ + 'contact_email' => ['type' => 'email', 'rules' => ['nullable', 'email', 'max:255'], 'label' => 'Public email'], + 'contact_phone' => $text + ['label' => 'Public telephone'], + 'address' => $textarea + ['label' => 'Address'], + ], + 'Social' => [ + 'facebook_url' => $url + ['label' => 'Facebook'], + 'x_url' => $url + ['label' => 'X / Twitter'], + 'linkedin_url' => $url + ['label' => 'LinkedIn'], + 'instagram_url' => $url + ['label' => 'Instagram'], + ], + 'Account navigation' => [ + 'dashboard_label' => $text + ['label' => 'Dashboard label'], + 'login_label' => $text + ['label' => 'Login label'], + 'registration_label' => $text + ['label' => 'Registration label'], + ], + 'SEO defaults' => [ + 'default_meta_title' => $text + ['label' => 'Default meta title', 'help' => 'Leave blank to use the site name.'], + 'default_meta_description' => $textarea + ['label' => 'Default meta description', 'help' => 'Leave blank to use the site description.'], + ], + ]; + } +} diff --git a/Settings/Models/Setting.php b/Settings/Models/Setting.php new file mode 100644 index 0000000..79c0ed6 --- /dev/null +++ b/Settings/Models/Setting.php @@ -0,0 +1,15 @@ + 'boolean']; + } +} diff --git a/Settings/Providers/RouteServiceProvider.php b/Settings/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..a5a3776 --- /dev/null +++ b/Settings/Providers/RouteServiceProvider.php @@ -0,0 +1,17 @@ +routes(function (): void { + Route::middleware('web')->group(module_path('Settings', 'Routes/web.php')); + }); + } +} diff --git a/Settings/Providers/SettingsServiceProvider.php b/Settings/Providers/SettingsServiceProvider.php new file mode 100644 index 0000000..6a5ea24 --- /dev/null +++ b/Settings/Providers/SettingsServiceProvider.php @@ -0,0 +1,21 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower()); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations')); + $this->loadViewsFrom(module_path(ModuleInfo::name(), 'Resources/views'), ModuleInfo::nameLower()); + } +} diff --git a/Settings/Resources/views/index.blade.php b/Settings/Resources/views/index.blade.php new file mode 100644 index 0000000..fcf669e --- /dev/null +++ b/Settings/Resources/views/index.blade.php @@ -0,0 +1,55 @@ + +
+
+
+

Single source of truth

+

Site settings

+

+ Global identity, branding, contact details and SEO are entered once here and reused by every theme, page, header and footer. +

+
+ Manage menus +
+ + @if (session('status')) +
{{ session('status') }}
+ @endif + +
+ @csrf + @method('PUT') + + @foreach ($sections as $section => $fields) +
+
+

{{ $section }}

+
+
+ @foreach ($fields as $key => $field) + + @endforeach +
+
+ @endforeach + +
+ Save site settings +
+
+
+
diff --git a/Settings/Routes/web.php b/Settings/Routes/web.php new file mode 100644 index 0000000..3f28a23 --- /dev/null +++ b/Settings/Routes/web.php @@ -0,0 +1,9 @@ +prefix('administration/settings')->name('administration.settings.')->group(function (): void { + Route::get('/', [SettingController::class, 'index'])->name('index'); + Route::put('/', [SettingController::class, 'update'])->name('update'); +}); diff --git a/Settings/Services/SettingsRepository.php b/Settings/Services/SettingsRepository.php new file mode 100644 index 0000000..4e80ad7 --- /dev/null +++ b/Settings/Services/SettingsRepository.php @@ -0,0 +1,137 @@ +tableExists()) { + return $default; + } + + /** @var array{value: ?string, type: string}|null $setting */ + $setting = Cache::rememberForever( + "settings.value.v2.$group.$key", + fn (): ?array => Setting::query() + ->where([ + 'group' => $group, + 'key' => $key, + ]) + ->first(['value', 'type']) + ?->only(['value', 'type']) + ); + + return $setting !== null + ? $this->decode($setting['value'], $setting['type']) + : $default; + } + + /** @return array */ + public function group(string $group): array + { + if (! $this->tableExists()) { + return []; + } + + return Cache::rememberForever("settings.group.$group", function () use ($group): array { + return Setting::query() + ->where('group', $group) + ->get() + ->mapWithKeys(fn (Setting $setting): array => [ + $setting->key => $this->decode($setting->value, $setting->type), + ]) + ->all(); + }); + } + + /** @return array */ + public function site(): array + { + $defaults = [ + 'site_name' => config('app.name', 'Content Manager'), + 'tagline' => '', + 'site_description' => 'Modern software for modern companies.', + 'company_name' => config('app.name', 'Content Manager'), + 'site_url' => url('/'), + 'logo_url' => '', + 'logo_dark_url' => '', + 'favicon_url' => '/favicon.ico', + 'contact_email' => '', + 'contact_phone' => '', + 'address' => '', + 'facebook_url' => '', + 'x_url' => '', + 'linkedin_url' => '', + 'instagram_url' => '', + 'copyright' => '', + 'dashboard_label' => 'Dashboard', + 'login_label' => 'Log in', + 'registration_label' => 'Get started', + 'default_meta_title' => '', + 'default_meta_description' => '', + 'backend_theme' => config('stylist.default_backend_theme', 'admin'), + 'frontend_theme' => config('stylist.default_frontend_theme', 'saasland'), + ]; + + return array_replace($defaults, $this->group('site')); + } + + public function put(string $key, mixed $value, string $group = 'general', string $type = 'string', bool $public = false): Setting + { + $setting = Setting::query()->updateOrCreate( + compact('group', 'key'), + [ + 'value' => $this->encode($value, $type), + 'type' => $type, + 'is_public' => $public, + ] + ); + + // Remove both the legacy model cache and the scalar-only cache. + Cache::forget("settings.$group.$key"); + Cache::forget("settings.value.v2.$group.$key"); + Cache::forget("settings.group.$group"); + + return $setting; + } + + private function tableExists(): bool + { + try { + return Schema::hasTable((new Setting())->getTable()); + } catch (\Throwable) { + return false; + } + } + + private function encode(mixed $value, string $type): ?string + { + if ($value === null) { + return null; + } + + return match ($type) { + 'boolean' => filter_var($value, FILTER_VALIDATE_BOOL) ? '1' : '0', + 'integer' => (string) (int) $value, + 'float' => (string) (float) $value, + 'json', 'array' => json_encode($value, JSON_THROW_ON_ERROR), + default => (string) $value, + }; + } + + private function decode(?string $value, string $type): mixed + { + return match ($type) { + 'boolean' => filter_var($value, FILTER_VALIDATE_BOOL), + 'integer' => (int) $value, + 'float' => (float) $value, + 'json', 'array' => filled($value) ? json_decode($value, true, 512, JSON_THROW_ON_ERROR) : [], + default => $value, + }; + } +} diff --git a/Settings/Support/ModuleInfo.php b/Settings/Support/ModuleInfo.php new file mode 100644 index 0000000..6c5d3ee --- /dev/null +++ b/Settings/Support/ModuleInfo.php @@ -0,0 +1,16 @@ +toBe('Settings'); +}); diff --git a/Settings/Tests/Feature/SiteSettingsSingleSourceTest.php b/Settings/Tests/Feature/SiteSettingsSingleSourceTest.php new file mode 100644 index 0000000..41b4f99 --- /dev/null +++ b/Settings/Tests/Feature/SiteSettingsSingleSourceTest.php @@ -0,0 +1,9 @@ +put('site_name', 'Unified CMS', 'site', 'string', true); + expect($repository->site()['site_name'])->toBe('Unified CMS'); +}); diff --git a/Settings/Tests/Feature/ThemeSelectionSettingsTest.php b/Settings/Tests/Feature/ThemeSelectionSettingsTest.php new file mode 100644 index 0000000..cae583a --- /dev/null +++ b/Settings/Tests/Feature/ThemeSelectionSettingsTest.php @@ -0,0 +1,27 @@ +put('frontend_theme', 'swiss', 'site', 'string', true); + $settings->put('backend_theme', 'admin', 'site', 'string', true); + + expect($settings->site()) + ->frontend_theme->toBe('swiss') + ->backend_theme->toBe('admin'); + + expect(app(Stylist::class)->frontend()->key())->toBe('swiss') + ->and(app(Stylist::class)->backend()->key())->toBe('admin'); +}); + +it('falls back to configured themes when stored theme keys are unavailable', function (): void { + $settings = app(SettingsRepository::class); + $settings->put('frontend_theme', 'missing-theme', 'site', 'string', true); + $settings->put('backend_theme', 'missing-theme', 'site', 'string', true); + + expect(app(Stylist::class)->frontend()->key())->toBe(config('stylist.default_frontend_theme')) + ->and(app(Stylist::class)->backend()->key())->toBe(config('stylist.default_backend_theme')); +}); diff --git a/Settings/composer.json b/Settings/composer.json new file mode 100644 index 0000000..2701db4 --- /dev/null +++ b/Settings/composer.json @@ -0,0 +1,10 @@ +{ + "name": "absdev/settings-module", + "description": "Settings foundation module for the ABSDEV Laravel application platform.", + "type": "laravel-module", + "autoload": { + "psr-4": { + "Modules\\Settings\\": "" + } + } +} diff --git a/Settings/module.json b/Settings/module.json new file mode 100644 index 0000000..a65856b --- /dev/null +++ b/Settings/module.json @@ -0,0 +1,20 @@ +{ + "name": "Settings", + "alias": "settings", + "version": "1.0.0", + "description": "Settings foundation module for the ABSDEV Laravel application platform.", + "keywords": [ + "laravel", + "modules", + "settings" + ], + "priority": 10, + "providers": [ + "Modules\\Settings\\Providers\\SettingsServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [ + "Core" + ] +} diff --git a/Sitemap/Config/config.php b/Sitemap/Config/config.php new file mode 100644 index 0000000..02edfd4 --- /dev/null +++ b/Sitemap/Config/config.php @@ -0,0 +1,3 @@ + 'Sitemap']; diff --git a/Sitemap/Http/Controllers/SitemapController.php b/Sitemap/Http/Controllers/SitemapController.php new file mode 100644 index 0000000..3486a49 --- /dev/null +++ b/Sitemap/Http/Controllers/SitemapController.php @@ -0,0 +1,23 @@ +push(['loc' => url('/'), 'updated_at' => now()]); + Page::query()->where('status', 'published')->get()->each(fn ($r) => $urls->push(['loc' => route('pages.public.show', $r->slug), 'updated_at' => $r->updated_at])); + Blog::published()->get()->each(fn ($r) => $urls->push(['loc' => route('blog.public.show', $r->slug), 'updated_at' => $r->updated_at])); + News::published()->get()->each(fn ($r) => $urls->push(['loc' => route('news.public.show', $r->slug), 'updated_at' => $r->updated_at])); + + return response()->view('sitemap::index', compact('urls'))->header('Content-Type', 'application/xml; charset=UTF-8'); + } +} diff --git a/Sitemap/Providers/RouteServiceProvider.php b/Sitemap/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..d3894cc --- /dev/null +++ b/Sitemap/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +group(module_path('Sitemap', 'Routes/web.php')); + } +} diff --git a/Sitemap/Providers/SitemapServiceProvider.php b/Sitemap/Providers/SitemapServiceProvider.php new file mode 100644 index 0000000..4549e61 --- /dev/null +++ b/Sitemap/Providers/SitemapServiceProvider.php @@ -0,0 +1,20 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Sitemap', 'Config/config.php'), 'sitemap'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Sitemap', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Sitemap', 'Resources/views'), 'sitemap'); + } +} diff --git a/Sitemap/Resources/views/index.blade.php b/Sitemap/Resources/views/index.blade.php new file mode 100644 index 0000000..0464fe2 --- /dev/null +++ b/Sitemap/Resources/views/index.blade.php @@ -0,0 +1,7 @@ +{!! '' !!} +@foreach($urls as $url) + + {{ $url['loc'] }} + {{ $url['updated_at']->toAtomString() }} + + @endforeach diff --git a/Sitemap/Routes/web.php b/Sitemap/Routes/web.php new file mode 100644 index 0000000..e6ebc68 --- /dev/null +++ b/Sitemap/Routes/web.php @@ -0,0 +1,6 @@ +name('sitemap.index'); diff --git a/Sitemap/Tests/Feature/ModuleBootTest.php b/Sitemap/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..4b1361f --- /dev/null +++ b/Sitemap/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,13 @@ +assertSame('Sitemap', config('sitemap.name')); + } +} diff --git a/Sitemap/module.json b/Sitemap/module.json new file mode 100644 index 0000000..ecf9761 --- /dev/null +++ b/Sitemap/module.json @@ -0,0 +1,12 @@ +{ + "name": "Sitemap", + "alias": "sitemap", + "version": "1.0.0", + "description": "Sitemap publishing module.", + "keywords": ["publishing", "sitemap"], + "priority": 30, + "providers": ["Modules\\Sitemap\\Providers\\SitemapServiceProvider"], + "aliases": {}, + "files": [], + "requires": ["Core", "Administration"] +} diff --git a/Sites/Concerns/BelongsToSites.php b/Sites/Concerns/BelongsToSites.php new file mode 100644 index 0000000..7634d6e --- /dev/null +++ b/Sites/Concerns/BelongsToSites.php @@ -0,0 +1,32 @@ +morphToMany(Site::class, 'content', 'site_content', 'content_id', 'site_id')->withPivot('is_primary')->withTimestamps(); + } + + public function scopeForCurrentSite(Builder $query): Builder + { + $site = app(SiteManager::class)->current(); + + return $site ? $query->whereHas('sites', fn (Builder $sites) => $sites->whereKey($site->id)) : $query; + } + + public function assignToSite(Site|int $site, bool $primary = false): void + { + $id = $site instanceof Site ? $site->getKey() : $site; + if ($primary) { + $this->sites()->updateExistingPivot($this->sites()->pluck('sites.id'), ['is_primary' => false]); + } + $this->sites()->syncWithoutDetaching([$id => ['is_primary' => $primary]]); + } +} diff --git a/Sites/Config/config.php b/Sites/Config/config.php new file mode 100644 index 0000000..4941723 --- /dev/null +++ b/Sites/Config/config.php @@ -0,0 +1,16 @@ + true, + 'session_key' => 'platform_site_id', + 'central_domains' => array_values(array_filter(explode(',', (string) env('CENTRAL_DOMAINS', '')))), + 'content_types' => [ + 'pages' => Page::class, + 'blog' => Blog::class, + 'news' => News::class, + ], +]; diff --git a/Sites/Contracts/SiteAwareModule.php b/Sites/Contracts/SiteAwareModule.php new file mode 100644 index 0000000..61f13f5 --- /dev/null +++ b/Sites/Contracts/SiteAwareModule.php @@ -0,0 +1,10 @@ +id(); + $table->string('name'); + $table->string('key')->unique(); + $table->string('status')->default('active'); + $table->string('theme')->nullable(); + $table->string('default_locale', 20)->default('en'); + $table->string('timezone')->default('UTC'); + $table->boolean('is_default')->default(false); + $table->timestamps(); + }); + Schema::create('site_domains', function (Blueprint $table): void { + $table->id(); + $table->foreignId('site_id')->constrained()->cascadeOnDelete(); + $table->string('domain')->unique(); + $table->boolean('is_primary')->default(false); + $table->boolean('redirect_to_primary')->default(false); + $table->boolean('force_https')->default(true); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + Schema::create('site_settings', function (Blueprint $table): void { + $table->id(); + $table->foreignId('site_id')->constrained()->cascadeOnDelete(); + $table->string('group')->default('general'); + $table->string('key'); + $table->longText('value')->nullable(); + $table->string('type')->default('string'); + $table->timestamps(); + $table->unique(['site_id', 'key']); + }); + Schema::create('site_content', function (Blueprint $table): void { + $table->id(); + $table->foreignId('site_id')->constrained()->cascadeOnDelete(); + $table->string('content_type'); + $table->unsignedBigInteger('content_id'); + $table->boolean('is_primary')->default(false); + $table->timestamps(); + $table->unique(['site_id', 'content_type', 'content_id']); + $table->index(['content_type', 'content_id']); + }); + Schema::create('site_user', function (Blueprint $table): void { + $table->foreignId('site_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('role')->default('editor'); + $table->timestamps(); + $table->primary(['site_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('site_user'); + Schema::dropIfExists('site_content'); + Schema::dropIfExists('site_settings'); + Schema::dropIfExists('site_domains'); + Schema::dropIfExists('sites'); + } +}; diff --git a/Sites/Database/Seeders/SiteSeeder.php b/Sites/Database/Seeders/SiteSeeder.php new file mode 100644 index 0000000..905f213 --- /dev/null +++ b/Sites/Database/Seeders/SiteSeeder.php @@ -0,0 +1,16 @@ +updateOrCreate(['key' => 'default'], ['name' => 'Default Site', 'status' => 'active', 'default_locale' => config('app.locale', 'en'), 'timezone' => config('app.timezone', 'UTC'), 'is_default' => true]); + $site->domains()->updateOrCreate(['domain' => $host], ['is_primary' => true, 'redirect_to_primary' => false, 'force_https' => str_starts_with((string) config('app.url'), 'https://'), 'is_active' => true]); + } +} diff --git a/Sites/Http/Controllers/DomainController.php b/Sites/Http/Controllers/DomainController.php new file mode 100644 index 0000000..90e2dca --- /dev/null +++ b/Sites/Http/Controllers/DomainController.php @@ -0,0 +1,33 @@ +validate(['domain' => ['required', 'string', 'max:255', 'regex:/^[a-z0-9.-]+$/i', Rule::unique('site_domains', 'domain')], 'is_primary' => ['sometimes', 'boolean'], 'redirect_to_primary' => ['sometimes', 'boolean'], 'force_https' => ['sometimes', 'boolean'], 'is_active' => ['sometimes', 'boolean']]); + if ($request->boolean('is_primary')) { + $site->domains()->update(['is_primary' => false]); + } + $site->domains()->create($data + ['is_primary' => $request->boolean('is_primary'), 'redirect_to_primary' => $request->boolean('redirect_to_primary'), 'force_https' => $request->boolean('force_https'), 'is_active' => $request->boolean('is_active', true)]); + + return back()->with('success', 'Domain added.'); + } + + public function destroy(Site $site, SiteDomain $domain): RedirectResponse + { + abort_unless($domain->site_id === $site->id, 404); + abort_if($domain->is_primary && $site->domains()->count() === 1, 422, 'A site must retain a domain.'); + $domain->delete(); + + return back()->with('success', 'Domain removed.'); + } +} diff --git a/Sites/Http/Controllers/SiteController.php b/Sites/Http/Controllers/SiteController.php new file mode 100644 index 0000000..3e719f9 --- /dev/null +++ b/Sites/Http/Controllers/SiteController.php @@ -0,0 +1,68 @@ + Site::query()->with('domains')->orderByDesc('is_default')->orderBy('name')->paginate(20)]); + } + + public function create(): View + { + return view('sites::admin.sites.form', ['site' => new Site]); + } + + public function store(Request $request): RedirectResponse + { + $site = Site::query()->create($this->validated($request)); + + return redirect()->route('administration.sites.edit', $site)->with('success', 'Site created.'); + } + + public function edit(Site $site): View + { + return view('sites::admin.sites.form', ['site' => $site->load(['domains', 'settings'])]); + } + + public function update(Request $request, Site $site): RedirectResponse + { + DB::transaction(function () use ($request, $site): void { + $data = $this->validated($request, $site); + if ($data['is_default']) { + Site::query()->whereKeyNot($site->id)->update(['is_default' => false]); + } $site->update($data); + }); + + return back()->with('success', 'Site updated.'); + } + + public function destroy(Site $site): RedirectResponse + { + abort_if($site->is_default, 422, 'The default site cannot be deleted.'); + $site->delete(); + + return redirect()->route('administration.sites.index')->with('success', 'Site deleted.'); + } + + public function switch(Request $request, Site $site): RedirectResponse + { + $request->session()->put(config('sites.session_key'), $site->id); + + return back()->with('success', "Site context changed to {$site->name}."); + } + + private function validated(Request $request, ?Site $site = null): array + { + return $request->validate(['name' => ['required', 'string', 'max:150'], 'key' => ['required', 'alpha_dash', 'max:100', Rule::unique('sites', 'key')->ignore($site)], 'status' => ['required', Rule::in(['active', 'inactive', 'maintenance'])], 'theme' => ['nullable', 'string', 'max:100'], 'default_locale' => ['required', 'string', 'max:20'], 'timezone' => ['required', 'timezone'], 'is_default' => ['sometimes', 'boolean']]) + ['is_default' => $request->boolean('is_default')]; + } +} diff --git a/Sites/Http/Middleware/ResolveSite.php b/Sites/Http/Middleware/ResolveSite.php new file mode 100644 index 0000000..fc3207d --- /dev/null +++ b/Sites/Http/Middleware/ResolveSite.php @@ -0,0 +1,24 @@ +sites->resolve($request); + if ($site) { + config(['app.url' => $request->getSchemeAndHttpHost(), 'app.timezone' => $site->timezone ?: config('app.timezone')]); + date_default_timezone_set((string) config('app.timezone')); + } + + return $next($request); + } +} diff --git a/Sites/Models/Site.php b/Sites/Models/Site.php new file mode 100644 index 0000000..a9c2689 --- /dev/null +++ b/Sites/Models/Site.php @@ -0,0 +1,44 @@ + 'boolean']; + } + + public function domains(): HasMany + { + return $this->hasMany(SiteDomain::class); + } + + public function settings(): HasMany + { + return $this->hasMany(SiteSetting::class); + } + + public function assignments(): HasMany + { + return $this->hasMany(SiteContent::class); + } + + public function scopeActive(Builder $query): Builder + { + return $query->where('status', 'active'); + } + + public function setting(string $key, mixed $default = null): mixed + { + $setting = $this->relationLoaded('settings') ? $this->settings->firstWhere('key', $key) : $this->settings()->where('key', $key)->first(); + + return $setting?->typedValue() ?? $default; + } +} diff --git a/Sites/Models/SiteContent.php b/Sites/Models/SiteContent.php new file mode 100644 index 0000000..c359fbb --- /dev/null +++ b/Sites/Models/SiteContent.php @@ -0,0 +1,29 @@ + 'boolean']; + } + + public function site(): BelongsTo + { + return $this->belongsTo(Site::class); + } + + public function content(): MorphTo + { + return $this->morphTo(__FUNCTION__, 'content_type', 'content_id'); + } +} diff --git a/Sites/Models/SiteDomain.php b/Sites/Models/SiteDomain.php new file mode 100644 index 0000000..b92e2cf --- /dev/null +++ b/Sites/Models/SiteDomain.php @@ -0,0 +1,21 @@ + 'boolean', 'redirect_to_primary' => 'boolean', 'force_https' => 'boolean', 'is_active' => 'boolean']; + } + + public function site(): BelongsTo + { + return $this->belongsTo(Site::class); + } +} diff --git a/Sites/Models/SiteSetting.php b/Sites/Models/SiteSetting.php new file mode 100644 index 0000000..249314a --- /dev/null +++ b/Sites/Models/SiteSetting.php @@ -0,0 +1,26 @@ +belongsTo(Site::class); + } + + public function typedValue(): mixed + { + return match ($this->type) { + 'boolean' => filter_var($this->value, FILTER_VALIDATE_BOOLEAN), + 'integer' => (int) $this->value, + 'json' => json_decode((string) $this->value, true), + default => $this->value, + }; + } +} diff --git a/Sites/Providers/RouteServiceProvider.php b/Sites/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..f4820fa --- /dev/null +++ b/Sites/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +group(module_path('Sites', 'Routes/web.php')); + } +} diff --git a/Sites/Providers/SitesServiceProvider.php b/Sites/Providers/SitesServiceProvider.php new file mode 100644 index 0000000..fbfd18f --- /dev/null +++ b/Sites/Providers/SitesServiceProvider.php @@ -0,0 +1,22 @@ +mergeConfigFrom(module_path('Sites', 'Config/config.php'), 'sites'); + $this->app->singleton(SiteManager::class); + $this->app->register(RouteServiceProvider::class); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Sites', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Sites', 'Resources/views'), 'sites'); + } +} diff --git a/Sites/Resources/views/admin/sites/form.blade.php b/Sites/Resources/views/admin/sites/form.blade.php new file mode 100644 index 0000000..987ee09 --- /dev/null +++ b/Sites/Resources/views/admin/sites/form.blade.php @@ -0,0 +1 @@ +
{{ $site->exists ? 'Edit site' : 'New site' }}Configure the site identity, theme and regional defaults.
@csrf @if($site->exists) @method('PUT') @endif
Default site
Save siteCancel
@if($site->exists)
Domains
@foreach($site->domains as $domain)
{{ $domain->domain }} @if($domain->is_primary)Primary@endif
@csrf @method('DELETE')Remove
@endforeach
@csrf
PrimaryForce HTTPSActive
Add domain
@endif
diff --git a/Sites/Resources/views/admin/sites/index.blade.php b/Sites/Resources/views/admin/sites/index.blade.php new file mode 100644 index 0000000..dc7414e --- /dev/null +++ b/Sites/Resources/views/admin/sites/index.blade.php @@ -0,0 +1 @@ +
SitesManage websites, domains, themes and regional defaults.
New site
@foreach($sites as $site)@endforeach
SiteDomainsThemeStatus
{{ $site->name }} @if($site->is_default)Default@endif{{ $site->domains->pluck('domain')->join(', ') ?: 'No domains' }}{{ $site->theme ?: 'Inherited' }}{{ ucfirst($site->status) }}Edit
{{ $sites->links() }}
diff --git a/Sites/Routes/web.php b/Sites/Routes/web.php new file mode 100644 index 0000000..78ebd55 --- /dev/null +++ b/Sites/Routes/web.php @@ -0,0 +1,12 @@ +prefix('administration')->name('administration.')->group(function (): void { + Route::post('sites/{site}/switch', [SiteController::class, 'switch'])->name('sites.switch'); + Route::resource('sites', SiteController::class)->except('show'); + Route::post('sites/{site}/domains', [DomainController::class, 'store'])->name('sites.domains.store'); + Route::delete('sites/{site}/domains/{domain}', [DomainController::class, 'destroy'])->name('sites.domains.destroy'); +}); diff --git a/Sites/Services/SiteManager.php b/Sites/Services/SiteManager.php new file mode 100644 index 0000000..c9e1306 --- /dev/null +++ b/Sites/Services/SiteManager.php @@ -0,0 +1,76 @@ +current; + } + + public function setCurrent(?Site $site): void + { + $this->current = $site; + + if ($site) { + app()->instance(Site::class, $site); + app()->instance('currentSite', $site); + } + } + + public function all(): Collection + { + if (! $this->tablesExist()) { + return collect(); + } + + return Site::query() + ->active() + ->orderByDesc('is_default') + ->orderBy('name') + ->get(); + } + + public function resolve(Request $request): ?Site + { + if (! $this->tablesExist()) { + $this->setCurrent(null); + + return null; + } + + $host = strtolower(preg_replace('/:\d+$/', '', $request->getHost())); + + $site = Site::query() + ->with(['domains', 'settings']) + ->whereHas('domains', fn ($query) => $query + ->where('domain', $host) + ->where('is_active', true)) + ->first(); + + if (! $site && config('sites.fallback_to_default')) { + $site = Site::query() + ->with(['domains', 'settings']) + ->active() + ->where('is_default', true) + ->first(); + } + + $this->setCurrent($site); + + return $site; + } + + private function tablesExist(): bool + { + return Schema::hasTable('sites') && Schema::hasTable('site_domains'); + } +} diff --git a/Sites/Tests/Feature/SitesModuleBootTest.php b/Sites/Tests/Feature/SitesModuleBootTest.php new file mode 100644 index 0000000..94916ed --- /dev/null +++ b/Sites/Tests/Feature/SitesModuleBootTest.php @@ -0,0 +1,14 @@ +assertTrue(app('router')->has('administration.sites.index')); + $this->assertTrue(app('router')->has('administration.sites.domains.store')); + } +} diff --git a/Sites/module.json b/Sites/module.json new file mode 100644 index 0000000..a8886dd --- /dev/null +++ b/Sites/module.json @@ -0,0 +1,9 @@ +{ + "name": "Sites", + "alias": "sites", + "description": "Multi-site domains, context, settings, content assignments and administration.", + "keywords": ["multisite", "sites", "domains", "tenancy"], + "priority": 35, + "providers": ["Modules\\Sites\\Providers\\SitesServiceProvider"], + "files": [] +} diff --git a/Stylist/Config/config.php b/Stylist/Config/config.php new file mode 100644 index 0000000..71b15d3 --- /dev/null +++ b/Stylist/Config/config.php @@ -0,0 +1,29 @@ + env('STYLIST_DEFAULT_THEME', 'admin'), + 'default_backend_theme' => env('STYLIST_BACKEND_THEME', 'admin'), + 'default_frontend_theme' => env('STYLIST_FRONTEND_THEME', 'saasland'), + + /* + | Themes are application resources, never module resources. + | Every theme is self-contained and owns its views and assets. + */ + 'themes_path' => env('STYLIST_THEMES_PATH', resource_path('themes')), + + 'tenant_theme_column' => 'theme_key', + 'tenant_brand_columns' => [ + 'brand_primary' => 'brand-primary', + 'brand_secondary' => 'brand-secondary', + 'brand_accent' => 'brand-accent', + ], + + 'fallback_variables' => [ + 'brand-primary' => '#2563eb', + 'brand-secondary' => '#0f172a', + 'brand-accent' => '#22c55e', + 'surface' => '#ffffff', + 'surface-muted' => '#f8fafc', + 'text-primary' => '#0f172a', + ], +]; diff --git a/Stylist/Console/CreateThemeCommand.php b/Stylist/Console/CreateThemeCommand.php new file mode 100644 index 0000000..c7e1dff --- /dev/null +++ b/Stylist/Console/CreateThemeCommand.php @@ -0,0 +1,64 @@ +argument('key'))); + if (! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $key)) { + $this->error('Theme keys may contain lowercase letters, numbers, dashes, and underscores only.'); + + return self::FAILURE; + } + + $path = rtrim((string) config('stylist.themes_path'), '/\\').DIRECTORY_SEPARATOR.$key; + if ($this->files->exists($path)) { + $this->error("Theme [{$key}] already exists."); + + return self::FAILURE; + } + + $parent = trim((string) $this->option('parent')); + $name = trim((string) ($this->option('name') ?: str($key)->headline())); + $this->files->ensureDirectoryExists($path.'/views'); + + $manifest = [ + 'manifest_version' => 1, + 'name' => $name, + 'slug' => $key, + 'version' => '1.0.0', + 'description' => "Child theme for {$name}.", + 'extends' => $parent !== '' ? $parent : null, + 'entry_view' => 'home', + 'views' => true, + 'variables' => [], + 'settings' => [], + ]; + + if ($manifest['extends'] === null) { + unset($manifest['extends']); + } + + $this->files->put($path.'/theme.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES).PHP_EOL); + $this->files->put($path.'/views/.gitkeep', ''); + + $this->info("Theme [{$key}] created at {$path}."); + $this->line('Shared views will resolve through its parent; only add genuine overrides.'); + + return self::SUCCESS; + } +} diff --git a/Stylist/Console/FindDuplicateViewsCommand.php b/Stylist/Console/FindDuplicateViewsCommand.php new file mode 100644 index 0000000..2304ca1 --- /dev/null +++ b/Stylist/Console/FindDuplicateViewsCommand.php @@ -0,0 +1,40 @@ +inspector->duplicateViews(); + if ($duplicates === []) { + $this->info('No duplicate theme views found.'); + + return self::SUCCESS; + } + + $rows = []; + foreach ($duplicates as $view => $owners) { + foreach ($owners as $owner) { + $rows[] = [$view, $owner['theme'], $owner['path']]; + } + } + + $this->table(['View', 'Theme', 'Path'], $rows); + $this->error(count($duplicates).' duplicate view task(s) found.'); + + return self::FAILURE; + } +} diff --git a/Stylist/Console/InspectThemesCommand.php b/Stylist/Console/InspectThemesCommand.php new file mode 100644 index 0000000..0764a1f --- /dev/null +++ b/Stylist/Console/InspectThemesCommand.php @@ -0,0 +1,28 @@ +inspector->themes(); + $this->table(['Key', 'Name', 'Parent', 'Entry view', 'Views', 'Path'], array_map(fn (array $theme): array => [ + $theme['key'], $theme['name'], $theme['parent'] ?? '—', $theme['entry_view'], $theme['views'], $theme['path'], + ], $themes)); + + return self::SUCCESS; + } +} diff --git a/Stylist/Console/PublishAssetsCommand.php b/Stylist/Console/PublishAssetsCommand.php new file mode 100644 index 0000000..ab661fa --- /dev/null +++ b/Stylist/Console/PublishAssetsCommand.php @@ -0,0 +1,34 @@ +option('force'); + + $this->call('vendor:publish', [ + '--tag' => 'stylist-config', + '--force' => $force, + ]); + + $themesPath = (string) config('stylist.themes_path', resource_path('themes')); + + if (! is_dir($themesPath) && ! mkdir($themesPath, 0775, true) && ! is_dir($themesPath)) { + $this->components->error("Unable to create theme directory: {$themesPath}"); + + return self::FAILURE; + } + + $this->components->info("Stylist config published. Themes remain in: {$themesPath}"); + + return self::SUCCESS; + } +} diff --git a/Stylist/Console/ThemeDoctorCommand.php b/Stylist/Console/ThemeDoctorCommand.php new file mode 100644 index 0000000..d59cb3d --- /dev/null +++ b/Stylist/Console/ThemeDoctorCommand.php @@ -0,0 +1,38 @@ +inspector->issues(); + if ($issues === []) { + $this->info('Stylist is healthy. No theme issues found.'); + + return self::SUCCESS; + } + + $this->table(['Severity', 'Theme', 'Message'], array_map(fn (array $issue): array => [ + strtoupper($issue['severity']), $issue['theme'], $issue['message'], + ], $issues)); + + $errors = count(array_filter($issues, fn (array $issue): bool => $issue['severity'] === 'error')); + $warnings = count($issues) - $errors; + $this->line("{$errors} error(s), {$warnings} warning(s)."); + + return $errors > 0 ? self::FAILURE : self::SUCCESS; + } +} diff --git a/Stylist/Console/ValidateThemeCommand.php b/Stylist/Console/ValidateThemeCommand.php new file mode 100644 index 0000000..5e21f93 --- /dev/null +++ b/Stylist/Console/ValidateThemeCommand.php @@ -0,0 +1,108 @@ +resolveThemePath((string) $this->argument('theme')); + $theme = $this->loader->fromPath($path); + $issues = $this->validator->validate($theme); + + $this->info("Theme: {$theme->name()} ({$theme->key()})"); + $this->line('Manifest schema: '.$theme->manifestVersion()); + + if ($issues === []) { + $this->info('Theme validation passed with no issues.'); + + return self::SUCCESS; + } + + $this->table( + ['Severity', 'File', 'Message'], + array_map(fn (array $issue) => [ + strtoupper($issue['severity']), + $issue['file'], + $issue['message'], + ], $issues), + ); + + $errors = count(array_filter($issues, fn (array $issue) => $issue['severity'] === 'error')); + $warnings = count($issues) - $errors; + $this->line("{$errors} error(s), {$warnings} warning(s)."); + + return $errors > 0 ? self::FAILURE : self::SUCCESS; + } catch (\Throwable $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } finally { + if ($temporary !== null) { + $this->files->deleteDirectory($temporary); + } + } + } + + /** @return array{0:string,1:?string} */ + private function resolveThemePath(string $input): array + { + if ($this->files->isDirectory($input)) { + return [realpath($input) ?: $input, null]; + } + + if ($this->files->isFile($input) && strtolower(pathinfo($input, PATHINFO_EXTENSION)) === 'zip') { + $temporary = storage_path('app/stylist/validate-'.bin2hex(random_bytes(8))); + $this->files->ensureDirectoryExists($temporary); + + $zip = new ZipArchive; + if ($zip->open($input) !== true) { + throw new RuntimeException('The theme ZIP could not be opened.'); + } + + for ($index = 0; $index < $zip->numFiles; $index++) { + $name = (string) $zip->getNameIndex($index); + if (str_contains($name, '..') || str_starts_with($name, '/') || str_contains($name, "\0")) { + $zip->close(); + throw new RuntimeException('The ZIP contains an unsafe path.'); + } + } + + $zip->extractTo($temporary); + $zip->close(); + + $manifest = collect($this->files->allFiles($temporary)) + ->first(fn ($file) => $file->getFilename() === 'theme.json'); + + if (! $manifest) { + throw new RuntimeException('The package does not contain theme.json.'); + } + + return [$manifest->getPath(), $temporary]; + } + + return [$this->loader->find($input)->path(), null]; + } +} diff --git a/Stylist/Database/Migrations/2026_07_08_000000_add_stylist_fields_to_companies_table.php b/Stylist/Database/Migrations/2026_07_08_000000_add_stylist_fields_to_companies_table.php new file mode 100644 index 0000000..2396070 --- /dev/null +++ b/Stylist/Database/Migrations/2026_07_08_000000_add_stylist_fields_to_companies_table.php @@ -0,0 +1,56 @@ +string('theme_key')->default('default')->after('timezone'); + } + + if (! Schema::hasColumn('companies', 'brand_primary')) { + $table->string('brand_primary')->nullable()->after('theme_key'); + } + + if (! Schema::hasColumn('companies', 'brand_secondary')) { + $table->string('brand_secondary')->nullable()->after('brand_primary'); + } + + if (! Schema::hasColumn('companies', 'brand_accent')) { + $table->string('brand_accent')->nullable()->after('brand_secondary'); + } + + if (! Schema::hasColumn('companies', 'logo_path')) { + $table->string('logo_path')->nullable()->after('brand_accent'); + } + + if (! Schema::hasColumn('companies', 'theme_settings')) { + $table->json('theme_settings')->nullable()->after('logo_path'); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('companies')) { + return; + } + + Schema::table('companies', function (Blueprint $table): void { + foreach (['theme_settings', 'logo_path', 'brand_accent', 'brand_secondary', 'brand_primary', 'theme_key'] as $column) { + if (Schema::hasColumn('companies', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/Stylist/Database/Migrations/2026_07_21_000001_create_stylist_theme_settings_table.php b/Stylist/Database/Migrations/2026_07_21_000001_create_stylist_theme_settings_table.php new file mode 100644 index 0000000..57b6955 --- /dev/null +++ b/Stylist/Database/Migrations/2026_07_21_000001_create_stylist_theme_settings_table.php @@ -0,0 +1,24 @@ +id(); + $table->string('theme_key')->unique(); + $table->string('variant')->default('light'); + $table->json('settings')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('stylist_theme_settings'); + } +}; diff --git a/Stylist/Diagnostics/ThemeInspector.php b/Stylist/Diagnostics/ThemeInspector.php new file mode 100644 index 0000000..371d543 --- /dev/null +++ b/Stylist/Diagnostics/ThemeInspector.php @@ -0,0 +1,147 @@ +> */ + public function themes(): array + { + return $this->loader->all()->map(function (Theme $theme): array { + return [ + 'key' => $theme->key(), + 'name' => $theme->name(), + 'parent' => $theme->parent(), + 'path' => $theme->path(), + 'views' => count($this->viewFiles($theme)), + 'entry_view' => $theme->entryView(), + ]; + })->values()->all(); + } + + /** @return array> */ + public function duplicateViews(): array + { + $ownership = []; + + foreach ($this->loader->all() as $theme) { + foreach ($this->viewFiles($theme) as $relative => $absolute) { + $ownership[$relative][] = [ + 'theme' => $theme->key(), + 'path' => $absolute, + ]; + } + } + + return array_filter($ownership, fn (array $owners): bool => count($owners) > 1); + } + + /** @return array */ + public function issues(): array + { + $issues = []; + $themes = $this->loader->all(); + + foreach ($themes as $theme) { + $parent = $theme->parent(); + + if ($parent !== null && ! $themes->has($parent)) { + $issues[] = [ + 'severity' => 'error', + 'theme' => $theme->key(), + 'message' => "Parent theme [{$parent}] is not installed.", + ]; + } + + if ($this->hasInheritanceCycle($theme, $themes->all())) { + $issues[] = [ + 'severity' => 'error', + 'theme' => $theme->key(), + 'message' => 'Theme inheritance contains a cycle.', + ]; + } + + $entry = $theme->path().'/views/'.str_replace('.', '/', $theme->entryView()).'.blade.php'; + if (! $this->files->exists($entry) && $parent === null) { + $issues[] = [ + 'severity' => 'warning', + 'theme' => $theme->key(), + 'message' => "Entry view [{$theme->entryView()}] does not exist in this theme.", + ]; + } + } + + foreach ($this->duplicateViews() as $view => $owners) { + $issues[] = [ + 'severity' => 'error', + 'theme' => implode(', ', array_column($owners, 'theme')), + 'message' => "View [{$view}] has multiple owners.", + ]; + } + + $default = (string) config('stylist.default_theme'); + if ($default !== '' && ! $themes->has($default)) { + $issues[] = [ + 'severity' => 'warning', + 'theme' => $default, + 'message' => 'Configured default theme is not installed.', + ]; + } + + return $issues; + } + + /** @return array */ + private function viewFiles(Theme $theme): array + { + $root = $theme->path().'/views'; + if (! $this->files->isDirectory($root)) { + return []; + } + + $views = []; + foreach ($this->files->allFiles($root) as $file) { + if (! str_ends_with($file->getFilename(), '.blade.php')) { + continue; + } + + $absolute = str_replace('\\', '/', $file->getPathname()); + $relative = ltrim(str_replace(str_replace('\\', '/', $root), '', $absolute), '/'); + $views[$relative] = $absolute; + } + + ksort($views); + + return $views; + } + + /** @param array $themes */ + private function hasInheritanceCycle(Theme $theme, array $themes): bool + { + $seen = []; + $key = $theme->key(); + + while (isset($themes[$key])) { + if (isset($seen[$key])) { + return true; + } + + $seen[$key] = true; + $key = $themes[$key]->parent() ?? ''; + if ($key === '') { + return false; + } + } + + return false; + } +} diff --git a/Stylist/Facades/StylistFacade.php b/Stylist/Facades/StylistFacade.php new file mode 100644 index 0000000..77d173e --- /dev/null +++ b/Stylist/Facades/StylistFacade.php @@ -0,0 +1,13 @@ +make($view, $data, $mergeData); + } +} + +if (! function_exists('stylist_theme_view')) { + function stylist_theme_view(string $theme, string $view, array $data = [], array $mergeData = []): View + { + return app(ThemeViewResolver::class)->makeForTheme($theme, $view, $data, $mergeData); + } +} + +if (! function_exists('stylist_frontend_view')) { + function stylist_frontend_view(string $view, array $data = [], array $mergeData = []): View + { + return app(ThemeViewResolver::class)->makeForTheme(stylist()->frontend()->key(), $view, $data, $mergeData); + } +} + +if (! function_exists('stylist_backend_view')) { + function stylist_backend_view(string $view, array $data = [], array $mergeData = []): View + { + return app(ThemeViewResolver::class)->makeForTheme(stylist()->backend()->key(), $view, $data, $mergeData); + } +} + +if (! function_exists('theme_asset')) { + function theme_asset(string $path): string + { + return app(UrlGenerator::class)->asset($path); + } +} + +if (! function_exists('theme_switcher')) { + function theme_switcher(): HtmlString + { + return stylist_html()->switcher(); + } +} diff --git a/Stylist/Html/ThemeHtmlBuilder.php b/Stylist/Html/ThemeHtmlBuilder.php new file mode 100644 index 0000000..b7b3c16 --- /dev/null +++ b/Stylist/Html/ThemeHtmlBuilder.php @@ -0,0 +1,99 @@ +stylist->current()->stylesheet(); + if (! $this->stylist->current()->has($path)) { + return new HtmlString(''); + } + + return new HtmlString(''); + } + + public function script(?string $path = null): HtmlString + { + $path ??= $this->stylist->current()->script(); + if (! $this->stylist->current()->has($path)) { + return new HtmlString(''); + } + + return new HtmlString(''); + } + + public function variables(): HtmlString + { + $theme = $this->stylist->current(); + $default = $this->declarations($this->stylist->variables()); + $rules = [':root{'.$default.'}']; + + foreach ($theme->appearances() as $name => $definition) { + $vars = (array) data_get($definition, 'variables', []); + if ($vars) { + $rules[] = ':root[data-theme="'.$this->selector($name).'"]{'.$this->declarations($vars).'}'; + } + } + foreach ($theme->accents() as $name => $definition) { + $vars = (array) data_get($definition, 'variables', []); + if ($vars) { + $rules[] = ':root[data-accent="'.$this->selector($name).'"]{'.$this->declarations($vars).'}'; + } + } + + if ($theme->appearances() === [] && $theme->accents() === []) { + return new HtmlString(''); + } + + $appearance = json_encode($this->stylist->appearance(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); + $accent = json_encode($this->stylist->accent(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); + $themeKey = json_encode($theme->key(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); + $hasAppearances = $theme->appearances() !== [] ? 'true' : 'false'; + $hasAccents = $theme->accents() !== [] ? 'true' : 'false'; + + $script = <<(function(){var d=document.documentElement,k={$themeKey},ha={$hasAppearances},hc={$hasAccents},a=ha?(localStorage.getItem('stylist:'+k+':appearance')||{$appearance}):'',c=hc?(localStorage.getItem('stylist:'+k+':accent')||{$accent}):'';function apply(){if(ha){var dark=a==='dark'||((a==='auto'||a==='system')&&matchMedia('(prefers-color-scheme: dark)').matches);d.dataset.theme=dark?'dark':'light';d.dataset.appearance=a;d.classList.toggle('dark',dark)}if(hc){d.dataset.accent=c}}apply();window.StylistTheme={setAppearance:function(v){if(!ha)return;a=v;localStorage.setItem('stylist:'+k+':appearance',v);apply()},setAccent:function(v){if(!hc)return;c=v;localStorage.setItem('stylist:'+k+':accent',v);apply()},appearance:function(){return a},accent:function(){return c}};if(ha){matchMedia('(prefers-color-scheme: dark)').addEventListener?.('change',function(){if(a==='auto'||a==='system')apply()})}document.dispatchEvent(new CustomEvent('stylist:ready',{detail:window.StylistTheme}))})(); +JS; + + return new HtmlString(''.$script); + } + + public function switcher(): HtmlString + { + $theme = $this->stylist->current(); + $appearances = array_keys($theme->appearances()); + $accents = array_keys($theme->accents()); + + if ($appearances === [] && $accents === []) { + return new HtmlString(''); + } + + $appearanceOptions = collect($appearances)->map(fn ($name) => '')->implode(''); + $accentOptions = collect($accents)->map(fn ($name) => '')->implode(''); + $appearanceSelect = $appearances ? '' : ''; + $accentSelect = $accents ? '' : ''; + + return new HtmlString('
'.$appearanceSelect.$accentSelect.'
'); + } + + private function declarations(array $variables): string + { + return collect($variables) + ->filter(fn ($value, $key) => is_string($key) && (is_string($value) || is_numeric($value))) + ->map(fn ($value, string $key) => '--'.ltrim($key, '-').': '.e((string) $value).';') + ->implode(''); + } + + private function selector(string $value): string + { + return preg_replace('/[^a-z0-9_-]/i', '', $value) ?: 'default'; + } +} diff --git a/Stylist/Html/ThemeSeoBuilder.php b/Stylist/Html/ThemeSeoBuilder.php new file mode 100644 index 0000000..28d5840 --- /dev/null +++ b/Stylist/Html/ThemeSeoBuilder.php @@ -0,0 +1,44 @@ +stylist->current(); + $seo = array_replace($theme->seo(), $overrides); + $settings = $this->stylist->settings(); + $title = (string) ($seo['title'] ?? $settings['site_title'] ?? $theme->name()); + $description = (string) ($seo['description'] ?? $settings['site_description'] ?? $theme->description()); + $image = $seo['image'] ?? $theme->preview(); + if ($image && $theme->has($image)) { + $image = $this->urls->asset($image); + } + $tags = [''.e($title).'']; + if ($description !== '') { + $tags[] = ''; + } + foreach (['og:title' => $title, 'og:description' => $description, 'twitter:card' => 'summary_large_image', 'twitter:title' => $title, 'twitter:description' => $description] as $property => $content) { + if ($content === '') { + continue; + }$attribute = str_starts_with($property, 'og:') ? 'property' : 'name'; + $tags[] = ''; + } + if ($image) { + $tags[] = ''; + $tags[] = ''; + } + if (isset($seo['json_ld']) && is_array($seo['json_ld'])) { + $tags[] = ''; + } + + return new HtmlString(implode("\n", $tags)); + } +} diff --git a/Stylist/Http/Controllers/StylistController.php b/Stylist/Http/Controllers/StylistController.php new file mode 100644 index 0000000..219359e --- /dev/null +++ b/Stylist/Http/Controllers/StylistController.php @@ -0,0 +1,23 @@ + $stylist->current(), 'themes' => $stylist->all(), 'variables' => $stylist->variables(), + 'settingsService' => $settings, 'accessibility' => $accessibility, 'urls' => $urls, + 'activeFrontendTheme' => $siteSettings->get('frontend_theme', config('stylist.default_frontend_theme', 'saasland'), 'site'), + 'activeBackendTheme' => $siteSettings->get('backend_theme', config('stylist.default_backend_theme', 'admin'), 'site'), + ]); + } +} diff --git a/Stylist/Http/Controllers/ThemeAssetController.php b/Stylist/Http/Controllers/ThemeAssetController.php new file mode 100644 index 0000000..e7695d1 --- /dev/null +++ b/Stylist/Http/Controllers/ThemeAssetController.php @@ -0,0 +1,64 @@ +find($theme); + $themeRoot = realpath($loadedTheme->path()); + abort_unless($themeRoot !== false, 404); + + $asset = realpath($themeRoot.DIRECTORY_SEPARATOR.$normalised); + + abort_unless( + $asset !== false + && str_starts_with($asset, $themeRoot.DIRECTORY_SEPARATOR) + && is_file($asset), + 404 + ); + + $mime = match ($extension) { + 'css' => 'text/css; charset=UTF-8', + 'js', 'mjs' => 'application/javascript; charset=UTF-8', + 'svg' => 'image/svg+xml', + 'json', 'map', 'webmanifest' => 'application/json', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + 'ttf' => 'font/ttf', + 'otf' => 'font/otf', + default => mime_content_type($asset) ?: 'application/octet-stream', + }; + + return response()->file($asset, [ + 'Content-Type' => $mime, + 'Cache-Control' => app()->isProduction() + ? 'public, max-age=31536000, immutable' + : 'no-cache, private', + 'X-Content-Type-Options' => 'nosniff', + 'Content-Security-Policy' => "default-src 'none'; style-src 'unsafe-inline'", + ]); + } +} diff --git a/Stylist/Http/Controllers/ThemeManagementController.php b/Stylist/Http/Controllers/ThemeManagementController.php new file mode 100644 index 0000000..3c4f12f --- /dev/null +++ b/Stylist/Http/Controllers/ThemeManagementController.php @@ -0,0 +1,87 @@ +find($theme); + abort_unless(in_array($item->type(), ['frontend', 'backend'], true), 422, 'Only frontend and backend themes can be activated.'); + + $settings->put($item->type().'_theme', $item->key(), 'site', 'string', true); + + return back()->with('status', $item->name().' is now the active '.$item->type().' theme.'); + } + + public function preview(string $theme, Loader $loader, ThemeViewResolver $views) + { + $item = $loader->find($theme); + + return $views->makeForTheme($item->key(), $item->entryView(), [ + 'themePreview' => true, + 'previewTheme' => $item, + ]); + } + + public function settings(Request $request, string $theme, Loader $loader, ThemeSettings $settings) + { + $item = $loader->find($theme); + $settings->save( + $item, + (array) $request->input('settings', []), + (string) $request->input('variant', 'light'), + $request->string('appearance')->toString() ?: null, + $request->string('accent')->toString() ?: null, + ); + + return back()->with('status', 'Theme settings saved.'); + } + + public function install(Request $request, ThemePackageManager $packages) + { + $request->validate(['theme' => ['required', 'file', 'mimes:zip', 'max:51200']]); + $theme = $packages->install($request->file('theme')); + + return back()->with('status', $theme->name().' installed.'); + } + + public function uninstall(string $theme, Loader $loader, ThemePackageManager $packages) + { + $item = $loader->find($theme); + $packages->uninstall($item); + + return back()->with('status', $item->name().' uninstalled.'); + } + + public function export(string $theme, Loader $loader, ThemePackageManager $packages) + { + $item = $loader->find($theme); + $path = $packages->export($item); + + return response()->download($path)->deleteFileAfterSend(); + } + + public function optimize(string $theme, Loader $loader, ThemeOptimizer $optimizer) + { + $item = $loader->find($theme); + $result = $optimizer->optimize($item); + + return back()->with('status', "Optimised {$result['css']} CSS, {$result['js']} JS and {$result['images']} images."); + } + + public function validateTheme(string $theme, Loader $loader, ThemeAccessibilityValidator $validator) + { + return response()->json(['issues' => $validator->validate($loader->find($theme))]); + } +} diff --git a/Stylist/Livewire/ThemePreview.php b/Stylist/Livewire/ThemePreview.php new file mode 100644 index 0000000..5e8560d --- /dev/null +++ b/Stylist/Livewire/ThemePreview.php @@ -0,0 +1,21 @@ + 12, + 'warehouses' => 34, + 'users' => 156, + ]; + + public function render() + { + return stylist_view('livewire.theme-preview', [ + 'stats' => $this->stats, + ]); + } +} diff --git a/Stylist/Models/ThemeSetting.php b/Stylist/Models/ThemeSetting.php new file mode 100644 index 0000000..fb01e6f --- /dev/null +++ b/Stylist/Models/ThemeSetting.php @@ -0,0 +1,14 @@ + 'array']; +} diff --git a/Stylist/Providers/RouteServiceProvider.php b/Stylist/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..68adb78 --- /dev/null +++ b/Stylist/Providers/RouteServiceProvider.php @@ -0,0 +1,22 @@ +group(module_path('Stylist', 'Routes/assets.php')); + + Route::middleware('web') + ->prefix('stylist') + ->name('stylist.') + ->group(module_path('Stylist', 'Routes/web.php')); + } +} diff --git a/Stylist/Providers/StylistServiceProvider.php b/Stylist/Providers/StylistServiceProvider.php new file mode 100644 index 0000000..31ee054 --- /dev/null +++ b/Stylist/Providers/StylistServiceProvider.php @@ -0,0 +1,215 @@ +app->register(RouteServiceProvider::class); + + $this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower()); + + $this->app->singleton('stylist', fn ($app) => $app->make(Stylist::class)); + $this->app->singleton('stylist.html', fn ($app) => $app->make(ThemeHtmlBuilder::class)); + $this->app->singleton('stylist.seo', fn ($app) => $app->make(ThemeSeoBuilder::class)); + } + + public function boot(): void + { + $this->registerViews(); + $this->registerThemeViews(); + $this->registerTranslations(); + $this->registerPublishing(); + $this->registerHelpers(); + $this->registerBlade(); + + if ($this->app->runningInConsole()) { + $this->commands([ + PublishAssetsCommand::class, + ValidateThemeCommand::class, + InspectThemesCommand::class, + FindDuplicateViewsCommand::class, + ThemeDoctorCommand::class, + CreateThemeCommand::class, + ]); + } + + $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations')); + + View::composer('*', function ($view): void { + if (! $this->app->bound(Stylist::class)) { + return; + } + + $view->with('activeTheme', $this->app->make(Stylist::class)->current()); + }); + + + View::composer('stylist-theme-saasland::*', function ($view): void { + $theme = $this->app->make(Loader::class)->find('saasland'); + $settings = $this->app->make(ThemeSettings::class)->get($theme); + $siteSettings = $this->app->make(SettingsRepository::class)->site(); + $navigation = $this->app->make(FrontendNavigation::class); + $homepage = $this->app->make(FrontendContent::class)->homepage($settings); + + $view->with([ + 'siteSettings' => $siteSettings, + 'siteTitle' => $siteSettings['site_name'], + 'siteDescription' => $siteSettings['site_description'], + 'logoUrl' => $siteSettings['logo_url'] ?: null, + 'facebookUrl' => $siteSettings['facebook_url'] ?: null, + 'xUrl' => $siteSettings['x_url'] ?: null, + 'linkedinUrl' => $siteSettings['linkedin_url'] ?: null, + 'primaryNavigation' => $navigation->primary(), + 'footerNavigation' => $navigation->footer(), + 'themeSettings' => $settings, + ...$homepage, + ]); + }); + } + + private function registerViews(): void + { + $sourcePath = module_path(ModuleInfo::name(), 'Resources/views'); + $viewPath = resource_path('views/modules/'.ModuleInfo::nameLower()); + + $this->publishes([$sourcePath => $viewPath], ['views', 'stylist-module-views']); + $this->loadViewsFrom([$viewPath, $sourcePath], ModuleInfo::nameLower()); + } + + private function registerThemeViews(): void + { + foreach ($this->themeRoots() as $root) { + if (! is_dir($root)) { + logger()->warning('Stylist theme root does not exist', [ + 'root' => $root, + ]); + + continue; + } + + $themeDirectories = glob($root.'/*', GLOB_ONLYDIR) ?: []; + + foreach ($themeDirectories as $themeDirectory) { + $themeViewsPath = $themeDirectory.'/views'; + + if (! is_dir($themeViewsPath)) { + continue; + } + + $themeKey = strtolower(basename($themeDirectory)); + + // Themes always live outside the module, normally in resources/themes. + // Register the canonical namespace and the legacy namespace as an alias + // to the same application-owned directory. The alias keeps existing + // routes and Blade templates working without reintroducing module themes. + $namespaces = [ + "stylist-theme-{$themeKey}", + "stylist-module-theme-{$themeKey}", + ]; + + $componentsPath = $themeViewsPath.'/components'; + + // The common theme is the application-wide owner of layouts, + // authentication, settings and shared components. Livewire 4 + // resolves its default full-page layout through layouts::app, + // so point that compatibility namespace at the common theme. + if ($themeKey === 'common') { + $layoutsPath = $componentsPath.'/layouts'; + + if (is_dir($layoutsPath)) { + View::addNamespace('layouts', $layoutsPath); + Blade::anonymousComponentPath($layoutsPath, 'layouts'); + } + + View::addNamespace('common', $themeViewsPath); + + if (is_dir($componentsPath)) { + Blade::anonymousComponentPath($componentsPath, 'common'); + } + } + + foreach ($namespaces as $namespace) { + View::addNamespace($namespace, $themeViewsPath); + + if (is_dir($componentsPath)) { + Blade::anonymousComponentPath($componentsPath, $namespace); + } + } + } + } + } + + private function normalisePath(string $path): string + { + return rtrim( + str_replace('\\', '/', realpath($path) ?: $path), + '/' + ); + } + + /** @return array */ + private function themeRoots(): array + { + return array_values(array_unique(array_filter([ + (string) config('stylist.themes_path'), + ]))); + } + + private function registerTranslations(): void + { + $langPath = resource_path('lang/modules/'.ModuleInfo::nameLower()); + $sourcePath = module_path(ModuleInfo::name(), 'Resources/lang'); + + $this->loadTranslationsFrom(is_dir($langPath) ? $langPath : $sourcePath, ModuleInfo::nameLower()); + } + + private function registerPublishing(): void + { + $this->publishes([ + module_path(ModuleInfo::name(), 'Config/config.php') => config_path('stylist.php'), + ], ['config', 'stylist-config']); + + } + + private function registerHelpers(): void + { + foreach (glob(module_path(ModuleInfo::name(), 'Helpers/*.php')) ?: [] as $filename) { + require_once $filename; + } + } + + private function registerBlade(): void + { + Blade::directive('themeVariables', fn () => 'variables(); ?>'); + Blade::directive('themeStylesheet', fn ($expression) => 'stylesheet('.($expression ?: 'null').'); ?>'); + Blade::directive('themeScript', fn ($expression) => 'script('.($expression ?: 'null').'); ?>'); + Blade::directive('themeSwitcher', fn () => 'switcher(); ?>'); + Blade::directive('themeSeo', fn ($expression) => 'render('.($expression ?: '[]').'); ?>'); + } +} diff --git a/Stylist/README.md b/Stylist/README.md new file mode 100644 index 0000000..810bf53 --- /dev/null +++ b/Stylist/README.md @@ -0,0 +1,183 @@ +# Stylist Module + +Tenant-aware styling module for Laravel 13 / nwidart Laravel Modules. + +## Theme layout + +Each theme is self-contained: + +```text +resources/themes/{theme}/ +├── theme.json +├── css/theme.css +└── views/ + ├── layouts/app.blade.php + ├── components/page-hero.blade.php + └── livewire/theme-preview.blade.php +``` + +The active tenant/company resolves to a theme key such as `default`, `mpba`, or `abbott`. + +## Rendering themed views + +Use the helper from controllers, Blade components, and Livewire components: + +```php +return stylist_view('layouts.app'); +``` + +It checks views in this order: + +```text +stylist-theme-{active-theme}::{view} +stylist-theme-{default-theme}::{view} +stylist::{view} +``` + +## Livewire + +Keep the PHP component class shared and put the template in each theme: + +```text +Livewire/ThemePreview.php +resources/themes/default/views/livewire/theme-preview.blade.php +resources/themes/mpba/views/livewire/theme-preview.blade.php +``` + +The component renders with: + +```php +return stylist_view('livewire.theme-preview', ['stats' => $this->stats]); +``` + +## Tenant assignment + +Add or use these fields on `companies`: + +```text +theme_key +brand_primary +brand_secondary +brand_accent +logo_path +favicon_path +theme_settings +``` + +Then assign: + +```php +$company->update(['theme_key' => 'mpba']); +``` + +## Publishing + +```bash +php artisan vendor:publish --tag=stylist-config +# or +php artisan stylist:publish-assets + +Themes are not published from or stored inside the module. Install them directly under `resources/themes`. +``` + +## Blade helpers + +```blade + + @themeVariables + @themeStylesheet + +``` + +## Self-contained application themes + +Application themes live in `resources/themes/{theme}` and own their runtime assets: + +```text +resources/themes/example/ +├── theme.json +├── css/theme.css +├── js/app.js +└── views/home.blade.php +``` + +Use `@themeStylesheet('css/theme.css')`, `@themeScript('js/app.js')`, or `theme_asset('images/example.svg')`. Assets are served from the selected theme directory by the `stylist.theme.asset` route; they do not need to be copied into `public/themes` or added to the global Vite inputs. + + +## Appearance and colour variants + +Themes may declare `appearances` (`light`, `dark`, `auto`) and named `accents` in `theme.json`. Include `@themeVariables` in the document `` before the theme stylesheet to prevent a flash of the wrong appearance. Add `@themeSwitcher` anywhere in a Blade layout for a ready-made runtime selector. User choices are stored in localStorage and are applied without rebuilding assets or reloading the page. + +## Theme manifest compatibility + +Stylist accepts versioned manifests while remaining compatible with older themes. New manifests should declare: + +```json +{ + "manifest_version": 1, + "key": "design-gallery", + "name": "Design Gallery" +} +``` + +`manifest_version` defaults to `1` when omitted. Unknown manifest fields are preserved and ignored by Stylist unless the current version explicitly uses them, allowing Theme Builder to add future metadata without preventing theme discovery. + +Appearance and accent features are optional. Stylist derives the internal capability flags only when the corresponding definitions exist: + +```json +{ + "appearances": ["light", "dark", "system"], + "accents": ["blue", "neutral"], + "default_appearance": "system", + "default_accent": "neutral" +} +``` + +Legacy themes with none of these fields continue to load unchanged and do not receive appearance or accent controls. + +Additional boolean capabilities may be declared explicitly: + +```json +{ + "capabilities": { + "marketplace": true + } +} +``` + +## Validate a theme + +Validate an installed theme key, unpacked theme directory, or ZIP package: + +```bash +php artisan stylist:validate design-gallery +php artisan stylist:validate resources/themes/design-gallery +php artisan stylist:validate ~/Downloads/design-gallery.zip +``` + +The command checks the manifest, required entry view, stylesheet, script, declared assets, preview and accessibility issues. It exits with a failure status when errors are found. + +## Theme inheritance and diagnostics + +Shared application views belong to the `common` theme. Child themes declare inheritance without copying those views: + +```json +{ + "name": "Dashboard", + "slug": "admin", + "extends": "common" +} +``` + +Resolution order is the active theme, each declared parent, the configured fallback, the common theme, module defaults, then Laravel's normal views. + +Use these maintenance commands: + +```bash +php artisan stylist:inspect +php artisan stylist:duplicates +php artisan stylist:doctor +php artisan stylist:create-theme customer --parent=common --name="Customer Portal" +``` + +`stylist:duplicates` fails when the same relative Blade task path exists in more than one theme. Add a child view only when it is a deliberate replacement; this application enforces one authoritative view per task. diff --git a/Stylist/Resources/assets/css/app.css b/Stylist/Resources/assets/css/app.css new file mode 100644 index 0000000..32b4fbc --- /dev/null +++ b/Stylist/Resources/assets/css/app.css @@ -0,0 +1,5 @@ +[data-theme] { + --brand-primary: var(--brand-primary, #2563eb); + --brand-secondary: var(--brand-secondary, #0f172a); + --brand-accent: var(--brand-accent, #22c55e); +} diff --git a/Stylist/Resources/assets/js/app.js b/Stylist/Resources/assets/js/app.js new file mode 100644 index 0000000..f65cc9b --- /dev/null +++ b/Stylist/Resources/assets/js/app.js @@ -0,0 +1 @@ +import '../css/app.css'; diff --git a/Stylist/Resources/lang/en/messages.php b/Stylist/Resources/lang/en/messages.php new file mode 100644 index 0000000..56494b1 --- /dev/null +++ b/Stylist/Resources/lang/en/messages.php @@ -0,0 +1,5 @@ + 'Stylist', +]; diff --git a/Stylist/Resources/views/index.blade.php b/Stylist/Resources/views/index.blade.php new file mode 100644 index 0000000..19ffd23 --- /dev/null +++ b/Stylist/Resources/views/index.blade.php @@ -0,0 +1,78 @@ + + + + @if (session('status')) +
{{ session('status') }}
+ @endif + @if ($errors->any()) +
{{ $errors->first() }}
+ @endif + +
+ @csrf + + +
+ +
+ @foreach ($themes as $item) + @php($saved = $settingsService->get($item)) + @php($variant = $settingsService->variant($item)) + @php($issues = $accessibility->validate($item)) + @php($appearance = $settingsService->appearance($item)) + @php($accent = $settingsService->accent($item)) +
+
+ @if ($item->preview() && $item->has($item->preview())) + {{ $item->name() }} theme preview + @else +
Add preview.webp to this theme
+ @endif +
+
+

{{ $item->name() }}

v{{ $item->version() }}@if($item->author()) · {{ $item->author() }}@endif

@if(($item->type() === 'frontend' && $item->key() === $activeFrontendTheme) || ($item->type() === 'backend' && $item->key() === $activeBackendTheme))Active {{ ucfirst($item->type()) }}@endif
+ @if($item->description())

{{ $item->description() }}

@endif + @if($item->parent())

Child of {{ $item->parent() }}

@endif +
+ @if(in_array($item->type(), ['frontend', 'backend'], true)) +
@csrf
+ Preview + @endif + Export ZIP +
@csrf
+ @if(str_starts_with(realpath($item->path()) ?: '', realpath(config('stylist.themes_path')) ?: '/__never__')) +
@csrf @method('DELETE')
+ @endif +
+
+ Theme settings +
@csrf @method('PUT') + @if($item->variants()) + + @endif + @if($item->supports('dark_mode') || $item->appearances()) + + @endif + @if($item->accents()) +
Accent colour
@foreach($item->accents() as $name => $definition)@endforeach
+ @endif + @foreach($item->settingsSchema() as $key => $definition) + @php($definition = is_array($definition) ? $definition : ['default' => $definition]) + + @endforeach + +
+
+
Accessibility: {{ count($issues) ? count($issues).' issue(s)' : 'passed static checks' }}
+
+
+ @endforeach +
+
diff --git a/Stylist/Routes/assets.php b/Stylist/Routes/assets.php new file mode 100644 index 0000000..9662db6 --- /dev/null +++ b/Stylist/Routes/assets.php @@ -0,0 +1,16 @@ +where('theme', '[A-Za-z0-9_-]+') + ->where('path', '.*') + ->name('stylist.theme.asset'); diff --git a/Stylist/Routes/web.php b/Stylist/Routes/web.php new file mode 100644 index 0000000..c39ec85 --- /dev/null +++ b/Stylist/Routes/web.php @@ -0,0 +1,17 @@ +group(function (): void { + Route::get('/', StylistController::class)->name('index'); + Route::post('/install', [ThemeManagementController::class, 'install'])->name('install'); + Route::put('/{theme}/settings', [ThemeManagementController::class, 'settings'])->name('settings'); + Route::post('/{theme}/activate', [ThemeManagementController::class, 'activate'])->name('activate'); + Route::get('/{theme}/preview', [ThemeManagementController::class, 'preview'])->name('preview'); + Route::delete('/{theme}', [ThemeManagementController::class, 'uninstall'])->name('uninstall'); + Route::get('/{theme}/export', [ThemeManagementController::class, 'export'])->name('export'); + Route::post('/{theme}/optimize', [ThemeManagementController::class, 'optimize'])->name('optimize'); + Route::get('/{theme}/accessibility', [ThemeManagementController::class, 'validateTheme'])->name('accessibility'); +}); diff --git a/Stylist/Services/FrontendContent.php b/Stylist/Services/FrontendContent.php new file mode 100644 index 0000000..e57aac5 --- /dev/null +++ b/Stylist/Services/FrontendContent.php @@ -0,0 +1,102 @@ + */ + public function homepage(array $settings): array + { + // Always provide source collections. Visibility is owned by page placements + // in the composable content system; the legacy theme still applies its + // existing show_* settings while no composition has been installed. + return [ + 'pricingPlans' => $this->pricingPlans(), + 'testimonials' => $this->testimonials(), + 'faqs' => $this->faqs(), + 'teamMembers' => $this->team(), + 'latestBlogPosts' => $this->blogPosts(), + 'latestNewsArticles' => $this->newsArticles(), + ]; + } + + private function pricingPlans(): Collection + { + return $this->query('products', fn () => Product::query() + ->where('is_active', true) + ->where('product_type', 'plan') + ->orderBy('sort_order') + ->orderBy('id') + ->limit(6) + ->get()); + } + + private function testimonials(): Collection + { + return $this->query('testimonials', fn () => Testimonial::query() + ->where('enabled', true) + ->latest('id') + ->limit(6) + ->get()); + } + + private function faqs(): Collection + { + return $this->query('faqs', fn () => Faq::query() + ->where('enabled', true) + ->orderBy('sort_order') + ->limit(12) + ->get()); + } + + private function team(): Collection + { + return $this->query('team_members', fn () => TeamMember::query() + ->where('enabled', true) + ->orderBy('sort_order') + ->limit(8) + ->get()); + } + + private function blogPosts(): Collection + { + return $this->query('blog_posts', fn () => Blog::query() + ->published() + ->latest('published_at') + ->limit(3) + ->get()); + } + + private function newsArticles(): Collection + { + return $this->query('news_articles', fn () => News::query() + ->published() + ->latest('published_at') + ->limit(3) + ->get()); + } + + private function query(string $table, callable $callback): Collection + { + try { + return Schema::hasTable($table) ? $callback() : collect(); + } catch (Throwable) { + return collect(); + } + } + + private function enabled(array $settings, string $key, bool $default): bool + { + return filter_var($settings[$key] ?? $default, FILTER_VALIDATE_BOOL); + } +} diff --git a/Stylist/Services/TenantThemeResolver.php b/Stylist/Services/TenantThemeResolver.php new file mode 100644 index 0000000..58d4920 --- /dev/null +++ b/Stylist/Services/TenantThemeResolver.php @@ -0,0 +1,65 @@ +context(); + $column = config('stylist.tenant_theme_column', 'theme_key'); + $user = Auth::user(); + $company = $user?->company ?? null; + + if ($company && filled($company->{$column} ?? null)) { + return (string) $company->{$column}; + } + + if (function_exists('tenant')) { + $tenant = tenant(); + if ($tenant && filled($tenant->{$column} ?? null)) { + return (string) $tenant->{$column}; + } + } + + $settingKey = $context === 'frontend' ? 'frontend_theme' : 'backend_theme'; + $default = $context === 'frontend' + ? config('stylist.default_frontend_theme', 'saasland') + : config('stylist.default_backend_theme', config('stylist.default_theme', 'admin')); + + return (string) $this->settings->get($settingKey, $default, 'site'); + } + + public function context(): string + { + if (app()->runningInConsole()) { + return 'backend'; + } + + $request = request(); + + return $request->routeIs('home', 'blog.public.*', 'news.public.*', 'pages.public.*') + || ! $request->user() + ? 'frontend' + : 'backend'; + } + + public function variables(): array + { + $variables = config('stylist.fallback_variables', []); + $company = Auth::user()?->company ?? null; + + foreach (config('stylist.tenant_brand_columns', []) as $column => $variable) { + if ($company && filled($company->{$column} ?? null)) { + $variables[$variable] = (string) $company->{$column}; + } + } + + return $variables; + } +} diff --git a/Stylist/Services/ThemeAccessibilityValidator.php b/Stylist/Services/ThemeAccessibilityValidator.php new file mode 100644 index 0000000..70b68bb --- /dev/null +++ b/Stylist/Services/ThemeAccessibilityValidator.php @@ -0,0 +1,32 @@ + */ + public function validate(Theme $theme): array + { + $issues = []; + foreach ($this->files->allFiles($theme->path().'/views') as $file) { + $content = $file->getContents(); + $relative = $file->getRelativePathname(); + if (preg_match_all('/]*\balt=)[^>]*>/i', $content, $matches)) { + $issues[] = ['severity' => 'error', 'message' => count($matches[0]).' image(s) missing alt text.', 'file' => $relative]; + } + if (preg_match('/]*\blang=)/i', $content)) { + $issues[] = ['severity' => 'warning', 'message' => 'HTML element is missing a lang attribute.', 'file' => $relative]; + } + if (preg_match_all('/]*>\s*(?:<[^>]+>\s*)*<\/button>/is', $content, $matches)) { + $issues[] = ['severity' => 'warning', 'message' => count($matches[0]).' button(s) may lack an accessible name.', 'file' => $relative]; + } + } + + return $issues; + } +} diff --git a/Stylist/Services/ThemeOptimizer.php b/Stylist/Services/ThemeOptimizer.php new file mode 100644 index 0000000..def6927 --- /dev/null +++ b/Stylist/Services/ThemeOptimizer.php @@ -0,0 +1,57 @@ + 0, 'js' => 0, 'images' => 0]; + foreach (['css' => 'css', 'js' => 'js'] as $type => $folder) { + $directory = $theme->path().'/'.$folder; + if (! $this->files->isDirectory($directory)) { + continue; + } + foreach ($this->files->files($directory) as $file) { + if ($file->getExtension() !== $type || str_ends_with($file->getFilename(), '.min.'.$type)) { + continue; + } + $content = $file->getContents(); + $content = preg_replace('!/\*.*?\*/!s', '', $content) ?? $content; + $content = preg_replace('/\s+/', ' ', $content) ?? $content; + $content = preg_replace('/\s*([{}:;,>])\s*/', '$1', $content) ?? $content; + $target = $file->getPath().'/'.$file->getBasename('.'.$type).'.min.'.$type; + $this->files->put($target, trim($content)); + $result[$type]++; + } + } + if (function_exists('imagewebp')) { + foreach (['images', 'icons'] as $folder) { + $directory = $theme->path().'/'.$folder; + if (! $this->files->isDirectory($directory)) { + continue; + } + foreach ($this->files->files($directory) as $file) { + $ext = strtolower($file->getExtension()); + if (! in_array($ext, ['jpg', 'jpeg', 'png'], true)) { + continue; + } + $image = $ext === 'png' ? @imagecreatefrompng($file->getPathname()) : @imagecreatefromjpeg($file->getPathname()); + if ($image) { + imagepalettetotruecolor($image); + imagewebp($image, $file->getPath().'/'.$file->getBasename('.'.$ext).'.webp', 82); + imagedestroy($image); + $result['images']++; + } + } + } + } + + return $result; + } +} diff --git a/Stylist/Services/ThemePackageManager.php b/Stylist/Services/ThemePackageManager.php new file mode 100644 index 0000000..56d1a4a --- /dev/null +++ b/Stylist/Services/ThemePackageManager.php @@ -0,0 +1,98 @@ +getClientOriginalExtension()) !== 'zip') { + throw new RuntimeException('Only ZIP theme packages may be installed.'); + } + $temporary = storage_path('app/stylist/install-'.bin2hex(random_bytes(8))); + $this->files->ensureDirectoryExists($temporary); + $zip = new ZipArchive; + if ($zip->open($upload->getRealPath()) !== true) { + throw new RuntimeException('The uploaded ZIP could not be opened.'); + } + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if (str_contains($name, '..') || str_starts_with($name, '/') || str_contains($name, "\0")) { + $zip->close(); + throw new RuntimeException('The ZIP contains an unsafe path.'); + } + } + $zip->extractTo($temporary); + $zip->close(); + $manifest = collect($this->files->allFiles($temporary))->first(fn ($file) => $file->getFilename() === 'theme.json'); + if (! $manifest) { + $this->files->deleteDirectory($temporary); + throw new RuntimeException('The package does not contain theme.json.'); + } + $source = $manifest->getPath(); + $theme = $this->loader->fromPath($source); + if ($theme->parent() && ! $this->loader->all()->has($theme->parent())) { + throw new RuntimeException('Parent theme '.$theme->parent().' is not installed.'); + } + $issues = $this->validator->validate($theme); + if (collect($issues)->contains('severity', 'error')) { + throw new RuntimeException('Accessibility validation failed: '.collect($issues)->where('severity', 'error')->pluck('message')->implode(' ')); + } + $target = rtrim((string) config('stylist.themes_path'), '/').'/'.$theme->key(); + if ($this->files->isDirectory($target)) { + $this->files->deleteDirectory($target); + } + $this->files->copyDirectory($source, $target); + $this->files->deleteDirectory($temporary); + $this->loader->flush(); + + return $this->loader->find($theme->key()); + } + + public function uninstall(Theme $theme): void + { + $active = $theme->type() === 'frontend' + ? $this->settings->get('frontend_theme', config('stylist.default_frontend_theme'), 'site') + : $this->settings->get('backend_theme', config('stylist.default_backend_theme'), 'site'); + if ($active === $theme->key()) { + throw new RuntimeException('The active theme cannot be uninstalled. Activate another theme first.'); + } + $projectRoot = realpath((string) config('stylist.themes_path')); + $themePath = realpath($theme->path()); + if (! $projectRoot || ! $themePath || ! str_starts_with($themePath, $projectRoot.DIRECTORY_SEPARATOR)) { + throw new RuntimeException('Bundled themes cannot be uninstalled.'); + } + if (collect($this->loader->all())->contains(fn (Theme $candidate) => $candidate->parent() === $theme->key())) { + throw new RuntimeException('This theme has installed child themes.'); + } + $this->files->deleteDirectory($themePath); + $this->loader->flush(); + } + + public function export(Theme $theme): string + { + $directory = storage_path('app/stylist/exports'); + $this->files->ensureDirectoryExists($directory); + $path = $directory.'/'.$theme->key().'-'.$theme->version().'.zip'; + $zip = new ZipArchive; + if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException('Unable to create theme archive.'); + } + foreach ($this->files->allFiles($theme->path()) as $file) { + $zip->addFile($file->getPathname(), $theme->key().'/'.$file->getRelativePathname()); + } + $zip->close(); + + return $path; + } +} diff --git a/Stylist/Services/ThemeSettings.php b/Stylist/Services/ThemeSettings.php new file mode 100644 index 0000000..0a1f253 --- /dev/null +++ b/Stylist/Services/ThemeSettings.php @@ -0,0 +1,97 @@ +stored($theme); + $defaults = collect($theme->settingsSchema()) + ->mapWithKeys(fn ($definition, $key) => [$key => is_array($definition) ? ($definition['default'] ?? null) : $definition]) + ->all(); + + return array_replace($defaults, array_diff_key((array) ($stored?->settings ?? []), array_flip([self::APPEARANCE_KEY, self::ACCENT_KEY]))); + } + + public function variant(Theme $theme): string + { + $variant = (string) ($this->stored($theme)?->variant ?? array_key_first($theme->variants()) ?? 'light'); + + return array_key_exists($variant, $theme->variants()) ? $variant : (array_key_first($theme->variants()) ?? 'light'); + } + + public function appearance(Theme $theme): string + { + $stored = (array) ($this->stored($theme)?->settings ?? []); + $allowed = array_keys($theme->appearances()); + if ($allowed === []) { + return ''; + } + $appearance = (string) ($stored[self::APPEARANCE_KEY] ?? $theme->defaultAppearance()); + + return in_array($appearance, $allowed, true) ? $appearance : (string) array_key_first($theme->appearances()); + } + + public function accent(Theme $theme): string + { + $stored = (array) ($this->stored($theme)?->settings ?? []); + $allowed = array_keys($theme->accents()); + if ($allowed === []) { + return ''; + } + $accent = (string) ($stored[self::ACCENT_KEY] ?? $theme->defaultAccent()); + + return in_array($accent, $allowed, true) ? $accent : (string) array_key_first($theme->accents()); + } + + public function save(Theme $theme, array $settings, string $variant, ?string $appearance = null, ?string $accent = null): ThemeSetting + { + $settings = array_intersect_key($settings, array_flip(array_keys($theme->settingsSchema()))); + $existing = (array) ($this->stored($theme)?->settings ?? []); + + $allowedAppearances = array_keys($theme->appearances()); + if ($allowedAppearances !== []) { + $appearance ??= (string) ($existing[self::APPEARANCE_KEY] ?? $theme->defaultAppearance()); + if (! in_array($appearance, $allowedAppearances, true)) { + $appearance = (string) array_key_first($theme->appearances()); + } + $settings[self::APPEARANCE_KEY] = $appearance; + } + + $allowedAccents = array_keys($theme->accents()); + if ($allowedAccents !== []) { + $accent ??= (string) ($existing[self::ACCENT_KEY] ?? $theme->defaultAccent()); + if (! in_array($accent, $allowedAccents, true)) { + $accent = (string) array_key_first($theme->accents()); + } + $settings[self::ACCENT_KEY] = $accent; + } + + if ($theme->variants() && ! array_key_exists($variant, $theme->variants())) { + $variant = array_key_first($theme->variants()); + } + + return ThemeSetting::query()->updateOrCreate( + ['theme_key' => $theme->key()], + ['settings' => $settings, 'variant' => $variant] + ); + } + + private function stored(Theme $theme): ?ThemeSetting + { + try { + return ThemeSetting::query()->where('theme_key', $theme->key())->first(); + } catch (QueryException) { + return null; + } + } +} diff --git a/Stylist/Services/ThemeValidator.php b/Stylist/Services/ThemeValidator.php new file mode 100644 index 0000000..472397c --- /dev/null +++ b/Stylist/Services/ThemeValidator.php @@ -0,0 +1,74 @@ + */ + public function validatePath(string $path): array + { + $theme = $this->loader->fromPath($path); + + return $this->validate($theme); + } + + /** @return array */ + public function validate(Theme $theme): array + { + $issues = []; + $entryView = 'views/'.str_replace('.', '/', $theme->entryView()).'.blade.php'; + + if (! $theme->has($entryView)) { + $issues[] = [ + 'severity' => 'error', + 'message' => "Entry view [{$theme->entryView()}] was not found.", + 'file' => $entryView, + ]; + } + + foreach (['stylesheet' => $theme->stylesheet(), 'script' => $theme->script()] as $label => $relativePath) { + if ($relativePath !== '' && ! $theme->has($relativePath)) { + $issues[] = [ + 'severity' => 'error', + 'message' => ucfirst($label)." file [{$relativePath}] was not found.", + 'file' => $relativePath, + ]; + } + } + + $preview = $theme->preview(); + if ($preview !== null && ! $theme->has($preview)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => "Preview file [{$preview}] was not found.", + 'file' => $preview, + ]; + } + + foreach ((array) ($theme->meta()['assets'] ?? []) as $asset) { + if (! is_string($asset) || $asset === '') { + continue; + } + + if (! $theme->has($asset)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => "Declared asset [{$asset}] was not found.", + 'file' => $asset, + ]; + } + } + + return array_merge($issues, $this->accessibility->validate($theme)); + } +} diff --git a/Stylist/Support/FrontendNavigation.php b/Stylist/Support/FrontendNavigation.php new file mode 100644 index 0000000..711f089 --- /dev/null +++ b/Stylist/Support/FrontendNavigation.php @@ -0,0 +1,76 @@ +> */ + public function primary(): array + { + return $this->location('primary', $this->fallbackPrimary()); + } + + /** @return array> */ + public function footer(): array + { + return $this->location('footer', $this->fallbackPrimary()); + } + + /** @param array> $fallback + * @return array> + */ + public function location(string $location, array $fallback = []): array + { + try { + if (! Schema::hasTable('menus')) { + return $fallback; + } + + $menu = Menu::query()->where('enabled', true)->where('location', $location)->first(); + if (! $menu || ! is_array($menu->items)) { + return $fallback; + } + + $items = $this->normaliseItems($menu->items); + + return $items === [] ? $fallback : $items; + } catch (Throwable) { + return $fallback; + } + } + + /** @param array $items + * @return array> + */ + private function normaliseItems(array $items): array + { + return collect($items) + ->filter(fn (mixed $item): bool => is_array($item) && filter_var($item['enabled'] ?? true, FILTER_VALIDATE_BOOL)) + ->map(function (array $item): ?array { + $label = trim((string) ($item['label'] ?? '')); + $url = trim((string) ($item['url'] ?? '')); + if ($label === '' || $url === '') return null; + + return [ + 'label' => $label, + 'url' => $url, + 'target' => ($item['target'] ?? '') === '_blank' ? '_blank' : null, + 'children' => $this->normaliseItems((array) ($item['children'] ?? [])), + ]; + })->filter()->values()->all(); + } + + /** @return array> */ + private function fallbackPrimary(): array + { + $items = [['label' => 'Home', 'url' => url('/'), 'target' => null, 'children' => []]]; + if (Route::has('blog.public.index')) $items[] = ['label' => 'Blog', 'url' => route('blog.public.index'), 'target' => null, 'children' => []]; + if (Route::has('news.public.index')) $items[] = ['label' => 'News', 'url' => route('news.public.index'), 'target' => null, 'children' => []]; + return $items; + } +} diff --git a/Stylist/Support/ModuleInfo.php b/Stylist/Support/ModuleInfo.php new file mode 100644 index 0000000..6c2f429 --- /dev/null +++ b/Stylist/Support/ModuleInfo.php @@ -0,0 +1,16 @@ +assertTrue(view()->exists($view), "Expected common view [{$view}] to exist."); + } + } + + public function test_authentication_and_settings_views_are_not_duplicated(): void + { + $this->assertDirectoryDoesNotExist(resource_path('views')); + $this->assertDirectoryDoesNotExist(resource_path('themes/admin/views/livewire')); + $this->assertDirectoryDoesNotExist(resource_path('themes/welcome/views/livewire')); + + $commonViews = resource_path('themes/common/views'); + + foreach ([ + 'livewire/auth/login.blade.php', + 'livewire/auth/register.blade.php', + 'livewire/auth/forgot-password.blade.php', + 'livewire/auth/reset-password.blade.php', + 'livewire/settings/profile.blade.php', + 'livewire/settings/appearance.blade.php', + 'livewire/settings/security.blade.php', + ] as $relativePath) { + $matches = collect(File::allFiles(resource_path('themes'))) + ->filter(fn ($file) => str_ends_with( + str_replace('\\', '/', $file->getPathname()), + '/views/'.$relativePath + )); + + $this->assertCount(1, $matches, "Expected one authoritative view for [{$relativePath}]."); + $this->assertStringStartsWith( + str_replace('\\', '/', $commonViews), + str_replace('\\', '/', $matches->first()->getPathname()) + ); + } + } +} diff --git a/Stylist/Tests/Feature/LegacyThemeNamespaceAliasTest.php b/Stylist/Tests/Feature/LegacyThemeNamespaceAliasTest.php new file mode 100644 index 0000000..2d0fd45 --- /dev/null +++ b/Stylist/Tests/Feature/LegacyThemeNamespaceAliasTest.php @@ -0,0 +1,14 @@ +assertTrue(view()->exists('stylist-module-theme-admin::dashboard')); + $this->assertTrue(view()->exists('stylist-theme-admin::dashboard')); + } +} diff --git a/Stylist/Tests/Feature/StylistModuleTest.php b/Stylist/Tests/Feature/StylistModuleTest.php new file mode 100644 index 0000000..386ac03 --- /dev/null +++ b/Stylist/Tests/Feature/StylistModuleTest.php @@ -0,0 +1,21 @@ +assertInstanceOf(Stylist::class, app(Stylist::class)); + } + + public function test_default_theme_resolves(): void + { + config(['stylist.default_theme' => 'admin']); + + $this->assertSame('admin', app(Stylist::class)->current()->key()); + } +} diff --git a/Stylist/Tests/Feature/ThemeAssetControllerTest.php b/Stylist/Tests/Feature/ThemeAssetControllerTest.php new file mode 100644 index 0000000..876ee02 --- /dev/null +++ b/Stylist/Tests/Feature/ThemeAssetControllerTest.php @@ -0,0 +1,41 @@ + resource_path('themes')]); + app(Loader::class)->flush(); + + $this->get(route('stylist.theme.asset', [ + 'theme' => 'saasland', + 'path' => 'css/theme.css', + ]))->assertOk()->assertHeader('X-Content-Type-Options', 'nosniff'); + } + + public function test_asset_url_uses_public_themes_path(): void + { + $url = route('stylist.theme.asset', [ + 'theme' => 'saasland', + 'path' => 'images/example.jpg', + ], false); + + $this->assertSame('/themes/saasland/images/example.jpg', $url); + } + + public function test_non_asset_theme_files_are_not_served(): void + { + config(['stylist.themes_path' => resource_path('themes')]); + app(Loader::class)->flush(); + + $this->get(route('stylist.theme.asset', [ + 'theme' => 'saasland', + 'path' => 'views/home.blade.php', + ]))->assertNotFound(); + } +} diff --git a/Stylist/Tests/Feature/ThemeHtmlBuilderTest.php b/Stylist/Tests/Feature/ThemeHtmlBuilderTest.php new file mode 100644 index 0000000..65b03aa --- /dev/null +++ b/Stylist/Tests/Feature/ThemeHtmlBuilderTest.php @@ -0,0 +1,18 @@ + '#111827']); + + $html = app(ThemeHtmlBuilder::class)->variables()->toHtml(); + + $this->assertStringContainsString('--brand-primary: #111827;', $html); + } +} diff --git a/Stylist/Tests/Feature/ThemeInheritanceTest.php b/Stylist/Tests/Feature/ThemeInheritanceTest.php new file mode 100644 index 0000000..495f581 --- /dev/null +++ b/Stylist/Tests/Feature/ThemeInheritanceTest.php @@ -0,0 +1,95 @@ +themesPath = storage_path('framework/testing/stylist-inheritance-'.uniqid()); + mkdir($this->themesPath, 0777, true); + config([ + 'stylist.themes_path' => $this->themesPath, + 'stylist.default_theme' => 'common', + ]); + app(Loader::class)->flush(); + } + + protected function tearDown(): void + { + app(Filesystem::class)->deleteDirectory($this->themesPath); + parent::tearDown(); + } + + public function test_child_theme_resolves_views_through_parent_without_copying_them(): void + { + $this->theme('common', null, ['components/layouts/app.blade.php' => '
{{ $slot }}
']); + $this->theme('admin', 'common', ['dashboard.blade.php' => '

Dashboard

']); + app(Loader::class)->flush(); + + $candidates = app(ThemeViewResolver::class)->candidatesForTheme('admin', 'components.layouts.app'); + + $this->assertSame('stylist-theme-admin::components.layouts.app', $candidates[0]); + $this->assertContains('stylist-theme-common::components.layouts.app', $candidates); + $this->assertLessThan( + array_search('stylist-theme-common::components.layouts.app', $candidates, true), + array_search('stylist-theme-admin::components.layouts.app', $candidates, true), + ); + } + + public function test_parent_alias_is_normalised_to_extends(): void + { + $path = $this->themesPath.'/child'; + mkdir($path, 0777, true); + file_put_contents($path.'/theme.json', json_encode([ + 'name' => 'Child', + 'parent' => 'common', + ])); + + $theme = app(Loader::class)->fromPath($path); + + $this->assertSame('common', $theme->parent()); + } + + public function test_duplicate_view_ownership_is_reported(): void + { + $this->theme('common', null, ['livewire/auth/login.blade.php' => 'common']); + $this->theme('admin', 'common', ['livewire/auth/login.blade.php' => 'admin']); + app(Loader::class)->flush(); + + $duplicates = app(ThemeInspector::class)->duplicateViews(); + + $this->assertArrayHasKey('livewire/auth/login.blade.php', $duplicates); + $this->assertEqualsCanonicalizing(['common', 'admin'], array_column($duplicates['livewire/auth/login.blade.php'], 'theme')); + } + + /** @param array $views */ + private function theme(string $key, ?string $parent, array $views): void + { + $path = $this->themesPath.'/'.$key; + mkdir($path.'/views', 0777, true); + file_put_contents($path.'/theme.json', json_encode(array_filter([ + 'name' => ucfirst($key), + 'slug' => $key, + 'extends' => $parent, + ], fn ($value) => $value !== null))); + + foreach ($views as $relative => $contents) { + $file = $path.'/views/'.$relative; + if (! is_dir(dirname($file))) { + mkdir(dirname($file), 0777, true); + } + file_put_contents($file, $contents); + } + } +} diff --git a/Stylist/Tests/Feature/ThemeManifestTest.php b/Stylist/Tests/Feature/ThemeManifestTest.php new file mode 100644 index 0000000..11d681e --- /dev/null +++ b/Stylist/Tests/Feature/ThemeManifestTest.php @@ -0,0 +1,157 @@ +themesPath = storage_path('framework/testing/stylist-themes-'.uniqid()); + mkdir($this->themesPath, 0777, true); + config(['stylist.themes_path' => $this->themesPath]); + app(Loader::class)->flush(); + } + + protected function tearDown(): void + { + app(Filesystem::class)->deleteDirectory($this->themesPath); + parent::tearDown(); + } + + public function test_manifest_is_normalised(): void + { + $path = $this->themesPath.'/sample'; + mkdir($path.'/css', 0777, true); + file_put_contents($path.'/theme.json', json_encode(['name' => 'Sample'])); + file_put_contents($path.'/css/theme.css', 'body{}'); + + $theme = app(Loader::class)->fromPath($path); + + $this->assertSame('sample', $theme->key()); + $this->assertSame('css/theme.css', $theme->stylesheet()); + $this->assertSame('js/app.js', $theme->script()); + $this->assertSame('home', $theme->entryView()); + } + + public function test_unsafe_manifest_path_is_rejected(): void + { + $path = $this->themesPath.'/unsafe'; + mkdir($path, 0777, true); + file_put_contents($path.'/theme.json', json_encode([ + 'name' => 'Unsafe', + 'stylesheet' => '../secret.css', + ])); + + $this->expectException(InvalidThemeManifestException::class); + app(Loader::class)->fromPath($path); + } + + public function test_phase_one_manifest_optional_capabilities_are_normalised(): void + { + $path = $this->themesPath.'/design-gallery'; + mkdir($path.'/css', 0777, true); + mkdir($path.'/js', 0777, true); + file_put_contents($path.'/theme.json', json_encode([ + 'key' => 'design-gallery', + 'name' => 'Design Gallery', + 'stylesheet' => 'css/app.css', + 'script' => 'js/app.js', + 'appearances' => ['light', 'dark', 'system'], + 'accents' => ['blue', 'emerald', 'neutral'], + 'default_appearance' => 'system', + 'default_accent' => 'neutral', + // Phase 1 incorrectly duplicated accents here; Stylist must not + // reject the whole theme because of that compact legacy list. + 'variants' => ['blue', 'emerald', 'neutral'], + ])); + file_put_contents($path.'/css/app.css', 'body{}'); + file_put_contents($path.'/js/app.js', ''); + + $theme = app(Loader::class)->fromPath($path); + + $this->assertSame(['light', 'dark', 'system'], array_keys($theme->appearances())); + $this->assertSame(['blue', 'emerald', 'neutral'], array_keys($theme->accents())); + $this->assertSame([], $theme->variants()); + $this->assertSame('system', $theme->defaultAppearance()); + $this->assertSame('neutral', $theme->defaultAccent()); + } + + public function test_legacy_manifest_does_not_gain_appearance_or_accent_capabilities(): void + { + $path = $this->themesPath.'/legacy'; + mkdir($path.'/css', 0777, true); + file_put_contents($path.'/theme.json', json_encode(['name' => 'Legacy'])); + file_put_contents($path.'/css/theme.css', 'body{}'); + + $theme = app(Loader::class)->fromPath($path); + + $this->assertSame([], $theme->appearances()); + $this->assertSame([], $theme->accents()); + $this->assertSame('', $theme->defaultAppearance()); + $this->assertSame('', $theme->defaultAccent()); + } + + public function test_defaults_must_reference_declared_optional_capabilities(): void + { + $path = $this->themesPath.'/invalid-default'; + mkdir($path, 0777, true); + file_put_contents($path.'/theme.json', json_encode([ + 'name' => 'Invalid Default', + 'appearances' => ['light', 'dark'], + 'default_appearance' => 'system', + ])); + + $this->expectException(InvalidThemeManifestException::class); + app(Loader::class)->fromPath($path); + } + + public function test_manifest_version_and_capabilities_are_normalised(): void + { + $path = $this->themesPath.'/capable'; + mkdir($path.'/css', 0777, true); + mkdir($path.'/js', 0777, true); + mkdir($path.'/views', 0777, true); + file_put_contents($path.'/theme.json', json_encode([ + 'manifest_version' => 1, + 'name' => 'Capable', + 'appearances' => ['light', 'dark'], + 'accents' => ['blue'], + 'capabilities' => ['marketplace' => true], + ])); + file_put_contents($path.'/css/theme.css', 'body{}'); + file_put_contents($path.'/js/app.js', ''); + file_put_contents($path.'/views/home.blade.php', '
Capable
'); + + $theme = app(Loader::class)->fromPath($path); + + $this->assertSame(1, $theme->manifestVersion()); + $this->assertTrue($theme->hasCapability('appearances')); + $this->assertTrue($theme->hasCapability('accents')); + $this->assertTrue($theme->hasCapability('marketplace')); + $this->assertFalse($theme->hasCapability('preview')); + } + + public function test_unknown_manifest_fields_are_preserved_without_preventing_loading(): void + { + $path = $this->themesPath.'/future'; + mkdir($path.'/css', 0777, true); + file_put_contents($path.'/theme.json', json_encode([ + 'name' => 'Future', + 'future_builder_metadata' => ['anything' => true], + ])); + file_put_contents($path.'/css/theme.css', 'body{}'); + + $theme = app(Loader::class)->fromPath($path); + + $this->assertSame(['anything' => true], $theme->meta()['future_builder_metadata']); + } +} diff --git a/Stylist/Tests/Feature/ThemePackageRoundTripTest.php b/Stylist/Tests/Feature/ThemePackageRoundTripTest.php new file mode 100644 index 0000000..633b3dc --- /dev/null +++ b/Stylist/Tests/Feature/ThemePackageRoundTripTest.php @@ -0,0 +1,79 @@ +themesPath = storage_path('framework/testing/stylist-installed-'.uniqid()); + $this->workspace = storage_path('framework/testing/stylist-package-'.uniqid()); + mkdir($this->themesPath, 0777, true); + mkdir($this->workspace, 0777, true); + config(['stylist.themes_path' => $this->themesPath]); + app(Loader::class)->flush(); + } + + protected function tearDown(): void + { + app(Filesystem::class)->deleteDirectory($this->themesPath); + app(Filesystem::class)->deleteDirectory($this->workspace); + app(Loader::class)->flush(); + parent::tearDown(); + } + + public function test_generated_theme_zip_installs_discovers_and_renders(): void + { + $source = $this->workspace.'/design-gallery'; + mkdir($source.'/css', 0777, true); + mkdir($source.'/js', 0777, true); + mkdir($source.'/views', 0777, true); + + file_put_contents($source.'/theme.json', json_encode([ + 'manifest_version' => 1, + 'key' => 'design-gallery', + 'name' => 'Design Gallery', + 'stylesheet' => 'css/app.css', + 'script' => 'js/app.js', + 'entry_view' => 'home', + 'appearances' => ['light', 'dark', 'system'], + 'accents' => ['blue', 'neutral'], + 'default_appearance' => 'system', + 'default_accent' => 'neutral', + ])); + file_put_contents($source.'/css/app.css', 'body{}'); + file_put_contents($source.'/js/app.js', ''); + file_put_contents($source.'/views/home.blade.php', '
Design Gallery works
'); + + $zipPath = $this->workspace.'/design-gallery.zip'; + $zip = new ZipArchive; + $this->assertTrue($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE)); + foreach (app(Filesystem::class)->allFiles($source) as $file) { + $zip->addFile($file->getPathname(), 'design-gallery/'.$file->getRelativePathname()); + } + $zip->close(); + + $upload = new UploadedFile($zipPath, 'design-gallery.zip', 'application/zip', null, true); + $theme = app(ThemePackageManager::class)->install($upload); + + $this->assertSame('design-gallery', $theme->key()); + $this->assertTrue(app(Loader::class)->all()->has('design-gallery')); + $this->assertStringContainsString( + 'Design Gallery works', + stylist_theme_view('design-gallery', 'home')->render(), + ); + } +} diff --git a/Stylist/Tests/Feature/ThemeViewResolverTest.php b/Stylist/Tests/Feature/ThemeViewResolverTest.php new file mode 100644 index 0000000..0ae83e8 --- /dev/null +++ b/Stylist/Tests/Feature/ThemeViewResolverTest.php @@ -0,0 +1,22 @@ + 'admin', + 'stylist.themes_path' => resource_path('themes'), + ]); + + $candidates = app(ThemeViewResolver::class)->candidates('components.page-hero'); + + $this->assertContains('stylist-theme-admin::components.page-hero', $candidates); + $this->assertContains('stylist::components.page-hero', $candidates); + } +} diff --git a/Stylist/Tests/Feature/WelcomeThemeViewTest.php b/Stylist/Tests/Feature/WelcomeThemeViewTest.php new file mode 100644 index 0000000..ec7624b --- /dev/null +++ b/Stylist/Tests/Feature/WelcomeThemeViewTest.php @@ -0,0 +1,21 @@ +assertTrue(view()->exists('stylist-theme-saasland::home')); + } + + public function test_home_route_renders_the_saasland_theme(): void + { + $this->get('/') + ->assertOk() + ->assertViewIs('stylist-theme-saasland::home') + ->assertSee('Transform Your Business With Our SaaS Solution'); + } +} diff --git a/Stylist/Theme/Exceptions/InvalidThemeManifestException.php b/Stylist/Theme/Exceptions/InvalidThemeManifestException.php new file mode 100644 index 0000000..a548f9f --- /dev/null +++ b/Stylist/Theme/Exceptions/InvalidThemeManifestException.php @@ -0,0 +1,18 @@ + $errors */ + public static function forPath(string $path, array $errors): self + { + return new self(sprintf( + 'Invalid theme manifest at [%s]: %s', + rtrim($path, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'theme.json', + implode(' ', $errors), + )); + } +} diff --git a/Stylist/Theme/Exceptions/ThemeJsonNotFoundException.php b/Stylist/Theme/Exceptions/ThemeJsonNotFoundException.php new file mode 100644 index 0000000..746d52e --- /dev/null +++ b/Stylist/Theme/Exceptions/ThemeJsonNotFoundException.php @@ -0,0 +1,13 @@ +files->exists($manifest)) { + throw ThemeJsonNotFoundException::forPath($path); + } + + return json_decode($this->files->get($manifest), true, flags: JSON_THROW_ON_ERROR); + } +} diff --git a/Stylist/Theme/Loader.php b/Stylist/Theme/Loader.php new file mode 100644 index 0000000..e93708d --- /dev/null +++ b/Stylist/Theme/Loader.php @@ -0,0 +1,83 @@ +themes !== null) { + return $this->themes; + } + + $themes = collect(); + + foreach ($this->roots() as $root) { + if (! $this->files->isDirectory($root)) { + continue; + } + + foreach ($this->files->directories($root) as $themePath) { + try { + $theme = $this->fromPath($themePath); + $themes->put($theme->key(), $theme); + } catch (\Throwable $exception) { + report($exception); + } + } + } + + return $this->themes = $themes->sortKeys(); + } + + public function find(string $key): Theme + { + $theme = $this->all()->get($key); + + if (! $theme) { + throw ThemeNotFoundException::forKey($key); + } + + return $theme; + } + + public function fromPath(string $path): Theme + { + $data = $this->validator->validate($this->json->read($path), $path); + + return new Theme( + key: $data['key'], + name: $data['name'], + path: rtrim($path, DIRECTORY_SEPARATOR), + stylesheet: $data['stylesheet'], + script: $data['script'], + variables: $data['variables'], + meta: $data, + ); + } + + public function flush(): void + { + $this->themes = null; + } + + /** @return array */ + public function roots(): array + { + return array_values(array_unique(array_filter([ + (string) config('stylist.themes_path'), + ]))); + } +} diff --git a/Stylist/Theme/ManifestValidator.php b/Stylist/Theme/ManifestValidator.php new file mode 100644 index 0000000..cc75998 --- /dev/null +++ b/Stylist/Theme/ManifestValidator.php @@ -0,0 +1,179 @@ + */ + public function validate(array $manifest, string $path): array + { + $errors = []; + $manifestVersion = $manifest['manifest_version'] ?? $manifest['schema_version'] ?? 1; + if ((is_string($manifestVersion) && ctype_digit($manifestVersion))) { + $manifestVersion = (int) $manifestVersion; + } + if (! is_int($manifestVersion) || $manifestVersion < 1) { + $errors[] = 'The manifest_version must be a positive integer.'; + } + + $key = $manifest['key'] ?? $manifest['slug'] ?? basename($path); + + if (! is_string($key) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $key)) { + $errors[] = 'The key/slug must contain only lowercase letters, numbers, dashes, and underscores.'; + } + if (isset($manifest['name']) && (! is_string($manifest['name']) || trim($manifest['name']) === '')) { + $errors[] = 'The name must be a non-empty string.'; + } + if (isset($manifest['version']) && (! is_string($manifest['version']) || ! preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', $manifest['version']))) { + $errors[] = 'The version must use semantic versioning, for example 1.2.0.'; + } + if (isset($manifest['type']) && ! in_array($manifest['type'], ['frontend', 'backend', 'common'], true)) { + $errors[] = 'The type must be frontend, backend, or common.'; + } + $parent = $manifest['extends'] ?? $manifest['parent'] ?? null; + if ($parent !== null && (! is_string($parent) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $parent))) { + $errors[] = 'The extends/parent value must be a valid theme key.'; + } + + foreach (['stylesheet', 'script', 'preview'] as $field) { + if (isset($manifest[$field]) && ! $this->isSafeRelativePath($manifest[$field])) { + $errors[] = "The {$field} value must be a safe relative path."; + } + } + + foreach (['assets', 'variables', 'supports', 'capabilities', 'variants', 'appearances', 'accents', 'settings', 'seo'] as $field) { + if (isset($manifest[$field]) && ! is_array($manifest[$field])) { + $errors[] = "The {$field} value must be an object or array."; + } + } + + $variants = $this->normaliseDefinitions($manifest['variants'] ?? [], 'variants', $errors, false); + $appearances = $this->normaliseDefinitions($manifest['appearances'] ?? [], 'appearances', $errors); + $accents = $this->normaliseDefinitions($manifest['accents'] ?? [], 'accents', $errors); + $capabilities = $this->normaliseCapabilities($manifest['capabilities'] ?? [], $errors); + $capabilities['appearances'] = $appearances !== []; + $capabilities['accents'] = $accents !== []; + $capabilities['preview'] = isset($manifest['preview']) || is_file($path.'/preview.webp'); + + $defaultAppearance = $manifest['default_appearance'] ?? null; + if ($defaultAppearance !== null) { + if (! is_string($defaultAppearance) || $defaultAppearance === '') { + $errors[] = 'The default_appearance value must be a non-empty string.'; + } elseif ($appearances === [] || ! array_key_exists($defaultAppearance, $appearances)) { + $errors[] = 'The default_appearance value must be declared in appearances.'; + } + } + + $defaultAccent = $manifest['default_accent'] ?? null; + if ($defaultAccent !== null) { + if (! is_string($defaultAccent) || $defaultAccent === '') { + $errors[] = 'The default_accent value must be a non-empty string.'; + } elseif ($accents === [] || ! array_key_exists($defaultAccent, $accents)) { + $errors[] = 'The default_accent value must be declared in accents.'; + } + } + + if ($errors !== []) { + throw InvalidThemeManifestException::forPath($path, $errors); + } + + $manifest['manifest_version'] = $manifestVersion; + unset($manifest['schema_version']); + $manifest['key'] = $key; + $manifest['slug'] = $key; + $manifest['name'] = $manifest['name'] ?? str($key)->headline()->toString(); + $manifest['extends'] = $parent; + $manifest['version'] = (string) ($manifest['version'] ?? '1.0.0'); + $manifest['entry_view'] = (string) ($manifest['entry_view'] ?? 'home'); + $manifest['stylesheet'] = $manifest['stylesheet'] ?? $manifest['css'] ?? 'css/theme.css'; + $manifest['script'] = $manifest['script'] ?? 'js/app.js'; + $manifest['assets'] = is_array($manifest['assets'] ?? null) ? $manifest['assets'] : []; + $manifest['variables'] = is_array($manifest['variables'] ?? null) ? $manifest['variables'] : []; + $manifest['supports'] = is_array($manifest['supports'] ?? null) ? $manifest['supports'] : []; + $manifest['capabilities'] = $capabilities; + $manifest['variants'] = $variants; + $manifest['appearances'] = $appearances; + $manifest['accents'] = $accents; + $manifest['settings'] = is_array($manifest['settings'] ?? null) ? $manifest['settings'] : []; + $manifest['seo'] = is_array($manifest['seo'] ?? null) ? $manifest['seo'] : []; + $manifest['preview'] = $manifest['preview'] ?? (is_file($path.'/preview.webp') ? 'preview.webp' : null); + + return $manifest; + } + + /** + * Compact lists such as ["light", "dark"] are accepted and converted to + * keyed empty definitions. Object definitions remain unchanged. + * + * @param array $value + * @param array $errors + * @return array> + */ + private function normaliseDefinitions(array $value, string $field, array &$errors, bool $allowCompact = true): array + { + if ($value === []) { + return []; + } + + if (array_is_list($value)) { + if (! $allowCompact && $field === 'variants') { + // Older Theme Builder Phase 1 builds incorrectly wrote accent names + // to variants. Ignore that list while preserving object-style variants. + return []; + } + + $normalised = []; + foreach ($value as $name) { + if (! is_string($name) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $name)) { + $errors[] = "Every {$field} entry must be a valid lowercase key."; + + continue; + } + $normalised[$name] = []; + } + + return $normalised; + } + + $normalised = []; + foreach ($value as $name => $definition) { + if (! is_string($name) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $name) || ! is_array($definition)) { + $errors[] = "Every {$field} definition must use a valid lowercase key and an object value."; + + continue; + } + $normalised[$name] = $definition; + } + + return $normalised; + } + + /** + * @param array $value + * @param array $errors + * @return array + */ + private function normaliseCapabilities(array $value, array &$errors): array + { + $normalised = []; + + foreach ($value as $name => $enabled) { + if (! is_string($name) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $name) || ! is_bool($enabled)) { + $errors[] = 'Every capability must use a valid lowercase key and a boolean value.'; + + continue; + } + + $normalised[$name] = $enabled; + } + + return $normalised; + } + + private function isSafeRelativePath(mixed $path): bool + { + return is_string($path) && $path !== '' && ! str_starts_with($path, '/') && ! str_contains($path, '..') && ! str_contains($path, "\0"); + } +} diff --git a/Stylist/Theme/Stylist.php b/Stylist/Theme/Stylist.php new file mode 100644 index 0000000..82793ef --- /dev/null +++ b/Stylist/Theme/Stylist.php @@ -0,0 +1,103 @@ +resolver->resolve($context); + try { + return $this->loader->find($requested); + } catch (ThemeNotFoundException) { + $fallback = $context === 'frontend' + ? (string) config('stylist.default_frontend_theme', 'saasland') + : (string) config('stylist.default_backend_theme', config('stylist.default_theme', 'admin')); + if ($requested !== $fallback) { + return $this->loader->find($fallback); + } + throw ThemeNotFoundException::forKey($requested); + } + } + + public function frontend(): Theme + { + return $this->current('frontend'); + } + + public function backend(): Theme + { + return $this->current('backend'); + } + + public function variables(): array + { + $theme = $this->current(); + $variant = $this->settings->variant($theme); + $variantVars = (array) data_get($theme->variants(), $variant.'.variables', []); + $appearanceVars = (array) data_get($theme->appearances(), $this->appearance().'.variables', []); + $accentVars = (array) data_get($theme->accents(), $this->accent().'.variables', []); + $settingVars = collect($this->settings->get($theme)) + ->filter(fn ($value, $key) => is_string($key) && str_starts_with($key, '--')) + ->mapWithKeys(fn ($value, $key) => [ltrim($key, '-') => $value]) + ->all(); + + return array_replace( + (array) config('stylist.fallback_variables', []), + $this->inheritedVariables($theme), + $variantVars, + $appearanceVars, + $accentVars, + $settingVars, + $this->resolver->variables(), + ); + } + + public function settings(): array + { + return $this->settings->get($this->current()); + } + + public function variant(): string + { + return $this->settings->variant($this->current()); + } + + public function appearance(): string + { + return $this->settings->appearance($this->current()); + } + + public function accent(): string + { + return $this->settings->accent($this->current()); + } + + public function all() + { + return $this->loader->all(); + } + + private function inheritedVariables(Theme $theme, array $seen = []): array + { + if (in_array($theme->key(), $seen, true)) { + return []; + } + $seen[] = $theme->key(); + $parent = []; + if ($theme->parent()) { + try { + $parent = $this->inheritedVariables($this->loader->find($theme->parent()), $seen); + } catch (ThemeNotFoundException) { + } + } + + return array_replace($parent, $theme->variables()); + } +} diff --git a/Stylist/Theme/Theme.php b/Stylist/Theme/Theme.php new file mode 100644 index 0000000..2010173 --- /dev/null +++ b/Stylist/Theme/Theme.php @@ -0,0 +1,161 @@ +key; + } + + public function name(): string + { + return $this->name; + } + + public function path(): string + { + return $this->path; + } + + public function stylesheet(): string + { + return $this->stylesheet; + } + + public function script(): string + { + return $this->script; + } + + public function variables(): array + { + return $this->variables; + } + + public function meta(): array + { + return $this->meta; + } + + public function manifestVersion(): int + { + return (int) ($this->meta['manifest_version'] ?? 1); + } + + public function version(): string + { + return (string) ($this->meta['version'] ?? '1.0.0'); + } + + public function description(): string + { + return (string) ($this->meta['description'] ?? ''); + } + + public function type(): string + { + return (string) ($this->meta['type'] ?? 'frontend'); + } + + public function author(): string + { + return (string) ($this->meta['author'] ?? ''); + } + + public function parent(): ?string + { + $parent = $this->meta['extends'] ?? null; + + return is_string($parent) && $parent !== '' ? $parent : null; + } + + public function preview(): ?string + { + $preview = $this->meta['preview'] ?? null; + + return is_string($preview) && $preview !== '' ? $preview : null; + } + + public function variants(): array + { + return (array) ($this->meta['variants'] ?? []); + } + + public function appearances(): array + { + return (array) ($this->meta['appearances'] ?? []); + } + + public function accents(): array + { + return (array) ($this->meta['accents'] ?? []); + } + + public function defaultAppearance(): string + { + return (string) ($this->meta['default_appearance'] ?? array_key_first($this->appearances()) ?? ''); + } + + public function defaultAccent(): string + { + return (string) ($this->meta['default_accent'] ?? array_key_first($this->accents()) ?? ''); + } + + public function settingsSchema(): array + { + return (array) ($this->meta['settings'] ?? []); + } + + public function seo(): array + { + return (array) ($this->meta['seo'] ?? []); + } + + public function entryView(): string + { + return (string) ($this->meta['entry_view'] ?? 'home'); + } + + public function capabilities(): array + { + return (array) ($this->meta['capabilities'] ?? []); + } + + public function hasCapability(string $feature): bool + { + return (bool) ($this->capabilities()[$feature] ?? false); + } + + public function supports(string $feature): bool + { + return (bool) data_get($this->meta, 'supports.'.$feature, $this->hasCapability($feature)); + } + + public function has(string $relativePath): bool + { + return is_file($this->path.DIRECTORY_SEPARATOR.ltrim($relativePath, '/')); + } + + public function absolutePath(string $relativePath): string + { + return $this->path.DIRECTORY_SEPARATOR.ltrim($relativePath, '/'); + } + + /** @deprecated Theme assets are served through UrlGenerator. */ + public function assetsPath(): string + { + return 'stylist/theme-assets/'.$this->key; + } +} diff --git a/Stylist/Theme/UrlGenerator.php b/Stylist/Theme/UrlGenerator.php new file mode 100644 index 0000000..77d08f8 --- /dev/null +++ b/Stylist/Theme/UrlGenerator.php @@ -0,0 +1,20 @@ +forTheme($this->stylist->current(), $path); + } + + public function forTheme(Theme|string $theme, string $path): string + { + $key = $theme instanceof Theme ? $theme->key() : $theme; + + return route('stylist.theme.asset', ['theme' => $key, 'path' => ltrim($path, '/')]); + } +} diff --git a/Stylist/View/ThemeViewResolver.php b/Stylist/View/ThemeViewResolver.php new file mode 100644 index 0000000..91d263d --- /dev/null +++ b/Stylist/View/ThemeViewResolver.php @@ -0,0 +1,130 @@ +> */ + private array $candidateCache = []; + + public function __construct( + private readonly Stylist $stylist, + private readonly ViewFactory $views, + private readonly Loader $loader, + ) {} + + public function make(string $view, array $data = [], array $mergeData = []): View + { + return $this->views->first( + $this->candidates($view), + array_replace([ + 'themeSettings' => $this->stylist->settings(), + 'themeVariant' => $this->stylist->variant(), + ], $data), + $mergeData, + ); + } + + public function makeForTheme(string $theme, string $view, array $data = [], array $mergeData = []): View + { + return $this->views->first($this->candidatesForTheme($theme, $view), $data, $mergeData); + } + + /** @return array */ + public function candidates(string $view): array + { + return $this->candidatesForTheme($this->stylist->current()->key(), $view); + } + + /** @return array */ + public function candidatesForTheme(string $theme, string $view): array + { + $view = ltrim($view, '.'); + $cacheKey = $theme.'|'.$view; + + if (isset($this->candidateCache[$cacheKey])) { + return $this->candidateCache[$cacheKey]; + } + + $keys = $this->inheritanceChain($theme); + $fallback = (string) config('stylist.default_theme', 'common'); + + if ($fallback !== '' && ! in_array($fallback, $keys, true)) { + try { + $keys = array_merge($keys, $this->inheritanceChain($fallback)); + } catch (ThemeNotFoundException) { + // A missing configured fallback is diagnosed by stylist:doctor. + } + } + + if (! in_array('common', $keys, true)) { + try { + $keys = array_merge($keys, $this->inheritanceChain('common')); + } catch (ThemeNotFoundException) { + // The module fallback below remains available. + } + } + + $candidates = []; + foreach (array_values(array_unique($keys)) as $key) { + $this->registerNamespace($key); + $candidates[] = "stylist-theme-{$key}::{$view}"; + $candidates[] = "stylist-module-theme-{$key}::{$view}"; + } + + $candidates[] = "stylist::{$view}"; + $candidates[] = $view; + + return $this->candidateCache[$cacheKey] = array_values(array_unique($candidates)); + } + + public function flush(): void + { + $this->candidateCache = []; + } + + /** @return array */ + private function inheritanceChain(string $theme): array + { + $keys = []; + $seen = []; + $key = $theme; + + while ($key !== '') { + if (isset($seen[$key])) { + throw new RuntimeException('Theme inheritance cycle detected at ['.$key.'].'); + } + + $seen[$key] = true; + $loaded = $this->loader->find($key); + $keys[] = $loaded->key(); + $key = $loaded->parent() ?? ''; + } + + return $keys; + } + + private function registerNamespace(string $theme): void + { + try { + $loaded = $this->loader->find($theme); + } catch (ThemeNotFoundException) { + return; + } + + $viewsPath = $loaded->path().DIRECTORY_SEPARATOR.'views'; + if (! is_dir($viewsPath)) { + return; + } + + $this->views->addNamespace("stylist-theme-{$theme}", $viewsPath); + $this->views->addNamespace("stylist-module-theme-{$theme}", $viewsPath); + } +} diff --git a/Stylist/changelog.yaml b/Stylist/changelog.yaml new file mode 100644 index 0000000..7a8e4e0 --- /dev/null +++ b/Stylist/changelog.yaml @@ -0,0 +1,24 @@ +latest: 1.1.0 +releases: + - version: 1.1.0 + date: 2026-07-08 + changes: + - Added self-contained theme folders with theme.json, CSS, Blade views, and Livewire views. + - Added ThemeViewResolver and stylist_view() fallback helper. + - Added default and MPBA example themes. + - Added theme publishing support. + - Updated Vite inputs to include theme CSS files. + - version: 1.0.0 + date: 2026-07-08 + changes: + - Initial Laravel 13 Stylist module. + + - version: 1.1.0 + date: 2026-07-23 + changes: + - Added optional manifest_version/schema_version normalisation. + - Added capability normalisation for appearances, accents, previews and future features. + - Added stylist:validate for installed themes, directories and ZIP packages. + - Added complete package validation during installation. + - Added dynamic view namespace registration after installing a theme. + - Added manifest compatibility and install-render regression coverage. diff --git a/Stylist/composer.json b/Stylist/composer.json new file mode 100644 index 0000000..d537e3a --- /dev/null +++ b/Stylist/composer.json @@ -0,0 +1,22 @@ +{ + "name": "mpba/stylist-module", + "description": "Tenant-aware theme and asset switching module for Laravel 13.", + "type": "laravel-module", + "require": { + "php": "^8.3", + "illuminate/support": "^13.0", + "illuminate/view": "^13.0", + "illuminate/filesystem": "^13.0", + "illuminate/console": "^13.0" + }, + "autoload": { + "psr-4": { + "Modules\\Stylist\\": "" + } + }, + "autoload-dev": { + "psr-4": { + "Modules\\Stylist\\Tests\\": "Tests/" + } + } +} diff --git a/Stylist/module.json b/Stylist/module.json new file mode 100644 index 0000000..214c2d2 --- /dev/null +++ b/Stylist/module.json @@ -0,0 +1,20 @@ +{ + "name": "Stylist", + "alias": "stylist", + "version": "1.1.0", + "description": "Tenant-aware theme and asset switching module for Laravel 13.", + "keywords": [ + "laravel", + "modules", + "themes", + "tenancy", + "styling" + ], + "priority": 0, + "providers": [ + "Modules\\Stylist\\Providers\\StylistServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [] +} diff --git a/Stylist/package.json b/Stylist/package.json new file mode 100644 index 0000000..e0b33af --- /dev/null +++ b/Stylist/package.json @@ -0,0 +1,15 @@ +{ + "scripts": { + "dev": "vite", + "build": "vite build", + + "stylist:dev": "vite --config Modules/Stylist/vite.config.js", + "stylist:build": "vite build --config Modules/Stylist/vite.config.js", + + "matches:dev": "vite --config Modules/Matches/vite.config.js", + "matches:build": "vite build --config Modules/Matches/vite.config.js", + + "modules:build": "npm run stylist:build && npm run matches:build", + "build:all": "npm run build && npm run modules:build" + } +} diff --git a/Stylist/vite.config.js b/Stylist/vite.config.js new file mode 100644 index 0000000..de55470 --- /dev/null +++ b/Stylist/vite.config.js @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [ + laravel({ + input: [ + 'Modules/Stylist/Resources/assets/css/app.css', + 'Modules/Stylist/Resources/assets/js/app.js', + ], + + refresh: [ + 'Modules/Stylist/Resources/views/**', + 'resources/themes/**/views/**', + 'Modules/Stylist/App/**', + 'Modules/Stylist/routes/**', + ], + + buildDirectory: 'build/stylist', + }), + + tailwindcss(), + ], +}); diff --git a/Tags/Config/config.php b/Tags/Config/config.php new file mode 100644 index 0000000..4f63319 --- /dev/null +++ b/Tags/Config/config.php @@ -0,0 +1,3 @@ + 'Tags']; diff --git a/Tags/Database/Migrations/2026_07_24_030200_create_tags_table.php b/Tags/Database/Migrations/2026_07_24_030200_create_tags_table.php new file mode 100644 index 0000000..8a969e7 --- /dev/null +++ b/Tags/Database/Migrations/2026_07_24_030200_create_tags_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->unsignedInteger('usage_count')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('tags'); + } +}; diff --git a/Tags/Http/Controllers/TagController.php b/Tags/Http/Controllers/TagController.php new file mode 100644 index 0000000..08e0a08 --- /dev/null +++ b/Tags/Http/Controllers/TagController.php @@ -0,0 +1,48 @@ + Tag::orderByDesc('usage_count')->paginate(30)]); + } + + public function create(): View + { + return view('tags::admin.form'); + } + + public function store(Request $request): RedirectResponse + { + Tag::create($request->validate(['name' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255', 'unique:tags,slug'], 'description' => ['nullable', 'string']])); + + return redirect()->route('administration.tags.index')->with('status', 'Tag created.'); + } + + public function edit(Tag $tag): View + { + return view('tags::admin.form', ['record' => $tag]); + } + + public function update(Request $request, Tag $tag): RedirectResponse + { + $tag->update($request->validate(['name' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255', 'unique:tags,slug,'.$tag->id], 'description' => ['nullable', 'string']])); + + return redirect()->route('administration.tags.index')->with('status', 'Tag updated.'); + } + + public function destroy(Tag $tag): RedirectResponse + { + $tag->delete(); + + return back()->with('status', 'Tag deleted.'); + } +} diff --git a/Tags/Models/Tag.php b/Tags/Models/Tag.php new file mode 100644 index 0000000..da7b39c --- /dev/null +++ b/Tags/Models/Tag.php @@ -0,0 +1,15 @@ + 'integer']; + } +} diff --git a/Tags/Providers/RouteServiceProvider.php b/Tags/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..3605636 --- /dev/null +++ b/Tags/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +group(module_path('Tags', 'Routes/web.php')); + } +} diff --git a/Tags/Providers/TagsServiceProvider.php b/Tags/Providers/TagsServiceProvider.php new file mode 100644 index 0000000..51407ff --- /dev/null +++ b/Tags/Providers/TagsServiceProvider.php @@ -0,0 +1,20 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Tags', 'Config/config.php'), 'tags'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Tags', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Tags', 'Resources/views'), 'tags'); + } +} diff --git a/Tags/Resources/views/admin/form.blade.php b/Tags/Resources/views/admin/form.blade.php new file mode 100644 index 0000000..9dfe384 --- /dev/null +++ b/Tags/Resources/views/admin/form.blade.php @@ -0,0 +1 @@ +
@csrf @isset($record) @method('PUT') @endisset {{ old('description',$record->description ?? '') }}Save
diff --git a/Tags/Resources/views/admin/index.blade.php b/Tags/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..537b5d9 --- /dev/null +++ b/Tags/Resources/views/admin/index.blade.php @@ -0,0 +1 @@ +

Tags

Manage publishing tags.

Create
@if(session('status'))
{{ session('status') }}
@endif
@forelse($records as $record)@empty@endforelse
Name / titleStatusActions
{{ $record->title ?? $record->name }}{{ $record->status ?? ($record->is_active ? 'Active' : 'Inactive') }}Edit
@csrf @method('DELETE')Delete
No records yet.
{{ $records->links() }}
diff --git a/Tags/Routes/web.php b/Tags/Routes/web.php new file mode 100644 index 0000000..2b425f6 --- /dev/null +++ b/Tags/Routes/web.php @@ -0,0 +1,13 @@ +prefix('administration/tags')->name('administration.tags.')->group(function (): void { + Route::get('/', [TagController::class, 'index'])->name('index'); + Route::get('/create', [TagController::class, 'create'])->name('create'); + Route::post('/', [TagController::class, 'store'])->name('store'); + Route::get('/{tag}/edit', [TagController::class, 'edit'])->name('edit'); + Route::match(['put', 'patch'], '/{tag}', [TagController::class, 'update'])->name('update'); + Route::delete('/{tag}', [TagController::class, 'destroy'])->name('destroy'); +}); diff --git a/Tags/Tests/Feature/ModuleBootTest.php b/Tags/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..1bc2dd9 --- /dev/null +++ b/Tags/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,13 @@ +assertSame('Tags', config('tags.name')); + } +} diff --git a/Tags/module.json b/Tags/module.json new file mode 100644 index 0000000..53001bb --- /dev/null +++ b/Tags/module.json @@ -0,0 +1,12 @@ +{ + "name": "Tags", + "alias": "tags", + "version": "1.0.0", + "description": "Tags publishing module.", + "keywords": ["publishing", "tags"], + "priority": 30, + "providers": ["Modules\\Tags\\Providers\\TagsServiceProvider"], + "aliases": {}, + "files": [], + "requires": ["Core", "Administration"] +} diff --git a/Team/Config/config.php b/Team/Config/config.php new file mode 100644 index 0000000..3342295 --- /dev/null +++ b/Team/Config/config.php @@ -0,0 +1,3 @@ + 'Team']; diff --git a/Team/Database/Migrations/2026_07_24_000800_create_team_members_table.php b/Team/Database/Migrations/2026_07_24_000800_create_team_members_table.php new file mode 100644 index 0000000..67bfdcc --- /dev/null +++ b/Team/Database/Migrations/2026_07_24_000800_create_team_members_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('name'); + $table->string('job_title')->nullable(); + $table->text('biography')->nullable(); + $table->string('email')->nullable(); + $table->unsignedInteger('sort_order')->default(0); + $table->boolean('enabled')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('team_members'); + } +}; diff --git a/Team/Http/Controllers/TeamMemberController.php b/Team/Http/Controllers/TeamMemberController.php new file mode 100644 index 0000000..270e4ad --- /dev/null +++ b/Team/Http/Controllers/TeamMemberController.php @@ -0,0 +1,62 @@ + TeamMember::query()->latest()->paginate(20)]); + } + + public function create(): View + { + return view('team::admin.form'); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate(['name' => ['required', 'string', 'max:255'], + 'job_title' => ['nullable', 'string'], + 'biography' => ['nullable', 'string'], + 'email' => ['nullable', 'string'], + 'sort_order' => ['nullable', 'integer', 'min:0'], + 'enabled' => ['sometimes', 'boolean']]); + $data['enabled'] = $request->boolean('enabled'); + TeamMember::create($data); + + return redirect()->route('administration.team.index')->with('status', 'TeamMember created.'); + } + + public function edit(TeamMember $teamMember): View + { + return view('team::admin.form', ['record' => $teamMember]); + } + + public function update(Request $request, TeamMember $teamMember): RedirectResponse + { + $data = $request->validate(['name' => ['required', 'string', 'max:255'], + 'job_title' => ['nullable', 'string'], + 'biography' => ['nullable', 'string'], + 'email' => ['nullable', 'string'], + 'sort_order' => ['nullable', 'integer', 'min:0'], + 'enabled' => ['sometimes', 'boolean']]); + $data['enabled'] = $request->boolean('enabled'); + $teamMember->update($data); + + return redirect()->route('administration.team.index')->with('status', 'TeamMember updated.'); + } + + public function destroy(TeamMember $teamMember): RedirectResponse + { + $teamMember->delete(); + + return back()->with('status', 'TeamMember deleted.'); + } +} diff --git a/Team/Models/TeamMember.php b/Team/Models/TeamMember.php new file mode 100644 index 0000000..1ada6eb --- /dev/null +++ b/Team/Models/TeamMember.php @@ -0,0 +1,24 @@ + 'integer', + 'enabled' => 'boolean', + ]; + } +} diff --git a/Team/Providers/RouteServiceProvider.php b/Team/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..a50f441 --- /dev/null +++ b/Team/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +routes(fn () => Route::middleware('web')->group(module_path('Team', 'Routes/web.php'))); + } +} diff --git a/Team/Providers/TeamServiceProvider.php b/Team/Providers/TeamServiceProvider.php new file mode 100644 index 0000000..6cdcac1 --- /dev/null +++ b/Team/Providers/TeamServiceProvider.php @@ -0,0 +1,20 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Team', 'Config/config.php'), 'team'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Team', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Team', 'Resources/views'), 'team'); + } +} diff --git a/Team/Resources/views/admin/form.blade.php b/Team/Resources/views/admin/form.blade.php new file mode 100644 index 0000000..621f780 --- /dev/null +++ b/Team/Resources/views/admin/form.blade.php @@ -0,0 +1,10 @@ + +

{{ isset($record) ? 'Edit' : 'Create' }} Team member

@csrf @isset($record) @method('PUT') @endisset +
@error('name')

{{ $message }}

@enderror
+
@error('job_title')

{{ $message }}

@enderror
+
@error('biography')

{{ $message }}

@enderror
+
@error('email')

{{ $message }}

@enderror
+
@error('sort_order')

{{ $message }}

@enderror
+ +
Cancel
+
diff --git a/Team/Resources/views/admin/index.blade.php b/Team/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..6345c12 --- /dev/null +++ b/Team/Resources/views/admin/index.blade.php @@ -0,0 +1,5 @@ + +

Team

Manage team content.

Add new
+@if(session('status'))
{{ session('status') }}
@endif +
@forelse($records as $record)@empty@endforelse
NameJob TitleBiographyActions
{{ is_array($record->name) ? json_encode($record->name) : Str::limit((string) $record->name, 70) }}{{ is_array($record->job_title) ? json_encode($record->job_title) : Str::limit((string) $record->job_title, 70) }}{{ is_array($record->biography) ? json_encode($record->biography) : Str::limit((string) $record->biography, 70) }}Edit
@csrf @method('DELETE')
No records yet.
{{ $records->links() }}
+
diff --git a/Team/Routes/web.php b/Team/Routes/web.php new file mode 100644 index 0000000..0c6678d --- /dev/null +++ b/Team/Routes/web.php @@ -0,0 +1,13 @@ +prefix('administration/team')->name('administration.team.')->group(function (): void { + Route::get('/', [TeamMemberController::class, 'index'])->name('index'); + Route::get('/create', [TeamMemberController::class, 'create'])->name('create'); + Route::post('/', [TeamMemberController::class, 'store'])->name('store'); + Route::get('/{teamMember}/edit', [TeamMemberController::class, 'edit'])->name('edit'); + Route::match(['put', 'patch'], '/{teamMember}', [TeamMemberController::class, 'update'])->name('update'); + Route::delete('/{teamMember}', [TeamMemberController::class, 'destroy'])->name('destroy'); +}); diff --git a/Team/Support/ModuleInfo.php b/Team/Support/ModuleInfo.php new file mode 100644 index 0000000..4fcd387 --- /dev/null +++ b/Team/Support/ModuleInfo.php @@ -0,0 +1,16 @@ +toBeTrue(); +}); diff --git a/Team/module.json b/Team/module.json new file mode 100644 index 0000000..7cbacd8 --- /dev/null +++ b/Team/module.json @@ -0,0 +1,20 @@ +{ + "name": "Team", + "alias": "team", + "version": "1.0.0", + "description": "Team CMS module.", + "keywords": [ + "cms", + "team" + ], + "priority": 28, + "providers": [ + "Modules\\Team\\Providers\\TeamServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [ + "Core", + "Administration" + ] +} diff --git a/Testimonials/Config/config.php b/Testimonials/Config/config.php new file mode 100644 index 0000000..ab940e8 --- /dev/null +++ b/Testimonials/Config/config.php @@ -0,0 +1,3 @@ + 'Testimonials']; diff --git a/Testimonials/Database/Migrations/2026_07_24_000900_create_testimonials_table.php b/Testimonials/Database/Migrations/2026_07_24_000900_create_testimonials_table.php new file mode 100644 index 0000000..bebe283 --- /dev/null +++ b/Testimonials/Database/Migrations/2026_07_24_000900_create_testimonials_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('name'); + $table->string('company')->nullable(); + + $table->unsignedInteger('rating')->default(5); + $table->boolean('enabled')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('testimonials'); + } +}; diff --git a/Testimonials/Database/Migrations/2026_07_27_070000_add_content_to_testimonials_table.php b/Testimonials/Database/Migrations/2026_07_27_070000_add_content_to_testimonials_table.php new file mode 100644 index 0000000..e81f8a9 --- /dev/null +++ b/Testimonials/Database/Migrations/2026_07_27_070000_add_content_to_testimonials_table.php @@ -0,0 +1,26 @@ +text('content')->nullable()->after('company'); + }); + } + } + + public function down(): void + { + if (Schema::hasTable('testimonials') && Schema::hasColumn('testimonials', 'content')) { + Schema::table('testimonials', function (Blueprint $table): void { + $table->dropColumn('content'); + }); + } + } +}; diff --git a/Testimonials/Http/Controllers/TestimonialController.php b/Testimonials/Http/Controllers/TestimonialController.php new file mode 100644 index 0000000..908e974 --- /dev/null +++ b/Testimonials/Http/Controllers/TestimonialController.php @@ -0,0 +1,60 @@ + Testimonial::query()->latest()->paginate(20)]); + } + + public function create(): View + { + return view('testimonials::admin.form'); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate(['name' => ['required', 'string', 'max:255'], + 'company' => ['nullable', 'string'], + 'content' => ['required', 'string'], + 'rating' => ['nullable', 'integer', 'min:0'], + 'enabled' => ['sometimes', 'boolean']]); + $data['enabled'] = $request->boolean('enabled'); + Testimonial::create($data); + + return redirect()->route('administration.testimonials.index')->with('status', 'Testimonial created.'); + } + + public function edit(Testimonial $testimonial): View + { + return view('testimonials::admin.form', ['record' => $testimonial]); + } + + public function update(Request $request, Testimonial $testimonial): RedirectResponse + { + $data = $request->validate(['name' => ['required', 'string', 'max:255'], + 'company' => ['nullable', 'string'], + 'content' => ['required', 'string'], + 'rating' => ['nullable', 'integer', 'min:0'], + 'enabled' => ['sometimes', 'boolean']]); + $data['enabled'] = $request->boolean('enabled'); + $testimonial->update($data); + + return redirect()->route('administration.testimonials.index')->with('status', 'Testimonial updated.'); + } + + public function destroy(Testimonial $testimonial): RedirectResponse + { + $testimonial->delete(); + + return back()->with('status', 'Testimonial deleted.'); + } +} diff --git a/Testimonials/Models/Testimonial.php b/Testimonials/Models/Testimonial.php new file mode 100644 index 0000000..590160f --- /dev/null +++ b/Testimonials/Models/Testimonial.php @@ -0,0 +1,24 @@ + 'integer', + 'enabled' => 'boolean', + ]; + } +} diff --git a/Testimonials/Providers/RouteServiceProvider.php b/Testimonials/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..98aac75 --- /dev/null +++ b/Testimonials/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +routes(fn () => Route::middleware('web')->group(module_path('Testimonials', 'Routes/web.php'))); + } +} diff --git a/Testimonials/Providers/TestimonialsServiceProvider.php b/Testimonials/Providers/TestimonialsServiceProvider.php new file mode 100644 index 0000000..fde762a --- /dev/null +++ b/Testimonials/Providers/TestimonialsServiceProvider.php @@ -0,0 +1,20 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Testimonials', 'Config/config.php'), 'testimonials'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Testimonials', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Testimonials', 'Resources/views'), 'testimonials'); + } +} diff --git a/Testimonials/Resources/views/admin/form.blade.php b/Testimonials/Resources/views/admin/form.blade.php new file mode 100644 index 0000000..c6f4e86 --- /dev/null +++ b/Testimonials/Resources/views/admin/form.blade.php @@ -0,0 +1,9 @@ + +

{{ isset($record) ? 'Edit' : 'Create' }} Testimonial

@csrf @isset($record) @method('PUT') @endisset +
@error('name')

{{ $message }}

@enderror
+
@error('company')

{{ $message }}

@enderror
+
@error('content')

{{ $message }}

@enderror
+
@error('rating')

{{ $message }}

@enderror
+ +
Cancel
+
diff --git a/Testimonials/Resources/views/admin/index.blade.php b/Testimonials/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..ab55427 --- /dev/null +++ b/Testimonials/Resources/views/admin/index.blade.php @@ -0,0 +1,5 @@ + +

Testimonials

Manage testimonials content.

Add new
+@if(session('status'))
{{ session('status') }}
@endif +
@forelse($records as $record)@empty@endforelse
NameCompanyContentActions
{{ is_array($record->name) ? json_encode($record->name) : Str::limit((string) $record->name, 70) }}{{ is_array($record->company) ? json_encode($record->company) : Str::limit((string) $record->company, 70) }}{{ is_array($record->content) ? json_encode($record->content) : Str::limit((string) $record->content, 70) }}Edit
@csrf @method('DELETE')
No records yet.
{{ $records->links() }}
+
diff --git a/Testimonials/Routes/web.php b/Testimonials/Routes/web.php new file mode 100644 index 0000000..3527aff --- /dev/null +++ b/Testimonials/Routes/web.php @@ -0,0 +1,13 @@ +prefix('administration/testimonials')->name('administration.testimonials.')->group(function (): void { + Route::get('/', [TestimonialController::class, 'index'])->name('index'); + Route::get('/create', [TestimonialController::class, 'create'])->name('create'); + Route::post('/', [TestimonialController::class, 'store'])->name('store'); + Route::get('/{testimonial}/edit', [TestimonialController::class, 'edit'])->name('edit'); + Route::match(['put', 'patch'], '/{testimonial}', [TestimonialController::class, 'update'])->name('update'); + Route::delete('/{testimonial}', [TestimonialController::class, 'destroy'])->name('destroy'); +}); diff --git a/Testimonials/Support/ModuleInfo.php b/Testimonials/Support/ModuleInfo.php new file mode 100644 index 0000000..afc0468 --- /dev/null +++ b/Testimonials/Support/ModuleInfo.php @@ -0,0 +1,16 @@ +toBeTrue(); +}); diff --git a/Testimonials/module.json b/Testimonials/module.json new file mode 100644 index 0000000..9ee5cfa --- /dev/null +++ b/Testimonials/module.json @@ -0,0 +1,20 @@ +{ + "name": "Testimonials", + "alias": "testimonials", + "version": "1.0.0", + "description": "Testimonials CMS module.", + "keywords": [ + "cms", + "testimonials" + ], + "priority": 29, + "providers": [ + "Modules\\Testimonials\\Providers\\TestimonialsServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [ + "Core", + "Administration" + ] +} diff --git a/Users/Config/config.php b/Users/Config/config.php new file mode 100644 index 0000000..d0471c5 --- /dev/null +++ b/Users/Config/config.php @@ -0,0 +1,6 @@ + 'Users', + 'enabled' => true, +]; diff --git a/Users/Http/Controllers/UserController.php b/Users/Http/Controllers/UserController.php new file mode 100644 index 0000000..79191a5 --- /dev/null +++ b/Users/Http/Controllers/UserController.php @@ -0,0 +1,56 @@ + User::with('roles')->latest()->paginate(20)]); + } + + public function create() + { + return view('stylist-theme-admin::administration.users.create', ['roles' => Role::orderBy('name')->pluck('name')]); + } + + public function store(Request $request) + { + $data = $request->validate(['name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'max:255', 'unique:users,email'], 'password' => ['required', 'string', 'min:8', 'confirmed'], 'roles' => ['array'], 'roles.*' => ['string', 'exists:roles,name']]); + $user = User::create($data); + $user->syncRoles($data['roles'] ?? []); + + return redirect()->route('administration.users.index')->with('status', 'User created.'); + } + + public function edit(User $user) + { + return view('stylist-theme-admin::administration.users.edit', ['user' => $user->load('roles'), 'roles' => Role::orderBy('name')->pluck('name')]); + } + + public function update(Request $request, User $user) + { + $data = $request->validate(['name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user)], 'password' => ['nullable', 'string', 'min:8', 'confirmed'], 'roles' => ['array'], 'roles.*' => ['string', 'exists:roles,name']]); + if (blank($data['password'] ?? null)) { + unset($data['password']); + } + $user->update($data); + $user->syncRoles($data['roles'] ?? []); + + return redirect()->route('administration.users.index')->with('status', 'User updated.'); + } + + public function destroy(User $user) + { + abort_if($user->is(auth()->user()), 422, 'You cannot delete your own account.'); + $user->delete(); + + return redirect()->route('administration.users.index')->with('status', 'User deleted.'); + } +} diff --git a/Users/Providers/RouteServiceProvider.php b/Users/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..89f776a --- /dev/null +++ b/Users/Providers/RouteServiceProvider.php @@ -0,0 +1,17 @@ +routes(function (): void { + Route::middleware('web')->group(module_path('Users', 'Routes/web.php')); + }); + } +} diff --git a/Users/Providers/UsersServiceProvider.php b/Users/Providers/UsersServiceProvider.php new file mode 100644 index 0000000..96446ad --- /dev/null +++ b/Users/Providers/UsersServiceProvider.php @@ -0,0 +1,21 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower()); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations')); + $this->loadViewsFrom(module_path(ModuleInfo::name(), 'Resources/views'), ModuleInfo::nameLower()); + } +} diff --git a/Users/Routes/web.php b/Users/Routes/web.php new file mode 100644 index 0000000..d4d0dc8 --- /dev/null +++ b/Users/Routes/web.php @@ -0,0 +1,8 @@ +prefix('administration/users')->name('administration.users.')->group(function (): void { + Route::resource('', UserController::class)->parameters(['' => 'user'])->except(['show']); +}); diff --git a/Users/Support/ModuleInfo.php b/Users/Support/ModuleInfo.php new file mode 100644 index 0000000..6122d1c --- /dev/null +++ b/Users/Support/ModuleInfo.php @@ -0,0 +1,16 @@ +toBe('Users'); +}); diff --git a/Users/composer.json b/Users/composer.json new file mode 100644 index 0000000..cbcc957 --- /dev/null +++ b/Users/composer.json @@ -0,0 +1,10 @@ +{ + "name": "absdev/users-module", + "description": "Users foundation module for the ABSDEV Laravel application platform.", + "type": "laravel-module", + "autoload": { + "psr-4": { + "Modules\\Users\\": "" + } + } +} diff --git a/Users/module.json b/Users/module.json new file mode 100644 index 0000000..fb2c657 --- /dev/null +++ b/Users/module.json @@ -0,0 +1,20 @@ +{ + "name": "Users", + "alias": "users", + "version": "1.0.0", + "description": "Users foundation module for the ABSDEV Laravel application platform.", + "keywords": [ + "laravel", + "modules", + "users" + ], + "priority": 10, + "providers": [ + "Modules\\Users\\Providers\\UsersServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [ + "Core" + ] +} diff --git a/Workflow/Concerns/HasWorkflow.php b/Workflow/Concerns/HasWorkflow.php new file mode 100644 index 0000000..d018a87 --- /dev/null +++ b/Workflow/Concerns/HasWorkflow.php @@ -0,0 +1,19 @@ +morphOne(WorkflowInstance::class, 'subject'); + } + + public function workflowLabel(): string + { + return (string) ($this->title ?? $this->name ?? class_basename($this).' #'.$this->getKey()); + } +} diff --git a/Workflow/Config/config.php b/Workflow/Config/config.php new file mode 100644 index 0000000..c61a662 --- /dev/null +++ b/Workflow/Config/config.php @@ -0,0 +1,3 @@ + 'Workflow', 'default_due_days' => 3]; diff --git a/Workflow/Contracts/Workflowable.php b/Workflow/Contracts/Workflowable.php new file mode 100644 index 0000000..9d76383 --- /dev/null +++ b/Workflow/Contracts/Workflowable.php @@ -0,0 +1,12 @@ +id(); + $t->string('name'); + $t->string('slug')->unique(); + $t->text('description')->nullable(); + $t->string('subject_type')->nullable()->index(); + $t->boolean('is_active')->default(true); + $t->timestamps(); + }); + Schema::create('workflow_stages', function (Blueprint $t) { + $t->id(); + $t->foreignId('workflow_definition_id')->constrained()->cascadeOnDelete(); + $t->string('name'); + $t->string('slug'); + $t->unsignedInteger('position')->default(0); + $t->string('required_permission')->nullable(); + $t->boolean('is_initial')->default(false); + $t->boolean('is_terminal')->default(false); + $t->boolean('allows_rejection')->default(true); + $t->unsignedInteger('due_days')->nullable(); + $t->timestamps(); + $t->unique(['workflow_definition_id', 'slug']); + }); + Schema::create('workflow_transitions', function (Blueprint $t) { + $t->id(); + $t->foreignId('workflow_definition_id')->constrained()->cascadeOnDelete(); + $t->foreignId('from_stage_id')->constrained('workflow_stages')->cascadeOnDelete(); + $t->foreignId('to_stage_id')->constrained('workflow_stages')->cascadeOnDelete(); + $t->string('name'); + $t->string('action'); + $t->string('required_permission')->nullable(); + $t->boolean('requires_comment')->default(false); + $t->timestamps(); + }); + Schema::create('workflow_instances', function (Blueprint $t) { + $t->id(); + $t->foreignId('workflow_definition_id')->constrained()->cascadeOnDelete(); + $t->foreignId('workflow_stage_id')->constrained('workflow_stages'); + $t->string('subject_type'); + $t->unsignedBigInteger('subject_id'); + $t->string('status')->default('active'); + $t->foreignId('started_by')->nullable()->constrained('users')->nullOnDelete(); + $t->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete(); + $t->timestamp('due_at')->nullable(); + $t->timestamp('completed_at')->nullable(); + $t->timestamps(); + $t->unique(['subject_type', 'subject_id']); + $t->index(['status', 'workflow_stage_id']); + }); + Schema::create('workflow_history', function (Blueprint $t) { + $t->id(); + $t->foreignId('workflow_instance_id')->constrained()->cascadeOnDelete(); + $t->foreignId('from_stage_id')->nullable()->constrained('workflow_stages')->nullOnDelete(); + $t->foreignId('to_stage_id')->nullable()->constrained('workflow_stages')->nullOnDelete(); + $t->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + $t->string('action'); + $t->text('comment')->nullable(); + $t->json('metadata')->nullable(); + $t->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('workflow_history'); + Schema::dropIfExists('workflow_instances'); + Schema::dropIfExists('workflow_transitions'); + Schema::dropIfExists('workflow_stages'); + Schema::dropIfExists('workflow_definitions'); + } +}; diff --git a/Workflow/Http/Controllers/WorkflowController.php b/Workflow/Http/Controllers/WorkflowController.php new file mode 100644 index 0000000..62479b1 --- /dev/null +++ b/Workflow/Http/Controllers/WorkflowController.php @@ -0,0 +1,44 @@ +user()->can('workflow.view'), 403); + + return view('workflow::admin.index', ['definitions' => WorkflowDefinition::with('stages')->latest()->get(), 'queue' => WorkflowInstance::with(['definition', 'stage', 'subject'])->where('status', 'active')->latest()->paginate(20), 'completed' => WorkflowInstance::where('status', 'completed')->count()]); + } + + public function store(Request $request): RedirectResponse + { + abort_unless($request->user()->can('workflow.manage'), 403); + $data = $request->validate(['name' => 'required|string|max:120', 'description' => 'nullable|string', 'subject_type' => 'nullable|string|max:255']); + $definition = WorkflowDefinition::create($data + ['slug' => Str::slug($data['name']), 'is_active' => true]); + $draft = $definition->stages()->create(['name' => 'Draft', 'slug' => 'draft', 'position' => 0, 'is_initial' => true, 'due_days' => null]); + $review = $definition->stages()->create(['name' => 'Review', 'slug' => 'review', 'position' => 10, 'required_permission' => 'workflow.review', 'due_days' => 3]); + $published = $definition->stages()->create(['name' => 'Approved', 'slug' => 'approved', 'position' => 20, 'is_terminal' => true, 'allows_rejection' => false]); + $definition->transitions()->createMany([['from_stage_id' => $draft->id, 'to_stage_id' => $review->id, 'name' => 'Submit for review', 'action' => 'submit'], ['from_stage_id' => $review->id, 'to_stage_id' => $published->id, 'name' => 'Approve', 'action' => 'approve', 'required_permission' => 'workflow.review'], ['from_stage_id' => $review->id, 'to_stage_id' => $draft->id, 'name' => 'Request changes', 'action' => 'reject', 'required_permission' => 'workflow.review', 'requires_comment' => true]]); + + return back()->with('success', 'Workflow created.'); + } + + public function transition(Request $request, WorkflowInstance $instance, WorkflowManager $manager): RedirectResponse + { + abort_unless($request->user()->can('workflow.review'), 403); + $data = $request->validate(['action' => 'required|string', 'comment' => 'nullable|string|max:2000']); + $manager->transition($instance, $data['action'], $request->user(), $data['comment'] ?? null); + + return back()->with('success', 'Workflow updated.'); + } +} diff --git a/Workflow/Models/WorkflowDefinition.php b/Workflow/Models/WorkflowDefinition.php new file mode 100644 index 0000000..2316842 --- /dev/null +++ b/Workflow/Models/WorkflowDefinition.php @@ -0,0 +1,26 @@ + 'boolean']; + } + + public function stages(): HasMany + { + return $this->hasMany(WorkflowStage::class)->orderBy('position'); + } + + public function transitions(): HasMany + { + return $this->hasMany(WorkflowTransition::class); + } +} diff --git a/Workflow/Models/WorkflowHistory.php b/Workflow/Models/WorkflowHistory.php new file mode 100644 index 0000000..2b8786f --- /dev/null +++ b/Workflow/Models/WorkflowHistory.php @@ -0,0 +1,17 @@ + 'array']; + } +} diff --git a/Workflow/Models/WorkflowInstance.php b/Workflow/Models/WorkflowInstance.php new file mode 100644 index 0000000..b3d1af4 --- /dev/null +++ b/Workflow/Models/WorkflowInstance.php @@ -0,0 +1,38 @@ + 'datetime', 'completed_at' => 'datetime']; + } + + public function definition(): BelongsTo + { + return $this->belongsTo(WorkflowDefinition::class, 'workflow_definition_id'); + } + + public function stage(): BelongsTo + { + return $this->belongsTo(WorkflowStage::class, 'workflow_stage_id'); + } + + public function subject(): MorphTo + { + return $this->morphTo(); + } + + public function history(): HasMany + { + return $this->hasMany(WorkflowHistory::class)->latest(); + } +} diff --git a/Workflow/Models/WorkflowStage.php b/Workflow/Models/WorkflowStage.php new file mode 100644 index 0000000..3d8a8ad --- /dev/null +++ b/Workflow/Models/WorkflowStage.php @@ -0,0 +1,21 @@ + 'boolean', 'is_terminal' => 'boolean', 'allows_rejection' => 'boolean']; + } + + public function definition(): BelongsTo + { + return $this->belongsTo(WorkflowDefinition::class, 'workflow_definition_id'); + } +} diff --git a/Workflow/Models/WorkflowTransition.php b/Workflow/Models/WorkflowTransition.php new file mode 100644 index 0000000..66bb87f --- /dev/null +++ b/Workflow/Models/WorkflowTransition.php @@ -0,0 +1,15 @@ + 'boolean']; + } +} diff --git a/Workflow/Providers/RouteServiceProvider.php b/Workflow/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..4197f68 --- /dev/null +++ b/Workflow/Providers/RouteServiceProvider.php @@ -0,0 +1,15 @@ +group(module_path('Workflow', 'Routes/web.php')); + } +} diff --git a/Workflow/Providers/WorkflowServiceProvider.php b/Workflow/Providers/WorkflowServiceProvider.php new file mode 100644 index 0000000..ab10b81 --- /dev/null +++ b/Workflow/Providers/WorkflowServiceProvider.php @@ -0,0 +1,22 @@ +mergeConfigFrom(module_path('Workflow', 'Config/config.php'), 'workflow'); + $this->app->singleton(WorkflowManager::class); + $this->app->register(RouteServiceProvider::class); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Workflow', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Workflow', 'Resources/views'), 'workflow'); + } +} diff --git a/Workflow/Resources/views/admin/index.blade.php b/Workflow/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..673cde3 --- /dev/null +++ b/Workflow/Resources/views/admin/index.blade.php @@ -0,0 +1 @@ +
WorkflowApproval definitions, active reviews and completed work.
Definitions{{ $definitions->count() }}
Awaiting action{{ $queue->total() }}
Completed{{ $completed }}
Review queue
@forelse($queue as $item)
{{ method_exists($item->subject,'workflowLabel') ? $item->subject->workflowLabel() : class_basename($item->subject_type).' #'.$item->subject_id }}
{{ $item->definition->name }} · {{ $item->stage->name }} @if($item->due_at) · due {{ $item->due_at->diffForHumans() }} @endif
@csrfApprove
@csrfChanges
@emptyNo items are awaiting review.@endforelse
{{ $queue->links() }}
Create workflow
@csrfCreate
@foreach($definitions as $definition)
{{ $definition->name }}
{{ $definition->stages->pluck('name')->join(' → ') }}
@endforeach
diff --git a/Workflow/Routes/web.php b/Workflow/Routes/web.php new file mode 100644 index 0000000..6653370 --- /dev/null +++ b/Workflow/Routes/web.php @@ -0,0 +1,10 @@ +prefix('administration/workflows')->name('administration.workflows.')->group(function () { + Route::get('/', [WorkflowController::class, 'index'])->name('index'); + Route::post('/', [WorkflowController::class, 'store'])->name('store'); + Route::post('/{instance}/transition', [WorkflowController::class, 'transition'])->name('transition'); +}); diff --git a/Workflow/Services/WorkflowManager.php b/Workflow/Services/WorkflowManager.php new file mode 100644 index 0000000..7b8dc1b --- /dev/null +++ b/Workflow/Services/WorkflowManager.php @@ -0,0 +1,46 @@ +stages()->where('is_initial', true)->first() ?? $definition->stages()->firstOrFail(); + $instance = WorkflowInstance::updateOrCreate(['subject_type' => $subject->getMorphClass(), 'subject_id' => $subject->getKey()], ['workflow_definition_id' => $definition->getKey(), 'workflow_stage_id' => $stage->getKey(), 'status' => $stage->is_terminal ? 'completed' : 'active', 'started_by' => $user?->getAuthIdentifier(), 'due_at' => $stage->due_days ? now()->addDays($stage->due_days) : null, 'completed_at' => $stage->is_terminal ? now() : null]); + WorkflowHistory::create(['workflow_instance_id' => $instance->id, 'to_stage_id' => $stage->id, 'user_id' => $user?->getAuthIdentifier(), 'action' => 'started']); + + return $instance->fresh(['definition', 'stage']); + }); + } + + public function transition(WorkflowInstance $instance, string $action, ?Authenticatable $user = null, ?string $comment = null): WorkflowInstance + { + return DB::transaction(function () use ($instance, $action, $user, $comment) { + $transition = WorkflowTransition::where('workflow_definition_id', $instance->workflow_definition_id)->where('from_stage_id', $instance->workflow_stage_id)->where('action', $action)->first(); + if (! $transition) { + throw ValidationException::withMessages(['action' => 'This transition is not available.']); + } if ($transition->requires_comment && blank($comment)) { + throw ValidationException::withMessages(['comment' => 'A comment is required.']); + } if ($transition->required_permission && (! $user || ! method_exists($user, 'can') || ! $user->can($transition->required_permission))) { + abort(403); + } $target = WorkflowStage::findOrFail($transition->to_stage_id); + $from = $instance->workflow_stage_id; + $instance->update(['workflow_stage_id' => $target->id, 'status' => $target->is_terminal ? 'completed' : 'active', 'due_at' => $target->due_days ? now()->addDays($target->due_days) : null, 'completed_at' => $target->is_terminal ? now() : null]); + WorkflowHistory::create(['workflow_instance_id' => $instance->id, 'from_stage_id' => $from, 'to_stage_id' => $target->id, 'user_id' => $user?->getAuthIdentifier(), 'action' => $action, 'comment' => $comment]); + + return $instance->fresh(['definition', 'stage', 'subject']); + }); + } +} diff --git a/Workflow/Tests/Feature/ModuleBootTest.php b/Workflow/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..e489747 --- /dev/null +++ b/Workflow/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,14 @@ +assertTrue(app()->providerIsLoaded(WorkflowServiceProvider::class)); + } +} diff --git a/Workflow/module.json b/Workflow/module.json new file mode 100644 index 0000000..2a022f0 --- /dev/null +++ b/Workflow/module.json @@ -0,0 +1,11 @@ +{ + "name": "Workflow", + "alias": "workflow", + "description": "Reusable approval workflows, review queues, transitions and audit history.", + "keywords": ["workflow", "approval", "review"], + "priority": 35, + "providers": [ + "Modules\\Workflow\\Providers\\WorkflowServiceProvider" + ], + "files": [] +} diff --git a/integration/PHASE5.md b/integration/PHASE5.md new file mode 100644 index 0000000..ca2ade8 --- /dev/null +++ b/integration/PHASE5.md @@ -0,0 +1,25 @@ +# Phase 5 — Administration authorization correction + +This is a **delta package** against Phase 4, not a full project. + +## What changed +- Updated the SonarDesk admin Gate to accept an explicitly granted Spatie `manage-sonardesk-licensing` permission (web guard) as well as the existing approved administrator roles. +- Added Pest authorization smoke tests for anonymous users, unprivileged verified users, and the explicit permission grant. +- Did not change any licensing API, checkout, webhook, or customer records. + +## Install +1. Back up `Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php`. +2. Copy the included `Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php` over the existing file. +3. Copy the included test into `tests/Feature/` if desired. +4. Run `composer dump-autoload && php artisan optimize:clear && php artisan permission:cache-reset`. +5. Run `php artisan route:list --path=administration/sonardesk -v`. +6. Run `php artisan test --filter=SonarDeskAdminAuthorizationTest` against a **disposable test database**. Do not run destructive test migrations against live customer data. + +## Next implementation milestones +- Phase 6: audited commerce checkout, webhook idempotency, customer identity mapping and data import. +- Phase 7: downloads, releases, appcast, system requirements, and ABSDev Stylist frontend. +- Phase 8: complete route parity, automated regression tests, staging deployment and cutover. + +## Limitations +- The tests have not been run against your local ABSDev database. +- No production-readiness claim is made; commerce/customer migration is still pending. diff --git a/integration/PHASE6.md b/integration/PHASE6.md new file mode 100644 index 0000000..bb2c396 --- /dev/null +++ b/integration/PHASE6.md @@ -0,0 +1,22 @@ +# Phase 6 — ABSDev sidebar and administration theme integration + +## Included changes +- Adds a SonarDesk sidebar group to the **existing ABSDev shared admin sidebar**, only when the licensing route exists and the current user is authorised. +- Adds links to SonarDesk overview and licences, with active route highlighting and automatic group expansion. +- Replaces the standalone licensing HTML document with the existing ABSDev `` shell, preserving licensing views and their read-only controllers. +- Does not modify payment processing, customer records, database schema, licence keys or API endpoints. + +## Install +Copy the `resources/` and `Modules/` directories over the matching directories in your ABSDev checkout. Back up modified files or commit existing changes first; this patch is based on the uploaded ABSDev source snapshot and may overwrite subsequent local sidebar changes. + +Run: +``` +php artisan optimize:clear +php artisan view:cache +php artisan route:list --path=administration/sonardesk -v +``` + +Check the sidebar and each licensing page as an authorised user. Confirm a non-authorised user cannot access the routes. Test mobile layout and dark/light modes. + +## Next integration +Paddle checkout, webhook idempotency, subscription mapping, customer migration and downloads require a separate integration and live-environment verification. These are **not** implemented in this patch. diff --git a/integration/PHASE7.md b/integration/PHASE7.md new file mode 100644 index 0000000..e956235 --- /dev/null +++ b/integration/PHASE7.md @@ -0,0 +1,20 @@ +# Phase 7 — Subscription visibility (read-only) + +Adds a SonarDesk **Subscriptions** sidebar link and an authorised `/administration/sonardesk/subscriptions` page showing existing subscription-linked licence records, status, provider, period end, and scheduled cancellation. Uses the same ABSDev administration layout as Phase 6. + +**No checkout, Paddle API calls, webhook changes, database migrations, customer merges, or billing mutations.** This phase is intentionally a safe first step toward commerce integration. Figures reflect local records only, not live Paddle data. + +## Install + +Back up/commit local changes first. Overlay the `Modules/` and `resources/` folders onto ABSDev, preserving paths. The sidebar file is a full replacement based on Phase 6; if you have edited it since then, merge the SonarDesk group manually instead. + +```bash +php artisan optimize:clear +php artisan route:list --path=administration/sonardesk -v +``` + +Check subscriptions page with authorised and unauthorised users. This view requires the licensing database migrations and the Phase 6 layout to be present. + +## Still required before billing cutover + +Verify Paddle credentials, webhook signatures and idempotency, customer mapping, checkout redirects, and existing production licence continuity in staging. Do not repoint production webhooks yet. diff --git a/modules_statuses.json b/modules_statuses.json index 30a8fa7..2acd30e 100644 --- a/modules_statuses.json +++ b/modules_statuses.json @@ -46,5 +46,6 @@ "Builder": true, "Marketplace": true, "CloudDevOps": true, - "Content": true + "Content": true, + "Licensing": true } diff --git a/resources/themes/common/views/components/layouts/app/sidebar.blade.php b/resources/themes/common/views/components/layouts/app/sidebar.blade.php index c848c25..44adba8 100644 --- a/resources/themes/common/views/components/layouts/app/sidebar.blade.php +++ b/resources/themes/common/views/components/layouts/app/sidebar.blade.php @@ -46,6 +46,16 @@ {{ __('Locations') }} + @if (\Illuminate\Support\Facades\Route::has('administration.sonardesk.index') && auth()->user()?->can('manage-sonardesk-licensing')) + + {{ __('Overview') }} + {{ __('Licences & Activations') }} + @if (\Illuminate\Support\Facades\Route::has('administration.sonardesk.subscriptions')) + {{ __('Subscriptions') }} + @endif + + @endif + {{ __('Users') }} {{ __('Roles & Permissions') }} diff --git a/tests/Feature/SonarDeskAdminAuthorizationTest.php b/tests/Feature/SonarDeskAdminAuthorizationTest.php new file mode 100644 index 0000000..2bedf45 --- /dev/null +++ b/tests/Feature/SonarDeskAdminAuthorizationTest.php @@ -0,0 +1,24 @@ +get('/administration/sonardesk')->assertRedirect(); +}); + +it('denies an authenticated verified user without licensing permission', function (): void { + $user = User::factory()->create(['email_verified_at' => now()]); + $this->actingAs($user)->get('/administration/sonardesk')->assertForbidden(); +}); + +it('allows a verified user granted the licensing permission past authorization', function (): void { + Permission::findOrCreate('manage-sonardesk-licensing', 'web'); + $user = User::factory()->create(['email_verified_at' => now()]); + $user->givePermissionTo('manage-sonardesk-licensing'); + expect($user->can('manage-sonardesk-licensing'))->toBeTrue(); + // Full dashboard request requires licensing migrations and database fixtures. +});